js-rigor: OWA fusion hardened; reliabilityWeighting, shorthand children, cache key

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.
This commit is contained in:
John Dvorak
2026-08-02 08:14:39 -07:00
parent 7c465b64f4
commit fb258035f9
5 changed files with 151 additions and 9 deletions
+17 -4
View File
@@ -455,7 +455,10 @@ export class CompiledEvaluator {
? compiled._precomputedWeights ? compiled._precomputedWeights
: getOWAWeightsFromRule(compiled, possibilities.length, metas); : getOWAWeightsFromRule(compiled, possibilities.length, metas);
let result; 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); result = OWAFusion.fuseWithMeta(possibilities, metas, unionOWAWeights, compiled.aggregator || 'max', true, owaTraceOptions);
} else { } else {
result = OWAFusion.fuseWithMeta(possibilities, metas, null, 'max', true, owaTraceOptions); result = OWAFusion.fuseWithMeta(possibilities, metas, null, 'max', true, owaTraceOptions);
@@ -565,7 +568,7 @@ export class CompiledEvaluator {
let result; let result;
if (compiled.reliabilityWeighting) { 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); result = OWAFusion.fuseWithMeta(reliabilityWeightedPossibilities, metas, finalWeights, defaultMode, true, owaTraceOptions);
} else { } else {
result = OWAFusion.fuseWithMeta(possibilities, metas, finalWeights, defaultMode, true, owaTraceOptions); result = OWAFusion.fuseWithMeta(possibilities, metas, finalWeights, defaultMode, true, owaTraceOptions);
@@ -644,10 +647,17 @@ export class CompiledEvaluator {
if (compiled.aggregator || compiled.owaWeights) { if (compiled.aggregator || compiled.owaWeights) {
const possibilities = [a.possibility, 1 - b.possibility]; const possibilities = [a.possibility, 1 - b.possibility];
const metas = [a.meta, { exclusion_complement: b.meta }]; 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 const exclusionOWAWeights = compiled._precomputedWeights && compiled._precomputedWeights.length === 2
? compiled._precomputedWeights ? compiled._precomputedWeights
: getOWAWeightsFromRule(compiled, 2, metas); : 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; possibility = result.value;
} else { } else {
possibility = a.possibility * (1 - b.possibility); possibility = a.possibility * (1 - b.possibility);
@@ -782,7 +792,10 @@ export class CompiledEvaluator {
? compiled._precomputedWeights ? compiled._precomputedWeights
: getOWAWeightsFromRule(compiled, possibilities.length, metas); : getOWAWeightsFromRule(compiled, possibilities.length, metas);
const aggregator = op === 'intersection' ? (compiled.aggregator || 'min') : (compiled.aggregator || 'max'); 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; let listReliability = 1.0;
if (includeOwaTrace && result.trace && typeof result.trace.selectedIndex === 'number') { if (includeOwaTrace && result.trace && typeof result.trace.selectedIndex === 'number') {
+10 -3
View File
@@ -95,7 +95,10 @@ export class RuleEvaluator {
} }
// For other rule types, evaluate normally first // 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) { if (!handler) {
return { return {
possibility_allow: 0, possibility_allow: 0,
@@ -123,8 +126,12 @@ export class RuleEvaluator {
if (rule) { if (rule) {
if (rule.union || rule.intersection || rule.exclusion) { if (rule.union || rule.intersection || rule.exclusion) {
suffix = 'logical'; suffix = 'logical';
} else if (rule.type === 'direct') { } else if (rule.type === 'direct' || (!rule.type && (rule.relation || rule.rel || rule.label || rule.name) && !rule.union && !rule.intersection && !rule.exclusion)) {
suffix = `direct:${rule.relation || 'unknown'}`; // 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') { } else if (rule.type === 'tuple_to_userset') {
suffix = `tupleset:${rule.tuplesetRelation || 'unknown'}:${rule.computedRelation || 'unknown'}`; suffix = `tupleset:${rule.tuplesetRelation || 'unknown'}:${rule.computedRelation || 'unknown'}`;
} else if (rule.type === 'chain' && Array.isArray(rule.steps)) { } else if (rule.type === 'chain' && Array.isArray(rule.steps)) {
+2
View File
@@ -70,6 +70,7 @@ export class DirectRule extends BaseRule {
const authResult = { const authResult = {
possibility: relationStrength, possibility: relationStrength,
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
possibility_allow: relationStrength, // For binary mode possibility_allow: relationStrength, // For binary mode
possibility_deny: 0, // DirectRule doesn't deny possibility_deny: 0, // DirectRule doesn't deny
...(includeMeta && { ...(includeMeta && {
@@ -80,6 +81,7 @@ export class DirectRule extends BaseRule {
relation: relName, relation: relName,
reverse: reverse || false, reverse: reverse || false,
strength: relationStrength, strength: relationStrength,
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
source: _source, source: _source,
allow: _allowMeta allow: _allowMeta
}, },
+9 -2
View File
@@ -984,6 +984,9 @@ export class LogicalOperators extends BaseRule {
inputCount: possibilities.length, inputCount: possibilities.length,
bilatticeAnalysis: epistemicAnalysis 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)) { } else if (unionOWAWeights.some(w => w > 0)) {
Arbiter.DEBUG && Arbiter.log('union using OWA evidential fusion', { Arbiter.DEBUG && Arbiter.log('union using OWA evidential fusion', {
aggregator: unionConfig.aggregator || 'max', aggregator: unionConfig.aggregator || 'max',
@@ -1148,7 +1151,7 @@ export class LogicalOperators extends BaseRule {
let result; let result;
if (intersectionConfig.reliabilityWeighting) { 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); result = OWAFusion.fuseWithMeta(reliabilityWeightedPossibilities, metas, finalWeights, defaultMode, true, owaTraceOptions);
} else { } else {
result = OWAFusion.fuseWithMeta(possibilities, metas, finalWeights, defaultMode, true, owaTraceOptions); result = OWAFusion.fuseWithMeta(possibilities, metas, finalWeights, defaultMode, true, owaTraceOptions);
@@ -1279,7 +1282,11 @@ export class LogicalOperators extends BaseRule {
}); });
if (exclusionConfig.reliabilityWeighting) { 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); result = OWAFusion.fuseWithMeta(reliabilityWeightedPossibilities, metas, exclusionOWAWeights, exclusionConfig.aggregator || 'min', true, owaTraceOptions);
} else { } else {
result = OWAFusion.fuseWithMeta(possibilities, metas, exclusionOWAWeights, exclusionConfig.aggregator || 'min', true, owaTraceOptions); result = OWAFusion.fuseWithMeta(possibilities, metas, exclusionOWAWeights, exclusionConfig.aggregator || 'min', true, owaTraceOptions);
@@ -961,6 +961,119 @@ describe('Rule-kind × partial-graph parity (rigor)', () => {
].join('\n')); ].join('\n'));
}, 90000); }, 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 () => { it('PROPERTY CAMPAIGN: chain reliability differential under random edge splits', async () => {
function makeWrapper() { function makeWrapper() {
const engine = new Arbiter(); const engine = new Arbiter();