From 1a2a6fc22ee0bb8764323cde03484aecb8b9c5c4 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Sun, 2 Aug 2026 14:33:31 -0700 Subject: [PATCH] rigor: anti-vacuity guards, snapshot O(n), mutation crucible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three improvements over the complex-graph crucibles: 1. Anti-vacuity guards (assertRealVerdict): rigor's complexity verdict PASSES on zero observations — a broken action (missing import, wrong args shape) silently goes green. Every complexity verdict now asserts observationCount >= 50, costSource == expected, and calibrated == true, so a vacuous verdict is a test failure. 2. Snapshot complexity: serialized snapshot BYTE SIZE is O(n) in graph size, verified deterministically (build and restore round-trip). Wall-clock timing at sub-ms scale is pure jitter for the e-process spread check (verified empirically — buildTime O(n) failed on spread while buildBytes passed); latency stays covered by benchmark percentiles. 3. complex-graph-mutation-crucible: MUTATION-FRESHNESS — random edge removals/additions on community + scale-free graphs, normal/binary agreement re-checked after EVERY mutation, stale grants and missing fresh grants are failures. Two real findings during bring-up, both fixture bugs rather than engine bugs: - the scale-free generator returned a raw edge COUNT as 'relations' while other generators returned edge arrays (now null, consistent with dense-adversarial; the crucible walks the arbiter's store) - arbiter.relations stores NUMERIC ids, so removals must resolve string keys to ids before matching (string-key comparison silently no-oped, looking like a stale grant) Snapshot read-only semantics documented in the crucible: enableCondensed- Snapshot flips the engine to read-only permanently, so mutation crucibles exercise the writable path, and frozen-snapshot properties stay with the snapshot-parity suites. Rigor 239/239, full suite 841/779/0. --- .../complex-graph-mutation-crucible.test.js | 102 ++++++++++++++++++ tests/rigor/complex-graphs.js | 2 +- tests/rigor/complexity-bench-crucible.test.js | 63 ++++++++++- 3 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 tests/rigor/complex-graph-mutation-crucible.test.js diff --git a/tests/rigor/complex-graph-mutation-crucible.test.js b/tests/rigor/complex-graph-mutation-crucible.test.js new file mode 100644 index 0000000..c823ac8 --- /dev/null +++ b/tests/rigor/complex-graph-mutation-crucible.test.js @@ -0,0 +1,102 @@ +/** + * rigor/complex-graph-mutation-crucible.test.js — mutation crucible over + * the complex graphs. + * + * Parity under mutation is the engine's stale-cache hunter: random edge + * removals and additions on community/scale-free graphs, with + * normal/binary agreement re-checked after EVERY mutation. A stale cache + * or an index desync surfaces as a disagreement on the very next query. + * + * MUTATION-FRESHNESS — the LIVE engine's grant is revoked/applied + * immediately after each mutation (no stale cache on the writable + * path), and binary mode agrees with normal at every step. + * + * Snapshot discipline: enableCondensedSnapshot() permanently flips the + * engine to read-only snapshot mode (writes throw), so the snapshot + * cannot be taken mid-mutation on the live engine. The writable engine + * and the read-only snapshot are therefore separate instances; the + * round-trip parity property (snapshot == live at the same state) is + * already covered by complex-graph-crucible and snapshot-parity. + */ +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 GENERATORS = [ + { name: 'community', make: makeCommunityGraph, directRel: 'direct_access' }, + { name: 'scale-free', make: makeScaleFreeGraph, directRel: 'can_read' } +]; + +function fail(message) { + throw new Error(message); +} + +function checkModes(arbiter, user, relation, object) { + const normal = arbiter.check(user, relation, object); + const binary = arbiter.check(user, relation, object, { binary: true }); + if (normal.possibility > 0 !== binary.possibility > 0) { + fail(`binary disagreement: normal=${normal.possibility} binary=${binary.possibility}`); + } + return normal; +} + +describe('Complex-graph mutation crucibles (rigor)', () => { + it('MUTATION-FRESHNESS: live grants revoke/apply immediately; binary agrees', async () => { + async function check(args) { + const { genKind, seed } = args; + const spec = GENERATORS.find(g => g.name === genKind); + const g = spec.make(seed); + const arbiter = g.arbiter; + const rel = spec.directRel; + const allKeys = [...g.users, ...(g.resources || [])]; + + for (let s = 0; s < 6; s++) { + const u = allKeys[Math.floor(Math.random() * allKeys.length)]; + const o = allKeys[Math.floor(Math.random() * allKeys.length)]; + const before = checkModes(arbiter, u, rel, o).possibility; + if (before > 0) { + // Revoke every direct edge between u and o, then verify 0. + // NOTE: arbiter.relations stores NUMERIC ids; compare against + // resolved ids, never string keys. + const srcId = arbiter.resolveNodeId(u); + const dstId = arbiter.resolveNodeId(o); + const rm = (g.relations || []).filter(r => r.src === u && r.rel === rel && r.dst === o); + if (rm.length > 0) { + for (const e of rm) arbiter.removeRelation(e.src, e.rel, e.dst); + } else { + for (const r of (arbiter.relations || [])) { + if (r.src === srcId && r.rel === rel && r.dst === dstId) arbiter.removeRelation(u, rel, o); + } + } + const after = checkModes(arbiter, u, rel, o).possibility; + if (after > 0) fail(`stale grant: ${u} ${rel} ${o} still ${after} after removal`); + } else { + // Grant a direct edge, verify it shows up immediately. + arbiter.addRelation(u, rel, o, { possibility: 0.9 }); + const after = checkModes(arbiter, u, rel, o).possibility; + if (after <= 0) fail(`missing grant: ${u} ${rel} ${o} still 0 after add`); + } + } + return { ok: true }; + } + + const report = await rigor.campaign( + [ + rigor.fn('mutate', check, rigor.args( + rigor.gen.object({ + genKind: rigor.gen.oneOf(GENERATORS.map(g => g.name)), + seed: rigor.gen.int(1, 8) + }) + )) + ], + rigor.crucible([ + rigor.invariant('mutation-freshness', ({ error, errorMessage }) => !error && !errorMessage) + ]) + ).run({ effort: 300, seed: 'complex-graph-mutation-freshness', artifacts: { dir: '', persist: 'never' } }); + + const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'mutation-freshness'); + assert.ok(inv); + assert.equal(inv.passed, true, `mutation freshness violated in ${inv.failureCount} cases`); + }); +}); diff --git a/tests/rigor/complex-graphs.js b/tests/rigor/complex-graphs.js index 77f0df8..2e8592b 100644 --- a/tests/rigor/complex-graphs.js +++ b/tests/rigor/complex-graphs.js @@ -229,7 +229,7 @@ export function makeScaleFreeGraph(seed = 42, opts = {}) { arbiter, users: userKeys, resources: resourceKeys, - relations: edges, + relations: null, meta: { kind: 'scale-free', users, resources, edges } }; } diff --git a/tests/rigor/complexity-bench-crucible.test.js b/tests/rigor/complexity-bench-crucible.test.js index ee3b4ba..bd51244 100644 --- a/tests/rigor/complexity-bench-crucible.test.js +++ b/tests/rigor/complexity-bench-crucible.test.js @@ -180,6 +180,18 @@ const metricReaders = (name) => ({ cost: ({ result }) => result.cost }); +// Anti-vacuity guard: rigor's complexity verdict PASSES when zero +// observations were recorded ("no observations" branch). A broken action +// (missing import, wrong args shape) silently degrades into that branch — +// the crucible goes green while testing nothing. Every verdict below must +// therefore carry a real, metric-driven signal. +function assertRealVerdict(v, expectedCostSource = 'metric') { + assert.equal(v.passed, true, `${v.name} (${v.formula}): eProcess=${v.eProcess} violated=${v.trendViolated || v.spreadExceeded}`); + assert.ok(v.observationCount >= 50, `${v.name}: only ${v.observationCount} observations — vacuous verdict`); + assert.equal(v.costSource, expectedCostSource, `${v.name}: expected costSource '${expectedCostSource}', got '${v.costSource}'`); + assert.equal(v.calibrated, true, `${v.name}: not calibrated — verdict not meaningful`); +} + describe('Complexity & benchmark crucibles (rigor)', () => { it('COMPLEXITY: direct/chain/ttu lookups are O(1) in graph size', async () => { const spec = []; @@ -201,7 +213,7 @@ describe('Complexity & benchmark crucibles (rigor)', () => { ).run({ effort: 600, seed: 'complexity-graph-size', artifacts: { dir: '', persist: 'never' } }); for (const v of report.crucibleVerdict.complexity) { - assert.equal(v.passed, true, `${v.name} (${v.formula}): eProcess=${v.eProcess} violated=${v.trendViolated || v.spreadExceeded}`); + assertRealVerdict(v); } }); @@ -226,7 +238,54 @@ describe('Complexity & benchmark crucibles (rigor)', () => { ).run({ effort: 600, seed: 'complexity-rule-count', artifacts: { dir: '', persist: 'never' } }); for (const v of report.crucibleVerdict.complexity) { - assert.equal(v.passed, true, `${v.name} (${v.formula}): eProcess=${v.eProcess} violated=${v.trendViolated || v.spreadExceeded}`); + assertRealVerdict(v); + } + }); + + it('COMPLEXITY: snapshot byte size is O(n) in graph size', async () => { + // Serialized snapshot size grows exactly linearly with graph size: a + // superlinear regression (re-scanning, duplicated payloads) trips the + // e-process. Byte size is deterministic — no wall-clock jitter. (The + // wall-clock latency of snapshot build/restore is covered by the + // benchmark percentiles below; sub-ms timings are pure jitter for the + // complexity spread check, as seen with the timing-based verdicts.) + const snapshotActions = { + buildBytes: ({ graphIdx }) => { + const g = COMMUNITY[graphIdx]; + g.arbiter.enableCondensedSnapshot(); + const buf = g.arbiter.toSnapshotBinary(); + return { result: null, cost: buf.byteLength }; + }, + restoreBytes: ({ graphIdx }) => { + const g = COMMUNITY[graphIdx]; + g.arbiter.enableCondensedSnapshot(); + const buf = g.arbiter.toSnapshotBinary(); + const restored = Arbiter.fromSnapshotBinary(buf); + const roundTrip = restored.toSnapshotBinary(); + return { result: null, cost: roundTrip.byteLength }; + } + }; + const spec = []; + for (const name of ['buildBytes', 'restoreBytes']) { + spec.push(rigor.fn(name, snapshotActions[name], rigor.args( + rigor.gen.object({ + graphIdx: rigor.gen.int(0, COMMUNITY.length - 1) + }) + ), rigor.metrics({ + n: ({ args }) => COMMUNITY[args[0].graphIdx].size, + cost: ({ result }) => result.cost + }))); + } + const report = await rigor.campaign( + spec, + rigor.crucible([ + rigor.complexity('buildBytes', 'O(n)'), + rigor.complexity('restoreBytes', 'O(n)') + ]) + ).run({ effort: 500, seed: 'complexity-snapshot', artifacts: { dir: '', persist: 'never' } }); + + for (const v of report.crucibleVerdict.complexity) { + assertRealVerdict(v, 'metric'); } });