2026-07-31 13:44:06 -07:00
|
|
|
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<number>, // 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,
|
2026-08-01 09:52:31 -07:00
|
|
|
1.0,
|
2026-07-31 13:44:06 -07:00
|
|
|
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
|
2026-08-01 09:52:31 -07:00
|
|
|
const { finalPossibility, finalReliability = 1.0, bestPath } = this._aggregatePaths(
|
2026-07-31 13:44:06 -07:00
|
|
|
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,
|
2026-08-01 09:52:31 -07:00
|
|
|
reliability: finalReliability,
|
2026-07-31 13:44:06 -07:00
|
|
|
...(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,
|
2026-08-01 09:52:31 -07:00
|
|
|
visited, currentPath = [], currentPoss = 1.0, currentReliability = 1.0,
|
2026-07-31 13:44:06 -07:00
|
|
|
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,
|
2026-08-01 09:52:31 -07:00
|
|
|
reliability: currentReliability,
|
2026-07-31 13:44:06 -07:00
|
|
|
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);
|
2026-08-01 09:52:31 -07:00
|
|
|
const nextReliability = currentReliability * (edge.reliability !== undefined ? edge.reliability : 1.0);
|
2026-07-31 13:44:06 -07:00
|
|
|
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,
|
2026-08-01 09:52:31 -07:00
|
|
|
nextReliability,
|
2026-07-31 13:44:06 -07:00
|
|
|
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 = [];
|
2026-08-01 09:52:31 -07:00
|
|
|
// 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;
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 09:52:31 -07:00
|
|
|
const blurred = valueManager.getBlurredValue(step.edge);
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-01 09:52:31 -07:00
|
|
|
const blurred = valueManager.getBlurredValue(tempRelation);
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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,
|
2026-08-01 09:52:31 -07:00
|
|
|
finalReliability: path.reliability !== undefined ? path.reliability : 1.0,
|
2026-07-31 13:44:06 -07:00
|
|
|
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 {
|
2026-08-01 09:52:31 -07:00
|
|
|
finalPossibility: possibilityResult.value,
|
|
|
|
|
finalReliability: bestPath ? (bestPath.reliability !== undefined ? bestPath.reliability : 1.0) : 1.0,
|
2026-07-31 13:44:06 -07:00
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
}
|