import { BaseRule } from './BaseRule.js'; import { Arbiter } from '../../core/Arbiter.js'; import { OWAQualitativeFusion, getOWAQualitativeWeights } from '../../qualitative/OWAQualitativeFusion.js'; import { QualitativeScale, DEFAULT_QUALITATIVE_SCALE } from '../../qualitative/QualitativeScale.js'; import { BilatticeOrderings } from '../../qualitative/BilatticeOrderings.js'; import { QualitativeCapacity } from '../../qualitative/QualitativeCapacity.js'; /** * QualitativeRelationalComparatorRule - Evaluates access by comparing qualitative values from relations, * treating values as qualitative intervals that "blur" over time based on decaying possibility. * * This implementation uses qualitative scales and possibility theory instead of numeric intervals. * * Configuration: * { * type: 'relational_comparator', * qualitative: true, // Flag to indicate qualitative mode * scaleName: string, // Name of the qualitative scale to use (e.g., 'five-point', 'ternary') * 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', 'majority', 'priority', 'optimistic', etc. * owaWeights: number[], // Optional: custom OWA weights for aggregation * decaySteps: number, // Number of steps to decay per period on the qualitative scale * 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) * baseBlurSteps: number, // Number of steps to blur per possibility decay step * minOperandPossibility: number, // If operand's decayed possibility < this, considered no value * evaluateFrom: string // 'user', 'object', or 'auto' (for rule evaluation perspective) * }, * rightOperand: {...}, // Same structure as leftOperand * comparator: string, // '>', '>=', '<', '<=', '==', '!=' * marginSteps: number, // Number of steps to shift right operand on the scale before blurring * minRulePossibility: number, // Optional: if final rule possibility < this, considered 0 * fallbackBehavior: string // 'allow' or 'deny' if values/operands are insufficient * } */ export class QualitativeRelationalComparatorRule extends BaseRule { constructor(arbiter, ruleEvaluator) { super(arbiter); this.ruleEvaluator = ruleEvaluator; } /** * Evaluate qualitative relational comparison * @protected */ _evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) { const { left: leftOperand, right: rightOperand, comparator, marginSteps = 0, fallbackBehavior = 'deny', minRulePossibility = 0, scaleName = 'five-point' } = rule; try { // Get the qualitative scale const scale = this._getQualitativeScale(scaleName); const ruleMetaBase = { ruleType: 'QualitativeRelationalComparatorRule', userKey, objectKey, comparator, scaleName, marginStepsApplied: rightOperand ? marginSteps : 0, 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, visited, currentRelation, options, 'left', 0, evaluationMeta, scale // No margin for left ); if (evaluationMeta) evaluationMeta.leftOperandDetails = leftOpResult.meta || {}; // Evaluate right operand const rightOpResult = this._evaluateOperand( userId, userKey, objectId, objectKey, rightOperand, visited, currentRelation, options, 'right', marginSteps, evaluationMeta, scale // Apply margin for right ); if (evaluationMeta) evaluationMeta.rightOperandDetails = rightOpResult.meta || {}; // Perform comparison of qualitative intervals let comparisonOutput = this._compareBlurredValues( leftOpResult, rightOpResult, comparator, fallbackBehavior, rule, options, ruleMetaBase, scale ); // Apply minimum rule possibility threshold if (scale.compare(comparisonOutput.possibility, minRulePossibility) < 0) { if (evaluationMeta && evaluationMeta.comparisonStep) { evaluationMeta.comparisonStep.finalPossibilityBeforeMinRule = comparisonOutput.possibility; } comparisonOutput.possibility = scale.bottom; 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.totalEvaluationTime = evaluationMeta.evaluationCompleted - evaluationMeta.evaluationStarted; } return { possibility: comparisonOutput.possibility, reliability: comparisonOutput.reliability, reason: comparisonOutput.reason, meta: evaluationMeta }; } catch (error) { console.error('QualitativeRelationalComparatorRule evaluation error:', error); return { possibility: fallbackBehavior === 'allow' ? 1 : 0, reliability: 0, reason: 'error', meta: { error: error.message, fallbackBehavior } }; } } /** * Get the qualitative scale by name * @private */ _getQualitativeScale(scaleName) { switch (scaleName) { case 'binary': return QualitativeScale.binary(); case 'ternary': return QualitativeScale.ternary(); case 'five-point': return QualitativeScale.fivePoint(); case 'ten-point': return QualitativeScale.tenPoint(); default: console.warn(`Unknown scale name: ${scaleName}, using default five-point scale`); return DEFAULT_QUALITATIVE_SCALE; } } /** * Evaluate a single operand and return qualitative interval and possibility * @private */ _evaluateOperand(userId, userKey, objectId, objectKey, operandConfig, visited, currentRelation, options, side, marginSteps, evaluationMeta, scale) { if (!operandConfig) { return { values: [], possibility: scale.bottom, meta: { error: `No ${side} operand configuration` } }; } try { const operandMeta = options.trackEvaluation ? { side, operandConfig: { ...operandConfig }, marginStepsApplied: marginSteps, evaluationStarted: Date.now() } : null; const evaluateFrom = operandConfig.evaluateFrom || 'auto'; let evalUserId = userId; let evalUserKey = userKey; let evalObjectId = objectId; let evalObjectKey = objectKey; const nestedRuleConfig = operandConfig.rule; if (evaluateFrom === 'user') { if (nestedRuleConfig.type === 'direct' || nestedRuleConfig.type === 'computed') { evalUserId = userId; evalUserKey = userKey; evalObjectId = userId; evalObjectKey = userKey; } else if (!nestedRuleConfig.extractValues) { evalUserId = objectId; evalUserKey = objectKey; evalObjectId = userId; evalObjectKey = userKey; } } else if (evaluateFrom === 'object') { if (nestedRuleConfig.type === 'direct' || nestedRuleConfig.type === 'computed') { evalUserId = objectId; evalUserKey = objectKey; evalObjectId = objectId; evalObjectKey = objectKey; } else { evalUserId = objectId; evalUserKey = objectKey; evalObjectId = userId; evalObjectKey = userKey; } } if (operandMeta) { operandMeta.evaluateFrom = evaluateFrom; operandMeta.evalUserKey = evalUserKey; operandMeta.evalObjectKey = evalObjectKey; } // Evaluate the underlying rule const ruleResult = this.ruleEvaluator.evaluateRule( evalUserId, evalUserKey, evalObjectId, evalObjectKey, operandConfig.rule, visited, currentRelation, options ); if (operandMeta) { operandMeta.ruleResult = { possibility: ruleResult.possibility, reliability: ruleResult.reliability, reason: ruleResult.reason }; } // Extract values from the rule result const extractedValues = this._extractValuesFromRuleResult(ruleResult, operandConfig, scale); if (operandMeta) { operandMeta.extractedValues = extractedValues; } // Apply margin of safety (shift on the scale) const adjustedValues = this._applyMarginSteps(extractedValues, marginSteps, scale); if (operandMeta) { operandMeta.adjustedValues = adjustedValues; } // Extract blurred values with qualitative decay and blur const blurredValues = this._extractBlurredValues(adjustedValues, operandConfig, scale, options); if (operandMeta) { operandMeta.blurredValues = blurredValues; operandMeta.evaluationCompleted = Date.now(); operandMeta.totalEvaluationTime = operandMeta.evaluationCompleted - operandMeta.evaluationStarted; } // Aggregate the blurred values using qualitative OWA const aggregatedResult = this._aggregateCrispValues(blurredValues, operandConfig, scale); return { values: blurredValues, possibility: aggregatedResult.possibility, meta: operandMeta }; } catch (error) { console.error(`Error evaluating ${side} operand:`, error); return { values: [], possibility: scale.bottom, meta: { error: error.message, side } }; } } /** * Extract values from rule result and convert to qualitative scale * @private */ _extractValuesFromRuleResult(ruleResult, operandConfig, scale) { const values = []; if (operandConfig.extractValue !== false && (ruleResult.values || ruleResult.collectedValues)) { const collected = ruleResult.values || ruleResult.collectedValues; // Extract actual values from relations for (const valueObj of collected) { if (valueObj.value !== undefined) { // Convert numeric value to closest qualitative scale value const qualitativeValue = this._convertToQualitativeValue(valueObj.value, scale); values.push({ value: qualitativeValue, possibility: this._convertToQualitativeValue(valueObj.possibility || 1, scale), timestamp: valueObj.timestamp, relation: valueObj.relation, meta: valueObj.meta }); } } } else { // Use rule's possibility as the value const qualitativeValue = this._convertToQualitativeValue(ruleResult.possibility, scale); values.push({ value: qualitativeValue, possibility: qualitativeValue, timestamp: options && options.now !== undefined && options.now !== null ? options.now : Date.now(), relation: 'rule_result', meta: { source: 'rule_possibility' } }); } return values; } /** * Convert a numeric value to the closest qualitative scale value * @private */ _convertToQualitativeValue(numericValue, scale) { // Find the closest value in the scale let closestValue = scale.bottom; let minDistance = Math.abs(numericValue - scale.bottom); for (const scaleValue of scale.values) { const distance = Math.abs(numericValue - scaleValue); if (distance < minDistance) { minDistance = distance; closestValue = scaleValue; } } return closestValue; } /** * Apply margin steps to shift values on the qualitative scale * @private */ _applyMarginSteps(values, marginSteps, scale) { if (marginSteps === 0) return values; return values.map(valueObj => { const currentIndex = scale.indexOf(valueObj.value); const newIndex = Math.max(0, Math.min(scale.size - 1, currentIndex + marginSteps)); const newValue = scale.at(newIndex); return { ...valueObj, value: newValue }; }); } /** * Extract blurred values with qualitative decay and blur * @private */ _extractBlurredValues(values, operandConfig, scale, options = null) { const { decaySteps = 1, decayPeriod = 'HOUR', possibilityDecayDirection = 'down', valueBlurDirection = 'neutral', baseBlurSteps = 1, minOperandPossibility = 0 } = operandConfig; const blurredValues = []; for (const valueObj of values) { const pointValue = valueObj.value; const initialPossibility = valueObj.possibility; const timestamp = valueObj.timestamp || (options && options.now !== undefined && options.now !== null ? options.now : Date.now()); // Calculate periods elapsed const periodsElapsed = this._calculatePeriodsElapsed(timestamp, decayPeriod, options); // Calculate decayed possibility const decayedPossibility = this._calculateDecayedPossibility( initialPossibility, periodsElapsed, decaySteps, possibilityDecayDirection, scale ); // Skip if possibility is too low if (scale.compare(decayedPossibility, minOperandPossibility) < 0) { continue; } // Calculate blur amount based on possibility loss const possibilityLossSteps = this._calculatePossibilityLossSteps( initialPossibility, decayedPossibility, scale ); const blurSteps = Math.floor(possibilityLossSteps * baseBlurSteps); // Create qualitative interval const blurredInterval = this._createQualitativeInterval( pointValue, blurSteps, valueBlurDirection, scale ); blurredValues.push({ interval: blurredInterval, possibility: decayedPossibility, originalValue: pointValue, originalPossibility: initialPossibility, timestamp, relation: valueObj.relation, meta: { ...valueObj.meta, periodsElapsed, possibilityLossSteps, blurSteps } }); } return blurredValues; } /** * Calculate periods elapsed since timestamp * @private */ _calculatePeriodsElapsed(timestamp, decayPeriod, options = null) { const now = options && options.now !== undefined && options.now !== null ? options.now : Date.now(); const elapsed = now - timestamp; const periodMs = { 'MINUTE': 60 * 1000, 'HOUR': 60 * 60 * 1000, 'DAY': 24 * 60 * 60 * 1000, 'WEEK': 7 * 24 * 60 * 60 * 1000, 'MONTH': 30 * 24 * 60 * 60 * 1000, 'YEAR': 365 * 24 * 60 * 60 * 1000 }; return Math.floor(elapsed / (periodMs[decayPeriod] || periodMs['HOUR'])); } /** * Calculate decayed possibility using qualitative scale steps * @private */ _calculateDecayedPossibility(initialPossibility, periodsElapsed, decaySteps, direction, scale) { const initialIndex = scale.indexOf(initialPossibility); const totalDecaySteps = periodsElapsed * decaySteps; let newIndex; switch (direction) { case 'down': newIndex = Math.max(0, initialIndex - totalDecaySteps); break; case 'up': newIndex = Math.min(scale.size - 1, initialIndex + totalDecaySteps); break; case 'neutral': const targetIndex = Math.floor(scale.size / 2); // Middle of scale if (initialIndex > targetIndex) { newIndex = Math.max(targetIndex, initialIndex - totalDecaySteps); } else { newIndex = Math.min(targetIndex, initialIndex + totalDecaySteps); } break; case 'stable': default: newIndex = initialIndex; break; } return scale.at(newIndex); } /** * Calculate the number of steps of possibility loss * @private */ _calculatePossibilityLossSteps(initialPossibility, decayedPossibility, scale) { const initialIndex = scale.indexOf(initialPossibility); const decayedIndex = scale.indexOf(decayedPossibility); return Math.abs(initialIndex - decayedIndex); } /** * Create a qualitative interval by blurring around a point value * @private */ _createQualitativeInterval(pointValue, blurSteps, direction, scale) { const pointIndex = scale.indexOf(pointValue); let lowerIndex, upperIndex; switch (direction) { case 'down': lowerIndex = Math.max(0, pointIndex - blurSteps); upperIndex = pointIndex; break; case 'up': lowerIndex = pointIndex; upperIndex = Math.min(scale.size - 1, pointIndex + blurSteps); break; case 'neutral': default: lowerIndex = Math.max(0, pointIndex - blurSteps); upperIndex = Math.min(scale.size - 1, pointIndex + blurSteps); break; } return { lower: scale.at(lowerIndex), upper: scale.at(upperIndex) }; } /** * Aggregate blurred values using qualitative OWA fusion with optional bilattice reasoning * @private */ _aggregateCrispValues(blurredValues, operandConfig, scale) { if (blurredValues.length === 0) { return { possibility: scale.bottom }; } if (blurredValues.length === 1) { return { possibility: blurredValues[0].possibility }; } const aggregator = operandConfig.aggregator || 'max'; const useBilattice = operandConfig.useBilattice || false; const epistemicMode = operandConfig.epistemicMode || 'hybrid'; const capacityType = operandConfig.capacityType || 'simple_support'; // Convert blurred values to collected values format for bilattice analysis const collectedValues = blurredValues.map((bv, index) => ({ value: bv.possibility, // Use possibility as the value for bilattice analysis possibility: bv.possibility, path: [`blurred_value_${index}`], source: { entityKey: 'qualitative_operand', relation: 'blurred_value', step: index }, metadata: { timestamp: bv.timestamp || (options && options.now !== undefined && options.now !== null ? options.now : Date.now()), reliability: 1.0, originalValue: bv.originalValue, originalPossibility: bv.originalPossibility, interval: bv.interval, blurSteps: bv.meta?.blurSteps, periodsElapsed: bv.meta?.periodsElapsed } })); // Use bilattice-enhanced evidence combination if enabled if (useBilattice) { const capacity = this._createCapacityFromValues(collectedValues, scale, capacityType); const bilatticeResult = this._combineEvidenceWithBilattice(collectedValues, { method: aggregator, useBilattice: true, capacity: capacity, scale: scale, epistemicMode: epistemicMode }); return { possibility: bilatticeResult.value, epistemicAnalysis: bilatticeResult.epistemicAnalysis, aggregationMethod: `bilattice_${epistemicMode}` }; } // Standard qualitative OWA fusion const values = blurredValues.map(v => v.possibility); const metas = blurredValues.map(v => v.meta); // Generate OWA weights const weights = getOWAQualitativeWeights(aggregator, values.length, null, scale); // Perform qualitative OWA fusion const result = OWAQualitativeFusion.fuseWithMeta(values, weights, weights, aggregator, scale); return { possibility: result.value, aggregationMethod: `owa_${aggregator}` }; } /** * @deprecated Use _aggregateCrispValues instead. Removal after Stage 2. */ _aggregateBlurredValues(blurredValues, operandConfig, scale) { if (!this._warnedAggregateBlurredValues) { this._warnedAggregateBlurredValues = true; console.warn('[QualitativeRelationalComparatorRule] _aggregateBlurredValues is deprecated; use _aggregateCrispValues instead.'); } return this._aggregateCrispValues(blurredValues, operandConfig, scale); } /** * Compare blurred qualitative intervals * @private */ _compareBlurredValues(leftResult, rightResult, comparator, fallbackBehavior, rule, options, ruleMetaBase, scale) { const leftValues = leftResult.values || []; const rightValues = rightResult.values || []; if (leftValues.length === 0 || rightValues.length === 0) { return { possibility: fallbackBehavior === 'allow' ? scale.top : scale.bottom, reliability: 0, reason: 'insufficient_values' }; } // Compare all combinations of left and right intervals const comparisonResults = []; for (const leftValue of leftValues) { for (const rightValue of rightValues) { const comparisonPossibility = this._calculateQualitativeIntervalComparison( leftValue.interval, rightValue.interval, comparator, scale ); // Combine with confidence weights (min operation in qualitative logic) const combinedPossibility = scale.min(comparisonPossibility, scale.min(leftValue.possibility, rightValue.possibility)); comparisonResults.push({ possibility: combinedPossibility, leftInterval: leftValue.interval, rightInterval: rightValue.interval, leftPossibility: leftValue.possibility, rightPossibility: rightValue.possibility }); } } // Take the maximum possibility across all comparisons const maxPossibility = scale.maxAll(comparisonResults.map(r => r.possibility)); return { possibility: maxPossibility, reliability: 1, // Qualitative comparisons are considered fully reliable reason: 'qualitative_interval_comparison' }; } /** * Calculate qualitative interval comparison using possibility theory * @private */ _calculateQualitativeIntervalComparison(leftInterval, rightInterval, comparator, scale) { const { lower: lLower, upper: lUpper } = leftInterval; const { lower: rLower, upper: rUpper } = rightInterval; switch (comparator) { case '>': // Possibility(L > R): Is it possible that a value from L is greater than a value from R? // This is true if the top of L is greater than the bottom of R return scale.compare(lUpper, rLower) > 0 ? scale.top : scale.bottom; case '>=': // Possibility(L >= R): Is it possible that a value from L is >= a value from R? return scale.compare(lUpper, rLower) >= 0 ? scale.top : scale.bottom; case '<': // Possibility(L < R): Is it possible that a value from L is less than a value from R? // This is true if the bottom of L is less than the top of R return scale.compare(lLower, rUpper) < 0 ? scale.top : scale.bottom; case '<=': // Possibility(L <= R): Is it possible that a value from L is <= a value from R? return scale.compare(lLower, rUpper) <= 0 ? scale.top : scale.bottom; case '==': // Possibility(L == R): Is it possible that intervals overlap? // This is true if there's any overlap between the intervals return (scale.compare(lUpper, rLower) >= 0 && scale.compare(lLower, rUpper) <= 0) ? scale.top : scale.bottom; case '!=': // Possibility(L != R): Is it possible that intervals don't overlap? // This is true if there's no overlap between the intervals return (scale.compare(lUpper, rLower) < 0 || scale.compare(lLower, rUpper) > 0) ? scale.top : scale.bottom; default: console.warn(`Unknown comparator: ${comparator}`); return scale.bottom; } } }