Files

560 lines
24 KiB
JavaScript
Raw Permalink Normal View History

/**
* rigor/graph-indices.test.js — js-rigor property tests for GraphIndices.
*
* GraphIndices maintains five indexes over the relation set:
* - relationsBySrcRelDst: Map<key, relationObj> — direct lookup (src,rel,dst) → rel
* - relationsBySrcRel: Map<key, Set<relationObj>> — all relations from src under rel
* - relationsByDstRel: Map<key, Set<relationObj>> — all relations to dst under rel
* - relationsByRel: Map<rel, Set<relationObj>> — all relations under a name
* - outgoingEdges: Map<srcId, dstId[]> — adjacency (with duplicates)
* - incomingEdges: Map<dstId, srcId[]> — 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<rel, Set<relObj>> — Set by reference identity
* - bySrc: Map<"src|rel", Set<relObj>>
* - byDst: Map<"dst|rel", Set<relObj>>
*
* 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`);
});
});