/** * rigor/complex-graph-values-crucible.test.js — value-carrying relations * and the relational-comparator path over a community graph, with an * injected clock. * * The community graph supplies the node universe; the test writes * value-carrying balance/price edges (pinned changed_last_at) on top and * evaluates a relational_comparator policy. The mirror computes the * comparator result from the raw values under the same freshness rule as * the engine (fresh iff age <= TTL). * * COMPARATOR-PARITY — the comparator answer equals the plain value * comparison at the pinned `now`, across a value * matrix that includes denying combinations. * VALUE-MUTATION — rewriting a value flips the decision immediately * at the pinned `now`, and binary mode agrees. * TTL-EXPIRY — once both operands age past TTL, the comparator * denies; the mirror agrees. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { makeCommunityGraph } from './complex-graphs.js'; const TTL = 60_000; const BASE_NOW = 1_000_000_000_000; const VALUE_SET = [5, 20, 40, 60, 100, 130]; function fail(message) { throw new Error(message); } describe('Complex-graph value/comparator crucibles (rigor)', () => { it('COMPARATOR-PARITY / VALUE-MUTATION / TTL-EXPIRY hold on the community graph', async () => { async function check(args) { const { seed, mutations } = args; const g = makeCommunityGraph(seed); const arbiter = g.arbiter; const u = g.users[0]; 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); const keys = g.resources.slice(0, 3); let engineNow = BASE_NOW; const values = new Map(); for (const k of keys) values.set(k, { balance: { v: 0, ts: -Infinity }, price: { v: 0, ts: -Infinity } }); const write = (k, kind, v) => { const src = kind === 'balance' ? u : k; arbiter.addRelation(src, kind, k, { value: v, possibility: 1.0, changed_last_at: engineNow }); const target = values.get(k)[kind]; // The engine keeps the old timestamp when a rewrite does not change // the value; the mirror mirrors that or it un-expires old values. if (target.v !== v) { target.v = v; target.ts = engineNow; } }; const fresh = ts => engineNow - ts <= TTL; const expected = k => { const v = values.get(k); return fresh(v.balance.ts) && fresh(v.price.ts) && v.balance.v > v.price.v ? 1 : 0; }; const checkAt = k => { const normal = arbiter.check(u, 'premium_access', k, { now: engineNow }).possibility; const binary = arbiter.check(u, 'premium_access', k, { now: engineNow, binary: true }).possibility; return { normal, binary }; }; // COMPARATOR-PARITY: value matrix at pinned clocks, including // denying combinations. const matrix = [ [100, 50], // grant [50, 100], // deny [100, 100], // deny (not strictly greater) [0, 10], // deny [200, 5], // grant [5, 5] // deny ]; for (let i = 0; i < matrix.length; i++) { const k = keys[i % keys.length]; engineNow = BASE_NOW + i * 1000; write(k, 'balance', matrix[i][0]); write(k, 'price', matrix[i][1]); const { normal, binary } = checkAt(k); if (normal !== expected(k)) { fail(`[parity] engine=${normal} mirror=${expected(k)} for balance=${matrix[i][0]} price=${matrix[i][1]} (seed=${seed})`); } if (binary !== normal) fail(`[parity] binary=${binary} normal=${normal} disagree (seed=${seed})`); } // VALUE-MUTATION: fresh grant, then flip by rewriting one operand. const k0 = keys[0]; engineNow = BASE_NOW + 1_000_000; write(k0, 'balance', 100); write(k0, 'price', 50); if (checkAt(k0).normal !== 1) fail(`[mutation] fresh grant missing (seed=${seed})`); engineNow += 1000; write(k0, 'balance', 40); const flipped = checkAt(k0); if (flipped.normal !== 0 || flipped.binary !== 0) { fail(`[mutation] value rewrite did not flip immediately (normal=${flipped.normal} binary=${flipped.binary} seed=${seed})`); } engineNow += 1000; write(k0, 'price', 10); const reGranted = checkAt(k0); if (reGranted.normal !== 1 || reGranted.binary !== 1) { fail(`[mutation] re-grant did not apply immediately (normal=${reGranted.normal} binary=${reGranted.binary} seed=${seed})`); } // Random rewrites with binary + mirror agreement after every change. for (let m = 0; m < mutations; m++) { engineNow += 1000 * (1 + m); const k = keys[m % keys.length]; write(k, m % 2 === 0 ? 'balance' : 'price', VALUE_SET[(seed + m * 7) % VALUE_SET.length]); const { normal, binary } = checkAt(k); if (normal !== expected(k)) fail(`[mutation] engine=${normal} mirror=${expected(k)} (seed=${seed} m=${m})`); if (binary !== normal) fail(`[mutation] binary=${binary} normal=${normal} disagree (seed=${seed} m=${m})`); } // TTL-EXPIRY-ON-COMPARATOR: both operands past TTL -> deny, mirror agrees. // Use values NOT in VALUE_SET (137 > 30): the engine keeps the OLD // timestamp when a rewrite does not change the value, so a value equal // to whatever the mutation loop last wrote would NOT refresh freshness // and the pre-expiry grant would (correctly) not materialize. Writing // guaranteed-different values forces a timestamp refresh. const kExp = keys[keys.length - 1]; engineNow = BASE_NOW + 2_000_000; write(kExp, 'balance', 137); write(kExp, 'price', 30); if (checkAt(kExp).normal !== 1) fail(`[expiry] pre-expiry grant missing (seed=${seed})`); engineNow += TTL + 1; const expired = checkAt(kExp); if (expired.normal !== 0 || expired.binary !== 0) { fail(`[expiry] comparator denied expected after TTL (normal=${expired.normal} binary=${expired.binary} seed=${seed})`); } if (expired.normal !== expected(kExp)) fail(`[expiry] mirror mismatch at expiry (seed=${seed})`); return { ok: true }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ seed: rigor.gen.int(1, 6), mutations: rigor.gen.int(2, 5) }) )) ], rigor.crucible([ rigor.invariant('comparator-parity', ({ actual }) => actual !== undefined), rigor.invariant('value-mutation', ({ actual }) => actual !== undefined), rigor.invariant('ttl-expiry-on-comparator', ({ actual }) => actual !== undefined) ]) ).run({ effort: 200, seed: 'complex-graph-values-crucible', artifacts: { dir: '', persist: 'never' } }); for (const name of ['comparator-parity', 'value-mutation', 'ttl-expiry-on-comparator']) { const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name); assert.ok(inv, `invariant ${name} missing`); assert.equal(inv.passed, true, `values ${name} violated in ${inv.failureCount} cases`); } }); });