Files
core/tests/rigor/traversal-parity.test.js
T
John Dvorak ed34df4474
CI / benchmark (push) Successful in 48s
CI / test (push) Successful in 5m26s
CI / publish (push) Has been skipped
feat: intermediate chain condition steps, graph-version cache invalidation, rolling-hash chain keys; fix vacuous rigor invariants
Chain intermediates (rule-based reachability):
- ChainRule: a condition step ({ rule, conditionStep }) at an INTERMEDIATE
  position is now EXPANDED from the current node — the rule's base edges'
  destinations, filtered by its defeaters/requirements — and traversal
  continues from each discovered node. Adds _expandRuleFromSrc / direct /
  logical(union/intersection) / defeasible / nested-chain expansion.
- RuleEvaluator: _subjectIsObject flag for unary predicate calls whose subject
  entity IS the object parameter (trusted(other) inside peer_trusted(user,
  other)); previously only subject-var unary calls (_subjectAsObject) were
  handled, so object-var unary defeaters never fired.

Graph-version cache invalidation:
- Arbiter gains a monotonic _graphVersion, incremented on every relation
  mutation. ChainRule result cache, RuleEvaluator rule-result cache, and
  DecisionCache rule cache now stamp entries with the graph version and treat
  any mismatch as a miss — graph mutations can no longer serve stale
  chain/authorization results.

Rolling-hash cache keys:
- UnifiedKeyManager.createChainKey now builds a 53-bit rolling hash (dual
  FNV-1a lanes, exact for ints/floats/strings/nested configs) instead of
  JSON.stringify — no string allocation or serialization on the chain-cache
  hot path. Composite keys stay structured strings because the direct-check
  cache pattern-invalidates by relation ID.

Rigor invariant migration (correctness):
- All 43 rigor test files' throw-based invariants ({ error, errorMessage } =>
  !error && !errorMessage) never saw fn throws — vacuous. Migrated to
  ({ actual }) => actual !== undefined, which fails on any thrown violation
  while passing legitimate null-skips. The migration immediately surfaced
  two latent bugs, now fixed:
    * node-manager/graph-indices skip paths returned bare undefined (falsy
      sentinel) — return { skipped: true }.
    * complex-graph-values-crucible expiry section rewrote values equal to the
      mutation loop's last write; the engine (by design) keeps the old
      timestamp on same-value rewrites so the pre-expiry grant never
      materialized. Now writes guaranteed-different values.
2026-08-03 13:26:42 -07:00

350 lines
13 KiB
JavaScript

/**
* 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([
rigor.invariant('chain-direction-parity', ({ actual }) => actual !== undefined)
])
).run({ effort: 1500, seed: 'traversal-chain-direction' , artifacts: { dir: '', persist: 'never' }});
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([
rigor.invariant('ttu-multi-tuple-parity', ({ actual }) => actual !== undefined)
])
).run({ effort: 1200, seed: 'traversal-ttu-multituple' , artifacts: { dir: '', persist: 'never' }});
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([
rigor.invariant('update-path-parity', ({ actual }) => actual !== undefined)
])
).run({ effort: 600, seed: 'traversal-update-path' , artifacts: { dir: '', persist: 'never' }});
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`);
});
});