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:
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user