rigor: six complex-graph crucibles + seed the TTU campaigns

Extends the complex-graph suite to the remaining uncovered surfaces:

- complex-graph-ttl-crucible: value-TTL expiry over community/scale-free
  graphs (pinned changed_last_at writes, {now} reads, mirror freshness
  rule), mutation-with-time binary parity, snapshot round-trip preserves
  the TTL gate. Notable find: snapshot restore resets changed_last_at to
  access time (RelationSnapshotAccess.js:91), so TTL assertions compare
  each engine against its own effective write clock.
- complex-graph-overlay-crucible: partial-graph overlay over complex
  graphs. Key discovery: pre-built PartialGraphContext must be passed as
  partialGraphContext (partialGraph is a raw spec re-ingested at
  ArbiterChecks.js:15); overlay rides the direct relations a policy
  consumes (TTU-derived can_read ignores it, verified by probe).
- complex-graph-values-crucible: relational-comparator over value-carrying
  relations with pinned clocks — comparator parity, value-mutation flips
  the decision immediately, TTL expiry on the comparator denies.
- complex-graph-reachability-crucible: PLTC reachability vs ground-truth
  BFS on scale-free/community graphs — verdict parity, fast-fail
  soundness (no false positives), null-defer contract honored.
- complex-graph-batch-crucible: addRelationsBatch vs sequential build
  parity, mutation parity across both, cache-freshness after mutation.
- complex-graph-quantization-crucible: 16-bit quantization band over real
  possibility spreads, allow/deny agreement outside the band, snapshot-of-
  snapshot semantic identity.

Also seeds the two unseeded campaigns in tuple-to-userset-rule.test.js
(flagged flake ~1/15 — nondeterministic runs on a deterministic engine).

Rigor 245/245, full suite 847/785/0.
This commit is contained in:
John Dvorak
2026-08-02 15:06:50 -07:00
parent 1a2a6fc22e
commit 8141930764
7 changed files with 969 additions and 2 deletions
@@ -0,0 +1,185 @@
/**
* rigor/complex-graph-ttl-crucible.test.js — value-TTL expiry over the
* complex graphs, with an injected clock.
*
* The graph generators ship relations WITHOUT changed_last_at, so TTL
* gating is exercised on value-carrying edges written by the test with
* pinned `changed_last_at` timestamps (the same mirror-friendly strategy
* as ttl-expiry-parity). A relational-comparator policy consumes the
* values, because TTL expiry only gates VALUE extraction — a plain direct
* check returns the relation possibility regardless of age.
*
* FRESHNESS-PARITY — a value written at `now` grants immediately,
* still grants at TTL-1, and denies at TTL+1;
* the engine matches a mirror freshness rule
* (fresh iff age <= TTL).
* MUTATION-WITH-TIME — after every value rewrite (pinned
* changed_last_at), binary mode agrees with
* normal at the current pinned `now`, and both
* agree with the mirror.
* SNAPSHOT-PRESERVES-TTL — the condensed snapshot round-trip preserves
* the TTL config AND the expiry gate: both the
* original engine (against its pinned clock) and
* the restored engine (against its effective
* write clock) grant within TTL and deny past it.
*/
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, makeScaleFreeGraph } from './complex-graphs.js';
const TTL = 60_000;
const BASE_NOW = 1_000_000_000_000;
// The restored engine reports value edges as written at snapshot-restore
// time; the deny check must sit comfortably past that wall clock.
const RESTORE_BUFFER = 5_000;
const GENERATORS = [
{ name: 'community', make: makeCommunityGraph, opts: {} },
// Default scale-free (150 users / 400 edges) is ~6x slower to build;
// a smaller power-law graph exercises the same TTL contract.
{ name: 'scale-free', make: makeScaleFreeGraph, opts: { users: 60, resources: 20, edges: 150 } }
];
function fail(message) {
throw new Error(message);
}
function configureComparator(arbiter, graphRels) {
arbiter.setRelationConfig('balance', { type: 'direct' });
arbiter.setRelationConfig('price', { type: 'direct' });
arbiter.setRelationConfig('premium_access', {
type: 'relational_comparator',
comparator: '>',
left: { rule: { type: 'direct', relation: 'balance' }, extractValue: true },
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'price' }, extractValue: true }
});
arbiter.valueManager.setTTL('balance', TTL);
arbiter.valueManager.setTTL('price', TTL);
// The graph's own direct relations carry no values, so a TTL on them
// only gates value extraction (never the plain check) — harmless, and
// it pins the snapshot's TTL-config round-trip for those names too.
for (const rel of graphRels) arbiter.valueManager.setTTL(rel, TTL);
}
function buildSnapshotEngine(spec, seed) {
const g = spec.make(seed, spec.opts);
const arbiter = g.arbiter;
configureComparator(arbiter, spec.name === 'community' ? ['direct_access', 'member'] : ['can_read']);
const u = g.users[0];
const o = (g.resources || g.subGroups || g.groups)[0];
arbiter.addRelation(u, 'balance', o, { value: 100, possibility: 1.0, changed_last_at: BASE_NOW });
// price evaluates from the object, so the edge is object -> object.
arbiter.addRelation(o, 'price', o, { value: 50, possibility: 1.0, changed_last_at: BASE_NOW });
return { g, arbiter, u, o };
}
describe('Complex-graph value-TTL crucibles (rigor)', () => {
it('FRESHNESS / MUTATION / SNAPSHOT-TTL: TTL expiry holds across complex graphs', async () => {
async function check(args) {
const { genKind, seed, mutationCount } = args;
const spec = GENERATORS.find(g => g.name === genKind);
const g = spec.make(seed, spec.opts);
const arbiter = g.arbiter;
configureComparator(arbiter, spec.name === 'community' ? ['direct_access', 'member'] : ['can_read']);
const u = g.users[0];
const o = (g.resources || g.subGroups || g.groups)[0];
let engineNow = BASE_NOW;
const bal = { value: 100, ts: engineNow };
const prc = { value: 50, ts: engineNow };
const write = (kind, value) => {
const src = kind === 'balance' ? u : o;
arbiter.addRelation(src, kind, o, { value, possibility: 1.0, changed_last_at: engineNow });
const target = kind === 'balance' ? bal : prc;
// The engine only refreshes changed_last_at when the value actually
// changes; the mirror must mirror that or it un-expires old values.
if (target.value !== value) { target.value = value; target.ts = engineNow; }
};
const expected = () => {
const bFresh = engineNow - bal.ts <= TTL;
const pFresh = engineNow - prc.ts <= TTL;
return bFresh && pFresh && bal.value > prc.value ? 1 : 0;
};
const checkAt = () => arbiter.check(u, 'premium_access', o, { now: engineNow }).possibility;
const checkAtBinary = () => arbiter.check(u, 'premium_access', o, { now: engineNow, binary: true }).possibility;
write('balance', 100);
write('price', 50);
// FRESHNESS-PARITY
if (checkAt() !== 1) fail(`[freshness] fresh write did not grant (${genKind} seed=${seed})`);
engineNow += TTL - 1;
if (checkAt() !== expected()) fail(`[freshness] TTL-1 mismatch (${genKind} seed=${seed})`);
engineNow += 2; // now exactly TTL+1 since the write
const expired = checkAt();
if (expired !== 0) fail(`[freshness] expired value still grants (${genKind} seed=${seed}: ${expired})`);
if (expired !== expected()) fail(`[freshness] mirror mismatch at expiry (${genKind} seed=${seed})`);
// MUTATION-WITH-TIME: refresh both operands, then mutate and check.
engineNow += 100_000;
write('balance', 130);
write('price', 40);
for (let m = 0; m < mutationCount; m++) {
engineNow += 1000 * (1 + m);
const kind = m % 2 === 0 ? 'balance' : 'price';
write(kind, [20, 60, 120][m % 3]);
const normal = checkAt();
const binary = checkAtBinary();
if (normal !== binary) fail(`[mutation] binary=${binary} normal=${normal} disagree at now=${engineNow} (${genKind} seed=${seed})`);
if (normal !== expected()) fail(`[mutation] engine=${normal} mirror=${expected()} disagree at now=${engineNow} (${genKind} seed=${seed})`);
}
// SNAPSHOT-PRESERVES-TTL on a dedicated never-mutated engine.
const { arbiter: sArb, u: su, o: so } = buildSnapshotEngine(spec, seed);
const expAt = BASE_NOW + TTL + 1;
if (sArb.check(su, 'premium_access', so, { now: BASE_NOW }).possibility !== 1) {
fail(`[snapshot] fresh snapshot engine did not grant (${genKind} seed=${seed})`);
}
if (sArb.check(su, 'premium_access', so, { now: expAt }).possibility !== 0) {
fail(`[snapshot] original engine did not expire at TTL+1 (${genKind} seed=${seed})`);
}
sArb.enableCondensedSnapshot();
const buf = sArb.toSnapshotBinary();
const restored = Arbiter.fromSnapshotBinary(buf);
if (restored.valueManager.getTTL('balance') !== TTL) {
fail(`[snapshot] TTL config lost across restore (${genKind} seed=${seed})`);
}
const restoredTs = restored.relationManager.getDirectRelation(
restored.nodeIdByKey.get(su), 'balance', restored.nodeIdByKey.get(so)
).changed_last_at;
if (restored.check(su, 'premium_access', so, { now: restoredTs }).possibility !== 1) {
fail(`[snapshot] restored value not fresh at its own write clock (${genKind} seed=${seed})`);
}
if (restored.check(su, 'premium_access', so, { now: restoredTs + TTL + RESTORE_BUFFER }).possibility !== 0) {
fail(`[snapshot] restored value did not expire past TTL (${genKind} seed=${seed})`);
}
return { ok: true };
}
const report = await rigor.campaign(
[
rigor.fn('check', check, rigor.args(
rigor.gen.object({
genKind: rigor.gen.oneOf(GENERATORS.map(g => g.name)),
seed: rigor.gen.int(1, 6),
mutationCount: rigor.gen.int(2, 4)
})
))
],
rigor.crucible([
rigor.invariant('freshness-parity', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[freshness]')),
rigor.invariant('mutation-with-time', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[mutation]')),
rigor.invariant('snapshot-preserves-ttl', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[snapshot]'))
])
).run({ effort: 250, seed: 'complex-graph-ttl-crucible', artifacts: { dir: '', persist: 'never' } });
for (const name of ['freshness-parity', 'mutation-with-time', 'snapshot-preserves-ttl']) {
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name);
assert.ok(inv, `invariant ${name} missing`);
assert.equal(inv.passed, true, `TTL ${name} violated in ${inv.failureCount} cases`);
}
});
});