import { BaseRule } from './BaseRule.js'; import { Arbiter } from '../../core/Arbiter.js'; import { OWAFusion } from '../../utils/OWAFusion.js'; /** * MultiHopRule - Evaluates access through multi-hop path finding and collects values along paths * * This rule implements path-based access control where access is granted if there exists * a valid path of relations from user to object (or vice versa) within specified constraints. * Simultaneously collects values along discovered paths. * * Supports multiple path aggregation strategies, * and fallback to basic connectivity when sophisticated search fails. * * Returns raw possibility values - polarity determined by logical context. * * Configuration: * { * type: 'multi_hop', * relation: string, // Relation to traverse * maxDepth: number, // Maximum path depth (default: 5) * pathAggregation: string, // 'max', 'sum', 'owa' (default: 'max') * reverse: boolean, // Search in reverse direction * fallbackToBasicPaths: boolean, // Use basic BFS fallback (default: true) * owaWeights: Array, // OWA weights for path aggregation * * // Value collection (optional) * collectValues: boolean, // Whether to collect values along paths (default: true) * valueFilters: { // Optional filters for value collection * relations: ['has_balance'], // Which relations to collect from (default: path relation) * minValue: 0, // Minimum value threshold * maxValue: 1000, // Maximum value threshold * }, * valueAggregation: 'sum' // How to aggregate values ('sum', 'max', 'min', 'average') * } */ export class MultiHopRule extends BaseRule { constructor(arbiter) { super(arbiter); } /** * Evaluate multi-hop path-based access with value collection and reachability optimization * @protected */ _evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) { const { fastPath = false, minPossibility = 0, valueContext, includeMeta = true } = options; // Normalize rule configuration const normalizedRule = this._normalizeRule(rule); const { relation, maxDepth = 5, pathAggregation = 'max', reverse = false, fallbackToBasicPaths = true, owaWeights, collectValues = true, valueFilters = {}, valueAggregation = 'sum', trackPaths = true, skipReachabilityCheck = false, allowZeroHop = false } = normalizedRule; const shouldSkipReachability = skipReachabilityCheck || !!options.partialGraphContext || (allowZeroHop && userId === objectId); const collectValuesEnabled = collectValues && !!valueContext; const shouldFastExit = fastPath && pathAggregation === 'max' && !collectValuesEnabled && !trackPaths; const stopSignal = shouldFastExit ? { stop: false } : null; if (!shouldSkipReachability) { // Quick reachability failure check before expensive path-finding const quickFailure = this._quickReachabilityFailure( userKey, objectKey, 'Multi-hop path not reachable via reachability index', reverse ? { direction: 'backward' } : undefined ); if (quickFailure) { return quickFailure; } } // Early exit for unknown relation if (!relation) { return this._createStandardResult({ possibility: 0, reliability: 1.0, meta: null, reason: 'no_relation_specified' }, []); } Arbiter.DEBUG && Arbiter.log('MultiHopRule evaluating:', { userKey, objectKey, relation, maxDepth, pathAggregation, reverse, collectValues, hasValueContext: !!valueContext }); // Track evaluation details const evaluation = { type: 'multi_hop', userKey, objectKey, relation, maxDepth, searchStarted: Date.now(), pathsFound: 0, fallbackUsed: false }; // Find paths and collect values in single traversal const pathsWithValues = this._findPathsAndCollectValues( userId, objectId, relation, maxDepth, new Set(), [], 1.0, 1.0, reverse, collectValuesEnabled, trackPaths, stopSignal, valueFilters, valueContext, evaluation, fastPath, minPossibility, options, allowZeroHop ); evaluation.searchCompleted = Date.now(); evaluation.searchDuration = evaluation.searchCompleted - evaluation.searchStarted; evaluation.pathsFound = pathsWithValues.length; // If no paths found and fallback enabled, try basic connectivity if (pathsWithValues.length === 0 && fallbackToBasicPaths) { const fallbackResult = this._findBasicPathWithValues( userId, objectId, relation, maxDepth, minPossibility, reverse, collectValuesEnabled, trackPaths, valueFilters, valueContext, evaluation, options, allowZeroHop ); if (fallbackResult) { pathsWithValues.push(fallbackResult); evaluation.fallbackUsed = true; } } if (pathsWithValues.length === 0) { evaluation.outcome = 'no_path_found'; return this._createStandardResult({ possibility: 0, reliability: 1.0, ...(includeMeta && { meta: null }), reason: undefined }, []); } // Aggregate paths to get final possibility const { finalPossibility, finalReliability = 1.0, bestPath } = this._aggregatePaths( pathsWithValues, pathAggregation, owaWeights, evaluation, options.trackEvaluation ); // Collect all values from all paths let allCollectedValues = []; for (const pathResult of pathsWithValues) { if (pathResult.collectedValues && pathResult.collectedValues.length > 0) { allCollectedValues.push(...pathResult.collectedValues); } } // Add collected values to ValueContext if available if (valueContext && allCollectedValues.length > 0) { valueContext.addCollectedValues(allCollectedValues, 'multi_hop', rule); } // Apply value aggregation if specified const aggregatedCollectedValues = this._aggregateCollectedValues( allCollectedValues, valueAggregation ); evaluation.outcome = 'path_found'; evaluation.finalPossibility = finalPossibility; evaluation.bestPath = bestPath; evaluation.totalValuesCollected = aggregatedCollectedValues.length; Arbiter.DEBUG && Arbiter.log('MultiHopRule evaluation complete:', { pathsFound: pathsWithValues.length, finalPossibility, valuesCollected: aggregatedCollectedValues.length, fallbackUsed: evaluation.fallbackUsed }); // Build authorization result (raw possibility values) const resolvedPathSteps = bestPath && bestPath.pathSteps ? bestPath.pathSteps : (pathsWithValues[0] && pathsWithValues[0].pathSteps ? pathsWithValues[0].pathSteps : null); const allowMeta = finalPossibility > 0 ? { ruleType: 'multi_hop', reason: 'multi_hop_path_found', rule, pathsFound: pathsWithValues.length, bestPath: bestPath, pathSteps: resolvedPathSteps, fallbackUsed: evaluation.fallbackUsed, evaluation } : null; const authResult = { possibility: finalPossibility, reliability: finalReliability, ...(includeMeta && { meta: allowMeta }), ...(includeMeta && { meta_allow: allowMeta }), reason: undefined }; return this._createStandardResult(authResult, aggregatedCollectedValues); } /** * Find paths and collect values in a single traversal * @private */ _findPathsAndCollectValues(startId, endId, relation, maxDepth, visited, currentPath = [], currentPoss = 1.0, currentReliability = 1.0, reverse = false, collectValues = true, trackPaths = true, stopSignal = null, valueFilters = {}, valueContext = null, evaluation, fastPath = false, minPossibility = 0, options = null, allowZeroHop = false) { if (stopSignal?.stop) { return []; } if (startId === endId && (allowZeroHop || currentPath.length > 0)) { const pathKeys = trackPaths ? [...currentPath.map(step => step.nodeKey), this.arbiter.resolveKey(endId, options)] : null; const pathResult = { nodes: pathKeys, nodeIds: trackPaths ? [...currentPath.map(step => step.nodeId), endId] : [endId], hops: currentPath.length, possibility: currentPoss, reliability: currentReliability, collectedValues: [], pathSteps: trackPaths ? [...currentPath] : null }; // Collect values from the complete path if enabled if (collectValues) { pathResult.collectedValues = this._collectValuesFromPath( currentPath, relation, valueFilters, valueContext ); } Arbiter.DEBUG && Arbiter.log('Found path with values:', { path: pathKeys, possibility: currentPoss, valuesCollected: pathResult.collectedValues.length }); if (stopSignal) { stopSignal.stop = true; } return [pathResult]; } if (maxDepth <= 0 || currentPoss < minPossibility || visited.has(startId)) { return []; } visited.add(startId); const pathsWithValues = []; // Find direct edges using indexed lookups let directEdges; if (reverse) { directEdges = this.arbiter.relationManager.getRelationsToDst(startId, relation, options); } else { directEdges = this.arbiter.relationManager.getRelationsFromSrc(startId, relation, options); } Arbiter.DEBUG && Arbiter.log('MultiHop exploring from', this.arbiter.resolveKey(startId, options), 'found direct edges:', directEdges.length); // Process direct edges for (const edge of directEdges) { if (stopSignal?.stop) { break; } const nextId = reverse ? edge.src : edge.dst; const nextKey = this.arbiter.resolveKey(nextId, options); if (!nextKey) continue; const nextPoss = Math.min(currentPoss, edge.possibility ?? 1.0); const nextReliability = currentReliability * (edge.reliability !== undefined ? edge.reliability : 1.0); if (fastPath && nextPoss < minPossibility) continue; const pathStep = (collectValues || trackPaths) ? { nodeId: startId, nodeKey: this.arbiter.resolveKey(startId, options), relation: relation, edge: edge, inferred: false, possibility: edge.possibility ?? 1.0, source: edge.source || 'persistent' } : null; const nextPath = (collectValues || trackPaths) ? [...currentPath, pathStep] : currentPath; const subPaths = this._findPathsAndCollectValues( nextId, endId, relation, maxDepth - 1, visited, nextPath, nextPoss, nextReliability, reverse, collectValues, trackPaths, stopSignal, valueFilters, valueContext, evaluation, fastPath, minPossibility, options ); pathsWithValues.push(...subPaths); } visited.delete(startId); return pathsWithValues; } /** * Collect values from a complete path * @private */ _collectValuesFromPath(pathSteps, defaultRelation, valueFilters, valueContext) { const collectedValues = []; // Get blurred interval from ValueManager (arbiter-level; some stubs and // older layouts keep it on the relation manager) const valueManager = this.arbiter.valueManager || this.arbiter.relationManager?.valueManager; for (let stepIndex = 0; stepIndex < pathSteps.length; stepIndex++) { const step = pathSteps[stepIndex]; if (step.inferred) { continue; } // Determine which relations to collect from const relationsToCollect = valueFilters.relations || [step.relation || defaultRelation]; for (const relationName of relationsToCollect) { // Collect from direct edge if available if (step.edge && step.edge.value !== undefined && step.edge.value !== null) { if (this._passesValueFilters(step.edge.value, valueFilters)) { // Check TTL const ttl = valueFilters.ttl || 24 * 60 * 60 * 1000; const timestamp = step.edge.changed_last_at || step.edge.updated_last_at || Date.now(); if (!OWAFusion.isWithinTTL(timestamp, ttl)) { Arbiter.DEBUG && Arbiter.log('MultiHop skipping value due to TTL:', { value: step.edge.value, timestamp, ttl, age: Date.now() - timestamp }); continue; } const blurred = valueManager.getBlurredValue(step.edge); if (blurred.interval) { const collectedValue = this._createCollectedValue( blurred.interval, blurred.possibility, pathSteps.map(s => s.nodeKey), { entityKey: step.nodeKey, relation: relationName, step: stepIndex, inferred: false, fullPath: pathSteps.map(s => s.nodeKey), stepPosition: stepIndex, originalValue: step.edge.value }, { timestamp, pathPossibility: step.possibility, relationPossibility: step.edge.possibility ?? 1.0, currentPossibility: blurred.possibility, interval: blurred.interval } ); collectedValues.push(collectedValue); Arbiter.DEBUG && Arbiter.log('MultiHop collected blurred value from edge:', { originalValue: step.edge.value, interval: blurred.interval, step: stepIndex, relation: relationName, node: step.nodeKey, inferred: false, decayedPossibility: blurred.possibility }); } } } // Also collect from ValueContext if available if (valueContext) { const contextValues = valueContext.getValues(step.nodeId, relationName); for (const contextValue of contextValues) { if (this._passesValueFilters(contextValue.value, valueFilters)) { // Check TTL const ttl = valueFilters.ttl || 24 * 60 * 60 * 1000; if (!OWAFusion.isWithinTTL(contextValue.timestamp, ttl)) { continue; } // Create temporary relation for ValueManager const tempRelation = { src: step.nodeId, dst: step.nodeId, rel: relationName, value: contextValue.value, possibility: contextValue.possibility, reliability: contextValue.reliability, changed_last_at: contextValue.timestamp }; const blurred = valueManager.getBlurredValue(tempRelation); if (blurred.interval) { const collectedValue = this._createCollectedValue( blurred.interval, Math.min(step.possibility, blurred.possibility), pathSteps.map(s => s.nodeKey), { entityKey: step.nodeKey, relation: relationName, step: stepIndex, inferred: false, fullPath: pathSteps.map(s => s.nodeKey), stepPosition: stepIndex, fromValueContext: true, originalValue: contextValue.value }, { timestamp: contextValue.timestamp, pathPossibility: step.possibility, relationPossibility: contextValue.possibility, currentPossibility: blurred.possibility, interval: blurred.interval } ); collectedValues.push(collectedValue); } } } } } } return collectedValues; } /** * Check if a value passes the filters * @private */ _passesValueFilters(value, valueFilters) { if (valueFilters.minValue !== undefined && value < valueFilters.minValue) { return false; } if (valueFilters.maxValue !== undefined && value > valueFilters.maxValue) { return false; } return true; } /** * Aggregate multiple paths to get final possibility using OWAFusion * @private */ _aggregatePaths(pathsWithValues, pathAggregation, owaWeights, evaluation, trackOwa = false) { if (pathsWithValues.length === 0) { return { finalPossibility: 0, bestPath: null }; } if (pathsWithValues.length === 1) { const path = pathsWithValues[0]; return { finalPossibility: path.possibility, finalReliability: path.reliability !== undefined ? path.reliability : 1.0, bestPath: path }; } // Prepare data for OWAFusion const possibilities = pathsWithValues.map(p => p.possibility); const metas = pathsWithValues.map(p => ({ path: p.nodes, hops: p.hops, inferred: false, fallback: p.fallback || false, nodeIds: p.nodeIds, collectedValuesCount: p.collectedValues ? p.collectedValues.length : 0, pathPossibility: p.possibility, pathSteps: p.pathSteps || null })); // Use OWAFusion for path aggregation let possibilityResult; const owaTraceOptions = trackOwa ? { includeTrace: true } : null; if (pathAggregation === 'owa' && owaWeights) { // Use custom OWA weights possibilityResult = OWAFusion.fuseWithMeta(possibilities, metas, owaWeights, 'owa', true, owaTraceOptions); evaluation.aggregation = { method: 'owa', weights: owaWeights, fusedPossibility: possibilityResult.value, selectedPath: possibilityResult.meta, ...(trackOwa && possibilityResult.trace ? { owa: { level: null, aggregator: 'owa', weights: possibilityResult.trace.weights, sortedValues: possibilityResult.trace.sortedValues, contributions: possibilityResult.trace.contributions, selectedIndex: possibilityResult.trace.selectedIndex } } : {}) }; } else { // Use standard aggregation methods possibilityResult = OWAFusion.fuseWithMeta(possibilities, metas, null, pathAggregation, true, owaTraceOptions); evaluation.aggregation = { method: pathAggregation, fusedPossibility: possibilityResult.value, selectedPath: possibilityResult.meta, pathCount: pathsWithValues.length, ...(trackOwa && possibilityResult.trace ? { owa: { level: null, aggregator: pathAggregation, weights: possibilityResult.trace.weights, sortedValues: possibilityResult.trace.sortedValues, contributions: possibilityResult.trace.contributions, selectedIndex: possibilityResult.trace.selectedIndex } } : {}) }; } // Find the best path based on the selected metadata let bestPath = possibilityResult.meta; // If meta doesn't contain the full path object, find it in the original paths if (!bestPath || !bestPath.nodes) { // Fall back to finding the path that contributed most to the result const selectedIndex = metas.findIndex(m => m.path === possibilityResult.meta?.path || m.pathPossibility === possibilityResult.meta?.pathPossibility ); if (selectedIndex >= 0) { bestPath = pathsWithValues[selectedIndex]; } else { // Ultimate fallback: use the first path bestPath = pathsWithValues[0]; } } if (bestPath && !bestPath.pathSteps) { const targetPath = bestPath.path || bestPath.nodes; if (Array.isArray(targetPath)) { const match = pathsWithValues.find(p => Array.isArray(p.nodes) && p.nodes.join('|') === targetPath.join('|')); if (match) { bestPath = match; } } } return { finalPossibility: possibilityResult.value, finalReliability: bestPath ? (bestPath.reliability !== undefined ? bestPath.reliability : 1.0) : 1.0, bestPath }; } /** * Aggregate collected values based on aggregation method using OWAFusion 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; } // Use OWAFusion for interval aggregation 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, timestamp: cv.metadata?.timestamp, originalInterval: cv.value })); // Aggregate intervals using OWAFusion const intervalResult = OWAFusion.fuseIntervalsWithMeta( intervals, metas, null, aggregationMethod ); const possibilityResult = OWAFusion.fuseWithMeta( possibilities, metas, null, aggregationMethod ); // Create aggregated collected value const aggregatedCV = this._createCollectedValue( intervalResult.interval, possibilityResult.value, pathValues[0].path, { ...pathValues[0].source, aggregationMethod, aggregatedFromCount: pathValues.length, selectedMeta: intervalResult.meta }, { ...pathValues[0].metadata, aggregatedFromIntervals: intervals, interval: intervalResult.interval, aggregationMeta: { method: aggregationMethod, sourceCount: pathValues.length, intervalContribution: intervalResult.interval, possibilityContribution: possibilityResult.value, selectedSource: intervalResult.meta } } ); aggregatedValues.push(aggregatedCV); } return aggregatedValues; } /** * Find a basic path using simple BFS when sophisticated multi-hop fails * @private */ _findBasicPathWithValues(startId, endId, relation, maxDepth, minPossibility = 0, reverse = false, collectValues = true, trackPaths = true, valueFilters = {}, valueContext = null, evaluation, options = null, allowZeroHop = false) { if (startId === endId && allowZeroHop) { const startKey = this.arbiter.resolveKey(startId, options); return { nodes: trackPaths ? [startKey] : null, nodeIds: [startId], hops: 0, possibility: 1.0, fallback: true, basic: true, collectedValues: [] }; } const visited = new Set(); const queue = [{ nodeId: startId, path: [], pathSteps: [], possibility: 1.0, depth: 0 }]; let queueIndex = 0; while (queueIndex < queue.length) { const current = queue[queueIndex++]; if (current.depth >= maxDepth || visited.has(current.nodeId) || current.possibility < minPossibility) { continue; } visited.add(current.nodeId); // Look for direct connections let edges; const useRelationGraph = !options?.partialGraphContext; const useGraphNeighbors = useRelationGraph && this.arbiter.relationManager.shouldUseRelationGraphTraversal(current.nodeId, relation, reverse); const relationGraphNeighbors = useGraphNeighbors ? this.arbiter.relationManager.getRelationGraphNeighbors(current.nodeId, relation, reverse) : null; if (relationGraphNeighbors) { edges = []; for (const neighborId of relationGraphNeighbors) { const edge = reverse ? this.arbiter.relationManager.getDirectRelation(neighborId, relation, current.nodeId, options) : this.arbiter.relationManager.getDirectRelation(current.nodeId, relation, neighborId, options); if (edge) edges.push(edge); } } else if (reverse) { edges = this.arbiter.relationManager.getRelationsToDst(current.nodeId, relation, options); } else { edges = this.arbiter.relationManager.getRelationsFromSrc(current.nodeId, relation, options); } for (const edge of edges) { const nextId = reverse ? edge.src : edge.dst; const nextKey = this.arbiter.resolveKey(nextId, options); if (!nextKey || visited.has(nextId)) continue; const nextPath = trackPaths ? [...current.path, this.arbiter.resolveKey(current.nodeId, options)] : current.path; const nextPossibility = Math.min(current.possibility, edge.possibility ?? 1.0); const pathStep = (collectValues || trackPaths) ? { nodeId: current.nodeId, nodeKey: this.arbiter.resolveKey(current.nodeId, options), relation: relation, edge: edge, inferred: false, possibility: edge.possibility ?? 1.0, source: edge.source || 'persistent' } : null; const nextPathSteps = (collectValues || trackPaths) ? [...current.pathSteps, pathStep] : current.pathSteps; // Check if we reached the target if (nextId === endId) { const finalPath = trackPaths ? [...nextPath, nextKey] : null; const pathResult = { nodes: finalPath, nodeIds: trackPaths ? [...nextPath.map(key => this.arbiter.resolveNodeId(key, options)), nextId] : [startId, nextId], hops: finalPath.length - 1, possibility: nextPossibility, fallback: true, basic: true, collectedValues: [], pathSteps: trackPaths ? nextPathSteps : null }; // Collect values from the path if enabled if (collectValues) { pathResult.collectedValues = this._collectValuesFromPath( nextPathSteps, relation, valueFilters, valueContext ); } Arbiter.DEBUG && Arbiter.log('Basic fallback found path with values:', { path: finalPath, valuesCollected: pathResult.collectedValues.length }); return pathResult; } // Continue searching if possibility is still acceptable if (nextPossibility >= minPossibility ) { queue.push({ nodeId: nextId, path: nextPath, pathSteps: nextPathSteps, possibility: nextPossibility, depth: current.depth + 1 }); } } } Arbiter.DEBUG && Arbiter.log('Basic fallback: no path found within constraints'); return null; } /** * Normalize rule configuration * @private */ _normalizeRule(rule) { return { ...rule }; } }