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,170 @@
/**
* 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', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[parity]')),
rigor.invariant('batch-mutation', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[mutation]')),
rigor.invariant('cache-interaction', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[cache]')),
rigor.invariant('fixture-size', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[fixture]'))
])
).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`);
}
});
});
@@ -0,0 +1,161 @@
/**
* rigor/complex-graph-overlay-crucible.test.js — partial-graph overlay over
* the complex graphs (community block model + org hierarchy).
*
* An overlay is a caller-supplied set of facts consulted alongside the
* persistent graph. The check option key for a pre-built
* PartialGraphContext is `partialGraphContext` (AuthorizationChecker
* reads that key; passing the context under `partialGraph` would be
* re-ingested as a raw spec and silently empty). The persistent relation
* is ORed into the direct lookup, so it wins when both are present.
*
* OVERLAY-SURFACES — with no persistent edge, an overlay fact
* grants exactly its possibility on a direct
* relation.
* PERSISTENT-WINS — persistent + overlay -> persistent value;
* removing the persistent edge surfaces the
* overlay.
* OVERLAY-BINARY-PARITY — binary and normal agree on the same overlay
* (binary.allow === (normal >= 0.8), and the
* direct path returns the overlay possibility
* in both modes).
* OVERLAY-ON-COMPLEX — an overlay fact on a DIRECT relation that a
* complex policy consumes (union: direct_access;
* chain: member/parent/owns) surfaces through
* that policy. Overlay facts on a relation name
* that is itself configured tuple_to_userset
* are ignored by the TTU evaluator, so the
* overlay must ride the direct edge.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { PartialGraphContext } from '../../src/core/PartialGraphContext.js';
import { makeCommunityGraph, makeHierarchyGraph } from './complex-graphs.js';
const EPS = 1e-9;
const P_OVERLAY = [0.2, 0.4, 0.6, 0.8, 0.9];
const P_PERSISTENT = 0.85;
const GENERATORS = [
{ name: 'community', make: makeCommunityGraph, directRel: 'direct_access', complexRel: 'can_read_with_direct' },
{ name: 'hierarchy', make: makeHierarchyGraph, directRel: 'owns', complexRel: 'can_access_org' }
];
function fail(message) {
throw new Error(message);
}
describe('Complex-graph overlay crucibles (rigor)', () => {
it('OVERLAY-SURFACES / PERSISTENT-WINS / BINARY-PARITY / ON-COMPLEX hold across complex graphs', async () => {
async function check(args) {
const { genKind, seed, layer } = args;
const spec = GENERATORS.find(g => g.name === genKind);
const g = spec.make(seed);
const arbiter = g.arbiter;
const u = g.users[0];
const pOverlay = P_OVERLAY[layer % P_OVERLAY.length];
let o;
if (genKind === 'community') {
// Pick a resource with NO persistent can_read_with_direct path, so
// the overlay is the only source for the complex-policy check.
o = g.resources.find(r => arbiter.check(u, spec.complexRel, r).possibility === 0);
if (!o) fail(`[surfaces] no zero-persistent resource on community seed=${seed}`);
// Drop any persistent direct_access edge on the triple.
for (const r of (g.relations || [])) {
if (r.src === u && r.rel === 'direct_access' && r.dst === o) arbiter.removeRelation(u, 'direct_access', o);
}
} else {
// Hierarchy: users never hold persistent 'owns' edges, so any
// resource is overlay-clean on the direct relation.
o = g.resources[0];
}
const ctx = new PartialGraphContext(arbiter, {
relations: [{ src: u, relation: spec.directRel, dst: o, possibility: pOverlay }]
});
const checkWithOverlay = (rel, object, ctxFor) =>
arbiter.check(u, rel, object, { partialGraphContext: ctxFor });
// OVERLAY-SURFACES: no persistent edge -> overlay grants exactly pOverlay.
const surfaced = checkWithOverlay(spec.directRel, o, ctx).possibility;
if (Math.abs(surfaced - pOverlay) > EPS) {
fail(`[surfaces] overlay ${pOverlay} did not surface on ${genKind} ${u}->${o}: ${surfaced}`);
}
// PERSISTENT-WINS: persistent edge wins; removal surfaces the overlay.
arbiter.addRelation(u, spec.directRel, o, { possibility: P_PERSISTENT });
const withBoth = checkWithOverlay(spec.directRel, o, ctx).possibility;
if (Math.abs(withBoth - P_PERSISTENT) > EPS) {
fail(`[wins] persistent ${P_PERSISTENT} did not win over overlay ${pOverlay}: ${withBoth}`);
}
arbiter.removeRelation(u, spec.directRel, o);
const resurfaced = checkWithOverlay(spec.directRel, o, ctx).possibility;
if (Math.abs(resurfaced - pOverlay) > EPS) {
fail(`[wins] overlay did not resurface after persistent removal: ${resurfaced}`);
}
// OVERLAY-BINARY-PARITY on the direct relation.
const normal = checkWithOverlay(spec.directRel, o, ctx);
const binary = arbiter.check(u, spec.directRel, o, { partialGraphContext: ctx, binary: true });
if (Math.abs(binary.possibility - normal.possibility) > EPS) {
fail(`[binary] direct overlay binary=${binary.possibility} normal=${normal.possibility} disagree`);
}
if (binary.allow !== (normal.possibility >= 0.8)) {
fail(`[binary] binary.allow=${binary.allow} != normal>=0.8 (${normal.possibility})`);
}
// OVERLAY-ON-COMPLEX: the overlay rides a direct relation the policy
// consumes and surfaces through the complex relation.
if (genKind === 'community') {
const viaUnion = checkWithOverlay(spec.complexRel, o, ctx).possibility;
if (Math.abs(viaUnion - pOverlay) > EPS) {
fail(`[complex] overlay ${pOverlay} did not surface through union ${spec.complexRel}: ${viaUnion}`);
}
} else {
// can_access_org = member -> parent -> owns. The overlay provides the
// full chain; the last hop carries the overlay strength.
const team = g.teams[0];
const dept = team.split(':team:')[0];
const org = 'org:0';
const chainCtx = new PartialGraphContext(arbiter, {
relations: [
{ src: u, relation: 'member', dst: team, possibility: 1 },
{ src: team, relation: 'parent', dst: dept, possibility: 1 },
{ src: dept, relation: 'owns', dst: org, possibility: pOverlay }
]
});
const viaChain = checkWithOverlay(spec.complexRel, org, chainCtx).possibility;
if (Math.abs(viaChain - pOverlay) > EPS) {
fail(`[complex] overlay ${pOverlay} did not surface through chain ${spec.complexRel}: ${viaChain}`);
}
}
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),
layer: rigor.gen.int(0, 4)
})
))
],
rigor.crucible([
rigor.invariant('overlay-surfaces', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[surfaces]')),
rigor.invariant('persistent-wins', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[wins]')),
rigor.invariant('overlay-binary-parity', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[binary]')),
rigor.invariant('overlay-on-complex', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[complex]'))
])
).run({ effort: 200, seed: 'complex-graph-overlay-crucible', artifacts: { dir: '', persist: 'never' } });
for (const name of ['overlay-surfaces', 'persistent-wins', 'overlay-binary-parity', 'overlay-on-complex']) {
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name);
assert.ok(inv, `invariant ${name} missing`);
assert.equal(inv.passed, true, `overlay ${name} violated in ${inv.failureCount} cases`);
}
});
});
@@ -0,0 +1,124 @@
/**
* rigor/complex-graph-quantization-crucible.test.js — condensed-snapshot
* quantization parity over the community graph.
*
* The generator emits varied non-dyadic possibilities (0.3..1.0). The
* query set is drawn from the graph's OWN edges (direct_access, member-fed
* TTU can_read, delegate-fed chain can_delegate_read) so the sampled
* queries are granted and actually carry quantization error — random
* (user, resource) pairs are mostly denied (live == restored == 0) and
* would make the band vacuous.
*
* QUANT-BAND — |live - restored| <= 2 * QUANT_STEP (the chain path
* multiplies two quantized inputs, so twice the single
* value's band).
* DECISION — outside the quantization band of the threshold the
* allow/deny decision must agree; inside it a flip is
* allowed (the existing snapshot-quantization-parity rule).
* RE-SERIALIZE— snapshot-of-snapshot (serialize the restored engine,
* restore again) is semantically identical.
*
* Deterministic fixed loop over seeds — no campaign needed.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { Arbiter } from '../../src/index.js';
import { makeCommunityGraph } from './complex-graphs.js';
const QUANT_STEP = 0.5 / 65535;
const TOL = QUANT_STEP * 2;
const SAMPLES = 40;
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 grantedQuerySet(g) {
const qs = [];
const ownsBySrc = new Map();
for (const r of g.relations) if (r.rel === 'owns') ownsBySrc.set(r.src, r.dst);
for (const r of g.relations) {
if (r.rel === 'direct_access') qs.push([r.src, 'direct_access', r.dst]);
if (r.rel === 'member') {
const obj = ownsBySrc.get(r.dst);
if (obj) {
qs.push([r.src, 'can_read', obj]);
qs.push([r.src, 'can_read_not_blocked', obj]);
}
}
if (r.rel === 'delegate') {
for (const m of g.relations) {
if (m.rel === 'member' && m.dst === r.src) {
qs.push([m.src, 'can_delegate_read', r.dst]);
break;
}
}
}
}
return qs;
}
function sample(querySet, seed) {
const rng = mulberry32(seed * 997);
const out = [];
for (let i = 0; i < SAMPLES; i++) {
out.push(querySet[Math.floor(rng() * querySet.length)]);
}
return out;
}
describe('Complex-graph snapshot quantization crucible', () => {
it('QUANT-BAND + DECISION + RE-SERIALIZE hold across seeds', () => {
for (const seed of [1, 2, 3, 4]) {
const g = makeCommunityGraph(seed);
const arbiter = g.arbiter;
const queries = sample(grantedQuerySet(g), seed);
assert.ok(queries.length > 0, `seed ${seed}: empty granted query set`);
const live = queries.map(([u, rel, o]) => arbiter.check(u, rel, o).possibility);
arbiter.enableCondensedSnapshot();
const buf = arbiter.toSnapshotBinary();
const restored = Arbiter.fromSnapshotBinary(buf);
const restored2 = Arbiter.fromSnapshotBinary(restored.toSnapshotBinary());
for (let i = 0; i < queries.length; i++) {
const [u, rel, o] = queries[i];
const lv = live[i];
const rv = restored.check(u, rel, o).possibility;
const r2v = restored2.check(u, rel, o).possibility;
assert.ok(
Math.abs(lv - rv) <= TOL,
`seed ${seed} ${u} ${rel} ${o}: live=${lv} restored=${rv} exceeds ${TOL}`
);
// Allow/deny agreement on the 0.5 threshold, except inside the
// quantization band where a flip is permitted.
const inBand = Math.abs(lv - 0.5) < QUANT_STEP;
if (!inBand) {
assert.equal(
rv >= 0.5, lv >= 0.5,
`seed ${seed} ${u} ${rel} ${o}: decision flipped outside band (live=${lv} restored=${rv})`
);
}
// Grant parity (possibility > 0): granted queries all carry
// possibility >= 0.3, so a flip here would be a real loss.
assert.equal(rv > 0, lv > 0, `seed ${seed} ${u} ${rel} ${o}: grant lost in restore`);
// Snapshot-of-snapshot is semantically identical.
assert.ok(
Math.abs(r2v - rv) <= TOL,
`seed ${seed} ${u} ${rel} ${o}: re-serialized=${r2v} != first restore=${rv}`
);
}
}
});
});
@@ -0,0 +1,163 @@
/**
* rigor/complex-graph-reachability-crucible.test.js — PLTC reachability
* over the scale-free and community graphs, compared against ground-truth
* directed DFS.
*
* The PLTC index is built from every relation in arbiter.relations (edge
* src -> dst, deduplicated by pair), so the ground truth is the same edge
* set: the generator's relations for community, and the live engine's
* relation rows for scale-free (whose generator returns `relations:
* null`). All of these relations are configured `direct`, so "direct
* relations" and "all relations" coincide.
*
* VERDICT-PARITY — a non-null isReachable verdict equals ground truth.
* FAST-FAIL-SOUNDNESS — isReachable(...) === true implies ground truth
* is true (PLTC must not manufacture reachability).
* NULL-DEFER — isReachable returns null when the PLTC index is
* unavailable; a null verdict is the documented "delegate to rules"
* contract and is skipped, never failed.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { makeCommunityGraph, makeScaleFreeGraph } from './complex-graphs.js';
const SAMPLES = 12;
const GENERATORS = [
{ name: 'community', make: makeCommunityGraph, opts: {} },
// Default scale-free (150 users / 400 edges) is slow to build per case;
// a 60-user power-law graph keeps the PLTC-vs-DFS comparison meaningful
// without dominating the suite.
{ name: 'scale-free', make: makeScaleFreeGraph, opts: { users: 60, resources: 20, edges: 150 } }
];
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 allNodeKeys(g) {
const keys = [...g.users, ...(g.resources || []), ...(g.groups || []), ...(g.subGroups || [])];
return keys.filter(k => g.arbiter.nodeIdByKey.has(k));
}
function groundTruthEdges(g) {
const seen = new Set();
const edges = [];
const push = (s, d) => {
const k = `${s}\u0000${d}`;
if (!seen.has(k)) { seen.add(k); edges.push([s, d]); }
};
if (g.relations) {
for (const r of g.relations) push(r.src, r.dst);
} else {
for (const r of g.arbiter.relations) {
const sk = g.arbiter.keyByNodeId.get(r.src);
const dk = g.arbiter.keyByNodeId.get(r.dst);
if (sk !== undefined && dk !== undefined) push(sk, dk);
}
}
return edges;
}
function buildAdjacency(keys, edges) {
const adj = new Map();
for (const k of keys) adj.set(k, []);
for (const [s, d] of edges) {
if (!adj.has(s)) adj.set(s, []);
if (!adj.has(d)) adj.set(d, []);
adj.get(s).push(d);
}
return adj;
}
function dfsReachable(adj, src, dst) {
if (src === dst) return true;
const visited = new Set([src]);
const stack = [src];
while (stack.length) {
const cur = stack.pop();
for (const next of adj.get(cur) || []) {
if (next === dst) return true;
if (!visited.has(next)) { visited.add(next); stack.push(next); }
}
}
return false;
}
describe('Complex-graph PLTC reachability crucibles (rigor)', () => {
it('VERDICT-PARITY + FAST-FAIL-SOUNDNESS against ground-truth DFS, with NULL-DEFER', async () => {
async function check(args) {
const { genKind, seed, srcIdx, dstIdx } = args;
const spec = GENERATORS.find(g => g.name === genKind);
const g = spec.make(seed, spec.opts);
const keys = allNodeKeys(g);
const adj = buildAdjacency(keys, groundTruthEdges(g));
await g.arbiter.initializeReachabilityChecker();
const rng = mulberry32(seed * 7919 + 17);
// The campaign's srcIdx/dstIdx seed the first pair; the rest are
// drawn from a per-case deterministic stream so every case samples
// more than one edge of the graph.
let nonNull = 0;
let verdictChecks = 0;
for (let i = 0; i < SAMPLES; i++) {
const a = i === 0 ? keys[srcIdx % keys.length] : keys[Math.floor(rng() * keys.length)];
const b = i === 0 ? keys[dstIdx % keys.length] : keys[Math.floor(rng() * keys.length)];
const v = g.arbiter.isReachable(a, b);
if (v === null) continue; // PLTC unavailable -> defer to rule eval
nonNull++;
const gt = dfsReachable(adj, a, b);
verdictChecks++;
if (v !== gt) {
fail(`[verdict] ${genKind} seed=${seed}: isReachable(${a},${b})=${v} != ground truth ${gt}`);
}
if (v === true && gt !== true) {
fail(`[soundness] ${genKind} seed=${seed}: PLTC false positive on ${a}->${b}`);
}
}
// Vacuity guard: a case whose sampled pairs all hit the NULL-DEFER
// path proves nothing about verdict parity.
if (verdictChecks === 0) {
fail(`[vacuity] no non-null PLTC verdicts sampled for ${genKind} seed=${seed}`);
}
return { nonNull, verdictChecks };
}
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),
srcIdx: rigor.gen.int(0, 199),
dstIdx: rigor.gen.int(0, 199)
})
))
],
rigor.crucible([
rigor.invariant('verdict-parity', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[verdict]')),
rigor.invariant('fast-fail-soundness', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[soundness]')),
rigor.invariant('null-defer-contract', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[vacuity]'))
])
).run({ effort: 200, seed: 'complex-graph-reachability-crucible', artifacts: { dir: '', persist: 'never' } });
for (const name of ['verdict-parity', 'fast-fail-soundness', 'null-defer-contract']) {
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name);
assert.ok(inv, `invariant ${name} missing`);
assert.equal(inv.passed, true, `reachability ${name} violated in ${inv.failureCount} cases`);
}
});
});
@@ -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`);
}
});
});
@@ -0,0 +1,164 @@
/**
* rigor/complex-graph-values-crucible.test.js — value-carrying relations
* and the relational-comparator path over a community graph, with an
* injected clock.
*
* The community graph supplies the node universe; the test writes
* value-carrying balance/price edges (pinned changed_last_at) on top and
* evaluates a relational_comparator policy. The mirror computes the
* comparator result from the raw values under the same freshness rule as
* the engine (fresh iff age <= TTL).
*
* COMPARATOR-PARITY — the comparator answer equals the plain value
* comparison at the pinned `now`, across a value
* matrix that includes denying combinations.
* VALUE-MUTATION — rewriting a value flips the decision immediately
* at the pinned `now`, and binary mode agrees.
* TTL-EXPIRY — once both operands age past TTL, the comparator
* denies; the mirror agrees.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { makeCommunityGraph } from './complex-graphs.js';
const TTL = 60_000;
const BASE_NOW = 1_000_000_000_000;
const VALUE_SET = [5, 20, 40, 60, 100, 130];
function fail(message) {
throw new Error(message);
}
describe('Complex-graph value/comparator crucibles (rigor)', () => {
it('COMPARATOR-PARITY / VALUE-MUTATION / TTL-EXPIRY hold on the community graph', async () => {
async function check(args) {
const { seed, mutations } = args;
const g = makeCommunityGraph(seed);
const arbiter = g.arbiter;
const u = g.users[0];
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);
const keys = g.resources.slice(0, 3);
let engineNow = BASE_NOW;
const values = new Map();
for (const k of keys) values.set(k, { balance: { v: 0, ts: -Infinity }, price: { v: 0, ts: -Infinity } });
const write = (k, kind, v) => {
const src = kind === 'balance' ? u : k;
arbiter.addRelation(src, kind, k, { value: v, possibility: 1.0, changed_last_at: engineNow });
const target = values.get(k)[kind];
// The engine keeps the old timestamp when a rewrite does not change
// the value; the mirror mirrors that or it un-expires old values.
if (target.v !== v) { target.v = v; target.ts = engineNow; }
};
const fresh = ts => engineNow - ts <= TTL;
const expected = k => {
const v = values.get(k);
return fresh(v.balance.ts) && fresh(v.price.ts) && v.balance.v > v.price.v ? 1 : 0;
};
const checkAt = k => {
const normal = arbiter.check(u, 'premium_access', k, { now: engineNow }).possibility;
const binary = arbiter.check(u, 'premium_access', k, { now: engineNow, binary: true }).possibility;
return { normal, binary };
};
// COMPARATOR-PARITY: value matrix at pinned clocks, including
// denying combinations.
const matrix = [
[100, 50], // grant
[50, 100], // deny
[100, 100], // deny (not strictly greater)
[0, 10], // deny
[200, 5], // grant
[5, 5] // deny
];
for (let i = 0; i < matrix.length; i++) {
const k = keys[i % keys.length];
engineNow = BASE_NOW + i * 1000;
write(k, 'balance', matrix[i][0]);
write(k, 'price', matrix[i][1]);
const { normal, binary } = checkAt(k);
if (normal !== expected(k)) {
fail(`[parity] engine=${normal} mirror=${expected(k)} for balance=${matrix[i][0]} price=${matrix[i][1]} (seed=${seed})`);
}
if (binary !== normal) fail(`[parity] binary=${binary} normal=${normal} disagree (seed=${seed})`);
}
// VALUE-MUTATION: fresh grant, then flip by rewriting one operand.
const k0 = keys[0];
engineNow = BASE_NOW + 1_000_000;
write(k0, 'balance', 100);
write(k0, 'price', 50);
if (checkAt(k0).normal !== 1) fail(`[mutation] fresh grant missing (seed=${seed})`);
engineNow += 1000;
write(k0, 'balance', 40);
const flipped = checkAt(k0);
if (flipped.normal !== 0 || flipped.binary !== 0) {
fail(`[mutation] value rewrite did not flip immediately (normal=${flipped.normal} binary=${flipped.binary} seed=${seed})`);
}
engineNow += 1000;
write(k0, 'price', 10);
const reGranted = checkAt(k0);
if (reGranted.normal !== 1 || reGranted.binary !== 1) {
fail(`[mutation] re-grant did not apply immediately (normal=${reGranted.normal} binary=${reGranted.binary} seed=${seed})`);
}
// Random rewrites with binary + mirror agreement after every change.
for (let m = 0; m < mutations; m++) {
engineNow += 1000 * (1 + m);
const k = keys[m % keys.length];
write(k, m % 2 === 0 ? 'balance' : 'price', VALUE_SET[(seed + m * 7) % VALUE_SET.length]);
const { normal, binary } = checkAt(k);
if (normal !== expected(k)) fail(`[mutation] engine=${normal} mirror=${expected(k)} (seed=${seed} m=${m})`);
if (binary !== normal) fail(`[mutation] binary=${binary} normal=${normal} disagree (seed=${seed} m=${m})`);
}
// TTL-EXPIRY-ON-COMPARATOR: both operands past TTL -> deny, mirror agrees.
const kExp = keys[keys.length - 1];
engineNow = BASE_NOW + 2_000_000;
write(kExp, 'balance', 100);
write(kExp, 'price', 50);
if (checkAt(kExp).normal !== 1) fail(`[expiry] pre-expiry grant missing (seed=${seed})`);
engineNow += TTL + 1;
const expired = checkAt(kExp);
if (expired.normal !== 0 || expired.binary !== 0) {
fail(`[expiry] comparator denied expected after TTL (normal=${expired.normal} binary=${expired.binary} seed=${seed})`);
}
if (expired.normal !== expected(kExp)) fail(`[expiry] mirror mismatch at expiry (seed=${seed})`);
return { ok: true };
}
const report = await rigor.campaign(
[
rigor.fn('check', check, rigor.args(
rigor.gen.object({
seed: rigor.gen.int(1, 6),
mutations: rigor.gen.int(2, 5)
})
))
],
rigor.crucible([
rigor.invariant('comparator-parity', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[parity]')),
rigor.invariant('value-mutation', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[mutation]')),
rigor.invariant('ttl-expiry-on-comparator', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[expiry]'))
])
).run({ effort: 200, seed: 'complex-graph-values-crucible', artifacts: { dir: '', persist: 'never' } });
for (const name of ['comparator-parity', 'value-mutation', 'ttl-expiry-on-comparator']) {
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name);
assert.ok(inv, `invariant ${name} missing`);
assert.equal(inv.passed, true, `values ${name} violated in ${inv.failureCount} cases`);
}
});
});
+2 -2
View File
@@ -92,7 +92,7 @@ describe('TupleToUsersetRule evaluation (rigor)', () => {
rigor.crucible([
rigor.invariant('no-tuples', ({ error, errorMessage }) => !error && !errorMessage)
])
).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }});
).run({ effort: 500, seed: 'ttu-no-tuples', artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-tuples');
@@ -141,7 +141,7 @@ describe('TupleToUsersetRule evaluation (rigor)', () => {
rigor.crucible([
rigor.invariant('min-fusion', ({ error, errorMessage }) => !error && !errorMessage)
])
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
).run({ effort: 800, seed: 'ttu-min-fusion', artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'min-fusion');