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();