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';
|
|
|
|
|
import { BilatticeOrderings } from '../../qualitative/BilatticeOrderings.js';
|
|
|
|
|
import { QualitativeCapacity } from '../../qualitative/QualitativeCapacity.js';
|
|
|
|
|
import { QualitativeScale } from '../../qualitative/QualitativeScale.js';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* ChainRule - Evaluates access by following a chain of relations and collecting values along the path
|
|
|
|
|
*
|
|
|
|
|
* This rule enables traversing through multiple entities via relations and collects
|
|
|
|
|
* values from each step in the path. Perfect for scenarios like:
|
|
|
|
|
* "Sum balances from all accounts user can debit from"
|
|
|
|
|
*
|
|
|
|
|
* CLEAN SEMANTICS:
|
|
|
|
|
* - Authorization: Based on path reachability to target entity (returns raw possibility)
|
|
|
|
|
* - Value Collection: Collects values along the path with full path tracking
|
|
|
|
|
* - Uses ValueContext for efficient caching and aggregation
|
|
|
|
|
*
|
|
|
|
|
* Path Semantics:
|
|
|
|
|
* - Along chain: MIN operator (possibilistic conjunction)
|
|
|
|
|
* - Across paths: MAX/OWA operator (disjunctive)
|
|
|
|
|
* - Values: Collected with full path metadata for aggregation at logical level
|
|
|
|
|
*
|
|
|
|
|
* Configuration:
|
|
|
|
|
* {
|
|
|
|
|
* type: 'chain',
|
|
|
|
|
* steps: [
|
|
|
|
|
* { relation: 'can_debit', direction: 'out' }, // user → accounts
|
|
|
|
|
* { relation: 'has_balance', direction: 'out' } // accounts → currency
|
|
|
|
|
* ],
|
|
|
|
|
* // Value collection (optional - defaults to enabled)
|
|
|
|
|
* collectValues: true, // Whether to collect values (default: true)
|
|
|
|
|
* valueFilters: { // Optional filters for value collection
|
|
|
|
|
* steps: [0, 1], // Which steps to collect from (default: all)
|
|
|
|
|
* relations: ['has_balance'], // Which relations to collect from (default: step relations)
|
|
|
|
|
* minValue: 0, // Minimum value threshold
|
|
|
|
|
* maxValue: 1000 // Maximum value threshold
|
|
|
|
|
* },
|
|
|
|
|
* valueAggregation: 'sum', // How to pre-aggregate VALUES ('sum', 'max', 'min', 'average')
|
|
|
|
|
*
|
|
|
|
|
* // Standard rule fields
|
|
|
|
|
* reverse: false // Reverse traversal direction (default: false)
|
|
|
|
|
* }
|
|
|
|
|
*/
|
|
|
|
|
export class ChainRule extends BaseRule {
|
|
|
|
|
constructor(arbiter) {
|
|
|
|
|
super(arbiter);
|
|
|
|
|
|
|
|
|
|
// Chain-specific caching with HyperbolicLRUCache for better memory management
|
|
|
|
|
this.maxCacheSize = 2000;
|
|
|
|
|
this.cacheTTL = 600000; // 10 minutes
|
|
|
|
|
this.pathCacheTTL = 300000; // 5 minutes
|
|
|
|
|
|
|
|
|
|
// Only create caches if caching is not disabled
|
|
|
|
|
if (!arbiter.disableCaching && !arbiter.disableChainCaching) {
|
|
|
|
|
// Chain result cache with HyperbolicLRUCache
|
|
|
|
|
this.chainResultCache = arbiter.cacheFactory(this.maxCacheSize, {
|
|
|
|
|
onEvict: (key, value) => {
|
|
|
|
|
// Optional: track evictions for debugging
|
|
|
|
|
this.stats = this.stats || {};
|
|
|
|
|
this.stats.evictedResults = (this.stats.evictedResults || 0) + 1;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
this.chainResultCache = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Evaluate chain rule with smart reachability optimization
|
|
|
|
|
* Uses reachability check as a hint, but doesn't fail fast if TreeCover index is uncertain
|
|
|
|
|
*/
|
|
|
|
|
evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
|
|
|
|
// Normalize rule to get reverse flag
|
|
|
|
|
const normalizedRule = this._normalizeRule(rule);
|
|
|
|
|
const { reverse = false } = normalizedRule;
|
|
|
|
|
|
|
|
|
|
// Check if PLTC bypass is requested (for testing/ground truth computation)
|
|
|
|
|
// Also skip when a partial graph is present — PLTC is built from persistent data only
|
|
|
|
|
const { bypassPLTC = false } = options;
|
|
|
|
|
const hasPartialGraph = !!options.partialGraphContext;
|
|
|
|
|
|
|
|
|
|
if (!bypassPLTC && !hasPartialGraph) {
|
|
|
|
|
// Smart reachability check: PLTC is 100% accurate, so we can fail fast on false
|
|
|
|
|
// Use backward index for reverse chains
|
|
|
|
|
const direction = reverse ? 'backward' : 'forward';
|
|
|
|
|
const reachabilityResult = this._quickReachabilityCheck(userKey, objectKey, { direction });
|
|
|
|
|
|
|
|
|
|
if (reachabilityResult === false) {
|
|
|
|
|
// PLTC is 100% accurate - if it says false, definitely no path exists
|
|
|
|
|
// Fast fail for unreachable cases
|
|
|
|
|
return this._createStandardResult({
|
|
|
|
|
possibility: 0,
|
|
|
|
|
reliability: 1.0,
|
|
|
|
|
...(options.includeMeta && {
|
|
|
|
|
meta: {
|
|
|
|
|
method: 'pltc_fast_fail',
|
|
|
|
|
reason: 'not_reachable',
|
|
|
|
|
sourceKey: userKey,
|
|
|
|
|
targetKey: objectKey,
|
|
|
|
|
direction
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
reason: 'not_reachable'
|
|
|
|
|
}, []);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If reachabilityResult is true, path exists but we still need to check relation types
|
|
|
|
|
// If null, PLTC not initialized, proceed with chain evaluation
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Proceed with chain evaluation (either PLTC said true/null, or bypassPLTC is enabled)
|
|
|
|
|
return this._evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Evaluate chain traversal and value collection using ValueContext
|
|
|
|
|
* @protected
|
|
|
|
|
*/
|
|
|
|
|
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
|
|
|
|
const { fastPath, minPossibility, valueContext, includeMeta = true } = options;
|
|
|
|
|
// Normalize rule configuration
|
|
|
|
|
const normalizedRule = this._normalizeRule(rule);
|
|
|
|
|
const {
|
|
|
|
|
steps,
|
|
|
|
|
collectValues = true, // Default to true
|
|
|
|
|
valueFilters = {},
|
|
|
|
|
valueAggregation = 'sum',
|
|
|
|
|
reverse = false
|
|
|
|
|
} = normalizedRule;
|
|
|
|
|
const collectValuesEnabled = collectValues && !!valueContext;
|
|
|
|
|
|
|
|
|
|
if (!steps || steps.length === 0) {
|
|
|
|
|
return this._createStandardResult({
|
|
|
|
|
possibility: 0,
|
|
|
|
|
reliability: 1.0,
|
|
|
|
|
...(includeMeta && { meta: null }),
|
|
|
|
|
reason: 'no_chain_steps_defined'
|
|
|
|
|
}, []);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert string keys to numeric IDs if needed
|
|
|
|
|
const userIdNum = typeof userId === 'string' ? this.arbiter.resolveNodeId(userId, options) : userId;
|
|
|
|
|
const objectIdNum = typeof objectId === 'string' ? this.arbiter.resolveNodeId(objectId, options) : objectId;
|
|
|
|
|
|
|
|
|
|
const hasPartialGraph = !!(options.partialGraphContext);
|
|
|
|
|
|
|
|
|
|
// Threshold-mode (binary / fastPath-with-threshold) evaluations collapse
|
|
|
|
|
// sub-threshold paths to 0 and early-exit; their results are NOT
|
|
|
|
|
// interchangeable with full-mode values. The shared chain result cache
|
|
|
|
|
// must be neither consulted nor populated in threshold mode, or binary
|
|
|
|
|
// checks get served full-mode values (and vice versa).
|
|
|
|
|
const isThresholdEval = options.binary === true || (options.fastPath === true && options.minPossibility != null);
|
|
|
|
|
|
2026-08-02 16:07:55 -07:00
|
|
|
// A caller-pinned clock (options.now) makes the result per-clock: a
|
|
|
|
|
// chain result captured at one time (with then-fresh values) must not
|
|
|
|
|
// be served to a caller asking about another time. Same contract as
|
|
|
|
|
// the rule result cache (RuleEvaluator): pinned-clock callers bypass
|
|
|
|
|
// the chain cache entirely — both reads and writes.
|
|
|
|
|
const temporalPinned = options.now !== undefined && options.now !== null;
|
|
|
|
|
|
2026-07-31 13:44:06 -07:00
|
|
|
// Check for cached chain result (use numeric IDs) - only if caching is enabled
|
|
|
|
|
// Skip cache when a partial graph is present to prevent cross-request leakage
|
2026-08-02 16:07:55 -07:00
|
|
|
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval && !temporalPinned) {
|
2026-07-31 13:44:06 -07:00
|
|
|
const cachedResult = this._getCachedChainResult(userIdNum, objectIdNum, steps);
|
|
|
|
|
if (cachedResult) {
|
|
|
|
|
return cachedResult;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Determine starting point
|
|
|
|
|
let startId, startKey;
|
|
|
|
|
if (reverse) {
|
|
|
|
|
startId = objectIdNum;
|
|
|
|
|
startKey = objectKey;
|
|
|
|
|
} else {
|
|
|
|
|
startId = userIdNum;
|
|
|
|
|
startKey = userKey;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Track all paths through the chain with their accumulated possibilities
|
|
|
|
|
let currentPaths = [{
|
|
|
|
|
id: startId,
|
|
|
|
|
key: startKey,
|
|
|
|
|
possibility: 1.0,
|
|
|
|
|
path: [startKey], // Track the full path
|
|
|
|
|
pathEntities: [{ id: startId, key: startKey, source: 'persistent' }]
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
let allCollectedValues = [];
|
|
|
|
|
|
|
|
|
|
// Traverse through each step
|
|
|
|
|
for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
|
|
|
|
|
// Normalize string steps (emitted by the DSL compiler as
|
|
|
|
|
// ['works_in','has_access']) to the object form the traversal expects.
|
|
|
|
|
const rawStep = steps[stepIndex];
|
|
|
|
|
const step = typeof rawStep === 'string'
|
|
|
|
|
? { relation: rawStep, direction: 'out' }
|
|
|
|
|
: rawStep;
|
|
|
|
|
const { relation: stepRelation, direction } = step;
|
|
|
|
|
|
|
|
|
|
if (!stepRelation || !direction) {
|
|
|
|
|
currentPaths = [];
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// For each current path, extend it via the relation
|
|
|
|
|
const pathMap = new Map();
|
|
|
|
|
const MAX_PATHS_PER_STEP = 100;
|
|
|
|
|
|
|
|
|
|
for (const currentPath of currentPaths) {
|
|
|
|
|
if (pathMap.size >= MAX_PATHS_PER_STEP) break;
|
|
|
|
|
|
|
|
|
|
const relations = this._getRelationsForStep(currentPath.id, stepRelation, direction, options);
|
|
|
|
|
|
|
|
|
|
for (const rel of relations) {
|
|
|
|
|
const nextId = direction === 'in' ? rel.src : rel.dst;
|
|
|
|
|
const nextKey = this.arbiter.resolveKey(nextId, options);
|
|
|
|
|
|
|
|
|
|
if (nextKey) {
|
|
|
|
|
// Calculate path possibility (MIN along chain)
|
|
|
|
|
const nextPossibility = Math.min(currentPath.possibility, rel.possibility ?? 1.0);
|
|
|
|
|
|
|
|
|
|
if (fastPath && nextPossibility < minPossibility) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect values from this step BEFORE path deduplication:
|
|
|
|
|
// a weaker parallel path is still a valid value source, and
|
|
|
|
|
// dropping it first would silently discard its contribution.
|
|
|
|
|
if (collectValuesEnabled && this._shouldCollectFromStep(stepIndex, valueFilters)) {
|
|
|
|
|
const stepValues = this._collectValuesFromStep(
|
|
|
|
|
currentPath,
|
|
|
|
|
rel,
|
|
|
|
|
stepIndex,
|
|
|
|
|
stepRelation,
|
|
|
|
|
direction,
|
|
|
|
|
valueFilters,
|
|
|
|
|
valueContext,
|
|
|
|
|
options
|
|
|
|
|
);
|
|
|
|
|
allCollectedValues.push(...stepValues);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Deduplicate: keep best path per node
|
|
|
|
|
const existing = pathMap.get(nextId);
|
2026-08-01 09:52:31 -07:00
|
|
|
const nextReliability = (currentPath.reliability ?? 1.0) * (rel.reliability ?? 1.0);
|
|
|
|
|
if (existing && existing.possibility > nextPossibility) continue;
|
|
|
|
|
if (existing && existing.possibility === nextPossibility && (existing.reliability ?? 1.0) >= nextReliability) continue;
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
// Create extended path
|
|
|
|
|
const extendedPath = {
|
|
|
|
|
id: nextId,
|
|
|
|
|
key: nextKey,
|
|
|
|
|
possibility: nextPossibility,
|
2026-08-01 09:52:31 -07:00
|
|
|
reliability: nextReliability,
|
2026-07-31 13:44:06 -07:00
|
|
|
path: [...currentPath.path, nextKey],
|
|
|
|
|
pathEntities: [...currentPath.pathEntities, { id: nextId, key: nextKey, source: rel.source || 'persistent' }]
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
pathMap.set(nextId, extendedPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
currentPaths = Array.from(pathMap.values());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Early exit if no paths found
|
|
|
|
|
if (currentPaths.length === 0) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Determine if target was reached and calculate final possibility
|
|
|
|
|
const targetKey = reverse ? userKey : objectKey;
|
|
|
|
|
const targetId = reverse ? userIdNum : objectIdNum;
|
|
|
|
|
const targetPaths = currentPaths.filter(path => path.id === targetId);
|
|
|
|
|
let finalPossibility = 0;
|
2026-08-01 09:52:31 -07:00
|
|
|
let finalReliability = 1.0;
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
if (targetPaths.length > 0) {
|
2026-08-01 09:52:31 -07:00
|
|
|
// Use MAX across paths (disjunctive); the winning path's reliability
|
|
|
|
|
// is the product of its edges' reliabilities (MIN along the chain).
|
|
|
|
|
const best = targetPaths.reduce((a, b) =>
|
|
|
|
|
(b.possibility > a.possibility ||
|
|
|
|
|
(b.possibility === a.possibility && (b.reliability ?? 1.0) > (a.reliability ?? 1.0)))
|
|
|
|
|
? b : a
|
|
|
|
|
);
|
|
|
|
|
finalPossibility = best.possibility;
|
|
|
|
|
finalReliability = best.reliability ?? 1.0;
|
2026-07-31 13:44:06 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (fastPath && finalPossibility < minPossibility) {
|
|
|
|
|
return this._createStandardResult({
|
|
|
|
|
possibility: 0,
|
|
|
|
|
reliability: 1.0,
|
|
|
|
|
meta: null,
|
|
|
|
|
reason: 'no_chain_path_found'
|
|
|
|
|
}, []);
|
|
|
|
|
}
|
|
|
|
|
// Add collected values to ValueContext if available
|
|
|
|
|
if (valueContext && allCollectedValues.length > 0) {
|
|
|
|
|
valueContext.addCollectedValues(allCollectedValues, 'chain', rule);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Apply value aggregation with optional bilattice reasoning
|
|
|
|
|
const useBilattice = normalizedRule.useBilattice || false;
|
|
|
|
|
const epistemicMode = normalizedRule.epistemicMode || 'hybrid';
|
|
|
|
|
const capacityType = normalizedRule.capacityType || 'simple_support';
|
|
|
|
|
|
|
|
|
|
let aggregatedCollectedValues;
|
|
|
|
|
let epistemicAnalysis = null;
|
|
|
|
|
|
|
|
|
|
if (useBilattice && allCollectedValues.length > 0) {
|
|
|
|
|
const scale = QualitativeScale.fivePoint(); // Default scale for bilattice analysis
|
|
|
|
|
const capacity = this._createCapacityFromValues(allCollectedValues, scale, capacityType);
|
|
|
|
|
|
|
|
|
|
const bilatticeResult = this._combineEvidenceWithBilattice(allCollectedValues, {
|
|
|
|
|
method: valueAggregation,
|
|
|
|
|
useBilattice: true,
|
|
|
|
|
capacity: capacity,
|
|
|
|
|
scale: scale,
|
|
|
|
|
epistemicMode: epistemicMode
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Convert bilattice result back to collected values format
|
|
|
|
|
aggregatedCollectedValues = [{
|
|
|
|
|
value: bilatticeResult.value,
|
|
|
|
|
possibility: bilatticeResult.possibility,
|
|
|
|
|
path: ['bilattice_aggregated'],
|
|
|
|
|
source: {
|
|
|
|
|
entityKey: 'chain_aggregation',
|
|
|
|
|
relation: 'bilattice_fusion',
|
|
|
|
|
step: -1
|
|
|
|
|
},
|
|
|
|
|
metadata: {
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
reliability: 1.0,
|
|
|
|
|
aggregationMethod: `bilattice_${epistemicMode}`,
|
|
|
|
|
epistemicAnalysis: bilatticeResult.epistemicAnalysis
|
|
|
|
|
}
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
epistemicAnalysis = bilatticeResult.epistemicAnalysis;
|
|
|
|
|
} else {
|
|
|
|
|
aggregatedCollectedValues = this._aggregateCollectedValues(
|
|
|
|
|
allCollectedValues,
|
|
|
|
|
valueAggregation
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Build authorization result with single possibility value
|
|
|
|
|
const authResult = {
|
|
|
|
|
possibility: finalPossibility,
|
2026-08-01 09:52:31 -07:00
|
|
|
reliability: finalReliability,
|
2026-08-02 08:57:05 -07:00
|
|
|
validity: this._validity('min', normalizedRule.steps.map(s => s.relation).filter(Boolean), [], normalizedRule.steps.length, finalPossibility),
|
2026-07-31 13:44:06 -07:00
|
|
|
...(includeMeta && {
|
|
|
|
|
meta: finalPossibility > 0 ? {
|
|
|
|
|
ruleType: 'chain',
|
|
|
|
|
reason: 'chain_path_found',
|
|
|
|
|
rule,
|
|
|
|
|
chainLength: steps.length,
|
|
|
|
|
pathsToTarget: targetPaths.length,
|
|
|
|
|
totalPaths: currentPaths.length,
|
|
|
|
|
bestPath: targetPaths.length > 0 ? targetPaths[0].path : null,
|
|
|
|
|
bestPathEntities: targetPaths.length > 0 ? targetPaths[0].pathEntities : null,
|
|
|
|
|
pathSteps: targetPaths.length > 0 ? targetPaths[0].pathEntities : null,
|
|
|
|
|
useBilattice: useBilattice,
|
|
|
|
|
epistemicMode: useBilattice ? epistemicMode : undefined,
|
|
|
|
|
epistemicAnalysis: epistemicAnalysis
|
|
|
|
|
} : null
|
|
|
|
|
}),
|
|
|
|
|
...(includeMeta && {
|
|
|
|
|
meta_allow: finalPossibility > 0 ? {
|
|
|
|
|
ruleType: 'chain',
|
|
|
|
|
reason: 'chain_path_found',
|
|
|
|
|
rule,
|
|
|
|
|
chainLength: steps.length,
|
|
|
|
|
pathsToTarget: targetPaths.length,
|
|
|
|
|
totalPaths: currentPaths.length,
|
|
|
|
|
bestPath: targetPaths.length > 0 ? targetPaths[0].path : null,
|
|
|
|
|
bestPathEntities: targetPaths.length > 0 ? targetPaths[0].pathEntities : null,
|
|
|
|
|
pathSteps: targetPaths.length > 0 ? targetPaths[0].pathEntities : null
|
|
|
|
|
} : null
|
|
|
|
|
}),
|
|
|
|
|
reason: finalPossibility > 0 ? 'chain_path_found' : 'no_chain_path_found'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = this._createStandardResult(authResult, aggregatedCollectedValues);
|
|
|
|
|
|
|
|
|
|
// Cache the chain result (use numeric IDs) - only if caching is enabled
|
|
|
|
|
// Do not cache when a partial graph is present to prevent cross-request leakage
|
2026-08-02 16:07:55 -07:00
|
|
|
// Do not cache threshold-mode results (see isThresholdEval above).
|
|
|
|
|
// Do not cache pinned-clock results either — the entry is per-clock
|
|
|
|
|
// and would be served to later unpinned callers as if it were timeless.
|
|
|
|
|
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval && !temporalPinned) {
|
2026-07-31 13:44:06 -07:00
|
|
|
this._cacheChainResult(userIdNum, objectIdNum, steps, result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get relations for a step based on direction
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
_getRelationsForStep(entityId, relation, direction, options = null) {
|
|
|
|
|
if (direction === 'out') {
|
|
|
|
|
return this.arbiter.relationManager.getRelationsFromSrc(entityId, relation, options);
|
|
|
|
|
} else if (direction === 'in') {
|
|
|
|
|
return this.arbiter.relationManager.getRelationsToDst(entityId, relation, options);
|
|
|
|
|
} else {
|
|
|
|
|
// Default to 'out' for backward compatibility
|
|
|
|
|
return this.arbiter.relationManager.getRelationsFromSrc(entityId, relation, options);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate cache key for chain result using numeric IDs
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
_getChainResultCacheKey(userId, objectId, steps) {
|
|
|
|
|
return this.arbiter.keyManager.createChainKey(userId, objectId, steps);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_getCachedChainResult(userId, objectId, steps) {
|
|
|
|
|
if (!this.chainResultCache) return null;
|
|
|
|
|
|
|
|
|
|
const key = this._getChainResultCacheKey(userId, objectId, steps);
|
|
|
|
|
const entry = this.chainResultCache.get(key);
|
|
|
|
|
|
2026-08-02 17:50:24 -07:00
|
|
|
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
|
|
|
|
|
if (entry && cacheNow - entry.timestamp < this.cacheTTL) {
|
2026-07-31 13:44:06 -07:00
|
|
|
return entry.result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// HyperbolicLRUCache handles eviction automatically, no need to manually delete
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cache chain result
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
_cacheChainResult(userId, objectId, steps, result) {
|
|
|
|
|
if (!this.chainResultCache) return;
|
|
|
|
|
|
|
|
|
|
const key = this._getChainResultCacheKey(userId, objectId, steps);
|
|
|
|
|
|
|
|
|
|
// HyperbolicLRUCache handles eviction automatically based on frequency and recency
|
2026-08-02 17:50:24 -07:00
|
|
|
const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now();
|
2026-07-31 13:44:06 -07:00
|
|
|
this.chainResultCache.set(key, {
|
|
|
|
|
result,
|
2026-08-02 17:50:24 -07:00
|
|
|
timestamp: cacheNow
|
2026-07-31 13:44:06 -07:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_invalidateAllChainCaches() {
|
|
|
|
|
if (this.chainResultCache) {
|
|
|
|
|
this.chainResultCache.clear();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if we should collect values from this step
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
_shouldCollectFromStep(stepIndex, valueFilters) {
|
|
|
|
|
if (!valueFilters) return true;
|
|
|
|
|
|
|
|
|
|
// Check step filter
|
|
|
|
|
if (valueFilters.steps && Array.isArray(valueFilters.steps)) {
|
|
|
|
|
return valueFilters.steps.includes(stepIndex);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Collect values from a single step in the chain
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
_collectValuesFromStep(currentPath, relation, stepIndex, stepRelation, direction, valueFilters, valueContext, options = null) {
|
|
|
|
|
const collectedValues = [];
|
|
|
|
|
|
|
|
|
|
// Check if this relation should be collected based on filters
|
|
|
|
|
if (valueFilters.relations && Array.isArray(valueFilters.relations)) {
|
|
|
|
|
if (!valueFilters.relations.includes(stepRelation)) {
|
|
|
|
|
return collectedValues;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If relation has a value, collect it as a blurred interval
|
|
|
|
|
if (relation.value !== undefined && relation.value !== null) {
|
|
|
|
|
// Apply value filters
|
|
|
|
|
if (valueFilters.minValue !== undefined && relation.value < valueFilters.minValue) {
|
|
|
|
|
return collectedValues;
|
|
|
|
|
}
|
|
|
|
|
if (valueFilters.maxValue !== undefined && relation.value > valueFilters.maxValue) {
|
|
|
|
|
return collectedValues;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Let ValueManager handle age-based blurring instead of hard TTL filtering
|
|
|
|
|
// The ValueManager will apply appropriate blurring based on relation age
|
|
|
|
|
|
|
|
|
|
// Get blurred interval from ValueManager
|
|
|
|
|
if (!relation) {
|
|
|
|
|
return collectedValues;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.arbiter.valueManager) {
|
|
|
|
|
return collectedValues;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 17:50:24 -07:00
|
|
|
const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null;
|
|
|
|
|
const blurred = this.arbiter.valueManager.getBlurredValue(relation, callerNow);
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
if (blurred.interval) {
|
|
|
|
|
const sourceEntity = direction === 'in' ?
|
|
|
|
|
this.arbiter.resolveKey(relation.dst, options) :
|
|
|
|
|
this.arbiter.resolveKey(relation.src, options);
|
|
|
|
|
const targetEntity = direction === 'in' ?
|
|
|
|
|
this.arbiter.resolveKey(relation.src, options) :
|
|
|
|
|
this.arbiter.resolveKey(relation.dst, options);
|
|
|
|
|
|
|
|
|
|
const collectedValue = this._createCollectedValue(
|
|
|
|
|
blurred.interval, // Pass interval instead of point value
|
|
|
|
|
blurred.possibility, // Use decayed possibility
|
|
|
|
|
currentPath.path,
|
|
|
|
|
{
|
|
|
|
|
entityKey: sourceEntity,
|
|
|
|
|
relation: stepRelation,
|
|
|
|
|
step: stepIndex,
|
|
|
|
|
direction: direction,
|
|
|
|
|
fullPath: currentPath.path,
|
|
|
|
|
stepPosition: stepIndex,
|
|
|
|
|
originalValue: relation.value, // Keep original point value for reference
|
|
|
|
|
source: relation.source || 'persistent'
|
|
|
|
|
},
|
|
|
|
|
{
|
2026-08-02 17:07:20 -07:00
|
|
|
timestamp: relation.changed_last_at || relation.updated_last_at ||
|
|
|
|
|
(options && options.now !== undefined && options.now !== null ? options.now : Date.now()),
|
2026-07-31 13:44:06 -07:00
|
|
|
reliability: blurred.reliability,
|
|
|
|
|
pathPossibility: currentPath.possibility,
|
|
|
|
|
relationPossibility: relation.possibility ?? 1.0,
|
|
|
|
|
currentPossibility: blurred.possibility, // Add current (decayed) possibility
|
|
|
|
|
interval: blurred.interval, // Include interval in metadata
|
|
|
|
|
source: relation.source || 'persistent'
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
collectedValues.push(collectedValue);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Also try to get values from ValueContext if available
|
|
|
|
|
if (valueContext && currentPath.pathEntities.length > stepIndex) {
|
|
|
|
|
const entity = currentPath.pathEntities[stepIndex];
|
|
|
|
|
const contextValues = valueContext.getValues(entity.id, stepRelation);
|
|
|
|
|
|
|
|
|
|
for (const contextValue of contextValues) {
|
|
|
|
|
// Apply value filters
|
|
|
|
|
if (valueFilters.minValue !== undefined && contextValue.value < valueFilters.minValue) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (valueFilters.maxValue !== undefined && contextValue.value > valueFilters.maxValue) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Let ValueManager handle age-based blurring for ValueContext values too
|
|
|
|
|
|
|
|
|
|
// Create a temporary relation object for ValueManager
|
|
|
|
|
const tempRelation = {
|
|
|
|
|
src: entity.id,
|
|
|
|
|
dst: entity.id, // Self-relation for value storage
|
|
|
|
|
rel: stepRelation,
|
|
|
|
|
value: contextValue.value,
|
|
|
|
|
possibility: contextValue.possibility,
|
|
|
|
|
reliability: contextValue.reliability,
|
|
|
|
|
changed_last_at: contextValue.timestamp
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!tempRelation) {
|
|
|
|
|
Arbiter.DEBUG && Arbiter.log('ChainRule: tempRelation is undefined, skipping value collection');
|
|
|
|
|
return collectedValues;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.arbiter.relationManager || !this.arbiter.relationManager.valueManager) {
|
|
|
|
|
Arbiter.DEBUG && Arbiter.log('ChainRule: relationManager or valueManager is undefined');
|
|
|
|
|
return collectedValues;
|
|
|
|
|
}
|
2026-08-02 17:50:24 -07:00
|
|
|
const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null;
|
|
|
|
|
const blurred = this.arbiter.relationManager.valueManager.getBlurredValue(tempRelation, callerNow);
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
if (blurred.interval) {
|
|
|
|
|
const collectedValue = this._createCollectedValue(
|
|
|
|
|
blurred.interval,
|
|
|
|
|
Math.min(currentPath.possibility, blurred.possibility),
|
|
|
|
|
currentPath.path,
|
|
|
|
|
{
|
|
|
|
|
entityKey: entity.key,
|
|
|
|
|
relation: stepRelation,
|
|
|
|
|
step: stepIndex,
|
|
|
|
|
direction: direction,
|
|
|
|
|
fullPath: currentPath.path,
|
|
|
|
|
stepPosition: stepIndex,
|
|
|
|
|
fromValueContext: true,
|
|
|
|
|
originalValue: contextValue.value
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
timestamp: contextValue.timestamp,
|
|
|
|
|
reliability: blurred.reliability,
|
|
|
|
|
pathPossibility: currentPath.possibility,
|
|
|
|
|
relationPossibility: contextValue.possibility,
|
|
|
|
|
currentPossibility: blurred.possibility,
|
|
|
|
|
interval: blurred.interval,
|
|
|
|
|
source: contextValue.source || 'persistent'
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
collectedValues.push(collectedValue);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return collectedValues;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Aggregate collected values based on aggregation method using interval arithmetic
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
_aggregateCollectedValues(collectedValues, aggregationMethod) {
|
|
|
|
|
if (collectedValues.length === 0) return [];
|
|
|
|
|
if (collectedValues.length === 1) return collectedValues;
|
|
|
|
|
|
|
|
|
|
// Group values by path for aggregation
|
|
|
|
|
const valuesByPath = new Map();
|
|
|
|
|
|
|
|
|
|
for (const cv of collectedValues) {
|
|
|
|
|
const pathKey = cv.source?.fullPath?.join('->') || 'unknown_path';
|
|
|
|
|
if (!valuesByPath.has(pathKey)) {
|
|
|
|
|
valuesByPath.set(pathKey, []);
|
|
|
|
|
}
|
|
|
|
|
valuesByPath.get(pathKey).push(cv);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const aggregatedValues = [];
|
|
|
|
|
|
|
|
|
|
for (const [pathKey, pathValues] of valuesByPath) {
|
|
|
|
|
if (pathValues.length === 1) {
|
|
|
|
|
aggregatedValues.push(pathValues[0]);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract intervals and metadata for OWA fusion
|
|
|
|
|
const intervals = pathValues.map(cv => cv.value); // cv.value is already an interval
|
|
|
|
|
const possibilities = pathValues.map(cv => cv.possibility);
|
|
|
|
|
const metas = pathValues.map(cv => ({
|
|
|
|
|
...cv.source,
|
|
|
|
|
...cv.metadata,
|
|
|
|
|
originalInterval: cv.value
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Use OWAFusion's interval arithmetic
|
|
|
|
|
const intervalResult = OWAFusion.fuseIntervalsWithMeta(
|
|
|
|
|
intervals,
|
|
|
|
|
metas,
|
|
|
|
|
null, // Use default weights
|
|
|
|
|
aggregationMethod
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Aggregate possibilities
|
|
|
|
|
const possibilityResult = OWAFusion.fuseWithMeta(
|
|
|
|
|
possibilities,
|
|
|
|
|
metas,
|
|
|
|
|
null,
|
|
|
|
|
aggregationMethod === 'sum' ? 'average' : aggregationMethod // For sum, average possibilities
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Create aggregated collected value
|
|
|
|
|
const aggregatedCV = this._createCollectedValue(
|
|
|
|
|
intervalResult.interval,
|
|
|
|
|
possibilityResult.value,
|
|
|
|
|
pathValues[0].path,
|
|
|
|
|
{
|
|
|
|
|
...pathValues[0].source,
|
|
|
|
|
aggregationMethod,
|
|
|
|
|
aggregatedFromCount: pathValues.length
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
...pathValues[0].metadata,
|
|
|
|
|
reliability: intervalResult.meta?.reliability ||
|
|
|
|
|
Math.min(...pathValues.map(cv => cv.metadata?.reliability || 1.0)),
|
|
|
|
|
aggregatedFromIntervals: intervals,
|
|
|
|
|
interval: intervalResult.interval
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
aggregatedValues.push(aggregatedCV);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return aggregatedValues;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Normalize rule configuration (for future extensibility)
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
_normalizeRule(rule) {
|
|
|
|
|
if (rule.chain) return { ...rule, ...rule.chain, chain: undefined };
|
|
|
|
|
return rule;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|