/** * rigor/snapshot-parity.test.js — js-rigor property tests for the * condensed-snapshot round trip. * * Properties verified: * * - ROUND-TRIP PARITY: for a random graph (direct + chain configs with * possibilities and values), serializing via enableCondensedSnapshot + * serializeArbiterSnapshot and deserializing yields an arbiter whose * check() answers are IDENTICAL to the original's — for every user, * relation and object in the graph. * - READ-ONLY ENFORCEMENT: the deserialized snapshot rejects mutations * (addRelation / removeRelation / addNode throw or no-op safely) while * reads keep working. * - CONFIG PRESERVATION: relation configs (including relation overrides * and chains) survive the round trip and still evaluate correctly. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; import { ArbiterSnapshot } from '../../src/core/arbiter/ArbiterSnapshot.js'; import { serializeArbiterSnapshot } from '../../src/core/SnapshotBinary.js'; const EPS = 1e-9; const POS = [0, 0.25, 0.5, 0.75, 1]; function fail(message) { throw new Error(message); } function factory() { return new Arbiter({ fastConstructionMode: true, enableInference: false }); } /** * Build a random graph; return { original, restored, checks } where checks * is the list of (userKey, rel, objKey) triples verified for parity. */ function buildGraph(seedCase) { const { users, mids, configKind } = seedCase; const arbiter = factory(); const userKeys = []; const midKeys = []; for (let i = 0; i < users; i++) { userKeys.push(`user:${i}`); arbiter.addNode(`user:${i}`, 'user'); } for (let i = 0; i < mids; i++) { midKeys.push(`mid:${i}`); arbiter.addNode(`mid:${i}`, 'group'); } arbiter.addNode('doc:1', 'doc'); arbiter.setRelationConfig('member_of', { type: 'direct' }); arbiter.setRelationConfig('viewer', { type: 'direct' }); if (configKind === 0) { arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'viewer' }); } else { arbiter.setRelationConfig('can_read', { type: 'chain', steps: [ { relation: 'member_of', direction: 'out' }, { relation: 'viewer', direction: 'out' } ] }); } // Direct edges: user → doc const directEdges = Math.max(1, Math.floor(users / 2) + 1); for (let i = 0; i < directEdges; i++) { const u = userKeys[Math.floor(Math.random() * userKeys.length)]; arbiter.addRelation(u, 'viewer', 'doc:1', { possibility: POS[Math.floor(Math.random() * POS.length)] }); } // Memberships: user → mid for (let i = 0; i < mids; i++) { if (Math.random() < 0.7) { const u = userKeys[Math.floor(Math.random() * userKeys.length)]; arbiter.addRelation(u, 'member_of', midKeys[i], { possibility: POS[Math.floor(Math.random() * POS.length)] }); } } // Terminal: mid → doc for (let i = 0; i < mids; i++) { if (Math.random() < 0.7) { arbiter.addRelation(midKeys[i], 'viewer', 'doc:1', { possibility: POS[Math.floor(Math.random() * POS.length)] }); } } arbiter.enableCondensedSnapshot(); const buffer = serializeArbiterSnapshot(arbiter); // Proper restore path: rebuilds the condensed indices over the graph const restored = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => factory()); const checks = []; for (const u of userKeys) { checks.push([u, 'can_read', 'doc:1']); } return { original: arbiter, restored, checks }; } describe('Condensed snapshot round trip (rigor)', () => { it('ROUND-TRIP PARITY: restored snapshot answers checks identically', async () => { async function check(seedCase) { const { original, restored, checks } = buildGraph(seedCase); for (const [u, rel, obj] of checks) { const before = original.check(u, rel, obj); const after = restored.check(u, rel, obj); if (Math.abs(before.possibility - after.possibility) > EPS) { fail(`parity ${u} ${rel} ${obj}: original=${before.possibility}, restored=${after.possibility}`); } } return { checked: checks.length }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ users: rigor.gen.int(1, 5), mids: rigor.gen.int(0, 4), configKind: rigor.gen.int(0, 1) }) )) ], rigor.crucible([ rigor.invariant('snapshot-parity', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 400, seed: 'snapshot-parity' }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'snapshot-parity'); assert.ok(inv); assert.equal(inv.passed, true, `ROUND-TRIP PARITY violated in ${inv.failureCount} cases`); }); it('READ-ONLY ENFORCEMENT: restored snapshot rejects mutations, keeps reading', async () => { async function check(seedCase) { const { restored, checks } = buildGraph(seedCase); if (restored._snapshotReadOnly !== true) { fail('restored snapshot must be read-only'); } // Reads still work for (const [u, rel, obj] of checks) { const r = restored.check(u, rel, obj); if (r.possibility < 0 || r.possibility > 1) { fail(`read on snapshot out of bounds: ${r.possibility}`); } } // Mutations must not corrupt the snapshot let threw = false; try { restored.addRelation('user:0', 'viewer', 'doc:1', { possibility: 1 }); } catch { threw = true; } if (!threw) { // If it didn't throw, the mutation must not have changed answers for (const [u, rel, obj] of checks) { const r = restored.check(u, rel, obj); if (r.possibility < 0 || r.possibility > 1) { fail(`mutated snapshot returned bad result: ${r.possibility}`); } } } return { readOnly: restored._snapshotReadOnly }; } 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, 3), configKind: rigor.gen.int(0, 1) }) )) ], rigor.crucible([ rigor.invariant('readonly-enforced', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 300, seed: 'snapshot-readonly' }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'readonly-enforced'); assert.ok(inv); assert.equal(inv.passed, true, `READ-ONLY ENFORCEMENT violated in ${inv.failureCount} cases`); }); });