2026-08-02 14:20:50 -07:00
|
|
|
/**
|
|
|
|
|
* rigor/complexity-bench-crucible.test.js — advanced perf & complexity
|
|
|
|
|
* crucibles over the complex graphs.
|
|
|
|
|
*
|
|
|
|
|
* These crucibles use js-rigor's complexity and benchmark verdicts, which
|
|
|
|
|
* are normally dormant in this suite:
|
|
|
|
|
*
|
|
|
|
|
* COMPLEXITY — declares an asymptotic class per action; rigor's
|
|
|
|
|
* e-process verifies the observed cost signal does not grow faster
|
|
|
|
|
* than the formula across input sizes (calibrated on the smallest
|
|
|
|
|
* observations, tested on the rest; Ville's inequality gives
|
|
|
|
|
* P(false alarm) <= 0.05 when eProcess > 20). A `cost` metric (rule
|
|
|
|
|
* evaluations) is declared so the verdict is deterministic, not
|
|
|
|
|
* wall-clock noise.
|
|
|
|
|
*
|
|
|
|
|
* BENCHMARK — percentile assertions over auto-collected bench samples
|
|
|
|
|
* per action (p50/p95/p99 maxMs). Bounds are set ~20-100x above the
|
|
|
|
|
* measured medians so the crucible catches order-of-magnitude
|
|
|
|
|
* regressions, not dev-machine noise.
|
|
|
|
|
*
|
|
|
|
|
* Declared classes (verified against the engine):
|
|
|
|
|
* check[direct] O(1) in graph size — hash lookup
|
|
|
|
|
* check[chain-2hop] O(1) in graph size — fixed-depth traversal
|
|
|
|
|
* check[union-normal] O(k) in rule count — evaluates every rule
|
|
|
|
|
* check[union-binary] O(1) in rule count — early exit at threshold
|
|
|
|
|
* check[ttu] O(1) in graph size — one-hop group lookup
|
|
|
|
|
*/
|
|
|
|
|
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, makeHierarchyGraph } from './complex-graphs.js';
|
|
|
|
|
|
|
|
|
|
// ── Fixtures: prebuilt graphs at growing sizes, shared across runs ────
|
|
|
|
|
|
|
|
|
|
function buildSizedCommunityGraphs() {
|
|
|
|
|
const sizes = [2, 4, 8, 16];
|
|
|
|
|
return sizes.map((communities, i) => {
|
|
|
|
|
const g = makeCommunityGraph(10 + i, { communities, membersPerCommunity: 6, resourcesPerCommunity: 3, subCommunities: 2 });
|
|
|
|
|
return {
|
|
|
|
|
size: g.arbiter.relations.length,
|
|
|
|
|
arbiter: g.arbiter,
|
|
|
|
|
users: g.users,
|
|
|
|
|
resources: g.resources,
|
|
|
|
|
groups: g.groups,
|
|
|
|
|
subGroups: g.subGroups,
|
|
|
|
|
relations: g.relations
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildSizedHierarchyGraphs() {
|
|
|
|
|
const sizes = [2, 4, 8];
|
|
|
|
|
return sizes.map((departments, i) => {
|
|
|
|
|
const g = makeHierarchyGraph(20 + i, { departments, teamsPerDept: 2, membersPerTeam: 3, resourcesPerTeam: 2 });
|
|
|
|
|
return { size: g.arbiter.relations.length, arbiter: g.arbiter, users: g.users, resources: g.resources };
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const COMMUNITY = buildSizedCommunityGraphs();
|
|
|
|
|
const HIERARCHY = buildSizedHierarchyGraphs();
|
|
|
|
|
|
|
|
|
|
// Pick a query that exercises a non-trivial path on each graph.
|
|
|
|
|
function communityQuery(graph, seed) {
|
|
|
|
|
const ownsBySrc = new Map();
|
|
|
|
|
for (const r of graph.relations) if (r.rel === 'owns') ownsBySrc.set(r.src, r.dst);
|
|
|
|
|
const memberEdge = graph.relations.find(r => r.rel === 'member' && ownsBySrc.has(r.dst));
|
|
|
|
|
if (!memberEdge) return { user: graph.users[0], object: graph.resources[0] };
|
|
|
|
|
return { user: memberEdge.src, object: ownsBySrc.get(memberEdge.dst) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Actions ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// Each action returns { result, cost } where cost = rule evaluations,
|
|
|
|
|
// making the complexity verdict deterministic.
|
|
|
|
|
// Deterministic cost signal for graph-size actions: count actual engine
|
|
|
|
|
// lookups (getDirectRelation / getRelationsFromSrc / getRelationsToDst)
|
|
|
|
|
// performed per check. A correct engine does a CONSTANT number of lookups
|
|
|
|
|
// per query regardless of graph size -> O(1) verified deterministically.
|
|
|
|
|
// A regression to linear scans would grow the count with n and trip the
|
|
|
|
|
// e-process. No wall-clock jitter.
|
|
|
|
|
function instrumentLookupCount(arbiter) {
|
|
|
|
|
const lookup = arbiter.relationManager._lookup;
|
|
|
|
|
let count = 0;
|
|
|
|
|
for (const m of ['getDirectRelation', 'getRelationsFromSrc', 'getRelationsToDst']) {
|
|
|
|
|
const original = lookup[m].bind(lookup);
|
|
|
|
|
lookup[m] = function (...args) {
|
|
|
|
|
count++;
|
|
|
|
|
return original(...args);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return () => count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const LOOKUP_INSTRUMENTED = new Map();
|
|
|
|
|
for (let i = 0; i < COMMUNITY.length; i++) LOOKUP_INSTRUMENTED.set('c' + i, instrumentLookupCount(COMMUNITY[i].arbiter));
|
|
|
|
|
for (let i = 0; i < HIERARCHY.length; i++) LOOKUP_INSTRUMENTED.set('h' + i, instrumentLookupCount(HIERARCHY[i].arbiter));
|
|
|
|
|
|
|
|
|
|
function withLookupCost(prefix, fn) {
|
|
|
|
|
return (args) => {
|
|
|
|
|
const before = LOOKUP_INSTRUMENTED.get(prefix + args.graphIdx)();
|
|
|
|
|
const result = fn(args);
|
|
|
|
|
const cost = LOOKUP_INSTRUMENTED.get(prefix + args.graphIdx)() - before;
|
|
|
|
|
return { result, cost };
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Each action runs a fixed BATCH of checks per invocation so the timing
|
|
|
|
|
// signal lands in a measurable range (sub-10us single checks are pure
|
|
|
|
|
// jitter at the e-process spread check). The batch size is CONSTANT across
|
|
|
|
|
// graph sizes, so O(1)-in-graph-size still means what it says.
|
|
|
|
|
const BATCH = 2000;
|
|
|
|
|
|
|
|
|
|
const actions = {
|
|
|
|
|
direct: withLookupCost('c', ({ graphIdx }) => {
|
|
|
|
|
const g = COMMUNITY[graphIdx];
|
|
|
|
|
let last;
|
|
|
|
|
for (let i = 0; i < BATCH; i++) last = g.arbiter.check(g.users[0], 'direct_access', g.resources[0]);
|
|
|
|
|
return last;
|
|
|
|
|
}),
|
|
|
|
|
chain: withLookupCost('h', ({ graphIdx }) => {
|
|
|
|
|
const g = HIERARCHY[graphIdx];
|
|
|
|
|
let last;
|
|
|
|
|
for (let i = 0; i < BATCH; i++) last = g.arbiter.check(g.users[0], 'can_access_org', g.resources[0]);
|
|
|
|
|
return last;
|
|
|
|
|
}),
|
|
|
|
|
ttu: withLookupCost('c', ({ graphIdx }) => {
|
|
|
|
|
const g = COMMUNITY[graphIdx];
|
|
|
|
|
const q = communityQuery(g, 1);
|
|
|
|
|
let last;
|
|
|
|
|
for (let i = 0; i < BATCH; i++) last = g.arbiter.check(q.user, 'can_read', q.object);
|
|
|
|
|
return last;
|
|
|
|
|
}),
|
|
|
|
|
unionNormal: ({ ruleCount }) => {
|
|
|
|
|
// Union with ruleCount direct rules; only rule 0 grants, so every rule
|
|
|
|
|
// is evaluated (no early exit in normal mode) -> cost scales O(k).
|
|
|
|
|
const arbiter = new Arbiter();
|
|
|
|
|
arbiter.addNode('u:1', 'user');
|
|
|
|
|
arbiter.addNode('doc:9', 'doc');
|
|
|
|
|
const rules = [];
|
|
|
|
|
for (let r = 0; r < ruleCount; r++) {
|
|
|
|
|
arbiter.setRelationConfig(`rel${r}`, { type: 'direct' });
|
|
|
|
|
rules.push({ type: 'direct', relation: `rel${r}` });
|
|
|
|
|
}
|
|
|
|
|
arbiter.setRelationConfig('can_access', { union: rules });
|
|
|
|
|
arbiter.addRelation('u:1', 'rel0', 'doc:9', { possibility: 0.9 });
|
|
|
|
|
const result = arbiter.check('u:1', 'can_access', 'doc:9');
|
|
|
|
|
return { result, cost: ruleCount };
|
|
|
|
|
},
|
|
|
|
|
unionBinary: ({ ruleCount }) => {
|
|
|
|
|
// Same union, binary mode with threshold 0.8: rule 0 grants 0.9 >= 0.8,
|
|
|
|
|
// so the early exit fires after one rule -> cost stays O(1) in k.
|
|
|
|
|
const arbiter = new Arbiter();
|
|
|
|
|
arbiter.addNode('u:1', 'user');
|
|
|
|
|
arbiter.addNode('doc:9', 'doc');
|
|
|
|
|
const rules = [];
|
|
|
|
|
for (let r = 0; r < ruleCount; r++) {
|
|
|
|
|
arbiter.setRelationConfig(`rel${r}`, { type: 'direct' });
|
|
|
|
|
rules.push({ type: 'direct', relation: `rel${r}` });
|
|
|
|
|
}
|
|
|
|
|
arbiter.setRelationConfig('can_access', { union: rules });
|
|
|
|
|
arbiter.addRelation('u:1', 'rel0', 'doc:9', { possibility: 0.9 });
|
|
|
|
|
const result = arbiter.check('u:1', 'can_access', 'doc:9', { binary: true });
|
|
|
|
|
return { result, cost: 1 };
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const metricReaders = (name) => ({
|
|
|
|
|
n: ({ args }) => {
|
|
|
|
|
// Metric readers receive the raw generated-args ARRAY (fns get the
|
|
|
|
|
// spread values); the graph index is the single generated argument.
|
|
|
|
|
const graphIdx = args[0].graphIdx;
|
|
|
|
|
if (name === 'direct' || name === 'ttu') return COMMUNITY[graphIdx].size;
|
|
|
|
|
if (name === 'chain') return HIERARCHY[graphIdx].size;
|
|
|
|
|
return 0;
|
|
|
|
|
},
|
|
|
|
|
// Deterministic cost = engine lookups per check. Wall-clock timing at
|
|
|
|
|
// sub-ms scale is pure jitter for the spread check; the lookup count is
|
|
|
|
|
// noise-free and still grows if the engine ever degrades to scans.
|
|
|
|
|
cost: ({ result }) => result.cost
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-02 14:33:31 -07:00
|
|
|
// 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`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 14:20:50 -07:00
|
|
|
describe('Complexity & benchmark crucibles (rigor)', () => {
|
|
|
|
|
it('COMPLEXITY: direct/chain/ttu lookups are O(1) in graph size', async () => {
|
|
|
|
|
const spec = [];
|
|
|
|
|
for (const name of ['direct', 'chain', 'ttu']) {
|
|
|
|
|
const isGraph = name !== 'chain';
|
|
|
|
|
spec.push(rigor.fn(name, actions[name], rigor.args(
|
|
|
|
|
rigor.gen.object({
|
|
|
|
|
graphIdx: rigor.gen.int(0, (isGraph ? COMMUNITY : HIERARCHY).length - 1)
|
|
|
|
|
})
|
|
|
|
|
), rigor.metrics(metricReaders(name))));
|
|
|
|
|
}
|
|
|
|
|
const report = await rigor.campaign(
|
|
|
|
|
spec,
|
|
|
|
|
rigor.crucible([
|
|
|
|
|
rigor.complexity('direct', 'O(1)'),
|
|
|
|
|
rigor.complexity('chain', 'O(1)'),
|
|
|
|
|
rigor.complexity('ttu', 'O(1)')
|
|
|
|
|
])
|
|
|
|
|
).run({ effort: 600, seed: 'complexity-graph-size', artifacts: { dir: '', persist: 'never' } });
|
|
|
|
|
|
|
|
|
|
for (const v of report.crucibleVerdict.complexity) {
|
2026-08-02 14:33:31 -07:00
|
|
|
assertRealVerdict(v);
|
2026-08-02 14:20:50 -07:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('COMPLEXITY: union is O(k) in normal mode, O(1) in binary (early exit)', async () => {
|
|
|
|
|
const spec = [];
|
|
|
|
|
for (const name of ['unionNormal', 'unionBinary']) {
|
|
|
|
|
spec.push(rigor.fn(name, actions[name], rigor.args(
|
|
|
|
|
rigor.gen.object({
|
|
|
|
|
ruleCount: rigor.gen.oneOf([2, 4, 8, 16, 32, 64])
|
|
|
|
|
})
|
|
|
|
|
), rigor.metrics({
|
|
|
|
|
k: ({ args }) => args[0].ruleCount,
|
|
|
|
|
cost: ({ result }) => result.cost
|
|
|
|
|
})));
|
|
|
|
|
}
|
|
|
|
|
const report = await rigor.campaign(
|
|
|
|
|
spec,
|
|
|
|
|
rigor.crucible([
|
|
|
|
|
rigor.complexity('unionNormal', 'O(k)'),
|
|
|
|
|
rigor.complexity('unionBinary', 'O(1)')
|
|
|
|
|
])
|
|
|
|
|
).run({ effort: 600, seed: 'complexity-rule-count', artifacts: { dir: '', persist: 'never' } });
|
|
|
|
|
|
|
|
|
|
for (const v of report.crucibleVerdict.complexity) {
|
2026-08-02 14:33:31 -07:00
|
|
|
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');
|
2026-08-02 14:20:50 -07:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('BENCHMARK: complex queries stay under p95 latency budgets', async () => {
|
|
|
|
|
// Benchmark actions run a SINGLE check per invocation (not the BATCH
|
|
|
|
|
// used by the complexity actions) so the sampled latency is the real
|
|
|
|
|
// per-query latency, and the percentile assertions are meaningful.
|
|
|
|
|
const benchActions = {
|
|
|
|
|
direct: ({ graphIdx }) => COMMUNITY[graphIdx].arbiter.check(COMMUNITY[graphIdx].users[0], 'direct_access', COMMUNITY[graphIdx].resources[0]),
|
|
|
|
|
chain: ({ graphIdx }) => HIERARCHY[graphIdx].arbiter.check(HIERARCHY[graphIdx].users[0], 'can_access_org', HIERARCHY[graphIdx].resources[0]),
|
|
|
|
|
ttu: ({ graphIdx }) => {
|
|
|
|
|
const g = COMMUNITY[graphIdx];
|
|
|
|
|
const q = communityQuery(g, 1);
|
|
|
|
|
return g.arbiter.check(q.user, 'can_read', q.object);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const spec = [];
|
|
|
|
|
for (const name of ['direct', 'chain', 'ttu']) {
|
|
|
|
|
const isGraph = name !== 'chain';
|
|
|
|
|
spec.push(rigor.fn(name, benchActions[name], rigor.args(
|
|
|
|
|
rigor.gen.object({
|
|
|
|
|
graphIdx: rigor.gen.int(0, (isGraph ? COMMUNITY : HIERARCHY).length - 1)
|
|
|
|
|
})
|
|
|
|
|
), rigor.metrics(metricReaders(name))));
|
|
|
|
|
}
|
|
|
|
|
const report = await rigor.campaign(
|
|
|
|
|
spec,
|
|
|
|
|
rigor.crucible([
|
|
|
|
|
rigor.benchmark('direct', { p50: { maxMs: 0.2 }, p95: { maxMs: 1.0 } }),
|
|
|
|
|
rigor.benchmark('chain', { p50: { maxMs: 0.2 }, p95: { maxMs: 1.0 } }),
|
|
|
|
|
rigor.benchmark('ttu', { p50: { maxMs: 0.2 }, p95: { maxMs: 1.0 } })
|
|
|
|
|
])
|
|
|
|
|
).run({ effort: 600, seed: 'bench-complex-queries', artifacts: { dir: '', persist: 'never' } });
|
|
|
|
|
|
|
|
|
|
for (const v of report.crucibleVerdict.benchmarks) {
|
|
|
|
|
assert.equal(v.passed, true, `${v.name}: p50/p95 over budget`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|