diff --git a/tests/rigor/partial-graph-parity.test.js b/tests/rigor/partial-graph-parity.test.js new file mode 100644 index 0000000..5431639 --- /dev/null +++ b/tests/rigor/partial-graph-parity.test.js @@ -0,0 +1,310 @@ +/** + * 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('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 }); + + 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')); + }); +});