/** * rigor/dsl-mutation-parity.test.js — js-rigor property tests that a * DSL-compiled arbiter and an equivalent hand-written arbiter stay in * lock-step through identical MUTATION SEQUENCES. * * Properties verified: * * - SEQUENCE PARITY: starting from the same graph, apply the same random * add/remove sequence to both the DSL-compiled and the hand-written * arbiter; after EVERY mutation the check() answers agree exactly. * This exercises compiled configs (dependency indexes, caches, * invalidation) under mutation, not just static evaluation. * - GRANT/REVOKE CYCLES: interleaved add/remove of the same tuple keeps * both arbiters consistent (no stale compiled state). */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; import { DSLCompiler } from '../../src/ast/DSLCompiler.js'; const EPS = 1e-9; const POS = [0, 0.25, 0.5, 0.75, 1]; function fail(message) { throw new Error(message); } const DSL = ` definition Doc { id: string } definition Dept { id: string } fact owns(user: User, doc: Doc) fact works_in(user: User, dept: Dept) fact has_access(dept: Dept, doc: Doc) evidence can_read(user: User, doc: Doc) { owns(user, doc) } evidence can_access(user: User, doc: Doc) { works_in(user, *d) { has_access(d, doc) } } `; function buildCompiledArbiter() { const arbiter = new Arbiter(); arbiter.addNode('user:alice', 'user'); arbiter.addNode('dept:eng', 'group'); arbiter.addNode('doc:1', 'doc'); const compiler = new DSLCompiler(arbiter); const result = compiler.compile(DSL, 'mutation-parity'); if (!result.success) { throw new Error(`DSL compile failed: ${result.errors.join('; ')}`); } return arbiter; } function buildManualArbiter() { const arbiter = new Arbiter(); arbiter.addNode('user:alice', 'user'); arbiter.addNode('dept:eng', 'group'); arbiter.addNode('doc:1', 'doc'); arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owns' }); arbiter.setRelationConfig('can_access', { type: 'chain', steps: [ { relation: 'works_in', direction: 'out' }, { relation: 'has_access', direction: 'out' } ] }); return arbiter; } const RELATIONS = ['owns', 'works_in', 'has_access']; const SUBJECTS = ['user:alice', 'dept:eng']; function applyMutation(arbiter, op) { const [kind, src, rel, dst, p] = op; if (kind === 'add') { arbiter.addRelation(src, rel, dst, { possibility: p }); } else { arbiter.removeRelation(src, rel, dst); } } describe('DSL-compiled vs hand-written parity under mutation (rigor)', () => { it('SEQUENCE PARITY: identical mutation sequences keep compiled and manual arbiters in lock-step', async () => { async function check(operations) { const compiled = buildCompiledArbiter(); const manual = buildManualArbiter(); // Same starting graph on both for (const arb of [compiled, manual]) { arb.addRelation('user:alice', 'owns', 'doc:1', { possibility: 0.5 }); arb.addRelation('user:alice', 'works_in', 'dept:eng', { possibility: 1 }); arb.addRelation('dept:eng', 'has_access', 'doc:1', { possibility: 0.75 }); } for (const op of operations) { applyMutation(compiled, op); applyMutation(manual, op); for (const rel of ['can_read', 'can_access']) { const c = compiled.check('user:alice', rel, 'doc:1'); const m = manual.check('user:alice', rel, 'doc:1'); if (Math.abs(c.possibility - m.possibility) > EPS) { fail(`parity ${rel} after ${JSON.stringify(op)}: compiled=${c.possibility}, manual=${m.possibility}`); } } } return { ops: operations.length }; } const mutationGen = rigor.gen.oneOf([ rigor.gen.tuple( rigor.gen.constant('add'), rigor.gen.oneOf(SUBJECTS), rigor.gen.oneOf(RELATIONS), rigor.gen.oneOf(SUBJECTS), rigor.gen.oneOf(POS) ), rigor.gen.tuple( rigor.gen.constant('remove'), rigor.gen.oneOf(SUBJECTS), rigor.gen.oneOf(RELATIONS), rigor.gen.oneOf(SUBJECTS), rigor.gen.constant(0) ) ]); const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.array(mutationGen, 1, 10) )) ], rigor.crucible([ rigor.invariant('sequence-parity', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 400, seed: 'dsl-mutation-parity' }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'sequence-parity'); assert.ok(inv); assert.equal(inv.passed, true, `SEQUENCE PARITY violated in ${inv.failureCount} cases`); }); it('GRANT/REVOKE CYCLES: repeated add/remove of the same tuple never desyncs', async () => { async function check({ cycles, p }) { const compiled = buildCompiledArbiter(); const manual = buildManualArbiter(); for (let i = 0; i < cycles; i++) { for (const arb of [compiled, manual]) { arb.addRelation('user:alice', 'owns', 'doc:1', { possibility: p }); } for (const rel of ['can_read', 'can_access']) { const c = compiled.check('user:alice', rel, 'doc:1'); const m = manual.check('user:alice', rel, 'doc:1'); if (Math.abs(c.possibility - m.possibility) > EPS) { fail(`grant cycle ${i} ${rel}: compiled=${c.possibility}, manual=${m.possibility}`); } } for (const arb of [compiled, manual]) { arb.removeRelation('user:alice', 'owns', 'doc:1'); } const c = compiled.check('user:alice', 'can_read', 'doc:1'); const m = manual.check('user:alice', 'can_read', 'doc:1'); if (Math.abs(c.possibility - m.possibility) > EPS) { fail(`revoke cycle ${i}: compiled=${c.possibility}, manual=${m.possibility}`); } if (c.possibility !== 0) { fail(`revoke cycle ${i}: grant survived removal (${c.possibility})`); } } return { cycles }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ cycles: rigor.gen.int(2, 8), p: rigor.gen.oneOf([0.25, 0.5, 1]) }) )) ], rigor.crucible([ rigor.invariant('grant-revoke-cycles', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 300, seed: 'dsl-grant-revoke-cycles' }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'grant-revoke-cycles'); assert.ok(inv); assert.equal(inv.passed, true, `GRANT/REVOKE CYCLES violated in ${inv.failureCount} cases`); }); });