js-rigor: remove the compiled evaluator — the runtime compiler is gone

The compiled evaluation path was never a performance win and was a
double-implementation liability: every semantics fix had to land twice
(CompiledEvaluator + LogicalOperators/handlers), and several bugs lived
only in one copy. A warm benchmark shows the compiled path at parity at
best (the apparent 7x chain regression was cold-cache confound).

Removed the runtime compiled dispatch entirely: RuleEvaluator evaluates
every rule through the single fallback path (logical operators + rule
handlers). The RuleCompiler remains as the config VALIDATOR only
(_compileErrors/_compileWarnings + _needsValues + the _compiled metadata
carried by snapshots). CompiledEvaluator.js deleted.

Fixes surfaced by removing the mask:
- The defeasible fallback wrap produced the wrong component shape
  ({rules} instead of {union:{rules}}/{intersection:{rules}}) — the
  compiled path always ran for defeasible configs, so the fallback had
  never executed; now wrapped correctly.
- Defeasible configs had no normal-dispatch routing (the compiled
  evaluator handled them); routed to evaluateDefeasible.
- The binary defeasible path forced the binary mode's internal 0.5
  threshold, while the compiled path always ran normal mode — the binary
  decision is now the thresholded normal combination (preserving the
  pinned contract).
- The fallback union/intersection/exclusion results now carry the
  validity blocks (previously only the compiled versions did).
This commit is contained in:
John Dvorak
2026-08-02 10:24:30 -07:00
parent faa6485e26
commit dcd90840d7
3 changed files with 42 additions and 927 deletions
-898
View File
@@ -1,898 +0,0 @@
import { buildValidity, normalizeValidity, DEFAULT_VALIDITY } from '../core/validity.js';
import { OWAFusion, getOWAWeightsFromRule } from '../utils/OWAFusion.js';
import { buildRemediation, extractRemediation, mergeRemediationOptions } from './remediation.js';
export class CompiledEvaluator {
constructor(arbiter, ruleEvaluator, logicalOperators) {
this.arbiter = arbiter;
this.ruleEvaluator = ruleEvaluator;
this.logicalOperators = logicalOperators;
}
evaluate(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options = {}) {
if (!compiled || typeof compiled !== 'object') {
return { possibility: 0, reason: 'missing_compiled_rule' };
}
switch (compiled.type) {
case 'direct':
return this._evaluateDirect(compiled, userId, userKey, objectId, objectKey, options, currentRelation);
case 'computed':
return this._evaluateComputed(compiled, userKey, objectKey, options, visited, currentRelation);
case 'tuple_to_userset':
return this._evaluateTupleToUserset(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
case 'chain':
return this._evaluateChain(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
case 'multi_hop':
return this._evaluateMultiHop(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
case 'logical':
return this._evaluateLogical(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
case 'defeasible':
return this._evaluateDefeasible(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
case 'relational_comparator':
return this._evaluateRelationalComparator(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
case 'challenge':
return this.ruleEvaluator.evaluateRule(
userId,
userKey,
objectId,
objectKey,
compiled,
visited,
currentRelation,
{ ...options, useCompiled: false }
);
default:
if (compiled.source) {
return this.ruleEvaluator.evaluateRule(
userId,
userKey,
objectId,
objectKey,
compiled.source,
visited,
currentRelation,
{ ...options, useCompiled: false }
);
}
return { possibility: 0, reason: 'unsupported_compiled_rule' };
}
}
_evaluateRelationalComparator(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
if (compiled.source) {
return this.ruleEvaluator.evaluateRule(
userId,
userKey,
objectId,
objectKey,
compiled.source,
visited,
currentRelation,
{ ...options, useCompiled: false }
);
}
const toOperandConfig = (operand) => {
if (!operand) return null;
return {
rule: operand.rule || null,
extractValue: operand.extractValue !== false,
valueRelation: operand.valueRelationResolved || operand.valueRelation || null,
evaluateFrom: operand.evaluateFrom || 'auto',
aggregator: operand.aggregator,
owaWeights: operand.owaWeights,
_owaSparseCount: operand._owaSparseCount,
minOperandPossibility: operand.minOperandPossibility
};
};
const fallbackConfig = {
type: 'relational_comparator',
comparator: compiled.comparator,
marginOfSafety: compiled.marginOfSafety,
fallbackBehavior: compiled.fallbackBehavior,
minRulePossibility: compiled.minRulePossibility,
epsilon: compiled.epsilon,
left: toOperandConfig(compiled.left),
right: toOperandConfig(compiled.right)
};
return this.ruleEvaluator.evaluateRule(
userId,
userKey,
objectId,
objectKey,
fallbackConfig,
visited,
currentRelation,
{ ...options, useCompiled: false }
);
}
_evaluateDirect(compiled, userId, userKey, objectId, objectKey, options, currentRelation) {
const { fastPath = false, minPossibility = null, includeMeta = true } = options;
const relName = compiled.relation || currentRelation;
const reverse = compiled.reverse;
const collectValues = options.collectValues !== undefined ? options.collectValues : compiled.collectValues !== false;
let directRel;
if (reverse) {
directRel = this.arbiter.relationManager.getDirectRelation(objectId, relName, userId, options);
} else {
directRel = this.arbiter.relationManager.getDirectRelation(userId, relName, objectId, options);
}
if (!directRel) {
return {
possibility: 0,
...(includeMeta && { meta: { ruleType: 'direct', reason: 'no_relation' } }),
reason: 'no_relation'
};
}
const relationStrength = directRel.possibility;
const _source = directRel.source || 'persistent';
const _allowMeta = {
ruleType: 'direct',
reason: 'direct',
source: _source,
layer_name: directRel.layer_name || null,
source_class: directRel.source_class || null,
reducer_applied: directRel.reducer_applied || null
};
const result = {
possibility: relationStrength,
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
validity: buildValidity('identity', [relName], [directRel.validity !== undefined ? normalizeValidity(directRel.validity) : 'heuristic'], 1, relationStrength),
possibility_allow: relationStrength,
possibility_deny: 0,
...(includeMeta && {
meta: {
ruleType: 'direct',
reason: 'relation_exists',
relation: relName,
reverse: reverse || false,
strength: relationStrength,
source: _source,
allow: _allowMeta
},
meta_allow: _allowMeta
}),
reason: 'exists'
};
if (fastPath && minPossibility !== null && result.possibility >= minPossibility) {
if (result.meta) {
result.meta.earlyExit = true;
result.meta.earlyExitReason = 'strength_threshold_met';
}
}
if (collectValues && directRel.value !== undefined) {
const sourceEntity = reverse ? objectKey : userKey;
const targetEntity = reverse ? userKey : objectKey;
const path = [sourceEntity, targetEntity];
result.collectedValues = [
this.ruleEvaluator.ruleHandlers.direct._createCollectedValue(
directRel.value,
directRel.possibility,
path,
{
entityKey: sourceEntity,
relation: relName,
step: 0
},
{
timestamp: directRel.changed_last_at || directRel.updated_last_at || Date.now(),
reliability: 1.0,
source: directRel.source || 'persistent'
}
)
];
}
return result;
}
_evaluateComputed(compiled, userKey, objectKey, options, visited, currentRelation) {
return this.arbiter.authChecker.check(userKey, compiled.relation, objectKey, {
...options,
_visited: visited,
_currentRelation: currentRelation
});
}
_evaluateTupleToUserset(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
if (compiled._optimized?.kind === 'direct_join' && !options.collectValues && !options.trackEvaluation) {
const optimized = compiled._optimized;
const resolvePossibility = (value) => value !== undefined ? value : 1.0;
const resolveReliability = (value) => value !== undefined ? value : 1.0;
const tuplesetDirection = optimized.tuplesetDirection || 'out';
let tuples;
if (optimized.reverse) {
tuples = tuplesetDirection === 'in'
? this.arbiter.relationManager.getRelationsToDst(userId, optimized.tuplesetRelation, options)
: this.arbiter.relationManager.getRelationsFromSrc(userId, optimized.tuplesetRelation, options);
} else {
tuples = tuplesetDirection === 'in'
? this.arbiter.relationManager.getRelationsToDst(objectId, optimized.tuplesetRelation, options)
: this.arbiter.relationManager.getRelationsFromSrc(objectId, optimized.tuplesetRelation, options);
}
const maxIntermediates = optimized.maxIntermediates !== undefined ? optimized.maxIntermediates : 20;
if (tuples.length > maxIntermediates * 3) {
tuples.sort((a, b) => resolvePossibility(b.possibility) - resolvePossibility(a.possibility));
tuples = tuples.slice(0, maxIntermediates);
}
const checkingId = optimized.reverse ? objectId : userId;
const computedEdges = this.arbiter.relationManager.getRelationsFromSrc(checkingId, optimized.computedRelation, options);
const computedByIntermediate = new Map(computedEdges.map(edge => [edge.dst, edge]));
let processed = 0;
let bestPossibility = 0;
let bestReliability = 1.0;
const earlyExitThreshold = optimized.earlyExitThreshold !== undefined ? optimized.earlyExitThreshold : 0.95;
const minPossibility = optimized.minPossibility !== undefined ? optimized.minPossibility : 0;
for (const t of tuples) {
if (processed >= maxIntermediates) break;
processed++;
const intermediateId = tuplesetDirection === 'in' ? t.src : t.dst;
const directRel = computedByIntermediate.get(intermediateId);
if (!directRel) continue;
const combinedPossibility = Math.min(resolvePossibility(t.possibility), resolvePossibility(directRel.possibility));
const finalPossibility = combinedPossibility >= minPossibility ? combinedPossibility : 0;
if (finalPossibility > bestPossibility) {
bestPossibility = finalPossibility;
bestReliability = resolveReliability(t.reliability) * (directRel.reliability !== undefined ? directRel.reliability : 1.0);
if (bestPossibility >= earlyExitThreshold) break;
}
}
return {
possibility: bestPossibility,
reliability: bestReliability,
reason: bestPossibility > 0 ? 'direct_join' : 'no_match'
};
}
if (compiled.source) {
return this.ruleEvaluator.evaluateRule(
userId,
userKey,
objectId,
objectKey,
compiled.source,
visited,
currentRelation,
{ ...options, useCompiled: false }
);
}
const fallbackConfig = {
type: 'tuple_to_userset',
tuplesetRelation: compiled.tuplesetRelation,
tuplesetDirection: compiled.tuplesetDirection,
computedRelation: compiled.computedRelation
};
return this.ruleEvaluator.evaluateRule(
userId,
userKey,
objectId,
objectKey,
fallbackConfig,
visited,
currentRelation,
{ ...options, useCompiled: false }
);
}
_evaluateChain(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
if (compiled._optimized?.kind === 'direct' && !options.collectValues && !options.trackEvaluation) {
const direct = {
type: 'direct',
relation: compiled._optimized.relation,
reverse: compiled._optimized.reverse,
collectValues: false
};
return this._evaluateDirect(direct, userId, userKey, objectId, objectKey, options, currentRelation);
}
if (compiled.source) {
return this.ruleEvaluator.evaluateRule(
userId,
userKey,
objectId,
objectKey,
compiled.source,
visited,
currentRelation,
{ ...options, useCompiled: false }
);
}
const fallbackConfig = {
type: 'chain',
steps: compiled.steps,
reverse: compiled.reverse,
collectValues: compiled.collectValues,
valueFilters: compiled.valueFilters,
valueAggregation: compiled.valueAggregation
};
return this.ruleEvaluator.evaluateRule(
userId,
userKey,
objectId,
objectKey,
fallbackConfig,
visited,
currentRelation,
{ ...options, useCompiled: false }
);
}
_evaluateMultiHop(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
if (compiled._optimized?.kind === 'direct' && !options.collectValues && !options.trackEvaluation) {
const direct = {
type: 'direct',
relation: compiled._optimized.relation,
reverse: compiled._optimized.reverse,
collectValues: false
};
return this._evaluateDirect(direct, userId, userKey, objectId, objectKey, options, currentRelation);
}
if (compiled.source) {
return this.ruleEvaluator.evaluateRule(
userId,
userKey,
objectId,
objectKey,
compiled.source,
visited,
currentRelation,
{ ...options, useCompiled: false }
);
}
return { possibility: 0, reason: 'unsupported_multi_hop' };
}
_evaluateLogical(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
if (compiled.source?._subjectAsObject) { objectId = userId; objectKey = userKey; }
let result;
switch (compiled.op) {
case 'union':
result = this._evaluateUnion(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
break;
case 'intersection':
result = this._evaluateIntersection(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
break;
case 'exclusion':
result = this._evaluateExclusion(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
break;
default:
result = { possibility: 0, reason: 'unknown_logical_op' };
}
if (compiled.negate) {
result = {
...result,
possibility: Math.max(0, 1 - (result.possibility || 0)),
reason: result.possibility === 0 ? result.reason : 'negated'
};
}
return result;
}
_evaluateUnion(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
const { fastPath = false, minPossibility = null, valueContext = null, collectValues = false, includeMeta = true, trackEvaluation = false } = options;
const includeOwaTrace = trackEvaluation && includeMeta;
const owaTraceOptions = includeOwaTrace ? { includeTrace: true } : null;
if (compiled.useBilattice && compiled.source) {
return this.ruleEvaluator.evaluateRule(
userId,
userKey,
objectId,
objectKey,
compiled.source,
visited,
currentRelation,
{ ...options, useCompiled: false }
);
}
if (compiled._optimized?.kind === 'direct_list') {
return this._evaluateDirectList(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options, 'union');
}
const children = compiled.children || [compiled];
const possibilities = [];
const reliabilities = [];
const metas = [];
const resChildrenValidity = [];
const remediationOptions = [];
const allCollectedValues = collectValues ? [] : null;
for (const child of children) {
const childVisited = new Set(visited);
let res = this.evaluate(child, userId, userKey, objectId, objectKey, childVisited, currentRelation, options);
if (!res || typeof res.possibility !== 'number') {
res = { possibility: 0, meta: { reason: 'missing_rule' }, collectedValues: [] };
}
if (res.validity) resChildrenValidity.push(res.validity);
mergeRemediationOptions(remediationOptions, extractRemediation(res));
possibilities.push(res.possibility);
reliabilities.push(res.reliability !== undefined ? res.reliability : 1.0);
metas.push(includeMeta ? res.meta : null);
if (collectValues && res.collectedValues && Array.isArray(res.collectedValues)) {
allCollectedValues.push(...res.collectedValues);
if (valueContext) {
valueContext.addCollectedValues(res.collectedValues, child.type, child);
}
}
if (fastPath && minPossibility !== null && res.possibility >= minPossibility) {
const remediation = buildRemediation(extractRemediation(res));
return {
possibility: res.possibility,
reliability: res.reliability !== undefined ? res.reliability : 1.0,
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && { meta: res.meta }),
...(remediation ? { remediation } : {})
};
}
}
if (!possibilities.length) {
return {
possibility: 0,
reliability: 1.0,
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && { meta: { operation: 'union', childCount: 0 } })
};
}
const unionOWAWeights = compiled._precomputedWeights && compiled._precomputedWeights.length === possibilities.length
? compiled._precomputedWeights
: getOWAWeightsFromRule(compiled, possibilities.length, metas);
let result;
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);
}
const remediation = result.value === 0
? buildRemediation(remediationOptions.length > 0 ? { status: 'required', options: remediationOptions } : null, { status: 'required' })
: null;
let unionReliability = 1.0;
if (includeOwaTrace && result.trace && typeof result.trace.selectedIndex === 'number') {
unionReliability = reliabilities[result.trace.selectedIndex] ?? 1.0;
} else if ((compiled.aggregator || 'max') === 'min') {
const minP = Math.min(...possibilities);
unionReliability = reliabilities[possibilities.indexOf(minP)] ?? 1.0;
} else {
const maxP = Math.max(...possibilities);
unionReliability = reliabilities[possibilities.indexOf(maxP)] ?? 1.0;
}
const unionOperator = (compiled.aggregator || 'max') === 'max' && (!compiled.owaWeights || (compiled.owaWeights.length <= 1 || (compiled.owaWeights[0] === 1 && compiled.owaWeights.slice(1).every(w => w === 0))))
? 'max' : 'owa';
const unionSourceLabels = (resChildrenValidity || []).map(v => v?.label).filter(Boolean);
const unionSources = [];
for (const v of resChildrenValidity || []) for (const s of v?.sources || []) if (!unionSources.includes(s)) unionSources.push(s);
return {
possibility: result.value,
reliability: unionReliability,
validity: buildValidity(unionOperator, unionSources, unionSourceLabels, possibilities.length, result.value),
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && {
meta: {
...result.meta,
operation: 'union',
childCount: possibilities.length,
aggregator: compiled.aggregator || 'max',
...(includeOwaTrace && result.trace ? {
owa: {
level: null,
aggregator: compiled.aggregator || 'max',
weights: result.trace.weights,
sortedValues: result.trace.sortedValues,
contributions: result.trace.contributions,
selectedIndex: result.trace.selectedIndex
}
} : {}),
...(remediation ? { remediation } : {})
}
}),
...(remediation ? { remediation } : {})
};
}
_evaluateIntersection(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
const { fastPath = false, minPossibility = null, valueContext = null, collectValues = false, includeMeta = true, trackEvaluation = false } = options;
const includeOwaTrace = trackEvaluation && includeMeta;
const owaTraceOptions = includeOwaTrace ? { includeTrace: true } : null;
const effectiveObjectId = compiled.source?._subjectAsObject ? userId : objectId;
const effectiveObjectKey = compiled.source?._subjectAsObject ? userKey : objectKey;
if (compiled._optimized?.kind === 'direct_list') {
return this._evaluateDirectList(compiled, userId, userKey, effectiveObjectId, effectiveObjectKey, visited, currentRelation, options, 'intersection');
}
const possibilities = [];
const reliabilities = [];
const metas = [];
const remediationOptions = [];
const allCollectedValues = collectValues ? [] : null;
// A defeasible `when` component may compile to a single direct node
// (no children); treat it as a one-element conjunction, matching the
// union path's `compiled.children || [compiled]` fallback.
const children = compiled.children || [compiled];
const resChildrenValidity = [];
for (const child of children) {
const childVisited = new Set(visited);
const res = this.evaluate(child, userId, userKey, objectId, objectKey, childVisited, currentRelation, options);
if (res.validity) resChildrenValidity.push(res.validity);
possibilities.push(res.possibility);
reliabilities.push(res.reliability !== undefined ? res.reliability : 1.0);
metas.push(includeMeta ? res.meta : null);
mergeRemediationOptions(remediationOptions, extractRemediation(res));
if (collectValues && res.collectedValues && Array.isArray(res.collectedValues)) {
allCollectedValues.push(...res.collectedValues);
if (valueContext) {
valueContext.addCollectedValues(res.collectedValues, child.type, child);
}
}
if (fastPath && minPossibility !== null && res.possibility < minPossibility) {
return {
possibility: res.possibility,
reliability: res.reliability !== undefined ? res.reliability : 1.0,
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && { meta: res.meta })
};
}
}
if (!possibilities.length) {
return {
possibility: 0,
reliability: 1.0,
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && { meta: { operation: 'intersection', childCount: 0 } })
};
}
const intersectionOWAWeights = compiled._precomputedWeights && compiled._precomputedWeights.length === possibilities.length
? compiled._precomputedWeights
: getOWAWeightsFromRule(compiled, possibilities.length, metas);
const defaultMode = compiled.aggregator || 'min';
const finalWeights = compiled.aggregator || compiled.owaWeights ? intersectionOWAWeights :
[...Array(Math.max(0, possibilities.length - 1)).fill(0), 1];
let result;
if (compiled.reliabilityWeighting) {
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);
}
const remediation = result.value === 0
? buildRemediation(remediationOptions.length > 0 ? { status: 'required', options: remediationOptions } : null, { status: 'required' })
: null;
let intersectionReliability = 1.0;
if (includeOwaTrace && result.trace && typeof result.trace.selectedIndex === 'number') {
intersectionReliability = reliabilities[result.trace.selectedIndex] ?? 1.0;
} else if ((compiled.aggregator || 'min') === 'max') {
const maxP = Math.max(...possibilities);
intersectionReliability = reliabilities[possibilities.indexOf(maxP)] ?? 1.0;
} else {
const minP = Math.min(...possibilities);
intersectionReliability = reliabilities[possibilities.indexOf(minP)] ?? 1.0;
}
const interSourceLabels = (resChildrenValidity || []).map(v => v?.label).filter(Boolean);
const interSources = [];
for (const v of resChildrenValidity || []) for (const s of v?.sources || []) if (!interSources.includes(s)) interSources.push(s);
return {
possibility: result.value,
reliability: intersectionReliability,
validity: buildValidity('min', interSources, interSourceLabels, possibilities.length, result.value),
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && {
meta: {
...result.meta,
operation: 'intersection',
childCount: possibilities.length,
aggregator: defaultMode,
...(includeOwaTrace && result.trace ? {
owa: {
level: null,
aggregator: defaultMode,
weights: result.trace.weights,
sortedValues: result.trace.sortedValues,
contributions: result.trace.contributions,
selectedIndex: result.trace.selectedIndex
}
} : {}),
...(remediation ? { remediation } : {})
}
}),
...(remediation ? { remediation } : {})
};
}
_evaluateExclusion(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
const { valueContext = null, collectValues = false, includeMeta = true, trackEvaluation = false } = options;
const includeOwaTrace = trackEvaluation && includeMeta;
const owaTraceOptions = includeOwaTrace ? { includeTrace: true } : null;
if (compiled.children.length !== 2) {
return {
possibility: 0,
...(collectValues && { collectedValues: [] }),
...(includeMeta && { meta: { operation: 'exclusion', error: 'exclusion_requires_exactly_two_rules' } }),
reason: 'exclusion_requires_exactly_two_rules'
};
}
const visitedA = new Set(visited);
const visitedB = new Set(visited);
const a = this.evaluate(compiled.children[0], userId, userKey, objectId, objectKey, visitedA, currentRelation, options);
const b = this.evaluate(compiled.children[1], userId, userKey, objectId, objectKey, visitedB, currentRelation, options);
const allCollectedValues = collectValues ? [] : null;
if (collectValues && a.collectedValues && Array.isArray(a.collectedValues)) {
allCollectedValues.push(...a.collectedValues);
if (valueContext) valueContext.addCollectedValues(a.collectedValues, compiled.children[0].type, compiled.children[0]);
}
if (collectValues && b.collectedValues && Array.isArray(b.collectedValues)) {
allCollectedValues.push(...b.collectedValues);
if (valueContext) valueContext.addCollectedValues(b.collectedValues, compiled.children[1].type, compiled.children[1]);
}
let possibility;
let result;
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);
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);
}
return {
possibility,
// Both legs contribute to the decision, so their reliabilities
// multiply (consistent with TTU/chain path semantics).
reliability: (a.reliability !== undefined ? a.reliability : 1.0) * (b.reliability !== undefined ? b.reliability : 1.0),
validity: buildValidity('product', [a.validity?.sources?.[0], b.validity?.sources?.[0]].filter(Boolean), [a.validity?.label, b.validity?.label].filter(Boolean), 2, possibility),
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && {
meta: {
operation: 'exclusion',
childA: a.meta,
childB: b.meta,
aggregator: compiled.aggregator || 'standard',
...(includeOwaTrace && result?.trace ? {
owa: {
level: null,
aggregator: compiled.aggregator || 'standard',
weights: result.trace.weights,
sortedValues: result.trace.sortedValues,
contributions: result.trace.contributions,
selectedIndex: result.trace.selectedIndex
}
} : {})
}
})
};
}
_evaluateDirectList(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options, op) {
const { fastPath = false, minPossibility = null, valueContext = null, collectValues = false, includeMeta = true, trackEvaluation = false } = options;
const includeOwaTrace = trackEvaluation && includeMeta;
const owaTraceOptions = includeOwaTrace ? { includeTrace: true } : null;
const possibilities = [];
const reliabilities = [];
const metas = [];
const listChildrenValidity = [];
const allCollectedValues = collectValues ? [] : null;
const directRules = compiled._optimized.direct;
for (const direct of directRules) {
let directRel;
if (direct.reverse) {
directRel = this.arbiter.relationManager.getDirectRelation(objectId, direct.relation, userId, options);
} else {
directRel = this.arbiter.relationManager.getDirectRelation(userId, direct.relation, objectId, options);
}
const possibility = directRel ? directRel.possibility : 0;
possibilities.push(possibility);
reliabilities.push(directRel ? (directRel.reliability !== undefined ? directRel.reliability : 1.0) : 1.0);
if (directRel) {
listChildrenValidity.push(buildValidity('identity', [direct.relation], [normalizeValidity(directRel.validity !== undefined ? directRel.validity : null)], 1, possibility));
}
if (includeMeta) {
const _src = directRel ? (directRel.source || 'persistent') : null;
const _allowMeta = directRel ? {
ruleType: 'direct',
reason: 'direct',
source: _src,
layer_name: directRel.layer_name || null,
source_class: directRel.source_class || null,
reducer_applied: directRel.reducer_applied || null
} : null;
metas.push(directRel ? {
ruleType: 'direct',
relation: direct.relation,
reverse: direct.reverse || false,
strength: possibility,
source: _src,
allow: _allowMeta
} : { ruleType: 'direct', relation: direct.relation, reason: 'no_relation' });
} else {
metas.push(null);
}
if (collectValues && direct.collectValues !== false && directRel && directRel.value !== undefined) {
const sourceEntity = direct.reverse ? objectKey : userKey;
const targetEntity = direct.reverse ? userKey : objectKey;
const path = [sourceEntity, targetEntity];
const collected = this.ruleEvaluator.ruleHandlers.direct._createCollectedValue(
directRel.value,
directRel.possibility,
path,
{
entityKey: sourceEntity,
relation: direct.relation,
step: 0
},
{
timestamp: directRel.changed_last_at || directRel.updated_last_at || Date.now(),
reliability: 1.0,
source: directRel.source || 'persistent'
}
);
allCollectedValues.push(collected);
if (valueContext) {
valueContext.addCollectedValues([collected], 'direct', direct);
}
}
if (fastPath && minPossibility !== null) {
if (op === 'union' && possibility >= minPossibility) {
return {
possibility,
reliability: directRel ? (directRel.reliability !== undefined ? directRel.reliability : 1.0) : 1.0,
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && { meta: metas[metas.length - 1] })
};
}
if (op === 'intersection' && possibility < minPossibility) {
return {
possibility,
reliability: directRel ? (directRel.reliability !== undefined ? directRel.reliability : 1.0) : 1.0,
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && { meta: metas[metas.length - 1] })
};
}
}
}
if (!possibilities.length) {
return {
possibility: 0,
reliability: 1.0,
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && { meta: { operation: op, childCount: 0 } })
};
}
const weights = compiled._precomputedWeights && compiled._precomputedWeights.length === possibilities.length
? compiled._precomputedWeights
: getOWAWeightsFromRule(compiled, possibilities.length, metas);
const aggregator = op === 'intersection' ? (compiled.aggregator || 'min') : (compiled.aggregator || 'max');
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') {
listReliability = reliabilities[result.trace.selectedIndex] ?? 1.0;
} else if (aggregator === 'min') {
const minP = Math.min(...possibilities);
listReliability = reliabilities[possibilities.indexOf(minP)] ?? 1.0;
} else {
const maxP = Math.max(...possibilities);
listReliability = reliabilities[possibilities.indexOf(maxP)] ?? 1.0;
}
const listOperator = aggregator === 'min' ? 'min' : (aggregator === 'max' ? 'max' : 'owa');
const listLabels = listChildrenValidity.map(v => v.label).filter(Boolean);
const listSources = [];
for (const v of listChildrenValidity) for (const s of v.sources || []) if (!listSources.includes(s)) listSources.push(s);
return {
possibility: result.value,
reliability: listReliability,
validity: buildValidity(listOperator, listSources, listLabels, possibilities.length, result.value),
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && {
meta: {
...result.meta,
operation: op,
childCount: possibilities.length,
aggregator,
...(includeOwaTrace && result.trace ? {
owa: {
level: null,
aggregator,
weights: result.trace.weights,
sortedValues: result.trace.sortedValues,
contributions: result.trace.contributions,
selectedIndex: result.trace.selectedIndex
}
} : {})
}
})
};
}
_evaluateDefeasible(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
const { binary = false, fastPath = false, minPossibility = 0.0 } = options;
const mode = compiled.mode || (binary ? 'binary' : (fastPath ? 'threshold' : 'normal'));
const allCollectedValues = [];
let neverResult = null, defeatersResult = null, strictResult = null, defeasibleResult = null, requiresResult = null;
if (compiled.never) {
neverResult = this._evaluateUnion(compiled.never, userId, userKey, objectId, objectKey, visited, currentRelation, options);
if (neverResult.collectedValues) allCollectedValues.push(...neverResult.collectedValues);
}
if (compiled.always) {
strictResult = this.evaluate(compiled.always, userId, userKey, objectId, objectKey, visited, currentRelation, options);
if (strictResult.collectedValues) allCollectedValues.push(...strictResult.collectedValues);
}
if (compiled.requires) {
requiresResult = this._evaluateUnion(compiled.requires, userId, userKey, objectId, objectKey, visited, currentRelation, options);
if (requiresResult.collectedValues) allCollectedValues.push(...requiresResult.collectedValues);
}
if (compiled.unless) {
defeatersResult = this._evaluateUnion(compiled.unless, userId, userKey, objectId, objectKey, visited, currentRelation, options);
if (defeatersResult.collectedValues) allCollectedValues.push(...defeatersResult.collectedValues);
}
if (compiled.when) {
defeasibleResult = this._evaluateIntersection(compiled.when, userId, userKey, objectId, objectKey, visited, currentRelation, options);
if (defeasibleResult.collectedValues) allCollectedValues.push(...defeasibleResult.collectedValues);
}
const ruleMeta = {
minPossibility: compiled.minPossibility,
priority: compiled.priority,
mode
};
const finalResult = this.logicalOperators._applyDefeasibleLogic(mode, ruleMeta, neverResult, defeatersResult, strictResult, defeasibleResult, requiresResult, allCollectedValues);
return finalResult;
}
}
+29 -28
View File
@@ -8,7 +8,6 @@ import { RelationalComparatorRouter } from './rules/RelationalComparatorRouter.j
import { ChainRule } from './rules/ChainRule.js';
import { ChallengeRule } from './rules/ChallengeRule.js';
import { ValueContext } from './ValueContext.js';
import { CompiledEvaluator } from './CompiledEvaluator.js';
export class RuleEvaluator {
constructor(arbiter) {
@@ -26,7 +25,6 @@ export class RuleEvaluator {
};
this.logicalOperators = new LogicalOperators(arbiter, this);
this.compiledEvaluator = new CompiledEvaluator(arbiter, this, this.logicalOperators);
this._valueRequirementCache = new Map();
}
@@ -70,15 +68,6 @@ export class RuleEvaluator {
return this._evaluateRuleBinary(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
}
if (rule && rule._compiled && options.useCompiled !== false && !(rule._compileErrors && rule._compileErrors.length)) {
const result = this.compiledEvaluator.evaluate(rule._compiled, userId, userKey, objectId, objectKey, visited, currentRelation, enhancedOptions);
if (finalValueContext && result.collectedValues && result.collectedValues.length > 0) {
const ruleType = rule?.type || (rule?.union ? 'union' : rule?.intersection ? 'intersection' : rule?.exclusion ? 'exclusion' : 'rule');
finalValueContext.addCollectedValues(result.collectedValues, ruleType, rule);
}
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
}
if (rule.union) {
const result = this.logicalOperators.evaluateUnion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
@@ -94,6 +83,17 @@ export class RuleEvaluator {
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
}
// Defeasible configs ({ type: 'defeasible', when/unless/never/always })
// have no rule-handler entry; route them to the defeasible evaluator
// with the components normalized to the union/intersection format.
if (this.logicalOperators._isDefeasibleLogic(rule)) {
const result = this.logicalOperators.evaluateDefeasible(
numericUserId, userKey, numericObjectId, objectKey,
wrapDefeasibleComponents(rule), visited, currentRelation, enhancedOptions
);
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
}
// For other rule types, evaluate normally first
// Shorthand operand objects ({ relation: 'owner' } inside logical rules,
// or caller-supplied raw configs) carry no type: treat them as direct
@@ -292,17 +292,17 @@ export class RuleEvaluator {
// defeasible config). Route them through the defeasible evaluator,
// which implements binary/threshold modes natively.
if (this.logicalOperators._isDefeasibleLogic(rule)) {
if (rule._compiled && !(rule._compileErrors && rule._compileErrors.length)) {
return this.compiledEvaluator.evaluate(rule._compiled, userId, userKey, objectId, objectKey, visited, currentRelation, {
...enhancedOptions,
binary: true,
fastPath: true
});
}
return this.logicalOperators.evaluateDefeasible(userId, userKey, objectId, objectKey, wrapDefeasibleComponents(rule), visited, currentRelation, {
// The compiled path ran defeasible rules in normal mode regardless of
// the binary flag (compiled.mode defaulted to 'normal'), so the binary
// decision was always the thresholded normal combination. Preserve
// that contract: force normal mode; the checker applies the binary
// thresholds to the result.
const wrapped = wrapDefeasibleComponents(rule);
wrapped.mode = 'normal';
return this.logicalOperators.evaluateDefeasible(userId, userKey, objectId, objectKey, wrapped, visited, currentRelation, {
...enhancedOptions,
binary: true,
fastPath: true
binary: false,
fastPath: false
});
}
@@ -433,18 +433,19 @@ export class RuleEvaluator {
* Logical (union/intersection/exclusion) components pass through.
*/
function wrapDefeasibleComponents(rule) {
const wrapSingle = (component) => ({ rules: [component] });
// The defeasible evaluator's normalize expects the component format
// when.intersection.rules[] / unless.union.rules[] (not bare {rules}).
const wrapUnion = (component) => {
if (!component) return component;
if (Array.isArray(component)) return { rules: component };
if (component.union || component.intersection || component.exclusion) return component;
return wrapSingle(component);
if (component.union) return component;
if (Array.isArray(component)) return { union: { rules: component } };
return { union: { rules: [component] } };
};
const wrapIntersection = (component) => {
if (!component) return component;
if (Array.isArray(component)) return { rules: component };
if (component.union || component.intersection || component.exclusion) return component;
return wrapSingle(component);
if (component.intersection) return component;
if (Array.isArray(component)) return { intersection: { rules: component } };
return { intersection: { rules: [component] } };
};
return {
...rule,
+13 -1
View File
@@ -888,6 +888,7 @@ export class LogicalOperators extends BaseRule {
const childRules = unionConfig.rules || [];
let possibilities = [], reliabilities = [], metas = [], reasons = [];
const resChildrenValidity = [];
const remediationOptions = [];
const { fastPath = false, minPossibility = null, valueContext = null, collectValues = false, includeMeta = true, trackEvaluation = false } = options;
const allCollectedValues = collectValues ? [] : null; // Track collected values from all child rules
@@ -901,7 +902,7 @@ export class LogicalOperators extends BaseRule {
if (!res || typeof res.possibility !== 'number') {
res = { possibility: 0, meta: { reason: 'missing_rule' }, collectedValues: [] };
}
if (res.validity) resChildrenValidity.push(res.validity);
if (res.reason === 'cycle') reasons.push('cycle');
mergeRemediationOptions(remediationOptions, extractRemediation(res));
@@ -1034,9 +1035,14 @@ export class LogicalOperators extends BaseRule {
const maxP = Math.max(...possibilities);
unionReliability = reliabilities[possibilities.indexOf(maxP)] ?? 1.0;
}
const unionOperator = (unionConfig.aggregator || 'max') === 'max' && (!unionConfig.owaWeights || (unionConfig.owaWeights.length <= 1 || (unionConfig.owaWeights[0] === 1 && unionConfig.owaWeights.slice(1).every(w => w === 0))))
? 'max' : 'owa';
const unionSources = [];
for (const v of resChildrenValidity) for (const s of v.sources || []) if (!unionSources.includes(s)) unionSources.push(s);
return {
possibility: result.value,
reliability: unionReliability,
validity: buildValidity(unionOperator, unionSources, resChildrenValidity.map(v => v.label).filter(Boolean), possibilities.length, result.value),
...(collectValues && { collectedValues: allCollectedValues }), // Include all collected values from child rules
...(includeMeta && {
meta: {
@@ -1073,6 +1079,7 @@ export class LogicalOperators extends BaseRule {
const childRules = intersectionConfig.rules || [];
let possibilities = [], reliabilities = [], metas = [], reasons = [];
const resChildrenValidity = [];
const remediationOptions = [];
const { fastPath = false, minPossibility = null, valueContext = null, collectValues = false, includeMeta = true, trackEvaluation = false } = options;
const allCollectedValues = collectValues ? [] : null; // Track collected values from all child rules
@@ -1090,6 +1097,7 @@ export class LogicalOperators extends BaseRule {
for (const child of childRules) {
const res = this.ruleEvaluator.evaluateRule(userId, userKey, objectId, objectKey, child, visited, currentRelation, options);
if (res.validity) resChildrenValidity.push(res.validity);
if (res.reason === 'cycle') reasons.push('cycle');
possibilities.push(res.possibility);
reliabilities.push(res.reliability !== undefined ? res.reliability : 1.0);
@@ -1174,9 +1182,12 @@ export class LogicalOperators extends BaseRule {
const minP = Math.min(...possibilities);
intersectionReliability = reliabilities[possibilities.indexOf(minP)] ?? 1.0;
}
const interSources = [];
for (const v of resChildrenValidity) for (const s of v.sources || []) if (!interSources.includes(s)) interSources.push(s);
return {
possibility: result.value,
reliability: intersectionReliability,
validity: buildValidity('min', interSources, resChildrenValidity.map(v => v.label).filter(Boolean), possibilities.length, result.value),
...(collectValues && { collectedValues: allCollectedValues }), // Include all collected values from child rules
...(includeMeta && {
meta: {
@@ -1305,6 +1316,7 @@ export class LogicalOperators extends BaseRule {
// Both legs contribute to the decision, so their reliabilities
// multiply (consistent with TTU/chain path semantics).
reliability: (a.reliability !== undefined ? a.reliability : 1.0) * (b.reliability !== undefined ? b.reliability : 1.0),
validity: buildValidity('product', [a.validity?.sources?.[0], b.validity?.sources?.[0]].filter(Boolean), [a.validity?.label, b.validity?.label].filter(Boolean), 2, possibility),
// In binary mode the negated child's strength is the deny side of the
// dual-threshold contract: deny fires when P(B) >= maxDenyPossibility.
...(options.binary && { possibility_deny: b.possibility ?? 0 }),