560 lines
23 KiB
JavaScript
560 lines
23 KiB
JavaScript
|
|
/**
|
||
|
|
* 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;
|
||
|
|
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', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 1500, seed: 'graph-indices-direct-a' });
|
||
|
|
|
||
|
|
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', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 1500, seed: 'graph-indices-direct-b' });
|
||
|
|
|
||
|
|
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', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 1500, seed: 'graph-indices-direct-c' });
|
||
|
|
|
||
|
|
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', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 1500, seed: 'graph-indices-direct-d' });
|
||
|
|
|
||
|
|
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', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 800, seed: 'graph-indices-src' });
|
||
|
|
|
||
|
|
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', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 800, seed: 'graph-indices-dst' });
|
||
|
|
|
||
|
|
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', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 800, seed: 'graph-indices-name' });
|
||
|
|
|
||
|
|
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', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 800, seed: 'graph-indices-cycle' });
|
||
|
|
|
||
|
|
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`);
|
||
|
|
});
|
||
|
|
});
|