diff --git a/src/core/relation/RelationUpdates.js b/src/core/relation/RelationUpdates.js index 277b6ea..2353a00 100644 --- a/src/core/relation/RelationUpdates.js +++ b/src/core/relation/RelationUpdates.js @@ -305,7 +305,7 @@ export class RelationUpdates { src: srcId, rel: relation, dst: dstId, - possibility: options.possibility !== undefined ? options.possibility : 1.0, + possibility: options.possibility !== undefined ? validatePossibility(options.possibility) : 1.0, reliability: options.reliability !== undefined ? options.reliability : 1.0, value: options.value, decayConfig: options.decayConfig, diff --git a/tests/rigor/tx-rollback-parity.test.js b/tests/rigor/tx-rollback-parity.test.js new file mode 100644 index 0000000..9bc4943 --- /dev/null +++ b/tests/rigor/tx-rollback-parity.test.js @@ -0,0 +1,188 @@ +/** + * rigor/tx-rollback-parity.test.js — transactional batch atomicity and + * batch+PLTC reachability. + * + * Contracts pinned: + * - updateRelationsBatchTransactional is ATOMIC: a poison op anywhere in + * the batch (invalid possibility) fails the whole batch and rolls back + * every earlier op; on success every op applies in the given order. + * - batch ADD validates possibility exactly like single adds (an invalid + * possibility must never be stored, even via the batch path). + * - with enableReachabilityCheck, batch adds/removes update the PLTC + * indices: chain checks reflect batch-written edges immediately. + */ +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}`; +const tupleKey = (d) => `u:0|owner|doc:${d}`; + +function applyMirror(tuples, ops) { + for (const op of ops) { + if (op.operation === 'remove') { + tuples.delete(tupleKey(op.doc)); + } else if (op.operation === 'modify' && !tuples.has(tupleKey(op.doc))) { + continue; // engine no-op on missing tuple + } else { + tuples.set(tupleKey(op.doc), op.value); + } + } +} + +function makeWrapper() { + const arbiter = new Arbiter(); + arbiter.addNode('u:0', 'user'); + for (let i = 0; i < DOCS; i++) arbiter.addNode(docKey(i), 'doc'); + arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); + const tuples = new Map(); + const ops = []; + + const wrapper = { + engine: arbiter, + tuples, + add(doc, value) { + arbiter.addRelation('u:0', 'owner', docKey(doc), { possibility: value }); + tuples.set(tupleKey(doc), value); + return { ok: true }; + }, + txBatch(batchOps, poisonAt) { + const batch = batchOps.map((op, i) => ({ + operation: op.operation, + srcKey: 'u:0', + relation: 'owner', + dstKey: docKey(op.doc), + options: op.operation === 'remove' + ? {} + : { possibility: i === poisonAt ? 1.5 : op.value } + })); + const result = arbiter.relationManager.updateRelationsBatchTransactional(batch); + if (result.success) { + applyMirror(tuples, batchOps); + } + return { success: result.success, error: result.error }; + }, + check(doc) { + const result = arbiter.check('u:0', 'can_read', docKey(doc)); + const expected = tuples.get(tupleKey(doc)) ?? 0; + return { engine: result.possibility, expected, 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.txBatch = record('txBatch', wrapper.txBatch); + wrapper.check = record('check', wrapper.check); + return wrapper; +} + +const opGen = rigor.gen.record({ + operation: rigor.gen.enum(['add', 'modify', 'remove']), + doc: rigor.gen.int(0, DOCS - 1), + value: rigor.gen.oneOf([0.2, 0.5, 0.8, 0.9]) +}); + +describe('Transactional batch atomicity and batch+PLTC (rigor)', () => { + it('FIXED MATRIX: poison at every position rolls back everything; batch add validates possibility', () => { + const w = makeWrapper(); + w.add(0, 0.9); + w.add(1, 0.7); + + for (const poisonAt of [0, 1, 2]) { + const tx = w.txBatch([ + { operation: 'add', doc: 2, value: 0.4 }, + { operation: 'modify', doc: 1, value: 0.6 }, + { operation: 'modify', doc: 0, value: 0.6 } + ], poisonAt); + assert.equal(tx.success, false, `poison at ${poisonAt} must fail`); + assert.equal(w.check(0).engine, 0.9, `poison at ${poisonAt}: doc0 rolled back`); + assert.equal(w.check(1).engine, 0.7, `poison at ${poisonAt}: doc1 rolled back`); + assert.equal(w.check(2).engine, 0, `poison at ${poisonAt}: doc2 rolled back`); + } + + const clean = w.txBatch([ + { operation: 'add', doc: 2, value: 0.4 }, + { operation: 'modify', doc: 1, value: 0.6 }, + { operation: 'modify', doc: 0, value: 0.6 } + ], -1); + assert.equal(clean.success, true, 'clean tx succeeds'); + assert.equal(w.check(0).engine, 0.6, 'clean tx: doc0 last-write-wins'); + assert.equal(w.check(1).engine, 0.6, 'clean tx: doc1 modified'); + assert.equal(w.check(2).engine, 0.4, 'clean tx: doc2 added'); + + assert.throws( + () => w.engine.relationManager.updateRelationsBatch([ + { operation: 'add', srcKey: 'u:0', relation: 'owner', dstKey: 'doc:0', options: { possibility: 7 } } + ]), + /Invalid possibility/, + 'batch add must reject invalid possibility' + ); + }); + + it('BATCH+PLTC: chain reachability reflects batch writes immediately', () => { + const arb = new Arbiter({ enableReachabilityCheck: true }); + arb.addNode('u:0', 'user'); + arb.addNode('g:0', 'group'); + arb.addNode('doc:0', 'doc'); + arb.setRelationConfig('can_access', { + type: 'chain', + steps: [ + { relation: 'member_of', direction: 'out' }, + { relation: 'reads', direction: 'out' } + ] + }); + arb.relationManager.updateRelationsBatch([ + { operation: 'add', srcKey: 'u:0', relation: 'member_of', dstKey: 'g:0', options: { possibility: 0.8 } }, + { operation: 'add', srcKey: 'g:0', relation: 'reads', dstKey: 'doc:0', options: { possibility: 0.7 } } + ]); + assert.equal(arb.check('u:0', 'can_access', 'doc:0').possibility, 0.7, 'chain allow after batch add'); + arb.relationManager.updateRelationsBatch([ + { operation: 'remove', srcKey: 'g:0', relation: 'reads', dstKey: 'doc:0', options: {} } + ]); + assert.equal(arb.check('u:0', 'can_access', 'doc:0').possibility, 0, 'chain deny after batch remove'); + }); + + it('PROPERTY CAMPAIGN: tx atomicity and in-order application hold under fuzzed batches', async () => { + const result = await rigor.campaign( + [rigor.object('graph', makeWrapper, [ + rigor.method('add', function (d, v) { return this.add(d, v); }, rigor.args(rigor.gen.int(0, DOCS - 1), rigor.gen.oneOf([0.2, 0.5, 0.8, 0.9]))), + rigor.method('txBatch', function (batchOps, poisonAt) { return this.txBatch(batchOps, poisonAt); }, + rigor.args(rigor.gen.array(opGen, 1, 4), rigor.gen.oneOf([-1, 0, 1, 2, 3]))), + rigor.method('check', function (d) { return this.check(d); }, rigor.args(rigor.gen.int(0, DOCS - 1))) + ])], + 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('tx returns a boolean success flag', (ctx) => { + if (ctx.action !== 'graph.txBatch') return true; + return typeof ctx.actual.success === 'boolean'; + }), + rigor.invariant('no action errors', (ctx) => ctx.error === null) + ]) + ).run({ effort: 400, seed: 'tx-rollback-parity', maxTraceLength: 25 }); + + const inv = result.crucibleVerdict; + assert.equal(inv.passed, true, [ + `tx atomicity 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')); + }); +});