/** * rigor/traversal-parity.test.js — js-rigor property tests for chain * direction semantics, TTU multi-tuple aggregation, and the update path. * * Properties verified: * * - CHAIN DIRECTION PARITY: for 2-3 step chains with arbitrary * out/in directions, the engine's traversal (getRelationsFromSrc / * getRelationsToDst) matches a BFS oracle: step 'out' walks * src -r-> dst, step 'in' walks src <-r- dst; path possibility is * the MIN along the path; multiple paths take the MAX. * - TTU MULTI-TUPLE PARITY: possibility = max over tuple edges of * min(tuplePossibility, memberPossibility) where membership is a * DIRECT user->group lookup. * - UPDATE PATH PARITY: overwriting an existing tuple * (addRelation on an existing src/rel/dst) is last-write-wins: * the check reflects the new possibility, the engine keeps exactly * one tuple, and indices stay consistent — for direct, chain, and * TTU configs alike. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; const EPS = 1e-9; const POS = [0, 0.25, 0.5, 0.75, 1]; const NODES = ['user:alice', 'mid:1', 'mid:2', 'doc:1']; function fail(message) { throw new Error(message); } function mulberry32(seed) { let a = seed >>> 0; return { next() { 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; } }; } // Edge universe: every possible directed edge on the 4-node graph // (u, m1, m2, o) for relations r1 and r2. const EDGE_UNIVERSE = { r1: [ ['user:alice', 'mid:1'], ['mid:1', 'user:alice'], ['mid:1', 'mid:2'], ['doc:1', 'mid:2'], ['mid:2', 'doc:1'] ], r2: [ ['mid:1', 'doc:1'], ['doc:1', 'mid:1'], ['mid:2', 'user:alice'], ['user:alice', 'mid:2'], ['user:alice', 'doc:1'], ['mid:2', 'mid:1'] ] }; function randomEdges(rng, density = 0.5) { const edges = []; for (const rel of ['r1', 'r2']) { for (const [src, dst] of EDGE_UNIVERSE[rel]) { if (rng.next() < density) { edges.push([src, rel, dst, POS[Math.floor(rng.next() * POS.length)]]); } } } return edges; } function edgeKey(src, rel, dst) { return `${src}|${rel}|${dst}`; } function chainOracle(steps, edges) { const em = new Map(edges.map(e => [edgeKey(e[0], e[1], e[2]), e[3]])); let frontier = new Map([['user:alice', 1.0]]); for (const step of steps) { const { relation, direction } = step; const next = new Map(); for (const [node, p] of frontier) { for (const [key, ep] of em) { const [s, r, d] = key.split('|'); if (r !== relation) continue; const matches = direction === 'out' ? s === node : d === node; if (!matches) continue; const nxt = direction === 'out' ? d : s; if (nxt === node) continue; // no self-loop progress const np = Math.min(p, ep); const cur = next.get(nxt); if (cur === undefined || np > cur) next.set(nxt, np); } } frontier = next; if (frontier.size === 0) break; } return frontier.get('doc:1') || 0; } function makeChainConfig(steps) { return { type: 'chain', steps }; } function applyEdges(arb, edges, mode) { for (const [src, rel, dst, p] of edges) { if (mode === 'add') arb.addRelation(src, rel, dst, { possibility: p }); else arb.removeRelation(src, rel, dst); } } function buildArbiter() { const arb = new Arbiter(); for (const k of NODES) arb.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('mid') ? 'mid' : 'doc'); arb.setRelationConfig('r1', { type: 'direct' }); arb.setRelationConfig('r2', { type: 'direct' }); arb.setRelationConfig('owner', { type: 'direct' }); arb.setRelationConfig('member_of', { type: 'direct' }); return arb; } describe('Traversal semantics parity (rigor)', () => { it('CHAIN DIRECTION PARITY: arbitrary out/in step mixes match the BFS oracle', async () => { async function check({ seed, dirs, threeSteps }) { const rng = mulberry32(seed); const edges = randomEdges(rng); const arb = buildArbiter(); const d = dirs.split('-'); const steps = threeSteps ? [ { relation: 'r1', direction: d[0] }, { relation: 'r2', direction: d[1] }, { relation: 'r1', direction: d[2 % 2] } ] : [ { relation: 'r1', direction: d[0] }, { relation: 'r2', direction: d[1] } ]; arb.setRelationConfig('target', makeChainConfig(steps)); applyEdges(arb, edges, 'add'); const expectedP = chainOracle(steps, edges); const res = arb.check('user:alice', 'target', 'doc:1', {}); if (Math.abs(res.possibility - expectedP) > EPS) { fail(`chain mismatch dirs=${JSON.stringify(dirs)} steps=${steps.length} p=${expectedP} got=${res.possibility} edges=${JSON.stringify(edges)}`); } // Mutations must keep parity const rels = ['r1', 'r2']; for (let i = 0; i < 3; i++) { const rel = rels[Math.floor(rng.next() * 2)]; const [src, dst] = EDGE_UNIVERSE[rel][Math.floor(rng.next() * EDGE_UNIVERSE[rel].length)]; const idx = edges.findIndex(e => e[0] === src && e[1] === rel && e[2] === dst); if (idx !== -1) { arb.removeRelation(src, rel, dst); edges.splice(idx, 1); } else { const p = POS[Math.floor(rng.next() * POS.length)]; arb.addRelation(src, rel, dst, { possibility: p }); edges.push([src, rel, dst, p]); } const eP = chainOracle(steps, edges); const r = arb.check('user:alice', 'target', 'doc:1', {}); if (Math.abs(r.possibility - eP) > EPS) { fail(`chain mutation mismatch dirs=${JSON.stringify(dirs)} p=${eP} got=${r.possibility} edges=${JSON.stringify(edges)}`); } } return { steps: steps.length }; } const dirPairs = ['out-out', 'out-in', 'in-out', 'in-in']; const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ seed: rigor.gen.int(1, 100000), dirs: rigor.gen.oneOf(dirPairs), threeSteps: rigor.gen.boolean() }) )) ], rigor.crucible([ rigor.invariant('chain-direction-parity', ({ actual }) => actual !== undefined) ]) ).run({ effort: 1500, seed: 'traversal-chain-direction' , artifacts: { dir: '', persist: 'never' }}); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'chain-direction-parity'); assert.ok(inv, 'invariant missing'); assert.equal(inv.passed, true, `chain direction parity violated in ${inv.failureCount} cases`); }); it('TTU MULTI-TUPLE PARITY: max over tuples of min(tuple, membership)', async () => { async function check({ seed }) { const rng = mulberry32(seed); const arb = buildArbiter(); arb.setRelationConfig('target', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' }); const tuples = [ ['doc:1', 'group:eng'], ['doc:1', 'group:design'] ]; arb.addNode('group:eng', 'group'); arb.addNode('group:design', 'group'); const edges = []; let expectedP = 0; for (const [doc, grp] of tuples) { if (rng.next() < 0.7) { const tp = POS[Math.floor(rng.next() * POS.length)]; arb.addRelation(doc, 'owner', grp, { possibility: tp }); edges.push([doc, 'owner', grp, tp]); let mp = 0; if (rng.next() < 0.8) { mp = POS[Math.floor(rng.next() * POS.length)]; arb.addRelation('user:alice', 'member_of', grp, { possibility: mp }); edges.push(['user:alice', 'member_of', grp, mp]); } expectedP = Math.max(expectedP, Math.min(tp, mp)); } } const res = arb.check('user:alice', 'target', 'doc:1', {}); if (Math.abs(res.possibility - expectedP) > EPS) { fail(`ttu mismatch p=${expectedP} got=${res.possibility} edges=${JSON.stringify(edges)}`); } // Mutation: flip one membership const grp = tuples[Math.floor(rng.next() * 2)][1]; const hasMember = edges.some(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp); if (hasMember) { arb.removeRelation('user:alice', 'member_of', grp); const idx = edges.findIndex(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp); if (idx !== -1) edges.splice(idx, 1); } else { const p = POS[Math.floor(rng.next() * POS.length)]; arb.addRelation('user:alice', 'member_of', grp, { possibility: p }); edges.push(['user:alice', 'member_of', grp, p]); } let eP = 0; const tupleMap = new Map(); for (const [s, r, d, p] of edges) { if (r === 'owner') tupleMap.set(d, p); } for (const [grp2, tp] of tupleMap) { const mem = edges.find(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp2); eP = Math.max(eP, Math.min(tp, mem ? mem[3] : 0)); } const r2 = arb.check('user:alice', 'target', 'doc:1', {}); if (Math.abs(r2.possibility - eP) > EPS) { fail(`ttu mutation mismatch p=${eP} got=${r2.possibility} edges=${JSON.stringify(edges)}`); } return { edges: edges.length }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ seed: rigor.gen.int(1, 100000) }) )) ], rigor.crucible([ rigor.invariant('ttu-multi-tuple-parity', ({ actual }) => actual !== undefined) ]) ).run({ effort: 1200, seed: 'traversal-ttu-multituple' , artifacts: { dir: '', persist: 'never' }}); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'ttu-multi-tuple-parity'); assert.ok(inv, 'invariant missing'); assert.equal(inv.passed, true, `ttu parity violated in ${inv.failureCount} cases`); }); it('UPDATE PATH PARITY: overwrites are last-write-wins with a single tuple and fresh checks', async () => { async function check({ seed, kind }) { const rng = mulberry32(seed); const arb = buildArbiter(); let target; if (kind === 0) { arb.setRelationConfig('target', { type: 'direct', relation: 'r1' }); target = { check: (u, o) => arb.check(u, 'target', o), base: 'r1' }; } else if (kind === 1) { arb.setRelationConfig('target', { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }] }); target = { check: (u, o) => arb.check(u, 'target', o), base: 'r1' }; } else { arb.setRelationConfig('target', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' }); arb.addNode('group:eng', 'group'); target = { check: (u, o) => arb.check(u, 'target', o), base: 'owner' }; } const src = kind === 2 ? 'doc:1' : 'user:alice'; const dst = kind === 0 ? 'mid:1' : kind === 1 ? 'mid:1' : 'group:eng'; // Warm with the first value const p0 = POS[Math.floor(rng.next() * POS.length)]; arb.addRelation(src, target.base, dst, { possibility: p0 }); let lastP = p0; for (let i = 0; i < 4; i++) { const p = POS[Math.floor(rng.next() * POS.length)]; arb.addRelation(src, target.base, dst, { possibility: p }); lastP = p; } // Exactly one tuple in the engine (no duplicates from overwrites) const count = arb.relations.filter(r => r.rel === target.base && r.src === arb.resolveNodeId(src) && r.dst === arb.resolveNodeId(dst)).length; if (count !== 1) { fail(`overwrite left ${count} tuples for ${target.base} (kind=${kind})`); } if (kind === 0) { const res = arb.check(src, 'target', kind === 0 ? 'mid:1' : 'doc:1'); if (Math.abs(res.possibility - lastP) > EPS) { fail(`update path stale: expected ${lastP}, got ${res.possibility} (kind=${kind})`); } } else if (kind === 1) { // chain: seed an r2 edge so the path exists arb.addRelation('mid:1', 'r2', 'doc:1', { possibility: 1 }); const res = arb.check(src, 'target', 'doc:1'); if (Math.abs(res.possibility - lastP) > EPS) { fail(`chain update path stale: expected ${lastP}, got ${res.possibility}`); } } else { arb.addRelation('user:alice', 'member_of', 'group:eng', { possibility: 1 }); const res = arb.check('user:alice', 'target', 'doc:1'); if (Math.abs(res.possibility - lastP) > EPS) { fail(`ttu update path stale: expected ${lastP}, got ${res.possibility}`); } } return { lastP }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ seed: rigor.gen.int(1, 50000), kind: rigor.gen.int(0, 2) }) )) ], rigor.crucible([ rigor.invariant('update-path-parity', ({ actual }) => actual !== undefined) ]) ).run({ effort: 600, seed: 'traversal-update-path' , artifacts: { dir: '', persist: 'never' }}); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'update-path-parity'); assert.ok(inv, 'invariant missing'); assert.equal(inv.passed, true, `update path parity violated in ${inv.failureCount} cases`); }); });