8141930764
Extends the complex-graph suite to the remaining uncovered surfaces:
- complex-graph-ttl-crucible: value-TTL expiry over community/scale-free
graphs (pinned changed_last_at writes, {now} reads, mirror freshness
rule), mutation-with-time binary parity, snapshot round-trip preserves
the TTL gate. Notable find: snapshot restore resets changed_last_at to
access time (RelationSnapshotAccess.js:91), so TTL assertions compare
each engine against its own effective write clock.
- complex-graph-overlay-crucible: partial-graph overlay over complex
graphs. Key discovery: pre-built PartialGraphContext must be passed as
partialGraphContext (partialGraph is a raw spec re-ingested at
ArbiterChecks.js:15); overlay rides the direct relations a policy
consumes (TTU-derived can_read ignores it, verified by probe).
- complex-graph-values-crucible: relational-comparator over value-carrying
relations with pinned clocks — comparator parity, value-mutation flips
the decision immediately, TTL expiry on the comparator denies.
- complex-graph-reachability-crucible: PLTC reachability vs ground-truth
BFS on scale-free/community graphs — verdict parity, fast-fail
soundness (no false positives), null-defer contract honored.
- complex-graph-batch-crucible: addRelationsBatch vs sequential build
parity, mutation parity across both, cache-freshness after mutation.
- complex-graph-quantization-crucible: 16-bit quantization band over real
possibility spreads, allow/deny agreement outside the band, snapshot-of-
snapshot semantic identity.
Also seeds the two unseeded campaigns in tuple-to-userset-rule.test.js
(flagged flake ~1/15 — nondeterministic runs on a deterministic engine).
Rigor 245/245, full suite 847/785/0.
171 lines
7.3 KiB
JavaScript
171 lines
7.3 KiB
JavaScript
/**
|
|
* rigor/complex-graph-batch-crucible.test.js — batch-loading and decision
|
|
* cache parity on the community graph.
|
|
*
|
|
* Two engines are rebuilt from the same makeCommunityGraph(seed) fixture:
|
|
* one loaded relation-by-relation, one loaded through
|
|
* relationManager.addRelationsBatch (which expects { srcKey, relation,
|
|
* dstKey, options } — the generator's { src, rel, dst, possibility }
|
|
* objects are mapped into that shape).
|
|
*
|
|
* BATCH-SEQUENTIAL-PARITY — identical check answers on both engines for
|
|
* sampled (user, relation, object) triples
|
|
* across direct, TTU, union, exclusion, and
|
|
* chain policies.
|
|
* BATCH-MUTATION — after the same edge mutation on both engines,
|
|
* parity holds and the post-mutation answer is
|
|
* fresh (the decision cache is invalidated, not
|
|
* served stale) — even right after a warm read.
|
|
* FIXTURE-SIZE — the community fixture actually carries a
|
|
* batch-sized relation set (> 50 edges).
|
|
*/
|
|
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 } from './complex-graphs.js';
|
|
|
|
const EPS = 1e-9;
|
|
const POLICIES = ['direct_access', 'can_read', 'can_read_with_direct', 'can_read_not_blocked', 'can_delegate_read'];
|
|
|
|
function fail(message) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
function mulberry32(seed) {
|
|
let a = seed >>> 0;
|
|
return function () {
|
|
a |= 0;
|
|
a = (a + 0x6d2b79f5) | 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
function rebuildForBatch(g) {
|
|
const arb = new Arbiter();
|
|
for (const node of g.arbiter.nodes.values()) arb.addNode(node.key, node.type);
|
|
for (const [name, config] of g.arbiter.relationConfigs.entries()) arb.setRelationConfig(name, config);
|
|
const edges = g.relations.map(r => ({
|
|
srcKey: r.src,
|
|
relation: r.rel,
|
|
dstKey: r.dst,
|
|
options: { possibility: r.possibility }
|
|
}));
|
|
arb.relationManager.addRelationsBatch(edges);
|
|
return arb;
|
|
}
|
|
|
|
function sampleTriples(g, rng) {
|
|
const triples = [];
|
|
// Real graph edges exercise the actual membership/ownership structure.
|
|
for (const r of g.relations.slice(0, 40)) triples.push([r.src, r.rel, r.dst]);
|
|
for (let i = 0; i < 40; i++) {
|
|
const u = g.users[Math.floor(rng() * g.users.length)];
|
|
const o = g.resources[Math.floor(rng() * g.resources.length)];
|
|
triples.push([u, POLICIES[Math.floor(rng() * POLICIES.length)], o]);
|
|
}
|
|
return triples;
|
|
}
|
|
|
|
function assertParity(seq, batch, triples, tag) {
|
|
for (const [u, rel, o] of triples) {
|
|
const a = seq.check(u, rel, o).possibility;
|
|
const b = batch.check(u, rel, o).possibility;
|
|
if (Math.abs(a - b) > EPS) {
|
|
fail(`[parity] ${tag} ${u} ${rel} ${o}: seq=${a} batch=${b}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('Complex-graph batch/cache crucibles (rigor)', () => {
|
|
it('BATCH-SEQUENTIAL-PARITY + BATCH-MUTATION + CACHE-FRESHNESS hold on the community graph', async () => {
|
|
async function check(args) {
|
|
const { seed, mode } = args;
|
|
const g = makeCommunityGraph(seed);
|
|
const seq = g.arbiter;
|
|
if (seq.relations.length <= 50) {
|
|
fail(`[fixture] community fixture has only ${seq.relations.length} relations`);
|
|
}
|
|
const batch = rebuildForBatch(g);
|
|
const rng = mulberry32(seed * 101);
|
|
|
|
// BATCH-SEQUENTIAL-PARITY on the untouched graph.
|
|
const triples = sampleTriples(g, rng);
|
|
assertParity(seq, batch, triples, 'initial');
|
|
|
|
// BATCH-MUTATION + CACHE-INTERACTION. mode 0 removes an existing
|
|
// direct_access edge; mode 1 adds one to a triple that has none.
|
|
let pair;
|
|
if (mode === 0) {
|
|
pair = g.relations.find(r => r.rel === 'direct_access');
|
|
if (!pair) fail(`[fixture] no direct_access edge on seed=${seed}`);
|
|
} else {
|
|
const clean = () => {
|
|
for (let i = 0; i < 200; i++) {
|
|
const u = g.users[Math.floor(rng() * g.users.length)];
|
|
const o = g.resources[Math.floor(rng() * g.resources.length)];
|
|
if (g.relations.some(r => r.src === u && r.rel === 'direct_access' && r.dst === o)) continue;
|
|
if (seq.check(u, 'direct_access', o).possibility !== 0) continue;
|
|
return { src: u, rel: 'direct_access', dst: o };
|
|
}
|
|
return null;
|
|
};
|
|
pair = clean();
|
|
if (!pair) fail(`[fixture] no clean direct_access pair on seed=${seed}`);
|
|
}
|
|
|
|
// Warm the decision cache on both engines before mutating.
|
|
seq.check(pair.src, 'direct_access', pair.dst);
|
|
batch.check(pair.src, 'direct_access', pair.dst);
|
|
batch.check(pair.src, 'direct_access', pair.dst);
|
|
|
|
const expectedAfter = mode === 0 ? 0 : 0.77;
|
|
if (mode === 0) {
|
|
seq.removeRelation(pair.src, 'direct_access', pair.dst);
|
|
batch.removeRelation(pair.src, 'direct_access', pair.dst);
|
|
} else {
|
|
seq.addRelation(pair.src, 'direct_access', pair.dst, { possibility: expectedAfter });
|
|
batch.addRelation(pair.src, 'direct_access', pair.dst, { possibility: expectedAfter });
|
|
}
|
|
const sa = seq.check(pair.src, 'direct_access', pair.dst).possibility;
|
|
const ba = batch.check(pair.src, 'direct_access', pair.dst).possibility;
|
|
if (Math.abs(sa - ba) > EPS) fail(`[mutation] seq=${sa} batch=${ba} diverge after mutation`);
|
|
if (Math.abs(ba - expectedAfter) > EPS) {
|
|
fail(`[cache] batched engine returned ${ba}, expected ${expectedAfter} after mutation (stale cache)`);
|
|
}
|
|
if (Math.abs(sa - expectedAfter) > EPS) {
|
|
fail(`[cache] sequential engine returned ${sa}, expected ${expectedAfter} after mutation`);
|
|
}
|
|
|
|
// No divergence on the broader policy surface after the mutation.
|
|
assertParity(seq, batch, triples, 'post-mutation');
|
|
return { relations: seq.relations.length, triples: triples.length };
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.object({
|
|
seed: rigor.gen.int(1, 6),
|
|
mode: rigor.gen.oneOf([0, 1])
|
|
})
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('batch-sequential-parity', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[parity]')),
|
|
rigor.invariant('batch-mutation', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[mutation]')),
|
|
rigor.invariant('cache-interaction', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[cache]')),
|
|
rigor.invariant('fixture-size', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[fixture]'))
|
|
])
|
|
).run({ effort: 150, seed: 'complex-graph-batch-crucible', artifacts: { dir: '', persist: 'never' } });
|
|
|
|
for (const name of ['batch-sequential-parity', 'batch-mutation', 'cache-interaction', 'fixture-size']) {
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name);
|
|
assert.ok(inv, `invariant ${name} missing`);
|
|
assert.equal(inv.passed, true, `batch ${name} violated in ${inv.failureCount} cases`);
|
|
}
|
|
});
|
|
});
|