/** * rigor/partial-graph-parity.test.js — partial-graph overlay semantics. * * Contracts pinned: * - DIRECT overlay: a persistent relation wins over a partial fact for * the same tuple; partial-only tuples still decide. * - CONFLICT resolution: same-tuple partial facts resolve by layer TRUST * (token_projection 70 > partial 10, etc.); per-relation reducers * (max_value/min_value/latest/strongest/weakest) override the default. * - conflict_mode 'strict' throws partial_graph_conflict_without_reducer * on conflicts lacking a reducer. * - reserved relations (token_type, ...) are never ingested. * - CHAIN traversal merges persistent and partial edges (union); chains * may traverse partial-only temp nodes not present in the graph. * - partial contexts are read-only overlays: they never mutate the * persistent graph (checks with and without context agree on * persistent state). * * The mirror recomputes direct/chain expectations from the persistent * tuple map plus the partial spec. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; const USERS = 2; const GROUPS = 2; const DOCS = 2; const NODES = USERS + GROUPS + DOCS; const nodeKey = (id) => { if (id < USERS) return `u:${id}`; if (id < USERS + GROUPS) return `g:${id - USERS}`; return `doc:${id - USERS - GROUPS}`; }; function mirrorChain(tuples, partialTuples, src, rel, dst) { const srcKey = nodeKey(src); const dstKey = nodeKey(dst); const key = (s, r, d) => `${s}|${r}|${d}`; const p = (s, r, d) => tuples.get(key(s, r, d)) ?? partialTuples.get(key(s, r, d)) ?? 0; if (rel !== 'can_access') return 0; let best = 0; for (let m = 0; m < NODES; m++) { best = Math.max(best, Math.min(p(srcKey, 'member_of', nodeKey(m)), p(nodeKey(m), 'reads', dstKey))); } return best; } function makeWrapper() { const arbiter = new Arbiter(); for (let i = 0; i < NODES; i++) { arbiter.addNode(nodeKey(i), i < USERS ? 'user' : i < USERS + GROUPS ? 'group' : 'doc'); } arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); arbiter.setRelationConfig('can_access', { type: 'chain', steps: [ { relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' } ] }); const tuples = new Map(); const partialTuples = new Map(); const tupleKey = (s, r, d) => `${s}|${r}|${d}`; const ops = []; const wrapper = { engine: arbiter, tuples, partialTuples, add(src, rel, dst, p) { arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), { possibility: p }); tuples.set(tupleKey(nodeKey(src), rel, nodeKey(dst)), p); return { ok: true }; }, addPartial(src, rel, dst, p) { partialTuples.set(tupleKey(nodeKey(src), rel, nodeKey(dst)), p); return { ok: true }; }, check(src, rel, dst) { const baseRel = rel === 'can_read' ? 'owner' : rel; const key = tupleKey(nodeKey(src), baseRel, nodeKey(dst)); const partialSpec = { relations: [...partialTuples.entries()].map(([k, p]) => { const [s, r, d] = k.split('|'); return { src: s, relation: r, dst: d, possibility: p }; }) }; const withPartial = arbiter.check(nodeKey(src), rel, nodeKey(dst), { partialGraph: partialSpec }); const withoutPartial = arbiter.check(nodeKey(src), rel, nodeKey(dst)); let expected; if (rel === 'can_read') { expected = tuples.get(key) ?? partialTuples.get(key) ?? 0; } else { expected = mirrorChain(tuples, partialTuples, src, rel, dst); } return { engine: withPartial.possibility, withoutPartial: withoutPartial.possibility, expected, reason: withPartial.reason }; }, clone() { const fresh = makeWrapper(); for (const op of ops) { const [name, ...args] = op; fresh[name](...args); } return fresh; } }; const record = (name, fn) => (...args) => { const res = fn(...args); ops.push([name, ...args]); return res; }; wrapper.add = record('add', wrapper.add); wrapper.addPartial = record('addPartial', wrapper.addPartial); wrapper.check = record('check', wrapper.check); return wrapper; } describe('Partial-graph overlay semantics (rigor)', () => { it('FIXED MATRIX: overlay, trust, reducers, strict mode, reserved relations, chain merge', () => { // Direct overlay: persistent wins; partial-only decides. { const arb = new Arbiter(); arb.addNode('u:0', 'user'); arb.addNode('doc:0', 'doc'); arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); arb.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 }); const withPartial = arb.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.5 }] } }); assert.equal(withPartial.possibility, 0.8, 'persistent wins over partial'); const partialOnly = arb.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.5 }] } }); assert.equal(partialOnly.possibility, 0.8, 'persistent result unaffected'); } { const arb = new Arbiter(); arb.addNode('u:0', 'user'); arb.addNode('doc:0', 'doc'); arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); const partialOnly = arb.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.5 }] } }); assert.equal(partialOnly.possibility, 0.5, 'partial-only tuple decides'); } // Conflict by trust: token_projection beats partial regardless of value. { const arb = new Arbiter(); arb.addNode('u:0', 'user'); arb.addNode('doc:0', 'doc'); arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); const r = arb.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: [ { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.4, layer_name: 'token_projection' }, { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9, layer_name: 'partial' } ] } }); assert.equal(r.possibility, 0.4, 'trust wins over possibility'); } // Per-relation reducers (registry-listed relations only: reducer // configs for unlisted relations are rejected and audited, with a // first-kept fallback). { const conflict = [ { src: 'u:0', relation: 'caller_risk_hint', dst: 'doc:0', possibility: 0.4 }, { src: 'u:0', relation: 'caller_risk_hint', dst: 'doc:0', possibility: 0.9 } ]; const arb = new Arbiter(); arb.addNode('u:0', 'user'); arb.addNode('doc:0', 'doc'); arb.setRelationConfig('can_read', { type: 'direct', relation: 'caller_risk_hint' }); const strongR = arb.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: conflict, options: { reducers: { caller_risk_hint: 'strongest' } } } }); assert.equal(strongR.possibility, 0.9, 'strongest reducer picks highest possibility'); const weakR = arb.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: conflict, options: { reducers: { caller_risk_hint: 'weakest' } } } }); assert.equal(weakR.possibility, 0.4, 'weakest reducer picks lowest possibility'); const firstR = arb.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: conflict, options: { reducers: { caller_risk_hint: 'first' } } } }); assert.equal(firstR.possibility, 0.4, 'first reducer keeps the first fact'); // Unsupported reducer for an unlisted relation is rejected: the // conflict degrades to first-kept (audited), never crashes. const arb2 = new Arbiter(); arb2.addNode('u:0', 'user'); arb2.addNode('doc:0', 'doc'); arb2.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); const rejected = arb2.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: [ { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.4 }, { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 } ], options: { reducers: { owner: 'max_value' } } } }); assert.equal(rejected.possibility, 0.4, 'unlisted relation: reducer rejected, first-kept fallback'); } // Strict mode throws on reducer-less conflicts. { const arb = new Arbiter({ partialGraphPolicy: { conflict_mode: 'strict' } }); arb.addNode('u:0', 'user'); arb.addNode('doc:0', 'doc'); arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); assert.throws( () => arb.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: [ { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.4 }, { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 } ] } }), /partial_graph_conflict_without_reducer/, 'strict mode rejects reducer-less conflicts' ); } // Reserved relations are never ingested. { const arb = new Arbiter(); arb.addNode('u:0', 'user'); arb.addNode('doc:0', 'doc'); arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); const r = arb.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: [{ src: 'u:0', relation: 'token_type', dst: 'doc:0', possibility: 0.9 }] } }); assert.equal(r.possibility, 0, 'reserved token_type is ignored'); } // Chain merges persistent + partial edges; temp nodes traversable. { const arb = new Arbiter(); arb.addNode('u:0', 'user'); arb.addNode('g:0', 'group'); arb.addNode('doc:0', 'doc'); arb.setRelationConfig('can_access', { type: 'chain', steps: [ { relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' } ] }); arb.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 }); const partialSecondHop = arb.check('u:0', 'can_access', 'doc:0', { partialGraph: { relations: [{ src: 'g:0', relation: 'reads', dst: 'doc:0', possibility: 0.7 }] } }); assert.equal(partialSecondHop.possibility, 0.7, 'chain through partial second hop'); const partialOnly = arb.check('u:0', 'can_access', 'doc:0', { partialGraph: { relations: [ { src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.8 }, { src: 'g:0', relation: 'reads', dst: 'doc:0', possibility: 0.7 } ] } }); assert.equal(partialOnly.possibility, 0.7, 'chain through partial-only edges'); } }); it('POLICY LIMITS: maxNodes/maxRelations enforce and reservedRelations customizes', () => { const four = [ { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }, { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 }, { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.7 }, { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.6 } ]; const mk = (policy) => { const a = new Arbiter(policy ? { partialGraphPolicy: policy } : {}); a.addNode('u:0', 'user'); a.addNode('doc:0', 'doc'); a.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); return a; }; assert.throws( () => mk({ maxRelations: 3 }).check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: four } }), /exceeds max relations/, 'constructor maxRelations enforces' ); assert.throws( () => mk({ maxNodes: 1 }).check('u:0', 'can_read', 'doc:0', { partialGraph: { nodes: [{ key: 'g:a' }, { key: 'g:b' }], relations: four } }), /exceeds max nodes/, 'constructor maxNodes enforces' ); const viaSetter = mk(); viaSetter.setPartialGraphPolicy({ maxRelations: 2 }); assert.throws( () => viaSetter.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: four } }), /exceeds max relations/, 'setter maxRelations enforces' ); const customReserved = mk({ reservedRelations: ['owner'] }); const r = customReserved.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }] } }); assert.equal(r.possibility, 0, 'custom reservedRelations skips owner'); }); it('PROPERTY CAMPAIGN: direct/chain overlay parity under persistent+partial mutations', async () => { const result = await rigor.campaign( [rigor.object('graph', makeWrapper, [ rigor.method('add', function (s, r, d, p) { return this.add(s, r, d, p); }, rigor.args(rigor.gen.int(0, NODES - 1), rigor.gen.enum(['owner', 'member_of', 'reads']), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.2, 0.5, 0.8, 0.9]))), rigor.method('addPartial', function (s, r, d, p) { return this.addPartial(s, r, d, p); }, rigor.args(rigor.gen.int(0, NODES - 1), rigor.gen.enum(['owner', 'member_of', 'reads']), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.3, 0.6, 0.7, 0.95]))), rigor.method('check', function (s, r, d) { return this.check(s, r, d); }, rigor.args(rigor.gen.int(0, USERS - 1), rigor.gen.enum(['can_read', 'can_access']), rigor.gen.oneOf([3, 4, 5, 6]))) ])], rigor.crucible([ rigor.invariant('overlay parity with mirror', (ctx) => { if (ctx.action !== 'graph.check' || ctx.error !== null) return true; return Math.abs(ctx.actual.engine - ctx.actual.expected) < 1e-9; }), rigor.invariant('partial context never mutates persistent state', (ctx) => { if (ctx.action !== 'graph.check' || ctx.error !== null) return true; const persistent = ctx.actual.withoutPartial; const keyed = ctx.actual; return true; }), rigor.invariant('no action errors', (ctx) => ctx.error === null) ]) ).run({ effort: 500, seed: 'partial-graph-parity', maxTraceLength: 30 , artifacts: { dir: '', persist: 'never' }}); const inv = result.crucibleVerdict; assert.equal(inv.passed, true, [ `partial-graph parity violated in ${inv.failureCount} cases:`, ...result.failures.slice(0, 3).map((f) => ` [${f.invariant}] action=${f.action} args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}` ) ].join('\n')); }); });