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'); } });