From fb258035f9eb97d19aeb36320e60f87bf24dcb3b Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Sun, 2 Aug 2026 08:14:39 -0700 Subject: [PATCH] js-rigor: OWA fusion hardened; reliabilityWeighting, shorthand children, cache key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probe sweep of the OWA surfaces found three real defects: - reliabilityWeighting was a silent no-op everywhere: every implementation scaled possibilities by metas[i].reliability, but no child meta ever carried a reliability field (the compiled direct omitted it and the DirectRule handler omitted it too), so the weighting was always x1.0. All weighting branches now use the tracked child reliabilities, and the DirectRule handler + its meta now carry the relation's reliability. - The compiled union and the direct_list fast path had no reliabilityWeighting branch at all; both now apply it. - Shorthand children ({ relation: 'editor' }) dispatch to the direct handler but carry no type, so _getRuleResultCacheKey derived the generic 'rule' suffix for every shorthand child of a logical rule — the first child's cached result was served for all of them (the fallback path returned the owner's 0.8 for the editor). The key derivation now matches the shorthand dispatch. The RuleEvaluator also treats shorthand operands as direct rules instead of unknown_rule_type on the non-compiled path. New pins: an OWA differential property (custom weights, max/min/average aggregators, reliabilityWeighting, compiled path) and a multi_hop pathAggregation=owa fixed pin with reliability propagation. --- src/authorization/CompiledEvaluator.js | 21 +++- src/authorization/RuleEvaluator.js | 13 ++- src/authorization/rules/DirectRule.js | 2 + src/authorization/rules/LogicalOperators.js | 11 +- tests/rigor/rule-kind-partial-parity.test.js | 113 +++++++++++++++++++ 5 files changed, 151 insertions(+), 9 deletions(-) diff --git a/src/authorization/CompiledEvaluator.js b/src/authorization/CompiledEvaluator.js index 88532c5..02cedaf 100644 --- a/src/authorization/CompiledEvaluator.js +++ b/src/authorization/CompiledEvaluator.js @@ -455,7 +455,10 @@ export class CompiledEvaluator { ? compiled._precomputedWeights : getOWAWeightsFromRule(compiled, possibilities.length, metas); let result; - if (unionOWAWeights.some(w => w > 0)) { + if (compiled.reliabilityWeighting) { + const weighted = possibilities.map((poss, i) => poss * (reliabilities[i] ?? 1.0)); + result = OWAFusion.fuseWithMeta(weighted, metas, unionOWAWeights, compiled.aggregator || 'max', true, owaTraceOptions); + } else if (unionOWAWeights.some(w => w > 0)) { result = OWAFusion.fuseWithMeta(possibilities, metas, unionOWAWeights, compiled.aggregator || 'max', true, owaTraceOptions); } else { result = OWAFusion.fuseWithMeta(possibilities, metas, null, 'max', true, owaTraceOptions); @@ -565,7 +568,7 @@ export class CompiledEvaluator { let result; if (compiled.reliabilityWeighting) { - const reliabilityWeightedPossibilities = possibilities.map((poss, i) => poss * (metas[i]?.reliability || 1.0)); + const reliabilityWeightedPossibilities = possibilities.map((poss, i) => poss * (reliabilities[i] ?? 1.0)); result = OWAFusion.fuseWithMeta(reliabilityWeightedPossibilities, metas, finalWeights, defaultMode, true, owaTraceOptions); } else { result = OWAFusion.fuseWithMeta(possibilities, metas, finalWeights, defaultMode, true, owaTraceOptions); @@ -644,10 +647,17 @@ export class CompiledEvaluator { if (compiled.aggregator || compiled.owaWeights) { const possibilities = [a.possibility, 1 - b.possibility]; const metas = [a.meta, { exclusion_complement: b.meta }]; + const reliabilityWeights = [ + a.reliability !== undefined ? a.reliability : 1.0, + b.reliability !== undefined ? b.reliability : 1.0 + ]; const exclusionOWAWeights = compiled._precomputedWeights && compiled._precomputedWeights.length === 2 ? compiled._precomputedWeights : getOWAWeightsFromRule(compiled, 2, metas); - result = OWAFusion.fuseWithMeta(possibilities, metas, exclusionOWAWeights, compiled.aggregator || 'min', true, owaTraceOptions); + const fusedInputs = compiled.reliabilityWeighting + ? possibilities.map((poss, i) => poss * (reliabilityWeights[i] ?? 1.0)) + : possibilities; + result = OWAFusion.fuseWithMeta(fusedInputs, metas, exclusionOWAWeights, compiled.aggregator || 'min', true, owaTraceOptions); possibility = result.value; } else { possibility = a.possibility * (1 - b.possibility); @@ -782,7 +792,10 @@ export class CompiledEvaluator { ? compiled._precomputedWeights : getOWAWeightsFromRule(compiled, possibilities.length, metas); const aggregator = op === 'intersection' ? (compiled.aggregator || 'min') : (compiled.aggregator || 'max'); - const result = OWAFusion.fuseWithMeta(possibilities, metas, weights, aggregator, true, owaTraceOptions); + const fusedInputs = compiled.reliabilityWeighting + ? possibilities.map((poss, i) => poss * (reliabilities[i] ?? 1.0)) + : possibilities; + const result = OWAFusion.fuseWithMeta(fusedInputs, metas, weights, aggregator, true, owaTraceOptions); let listReliability = 1.0; if (includeOwaTrace && result.trace && typeof result.trace.selectedIndex === 'number') { diff --git a/src/authorization/RuleEvaluator.js b/src/authorization/RuleEvaluator.js index f93a210..5a66dc9 100644 --- a/src/authorization/RuleEvaluator.js +++ b/src/authorization/RuleEvaluator.js @@ -95,7 +95,10 @@ export class RuleEvaluator { } // For other rule types, evaluate normally first - const handler = this.ruleHandlers[rule.type]; + // Shorthand operand objects ({ relation: 'owner' } inside logical rules, + // or caller-supplied raw configs) carry no type: treat them as direct + // rules instead of failing with unknown_rule_type. + const handler = this.ruleHandlers[rule.type || (rule.relation || rule.rel || rule.label || rule.name ? 'direct' : null)]; if (!handler) { return { possibility_allow: 0, @@ -123,8 +126,12 @@ export class RuleEvaluator { if (rule) { if (rule.union || rule.intersection || rule.exclusion) { suffix = 'logical'; - } else if (rule.type === 'direct') { - suffix = `direct:${rule.relation || 'unknown'}`; + } else if (rule.type === 'direct' || (!rule.type && (rule.relation || rule.rel || rule.label || rule.name) && !rule.union && !rule.intersection && !rule.exclusion)) { + // Shorthand operands ({ relation: 'editor' }) dispatch to the direct + // handler but carry no type; without this, every shorthand child of + // a logical rule shares one cache key and the first child's result + // is served for all of them. + suffix = `direct:${rule.relation || rule.rel || rule.label || rule.name || 'unknown'}`; } else if (rule.type === 'tuple_to_userset') { suffix = `tupleset:${rule.tuplesetRelation || 'unknown'}:${rule.computedRelation || 'unknown'}`; } else if (rule.type === 'chain' && Array.isArray(rule.steps)) { diff --git a/src/authorization/rules/DirectRule.js b/src/authorization/rules/DirectRule.js index 6a2ca05..7d9f29b 100644 --- a/src/authorization/rules/DirectRule.js +++ b/src/authorization/rules/DirectRule.js @@ -70,6 +70,7 @@ export class DirectRule extends BaseRule { const authResult = { possibility: relationStrength, + reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0, possibility_allow: relationStrength, // For binary mode possibility_deny: 0, // DirectRule doesn't deny ...(includeMeta && { @@ -80,6 +81,7 @@ export class DirectRule extends BaseRule { relation: relName, reverse: reverse || false, strength: relationStrength, + reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0, source: _source, allow: _allowMeta }, diff --git a/src/authorization/rules/LogicalOperators.js b/src/authorization/rules/LogicalOperators.js index 5139707..036810f 100644 --- a/src/authorization/rules/LogicalOperators.js +++ b/src/authorization/rules/LogicalOperators.js @@ -984,6 +984,9 @@ export class LogicalOperators extends BaseRule { inputCount: possibilities.length, bilatticeAnalysis: epistemicAnalysis }); + } else if (unionConfig.reliabilityWeighting) { + const weighted = possibilities.map((poss, i) => poss * (reliabilities[i] ?? 1.0)); + result = OWAFusion.fuseWithMeta(weighted, metas, unionOWAWeights, unionConfig.aggregator || 'max', true, owaTraceOptions); } else if (unionOWAWeights.some(w => w > 0)) { Arbiter.DEBUG && Arbiter.log('union using OWA evidential fusion', { aggregator: unionConfig.aggregator || 'max', @@ -1148,7 +1151,7 @@ export class LogicalOperators extends BaseRule { let result; if (intersectionConfig.reliabilityWeighting) { - const reliabilityWeightedPossibilities = possibilities.map((poss, i) => poss * (metas[i]?.reliability || 1.0)); + const reliabilityWeightedPossibilities = possibilities.map((poss, i) => poss * (reliabilities[i] ?? 1.0)); result = OWAFusion.fuseWithMeta(reliabilityWeightedPossibilities, metas, finalWeights, defaultMode, true, owaTraceOptions); } else { result = OWAFusion.fuseWithMeta(possibilities, metas, finalWeights, defaultMode, true, owaTraceOptions); @@ -1279,7 +1282,11 @@ export class LogicalOperators extends BaseRule { }); if (exclusionConfig.reliabilityWeighting) { - const reliabilityWeightedPossibilities = possibilities.map((poss, i) => poss * (metas[i]?.reliability || 1.0)); + const legReliabilities = [ + a.reliability !== undefined ? a.reliability : 1.0, + b.reliability !== undefined ? b.reliability : 1.0 + ]; + const reliabilityWeightedPossibilities = possibilities.map((poss, i) => poss * (legReliabilities[i] ?? 1.0)); result = OWAFusion.fuseWithMeta(reliabilityWeightedPossibilities, metas, exclusionOWAWeights, exclusionConfig.aggregator || 'min', true, owaTraceOptions); } else { result = OWAFusion.fuseWithMeta(possibilities, metas, exclusionOWAWeights, exclusionConfig.aggregator || 'min', true, owaTraceOptions); diff --git a/tests/rigor/rule-kind-partial-parity.test.js b/tests/rigor/rule-kind-partial-parity.test.js index 5fb4f89..ead2967 100644 --- a/tests/rigor/rule-kind-partial-parity.test.js +++ b/tests/rigor/rule-kind-partial-parity.test.js @@ -961,6 +961,119 @@ describe('Rule-kind × partial-graph parity (rigor)', () => { ].join('\n')); }, 90000); + it('PROPERTY CAMPAIGN: OWA fusion differential (weights, aggregators, reliabilityWeighting)', async () => { + function makeWrapper(useCompiled) { + const engine = new Arbiter(); + engine.addNode('u:0', 'user'); + engine.addNode('doc:0', 'doc'); + engine.setRelationConfig('can_access', { union: { rules: [ + { relation: 'owner' }, { relation: 'editor' }, { relation: 'verified' } + ] } }); + + const persistent = new Map(); + + const w = { + engine, + setConfig(aggregator, weights, weighting) { + const cfg = { union: { rules: [{ relation: 'owner' }, { relation: 'editor' }, { relation: 'verified' }] } }; + if (aggregator) cfg.union.aggregator = aggregator; + if (weights) cfg.union.owaWeights = weights; + if (weighting) cfg.union.reliabilityWeighting = true; + engine.setRelationConfig('can_access', cfg); + return { ok: true }; + }, + setEdge(rel, p, reli) { + engine.removeRelation('u:0', rel, 'doc:0'); + engine.addRelation('u:0', rel, 'doc:0', { possibility: p, reliability: reli }); + persistent.set(rel, { p, r: reli }); + return { ok: true }; + }, + check() { + const r = engine.check('u:0', 'can_access', 'doc:0', { useCompiled }); + // Mirror: OWA = sum(weight_i * sorted_desc(value_i)); with + // reliabilityWeighting the values are pre-scaled by reliability. + const values = []; + for (const rel of ['owner', 'editor', 'verified']) { + const e = persistent.get(rel); + values.push({ v: e ? e.p : 0, r: e ? e.r : 1.0 }); + } + const engineCfg = engine.relationConfigs.get('can_access').union; + const scaled = engineCfg.reliabilityWeighting + ? values.map(x => x.v * x.r) + : values.map(x => x.v); + scaled.sort((a, b) => b - a); + let weights; + if (engineCfg.owaWeights && engineCfg.owaWeights.length === scaled.length) { + weights = engineCfg.owaWeights; + } else if ((engineCfg.aggregator || 'max') === 'min') { + weights = [...Array(scaled.length - 1).fill(0), 1]; + } else if ((engineCfg.aggregator || 'max') === 'average' || (engineCfg.aggregator || 'max') === 'mean') { + weights = Array(scaled.length).fill(1 / scaled.length); + } else { + weights = [1, ...Array(scaled.length - 1).fill(0)]; + } + const expected = round4(scaled.reduce((s, v, i) => s + (weights[i] ?? 0) * v, 0)); + return { engine: round4(r.possibility), expected }; + }, + clone() { return w; } + }; + return w; + } + + const weightSets = [[0.7, 0.3, 0], [0.2, 0.3, 0.5], [1, 0, 0], [0, 0, 1]]; + const result = await rigor.campaign( + [rigor.object('graph', makeWrapper.bind(null, true), [ + rigor.method('setConfig', function (agg, wIdx, weighting) { return this.setConfig(agg, wIdx !== null ? weightSets[wIdx] : null, weighting); }, + rigor.args( + rigor.gen.oneOf([null, 'max', 'min', 'average']), + rigor.gen.oneOf([null, 0, 1, 2, 3]), + rigor.gen.oneOf([false, true]) + )), + rigor.method('setEdge', function (rel, p, reli) { return this.setEdge(rel, p, reli); }, + rigor.args( + rigor.gen.oneOf(['owner', 'editor', 'verified']), + rigor.gen.float(0.0, 1.0), + rigor.gen.oneOf([0.3, 0.6, 0.9]) + )), + rigor.method('check', function () { return this.check(); }) + ])], + rigor.crucible([ + rigor.invariant('OWA value parity', (ctx) => { + if (ctx.action !== 'graph.check' || ctx.error !== null) return true; + return ctx.actual.engine === ctx.actual.expected; + }), + rigor.invariant('no action errors', (ctx) => ctx.error === null) + ]) + ).run({ effort: 400, seed: "owa-fusion-2026", maxTraceLength: 25, artifacts: { dir: "", persist: "never" } }); + + const inv = result.crucibleVerdict; + assert.equal(inv.passed, true, [ + `OWA 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('FIXED: multi_hop pathAggregation owa with custom weights', () => { + const a = new Arbiter(); + a.addNode('u:0', 'user'); + a.addNode('g:0', 'group'); a.addNode('g:1', 'group'); a.addNode('g:2', 'group'); + a.setRelationConfig('can_access', { type: 'multi_hop', relation: 'member_of', maxDepth: 3, pathAggregation: 'owa', owaWeights: [0.7, 0.3] }); + a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 }); + a.addRelation('g:0', 'member_of', 'g:2', { possibility: 0.9 }); + a.addRelation('u:0', 'member_of', 'g:1', { possibility: 0.5 }); + a.addRelation('g:1', 'member_of', 'g:2', { possibility: 0.6 }); + const p = a.check('u:0', 'can_access', 'g:2'); + // paths 0.8 and 0.5 -> sorted [0.8, 0.5] -> 0.7*0.8 + 0.3*0.5 + assert.equal(round4(p.possibility), 0.71, `multi_hop owa fusion, got ${p.possibility}`); + // reliability flows from the winning path + a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8, reliability: 0.9 }); + a.addRelation('g:0', 'member_of', 'g:2', { possibility: 0.9, reliability: 0.8 }); + const p2 = a.check('u:0', 'can_access', 'g:2'); + assert.ok(Math.abs(p2.reliability - 0.72) < 0.01, `multi_hop owa reliability, got ${p2.reliability}`); + }); + it('PROPERTY CAMPAIGN: chain reliability differential under random edge splits', async () => { function makeWrapper() { const engine = new Arbiter();