From ff6e52111d16bc0e4cf854fc8c85c41a0e524e9b Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Sat, 1 Aug 2026 11:18:20 -0700 Subject: [PATCH] js-rigor: reliability crucibles across the campaigns; denied-decision leak fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/authorization/AuthorizationChecker.js | 7 +- tests/rigor/batch-order-parity.test.js | 27 ++- tests/rigor/model-based-graph.test.js | 46 +++-- tests/rigor/rule-kind-partial-parity.test.js | 166 ++++++++++++++++-- .../snapshot-quantization-parity.test.js | 21 ++- 5 files changed, 229 insertions(+), 38 deletions(-) diff --git a/src/authorization/AuthorizationChecker.js b/src/authorization/AuthorizationChecker.js index 144aeed..41a6226 100644 --- a/src/authorization/AuthorizationChecker.js +++ b/src/authorization/AuthorizationChecker.js @@ -155,13 +155,18 @@ export class AuthorizationChecker { if (effectiveThreshold !== null && directRel.possibility < effectiveThreshold) { result = { possibility: 0, + reliability: 0, ...(includeMeta && { meta: { reason: 'threshold_not_met', threshold: effectiveThreshold, actual: directRel.possibility } }), reason: 'threshold_not_met' }; } else { result = { possibility: directRel.possibility, - reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0, + // A denied decision (possibility 0) must not leak the + // relation's reliability — the rule-collection path zeroes it. + reliability: directRel.possibility > 0 + ? (directRel.reliability !== undefined ? directRel.reliability : 1.0) + : 0, ...(includeMeta && { meta: { allow: { diff --git a/tests/rigor/batch-order-parity.test.js b/tests/rigor/batch-order-parity.test.js index a7397e7..326b240 100644 --- a/tests/rigor/batch-order-parity.test.js +++ b/tests/rigor/batch-order-parity.test.js @@ -18,6 +18,7 @@ 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) { @@ -26,7 +27,9 @@ function applyMirror(tuples, ops) { } else if (op.operation === 'modify' && !tuples.has(KEY)) { continue; // engine no-op on missing tuple } else { - tuples.set(KEY, op.value); + // 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 }); } } } @@ -44,7 +47,9 @@ function makeWrapper() { tuples, add(value) { arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: value }); - tuples.set(KEY, 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() { @@ -58,7 +63,7 @@ function makeWrapper() { srcKey: 'u:0', relation: 'owner', dstKey: 'doc:0', - options: op.operation === 'remove' ? {} : { possibility: op.value } + options: op.operation === 'remove' ? {} : { possibility: op.value, reliability: op.reliability } })); arbiter.relationManager.updateRelationsBatch(batch); applyMirror(tuples, batchOps); @@ -66,8 +71,10 @@ function makeWrapper() { }, check() { const result = arbiter.check('u:0', 'can_read', 'doc:0'); - const expected = tuples.get(KEY) ?? 0; - return { engine: result.possibility, expected, reason: result.reason }; + 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(); @@ -93,7 +100,8 @@ function makeWrapper() { const opGen = rigor.gen.record({ operation: rigor.gen.enum(['add', 'modify', 'remove']), - value: rigor.gen.oneOf(VALUES) + value: rigor.gen.oneOf(VALUES), + reliability: rigor.gen.oneOf(RELIABILITIES) }); describe('Batch update ordering semantics (rigor)', () => { @@ -140,6 +148,11 @@ describe('Batch update ordering semantics (rigor)', () => { 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' }}); @@ -148,7 +161,7 @@ describe('Batch update ordering semantics (rigor)', () => { assert.equal(inv.passed, true, [ `batch ordering 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}` + ` [${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')); }); diff --git a/tests/rigor/model-based-graph.test.js b/tests/rigor/model-based-graph.test.js index 51a5025..5dff56f 100644 --- a/tests/rigor/model-based-graph.test.js +++ b/tests/rigor/model-based-graph.test.js @@ -51,8 +51,8 @@ function makeModel() { copy.check = this.check; return copy; }, - add(src, rel, dst, p) { - this.tuples.set(this.key(src, rel, dst), p); + add(src, rel, dst, p, reli) { + this.tuples.set(this.key(src, rel, dst), { p, r: reli ?? 1.0 }); return { ok: true }; }, remove(src, rel, dst) { @@ -61,22 +61,36 @@ function makeModel() { }, check(src, rel, dst) { let possibility = 0; + let reliability = 0; if (rel === 'can_read') { - for (const [k, p] of this.tuples) { - if (k === this.key(src, 'owner', DOC)) possibility = Math.max(possibility, p); + for (const [k, v] of this.tuples) { + if (k === this.key(src, 'owner', DOC)) { + if (v.p > possibility) { + possibility = v.p; + reliability = v.r; + } + } } } else if (rel === 'can_access') { // The chain traverses member_of from ANY node, then reads into the - // object from any reached node — mirror the engine exactly. + // object from any reached node — mirror the engine exactly. The + // winning path's reliability is the product of its edges. for (let m = 0; m < NODES; m++) { const a = this.tuples.get(this.key(src, 'member_of', m)); const b = this.tuples.get(this.key(m, 'reads', DOC)); if (a !== undefined && b !== undefined) { - possibility = Math.max(possibility, Math.min(a, b)); + const combined = Math.min(a.p, b.p); + if (combined > possibility) { + possibility = combined; + reliability = a.r * b.r; + } } } } - return { possibility: Math.round(possibility * 10000) / 10000 }; + return { + possibility: Math.round(possibility * 10000) / 10000, + reliability: Math.round(reliability * 10000) / 10000 + }; } }; } @@ -99,9 +113,9 @@ function makeSut() { return { ops, - addRelation(src, rel, dst, p) { - arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), { possibility: p }); - ops.push(['add', src, rel, dst, p]); + addRelation(src, rel, dst, p, reli) { + arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), { possibility: p, reliability: reli }); + ops.push(['add', src, rel, dst, p, reli]); return { ok: true }; }, removeRelation(src, rel, dst) { @@ -111,12 +125,15 @@ function makeSut() { }, check(src, rel, dst) { const result = arbiter.check(nodeKey(src), rel, nodeKey(dst)); - return { possibility: Math.round(result.possibility * 10000) / 10000 }; + return { + possibility: Math.round(result.possibility * 10000) / 10000, + reliability: Math.round((result.reliability ?? 0) * 10000) / 10000 + }; }, clone() { const fresh = makeSut(); for (const op of ops) { - if (op[0] === 'add') fresh.addRelation(op[1], op[2], op[3], op[4]); + if (op[0] === 'add') fresh.addRelation(op[1], op[2], op[3], op[4], op[5]); else fresh.removeRelation(op[1], op[2], op[3]); } return fresh; @@ -128,7 +145,8 @@ const tupleArgs = 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(POS) + rigor.gen.oneOf(POS), + rigor.gen.oneOf([0.3, 0.6, 0.9]) ); const removeArgs = rigor.gen.tuple( rigor.gen.int(0, NODES - 1), @@ -145,7 +163,7 @@ const OPERATIONS = [ { name: 'addRelation', args: tupleArgs, - run: (model, src, rel, dst, p) => model.add(src, rel, dst, p) + run: (model, src, rel, dst, p, reli) => model.add(src, rel, dst, p, reli) }, { name: 'removeRelation', diff --git a/tests/rigor/rule-kind-partial-parity.test.js b/tests/rigor/rule-kind-partial-parity.test.js index 08c5f41..fab9bcb 100644 --- a/tests/rigor/rule-kind-partial-parity.test.js +++ b/tests/rigor/rule-kind-partial-parity.test.js @@ -582,15 +582,15 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { const w = { engine, - setEdge(rel, dst, p, side) { + setEdge(rel, dst, p, reli, side) { const src = rel === 'owner' ? 'doc:0' : 'u:0'; if (side === 'persistent') { - engine.addRelation(src, rel, dst, { possibility: p }); - persistent.set(src + '|' + rel + '|' + dst, { p }); + engine.addRelation(src, rel, dst, { possibility: p, reliability: reli }); + persistent.set(src + '|' + rel + '|' + dst, { p, r: reli }); } else { const idx = partialEdges.findIndex(e => e.relation === rel && e.dst === dst); if (idx >= 0) partialEdges.splice(idx, 1); - partialEdges.push({ src, relation: rel, dst, possibility: p }); + partialEdges.push({ src, relation: rel, dst, possibility: p, reliability: reli }); } return { ok: true }; }, @@ -609,25 +609,37 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { : {}; const r = engine.check('u:0', 'can_read', 'doc:0', options); // TTU mirror: join both legs per intermediate — max over mids of - // min(tupleset.possibility, computed.possibility). Edges come from - // persistent and/or partial; on same-tuple conflicts the overlay - // contract is persistent-wins (persistent outranks partial trust). - const tupleset = new Map(); // dst -> p - const computed = new Map(); // dst -> p + // min(tupleset.possibility, computed.possibility), with the winning + // intermediate's reliability = tupleset.reli * computed.reli. Edges + // come from persistent and/or partial; on same-tuple conflicts the + // overlay contract is persistent-wins (persistent outranks partial). + const tupleset = new Map(); // dst -> { p, r } + const computed = new Map(); // dst -> { p, r } for (const [key, v] of persistent) { const [src, rel, dst] = key.split('|'); - (rel === 'owner' ? tupleset : computed).set(dst, v.p); + (rel === 'owner' ? tupleset : computed).set(dst, v); } for (const e of partialEdges) { const merged = e.relation === 'owner' ? tupleset : computed; - if (!merged.has(e.dst)) merged.set(e.dst, e.possibility); + if (!merged.has(e.dst)) merged.set(e.dst, { p: e.possibility, r: e.reliability ?? 1.0 }); } let best = 0; + let bestReliability = 0; for (const [mid, tp] of tupleset) { const cp = computed.get(mid); - if (cp !== undefined && Math.min(tp, cp) > best) best = Math.min(tp, cp); + if (cp === undefined) continue; + const combinedP = Math.min(tp.p, cp.p); + if (combinedP > best) { + best = combinedP; + bestReliability = (tp.r ?? 1.0) * (cp.r ?? 1.0); + } } - return { engine: round4(r.possibility), expected: round4(best) }; + return { + engine: round4(r.possibility), + expected: round4(best), + engineReliability: round4(r.reliability ?? 0), + expectedReliability: round4(bestReliability) + }; }, clone() { return w; } }; @@ -636,11 +648,12 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { const result = await rigor.campaign( [rigor.object('graph', makeWrapper, [ - rigor.method('setEdge', function (rel, dst, p, side) { return this.setEdge(rel, dst, p, side); }, + rigor.method('setEdge', function (rel, dst, p, reli, side) { return this.setEdge(rel, dst, p, reli, side); }, rigor.args( rigor.gen.oneOf(['owner', 'member_of']), rigor.gen.oneOf(['g:0', 'g:1', 'g:2']), rigor.gen.float(0.1, 1.0), + rigor.gen.oneOf([0.3, 0.6, 0.9]), rigor.gen.oneOf(['persistent', 'partial']) )), rigor.method('clearAll', function () { return this.clearAll(); }), @@ -651,6 +664,11 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { if (ctx.action !== 'graph.check' || ctx.error !== null) return true; return ctx.actual.engine === ctx.actual.expected; }), + rigor.invariant('TTU reliability parity', (ctx) => { + if (ctx.action !== 'graph.check' || ctx.error !== null) return true; + if (ctx.actual.expected === 0) return true; // denied: reliability 0 + return ctx.actual.engineReliability === ctx.actual.expectedReliability; + }), rigor.invariant('no action errors', (ctx) => ctx.error === null) ]) ).run({ effort: 400, seed: "ttu-partial-split-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } }); @@ -720,6 +738,126 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { ].join('\n')); }, 90000); + it('PROPERTY CAMPAIGN: chain reliability differential under random edge splits', async () => { + function makeWrapper() { + const engine = new Arbiter(); + engine.addNode('u:0', 'user'); + engine.addNode('doc:0', 'doc'); + engine.addNode('g:0', 'group'); + engine.addNode('g:1', 'group'); + engine.setRelationConfig('can_access', { type: 'chain', steps: [ + { relation: 'member_of', direction: 'out' }, + { relation: 'reads', direction: 'out' } + ] }); + + const persistent = new Map(); + const partialEdges = []; + + const w = { + engine, + setEdge(rel, dst, p, reli, side) { + const src = rel === 'member_of' ? 'u:0' : 'g:0'; + if (rel === 'reads') dst = 'doc:0'; // the chain's second leg must point at the target + if (side === 'persistent') { + engine.addRelation(src, rel, dst, { possibility: p, reliability: reli }); + persistent.set(src + '|' + rel + '|' + dst, { p, r: reli }); + } else { + const idx = partialEdges.findIndex(e => e.relation === rel && e.dst === dst); + if (idx >= 0) partialEdges.splice(idx, 1); + partialEdges.push({ src, relation: rel, dst, possibility: p, reliability: reli }); + } + return { ok: true }; + }, + clearAll() { + for (const key of [...persistent.keys()]) { + const [src, rel, dst] = key.split('|'); + engine.removeRelation(src, rel, dst); + } + persistent.clear(); + partialEdges.length = 0; + return { ok: true }; + }, + check() { + const options = partialEdges.length > 0 + ? { partialGraph: { relations: partialEdges.slice() } } + : {}; + const r = engine.check('u:0', 'can_access', 'doc:0', options); + // Mirror: chain = max over intermediates of min(legs); the winning + // path's reliability is the product of its edges' reliabilities. + // Same-tuple conflicts: persistent wins. + const memberOf = new Map(); // dst -> { p, r } + const reads = new Map(); // src -> { p, r } (edge src ->reads-> doc:0) + for (const [key, v] of persistent) { + const [src, rel, dst] = key.split('|'); + if (rel === 'member_of') memberOf.set(dst, v); + else reads.set(src, v); + } + for (const e of partialEdges) { + if (e.relation === 'member_of') { + if (!memberOf.has(e.dst)) memberOf.set(e.dst, { p: e.possibility, r: e.reliability ?? 1.0 }); + } else { + if (!reads.has(e.src)) reads.set(e.src, { p: e.possibility, r: e.reliability ?? 1.0 }); + } + } + let best = 0; + let bestReliability = 0; + for (const [mid, m] of memberOf) { + const rd = reads.get(mid); + if (rd === undefined) continue; + const combinedP = Math.min(m.p, rd.p); + if (combinedP > best) { + best = combinedP; + bestReliability = (m.r ?? 1.0) * (rd.r ?? 1.0); + } + } + return { + engine: round4(r.possibility), + expected: round4(best), + engineReliability: round4(r.reliability ?? 0), + expectedReliability: round4(bestReliability) + }; + }, + clone() { return w; } + }; + return w; + } + + const result = await rigor.campaign( + [rigor.object('graph', makeWrapper, [ + rigor.method('setEdge', function (rel, dst, p, reli, side) { return this.setEdge(rel, dst, p, reli, side); }, + rigor.args( + rigor.gen.oneOf(['member_of', 'reads']), + rigor.gen.oneOf(['g:0', 'g:1', 'doc:0']), + rigor.gen.float(0.1, 1.0), + rigor.gen.oneOf([0.3, 0.6, 0.9]), + rigor.gen.oneOf(['persistent', 'partial']) + )), + rigor.method('clearAll', function () { return this.clearAll(); }), + rigor.method('check', function () { return this.check(); }) + ])], + rigor.crucible([ + rigor.invariant('chain decision parity', (ctx) => { + if (ctx.action !== 'graph.check' || ctx.error !== null) return true; + return ctx.actual.engine === ctx.actual.expected; + }), + rigor.invariant('chain reliability parity', (ctx) => { + if (ctx.action !== 'graph.check' || ctx.error !== null) return true; + if (ctx.actual.expected === 0) return true; // denied: reliability 0 + return ctx.actual.engineReliability === ctx.actual.expectedReliability; + }), + rigor.invariant('no action errors', (ctx) => ctx.error === null) + ]) + ).run({ effort: 400, seed: "chain-reliability-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } }); + + const inv = result.crucibleVerdict; + assert.equal(inv.passed, true, [ + `chain reliability 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))} error=${f.error}` + ) + ].join('\n')); + }, 90000); + it('PROPERTY CAMPAIGN: exclusion differential under random split edges', async () => { function makeWrapper() { const engine = new Arbiter(); diff --git a/tests/rigor/snapshot-quantization-parity.test.js b/tests/rigor/snapshot-quantization-parity.test.js index d6b9231..64a41b6 100644 --- a/tests/rigor/snapshot-quantization-parity.test.js +++ b/tests/rigor/snapshot-quantization-parity.test.js @@ -48,7 +48,12 @@ function buildGraph(edges, values) { ] }); for (let i = 0; i < edges.length; i++) { - arb.addRelation(nodeKey(edges[i][0]), edges[i][1], nodeKey(edges[i][2]), { possibility: values[i] }); + arb.addRelation(nodeKey(edges[i][0]), edges[i][1], nodeKey(edges[i][2]), { + possibility: values[i], + // deterministic per-edge reliability so the round-trip pins the + // codec's reliability channel, not just possibility + reliability: 0.1 + ((i * 7 + 3) % 9) / 10 + }); } return arb; } @@ -64,12 +69,14 @@ for (const u of [0, 1]) { function roundTripReport(edges, values) { const live = buildGraph(edges, values); const before = QUERIES.map(([rel, u, d]) => live.check(nodeKey(u), rel, nodeKey(d)).possibility); + const beforeReliability = QUERIES.map(([rel, u, d]) => live.check(nodeKey(u), rel, nodeKey(d)).reliability ?? 0); 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 after1Reliability = QUERIES.map(([rel, u, d]) => r1.check(nodeKey(u), rel, nodeKey(d)).reliability ?? 0); const r1b = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => new Arbiter()); const after1b = QUERIES.map(([rel, u, d]) => r1b.check(nodeKey(u), rel, nodeKey(d)).possibility); @@ -78,7 +85,7 @@ function roundTripReport(edges, values) { 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 }; + return { before, after1, after1b, after2, beforeReliability, after1Reliability, graphBytes: live.snapshotGraph.toBinary().byteLength }; } describe('Condensed snapshot quantization parity (rigor)', () => { @@ -106,6 +113,16 @@ describe('Condensed snapshot quantization parity (rigor)', () => { } assert.deepEqual(r.after1, r.after1b, 'restoring the same buffer is deterministic'); assert.deepEqual(r.after1, r.after2, 'snapshot-of-snapshot preserves values'); + // Reliability passes through the same 16-bit quantization; chain + // reliabilities multiply two quantized inputs, so the band is 4x the + // single-value tolerance (still far tighter than any codec loss). + const reliabilityTol = TOL * 4; + for (let i = 0; i < r.beforeReliability.length; i++) { + assert.ok( + Math.abs(r.after1Reliability[i] - r.beforeReliability[i]) <= reliabilityTol, + `query ${i}: live reliability=${r.beforeReliability[i]} restored=${r.after1Reliability[i]} exceeds tolerance ${reliabilityTol}` + ); + } const g1 = buildGraph(edges, values); g1.enableCondensedSnapshot();