189 lines
6.9 KiB
JavaScript
189 lines
6.9 KiB
JavaScript
|
|
/**
|
||
|
|
* rigor/batch-loading-parity.test.js — js-rigor property tests for
|
||
|
|
* batch construction consistency.
|
||
|
|
*
|
||
|
|
* Properties verified:
|
||
|
|
*
|
||
|
|
* - BATCH PARITY: the same random graph loaded via addRelationsBatch
|
||
|
|
* answers check() IDENTICALLY to the same graph loaded relation by
|
||
|
|
* relation (both for direct and chain configs).
|
||
|
|
* - BATCH DEDUP: duplicate tuples inside a batch honor last-write-wins
|
||
|
|
* exactly like sequential re-adds (the final possibility is the last
|
||
|
|
* one, regardless of order).
|
||
|
|
* - BATCH + MUTATION: after batch loading, subsequent single mutations
|
||
|
|
* (add/remove) behave exactly as on the sequentially-built arbiter.
|
||
|
|
*/
|
||
|
|
import { describe, it } from 'node:test';
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import { rigor } from '@rigor/core';
|
||
|
|
import { Arbiter } from '../../src/index.js';
|
||
|
|
|
||
|
|
const EPS = 1e-9;
|
||
|
|
const POS = [0, 0.25, 0.5, 0.75, 1];
|
||
|
|
|
||
|
|
function fail(message) {
|
||
|
|
throw new Error(message);
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildBase(users, mids) {
|
||
|
|
const arbiter = new Arbiter({ fastConstructionMode: true });
|
||
|
|
for (let i = 0; i < users; i++) arbiter.addNode(`user:${i}`, 'user');
|
||
|
|
for (let i = 0; i < mids; i++) arbiter.addNode(`mid:${i}`, 'group');
|
||
|
|
arbiter.addNode('doc:1', 'doc');
|
||
|
|
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||
|
|
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||
|
|
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'viewer' });
|
||
|
|
arbiter.setRelationConfig('can_access', {
|
||
|
|
type: 'chain',
|
||
|
|
steps: [
|
||
|
|
{ relation: 'member_of', direction: 'out' },
|
||
|
|
{ relation: 'viewer', direction: 'out' }
|
||
|
|
]
|
||
|
|
});
|
||
|
|
return arbiter;
|
||
|
|
}
|
||
|
|
|
||
|
|
function randomEdges(users, mids) {
|
||
|
|
const edges = [];
|
||
|
|
const userKeys = Array.from({ length: users }, (_, i) => `user:${i}`);
|
||
|
|
const midKeys = Array.from({ length: mids }, (_, i) => `mid:${i}`);
|
||
|
|
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||
|
|
const count = Math.max(2, users + mids);
|
||
|
|
for (let i = 0; i < count; i++) {
|
||
|
|
const kind = Math.floor(Math.random() * 3);
|
||
|
|
if (kind === 0) {
|
||
|
|
edges.push({ srcKey: pick(userKeys), relation: 'viewer', dstKey: 'doc:1', options: { possibility: pick(POS) } });
|
||
|
|
} else if (kind === 1 && mids > 0) {
|
||
|
|
edges.push({ srcKey: pick(userKeys), relation: 'member_of', dstKey: pick(midKeys), options: { possibility: pick(POS) } });
|
||
|
|
} else if (mids > 0) {
|
||
|
|
edges.push({ srcKey: pick(midKeys), relation: 'viewer', dstKey: 'doc:1', options: { possibility: pick(POS) } });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Fast-construction sequential adds skip duplicate detection (bulk-loading
|
||
|
|
// contract: callers supply distinct tuples). Dedup so both loading paths
|
||
|
|
// see identical state — last-write-wins on the tuple.
|
||
|
|
const seen = new Set();
|
||
|
|
const deduped = [];
|
||
|
|
for (const e of edges) {
|
||
|
|
const key = `${e.srcKey}|${e.relation}|${e.dstKey}`;
|
||
|
|
if (seen.has(key)) continue;
|
||
|
|
seen.add(key);
|
||
|
|
deduped.push(e);
|
||
|
|
}
|
||
|
|
return deduped;
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyEdgesSequential(arbiter, edges) {
|
||
|
|
for (const e of edges) {
|
||
|
|
arbiter.addRelation(e.srcKey, e.relation, e.dstKey, e.options);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function allChecks(arbiter, users) {
|
||
|
|
const results = {};
|
||
|
|
for (let i = 0; i < users; i++) {
|
||
|
|
results[`u${i}`] = {
|
||
|
|
read: arbiter.check(`user:${i}`, 'can_read', 'doc:1').possibility,
|
||
|
|
access: arbiter.check(`user:${i}`, 'can_access', 'doc:1').possibility
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('Batch loading consistency (rigor)', () => {
|
||
|
|
it('BATCH PARITY: batch-loaded graphs answer checks identically to sequential loading', async () => {
|
||
|
|
async function check(seedCase) {
|
||
|
|
const { users, mids, includeDupes } = seedCase;
|
||
|
|
let edges = randomEdges(users, mids);
|
||
|
|
if (includeDupes && edges.length > 0) {
|
||
|
|
// Duplicate one edge with a different possibility (last-write-wins)
|
||
|
|
const dup = { ...edges[0] };
|
||
|
|
dup.options = { possibility: POS[Math.floor(Math.random() * POS.length)] };
|
||
|
|
edges = [...edges, dup];
|
||
|
|
}
|
||
|
|
|
||
|
|
const batched = buildBase(users, mids);
|
||
|
|
batched.relationManager.addRelationsBatch(edges);
|
||
|
|
|
||
|
|
const sequential = buildBase(users, mids);
|
||
|
|
applyEdgesSequential(sequential, edges);
|
||
|
|
|
||
|
|
const b = allChecks(batched, users);
|
||
|
|
const s = allChecks(sequential, users);
|
||
|
|
for (const key of Object.keys(b)) {
|
||
|
|
if (Math.abs(b[key].read - s[key].read) > EPS || Math.abs(b[key].access - s[key].access) > EPS) {
|
||
|
|
fail(`batch parity ${key}: batch=${JSON.stringify(b[key])}, seq=${JSON.stringify(s[key])}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { edges: edges.length };
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[
|
||
|
|
rigor.fn('check', check, rigor.args(
|
||
|
|
rigor.gen.object({
|
||
|
|
users: rigor.gen.int(1, 4),
|
||
|
|
mids: rigor.gen.int(0, 4),
|
||
|
|
includeDupes: rigor.gen.boolean()
|
||
|
|
})
|
||
|
|
))
|
||
|
|
],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('batch-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 400, seed: 'batch-parity' });
|
||
|
|
|
||
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'batch-parity');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `BATCH PARITY violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('BATCH + MUTATION: post-batch mutations behave like post-sequential mutations', async () => {
|
||
|
|
async function check(seedCase) {
|
||
|
|
const { users, mids, removeRel } = seedCase;
|
||
|
|
const edges = randomEdges(users, mids);
|
||
|
|
|
||
|
|
const batched = buildBase(users, mids);
|
||
|
|
batched.relationManager.addRelationsBatch(edges);
|
||
|
|
const sequential = buildBase(users, mids);
|
||
|
|
applyEdgesSequential(sequential, edges);
|
||
|
|
|
||
|
|
// Same mutation on both: remove every edge of one relation kind
|
||
|
|
for (const e of edges) {
|
||
|
|
if (e.relation === removeRel) {
|
||
|
|
batched.removeRelation(e.srcKey, e.relation, e.dstKey);
|
||
|
|
sequential.removeRelation(e.srcKey, e.relation, e.dstKey);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const b = allChecks(batched, users);
|
||
|
|
const s = allChecks(sequential, users);
|
||
|
|
for (const key of Object.keys(b)) {
|
||
|
|
if (Math.abs(b[key].read - s[key].read) > EPS || Math.abs(b[key].access - s[key].access) > EPS) {
|
||
|
|
fail(`post-mutation parity ${key}: batch=${JSON.stringify(b[key])}, seq=${JSON.stringify(s[key])}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { removed: removeRel };
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[
|
||
|
|
rigor.fn('check', check, rigor.args(
|
||
|
|
rigor.gen.object({
|
||
|
|
users: rigor.gen.int(1, 4),
|
||
|
|
mids: rigor.gen.int(0, 4),
|
||
|
|
removeRel: rigor.gen.oneOf(['viewer', 'member_of'])
|
||
|
|
})
|
||
|
|
))
|
||
|
|
],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('batch-mutation-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 400, seed: 'batch-mutation-parity' });
|
||
|
|
|
||
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'batch-mutation-parity');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `BATCH+MUTATION violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
});
|