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:
@@ -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') {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user