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,822 @@
|
||||
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,
|
||||
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, 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: 1.0,
|
||||
...(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,
|
||||
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,
|
||||
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);
|
||||
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,
|
||||
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 = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Get blurred interval from ValueManager
|
||||
const blurred = this.arbiter.relationManager.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 = this.arbiter.relationManager.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,
|
||||
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,
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user