/** * 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', ({ actual }) => actual !== undefined), rigor.invariant('mutation-with-time', ({ actual }) => actual !== undefined), rigor.invariant('snapshot-preserves-ttl', ({ actual }) => actual !== undefined) ]) ).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`); } }); });