257c52ea91
The now parameter (and partialGraph.now) replaced the need for global Date.now patching in tests. The ttl-expiry parity campaign's module-scope patch and the relational-comparator Time-Based Decay tests' before/after patches now pass the clock explicitly through check options — no global mutation, no restore-order fragility, and the pinned-clock cache bypass keeps every evaluation honest. No stale references to the removed compiled evaluator or useCompiled option remain in the test suite.
156 lines
6.4 KiB
JavaScript
156 lines
6.4 KiB
JavaScript
/**
|
|
* rigor/ttl-expiry-parity.test.js — value-TTL expiry through the full
|
|
* comparator check path with an injected clock.
|
|
*
|
|
* Contracts pinned:
|
|
* - values written with setTTL(rel, ttlMs) expire after ttlMs of engine
|
|
* time; expired operands no longer participate in relational
|
|
* comparisons (the comparator's value extraction must consult the
|
|
* value manager's TTL gate, not raw relation values).
|
|
* - with the decision cache enabled, a decision cached before expiry may
|
|
* linger up to the rule-result-cache TTL, but MUST become fresh once
|
|
* the cache entry itself expires (bounded staleness).
|
|
*
|
|
* The mirror tracks per-relation write timestamps under the same injected
|
|
* clock and computes the expected comparison result.
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { rigor } from '@rigor/core';
|
|
import { Arbiter } from '../../src/index.js';
|
|
|
|
const TTL = 5000;
|
|
const DOCS = 2;
|
|
const docKey = (i) => `doc:${i}`;
|
|
|
|
let engineNow = 1_000_000_000_000;
|
|
|
|
function makeWrapper(disableCaching) {
|
|
const arbiter = new Arbiter(disableCaching ? { disableCaching: true } : {});
|
|
arbiter.addNode('user:alice', 'user');
|
|
for (let i = 0; i < DOCS; i++) arbiter.addNode(docKey(i), 'doc');
|
|
arbiter.setRelationConfig('has_balance', { type: 'direct' });
|
|
arbiter.setRelationConfig('has_price', { type: 'direct' });
|
|
arbiter.setRelationConfig('premium', {
|
|
type: 'relational_comparator',
|
|
comparator: '>',
|
|
left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true },
|
|
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
|
|
});
|
|
arbiter.valueManager.setTTL('has_balance', TTL);
|
|
arbiter.valueManager.setTTL('has_price', TTL);
|
|
|
|
const balance = new Map();
|
|
const price = new Map();
|
|
const ops = [];
|
|
|
|
const wrapper = {
|
|
engine: arbiter,
|
|
balance,
|
|
price,
|
|
setValue(doc, kind, value) {
|
|
const rel = kind === 'balance' ? 'has_balance' : 'has_price';
|
|
const src = kind === 'balance' ? 'user:alice' : docKey(doc);
|
|
// Pin the write timestamp: clone-replay re-writes relations with the
|
|
// CURRENT Date.now(), which would silently un-expire old values; the
|
|
// engine honors the changed_last_at override.
|
|
arbiter.addRelation(src, rel, docKey(doc), { value, possibility: 1.0, changed_last_at: engineNow });
|
|
// The engine only refreshes changed_last_at when the value actually
|
|
// changes; the mirror must mirror that.
|
|
const target = kind === 'balance' ? balance : price;
|
|
const existing = target.get(doc);
|
|
if (!existing || existing.value !== value) target.set(doc, { value, ts: engineNow });
|
|
return { ok: true };
|
|
},
|
|
advanceTime(ms) {
|
|
engineNow += ms;
|
|
return { ok: true, now: engineNow };
|
|
},
|
|
check(doc) {
|
|
const result = arbiter.check('user:alice', 'premium', docKey(doc), { now: engineNow });
|
|
const b = balance.get(doc);
|
|
const p = price.get(doc);
|
|
const bFresh = b !== undefined && engineNow - b.ts <= TTL;
|
|
const pFresh = p !== undefined && engineNow - p.ts <= TTL;
|
|
const expected = bFresh && pFresh && b.value > p.value ? 1 : 0;
|
|
return { engine: result.possibility, expected, reason: result.reason, bFresh, pFresh };
|
|
},
|
|
clone() {
|
|
const fresh = makeWrapper(disableCaching);
|
|
for (const op of ops) {
|
|
const [name, ...args] = op;
|
|
fresh[name](...args);
|
|
}
|
|
return fresh;
|
|
}
|
|
};
|
|
|
|
const record = (name, fn) => (...args) => {
|
|
const res = fn(...args);
|
|
ops.push([name, ...args]);
|
|
return res;
|
|
};
|
|
wrapper.setValue = record('setValue', wrapper.setValue);
|
|
wrapper.advanceTime = record('advanceTime', wrapper.advanceTime);
|
|
wrapper.check = record('check', wrapper.check);
|
|
return wrapper;
|
|
}
|
|
|
|
describe('Value TTL expiry through the comparator path (rigor)', () => {
|
|
it('FIXED MATRIX: expired operands flip the decision; cache staleness is bounded', () => {
|
|
{
|
|
const w = makeWrapper(true);
|
|
w.setValue(0, 'balance', 100);
|
|
w.setValue(0, 'price', 50);
|
|
assert.equal(w.check(0).engine, 1, 'fresh: 100 > 50');
|
|
|
|
w.advanceTime(TTL + 1000);
|
|
assert.equal(w.check(0).engine, 0, 'after TTL both operands expired -> deny');
|
|
|
|
w.setValue(0, 'price', 200);
|
|
assert.equal(w.check(0).engine, 0, 'balance expired, price fresh -> deny');
|
|
|
|
w.setValue(0, 'balance', 300);
|
|
assert.equal(w.check(0).engine, 1, 'both refreshed -> allow');
|
|
|
|
const cw = makeWrapper(false);
|
|
cw.setValue(0, 'balance', 100);
|
|
cw.setValue(0, 'price', 50);
|
|
assert.equal(cw.check(0).engine, 1, 'cached arbiter: allow cached');
|
|
cw.advanceTime(TTL + 1000);
|
|
const staleWindow = cw.check(0);
|
|
cw.advanceTime(70000); // past ruleResultCacheTTL (60s)
|
|
assert.equal(cw.check(0).engine, 0, 'bounded staleness: fresh deny after cache TTL');
|
|
assert.ok(staleWindow.engine >= 0, 'stale window decision is well-formed');
|
|
}
|
|
});
|
|
|
|
it('PROPERTY CAMPAIGN: parity holds across time advances and refreshes (no decision cache)', async () => {
|
|
const result = await rigor.campaign(
|
|
[rigor.object('graph', makeWrapper.bind(null, true), [
|
|
rigor.method('setValue', function (d, k, v) { return this.setValue(d, k, v); },
|
|
rigor.args(rigor.gen.int(0, DOCS - 1), rigor.gen.enum(['balance', 'price']), rigor.gen.oneOf([5, 50, 100, 200]))),
|
|
rigor.method('advanceTime', function (ms) { return this.advanceTime(ms); },
|
|
rigor.args(rigor.gen.oneOf([1000, 4000, 6000, 30000, 70000]))),
|
|
rigor.method('check', function (d) { return this.check(d); },
|
|
rigor.args(rigor.gen.int(0, DOCS - 1)))
|
|
])],
|
|
rigor.crucible([
|
|
rigor.invariant('comparator parity under time', (ctx) => {
|
|
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
|
|
return ctx.actual.engine === ctx.actual.expected;
|
|
}),
|
|
rigor.invariant('no action errors', (ctx) => ctx.error === null)
|
|
])
|
|
).run({ effort: 400, seed: 'ttl-expiry-parity', maxTraceLength: 30 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
const inv = result.crucibleVerdict;
|
|
assert.equal(inv.passed, true, [
|
|
`TTL parity violated in ${inv.failureCount} cases:`,
|
|
...result.failures.slice(0, 3).map((f) =>
|
|
` [${f.invariant}] action=${f.action} args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}`
|
|
)
|
|
].join('\n'));
|
|
});
|
|
});
|