Files
core/tests/rigor/value-freshness-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

204 lines
8.7 KiB
JavaScript

/**
* rigor/value-freshness-parity.test.js — value-layer and batch-path cache
* freshness.
*
* The needle class: queries that warm the rule-result / decision caches,
* followed by mutations applied through the BATCH path (which bypasses
* Arbiter.addRelation/removeRelation and their cache invalidation), must
* still return fresh answers:
* - comparator decisions reflect the latest values after batch modify
* and batch remove+add;
* - direct checks reflect batch adds/removes with warm caches;
* - transactional batches either fully apply (success) or fully roll
* back (poisoned op -> state unchanged, checks unchanged).
*
* The mirror tracks balance/price per document; comparator expectation is
* balance > price (engine returns 1/0 with reasons
* allow_rule_matched / values_compared_comparison_false).
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';
const DOCS = 3;
const docKey = (i) => `doc:${i}`;
function makeWrapper() {
const arbiter = new Arbiter();
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('can_read', { type: 'direct', relation: 'owner' });
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 }
});
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);
arbiter.addRelation(src, rel, docKey(doc), { value, possibility: 1.0 });
(kind === 'balance' ? balance : price).set(doc, value);
return { ok: true };
},
batchSetValue(doc, kind, value) {
const rel = kind === 'balance' ? 'has_balance' : 'has_price';
const src = kind === 'balance' ? 'user:alice' : docKey(doc);
const target = kind === 'balance' ? balance : price;
const batch = [{ operation: 'modify', srcKey: src, relation: rel, dstKey: docKey(doc), options: { value, possibility: 1.0 } }];
arbiter.relationManager.updateRelationsBatch(batch);
// A batch MODIFY of a relation that does not exist is a silent no-op
// in the engine (mirrors _modifyRelation), so the mirror must only
// record when the relation was already present.
if (target.has(doc)) target.set(doc, value);
return { ok: true };
},
batchSwap(doc, kind, value) {
const rel = kind === 'balance' ? 'has_balance' : 'has_price';
const src = kind === 'balance' ? 'user:alice' : docKey(doc);
const batch = [
{ operation: 'remove', srcKey: src, relation: rel, dstKey: docKey(doc), options: {} },
{ operation: 'add', srcKey: src, relation: rel, dstKey: docKey(doc), options: { value, possibility: 1.0 } }
];
arbiter.relationManager.updateRelationsBatch(batch);
(kind === 'balance' ? balance : price).set(doc, value);
return { ok: true };
},
txBatch(doc, kind, value, poison) {
const rel = kind === 'balance' ? 'has_balance' : 'has_price';
const src = kind === 'balance' ? 'user:alice' : docKey(doc);
const batch = [{
operation: 'modify',
srcKey: src,
relation: rel,
dstKey: docKey(doc),
options: poison ? { value, possibility: 1.5 } : { value, possibility: 1.0 }
}];
const result = arbiter.relationManager.updateRelationsBatchTransactional(batch);
if (result.success) {
const target = kind === 'balance' ? balance : price;
if (target.has(doc)) target.set(doc, value);
}
return { success: result.success, error: result.error };
},
check(doc) {
const result = arbiter.check('user:alice', 'premium', docKey(doc));
const b = balance.get(doc);
const p = price.get(doc);
const expected = b !== undefined && p !== undefined && b > p ? 1 : 0;
return {
engine: result.possibility,
expected,
reason: result.reason,
balance: b,
price: p
};
},
checkRead(doc) {
const result = arbiter.check('user:alice', 'can_read', docKey(doc));
return { engine: result.possibility, reason: result.reason };
},
clone() {
const fresh = makeWrapper();
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.batchSetValue = record('batchSetValue', wrapper.batchSetValue);
wrapper.batchSwap = record('batchSwap', wrapper.batchSwap);
wrapper.txBatch = record('txBatch', wrapper.txBatch);
wrapper.check = record('check', wrapper.check);
wrapper.checkRead = record('checkRead', wrapper.checkRead);
return wrapper;
}
const docArg = rigor.gen.int(0, DOCS - 1);
const kindArg = rigor.gen.enum(['balance', 'price']);
const valueArg = rigor.gen.oneOf([5, 10, 50, 100, 200]);
describe('Value-layer and batch-path freshness (rigor)', () => {
it('FIXED MATRIX: batch modify, batch swap, and transactional rollback stay fresh', () => {
const w = makeWrapper();
w.setValue(0, 'balance', 100);
w.setValue(0, 'price', 50);
assert.equal(w.check(0).engine, 1, 'v1: 100 > 50');
w.batchSetValue(0, 'balance', 10);
assert.equal(w.check(0).engine, 0, 'batch modify must be fresh: 10 > 50 is false');
w.batchSwap(0, 'balance', 200);
assert.equal(w.check(0).engine, 1, 'batch remove+add must be fresh: 200 > 50');
const tx = w.txBatch(0, 'balance', 1, true);
assert.equal(tx.success, false, 'poisoned tx must report failure');
assert.equal(w.check(0).engine, 1, 'tx rollback must leave state unchanged: 200 > 50');
const txOk = w.txBatch(0, 'balance', 5, false);
assert.equal(txOk.success, true, 'clean tx must succeed');
assert.equal(w.check(0).engine, 0, 'clean tx must apply: 5 > 50 is false');
w.engine.addRelation('user:alice', 'owner', 'doc:0', { possibility: 0.9 });
assert.equal(w.checkRead(0).engine, 0.9, 'direct check before batch');
w.engine.relationManager.updateRelationsBatch([
{ operation: 'remove', srcKey: 'user:alice', relation: 'owner', dstKey: 'doc:0', options: {} }
]);
assert.equal(w.checkRead(0).engine, 0, 'batch remove must invalidate warm direct caches');
});
it('PROPERTY CAMPAIGN: interleaved single/batch/tx mutations keep comparator parity', async () => {
const result = await rigor.campaign(
[rigor.object('graph', makeWrapper, [
rigor.method('setValue', function (d, k, v) { return this.setValue(d, k, v); }, rigor.args(docArg, kindArg, valueArg)),
rigor.method('batchSetValue', function (d, k, v) { return this.batchSetValue(d, k, v); }, rigor.args(docArg, kindArg, valueArg)),
rigor.method('batchSwap', function (d, k, v) { return this.batchSwap(d, k, v); }, rigor.args(docArg, kindArg, valueArg)),
rigor.method('txBatch', function (d, k, v, poison) { return this.txBatch(d, k, v, poison); }, rigor.args(docArg, kindArg, valueArg, rigor.gen.boolean())),
rigor.method('check', function (d) { return this.check(d); }, rigor.args(docArg))
])],
rigor.crucible([
rigor.invariant('comparator parity after every mutation', (ctx) => {
if (ctx.action !== 'graph.check') return true;
if (ctx.error !== null) return true;
return ctx.actual.engine === ctx.actual.expected;
}),
rigor.invariant('no action errors', (ctx) => {
if (ctx.action === 'graph.txBatch') return true;
return ctx.error === null;
}),
rigor.after('graph.txBatch', ({ actual }) => {
return typeof actual.success === 'boolean';
})
])
).run({ effort: 400, seed: 'value-freshness-parity', maxTraceLength: 30 , artifacts: { dir: '', persist: 'never' }});
const inv = result.crucibleVerdict;
assert.equal(inv.passed, true, [
`value freshness 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'));
});
});