rigor: anti-vacuity guards, snapshot O(n), mutation crucible
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.
This commit is contained in:
@@ -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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -229,7 +229,7 @@ export function makeScaleFreeGraph(seed = 42, opts = {}) {
|
|||||||
arbiter,
|
arbiter,
|
||||||
users: userKeys,
|
users: userKeys,
|
||||||
resources: resourceKeys,
|
resources: resourceKeys,
|
||||||
relations: edges,
|
relations: null,
|
||||||
meta: { kind: 'scale-free', users, resources, edges }
|
meta: { kind: 'scale-free', users, resources, edges }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,18 @@ const metricReaders = (name) => ({
|
|||||||
cost: ({ result }) => result.cost
|
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)', () => {
|
describe('Complexity & benchmark crucibles (rigor)', () => {
|
||||||
it('COMPLEXITY: direct/chain/ttu lookups are O(1) in graph size', async () => {
|
it('COMPLEXITY: direct/chain/ttu lookups are O(1) in graph size', async () => {
|
||||||
const spec = [];
|
const spec = [];
|
||||||
@@ -201,7 +213,7 @@ describe('Complexity & benchmark crucibles (rigor)', () => {
|
|||||||
).run({ effort: 600, seed: 'complexity-graph-size', artifacts: { dir: '', persist: 'never' } });
|
).run({ effort: 600, seed: 'complexity-graph-size', artifacts: { dir: '', persist: 'never' } });
|
||||||
|
|
||||||
for (const v of report.crucibleVerdict.complexity) {
|
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' } });
|
).run({ effort: 600, seed: 'complexity-rule-count', artifacts: { dir: '', persist: 'never' } });
|
||||||
|
|
||||||
for (const v of report.crucibleVerdict.complexity) {
|
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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user