/** * rigor/complex-graph-mutation-crucible.test.js — mutation crucible over * the complex graphs. * * Parity under mutation is the engine's stale-cache hunter: random edge * removals and additions on community/scale-free graphs, with * normal/binary agreement re-checked after EVERY mutation. A stale cache * or an index desync surfaces as a disagreement on the very next query. * * MUTATION-FRESHNESS — the LIVE engine's grant is revoked/applied * immediately after each mutation (no stale cache on the writable * path), and binary mode agrees with normal at every step. * * Snapshot discipline: enableCondensedSnapshot() permanently flips the * engine to read-only snapshot mode (writes throw), so the snapshot * cannot be taken mid-mutation on the live engine. The writable engine * and the read-only snapshot are therefore separate instances; the * round-trip parity property (snapshot == live at the same state) is * already covered by complex-graph-crucible and snapshot-parity. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { makeCommunityGraph, makeScaleFreeGraph } from './complex-graphs.js'; const GENERATORS = [ { name: 'community', make: makeCommunityGraph, directRel: 'direct_access' }, { name: 'scale-free', make: makeScaleFreeGraph, directRel: 'can_read' } ]; function fail(message) { throw new Error(message); } function checkModes(arbiter, user, relation, object) { const normal = arbiter.check(user, relation, object); const binary = arbiter.check(user, relation, object, { binary: true }); if (normal.possibility > 0 !== binary.possibility > 0) { fail(`binary disagreement: normal=${normal.possibility} binary=${binary.possibility}`); } return normal; } describe('Complex-graph mutation crucibles (rigor)', () => { it('MUTATION-FRESHNESS: live grants revoke/apply immediately; binary agrees', async () => { async function check(args) { const { genKind, seed } = args; const spec = GENERATORS.find(g => g.name === genKind); const g = spec.make(seed); const arbiter = g.arbiter; const rel = spec.directRel; const allKeys = [...g.users, ...(g.resources || [])]; for (let s = 0; s < 6; s++) { const u = allKeys[Math.floor(Math.random() * allKeys.length)]; const o = allKeys[Math.floor(Math.random() * allKeys.length)]; const before = checkModes(arbiter, u, rel, o).possibility; if (before > 0) { // Revoke every direct edge between u and o, then verify 0. // NOTE: arbiter.relations stores NUMERIC ids; compare against // resolved ids, never string keys. const srcId = arbiter.resolveNodeId(u); const dstId = arbiter.resolveNodeId(o); const rm = (g.relations || []).filter(r => r.src === u && r.rel === rel && r.dst === o); if (rm.length > 0) { for (const e of rm) arbiter.removeRelation(e.src, e.rel, e.dst); } else { for (const r of (arbiter.relations || [])) { if (r.src === srcId && r.rel === rel && r.dst === dstId) arbiter.removeRelation(u, rel, o); } } const after = checkModes(arbiter, u, rel, o).possibility; if (after > 0) fail(`stale grant: ${u} ${rel} ${o} still ${after} after removal`); } else { // Grant a direct edge, verify it shows up immediately. arbiter.addRelation(u, rel, o, { possibility: 0.9 }); const after = checkModes(arbiter, u, rel, o).possibility; if (after <= 0) fail(`missing grant: ${u} ${rel} ${o} still 0 after add`); } } return { ok: true }; } const report = await rigor.campaign( [ rigor.fn('mutate', check, rigor.args( rigor.gen.object({ genKind: rigor.gen.oneOf(GENERATORS.map(g => g.name)), seed: rigor.gen.int(1, 8) }) )) ], rigor.crucible([ rigor.invariant('mutation-freshness', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 300, seed: 'complex-graph-mutation-freshness', artifacts: { dir: '', persist: 'never' } }); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'mutation-freshness'); assert.ok(inv); assert.equal(inv.passed, true, `mutation freshness violated in ${inv.failureCount} cases`); }); });