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

684 lines
24 KiB
JavaScript
Raw Normal View History

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