2026-07-31 13:44:06 -07:00
|
|
|
/**
|
|
|
|
|
* rigor/traversal-parity.test.js — js-rigor property tests for chain
|
|
|
|
|
* direction semantics, TTU multi-tuple aggregation, and the update path.
|
|
|
|
|
*
|
|
|
|
|
* Properties verified:
|
|
|
|
|
*
|
|
|
|
|
* - CHAIN DIRECTION PARITY: for 2-3 step chains with arbitrary
|
|
|
|
|
* out/in directions, the engine's traversal (getRelationsFromSrc /
|
|
|
|
|
* getRelationsToDst) matches a BFS oracle: step 'out' walks
|
|
|
|
|
* src -r-> dst, step 'in' walks src <-r- dst; path possibility is
|
|
|
|
|
* the MIN along the path; multiple paths take the MAX.
|
|
|
|
|
* - TTU MULTI-TUPLE PARITY: possibility = max over tuple edges of
|
|
|
|
|
* min(tuplePossibility, memberPossibility) where membership is a
|
|
|
|
|
* DIRECT user->group lookup.
|
|
|
|
|
* - UPDATE PATH PARITY: overwriting an existing tuple
|
|
|
|
|
* (addRelation on an existing src/rel/dst) is last-write-wins:
|
|
|
|
|
* the check reflects the new possibility, the engine keeps exactly
|
|
|
|
|
* one tuple, and indices stay consistent — for direct, chain, and
|
|
|
|
|
* TTU configs alike.
|
|
|
|
|
*/
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Edge universe: every possible directed edge on the 4-node graph
|
|
|
|
|
// (u, m1, m2, o) for relations r1 and r2.
|
|
|
|
|
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, density = 0.5) {
|
|
|
|
|
const edges = [];
|
|
|
|
|
for (const rel of ['r1', 'r2']) {
|
|
|
|
|
for (const [src, dst] of EDGE_UNIVERSE[rel]) {
|
|
|
|
|
if (rng.next() < density) {
|
|
|
|
|
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; // no self-loop progress
|
|
|
|
|
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 makeChainConfig(steps) {
|
|
|
|
|
return { type: 'chain', steps };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyEdges(arb, edges, mode) {
|
|
|
|
|
for (const [src, rel, dst, p] of edges) {
|
|
|
|
|
if (mode === 'add') arb.addRelation(src, rel, dst, { possibility: p });
|
|
|
|
|
else arb.removeRelation(src, rel, dst);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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('owner', { type: 'direct' });
|
|
|
|
|
arb.setRelationConfig('member_of', { type: 'direct' });
|
|
|
|
|
return arb;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('Traversal semantics parity (rigor)', () => {
|
|
|
|
|
it('CHAIN DIRECTION PARITY: arbitrary out/in step mixes match the BFS oracle', async () => {
|
|
|
|
|
async function check({ seed, dirs, threeSteps }) {
|
|
|
|
|
const rng = mulberry32(seed);
|
|
|
|
|
const edges = randomEdges(rng);
|
|
|
|
|
const arb = buildArbiter();
|
|
|
|
|
const d = dirs.split('-');
|
|
|
|
|
const steps = threeSteps
|
|
|
|
|
? [
|
|
|
|
|
{ relation: 'r1', direction: d[0] },
|
|
|
|
|
{ relation: 'r2', direction: d[1] },
|
|
|
|
|
{ relation: 'r1', direction: d[2 % 2] }
|
|
|
|
|
]
|
|
|
|
|
: [
|
|
|
|
|
{ relation: 'r1', direction: d[0] },
|
|
|
|
|
{ relation: 'r2', direction: d[1] }
|
|
|
|
|
];
|
|
|
|
|
arb.setRelationConfig('target', makeChainConfig(steps));
|
|
|
|
|
applyEdges(arb, edges, 'add');
|
|
|
|
|
|
|
|
|
|
const expectedP = chainOracle(steps, edges);
|
|
|
|
|
const res = arb.check('user:alice', 'target', 'doc:1', {});
|
|
|
|
|
if (Math.abs(res.possibility - expectedP) > EPS) {
|
|
|
|
|
fail(`chain mismatch dirs=${JSON.stringify(dirs)} steps=${steps.length} p=${expectedP} got=${res.possibility} edges=${JSON.stringify(edges)}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mutations must keep parity
|
|
|
|
|
const rels = ['r1', 'r2'];
|
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
|
|
const rel = rels[Math.floor(rng.next() * 2)];
|
|
|
|
|
const [src, dst] = EDGE_UNIVERSE[rel][Math.floor(rng.next() * EDGE_UNIVERSE[rel].length)];
|
|
|
|
|
const idx = edges.findIndex(e => e[0] === src && e[1] === rel && e[2] === dst);
|
|
|
|
|
if (idx !== -1) {
|
|
|
|
|
arb.removeRelation(src, rel, dst);
|
|
|
|
|
edges.splice(idx, 1);
|
|
|
|
|
} else {
|
|
|
|
|
const p = POS[Math.floor(rng.next() * POS.length)];
|
|
|
|
|
arb.addRelation(src, rel, dst, { possibility: p });
|
|
|
|
|
edges.push([src, rel, dst, p]);
|
|
|
|
|
}
|
|
|
|
|
const eP = chainOracle(steps, edges);
|
|
|
|
|
const r = arb.check('user:alice', 'target', 'doc:1', {});
|
|
|
|
|
if (Math.abs(r.possibility - eP) > EPS) {
|
|
|
|
|
fail(`chain mutation mismatch dirs=${JSON.stringify(dirs)} p=${eP} got=${r.possibility} edges=${JSON.stringify(edges)}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { steps: steps.length };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dirPairs = ['out-out', 'out-in', 'in-out', 'in-in'];
|
|
|
|
|
const report = await rigor.campaign(
|
|
|
|
|
[
|
|
|
|
|
rigor.fn('check', check, rigor.args(
|
|
|
|
|
rigor.gen.object({
|
|
|
|
|
seed: rigor.gen.int(1, 100000),
|
|
|
|
|
dirs: rigor.gen.oneOf(dirPairs),
|
|
|
|
|
threeSteps: rigor.gen.boolean()
|
|
|
|
|
})
|
|
|
|
|
))
|
|
|
|
|
],
|
|
|
|
|
rigor.crucible([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('chain-direction-parity', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-01 09:52:31 -07:00
|
|
|
).run({ effort: 1500, seed: 'traversal-chain-direction' , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'chain-direction-parity');
|
|
|
|
|
assert.ok(inv, 'invariant missing');
|
|
|
|
|
assert.equal(inv.passed, true, `chain direction parity violated in ${inv.failureCount} cases`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('TTU MULTI-TUPLE PARITY: max over tuples of min(tuple, membership)', async () => {
|
|
|
|
|
async function check({ seed }) {
|
|
|
|
|
const rng = mulberry32(seed);
|
|
|
|
|
const arb = buildArbiter();
|
|
|
|
|
arb.setRelationConfig('target', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
|
|
|
|
|
const tuples = [
|
|
|
|
|
['doc:1', 'group:eng'],
|
|
|
|
|
['doc:1', 'group:design']
|
|
|
|
|
];
|
|
|
|
|
arb.addNode('group:eng', 'group');
|
|
|
|
|
arb.addNode('group:design', 'group');
|
|
|
|
|
const edges = [];
|
|
|
|
|
let expectedP = 0;
|
|
|
|
|
for (const [doc, grp] of tuples) {
|
|
|
|
|
if (rng.next() < 0.7) {
|
|
|
|
|
const tp = POS[Math.floor(rng.next() * POS.length)];
|
|
|
|
|
arb.addRelation(doc, 'owner', grp, { possibility: tp });
|
|
|
|
|
edges.push([doc, 'owner', grp, tp]);
|
|
|
|
|
let mp = 0;
|
|
|
|
|
if (rng.next() < 0.8) {
|
|
|
|
|
mp = POS[Math.floor(rng.next() * POS.length)];
|
|
|
|
|
arb.addRelation('user:alice', 'member_of', grp, { possibility: mp });
|
|
|
|
|
edges.push(['user:alice', 'member_of', grp, mp]);
|
|
|
|
|
}
|
|
|
|
|
expectedP = Math.max(expectedP, Math.min(tp, mp));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const res = arb.check('user:alice', 'target', 'doc:1', {});
|
|
|
|
|
if (Math.abs(res.possibility - expectedP) > EPS) {
|
|
|
|
|
fail(`ttu mismatch p=${expectedP} got=${res.possibility} edges=${JSON.stringify(edges)}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mutation: flip one membership
|
|
|
|
|
const grp = tuples[Math.floor(rng.next() * 2)][1];
|
|
|
|
|
const hasMember = edges.some(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp);
|
|
|
|
|
if (hasMember) {
|
|
|
|
|
arb.removeRelation('user:alice', 'member_of', grp);
|
|
|
|
|
const idx = edges.findIndex(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp);
|
|
|
|
|
if (idx !== -1) edges.splice(idx, 1);
|
|
|
|
|
} else {
|
|
|
|
|
const p = POS[Math.floor(rng.next() * POS.length)];
|
|
|
|
|
arb.addRelation('user:alice', 'member_of', grp, { possibility: p });
|
|
|
|
|
edges.push(['user:alice', 'member_of', grp, p]);
|
|
|
|
|
}
|
|
|
|
|
let eP = 0;
|
|
|
|
|
const tupleMap = new Map();
|
|
|
|
|
for (const [s, r, d, p] of edges) {
|
|
|
|
|
if (r === 'owner') tupleMap.set(d, p);
|
|
|
|
|
}
|
|
|
|
|
for (const [grp2, tp] of tupleMap) {
|
|
|
|
|
const mem = edges.find(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp2);
|
|
|
|
|
eP = Math.max(eP, Math.min(tp, mem ? mem[3] : 0));
|
|
|
|
|
}
|
|
|
|
|
const r2 = arb.check('user:alice', 'target', 'doc:1', {});
|
|
|
|
|
if (Math.abs(r2.possibility - eP) > EPS) {
|
|
|
|
|
fail(`ttu mutation mismatch p=${eP} got=${r2.possibility} edges=${JSON.stringify(edges)}`);
|
|
|
|
|
}
|
|
|
|
|
return { edges: edges.length };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const report = await rigor.campaign(
|
|
|
|
|
[
|
|
|
|
|
rigor.fn('check', check, rigor.args(
|
|
|
|
|
rigor.gen.object({ seed: rigor.gen.int(1, 100000) })
|
|
|
|
|
))
|
|
|
|
|
],
|
|
|
|
|
rigor.crucible([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('ttu-multi-tuple-parity', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-01 09:52:31 -07:00
|
|
|
).run({ effort: 1200, seed: 'traversal-ttu-multituple' , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'ttu-multi-tuple-parity');
|
|
|
|
|
assert.ok(inv, 'invariant missing');
|
|
|
|
|
assert.equal(inv.passed, true, `ttu parity violated in ${inv.failureCount} cases`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('UPDATE PATH PARITY: overwrites are last-write-wins with a single tuple and fresh checks', async () => {
|
|
|
|
|
async function check({ seed, kind }) {
|
|
|
|
|
const rng = mulberry32(seed);
|
|
|
|
|
const arb = buildArbiter();
|
|
|
|
|
let target;
|
|
|
|
|
if (kind === 0) {
|
|
|
|
|
arb.setRelationConfig('target', { type: 'direct', relation: 'r1' });
|
|
|
|
|
target = { check: (u, o) => arb.check(u, 'target', o), base: 'r1' };
|
|
|
|
|
} else if (kind === 1) {
|
|
|
|
|
arb.setRelationConfig('target', { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }] });
|
|
|
|
|
target = { check: (u, o) => arb.check(u, 'target', o), base: 'r1' };
|
|
|
|
|
} else {
|
|
|
|
|
arb.setRelationConfig('target', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
|
|
|
|
|
arb.addNode('group:eng', 'group');
|
|
|
|
|
target = { check: (u, o) => arb.check(u, 'target', o), base: 'owner' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const src = kind === 2 ? 'doc:1' : 'user:alice';
|
|
|
|
|
const dst = kind === 0 ? 'mid:1' : kind === 1 ? 'mid:1' : 'group:eng';
|
|
|
|
|
// Warm with the first value
|
|
|
|
|
const p0 = POS[Math.floor(rng.next() * POS.length)];
|
|
|
|
|
arb.addRelation(src, target.base, dst, { possibility: p0 });
|
|
|
|
|
|
|
|
|
|
let lastP = p0;
|
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
|
|
|
const p = POS[Math.floor(rng.next() * POS.length)];
|
|
|
|
|
arb.addRelation(src, target.base, dst, { possibility: p });
|
|
|
|
|
lastP = p;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Exactly one tuple in the engine (no duplicates from overwrites)
|
|
|
|
|
const count = arb.relations.filter(r => r.rel === target.base && r.src === arb.resolveNodeId(src) && r.dst === arb.resolveNodeId(dst)).length; if (count !== 1) {
|
|
|
|
|
fail(`overwrite left ${count} tuples for ${target.base} (kind=${kind})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (kind === 0) {
|
|
|
|
|
const res = arb.check(src, 'target', kind === 0 ? 'mid:1' : 'doc:1');
|
|
|
|
|
if (Math.abs(res.possibility - lastP) > EPS) {
|
|
|
|
|
fail(`update path stale: expected ${lastP}, got ${res.possibility} (kind=${kind})`);
|
|
|
|
|
}
|
|
|
|
|
} else if (kind === 1) {
|
|
|
|
|
// chain: seed an r2 edge so the path exists
|
|
|
|
|
arb.addRelation('mid:1', 'r2', 'doc:1', { possibility: 1 });
|
|
|
|
|
const res = arb.check(src, 'target', 'doc:1');
|
|
|
|
|
if (Math.abs(res.possibility - lastP) > EPS) {
|
|
|
|
|
fail(`chain update path stale: expected ${lastP}, got ${res.possibility}`);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
arb.addRelation('user:alice', 'member_of', 'group:eng', { possibility: 1 });
|
|
|
|
|
const res = arb.check('user:alice', 'target', 'doc:1');
|
|
|
|
|
if (Math.abs(res.possibility - lastP) > EPS) {
|
|
|
|
|
fail(`ttu update path stale: expected ${lastP}, got ${res.possibility}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { lastP };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const report = await rigor.campaign(
|
|
|
|
|
[
|
|
|
|
|
rigor.fn('check', check, rigor.args(
|
|
|
|
|
rigor.gen.object({
|
|
|
|
|
seed: rigor.gen.int(1, 50000),
|
|
|
|
|
kind: rigor.gen.int(0, 2)
|
|
|
|
|
})
|
|
|
|
|
))
|
|
|
|
|
],
|
|
|
|
|
rigor.crucible([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('update-path-parity', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-01 09:52:31 -07:00
|
|
|
).run({ effort: 600, seed: 'traversal-update-path' , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'update-path-parity');
|
|
|
|
|
assert.ok(inv, 'invariant missing');
|
|
|
|
|
assert.equal(inv.passed, true, `update path parity violated in ${inv.failureCount} cases`);
|
|
|
|
|
});
|
|
|
|
|
});
|