From dab9671d2049d073786c63fa0aa4f9ab6f419ec3 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Sat, 1 Aug 2026 22:34:00 -0700 Subject: [PATCH] js-rigor: value-collection crucibles; TTU 0-strength paths, crash, fusion reliability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new value-collection crucibles in the TTU and chain differential campaigns immediately found three engine defects: - TTU join pushed 0-strength 'matches' (missing computed leg, or 0-possibility edges, with minPossibility 0) as valid paths: denied decisions reported tuple_to_userset_found and leaked the tupleset edge's value into collectedValues. Both join modes now require combined > 0. - A ReferenceError (bare resolveKey) crashed the computed-join mode under collectValues, silently turning the whole check into an evaluation_error denial. Fixed the call to this.arbiter.resolveKey. - Multi-path TTU fusion fell back to Math.max over all path reliabilities, pairing the winning possibility with another intermediate's reliability. The fallback now picks the max-possibility path's reliability. New campaigns: defeasible and intersection differential properties (when/unless and min-children with reliability parity under persistent/ partial splits). The model-based campaign keeps its reliability crucible; its value comparison was reverted — the harness's shrink reporting is opaque and unreconstructable there, and the value semantics are covered by the TTU/chain campaigns instead. --- src/authorization/rules/TupleToUsersetRule.js | 26 +- tests/rigor/model-based-graph.test.js | 31 ++- tests/rigor/rule-kind-partial-parity.test.js | 245 +++++++++++++++++- 3 files changed, 274 insertions(+), 28 deletions(-) diff --git a/src/authorization/rules/TupleToUsersetRule.js b/src/authorization/rules/TupleToUsersetRule.js index 253f383..e19a52b 100644 --- a/src/authorization/rules/TupleToUsersetRule.js +++ b/src/authorization/rules/TupleToUsersetRule.js @@ -264,7 +264,11 @@ export class TupleToUsersetRule extends BaseRule { const combinedPossibility = Math.min(resolvePossibility(tupleEdge.possibility), res.possibility); const combinedReliability = resolveReliability(tupleEdge.reliability) * (res.reliability !== undefined ? res.reliability : 1.0); - if (combinedPossibility >= minPossibility) { + // A 0-strength "match" (e.g. the computed leg missing with + // minPossibility 0) is not a valid path: it would report + // tuple_to_userset_found and leak the tupleset value into the + // collected values on a denied decision. + if (combinedPossibility > 0 && combinedPossibility >= minPossibility) { const path = { possibility: combinedPossibility, reliability: combinedReliability, @@ -279,7 +283,7 @@ export class TupleToUsersetRule extends BaseRule { computedRelation: { relation: rule.computedRelation, possibility: res.possibility, reliability: res.reliability, meta: res.meta } } }), - ...(!useLightweightPaths && collectValues && { collectedValue: buildCollectedValue(tupleEdge, resolveKey(tupleEdge.src, options), intermediateKey) }) + ...(!useLightweightPaths && collectValues && { collectedValue: buildCollectedValue(tupleEdge, this.arbiter.resolveKey(tupleEdge.src, options), intermediateKey) }) }; allValidPaths.push(path); if (!bestPath || combinedPossibility > bestPath.possibility) { @@ -374,7 +378,9 @@ export class TupleToUsersetRule extends BaseRule { const combinedPossibility = Math.min(resolvePossibility(t.possibility), res.possibility); const combinedReliability = resolveReliability(t.reliability) * (res.reliability !== undefined ? res.reliability : 1.0); - if (combinedPossibility >= minPossibility) { + // 0-strength tuples (missing computed leg, or 0-possibility edges) + // must not count as found paths (see computed join mode). + if (combinedPossibility > 0 && combinedPossibility >= minPossibility) { const pathData = { possibility: combinedPossibility, reliability: combinedReliability, @@ -512,11 +518,15 @@ export class TupleToUsersetRule extends BaseRule { const winningPath = validPathData.find(p => p.meta.intermediateKey === fusionResult.meta.intermediateKey && p.meta.pathType === fusionResult.meta.pathType); if (winningPath) { fusedReliability = winningPath.reliability; - } else if (reliabilities.length > 0) { - fusedReliability = Math.max(...reliabilities); // Use max reliability for performance - } - } else if (reliabilities.length > 0) { - fusedReliability = Math.max(...reliabilities); + } + } + if (fusedReliability === 1.0 && reliabilities.length > 0) { + // Fallback: the fusion aggregator is max, so the winner is the + // max-possibility path — its reliability, not the max reliability + // (which would pair a possibility from one intermediate with a + // reliability from another). + const maxIndex = possibilities.indexOf(Math.max(...possibilities)); + fusedReliability = maxIndex >= 0 ? (reliabilities[maxIndex] ?? 1.0) : 1.0; } if (evaluationMeta) { diff --git a/tests/rigor/model-based-graph.test.js b/tests/rigor/model-based-graph.test.js index 5dff56f..a21d8b3 100644 --- a/tests/rigor/model-based-graph.test.js +++ b/tests/rigor/model-based-graph.test.js @@ -51,8 +51,13 @@ function makeModel() { copy.check = this.check; return copy; }, - add(src, rel, dst, p, reli) { - this.tuples.set(this.key(src, rel, dst), { p, r: reli ?? 1.0 }); + add(src, rel, dst, p, reli, value) { + const key = this.key(src, rel, dst); + const existing = this.tuples.get(key); + // add-on-existing is a modify: an absent new value preserves the old + // one (mirror the engine's value preservation). + const v = value !== undefined && value !== null ? value : (existing ? existing.v : undefined); + this.tuples.set(key, { p, r: reli ?? 1.0, v }); return { ok: true }; }, remove(src, rel, dst) { @@ -87,6 +92,10 @@ function makeModel() { } } } + // Value parity is exercised by the TTU/chain differential campaigns; + // the model-based harness's opaque shrink reporting makes value + // divergence unreconstructable here, so the model compares + // possibility + reliability only. return { possibility: Math.round(possibility * 10000) / 10000, reliability: Math.round(reliability * 10000) / 10000 @@ -113,9 +122,11 @@ function makeSut() { return { ops, - 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]); + addRelation(src, rel, dst, p, reli, value) { + const opts = { possibility: p, reliability: reli }; + if (value !== null && value !== undefined) opts.value = value; + arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), opts); + ops.push(['add', src, rel, dst, p, reli, value]); return { ok: true }; }, removeRelation(src, rel, dst) { @@ -133,7 +144,7 @@ function makeSut() { clone() { const fresh = makeSut(); for (const op of ops) { - if (op[0] === 'add') fresh.addRelation(op[1], op[2], op[3], op[4], op[5]); + if (op[0] === 'add') fresh.addRelation(op[1], op[2], op[3], op[4], op[5], op[6]); else fresh.removeRelation(op[1], op[2], op[3]); } return fresh; @@ -146,7 +157,8 @@ const tupleArgs = rigor.gen.tuple( rigor.gen.enum(['owner', 'member_of', 'reads']), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf(POS), - rigor.gen.oneOf([0.3, 0.6, 0.9]) + rigor.gen.oneOf([0.3, 0.6, 0.9]), + rigor.gen.oneOf([null, 5, 42]) // values ride owner/reads edges only ); const removeArgs = rigor.gen.tuple( rigor.gen.int(0, NODES - 1), @@ -163,7 +175,7 @@ const OPERATIONS = [ { name: 'addRelation', args: tupleArgs, - run: (model, src, rel, dst, p, reli) => model.add(src, rel, dst, p, reli) + run: (model, src, rel, dst, p, reli, value) => model.add(src, rel, dst, p, reli, value) }, { name: 'removeRelation', @@ -194,7 +206,8 @@ describe('Model-based authorization graph (rigor.model.check)', () => { assert.equal(result.passed, true, [ `engine diverged from model in ${result.failures.length} sequences:`, ...result.failures.slice(0, 3).map((f) => - ` [${f.commandIndex}] ${f.sequence.map((c) => `${c.name}(${JSON.stringify(c.args)})`).join(' → ')}\n` + + ` [${f.commandIndex}] ${f.sequence.map((c) => `${c.name}(${JSON.stringify(c.args)})`).join(' -> ')} +` + ` expected=${JSON.stringify(f.expected)} actual=${JSON.stringify(f.actual)}` ) ].join('\n')); diff --git a/tests/rigor/rule-kind-partial-parity.test.js b/tests/rigor/rule-kind-partial-parity.test.js index fab9bcb..5fb4f89 100644 --- a/tests/rigor/rule-kind-partial-parity.test.js +++ b/tests/rigor/rule-kind-partial-parity.test.js @@ -582,15 +582,21 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { const w = { engine, - setEdge(rel, dst, p, reli, side) { + setEdge(rel, dst, p, reli, value, side) { const src = rel === 'owner' ? 'doc:0' : 'u:0'; + const opts = { possibility: p, reliability: reli }; + if (rel === 'owner' && value !== null) opts.value = value; // values ride the tupleset leg if (side === 'persistent') { - engine.addRelation(src, rel, dst, { possibility: p, reliability: reli }); - persistent.set(src + '|' + rel + '|' + dst, { p, r: reli }); + engine.addRelation(src, rel, dst, opts); + // add-on-existing is a modify: an absent new value preserves the + // stored one (mirror the engine). + const key = src + '|' + rel + '|' + dst; + const existing = persistent.get(key); + persistent.set(key, { p, r: reli, v: opts.value !== undefined ? opts.value : (existing ? existing.v : undefined) }); } 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 }); + partialEdges.push({ src, relation: rel, dst, possibility: p, reliability: reli, value: opts.value }); } return { ok: true }; }, @@ -607,38 +613,55 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { const options = partialEdges.length > 0 ? { partialGraph: { relations: partialEdges.slice() } } : {}; - const r = engine.check('u:0', 'can_read', 'doc:0', options); + const r = engine.check('u:0', 'can_read', 'doc:0', { ...options, collectValues: true }); // TTU mirror: join both legs per intermediate — max over mids of // 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 tupleset = new Map(); // dst -> { p, r, v } 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); + if (rel === 'owner') tupleset.set(dst, v); + else 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, { p: e.possibility, r: e.reliability ?? 1.0 }); + if (e.relation === 'owner') { + if (!tupleset.has(e.dst)) tupleset.set(e.dst, { p: e.possibility, r: e.reliability ?? 1.0, v: e.value }); + } else if (!computed.has(e.dst)) { + computed.set(e.dst, { p: e.possibility, r: e.reliability ?? 1.0 }); + } } let best = 0; let bestReliability = 0; + const expectedValues = []; for (const [mid, tp] of tupleset) { const cp = computed.get(mid); if (cp === undefined) continue; const combinedP = Math.min(tp.p, cp.p); + if (combinedP > 0) { + if (tp.v !== undefined) expectedValues.push(round4(tp.v)); + } if (combinedP > best) { best = combinedP; bestReliability = (tp.r ?? 1.0) * (cp.r ?? 1.0); } } + expectedValues.sort((a, b) => a - b); + const engineValues = (r.collectedValues || []) + .map(v => typeof v === 'number' ? round4(v) : (typeof v.value === 'number' ? round4(v.value) : null)) + .filter(v => v !== null) + .sort((a, b) => a - b); return { engine: round4(r.possibility), expected: round4(best), engineReliability: round4(r.reliability ?? 0), - expectedReliability: round4(bestReliability) + expectedReliability: round4(bestReliability), + engineValues, + expectedValues, + persistent: [...persistent.keys()], + partial: partialEdges.map(e => e.relation + ':' + e.dst) }; }, clone() { return w; } @@ -648,12 +671,13 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { 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.method('setEdge', function (rel, dst, p, reli, value, side) { return this.setEdge(rel, dst, p, reli, value, 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([null, 5, 42]), rigor.gen.oneOf(['persistent', 'partial']) )), rigor.method('clearAll', function () { return this.clearAll(); }), @@ -669,6 +693,10 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { if (ctx.actual.expected === 0) return true; // denied: reliability 0 return ctx.actual.engineReliability === ctx.actual.expectedReliability; }), + rigor.invariant('TTU collected-value parity', (ctx) => { + if (ctx.action !== 'graph.check' || ctx.error !== null) return true; + return JSON.stringify(ctx.actual.engineValues) === JSON.stringify(ctx.actual.expectedValues); + }), rigor.invariant('no action errors', (ctx) => ctx.error === null) ]) ).run({ effort: 400, seed: "ttu-partial-split-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } }); @@ -738,6 +766,201 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { ].join('\n')); }, 90000); + it('PROPERTY CAMPAIGN: defeasible differential under random persistent/partial splits', async () => { + function makeWrapper() { + const engine = new Arbiter(); + engine.addNode('u:0', 'user'); + engine.addNode('doc:0', 'doc'); + engine.setRelationConfig('can_access', { type: 'defeasible', when: { relation: 'owner' }, unless: { relation: 'banned' } }); + + const persistent = new Map(); + const partialEdges = []; + + const w = { + engine, + setEdge(rel, p, reli, side) { + if (side === 'persistent') { + engine.addRelation('u:0', rel, 'doc:0', { possibility: p, reliability: reli }); + persistent.set(rel, { p, r: reli }); + } else { + const idx = partialEdges.findIndex(e => e.relation === rel); + if (idx >= 0) partialEdges.splice(idx, 1); + partialEdges.push({ src: 'u:0', relation: rel, dst: 'doc:0', possibility: p, reliability: reli }); + } + return { ok: true }; + }, + clearAll() { + for (const rel of [...persistent.keys()]) engine.removeRelation('u:0', rel, 'doc:0'); + 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: P(when) * (1 - P(unless)) with reliability + // when.reli * unless.reli; persistent wins on same-tuple conflicts. + const when = persistent.get('owner') ?? partialEdges.find(e => e.relation === 'owner'); + const unless = persistent.get('banned') ?? partialEdges.find(e => e.relation === 'banned'); + const whenP = when ? (when.p ?? when.possibility) : 0; + const whenR = when ? (when.r ?? when.reliability ?? 1.0) : 1.0; + const unlessP = unless ? (unless.p ?? unless.possibility) : 0; + const unlessR = unless ? (unless.r ?? unless.reliability ?? 1.0) : 1.0; + const wp = whenP; + const wpR = whenR; + const up = unlessP; + const upR = unlessR; + const expected = round4(wp * (1 - up)); + const expectedReliability = round4(wpR * upR); + return { + engine: round4(r.possibility), + expected, + engineReliability: round4(r.reliability ?? 0), + expectedReliability + }; + }, + clone() { return w; } + }; + return w; + } + + const result = await rigor.campaign( + [rigor.object('graph', makeWrapper, [ + rigor.method('setEdge', function (rel, p, reli, side) { return this.setEdge(rel, p, reli, side); }, + rigor.args( + rigor.gen.oneOf(['owner', 'banned']), + rigor.gen.float(0.0, 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('defeasible decision parity', (ctx) => { + if (ctx.action !== 'graph.check' || ctx.error !== null) return true; + return ctx.actual.engine === ctx.actual.expected; + }), + rigor.invariant('defeasible 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: "defeasible-split-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } }); + + const inv = result.crucibleVerdict; + assert.equal(inv.passed, true, [ + `defeasible 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: intersection differential under random persistent/partial splits', async () => { + function makeWrapper() { + const engine = new Arbiter(); + engine.addNode('u:0', 'user'); + engine.addNode('doc:0', 'doc'); + engine.setRelationConfig('can_access', { intersection: { rules: [{ relation: 'owner' }, { relation: 'verified' }] } }); + + const persistent = new Map(); + const partialEdges = []; + + const w = { + engine, + setEdge(rel, p, reli, side) { + if (side === 'persistent') { + engine.addRelation('u:0', rel, 'doc:0', { possibility: p, reliability: reli }); + persistent.set(rel, { p, r: reli }); + } else { + const idx = partialEdges.findIndex(e => e.relation === rel); + if (idx >= 0) partialEdges.splice(idx, 1); + partialEdges.push({ src: 'u:0', relation: rel, dst: 'doc:0', possibility: p, reliability: reli }); + } + return { ok: true }; + }, + clearAll() { + for (const rel of [...persistent.keys()]) engine.removeRelation('u:0', rel, 'doc:0'); + 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: min over children with the min child's reliability; + // persistent wins on same-tuple conflicts. + const edges = {}; + for (const rel of ['owner', 'verified']) { + const p = persistent.get(rel); + if (p) edges[rel] = p; + } + for (const e of partialEdges) { + if (!edges[e.relation]) edges[e.relation] = { p: e.possibility, r: e.reliability ?? 1.0 }; + } + const present = Object.values(edges); + let expected = 0; + let expectedReliability = 0; + if (present.length === 2) { + const minP = Math.min(present[0].p, present[1].p); + const minIdx = present[0].p <= present[1].p ? 0 : 1; + expected = round4(minP); + expectedReliability = round4(present[minIdx].r ?? 1.0); + } + return { + engine: round4(r.possibility), + expected, + engineReliability: round4(r.reliability ?? 0), + expectedReliability + }; + }, + clone() { return w; } + }; + return w; + } + + const result = await rigor.campaign( + [rigor.object('graph', makeWrapper, [ + rigor.method('setEdge', function (rel, p, reli, side) { return this.setEdge(rel, p, reli, side); }, + rigor.args( + rigor.gen.oneOf(['owner', 'verified']), + rigor.gen.float(0.0, 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('intersection decision parity', (ctx) => { + if (ctx.action !== 'graph.check' || ctx.error !== null) return true; + return ctx.actual.engine === ctx.actual.expected; + }), + rigor.invariant('intersection 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: "intersection-split-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } }); + + const inv = result.crucibleVerdict; + assert.equal(inv.passed, true, [ + `intersection 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: chain reliability differential under random edge splits', async () => { function makeWrapper() { const engine = new Arbiter();