/** * rigor/complex-graph-batch-crucible.test.js — batch-loading and decision * cache parity on the community graph. * * Two engines are rebuilt from the same makeCommunityGraph(seed) fixture: * one loaded relation-by-relation, one loaded through * relationManager.addRelationsBatch (which expects { srcKey, relation, * dstKey, options } — the generator's { src, rel, dst, possibility } * objects are mapped into that shape). * * BATCH-SEQUENTIAL-PARITY — identical check answers on both engines for * sampled (user, relation, object) triples * across direct, TTU, union, exclusion, and * chain policies. * BATCH-MUTATION — after the same edge mutation on both engines, * parity holds and the post-mutation answer is * fresh (the decision cache is invalidated, not * served stale) — even right after a warm read. * FIXTURE-SIZE — the community fixture actually carries a * batch-sized relation set (> 50 edges). */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; import { makeCommunityGraph } from './complex-graphs.js'; const EPS = 1e-9; const POLICIES = ['direct_access', 'can_read', 'can_read_with_direct', 'can_read_not_blocked', 'can_delegate_read']; function fail(message) { throw new Error(message); } function mulberry32(seed) { let a = seed >>> 0; return function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function rebuildForBatch(g) { const arb = new Arbiter(); for (const node of g.arbiter.nodes.values()) arb.addNode(node.key, node.type); for (const [name, config] of g.arbiter.relationConfigs.entries()) arb.setRelationConfig(name, config); const edges = g.relations.map(r => ({ srcKey: r.src, relation: r.rel, dstKey: r.dst, options: { possibility: r.possibility } })); arb.relationManager.addRelationsBatch(edges); return arb; } function sampleTriples(g, rng) { const triples = []; // Real graph edges exercise the actual membership/ownership structure. for (const r of g.relations.slice(0, 40)) triples.push([r.src, r.rel, r.dst]); for (let i = 0; i < 40; i++) { const u = g.users[Math.floor(rng() * g.users.length)]; const o = g.resources[Math.floor(rng() * g.resources.length)]; triples.push([u, POLICIES[Math.floor(rng() * POLICIES.length)], o]); } return triples; } function assertParity(seq, batch, triples, tag) { for (const [u, rel, o] of triples) { const a = seq.check(u, rel, o).possibility; const b = batch.check(u, rel, o).possibility; if (Math.abs(a - b) > EPS) { fail(`[parity] ${tag} ${u} ${rel} ${o}: seq=${a} batch=${b}`); } } } describe('Complex-graph batch/cache crucibles (rigor)', () => { it('BATCH-SEQUENTIAL-PARITY + BATCH-MUTATION + CACHE-FRESHNESS hold on the community graph', async () => { async function check(args) { const { seed, mode } = args; const g = makeCommunityGraph(seed); const seq = g.arbiter; if (seq.relations.length <= 50) { fail(`[fixture] community fixture has only ${seq.relations.length} relations`); } const batch = rebuildForBatch(g); const rng = mulberry32(seed * 101); // BATCH-SEQUENTIAL-PARITY on the untouched graph. const triples = sampleTriples(g, rng); assertParity(seq, batch, triples, 'initial'); // BATCH-MUTATION + CACHE-INTERACTION. mode 0 removes an existing // direct_access edge; mode 1 adds one to a triple that has none. let pair; if (mode === 0) { pair = g.relations.find(r => r.rel === 'direct_access'); if (!pair) fail(`[fixture] no direct_access edge on seed=${seed}`); } else { const clean = () => { for (let i = 0; i < 200; i++) { const u = g.users[Math.floor(rng() * g.users.length)]; const o = g.resources[Math.floor(rng() * g.resources.length)]; if (g.relations.some(r => r.src === u && r.rel === 'direct_access' && r.dst === o)) continue; if (seq.check(u, 'direct_access', o).possibility !== 0) continue; return { src: u, rel: 'direct_access', dst: o }; } return null; }; pair = clean(); if (!pair) fail(`[fixture] no clean direct_access pair on seed=${seed}`); } // Warm the decision cache on both engines before mutating. seq.check(pair.src, 'direct_access', pair.dst); batch.check(pair.src, 'direct_access', pair.dst); batch.check(pair.src, 'direct_access', pair.dst); const expectedAfter = mode === 0 ? 0 : 0.77; if (mode === 0) { seq.removeRelation(pair.src, 'direct_access', pair.dst); batch.removeRelation(pair.src, 'direct_access', pair.dst); } else { seq.addRelation(pair.src, 'direct_access', pair.dst, { possibility: expectedAfter }); batch.addRelation(pair.src, 'direct_access', pair.dst, { possibility: expectedAfter }); } const sa = seq.check(pair.src, 'direct_access', pair.dst).possibility; const ba = batch.check(pair.src, 'direct_access', pair.dst).possibility; if (Math.abs(sa - ba) > EPS) fail(`[mutation] seq=${sa} batch=${ba} diverge after mutation`); if (Math.abs(ba - expectedAfter) > EPS) { fail(`[cache] batched engine returned ${ba}, expected ${expectedAfter} after mutation (stale cache)`); } if (Math.abs(sa - expectedAfter) > EPS) { fail(`[cache] sequential engine returned ${sa}, expected ${expectedAfter} after mutation`); } // No divergence on the broader policy surface after the mutation. assertParity(seq, batch, triples, 'post-mutation'); return { relations: seq.relations.length, triples: triples.length }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ seed: rigor.gen.int(1, 6), mode: rigor.gen.oneOf([0, 1]) }) )) ], rigor.crucible([ rigor.invariant('batch-sequential-parity', ({ actual }) => actual !== undefined), rigor.invariant('batch-mutation', ({ actual }) => actual !== undefined), rigor.invariant('cache-interaction', ({ actual }) => actual !== undefined), rigor.invariant('fixture-size', ({ actual }) => actual !== undefined) ]) ).run({ effort: 150, seed: 'complex-graph-batch-crucible', artifacts: { dir: '', persist: 'never' } }); for (const name of ['batch-sequential-parity', 'batch-mutation', 'cache-interaction', 'fixture-size']) { const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name); assert.ok(inv, `invariant ${name} missing`); assert.equal(inv.passed, true, `batch ${name} violated in ${inv.failureCount} cases`); } }); });