initial commit: @arbiter/core authorization engine with js-rigor hardening
Zanzibar-style authorization graph engine (direct/chain/TTU/defeasible/ binary modes, condensed snapshots, value relations) with 39 rigor test campaigns. Includes fixes for snapshot binary writer/reader format mismatch (snapshot-of-snapshot corruption), possibility write-boundary validation, empty-graph snapshot serialization, relation lookup cache direction collision, config-redefinition cache invalidation, binary threshold semantics, defeasible compiled routing, and comparator reason whitelisting.
This commit is contained in:
@@ -0,0 +1,693 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { Arbiter } from '../../core/Arbiter.js';
|
||||
import { OWAFusion } from '../../utils/OWAFusion.js';
|
||||
import { BilatticeOrderings } from '../../qualitative/BilatticeOrderings.js';
|
||||
import { QualitativeCapacity } from '../../qualitative/QualitativeCapacity.js';
|
||||
import { QualitativeScale } from '../../qualitative/QualitativeScale.js';
|
||||
|
||||
/**
|
||||
* ChainRule - Evaluates access by following a chain of relations and collecting values along the path
|
||||
*
|
||||
* This rule enables traversing through multiple entities via relations and collects
|
||||
* values from each step in the path. Perfect for scenarios like:
|
||||
* "Sum balances from all accounts user can debit from"
|
||||
*
|
||||
* CLEAN SEMANTICS:
|
||||
* - Authorization: Based on path reachability to target entity (returns raw possibility)
|
||||
* - Value Collection: Collects values along the path with full path tracking
|
||||
* - Uses ValueContext for efficient caching and aggregation
|
||||
*
|
||||
* Path Semantics:
|
||||
* - Along chain: MIN operator (possibilistic conjunction)
|
||||
* - Across paths: MAX/OWA operator (disjunctive)
|
||||
* - Values: Collected with full path metadata for aggregation at logical level
|
||||
*
|
||||
* Configuration:
|
||||
* {
|
||||
* type: 'chain',
|
||||
* steps: [
|
||||
* { relation: 'can_debit', direction: 'out' }, // user → accounts
|
||||
* { relation: 'has_balance', direction: 'out' } // accounts → currency
|
||||
* ],
|
||||
* // Value collection (optional - defaults to enabled)
|
||||
* collectValues: true, // Whether to collect values (default: true)
|
||||
* valueFilters: { // Optional filters for value collection
|
||||
* steps: [0, 1], // Which steps to collect from (default: all)
|
||||
* relations: ['has_balance'], // Which relations to collect from (default: step relations)
|
||||
* minValue: 0, // Minimum value threshold
|
||||
* maxValue: 1000 // Maximum value threshold
|
||||
* },
|
||||
* valueAggregation: 'sum', // How to pre-aggregate VALUES ('sum', 'max', 'min', 'average')
|
||||
*
|
||||
* // Standard rule fields
|
||||
* reverse: false // Reverse traversal direction (default: false)
|
||||
* }
|
||||
*/
|
||||
export class ChainRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
|
||||
// Chain-specific caching with HyperbolicLRUCache for better memory management
|
||||
this.maxCacheSize = 2000;
|
||||
this.cacheTTL = 600000; // 10 minutes
|
||||
this.pathCacheTTL = 300000; // 5 minutes
|
||||
|
||||
// Only create caches if caching is not disabled
|
||||
if (!arbiter.disableCaching && !arbiter.disableChainCaching) {
|
||||
// Chain result cache with HyperbolicLRUCache
|
||||
this.chainResultCache = arbiter.cacheFactory(this.maxCacheSize, {
|
||||
onEvict: (key, value) => {
|
||||
// Optional: track evictions for debugging
|
||||
this.stats = this.stats || {};
|
||||
this.stats.evictedResults = (this.stats.evictedResults || 0) + 1;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.chainResultCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate chain rule with smart reachability optimization
|
||||
* Uses reachability check as a hint, but doesn't fail fast if TreeCover index is uncertain
|
||||
*/
|
||||
evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
// Normalize rule to get reverse flag
|
||||
const normalizedRule = this._normalizeRule(rule);
|
||||
const { reverse = false } = normalizedRule;
|
||||
|
||||
// Check if PLTC bypass is requested (for testing/ground truth computation)
|
||||
// Also skip when a partial graph is present — PLTC is built from persistent data only
|
||||
const { bypassPLTC = false } = options;
|
||||
const hasPartialGraph = !!options.partialGraphContext;
|
||||
|
||||
if (!bypassPLTC && !hasPartialGraph) {
|
||||
// Smart reachability check: PLTC is 100% accurate, so we can fail fast on false
|
||||
// Use backward index for reverse chains
|
||||
const direction = reverse ? 'backward' : 'forward';
|
||||
const reachabilityResult = this._quickReachabilityCheck(userKey, objectKey, { direction });
|
||||
|
||||
if (reachabilityResult === false) {
|
||||
// PLTC is 100% accurate - if it says false, definitely no path exists
|
||||
// Fast fail for unreachable cases
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
reliability: 1.0,
|
||||
...(options.includeMeta && {
|
||||
meta: {
|
||||
method: 'pltc_fast_fail',
|
||||
reason: 'not_reachable',
|
||||
sourceKey: userKey,
|
||||
targetKey: objectKey,
|
||||
direction
|
||||
}
|
||||
}),
|
||||
reason: 'not_reachable'
|
||||
}, []);
|
||||
}
|
||||
|
||||
// If reachabilityResult is true, path exists but we still need to check relation types
|
||||
// If null, PLTC not initialized, proceed with chain evaluation
|
||||
}
|
||||
|
||||
// Proceed with chain evaluation (either PLTC said true/null, or bypassPLTC is enabled)
|
||||
return this._evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate chain traversal and value collection using ValueContext
|
||||
* @protected
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
const { fastPath, minPossibility, valueContext, includeMeta = true } = options;
|
||||
// Normalize rule configuration
|
||||
const normalizedRule = this._normalizeRule(rule);
|
||||
const {
|
||||
steps,
|
||||
collectValues = true, // Default to true
|
||||
valueFilters = {},
|
||||
valueAggregation = 'sum',
|
||||
reverse = false
|
||||
} = normalizedRule;
|
||||
const collectValuesEnabled = collectValues && !!valueContext;
|
||||
|
||||
if (!steps || steps.length === 0) {
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
reliability: 1.0,
|
||||
...(includeMeta && { meta: null }),
|
||||
reason: 'no_chain_steps_defined'
|
||||
}, []);
|
||||
}
|
||||
|
||||
// Convert string keys to numeric IDs if needed
|
||||
const userIdNum = typeof userId === 'string' ? this.arbiter.resolveNodeId(userId, options) : userId;
|
||||
const objectIdNum = typeof objectId === 'string' ? this.arbiter.resolveNodeId(objectId, options) : objectId;
|
||||
|
||||
const hasPartialGraph = !!(options.partialGraphContext);
|
||||
|
||||
// Threshold-mode (binary / fastPath-with-threshold) evaluations collapse
|
||||
// sub-threshold paths to 0 and early-exit; their results are NOT
|
||||
// interchangeable with full-mode values. The shared chain result cache
|
||||
// must be neither consulted nor populated in threshold mode, or binary
|
||||
// checks get served full-mode values (and vice versa).
|
||||
const isThresholdEval = options.binary === true || (options.fastPath === true && options.minPossibility != null);
|
||||
|
||||
// Check for cached chain result (use numeric IDs) - only if caching is enabled
|
||||
// Skip cache when a partial graph is present to prevent cross-request leakage
|
||||
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval) {
|
||||
const cachedResult = this._getCachedChainResult(userIdNum, objectIdNum, steps);
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine starting point
|
||||
let startId, startKey;
|
||||
if (reverse) {
|
||||
startId = objectIdNum;
|
||||
startKey = objectKey;
|
||||
} else {
|
||||
startId = userIdNum;
|
||||
startKey = userKey;
|
||||
}
|
||||
|
||||
// Track all paths through the chain with their accumulated possibilities
|
||||
let currentPaths = [{
|
||||
id: startId,
|
||||
key: startKey,
|
||||
possibility: 1.0,
|
||||
path: [startKey], // Track the full path
|
||||
pathEntities: [{ id: startId, key: startKey, source: 'persistent' }]
|
||||
}];
|
||||
|
||||
let allCollectedValues = [];
|
||||
|
||||
// Traverse through each step
|
||||
for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
|
||||
// Normalize string steps (emitted by the DSL compiler as
|
||||
// ['works_in','has_access']) to the object form the traversal expects.
|
||||
const rawStep = steps[stepIndex];
|
||||
const step = typeof rawStep === 'string'
|
||||
? { relation: rawStep, direction: 'out' }
|
||||
: rawStep;
|
||||
const { relation: stepRelation, direction } = step;
|
||||
|
||||
if (!stepRelation || !direction) {
|
||||
currentPaths = [];
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
// For each current path, extend it via the relation
|
||||
const pathMap = new Map();
|
||||
const MAX_PATHS_PER_STEP = 100;
|
||||
|
||||
for (const currentPath of currentPaths) {
|
||||
if (pathMap.size >= MAX_PATHS_PER_STEP) break;
|
||||
|
||||
const relations = this._getRelationsForStep(currentPath.id, stepRelation, direction, options);
|
||||
|
||||
for (const rel of relations) {
|
||||
const nextId = direction === 'in' ? rel.src : rel.dst;
|
||||
const nextKey = this.arbiter.resolveKey(nextId, options);
|
||||
|
||||
if (nextKey) {
|
||||
// Calculate path possibility (MIN along chain)
|
||||
const nextPossibility = Math.min(currentPath.possibility, rel.possibility ?? 1.0);
|
||||
|
||||
if (fastPath && nextPossibility < minPossibility) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect values from this step BEFORE path deduplication:
|
||||
// a weaker parallel path is still a valid value source, and
|
||||
// dropping it first would silently discard its contribution.
|
||||
if (collectValuesEnabled && this._shouldCollectFromStep(stepIndex, valueFilters)) {
|
||||
const stepValues = this._collectValuesFromStep(
|
||||
currentPath,
|
||||
rel,
|
||||
stepIndex,
|
||||
stepRelation,
|
||||
direction,
|
||||
valueFilters,
|
||||
valueContext,
|
||||
options
|
||||
);
|
||||
allCollectedValues.push(...stepValues);
|
||||
}
|
||||
|
||||
// Deduplicate: keep best path per node
|
||||
const existing = pathMap.get(nextId);
|
||||
if (existing && existing.possibility >= nextPossibility) continue;
|
||||
|
||||
// Create extended path
|
||||
const extendedPath = {
|
||||
id: nextId,
|
||||
key: nextKey,
|
||||
possibility: nextPossibility,
|
||||
path: [...currentPath.path, nextKey],
|
||||
pathEntities: [...currentPath.pathEntities, { id: nextId, key: nextKey, source: rel.source || 'persistent' }]
|
||||
};
|
||||
|
||||
pathMap.set(nextId, extendedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentPaths = Array.from(pathMap.values());
|
||||
|
||||
|
||||
// Early exit if no paths found
|
||||
if (currentPaths.length === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if target was reached and calculate final possibility
|
||||
const targetKey = reverse ? userKey : objectKey;
|
||||
const targetId = reverse ? userIdNum : objectIdNum;
|
||||
const targetPaths = currentPaths.filter(path => path.id === targetId);
|
||||
let finalPossibility = 0;
|
||||
|
||||
if (targetPaths.length > 0) {
|
||||
// Use MAX across paths (disjunctive)
|
||||
finalPossibility = Math.max(...targetPaths.map(path => path.possibility));
|
||||
}
|
||||
|
||||
if (fastPath && finalPossibility < minPossibility) {
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
reliability: 1.0,
|
||||
meta: null,
|
||||
reason: 'no_chain_path_found'
|
||||
}, []);
|
||||
}
|
||||
// Add collected values to ValueContext if available
|
||||
if (valueContext && allCollectedValues.length > 0) {
|
||||
valueContext.addCollectedValues(allCollectedValues, 'chain', rule);
|
||||
}
|
||||
|
||||
// Apply value aggregation with optional bilattice reasoning
|
||||
const useBilattice = normalizedRule.useBilattice || false;
|
||||
const epistemicMode = normalizedRule.epistemicMode || 'hybrid';
|
||||
const capacityType = normalizedRule.capacityType || 'simple_support';
|
||||
|
||||
let aggregatedCollectedValues;
|
||||
let epistemicAnalysis = null;
|
||||
|
||||
if (useBilattice && allCollectedValues.length > 0) {
|
||||
const scale = QualitativeScale.fivePoint(); // Default scale for bilattice analysis
|
||||
const capacity = this._createCapacityFromValues(allCollectedValues, scale, capacityType);
|
||||
|
||||
const bilatticeResult = this._combineEvidenceWithBilattice(allCollectedValues, {
|
||||
method: valueAggregation,
|
||||
useBilattice: true,
|
||||
capacity: capacity,
|
||||
scale: scale,
|
||||
epistemicMode: epistemicMode
|
||||
});
|
||||
|
||||
// Convert bilattice result back to collected values format
|
||||
aggregatedCollectedValues = [{
|
||||
value: bilatticeResult.value,
|
||||
possibility: bilatticeResult.possibility,
|
||||
path: ['bilattice_aggregated'],
|
||||
source: {
|
||||
entityKey: 'chain_aggregation',
|
||||
relation: 'bilattice_fusion',
|
||||
step: -1
|
||||
},
|
||||
metadata: {
|
||||
timestamp: Date.now(),
|
||||
reliability: 1.0,
|
||||
aggregationMethod: `bilattice_${epistemicMode}`,
|
||||
epistemicAnalysis: bilatticeResult.epistemicAnalysis
|
||||
}
|
||||
}];
|
||||
|
||||
epistemicAnalysis = bilatticeResult.epistemicAnalysis;
|
||||
} else {
|
||||
aggregatedCollectedValues = this._aggregateCollectedValues(
|
||||
allCollectedValues,
|
||||
valueAggregation
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Build authorization result with single possibility value
|
||||
const authResult = {
|
||||
possibility: finalPossibility,
|
||||
reliability: 1.0,
|
||||
...(includeMeta && {
|
||||
meta: finalPossibility > 0 ? {
|
||||
ruleType: 'chain',
|
||||
reason: 'chain_path_found',
|
||||
rule,
|
||||
chainLength: steps.length,
|
||||
pathsToTarget: targetPaths.length,
|
||||
totalPaths: currentPaths.length,
|
||||
bestPath: targetPaths.length > 0 ? targetPaths[0].path : null,
|
||||
bestPathEntities: targetPaths.length > 0 ? targetPaths[0].pathEntities : null,
|
||||
pathSteps: targetPaths.length > 0 ? targetPaths[0].pathEntities : null,
|
||||
useBilattice: useBilattice,
|
||||
epistemicMode: useBilattice ? epistemicMode : undefined,
|
||||
epistemicAnalysis: epistemicAnalysis
|
||||
} : null
|
||||
}),
|
||||
...(includeMeta && {
|
||||
meta_allow: finalPossibility > 0 ? {
|
||||
ruleType: 'chain',
|
||||
reason: 'chain_path_found',
|
||||
rule,
|
||||
chainLength: steps.length,
|
||||
pathsToTarget: targetPaths.length,
|
||||
totalPaths: currentPaths.length,
|
||||
bestPath: targetPaths.length > 0 ? targetPaths[0].path : null,
|
||||
bestPathEntities: targetPaths.length > 0 ? targetPaths[0].pathEntities : null,
|
||||
pathSteps: targetPaths.length > 0 ? targetPaths[0].pathEntities : null
|
||||
} : null
|
||||
}),
|
||||
reason: finalPossibility > 0 ? 'chain_path_found' : 'no_chain_path_found'
|
||||
};
|
||||
|
||||
const result = this._createStandardResult(authResult, aggregatedCollectedValues);
|
||||
|
||||
// Cache the chain result (use numeric IDs) - only if caching is enabled
|
||||
// Do not cache when a partial graph is present to prevent cross-request leakage
|
||||
// Do not cache threshold-mode results (see isThresholdEval above)
|
||||
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval) {
|
||||
this._cacheChainResult(userIdNum, objectIdNum, steps, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relations for a step based on direction
|
||||
* @private
|
||||
*/
|
||||
_getRelationsForStep(entityId, relation, direction, options = null) {
|
||||
if (direction === 'out') {
|
||||
return this.arbiter.relationManager.getRelationsFromSrc(entityId, relation, options);
|
||||
} else if (direction === 'in') {
|
||||
return this.arbiter.relationManager.getRelationsToDst(entityId, relation, options);
|
||||
} else {
|
||||
// Default to 'out' for backward compatibility
|
||||
return this.arbiter.relationManager.getRelationsFromSrc(entityId, relation, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for chain result using numeric IDs
|
||||
* @private
|
||||
*/
|
||||
_getChainResultCacheKey(userId, objectId, steps) {
|
||||
return this.arbiter.keyManager.createChainKey(userId, objectId, steps);
|
||||
}
|
||||
|
||||
_getCachedChainResult(userId, objectId, steps) {
|
||||
if (!this.chainResultCache) return null;
|
||||
|
||||
const key = this._getChainResultCacheKey(userId, objectId, steps);
|
||||
const entry = this.chainResultCache.get(key);
|
||||
|
||||
if (entry && Date.now() - entry.timestamp < this.cacheTTL) {
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
// HyperbolicLRUCache handles eviction automatically, no need to manually delete
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache chain result
|
||||
* @private
|
||||
*/
|
||||
_cacheChainResult(userId, objectId, steps, result) {
|
||||
if (!this.chainResultCache) return;
|
||||
|
||||
const key = this._getChainResultCacheKey(userId, objectId, steps);
|
||||
|
||||
// HyperbolicLRUCache handles eviction automatically based on frequency and recency
|
||||
this.chainResultCache.set(key, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
_invalidateAllChainCaches() {
|
||||
if (this.chainResultCache) {
|
||||
this.chainResultCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should collect values from this step
|
||||
* @private
|
||||
*/
|
||||
_shouldCollectFromStep(stepIndex, valueFilters) {
|
||||
if (!valueFilters) return true;
|
||||
|
||||
// Check step filter
|
||||
if (valueFilters.steps && Array.isArray(valueFilters.steps)) {
|
||||
return valueFilters.steps.includes(stepIndex);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect values from a single step in the chain
|
||||
* @private
|
||||
*/
|
||||
_collectValuesFromStep(currentPath, relation, stepIndex, stepRelation, direction, valueFilters, valueContext, options = null) {
|
||||
const collectedValues = [];
|
||||
|
||||
// Check if this relation should be collected based on filters
|
||||
if (valueFilters.relations && Array.isArray(valueFilters.relations)) {
|
||||
if (!valueFilters.relations.includes(stepRelation)) {
|
||||
return collectedValues;
|
||||
}
|
||||
}
|
||||
|
||||
// If relation has a value, collect it as a blurred interval
|
||||
if (relation.value !== undefined && relation.value !== null) {
|
||||
// Apply value filters
|
||||
if (valueFilters.minValue !== undefined && relation.value < valueFilters.minValue) {
|
||||
return collectedValues;
|
||||
}
|
||||
if (valueFilters.maxValue !== undefined && relation.value > valueFilters.maxValue) {
|
||||
return collectedValues;
|
||||
}
|
||||
|
||||
// Let ValueManager handle age-based blurring instead of hard TTL filtering
|
||||
// The ValueManager will apply appropriate blurring based on relation age
|
||||
|
||||
// Get blurred interval from ValueManager
|
||||
if (!relation) {
|
||||
return collectedValues;
|
||||
}
|
||||
|
||||
if (!this.arbiter.valueManager) {
|
||||
return collectedValues;
|
||||
}
|
||||
|
||||
const blurred = this.arbiter.valueManager.getBlurredValue(relation);
|
||||
|
||||
if (blurred.interval) {
|
||||
const sourceEntity = direction === 'in' ?
|
||||
this.arbiter.resolveKey(relation.dst, options) :
|
||||
this.arbiter.resolveKey(relation.src, options);
|
||||
const targetEntity = direction === 'in' ?
|
||||
this.arbiter.resolveKey(relation.src, options) :
|
||||
this.arbiter.resolveKey(relation.dst, options);
|
||||
|
||||
const collectedValue = this._createCollectedValue(
|
||||
blurred.interval, // Pass interval instead of point value
|
||||
blurred.possibility, // Use decayed possibility
|
||||
currentPath.path,
|
||||
{
|
||||
entityKey: sourceEntity,
|
||||
relation: stepRelation,
|
||||
step: stepIndex,
|
||||
direction: direction,
|
||||
fullPath: currentPath.path,
|
||||
stepPosition: stepIndex,
|
||||
originalValue: relation.value, // Keep original point value for reference
|
||||
source: relation.source || 'persistent'
|
||||
},
|
||||
{
|
||||
timestamp: relation.changed_last_at || relation.updated_last_at || Date.now(),
|
||||
reliability: blurred.reliability,
|
||||
pathPossibility: currentPath.possibility,
|
||||
relationPossibility: relation.possibility ?? 1.0,
|
||||
currentPossibility: blurred.possibility, // Add current (decayed) possibility
|
||||
interval: blurred.interval, // Include interval in metadata
|
||||
source: relation.source || 'persistent'
|
||||
}
|
||||
);
|
||||
|
||||
collectedValues.push(collectedValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Also try to get values from ValueContext if available
|
||||
if (valueContext && currentPath.pathEntities.length > stepIndex) {
|
||||
const entity = currentPath.pathEntities[stepIndex];
|
||||
const contextValues = valueContext.getValues(entity.id, stepRelation);
|
||||
|
||||
for (const contextValue of contextValues) {
|
||||
// Apply value filters
|
||||
if (valueFilters.minValue !== undefined && contextValue.value < valueFilters.minValue) {
|
||||
continue;
|
||||
}
|
||||
if (valueFilters.maxValue !== undefined && contextValue.value > valueFilters.maxValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Let ValueManager handle age-based blurring for ValueContext values too
|
||||
|
||||
// Create a temporary relation object for ValueManager
|
||||
const tempRelation = {
|
||||
src: entity.id,
|
||||
dst: entity.id, // Self-relation for value storage
|
||||
rel: stepRelation,
|
||||
value: contextValue.value,
|
||||
possibility: contextValue.possibility,
|
||||
reliability: contextValue.reliability,
|
||||
changed_last_at: contextValue.timestamp
|
||||
};
|
||||
|
||||
if (!tempRelation) {
|
||||
Arbiter.DEBUG && Arbiter.log('ChainRule: tempRelation is undefined, skipping value collection');
|
||||
return collectedValues;
|
||||
}
|
||||
|
||||
if (!this.arbiter.relationManager || !this.arbiter.relationManager.valueManager) {
|
||||
Arbiter.DEBUG && Arbiter.log('ChainRule: relationManager or valueManager is undefined');
|
||||
return collectedValues;
|
||||
}
|
||||
const blurred = this.arbiter.relationManager.valueManager.getBlurredValue(tempRelation);
|
||||
|
||||
if (blurred.interval) {
|
||||
const collectedValue = this._createCollectedValue(
|
||||
blurred.interval,
|
||||
Math.min(currentPath.possibility, blurred.possibility),
|
||||
currentPath.path,
|
||||
{
|
||||
entityKey: entity.key,
|
||||
relation: stepRelation,
|
||||
step: stepIndex,
|
||||
direction: direction,
|
||||
fullPath: currentPath.path,
|
||||
stepPosition: stepIndex,
|
||||
fromValueContext: true,
|
||||
originalValue: contextValue.value
|
||||
},
|
||||
{
|
||||
timestamp: contextValue.timestamp,
|
||||
reliability: blurred.reliability,
|
||||
pathPossibility: currentPath.possibility,
|
||||
relationPossibility: contextValue.possibility,
|
||||
currentPossibility: blurred.possibility,
|
||||
interval: blurred.interval,
|
||||
source: contextValue.source || 'persistent'
|
||||
}
|
||||
);
|
||||
|
||||
collectedValues.push(collectedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collectedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate collected values based on aggregation method using interval arithmetic
|
||||
* @private
|
||||
*/
|
||||
_aggregateCollectedValues(collectedValues, aggregationMethod) {
|
||||
if (collectedValues.length === 0) return [];
|
||||
if (collectedValues.length === 1) return collectedValues;
|
||||
|
||||
// Group values by path for aggregation
|
||||
const valuesByPath = new Map();
|
||||
|
||||
for (const cv of collectedValues) {
|
||||
const pathKey = cv.source?.fullPath?.join('->') || 'unknown_path';
|
||||
if (!valuesByPath.has(pathKey)) {
|
||||
valuesByPath.set(pathKey, []);
|
||||
}
|
||||
valuesByPath.get(pathKey).push(cv);
|
||||
}
|
||||
|
||||
const aggregatedValues = [];
|
||||
|
||||
for (const [pathKey, pathValues] of valuesByPath) {
|
||||
if (pathValues.length === 1) {
|
||||
aggregatedValues.push(pathValues[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract intervals and metadata for OWA fusion
|
||||
const intervals = pathValues.map(cv => cv.value); // cv.value is already an interval
|
||||
const possibilities = pathValues.map(cv => cv.possibility);
|
||||
const metas = pathValues.map(cv => ({
|
||||
...cv.source,
|
||||
...cv.metadata,
|
||||
originalInterval: cv.value
|
||||
}));
|
||||
|
||||
// Use OWAFusion's interval arithmetic
|
||||
const intervalResult = OWAFusion.fuseIntervalsWithMeta(
|
||||
intervals,
|
||||
metas,
|
||||
null, // Use default weights
|
||||
aggregationMethod
|
||||
);
|
||||
|
||||
// Aggregate possibilities
|
||||
const possibilityResult = OWAFusion.fuseWithMeta(
|
||||
possibilities,
|
||||
metas,
|
||||
null,
|
||||
aggregationMethod === 'sum' ? 'average' : aggregationMethod // For sum, average possibilities
|
||||
);
|
||||
|
||||
// Create aggregated collected value
|
||||
const aggregatedCV = this._createCollectedValue(
|
||||
intervalResult.interval,
|
||||
possibilityResult.value,
|
||||
pathValues[0].path,
|
||||
{
|
||||
...pathValues[0].source,
|
||||
aggregationMethod,
|
||||
aggregatedFromCount: pathValues.length
|
||||
},
|
||||
{
|
||||
...pathValues[0].metadata,
|
||||
reliability: intervalResult.meta?.reliability ||
|
||||
Math.min(...pathValues.map(cv => cv.metadata?.reliability || 1.0)),
|
||||
aggregatedFromIntervals: intervals,
|
||||
interval: intervalResult.interval
|
||||
}
|
||||
);
|
||||
|
||||
aggregatedValues.push(aggregatedCV);
|
||||
}
|
||||
|
||||
return aggregatedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize rule configuration (for future extensibility)
|
||||
* @private
|
||||
*/
|
||||
_normalizeRule(rule) {
|
||||
if (rule.chain) return { ...rule, ...rule.chain, chain: undefined };
|
||||
return rule;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user