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; if (this.arbiter.valueManager && this.arbiter.valueManager._isValueExpired(rel)) 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) { if (this.arbiter.valueManager && this.arbiter.valueManager._isValueExpired(relation)) { return null; } 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'; } }