Files
core/tests/rigor/complexity-bench-crucible.test.js
T

269 lines
11 KiB
JavaScript
Raw Normal View History

/**
* 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
});
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) {
assert.equal(v.passed, true, `${v.name} (${v.formula}): eProcess=${v.eProcess} violated=${v.trendViolated || v.spreadExceeded}`);
}
});
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) {
assert.equal(v.passed, true, `${v.name} (${v.formula}): eProcess=${v.eProcess} violated=${v.trendViolated || v.spreadExceeded}`);
}
});
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`);
}
});
});