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); // A caller-pinned clock (options.now) makes the result per-clock: a // chain result captured at one time (with then-fresh values) must not // be served to a caller asking about another time. Same contract as // the rule result cache (RuleEvaluator): pinned-clock callers bypass // the chain cache entirely — both reads and writes. const temporalPinned = options.now !== undefined && options.now !== 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 && !temporalPinned) { 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); const nextReliability = (currentPath.reliability ?? 1.0) * (rel.reliability ?? 1.0); if (existing && existing.possibility > nextPossibility) continue; if (existing && existing.possibility === nextPossibility && (existing.reliability ?? 1.0) >= nextReliability) continue; // Create extended path const extendedPath = { id: nextId, key: nextKey, possibility: nextPossibility, reliability: nextReliability, 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; let finalReliability = 1.0; if (targetPaths.length > 0) { // Use MAX across paths (disjunctive); the winning path's reliability // is the product of its edges' reliabilities (MIN along the chain). const best = targetPaths.reduce((a, b) => (b.possibility > a.possibility || (b.possibility === a.possibility && (b.reliability ?? 1.0) > (a.reliability ?? 1.0))) ? b : a ); finalPossibility = best.possibility; finalReliability = best.reliability ?? 1.0; } 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: finalReliability, validity: this._validity('min', normalizedRule.steps.map(s => s.relation).filter(Boolean), [], normalizedRule.steps.length, finalPossibility), ...(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). // Do not cache pinned-clock results either — the entry is per-clock // and would be served to later unpinned callers as if it were timeless. if (this.chainResultCache && !hasPartialGraph && !isThresholdEval && !temporalPinned) { 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); const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now(); if (entry && cacheNow - 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 const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now(); this.chainResultCache.set(key, { result, timestamp: cacheNow }); } _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 callerNow = options && options.now !== undefined && options.now !== null ? options.now : null; const blurred = this.arbiter.valueManager.getBlurredValue(relation, callerNow); 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 || (options && options.now !== undefined && options.now !== null ? options.now : 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 callerNow = options && options.now !== undefined && options.now !== null ? options.now : null; const blurred = this.arbiter.relationManager.valueManager.getBlurredValue(tempRelation, callerNow); 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; } }