Files
core/tests/rigor/batch-order-parity.test.js
T
John Dvorak ff6e52111d js-rigor: reliability crucibles across the campaigns; denied-decision leak fixed
The reliability gap found last round was invisible to every parity mirror
(they compared possibility only). Hardened the existing campaigns so the
mirrors carry reliability too:

- batch-order-parity: batch ops carry reliability; the mirror tracks
  last-write-wins reliability and the crucible asserts engine reliability
  parity (mirror corrected: add-on-existing preserves reliability, it does
  not reset it).
- rule-kind-partial-parity: the TTU differential property now generates
  per-edge reliabilities and asserts the winning intermediate's
  reliability (tupleset.reli * computed.reli); a new chain reliability
  differential property does the same for 2-step chains.
- snapshot-quantization-parity: edges carry deterministic reliabilities and
  the round-trip pins the codec's reliability channel (product-aware
  tolerance: chain reliability multiplies two quantized inputs).
- model-based-graph: the reference model tracks reliability per tuple and
  checks it alongside possibility for direct and chain queries.

The model crucible immediately caught a real bug: the direct-check fast
path returned the relation's reliability on a DENIED decision (possibility
0), while the rule-collection path zeroes it — denied results leaked
reliability. Both fast-path branches (direct match and threshold_not_met)
now report reliability 0 when the decision is denied.
2026-08-01 11:18:20 -07:00

169 lines
6.2 KiB
JavaScript

/**
* rigor/batch-order-parity.test.js — batch update ordering semantics.
*
* updateRelationsBatch applies operations in the GIVEN order
* (last-write-wins per tuple). Contracts pinned:
* - [add v1, remove] ends absent; [remove, add v1] ends v1.
* - [modify, add, modify] ends with the LAST modify's value.
* - modify of a tuple that does not exist (yet) is a silent no-op.
* - decisions after a mixed batch equal the in-order mirror.
*
* The mirror is a plain last-write-wins map with the modify-no-op rule;
* the engine must agree after every mixed batch.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';
const KEY = 'u:0|owner|doc:0';
const VALUES = [0.2, 0.5, 0.8, 0.9];
const RELIABILITIES = [0.3, 0.6, 0.9];
function applyMirror(tuples, ops) {
for (const op of ops) {
if (op.operation === 'remove') {
tuples.delete(KEY);
} else if (op.operation === 'modify' && !tuples.has(KEY)) {
continue; // engine no-op on missing tuple
} else {
// Last write wins for the whole tuple: reliability rides along, so
// the mirror carries it and the decision invariant can check it too.
tuples.set(KEY, { p: op.value, r: op.reliability });
}
}
}
function makeWrapper() {
const arbiter = new Arbiter();
arbiter.addNode('u:0', 'user');
arbiter.addNode('doc:0', 'doc');
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
const tuples = new Map();
const ops = [];
const wrapper = {
engine: arbiter,
tuples,
add(value) {
arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: value });
// add-on-existing is a modify: reliability is preserved, not reset.
const existing = tuples.get(KEY);
tuples.set(KEY, { p: value, r: existing ? existing.r : 1.0 });
return { ok: true };
},
remove() {
arbiter.removeRelation('u:0', 'owner', 'doc:0');
tuples.delete(KEY);
return { ok: true };
},
mixedBatch(batchOps) {
const batch = batchOps.map((op) => ({
operation: op.operation,
srcKey: 'u:0',
relation: 'owner',
dstKey: 'doc:0',
options: op.operation === 'remove' ? {} : { possibility: op.value, reliability: op.reliability }
}));
arbiter.relationManager.updateRelationsBatch(batch);
applyMirror(tuples, batchOps);
return { ok: true, count: batchOps.length };
},
check() {
const result = arbiter.check('u:0', 'can_read', 'doc:0');
const entry = tuples.get(KEY);
const expected = entry ? entry.p : 0;
const expectedReliability = entry ? entry.r : 0;
return { engine: result.possibility, expected, expectedReliability, reliability: result.reliability, 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.add = record('add', wrapper.add);
wrapper.remove = record('remove', wrapper.remove);
wrapper.mixedBatch = record('mixedBatch', wrapper.mixedBatch);
wrapper.check = record('check', wrapper.check);
return wrapper;
}
const opGen = rigor.gen.record({
operation: rigor.gen.enum(['add', 'modify', 'remove']),
value: rigor.gen.oneOf(VALUES),
reliability: rigor.gen.oneOf(RELIABILITIES)
});
describe('Batch update ordering semantics (rigor)', () => {
it('FIXED MATRIX: mixed-kind batches preserve last-write-wins', () => {
const w = makeWrapper();
w.add(0.9);
w.mixedBatch([
{ operation: 'add', value: 0.5 },
{ operation: 'remove', value: 0.5 }
]);
assert.equal(w.check().engine, 0, '[add, remove] ends absent');
w.mixedBatch([
{ operation: 'remove', value: 0.5 },
{ operation: 'add', value: 0.5 }
]);
assert.equal(w.check().engine, 0.5, '[remove, add] ends 0.5');
w.mixedBatch([
{ operation: 'modify', value: 0.6 },
{ operation: 'add', value: 0.8 },
{ operation: 'modify', value: 0.9 }
]);
assert.equal(w.check().engine, 0.9, '[modify, add, modify] ends with last value');
w.mixedBatch([
{ operation: 'remove', value: 0.9 },
{ operation: 'modify', value: 0.7 }
]);
assert.equal(w.check().engine, 0, 'modify after remove (missing tuple) is a no-op');
});
it('PROPERTY CAMPAIGN: engine agrees with the in-order mirror after every mixed batch', async () => {
const result = await rigor.campaign(
[rigor.object('graph', makeWrapper, [
rigor.method('add', function (v) { return this.add(v); }, rigor.args(rigor.gen.oneOf(VALUES))),
rigor.method('remove', function () { return this.remove(); }),
rigor.method('mixedBatch', function (batchOps) { return this.mixedBatch(batchOps); },
rigor.args(rigor.gen.array(opGen, 2, 4))),
rigor.method('check', function () { return this.check(); })
])],
rigor.crucible([
rigor.invariant('decision parity after every step', (ctx) => {
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
return ctx.actual.engine === ctx.actual.expected;
}),
rigor.invariant('reliability parity after every step', (ctx) => {
if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
if (ctx.actual.expectedReliability === 0) return true; // denied decision: reliability 0
return Math.abs((ctx.actual.reliability ?? 1.0) - ctx.actual.expectedReliability) < 1e-9;
}),
rigor.invariant('no action errors', (ctx) => ctx.error === null)
])
).run({ effort: 400, seed: 'batch-order-parity', maxTraceLength: 25 , artifacts: { dir: '', persist: 'never' }});
const inv = result.crucibleVerdict;
assert.equal(inv.passed, true, [
`batch ordering violated in ${inv.failureCount} cases:`,
...result.failures.slice(0, 3).map((f) =>
` [${f.name}] action=${f.actionName} seq=${JSON.stringify((f.sequence || []).map(s => s.args).filter(a => a && a.length))} actual=${JSON.stringify(f.actual)} error=${f.error}`
)
].join('\n'));
});
});