/** * rigor/snapshot-quantization-parity.test.js — condensed-snapshot round-trip * parity for non-dyadic possibility values. * * The condensed snapshot encodes each edge possibility as a 16-bit uniform * quantizer on the 65535 scale (_floatToBits = round(p * 65535), * _bitsToFloat = bits / 65535). Contract: * - endpoints 0 and 1 are exact; every other value round-trips with * absolute error <= 0.5 / 65535 (~7.63e-6). * - restored values never leave [0, 1]. * - binary-mode decisions can only flip when the live value sits inside * the quantization band of the threshold; outside the band decisions * must agree exactly. * - restoring the same buffer twice is deterministic; snapshot-of-snapshot * (serialize a restored arbiter, restore again) preserves values. * - the graph binary round-trips self-consistently (toBinary(restored) * is byte-stable across generations). * * The oracle is the live arbiter's own pre-enable check results. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; import { ArbiterSnapshot } from '../../src/core/arbiter/ArbiterSnapshot.js'; import { serializeArbiterSnapshot } from '../../src/core/SnapshotBinary.js'; const QUANT_STEP = 0.5 / 65535; // max absolute quantization error const TOL = QUANT_STEP + 1e-9; const NONDYADIC = [0.1, 0.3, 0.7, 0.9, 0.111, 0.333, 0.999, 0.001, 0.8999999, 0.5000001]; function nodeKey(id) { if (id < 2) return `u:${id}`; if (id < 3) return 'g:0'; return `doc:${id - 3}`; } function buildGraph(edges, values) { const arb = new Arbiter(); for (let i = 0; i < 6; i++) arb.addNode(nodeKey(i), i < 2 ? 'user' : i === 2 ? 'group' : 'doc'); arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); arb.setRelationConfig('can_access', { type: 'chain', steps: [ { relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' } ] }); for (let i = 0; i < edges.length; i++) { arb.addRelation(nodeKey(edges[i][0]), edges[i][1], nodeKey(edges[i][2]), { possibility: values[i] }); } return arb; } const QUERIES = []; for (const u of [0, 1]) { for (const d of [3, 4, 5]) { QUERIES.push(['can_read', u, d]); QUERIES.push(['can_access', u, d]); } } function roundTripReport(edges, values) { const live = buildGraph(edges, values); const before = QUERIES.map(([rel, u, d]) => live.check(nodeKey(u), rel, nodeKey(d)).possibility); live.enableCondensedSnapshot(); const buffer = serializeArbiterSnapshot(live); const r1 = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => new Arbiter()); const after1 = QUERIES.map(([rel, u, d]) => r1.check(nodeKey(u), rel, nodeKey(d)).possibility); const r1b = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => new Arbiter()); const after1b = QUERIES.map(([rel, u, d]) => r1b.check(nodeKey(u), rel, nodeKey(d)).possibility); const buffer2 = serializeArbiterSnapshot(r1); const r2 = ArbiterSnapshot.fromSnapshotBinary(buffer2, {}, () => new Arbiter()); const after2 = QUERIES.map(([rel, u, d]) => r2.check(nodeKey(u), rel, nodeKey(d)).possibility); return { before, after1, after1b, after2, graphBytes: live.snapshotGraph.toBinary().byteLength }; } describe('Condensed snapshot quantization parity (rigor)', () => { it('FIXED VALUES: quantization error band, bounds, determinism, snapshot-of-snapshot', () => { const edges = [ [0, 'owner', 3], [1, 'owner', 4], [0, 'member_of', 2], [2, 'reads', 3], [1, 'member_of', 2], [2, 'reads', 4], [0, 'reads', 5], [1, 'owner', 5] ]; const values = [0.9, 0.1, 0.7, 0.333, 0.999, 0.111, 0.5000001, 0.001]; const r = roundTripReport(edges, values); for (let i = 0; i < r.before.length; i++) { const live = r.before[i]; assert.ok( Math.abs(r.after1[i] - live) <= TOL, `query ${i}: live=${live} restored=${r.after1[i]} exceeds tolerance ${TOL}` ); assert.ok(r.after1[i] >= 0 && r.after1[i] <= 1, `restored value ${r.after1[i]} outside [0,1]`); } assert.deepEqual(r.after1, r.after1b, 'restoring the same buffer is deterministic'); assert.deepEqual(r.after1, r.after2, 'snapshot-of-snapshot preserves values'); const g1 = buildGraph(edges, values); g1.enableCondensedSnapshot(); const b1 = g1.snapshotGraph.toBinary(); const g2 = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(g1), {}, () => new Arbiter()); const b2 = g2.snapshotGraph.toBinary(); const g3 = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(g2), {}, () => new Arbiter()); const b3 = g3.snapshotGraph.toBinary(); assert.equal(b2.byteLength, b3.byteLength, 'reserialized graph binary is byte-stable'); }); it('PROPERTY CAMPAIGN: random graphs keep parity under quantization and threshold decisions', async () => { const edgeSet = rigor.gen.oneOf([ [[0, 'owner', 3]], [[0, 'owner', 3], [1, 'owner', 4]], [[0, 'owner', 3], [0, 'member_of', 2], [2, 'reads', 3]], [[0, 'member_of', 2], [1, 'member_of', 2], [2, 'reads', 3], [2, 'reads', 4]], [[0, 'owner', 3], [1, 'owner', 4], [0, 'member_of', 2], [1, 'member_of', 2], [2, 'reads', 3], [2, 'reads', 4], [0, 'reads', 5], [1, 'owner', 5]], [[0, 'owner', 5], [2, 'reads', 5], [0, 'member_of', 2], [1, 'member_of', 2], [2, 'reads', 3]] ]); const valuesGen = rigor.gen.array(rigor.gen.oneOf(NONDYADIC), 0, 12); const result = await rigor.campaign( [rigor.fn('roundtrip', (edges, values) => roundTripReport(edges, values), rigor.args(edgeSet, valuesGen))], rigor.crucible([ rigor.invariant('values within quantization tolerance', (ctx) => { const { before, after1 } = ctx.actual; for (let i = 0; i < before.length; i++) { if (Math.abs(after1[i] - before[i]) > TOL) return false; } return true; }), rigor.invariant('restored values stay in [0,1]', (ctx) => { const { after1 } = ctx.actual; for (const v of after1) { if (!(v >= 0 && v <= 1)) return false; } return true; }), rigor.invariant('deterministic restore', (ctx) => { const { after1, after1b } = ctx.actual; return after1.every((v, i) => Math.abs(v - after1b[i]) <= TOL); }), rigor.invariant('snapshot-of-snapshot preserves values', (ctx) => { const { after1, after2 } = ctx.actual; return after1.every((v, i) => Math.abs(v - after2[i]) <= TOL); }) ]) ).run({ effort: 300, seed: 'snapshot-quantization-parity' }); const inv = result.crucibleVerdict; assert.equal(inv.passed, true, [ `quantization parity violated in ${inv.failureCount} cases:`, ...result.failures.slice(0, 3).map((f) => ` [${f.invariant}] args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)}` ) ].join('\n')); }); it('THRESHOLD BAND: binary decisions only flip inside the quantization band', () => { const thresholds = [0.2, 0.5, 0.8, 0.9]; const edges = [[0, 'owner', 3], [1, 'owner', 4]]; for (const t of thresholds) { for (const v of NONDYADIC) { const live = buildGraph([[0, 'owner', 3]], [v]); const before = live.check('u:0', 'can_read', 'doc:3').possibility; const beforeDecision = before >= t; live.enableCondensedSnapshot(); const restored = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(live), {}, () => new Arbiter()); const after = restored.check('u:0', 'can_read', 'doc:3').possibility; const afterDecision = after >= t; const delta = Math.abs(before - t); if (delta >= QUANT_STEP) { assert.equal(afterDecision, beforeDecision, `threshold ${t}: value ${v} (live ${before}, restored ${after}) flips outside the band`); } } } }); });