Files
core/tests/rigor/ttl-expiry-parity.test.js
T
John Dvorak 4fd4e20bd0 js-rigor: reliability flows through every rule kind; multi_hop value collection fixed
Systemic reliability gap found by the probe sweep: the compiled evaluation
paths never emitted the reliability the engine computes.

- Compiled _evaluateDirect omitted the relation's reliability, and the
  chain/multi_hop rules hardcoded reliability: 1.0 — so check() results
  reported 1.0 for any rule whose decision came through a chain, multi_hop,
  union, intersection, exclusion, or defeasible combination.
- The chain and multi_hop traversals now track per-path reliability (product
  of edge reliabilities) and report the winning path's value; the compiled
  and fallback logical operators (union/intersection/exclusion, direct_list
  fast path, early exits) report the selected child's reliability
  (max/min child or OWA trace index; exclusion multiplies both legs), and
  normal-mode defeasible combines base x requires x defeater reliabilities.
- The checker's logical fast path dropped collectedValues from union/
  intersection/exclusion results; it now passes them through.
- MultiHopRule.valueManager was read off relationManager where the real
  arbiter keeps it on the arbiter — collectValues: true on a multi_hop rule
  with a value-carrying edge crashed the evaluation (error result, silent
  denial). Now resolved at the arbiter level with a relationManager
  fallback for stubs.

Campaign pins: reliability per kind (chain/multi_hop product, union/intersection
selected child, exclusion/defeasible product), and multi_hop value collection
through persistent and partial contexts.
2026-08-01 09:52:31 -07:00

158 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}`;
const realNow = Date.now;
let engineNow = 1_000_000_000_000;
Date.now = () => engineNow;
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));
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'));
});
});