1192 lines
48 KiB
JavaScript
1192 lines
48 KiB
JavaScript
|
|
import { BaseRule } from './BaseRule.js';
|
||
|
|
import { Arbiter } from '../../core/Arbiter.js';
|
||
|
|
import { OWAFusion, getOWAWeightsFromRule } from '../../utils/OWAFusion.js';
|
||
|
|
|
||
|
|
// Default epsilon for floating point comparisons
|
||
|
|
const DEFAULT_EPSILON = 0.0001;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* RelationalComparatorRule - Evaluates access by comparing values from relations,
|
||
|
|
* treating values as intervals that "blur" over time based on decaying possibility.
|
||
|
|
*
|
||
|
|
* Configuration:
|
||
|
|
* {
|
||
|
|
* type: 'relational_comparator',
|
||
|
|
* leftOperand: {
|
||
|
|
* rule: {...}, // Any rule configuration
|
||
|
|
* extractValue: true, // Extract value from relation (if false, rule's possibility is used as value)
|
||
|
|
* valueRelation: string, // Optional: specific relation for value
|
||
|
|
* aggregator: string, // 'max', 'min', 'sum', 'average', 'median', 'majority', 'priority', 'optimistic', etc.
|
||
|
|
* owaWeights: number[], // Optional: custom OWA weights for aggregation
|
||
|
|
* decayRate: number, // Governs speed of possibility decay & value blurring (e.g., 0.1 per period)
|
||
|
|
* decayPeriod: string, // 'MINUTE', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR' (default 'HOUR')
|
||
|
|
* possibilityDecayDirection: string, // 'down' (towards 0), 'neutral' (towards 0.5), 'up' (towards 1), 'stable' (no decay)
|
||
|
|
* valueBlurDirection: string, // 'neutral' (symmetric), 'down' (expands lower bound), 'up' (expands upper bound), 'stable' (minimal blur)
|
||
|
|
* baseBlurAmount: number, // Factor scaling blur magnitude relative to possibility loss (e.g., 1.0)
|
||
|
|
* minOperandPossibility: number, // If operand's decayed possibility < this, considered no value (e.g., 0.05)
|
||
|
|
* evaluateFrom: string // 'user', 'object', or 'auto' (for rule evaluation perspective)
|
||
|
|
* },
|
||
|
|
* rightOperand: {...}, // Same structure as leftOperand
|
||
|
|
* comparator: string, // '>', '>=', '<', '<=', '==', '!='
|
||
|
|
* marginOfSafety: number, // Optional: scales right operand's point value before blurring (e.g., 1.1 for 10% margin)
|
||
|
|
* minRulePossibility: number,// Optional: if final rule possibility < this, considered 0 (e.g., 0.1)
|
||
|
|
* fallbackBehavior: string // 'allow' or 'deny' if values/operands are insufficient
|
||
|
|
* }
|
||
|
|
*/
|
||
|
|
export class RelationalComparatorRule extends BaseRule {
|
||
|
|
constructor(arbiter, ruleEvaluator) {
|
||
|
|
super(arbiter);
|
||
|
|
this.ruleEvaluator = ruleEvaluator;
|
||
|
|
this.epsilon = DEFAULT_EPSILON; // Rule specific epsilon for '==' and '!='
|
||
|
|
this._directValueCache = new Map();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Evaluate relational comparison
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||
|
|
const { left: leftOperand, right: rightOperand, comparator, marginOfSafety = 1.0, fallbackBehavior = 'deny', minRulePossibility = 0 } = rule;
|
||
|
|
try {
|
||
|
|
this.epsilon = rule.epsilon === undefined ? DEFAULT_EPSILON : rule.epsilon;
|
||
|
|
const compiledRule = rule?._compiled && rule._compiled.type === 'relational_comparator' ? rule._compiled : null;
|
||
|
|
const compiledLeft = compiledRule?.left || null;
|
||
|
|
const compiledRight = compiledRule?.right || null;
|
||
|
|
|
||
|
|
const ruleMetaBase = {
|
||
|
|
ruleType: 'RelationalComparatorRule',
|
||
|
|
userKey,
|
||
|
|
objectKey,
|
||
|
|
comparator,
|
||
|
|
marginOfSafetyApplied: rightOperand ? marginOfSafety : 1.0, // Only if rightOperand exists
|
||
|
|
fallbackBehavior,
|
||
|
|
minRulePossibilityUsed: minRulePossibility,
|
||
|
|
evaluationStarted: Date.now()
|
||
|
|
};
|
||
|
|
let evaluationMeta = options.trackEvaluation ? { ...ruleMetaBase, leftOperandDetails: {}, rightOperandDetails: {} } : null;
|
||
|
|
|
||
|
|
// Evaluate left operand
|
||
|
|
const leftOpResult = this._evaluateOperand(
|
||
|
|
userId, userKey, objectId, objectKey,
|
||
|
|
leftOperand, compiledLeft, visited, currentRelation, options, 'left', 1.0, evaluationMeta // No margin for left
|
||
|
|
);
|
||
|
|
if (evaluationMeta) evaluationMeta.leftOperandDetails = leftOpResult.meta || {};
|
||
|
|
|
||
|
|
// Evaluate right operand
|
||
|
|
const rightOpResult = this._evaluateOperand(
|
||
|
|
userId, userKey, objectId, objectKey,
|
||
|
|
rightOperand, compiledRight, visited, currentRelation, options, 'right', marginOfSafety, evaluationMeta // Apply margin for right
|
||
|
|
);
|
||
|
|
if (evaluationMeta) evaluationMeta.rightOperandDetails = rightOpResult.meta || {};
|
||
|
|
|
||
|
|
// Perform comparison of blurred intervals
|
||
|
|
let comparisonOutput = this._compareBlurredValues(
|
||
|
|
leftOpResult, rightOpResult, comparator, fallbackBehavior, rule, options, ruleMetaBase
|
||
|
|
);
|
||
|
|
|
||
|
|
// Apply minimum rule possibility threshold
|
||
|
|
if (comparisonOutput.possibility < minRulePossibility) {
|
||
|
|
if (evaluationMeta && evaluationMeta.comparisonStep) evaluationMeta.comparisonStep.finalPossibilityBeforeMinRule = comparisonOutput.possibility;
|
||
|
|
comparisonOutput.possibility = 0;
|
||
|
|
comparisonOutput.reason = comparisonOutput.reason + '_belowMinRulePossibility';
|
||
|
|
if (evaluationMeta && evaluationMeta.comparisonStep) evaluationMeta.comparisonStep.adjustedToZeroByMinRule = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (evaluationMeta) {
|
||
|
|
evaluationMeta.finalResult = { possibility: comparisonOutput.possibility, reliability: comparisonOutput.reliability, reason: comparisonOutput.reason };
|
||
|
|
evaluationMeta.evaluationCompleted = Date.now();
|
||
|
|
evaluationMeta.evaluationDuration = evaluationMeta.evaluationCompleted - ruleMetaBase.evaluationStarted;
|
||
|
|
if (comparisonOutput.meta) comparisonOutput.meta.fullEvaluationTrace = evaluationMeta;
|
||
|
|
else comparisonOutput.meta = { fullEvaluationTrace: evaluationMeta };
|
||
|
|
}
|
||
|
|
|
||
|
|
return comparisonOutput;
|
||
|
|
} catch (error) {
|
||
|
|
return {
|
||
|
|
possibility: 0,
|
||
|
|
reliability: 0,
|
||
|
|
...(options.includeMeta && {
|
||
|
|
meta: {
|
||
|
|
ruleType: 'RelationalComparatorRule',
|
||
|
|
userKey,
|
||
|
|
objectKey,
|
||
|
|
comparator,
|
||
|
|
marginOfSafetyApplied: rightOperand ? marginOfSafety : 1.0,
|
||
|
|
fallbackBehavior,
|
||
|
|
minRulePossibilityUsed: minRulePossibility,
|
||
|
|
evaluationStarted: Date.now(),
|
||
|
|
evaluationError: error.message
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
collectedValues: [],
|
||
|
|
reason: 'evaluation_error'
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Evaluate an operand (left or right)
|
||
|
|
* @private
|
||
|
|
*/
|
||
|
|
_evaluateOperand(userId, userKey, objectId, objectKey, operandConfig, operandCompiled, visited, currentRelation, options, side, marginOfSafety = 1.0, parentEvaluationMeta) {
|
||
|
|
const {
|
||
|
|
rule: nestedRuleConfig,
|
||
|
|
extractValue = true,
|
||
|
|
valueRelation,
|
||
|
|
aggregator, // handles 'aggregation' for backward compatibility
|
||
|
|
owaWeights,
|
||
|
|
minOperandPossibility = 0.01, // Default minimum possibility for an operand to be valid
|
||
|
|
evaluateFrom = 'auto',
|
||
|
|
ttl = 24 * 60 * 60 * 1000 // Default TTL: 24 hours in milliseconds
|
||
|
|
} = operandConfig;
|
||
|
|
|
||
|
|
const finalAggregator = aggregator || operandConfig.aggregation || 'max'; // Backward compatibility for 'aggregation'
|
||
|
|
const resolvedEvaluateFrom = operandCompiled?.evaluateFrom || evaluateFrom;
|
||
|
|
const resolvedValueRelation = operandCompiled?.valueRelationResolved || valueRelation;
|
||
|
|
const resolvedRule = operandCompiled?.rule || nestedRuleConfig;
|
||
|
|
const { valueContext = null } = options;
|
||
|
|
|
||
|
|
const operandMetaBase = {
|
||
|
|
operandSide: side,
|
||
|
|
nestedRuleType: resolvedRule?.type || nestedRuleConfig.type,
|
||
|
|
extractingValue: extractValue,
|
||
|
|
valueRelationUsed: resolvedValueRelation || resolvedRule?.relation || nestedRuleConfig.relation || nestedRuleConfig.rel || nestedRuleConfig.label || nestedRuleConfig.name,
|
||
|
|
aggregatorUsed: finalAggregator,
|
||
|
|
minOperandPossibility,
|
||
|
|
marginAppliedToValue: marginOfSafety,
|
||
|
|
evaluateFromPerspective: resolvedEvaluateFrom,
|
||
|
|
ttl
|
||
|
|
};
|
||
|
|
let operandEvalMeta = options.trackEvaluation ? { ...operandMetaBase, steps: [] } : null;
|
||
|
|
|
||
|
|
// Determine evaluation perspective for the nested rule
|
||
|
|
let evalUserId = userId, evalUserKey = userKey, evalObjectId = objectId, evalObjectKey = objectKey;
|
||
|
|
const isLogicalRule = resolvedRule?.type === 'logical' || !!(nestedRuleConfig.union || nestedRuleConfig.intersection || nestedRuleConfig.exclusion);
|
||
|
|
if (resolvedEvaluateFrom === 'user') {
|
||
|
|
// Evaluate from user perspective
|
||
|
|
if (resolvedRule?.type === 'direct' || resolvedRule?.type === 'computed' || isLogicalRule) {
|
||
|
|
// Direct rules: user becomes both subject and object
|
||
|
|
evalUserId = userId;
|
||
|
|
evalUserKey = userKey;
|
||
|
|
evalObjectId = userId;
|
||
|
|
evalObjectKey = userKey;
|
||
|
|
} else {
|
||
|
|
// Chain/traversal rules: handle based on whether we're doing value extraction
|
||
|
|
if (nestedRuleConfig.extractValues) {
|
||
|
|
// For value extraction chains, we want to traverse from the specified entity
|
||
|
|
// and extract values from whatever entities we can reach
|
||
|
|
evalUserId = userId;
|
||
|
|
evalUserKey = userKey;
|
||
|
|
evalObjectId = objectId;
|
||
|
|
evalObjectKey = objectKey;
|
||
|
|
} else {
|
||
|
|
// For path-checking chains, reverse the traversal direction
|
||
|
|
evalUserId = objectId;
|
||
|
|
evalUserKey = objectKey;
|
||
|
|
evalObjectId = userId;
|
||
|
|
evalObjectKey = userKey;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else if (resolvedEvaluateFrom === 'object') {
|
||
|
|
// Evaluate from object perspective
|
||
|
|
if (resolvedRule?.type === 'direct' || resolvedRule?.type === 'computed' || isLogicalRule) {
|
||
|
|
// Direct rules: object becomes both subject and object
|
||
|
|
evalUserId = objectId;
|
||
|
|
evalUserKey = objectKey;
|
||
|
|
evalObjectId = objectId;
|
||
|
|
evalObjectKey = objectKey;
|
||
|
|
} else {
|
||
|
|
// Chain/traversal rules: reverse the traversal direction
|
||
|
|
evalUserId = objectId;
|
||
|
|
evalUserKey = objectKey;
|
||
|
|
evalObjectId = userId;
|
||
|
|
evalObjectKey = userKey;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Auto mode - use conventional subject/object relationship
|
||
|
|
evalUserId = userId;
|
||
|
|
evalUserKey = userKey;
|
||
|
|
evalObjectId = objectId;
|
||
|
|
evalObjectKey = objectKey;
|
||
|
|
}
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'PerspectiveSet', evalUserKey, evalObjectKey });
|
||
|
|
|
||
|
|
const canCacheDerived = extractValue && !!this.arbiter.ruleResultCache &&
|
||
|
|
!options.partialGraphContext && !options.includeMeta &&
|
||
|
|
options.cacheDerivedValues !== false;
|
||
|
|
const operandIdentity = this._getOperandCacheIdentity(resolvedValueRelation, resolvedRule, nestedRuleConfig);
|
||
|
|
const derivedCacheKey = canCacheDerived
|
||
|
|
? this.arbiter.keyManager.createCompositeKey(
|
||
|
|
evalUserId,
|
||
|
|
`${currentRelation}:operand:${side}:${operandIdentity}:${finalAggregator}:${resolvedEvaluateFrom}:${marginOfSafety}`,
|
||
|
|
evalObjectId
|
||
|
|
)
|
||
|
|
: null;
|
||
|
|
if (canCacheDerived) {
|
||
|
|
const cached = this.arbiter.ruleResultCache.get(derivedCacheKey);
|
||
|
|
if (cached && Date.now() - cached.timestamp < this.arbiter.ruleResultCacheTTL) {
|
||
|
|
const cachedResult = cached.result;
|
||
|
|
let valueInterval = cachedResult.valueInterval;
|
||
|
|
let operandPossibility = cachedResult.operandPossibility || 0;
|
||
|
|
let finalReliability = cachedResult.reliability || 1.0;
|
||
|
|
let hasValue = cachedResult.hasValue || false;
|
||
|
|
let operandSource = cachedResult.source || null;
|
||
|
|
const cachedCollectedValues = cachedResult.collectedValues || [];
|
||
|
|
|
||
|
|
if (operandEvalMeta) {
|
||
|
|
operandEvalMeta.steps.push({ step: 'DerivedValueCacheHit', derivedCacheKey });
|
||
|
|
}
|
||
|
|
|
||
|
|
if (options.collectValues && valueContext && cachedCollectedValues.length > 0) {
|
||
|
|
valueContext.addCollectedValues(cachedCollectedValues, nestedRuleConfig.type || 'cached_operand', nestedRuleConfig);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (operandPossibility < minOperandPossibility) {
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({
|
||
|
|
step: 'OperandPossibilityBelowMinimum',
|
||
|
|
operandPossibility,
|
||
|
|
minOperandPossibility
|
||
|
|
});
|
||
|
|
operandPossibility = 0;
|
||
|
|
hasValue = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (operandEvalMeta && parentEvaluationMeta) {
|
||
|
|
if (side === 'left') parentEvaluationMeta.leftOperandProcessed = operandEvalMeta;
|
||
|
|
if (side === 'right') parentEvaluationMeta.rightOperandProcessed = operandEvalMeta;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
valueInterval,
|
||
|
|
operandPossibility,
|
||
|
|
reliability: finalReliability,
|
||
|
|
hasValue,
|
||
|
|
source: operandSource,
|
||
|
|
...(options.collectValues && { collectedValues: cachedCollectedValues }),
|
||
|
|
...(options.includeMeta && { meta: operandEvalMeta })
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const compiledDirect = operandCompiled?.rule?.type === 'direct' ? operandCompiled.rule : null;
|
||
|
|
if (extractValue && compiledDirect) {
|
||
|
|
const relationName = compiledDirect.relation;
|
||
|
|
const directRel = this._getDirectRelationForValue(evalUserId, evalObjectId, relationName, resolvedEvaluateFrom, options);
|
||
|
|
const operandCollectedValues = [];
|
||
|
|
let valueInterval = null;
|
||
|
|
let operandPossibility = 0;
|
||
|
|
let finalReliability = 1.0;
|
||
|
|
let hasValue = false;
|
||
|
|
let operandSource = null;
|
||
|
|
|
||
|
|
if (directRel && typeof directRel.value === 'number') {
|
||
|
|
const cached = this._getCachedDirectValue(directRel, ttl);
|
||
|
|
if (cached) {
|
||
|
|
const aggregatedResult = this._aggregateCrispValues(
|
||
|
|
[cached], finalAggregator, owaWeights, operandEvalMeta, options
|
||
|
|
);
|
||
|
|
valueInterval = aggregatedResult.interval;
|
||
|
|
operandPossibility = aggregatedResult.possibility;
|
||
|
|
finalReliability = aggregatedResult.reliability;
|
||
|
|
hasValue = true;
|
||
|
|
operandSource = aggregatedResult.source || null;
|
||
|
|
|
||
|
|
if (marginOfSafety !== 1.0 && valueInterval && typeof valueInterval.min === 'number' && typeof valueInterval.max === 'number') {
|
||
|
|
valueInterval = {
|
||
|
|
min: valueInterval.min * marginOfSafety,
|
||
|
|
max: valueInterval.max * marginOfSafety
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (options.collectValues) {
|
||
|
|
const srcKey = this.arbiter.resolveKey(directRel.src, options);
|
||
|
|
const dstKey = this.arbiter.resolveKey(directRel.dst, options);
|
||
|
|
const path = [srcKey, dstKey];
|
||
|
|
operandCollectedValues.push(this.ruleEvaluator.ruleHandlers.direct._createCollectedValue(
|
||
|
|
directRel.value,
|
||
|
|
directRel.possibility !== undefined ? directRel.possibility : 1.0,
|
||
|
|
path,
|
||
|
|
{
|
||
|
|
entityKey: srcKey,
|
||
|
|
relation: relationName,
|
||
|
|
step: 0
|
||
|
|
},
|
||
|
|
{
|
||
|
|
timestamp: directRel.changed_last_at || directRel.updated_last_at || Date.now(),
|
||
|
|
reliability: directRel.reliability || 1.0,
|
||
|
|
source: directRel.source || 'persistent'
|
||
|
|
}
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (canCacheDerived && derivedCacheKey) {
|
||
|
|
this.arbiter.ruleResultCache.set(derivedCacheKey, {
|
||
|
|
result: {
|
||
|
|
valueInterval,
|
||
|
|
operandPossibility,
|
||
|
|
reliability: finalReliability,
|
||
|
|
hasValue,
|
||
|
|
source: operandSource,
|
||
|
|
collectedValues: operandCollectedValues
|
||
|
|
},
|
||
|
|
timestamp: Date.now()
|
||
|
|
});
|
||
|
|
this.arbiter._cacheRuleResult(currentRelation, derivedCacheKey);
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'DerivedValueCached', derivedCacheKey });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (operandPossibility < minOperandPossibility) {
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({
|
||
|
|
step: 'OperandPossibilityBelowMinimum',
|
||
|
|
operandPossibility,
|
||
|
|
minOperandPossibility
|
||
|
|
});
|
||
|
|
operandPossibility = 0;
|
||
|
|
hasValue = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (operandEvalMeta && parentEvaluationMeta) {
|
||
|
|
if (side === 'left') parentEvaluationMeta.leftOperandProcessed = operandEvalMeta;
|
||
|
|
if (side === 'right') parentEvaluationMeta.rightOperandProcessed = operandEvalMeta;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
valueInterval,
|
||
|
|
operandPossibility,
|
||
|
|
reliability: finalReliability,
|
||
|
|
hasValue,
|
||
|
|
source: operandSource,
|
||
|
|
...(options.collectValues && { collectedValues: operandCollectedValues }),
|
||
|
|
...(options.includeMeta && { meta: operandEvalMeta })
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const compiledOperandRule = operandCompiled?.rule || null;
|
||
|
|
const directList = (compiledOperandRule && compiledOperandRule.type === 'logical' && compiledOperandRule.op === 'union'
|
||
|
|
&& compiledOperandRule._optimized?.kind === 'direct_list')
|
||
|
|
? compiledOperandRule._optimized.direct
|
||
|
|
: null;
|
||
|
|
if (extractValue && Array.isArray(directList) && directList.length > 0) {
|
||
|
|
const valueResults = [];
|
||
|
|
const operandCollectedValues = options.collectValues ? [] : [];
|
||
|
|
|
||
|
|
for (const direct of directList) {
|
||
|
|
const reverse = !!direct.reverse;
|
||
|
|
const relName = direct.relation;
|
||
|
|
let srcId = evalUserId;
|
||
|
|
let dstId = evalObjectId;
|
||
|
|
let srcKey = evalUserKey;
|
||
|
|
let dstKey = evalObjectKey;
|
||
|
|
if (reverse) {
|
||
|
|
srcId = evalObjectId;
|
||
|
|
dstId = evalUserId;
|
||
|
|
srcKey = evalObjectKey;
|
||
|
|
dstKey = evalUserKey;
|
||
|
|
}
|
||
|
|
const rel = this.arbiter.relationManager.getDirectRelation(srcId, relName, dstId, options);
|
||
|
|
if (!rel || typeof rel.value !== 'number') continue;
|
||
|
|
|
||
|
|
valueResults.push({
|
||
|
|
value: rel.value,
|
||
|
|
possibility: rel.possibility !== undefined ? rel.possibility : 1.0,
|
||
|
|
reliability: rel.reliability || 1.0,
|
||
|
|
timestamp: rel.changed_last_at || rel.updated_last_at || Date.now(),
|
||
|
|
source: 'direct_relation',
|
||
|
|
originalValue: rel.value
|
||
|
|
});
|
||
|
|
|
||
|
|
if (options.collectValues && direct.collectValues !== false) {
|
||
|
|
const path = [srcKey, dstKey];
|
||
|
|
operandCollectedValues.push(this.ruleEvaluator.ruleHandlers.direct._createCollectedValue(
|
||
|
|
rel.value,
|
||
|
|
rel.possibility !== undefined ? rel.possibility : 1.0,
|
||
|
|
path,
|
||
|
|
{
|
||
|
|
entityKey: srcKey,
|
||
|
|
relation: relName,
|
||
|
|
step: 0
|
||
|
|
},
|
||
|
|
{
|
||
|
|
timestamp: rel.changed_last_at || rel.updated_last_at || Date.now(),
|
||
|
|
reliability: rel.reliability || 1.0,
|
||
|
|
source: rel.source || 'persistent'
|
||
|
|
}
|
||
|
|
));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (options.collectValues && valueContext && operandCollectedValues.length > 0) {
|
||
|
|
valueContext.addCollectedValues(operandCollectedValues, 'direct_list', operandConfig);
|
||
|
|
}
|
||
|
|
|
||
|
|
let valueInterval = null;
|
||
|
|
let operandPossibility = 0;
|
||
|
|
let finalReliability = 1.0;
|
||
|
|
let hasValue = false;
|
||
|
|
let operandSource = null;
|
||
|
|
|
||
|
|
if (valueResults.length > 0) {
|
||
|
|
const aggregatedResult = this._aggregateCrispValues(
|
||
|
|
valueResults, finalAggregator, owaWeights, operandEvalMeta, options
|
||
|
|
);
|
||
|
|
valueInterval = aggregatedResult.interval;
|
||
|
|
operandPossibility = aggregatedResult.possibility;
|
||
|
|
finalReliability = aggregatedResult.reliability;
|
||
|
|
hasValue = true;
|
||
|
|
operandSource = aggregatedResult.source || null;
|
||
|
|
|
||
|
|
if (marginOfSafety !== 1.0 && valueInterval && typeof valueInterval.min === 'number' && typeof valueInterval.max === 'number') {
|
||
|
|
valueInterval = {
|
||
|
|
min: valueInterval.min * marginOfSafety,
|
||
|
|
max: valueInterval.max * marginOfSafety
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (canCacheDerived && derivedCacheKey) {
|
||
|
|
this.arbiter.ruleResultCache.set(derivedCacheKey, {
|
||
|
|
result: {
|
||
|
|
valueInterval,
|
||
|
|
operandPossibility,
|
||
|
|
reliability: finalReliability,
|
||
|
|
hasValue,
|
||
|
|
source: operandSource,
|
||
|
|
collectedValues: operandCollectedValues
|
||
|
|
},
|
||
|
|
timestamp: Date.now()
|
||
|
|
});
|
||
|
|
this.arbiter._cacheRuleResult(currentRelation, derivedCacheKey);
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'DerivedValueCached', derivedCacheKey });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (operandPossibility < minOperandPossibility) {
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({
|
||
|
|
step: 'OperandPossibilityBelowMinimum',
|
||
|
|
operandPossibility,
|
||
|
|
minOperandPossibility
|
||
|
|
});
|
||
|
|
operandPossibility = 0;
|
||
|
|
hasValue = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (operandEvalMeta && parentEvaluationMeta) {
|
||
|
|
if (side === 'left') parentEvaluationMeta.leftOperandProcessed = operandEvalMeta;
|
||
|
|
if (side === 'right') parentEvaluationMeta.rightOperandProcessed = operandEvalMeta;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
valueInterval,
|
||
|
|
operandPossibility,
|
||
|
|
reliability: finalReliability,
|
||
|
|
hasValue,
|
||
|
|
source: operandSource,
|
||
|
|
...(options.collectValues && { collectedValues: operandCollectedValues }),
|
||
|
|
...(options.includeMeta && { meta: operandEvalMeta })
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Evaluate the nested rule
|
||
|
|
const ruleResult = this.ruleEvaluator.evaluateRule(
|
||
|
|
evalUserId, evalUserKey, evalObjectId, evalObjectKey,
|
||
|
|
nestedRuleConfig, visited, currentRelation, { ...options, trackEvaluation: options.trackEvaluation } // Pass trackEvaluation
|
||
|
|
);
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'NestedRuleEvaluated', ruleResultPossibility: ruleResult.possibility, ruleResultReliability: ruleResult.reliability, nestedMeta: ruleResult.meta });
|
||
|
|
|
||
|
|
let valueInterval = null;
|
||
|
|
let operandPossibility = 0;
|
||
|
|
let finalReliability = ruleResult.reliability || 1.0;
|
||
|
|
let hasValue = false;
|
||
|
|
let operandSource = null;
|
||
|
|
const operandCollectedValues = options.collectValues && Array.isArray(ruleResult.collectedValues)
|
||
|
|
? ruleResult.collectedValues
|
||
|
|
: [];
|
||
|
|
|
||
|
|
if (!extractValue) {
|
||
|
|
// Use the rule's possibility as the value (point value)
|
||
|
|
const pointValue = ruleResult.possibility;
|
||
|
|
valueInterval = { min: pointValue, max: pointValue };
|
||
|
|
operandPossibility = ruleResult.possibility; // Confidence in this value is the value itself
|
||
|
|
hasValue = true;
|
||
|
|
operandSource = 'rule_possibility';
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'ValueFromRulePossibility', valueInterval, operandPossibility });
|
||
|
|
} else {
|
||
|
|
// Extract values
|
||
|
|
const valueResults = this._extractValues(
|
||
|
|
evalUserId, evalUserKey, evalObjectId, evalObjectKey,
|
||
|
|
resolvedRule, resolvedValueRelation, ruleResult, resolvedEvaluateFrom,
|
||
|
|
valueContext, ttl, operandEvalMeta, options
|
||
|
|
);
|
||
|
|
|
||
|
|
if (valueResults.length > 0) {
|
||
|
|
// Aggregate intervals using OWAFusion
|
||
|
|
const aggregatedResult = this._aggregateCrispValues(
|
||
|
|
valueResults, finalAggregator, owaWeights, operandEvalMeta, options
|
||
|
|
);
|
||
|
|
|
||
|
|
valueInterval = aggregatedResult.interval;
|
||
|
|
operandPossibility = aggregatedResult.possibility;
|
||
|
|
finalReliability = aggregatedResult.reliability;
|
||
|
|
hasValue = true;
|
||
|
|
operandSource = aggregatedResult.source || null;
|
||
|
|
|
||
|
|
// Defensive: ensure interval is valid before margin or downstream use
|
||
|
|
if (
|
||
|
|
!valueInterval ||
|
||
|
|
typeof valueInterval.min !== 'number' ||
|
||
|
|
typeof valueInterval.max !== 'number' ||
|
||
|
|
isNaN(valueInterval.min) ||
|
||
|
|
isNaN(valueInterval.max)
|
||
|
|
) {
|
||
|
|
valueInterval = null;
|
||
|
|
}
|
||
|
|
// Apply margin of safety to the interval
|
||
|
|
if (marginOfSafety !== 1.0 && valueInterval && typeof valueInterval.min === 'number' && typeof valueInterval.max === 'number') {
|
||
|
|
const originalInterval = { min: valueInterval.min, max: valueInterval.max };
|
||
|
|
valueInterval = {
|
||
|
|
min: valueInterval.min * marginOfSafety,
|
||
|
|
max: valueInterval.max * marginOfSafety
|
||
|
|
};
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({
|
||
|
|
step: 'MarginApplied',
|
||
|
|
originalInterval,
|
||
|
|
intervalAfterMargin: valueInterval
|
||
|
|
});
|
||
|
|
} else if (marginOfSafety !== 1.0 && valueInterval) {
|
||
|
|
// Optionally log a warning if margin requested but interval is invalid
|
||
|
|
if (typeof console !== 'undefined' && console.warn) {
|
||
|
|
console.warn('Margin of safety requested but interval is invalid:', valueInterval);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (canCacheDerived && derivedCacheKey) {
|
||
|
|
this.arbiter.ruleResultCache.set(derivedCacheKey, {
|
||
|
|
result: {
|
||
|
|
valueInterval,
|
||
|
|
operandPossibility,
|
||
|
|
reliability: finalReliability,
|
||
|
|
hasValue,
|
||
|
|
source: operandSource,
|
||
|
|
collectedValues: operandCollectedValues
|
||
|
|
},
|
||
|
|
timestamp: Date.now()
|
||
|
|
});
|
||
|
|
this.arbiter._cacheRuleResult(currentRelation, derivedCacheKey);
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'DerivedValueCached', derivedCacheKey });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check against minOperandPossibility
|
||
|
|
if (operandPossibility < minOperandPossibility) {
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({
|
||
|
|
step: 'OperandPossibilityBelowMinimum',
|
||
|
|
operandPossibility,
|
||
|
|
minOperandPossibility
|
||
|
|
});
|
||
|
|
operandPossibility = 0;
|
||
|
|
hasValue = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (operandEvalMeta && parentEvaluationMeta) {
|
||
|
|
if (side === 'left') parentEvaluationMeta.leftOperandProcessed = operandEvalMeta;
|
||
|
|
if (side === 'right') parentEvaluationMeta.rightOperandProcessed = operandEvalMeta;
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
return {
|
||
|
|
valueInterval,
|
||
|
|
operandPossibility,
|
||
|
|
reliability: finalReliability,
|
||
|
|
hasValue,
|
||
|
|
source: operandSource,
|
||
|
|
...(options.collectValues && { collectedValues: operandCollectedValues }),
|
||
|
|
...(options.includeMeta && { meta: operandEvalMeta })
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Extract crisp values (no blur/decay)
|
||
|
|
* @private
|
||
|
|
*/
|
||
|
|
_extractValues(userId, userKey, objectId, objectKey, rule, valueRelation, ruleResult, evaluateFrom, valueContext, ttl, operandEvalMeta, options = null) {
|
||
|
|
const results = [];
|
||
|
|
const now = Date.now();
|
||
|
|
const ttlCutoff = now - ttl;
|
||
|
|
|
||
|
|
// First check if rule result directly provides values
|
||
|
|
if (ruleResult.value !== undefined) {
|
||
|
|
// Single value from rule - use direct point value
|
||
|
|
const relation = {
|
||
|
|
src: userId,
|
||
|
|
dst: objectId,
|
||
|
|
rel: valueRelation || rule.relation || 'value',
|
||
|
|
value: ruleResult.value,
|
||
|
|
possibility: ruleResult.possibility,
|
||
|
|
reliability: ruleResult.reliability || 1.0,
|
||
|
|
changed_last_at: ruleResult.meta?.timestamp || now
|
||
|
|
};
|
||
|
|
|
||
|
|
results.push({
|
||
|
|
value: relation.value,
|
||
|
|
possibility: relation.possibility,
|
||
|
|
reliability: relation.reliability,
|
||
|
|
timestamp: relation.changed_last_at,
|
||
|
|
source: 'direct_from_rule',
|
||
|
|
originalValue: relation.value
|
||
|
|
});
|
||
|
|
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({
|
||
|
|
step: 'ValueDirectFromRule',
|
||
|
|
value: relation.value
|
||
|
|
});
|
||
|
|
|
||
|
|
} else if (ruleResult.collectedValues && Array.isArray(ruleResult.collectedValues) && ruleResult.collectedValues.length > 0) {
|
||
|
|
// Multiple collected values - get decayed intervals for each
|
||
|
|
for (const cv of ruleResult.collectedValues) {
|
||
|
|
// Extract numeric value from collected value structure
|
||
|
|
const timestamp = cv.metadata?.timestamp || cv.meta?.timestamp || now;
|
||
|
|
if (timestamp < ttlCutoff) {
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({
|
||
|
|
step: 'ValueSkippedDueToTTL',
|
||
|
|
value: cv.value,
|
||
|
|
timestamp,
|
||
|
|
ttl,
|
||
|
|
age: now - timestamp
|
||
|
|
});
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
let numericValue;
|
||
|
|
if (typeof cv.value === 'number') {
|
||
|
|
numericValue = cv.value;
|
||
|
|
} else if (cv.value && typeof cv.value === 'object' && typeof cv.value.value === 'number') {
|
||
|
|
numericValue = cv.value.value; // Extract from nested structure
|
||
|
|
} else if (cv.value && typeof cv.value === 'object' &&
|
||
|
|
typeof cv.value.min === 'number' && typeof cv.value.max === 'number') {
|
||
|
|
// Handle interval objects by taking the midpoint
|
||
|
|
numericValue = (cv.value.min + cv.value.max) / 2;
|
||
|
|
} else {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
results.push({
|
||
|
|
value: numericValue,
|
||
|
|
possibility: cv.possibility ?? 1.0,
|
||
|
|
reliability: cv.metadata?.reliability || cv.meta?.reliability || 1.0,
|
||
|
|
timestamp,
|
||
|
|
source: cv.metadata?.source || cv.source || 'collected_value',
|
||
|
|
originalValue: numericValue
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({
|
||
|
|
step: 'ValuesFromCollected',
|
||
|
|
count: ruleResult.collectedValues.length,
|
||
|
|
decayedCount: results.length
|
||
|
|
});
|
||
|
|
|
||
|
|
} else {
|
||
|
|
// Extract from relations directly - use point values
|
||
|
|
const relationName = valueRelation || rule.relation || rule.rel || rule.label || rule.name;
|
||
|
|
if (!relationName) return results;
|
||
|
|
|
||
|
|
if (rule.type === 'direct') {
|
||
|
|
const directRel = this._getDirectRelationForValue(userId, objectId, relationName, evaluateFrom, options);
|
||
|
|
if (directRel && typeof directRel.value === 'number') {
|
||
|
|
const cached = this._getCachedDirectValue(directRel, ttl);
|
||
|
|
if (cached) {
|
||
|
|
results.push(cached);
|
||
|
|
}
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const entityIdToExtract = evaluateFrom === 'user' ? userId :
|
||
|
|
(evaluateFrom === 'object' ? objectId : userId);
|
||
|
|
const preferredTargetId = evaluateFrom === 'object' ? objectId :
|
||
|
|
(evaluateFrom === 'user' ? userId : objectId);
|
||
|
|
|
||
|
|
// Get relations and use their point values
|
||
|
|
const valueRels = this.arbiter.relationManager.getAllValueRelationsFromSrc(entityIdToExtract, relationName, options);
|
||
|
|
let targetId = preferredTargetId;
|
||
|
|
if (evaluateFrom === 'auto') {
|
||
|
|
if (!valueRels.some(rel => rel.dst === targetId)) {
|
||
|
|
if (valueRels.some(rel => rel.dst === userId)) {
|
||
|
|
targetId = userId;
|
||
|
|
} else if (valueRels.some(rel => rel.dst === objectId)) {
|
||
|
|
targetId = objectId;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const rel of valueRels) {
|
||
|
|
if (rel.dst !== targetId) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
// Check TTL
|
||
|
|
const timestamp = rel.changed_last_at || rel.updated_last_at || now;
|
||
|
|
if (timestamp < ttlCutoff) {
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({
|
||
|
|
step: 'RelationSkippedDueToTTL',
|
||
|
|
relation: relationName,
|
||
|
|
timestamp,
|
||
|
|
ttl,
|
||
|
|
age: now - timestamp
|
||
|
|
});
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (typeof rel.value === 'number') {
|
||
|
|
results.push({
|
||
|
|
value: rel.value,
|
||
|
|
possibility: rel.possibility !== undefined ? rel.possibility : 1.0,
|
||
|
|
reliability: rel.reliability || 1.0,
|
||
|
|
timestamp,
|
||
|
|
source: 'direct_relation',
|
||
|
|
originalValue: rel.value
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({
|
||
|
|
step: 'ValuesFromRelations',
|
||
|
|
relationName,
|
||
|
|
count: valueRels.length,
|
||
|
|
decayedCount: results.length
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
_getDirectRelationForValue(userId, objectId, relationName, evaluateFrom, options = null) {
|
||
|
|
if (evaluateFrom === 'user') {
|
||
|
|
return this.arbiter.relationManager.getDirectRelation(userId, relationName, userId, options);
|
||
|
|
}
|
||
|
|
if (evaluateFrom === 'object') {
|
||
|
|
return this.arbiter.relationManager.getDirectRelation(objectId, relationName, objectId, options);
|
||
|
|
}
|
||
|
|
|
||
|
|
let directRel = this.arbiter.relationManager.getDirectRelation(userId, relationName, objectId, options);
|
||
|
|
if (!directRel && userId !== objectId) {
|
||
|
|
directRel = this.arbiter.relationManager.getDirectRelation(userId, relationName, userId, options);
|
||
|
|
}
|
||
|
|
if (!directRel && userId !== objectId) {
|
||
|
|
directRel = this.arbiter.relationManager.getDirectRelation(objectId, relationName, objectId, options);
|
||
|
|
}
|
||
|
|
return directRel;
|
||
|
|
}
|
||
|
|
|
||
|
|
_getCachedDirectValue(relation, ttl) {
|
||
|
|
const cacheKey = `${relation.src}|${relation.rel}|${relation.dst}`;
|
||
|
|
const cached = this._directValueCache.get(cacheKey);
|
||
|
|
if (cached && cached.stateId === relation.stateId) {
|
||
|
|
return cached;
|
||
|
|
}
|
||
|
|
const timestamp = relation.changed_last_at || relation.updated_last_at || Date.now();
|
||
|
|
if (!OWAFusion.isWithinTTL(timestamp, ttl)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const entry = {
|
||
|
|
value: relation.value,
|
||
|
|
possibility: relation.possibility !== undefined ? relation.possibility : 1.0,
|
||
|
|
reliability: relation.reliability || 1.0,
|
||
|
|
timestamp,
|
||
|
|
source: relation.source || 'persistent',
|
||
|
|
originalValue: relation.value,
|
||
|
|
stateId: relation.stateId
|
||
|
|
};
|
||
|
|
this._directValueCache.set(cacheKey, entry);
|
||
|
|
return entry;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Aggregate crisp values using OWAFusion
|
||
|
|
* @private
|
||
|
|
*/
|
||
|
|
_aggregateCrispValues(results, aggregator = 'max', owaWeights, operandEvalMeta, options = null) {
|
||
|
|
if (results.length === 0) {
|
||
|
|
return { interval: null, possibility: 0, reliability: 0 };
|
||
|
|
}
|
||
|
|
|
||
|
|
if (results.length === 1) {
|
||
|
|
return {
|
||
|
|
interval: { min: results[0].value, max: results[0].value },
|
||
|
|
possibility: results[0].possibility,
|
||
|
|
reliability: results[0].reliability,
|
||
|
|
source: results[0].source || 'unknown'
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const count = results.length;
|
||
|
|
const scratch = options && options.scratch ? options.scratch : null;
|
||
|
|
const values = scratch ? scratch.getArray('owa_values', count) : new Array(count);
|
||
|
|
const possibilities = scratch ? scratch.getArray('owa_possibilities', count) : new Array(count);
|
||
|
|
const reliabilities = scratch ? scratch.getArray('owa_reliabilities', count) : new Array(count);
|
||
|
|
const metas = scratch ? scratch.getArray('owa_metas', count) : new Array(count);
|
||
|
|
for (let i = 0; i < count; i++) {
|
||
|
|
const result = results[i];
|
||
|
|
values[i] = result.value;
|
||
|
|
possibilities[i] = result.possibility;
|
||
|
|
reliabilities[i] = result.reliability;
|
||
|
|
metas[i] = result;
|
||
|
|
}
|
||
|
|
|
||
|
|
const fused = OWAFusion.fuseTriplesWithMeta(values, possibilities, reliabilities, metas, owaWeights, aggregator);
|
||
|
|
|
||
|
|
const result = {
|
||
|
|
interval: { min: fused.value, max: fused.value },
|
||
|
|
possibility: fused.possibility,
|
||
|
|
reliability: fused.reliability,
|
||
|
|
source: fused.meta?.source || 'unknown',
|
||
|
|
aggregationMeta: {
|
||
|
|
method: aggregator,
|
||
|
|
sourceCount: results.length,
|
||
|
|
selectedMeta: fused.meta
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (operandEvalMeta) operandEvalMeta.steps.push({
|
||
|
|
step: 'CrispValuesAggregated',
|
||
|
|
aggregationResult: result
|
||
|
|
});
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
_aggregateSmallIntervals(blurredResults, weights) {
|
||
|
|
const count = blurredResults.length;
|
||
|
|
const midpoints = new Array(count);
|
||
|
|
const metas = new Array(count);
|
||
|
|
for (let i = 0; i < count; i++) {
|
||
|
|
const interval = blurredResults[i].interval;
|
||
|
|
midpoints[i] = (interval.min + interval.max) / 2;
|
||
|
|
metas[i] = {
|
||
|
|
source: blurredResults[i].source,
|
||
|
|
timestamp: blurredResults[i].timestamp,
|
||
|
|
originalValue: blurredResults[i].originalValue,
|
||
|
|
possibility: blurredResults[i].possibility,
|
||
|
|
reliability: blurredResults[i].reliability
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const order = this._sortSmallIndices(midpoints, null, null);
|
||
|
|
let fusedMin = 0;
|
||
|
|
let fusedMax = 0;
|
||
|
|
let maxWeight = -Infinity;
|
||
|
|
let selectedIdx = order[0];
|
||
|
|
for (let i = 0; i < order.length; i++) {
|
||
|
|
const idx = order[i];
|
||
|
|
const interval = blurredResults[idx].interval;
|
||
|
|
const weight = weights[i];
|
||
|
|
fusedMin += weight * interval.min;
|
||
|
|
fusedMax += weight * interval.max;
|
||
|
|
if (weight > maxWeight) {
|
||
|
|
maxWeight = weight;
|
||
|
|
selectedIdx = idx;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { interval: { min: fusedMin, max: fusedMax }, meta: metas[selectedIdx] };
|
||
|
|
}
|
||
|
|
|
||
|
|
_aggregateSmallValues(blurredResults, weights) {
|
||
|
|
const count = blurredResults.length;
|
||
|
|
const values = new Array(count);
|
||
|
|
const priorities = new Array(count);
|
||
|
|
const ruleTypes = new Array(count);
|
||
|
|
const metas = new Array(count);
|
||
|
|
const typeOrder = { strict: 3, defeasible: 2, defeater: 1 };
|
||
|
|
for (let i = 0; i < count; i++) {
|
||
|
|
const value = blurredResults[i].possibility;
|
||
|
|
values[i] = value;
|
||
|
|
metas[i] = {
|
||
|
|
source: blurredResults[i].source,
|
||
|
|
timestamp: blurredResults[i].timestamp,
|
||
|
|
originalValue: blurredResults[i].originalValue,
|
||
|
|
possibility: blurredResults[i].possibility,
|
||
|
|
reliability: blurredResults[i].reliability
|
||
|
|
};
|
||
|
|
priorities[i] = metas[i].rule?.priority ?? 0;
|
||
|
|
ruleTypes[i] = typeOrder[metas[i].ruleType] ?? 0;
|
||
|
|
}
|
||
|
|
const order = this._sortSmallIndices(values, priorities, ruleTypes);
|
||
|
|
let sum = 0;
|
||
|
|
let selectedIdx = order[0];
|
||
|
|
let maxContribution = 0;
|
||
|
|
for (let i = 0; i < order.length; i++) {
|
||
|
|
const idx = order[i];
|
||
|
|
const contribution = weights[i] * values[idx];
|
||
|
|
sum += contribution;
|
||
|
|
const currentPriority = priorities[idx];
|
||
|
|
const selectedPriority = priorities[selectedIdx];
|
||
|
|
const currentRuleType = ruleTypes[idx];
|
||
|
|
const selectedRuleType = ruleTypes[selectedIdx];
|
||
|
|
const contributionDiff = Math.abs(contribution - maxContribution);
|
||
|
|
const avgContribution = (contribution + maxContribution) / 2;
|
||
|
|
const relativeContributionDiff = avgContribution > 0 ? contributionDiff / avgContribution : 0;
|
||
|
|
if (contribution > maxContribution ||
|
||
|
|
(relativeContributionDiff < 0.15 && currentPriority > selectedPriority) ||
|
||
|
|
(relativeContributionDiff < 0.15 && currentPriority === selectedPriority && currentRuleType > selectedRuleType) ||
|
||
|
|
(contribution === maxContribution && values[idx] > values[selectedIdx])) {
|
||
|
|
maxContribution = contribution;
|
||
|
|
selectedIdx = idx;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { value: sum, meta: metas[selectedIdx] };
|
||
|
|
}
|
||
|
|
|
||
|
|
_minReliability(blurredResults) {
|
||
|
|
let minReliability = 1.0;
|
||
|
|
for (let i = 0; i < blurredResults.length; i++) {
|
||
|
|
const reliability = blurredResults[i].reliability;
|
||
|
|
if (reliability < minReliability) minReliability = reliability;
|
||
|
|
}
|
||
|
|
return minReliability;
|
||
|
|
}
|
||
|
|
|
||
|
|
_sortSmallIndices(values, priorities, ruleTypes) {
|
||
|
|
if (values.length === 2) {
|
||
|
|
const aFirst = this._compareIndex(values, priorities, ruleTypes, 0, 1) <= 0;
|
||
|
|
return aFirst ? [0, 1] : [1, 0];
|
||
|
|
}
|
||
|
|
if (values.length === 3) {
|
||
|
|
let i0 = 0;
|
||
|
|
let i1 = 1;
|
||
|
|
let i2 = 2;
|
||
|
|
if (this._compareIndex(values, priorities, ruleTypes, i0, i1) > 0) {
|
||
|
|
const tmp = i0;
|
||
|
|
i0 = i1;
|
||
|
|
i1 = tmp;
|
||
|
|
}
|
||
|
|
if (this._compareIndex(values, priorities, ruleTypes, i0, i2) > 0) {
|
||
|
|
const tmp = i0;
|
||
|
|
i0 = i2;
|
||
|
|
i2 = tmp;
|
||
|
|
}
|
||
|
|
if (this._compareIndex(values, priorities, ruleTypes, i1, i2) > 0) {
|
||
|
|
const tmp = i1;
|
||
|
|
i1 = i2;
|
||
|
|
i2 = tmp;
|
||
|
|
}
|
||
|
|
return [i0, i1, i2];
|
||
|
|
}
|
||
|
|
const indices = values.map((_, i) => i);
|
||
|
|
indices.sort((a, b) => this._compareIndex(values, priorities, ruleTypes, a, b));
|
||
|
|
return indices;
|
||
|
|
}
|
||
|
|
|
||
|
|
_compareIndex(values, priorities, ruleTypes, a, b) {
|
||
|
|
const valDiff = values[b] - values[a];
|
||
|
|
if (valDiff !== 0) return valDiff;
|
||
|
|
if (priorities && ruleTypes) {
|
||
|
|
const prioDiff = priorities[b] - priorities[a];
|
||
|
|
if (prioDiff !== 0) return prioDiff;
|
||
|
|
return ruleTypes[b] - ruleTypes[a];
|
||
|
|
}
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
_calculateIntervalComparisonPossibility(L_interval, R_interval, comparator) {
|
||
|
|
const leftValue = this._extractPointValueFromInterval(L_interval);
|
||
|
|
const rightValue = this._extractPointValueFromInterval(R_interval);
|
||
|
|
const epsilon = this.epsilon;
|
||
|
|
|
||
|
|
if (leftValue === null || rightValue === null) return 0;
|
||
|
|
|
||
|
|
switch (comparator) {
|
||
|
|
case '>': return leftValue > rightValue ? 1 : 0;
|
||
|
|
case '>=': return leftValue >= rightValue ? 1 : 0;
|
||
|
|
case '<': return leftValue < rightValue ? 1 : 0;
|
||
|
|
case '<=': return leftValue <= rightValue ? 1 : 0;
|
||
|
|
case '==': return Math.abs(leftValue - rightValue) < epsilon ? 1 : 0;
|
||
|
|
case '!=': return Math.abs(leftValue - rightValue) >= epsilon ? 1 : 0;
|
||
|
|
default:
|
||
|
|
Arbiter.warn('Unknown comparator in _calculateIntervalComparisonPossibility:', comparator);
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
_compareBlurredValues(leftOpResult, rightOpResult, comparator, fallbackBehavior, ruleConfig, options, ruleMetaBase) {
|
||
|
|
// evaluationMetaFromOptions is for the parent rule's trace, if options.trackEvaluation is true
|
||
|
|
const evaluationMetaFromOptions = options.trackEvaluation ? (options.evaluationMeta || ruleMetaBase) : null;
|
||
|
|
|
||
|
|
let finalPossibility = 0;
|
||
|
|
let finalReliability = Math.min(leftOpResult.reliability || 1.0, rightOpResult.reliability || 1.0);
|
||
|
|
let topLevelReason = 'comparison_initialization_failed'; // Default reason
|
||
|
|
|
||
|
|
// Basic meta structure, will be augmented based on outcome
|
||
|
|
let topLevelMeta = {
|
||
|
|
...(ruleMetaBase || {}), // Includes original ruleType, userKey, objectKey etc.
|
||
|
|
type: 'relational_comparator', // Override ruleType from base if needed
|
||
|
|
leftOperandDetails: leftOpResult.meta,
|
||
|
|
rightOperandDetails: rightOpResult.meta,
|
||
|
|
// Store raw inputs for easier debugging from meta
|
||
|
|
_leftOpRaw: {
|
||
|
|
hasValue: leftOpResult.hasValue,
|
||
|
|
interval: leftOpResult.valueInterval,
|
||
|
|
possibility: leftOpResult.operandPossibility
|
||
|
|
},
|
||
|
|
_rightOpRaw: {
|
||
|
|
hasValue: rightOpResult.hasValue,
|
||
|
|
interval: rightOpResult.valueInterval,
|
||
|
|
possibility: rightOpResult.operandPossibility
|
||
|
|
},
|
||
|
|
comparatorUsed: comparator
|
||
|
|
};
|
||
|
|
|
||
|
|
if (evaluationMetaFromOptions && !evaluationMetaFromOptions.comparisonStep) {
|
||
|
|
evaluationMetaFromOptions.comparisonStep = {};
|
||
|
|
}
|
||
|
|
if (evaluationMetaFromOptions) {
|
||
|
|
Object.assign(evaluationMetaFromOptions.comparisonStep, {
|
||
|
|
leftValueIntervalAtCompare: leftOpResult.valueInterval,
|
||
|
|
leftPossibilityAtCompare: leftOpResult.operandPossibility,
|
||
|
|
rightValueIntervalAtCompare: rightOpResult.valueInterval,
|
||
|
|
rightPossibilityAtCompare: rightOpResult.operandPossibility,
|
||
|
|
comparatorForCompare: comparator,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!leftOpResult.hasValue || !rightOpResult.hasValue) {
|
||
|
|
if (!leftOpResult.hasValue && !rightOpResult.hasValue) { // Both missing
|
||
|
|
finalPossibility = fallbackBehavior === 'allow' ? 1.0 : 0;
|
||
|
|
topLevelReason = 'both_operands_missing_fallback_' + fallbackBehavior;
|
||
|
|
} else { // Exactly one is missing
|
||
|
|
const missingSide = !leftOpResult.hasValue ? 'left' : 'right';
|
||
|
|
if (comparator === '!=') {
|
||
|
|
finalPossibility = 1.0;
|
||
|
|
topLevelReason = `${missingSide}_operand_missing_inequality_true`;
|
||
|
|
} else { // For >, <, >=, <=, ==
|
||
|
|
finalPossibility = 0;
|
||
|
|
topLevelReason = `${missingSide}_operand_missing_comparison_false`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// When an operand is missing, its contribution to reliability is via its (likely low) operandPossibility
|
||
|
|
finalReliability = Math.min(finalReliability, leftOpResult.operandPossibility || 0, rightOpResult.operandPossibility || 0);
|
||
|
|
if (evaluationMetaFromOptions) evaluationMetaFromOptions.comparisonStep.reasonForOutcome = topLevelReason;
|
||
|
|
|
||
|
|
} else { // Both operands have values
|
||
|
|
const comparisonResultPossibility = this._calculateIntervalComparisonPossibility(
|
||
|
|
leftOpResult.valueInterval,
|
||
|
|
rightOpResult.valueInterval,
|
||
|
|
comparator
|
||
|
|
);
|
||
|
|
|
||
|
|
if (evaluationMetaFromOptions) evaluationMetaFromOptions.comparisonStep.possibilityFromIntervalComparison = comparisonResultPossibility;
|
||
|
|
|
||
|
|
const averageOperandPossibility = (leftOpResult.operandPossibility + rightOpResult.operandPossibility) / 2;
|
||
|
|
const confidenceWeight = Math.min(averageOperandPossibility * 2, 1.0);
|
||
|
|
finalPossibility = comparisonResultPossibility * confidenceWeight;
|
||
|
|
|
||
|
|
// Reliability already initialized based on operand reliabilities
|
||
|
|
// finalReliability = (leftOpResult.reliability || 1.0) * (rightOpResult.reliability || 1.0); // This might double-penalize if already low.
|
||
|
|
|
||
|
|
if (evaluationMetaFromOptions) {
|
||
|
|
evaluationMetaFromOptions.comparisonStep.finalCalculatedPossibility = finalPossibility;
|
||
|
|
evaluationMetaFromOptions.comparisonStep.averageOperandPossibility = averageOperandPossibility;
|
||
|
|
evaluationMetaFromOptions.comparisonStep.confidenceWeight = confidenceWeight;
|
||
|
|
}
|
||
|
|
|
||
|
|
const reasonSuffix = finalPossibility > (this.epsilon || DEFAULT_EPSILON) ? '_comparison_true' : '_comparison_false';
|
||
|
|
topLevelReason = `values_compared${reasonSuffix}`;
|
||
|
|
|
||
|
|
topLevelMeta.details = {
|
||
|
|
leftInterval: leftOpResult.valueInterval,
|
||
|
|
rightInterval: rightOpResult.valueInterval,
|
||
|
|
leftOperandPoss: leftOpResult.operandPossibility,
|
||
|
|
rightOperandPoss: rightOpResult.operandPossibility,
|
||
|
|
intervalComparePoss: comparisonResultPossibility,
|
||
|
|
averageOperandPoss: averageOperandPossibility,
|
||
|
|
confidenceWeight: confidenceWeight,
|
||
|
|
leftSource: leftOpResult.source || null,
|
||
|
|
rightSource: rightOpResult.source || null
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Populate final meta fields for allow/deny based on possibility
|
||
|
|
if (finalPossibility > (this.epsilon || DEFAULT_EPSILON)) {
|
||
|
|
topLevelMeta.allow = {
|
||
|
|
type: 'relational_comparator',
|
||
|
|
leftValue: this._extractPointValueFromInterval(leftOpResult.valueInterval),
|
||
|
|
rightValue: this._extractPointValueFromInterval(rightOpResult.valueInterval),
|
||
|
|
leftInterval: leftOpResult.valueInterval,
|
||
|
|
rightInterval: rightOpResult.valueInterval,
|
||
|
|
comparator: comparator,
|
||
|
|
possibility: finalPossibility,
|
||
|
|
reason: topLevelReason,
|
||
|
|
details: topLevelMeta.details
|
||
|
|
};
|
||
|
|
} else {
|
||
|
|
topLevelMeta.deny = {
|
||
|
|
type: 'relational_comparator',
|
||
|
|
leftValue: this._extractPointValueFromInterval(leftOpResult.valueInterval),
|
||
|
|
rightValue: this._extractPointValueFromInterval(rightOpResult.valueInterval),
|
||
|
|
leftInterval: leftOpResult.valueInterval,
|
||
|
|
rightInterval: rightOpResult.valueInterval,
|
||
|
|
comparator: comparator,
|
||
|
|
possibility: finalPossibility,
|
||
|
|
reason: topLevelReason,
|
||
|
|
details: topLevelMeta.details
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
topLevelMeta.reason = topLevelReason;
|
||
|
|
topLevelMeta.finalOutcomeReason = topLevelReason; // For clarity in traces
|
||
|
|
if (ruleConfig && ruleConfig.priority) topLevelMeta.priority = ruleConfig.priority;
|
||
|
|
|
||
|
|
|
||
|
|
const collectedValues = [...(leftOpResult.collectedValues || []), ...(rightOpResult.collectedValues || [])];
|
||
|
|
|
||
|
|
return {
|
||
|
|
possibility: finalPossibility,
|
||
|
|
reliability: finalReliability,
|
||
|
|
...(options.includeMeta && {
|
||
|
|
meta: topLevelMeta,
|
||
|
|
meta_allow: topLevelMeta.allow, // Add this field for AuthorizationChecker
|
||
|
|
meta_deny: topLevelMeta.deny // Add this field for AuthorizationChecker
|
||
|
|
}),
|
||
|
|
collectedValues: collectedValues,
|
||
|
|
reason: topLevelReason
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
_extractPointValueFromInterval(interval) {
|
||
|
|
if (interval && typeof interval.min === 'number' && typeof interval.max === 'number') {
|
||
|
|
return (interval.min + interval.max) / 2;
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
_getOperandCacheIdentity(resolvedValueRelation, resolvedRule, nestedRuleConfig) {
|
||
|
|
if (resolvedValueRelation) return resolvedValueRelation;
|
||
|
|
if (resolvedRule?.relation) return resolvedRule.relation;
|
||
|
|
const fallbackRelation = nestedRuleConfig?.relation || nestedRuleConfig?.rel || nestedRuleConfig?.label || nestedRuleConfig?.name;
|
||
|
|
if (fallbackRelation) return fallbackRelation;
|
||
|
|
|
||
|
|
if (resolvedRule?.type === 'logical') {
|
||
|
|
if (resolvedRule._optimized?.kind === 'direct_list') {
|
||
|
|
const rels = resolvedRule._optimized.direct
|
||
|
|
.map((direct) => `${direct.reverse ? 'rev:' : ''}${direct.relation}`)
|
||
|
|
.join(',');
|
||
|
|
return `direct_list:${resolvedRule.op || 'logical'}:${rels}`;
|
||
|
|
}
|
||
|
|
if (Array.isArray(resolvedRule.children)) {
|
||
|
|
const childKeys = resolvedRule.children.map((child) => {
|
||
|
|
if (child?.type === 'direct') {
|
||
|
|
return `${child.reverse ? 'rev:' : ''}${child.relation}`;
|
||
|
|
}
|
||
|
|
return child?.type || 'unknown';
|
||
|
|
}).join(',');
|
||
|
|
return `logical:${resolvedRule.op || 'logical'}:${childKeys}`;
|
||
|
|
}
|
||
|
|
return `logical:${resolvedRule.op || 'logical'}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (resolvedRule?.type) return `type:${resolvedRule.type}`;
|
||
|
|
if (nestedRuleConfig?.type) return `type:${nestedRuleConfig.type}`;
|
||
|
|
return 'value';
|
||
|
|
}
|
||
|
|
}
|