/** * 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 }); 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')); }); });