Files
core/src/authorization/rules/RelationalComparatorRule.js
T

1192 lines
48 KiB
JavaScript
Raw Normal View History

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';
}
}