/** * rigor/node-lifecycle.test.js — js-rigor property tests for node removal * and re-addition semantics. * * Properties verified: * * - REMOVE CASCADE: removeNode(key) deletes every relation incident to * the node (from the relations array AND the indices), and checks * reflect the pruned graph exactly — chain results match a BFS oracle * on the pruned edge set, and checks that never touched the removed * node are unchanged. * - MISSING USER: removing the user node makes its checks report * missing_node semantics (0). * - IDEMPOTENCE: removing a non-existent node returns false and leaves * the state untouched. * - RE-ADD: re-adding the key yields a fresh node with no stale * relations; new edges take effect; old ids do not leak. */ 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; } }; } 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) { const edges = []; for (const rel of ['r1', 'r2']) { for (const [src, dst] of EDGE_UNIVERSE[rel]) { if (rng.next() < 0.5) { 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; 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 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('target', { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }] }); return arb; } describe('Node lifecycle semantics (rigor)', () => { it('REMOVE CASCADE: removing a node prunes its edges exactly; checks match the pruned graph', async () => { async function check({ seed, removeTarget }) { const rng = mulberry32(seed); const edges = randomEdges(rng); const arb = buildArbiter(); for (const [src, rel, dst, p] of edges) arb.addRelation(src, rel, dst, { possibility: p }); const baseline = arb.check('user:alice', 'target', 'doc:1', {}).possibility; const baselineOracle = chainOracle( [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }], edges ); if (Math.abs(baseline - baselineOracle) > EPS) { fail(`baseline mismatch: ${baseline} vs ${baselineOracle}`); } // Sanity: every edge lands in the index for (const [src, rel, dst] of edges) { const srcId = arb.resolveNodeId(src); const dstId = arb.resolveNodeId(dst); const found = arb.indices.getDirectRelation(srcId, rel, dstId); if (!found) { fail(`edge ${src} ${rel} ${dst} missing from index before removal`); } } const prunedEdges = edges.filter(e => e[0] !== removeTarget && e[2] !== removeTarget); // Remove twice: first cascade, then idempotent no-op const first = arb.nodeManager.removeNode(removeTarget); if (!first) fail(`removeNode(${removeTarget}) returned false on existing node`); // 1. No incident edges remain in the relations array const nodeId = null; // id was deleted; scan by key instead const incidentLeft = arb.relations.some(r => { const srcKey = arb.keyByNodeId.get(r.src); const dstKey = arb.keyByNodeId.get(r.dst); return srcKey === removeTarget || dstKey === removeTarget; }); if (incidentLeft) { fail(`relations still reference removed node ${removeTarget}`); } // 2. Chain check matches the pruned-graph oracle const expectedP = chainOracle( [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }], prunedEdges ); const res = arb.check('user:alice', 'target', 'doc:1', {}); if (removeTarget === 'user:alice') { if (res.reason !== 'missing_node' && res.possibility !== 0) { fail(`removed user check: expected missing_node, got ${JSON.stringify(res)}`); } } else if (Math.abs(res.possibility - expectedP) > EPS) { fail(`post-removal mismatch: oracle=${expectedP} got=${res.possibility} removed=${removeTarget} edges=${JSON.stringify(prunedEdges)}`); } // 3. Direct checks on remaining edges still work for (const [src, rel, dst, p] of prunedEdges.slice(0, 3)) { const srcId = arb.resolveNodeId(src); const dstId = arb.resolveNodeId(dst); if (srcId === undefined || dstId === undefined) continue; const found = arb.indices.getDirectRelation(srcId, rel, dstId); if (!found || Math.abs(found.possibility - p) > EPS) { fail(`surviving edge ${src} ${rel} ${dst} lost (${JSON.stringify(found)})`); } } // 4. Idempotence const second = arb.nodeManager.removeNode(removeTarget); if (second !== false) fail(`second removeNode returned ${second}, expected false`); const afterSecond = arb.relations.length; if (afterSecond !== prunedEdges.length) { fail(`idempotent remove changed state: ${afterSecond} relations, expected ${prunedEdges.length}`); } return { pruned: prunedEdges.length }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ seed: rigor.gen.int(1, 80000), removeTarget: rigor.gen.oneOf(NODES) }) )) ], rigor.crucible([ rigor.invariant('remove-cascade', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 1000, seed: 'node-lifecycle-remove' , artifacts: { dir: '', persist: 'never' }}); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'remove-cascade'); assert.ok(inv, 'invariant missing'); assert.equal(inv.passed, true, `remove cascade violated in ${inv.failureCount} cases`); }); it('RE-ADD: re-adding a removed node key is a fresh node with working edges and no stale state', async () => { async function check({ seed }) { const rng = mulberry32(seed); const edges = randomEdges(rng); const arb = buildArbiter(); for (const [src, rel, dst, p] of edges) arb.addRelation(src, rel, dst, { possibility: p }); arb.nodeManager.removeNode('mid:1'); // Re-add the node and a fresh edge through it arb.addNode('mid:1', 'mid'); const fresh = Math.random() < 0.5 ? 0.5 : 1; arb.addRelation('user:alice', 'r1', 'mid:1', { possibility: fresh }); arb.addRelation('mid:1', 'r2', 'doc:1', { possibility: 1 }); const pruned = edges.filter(e => e[0] !== 'mid:1' && e[2] !== 'mid:1'); const viaOld = chainOracle( [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }], [...pruned, ['user:alice', 'r1', 'mid:1', fresh], ['mid:1', 'r2', 'doc:1', 1]] ); // The path through the re-added node is min(fresh, 1) = fresh const expected = Math.max( chainOracle([{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }], pruned), fresh ); const res = arb.check('user:alice', 'target', 'doc:1', {}); if (Math.abs(res.possibility - expected) > EPS) { fail(`re-add mismatch: oracle=${expected} got=${res.possibility}`); } // Exactly one r1 edge user->mid:1 const count = arb.relations.filter(r => r.rel === 'r1' && r.src === arb.resolveNodeId('user:alice') && r.dst === arb.resolveNodeId('mid:1')).length; if (count !== 1) { fail(`re-add left ${count} user->mid:1 r1 tuples`); } return { expected }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ seed: rigor.gen.int(1, 60000) }) )) ], rigor.crucible([ rigor.invariant('node-readd', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 600, seed: 'node-lifecycle-readd' , artifacts: { dir: '', persist: 'never' }}); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'node-readd'); assert.ok(inv, 'invariant missing'); assert.equal(inv.passed, true, `re-add violated in ${inv.failureCount} cases`); }); });