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