initial commit: @arbiter/core authorization engine with js-rigor hardening
Zanzibar-style authorization graph engine (direct/chain/TTU/defeasible/ binary modes, condensed snapshots, value relations) with 39 rigor test campaigns. Includes fixes for snapshot binary writer/reader format mismatch (snapshot-of-snapshot corruption), possibility write-boundary validation, empty-graph snapshot serialization, relation lookup cache direction collision, config-redefinition cache invalidation, binary threshold semantics, defeasible compiled routing, and comparator reason whitelisting.
This commit is contained in:
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* 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`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user