186 lines
9.2 KiB
JavaScript
186 lines
9.2 KiB
JavaScript
|
|
/**
|
||
|
|
* rigor/complex-graph-ttl-crucible.test.js — value-TTL expiry over the
|
||
|
|
* complex graphs, with an injected clock.
|
||
|
|
*
|
||
|
|
* The graph generators ship relations WITHOUT changed_last_at, so TTL
|
||
|
|
* gating is exercised on value-carrying edges written by the test with
|
||
|
|
* pinned `changed_last_at` timestamps (the same mirror-friendly strategy
|
||
|
|
* as ttl-expiry-parity). A relational-comparator policy consumes the
|
||
|
|
* values, because TTL expiry only gates VALUE extraction — a plain direct
|
||
|
|
* check returns the relation possibility regardless of age.
|
||
|
|
*
|
||
|
|
* FRESHNESS-PARITY — a value written at `now` grants immediately,
|
||
|
|
* still grants at TTL-1, and denies at TTL+1;
|
||
|
|
* the engine matches a mirror freshness rule
|
||
|
|
* (fresh iff age <= TTL).
|
||
|
|
* MUTATION-WITH-TIME — after every value rewrite (pinned
|
||
|
|
* changed_last_at), binary mode agrees with
|
||
|
|
* normal at the current pinned `now`, and both
|
||
|
|
* agree with the mirror.
|
||
|
|
* SNAPSHOT-PRESERVES-TTL — the condensed snapshot round-trip preserves
|
||
|
|
* the TTL config AND the expiry gate: both the
|
||
|
|
* original engine (against its pinned clock) and
|
||
|
|
* the restored engine (against its effective
|
||
|
|
* write clock) grant within TTL and deny past it.
|
||
|
|
*/
|
||
|
|
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, makeScaleFreeGraph } from './complex-graphs.js';
|
||
|
|
|
||
|
|
const TTL = 60_000;
|
||
|
|
const BASE_NOW = 1_000_000_000_000;
|
||
|
|
// The restored engine reports value edges as written at snapshot-restore
|
||
|
|
// time; the deny check must sit comfortably past that wall clock.
|
||
|
|
const RESTORE_BUFFER = 5_000;
|
||
|
|
|
||
|
|
const GENERATORS = [
|
||
|
|
{ name: 'community', make: makeCommunityGraph, opts: {} },
|
||
|
|
// Default scale-free (150 users / 400 edges) is ~6x slower to build;
|
||
|
|
// a smaller power-law graph exercises the same TTL contract.
|
||
|
|
{ name: 'scale-free', make: makeScaleFreeGraph, opts: { users: 60, resources: 20, edges: 150 } }
|
||
|
|
];
|
||
|
|
|
||
|
|
function fail(message) {
|
||
|
|
throw new Error(message);
|
||
|
|
}
|
||
|
|
|
||
|
|
function configureComparator(arbiter, graphRels) {
|
||
|
|
arbiter.setRelationConfig('balance', { type: 'direct' });
|
||
|
|
arbiter.setRelationConfig('price', { type: 'direct' });
|
||
|
|
arbiter.setRelationConfig('premium_access', {
|
||
|
|
type: 'relational_comparator',
|
||
|
|
comparator: '>',
|
||
|
|
left: { rule: { type: 'direct', relation: 'balance' }, extractValue: true },
|
||
|
|
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'price' }, extractValue: true }
|
||
|
|
});
|
||
|
|
arbiter.valueManager.setTTL('balance', TTL);
|
||
|
|
arbiter.valueManager.setTTL('price', TTL);
|
||
|
|
// The graph's own direct relations carry no values, so a TTL on them
|
||
|
|
// only gates value extraction (never the plain check) — harmless, and
|
||
|
|
// it pins the snapshot's TTL-config round-trip for those names too.
|
||
|
|
for (const rel of graphRels) arbiter.valueManager.setTTL(rel, TTL);
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildSnapshotEngine(spec, seed) {
|
||
|
|
const g = spec.make(seed, spec.opts);
|
||
|
|
const arbiter = g.arbiter;
|
||
|
|
configureComparator(arbiter, spec.name === 'community' ? ['direct_access', 'member'] : ['can_read']);
|
||
|
|
const u = g.users[0];
|
||
|
|
const o = (g.resources || g.subGroups || g.groups)[0];
|
||
|
|
arbiter.addRelation(u, 'balance', o, { value: 100, possibility: 1.0, changed_last_at: BASE_NOW });
|
||
|
|
// price evaluates from the object, so the edge is object -> object.
|
||
|
|
arbiter.addRelation(o, 'price', o, { value: 50, possibility: 1.0, changed_last_at: BASE_NOW });
|
||
|
|
return { g, arbiter, u, o };
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('Complex-graph value-TTL crucibles (rigor)', () => {
|
||
|
|
it('FRESHNESS / MUTATION / SNAPSHOT-TTL: TTL expiry holds across complex graphs', async () => {
|
||
|
|
async function check(args) {
|
||
|
|
const { genKind, seed, mutationCount } = args;
|
||
|
|
const spec = GENERATORS.find(g => g.name === genKind);
|
||
|
|
const g = spec.make(seed, spec.opts);
|
||
|
|
const arbiter = g.arbiter;
|
||
|
|
configureComparator(arbiter, spec.name === 'community' ? ['direct_access', 'member'] : ['can_read']);
|
||
|
|
const u = g.users[0];
|
||
|
|
const o = (g.resources || g.subGroups || g.groups)[0];
|
||
|
|
|
||
|
|
let engineNow = BASE_NOW;
|
||
|
|
const bal = { value: 100, ts: engineNow };
|
||
|
|
const prc = { value: 50, ts: engineNow };
|
||
|
|
|
||
|
|
const write = (kind, value) => {
|
||
|
|
const src = kind === 'balance' ? u : o;
|
||
|
|
arbiter.addRelation(src, kind, o, { value, possibility: 1.0, changed_last_at: engineNow });
|
||
|
|
const target = kind === 'balance' ? bal : prc;
|
||
|
|
// The engine only refreshes changed_last_at when the value actually
|
||
|
|
// changes; the mirror must mirror that or it un-expires old values.
|
||
|
|
if (target.value !== value) { target.value = value; target.ts = engineNow; }
|
||
|
|
};
|
||
|
|
const expected = () => {
|
||
|
|
const bFresh = engineNow - bal.ts <= TTL;
|
||
|
|
const pFresh = engineNow - prc.ts <= TTL;
|
||
|
|
return bFresh && pFresh && bal.value > prc.value ? 1 : 0;
|
||
|
|
};
|
||
|
|
const checkAt = () => arbiter.check(u, 'premium_access', o, { now: engineNow }).possibility;
|
||
|
|
const checkAtBinary = () => arbiter.check(u, 'premium_access', o, { now: engineNow, binary: true }).possibility;
|
||
|
|
|
||
|
|
write('balance', 100);
|
||
|
|
write('price', 50);
|
||
|
|
|
||
|
|
// FRESHNESS-PARITY
|
||
|
|
if (checkAt() !== 1) fail(`[freshness] fresh write did not grant (${genKind} seed=${seed})`);
|
||
|
|
engineNow += TTL - 1;
|
||
|
|
if (checkAt() !== expected()) fail(`[freshness] TTL-1 mismatch (${genKind} seed=${seed})`);
|
||
|
|
engineNow += 2; // now exactly TTL+1 since the write
|
||
|
|
const expired = checkAt();
|
||
|
|
if (expired !== 0) fail(`[freshness] expired value still grants (${genKind} seed=${seed}: ${expired})`);
|
||
|
|
if (expired !== expected()) fail(`[freshness] mirror mismatch at expiry (${genKind} seed=${seed})`);
|
||
|
|
|
||
|
|
// MUTATION-WITH-TIME: refresh both operands, then mutate and check.
|
||
|
|
engineNow += 100_000;
|
||
|
|
write('balance', 130);
|
||
|
|
write('price', 40);
|
||
|
|
for (let m = 0; m < mutationCount; m++) {
|
||
|
|
engineNow += 1000 * (1 + m);
|
||
|
|
const kind = m % 2 === 0 ? 'balance' : 'price';
|
||
|
|
write(kind, [20, 60, 120][m % 3]);
|
||
|
|
const normal = checkAt();
|
||
|
|
const binary = checkAtBinary();
|
||
|
|
if (normal !== binary) fail(`[mutation] binary=${binary} normal=${normal} disagree at now=${engineNow} (${genKind} seed=${seed})`);
|
||
|
|
if (normal !== expected()) fail(`[mutation] engine=${normal} mirror=${expected()} disagree at now=${engineNow} (${genKind} seed=${seed})`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// SNAPSHOT-PRESERVES-TTL on a dedicated never-mutated engine.
|
||
|
|
const { arbiter: sArb, u: su, o: so } = buildSnapshotEngine(spec, seed);
|
||
|
|
const expAt = BASE_NOW + TTL + 1;
|
||
|
|
if (sArb.check(su, 'premium_access', so, { now: BASE_NOW }).possibility !== 1) {
|
||
|
|
fail(`[snapshot] fresh snapshot engine did not grant (${genKind} seed=${seed})`);
|
||
|
|
}
|
||
|
|
if (sArb.check(su, 'premium_access', so, { now: expAt }).possibility !== 0) {
|
||
|
|
fail(`[snapshot] original engine did not expire at TTL+1 (${genKind} seed=${seed})`);
|
||
|
|
}
|
||
|
|
sArb.enableCondensedSnapshot();
|
||
|
|
const buf = sArb.toSnapshotBinary();
|
||
|
|
const restored = Arbiter.fromSnapshotBinary(buf);
|
||
|
|
if (restored.valueManager.getTTL('balance') !== TTL) {
|
||
|
|
fail(`[snapshot] TTL config lost across restore (${genKind} seed=${seed})`);
|
||
|
|
}
|
||
|
|
const restoredTs = restored.relationManager.getDirectRelation(
|
||
|
|
restored.nodeIdByKey.get(su), 'balance', restored.nodeIdByKey.get(so)
|
||
|
|
).changed_last_at;
|
||
|
|
if (restored.check(su, 'premium_access', so, { now: restoredTs }).possibility !== 1) {
|
||
|
|
fail(`[snapshot] restored value not fresh at its own write clock (${genKind} seed=${seed})`);
|
||
|
|
}
|
||
|
|
if (restored.check(su, 'premium_access', so, { now: restoredTs + TTL + RESTORE_BUFFER }).possibility !== 0) {
|
||
|
|
fail(`[snapshot] restored value did not expire past TTL (${genKind} seed=${seed})`);
|
||
|
|
}
|
||
|
|
return { ok: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[
|
||
|
|
rigor.fn('check', check, rigor.args(
|
||
|
|
rigor.gen.object({
|
||
|
|
genKind: rigor.gen.oneOf(GENERATORS.map(g => g.name)),
|
||
|
|
seed: rigor.gen.int(1, 6),
|
||
|
|
mutationCount: rigor.gen.int(2, 4)
|
||
|
|
})
|
||
|
|
))
|
||
|
|
],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('freshness-parity', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[freshness]')),
|
||
|
|
rigor.invariant('mutation-with-time', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[mutation]')),
|
||
|
|
rigor.invariant('snapshot-preserves-ttl', ({ error, errorMessage }) => !error || !String(errorMessage).startsWith('[snapshot]'))
|
||
|
|
])
|
||
|
|
).run({ effort: 250, seed: 'complex-graph-ttl-crucible', artifacts: { dir: '', persist: 'never' } });
|
||
|
|
|
||
|
|
for (const name of ['freshness-parity', 'mutation-with-time', 'snapshot-preserves-ttl']) {
|
||
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name);
|
||
|
|
assert.ok(inv, `invariant ${name} missing`);
|
||
|
|
assert.equal(inv.passed, true, `TTL ${name} violated in ${inv.failureCount} cases`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|