Files
core/src/authorization/rules/MultiHopRule.js
T
John Dvorak 4fd4e20bd0 js-rigor: reliability flows through every rule kind; multi_hop value collection fixed
Systemic reliability gap found by the probe sweep: the compiled evaluation
paths never emitted the reliability the engine computes.

- Compiled _evaluateDirect omitted the relation's reliability, and the
  chain/multi_hop rules hardcoded reliability: 1.0 — so check() results
  reported 1.0 for any rule whose decision came through a chain, multi_hop,
  union, intersection, exclusion, or defeasible combination.
- The chain and multi_hop traversals now track per-path reliability (product
  of edge reliabilities) and report the winning path's value; the compiled
  and fallback logical operators (union/intersection/exclusion, direct_list
  fast path, early exits) report the selected child's reliability
  (max/min child or OWA trace index; exclusion multiplies both legs), and
  normal-mode defeasible combines base x requires x defeater reliabilities.
- The checker's logical fast path dropped collectedValues from union/
  intersection/exclusion results; it now passes them through.
- MultiHopRule.valueManager was read off relationManager where the real
  arbiter keeps it on the arbiter — collectValues: true on a multi_hop rule
  with a value-carrying edge crashed the evaluation (error result, silent
  denial). Now resolved at the arbiter level with a relationManager
  fallback for stubs.

Campaign pins: reliability per kind (chain/multi_hop product, union/intersection
selected child, exclusion/defeasible product), and multi_hop value collection
through persistent and partial contexts.
2026-08-01 09:52:31 -07:00

831 lines
28 KiB
JavaScript

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,
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 };
}
}