/** * rigor/relation-manager.test.js — js-rigor property tests for RelationManager. * * RelationManager owns relation lifecycle (addRelation, removeRelation) and * exposes getDirectRelation. Properties verified using a real Arbiter (no * mocking — the manager surface is small and Arbiter construction is cheap): * * - addRelation(src, rel, dst) is idempotent (returns relationIndex, but the * second call routes to _modifyRelation and updates the existing entry) * - getDirectRelation(src, rel, dst) returns null for unknown tuples * - getDirectRelation returns the live relation object after addRelation * - removeRelation clears all five indexes (GraphIndices + arbiter.relations) * - Cross-check: arbiter.indices.getDirectRelation === relationManager.getDirectRelation * - _relationKeys Set size matches arbiter.relations length * - _relationKeyToIndex points at the correct index in arbiter.relations * - getDirectRelation after removeRelation returns null */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent']; /** * Build an Arbiter pre-populated with the given relations. Each entry is * {src, rel, dst, possibility}. */ function makeArbiter(initialRelations = []) { const arbiter = new Arbiter(); const keyIds = new Map(); let nextId = 0; for (const r of initialRelations) { const srcKey = `u${r.src}`; const dstKey = `d${r.dst}`; if (!arbiter.nodeIdByKey.has(srcKey)) arbiter.addNode(srcKey, 'user'); if (!arbiter.nodeIdByKey.has(dstKey)) arbiter.addNode(dstKey, 'doc'); arbiter.addRelation(srcKey, r.rel, dstKey, { possibility: r.possibility ?? 1.0 }); } return arbiter; } describe('RelationManager.addRelation/removeRelation (rigor)', () => { it('addRelation then getDirectRelation returns the live relation object', async () => { async function check(src, rel, dst, possibility) { const arbiter = makeArbiter(); arbiter.addNode(`u${src}`, 'user'); arbiter.addNode(`d${dst}`, 'doc'); const srcId = arbiter.nodeIdByKey.get(`u${src}`); const dstId = arbiter.nodeIdByKey.get(`d${dst}`); arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility }); const result = arbiter.relationManager.getDirectRelation(srcId, rel, dstId); if (!result) { throw new Error(`expected getDirectRelation(${srcId},${rel},${dstId}) to return relation, got null`); } if (result.possibility !== possibility) { throw new Error(`possibility=${result.possibility}, expected ${possibility}`); } if (result.rel !== rel) { throw new Error(`rel=${result.rel}, expected ${rel}`); } return result; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args( rigor.gen.int(0, 20), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 20), rigor.gen.float({ min: 0.01, max: 1 }) ) )], rigor.crucible([ rigor.invariant('add-and-get', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 800 }); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'add-and-get'); assert.ok(inv); assert.equal(inv.passed, true, `addRelation→getDirectRelation contract violated in ${inv.failureCount} cases`); }); it('addRelation is idempotent: re-add with same (src,rel,dst) updates in place, not adds new', async () => { async function check(src, rel, dst, possibility1, possibility2) { const arbiter = makeArbiter(); arbiter.addNode(`u${src}`, 'user'); arbiter.addNode(`d${dst}`, 'doc'); const srcId = arbiter.nodeIdByKey.get(`u${src}`); const dstId = arbiter.nodeIdByKey.get(`d${dst}`); arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility: possibility1 }); const initialLen = arbiter.relations.length; // Re-add with different possibility arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility: possibility2 }); // Length should not have grown (duplicate → modify, not insert) if (arbiter.relations.length !== initialLen) { throw new Error(`relations array grew from ${initialLen} to ${arbiter.relations.length} after duplicate add`); } // _relationKeys Set should have only one entry for this (src, rel, dst) if (arbiter.relationManager._relationKeys.size !== 1) { throw new Error(`_relationKeys.size=${arbiter.relationManager._relationKeys.size}, expected 1`); } // The new possibility should be reflected in getDirectRelation const result = arbiter.relationManager.getDirectRelation(srcId, rel, dstId); if (!result) { throw new Error(`getDirectRelation returned null after duplicate add`); } if (result.possibility !== possibility2) { throw new Error(`possibility=${result.possibility}, expected ${possibility2} (the second add)`); } return true; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args( rigor.gen.int(0, 20), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 20), rigor.gen.float({ min: 0.01, max: 1 }), rigor.gen.float({ min: 0.01, max: 1 }) ) )], rigor.crucible([ rigor.invariant('addRelation-idempotent', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 800 }); 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('removeRelation clears all five indexes and returns getDirectRelation to null', async () => { async function check(src, rel, dst) { const arbiter = makeArbiter(); arbiter.addNode(`u${src}`, 'user'); arbiter.addNode(`d${dst}`, 'doc'); const srcId = arbiter.nodeIdByKey.get(`u${src}`); const dstId = arbiter.nodeIdByKey.get(`d${dst}`); arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility: 0.5 }); // Verify pre-conditions if (!arbiter.relationManager.getDirectRelation(srcId, rel, dstId)) { throw new Error('pre-condition failed: relation not present after add'); } arbiter.relationManager.removeRelation(`u${src}`, rel, `d${dst}`); // After remove, getDirectRelation should be null const after = arbiter.relationManager.getDirectRelation(srcId, rel, dstId); if (after !== null && after !== undefined) { throw new Error(`expected null after remove, got ${JSON.stringify(after)}`); } // arbiter.indices should also be clear const fromIdx = arbiter.indices.getDirectRelation(srcId, rel, dstId); if (fromIdx !== undefined) { throw new Error(`arbiter.indices still has entry after remove: ${JSON.stringify(fromIdx)}`); } // _relationKeys should not have this tuple const key = arbiter.relationManager._makeRelationKey(srcId, rel, dstId); if (arbiter.relationManager._relationKeys.has(key)) { throw new Error(`_relationKeys still has ${key} after remove`); } // byName should not have this relation const byName = arbiter.indices.getRelationsByName(rel); if (byName.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) { throw new Error(`getRelationsByName still contains removed relation`); } const bySrc = arbiter.indices.getRelationsFromSrc(srcId, rel); if (bySrc.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) { throw new Error(`getRelationsFromSrc still contains removed relation`); } const byDst = arbiter.indices.getRelationsToDst(dstId, rel); if (byDst.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) { throw new Error(`getRelationsToDst still contains removed relation`); } return true; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args( rigor.gen.int(0, 20), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 20) ) )], rigor.crucible([ rigor.invariant('remove-clears-indexes', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 800 }); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'remove-clears-indexes'); assert.ok(inv); assert.equal(inv.passed, true, `removeRelation index cleanup violated in ${inv.failureCount} cases`); }); it('index coherence: every relation in arbiter.relations is queryable through every index', async () => { async function check(relations) { // Cap input size to keep the test fast if (relations.length > 5) return null; const arbiter = makeArbiter(); const seenKeys = new Set(); for (const r of relations) { const srcKey = `u${r.src}`; const dstKey = `d${r.dst}`; if (!arbiter.nodeIdByKey.has(srcKey)) arbiter.addNode(srcKey, 'user'); if (!arbiter.nodeIdByKey.has(dstKey)) arbiter.addNode(dstKey, 'doc'); const k = `${r.src}|${r.rel}|${r.dst}`; if (seenKeys.has(k)) continue; // skip duplicates — relation manager would modify seenKeys.add(k); arbiter.addRelation(srcKey, r.rel, dstKey, { possibility: r.possibility }); } // For each unique (src, rel, dst) in the input, verify all indexes agree for (const k of seenKeys) { const [src, rel, dst] = k.split('|'); const srcKey = `u${src}`; const dstKey = `d${dst}`; const srcId = arbiter.nodeIdByKey.get(srcKey); const dstId = arbiter.nodeIdByKey.get(dstKey); // 1. arbiter.indices.getDirectRelation const fromIdx = arbiter.indices.getDirectRelation(srcId, rel, dstId); if (!fromIdx) throw new Error(`arbiter.indices.getDirectRelation(${k}) returned null`); // 2. relationManager.getDirectRelation (should agree) const fromMgr = arbiter.relationManager.getDirectRelation(srcId, rel, dstId); if (fromMgr !== fromIdx) { throw new Error(`relationManager.getDirectRelation !== arbiter.indices.getDirectRelation for ${k}`); } // 3. byName must contain this relation const byName = arbiter.indices.getRelationsByName(rel); if (!byName.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) { throw new Error(`getRelationsByName(${rel}) missing ${k}`); } // 4. bySrc must contain this relation const bySrc = arbiter.indices.getRelationsFromSrc(srcId, rel); if (!bySrc.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) { throw new Error(`getRelationsFromSrc(${srcId},${rel}) missing ${k}`); } // 5. byDst must contain this relation const byDst = arbiter.indices.getRelationsToDst(dstId, rel); if (!byDst.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) { throw new Error(`getRelationsToDst(${dstId},${rel}) missing ${k}`); } } // Cross-consistency: _relationKeys.size matches arbiter.relations.length // (modulo dedup — we passed unique keys above) if (arbiter.relationManager._relationKeys.size !== arbiter.relations.length) { throw new Error( `_relationKeys.size=${arbiter.relationManager._relationKeys.size} !== ` + `arbiter.relations.length=${arbiter.relations.length}` ); } return true; } const relGen = rigor.gen.array( rigor.gen.tuple( rigor.gen.int(0, 4), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 4), rigor.gen.float({ min: 0.01, max: 1 }) ).map(([src, rel, dst, p]) => ({ src, rel, dst, possibility: p })), 1, 5 ); const report = await rigor.campaign( [rigor.fn('check', check, rigor.args(relGen))], rigor.crucible([ rigor.invariant('index-coherence', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 1500 }); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'index-coherence'); assert.ok(inv); assert.equal(inv.passed, true, `index coherence violated in ${inv.failureCount} cases`); }); it('add-then-remove sequence: state matches initial (no leaks)', async () => { async function check(operations) { const arbiter = makeArbiter(); // Each operation: [opCode, src, rel, dst] // 0 = addRelation, 1 = removeRelation for (const op of operations) { if (op[0] === 0) { const [, src, rel, dst] = op; if (!arbiter.nodeIdByKey.has(`u${src}`)) arbiter.addNode(`u${src}`, 'user'); if (!arbiter.nodeIdByKey.has(`d${dst}`)) arbiter.addNode(`d${dst}`, 'doc'); arbiter.addRelation(`u${src}`, rel, `d${dst}`); } else if (op[0] === 1) { const [, src, rel, dst] = op; if (!arbiter.nodeIdByKey.has(`u${src}`)) continue; // node not added yet if (!arbiter.nodeIdByKey.has(`d${dst}`)) continue; arbiter.relationManager.removeRelation(`u${src}`, rel, `d${dst}`); } } // Verify: for every key in _relationKeys, arbiter.relations has it // AND arbiter.indices.getDirectRelation returns it // _relationKeys keys are formatted as "srcId|relationId|dstId" where // relationId is an internal id, not the relation name. Build a reverse map. const relIdToName = new Map(); for (const [name, id] of arbiter.relationManager._relationNameToId) { relIdToName.set(id, name); } for (const key of arbiter.relationManager._relationKeys) { const [srcIdStr, relationIdStr, dstIdStr] = key.split('|'); const srcId = parseInt(srcIdStr, 10); const dstId = parseInt(dstIdStr, 10); const relId = parseInt(relationIdStr, 10); const relName = relIdToName.get(relId); if (!relName) { throw new Error(`cannot resolve relationId ${relId} to name (keyManager state: ${JSON.stringify([...relIdToName])})`); } const idx = arbiter.indices.getDirectRelation(srcId, relName, dstId); if (!idx) { throw new Error(`_relationKeys has ${key} but indices.getDirectRelation returns null`); } // The relation should also be findable in arbiter.relations const found = arbiter.relations.some(r => r.src === srcId && r.dst === dstId && r.rel === relName); if (!found) { throw new Error(`_relationKeys has ${key} but arbiter.relations does not`); } } // Verify: for every relation in arbiter.relations, _relationKeys has it for (const r of arbiter.relations) { const key = arbiter.relationManager._makeRelationKey(r.src, r.rel, r.dst); if (!arbiter.relationManager._relationKeys.has(key)) { throw new Error(`arbiter.relations has ${r.src}|${r.rel}|${r.dst} but _relationKeys missing`); } } return true; } const opGen = rigor.gen.array( rigor.gen.oneOf([ rigor.gen.tuple(rigor.gen.constant(0), rigor.gen.int(0, 4), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 4)), rigor.gen.tuple(rigor.gen.constant(1), rigor.gen.int(0, 4), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 4)) ]), 1, 8 ); const report = await rigor.campaign( [rigor.fn('check', check, rigor.args(opGen))], rigor.crucible([ rigor.invariant('add-remove-roundtrip', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 1500 }); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'add-remove-roundtrip'); assert.ok(inv); assert.equal(inv.passed, true, `add-then-remove sequence violated in ${inv.failureCount} cases`); }); it('getDirectRelation returns null for unknown (src, rel, dst)', async () => { async function check(src, rel, dst) { const arbiter = makeArbiter(); // No relations added; any query should return null/undefined // Note: src/dst are arbitrary ints because we never added those nodes, // so the relation can't exist by definition. const result = arbiter.relationManager.getDirectRelation(src, rel, dst); if (result !== null && result !== undefined) { throw new Error(`expected null/undefined for unknown relation, got ${JSON.stringify(result)}`); } return true; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args( rigor.gen.int(0, 1000), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 1000) ) )], rigor.crucible([ rigor.invariant('getDirectRelation-unknown', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 500 }); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getDirectRelation-unknown'); assert.ok(inv); assert.equal(inv.passed, true, `getDirectRelation-unknown contract violated in ${inv.failureCount} cases`); }); });