/** * rigor/input-range-parity.test.js — write-boundary possibility validation * and id-hygiene contracts. * * The engine contract for addRelation()/removeRelation() writes: * - possibility must be a finite number in [0, 1]; anything else THROWS * and leaves the graph untouched (no partial writes). * - undefined possibility defaults to 1.0 (pre-existing contract). * - node keys and relation names are exact-match strings: a numeric key * is a DIFFERENT node than its string form (missing_node), and '|' * inside keys/relation names is harmless (keys are numeric ids in the * cache layer, so delimiter injection is structurally impossible). * * Two arms: * 1. FIXED MATRIX — every invalid shape is rejected, every boundary value * accepted, graph state preserved across failed writes. * 2. STATE PROPERTY CAMPAIGN — random add/check sequences against a mirror * model; the mirror independently classifies each possibility as * valid/invalid and the engine must agree exactly, plus check() * results always stay in [0, 1]. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; const U = 2; // users const D = U + 2; // docs const NODES = D + 1; function nodeKey(id) { if (id < U) return `u:${id}`; if (id < D) return `doc:${id - U}`; return 'g:0'; } function isValid(p) { return typeof p === 'number' && Number.isFinite(p) && p >= 0 && p <= 1; } function makeWrapper() { const arbiter = new Arbiter(); for (let i = 0; i < NODES; i++) arbiter.addNode(nodeKey(i), i < U ? 'user' : 'doc'); arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); const tuples = new Map(); const tupleKey = (src, rel, dst) => `${src}|${rel}|${dst}`; return { tuples, arbiter, add(src, rel, dst, p) { const key = nodeKey(src); const dstKey = nodeKey(dst); if (p === undefined || isValid(p)) { arbiter.addRelation(key, rel, dstKey, p === undefined ? undefined : { possibility: p }); tuples.set(tupleKey(key, rel, dstKey), p === undefined ? 1.0 : p); return { accepted: true }; } return { accepted: false }; }, check(src, rel, dst) { const result = arbiter.check(nodeKey(src), rel, nodeKey(dst)); let expected = 0; for (const [k, p] of tuples) { if (k === tupleKey(nodeKey(src), 'owner', nodeKey(dst))) expected = Math.max(expected, p); } const engine = typeof result.possibility === 'number' && Number.isFinite(result.possibility) ? result.possibility : -1; return { engine: Math.round(engine * 10000) / 10000, expected: Math.round(expected * 10000) / 10000 }; }, checkNumericId(src, rel, dst) { const result = arbiter.check(src, rel, dst); return result.reason; }, clone() { const fresh = makeWrapper(); for (const [k, p] of tuples) { const [src, rel, dst] = k.split('|'); fresh.arbiter.addRelation(src, rel, dst, { possibility: p }); fresh.tuples.set(k, p); } return fresh; } }; } describe('Possibility write-boundary validation (rigor)', () => { it('FIXED MATRIX: invalid possibilities throw and leave the graph untouched; boundaries accepted', () => { const w = makeWrapper(); const invalid = [2.0, -1, -0.0001, 1.0001, NaN, Infinity, -Infinity, null]; for (const p of invalid) { assert.throws( () => w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: p }), /Invalid possibility/, `possibility ${String(p)} must be rejected` ); assert.equal(w.arbiter.check('u:0', 'can_read', 'doc:0').possibility, 0, 'graph must stay untouched'); } for (const p of [0, 1, 0.001, 0.99999, 0.3]) { w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: p }); const got = w.arbiter.check('u:0', 'can_read', 'doc:0').possibility; assert.ok(Math.abs(got - p) < 1e-12, `boundary value ${p} accepted and returned (got ${got})`); } w.arbiter.addRelation('u:0', 'owner', 'doc:1', {}); assert.equal(w.arbiter.check('u:0', 'can_read', 'doc:1').possibility, 1.0, 'undefined possibility defaults to 1.0 on create'); assert.throws( () => w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: -0.5 }), /Invalid possibility/ ); assert.equal(w.arbiter.check('u:0', 'can_read', 'doc:0').possibility, 0.3, 'failed modify keeps old value'); const fresh = makeWrapper(); fresh.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.3 }); fresh.arbiter.addRelation('u:0', 'owner', 'doc:0', {}); assert.equal(fresh.arbiter.check('u:0', 'can_read', 'doc:0').possibility, 0.3, 'modify with undefined possibility preserves old value'); }); it('ID HYGIENE: numeric keys are distinct nodes; pipe characters are harmless', () => { const w = makeWrapper(); w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.9 }); assert.equal(w.checkNumericId('u:0', 'can_read', 'doc:0'), 'direct_match', 'string keys resolve'); assert.equal(w.checkNumericId(0, 'can_read', 2), 'missing_node', 'numeric keys are different nodes'); assert.equal(w.arbiter.keyManager.getStringId('u:0') !== w.arbiter.keyManager.getStringId(0), true, 'string/number ids never collide'); assert.equal(w.arbiter.keyManager.getStringId('a|b') !== w.arbiter.keyManager.getStringId('a'), true, 'pipe-bearing keys are distinct'); w.arbiter.setRelationConfig('r|x', { type: 'direct', relation: 'owner' }); w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.7 }); assert.equal(w.arbiter.check('u:0', 'r|x', 'doc:0').possibility, 0.7, 'pipe in relation name works'); assert.equal(w.arbiter.check('u:0', 'r', 'doc:0').possibility, 0, 'relation r is NOT r|x'); }); it('PROPERTY CAMPAIGN: engine write contract agrees with the independent classifier', async () => { const addArgs = rigor.gen.tuple( rigor.gen.int(0, NODES - 1), rigor.gen.enum(['owner', 'member_of', 'reads']), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.1, 0.5, 0.9, NaN, Infinity, -Infinity, 2.5, -0.5, 1.001, null, undefined]) ); const checkArgs = rigor.gen.tuple( rigor.gen.int(0, U - 1), rigor.gen.constant('can_read'), rigor.gen.constant(D) ); const result = await rigor.campaign( [rigor.object('graph', makeWrapper, [ rigor.method('add', function (src, rel, dst, p) { return this.add(src, rel, dst, p); }, rigor.args(addArgs)), rigor.method('check', function (src, rel, dst) { return this.check(src, rel, dst); }, rigor.args(checkArgs)) ])], rigor.crucible([ rigor.invariant('rejected iff invalid', (ctx) => { if (ctx.action !== 'graph.add') return true; const p = ctx.args[3]; return ctx.actual.accepted === (p === undefined || isValid(p)); }), rigor.invariant('no partial writes on rejection', (ctx) => { if (ctx.action !== 'graph.add') return true; if (ctx.actual.accepted) return true; return ctx.error === null; }), rigor.invariant('check parity with mirror', (ctx) => { if (ctx.action !== 'graph.check') return true; return ctx.actual.engine === ctx.actual.expected; }), rigor.invariant('possibility always in [0,1] or absent', (ctx) => { if (ctx.action !== 'graph.check') return true; return ctx.actual.engine >= 0 && ctx.actual.engine <= 1; }) ]) ).run({ effort: 400, seed: 'input-range-contract' }); const inv = result.crucibleVerdict; assert.equal(inv.passed, true, [ `engine diverged from contract in ${inv.failureCount} cases:`, ...result.failures.slice(0, 3).map((f) => ` [${f.action}] args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}` ) ].join('\n')); }); });