Files
core/tests/rigor/batch-loading-parity.test.js
T
John Dvorak 717ae1031e initial commit: @arbiter/core authorization engine with js-rigor hardening
Zanzibar-style authorization graph engine (direct/chain/TTU/defeasible/
binary modes, condensed snapshots, value relations) with 39 rigor test
campaigns. Includes fixes for snapshot binary writer/reader format
mismatch (snapshot-of-snapshot corruption), possibility write-boundary
validation, empty-graph snapshot serialization, relation lookup cache
direction collision, config-redefinition cache invalidation, binary
threshold semantics, defeasible compiled routing, and comparator
reason whitelisting.
2026-07-31 13:44:06 -07:00

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`);
});
});