/** * rigor/graph-indices.test.js — js-rigor property tests for GraphIndices. * * GraphIndices maintains five indexes over the relation set: * - relationsBySrcRelDst: Map — direct lookup (src,rel,dst) → rel * - relationsBySrcRel: Map> — all relations from src under rel * - relationsByDstRel: Map> — all relations to dst under rel * - relationsByRel: Map> — all relations under a name * - outgoingEdges: Map — adjacency (with duplicates) * - incomingEdges: Map — reverse adjacency * * Properties verified against a brute-force oracle (two naive Maps): * - getDirectRelation(src, rel, dst) matches the oracle * - getRelationsFromSrc(src, rel) returns the exact set the oracle records * - getRelationsToDst(dst, rel) returns the exact set the oracle records * - getRelationsByName(rel) returns the exact set the oracle records * - After add+remove cycle, getDirectRelation returns undefined * - Adding the same relation twice is idempotent (Set semantics) * - clear() empties every index */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { GraphIndices } from '../../src/core/GraphIndices.js'; const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent', 'admin']; /** * Build a relation object with optional strength metadata. */ function relObj(src, rel, dst, possibility = 1.0, value = undefined) { const r = { src, rel, dst, possibility }; if (value !== undefined) r.value = value; r.changed_last_at = 1000; r.updated_last_at = 1000; r.source = 'persistent'; return r; } /** * Brute-force oracle that mirrors GraphIndices' production contract. * After RF-22, addRelation enforces (src, rel, dst) uniqueness and replaces * any existing entry — both in the composite-key index AND in the Set-backed * indexes. The oracle mirrors that exactly: * - direct: Map<"src|rel|dst", relObj> — last writer wins * - byName: Map> — Set by reference identity * - bySrc: Map<"src|rel", Set> * - byDst: Map<"dst|rel", Set> * * add(r): if (src,rel,dst) is new, insert into all four. If duplicate, replace * in `direct` AND swap the old ref out of all Sets before adding the new. * remove(r, opSrc, opDst, opRel): lookup by the (opSrc, opRel, opDst) args * (production uses these, not the relObj's fields), then delete from all. */ function makeOracle() { const direct = new Map(); const byName = new Map(); const bySrc = new Map(); const byDst = new Map(); const outgoingEdges = new Map(); const incomingEdges = new Map(); function key(a, b, c) { return c !== undefined ? `${a}|${b}|${c}` : `${a}|${b}`; } function add(r) { const directKey = key(r.src, r.rel, r.dst); const existing = direct.get(directKey); if (existing && existing !== r) { // Replace: remove existing from all Sets, then insert r const srcRel = key(existing.src, existing.rel); const dstRel = key(existing.dst, existing.rel); byName.get(existing.rel)?.delete(existing); if (byName.get(existing.rel)?.size === 0) byName.delete(existing.rel); bySrc.get(srcRel)?.delete(existing); if (bySrc.get(srcRel)?.size === 0) bySrc.delete(srcRel); byDst.get(dstRel)?.delete(existing); if (byDst.get(dstRel)?.size === 0) byDst.delete(dstRel); } if (!existing) { // New: also update outgoingEdges / incomingEdges (production appends on every add) if (!outgoingEdges.has(r.src)) outgoingEdges.set(r.src, []); outgoingEdges.get(r.src).push(r.dst); if (!incomingEdges.has(r.dst)) incomingEdges.set(r.dst, []); incomingEdges.get(r.dst).push(r.src); } direct.set(directKey, r); if (!byName.has(r.rel)) byName.set(r.rel, new Set()); byName.get(r.rel).add(r); const srcRel = key(r.src, r.rel); if (!bySrc.has(srcRel)) bySrc.set(srcRel, new Set()); bySrc.get(srcRel).add(r); const dstRel = key(r.dst, r.rel); if (!byDst.has(dstRel)) byDst.set(dstRel, new Set()); byDst.get(dstRel).add(r); } function remove(r, opSrc, opDst, opRel) { // Production uses the args (srcId, dstId, relation) for the composite key const srcId = opSrc !== undefined ? opSrc : r.src; const dstId = opDst !== undefined ? opDst : r.dst; const rel = opRel !== undefined ? opRel : r.rel; const directKey = key(srcId, rel, dstId); const stored = direct.get(directKey); if (!stored) return { skipped: true }; direct.delete(directKey); const srcRel = key(stored.src, stored.rel); const dstRel = key(stored.dst, stored.rel); byName.get(stored.rel)?.delete(stored); if (byName.get(stored.rel)?.size === 0) byName.delete(stored.rel); bySrc.get(srcRel)?.delete(stored); if (bySrc.get(srcRel)?.size === 0) bySrc.delete(srcRel); byDst.get(dstRel)?.delete(stored); if (byDst.get(dstRel)?.size === 0) byDst.delete(dstRel); } function clear() { direct.clear(); byName.clear(); bySrc.clear(); byDst.clear(); outgoingEdges.clear(); incomingEdges.clear(); } function getDirect(src, rel, dst) { return direct.get(key(src, rel, dst)); } function getByName(rel) { return Array.from(byName.get(rel) ?? []); } function getBySrc(src, rel) { return Array.from(bySrc.get(key(src, rel)) ?? []); } function getByDst(dst, rel) { return Array.from(byDst.get(key(dst, rel)) ?? []); } return { add, remove, clear, getDirect, getByName, getBySrc, getByDst, direct, byName, bySrc, byDst, outgoingEdges, incomingEdges }; } describe('GraphIndices indexes (rigor)', () => { it('getDirectRelation matches the brute-force oracle', async () => { async function check(operations) { const gi = new GraphIndices(); const oracle = makeOracle(); // op codes: 0=add, 1=remove, 2=query for (const op of operations) { if (op[0] === 0) { gi.addRelation(op[1]); oracle.add(op[1]); } else if (op[0] === 1) { gi.removeRelation(op[1], op[2], op[3], op[4]); // Mirror production's key construction exactly: it looks up by the // (srcId, dstId, relation) ARGS, not the relObj's own fields. oracle.remove(op[1], op[2], op[3], op[4]); } else if (op[0] === 2) { const [, src, rel, dst] = op; const actual = gi.getDirectRelation(src, rel, dst); const expected = oracle.getDirect(src, rel, dst); if (actual !== expected) { throw new Error( `getDirectRelation(${src},${rel},${dst}): actual=${JSON.stringify(actual)} expected=${JSON.stringify(expected)}` ); } } } return true; } const relGen = rigor.gen.tuple( rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 5), rigor.gen.float({ min: 0, max: 1 }) ).map(([src, rel, dst, p]) => relObj(src, rel, dst, p)); const opGen = rigor.gen.array( rigor.gen.oneOf([ rigor.gen.tuple(rigor.gen.constant(0), relGen), rigor.gen.tuple(rigor.gen.constant(1), relGen, rigor.gen.int(0, 5), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS)), rigor.gen.tuple(rigor.gen.constant(2), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 5)) ]), 1, 12 ); const report = await rigor.campaign( [rigor.fn('check', check, rigor.args(opGen))], rigor.crucible([ rigor.invariant('getDirectRelation-matches-oracle', ({ actual }) => actual !== undefined) ]) ).run({ effort: 1500, seed: 'graph-indices-direct-a' , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getDirectRelation-matches-oracle'); assert.ok(inv); assert.equal(inv.passed, true, `getDirectRelation diverged from oracle in ${inv.failureCount} cases`); }); it('getRelationsFromSrc matches the brute-force oracle', async () => { async function check(operations) { const gi = new GraphIndices(); const oracle = makeOracle(); for (const op of operations) { if (op[0] === 0) { gi.addRelation(op[1]); oracle.add(op[1]); } else if (op[0] === 1) { gi.removeRelation(op[1], op[2], op[3], op[4]); oracle.remove(op[1], op[2], op[3], op[4]); } else if (op[0] === 2) { const [, src, rel] = op; const actual = gi.getRelationsFromSrc(src, rel); const expected = oracle.getBySrc(src, rel); // Both should be Sets — compare content if (actual.length !== expected.length) { throw new Error( `getRelationsFromSrc(${src},${rel}): length actual=${actual.length} expected=${expected.length}` ); } const actualKeys = new Set(actual.map(r => `${r.src}|${r.rel}|${r.dst}`)); const expectedKeys = new Set(expected.map(r => `${r.src}|${r.rel}|${r.dst}`)); if (actualKeys.size !== expectedKeys.size || ![...actualKeys].every(k => expectedKeys.has(k))) { throw new Error( `getRelationsFromSrc(${src},${rel}): content mismatch` ); } } } return true; } const relGen = rigor.gen.tuple( rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 5), rigor.gen.float({ min: 0, max: 1 }) ).map(([src, rel, dst, p]) => relObj(src, rel, dst, p)); const opGen = rigor.gen.array( rigor.gen.oneOf([ rigor.gen.tuple(rigor.gen.constant(0), relGen), rigor.gen.tuple(rigor.gen.constant(1), relGen, rigor.gen.int(0, 5), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS)), rigor.gen.tuple(rigor.gen.constant(2), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS)) ]), 1, 12 ); const report = await rigor.campaign( [rigor.fn('check', check, rigor.args(opGen))], rigor.crucible([ rigor.invariant('getRelationsFromSrc-matches-oracle', ({ actual }) => actual !== undefined) ]) ).run({ effort: 1500, seed: 'graph-indices-direct-b' , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getRelationsFromSrc-matches-oracle'); assert.ok(inv); assert.equal(inv.passed, true, `getRelationsFromSrc diverged from oracle in ${inv.failureCount} cases`); }); it('getRelationsToDst matches the brute-force oracle', async () => { async function check(operations) { const gi = new GraphIndices(); const oracle = makeOracle(); for (const op of operations) { if (op[0] === 0) { gi.addRelation(op[1]); oracle.add(op[1]); } else if (op[0] === 1) { gi.removeRelation(op[1], op[2], op[3], op[4]); oracle.remove(op[1], op[2], op[3], op[4]); } else if (op[0] === 2) { const [, dst, rel] = op; const actual = gi.getRelationsToDst(dst, rel); const expected = oracle.getByDst(dst, rel); if (actual.length !== expected.length) { throw new Error( `getRelationsToDst(${dst},${rel}): length actual=${actual.length} expected=${expected.length}` ); } const actualKeys = new Set(actual.map(r => `${r.src}|${r.rel}|${r.dst}`)); const expectedKeys = new Set(expected.map(r => `${r.src}|${r.rel}|${r.dst}`)); if (actualKeys.size !== expectedKeys.size || ![...actualKeys].every(k => expectedKeys.has(k))) { throw new Error(`getRelationsToDst(${dst},${rel}): content mismatch`); } } } return true; } const relGen = rigor.gen.tuple( rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 5), rigor.gen.float({ min: 0, max: 1 }) ).map(([src, rel, dst, p]) => relObj(src, rel, dst, p)); const opGen = rigor.gen.array( rigor.gen.oneOf([ rigor.gen.tuple(rigor.gen.constant(0), relGen), rigor.gen.tuple(rigor.gen.constant(1), relGen, rigor.gen.int(0, 5), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS)), rigor.gen.tuple(rigor.gen.constant(2), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS)) ]), 1, 12 ); const report = await rigor.campaign( [rigor.fn('check', check, rigor.args(opGen))], rigor.crucible([ rigor.invariant('getRelationsToDst-matches-oracle', ({ actual }) => actual !== undefined) ]) ).run({ effort: 1500, seed: 'graph-indices-direct-c' , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getRelationsToDst-matches-oracle'); assert.ok(inv); assert.equal(inv.passed, true, `getRelationsToDst diverged from oracle in ${inv.failureCount} cases`); }); it('getRelationsByName matches the brute-force oracle', async () => { async function check(operations) { const gi = new GraphIndices(); const oracle = makeOracle(); for (const op of operations) { if (op[0] === 0) { gi.addRelation(op[1]); oracle.add(op[1]); } else if (op[0] === 1) { gi.removeRelation(op[1], op[2], op[3], op[4]); oracle.remove(op[1], op[2], op[3], op[4]); } else if (op[0] === 2) { const [, rel] = op; const actual = gi.getRelationsByName(rel); const expected = oracle.getByName(rel); if (actual.length !== expected.length) { throw new Error( `getRelationsByName(${rel}): length actual=${actual.length} expected=${expected.length}` ); } const actualKeys = new Set(actual.map(r => `${r.src}|${r.rel}|${r.dst}`)); const expectedKeys = new Set(expected.map(r => `${r.src}|${r.rel}|${r.dst}`)); if (actualKeys.size !== expectedKeys.size || ![...actualKeys].every(k => expectedKeys.has(k))) { throw new Error(`getRelationsByName(${rel}): content mismatch`); } } } return true; } const relGen = rigor.gen.tuple( rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 5), rigor.gen.float({ min: 0, max: 1 }) ).map(([src, rel, dst, p]) => relObj(src, rel, dst, p)); const opGen = rigor.gen.array( rigor.gen.oneOf([ rigor.gen.tuple(rigor.gen.constant(0), relGen), rigor.gen.tuple(rigor.gen.constant(1), relGen, rigor.gen.int(0, 5), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS)), rigor.gen.tuple(rigor.gen.constant(2), rigor.gen.enum(RELATIONS)) ]), 1, 12 ); const report = await rigor.campaign( [rigor.fn('check', check, rigor.args(opGen))], rigor.crucible([ rigor.invariant('getRelationsByName-matches-oracle', ({ actual }) => actual !== undefined) ]) ).run({ effort: 1500, seed: 'graph-indices-direct-d' , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getRelationsByName-matches-oracle'); assert.ok(inv); assert.equal(inv.passed, true, `getRelationsByName diverged from oracle in ${inv.failureCount} cases`); }); it('addRelation is idempotent for same (src,rel,dst) regardless of object identity (RF-22)', async () => { // RF-22 closure regression test. Two distinct objects sharing the same // (src, rel, dst) tuple must not produce two entries in the by-rel/ // by-src-rel/by-dst-rel indexes — relationsBySrcRelDst's composite-key // dedup is the canonical invariant. async function check(src, rel, dst, possibility1, possibility2) { const gi = new GraphIndices(); const r1 = relObj(src, rel, dst, possibility1); const r2 = relObj(src, rel, dst, possibility2); gi.addRelation(r1); gi.addRelation(r2); // direct lookup returns the FIRST (last-writer-wins on the composite key // means the second call overwrites, so r2 should be returned) const direct = gi.getDirectRelation(src, rel, dst); if (direct !== r2) { throw new Error(`getDirectRelation should return r2, got ${JSON.stringify(direct)}`); } // byName/bySrcRel/byDstRel must contain only r2 (the surviving entry) const byName = gi.getRelationsByName(rel); if (byName.length !== 1 || byName[0] !== r2) { throw new Error(`getRelationsByName should return only r2, got ${byName.length} entries`); } const bySrc = gi.getRelationsFromSrc(src, rel); if (bySrc.length !== 1 || bySrc[0] !== r2) { throw new Error(`getRelationsFromSrc should return only r2, got ${bySrc.length} entries`); } const byDst = gi.getRelationsToDst(dst, rel); if (byDst.length !== 1 || byDst[0] !== r2) { throw new Error(`getRelationsToDst should return only r2, got ${byDst.length} entries`); } return true; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args( rigor.gen.int(0, 10), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 10), rigor.gen.float({ min: 0, max: 1 }), rigor.gen.float({ min: 0, max: 1 }) ) )], rigor.crucible([ rigor.invariant('addRelation-tuple-idempotent', ({ actual }) => actual !== undefined) ]) ).run({ effort: 800, seed: 'graph-indices-src' , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'addRelation-tuple-idempotent'); assert.ok(inv); assert.equal(inv.passed, true, `RF-22 idempotence violated in ${inv.failureCount} cases`); }); it('addRelation is idempotent (Set semantics, same obj not added twice)', async () => { async function check(r) { const gi = new GraphIndices(); gi.addRelation(r); const before = gi.relationsByRel.get(r.rel)?.size ?? 0; gi.addRelation(r); // same object again const after = gi.relationsByRel.get(r.rel)?.size ?? 0; if (before !== after) { throw new Error(`addRelation not idempotent: before=${before} after=${after}`); } // direct lookup should return the same object const a = gi.getDirectRelation(r.src, r.rel, r.dst); if (a !== r) { throw new Error(`getDirectRelation returned different object identity`); } return true; } const relGen = rigor.gen.tuple( rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 5), rigor.gen.float({ min: 0, max: 1 }) ).map(([src, rel, dst, p]) => relObj(src, rel, dst, p)); const report = await rigor.campaign( [rigor.fn('check', check, rigor.args(relGen))], rigor.crucible([ rigor.invariant('addRelation-idempotent', ({ actual }) => actual !== undefined) ]) ).run({ effort: 800, seed: 'graph-indices-dst' , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'addRelation-idempotent'); assert.ok(inv); assert.equal(inv.passed, true, `addRelation idempotence violated in ${inv.failureCount} cases`); }); it('clear empties every index', async () => { async function check(rels) { const gi = new GraphIndices(); for (const r of rels) gi.addRelation(r); gi.clear(); if (gi.relationsBySrcRelDst.size !== 0) throw new Error('relationsBySrcRelDst not empty'); if (gi.relationsBySrcRel.size !== 0) throw new Error('relationsBySrcRel not empty'); if (gi.relationsByDstRel.size !== 0) throw new Error('relationsByDstRel not empty'); if (gi.relationsByRel.size !== 0) throw new Error('relationsByRel not empty'); if (gi.outgoingEdges.size !== 0) throw new Error('outgoingEdges not empty'); if (gi.incomingEdges.size !== 0) throw new Error('incomingEdges not empty'); // keyManager should be cleared too — re-adding same rel returns different id only if cleared // Actually re-adding after clear should still work gi.addRelation(rels[0]); if (!gi.getDirectRelation(rels[0].src, rels[0].rel, rels[0].dst)) { throw new Error('cannot re-add after clear'); } return true; } const relGen = rigor.gen.array( rigor.gen.tuple( rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 5), rigor.gen.float({ min: 0, max: 1 }) ).map(([src, rel, dst, p]) => relObj(src, rel, dst, p)), 1, 8 ); const report = await rigor.campaign( [rigor.fn('check', check, rigor.args(relGen))], rigor.crucible([ rigor.invariant('clear-empties-indexes', ({ actual }) => actual !== undefined) ]) ).run({ effort: 800, seed: 'graph-indices-name' , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'clear-empties-indexes'); assert.ok(inv); assert.equal(inv.passed, true, `clear contract violated in ${inv.failureCount} cases`); }); it('add then remove returns undefined from getDirectRelation', async () => { async function check(r) { const gi = new GraphIndices(); gi.addRelation(r); const found = gi.getDirectRelation(r.src, r.rel, r.dst); if (!found) throw new Error(`expected to find ${JSON.stringify(r)}`); gi.removeRelation(r, r.src, r.dst, r.rel); const afterRemove = gi.getDirectRelation(r.src, r.rel, r.dst); if (afterRemove !== undefined) { throw new Error(`expected undefined after remove, got ${JSON.stringify(afterRemove)}`); } // Indexes should be empty for this (src,rel,dst) const fromSrc = gi.getRelationsFromSrc(r.src, r.rel); if (fromSrc.some(x => x.src === r.src && x.dst === r.dst && x.rel === r.rel)) { throw new Error(`getRelationsFromSrc still contains removed relation`); } const fromDst = gi.getRelationsToDst(r.dst, r.rel); if (fromDst.some(x => x.src === r.src && x.dst === r.dst && x.rel === r.rel)) { throw new Error(`getRelationsToDst still contains removed relation`); } const byName = gi.getRelationsByName(r.rel); if (byName.some(x => x.src === r.src && x.dst === r.dst && x.rel === r.rel)) { throw new Error(`getRelationsByName still contains removed relation`); } return true; } const relGen = rigor.gen.tuple( rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 5), rigor.gen.float({ min: 0, max: 1 }) ).map(([src, rel, dst, p]) => relObj(src, rel, dst, p)); const report = await rigor.campaign( [rigor.fn('check', check, rigor.args(relGen))], rigor.crucible([ rigor.invariant('add-remove-cycle', ({ actual }) => actual !== undefined) ]) ).run({ effort: 800, seed: 'graph-indices-cycle' , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'add-remove-cycle'); assert.ok(inv); assert.equal(inv.passed, true, `add+remove cycle violated in ${inv.failureCount} cases`); }); });