Files
core/tests/rigor/complex-graph-batch-crucible.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

171 lines
7.1 KiB
JavaScript

/**
* rigor/complex-graph-batch-crucible.test.js — batch-loading and decision
* cache parity on the community graph.
*
* Two engines are rebuilt from the same makeCommunityGraph(seed) fixture:
* one loaded relation-by-relation, one loaded through
* relationManager.addRelationsBatch (which expects { srcKey, relation,
* dstKey, options } — the generator's { src, rel, dst, possibility }
* objects are mapped into that shape).
*
* BATCH-SEQUENTIAL-PARITY — identical check answers on both engines for
* sampled (user, relation, object) triples
* across direct, TTU, union, exclusion, and
* chain policies.
* BATCH-MUTATION — after the same edge mutation on both engines,
* parity holds and the post-mutation answer is
* fresh (the decision cache is invalidated, not
* served stale) — even right after a warm read.
* FIXTURE-SIZE — the community fixture actually carries a
* batch-sized relation set (> 50 edges).
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';
import { makeCommunityGraph } from './complex-graphs.js';
const EPS = 1e-9;
const POLICIES = ['direct_access', 'can_read', 'can_read_with_direct', 'can_read_not_blocked', 'can_delegate_read'];
function fail(message) {
throw new Error(message);
}
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
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;
};
}
function rebuildForBatch(g) {
const arb = new Arbiter();
for (const node of g.arbiter.nodes.values()) arb.addNode(node.key, node.type);
for (const [name, config] of g.arbiter.relationConfigs.entries()) arb.setRelationConfig(name, config);
const edges = g.relations.map(r => ({
srcKey: r.src,
relation: r.rel,
dstKey: r.dst,
options: { possibility: r.possibility }
}));
arb.relationManager.addRelationsBatch(edges);
return arb;
}
function sampleTriples(g, rng) {
const triples = [];
// Real graph edges exercise the actual membership/ownership structure.
for (const r of g.relations.slice(0, 40)) triples.push([r.src, r.rel, r.dst]);
for (let i = 0; i < 40; i++) {
const u = g.users[Math.floor(rng() * g.users.length)];
const o = g.resources[Math.floor(rng() * g.resources.length)];
triples.push([u, POLICIES[Math.floor(rng() * POLICIES.length)], o]);
}
return triples;
}
function assertParity(seq, batch, triples, tag) {
for (const [u, rel, o] of triples) {
const a = seq.check(u, rel, o).possibility;
const b = batch.check(u, rel, o).possibility;
if (Math.abs(a - b) > EPS) {
fail(`[parity] ${tag} ${u} ${rel} ${o}: seq=${a} batch=${b}`);
}
}
}
describe('Complex-graph batch/cache crucibles (rigor)', () => {
it('BATCH-SEQUENTIAL-PARITY + BATCH-MUTATION + CACHE-FRESHNESS hold on the community graph', async () => {
async function check(args) {
const { seed, mode } = args;
const g = makeCommunityGraph(seed);
const seq = g.arbiter;
if (seq.relations.length <= 50) {
fail(`[fixture] community fixture has only ${seq.relations.length} relations`);
}
const batch = rebuildForBatch(g);
const rng = mulberry32(seed * 101);
// BATCH-SEQUENTIAL-PARITY on the untouched graph.
const triples = sampleTriples(g, rng);
assertParity(seq, batch, triples, 'initial');
// BATCH-MUTATION + CACHE-INTERACTION. mode 0 removes an existing
// direct_access edge; mode 1 adds one to a triple that has none.
let pair;
if (mode === 0) {
pair = g.relations.find(r => r.rel === 'direct_access');
if (!pair) fail(`[fixture] no direct_access edge on seed=${seed}`);
} else {
const clean = () => {
for (let i = 0; i < 200; i++) {
const u = g.users[Math.floor(rng() * g.users.length)];
const o = g.resources[Math.floor(rng() * g.resources.length)];
if (g.relations.some(r => r.src === u && r.rel === 'direct_access' && r.dst === o)) continue;
if (seq.check(u, 'direct_access', o).possibility !== 0) continue;
return { src: u, rel: 'direct_access', dst: o };
}
return null;
};
pair = clean();
if (!pair) fail(`[fixture] no clean direct_access pair on seed=${seed}`);
}
// Warm the decision cache on both engines before mutating.
seq.check(pair.src, 'direct_access', pair.dst);
batch.check(pair.src, 'direct_access', pair.dst);
batch.check(pair.src, 'direct_access', pair.dst);
const expectedAfter = mode === 0 ? 0 : 0.77;
if (mode === 0) {
seq.removeRelation(pair.src, 'direct_access', pair.dst);
batch.removeRelation(pair.src, 'direct_access', pair.dst);
} else {
seq.addRelation(pair.src, 'direct_access', pair.dst, { possibility: expectedAfter });
batch.addRelation(pair.src, 'direct_access', pair.dst, { possibility: expectedAfter });
}
const sa = seq.check(pair.src, 'direct_access', pair.dst).possibility;
const ba = batch.check(pair.src, 'direct_access', pair.dst).possibility;
if (Math.abs(sa - ba) > EPS) fail(`[mutation] seq=${sa} batch=${ba} diverge after mutation`);
if (Math.abs(ba - expectedAfter) > EPS) {
fail(`[cache] batched engine returned ${ba}, expected ${expectedAfter} after mutation (stale cache)`);
}
if (Math.abs(sa - expectedAfter) > EPS) {
fail(`[cache] sequential engine returned ${sa}, expected ${expectedAfter} after mutation`);
}
// No divergence on the broader policy surface after the mutation.
assertParity(seq, batch, triples, 'post-mutation');
return { relations: seq.relations.length, triples: triples.length };
}
const report = await rigor.campaign(
[
rigor.fn('check', check, rigor.args(
rigor.gen.object({
seed: rigor.gen.int(1, 6),
mode: rigor.gen.oneOf([0, 1])
})
))
],
rigor.crucible([
rigor.invariant('batch-sequential-parity', ({ actual }) => actual !== undefined),
rigor.invariant('batch-mutation', ({ actual }) => actual !== undefined),
rigor.invariant('cache-interaction', ({ actual }) => actual !== undefined),
rigor.invariant('fixture-size', ({ actual }) => actual !== undefined)
])
).run({ effort: 150, seed: 'complex-graph-batch-crucible', artifacts: { dir: '', persist: 'never' } });
for (const name of ['batch-sequential-parity', 'batch-mutation', 'cache-interaction', 'fixture-size']) {
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name);
assert.ok(inv, `invariant ${name} missing`);
assert.equal(inv.passed, true, `batch ${name} violated in ${inv.failureCount} cases`);
}
});
});