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,888 @@
|
||||
import { Arbiter } from '../core/Arbiter.js';
|
||||
import { RuleEvaluator } from './RuleEvaluator.js';
|
||||
import { RuleCollector } from './RuleCollector.js';
|
||||
import { ScratchBuffers } from './ScratchBuffers.js';
|
||||
import { buildRemediation, extractRemediation, mergeRemediationOptions } from './remediation.js';
|
||||
import { DecisionCache } from './DecisionCache.js';
|
||||
|
||||
export class AuthorizationChecker {
|
||||
constructor(arbiter, options = {}) {
|
||||
this.arbiter = arbiter;
|
||||
this.ruleEvaluator = new RuleEvaluator(arbiter);
|
||||
this.ruleCollector = new RuleCollector(arbiter);
|
||||
// DecisionCache port — RF-03 closure. When not injected, default to
|
||||
// an ArbiterDecisionCache that forwards to the arbiter's existing
|
||||
// cache fields, preserving the behavior every test relies on.
|
||||
this.decisionCache = options.decisionCache || new DecisionCache(arbiter);
|
||||
}
|
||||
|
||||
check(userKey, relation, objectKey, options = {}) {
|
||||
// Handle backward compatibility
|
||||
if (options instanceof Set) {
|
||||
options = { _visited: options, _currentRelation: arguments[4] };
|
||||
}
|
||||
|
||||
if (!options.scratch) {
|
||||
options.scratch = new ScratchBuffers();
|
||||
}
|
||||
|
||||
const {
|
||||
_visited = new Set(),
|
||||
_currentRelation = null,
|
||||
// Threshold-based early exit options
|
||||
minAllowPossibility = null,
|
||||
maxDenyPossibility = null,
|
||||
fastPath = false,
|
||||
// NEW: Binary mode for ultra-fast decisive authorization
|
||||
binary = false
|
||||
} = options;
|
||||
const explain = options.explain === true;
|
||||
const includeMeta = options.includeMeta === undefined ? explain : options.includeMeta;
|
||||
const trackEvaluation = options.trackEvaluation === undefined ? (explain ? true : false) : options.trackEvaluation;
|
||||
let collectValues = options.collectValues;
|
||||
const hasPartialGraph = !!options.partialGraphContext;
|
||||
|
||||
// BINARY MODE: Ultra-fast decisive authorization
|
||||
if (binary) {
|
||||
return this._checkBinary(userKey, relation, objectKey, {
|
||||
_visited,
|
||||
_currentRelation,
|
||||
minAllowPossibility: minAllowPossibility || 0.8, // Default strict threshold
|
||||
maxDenyPossibility: maxDenyPossibility || 0.8,
|
||||
includeMeta,
|
||||
trackEvaluation
|
||||
});
|
||||
}
|
||||
|
||||
const config = this.arbiter.relationConfigs.get(relation);
|
||||
if (collectValues === undefined) {
|
||||
collectValues = explain || config?._needsValues || false;
|
||||
}
|
||||
// CI-001 fix: the previous `!config._compiled` guard made the
|
||||
// direct-check fast path dead. `setRelationConfig` compiles
|
||||
// synchronously and sets `_compiled` immediately (ArbiterConfig.js),
|
||||
// so the guard was always false in production and `_cacheDirectCheckResult`
|
||||
// never fired. The fast-path decision is independent of compilation state.
|
||||
//
|
||||
// Narrowing: derived evidence rules normalize to type 'direct' but carry
|
||||
// `dependsOn` (e.g. session_authenticated_action { userIsActive(user) }).
|
||||
// Their semantics live in the compiled dependency evaluation, not in a raw
|
||||
// direct lookup — the fast path must not bypass them, or gate checks deny
|
||||
// with 'no_relation' where the full path derives possibility 1.0.
|
||||
const hasDerivedDependencies = Array.isArray(config?.dependsOn) && config.dependsOn.length > 0;
|
||||
const useFastPath = config && config.type === 'direct' && !config.union && !config.intersection && !config.exclusion && !hasDerivedDependencies;
|
||||
const effectiveThreshold = minAllowPossibility ?? config?.minPossibility ?? null;
|
||||
// A direct config may override the relation it checks (rule.relation).
|
||||
// The fast path must honor that override or it diverges from the
|
||||
// rule-evaluation path (e.g. can_read -> gateway_context_ref).
|
||||
const effectiveRelation = (config && (config.relation || config.rel)) || relation;
|
||||
if (useFastPath) {
|
||||
// Check cache first using composite key (if caching is enabled)
|
||||
let cachedResult = null;
|
||||
let cacheHint = null;
|
||||
if (!hasPartialGraph && this.decisionCache.directEnabled) {
|
||||
const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey);
|
||||
const [hitResult, status] = this.decisionCache.peekDirect(cacheKey);
|
||||
cachedResult = status === 'hit' || status === 'expired' ? { result: hitResult, timestamp: 0 } : null;
|
||||
if (status === 'hit') {
|
||||
cacheHint = { hit: true, result: hitResult };
|
||||
if (!explain) {
|
||||
return hitResult;
|
||||
}
|
||||
} else if (explain && status === 'expired') {
|
||||
cacheHint = { hit: false, result: hitResult };
|
||||
}
|
||||
}
|
||||
|
||||
// Ultra-fast direct check
|
||||
const userId = this.arbiter.resolveNodeId(userKey, options);
|
||||
const objectId = this.arbiter.resolveNodeId(objectKey, options);
|
||||
|
||||
if (userId === undefined || objectId === undefined) {
|
||||
const result = {
|
||||
possibility: 0,
|
||||
...(includeMeta && { meta: { reason: 'missing_node' } }),
|
||||
reason: 'missing_node'
|
||||
};
|
||||
|
||||
// Cache the result (only when no partial graph — same guard as success path)
|
||||
if (!explain && !hasPartialGraph) {
|
||||
this._cacheDirectCheckResult(userKey, relation, objectKey, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (_visited.size) {
|
||||
const useKeyedVisited = this._getVisitedMode(_visited);
|
||||
const visitKey = useKeyedVisited ? this._getVisitedKey(userId, relation, objectId) : null;
|
||||
if (useKeyedVisited) {
|
||||
if (_visited.has(visitKey)) {
|
||||
return {
|
||||
possibility: 0,
|
||||
...(includeMeta && { meta: { reason: 'cycle' } }),
|
||||
reason: 'cycle'
|
||||
};
|
||||
}
|
||||
} else {
|
||||
for (const visited of _visited) {
|
||||
if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) {
|
||||
return {
|
||||
possibility: 0,
|
||||
...(includeMeta && { meta: { reason: 'cycle' } }),
|
||||
reason: 'cycle'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direct index lookup
|
||||
let directRel = null;
|
||||
let partialRel = null;
|
||||
if (hasPartialGraph) {
|
||||
partialRel = options.partialGraphContext.getDirectRelation(userId, effectiveRelation, objectId);
|
||||
}
|
||||
directRel = this.arbiter.indices.getDirectRelation(userId, effectiveRelation, objectId) || partialRel;
|
||||
|
||||
let result;
|
||||
if (directRel) {
|
||||
// Check threshold-based early exit
|
||||
if (effectiveThreshold !== null && directRel.possibility < effectiveThreshold) {
|
||||
result = {
|
||||
possibility: 0,
|
||||
...(includeMeta && { meta: { reason: 'threshold_not_met', threshold: effectiveThreshold, actual: directRel.possibility } }),
|
||||
reason: 'threshold_not_met'
|
||||
};
|
||||
} else {
|
||||
result = {
|
||||
possibility: directRel.possibility,
|
||||
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
allow: {
|
||||
ruleType: 'direct',
|
||||
reason: 'direct',
|
||||
source: directRel.source || 'persistent',
|
||||
layer_name: directRel.layer_name || null,
|
||||
source_class: directRel.source_class || null,
|
||||
reducer_applied: directRel.reducer_applied || null
|
||||
}
|
||||
}
|
||||
}),
|
||||
reason: 'direct_match'
|
||||
};
|
||||
|
||||
// Collect values if present
|
||||
if (collectValues && directRel.value !== undefined) {
|
||||
result.collectedValues = [{
|
||||
value: directRel.value,
|
||||
source: 'direct_relation',
|
||||
relation: relation,
|
||||
userKey: userKey,
|
||||
objectKey: objectKey
|
||||
}];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fast-path miss: attach remediation when the missing (effective)
|
||||
// relation is declared as an injectable witness source — the caller
|
||||
// needs to know which relation to satisfy.
|
||||
const missingConfig = this.arbiter.relationConfigs.get(effectiveRelation);
|
||||
let remediation = null;
|
||||
if (missingConfig && missingConfig.injectable) {
|
||||
remediation = buildRemediation(null, {
|
||||
status: 'required',
|
||||
additional_options: [{ relation: effectiveRelation, object: objectKey }]
|
||||
});
|
||||
}
|
||||
result = {
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
...(includeMeta && { meta: { reason: 'no_relation' } }),
|
||||
reason: 'no_relation',
|
||||
...(remediation ? { remediation } : {})
|
||||
};
|
||||
}
|
||||
|
||||
if (explain && cacheHint) {
|
||||
result.meta = result.meta || {};
|
||||
result.meta.cache = cacheHint;
|
||||
}
|
||||
|
||||
// Cache the result using composite key
|
||||
if (!explain && !hasPartialGraph) {
|
||||
this._cacheDirectCheckResult(userKey, relation, objectKey, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
this.arbiter.relationManager._ensureIndicesBuilt();
|
||||
|
||||
|
||||
const userId = this.arbiter.resolveNodeId(userKey, options);
|
||||
const objectId = this.arbiter.resolveNodeId(objectKey, options);
|
||||
|
||||
if (userId === undefined || objectId === undefined) {
|
||||
const missingNode = userId === undefined ? userKey : objectKey;
|
||||
const missingType = userId === undefined ? 'user' : 'object';
|
||||
|
||||
return {
|
||||
possibility: 0,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
reason: 'missing_node',
|
||||
missingNode,
|
||||
missingType
|
||||
}
|
||||
}),
|
||||
reason: 'missing_node'
|
||||
};
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
possibility: 0,
|
||||
...(includeMeta && { meta: { reason: 'no_config' } }),
|
||||
reason: 'no_config'
|
||||
};
|
||||
}
|
||||
|
||||
const canCacheRuleResult = !hasPartialGraph && !explain && !includeMeta &&
|
||||
!binary && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled;
|
||||
const ruleCacheKey = canCacheRuleResult
|
||||
? this._getRuleResultCacheKey(userId, relation, objectId)
|
||||
: null;
|
||||
if (canCacheRuleResult) {
|
||||
const cached = this.decisionCache.getRule(ruleCacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const useKeyedVisited = this._getVisitedMode(_visited);
|
||||
const visitKey = useKeyedVisited ? this._getVisitedKey(userId, relation, objectId) : null;
|
||||
if (useKeyedVisited) {
|
||||
if (_visited.has(visitKey)) {
|
||||
return {
|
||||
possibility: 0,
|
||||
...(includeMeta && { meta: { reason: 'cycle' } }),
|
||||
reason: 'cycle'
|
||||
};
|
||||
}
|
||||
} else {
|
||||
for (const visited of _visited) {
|
||||
if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) {
|
||||
return {
|
||||
possibility: 0,
|
||||
...(includeMeta && { meta: { reason: 'cycle' } }),
|
||||
reason: 'cycle'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_visited.add(useKeyedVisited ? visitKey : { userKey, relation, objectKey });
|
||||
|
||||
const shouldTrackEvaluation = trackEvaluation && includeMeta;
|
||||
const evaluationErrors = shouldTrackEvaluation ? [] : null;
|
||||
const evaluationPath = shouldTrackEvaluation ? {
|
||||
userKey,
|
||||
relation,
|
||||
objectKey,
|
||||
config,
|
||||
rules: [],
|
||||
visitedPath: Array.from(_visited),
|
||||
errors: evaluationErrors
|
||||
} : null;
|
||||
|
||||
const evalOptions = {
|
||||
...options,
|
||||
fastPath,
|
||||
minAllowPossibility,
|
||||
minPossibility: minAllowPossibility,
|
||||
maxDenyPossibility,
|
||||
trackEvaluation: shouldTrackEvaluation,
|
||||
collectValues,
|
||||
includeMeta
|
||||
};
|
||||
|
||||
if (config.union || config.intersection || config.exclusion) {
|
||||
const res = this.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
userKey,
|
||||
objectId,
|
||||
objectKey,
|
||||
config,
|
||||
_visited,
|
||||
relation,
|
||||
evalOptions
|
||||
);
|
||||
|
||||
if (evaluationPath) {
|
||||
evaluationPath.type = 'logical_operator';
|
||||
evaluationPath.operator = config.union ? 'union' : config.intersection ? 'intersection' : 'exclusion';
|
||||
evaluationPath.result = res;
|
||||
}
|
||||
|
||||
// Handle both old format (possibility_allow) and new format (possibility)
|
||||
const resPossibility = res.possibility_allow !== undefined ? res.possibility_allow : res.possibility;
|
||||
|
||||
// Extract allow/deny from meta to avoid conflicts
|
||||
const { allow: metaAllow, deny: metaDeny, ...restMeta } = res.meta || {};
|
||||
|
||||
const remediation = buildRemediation(extractRemediation(res));
|
||||
const finalResult = {
|
||||
possibility: resPossibility || 0,
|
||||
reliability: res.reliability !== undefined ? res.reliability : 1.0,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
...restMeta, // Spread meta without allow/deny
|
||||
allow: res.meta_allow || metaAllow, // Use meta_allow if available, otherwise meta.allow
|
||||
deny: res.meta_deny || metaDeny,
|
||||
...(evaluationPath && { evaluation: evaluationPath }),
|
||||
earlyExit: res.meta?.earlyExit || false
|
||||
}
|
||||
}),
|
||||
...(remediation ? { remediation } : {}),
|
||||
reason: res.reason || 'logical_operator_evaluation'
|
||||
};
|
||||
return this._maybeCacheRuleResult(finalResult, relation, ruleCacheKey, canCacheRuleResult);
|
||||
}
|
||||
|
||||
const rules = this.ruleCollector.collectRules(config, null, relation);
|
||||
|
||||
if (evaluationPath) {
|
||||
evaluationPath.type = 'rule_collection';
|
||||
evaluationPath.collectedRules = rules.length;
|
||||
}
|
||||
|
||||
let maxAllow = 0;
|
||||
let maxDeny = 0;
|
||||
let bestAllowReliability = 0;
|
||||
let bestDenyReliability = 0;
|
||||
let bestAllow = null;
|
||||
let bestDeny = null;
|
||||
let reason = undefined;
|
||||
let allRuleResults = shouldTrackEvaluation ? [] : null;
|
||||
let allCollectedValues = collectValues ? [] : null; // Collect values from all evaluated rules
|
||||
|
||||
const remediationOptions = [];
|
||||
let ruleIndex = 0;
|
||||
for (const rule of rules) {
|
||||
const res = this.ruleEvaluator.evaluateRule(userId, userKey, objectId, objectKey, rule, _visited, relation, evalOptions);
|
||||
|
||||
// Handle both old format (possibility_allow/deny) and new format (possibility)
|
||||
const resAllowPossibility = res.possibility_allow !== undefined ? res.possibility_allow : res.possibility;
|
||||
const resDenyPossibility = res.possibility_deny !== undefined ? res.possibility_deny : 0;
|
||||
const resMeta = res.meta_allow || res.meta?.allow || null;
|
||||
mergeRemediationOptions(remediationOptions, extractRemediation(res));
|
||||
|
||||
if (evaluationErrors && (res.error || res.reason === 'evaluation_error')) {
|
||||
evaluationErrors.push({
|
||||
ruleIndex,
|
||||
level: rule.ruleType || null,
|
||||
relation: rule.relation || relation,
|
||||
message: res.details?.error || res.reason || 'evaluation_error',
|
||||
stack: res.details?.stack || null
|
||||
});
|
||||
}
|
||||
|
||||
// Track each rule evaluation
|
||||
if (shouldTrackEvaluation) {
|
||||
const ruleEvaluation = {
|
||||
rule: {
|
||||
type: rule.type,
|
||||
relation: rule.relation,
|
||||
ruleType: rule.ruleType,
|
||||
reverse: rule.reverse
|
||||
},
|
||||
result: {
|
||||
possibility_allow: resAllowPossibility,
|
||||
possibility_deny: resDenyPossibility,
|
||||
reason: res.reason
|
||||
},
|
||||
meta: {
|
||||
allow: resMeta,
|
||||
deny: res.meta_deny,
|
||||
full: res.meta || null
|
||||
}
|
||||
};
|
||||
allRuleResults.push(ruleEvaluation);
|
||||
}
|
||||
|
||||
ruleIndex += 1;
|
||||
|
||||
// Preserve specific reasons from rule evaluations
|
||||
if (res.reason === 'cycle') reason = 'cycle';
|
||||
if (res.reason === 'no_path') reason = 'no_path';
|
||||
if (res.reason === 'no_similar_users') reason = 'no_similar_users';
|
||||
if (res.reason === 'no_similar_authorized') reason = 'no_similar_authorized';
|
||||
if (res.reason === 'no_similar_objects') reason = 'no_similar_objects';
|
||||
if (res.reason === 'no_user_objects') reason = 'no_user_objects';
|
||||
if (res.reason === 'no_target_embedding') reason = 'no_target_embedding';
|
||||
if (res.reason === 'no_embedding') reason = 'no_embedding';
|
||||
// Add chain rule reasons
|
||||
if (res.reason === 'chain_path_found') reason = 'chain_path_found';
|
||||
if (res.reason === 'no_chain_path_found') reason = 'no_chain_path_found';
|
||||
if (res.reason === 'no_chain_steps_defined') reason = 'no_chain_steps_defined';
|
||||
// Add parent rule reasons
|
||||
if (res.reason === 'no_parent_relationship_found') reason = 'no_parent_relationship_found';
|
||||
if (res.reason === 'no_parent_relationship_path_above_threshold') reason = 'no_parent_relationship_path_above_threshold';
|
||||
// Add multi-hop rule reasons
|
||||
if (res.reason === 'no_multihop_path_found') reason = 'no_multihop_path_found';
|
||||
if (res.reason === 'multihop_path_found') reason = 'multihop_path_found';
|
||||
// Add direct rule reasons
|
||||
if (res.reason === 'direct_match') reason = 'direct_match';
|
||||
if (res.reason === 'no_direct_match') reason = 'no_direct_match';
|
||||
// Add tuple-to-userset reasons
|
||||
if (res.reason === 'tuple_to_userset_match') reason = 'tuple_to_userset_match';
|
||||
if (res.reason === 'no_tuple_to_userset_match') reason = 'no_tuple_to_userset_match';
|
||||
// Add relational comparator reasons
|
||||
if (res.reason === 'values_compared_comparison_true') reason = 'values_compared_comparison_true';
|
||||
if (res.reason === 'values_compared_comparison_false') reason = 'values_compared_comparison_false';
|
||||
if (res.reason === 'values_compared_comparison_insufficient') reason = 'values_compared_comparison_insufficient';
|
||||
|
||||
if (resAllowPossibility > maxAllow) {
|
||||
maxAllow = resAllowPossibility;
|
||||
bestAllow = resMeta;
|
||||
bestAllowReliability = res.reliability !== undefined ? res.reliability : 1.0;
|
||||
}
|
||||
|
||||
if (resDenyPossibility > maxDeny) {
|
||||
maxDeny = resDenyPossibility;
|
||||
bestDeny = res.meta_deny;
|
||||
bestDenyReliability = res.reliability !== undefined ? res.reliability : 1.0;
|
||||
}
|
||||
|
||||
// Fast path early exit checks
|
||||
if (fastPath) {
|
||||
let shouldExit = false;
|
||||
let exitReason = null;
|
||||
|
||||
// Check allow threshold
|
||||
if (minAllowPossibility !== null && maxAllow >= minAllowPossibility) {
|
||||
shouldExit = true;
|
||||
exitReason = 'allow_threshold_met';
|
||||
}
|
||||
|
||||
// Check deny threshold
|
||||
if (maxDenyPossibility !== null && maxDeny >= maxDenyPossibility) {
|
||||
shouldExit = true;
|
||||
exitReason = 'deny_threshold_met';
|
||||
}
|
||||
|
||||
if (shouldExit) {
|
||||
if (evaluationPath) {
|
||||
evaluationPath.rules = allRuleResults;
|
||||
evaluationPath.earlyExit = {
|
||||
reason: exitReason,
|
||||
threshold: exitReason === 'allow_threshold_met' ? minAllowPossibility : maxDenyPossibility,
|
||||
actualValue: exitReason === 'allow_threshold_met' ? maxAllow : maxDeny,
|
||||
rulesEvaluated: allRuleResults.length,
|
||||
totalRules: rules.length
|
||||
};
|
||||
Arbiter.log('early exit triggered:', evaluationPath.earlyExit);
|
||||
}
|
||||
|
||||
const finalResult = {
|
||||
possibility: maxAllow,
|
||||
reliability: maxAllow > 0 ? bestAllowReliability : 0,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
allow: bestAllow,
|
||||
deny: bestDeny,
|
||||
...(evaluationPath && { evaluation: evaluationPath }),
|
||||
earlyExit: true,
|
||||
maxDeny: maxDeny
|
||||
}
|
||||
}),
|
||||
reason: exitReason
|
||||
};
|
||||
return this._maybeCacheRuleResult(finalResult, relation, ruleCacheKey, canCacheRuleResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect values from all evaluated rules
|
||||
if (collectValues && res.collectedValues && Array.isArray(res.collectedValues)) {
|
||||
allCollectedValues.push(...res.collectedValues);
|
||||
}
|
||||
}
|
||||
|
||||
if (evaluationPath) {
|
||||
evaluationPath.rules = allRuleResults;
|
||||
evaluationPath.finalResult = {
|
||||
maxAllow,
|
||||
maxDeny,
|
||||
reason
|
||||
};
|
||||
}
|
||||
|
||||
// Determine final reason based on evaluation
|
||||
let finalReason = reason;
|
||||
if (maxAllow === 0 && maxDeny === 0) {
|
||||
finalReason = reason || 'no_matching_rule';
|
||||
} else if (maxAllow > 0 && maxDeny > 0) {
|
||||
finalReason = 'conflicting_rules';
|
||||
} else if (maxAllow > 0) {
|
||||
finalReason = 'allow_rule_matched';
|
||||
} else if (maxDeny > 0) {
|
||||
finalReason = 'deny_rule_matched';
|
||||
}
|
||||
|
||||
const remediation = maxAllow === 0
|
||||
? buildRemediation(remediationOptions.length > 0 ? { status: 'required', options: remediationOptions } : null, { status: 'required' })
|
||||
: null;
|
||||
const result = {
|
||||
possibility: maxAllow,
|
||||
reliability: maxAllow > 0 ? bestAllowReliability : maxDeny > 0 ? bestDenyReliability : 0,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
allow: bestAllow,
|
||||
deny: bestDeny,
|
||||
...(evaluationPath && { evaluation: evaluationPath }),
|
||||
maxDeny: maxDeny, // Keep deny info in meta for debugging
|
||||
...(collectValues && allCollectedValues.length > 0 && { collectedValues: allCollectedValues }),
|
||||
...(maxAllow === 0 && remediation ? { remediation } : {})
|
||||
}
|
||||
}),
|
||||
...(maxAllow === 0 && remediation ? { remediation } : {}),
|
||||
reason: finalReason
|
||||
};
|
||||
|
||||
// Add collectedValues at top level if there are any
|
||||
if (collectValues && allCollectedValues.length > 0) {
|
||||
result.collectedValues = allCollectedValues;
|
||||
}
|
||||
|
||||
return this._maybeCacheRuleResult(result, relation, ruleCacheKey, canCacheRuleResult);
|
||||
}
|
||||
|
||||
_getRuleResultCacheKey(userId, relation, objectId) {
|
||||
return this.arbiter.keyManager.createCompositeKey(userId, relation, objectId);
|
||||
}
|
||||
|
||||
_maybeCacheRuleResult(result, relation, cacheKey, enabled) {
|
||||
if (!enabled || !cacheKey) return result;
|
||||
this.decisionCache.setRule(cacheKey, result);
|
||||
this.decisionCache.trackRuleKeyForRelation(relation, cacheKey);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary mode: Ultra-fast decisive authorization with strict thresholds
|
||||
* Returns simple allow/deny decisions with minimal overhead
|
||||
*/
|
||||
_checkBinary(userKey, relation, objectKey, options = {}) {
|
||||
const {
|
||||
_visited = new Set(),
|
||||
_currentRelation = null,
|
||||
minAllowPossibility = 0.8,
|
||||
maxDenyPossibility = 0.8,
|
||||
includeMeta = false,
|
||||
trackEvaluation = false
|
||||
} = options;
|
||||
|
||||
// Track evaluation for binary mode
|
||||
const evaluation = (includeMeta || trackEvaluation) ? {
|
||||
type: 'binary',
|
||||
userKey,
|
||||
relation,
|
||||
objectKey,
|
||||
thresholds: { minAllowPossibility, maxDenyPossibility },
|
||||
evaluationStarted: Date.now()
|
||||
} : null;
|
||||
|
||||
const userId = this.arbiter.resolveNodeId(userKey, options);
|
||||
const objectId = this.arbiter.resolveNodeId(objectKey, options);
|
||||
|
||||
if (userId === undefined || objectId === undefined) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reason: 'missing_node',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation })
|
||||
};
|
||||
}
|
||||
|
||||
// Check for cycles using efficient approach
|
||||
const useKeyedVisited = this._getVisitedMode(_visited);
|
||||
const visitKey = useKeyedVisited ? this._getVisitedKey(userId, relation, objectId) : null;
|
||||
if (useKeyedVisited) {
|
||||
if (_visited.has(visitKey)) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reason: 'cycle',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation })
|
||||
};
|
||||
}
|
||||
} else {
|
||||
for (const visited of _visited) {
|
||||
if (visited.userKey === userKey && visited.relation === relation && visited.objectKey === objectKey) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reason: 'cycle',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation })
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_visited.add(useKeyedVisited ? visitKey : { userKey, relation, objectKey });
|
||||
|
||||
const config = this.arbiter.relationConfigs.get(relation);
|
||||
if (!config) {
|
||||
return {
|
||||
possibility: 0,
|
||||
reason: 'no_config',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation })
|
||||
};
|
||||
}
|
||||
|
||||
if (evaluation) {
|
||||
evaluation.rulesEvaluated = 0;
|
||||
evaluation.earlyTermination = false;
|
||||
}
|
||||
|
||||
// Fast path for direct relations in binary mode
|
||||
if (config.type === 'direct') {
|
||||
let directRel = null;
|
||||
let partialRel = null;
|
||||
// A direct config may alias an underlying relation (config.relation);
|
||||
// the checked relation name alone is the wrong lookup key.
|
||||
const effectiveRelation = config.relation || relation;
|
||||
if (options.partialGraphContext) {
|
||||
partialRel = options.partialGraphContext.getDirectRelation(userId, effectiveRelation, objectId);
|
||||
}
|
||||
directRel = this.arbiter.indices.getDirectRelation(userId, effectiveRelation, objectId) || partialRel;
|
||||
if (directRel) {
|
||||
const allow = directRel.possibility >= minAllowPossibility;
|
||||
const deny = false; // Direct relations don't have explicit deny values
|
||||
|
||||
if (evaluation) {
|
||||
evaluation.rulesEvaluated = 1;
|
||||
evaluation.evaluationCompleted = Date.now();
|
||||
evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted;
|
||||
}
|
||||
|
||||
return {
|
||||
possibility: directRel.possibility,
|
||||
reason: allow ? 'allow' : deny ? 'deny' : 'insufficient_confidence',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation }),
|
||||
allow,
|
||||
deny
|
||||
};
|
||||
} else {
|
||||
if (evaluation) {
|
||||
evaluation.rulesEvaluated = 1;
|
||||
evaluation.evaluationCompleted = Date.now();
|
||||
evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted;
|
||||
}
|
||||
|
||||
return {
|
||||
possibility: 0,
|
||||
reason: 'insufficient_confidence',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation }),
|
||||
allow: false,
|
||||
deny: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle logical operators with binary evaluation
|
||||
if (config.union || config.intersection || config.exclusion) {
|
||||
const res = this.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
userKey,
|
||||
objectId,
|
||||
objectKey,
|
||||
config,
|
||||
_visited,
|
||||
relation,
|
||||
{
|
||||
fastPath: true,
|
||||
minAllowPossibility,
|
||||
maxDenyPossibility,
|
||||
binary: true,
|
||||
...options
|
||||
}
|
||||
);
|
||||
|
||||
if (evaluation) {
|
||||
evaluation.rulesEvaluated = 1;
|
||||
evaluation.evaluationCompleted = Date.now();
|
||||
evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted;
|
||||
}
|
||||
|
||||
// Handle both old format (possibility_allow/deny) and new format (possibility)
|
||||
const resAllowPossibility = res.possibility_allow !== undefined ? res.possibility_allow : res.possibility;
|
||||
const resDenyPossibility = res.possibility_deny !== undefined ? res.possibility_deny : 0;
|
||||
|
||||
// Binary decision based on strict thresholds
|
||||
const allow = resAllowPossibility >= minAllowPossibility;
|
||||
const deny = resDenyPossibility >= maxDenyPossibility;
|
||||
|
||||
return {
|
||||
possibility: resAllowPossibility || 0,
|
||||
reason: allow ? 'allow' : deny ? 'deny' : 'insufficient_confidence',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation }),
|
||||
allow, // Keep for backwards compatibility
|
||||
deny // Keep for backwards compatibility
|
||||
};
|
||||
}
|
||||
|
||||
// Collect and evaluate rules with early termination
|
||||
const rules = this.ruleCollector.collectRules(config, null, relation);
|
||||
|
||||
let maxAllow = 0;
|
||||
let maxDeny = 0;
|
||||
|
||||
for (const rule of rules) {
|
||||
if (evaluation) {
|
||||
evaluation.rulesEvaluated++;
|
||||
}
|
||||
|
||||
const res = this.ruleEvaluator.evaluateRule(userId, userKey, objectId, objectKey, rule, _visited, relation, {
|
||||
fastPath: true,
|
||||
minAllowPossibility,
|
||||
maxDenyPossibility,
|
||||
binary: true,
|
||||
...options
|
||||
});
|
||||
|
||||
// Prefer the continuous possibility; binarized possibility_allow (1|0)
|
||||
// must only be a fallback so reported strengths stay continuous.
|
||||
const resAllowPossibility = typeof res.possibility === 'number' ? res.possibility : (res.possibility_allow !== undefined ? res.possibility_allow : 0);
|
||||
const resDenyPossibility = res.possibility_deny !== undefined ? res.possibility_deny : 0;
|
||||
|
||||
if (resAllowPossibility > maxAllow) {
|
||||
maxAllow = resAllowPossibility;
|
||||
}
|
||||
|
||||
if (resDenyPossibility > maxDeny) {
|
||||
maxDeny = resDenyPossibility;
|
||||
}
|
||||
|
||||
// BINARY EARLY TERMINATION: Stop as soon as we hit a threshold
|
||||
if (maxAllow >= minAllowPossibility) {
|
||||
if (evaluation) {
|
||||
evaluation.earlyTermination = true;
|
||||
evaluation.terminationReason = 'allow_threshold_met';
|
||||
evaluation.evaluationCompleted = Date.now();
|
||||
evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted;
|
||||
}
|
||||
|
||||
return {
|
||||
possibility: maxAllow,
|
||||
reason: 'allow',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation }),
|
||||
allow: true, // Keep for backwards compatibility
|
||||
deny: false // Keep for backwards compatibility
|
||||
};
|
||||
}
|
||||
|
||||
if (maxDeny >= maxDenyPossibility) {
|
||||
if (evaluation) {
|
||||
evaluation.earlyTermination = true;
|
||||
evaluation.terminationReason = 'deny_threshold_met';
|
||||
evaluation.evaluationCompleted = Date.now();
|
||||
evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted;
|
||||
}
|
||||
|
||||
return {
|
||||
possibility: 0,
|
||||
reason: 'deny',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation }),
|
||||
allow: false, // Keep for backwards compatibility
|
||||
deny: true // Keep for backwards compatibility
|
||||
};
|
||||
}
|
||||
|
||||
// Limit rule evaluation in binary mode for performance.
|
||||
// maxBinaryRules defaults to Infinity (no cap) — set lower if you
|
||||
// understand the false-denial risk for policies with many rules.
|
||||
if (evaluation && evaluation.rulesEvaluated >= (options.maxBinaryRules ?? Infinity)) {
|
||||
evaluation.earlyTermination = true;
|
||||
evaluation.terminationReason = 'max_rules_evaluated';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (evaluation) {
|
||||
evaluation.evaluationCompleted = Date.now();
|
||||
evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted;
|
||||
}
|
||||
|
||||
// Final binary decision
|
||||
const allow = maxAllow >= minAllowPossibility;
|
||||
const deny = maxDeny >= maxDenyPossibility;
|
||||
|
||||
return {
|
||||
possibility: maxAllow,
|
||||
reason: allow ? 'allow' : deny ? 'deny' : 'insufficient_confidence',
|
||||
binary: true,
|
||||
...(evaluation && { evaluation }),
|
||||
allow, // Keep for backwards compatibility
|
||||
deny // Keep for backwards compatibility
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for direct check
|
||||
* @private
|
||||
*/
|
||||
_getDirectCheckCacheKey(userKey, relation, objectKey) {
|
||||
// Use composite key for better performance - keyManager handles string-to-ID conversion internally
|
||||
return this.arbiter.keyManager.createCompositeKey(
|
||||
this.arbiter.keyManager.getStringId(userKey),
|
||||
relation,
|
||||
this.arbiter.keyManager.getStringId(objectKey)
|
||||
);
|
||||
}
|
||||
|
||||
_cacheDirectCheckResult(userKey, relation, objectKey, result) {
|
||||
if (!this.decisionCache.directEnabled) return;
|
||||
const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey);
|
||||
this.decisionCache.setDirect(cacheKey, result);
|
||||
}
|
||||
|
||||
_getVisitedMode(visited) {
|
||||
if (visited.__fastKeyed !== undefined) {
|
||||
return visited.__fastKeyed;
|
||||
}
|
||||
if (visited.size === 0) {
|
||||
visited.__fastKeyed = true;
|
||||
return true;
|
||||
}
|
||||
for (const entry of visited) {
|
||||
const isKeyed = typeof entry === 'string';
|
||||
visited.__fastKeyed = isKeyed;
|
||||
return isKeyed;
|
||||
}
|
||||
visited.__fastKeyed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
_getVisitedKey(userId, relation, objectId) {
|
||||
const relationId = this.arbiter.keyManager._getRelationId(relation);
|
||||
return `${userId}|${relationId}|${objectId}`;
|
||||
}
|
||||
|
||||
invalidateRuleCaches(relation) {
|
||||
// Invalidate ChainRule caches if it exists
|
||||
if (this.ruleEvaluator && this.ruleEvaluator.ruleHandlers && this.ruleEvaluator.ruleHandlers.chain) {
|
||||
this.ruleEvaluator.ruleHandlers.chain._invalidateAllChainCaches();
|
||||
}
|
||||
|
||||
// Invalidate other rule caches as needed
|
||||
// TODO: Add invalidation for other rule types that have caches
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
import { OWAFusion, getOWAWeightsFromRule } from '../utils/OWAFusion.js';
|
||||
import { buildRemediation, extractRemediation, mergeRemediationOptions } from './remediation.js';
|
||||
|
||||
export class CompiledEvaluator {
|
||||
constructor(arbiter, ruleEvaluator, logicalOperators) {
|
||||
this.arbiter = arbiter;
|
||||
this.ruleEvaluator = ruleEvaluator;
|
||||
this.logicalOperators = logicalOperators;
|
||||
}
|
||||
|
||||
evaluate(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options = {}) {
|
||||
if (!compiled || typeof compiled !== 'object') {
|
||||
return { possibility: 0, reason: 'missing_compiled_rule' };
|
||||
}
|
||||
|
||||
switch (compiled.type) {
|
||||
case 'direct':
|
||||
return this._evaluateDirect(compiled, userId, userKey, objectId, objectKey, options, currentRelation);
|
||||
case 'computed':
|
||||
return this._evaluateComputed(compiled, userKey, objectKey, options, visited, currentRelation);
|
||||
case 'tuple_to_userset':
|
||||
return this._evaluateTupleToUserset(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
|
||||
case 'chain':
|
||||
return this._evaluateChain(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
|
||||
case 'multi_hop':
|
||||
return this._evaluateMultiHop(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
|
||||
case 'logical':
|
||||
return this._evaluateLogical(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
|
||||
case 'defeasible':
|
||||
return this._evaluateDefeasible(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
|
||||
case 'relational_comparator':
|
||||
return this._evaluateRelationalComparator(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
|
||||
case 'challenge':
|
||||
return this.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
userKey,
|
||||
objectId,
|
||||
objectKey,
|
||||
compiled,
|
||||
visited,
|
||||
currentRelation,
|
||||
{ ...options, useCompiled: false }
|
||||
);
|
||||
default:
|
||||
if (compiled.source) {
|
||||
return this.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
userKey,
|
||||
objectId,
|
||||
objectKey,
|
||||
compiled.source,
|
||||
visited,
|
||||
currentRelation,
|
||||
{ ...options, useCompiled: false }
|
||||
);
|
||||
}
|
||||
return { possibility: 0, reason: 'unsupported_compiled_rule' };
|
||||
}
|
||||
}
|
||||
|
||||
_evaluateRelationalComparator(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
|
||||
if (compiled.source) {
|
||||
return this.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
userKey,
|
||||
objectId,
|
||||
objectKey,
|
||||
compiled.source,
|
||||
visited,
|
||||
currentRelation,
|
||||
{ ...options, useCompiled: false }
|
||||
);
|
||||
}
|
||||
|
||||
const toOperandConfig = (operand) => {
|
||||
if (!operand) return null;
|
||||
return {
|
||||
rule: operand.rule || null,
|
||||
extractValue: operand.extractValue !== false,
|
||||
valueRelation: operand.valueRelationResolved || operand.valueRelation || null,
|
||||
evaluateFrom: operand.evaluateFrom || 'auto',
|
||||
aggregator: operand.aggregator,
|
||||
owaWeights: operand.owaWeights,
|
||||
_owaSparseCount: operand._owaSparseCount,
|
||||
minOperandPossibility: operand.minOperandPossibility
|
||||
};
|
||||
};
|
||||
|
||||
const fallbackConfig = {
|
||||
type: 'relational_comparator',
|
||||
comparator: compiled.comparator,
|
||||
marginOfSafety: compiled.marginOfSafety,
|
||||
fallbackBehavior: compiled.fallbackBehavior,
|
||||
minRulePossibility: compiled.minRulePossibility,
|
||||
epsilon: compiled.epsilon,
|
||||
left: toOperandConfig(compiled.left),
|
||||
right: toOperandConfig(compiled.right)
|
||||
};
|
||||
|
||||
return this.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
userKey,
|
||||
objectId,
|
||||
objectKey,
|
||||
fallbackConfig,
|
||||
visited,
|
||||
currentRelation,
|
||||
{ ...options, useCompiled: false }
|
||||
);
|
||||
}
|
||||
|
||||
_evaluateDirect(compiled, userId, userKey, objectId, objectKey, options, currentRelation) {
|
||||
const { fastPath = false, minPossibility = null, includeMeta = true } = options;
|
||||
const relName = compiled.relation || currentRelation;
|
||||
const reverse = compiled.reverse;
|
||||
const collectValues = options.collectValues !== undefined ? options.collectValues : compiled.collectValues !== false;
|
||||
|
||||
let directRel;
|
||||
if (reverse) {
|
||||
directRel = this.arbiter.relationManager.getDirectRelation(objectId, relName, userId, options);
|
||||
} else {
|
||||
directRel = this.arbiter.relationManager.getDirectRelation(userId, relName, objectId, options);
|
||||
}
|
||||
|
||||
if (!directRel) {
|
||||
return {
|
||||
possibility: 0,
|
||||
...(includeMeta && { meta: { ruleType: 'direct', reason: 'no_relation' } }),
|
||||
reason: 'no_relation'
|
||||
};
|
||||
}
|
||||
|
||||
const relationStrength = directRel.possibility;
|
||||
const _source = directRel.source || 'persistent';
|
||||
const _allowMeta = {
|
||||
ruleType: 'direct',
|
||||
reason: 'direct',
|
||||
source: _source,
|
||||
layer_name: directRel.layer_name || null,
|
||||
source_class: directRel.source_class || null,
|
||||
reducer_applied: directRel.reducer_applied || null
|
||||
};
|
||||
const result = {
|
||||
possibility: relationStrength,
|
||||
possibility_allow: relationStrength,
|
||||
possibility_deny: 0,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
ruleType: 'direct',
|
||||
reason: 'relation_exists',
|
||||
relation: relName,
|
||||
reverse: reverse || false,
|
||||
strength: relationStrength,
|
||||
source: _source,
|
||||
allow: _allowMeta
|
||||
},
|
||||
meta_allow: _allowMeta
|
||||
}),
|
||||
reason: 'exists'
|
||||
};
|
||||
|
||||
if (fastPath && minPossibility !== null && result.possibility >= minPossibility) {
|
||||
if (result.meta) {
|
||||
result.meta.earlyExit = true;
|
||||
result.meta.earlyExitReason = 'strength_threshold_met';
|
||||
}
|
||||
}
|
||||
|
||||
if (collectValues && directRel.value !== undefined) {
|
||||
const sourceEntity = reverse ? objectKey : userKey;
|
||||
const targetEntity = reverse ? userKey : objectKey;
|
||||
const path = [sourceEntity, targetEntity];
|
||||
result.collectedValues = [
|
||||
this.ruleEvaluator.ruleHandlers.direct._createCollectedValue(
|
||||
directRel.value,
|
||||
directRel.possibility,
|
||||
path,
|
||||
{
|
||||
entityKey: sourceEntity,
|
||||
relation: relName,
|
||||
step: 0
|
||||
},
|
||||
{
|
||||
timestamp: directRel.changed_last_at || directRel.updated_last_at || Date.now(),
|
||||
reliability: 1.0,
|
||||
source: directRel.source || 'persistent'
|
||||
}
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
_evaluateComputed(compiled, userKey, objectKey, options, visited, currentRelation) {
|
||||
return this.arbiter.authChecker.check(userKey, compiled.relation, objectKey, {
|
||||
...options,
|
||||
_visited: visited,
|
||||
_currentRelation: currentRelation
|
||||
});
|
||||
}
|
||||
|
||||
_evaluateTupleToUserset(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
|
||||
if (compiled._optimized?.kind === 'direct_join' && !options.collectValues && !options.trackEvaluation) {
|
||||
const optimized = compiled._optimized;
|
||||
const resolvePossibility = (value) => value !== undefined ? value : 1.0;
|
||||
const resolveReliability = (value) => value !== undefined ? value : 1.0;
|
||||
const tuplesetDirection = optimized.tuplesetDirection || 'out';
|
||||
let tuples;
|
||||
if (optimized.reverse) {
|
||||
tuples = this.arbiter.relationManager.getRelationsFromSrc(userId, optimized.tuplesetRelation, options);
|
||||
} else {
|
||||
tuples = tuplesetDirection === 'in'
|
||||
? this.arbiter.relationManager.getRelationsToDst(objectId, optimized.tuplesetRelation, options)
|
||||
: this.arbiter.relationManager.getRelationsFromSrc(objectId, optimized.tuplesetRelation, options);
|
||||
}
|
||||
|
||||
const maxIntermediates = optimized.maxIntermediates !== undefined ? optimized.maxIntermediates : 20;
|
||||
if (tuples.length > maxIntermediates * 3) {
|
||||
tuples.sort((a, b) => resolvePossibility(b.possibility) - resolvePossibility(a.possibility));
|
||||
tuples = tuples.slice(0, maxIntermediates);
|
||||
}
|
||||
|
||||
const checkingId = optimized.reverse ? objectId : userId;
|
||||
const computedEdges = this.arbiter.relationManager.getRelationsFromSrc(checkingId, optimized.computedRelation, options);
|
||||
const computedByIntermediate = new Map(computedEdges.map(edge => [edge.dst, edge]));
|
||||
|
||||
let processed = 0;
|
||||
let bestPossibility = 0;
|
||||
let bestReliability = 1.0;
|
||||
const earlyExitThreshold = optimized.earlyExitThreshold !== undefined ? optimized.earlyExitThreshold : 0.95;
|
||||
const minPossibility = optimized.minPossibility !== undefined ? optimized.minPossibility : 0;
|
||||
|
||||
for (const t of tuples) {
|
||||
if (processed >= maxIntermediates) break;
|
||||
processed++;
|
||||
const intermediateId = tuplesetDirection === 'in' ? t.src : t.dst;
|
||||
const directRel = computedByIntermediate.get(intermediateId);
|
||||
if (!directRel) continue;
|
||||
const combinedPossibility = Math.min(resolvePossibility(t.possibility), resolvePossibility(directRel.possibility));
|
||||
const finalPossibility = combinedPossibility >= minPossibility ? combinedPossibility : 0;
|
||||
if (finalPossibility > bestPossibility) {
|
||||
bestPossibility = finalPossibility;
|
||||
bestReliability = resolveReliability(t.reliability) * (directRel.reliability !== undefined ? directRel.reliability : 1.0);
|
||||
if (bestPossibility >= earlyExitThreshold) break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
possibility: bestPossibility,
|
||||
reliability: bestReliability,
|
||||
reason: bestPossibility > 0 ? 'direct_join' : 'no_match'
|
||||
};
|
||||
}
|
||||
|
||||
if (compiled.source) {
|
||||
return this.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
userKey,
|
||||
objectId,
|
||||
objectKey,
|
||||
compiled.source,
|
||||
visited,
|
||||
currentRelation,
|
||||
{ ...options, useCompiled: false }
|
||||
);
|
||||
}
|
||||
const fallbackConfig = {
|
||||
type: 'tuple_to_userset',
|
||||
tuplesetRelation: compiled.tuplesetRelation,
|
||||
tuplesetDirection: compiled.tuplesetDirection,
|
||||
computedRelation: compiled.computedRelation
|
||||
};
|
||||
return this.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
userKey,
|
||||
objectId,
|
||||
objectKey,
|
||||
fallbackConfig,
|
||||
visited,
|
||||
currentRelation,
|
||||
{ ...options, useCompiled: false }
|
||||
);
|
||||
}
|
||||
|
||||
_evaluateChain(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
|
||||
if (compiled._optimized?.kind === 'direct' && !options.collectValues && !options.trackEvaluation) {
|
||||
const direct = {
|
||||
type: 'direct',
|
||||
relation: compiled._optimized.relation,
|
||||
reverse: compiled._optimized.reverse,
|
||||
collectValues: false
|
||||
};
|
||||
return this._evaluateDirect(direct, userId, userKey, objectId, objectKey, options, currentRelation);
|
||||
}
|
||||
if (compiled.source) {
|
||||
return this.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
userKey,
|
||||
objectId,
|
||||
objectKey,
|
||||
compiled.source,
|
||||
visited,
|
||||
currentRelation,
|
||||
{ ...options, useCompiled: false }
|
||||
);
|
||||
}
|
||||
const fallbackConfig = {
|
||||
type: 'chain',
|
||||
steps: compiled.steps,
|
||||
reverse: compiled.reverse,
|
||||
collectValues: compiled.collectValues,
|
||||
valueFilters: compiled.valueFilters,
|
||||
valueAggregation: compiled.valueAggregation
|
||||
};
|
||||
return this.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
userKey,
|
||||
objectId,
|
||||
objectKey,
|
||||
fallbackConfig,
|
||||
visited,
|
||||
currentRelation,
|
||||
{ ...options, useCompiled: false }
|
||||
);
|
||||
}
|
||||
|
||||
_evaluateMultiHop(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
|
||||
if (compiled._optimized?.kind === 'direct' && !options.collectValues && !options.trackEvaluation) {
|
||||
const direct = {
|
||||
type: 'direct',
|
||||
relation: compiled._optimized.relation,
|
||||
reverse: compiled._optimized.reverse,
|
||||
collectValues: false
|
||||
};
|
||||
return this._evaluateDirect(direct, userId, userKey, objectId, objectKey, options, currentRelation);
|
||||
}
|
||||
if (compiled.source) {
|
||||
return this.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
userKey,
|
||||
objectId,
|
||||
objectKey,
|
||||
compiled.source,
|
||||
visited,
|
||||
currentRelation,
|
||||
{ ...options, useCompiled: false }
|
||||
);
|
||||
}
|
||||
return { possibility: 0, reason: 'unsupported_multi_hop' };
|
||||
}
|
||||
|
||||
_evaluateLogical(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
|
||||
if (compiled.source?._subjectAsObject) { objectId = userId; objectKey = userKey; }
|
||||
let result;
|
||||
switch (compiled.op) {
|
||||
case 'union':
|
||||
result = this._evaluateUnion(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
|
||||
break;
|
||||
case 'intersection':
|
||||
result = this._evaluateIntersection(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
|
||||
break;
|
||||
case 'exclusion':
|
||||
result = this._evaluateExclusion(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options);
|
||||
break;
|
||||
default:
|
||||
result = { possibility: 0, reason: 'unknown_logical_op' };
|
||||
}
|
||||
|
||||
if (compiled.negate) {
|
||||
result = {
|
||||
...result,
|
||||
possibility: Math.max(0, 1 - (result.possibility || 0)),
|
||||
reason: result.possibility === 0 ? result.reason : 'negated'
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
_evaluateUnion(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
|
||||
const { fastPath = false, minPossibility = null, valueContext = null, collectValues = false, includeMeta = true, trackEvaluation = false } = options;
|
||||
const includeOwaTrace = trackEvaluation && includeMeta;
|
||||
const owaTraceOptions = includeOwaTrace ? { includeTrace: true } : null;
|
||||
|
||||
if (compiled.useBilattice && compiled.source) {
|
||||
return this.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
userKey,
|
||||
objectId,
|
||||
objectKey,
|
||||
compiled.source,
|
||||
visited,
|
||||
currentRelation,
|
||||
{ ...options, useCompiled: false }
|
||||
);
|
||||
}
|
||||
|
||||
if (compiled._optimized?.kind === 'direct_list') {
|
||||
return this._evaluateDirectList(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options, 'union');
|
||||
}
|
||||
|
||||
const children = compiled.children || [compiled];
|
||||
const possibilities = [];
|
||||
const metas = [];
|
||||
const remediationOptions = [];
|
||||
const allCollectedValues = collectValues ? [] : null;
|
||||
|
||||
for (const child of children) {
|
||||
const childVisited = new Set(visited);
|
||||
let res = this.evaluate(child, userId, userKey, objectId, objectKey, childVisited, currentRelation, options);
|
||||
if (!res || typeof res.possibility !== 'number') {
|
||||
res = { possibility: 0, meta: { reason: 'missing_rule' }, collectedValues: [] };
|
||||
}
|
||||
|
||||
mergeRemediationOptions(remediationOptions, extractRemediation(res));
|
||||
|
||||
possibilities.push(res.possibility);
|
||||
metas.push(includeMeta ? res.meta : null);
|
||||
|
||||
if (collectValues && res.collectedValues && Array.isArray(res.collectedValues)) {
|
||||
allCollectedValues.push(...res.collectedValues);
|
||||
if (valueContext) {
|
||||
valueContext.addCollectedValues(res.collectedValues, child.type, child);
|
||||
}
|
||||
}
|
||||
|
||||
if (fastPath && minPossibility !== null && res.possibility >= minPossibility) {
|
||||
const remediation = buildRemediation(extractRemediation(res));
|
||||
return {
|
||||
possibility: res.possibility,
|
||||
...(collectValues && { collectedValues: allCollectedValues }),
|
||||
...(includeMeta && { meta: res.meta }),
|
||||
...(remediation ? { remediation } : {})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!possibilities.length) {
|
||||
return {
|
||||
possibility: 0,
|
||||
...(collectValues && { collectedValues: allCollectedValues }),
|
||||
...(includeMeta && { meta: { operation: 'union', childCount: 0 } })
|
||||
};
|
||||
}
|
||||
|
||||
const unionOWAWeights = compiled._precomputedWeights && compiled._precomputedWeights.length === possibilities.length
|
||||
? compiled._precomputedWeights
|
||||
: getOWAWeightsFromRule(compiled, possibilities.length, metas);
|
||||
let result;
|
||||
if (unionOWAWeights.some(w => w > 0)) {
|
||||
result = OWAFusion.fuseWithMeta(possibilities, metas, unionOWAWeights, compiled.aggregator || 'max', true, owaTraceOptions);
|
||||
} else {
|
||||
result = OWAFusion.fuseWithMeta(possibilities, metas, null, 'max', true, owaTraceOptions);
|
||||
}
|
||||
|
||||
const remediation = result.value === 0
|
||||
? buildRemediation(remediationOptions.length > 0 ? { status: 'required', options: remediationOptions } : null, { status: 'required' })
|
||||
: null;
|
||||
return {
|
||||
possibility: result.value,
|
||||
...(collectValues && { collectedValues: allCollectedValues }),
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
...result.meta,
|
||||
operation: 'union',
|
||||
childCount: possibilities.length,
|
||||
aggregator: compiled.aggregator || 'max',
|
||||
...(includeOwaTrace && result.trace ? {
|
||||
owa: {
|
||||
level: null,
|
||||
aggregator: compiled.aggregator || 'max',
|
||||
weights: result.trace.weights,
|
||||
sortedValues: result.trace.sortedValues,
|
||||
contributions: result.trace.contributions,
|
||||
selectedIndex: result.trace.selectedIndex
|
||||
}
|
||||
} : {}),
|
||||
...(remediation ? { remediation } : {})
|
||||
}
|
||||
}),
|
||||
...(remediation ? { remediation } : {})
|
||||
};
|
||||
}
|
||||
|
||||
_evaluateIntersection(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
|
||||
const { fastPath = false, minPossibility = null, valueContext = null, collectValues = false, includeMeta = true, trackEvaluation = false } = options;
|
||||
const includeOwaTrace = trackEvaluation && includeMeta;
|
||||
const owaTraceOptions = includeOwaTrace ? { includeTrace: true } : null;
|
||||
const effectiveObjectId = compiled.source?._subjectAsObject ? userId : objectId;
|
||||
const effectiveObjectKey = compiled.source?._subjectAsObject ? userKey : objectKey;
|
||||
if (compiled._optimized?.kind === 'direct_list') {
|
||||
return this._evaluateDirectList(compiled, userId, userKey, effectiveObjectId, effectiveObjectKey, visited, currentRelation, options, 'intersection');
|
||||
}
|
||||
const possibilities = [];
|
||||
const metas = [];
|
||||
const remediationOptions = [];
|
||||
const allCollectedValues = collectValues ? [] : null;
|
||||
|
||||
// A defeasible `when` component may compile to a single direct node
|
||||
// (no children); treat it as a one-element conjunction, matching the
|
||||
// union path's `compiled.children || [compiled]` fallback.
|
||||
const children = compiled.children || [compiled];
|
||||
|
||||
for (const child of children) {
|
||||
const childVisited = new Set(visited);
|
||||
const res = this.evaluate(child, userId, userKey, objectId, objectKey, childVisited, currentRelation, options);
|
||||
possibilities.push(res.possibility);
|
||||
metas.push(includeMeta ? res.meta : null);
|
||||
|
||||
mergeRemediationOptions(remediationOptions, extractRemediation(res));
|
||||
|
||||
if (collectValues && res.collectedValues && Array.isArray(res.collectedValues)) {
|
||||
allCollectedValues.push(...res.collectedValues);
|
||||
if (valueContext) {
|
||||
valueContext.addCollectedValues(res.collectedValues, child.type, child);
|
||||
}
|
||||
}
|
||||
|
||||
if (fastPath && minPossibility !== null && res.possibility < minPossibility) {
|
||||
return {
|
||||
possibility: res.possibility,
|
||||
...(collectValues && { collectedValues: allCollectedValues }),
|
||||
...(includeMeta && { meta: res.meta })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!possibilities.length) {
|
||||
return {
|
||||
possibility: 0,
|
||||
...(collectValues && { collectedValues: allCollectedValues }),
|
||||
...(includeMeta && { meta: { operation: 'intersection', childCount: 0 } })
|
||||
};
|
||||
}
|
||||
|
||||
const intersectionOWAWeights = compiled._precomputedWeights && compiled._precomputedWeights.length === possibilities.length
|
||||
? compiled._precomputedWeights
|
||||
: getOWAWeightsFromRule(compiled, possibilities.length, metas);
|
||||
const defaultMode = compiled.aggregator || 'min';
|
||||
const finalWeights = compiled.aggregator || compiled.owaWeights ? intersectionOWAWeights :
|
||||
[...Array(Math.max(0, possibilities.length - 1)).fill(0), 1];
|
||||
|
||||
let result;
|
||||
if (compiled.reliabilityWeighting) {
|
||||
const reliabilityWeightedPossibilities = possibilities.map((poss, i) => poss * (metas[i]?.reliability || 1.0));
|
||||
result = OWAFusion.fuseWithMeta(reliabilityWeightedPossibilities, metas, finalWeights, defaultMode, true, owaTraceOptions);
|
||||
} else {
|
||||
result = OWAFusion.fuseWithMeta(possibilities, metas, finalWeights, defaultMode, true, owaTraceOptions);
|
||||
}
|
||||
|
||||
const remediation = result.value === 0
|
||||
? buildRemediation(remediationOptions.length > 0 ? { status: 'required', options: remediationOptions } : null, { status: 'required' })
|
||||
: null;
|
||||
return {
|
||||
possibility: result.value,
|
||||
...(collectValues && { collectedValues: allCollectedValues }),
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
...result.meta,
|
||||
operation: 'intersection',
|
||||
childCount: possibilities.length,
|
||||
aggregator: defaultMode,
|
||||
...(includeOwaTrace && result.trace ? {
|
||||
owa: {
|
||||
level: null,
|
||||
aggregator: defaultMode,
|
||||
weights: result.trace.weights,
|
||||
sortedValues: result.trace.sortedValues,
|
||||
contributions: result.trace.contributions,
|
||||
selectedIndex: result.trace.selectedIndex
|
||||
}
|
||||
} : {}),
|
||||
...(remediation ? { remediation } : {})
|
||||
}
|
||||
}),
|
||||
...(remediation ? { remediation } : {})
|
||||
};
|
||||
}
|
||||
|
||||
_evaluateExclusion(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
|
||||
const { valueContext = null, collectValues = false, includeMeta = true, trackEvaluation = false } = options;
|
||||
const includeOwaTrace = trackEvaluation && includeMeta;
|
||||
const owaTraceOptions = includeOwaTrace ? { includeTrace: true } : null;
|
||||
if (compiled.children.length !== 2) {
|
||||
return {
|
||||
possibility: 0,
|
||||
...(collectValues && { collectedValues: [] }),
|
||||
...(includeMeta && { meta: { operation: 'exclusion', error: 'exclusion_requires_exactly_two_rules' } }),
|
||||
reason: 'exclusion_requires_exactly_two_rules'
|
||||
};
|
||||
}
|
||||
|
||||
const visitedA = new Set(visited);
|
||||
const visitedB = new Set(visited);
|
||||
const a = this.evaluate(compiled.children[0], userId, userKey, objectId, objectKey, visitedA, currentRelation, options);
|
||||
const b = this.evaluate(compiled.children[1], userId, userKey, objectId, objectKey, visitedB, currentRelation, options);
|
||||
|
||||
const allCollectedValues = collectValues ? [] : null;
|
||||
if (collectValues && a.collectedValues && Array.isArray(a.collectedValues)) {
|
||||
allCollectedValues.push(...a.collectedValues);
|
||||
if (valueContext) valueContext.addCollectedValues(a.collectedValues, compiled.children[0].type, compiled.children[0]);
|
||||
}
|
||||
if (collectValues && b.collectedValues && Array.isArray(b.collectedValues)) {
|
||||
allCollectedValues.push(...b.collectedValues);
|
||||
if (valueContext) valueContext.addCollectedValues(b.collectedValues, compiled.children[1].type, compiled.children[1]);
|
||||
}
|
||||
|
||||
let possibility;
|
||||
let result;
|
||||
if (compiled.aggregator || compiled.owaWeights) {
|
||||
const possibilities = [a.possibility, 1 - b.possibility];
|
||||
const metas = [a.meta, { exclusion_complement: b.meta }];
|
||||
const exclusionOWAWeights = compiled._precomputedWeights && compiled._precomputedWeights.length === 2
|
||||
? compiled._precomputedWeights
|
||||
: getOWAWeightsFromRule(compiled, 2, metas);
|
||||
result = OWAFusion.fuseWithMeta(possibilities, metas, exclusionOWAWeights, compiled.aggregator || 'min', true, owaTraceOptions);
|
||||
possibility = result.value;
|
||||
} else {
|
||||
possibility = a.possibility * (1 - b.possibility);
|
||||
}
|
||||
|
||||
return {
|
||||
possibility,
|
||||
...(collectValues && { collectedValues: allCollectedValues }),
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
operation: 'exclusion',
|
||||
childA: a.meta,
|
||||
childB: b.meta,
|
||||
aggregator: compiled.aggregator || 'standard',
|
||||
...(includeOwaTrace && result?.trace ? {
|
||||
owa: {
|
||||
level: null,
|
||||
aggregator: compiled.aggregator || 'standard',
|
||||
weights: result.trace.weights,
|
||||
sortedValues: result.trace.sortedValues,
|
||||
contributions: result.trace.contributions,
|
||||
selectedIndex: result.trace.selectedIndex
|
||||
}
|
||||
} : {})
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
_evaluateDirectList(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options, op) {
|
||||
const { fastPath = false, minPossibility = null, valueContext = null, collectValues = false, includeMeta = true, trackEvaluation = false } = options;
|
||||
const includeOwaTrace = trackEvaluation && includeMeta;
|
||||
const owaTraceOptions = includeOwaTrace ? { includeTrace: true } : null;
|
||||
|
||||
const possibilities = [];
|
||||
const metas = [];
|
||||
const allCollectedValues = collectValues ? [] : null;
|
||||
const directRules = compiled._optimized.direct;
|
||||
|
||||
for (const direct of directRules) {
|
||||
let directRel;
|
||||
if (direct.reverse) {
|
||||
directRel = this.arbiter.relationManager.getDirectRelation(objectId, direct.relation, userId, options);
|
||||
} else {
|
||||
directRel = this.arbiter.relationManager.getDirectRelation(userId, direct.relation, objectId, options);
|
||||
}
|
||||
|
||||
const possibility = directRel ? directRel.possibility : 0;
|
||||
possibilities.push(possibility);
|
||||
if (includeMeta) {
|
||||
const _src = directRel ? (directRel.source || 'persistent') : null;
|
||||
const _allowMeta = directRel ? {
|
||||
ruleType: 'direct',
|
||||
reason: 'direct',
|
||||
source: _src,
|
||||
layer_name: directRel.layer_name || null,
|
||||
source_class: directRel.source_class || null,
|
||||
reducer_applied: directRel.reducer_applied || null
|
||||
} : null;
|
||||
metas.push(directRel ? {
|
||||
ruleType: 'direct',
|
||||
relation: direct.relation,
|
||||
reverse: direct.reverse || false,
|
||||
strength: possibility,
|
||||
source: _src,
|
||||
allow: _allowMeta
|
||||
} : { ruleType: 'direct', relation: direct.relation, reason: 'no_relation' });
|
||||
} else {
|
||||
metas.push(null);
|
||||
}
|
||||
|
||||
if (collectValues && direct.collectValues !== false && directRel && directRel.value !== undefined) {
|
||||
const sourceEntity = direct.reverse ? objectKey : userKey;
|
||||
const targetEntity = direct.reverse ? userKey : objectKey;
|
||||
const path = [sourceEntity, targetEntity];
|
||||
const collected = this.ruleEvaluator.ruleHandlers.direct._createCollectedValue(
|
||||
directRel.value,
|
||||
directRel.possibility,
|
||||
path,
|
||||
{
|
||||
entityKey: sourceEntity,
|
||||
relation: direct.relation,
|
||||
step: 0
|
||||
},
|
||||
{
|
||||
timestamp: directRel.changed_last_at || directRel.updated_last_at || Date.now(),
|
||||
reliability: 1.0,
|
||||
source: directRel.source || 'persistent'
|
||||
}
|
||||
);
|
||||
allCollectedValues.push(collected);
|
||||
if (valueContext) {
|
||||
valueContext.addCollectedValues([collected], 'direct', direct);
|
||||
}
|
||||
}
|
||||
|
||||
if (fastPath && minPossibility !== null) {
|
||||
if (op === 'union' && possibility >= minPossibility) {
|
||||
return {
|
||||
possibility,
|
||||
...(collectValues && { collectedValues: allCollectedValues }),
|
||||
...(includeMeta && { meta: metas[metas.length - 1] })
|
||||
};
|
||||
}
|
||||
if (op === 'intersection' && possibility < minPossibility) {
|
||||
return {
|
||||
possibility,
|
||||
...(collectValues && { collectedValues: allCollectedValues }),
|
||||
...(includeMeta && { meta: metas[metas.length - 1] })
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!possibilities.length) {
|
||||
return {
|
||||
possibility: 0,
|
||||
...(collectValues && { collectedValues: allCollectedValues }),
|
||||
...(includeMeta && { meta: { operation: op, childCount: 0 } })
|
||||
};
|
||||
}
|
||||
|
||||
const weights = compiled._precomputedWeights && compiled._precomputedWeights.length === possibilities.length
|
||||
? compiled._precomputedWeights
|
||||
: getOWAWeightsFromRule(compiled, possibilities.length, metas);
|
||||
const aggregator = op === 'intersection' ? (compiled.aggregator || 'min') : (compiled.aggregator || 'max');
|
||||
const result = OWAFusion.fuseWithMeta(possibilities, metas, weights, aggregator, true, owaTraceOptions);
|
||||
|
||||
return {
|
||||
possibility: result.value,
|
||||
...(collectValues && { collectedValues: allCollectedValues }),
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
...result.meta,
|
||||
operation: op,
|
||||
childCount: possibilities.length,
|
||||
aggregator,
|
||||
...(includeOwaTrace && result.trace ? {
|
||||
owa: {
|
||||
level: null,
|
||||
aggregator,
|
||||
weights: result.trace.weights,
|
||||
sortedValues: result.trace.sortedValues,
|
||||
contributions: result.trace.contributions,
|
||||
selectedIndex: result.trace.selectedIndex
|
||||
}
|
||||
} : {})
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
_evaluateDefeasible(compiled, userId, userKey, objectId, objectKey, visited, currentRelation, options) {
|
||||
const { binary = false, fastPath = false, minPossibility = 0.0 } = options;
|
||||
const mode = compiled.mode || (binary ? 'binary' : (fastPath ? 'threshold' : 'normal'));
|
||||
const allCollectedValues = [];
|
||||
let neverResult = null, defeatersResult = null, strictResult = null, defeasibleResult = null, requiresResult = null;
|
||||
|
||||
if (compiled.never) {
|
||||
neverResult = this._evaluateUnion(compiled.never, userId, userKey, objectId, objectKey, visited, currentRelation, options);
|
||||
if (neverResult.collectedValues) allCollectedValues.push(...neverResult.collectedValues);
|
||||
}
|
||||
if (compiled.always) {
|
||||
strictResult = this.evaluate(compiled.always, userId, userKey, objectId, objectKey, visited, currentRelation, options);
|
||||
if (strictResult.collectedValues) allCollectedValues.push(...strictResult.collectedValues);
|
||||
}
|
||||
if (compiled.requires) {
|
||||
requiresResult = this._evaluateUnion(compiled.requires, userId, userKey, objectId, objectKey, visited, currentRelation, options);
|
||||
if (requiresResult.collectedValues) allCollectedValues.push(...requiresResult.collectedValues);
|
||||
}
|
||||
if (compiled.unless) {
|
||||
defeatersResult = this._evaluateUnion(compiled.unless, userId, userKey, objectId, objectKey, visited, currentRelation, options);
|
||||
if (defeatersResult.collectedValues) allCollectedValues.push(...defeatersResult.collectedValues);
|
||||
}
|
||||
if (compiled.when) {
|
||||
defeasibleResult = this._evaluateIntersection(compiled.when, userId, userKey, objectId, objectKey, visited, currentRelation, options);
|
||||
if (defeasibleResult.collectedValues) allCollectedValues.push(...defeasibleResult.collectedValues);
|
||||
}
|
||||
|
||||
const ruleMeta = {
|
||||
minPossibility: compiled.minPossibility,
|
||||
priority: compiled.priority,
|
||||
mode
|
||||
};
|
||||
|
||||
const finalResult = this.logicalOperators._applyDefeasibleLogic(mode, ruleMeta, neverResult, defeatersResult, strictResult, defeasibleResult, requiresResult, allCollectedValues);
|
||||
return finalResult;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* DecisionCache — accidental-state port for authorization decision caching.
|
||||
*
|
||||
* Wraps the directCheckCache + ruleResultCache + reverse-indexes
|
||||
* + dependencyIndex + TTL settings that the engine uses to short-circuit
|
||||
* repeated authorization decisions. Implementing this port gives test
|
||||
* code an explicit, dependency-injectable seam — and frees the engine
|
||||
* from the rule that "function signature lies" (RF-03 closure).
|
||||
*
|
||||
* The port exposes ONLY the surface used by the engine:
|
||||
* - getDirect(key) / setDirect(key, result)
|
||||
* - getRule(ruleKey) / setRule(ruleKey, result)
|
||||
* - invalidateByRelation(relation)
|
||||
* - invalidateByNodeKey(nodeKey) // for NodeManager cache leak fix
|
||||
* - invalidateAll()
|
||||
*
|
||||
* All TTL / clock concerns move into the port implementation so the
|
||||
* AuthorizationChecker no longer reads wall-clock from its hot path.
|
||||
*
|
||||
* The default `ArbiterDecisionCache` implementation forwards every call
|
||||
* to the corresponding field on the Arbiter instance — preserving the
|
||||
* existing behavior of every test that inspects `arbiter.directCheckCache`
|
||||
* directly while moving the read site out of AuthorizationChecker.
|
||||
*/
|
||||
export class DecisionCache {
|
||||
/**
|
||||
* @param {object} arbiter The Arbiter instance whose caches we wrap.
|
||||
* `null` produces a fully inert cache (every
|
||||
* get returns undefined, every set is a no-op)
|
||||
* — useful for tests that want to disable caching
|
||||
* without constructing a full Arbiter.
|
||||
* @param {object} [options]
|
||||
* @param {(unit?:string) => number} [options.clock] Time source for TTL
|
||||
* checks. Defaults to Date.now (ms) but tests
|
||||
* can inject a fake clock.
|
||||
*/
|
||||
constructor(arbiter, options = {}) {
|
||||
this.arbiter = arbiter;
|
||||
this.clock = options.clock || (() => Date.now());
|
||||
this._enabled = !!arbiter;
|
||||
}
|
||||
|
||||
/** True if this cache can store anything. */
|
||||
get enabled() {
|
||||
if (!this._enabled) return false;
|
||||
const a = this.arbiter;
|
||||
return !a.disableCaching;
|
||||
}
|
||||
|
||||
/** True if direct (single-step) caching is active. */
|
||||
get directEnabled() {
|
||||
if (!this.enabled) return false;
|
||||
return !!(this.arbiter.directCheckCache) && !this.arbiter.disableDirectCaching;
|
||||
}
|
||||
|
||||
/** True if rule-result caching is active. */
|
||||
get ruleEnabled() {
|
||||
if (!this.enabled) return false;
|
||||
return !!this.arbiter.ruleResultCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a previously-cached direct-check result.
|
||||
* Returns undefined on miss / disabled.
|
||||
* Distinguishes "expired" from "miss" via the second tuple element.
|
||||
*
|
||||
* @returns {[result, status]} status is 'hit' | 'expired' | 'miss' | 'disabled'
|
||||
*/
|
||||
peekDirect(cacheKey) {
|
||||
if (!this.directEnabled) return [undefined, 'disabled'];
|
||||
const entry = this.arbiter.directCheckCache.get(cacheKey);
|
||||
if (!entry) return [undefined, 'miss'];
|
||||
if (this.clock() - entry.timestamp >= this.arbiter.directCheckCacheTTL) {
|
||||
return [entry.result, 'expired'];
|
||||
}
|
||||
return [entry.result, 'hit'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified form: returns just the result on hit, undefined otherwise.
|
||||
*/
|
||||
getDirect(cacheKey) {
|
||||
const [result, status] = this.peekDirect(cacheKey);
|
||||
return status === 'hit' ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a direct-check result. Caller is responsible for key construction
|
||||
* (so the cache key derivation logic stays in AuthorizationChecker where
|
||||
* the schema lives).
|
||||
*/
|
||||
setDirect(cacheKey, result) {
|
||||
if (!this.directEnabled) return;
|
||||
this.arbiter.directCheckCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: this.clock()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a previously-cached rule-evaluation result.
|
||||
* Returns undefined on miss / disabled / expired.
|
||||
*/
|
||||
getRule(ruleCacheKey) {
|
||||
if (!this.ruleEnabled) return undefined;
|
||||
const entry = this.arbiter.ruleResultCache.get(ruleCacheKey);
|
||||
if (!entry) return undefined;
|
||||
if (this.clock() - entry.timestamp >= this.arbiter.ruleResultCacheTTL) {
|
||||
return undefined;
|
||||
}
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
setRule(ruleCacheKey, result) {
|
||||
if (!this.ruleEnabled) return;
|
||||
this.arbiter.ruleResultCache.set(ruleCacheKey, {
|
||||
result,
|
||||
timestamp: this.clock()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a cache key as belonging to a particular relation so that
|
||||
* future invalidateByRelation(relation) calls can find it.
|
||||
*/
|
||||
trackRuleKeyForRelation(relation, cacheKey) {
|
||||
const a = this.arbiter;
|
||||
if (!a.ruleResultCache || !cacheKey) return;
|
||||
let set = a.ruleResultCacheKeysByRelation.get(relation);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
a.ruleResultCacheKeysByRelation.set(relation, set);
|
||||
}
|
||||
set.add(cacheKey);
|
||||
a.ruleResultCacheRelationByKey.set(cacheKey, relation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate every direct + rule-result cache entry that could
|
||||
* have been affected by a relation change. This mirrors the old
|
||||
* Arbiter._invalidateDirectCheckCache + invalidateRuleResultCacheByRelation
|
||||
* pair, fused into one port-level call.
|
||||
*/
|
||||
invalidateByRelation(relation) {
|
||||
const a = this.arbiter;
|
||||
if (!a) return;
|
||||
if (a.directCheckCache) {
|
||||
a._invalidateDirectCheckCache?.(/*srcKey*/ null, relation, /*dstKey*/ null);
|
||||
}
|
||||
a.invalidateRuleResultCacheByRelation?.(relation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate every direct-check entry whose composite key contains
|
||||
* a given node id. Replaces the leaky `cacheKey.includes(String(nodeId))`
|
||||
* walk that NodeManager used to perform. Now the cache file is the
|
||||
* only place that knows its own storage layout.
|
||||
*
|
||||
* Requires the cache implementation to expose either a key iterator
|
||||
* (`keys()`) or a pattern-invalidate method (`invalidateByPattern`).
|
||||
* The local @tenere/hyperbolic-lru@1.0.3 provides both. The previous
|
||||
* npm hyperbolic-lru@1.0.2 exposed neither, making this method a no-op.
|
||||
*/
|
||||
invalidateByNodeKey(nodeKey) {
|
||||
const a = this.arbiter;
|
||||
if (!a?.directCheckCache) return;
|
||||
const nodeId = a.nodeIdByKey?.get(nodeKey);
|
||||
if (nodeId === undefined) return;
|
||||
const cache = a.directCheckCache;
|
||||
|
||||
// Pattern-based invalidation is the safest path — the cache file
|
||||
// owns its storage layout and decides how to enumerate keys.
|
||||
if (typeof cache.invalidateByPattern === 'function') {
|
||||
// Match a nodeId that appears in any position of the composite
|
||||
// key. The composite key format is `${srcId}|${rel}|${dstId}` and
|
||||
// components are pipe-delimited, so a digit-boundary regex is
|
||||
// safer than a plain substring match.
|
||||
const escaped = String(nodeId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`(?:^|\\|)${escaped}(?:\\||$)`);
|
||||
cache.invalidateByPattern(pattern);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: explicit key iteration. HyperbolicLRUCache does not
|
||||
// expose `keys()`, so this branch is unreachable for that cache
|
||||
// family. If a future cache implementation exposes iteration,
|
||||
// we walk it without leaking storage-layout details.
|
||||
if (typeof cache.keys === 'function') {
|
||||
for (const key of cache.keys()) {
|
||||
if (typeof key === 'string' && key.includes(String(nodeId))) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush every cache. Equivalent to the old
|
||||
* `disableCaching = true` test path.
|
||||
*/
|
||||
invalidateAll() {
|
||||
const a = this.arbiter;
|
||||
if (!a) return;
|
||||
a.directCheckCache?.clear?.();
|
||||
a.invalidateAllRuleResultCache?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NullDecisionCache — every operation is a no-op. Use in tests that want
|
||||
* a pure decision flow with no accidental state at all.
|
||||
*/
|
||||
export class NullDecisionCache {
|
||||
constructor() { this._enabled = true; }
|
||||
get enabled() { return false; }
|
||||
get directEnabled() { return false; }
|
||||
get ruleEnabled() { return false; }
|
||||
getDirect() { return undefined; }
|
||||
setDirect() {}
|
||||
getRule() { return undefined; }
|
||||
setRule() {}
|
||||
trackRuleKeyForRelation() {}
|
||||
invalidateByRelation() {}
|
||||
invalidateByNodeKey() {}
|
||||
invalidateAll() {}
|
||||
}
|
||||
|
||||
export default DecisionCache;
|
||||
@@ -0,0 +1,37 @@
|
||||
export class RuleCollector {
|
||||
constructor(arbiter) {
|
||||
this.arbiter = arbiter;
|
||||
}
|
||||
|
||||
collectRules(config, parentRelation = null, relationName = null) {
|
||||
const rules = [];
|
||||
|
||||
const base = {
|
||||
ruleType: config.ruleType || 'defeasible',
|
||||
priority: config.priority || 0,
|
||||
weight: config.weight !== undefined ? config.weight : 1.0,
|
||||
...config,
|
||||
parentRelation: config.parentRelation !== undefined ? config.parentRelation : parentRelation,
|
||||
};
|
||||
|
||||
if (!base.relation && relationName) {
|
||||
base.relation = relationName;
|
||||
}
|
||||
|
||||
// Logical operators must not be flattened — intersection/exclusion lose their semantics if
|
||||
// treated as a flat union (max-allow). Only flatten union operators into the parent list.
|
||||
if (config.union) {
|
||||
for (const child of config.union) {
|
||||
rules.push(...this.collectRules(child, parentRelation, relationName));
|
||||
}
|
||||
} else if (config.intersection || config.exclusion) {
|
||||
// Intersection and exclusion carry structure that must be preserved at the parent level.
|
||||
// They are handled by AuthorizationChecker's logical dispatch, not flattened here.
|
||||
rules.push(base);
|
||||
} else {
|
||||
rules.push(base);
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { getOWAWeightsFromRule } from '../utils/OWAFusion.js';
|
||||
|
||||
export class RuleCompiler {
|
||||
constructor(arbiter) {
|
||||
this.arbiter = arbiter;
|
||||
}
|
||||
|
||||
compile(relationName, config) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const compiled = this._compileNode(config, relationName, errors, warnings);
|
||||
if (compiled) {
|
||||
compiled.source = config;
|
||||
}
|
||||
this._optimizeCompiled(compiled);
|
||||
return { compiled, errors, warnings };
|
||||
}
|
||||
|
||||
_optimizeCompiled(compiled) {
|
||||
if (!compiled || typeof compiled !== 'object') return;
|
||||
if (compiled.type === 'logical') {
|
||||
compiled._optimized = this._optimizeLogical(compiled);
|
||||
compiled._precomputedWeights = this._precomputeLogicalWeights(compiled);
|
||||
}
|
||||
if (compiled.type === 'tuple_to_userset') {
|
||||
compiled._optimized = this._optimizeTupleToUserset(compiled);
|
||||
}
|
||||
if (compiled.type === 'chain') {
|
||||
compiled._optimized = this._optimizeChain(compiled);
|
||||
}
|
||||
if (compiled.type === 'multi_hop') {
|
||||
compiled._optimized = this._optimizeMultiHop(compiled);
|
||||
}
|
||||
if (compiled.type === 'defeasible') {
|
||||
if (compiled.never) this._optimizeCompiled(compiled.never);
|
||||
if (compiled.unless) this._optimizeCompiled(compiled.unless);
|
||||
if (compiled.always) this._optimizeCompiled(compiled.always);
|
||||
if (compiled.when) this._optimizeCompiled(compiled.when);
|
||||
if (compiled.requires) this._optimizeCompiled(compiled.requires);
|
||||
}
|
||||
if (compiled.type === 'relational_comparator') {
|
||||
if (compiled.left?.rule) this._optimizeCompiled(compiled.left.rule);
|
||||
if (compiled.right?.rule) this._optimizeCompiled(compiled.right.rule);
|
||||
}
|
||||
if (compiled.type === 'logical') {
|
||||
for (const child of compiled.children) {
|
||||
this._optimizeCompiled(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_compileNode(config, relationName, errors, warnings) {
|
||||
if (!config || typeof config !== 'object') {
|
||||
errors.push({ relation: relationName, message: 'Missing or invalid config node' });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(config)) {
|
||||
if (config.length === 0) {
|
||||
errors.push({ relation: relationName, message: 'Empty rule array' });
|
||||
return null;
|
||||
}
|
||||
if (config.length === 1) {
|
||||
return this._compileNode(config[0], relationName, errors, warnings);
|
||||
}
|
||||
return this._compileNode({ type: 'logical', union: { rules: config } }, relationName, errors, warnings);
|
||||
}
|
||||
|
||||
if (config.never || config.always || config.when || config.unless || config.requires) {
|
||||
const compiled = this._compileDefeasible(config, relationName, errors, warnings);
|
||||
if (compiled) {
|
||||
compiled.source = config;
|
||||
config._compiled = compiled;
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
|
||||
if (config.union || config.intersection || config.exclusion) {
|
||||
const compiled = this._compileLogical(config, relationName, errors, warnings);
|
||||
if (compiled) {
|
||||
compiled.source = config;
|
||||
config._compiled = compiled;
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
|
||||
const type = config.type || 'direct';
|
||||
let compiled;
|
||||
switch (type) {
|
||||
case 'direct':
|
||||
compiled = this._compileDirect(config, relationName, errors);
|
||||
break;
|
||||
case 'computed':
|
||||
compiled = this._compileComputed(config, relationName, errors);
|
||||
break;
|
||||
case 'parent':
|
||||
compiled = this._compileParent(config, relationName, errors);
|
||||
break;
|
||||
case 'tuple_to_userset':
|
||||
compiled = this._compileTupleToUserset(config, relationName, errors);
|
||||
break;
|
||||
case 'chain':
|
||||
compiled = this._compileChain(config, relationName, errors);
|
||||
break;
|
||||
case 'multi_hop':
|
||||
compiled = this._compileMultiHop(config, relationName, errors);
|
||||
break;
|
||||
case 'relational_comparator':
|
||||
compiled = this._compileRelationalComparator(config, relationName, errors, warnings);
|
||||
break;
|
||||
case 'challenge':
|
||||
compiled = this._compileChallenge(config, relationName, errors);
|
||||
break;
|
||||
default:
|
||||
errors.push({ relation: relationName, message: `Unknown rule type: ${type}` });
|
||||
return null;
|
||||
}
|
||||
if (compiled) {
|
||||
compiled.source = config;
|
||||
config._compiled = compiled;
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
|
||||
_compileLogical(config, relationName, errors, warnings) {
|
||||
const logicalKey = config.union ? 'union' : config.intersection ? 'intersection' : 'exclusion';
|
||||
const logicalConfig = config[logicalKey];
|
||||
const rawRules = Array.isArray(logicalConfig?.rules)
|
||||
? logicalConfig.rules
|
||||
: Array.isArray(config[logicalKey])
|
||||
? config[logicalKey]
|
||||
: [];
|
||||
|
||||
if (!rawRules.length) {
|
||||
errors.push({ relation: relationName, message: `${logicalKey} has no child rules` });
|
||||
}
|
||||
|
||||
const children = rawRules.map(child => this._compileNode(child, relationName, errors, warnings)).filter(Boolean);
|
||||
const compiled = {
|
||||
type: 'logical',
|
||||
op: logicalKey,
|
||||
children,
|
||||
aggregator: logicalConfig?.aggregator,
|
||||
owaWeights: logicalConfig?.owaWeights,
|
||||
_owaSparseCount: logicalConfig?._owaSparseCount,
|
||||
reliabilityWeighting: logicalConfig?.reliabilityWeighting || false,
|
||||
useBilattice: logicalConfig?.useBilattice || false,
|
||||
capacityType: logicalConfig?.capacityType,
|
||||
epistemicMode: logicalConfig?.epistemicMode,
|
||||
negate: logicalConfig?.negate || false
|
||||
};
|
||||
|
||||
return compiled;
|
||||
}
|
||||
|
||||
_compileDefeasible(config, relationName, errors, warnings) {
|
||||
return {
|
||||
type: 'defeasible',
|
||||
never: config.never ? this._compileNode(config.never, relationName, errors, warnings) : null,
|
||||
unless: config.unless ? this._compileNode(config.unless, relationName, errors, warnings) : null,
|
||||
always: config.always ? this._compileNode(config.always, relationName, errors, warnings) : null,
|
||||
when: config.when ? this._compileNode(config.when, relationName, errors, warnings) : null,
|
||||
requires: config.requires ? this._compileNode(config.requires, relationName, errors, warnings) : null,
|
||||
mode: config.mode || 'normal',
|
||||
minPossibility: config.minPossibility,
|
||||
priority: config.priority || 0
|
||||
};
|
||||
}
|
||||
|
||||
_compileDirect(config, relationName, errors) {
|
||||
const relation = config.relation || config.rel || config.label || config.name || relationName;
|
||||
if (!relation) {
|
||||
errors.push({ relation: relationName, message: 'Direct rule missing relation name' });
|
||||
}
|
||||
return {
|
||||
type: 'direct',
|
||||
relation,
|
||||
relationId: relation ? this.arbiter.keyManager._getRelationId(relation) : null,
|
||||
reverse: !!config.reverse,
|
||||
collectValues: config.collectValues !== false
|
||||
};
|
||||
}
|
||||
|
||||
_compileComputed(config, relationName, errors) {
|
||||
const relation = config.relation || relationName;
|
||||
if (!relation) {
|
||||
errors.push({ relation: relationName, message: 'Computed rule missing relation name' });
|
||||
}
|
||||
return {
|
||||
type: 'computed',
|
||||
relation
|
||||
};
|
||||
}
|
||||
|
||||
_compileParent(config, relationName, errors) {
|
||||
const parentRelation = config.parentRelation || 'parent';
|
||||
return {
|
||||
type: 'parent',
|
||||
parentRelation,
|
||||
parentRelationId: this.arbiter.keyManager._getRelationId(parentRelation),
|
||||
relation: config.relation || relationName,
|
||||
reverse: !!config.reverse,
|
||||
aggregator: config.aggregator,
|
||||
owaWeights: config.owaWeights,
|
||||
_owaSparseCount: config._owaSparseCount,
|
||||
reliabilityWeighting: !!config.reliabilityWeighting
|
||||
};
|
||||
}
|
||||
|
||||
_compileTupleToUserset(config, relationName, errors) {
|
||||
if (!config.tuplesetRelation) {
|
||||
errors.push({ relation: relationName, message: 'tuple_to_userset missing tuplesetRelation' });
|
||||
}
|
||||
if (!config.computedRelation) {
|
||||
errors.push({ relation: relationName, message: 'tuple_to_userset missing computedRelation' });
|
||||
}
|
||||
return {
|
||||
type: 'tuple_to_userset',
|
||||
tuplesetRelation: config.tuplesetRelation,
|
||||
tuplesetRelationId: config.tuplesetRelation ? this.arbiter.keyManager._getRelationId(config.tuplesetRelation) : null,
|
||||
computedRelation: config.computedRelation,
|
||||
tuplesetDirection: config.tuplesetDirection || 'out',
|
||||
reverse: !!config.reverse,
|
||||
minPossibility: config.minPossibility,
|
||||
earlyExitThreshold: config.earlyExitThreshold,
|
||||
maxIntermediates: config.maxIntermediates
|
||||
};
|
||||
}
|
||||
|
||||
_compileChain(config, relationName, errors) {
|
||||
const chainConfig = config.chain || config;
|
||||
const steps = Array.isArray(chainConfig.steps) ? chainConfig.steps : [];
|
||||
if (!steps.length) {
|
||||
errors.push({ relation: relationName, message: 'chain missing steps' });
|
||||
}
|
||||
const reverse = !!config.reverse;
|
||||
const orderedSteps = reverse ? steps.slice().reverse() : steps;
|
||||
const normalizedSteps = orderedSteps.map(step => ({
|
||||
relation: step.relation,
|
||||
relationId: step.relation ? this.arbiter.keyManager._getRelationId(step.relation) : null,
|
||||
direction: reverse ? (step.direction === 'out' ? 'in' : 'out') : (step.direction || 'out')
|
||||
}));
|
||||
return {
|
||||
type: 'chain',
|
||||
steps: normalizedSteps,
|
||||
reverse,
|
||||
collectValues: config.collectValues !== false,
|
||||
valueFilters: config.valueFilters,
|
||||
valueAggregation: config.valueAggregation
|
||||
};
|
||||
}
|
||||
|
||||
_compileMultiHop(config, relationName, errors) {
|
||||
const relation = config.relation || relationName;
|
||||
if (!relation) {
|
||||
errors.push({ relation: relationName, message: 'multi_hop missing relation name' });
|
||||
}
|
||||
return {
|
||||
type: 'multi_hop',
|
||||
relation,
|
||||
relationId: relation ? this.arbiter.keyManager._getRelationId(relation) : null,
|
||||
maxDepth: config.maxDepth || 5,
|
||||
reverse: !!config.reverse,
|
||||
pathAggregation: config.pathAggregation || 'max',
|
||||
owaWeights: config.owaWeights,
|
||||
_owaSparseCount: config._owaSparseCount,
|
||||
collectValues: config.collectValues !== false,
|
||||
valueFilters: config.valueFilters,
|
||||
valueAggregation: config.valueAggregation || 'sum',
|
||||
fallbackToBasicPaths: config.fallbackToBasicPaths !== false,
|
||||
trackPaths: config.trackPaths !== false
|
||||
};
|
||||
}
|
||||
|
||||
_compileRelationalComparator(config, relationName, errors, warnings) {
|
||||
const left = config.left || (config.leftRelation ? { rule: { type: 'direct', relation: config.leftRelation }, extractValue: true } : null);
|
||||
const right = config.right || (config.rightRelation ? { rule: { type: 'direct', relation: config.rightRelation }, extractValue: true, evaluateFrom: 'object' } : null);
|
||||
if (!left || !right) {
|
||||
errors.push({ relation: relationName, message: 'relational_comparator missing operands' });
|
||||
}
|
||||
// Normalize source config so the non-compiled fallback path can find left/right
|
||||
if (!config.left && left) config.left = left;
|
||||
if (!config.right && right) config.right = right;
|
||||
if (!config.comparator && config.operator) config.comparator = config.operator;
|
||||
return {
|
||||
type: 'relational_comparator',
|
||||
comparator: config.comparator || config.operator,
|
||||
marginOfSafety: config.marginOfSafety,
|
||||
minRulePossibility: config.minRulePossibility,
|
||||
fallbackBehavior: config.fallbackBehavior,
|
||||
epsilon: config.epsilon,
|
||||
left: this._compileComparatorOperand(left, relationName, errors, warnings),
|
||||
right: this._compileComparatorOperand(right, relationName, errors, warnings),
|
||||
qualitative: !!config.qualitative
|
||||
};
|
||||
}
|
||||
|
||||
_compileChallenge(config, relationName, errors) {
|
||||
const challenge = config.challenge || config.name || relationName;
|
||||
if (!challenge) {
|
||||
errors.push({ relation: relationName, message: 'challenge rule missing challenge name' });
|
||||
}
|
||||
return {
|
||||
type: 'challenge',
|
||||
challenge,
|
||||
subject: config.subject || 'user',
|
||||
subjectKey: config.subjectKey,
|
||||
withinMs: config.withinMs,
|
||||
withinSeconds: config.withinSeconds,
|
||||
withinMinutes: config.withinMinutes,
|
||||
withinHours: config.withinHours
|
||||
};
|
||||
}
|
||||
|
||||
_compileComparatorOperand(operand, relationName, errors, warnings) {
|
||||
if (!operand) {
|
||||
errors.push({ relation: relationName, message: 'relational_comparator operand missing' });
|
||||
return null;
|
||||
}
|
||||
const nestedRule = operand.rule ? this._compileNode(operand.rule, relationName, errors, warnings) : null;
|
||||
const resolvedValueRelation = operand.valueRelation || nestedRule?.relation || null;
|
||||
const resolvedEvaluateFrom = operand.evaluateFrom || 'auto';
|
||||
const valueRelation = operand.valueRelation;
|
||||
return {
|
||||
rule: nestedRule,
|
||||
extractValue: operand.extractValue !== false,
|
||||
valueRelation,
|
||||
valueRelationResolved: resolvedValueRelation,
|
||||
valueRelationId: resolvedValueRelation ? this.arbiter.keyManager._getRelationId(resolvedValueRelation) : null,
|
||||
evaluateFrom: resolvedEvaluateFrom,
|
||||
aggregator: operand.aggregator,
|
||||
owaWeights: operand.owaWeights,
|
||||
_owaSparseCount: operand._owaSparseCount,
|
||||
minOperandPossibility: operand.minOperandPossibility
|
||||
};
|
||||
}
|
||||
|
||||
_optimizeLogical(node) {
|
||||
if (!node || node.type !== 'logical') return null;
|
||||
const allDirect = node.children.length > 0 && node.children.every(child => child?.type === 'direct');
|
||||
if (allDirect) {
|
||||
return {
|
||||
kind: 'direct_list',
|
||||
size: node.children.length,
|
||||
direct: node.children.map(child => ({
|
||||
relation: child.relation,
|
||||
relationId: child.relationId,
|
||||
reverse: !!child.reverse,
|
||||
collectValues: child.collectValues !== false
|
||||
}))
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_optimizeTupleToUserset(node) {
|
||||
if (!node || node.type !== 'tuple_to_userset') return null;
|
||||
const tuplesetConfig = this.arbiter.relationConfigs.get(node.tuplesetRelation);
|
||||
const computedConfig = this.arbiter.relationConfigs.get(node.computedRelation);
|
||||
if (!tuplesetConfig || !computedConfig) return null;
|
||||
if (tuplesetConfig.type !== 'direct' || computedConfig.type !== 'direct') return null;
|
||||
return {
|
||||
kind: 'direct_join',
|
||||
tuplesetRelation: node.tuplesetRelation,
|
||||
computedRelation: node.computedRelation,
|
||||
tuplesetDirection: node.tuplesetDirection || 'out',
|
||||
reverse: !!node.reverse,
|
||||
minPossibility: node.minPossibility,
|
||||
earlyExitThreshold: node.earlyExitThreshold,
|
||||
maxIntermediates: node.maxIntermediates
|
||||
};
|
||||
}
|
||||
|
||||
_optimizeChain(node) {
|
||||
if (!node || node.type !== 'chain') return null;
|
||||
if (!Array.isArray(node.steps) || node.steps.length !== 1) return null;
|
||||
const step = node.steps[0];
|
||||
if (!step || !step.relation) return null;
|
||||
return {
|
||||
kind: 'direct',
|
||||
relation: step.relation,
|
||||
relationId: step.relationId,
|
||||
reverse: step.direction === 'in'
|
||||
};
|
||||
}
|
||||
|
||||
_optimizeMultiHop(node) {
|
||||
if (!node || node.type !== 'multi_hop') return null;
|
||||
if (!node.relation) return null;
|
||||
if (node.maxDepth && node.maxDepth !== 1) return null;
|
||||
return {
|
||||
kind: 'direct',
|
||||
relation: node.relation,
|
||||
relationId: node.relationId,
|
||||
reverse: !!node.reverse
|
||||
};
|
||||
}
|
||||
|
||||
_precomputeLogicalWeights(node) {
|
||||
if (!node || node.type !== 'logical' || !Array.isArray(node.children)) return null;
|
||||
const length = node.children.length;
|
||||
if (!length) return null;
|
||||
if (node.aggregator === 'priority') return null;
|
||||
|
||||
if (node.op === 'intersection' && !node.aggregator && !node.owaWeights) {
|
||||
return [...Array(Math.max(0, length - 1)).fill(0), 1];
|
||||
}
|
||||
|
||||
if (node.op === 'exclusion' && !node.aggregator && !node.owaWeights) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getOWAWeightsFromRule(node, length, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import { DirectRule } from './rules/DirectRule.js';
|
||||
import { ComputedRule } from './rules/ComputedRule.js';
|
||||
import { ParentRule } from './rules/ParentRule.js';
|
||||
import { TupleToUsersetRule } from './rules/TupleToUsersetRule.js';
|
||||
import { MultiHopRule } from './rules/MultiHopRule.js';
|
||||
import { LogicalOperators } from './rules/LogicalOperators.js';
|
||||
import { RelationalComparatorRouter } from './rules/RelationalComparatorRouter.js';
|
||||
import { ChainRule } from './rules/ChainRule.js';
|
||||
import { ChallengeRule } from './rules/ChallengeRule.js';
|
||||
import { ValueContext } from './ValueContext.js';
|
||||
import { CompiledEvaluator } from './CompiledEvaluator.js';
|
||||
|
||||
export class RuleEvaluator {
|
||||
constructor(arbiter) {
|
||||
this.arbiter = arbiter;
|
||||
|
||||
this.ruleHandlers = {
|
||||
direct: new DirectRule(arbiter),
|
||||
computed: new ComputedRule(arbiter),
|
||||
parent: new ParentRule(arbiter),
|
||||
tuple_to_userset: new TupleToUsersetRule(arbiter),
|
||||
multi_hop: new MultiHopRule(arbiter),
|
||||
relational_comparator: new RelationalComparatorRouter(arbiter, this),
|
||||
chain: new ChainRule(arbiter),
|
||||
challenge: new ChallengeRule(arbiter)
|
||||
};
|
||||
|
||||
this.logicalOperators = new LogicalOperators(arbiter, this);
|
||||
this.compiledEvaluator = new CompiledEvaluator(arbiter, this, this.logicalOperators);
|
||||
this._valueRequirementCache = new Map();
|
||||
}
|
||||
|
||||
evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
const { binary = false, valueContext = null, includeMeta = true } = options;
|
||||
|
||||
|
||||
// Convert string keys to numeric IDs if needed
|
||||
const numericUserId = typeof userId === 'string' ? this.arbiter.resolveNodeId(userId, options) : userId;
|
||||
const numericObjectId = typeof objectId === 'string' ? this.arbiter.resolveNodeId(objectId, options) : objectId;
|
||||
|
||||
const needsValueContext = valueContext !== null && valueContext !== undefined
|
||||
? true
|
||||
: (options.collectValues === true || this._ruleRequiresValues(rule, new Set()));
|
||||
const finalValueContext = needsValueContext ? (valueContext || new ValueContext(this.arbiter, options)) : null;
|
||||
const collectValues = options.collectValues !== undefined ? options.collectValues : (rule._needsValues ?? needsValueContext);
|
||||
const enhancedOptions = { ...options, minPossibility: options.minPossibility ?? options.minAllowPossibility, valueContext: finalValueContext, collectValues, includeMeta };
|
||||
|
||||
const canCacheRuleResult = !!this.arbiter.ruleResultCache && currentRelation &&
|
||||
!binary && !options.partialGraphContext && !includeMeta &&
|
||||
options.cacheRuleResult !== false;
|
||||
const ruleCacheKey = canCacheRuleResult
|
||||
? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule)
|
||||
: null;
|
||||
|
||||
if (canCacheRuleResult) {
|
||||
const cached = this.arbiter.ruleResultCache.get(ruleCacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < this.arbiter.ruleResultCacheTTL) {
|
||||
this.arbiter.ruleResultCacheStats.hits++;
|
||||
if (collectValues && finalValueContext && cached.result?.collectedValues?.length) {
|
||||
const ruleType = rule?.type || (rule?.union ? 'union' : rule?.intersection ? 'intersection' : rule?.exclusion ? 'exclusion' : 'rule');
|
||||
finalValueContext.addCollectedValues(cached.result.collectedValues, ruleType, rule);
|
||||
}
|
||||
return cached.result;
|
||||
}
|
||||
this.arbiter.ruleResultCacheStats.misses++;
|
||||
}
|
||||
|
||||
// BINARY MODE: Fast evaluation with early termination
|
||||
if (binary) {
|
||||
return this._evaluateRuleBinary(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
|
||||
}
|
||||
|
||||
if (rule && rule._compiled && options.useCompiled !== false && !(rule._compileErrors && rule._compileErrors.length)) {
|
||||
const result = this.compiledEvaluator.evaluate(rule._compiled, userId, userKey, objectId, objectKey, visited, currentRelation, enhancedOptions);
|
||||
if (finalValueContext && result.collectedValues && result.collectedValues.length > 0) {
|
||||
const ruleType = rule?.type || (rule?.union ? 'union' : rule?.intersection ? 'intersection' : rule?.exclusion ? 'exclusion' : 'rule');
|
||||
finalValueContext.addCollectedValues(result.collectedValues, ruleType, rule);
|
||||
}
|
||||
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
|
||||
}
|
||||
|
||||
if (rule.union) {
|
||||
const result = this.logicalOperators.evaluateUnion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
|
||||
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
|
||||
}
|
||||
|
||||
if (rule.intersection) {
|
||||
const result = this.logicalOperators.evaluateIntersection(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
|
||||
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
|
||||
}
|
||||
|
||||
if (rule.exclusion) {
|
||||
const result = this.logicalOperators.evaluateExclusion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
|
||||
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
|
||||
}
|
||||
|
||||
// For other rule types, evaluate normally first
|
||||
const handler = this.ruleHandlers[rule.type];
|
||||
if (!handler) {
|
||||
return {
|
||||
possibility_allow: 0,
|
||||
possibility_deny: 0,
|
||||
...(collectValues && { collectedValues: [] }),
|
||||
...(includeMeta && { meta_allow: null, meta_deny: null }),
|
||||
reason: 'unknown_rule_type'
|
||||
};
|
||||
}
|
||||
|
||||
const result = handler.evaluate(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
|
||||
|
||||
// Add collected values to value context
|
||||
if (finalValueContext && result.collectedValues && result.collectedValues.length > 0) {
|
||||
finalValueContext.addCollectedValues(result.collectedValues, rule.type, rule);
|
||||
}
|
||||
|
||||
// Return original result if inference didn't help or values weren't sufficient
|
||||
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult);
|
||||
}
|
||||
|
||||
_getRuleResultCacheKey(userId, relation, objectId, rule) {
|
||||
const base = this.arbiter.keyManager.createCompositeKey(userId, relation, objectId);
|
||||
let suffix = 'rule';
|
||||
if (rule) {
|
||||
if (rule.union || rule.intersection || rule.exclusion) {
|
||||
suffix = 'logical';
|
||||
} else if (rule.type === 'direct') {
|
||||
suffix = `direct:${rule.relation || 'unknown'}`;
|
||||
} else if (rule.type === 'tuple_to_userset') {
|
||||
suffix = `tupleset:${rule.tuplesetRelation || 'unknown'}:${rule.computedRelation || 'unknown'}`;
|
||||
} else if (rule.type === 'chain' && Array.isArray(rule.steps)) {
|
||||
const stepSig = rule.steps.map(step => {
|
||||
if (typeof step === 'string') return step;
|
||||
return `${step.relation || 'unknown'}:${step.direction || 'out'}`;
|
||||
}).join('>');
|
||||
suffix = `chain:${stepSig}`;
|
||||
} else if (rule.type === 'multi_hop') {
|
||||
suffix = `multihop:${rule.relation || 'unknown'}:${rule.maxDepth || 'auto'}`;
|
||||
} else if (rule.type === 'parent') {
|
||||
suffix = `parent:${rule.parentRelation || 'unknown'}`;
|
||||
} else if (rule.type === 'relational_comparator') {
|
||||
const signature = this._getComparatorCacheSignature(rule);
|
||||
suffix = `comparator:${rule.comparator || 'unknown'}:${signature}`;
|
||||
} else {
|
||||
suffix = rule.type || 'rule';
|
||||
}
|
||||
}
|
||||
return `${base}|${suffix}`;
|
||||
}
|
||||
|
||||
_maybeCacheRuleResult(result, relation, cacheKey, enabled) {
|
||||
if (!enabled || !cacheKey || !relation) return result;
|
||||
this.arbiter.ruleResultCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
this.arbiter._cacheRuleResult(relation, cacheKey);
|
||||
return result;
|
||||
}
|
||||
|
||||
_getComparatorCacheSignature(rule) {
|
||||
const leftSig = this._getComparatorOperandSignature(rule.left || rule.leftOperand);
|
||||
const rightSig = this._getComparatorOperandSignature(rule.right || rule.rightOperand);
|
||||
return `L(${leftSig})|R(${rightSig})`;
|
||||
}
|
||||
|
||||
_getComparatorOperandSignature(operand) {
|
||||
if (!operand || typeof operand !== 'object') return 'none';
|
||||
const valueRelation = operand.valueRelation || operand.valueRelationResolved;
|
||||
if (valueRelation) return `relation:${valueRelation}`;
|
||||
|
||||
const nestedRule = operand.rule || null;
|
||||
if (!nestedRule || typeof nestedRule !== 'object') return 'value';
|
||||
|
||||
if (nestedRule.type === 'direct') {
|
||||
const rel = nestedRule.relation || nestedRule.rel || nestedRule.label || nestedRule.name || 'unknown';
|
||||
return `${nestedRule.reverse ? 'rev:' : ''}${rel}`;
|
||||
}
|
||||
|
||||
if (nestedRule.type === 'logical') {
|
||||
if (nestedRule._optimized?.kind === 'direct_list') {
|
||||
const rels = nestedRule._optimized.direct
|
||||
.map((direct) => `${direct.reverse ? 'rev:' : ''}${direct.relation}`)
|
||||
.join(',');
|
||||
return `direct_list:${nestedRule.op || 'logical'}:${rels}`;
|
||||
}
|
||||
if (Array.isArray(nestedRule.children)) {
|
||||
const childKeys = nestedRule.children.map((child) => {
|
||||
if (child?.type === 'direct') {
|
||||
return `${child.reverse ? 'rev:' : ''}${child.relation}`;
|
||||
}
|
||||
return child?.type || 'unknown';
|
||||
}).join(',');
|
||||
return `logical:${nestedRule.op || 'logical'}:${childKeys}`;
|
||||
}
|
||||
return `logical:${nestedRule.op || 'logical'}`;
|
||||
}
|
||||
|
||||
if (nestedRule.union) {
|
||||
const rules = Array.isArray(nestedRule.union.rules) ? nestedRule.union.rules : (Array.isArray(nestedRule.union) ? nestedRule.union : []);
|
||||
const rels = rules.map((child) => {
|
||||
if (child?.type === 'direct') {
|
||||
const rel = child.relation || child.rel || child.label || child.name || 'unknown';
|
||||
return `${child.reverse ? 'rev:' : ''}${rel}`;
|
||||
}
|
||||
return child?.type || 'unknown';
|
||||
}).join(',');
|
||||
return `logical:union:${rels}`;
|
||||
}
|
||||
|
||||
if (nestedRule.intersection) {
|
||||
const rules = Array.isArray(nestedRule.intersection.rules) ? nestedRule.intersection.rules : (Array.isArray(nestedRule.intersection) ? nestedRule.intersection : []);
|
||||
const rels = rules.map((child) => {
|
||||
if (child?.type === 'direct') {
|
||||
const rel = child.relation || child.rel || child.label || child.name || 'unknown';
|
||||
return `${child.reverse ? 'rev:' : ''}${rel}`;
|
||||
}
|
||||
return child?.type || 'unknown';
|
||||
}).join(',');
|
||||
return `logical:intersection:${rels}`;
|
||||
}
|
||||
|
||||
if (nestedRule.exclusion) {
|
||||
const rules = Array.isArray(nestedRule.exclusion.rules) ? nestedRule.exclusion.rules : (Array.isArray(nestedRule.exclusion) ? nestedRule.exclusion : []);
|
||||
const rels = rules.map((child) => {
|
||||
if (child?.type === 'direct') {
|
||||
const rel = child.relation || child.rel || child.label || child.name || 'unknown';
|
||||
return `${child.reverse ? 'rev:' : ''}${rel}`;
|
||||
}
|
||||
return child?.type || 'unknown';
|
||||
}).join(',');
|
||||
return `logical:exclusion:${rels}`;
|
||||
}
|
||||
|
||||
return nestedRule.type || 'value';
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary mode rule evaluation: Ultra-fast with strict thresholds and value context
|
||||
*/
|
||||
_evaluateRuleBinary(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
const {
|
||||
minAllowPossibility = 0.8,
|
||||
maxDenyPossibility = 0.8,
|
||||
fastPath = true,
|
||||
valueContext = null,
|
||||
includeMeta = true
|
||||
} = options;
|
||||
|
||||
const needsValueContext = valueContext !== null && valueContext !== undefined
|
||||
? true
|
||||
: (options.collectValues === true || this._ruleRequiresValues(rule, new Set()));
|
||||
const finalValueContext = needsValueContext ? (valueContext || new ValueContext(this.arbiter, options)) : null;
|
||||
const collectValues = options.collectValues !== undefined ? options.collectValues : (rule._needsValues ?? needsValueContext);
|
||||
const enhancedOptions = { ...options, minPossibility: options.minPossibility ?? options.minAllowPossibility, valueContext: finalValueContext, collectValues, includeMeta };
|
||||
|
||||
// Handle logical operators in binary mode
|
||||
if (rule.union) {
|
||||
return this.logicalOperators.evaluateUnion(userId, userKey, objectId, objectKey, rule, visited, currentRelation, {
|
||||
...enhancedOptions,
|
||||
binary: true,
|
||||
fastPath: true
|
||||
});
|
||||
}
|
||||
|
||||
if (rule.intersection) {
|
||||
return this.logicalOperators.evaluateIntersection(userId, userKey, objectId, objectKey, rule, visited, currentRelation, {
|
||||
...enhancedOptions,
|
||||
binary: true,
|
||||
fastPath: true
|
||||
});
|
||||
}
|
||||
|
||||
if (rule.exclusion) {
|
||||
return this.logicalOperators.evaluateExclusion(userId, userKey, objectId, objectKey, rule, visited, currentRelation, {
|
||||
...enhancedOptions,
|
||||
binary: true,
|
||||
fastPath: true
|
||||
});
|
||||
}
|
||||
|
||||
// Defeasible rules (when/unless/never/always/requires) have no entry in
|
||||
// ruleHandlers — normal mode reaches them via the compiled evaluator,
|
||||
// but binary mode short-circuits to this method, where a bare handler
|
||||
// lookup returned unknown_rule_type (a hard binary deny for every
|
||||
// defeasible config). Route them through the defeasible evaluator,
|
||||
// which implements binary/threshold modes natively.
|
||||
if (this.logicalOperators._isDefeasibleLogic(rule)) {
|
||||
if (rule._compiled && !(rule._compileErrors && rule._compileErrors.length)) {
|
||||
return this.compiledEvaluator.evaluate(rule._compiled, userId, userKey, objectId, objectKey, visited, currentRelation, {
|
||||
...enhancedOptions,
|
||||
binary: true,
|
||||
fastPath: true
|
||||
});
|
||||
}
|
||||
return this.logicalOperators.evaluateDefeasible(userId, userKey, objectId, objectKey, wrapDefeasibleComponents(rule), visited, currentRelation, {
|
||||
...enhancedOptions,
|
||||
binary: true,
|
||||
fastPath: true
|
||||
});
|
||||
}
|
||||
|
||||
// Get rule handler
|
||||
const handler = this.ruleHandlers[rule.type];
|
||||
if (!handler) {
|
||||
return {
|
||||
possibility_allow: 0,
|
||||
possibility_deny: 0,
|
||||
...(collectValues && { collectedValues: [] }),
|
||||
...(includeMeta && { meta_allow: null, meta_deny: null }),
|
||||
reason: 'unknown_rule_type',
|
||||
binary: true
|
||||
};
|
||||
}
|
||||
|
||||
// Evaluate rule with binary options
|
||||
const result = handler.evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, {
|
||||
...enhancedOptions,
|
||||
binary: true,
|
||||
fastPath: true
|
||||
});
|
||||
|
||||
// Add collected values to value context in binary mode too
|
||||
// This is important if downstream rules in a logical operator need them
|
||||
if (finalValueContext && result.collectedValues && result.collectedValues.length > 0) {
|
||||
finalValueContext.addCollectedValues(result.collectedValues, rule.type, rule);
|
||||
}
|
||||
|
||||
// Binary mode means we only care about allow/deny based on thresholds
|
||||
const allow = result.possibility_allow >= minAllowPossibility;
|
||||
const deny = result.possibility_deny >= maxDenyPossibility;
|
||||
|
||||
return {
|
||||
possibility_allow: allow ? 1 : 0,
|
||||
possibility_deny: deny ? 1 : 0,
|
||||
// Preserve the real continuous possibility so logical operators
|
||||
// (union/intersection/exclusion) can aggregate child strengths —
|
||||
// binarized 0/1 values alone corrupt max/min/product aggregation.
|
||||
possibility: result.possibility ?? result.possibility_allow ?? (allow ? 1 : 0),
|
||||
...(includeMeta && { meta_allow: allow ? (result.meta_allow || result.meta) : null, meta_deny: deny ? result.meta_deny : null }),
|
||||
reason: result.reason || (allow ? 'binary_allow_threshold_met' : (deny ? 'binary_deny_threshold_met' : 'binary_threshold_not_met')),
|
||||
binary: true,
|
||||
collectedValues: result.collectedValues || [] // Preserve collected values
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value context statistics for debugging
|
||||
*/
|
||||
getValueContextStats(options = {}) {
|
||||
const { valueContext } = options;
|
||||
return valueContext ? valueContext.getStats() : null;
|
||||
}
|
||||
|
||||
_ruleRequiresValues(rule, visitedRelations) {
|
||||
if (!rule || typeof rule !== 'object') return false;
|
||||
|
||||
if (rule.type === 'relational_comparator' || rule.qualitative === true) {
|
||||
rule._needsValues = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (rule.type === 'computed') {
|
||||
const relationName = rule.relation;
|
||||
if (relationName) {
|
||||
if (visitedRelations.has(relationName)) return false;
|
||||
visitedRelations.add(relationName);
|
||||
if (this._valueRequirementCache.has(relationName)) {
|
||||
return this._valueRequirementCache.get(relationName);
|
||||
}
|
||||
const config = this.arbiter.relationConfigs.get(relationName);
|
||||
const requiresValues = this._ruleRequiresValues(config, visitedRelations);
|
||||
this._valueRequirementCache.set(relationName, requiresValues);
|
||||
if (config && typeof config === 'object') {
|
||||
config._needsValues = requiresValues;
|
||||
}
|
||||
return requiresValues;
|
||||
}
|
||||
}
|
||||
|
||||
const traverse = (child) => this._ruleRequiresValues(child, visitedRelations);
|
||||
|
||||
if (Array.isArray(rule.union) && rule.union.some(traverse)) {
|
||||
rule._needsValues = true;
|
||||
return true;
|
||||
}
|
||||
if (rule.union && Array.isArray(rule.union.rules) && rule.union.rules.some(traverse)) {
|
||||
rule._needsValues = true;
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(rule.intersection) && rule.intersection.some(traverse)) {
|
||||
rule._needsValues = true;
|
||||
return true;
|
||||
}
|
||||
if (rule.intersection && Array.isArray(rule.intersection.rules) && rule.intersection.rules.some(traverse)) {
|
||||
rule._needsValues = true;
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(rule.exclusion) && rule.exclusion.some(traverse)) {
|
||||
rule._needsValues = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (rule.when && this._ruleRequiresValues(rule.when, visitedRelations)) {
|
||||
rule._needsValues = true;
|
||||
return true;
|
||||
}
|
||||
if (rule.unless && this._ruleRequiresValues(rule.unless, visitedRelations)) {
|
||||
rule._needsValues = true;
|
||||
return true;
|
||||
}
|
||||
if (rule.requires && this._ruleRequiresValues(rule.requires, visitedRelations)) {
|
||||
rule._needsValues = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
rule._needsValues = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap raw defeasible components (single rules like
|
||||
* `{ when: { type: 'direct', relation: 'x' } }`) into the object shape
|
||||
* LogicalOperators.evaluateDefeasible expects
|
||||
* (`when.intersection.rules[]`, `unless.union.rules[]`, `always.direct`).
|
||||
* Logical (union/intersection/exclusion) components pass through.
|
||||
*/
|
||||
function wrapDefeasibleComponents(rule) {
|
||||
const wrapSingle = (component) => ({ rules: [component] });
|
||||
const wrapUnion = (component) => {
|
||||
if (!component) return component;
|
||||
if (Array.isArray(component)) return { rules: component };
|
||||
if (component.union || component.intersection || component.exclusion) return component;
|
||||
return wrapSingle(component);
|
||||
};
|
||||
const wrapIntersection = (component) => {
|
||||
if (!component) return component;
|
||||
if (Array.isArray(component)) return { rules: component };
|
||||
if (component.union || component.intersection || component.exclusion) return component;
|
||||
return wrapSingle(component);
|
||||
};
|
||||
return {
|
||||
...rule,
|
||||
when: rule.when ? wrapIntersection(rule.when) : rule.when,
|
||||
unless: rule.unless ? wrapUnion(rule.unless) : rule.unless,
|
||||
never: rule.never ? wrapUnion(rule.never) : rule.never,
|
||||
requires: rule.requires ? wrapUnion(rule.requires) : rule.requires,
|
||||
always: rule.always ? { direct: rule.always.direct || rule.always } : rule.always
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export class ScratchBuffers {
|
||||
constructor() {
|
||||
this._arrays = new Map();
|
||||
}
|
||||
|
||||
getArray(key, length) {
|
||||
let arr = this._arrays.get(key);
|
||||
if (!arr || arr.length < length) {
|
||||
arr = new Array(length);
|
||||
this._arrays.set(key, arr);
|
||||
}
|
||||
if (arr.length !== length) {
|
||||
arr.length = length;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
# Value Handling Optimization Summary
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the comprehensive optimization of value handling in the RuleEvaluator system, which eliminates wasteful recalculations and dramatically improves performance for complex rule evaluations.
|
||||
|
||||
## Problems Identified
|
||||
|
||||
### 1. **Redundant Value Fetching**
|
||||
- **Issue**: Multiple rules in the same evaluation tree were independently fetching the same values from relations
|
||||
- **Example**: A union rule with 3 child RelationalComparatorRules would fetch `user.balance` 3 times
|
||||
- **Impact**: O(n) redundant database/relation queries per evaluation
|
||||
|
||||
### 2. **Missing Value Propagation**
|
||||
- **Issue**: LogicalOperators (union/intersection) weren't combining `collectedValues` from child rules
|
||||
- **Impact**: Lost value information that could be reused by parent rules
|
||||
|
||||
### 3. **Wasteful Inference Triggering**
|
||||
- **Issue**: Inference engine was called even when sufficient values existed but weren't being considered
|
||||
- **Impact**: Expensive similarity calculations when values were already available
|
||||
|
||||
### 4. **No Context Sharing**
|
||||
- **Issue**: Each rule evaluation was isolated with no mechanism to share computed values
|
||||
- **Impact**: Repeated work across the evaluation tree
|
||||
|
||||
## Solution: ValueContext System
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. **ValueContext Class** (`ValueContext.js`)
|
||||
```javascript
|
||||
export class ValueContext {
|
||||
// Centralized value cache and aggregation
|
||||
// - Value cache: entity:relation -> raw values
|
||||
// - Aggregated cache: entity:relation:method -> aggregated result
|
||||
// - Performance tracking: hits/misses/fetch counts
|
||||
}
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- **Caching**: Eliminate redundant fetches with `entity:relation` keyed cache
|
||||
- **Aggregation**: Pre-compute and cache aggregated values (sum, max, min, etc.)
|
||||
- **Smart Inference**: Check value sufficiency before triggering expensive inference
|
||||
- **Performance Tracking**: Monitor cache hit rates and fetch reduction
|
||||
|
||||
#### 2. **Enhanced RuleEvaluator** (`RuleEvaluator.js`)
|
||||
```javascript
|
||||
evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
const { valueContext = null } = options;
|
||||
const finalValueContext = valueContext || new ValueContext(this.arbiter);
|
||||
|
||||
// Pass valueContext to all child rule evaluations
|
||||
// Add collected values to context
|
||||
// Use context to make smarter inference decisions
|
||||
}
|
||||
```
|
||||
|
||||
**Key Improvements:**
|
||||
- **Context Propagation**: Pass ValueContext through entire evaluation tree
|
||||
- **Value Collection**: Automatically add `collectedValues` to context
|
||||
- **Smart Inference**: Check `hasSufficientValues()` before expensive inference
|
||||
- **Batch Optimization**: Shared ValueContext across batch evaluations
|
||||
|
||||
#### 3. **Optimized LogicalOperators** (`LogicalOperators_v2.js`)
|
||||
```javascript
|
||||
evaluateUnion(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
const allCollectedValues = []; // Track from all child rules
|
||||
|
||||
for (const child of childRules) {
|
||||
const res = this.ruleEvaluator.evaluateRule(/* ... */, options);
|
||||
|
||||
// Merge collected values from child
|
||||
if (res.collectedValues) {
|
||||
allCollectedValues.push(...res.collectedValues);
|
||||
if (valueContext) {
|
||||
valueContext.addCollectedValues(res.collectedValues, child.type, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { /* ... */, collectedValues: allCollectedValues };
|
||||
}
|
||||
```
|
||||
|
||||
**Key Improvements:**
|
||||
- **Value Merging**: Combine `collectedValues` from all child rules
|
||||
- **Context Integration**: Add child values to shared ValueContext
|
||||
- **Preserved Information**: Ensure no value information is lost in logical operations
|
||||
|
||||
#### 4. **Optimized RelationalComparatorRule** (`RelationalComparatorRule_v2.js`)
|
||||
```javascript
|
||||
_evaluateOperand(/* ... */, options, side) {
|
||||
const { valueContext = null } = options;
|
||||
|
||||
// 1. Check if rule already provided values (collectedValues)
|
||||
if (ruleResult.collectedValues?.length > 0) {
|
||||
return this._useCollectedValues(ruleResult.collectedValues);
|
||||
}
|
||||
|
||||
// 2. Check ValueContext cache before extraction
|
||||
if (valueContext?.hasValues(entityId, relationName)) {
|
||||
return valueContext.getAggregatedValue(entityId, relationName, aggregator);
|
||||
}
|
||||
|
||||
// 3. Fallback to original extraction (but cache results)
|
||||
return this._extractValuesWithContext(/* ... */, valueContext);
|
||||
}
|
||||
```
|
||||
|
||||
**Key Improvements:**
|
||||
- **Cached Value Reuse**: Check ValueContext before fetching from relations
|
||||
- **Collected Value Utilization**: Prefer values from child rule `collectedValues`
|
||||
- **Fallback Safety**: Maintain backward compatibility with original extraction logic
|
||||
|
||||
## Performance Benefits
|
||||
|
||||
### Quantitative Improvements
|
||||
|
||||
#### Value Fetch Reduction
|
||||
- **Before**: O(n × m) fetches where n = rule count, m = unique values per rule
|
||||
- **After**: O(k) fetches where k = unique entity:relation combinations
|
||||
- **Typical Reduction**: 60-90% fewer database/relation queries
|
||||
|
||||
#### Cache Effectiveness
|
||||
- **Hit Rate**: 70-95% in complex rule evaluations
|
||||
- **Memory Usage**: Minimal overhead (values cached only for evaluation duration)
|
||||
- **Invalidation**: Automatic cleanup after evaluation completion
|
||||
|
||||
#### Inference Optimization
|
||||
- **Before**: Inference triggered on every zero-result rule
|
||||
- **After**: Inference only when `hasSufficientValues()` returns false
|
||||
- **Typical Reduction**: 40-70% fewer expensive inference operations
|
||||
|
||||
### Example Performance Case
|
||||
|
||||
**Scenario**: User purchasing premium feature
|
||||
```
|
||||
Rule Structure:
|
||||
└── UNION
|
||||
├── RELATIONAL_COMPARATOR (user.balance >= feature.price)
|
||||
└── INTERSECTION
|
||||
├── CHAIN (user -> subscriptions)
|
||||
└── UNION
|
||||
├── RELATIONAL_COMPARATOR (user.credit >= feature.price)
|
||||
└── RELATIONAL_COMPARATOR (user.balance >= feature.price)
|
||||
```
|
||||
|
||||
**Old System Value Fetches:**
|
||||
- `user.balance`: 2 times (redundant!)
|
||||
- `user.credit`: 1 time
|
||||
- `feature.price`: 3 times (redundant!)
|
||||
- **Total**: 6 fetches
|
||||
|
||||
**New System Value Fetches:**
|
||||
- `user.balance`: 1 time (cached)
|
||||
- `user.credit`: 1 time (cached)
|
||||
- `feature.price`: 1 time (cached)
|
||||
- **Total**: 3 fetches (50% reduction)
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
### 1. **Using ValueContext**
|
||||
|
||||
```javascript
|
||||
// Single evaluation with context
|
||||
const valueContext = new ValueContext(arbiter);
|
||||
const result = ruleEvaluator.evaluateRule(/* ... */, { valueContext });
|
||||
|
||||
// Batch evaluation (automatic shared context)
|
||||
const results = ruleEvaluator.batchEvaluateRules(queries);
|
||||
|
||||
// Performance monitoring
|
||||
const stats = valueContext.getStats();
|
||||
console.log(`Cache hit rate: ${stats.hitRate * 100}%`);
|
||||
```
|
||||
|
||||
### 2. **Adding Value Collection to Custom Rules**
|
||||
|
||||
```javascript
|
||||
export class CustomRule extends BaseRule {
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
const { valueContext } = options;
|
||||
|
||||
// Collect values during evaluation
|
||||
const collectedValues = [];
|
||||
|
||||
// ... rule logic ...
|
||||
|
||||
// Add values you've collected
|
||||
const collectedValue = this._createCollectedValue(
|
||||
value, possibility, path, source, metadata
|
||||
);
|
||||
collectedValues.push(collectedValue);
|
||||
|
||||
return this._createStandardResult(authResult, collectedValues);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **Performance Monitoring**
|
||||
|
||||
```javascript
|
||||
// Enable value context stats in production
|
||||
const result = ruleEvaluator.evaluateRule(/* ... */, {
|
||||
valueContext,
|
||||
trackPerformance: true
|
||||
});
|
||||
|
||||
const stats = ruleEvaluator.getValueContextStats({ valueContext });
|
||||
logger.info('Rule evaluation performance', {
|
||||
cacheHitRate: stats.hitRate,
|
||||
fetchReduction: stats.fetchCount,
|
||||
executionTime: result.executionTime
|
||||
});
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Backward Compatibility
|
||||
- ✅ **Complete**: All optimizations work alongside existing code
|
||||
- ✅ **Complete**: No breaking changes to existing rule configurations
|
||||
- ✅ **Complete**: Automatic fallback to original logic when ValueContext unavailable
|
||||
|
||||
### Phase 2: Gradual Adoption
|
||||
- **Recommended**: Use ValueContext in new rule evaluations
|
||||
- **Recommended**: Enable for batch operations (automatic)
|
||||
- **Optional**: Retrofit existing custom rules to use ValueContext
|
||||
|
||||
### Phase 3: Full Optimization
|
||||
- **Future**: Require ValueContext for all evaluations
|
||||
- **Future**: Remove fallback extraction logic
|
||||
- **Future**: Add advanced caching strategies (LRU, TTL, etc.)
|
||||
|
||||
## Validation & Testing
|
||||
|
||||
### Correctness Validation
|
||||
- ✅ **Complete**: All optimizations maintain identical results to original system
|
||||
- ✅ **Complete**: Comprehensive test coverage with `ValueOptimizationDemo`
|
||||
- ✅ **Complete**: Edge case handling (missing values, cache misses, etc.)
|
||||
|
||||
### Performance Testing
|
||||
- ✅ **Available**: Demo shows 50%+ fetch reduction in typical scenarios
|
||||
- ✅ **Available**: Cache hit rates consistently above 70%
|
||||
- ✅ **Available**: Performance tracking and monitoring built-in
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### 1. **Advanced Caching**
|
||||
- **LRU Eviction**: Limit memory usage in long-running processes
|
||||
- **TTL Support**: Expire stale values automatically
|
||||
- **Persistent Cache**: Cross-evaluation value persistence
|
||||
|
||||
### 2. **Smart Prefetching**
|
||||
- **Dependency Analysis**: Pre-fetch values based on rule structure
|
||||
- **Batch Loading**: Group value fetches by entity/relation patterns
|
||||
- **Predictive Caching**: Learn from evaluation patterns
|
||||
|
||||
### 3. **Distributed Caching**
|
||||
- **Redis Integration**: Share ValueContext across service instances
|
||||
- **Cluster Coordination**: Synchronized cache invalidation
|
||||
- **Partitioning**: Shard value cache by entity patterns
|
||||
|
||||
## Conclusion
|
||||
|
||||
The ValueContext optimization system provides:
|
||||
|
||||
1. **🚀 Performance**: 50-90% reduction in redundant value fetches
|
||||
2. **💡 Intelligence**: Smart inference decisions based on available values
|
||||
3. **🔄 Compatibility**: Zero breaking changes to existing code
|
||||
4. **📊 Observability**: Built-in performance monitoring and stats
|
||||
5. **🛡️ Reliability**: Maintained result consistency with comprehensive testing
|
||||
|
||||
This optimization eliminates the core inefficiencies in value handling while maintaining full backward compatibility and providing a foundation for future enhancements.
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* ValueContext - Manages value collection and caching across rule evaluations
|
||||
*
|
||||
* This class provides a centralized value cache and collection mechanism to:
|
||||
* 1. Eliminate redundant value fetching from the same entities/relations
|
||||
* 2. Provide efficient value aggregation and propagation
|
||||
* 3. Enable smart inference decisions based on available values
|
||||
* 4. Track value provenance and metadata for debugging
|
||||
*
|
||||
* Usage:
|
||||
* const valueContext = new ValueContext(arbiter);
|
||||
* const result = ruleEvaluator.evaluateRule(..., { valueContext });
|
||||
*/
|
||||
export class ValueContext {
|
||||
constructor(arbiter, options = null) {
|
||||
this.arbiter = arbiter;
|
||||
this.partialGraphContext = options?.partialGraphContext || null;
|
||||
|
||||
// Value cache: key -> Set of values with metadata
|
||||
this.valueCache = new Map();
|
||||
|
||||
// Aggregated values: key -> aggregated value with metadata
|
||||
this.aggregatedCache = new Map();
|
||||
|
||||
// Collected values from all rule evaluations
|
||||
this.allCollectedValues = [];
|
||||
|
||||
// Track which entities/relations have been fetched
|
||||
this.fetchedRelations = new Set();
|
||||
|
||||
// Performance tracking
|
||||
this.cacheHits = 0;
|
||||
this.cacheMisses = 0;
|
||||
this.fetchCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get values from a specific entity and relation, with caching
|
||||
*/
|
||||
getValues(entityId, relationName, options = {}) {
|
||||
// Use plain string key to avoid interning numbers into the UnifiedKeyManager string table
|
||||
const cacheKey = `${entityId}|${relationName}`;
|
||||
|
||||
// Check cache first
|
||||
if (this.valueCache.has(cacheKey)) {
|
||||
this.cacheHits++;
|
||||
return this.valueCache.get(cacheKey);
|
||||
}
|
||||
|
||||
this.cacheMisses++;
|
||||
this.fetchCount++;
|
||||
|
||||
// Fetch from relation manager
|
||||
const valueRels = this.arbiter.relationManager.getAllValueRelationsFromSrc(entityId, relationName, {
|
||||
partialGraphContext: this.partialGraphContext
|
||||
});
|
||||
|
||||
const values = valueRels
|
||||
.filter(rel => rel.value !== undefined && rel.value !== null)
|
||||
.map(rel => ({
|
||||
value: rel.value,
|
||||
timestamp: rel.changed_last_at || rel.updated_last_at || Date.now(),
|
||||
possibility: rel.possibility ?? 1.0,
|
||||
reliability: rel.reliability || 1.0,
|
||||
entityId: entityId,
|
||||
relationName: relationName,
|
||||
targetId: rel.dst,
|
||||
targetKey: this.arbiter.resolveKey(rel.dst, { partialGraphContext: this.partialGraphContext }),
|
||||
source: rel.source || 'persistent'
|
||||
}));
|
||||
|
||||
// Cache the result
|
||||
this.valueCache.set(cacheKey, values);
|
||||
this.fetchedRelations.add(cacheKey);
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add collected values from a rule evaluation
|
||||
*/
|
||||
addCollectedValues(collectedValues, ruleType, ruleConfig = {}) {
|
||||
if (!Array.isArray(collectedValues)) return;
|
||||
|
||||
for (const cv of collectedValues) {
|
||||
if (!cv) continue;
|
||||
// Enhance with rule context
|
||||
const enhanced = {
|
||||
...cv,
|
||||
ruleType,
|
||||
ruleConfig,
|
||||
collectedAt: Date.now()
|
||||
};
|
||||
|
||||
this.allCollectedValues.push(enhanced);
|
||||
|
||||
// Also add to entity-specific cache if not already there
|
||||
if (cv.source && cv.source.entityKey && cv.source.relation) {
|
||||
const entityId = this.arbiter.resolveNodeId(cv.source.entityKey, { partialGraphContext: this.partialGraphContext });
|
||||
if (entityId) {
|
||||
const cacheKey = `${entityId}|${cv.source.relation}`;
|
||||
if (!this.valueCache.has(cacheKey)) {
|
||||
this.valueCache.set(cacheKey, [{
|
||||
value: cv.value,
|
||||
timestamp: cv.metadata?.timestamp || Date.now(),
|
||||
possibility: cv.possibility ?? 1.0,
|
||||
reliability: cv.metadata?.reliability || 1.0,
|
||||
entityId: entityId,
|
||||
relationName: cv.source.relation,
|
||||
fromCollectedValues: true
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated value for a specific entity and relation
|
||||
*/
|
||||
getAggregatedValue(entityId, relationName, aggregationMethod = 'max', options = {}) {
|
||||
// Use plain string key to avoid interning numbers into the UnifiedKeyManager string table
|
||||
const cacheKey = `${entityId}|${relationName}|${aggregationMethod}`;
|
||||
|
||||
// Check aggregated cache
|
||||
if (this.aggregatedCache.has(cacheKey)) {
|
||||
return this.aggregatedCache.get(cacheKey);
|
||||
}
|
||||
|
||||
// Get raw values
|
||||
const values = this.getValues(entityId, relationName, options);
|
||||
|
||||
if (values.length === 0) {
|
||||
const result = {
|
||||
value: null,
|
||||
hasValue: false,
|
||||
possibility: 0,
|
||||
reliability: 0,
|
||||
timestamp: null,
|
||||
aggregationMethod,
|
||||
sourceCount: 0
|
||||
};
|
||||
this.aggregatedCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Single value case
|
||||
if (values.length === 1) {
|
||||
const result = {
|
||||
value: values[0].value,
|
||||
hasValue: true,
|
||||
possibility: values[0].possibility,
|
||||
reliability: values[0].reliability,
|
||||
timestamp: values[0].timestamp,
|
||||
aggregationMethod,
|
||||
sourceCount: 1
|
||||
};
|
||||
this.aggregatedCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Aggregate multiple values
|
||||
let aggregatedValue, aggregatedPossibility, aggregatedReliability, aggregatedTimestamp;
|
||||
|
||||
switch (aggregationMethod) {
|
||||
case 'max':
|
||||
aggregatedValue = Math.max(...values.map(v => v.value));
|
||||
const maxEntry = values.find(v => v.value === aggregatedValue);
|
||||
aggregatedPossibility = maxEntry.possibility;
|
||||
aggregatedReliability = maxEntry.reliability;
|
||||
aggregatedTimestamp = maxEntry.timestamp;
|
||||
break;
|
||||
|
||||
case 'min':
|
||||
aggregatedValue = Math.min(...values.map(v => v.value));
|
||||
const minEntry = values.find(v => v.value === aggregatedValue);
|
||||
aggregatedPossibility = minEntry.possibility;
|
||||
aggregatedReliability = minEntry.reliability;
|
||||
aggregatedTimestamp = minEntry.timestamp;
|
||||
break;
|
||||
|
||||
case 'sum':
|
||||
aggregatedValue = values.reduce((sum, v) => sum + v.value, 0);
|
||||
aggregatedPossibility = Math.max(...values.map(v => v.possibility));
|
||||
aggregatedReliability = values.reduce((prod, v) => prod * v.reliability, 1.0);
|
||||
aggregatedTimestamp = Math.max(...values.map(v => v.timestamp || 0));
|
||||
break;
|
||||
|
||||
case 'average':
|
||||
aggregatedValue = values.reduce((sum, v) => sum + v.value, 0) / values.length;
|
||||
aggregatedPossibility = values.reduce((sum, v) => sum + v.possibility, 0) / values.length;
|
||||
aggregatedReliability = values.reduce((sum, v) => sum + v.reliability, 0) / values.length;
|
||||
aggregatedTimestamp = Math.max(...values.map(v => v.timestamp || 0));
|
||||
break;
|
||||
|
||||
default:
|
||||
// Default to max
|
||||
aggregatedValue = Math.max(...values.map(v => v.value));
|
||||
const defaultMaxEntry = values.find(v => v.value === aggregatedValue);
|
||||
aggregatedPossibility = defaultMaxEntry.possibility;
|
||||
aggregatedReliability = defaultMaxEntry.reliability;
|
||||
aggregatedTimestamp = defaultMaxEntry.timestamp;
|
||||
}
|
||||
|
||||
const result = {
|
||||
value: aggregatedValue,
|
||||
hasValue: true,
|
||||
possibility: aggregatedPossibility,
|
||||
reliability: aggregatedReliability,
|
||||
timestamp: aggregatedTimestamp,
|
||||
aggregationMethod,
|
||||
sourceCount: values.length,
|
||||
sourceValues: values
|
||||
};
|
||||
|
||||
this.aggregatedCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have sufficient values to make decisions without inference
|
||||
*/
|
||||
hasSufficientValues(userKey, objectKey, relationName, options = {}) {
|
||||
const { minValueCount = 1, minReliability = 0.5 } = options;
|
||||
|
||||
const userId = this.arbiter.nodeIdByKey.get(userKey);
|
||||
const objectId = this.arbiter.nodeIdByKey.get(objectKey);
|
||||
|
||||
if (!userId || !objectId) return false;
|
||||
|
||||
// Check both user and object for values
|
||||
const userValues = this.getValues(userId, relationName);
|
||||
const objectValues = this.getValues(objectId, relationName);
|
||||
|
||||
const allValues = [...userValues, ...objectValues];
|
||||
|
||||
if (allValues.length < minValueCount) return false;
|
||||
|
||||
// Check reliability threshold
|
||||
const avgReliability = allValues.reduce((sum, v) => sum + v.reliability, 0) / allValues.length;
|
||||
|
||||
return avgReliability >= minReliability;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all collected values filtered by criteria
|
||||
*/
|
||||
getCollectedValues(filter = {}) {
|
||||
const { ruleType, entityKey, relationName, minValue, maxValue } = filter;
|
||||
|
||||
return this.allCollectedValues.filter(cv => {
|
||||
if (ruleType && cv.ruleType !== ruleType) return false;
|
||||
if (entityKey && cv.source?.entityKey !== entityKey) return false;
|
||||
if (relationName && cv.source?.relation !== relationName) return false;
|
||||
if (minValue !== undefined && cv.value < minValue) return false;
|
||||
if (maxValue !== undefined && cv.value > maxValue) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge collected values from child rule results
|
||||
*/
|
||||
mergeCollectedValues(childResults) {
|
||||
const mergedValues = [];
|
||||
|
||||
for (const result of childResults) {
|
||||
if (result.collectedValues && Array.isArray(result.collectedValues)) {
|
||||
mergedValues.push(...result.collectedValues);
|
||||
this.addCollectedValues(result.collectedValues, 'merged', { fromChildRule: true });
|
||||
}
|
||||
}
|
||||
|
||||
return mergedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
cacheHits: this.cacheHits,
|
||||
cacheMisses: this.cacheMisses,
|
||||
fetchCount: this.fetchCount,
|
||||
hitRate: this.cacheHits / (this.cacheHits + this.cacheMisses),
|
||||
uniqueValuesCollected: this.allCollectedValues.length,
|
||||
uniqueRelationsFetched: this.fetchedRelations.size,
|
||||
cachedEntities: this.valueCache.size,
|
||||
aggregatedCacheSize: this.aggregatedCache.size
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches (useful for testing or memory management)
|
||||
*/
|
||||
clear() {
|
||||
this.valueCache.clear();
|
||||
this.aggregatedCache.clear();
|
||||
this.allCollectedValues = [];
|
||||
this.fetchedRelations.clear();
|
||||
this.cacheHits = 0;
|
||||
this.cacheMisses = 0;
|
||||
this.fetchCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate caches when relations change
|
||||
* @param {string} relation - The relation that changed
|
||||
*/
|
||||
invalidateCaches(relation) {
|
||||
// Clear all caches when relations change since we can't easily determine
|
||||
// which cached values are affected by the relation change
|
||||
this.valueCache.clear();
|
||||
this.aggregatedCache.clear();
|
||||
this.fetchedRelations.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export function extractRemediation(res) {
|
||||
return res?.remediation || null;
|
||||
}
|
||||
|
||||
export function mergeRemediationOptions(into, remediation) {
|
||||
if (!Array.isArray(remediation?.options)) return;
|
||||
for (const option of remediation.options) {
|
||||
if (!option?.relation || !option?.object) continue;
|
||||
into.push(option);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRemediation(remediation = null, options = {}) {
|
||||
if (!remediation && !Array.isArray(options.additional_options)) return null;
|
||||
|
||||
const mergedOptions = [];
|
||||
mergeRemediationOptions(mergedOptions, remediation);
|
||||
if (Array.isArray(options.additional_options)) {
|
||||
for (const option of options.additional_options) {
|
||||
if (!option?.relation || !option?.object) continue;
|
||||
mergedOptions.push(option);
|
||||
}
|
||||
}
|
||||
if (mergedOptions.length === 0) return null;
|
||||
|
||||
const deduped = [];
|
||||
const seen = new Set();
|
||||
for (const option of mergedOptions) {
|
||||
const key = `${option.relation}|${option.object}|${option.url || ''}|${option.method || ''}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(option);
|
||||
}
|
||||
|
||||
return {
|
||||
status: options.status || remediation?.status || 'required',
|
||||
...(options.request_id
|
||||
? { request_id: options.request_id }
|
||||
: (remediation?.request_id ? { request_id: remediation.request_id } : {})),
|
||||
options: deduped
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRemediationFromChallenges(challengeRequirements, options = {}) {
|
||||
const list = Array.isArray(challengeRequirements)
|
||||
? challengeRequirements
|
||||
: (challengeRequirements ? [challengeRequirements] : []);
|
||||
const derivedOptions = [];
|
||||
for (const requirement of list) {
|
||||
const challengeName = String(requirement?.name || '').trim().replace(/^!/, '');
|
||||
if (!challengeName) continue;
|
||||
const metadata = {};
|
||||
if (requirement?.subject) metadata.subject = requirement.subject;
|
||||
if (requirement?.withinMs !== undefined && requirement?.withinMs !== null) {
|
||||
metadata.within_ms = requirement.withinMs;
|
||||
}
|
||||
derivedOptions.push({
|
||||
relation: 'challenge_satisfied',
|
||||
object: `challenge:${challengeName}`,
|
||||
...(Object.keys(metadata).length > 0 ? { metadata } : {})
|
||||
});
|
||||
}
|
||||
return buildRemediation(null, {
|
||||
...options,
|
||||
additional_options: derivedOptions
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
# Authorization Rules API Specification
|
||||
|
||||
This document defines the standardized API that all authorization rules must implement to ensure consistency, performance, and maintainability across the zanzibar-graph system.
|
||||
|
||||
## Overview
|
||||
|
||||
All authorization rules must extend the `BaseRule` class and implement the required abstract methods. This ensures:
|
||||
|
||||
- **Consistent Interface**: All rules have the same public API
|
||||
- **Performance Tracking**: Built-in performance monitoring
|
||||
- **Error Handling**: Standardized error responses
|
||||
- **Batch Processing**: Optional but encouraged for performance
|
||||
- **Early Exit**: Threshold-based optimization support
|
||||
- **Input Validation**: Automatic parameter validation
|
||||
|
||||
## Core Interface
|
||||
|
||||
### Constructor
|
||||
|
||||
```javascript
|
||||
constructor(arbiter)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `arbiter` (Arbiter): The main Arbiter instance
|
||||
|
||||
**Requirements:**
|
||||
- Must call `super(arbiter)` first
|
||||
- Should initialize any rule-specific state
|
||||
- Must not throw exceptions
|
||||
|
||||
### Primary Evaluation Method
|
||||
|
||||
```javascript
|
||||
evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {})
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `userId` (number): Internal user node ID
|
||||
- `userKey` (string): User key (e.g., "user:alice")
|
||||
- `objectId` (number): Internal object node ID
|
||||
- `objectKey` (string): Object key (e.g., "doc:secret")
|
||||
- `rule` (Object): Rule configuration object
|
||||
- `visited` (Set): Set of visited nodes for cycle detection
|
||||
- `currentRelation` (string): Current relation being evaluated
|
||||
- `options` (Object): Evaluation options
|
||||
|
||||
**Returns:** `RuleEvaluationResult` object
|
||||
|
||||
**Standard Options:**
|
||||
```javascript
|
||||
{
|
||||
// Performance options
|
||||
fastPath: false, // Enable early termination
|
||||
minAllowPossibility: null, // Threshold for allow early exit (0-1)
|
||||
maxDenyPossibility: null, // Threshold for deny early exit (0-1)
|
||||
|
||||
// Inference options
|
||||
noInfer: false, // Disable inference
|
||||
allowInference: true, // Allow inference (opposite of noInfer)
|
||||
|
||||
// Binary mode
|
||||
binary: false, // Return binary allow/deny decisions
|
||||
|
||||
// Evaluation tracking
|
||||
trackEvaluation: true, // Include evaluation metadata
|
||||
|
||||
// Rule-specific options (passed through)
|
||||
// ... additional options specific to rule type
|
||||
}
|
||||
```
|
||||
|
||||
### Batch Evaluation Method (Optional)
|
||||
|
||||
```javascript
|
||||
batchEvaluate(queries, batchOptions = {})
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `queries` (Array): Array of query objects with same structure as `evaluate` parameters
|
||||
- `batchOptions` (Object): Batch-specific options
|
||||
|
||||
**Returns:** Array of `RuleEvaluationResult` objects
|
||||
|
||||
**Query Object Structure:**
|
||||
```javascript
|
||||
{
|
||||
userId: number,
|
||||
userKey: string,
|
||||
objectId: number,
|
||||
objectKey: string,
|
||||
rule: Object,
|
||||
visited: Set,
|
||||
currentRelation: string,
|
||||
options: Object
|
||||
}
|
||||
```
|
||||
|
||||
### Batch Support Check
|
||||
|
||||
```javascript
|
||||
canBatchProcess()
|
||||
```
|
||||
|
||||
**Returns:** `boolean` - Whether this rule supports efficient batch processing
|
||||
|
||||
## Standard Result Structure
|
||||
|
||||
All evaluation methods must return a `RuleEvaluationResult` object:
|
||||
|
||||
```javascript
|
||||
{
|
||||
// Core evaluation results (required)
|
||||
possibility_allow: number, // 0-1, possibility of allowing access
|
||||
possibility_deny: number, // 0-1, possibility of denying access
|
||||
reliability: number, // 0-1, reliability of the evaluation
|
||||
|
||||
// Metadata (required, can be null)
|
||||
meta_allow: Object | null, // Metadata for allow decision
|
||||
meta_deny: Object | null, // Metadata for deny decision
|
||||
|
||||
// Optional fields
|
||||
reason: string, // Reason for the result
|
||||
error: boolean, // Whether this is an error result
|
||||
details: Object, // Additional details
|
||||
evaluation: Object // Evaluation tracking data
|
||||
}
|
||||
```
|
||||
|
||||
### Standard Metadata Structure
|
||||
|
||||
```javascript
|
||||
// meta_allow / meta_deny structure
|
||||
{
|
||||
ruleType: string, // Type of rule (e.g., 'direct', 'tuple_to_userset')
|
||||
reason: string, // Reason for decision
|
||||
rule: Object, // Original rule configuration
|
||||
|
||||
// Performance metadata
|
||||
earlyExit: boolean, // Whether early exit was used
|
||||
earlyExitReason: string, // Reason for early exit
|
||||
|
||||
// Rule-specific metadata
|
||||
// ... additional fields specific to rule type
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Requirements
|
||||
|
||||
### Abstract Methods (Must Implement)
|
||||
|
||||
```javascript
|
||||
// Core evaluation logic
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options)
|
||||
|
||||
// Batch evaluation logic (optional, only if canBatchProcess() returns true)
|
||||
_batchEvaluateRule(queries, batchOptions)
|
||||
```
|
||||
|
||||
### Performance Tracking
|
||||
|
||||
All rules automatically track:
|
||||
- Individual evaluation count and timing
|
||||
- Batch evaluation count and timing
|
||||
- Average evaluation times
|
||||
|
||||
Access via:
|
||||
```javascript
|
||||
rule.getPerformanceStats()
|
||||
rule.resetPerformanceStats()
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Rules should handle errors gracefully:
|
||||
- Input validation is automatic
|
||||
- Exceptions are caught and converted to error results
|
||||
- Batch operations fall back to individual evaluations on error
|
||||
|
||||
## Rule-Specific Configurations
|
||||
|
||||
### Common Rule Properties
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: string, // Rule type identifier
|
||||
ruleType: string, // 'defeasible', 'defeater', 'strict'
|
||||
priority: number, // Rule priority (higher = more important)
|
||||
weight: number, // Rule weight for aggregation
|
||||
|
||||
// Performance options
|
||||
no_infer: boolean, // Disable inference for this rule
|
||||
allowInference: boolean, // Allow inference
|
||||
|
||||
// Aggregation options
|
||||
owaWeights: Array<number>, // OWA aggregation weights
|
||||
aggregator: string, // Aggregation method ('max', 'min', 'avg', etc.)
|
||||
|
||||
// Rule-specific properties
|
||||
// ... varies by rule type
|
||||
}
|
||||
```
|
||||
|
||||
### DirectRule Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'direct',
|
||||
relation: string, // Relation to check (optional, uses currentRelation)
|
||||
reverse: boolean // Check in reverse direction
|
||||
}
|
||||
```
|
||||
|
||||
### TupleToUsersetRule Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'tuple_to_userset',
|
||||
tuplesetRelation: string, // Relation from object to intermediate
|
||||
computedRelation: string, // Relation from user to intermediate
|
||||
reverse: boolean // Check in reverse direction
|
||||
}
|
||||
```
|
||||
|
||||
### ParentRule Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'parent',
|
||||
parentRelation: string, // Relation defining parent-child
|
||||
relation: string, // Relation to check on parent
|
||||
reverse: boolean // Check in reverse direction
|
||||
}
|
||||
```
|
||||
|
||||
### SimilarityRule Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'similar_to',
|
||||
relation: string, // Relation to find similarities for
|
||||
k: number, // Number of similar entities to consider
|
||||
similarityThreshold: number, // Minimum similarity threshold (0-1)
|
||||
reverse: boolean, // Check in reverse direction
|
||||
fallbackToBasicSimilarity: boolean // Use basic similarity when embeddings unavailable
|
||||
}
|
||||
```
|
||||
|
||||
### MultiHopRule Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'multi_hop',
|
||||
relation: string, // Relation to traverse
|
||||
maxDepth: number, // Maximum path depth
|
||||
minReliability: number, // Minimum path reliability
|
||||
decayFactor: number, // Reliability decay per hop
|
||||
pathAggregation: string, // How to aggregate multiple paths ('max', 'sum', 'owa')
|
||||
reverse: boolean, // Search in reverse direction
|
||||
allowInference: boolean, // Allow inference for missing edges
|
||||
fallbackToBasicPaths: boolean // Use basic path finding as fallback
|
||||
}
|
||||
```
|
||||
|
||||
### ComputedRule Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'computed',
|
||||
relation: string // Relation to recursively evaluate
|
||||
}
|
||||
```
|
||||
|
||||
## Logical Operators
|
||||
|
||||
### Union (OR) Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
union: Array<RuleConfig>, // Array of rules to OR together
|
||||
aggregator: string, // Aggregation method
|
||||
owaWeights: Array<number>, // OWA weights for fusion
|
||||
reliabilityWeighting: boolean // Weight by reliability
|
||||
}
|
||||
```
|
||||
|
||||
### Intersection (AND) Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
intersection: Array<RuleConfig>, // Array of rules to AND together
|
||||
aggregator: string, // Aggregation method (defaults to 'min')
|
||||
owaWeights: Array<number> // OWA weights for fusion
|
||||
}
|
||||
```
|
||||
|
||||
### Exclusion (A AND NOT B) Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
exclusion: [RuleConfig, RuleConfig] // [base rule, exclusion rule]
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimization Guidelines
|
||||
|
||||
### Early Exit Support
|
||||
|
||||
Rules should support early termination when:
|
||||
- `options.fastPath` is true
|
||||
- `options.minAllowPossibility` threshold is met
|
||||
- `options.maxDenyPossibility` threshold is met
|
||||
|
||||
### Batch Processing
|
||||
|
||||
Rules that can benefit from batch processing should:
|
||||
- Override `canBatchProcess()` to return `true`
|
||||
- Implement `_batchEvaluateRule()` method
|
||||
- Group similar operations for efficiency
|
||||
- Use indexed lookups instead of linear scans
|
||||
|
||||
### Caching
|
||||
|
||||
Rules should leverage:
|
||||
- Arbiter's built-in caching mechanisms
|
||||
- Batch caches for intermediate results
|
||||
- Embedding caches for similarity calculations
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
All rules must include:
|
||||
- Unit tests for individual evaluation
|
||||
- Batch evaluation tests (if supported)
|
||||
- Performance benchmarks
|
||||
- Error handling tests
|
||||
- Edge case coverage
|
||||
|
||||
## Migration Guide
|
||||
|
||||
To migrate existing rules to the new API:
|
||||
|
||||
1. **Extend BaseRule**: Change `export class MyRule` to `export class MyRule extends BaseRule`
|
||||
2. **Rename evaluate**: Rename `evaluate()` to `_evaluateRule()`
|
||||
3. **Update constructor**: Call `super(arbiter)` first
|
||||
4. **Handle options**: Use normalized options parameter
|
||||
5. **Update tests**: Test against new interface
|
||||
|
||||
## Example Implementation
|
||||
|
||||
```javascript
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
|
||||
export class ExampleRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
// Rule-specific initialization
|
||||
}
|
||||
|
||||
canBatchProcess() {
|
||||
return true; // This rule supports batching
|
||||
}
|
||||
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
// Implement rule-specific logic
|
||||
const result = {
|
||||
possibility_allow: 0.8,
|
||||
possibility_deny: 0.1,
|
||||
reliability: 0.9,
|
||||
meta_allow: {
|
||||
ruleType: 'example',
|
||||
reason: 'example_evaluation',
|
||||
rule
|
||||
},
|
||||
meta_deny: null,
|
||||
reason: 'example_result'
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
_batchEvaluateRule(queries, batchOptions) {
|
||||
// Implement efficient batch processing
|
||||
return queries.map(query =>
|
||||
this._evaluateRule(
|
||||
query.userId,
|
||||
query.userKey,
|
||||
query.objectId,
|
||||
query.objectKey,
|
||||
query.rule,
|
||||
query.visited,
|
||||
query.currentRelation,
|
||||
query.options
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This standardized API ensures all rules work consistently while allowing for rule-specific optimizations and features.
|
||||
@@ -0,0 +1,686 @@
|
||||
import { OWAFusion } from '../../utils/OWAFusion.js';
|
||||
import { BilatticeOrderings } from '../../qualitative/BilatticeOrderings.js';
|
||||
import { QualitativeCapacity } from '../../qualitative/QualitativeCapacity.js';
|
||||
import { QualitativeScale } from '../../qualitative/QualitativeScale.js';
|
||||
import { UnifiedEvidenceFusion } from '../../qualitative/UnifiedEvidenceFusion.js';
|
||||
|
||||
/**
|
||||
* BaseRule - Standard interface for all authorization rules
|
||||
*
|
||||
* This abstract base class defines the standard API that all rule implementations
|
||||
* must follow to ensure consistency, performance, and maintainability.
|
||||
*
|
||||
* STANDARDIZED RESULT STRUCTURE:
|
||||
* All rules return both authorization results AND collected values:
|
||||
* {
|
||||
* // AUTHORIZATION (core purpose)
|
||||
* possibility_allow: number, // 0-1, aggregated possibility across paths
|
||||
* possibility_deny: number, // 0-1, denial confidence
|
||||
* reliability: number, // 0-1, confidence in evaluation
|
||||
*
|
||||
* // VALUE COLLECTION (optional metadata)
|
||||
* collectedValues: [ // Array of individual values with paths
|
||||
* {
|
||||
* value: number, // The actual value
|
||||
* possibility: number, // Individual confidence in this value
|
||||
* path: string[], // Full traversal path to this value
|
||||
* source: { // Where value came from
|
||||
* entityKey: string,
|
||||
* relation: string,
|
||||
* step: number
|
||||
* },
|
||||
* metadata: { // Rich metadata
|
||||
* timestamp: number,
|
||||
* reliability: number,
|
||||
* decay: object
|
||||
* }
|
||||
* }
|
||||
* ],
|
||||
*
|
||||
* // EXISTING FIELDS
|
||||
* meta_allow: object,
|
||||
* meta_deny: object,
|
||||
* reason: string
|
||||
* }
|
||||
*/
|
||||
export class BaseRule {
|
||||
constructor(arbiter) {
|
||||
if (new.target === BaseRule) {
|
||||
throw new Error('BaseRule is abstract and cannot be instantiated directly');
|
||||
}
|
||||
|
||||
this.arbiter = arbiter;
|
||||
this.ruleType = this.constructor.name.replace('Rule', '').toLowerCase();
|
||||
|
||||
// Performance tracking
|
||||
this.evaluationCount = 0;
|
||||
this.totalEvaluationTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard rule evaluation interface
|
||||
*
|
||||
* @param {number} userId - Internal user node ID
|
||||
* @param {string} userKey - User key (e.g., "user:alice")
|
||||
* @param {number} objectId - Internal object node ID
|
||||
* @param {string} objectKey - Object key (e.g., "doc:secret")
|
||||
* @param {Object} rule - Rule configuration object
|
||||
* @param {Set} visited - Set of visited nodes for cycle detection
|
||||
* @param {string} currentRelation - Current relation being evaluated
|
||||
* @param {Object} options - Evaluation options
|
||||
* @returns {RuleEvaluationResult} Standardized result object
|
||||
*/
|
||||
evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
const startTime = Date.now();
|
||||
this.evaluationCount++;
|
||||
|
||||
try {
|
||||
// Validate inputs
|
||||
const validation = this._validateInputs(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options);
|
||||
if (!validation.valid) {
|
||||
return this._createErrorResult(validation.reason, validation.details);
|
||||
}
|
||||
|
||||
// Normalize options
|
||||
const normalizedOptions = this._normalizeOptions(options, rule);
|
||||
|
||||
// Check for early termination conditions
|
||||
const earlyExit = this._checkEarlyExit(normalizedOptions, rule);
|
||||
if (earlyExit) {
|
||||
return earlyExit;
|
||||
}
|
||||
|
||||
// Delegate to concrete implementation
|
||||
const result = this._evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, normalizedOptions);
|
||||
|
||||
// Post-process result
|
||||
const finalResult = this._postProcessResult(result, rule, normalizedOptions);
|
||||
|
||||
// Track performance
|
||||
this.totalEvaluationTime += Date.now() - startTime;
|
||||
|
||||
return finalResult;
|
||||
|
||||
} catch (error) {
|
||||
this.totalEvaluationTime += Date.now() - startTime;
|
||||
return this._createErrorResult('evaluation_error', { error: error.message, stack: error.stack });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics for this rule
|
||||
* @returns {Object} Performance metrics
|
||||
*/
|
||||
getPerformanceStats() {
|
||||
return {
|
||||
ruleType: this.ruleType,
|
||||
evaluationCount: this.evaluationCount,
|
||||
totalEvaluationTime: this.totalEvaluationTime,
|
||||
averageEvaluationTime: this.evaluationCount > 0 ? this.totalEvaluationTime / this.evaluationCount : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset performance counters
|
||||
*/
|
||||
resetPerformanceStats() {
|
||||
this.evaluationCount = 0;
|
||||
this.totalEvaluationTime = 0;
|
||||
}
|
||||
|
||||
// ========== HELPER METHODS FOR STANDARDIZED RESULTS ==========
|
||||
|
||||
/**
|
||||
* Create standardized rule result with collected values
|
||||
* @protected
|
||||
*/
|
||||
_createStandardResult(authResult, collectedValues = []) {
|
||||
const result = {
|
||||
// AUTHORIZATION
|
||||
possibility: authResult.possibility || 0,
|
||||
reliability: authResult.reliability !== undefined ? authResult.reliability : 1.0,
|
||||
|
||||
// BINARY MODE FIELDS
|
||||
possibility_allow: authResult.possibility_allow !== undefined ? authResult.possibility_allow : (authResult.possibility || 0),
|
||||
possibility_deny: authResult.possibility_deny !== undefined ? authResult.possibility_deny : 0,
|
||||
|
||||
// VALUE COLLECTION
|
||||
collectedValues: collectedValues,
|
||||
|
||||
// EXISTING FIELDS
|
||||
meta: authResult.meta || null,
|
||||
meta_allow: authResult.meta_allow,
|
||||
meta_deny: authResult.meta_deny,
|
||||
remediation: authResult.remediation,
|
||||
reason: authResult.reason
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a collected value entry with full metadata
|
||||
* @protected
|
||||
*/
|
||||
_createCollectedValue(value, possibility, path, source, metadata = {}) {
|
||||
return {
|
||||
value: value,
|
||||
possibility: possibility ?? 1.0,
|
||||
path: Array.isArray(path) ? [...path] : [path],
|
||||
source: {
|
||||
...source,
|
||||
entityKey: source.entityKey,
|
||||
relation: source.relation,
|
||||
step: source.step !== undefined ? source.step : 0
|
||||
},
|
||||
metadata: {
|
||||
timestamp: metadata.timestamp || Date.now(),
|
||||
reliability: metadata.reliability !== undefined ? metadata.reliability : 1.0,
|
||||
decay: metadata.decay || null,
|
||||
...metadata
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick reachability failure check for early termination
|
||||
* @protected
|
||||
* @param {string} sourceKey - Source entity key
|
||||
* @param {string} targetKey - Target entity key
|
||||
* @param {Object} options - Options (direction: 'forward' | 'backward')
|
||||
* @returns {boolean|null} - true if reachable, false if not reachable, null if no reachability checker
|
||||
*/
|
||||
_quickReachabilityCheck(sourceKey, targetKey, options = {}) {
|
||||
if (!this.arbiter.reachabilityChecker) {
|
||||
return null; // No reachability checker available
|
||||
}
|
||||
|
||||
try {
|
||||
// Get node IDs for PLTC query
|
||||
const sourceId = this.arbiter.resolveNodeId(sourceKey, options);
|
||||
const targetId = this.arbiter.resolveNodeId(targetKey, options);
|
||||
|
||||
if (sourceId === undefined || targetId === undefined) {
|
||||
return false; // Nodes don't exist
|
||||
}
|
||||
|
||||
// Use reachability checker with direction option for PLTC
|
||||
return this.arbiter.reachabilityChecker.isReachable(sourceId, targetId, options);
|
||||
} catch (error) {
|
||||
console.warn(`Reachability check failed for ${sourceKey} -> ${targetKey}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick reachability failure check with early termination
|
||||
* Returns a standardized result for unreachable cases
|
||||
* @protected
|
||||
* @param {string} sourceKey - Source entity key
|
||||
* @param {string} targetKey - Target entity key
|
||||
* @param {string} reason - Reason for the check
|
||||
* @returns {Object|null} - Standardized result if unreachable, null if reachable or no checker
|
||||
*/
|
||||
_quickReachabilityFailure(sourceKey, targetKey, reason = 'Not reachable via reachability index', options = {}) {
|
||||
const isReachable = this._quickReachabilityCheck(sourceKey, targetKey, options);
|
||||
|
||||
if (isReachable === false) {
|
||||
// Quick failure - not reachable
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
possibility_allow: 0,
|
||||
possibility_deny: 1.0,
|
||||
reliability: 1.0,
|
||||
reason: reason,
|
||||
meta: {
|
||||
method: 'reachability_quick_fail',
|
||||
sourceKey,
|
||||
targetKey,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return null; // Reachable or no checker available
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch reachability check for multiple source-target pairs
|
||||
* @protected
|
||||
* @param {Array} pairs - Array of {sourceKey, targetKey} objects
|
||||
* @returns {Object} - Map of reachability results
|
||||
*/
|
||||
_batchReachabilityCheck(pairs) {
|
||||
if (!this.arbiter.reachabilityChecker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = {};
|
||||
|
||||
for (const {sourceKey, targetKey} of pairs) {
|
||||
try {
|
||||
results[`${sourceKey}->${targetKey}`] = this.arbiter.isReachable(sourceKey, targetKey);
|
||||
} catch (error) {
|
||||
console.warn(`Batch reachability check failed for ${sourceKey} -> ${targetKey}:`, error);
|
||||
results[`${sourceKey}->${targetKey}`] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reachable nodes for a source with quick failure check
|
||||
* @protected
|
||||
* @param {string} sourceKey - Source entity key
|
||||
* @param {number} maxResults - Maximum number of results
|
||||
* @returns {Array|null} - Array of reachable node keys or null if no checker
|
||||
*/
|
||||
_getReachableNodes(sourceKey, maxResults = 1000) {
|
||||
if (!this.arbiter.reachabilityChecker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.arbiter.getReachableNodes(sourceKey, maxResults);
|
||||
} catch (error) {
|
||||
console.warn(`Get reachable nodes failed for ${sourceKey}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nodes that can reach a target with quick failure check
|
||||
* @protected
|
||||
* @param {string} targetKey - Target entity key
|
||||
* @param {number} maxResults - Maximum number of results
|
||||
* @returns {Array|null} - Array of reaching node keys or null if no checker
|
||||
*/
|
||||
_getReachingNodes(targetKey, maxResults = 1000) {
|
||||
if (!this.arbiter.reachabilityChecker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.arbiter.getReachingNodes(targetKey, maxResults);
|
||||
} catch (error) {
|
||||
console.warn(`Get reaching nodes failed for ${targetKey}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate collected values for consumption by other rules using OWAFusion
|
||||
* @protected
|
||||
*/
|
||||
_aggregateCollectedValues(collectedValues, method = 'sum', options = {}) {
|
||||
if (!collectedValues || collectedValues.length === 0) {
|
||||
return { value: null, hasValue: false };
|
||||
}
|
||||
|
||||
const values = collectedValues.map(cv => cv.value);
|
||||
const possibilities = collectedValues.map(cv => cv.possibility);
|
||||
const metas = collectedValues.map(cv => ({
|
||||
path: cv.path,
|
||||
source: cv.source,
|
||||
reliability: cv.metadata?.reliability || 1.0,
|
||||
timestamp: cv.metadata?.timestamp,
|
||||
originalValue: cv.value,
|
||||
originalPossibility: cv.possibility
|
||||
}));
|
||||
|
||||
// Use OWAFusion for aggregation
|
||||
const valueResult = OWAFusion.fuseWithMeta(values, metas, null, method);
|
||||
const possibilityResult = OWAFusion.fuseWithMeta(possibilities, metas, null, method);
|
||||
|
||||
return {
|
||||
value: valueResult.value,
|
||||
possibility: possibilityResult.value,
|
||||
hasValue: true,
|
||||
aggregationMeta: {
|
||||
method: method,
|
||||
sourceCount: collectedValues.length,
|
||||
sourcePaths: collectedValues.map(cv => cv.path),
|
||||
selectedSource: valueResult.meta,
|
||||
valueContribution: valueResult.value,
|
||||
possibilityContribution: possibilityResult.value,
|
||||
allValues: values,
|
||||
allPossibilities: possibilities
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bilattice-enhanced evidence combination for rigorous epistemic reasoning
|
||||
*
|
||||
* This method provides sophisticated evidence fusion using bilattice orderings
|
||||
* while maintaining compatibility with existing possibilistic infrastructure.
|
||||
*
|
||||
* @param {Array} collectedValues - Array of collected values with metadata
|
||||
* @param {Object} options - Combination options
|
||||
* @param {string} options.method - Fusion method ('max', 'min', 'majority', etc.)
|
||||
* @param {boolean} options.useBilattice - Whether to use bilattice reasoning (default: false)
|
||||
* @param {QualitativeCapacity} options.capacity - Capacity for bilattice analysis
|
||||
* @param {QualitativeScale} options.scale - Qualitative scale for bilattice operations
|
||||
* @param {string} options.epistemicMode - 'information', 'truth', or 'hybrid' (default: 'hybrid')
|
||||
* @returns {Object} Enhanced fusion result with epistemic analysis
|
||||
* @protected
|
||||
*/
|
||||
_combineEvidenceWithBilattice(collectedValues, options = {}) {
|
||||
const {
|
||||
method = 'max',
|
||||
useBilattice = false,
|
||||
capacity = null,
|
||||
scale = null,
|
||||
epistemicMode = 'hybrid',
|
||||
reconciliationMethod = 'bilattice',
|
||||
capacityType = 'simple_support',
|
||||
mode = 'qualitative'
|
||||
} = options;
|
||||
|
||||
if (!useBilattice) {
|
||||
return this._aggregateCollectedValues(collectedValues, method, options);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the unified evidence fusion system
|
||||
const fusionResult = UnifiedEvidenceFusion.fuse(collectedValues, {
|
||||
mode,
|
||||
aggregationMethod: method,
|
||||
reconciliationMethod: useBilattice ? reconciliationMethod : 'none',
|
||||
epistemicMode,
|
||||
capacityType,
|
||||
scale: scale || QualitativeScale.fivePoint(),
|
||||
useReconciliation: useBilattice,
|
||||
reliabilityWeighting: options.reliabilityWeighting || false
|
||||
});
|
||||
|
||||
return {
|
||||
value: fusionResult.value,
|
||||
possibility: fusionResult.possibility,
|
||||
hasValue: fusionResult.hasValue,
|
||||
aggregationMeta: {
|
||||
method: fusionResult.fusionMethod,
|
||||
aggregationMethod: fusionResult.aggregationMethod,
|
||||
reconciliationMethod: fusionResult.reconciliationMethod,
|
||||
selectedSource: {
|
||||
type: 'unified_evidence_fusion',
|
||||
mode,
|
||||
epistemicMode,
|
||||
capacityType
|
||||
}
|
||||
},
|
||||
epistemicAnalysis: fusionResult.epistemicAnalysis
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Unified evidence fusion failed, falling back to standard aggregation:', error);
|
||||
return this._aggregateCollectedValues(collectedValues, method, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a qualitative capacity from collected values for bilattice analysis
|
||||
*
|
||||
* @param {Array} collectedValues - Array of collected values
|
||||
* @param {QualitativeScale} scale - Qualitative scale to use
|
||||
* @param {string} capacityType - Type of capacity ('simple_support', 'possibility', 'necessity')
|
||||
* @returns {QualitativeCapacity} Capacity for bilattice analysis
|
||||
* @protected
|
||||
*/
|
||||
_createCapacityFromValues(collectedValues, scale, capacityType = 'simple_support') {
|
||||
if (!collectedValues || collectedValues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create state space from unique values
|
||||
const uniqueValues = [...new Set(collectedValues.map(cv => cv.value))];
|
||||
const stateSpace = uniqueValues.map((_, index) => `evidence_${index}`);
|
||||
|
||||
switch (capacityType) {
|
||||
case 'simple_support':
|
||||
// Create simple support capacity for each piece of evidence
|
||||
const qmt = new Map();
|
||||
collectedValues.forEach((cv, index) => {
|
||||
const evidenceSet = new Set([`evidence_${index}`]);
|
||||
qmt.set(evidenceSet, cv.possibility);
|
||||
});
|
||||
return new QualitativeCapacity(stateSpace, scale, qmt);
|
||||
|
||||
case 'possibility':
|
||||
// Create possibility measure (all focal sets are singletons)
|
||||
const possibilityQmt = new Map();
|
||||
collectedValues.forEach((cv, index) => {
|
||||
const singletonSet = new Set([`evidence_${index}`]);
|
||||
possibilityQmt.set(singletonSet, cv.possibility);
|
||||
});
|
||||
return new QualitativeCapacity(stateSpace, scale, possibilityQmt);
|
||||
|
||||
case 'necessity':
|
||||
// Create necessity measure (nested focal sets)
|
||||
const necessityQmt = new Map();
|
||||
const sortedValues = collectedValues
|
||||
.map((cv, index) => ({ value: cv.value, possibility: cv.possibility, index }))
|
||||
.sort((a, b) => b.possibility - a.possibility);
|
||||
|
||||
sortedValues.forEach((item, rank) => {
|
||||
const nestedSet = new Set(sortedValues.slice(0, rank + 1).map(sv => `evidence_${sv.index}`));
|
||||
necessityQmt.set(nestedSet, item.possibility);
|
||||
});
|
||||
return new QualitativeCapacity(stateSpace, scale, necessityQmt);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown capacity type: ${capacityType}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== PROTECTED METHODS (to be implemented by subclasses) ==========
|
||||
|
||||
/**
|
||||
* Concrete rule evaluation implementation
|
||||
* @protected
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
throw new Error(`${this.constructor.name} must implement _evaluateRule()`);
|
||||
}
|
||||
|
||||
// ========== PRIVATE HELPER METHODS ==========
|
||||
|
||||
/**
|
||||
* Validate input parameters
|
||||
* @private
|
||||
*/
|
||||
_validateInputs(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
// Check for required parameters
|
||||
if (userId === undefined || userId === null) {
|
||||
return { valid: false, reason: 'missing_user_id', details: { userKey } };
|
||||
}
|
||||
|
||||
if (objectId === undefined || objectId === null) {
|
||||
return { valid: false, reason: 'missing_object_id', details: { objectKey } };
|
||||
}
|
||||
|
||||
if (!userKey || typeof userKey !== 'string') {
|
||||
return { valid: false, reason: 'invalid_user_key', details: { userKey } };
|
||||
}
|
||||
|
||||
if (!objectKey || typeof objectKey !== 'string') {
|
||||
return { valid: false, reason: 'invalid_object_key', details: { objectKey } };
|
||||
}
|
||||
|
||||
if (!rule || typeof rule !== 'object') {
|
||||
return { valid: false, reason: 'invalid_rule', details: { rule } };
|
||||
}
|
||||
|
||||
if (!visited || typeof visited.has !== 'function') {
|
||||
return { valid: false, reason: 'invalid_visited_set', details: { visited } };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and validate options
|
||||
* @private
|
||||
*/
|
||||
_normalizeOptions(options, rule) {
|
||||
const normalized = {
|
||||
// Performance options
|
||||
fastPath: options.fastPath || false,
|
||||
minPossibility: options.minPossibility || null,
|
||||
maxPossibility: options.maxPossibility || null,
|
||||
|
||||
// Inference options
|
||||
noInfer: options.noInfer || options.no_infer || rule.no_infer || false,
|
||||
allowInference: options.allowInference !== false && !options.noInfer && !options.no_infer && !rule.no_infer,
|
||||
|
||||
// Binary mode
|
||||
binary: options.binary || false,
|
||||
|
||||
// Evaluation tracking
|
||||
trackEvaluation: options.trackEvaluation !== false,
|
||||
|
||||
// Rule-specific options (pass through)
|
||||
...options
|
||||
};
|
||||
|
||||
// Validate threshold values
|
||||
if (normalized.minPossibility !== null) {
|
||||
normalized.minPossibility = Math.max(0, Math.min(1, normalized.minPossibility));
|
||||
}
|
||||
|
||||
if (normalized.maxPossibility !== null) {
|
||||
normalized.maxPossibility = Math.max(0, Math.min(1, normalized.maxPossibility));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for early exit conditions
|
||||
* @private
|
||||
*/
|
||||
_checkEarlyExit(options, rule) {
|
||||
// Cycle detection is handled at a higher level
|
||||
// Rule-specific early exits should be implemented in _evaluateRule
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process evaluation result
|
||||
* @private
|
||||
*/
|
||||
_postProcessResult(result, rule, options) {
|
||||
const includeMeta = options.includeMeta !== undefined ? options.includeMeta : true;
|
||||
const includeValues = options.collectValues !== undefined ? options.collectValues : true;
|
||||
const { meta, meta_allow, meta_deny, collectedValues, ...rest } = result;
|
||||
|
||||
// Ensure result has required fields with standardized structure
|
||||
const processed = {
|
||||
// AUTHORIZATION
|
||||
possibility: result.possibility || 0,
|
||||
reliability: result.reliability !== undefined ? result.reliability : 1.0,
|
||||
|
||||
// EXISTING FIELDS
|
||||
reason: result.reason,
|
||||
|
||||
// PRESERVE OTHER FIELDS (for backward compatibility)
|
||||
...rest
|
||||
};
|
||||
|
||||
if (includeValues) {
|
||||
processed.collectedValues = collectedValues || [];
|
||||
}
|
||||
|
||||
if (includeMeta) {
|
||||
processed.meta = meta || null;
|
||||
if (meta_allow !== undefined) {
|
||||
processed.meta_allow = meta_allow;
|
||||
}
|
||||
if (meta_deny !== undefined) {
|
||||
processed.meta_deny = meta_deny;
|
||||
}
|
||||
}
|
||||
|
||||
// Add evaluation metadata if tracking is enabled
|
||||
if (options.trackEvaluation && includeMeta && result.evaluation) {
|
||||
processed.evaluation = {
|
||||
ruleType: this.ruleType,
|
||||
evaluationTime: Date.now() - (result.evaluation.evaluationStarted || Date.now()),
|
||||
...result.evaluation
|
||||
};
|
||||
}
|
||||
|
||||
// Apply early exit metadata if applicable
|
||||
if (options.fastPath && includeMeta && this._shouldMarkEarlyExit(processed, options)) {
|
||||
if (processed.meta) {
|
||||
processed.meta.earlyExit = true;
|
||||
processed.meta.earlyExitReason = this._getEarlyExitReason(processed, options);
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standardized error result
|
||||
* @private
|
||||
*/
|
||||
_createErrorResult(reason, details = {}) {
|
||||
return {
|
||||
possibility_allow: 0,
|
||||
possibility_deny: 0,
|
||||
reliability: 1.0,
|
||||
meta_allow: null,
|
||||
meta_deny: null,
|
||||
reason,
|
||||
error: true,
|
||||
details
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if result should be marked as early exit
|
||||
* @private
|
||||
*/
|
||||
_shouldMarkEarlyExit(result, options) {
|
||||
if (options.minAllowPossibility !== null && result.possibility_allow >= options.minAllowPossibility) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.maxDenyPossibility !== null && result.possibility_deny >= options.maxDenyPossibility) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get early exit reason
|
||||
* @private
|
||||
*/
|
||||
_getEarlyExitReason(result, options) {
|
||||
if (options.minAllowPossibility !== null && result.possibility_allow >= options.minAllowPossibility) {
|
||||
return 'allow_threshold_met';
|
||||
}
|
||||
|
||||
if (options.maxDenyPossibility !== null && result.possibility_deny >= options.maxDenyPossibility) {
|
||||
return 'deny_threshold_met';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard result structure for rule evaluations
|
||||
* @typedef {Object} RuleEvaluationResult
|
||||
* @property {number} possibility_allow - Possibility of allowing access (0-1)
|
||||
* @property {number} possibility_deny - Possibility of denying access (0-1)
|
||||
* @property {number} reliability - Reliability of the evaluation (0-1)
|
||||
* @property {Object|null} meta_allow - Metadata for allow decision
|
||||
* @property {Object|null} meta_deny - Metadata for deny decision
|
||||
* @property {string} reason - Reason for the result (optional)
|
||||
* @property {boolean} error - Whether this is an error result (optional)
|
||||
* @property {Object} details - Additional details (optional)
|
||||
* @property {Object} evaluation - Evaluation tracking data (optional)
|
||||
*/
|
||||
@@ -0,0 +1,693 @@
|
||||
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);
|
||||
|
||||
// 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
|
||||
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval) {
|
||||
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);
|
||||
if (existing && existing.possibility >= nextPossibility) continue;
|
||||
|
||||
// Create extended path
|
||||
const extendedPath = {
|
||||
id: nextId,
|
||||
key: nextKey,
|
||||
possibility: nextPossibility,
|
||||
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;
|
||||
|
||||
if (targetPaths.length > 0) {
|
||||
// Use MAX across paths (disjunctive)
|
||||
finalPossibility = Math.max(...targetPaths.map(path => path.possibility));
|
||||
}
|
||||
|
||||
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,
|
||||
reliability: 1.0,
|
||||
...(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
|
||||
// Do not cache threshold-mode results (see isThresholdEval above)
|
||||
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval) {
|
||||
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);
|
||||
|
||||
if (entry && Date.now() - entry.timestamp < this.cacheTTL) {
|
||||
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
|
||||
this.chainResultCache.set(key, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
_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;
|
||||
}
|
||||
|
||||
const blurred = this.arbiter.valueManager.getBlurredValue(relation);
|
||||
|
||||
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'
|
||||
},
|
||||
{
|
||||
timestamp: relation.changed_last_at || relation.updated_last_at || Date.now(),
|
||||
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;
|
||||
}
|
||||
const blurred = this.arbiter.relationManager.valueManager.getBlurredValue(tempRelation);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { buildRemediationFromChallenges } from '../remediation.js';
|
||||
|
||||
export class ChallengeRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
}
|
||||
|
||||
evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
const { includeMeta = true } = options;
|
||||
const subjectKey = this._resolveSubjectKey(rule, userKey, objectKey, options);
|
||||
const partialContext = options.partialGraphContext || null;
|
||||
const challenge = rule.challenge || rule.name || rule.relation || currentRelation;
|
||||
const withinMs = this._resolveWithinMs(rule);
|
||||
|
||||
if (!partialContext || !subjectKey || !challenge) {
|
||||
const required = [this._buildRequirement(challenge, subjectKey, withinMs, 'missing_context')];
|
||||
const remediation = buildRemediationFromChallenges(required);
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
remediation,
|
||||
meta_allow: {
|
||||
ruleType: 'challenge',
|
||||
remediation
|
||||
},
|
||||
reason: 'challenge_missing_context'
|
||||
});
|
||||
}
|
||||
|
||||
const subjectId = this.arbiter.resolveNodeId(subjectKey, { partialGraphContext: partialContext });
|
||||
const now = Date.now();
|
||||
const proof = partialContext.getChallengeProof(challenge, subjectId, withinMs, now);
|
||||
|
||||
if (!proof) {
|
||||
const required = [this._buildRequirement(challenge, subjectKey, withinMs, 'missing')];
|
||||
const remediation = buildRemediationFromChallenges(required);
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
remediation,
|
||||
meta_allow: {
|
||||
ruleType: 'challenge',
|
||||
remediation
|
||||
},
|
||||
reason: 'challenge_missing'
|
||||
});
|
||||
}
|
||||
|
||||
return this._createStandardResult({
|
||||
possibility: 1,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
ruleType: 'challenge',
|
||||
reason: 'challenge_satisfied',
|
||||
challenge,
|
||||
subject: subjectKey,
|
||||
issuedAt: proof.issuedAt,
|
||||
expiresAt: proof.expiresAt || null
|
||||
}
|
||||
}),
|
||||
reason: 'challenge_satisfied'
|
||||
});
|
||||
}
|
||||
|
||||
_resolveSubjectKey(rule, userKey, objectKey, options) {
|
||||
if (rule.subjectKey) return rule.subjectKey;
|
||||
const subject = rule.subject || 'user';
|
||||
if (subject === 'object') return objectKey;
|
||||
if (subject === 'session') return options.sessionKey || userKey;
|
||||
return userKey;
|
||||
}
|
||||
|
||||
_resolveWithinMs(rule) {
|
||||
if (rule.withinMs !== undefined && rule.withinMs !== null) return rule.withinMs;
|
||||
if (rule.withinSeconds !== undefined && rule.withinSeconds !== null) return rule.withinSeconds * 1000;
|
||||
if (rule.withinMinutes !== undefined && rule.withinMinutes !== null) return rule.withinMinutes * 60 * 1000;
|
||||
if (rule.withinHours !== undefined && rule.withinHours !== null) return rule.withinHours * 60 * 60 * 1000;
|
||||
return null;
|
||||
}
|
||||
|
||||
_buildRequirement(challenge, subjectKey, withinMs, status) {
|
||||
return {
|
||||
name: challenge,
|
||||
subject: subjectKey,
|
||||
withinMs: withinMs || null,
|
||||
status
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { extractRemediation } from '../remediation.js';
|
||||
|
||||
/**
|
||||
* ComputedRule - Recursively evaluates a relation through the authorization checker
|
||||
*
|
||||
* This rule delegates to the main authorization checker to recursively evaluate
|
||||
* a computed relation. It's essentially a way to invoke the full authorization
|
||||
* logic as part of a rule evaluation.
|
||||
*
|
||||
* Configuration:
|
||||
* {
|
||||
* type: 'computed',
|
||||
* relation: string // Relation to recursively evaluate
|
||||
* }
|
||||
*/
|
||||
export class ComputedRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate computed relation by delegating to authorization checker
|
||||
* @protected
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
const computedRelation = rule.relation;
|
||||
|
||||
// Track evaluation details if requested
|
||||
const evaluation = options.trackEvaluation ? {
|
||||
type: 'computed',
|
||||
userKey,
|
||||
objectKey,
|
||||
computedRelation,
|
||||
evaluationStarted: Date.now()
|
||||
} : null;
|
||||
|
||||
// Delegate to authorization checker with proper context (including valueContext)
|
||||
const res = this.arbiter.authChecker.check(userKey, computedRelation, objectKey, {
|
||||
...options,
|
||||
_visited: visited,
|
||||
_currentRelation: currentRelation
|
||||
});
|
||||
|
||||
const remediation = extractRemediation(res);
|
||||
|
||||
if (evaluation) {
|
||||
evaluation.evaluationCompleted = Date.now();
|
||||
evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted;
|
||||
evaluation.delegatedResult = res;
|
||||
}
|
||||
|
||||
// Convert the check result to standardized rule evaluation format
|
||||
const result = {
|
||||
possibility: res.possibility || 0,
|
||||
reliability: res.reliability !== undefined ? res.reliability : 1.0,
|
||||
// Propagate collected values from delegated result
|
||||
collectedValues: res.collectedValues || [],
|
||||
...(options.includeMeta && {
|
||||
meta: {
|
||||
...(res.meta || {}),
|
||||
...(remediation ? { remediation } : {}),
|
||||
ruleType: 'computed',
|
||||
computedRelation,
|
||||
delegated: true
|
||||
}
|
||||
}),
|
||||
...(remediation ? { remediation } : {}),
|
||||
reason: res.reason || 'computed_delegation'
|
||||
};
|
||||
|
||||
if (evaluation) {
|
||||
result.evaluation = evaluation;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { Arbiter } from '../../core/Arbiter.js';
|
||||
|
||||
/**
|
||||
* DirectRule - Checks for direct relationships between user and object
|
||||
*
|
||||
* This rule evaluates direct relations in the graph, optionally in reverse direction.
|
||||
* It supports efficient batch processing and early exit optimizations.
|
||||
*
|
||||
* Returns standardized results with raw possibility values:
|
||||
* - possibility_allow: The strength/possibility of the relation (0.0 to 1.0)
|
||||
* - possibility_deny: Always 0 (DirectRule only reports relation strength, not polarity)
|
||||
* - Collected Values: Values from relations with full path metadata
|
||||
*
|
||||
* Note: This rule returns raw relation strength. The logical context (defeater, strict,
|
||||
* defeasible, etc.) determines how this strength is interpreted as positive or negative evidence.
|
||||
*
|
||||
* Configuration:
|
||||
* {
|
||||
* type: 'direct',
|
||||
* relation: string, // Optional: relation to check (uses currentRelation if not specified)
|
||||
* reverse: boolean, // Optional: check in reverse direction (default: false)
|
||||
* collectValues: boolean // Optional: collect values from relations (default: true if relation has values)
|
||||
* }
|
||||
*/
|
||||
export class DirectRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate method returns raw relation strength
|
||||
*/
|
||||
evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
const { fastPath = false, minPossibility = null, collectValues: collectValuesOption, includeMeta = true } = options;
|
||||
|
||||
const relName = rule.relation || rule.rel || rule.label || rule.name || currentRelation;
|
||||
const reverse = rule.reverse;
|
||||
const collectValues = collectValuesOption !== undefined ? collectValuesOption : rule.collectValues !== false;
|
||||
|
||||
let directRel;
|
||||
if (reverse) {
|
||||
directRel = this.arbiter.relationManager.getDirectRelation(objectId, relName, userId, options);
|
||||
} else {
|
||||
directRel = this.arbiter.relationManager.getDirectRelation(userId, relName, objectId, options);
|
||||
}
|
||||
|
||||
if (!directRel) {
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
ruleType: 'direct',
|
||||
reason: 'no_relation'
|
||||
}
|
||||
})
|
||||
}, []);
|
||||
}
|
||||
|
||||
const relationStrength = directRel.possibility;
|
||||
const _source = directRel.source || 'persistent';
|
||||
const _allowMeta = {
|
||||
ruleType: 'direct',
|
||||
reason: 'direct',
|
||||
source: _source,
|
||||
layer_name: directRel.layer_name || null,
|
||||
source_class: directRel.source_class || null,
|
||||
reducer_applied: directRel.reducer_applied || null
|
||||
};
|
||||
|
||||
const authResult = {
|
||||
possibility: relationStrength,
|
||||
possibility_allow: relationStrength, // For binary mode
|
||||
possibility_deny: 0, // DirectRule doesn't deny
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
ruleType: 'direct',
|
||||
reason: 'relation_exists',
|
||||
rule,
|
||||
relation: relName,
|
||||
reverse: reverse || false,
|
||||
strength: relationStrength,
|
||||
source: _source,
|
||||
allow: _allowMeta
|
||||
},
|
||||
meta_allow: _allowMeta
|
||||
}),
|
||||
reason: 'exists'
|
||||
};
|
||||
|
||||
// Collect values if relation has them and collection is enabled
|
||||
let collectedValues = [];
|
||||
if (collectValues && directRel.value !== undefined) {
|
||||
const sourceEntity = reverse ? objectKey : userKey;
|
||||
const targetEntity = reverse ? userKey : objectKey;
|
||||
const path = [sourceEntity, targetEntity];
|
||||
|
||||
collectedValues.push(this._createCollectedValue(
|
||||
directRel.value,
|
||||
directRel.possibility,
|
||||
path,
|
||||
{
|
||||
entityKey: sourceEntity,
|
||||
relation: relName,
|
||||
step: 0
|
||||
},
|
||||
{
|
||||
timestamp: directRel.changed_last_at || directRel.updated_last_at || Date.now(),
|
||||
reliability: 1.0,
|
||||
source: directRel.source || 'persistent'
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
// Apply early exit logic if thresholds are enabled
|
||||
if (fastPath) {
|
||||
// Early exit based on relation strength threshold
|
||||
if (minPossibility !== null && authResult.possibility >= minPossibility) {
|
||||
|
||||
if (authResult.meta) {
|
||||
authResult.meta.earlyExit = true;
|
||||
authResult.meta.earlyExitReason = 'strength_threshold_met';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this._createStandardResult(authResult, collectedValues);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { Arbiter } from '../../core/Arbiter.js';
|
||||
import { OWAFusion } from '../../utils/OWAFusion.js';
|
||||
|
||||
/**
|
||||
* ParentRule - Evaluates access through parent-child relationships (hierarchical inheritance)
|
||||
*
|
||||
* This rule implements hierarchical access where access is granted if:
|
||||
* 1. Object has a parent relationship to another entity (e.g., file -> folder)
|
||||
* 2. A direct edge exists from the user to that parent entity for the target relation
|
||||
*
|
||||
* Access is checked via direct edge lookup on the parent (not recursive authorization).
|
||||
* This prevents infinite recursion when parent entities also use ParentRule definitions.
|
||||
* Full transitive parent evaluation requires multi-hop configurations (MultiHopRule).
|
||||
*
|
||||
* Returns single possibility value representing the likelihood that user has access
|
||||
* through the parent hierarchy. Uses minPossibility as a cutoff threshold.
|
||||
*
|
||||
* Configuration:
|
||||
* {
|
||||
* type: 'parent',
|
||||
* parentRelation: string, // Relation defining parent-child (default: 'parent')
|
||||
* relation: string, // Relation to check on parent (uses currentRelation if not specified)
|
||||
* reverse: boolean, // Check in reverse direction (default: false)
|
||||
* aggregator?: string, // OWA aggregation method for multiple parents
|
||||
* owaWeights?: Array<number>, // Custom OWA weights for fusion
|
||||
* reliabilityWeighting?: boolean // Weight by reliability
|
||||
* }
|
||||
*/
|
||||
export class ParentRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate parent relationship with reachability optimization
|
||||
* @protected
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
const { minPossibility = 0.0, includeMeta = true, trackEvaluation = false } = options;
|
||||
const includeOwaTrace = trackEvaluation && includeMeta;
|
||||
const owaTraceOptions = includeOwaTrace ? { includeTrace: true } : null;
|
||||
|
||||
const reverse = rule.reverse || false;
|
||||
const parentRelName = rule.parentRelation || 'parent';
|
||||
|
||||
// Get parent relationships
|
||||
let parentRels;
|
||||
if (reverse) {
|
||||
parentRels = this.arbiter.relationManager.getRelationsFromSrc(objectId, parentRelName, options);
|
||||
} else {
|
||||
parentRels = this.arbiter.relationManager.getRelationsToDst(objectId, parentRelName, options);
|
||||
}
|
||||
|
||||
// Filter parent relationships by reachability
|
||||
const reachableParents = [];
|
||||
for (const rel of parentRels) {
|
||||
const parentId = reverse ? rel.dst : rel.src;
|
||||
const parentKey = this.arbiter.resolveKey(parentId, options);
|
||||
|
||||
if (!parentKey) continue;
|
||||
|
||||
// Quick reachability check for parent
|
||||
const isReachable = this._quickReachabilityCheck(userKey, parentKey);
|
||||
if (isReachable !== false) { // Include reachable or unknown
|
||||
reachableParents.push(rel);
|
||||
}
|
||||
}
|
||||
|
||||
// Use reachable parents for evaluation
|
||||
parentRels = reachableParents;
|
||||
|
||||
|
||||
let possibilities = [], metas = [], reasons = [];
|
||||
|
||||
// Track if there were any direct parent relationships (regardless of threshold)
|
||||
let anyDirectParent = parentRels.length > 0;
|
||||
|
||||
// Check for circular dependencies first
|
||||
for (const rel of parentRels) {
|
||||
const parentId = reverse ? rel.dst : rel.src;
|
||||
const parentKey = this.arbiter.resolveKey(parentId, options);
|
||||
|
||||
if (!parentKey) continue;
|
||||
|
||||
// Check if this would create a cycle: if parentKey is the same as userKey
|
||||
if (parentKey === userKey) {
|
||||
return {
|
||||
possibility: 0,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
parentRule: {
|
||||
type: 'cycle_detected',
|
||||
parentRelation: parentRelName,
|
||||
cyclePath: [userKey, objectKey, userKey]
|
||||
}
|
||||
}
|
||||
}),
|
||||
reason: 'cycle'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Process direct parent relationships
|
||||
const targetRelation = rule.relation || currentRelation;
|
||||
for (const rel of parentRels) {
|
||||
const parentId = reverse ? rel.dst : rel.src;
|
||||
const parentKey = this.arbiter.resolveKey(parentId, options);
|
||||
|
||||
if (!parentKey) continue;
|
||||
|
||||
if (parentKey === userKey) continue;
|
||||
|
||||
// Check direct edges from user to parent entity, bypassing the relation config
|
||||
// (which would re-enter the parent rule). This implements the contract:
|
||||
// "User has access to that parent entity" via direct relation check.
|
||||
const parentResult = this.arbiter.indices.getDirectRelation(userId, targetRelation, parentId);
|
||||
const directRelFromPartial = options.partialGraphContext
|
||||
? options.partialGraphContext.getDirectRelation(userId, targetRelation, parentId)
|
||||
: null;
|
||||
const directRel = parentResult || directRelFromPartial;
|
||||
const parentPossibility = directRel ? (directRel.possibility ?? 1.0) : 0;
|
||||
|
||||
// Apply cutoff threshold
|
||||
const finalPossibility = parentPossibility >= minPossibility ? parentPossibility : 0;
|
||||
|
||||
if (finalPossibility > 0) {
|
||||
possibilities.push(finalPossibility);
|
||||
metas.push(includeMeta ? {
|
||||
parentRule: {
|
||||
type: 'parent_access_checked',
|
||||
parentKey,
|
||||
parentRelation: parentRelName,
|
||||
targetRelation,
|
||||
parentAccessPossibility: parentPossibility
|
||||
}
|
||||
} : null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!possibilities.length) {
|
||||
// If there were any direct parents, but all were below threshold, cutoff was applied
|
||||
const cutoffApplied = anyDirectParent;
|
||||
return {
|
||||
possibility: 0,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
parentRule: {
|
||||
type: 'no_parent_relationship_found',
|
||||
parentRelation: parentRelName,
|
||||
directParentsSearched: parentRels.length,
|
||||
threshold: minPossibility,
|
||||
cutoffAppliedPostFusion: cutoffApplied
|
||||
}
|
||||
}
|
||||
}),
|
||||
reason: 'no_parent_relationship_path_above_threshold'
|
||||
};
|
||||
}
|
||||
|
||||
// Apply OWA fusion to combine possibilities of multiple parent relationships existing
|
||||
let result;
|
||||
const aggregator = rule.aggregator || 'max'; // Default to max: if any parent relationship exists strongly, that's enough
|
||||
|
||||
if (rule.aggregator || rule.owaWeights) {
|
||||
const weights = rule.owaWeights || OWAFusion.generateOWAWeights(possibilities.length, aggregator);
|
||||
result = OWAFusion.fuseWithMeta(possibilities, metas, weights, aggregator, true, owaTraceOptions);
|
||||
} else {
|
||||
result = OWAFusion.fuseWithMeta(possibilities, metas, null, aggregator, true, owaTraceOptions);
|
||||
}
|
||||
|
||||
// Final cutoff check after fusion
|
||||
const finalFusedPossibility = result.value >= minPossibility ? result.value : 0;
|
||||
|
||||
// Always set cutoffAppliedPostFusion in meta
|
||||
const cutoffApplied = result.value !== finalFusedPossibility;
|
||||
const parentRuleMeta = includeMeta ? {
|
||||
...(finalFusedPossibility > 0 ? result.meta?.parentRule : {}),
|
||||
finalOutcomeType: finalFusedPossibility > 0 ? 'parent_relationship_possible' : 'no_strong_parent_relationship',
|
||||
fusionMethod: aggregator,
|
||||
pathsConsidered: possibilities.length,
|
||||
cutoffAppliedPostFusion: cutoffApplied,
|
||||
threshold: minPossibility,
|
||||
...(includeOwaTrace && result.trace ? {
|
||||
owa: {
|
||||
level: null,
|
||||
aggregator,
|
||||
weights: result.trace.weights,
|
||||
sortedValues: result.trace.sortedValues,
|
||||
contributions: result.trace.contributions,
|
||||
selectedIndex: result.trace.selectedIndex
|
||||
}
|
||||
} : {})
|
||||
} : null;
|
||||
|
||||
return {
|
||||
possibility: finalFusedPossibility,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
...(finalFusedPossibility > 0 ? result.meta : {}),
|
||||
parentRule: parentRuleMeta
|
||||
}
|
||||
}),
|
||||
reason: finalFusedPossibility > 0 ? 'parent_relationship_path_found' : 'no_parent_relationship_path_above_threshold'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { Arbiter } from '../../core/Arbiter.js';
|
||||
import { OWAQualitativeFusion, getOWAQualitativeWeights } from '../../qualitative/OWAQualitativeFusion.js';
|
||||
import { QualitativeScale, DEFAULT_QUALITATIVE_SCALE } from '../../qualitative/QualitativeScale.js';
|
||||
import { BilatticeOrderings } from '../../qualitative/BilatticeOrderings.js';
|
||||
import { QualitativeCapacity } from '../../qualitative/QualitativeCapacity.js';
|
||||
|
||||
/**
|
||||
* QualitativeRelationalComparatorRule - Evaluates access by comparing qualitative values from relations,
|
||||
* treating values as qualitative intervals that "blur" over time based on decaying possibility.
|
||||
*
|
||||
* This implementation uses qualitative scales and possibility theory instead of numeric intervals.
|
||||
*
|
||||
* Configuration:
|
||||
* {
|
||||
* type: 'relational_comparator',
|
||||
* qualitative: true, // Flag to indicate qualitative mode
|
||||
* scaleName: string, // Name of the qualitative scale to use (e.g., 'five-point', 'ternary')
|
||||
* leftOperand: {
|
||||
* rule: {...}, // Any rule configuration
|
||||
* extractValue: true, // Extract value from relation (if false, rule's possibility is used as value)
|
||||
* valueRelation: string, // Optional: specific relation for value
|
||||
* aggregator: string, // 'max', 'min', 'majority', 'priority', 'optimistic', etc.
|
||||
* owaWeights: number[], // Optional: custom OWA weights for aggregation
|
||||
* decaySteps: number, // Number of steps to decay per period on the qualitative scale
|
||||
* decayPeriod: string, // 'MINUTE', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR' (default 'HOUR')
|
||||
* possibilityDecayDirection: string, // 'down' (towards 0), 'neutral' (towards 0.5), 'up' (towards 1), 'stable' (no decay)
|
||||
* valueBlurDirection: string, // 'neutral' (symmetric), 'down' (expands lower bound), 'up' (expands upper bound), 'stable' (minimal blur)
|
||||
* baseBlurSteps: number, // Number of steps to blur per possibility decay step
|
||||
* minOperandPossibility: number, // If operand's decayed possibility < this, considered no value
|
||||
* evaluateFrom: string // 'user', 'object', or 'auto' (for rule evaluation perspective)
|
||||
* },
|
||||
* rightOperand: {...}, // Same structure as leftOperand
|
||||
* comparator: string, // '>', '>=', '<', '<=', '==', '!='
|
||||
* marginSteps: number, // Number of steps to shift right operand on the scale before blurring
|
||||
* minRulePossibility: number, // Optional: if final rule possibility < this, considered 0
|
||||
* fallbackBehavior: string // 'allow' or 'deny' if values/operands are insufficient
|
||||
* }
|
||||
*/
|
||||
export class QualitativeRelationalComparatorRule extends BaseRule {
|
||||
constructor(arbiter, ruleEvaluator) {
|
||||
super(arbiter);
|
||||
this.ruleEvaluator = ruleEvaluator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate qualitative relational comparison
|
||||
* @protected
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
const {
|
||||
left: leftOperand,
|
||||
right: rightOperand,
|
||||
comparator,
|
||||
marginSteps = 0,
|
||||
fallbackBehavior = 'deny',
|
||||
minRulePossibility = 0,
|
||||
scaleName = 'five-point'
|
||||
} = rule;
|
||||
|
||||
try {
|
||||
// Get the qualitative scale
|
||||
const scale = this._getQualitativeScale(scaleName);
|
||||
|
||||
const ruleMetaBase = {
|
||||
ruleType: 'QualitativeRelationalComparatorRule',
|
||||
userKey,
|
||||
objectKey,
|
||||
comparator,
|
||||
scaleName,
|
||||
marginStepsApplied: rightOperand ? marginSteps : 0,
|
||||
fallbackBehavior,
|
||||
minRulePossibilityUsed: minRulePossibility,
|
||||
evaluationStarted: Date.now()
|
||||
};
|
||||
|
||||
let evaluationMeta = options.trackEvaluation ? {
|
||||
...ruleMetaBase,
|
||||
leftOperandDetails: {},
|
||||
rightOperandDetails: {}
|
||||
} : null;
|
||||
|
||||
// Evaluate left operand
|
||||
const leftOpResult = this._evaluateOperand(
|
||||
userId, userKey, objectId, objectKey,
|
||||
leftOperand, visited, currentRelation, options, 'left', 0, evaluationMeta, scale // No margin for left
|
||||
);
|
||||
if (evaluationMeta) evaluationMeta.leftOperandDetails = leftOpResult.meta || {};
|
||||
|
||||
// Evaluate right operand
|
||||
const rightOpResult = this._evaluateOperand(
|
||||
userId, userKey, objectId, objectKey,
|
||||
rightOperand, visited, currentRelation, options, 'right', marginSteps, evaluationMeta, scale // Apply margin for right
|
||||
);
|
||||
if (evaluationMeta) evaluationMeta.rightOperandDetails = rightOpResult.meta || {};
|
||||
|
||||
// Perform comparison of qualitative intervals
|
||||
let comparisonOutput = this._compareBlurredValues(
|
||||
leftOpResult, rightOpResult, comparator, fallbackBehavior, rule, options, ruleMetaBase, scale
|
||||
);
|
||||
|
||||
// Apply minimum rule possibility threshold
|
||||
if (scale.compare(comparisonOutput.possibility, minRulePossibility) < 0) {
|
||||
if (evaluationMeta && evaluationMeta.comparisonStep) {
|
||||
evaluationMeta.comparisonStep.finalPossibilityBeforeMinRule = comparisonOutput.possibility;
|
||||
}
|
||||
comparisonOutput.possibility = scale.bottom;
|
||||
comparisonOutput.reason = comparisonOutput.reason + '_belowMinRulePossibility';
|
||||
if (evaluationMeta && evaluationMeta.comparisonStep) {
|
||||
evaluationMeta.comparisonStep.adjustedToZeroByMinRule = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (evaluationMeta) {
|
||||
evaluationMeta.finalResult = {
|
||||
possibility: comparisonOutput.possibility,
|
||||
reliability: comparisonOutput.reliability,
|
||||
reason: comparisonOutput.reason
|
||||
};
|
||||
evaluationMeta.evaluationCompleted = Date.now();
|
||||
evaluationMeta.totalEvaluationTime = evaluationMeta.evaluationCompleted - evaluationMeta.evaluationStarted;
|
||||
}
|
||||
|
||||
return {
|
||||
possibility: comparisonOutput.possibility,
|
||||
reliability: comparisonOutput.reliability,
|
||||
reason: comparisonOutput.reason,
|
||||
meta: evaluationMeta
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('QualitativeRelationalComparatorRule evaluation error:', error);
|
||||
return {
|
||||
possibility: fallbackBehavior === 'allow' ? 1 : 0,
|
||||
reliability: 0,
|
||||
reason: 'error',
|
||||
meta: { error: error.message, fallbackBehavior }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the qualitative scale by name
|
||||
* @private
|
||||
*/
|
||||
_getQualitativeScale(scaleName) {
|
||||
switch (scaleName) {
|
||||
case 'binary':
|
||||
return QualitativeScale.binary();
|
||||
case 'ternary':
|
||||
return QualitativeScale.ternary();
|
||||
case 'five-point':
|
||||
return QualitativeScale.fivePoint();
|
||||
case 'ten-point':
|
||||
return QualitativeScale.tenPoint();
|
||||
default:
|
||||
console.warn(`Unknown scale name: ${scaleName}, using default five-point scale`);
|
||||
return DEFAULT_QUALITATIVE_SCALE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single operand and return qualitative interval and possibility
|
||||
* @private
|
||||
*/
|
||||
_evaluateOperand(userId, userKey, objectId, objectKey, operandConfig, visited, currentRelation, options, side, marginSteps, evaluationMeta, scale) {
|
||||
if (!operandConfig) {
|
||||
return {
|
||||
values: [],
|
||||
possibility: scale.bottom,
|
||||
meta: { error: `No ${side} operand configuration` }
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const operandMeta = options.trackEvaluation ? {
|
||||
side,
|
||||
operandConfig: { ...operandConfig },
|
||||
marginStepsApplied: marginSteps,
|
||||
evaluationStarted: Date.now()
|
||||
} : null;
|
||||
|
||||
const evaluateFrom = operandConfig.evaluateFrom || 'auto';
|
||||
let evalUserId = userId;
|
||||
let evalUserKey = userKey;
|
||||
let evalObjectId = objectId;
|
||||
let evalObjectKey = objectKey;
|
||||
const nestedRuleConfig = operandConfig.rule;
|
||||
if (evaluateFrom === 'user') {
|
||||
if (nestedRuleConfig.type === 'direct' || nestedRuleConfig.type === 'computed') {
|
||||
evalUserId = userId;
|
||||
evalUserKey = userKey;
|
||||
evalObjectId = userId;
|
||||
evalObjectKey = userKey;
|
||||
} else if (!nestedRuleConfig.extractValues) {
|
||||
evalUserId = objectId;
|
||||
evalUserKey = objectKey;
|
||||
evalObjectId = userId;
|
||||
evalObjectKey = userKey;
|
||||
}
|
||||
} else if (evaluateFrom === 'object') {
|
||||
if (nestedRuleConfig.type === 'direct' || nestedRuleConfig.type === 'computed') {
|
||||
evalUserId = objectId;
|
||||
evalUserKey = objectKey;
|
||||
evalObjectId = objectId;
|
||||
evalObjectKey = objectKey;
|
||||
} else {
|
||||
evalUserId = objectId;
|
||||
evalUserKey = objectKey;
|
||||
evalObjectId = userId;
|
||||
evalObjectKey = userKey;
|
||||
}
|
||||
}
|
||||
if (operandMeta) {
|
||||
operandMeta.evaluateFrom = evaluateFrom;
|
||||
operandMeta.evalUserKey = evalUserKey;
|
||||
operandMeta.evalObjectKey = evalObjectKey;
|
||||
}
|
||||
|
||||
// Evaluate the underlying rule
|
||||
const ruleResult = this.ruleEvaluator.evaluateRule(
|
||||
evalUserId, evalUserKey, evalObjectId, evalObjectKey, operandConfig.rule, visited, currentRelation, options
|
||||
);
|
||||
|
||||
if (operandMeta) {
|
||||
operandMeta.ruleResult = {
|
||||
possibility: ruleResult.possibility,
|
||||
reliability: ruleResult.reliability,
|
||||
reason: ruleResult.reason
|
||||
};
|
||||
}
|
||||
|
||||
// Extract values from the rule result
|
||||
const extractedValues = this._extractValuesFromRuleResult(ruleResult, operandConfig, scale);
|
||||
|
||||
if (operandMeta) {
|
||||
operandMeta.extractedValues = extractedValues;
|
||||
}
|
||||
|
||||
// Apply margin of safety (shift on the scale)
|
||||
const adjustedValues = this._applyMarginSteps(extractedValues, marginSteps, scale);
|
||||
|
||||
if (operandMeta) {
|
||||
operandMeta.adjustedValues = adjustedValues;
|
||||
}
|
||||
|
||||
// Extract blurred values with qualitative decay and blur
|
||||
const blurredValues = this._extractBlurredValues(adjustedValues, operandConfig, scale);
|
||||
|
||||
if (operandMeta) {
|
||||
operandMeta.blurredValues = blurredValues;
|
||||
operandMeta.evaluationCompleted = Date.now();
|
||||
operandMeta.totalEvaluationTime = operandMeta.evaluationCompleted - operandMeta.evaluationStarted;
|
||||
}
|
||||
|
||||
// Aggregate the blurred values using qualitative OWA
|
||||
const aggregatedResult = this._aggregateCrispValues(blurredValues, operandConfig, scale);
|
||||
|
||||
return {
|
||||
values: blurredValues,
|
||||
possibility: aggregatedResult.possibility,
|
||||
meta: operandMeta
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error evaluating ${side} operand:`, error);
|
||||
return {
|
||||
values: [],
|
||||
possibility: scale.bottom,
|
||||
meta: { error: error.message, side }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract values from rule result and convert to qualitative scale
|
||||
* @private
|
||||
*/
|
||||
_extractValuesFromRuleResult(ruleResult, operandConfig, scale) {
|
||||
const values = [];
|
||||
|
||||
if (operandConfig.extractValue !== false && (ruleResult.values || ruleResult.collectedValues)) {
|
||||
const collected = ruleResult.values || ruleResult.collectedValues;
|
||||
// Extract actual values from relations
|
||||
for (const valueObj of collected) {
|
||||
if (valueObj.value !== undefined) {
|
||||
// Convert numeric value to closest qualitative scale value
|
||||
const qualitativeValue = this._convertToQualitativeValue(valueObj.value, scale);
|
||||
values.push({
|
||||
value: qualitativeValue,
|
||||
possibility: this._convertToQualitativeValue(valueObj.possibility || 1, scale),
|
||||
timestamp: valueObj.timestamp,
|
||||
relation: valueObj.relation,
|
||||
meta: valueObj.meta
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use rule's possibility as the value
|
||||
const qualitativeValue = this._convertToQualitativeValue(ruleResult.possibility, scale);
|
||||
values.push({
|
||||
value: qualitativeValue,
|
||||
possibility: qualitativeValue,
|
||||
timestamp: Date.now(),
|
||||
relation: 'rule_result',
|
||||
meta: { source: 'rule_possibility' }
|
||||
});
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a numeric value to the closest qualitative scale value
|
||||
* @private
|
||||
*/
|
||||
_convertToQualitativeValue(numericValue, scale) {
|
||||
// Find the closest value in the scale
|
||||
let closestValue = scale.bottom;
|
||||
let minDistance = Math.abs(numericValue - scale.bottom);
|
||||
|
||||
for (const scaleValue of scale.values) {
|
||||
const distance = Math.abs(numericValue - scaleValue);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestValue = scaleValue;
|
||||
}
|
||||
}
|
||||
|
||||
return closestValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply margin steps to shift values on the qualitative scale
|
||||
* @private
|
||||
*/
|
||||
_applyMarginSteps(values, marginSteps, scale) {
|
||||
if (marginSteps === 0) return values;
|
||||
|
||||
return values.map(valueObj => {
|
||||
const currentIndex = scale.indexOf(valueObj.value);
|
||||
const newIndex = Math.max(0, Math.min(scale.size - 1, currentIndex + marginSteps));
|
||||
const newValue = scale.at(newIndex);
|
||||
|
||||
return {
|
||||
...valueObj,
|
||||
value: newValue
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract blurred values with qualitative decay and blur
|
||||
* @private
|
||||
*/
|
||||
_extractBlurredValues(values, operandConfig, scale) {
|
||||
const {
|
||||
decaySteps = 1,
|
||||
decayPeriod = 'HOUR',
|
||||
possibilityDecayDirection = 'down',
|
||||
valueBlurDirection = 'neutral',
|
||||
baseBlurSteps = 1,
|
||||
minOperandPossibility = 0
|
||||
} = operandConfig;
|
||||
|
||||
const blurredValues = [];
|
||||
|
||||
for (const valueObj of values) {
|
||||
const pointValue = valueObj.value;
|
||||
const initialPossibility = valueObj.possibility;
|
||||
const timestamp = valueObj.timestamp || Date.now();
|
||||
|
||||
// Calculate periods elapsed
|
||||
const periodsElapsed = this._calculatePeriodsElapsed(timestamp, decayPeriod);
|
||||
|
||||
// Calculate decayed possibility
|
||||
const decayedPossibility = this._calculateDecayedPossibility(
|
||||
initialPossibility, periodsElapsed, decaySteps, possibilityDecayDirection, scale
|
||||
);
|
||||
|
||||
// Skip if possibility is too low
|
||||
if (scale.compare(decayedPossibility, minOperandPossibility) < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate blur amount based on possibility loss
|
||||
const possibilityLossSteps = this._calculatePossibilityLossSteps(
|
||||
initialPossibility, decayedPossibility, scale
|
||||
);
|
||||
const blurSteps = Math.floor(possibilityLossSteps * baseBlurSteps);
|
||||
|
||||
// Create qualitative interval
|
||||
const blurredInterval = this._createQualitativeInterval(
|
||||
pointValue, blurSteps, valueBlurDirection, scale
|
||||
);
|
||||
|
||||
blurredValues.push({
|
||||
interval: blurredInterval,
|
||||
possibility: decayedPossibility,
|
||||
originalValue: pointValue,
|
||||
originalPossibility: initialPossibility,
|
||||
timestamp,
|
||||
relation: valueObj.relation,
|
||||
meta: {
|
||||
...valueObj.meta,
|
||||
periodsElapsed,
|
||||
possibilityLossSteps,
|
||||
blurSteps
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return blurredValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate periods elapsed since timestamp
|
||||
* @private
|
||||
*/
|
||||
_calculatePeriodsElapsed(timestamp, decayPeriod) {
|
||||
const now = Date.now();
|
||||
const elapsed = now - timestamp;
|
||||
|
||||
const periodMs = {
|
||||
'MINUTE': 60 * 1000,
|
||||
'HOUR': 60 * 60 * 1000,
|
||||
'DAY': 24 * 60 * 60 * 1000,
|
||||
'WEEK': 7 * 24 * 60 * 60 * 1000,
|
||||
'MONTH': 30 * 24 * 60 * 60 * 1000,
|
||||
'YEAR': 365 * 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
return Math.floor(elapsed / (periodMs[decayPeriod] || periodMs['HOUR']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate decayed possibility using qualitative scale steps
|
||||
* @private
|
||||
*/
|
||||
_calculateDecayedPossibility(initialPossibility, periodsElapsed, decaySteps, direction, scale) {
|
||||
const initialIndex = scale.indexOf(initialPossibility);
|
||||
const totalDecaySteps = periodsElapsed * decaySteps;
|
||||
|
||||
let newIndex;
|
||||
switch (direction) {
|
||||
case 'down':
|
||||
newIndex = Math.max(0, initialIndex - totalDecaySteps);
|
||||
break;
|
||||
case 'up':
|
||||
newIndex = Math.min(scale.size - 1, initialIndex + totalDecaySteps);
|
||||
break;
|
||||
case 'neutral':
|
||||
const targetIndex = Math.floor(scale.size / 2); // Middle of scale
|
||||
if (initialIndex > targetIndex) {
|
||||
newIndex = Math.max(targetIndex, initialIndex - totalDecaySteps);
|
||||
} else {
|
||||
newIndex = Math.min(targetIndex, initialIndex + totalDecaySteps);
|
||||
}
|
||||
break;
|
||||
case 'stable':
|
||||
default:
|
||||
newIndex = initialIndex;
|
||||
break;
|
||||
}
|
||||
|
||||
return scale.at(newIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the number of steps of possibility loss
|
||||
* @private
|
||||
*/
|
||||
_calculatePossibilityLossSteps(initialPossibility, decayedPossibility, scale) {
|
||||
const initialIndex = scale.indexOf(initialPossibility);
|
||||
const decayedIndex = scale.indexOf(decayedPossibility);
|
||||
return Math.abs(initialIndex - decayedIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a qualitative interval by blurring around a point value
|
||||
* @private
|
||||
*/
|
||||
_createQualitativeInterval(pointValue, blurSteps, direction, scale) {
|
||||
const pointIndex = scale.indexOf(pointValue);
|
||||
|
||||
let lowerIndex, upperIndex;
|
||||
switch (direction) {
|
||||
case 'down':
|
||||
lowerIndex = Math.max(0, pointIndex - blurSteps);
|
||||
upperIndex = pointIndex;
|
||||
break;
|
||||
case 'up':
|
||||
lowerIndex = pointIndex;
|
||||
upperIndex = Math.min(scale.size - 1, pointIndex + blurSteps);
|
||||
break;
|
||||
case 'neutral':
|
||||
default:
|
||||
lowerIndex = Math.max(0, pointIndex - blurSteps);
|
||||
upperIndex = Math.min(scale.size - 1, pointIndex + blurSteps);
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
lower: scale.at(lowerIndex),
|
||||
upper: scale.at(upperIndex)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate blurred values using qualitative OWA fusion with optional bilattice reasoning
|
||||
* @private
|
||||
*/
|
||||
_aggregateCrispValues(blurredValues, operandConfig, scale) {
|
||||
if (blurredValues.length === 0) {
|
||||
return { possibility: scale.bottom };
|
||||
}
|
||||
|
||||
if (blurredValues.length === 1) {
|
||||
return { possibility: blurredValues[0].possibility };
|
||||
}
|
||||
|
||||
const aggregator = operandConfig.aggregator || 'max';
|
||||
const useBilattice = operandConfig.useBilattice || false;
|
||||
const epistemicMode = operandConfig.epistemicMode || 'hybrid';
|
||||
const capacityType = operandConfig.capacityType || 'simple_support';
|
||||
|
||||
// Convert blurred values to collected values format for bilattice analysis
|
||||
const collectedValues = blurredValues.map((bv, index) => ({
|
||||
value: bv.possibility, // Use possibility as the value for bilattice analysis
|
||||
possibility: bv.possibility,
|
||||
path: [`blurred_value_${index}`],
|
||||
source: {
|
||||
entityKey: 'qualitative_operand',
|
||||
relation: 'blurred_value',
|
||||
step: index
|
||||
},
|
||||
metadata: {
|
||||
timestamp: bv.timestamp || Date.now(),
|
||||
reliability: 1.0,
|
||||
originalValue: bv.originalValue,
|
||||
originalPossibility: bv.originalPossibility,
|
||||
interval: bv.interval,
|
||||
blurSteps: bv.meta?.blurSteps,
|
||||
periodsElapsed: bv.meta?.periodsElapsed
|
||||
}
|
||||
}));
|
||||
|
||||
// Use bilattice-enhanced evidence combination if enabled
|
||||
if (useBilattice) {
|
||||
const capacity = this._createCapacityFromValues(collectedValues, scale, capacityType);
|
||||
const bilatticeResult = this._combineEvidenceWithBilattice(collectedValues, {
|
||||
method: aggregator,
|
||||
useBilattice: true,
|
||||
capacity: capacity,
|
||||
scale: scale,
|
||||
epistemicMode: epistemicMode
|
||||
});
|
||||
|
||||
return {
|
||||
possibility: bilatticeResult.value,
|
||||
epistemicAnalysis: bilatticeResult.epistemicAnalysis,
|
||||
aggregationMethod: `bilattice_${epistemicMode}`
|
||||
};
|
||||
}
|
||||
|
||||
// Standard qualitative OWA fusion
|
||||
const values = blurredValues.map(v => v.possibility);
|
||||
const metas = blurredValues.map(v => v.meta);
|
||||
|
||||
// Generate OWA weights
|
||||
const weights = getOWAQualitativeWeights(aggregator, values.length, null, scale);
|
||||
|
||||
// Perform qualitative OWA fusion
|
||||
const result = OWAQualitativeFusion.fuseWithMeta(values, weights, weights, aggregator, scale);
|
||||
|
||||
return {
|
||||
possibility: result.value,
|
||||
aggregationMethod: `owa_${aggregator}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use _aggregateCrispValues instead. Removal after Stage 2.
|
||||
*/
|
||||
_aggregateBlurredValues(blurredValues, operandConfig, scale) {
|
||||
if (!this._warnedAggregateBlurredValues) {
|
||||
this._warnedAggregateBlurredValues = true;
|
||||
console.warn('[QualitativeRelationalComparatorRule] _aggregateBlurredValues is deprecated; use _aggregateCrispValues instead.');
|
||||
}
|
||||
return this._aggregateCrispValues(blurredValues, operandConfig, scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare blurred qualitative intervals
|
||||
* @private
|
||||
*/
|
||||
_compareBlurredValues(leftResult, rightResult, comparator, fallbackBehavior, rule, options, ruleMetaBase, scale) {
|
||||
const leftValues = leftResult.values || [];
|
||||
const rightValues = rightResult.values || [];
|
||||
|
||||
if (leftValues.length === 0 || rightValues.length === 0) {
|
||||
return {
|
||||
possibility: fallbackBehavior === 'allow' ? scale.top : scale.bottom,
|
||||
reliability: 0,
|
||||
reason: 'insufficient_values'
|
||||
};
|
||||
}
|
||||
|
||||
// Compare all combinations of left and right intervals
|
||||
const comparisonResults = [];
|
||||
|
||||
for (const leftValue of leftValues) {
|
||||
for (const rightValue of rightValues) {
|
||||
const comparisonPossibility = this._calculateQualitativeIntervalComparison(
|
||||
leftValue.interval, rightValue.interval, comparator, scale
|
||||
);
|
||||
|
||||
// Combine with confidence weights (min operation in qualitative logic)
|
||||
const combinedPossibility = scale.min(comparisonPossibility, scale.min(leftValue.possibility, rightValue.possibility));
|
||||
|
||||
comparisonResults.push({
|
||||
possibility: combinedPossibility,
|
||||
leftInterval: leftValue.interval,
|
||||
rightInterval: rightValue.interval,
|
||||
leftPossibility: leftValue.possibility,
|
||||
rightPossibility: rightValue.possibility
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Take the maximum possibility across all comparisons
|
||||
const maxPossibility = scale.maxAll(comparisonResults.map(r => r.possibility));
|
||||
|
||||
return {
|
||||
possibility: maxPossibility,
|
||||
reliability: 1, // Qualitative comparisons are considered fully reliable
|
||||
reason: 'qualitative_interval_comparison'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate qualitative interval comparison using possibility theory
|
||||
* @private
|
||||
*/
|
||||
_calculateQualitativeIntervalComparison(leftInterval, rightInterval, comparator, scale) {
|
||||
const { lower: lLower, upper: lUpper } = leftInterval;
|
||||
const { lower: rLower, upper: rUpper } = rightInterval;
|
||||
|
||||
switch (comparator) {
|
||||
case '>':
|
||||
// Possibility(L > R): Is it possible that a value from L is greater than a value from R?
|
||||
// This is true if the top of L is greater than the bottom of R
|
||||
return scale.compare(lUpper, rLower) > 0 ? scale.top : scale.bottom;
|
||||
|
||||
case '>=':
|
||||
// Possibility(L >= R): Is it possible that a value from L is >= a value from R?
|
||||
return scale.compare(lUpper, rLower) >= 0 ? scale.top : scale.bottom;
|
||||
|
||||
case '<':
|
||||
// Possibility(L < R): Is it possible that a value from L is less than a value from R?
|
||||
// This is true if the bottom of L is less than the top of R
|
||||
return scale.compare(lLower, rUpper) < 0 ? scale.top : scale.bottom;
|
||||
|
||||
case '<=':
|
||||
// Possibility(L <= R): Is it possible that a value from L is <= a value from R?
|
||||
return scale.compare(lLower, rUpper) <= 0 ? scale.top : scale.bottom;
|
||||
|
||||
case '==':
|
||||
// Possibility(L == R): Is it possible that intervals overlap?
|
||||
// This is true if there's any overlap between the intervals
|
||||
return (scale.compare(lUpper, rLower) >= 0 && scale.compare(lLower, rUpper) <= 0) ? scale.top : scale.bottom;
|
||||
|
||||
case '!=':
|
||||
// Possibility(L != R): Is it possible that intervals don't overlap?
|
||||
// This is true if there's no overlap between the intervals
|
||||
return (scale.compare(lUpper, rLower) < 0 || scale.compare(lLower, rUpper) > 0) ? scale.top : scale.bottom;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown comparator: ${comparator}`);
|
||||
return scale.bottom;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { RelationalComparatorRule } from './RelationalComparatorRule.js';
|
||||
import { QualitativeRelationalComparatorRule } from './QualitativeRelationalComparatorRule.js';
|
||||
|
||||
/**
|
||||
* RelationalComparatorRouter - Routes relational comparator rules to either
|
||||
* numeric or qualitative implementations based on rule configuration.
|
||||
*
|
||||
* This router acts as the public entry point for relational comparator rules,
|
||||
* automatically delegating to the appropriate implementation:
|
||||
* - Numeric: Traditional numeric intervals with OWA fusion
|
||||
* - Qualitative: Qualitative scales with possibility theory
|
||||
*
|
||||
* Configuration Detection:
|
||||
* - If rule.qualitative === true, uses qualitative implementation
|
||||
* - If leftOperand.scaleName or rightOperand.scaleName is present, uses qualitative
|
||||
* - Otherwise, uses numeric implementation (default)
|
||||
*/
|
||||
export class RelationalComparatorRouter extends BaseRule {
|
||||
constructor(arbiter, ruleEvaluator) {
|
||||
super(arbiter);
|
||||
this.ruleEvaluator = ruleEvaluator;
|
||||
|
||||
// Instantiate both concrete implementations
|
||||
this.numericRule = new RelationalComparatorRule(arbiter, ruleEvaluator);
|
||||
this.qualitativeRule = new QualitativeRelationalComparatorRule(arbiter, ruleEvaluator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the rule evaluation to the appropriate implementation
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
// Decide which rule to use based on the configuration
|
||||
if (this._isQualitativeRule(rule)) {
|
||||
return this.qualitativeRule._evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options);
|
||||
} else {
|
||||
return this.numericRule._evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if this rule should use qualitative implementation
|
||||
* @param {Object} rule - The rule configuration
|
||||
* @returns {boolean} True if qualitative implementation should be used
|
||||
*/
|
||||
_isQualitativeRule(rule) {
|
||||
// Check explicit qualitative flag
|
||||
if (rule.qualitative === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for scale names in operands
|
||||
if (rule.left?.scaleName || rule.right?.scaleName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for qualitative-specific properties (must be defined and not null)
|
||||
if (this._hasValidQualitativeProperty(rule.left?.decaySteps) ||
|
||||
this._hasValidQualitativeProperty(rule.right?.decaySteps) ||
|
||||
this._hasValidQualitativeProperty(rule.left?.baseBlurSteps) ||
|
||||
this._hasValidQualitativeProperty(rule.right?.baseBlurSteps) ||
|
||||
this._hasValidQualitativeProperty(rule.marginSteps)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate rule implementation for debugging/logging
|
||||
* @param {Object} rule - The rule configuration
|
||||
* @returns {string} 'qualitative' or 'numeric'
|
||||
*/
|
||||
getImplementationType(rule) {
|
||||
return this._isQualitativeRule(rule) ? 'qualitative' : 'numeric';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a property value is a valid qualitative property (defined and not null)
|
||||
* @private
|
||||
*/
|
||||
_hasValidQualitativeProperty(value) {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,542 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { Arbiter } from '../../core/Arbiter.js';
|
||||
import { OWAFusion } from '../../utils/OWAFusion.js';
|
||||
|
||||
/**
|
||||
* TupleToUsersetRule - Evaluates access through intermediate entities (group membership pattern)
|
||||
*
|
||||
* This rule implements the tuple-to-userset pattern where access is granted if:
|
||||
* 1. Object has a tupleset relation to an intermediate entity (e.g., doc -> group)
|
||||
* 2. User has a computed relation to that same intermediate entity (e.g., user -> group)
|
||||
*
|
||||
* Supports sophisticated batch processing and OWA fusion for combining multiple paths.
|
||||
*
|
||||
* Configuration:
|
||||
* {
|
||||
* type: 'tuple_to_userset',
|
||||
* tuplesetRelation: string, // Relation from object to intermediate (e.g., 'owner')
|
||||
* computedRelation: string, // Relation from user to intermediate (e.g., 'member_of')
|
||||
* reverse: boolean, // Check in reverse direction (default: false)
|
||||
* minPossibility: number, // Minimum possibility for a path to be considered (0-1, default: 0)
|
||||
* owaWeights: Array<number>, // OWA weights for fusion (default: 'max' like behavior [1,0,...])
|
||||
* earlyExitThreshold: number, // Stop when path exceeds this threshold (default: 0.95)
|
||||
* maxIntermediates: number // Maximum intermediates to check (default: 20)
|
||||
* }
|
||||
*/
|
||||
export class TupleToUsersetRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
// Performance tracking
|
||||
this.performanceStats = {
|
||||
totalChecks: 0,
|
||||
earlyExits: 0,
|
||||
maxIntermediatesHit: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate tuple-to-userset relationship with aggressive early exit optimization
|
||||
* @protected
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
this.performanceStats.totalChecks++;
|
||||
|
||||
const { fastPath, includeMeta = true } = options;
|
||||
const collectValues = options.collectValues !== undefined ? options.collectValues : true;
|
||||
const minPossibility = rule.minPossibility !== undefined ? rule.minPossibility : (options.minPossibility !== undefined ? options.minPossibility : 0);
|
||||
const earlyExitThreshold = rule.earlyExitThreshold !== undefined ? rule.earlyExitThreshold : 0.95;
|
||||
const maxIntermediates = rule.maxIntermediates !== undefined ? rule.maxIntermediates : 20;
|
||||
const resolvePossibility = (value) => value !== undefined ? value : 1.0;
|
||||
const resolveReliability = (value) => value !== undefined ? value : 1.0;
|
||||
|
||||
const reverse = rule.reverse || false;
|
||||
if (!rule._computedRelationId) {
|
||||
rule._computedRelationId = this.arbiter.keyManager._getRelationId(rule.computedRelation);
|
||||
}
|
||||
if (!rule.computedRelationConfig) {
|
||||
rule.computedRelationConfig = this.arbiter.relationConfigs.get(rule.computedRelation);
|
||||
}
|
||||
if (!rule.tuplesetRelationConfig) {
|
||||
rule.tuplesetRelationConfig = this.arbiter.relationConfigs.get(rule.tuplesetRelation);
|
||||
}
|
||||
|
||||
const ruleMetaBase = includeMeta ? {
|
||||
ruleType: 'TupleToUsersetRule',
|
||||
userKey,
|
||||
objectKey,
|
||||
tuplesetRelation: rule.tuplesetRelation,
|
||||
computedRelation: rule.computedRelation,
|
||||
reverse,
|
||||
minPossibilityUsed: minPossibility,
|
||||
earlyExitThreshold,
|
||||
maxIntermediates,
|
||||
evaluationStarted: Date.now()
|
||||
} : null;
|
||||
|
||||
let evaluationMeta = options.trackEvaluation && includeMeta ? { ...ruleMetaBase } : null;
|
||||
|
||||
// Get tuples (intermediate entities) with early circuit breaker
|
||||
// tuplesetDirection: 'out' = object has relation TO intermediates (document → owner → group)
|
||||
// tuplesetDirection: 'in' = intermediates have relation TO object (group → belongs_to → org)
|
||||
const tuplesetDirection = rule.tuplesetDirection || 'out'; // Default to 'out' for backward compatibility
|
||||
|
||||
let tuples;
|
||||
const useRelationGraph = !options?.partialGraphContext;
|
||||
if (reverse) {
|
||||
const useGraphNeighbors = useRelationGraph &&
|
||||
this.arbiter.relationManager.shouldUseRelationGraphTraversal(userId, rule.tuplesetRelation, false);
|
||||
const neighbors = useGraphNeighbors
|
||||
? this.arbiter.relationManager.getRelationGraphNeighbors(userId, rule.tuplesetRelation, false)
|
||||
: null;
|
||||
if (neighbors) {
|
||||
tuples = [];
|
||||
for (const neighborId of neighbors) {
|
||||
const edge = this.arbiter.relationManager.getDirectRelation(userId, rule.tuplesetRelation, neighborId, options);
|
||||
if (edge) tuples.push(edge);
|
||||
}
|
||||
} else {
|
||||
tuples = this.arbiter.relationManager.getRelationsFromSrc(userId, rule.tuplesetRelation, options);
|
||||
}
|
||||
} else {
|
||||
const reverseLookup = tuplesetDirection === 'in';
|
||||
const useGraphNeighbors = useRelationGraph &&
|
||||
this.arbiter.relationManager.shouldUseRelationGraphTraversal(objectId, rule.tuplesetRelation, reverseLookup);
|
||||
const neighbors = useGraphNeighbors
|
||||
? this.arbiter.relationManager.getRelationGraphNeighbors(objectId, rule.tuplesetRelation, reverseLookup)
|
||||
: null;
|
||||
if (neighbors) {
|
||||
tuples = [];
|
||||
for (const neighborId of neighbors) {
|
||||
const srcId = reverseLookup ? neighborId : objectId;
|
||||
const dstId = reverseLookup ? objectId : neighborId;
|
||||
const edge = this.arbiter.relationManager.getDirectRelation(srcId, rule.tuplesetRelation, dstId, options);
|
||||
if (edge) tuples.push(edge);
|
||||
}
|
||||
} else if (reverseLookup) {
|
||||
tuples = this.arbiter.relationManager.getRelationsToDst(objectId, rule.tuplesetRelation, options);
|
||||
} else {
|
||||
tuples = this.arbiter.relationManager.getRelationsFromSrc(objectId, rule.tuplesetRelation, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Circuit breaker: if too many intermediates, limit and warn
|
||||
if (tuples.length > maxIntermediates * 3) {
|
||||
// Sort by possibility and take top N
|
||||
tuples.sort((a, b) => resolvePossibility(b.possibility) - resolvePossibility(a.possibility));
|
||||
tuples = tuples.slice(0, maxIntermediates);
|
||||
if (evaluationMeta) evaluationMeta.circuitBreakerTriggered = true;
|
||||
}
|
||||
|
||||
const checkingId = reverse ? this.arbiter.resolveNodeId(objectKey, options) : userId;
|
||||
|
||||
const useDirectJoin = rule.computedRelation && rule.computedRelationConfig?.type === 'direct';
|
||||
const computedEdges = useDirectJoin
|
||||
? this.arbiter.relationManager.getRelationsFromSrc(checkingId, rule.computedRelation, options)
|
||||
: null;
|
||||
const joinMode = useDirectJoin && computedEdges && computedEdges.length < tuples.length
|
||||
? 'computed'
|
||||
: 'tuples';
|
||||
|
||||
const tuplesWithKeys = [];
|
||||
if (includeMeta || joinMode === 'tuples') {
|
||||
for (const t of tuples) {
|
||||
const srcKey = this.arbiter.resolveKey(t.src, options);
|
||||
const dstKey = this.arbiter.resolveKey(t.dst, options);
|
||||
const intermediateId = tuplesetDirection === 'in' ? t.src : t.dst;
|
||||
const intermediateKey = tuplesetDirection === 'in' ? srcKey : dstKey;
|
||||
tuplesWithKeys.push({ tuple: t, srcKey, dstKey, intermediateId, intermediateKey });
|
||||
}
|
||||
}
|
||||
|
||||
if (evaluationMeta) {
|
||||
evaluationMeta.directTuplesFound = tuplesWithKeys.length;
|
||||
evaluationMeta.directTuples = tuplesWithKeys.map(({ tuple, srcKey, dstKey }) => ({
|
||||
from: srcKey,
|
||||
to: dstKey,
|
||||
relation: tuple.rel,
|
||||
possibility: tuple.possibility,
|
||||
reliability: tuple.reliability,
|
||||
source: tuple.source || 'persistent'
|
||||
}));
|
||||
}
|
||||
|
||||
const useLightweightPaths = !includeMeta && !collectValues;
|
||||
let bestPath = null;
|
||||
let allValidPaths = [];
|
||||
let reasons = [];
|
||||
let intermediateEvaluationDetails = includeMeta ? [] : null;
|
||||
let processedCount = 0;
|
||||
|
||||
// Process direct tuples with early exit optimization
|
||||
const computedRelationCache = new Map();
|
||||
const computedByIntermediate = joinMode === 'tuples' && computedEdges
|
||||
? new Map(computedEdges.map(edge => [edge.dst, edge]))
|
||||
: null;
|
||||
|
||||
if (joinMode === 'computed' && computedEdges) {
|
||||
for (const edge of computedEdges) {
|
||||
if (processedCount >= maxIntermediates) {
|
||||
if (evaluationMeta) evaluationMeta.maxIntermediatesReached = true;
|
||||
this.performanceStats.maxIntermediatesHit++;
|
||||
break;
|
||||
}
|
||||
|
||||
const intermediateId = edge.dst;
|
||||
const intermediateKey = this.arbiter.resolveKey(intermediateId, options);
|
||||
if (!intermediateKey) continue;
|
||||
|
||||
processedCount++;
|
||||
|
||||
const tupleEdge = reverse
|
||||
? this.arbiter.relationManager.getDirectRelation(userId, rule.tuplesetRelation, intermediateId, options)
|
||||
: (tuplesetDirection === 'in'
|
||||
? this.arbiter.relationManager.getDirectRelation(intermediateId, rule.tuplesetRelation, objectId, options)
|
||||
: this.arbiter.relationManager.getDirectRelation(objectId, rule.tuplesetRelation, intermediateId, options));
|
||||
|
||||
if (!tupleEdge) continue;
|
||||
|
||||
let res;
|
||||
if (visited && visited.size) {
|
||||
const visitKey = `${checkingId}|${rule._computedRelationId}|${intermediateId}`;
|
||||
if (visited.has(visitKey)) {
|
||||
res = { possibility: 0, reliability: 1.0, reason: 'cycle' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!res) {
|
||||
res = {
|
||||
possibility: edge.possibility,
|
||||
reliability: edge.reliability !== undefined ? edge.reliability : 1.0,
|
||||
reason: 'direct_match'
|
||||
};
|
||||
}
|
||||
|
||||
if (includeMeta && intermediateEvaluationDetails) {
|
||||
const pathDetail = {
|
||||
intermediateKey,
|
||||
type: 'direct_tuple',
|
||||
tuplesetRelationPossibility: resolvePossibility(tupleEdge.possibility),
|
||||
tuplesetRelationReliability: resolveReliability(tupleEdge.reliability),
|
||||
computedRelationResult: {
|
||||
possibility: res.possibility,
|
||||
reliability: res.reliability,
|
||||
reason: res.reason
|
||||
},
|
||||
metaFromCheck: res.meta
|
||||
};
|
||||
if (evaluationMeta) intermediateEvaluationDetails.push(pathDetail);
|
||||
}
|
||||
|
||||
if (res.reason === 'cycle') reasons.push('cycle');
|
||||
|
||||
const combinedPossibility = Math.min(resolvePossibility(tupleEdge.possibility), res.possibility);
|
||||
const combinedReliability = resolveReliability(tupleEdge.reliability) * (res.reliability !== undefined ? res.reliability : 1.0);
|
||||
|
||||
if (combinedPossibility >= minPossibility) {
|
||||
const path = {
|
||||
intermediateKey,
|
||||
tuplesetPossibility: resolvePossibility(tupleEdge.possibility),
|
||||
computedPossibility: res.possibility,
|
||||
combinedPossibility,
|
||||
combinedReliability
|
||||
};
|
||||
allValidPaths.push(path);
|
||||
if (!bestPath || combinedPossibility > bestPath.combinedPossibility) {
|
||||
bestPath = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (joinMode === 'tuples') for (const entry of tuplesWithKeys) {
|
||||
const t = entry.tuple;
|
||||
if (processedCount >= maxIntermediates) {
|
||||
if (evaluationMeta) evaluationMeta.maxIntermediatesReached = true;
|
||||
this.performanceStats.maxIntermediatesHit++;
|
||||
break;
|
||||
}
|
||||
|
||||
// For 'out' direction: intermediate is destination (document → owner → group)
|
||||
// For 'in' direction: intermediate is source (group → belongs_to → org)
|
||||
const intermediateKey = entry.intermediateKey;
|
||||
if (!intermediateKey) continue;
|
||||
|
||||
processedCount++;
|
||||
|
||||
// In reverse mode: check if the "object" (3rd param) has computed relation to intermediate
|
||||
// In normal mode: check if the "user" (1st param) has computed relation to intermediate
|
||||
const checkingEntityKey = reverse ? objectKey : userKey;
|
||||
|
||||
|
||||
let res = computedRelationCache.get(entry.intermediateId);
|
||||
if (!res) {
|
||||
if (useDirectJoin) {
|
||||
if (visited && visited.size) {
|
||||
const visitKey = `${checkingId}|${rule._computedRelationId}|${entry.intermediateId}`;
|
||||
if (visited.has(visitKey)) {
|
||||
res = {
|
||||
possibility: 0,
|
||||
reliability: 1.0,
|
||||
reason: 'cycle'
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!res) {
|
||||
const directRel = computedByIntermediate
|
||||
? computedByIntermediate.get(entry.intermediateId)
|
||||
: this.arbiter.relationManager.getDirectRelation(checkingId, rule.computedRelation, entry.intermediateId, options);
|
||||
if (directRel && (minPossibility === null || directRel.possibility >= minPossibility)) {
|
||||
res = {
|
||||
possibility: directRel.possibility,
|
||||
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
|
||||
reason: 'direct_match'
|
||||
};
|
||||
} else {
|
||||
res = {
|
||||
possibility: 0,
|
||||
reliability: 1.0,
|
||||
reason: 'no_direct_match'
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
res = this.arbiter.authChecker.check(checkingEntityKey, rule.computedRelation, intermediateKey, {
|
||||
...options,
|
||||
minPossibility,
|
||||
fastPath: true, // Enable fast path for intermediate checks
|
||||
_visited: visited,
|
||||
_currentRelation: rule.computedRelation
|
||||
});
|
||||
}
|
||||
computedRelationCache.set(entry.intermediateId, res);
|
||||
}
|
||||
|
||||
|
||||
if (includeMeta && intermediateEvaluationDetails) {
|
||||
const pathDetail = {
|
||||
intermediateKey,
|
||||
type: 'direct_tuple',
|
||||
tuplesetRelationPossibility: resolvePossibility(t.possibility),
|
||||
tuplesetRelationReliability: resolveReliability(t.reliability),
|
||||
computedRelationResult: {
|
||||
possibility: res.possibility,
|
||||
reliability: res.reliability,
|
||||
reason: res.reason
|
||||
},
|
||||
metaFromCheck: res.meta
|
||||
};
|
||||
if (evaluationMeta) intermediateEvaluationDetails.push(pathDetail);
|
||||
}
|
||||
|
||||
if (res.reason === 'cycle') reasons.push('cycle');
|
||||
|
||||
const combinedPossibility = Math.min(resolvePossibility(t.possibility), res.possibility);
|
||||
const combinedReliability = resolveReliability(t.reliability) * (res.reliability !== undefined ? res.reliability : 1.0);
|
||||
|
||||
if (combinedPossibility >= minPossibility) {
|
||||
const pathData = {
|
||||
possibility: combinedPossibility,
|
||||
reliability: combinedReliability,
|
||||
...(!useLightweightPaths && includeMeta && {
|
||||
meta: {
|
||||
intermediateKey,
|
||||
pathType: 'direct',
|
||||
tuplesetRelation: { relation: rule.tuplesetRelation, possibility: resolvePossibility(t.possibility), reliability: resolveReliability(t.reliability) },
|
||||
computedRelation: { relation: rule.computedRelation, possibility: res.possibility, reliability: res.reliability, meta: res.meta }
|
||||
}
|
||||
}),
|
||||
...(!useLightweightPaths && collectValues && { collectedValue: intermediateKey })
|
||||
};
|
||||
|
||||
allValidPaths.push(pathData);
|
||||
|
||||
// Update best path
|
||||
if (!bestPath || combinedPossibility > bestPath.possibility) {
|
||||
bestPath = pathData;
|
||||
}
|
||||
|
||||
// AGGRESSIVE EARLY EXIT: Stop if we found a very good path
|
||||
if (combinedPossibility >= earlyExitThreshold) {
|
||||
if (evaluationMeta) {
|
||||
evaluationMeta.earlyExitTriggered = true;
|
||||
evaluationMeta.earlyExitReason = 'direct_path_threshold_exceeded';
|
||||
evaluationMeta.earlyExitPossibility = combinedPossibility;
|
||||
evaluationMeta.intermediatesProcessed = processedCount;
|
||||
}
|
||||
this.performanceStats.earlyExits++;
|
||||
|
||||
// Return immediately with the excellent path
|
||||
return this._buildFinalResult(
|
||||
[pathData],
|
||||
reasons,
|
||||
ruleMetaBase,
|
||||
evaluationMeta,
|
||||
'early_exit_direct_path',
|
||||
includeMeta,
|
||||
collectValues,
|
||||
options.trackEvaluation
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (evaluationMeta) {
|
||||
evaluationMeta.evaluationCompleted = Date.now();
|
||||
evaluationMeta.evaluationDuration = evaluationMeta.evaluationCompleted - (evaluationMeta.evaluationStarted || evaluationMeta.evaluationCompleted);
|
||||
evaluationMeta.intermediateEvaluationDetails = intermediateEvaluationDetails;
|
||||
evaluationMeta.totalValidPathsFound = allValidPaths.length;
|
||||
evaluationMeta.intermediatesProcessed = processedCount;
|
||||
}
|
||||
|
||||
return this._buildFinalResult(allValidPaths, reasons, ruleMetaBase, evaluationMeta, 'complete_evaluation', includeMeta, collectValues, options.trackEvaluation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final result from valid paths
|
||||
* @private
|
||||
*/
|
||||
_buildFinalResult(validPathData, reasons, ruleMetaBase, evaluationMeta, evaluationType, includeMeta = true, collectValues = true, trackEvaluation = false) {
|
||||
if (!validPathData.length) {
|
||||
const reason = reasons.includes('cycle') ? 'cycle' : 'no_valid_intermediate_paths';
|
||||
if (evaluationMeta) {
|
||||
evaluationMeta.outcome = reason;
|
||||
evaluationMeta.finalPossibility = 0;
|
||||
evaluationMeta.finalReliability = 1.0;
|
||||
evaluationMeta.evaluationType = evaluationType;
|
||||
}
|
||||
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 1.0,
|
||||
...(includeMeta && { meta: { ...ruleMetaBase, outcomeReason: reason, evaluation: evaluationMeta } }),
|
||||
...(collectValues && { collectedValues: [] }),
|
||||
reason
|
||||
};
|
||||
}
|
||||
|
||||
// For single path (common with early exit), skip OWA fusion overhead
|
||||
if (validPathData.length === 1) {
|
||||
const singlePath = validPathData[0];
|
||||
if (evaluationMeta) {
|
||||
evaluationMeta.outcome = 'single_path_found';
|
||||
evaluationMeta.finalPossibility = singlePath.possibility;
|
||||
evaluationMeta.finalReliability = singlePath.reliability;
|
||||
evaluationMeta.evaluationType = evaluationType;
|
||||
evaluationMeta.fusionSkipped = 'single_path_optimization';
|
||||
}
|
||||
|
||||
return {
|
||||
possibility: singlePath.possibility,
|
||||
reliability: singlePath.reliability,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
...ruleMetaBase,
|
||||
outcomeReason: reasons.includes('cycle') ? 'cycle' : 'tuple_to_userset_evaluated',
|
||||
pathMeta: singlePath.meta,
|
||||
evaluation: evaluationMeta
|
||||
}
|
||||
}),
|
||||
...(collectValues && { collectedValues: [singlePath.collectedValue] }),
|
||||
reason: reasons.includes('cycle') ? 'cycle' : 'tuple_to_userset_found'
|
||||
};
|
||||
}
|
||||
|
||||
if (!includeMeta && !collectValues) {
|
||||
let maxPossibility = 0;
|
||||
let maxReliability = 1.0;
|
||||
for (const path of validPathData) {
|
||||
if (path.possibility > maxPossibility) {
|
||||
maxPossibility = path.possibility;
|
||||
maxReliability = path.reliability;
|
||||
}
|
||||
}
|
||||
return {
|
||||
possibility: maxPossibility,
|
||||
reliability: maxReliability,
|
||||
reason: reasons.includes('cycle') ? 'cycle' : (maxPossibility > 0 ? 'tuple_to_userset_found' : 'no_sufficient_tuple_to_userset_path')
|
||||
};
|
||||
}
|
||||
|
||||
// Multiple paths - use OWA fusion
|
||||
const possibilities = validPathData.map(p => p.possibility);
|
||||
const reliabilities = validPathData.map(p => p.reliability);
|
||||
const metaObjects = includeMeta ? validPathData.map(p => p.meta) : [];
|
||||
const collectedValues = collectValues ? validPathData.map(p => p.collectedValue) : [];
|
||||
|
||||
const owaWeights = [1, ...Array(possibilities.length - 1).fill(0)]; // Default to MAX for performance
|
||||
const fusionResult = OWAFusion.fuseWithMeta(possibilities, metaObjects, owaWeights, 'max', true, trackEvaluation ? { includeTrace: true } : null);
|
||||
|
||||
let fusedReliability = 1.0;
|
||||
if (fusionResult.meta && fusionResult.meta.intermediateKey) {
|
||||
const winningPath = validPathData.find(p => p.meta.intermediateKey === fusionResult.meta.intermediateKey && p.meta.pathType === fusionResult.meta.pathType);
|
||||
if (winningPath) {
|
||||
fusedReliability = winningPath.reliability;
|
||||
} else if (reliabilities.length > 0) {
|
||||
fusedReliability = Math.max(...reliabilities); // Use max reliability for performance
|
||||
}
|
||||
} else if (reliabilities.length > 0) {
|
||||
fusedReliability = Math.max(...reliabilities);
|
||||
}
|
||||
|
||||
if (evaluationMeta) {
|
||||
evaluationMeta.outcome = 'paths_evaluated_and_fused';
|
||||
evaluationMeta.fusion = {
|
||||
method: 'owa',
|
||||
weightsUsed: owaWeights,
|
||||
fusedPossibility: fusionResult.value,
|
||||
fusedReliability: fusedReliability,
|
||||
winningPathMeta: fusionResult.meta,
|
||||
...(trackEvaluation && fusionResult.trace ? {
|
||||
owa: {
|
||||
level: null,
|
||||
aggregator: 'max',
|
||||
weights: fusionResult.trace.weights,
|
||||
sortedValues: fusionResult.trace.sortedValues,
|
||||
contributions: fusionResult.trace.contributions,
|
||||
selectedIndex: fusionResult.trace.selectedIndex
|
||||
}
|
||||
} : {})
|
||||
};
|
||||
evaluationMeta.finalPossibility = fusionResult.value;
|
||||
evaluationMeta.finalReliability = fusedReliability;
|
||||
evaluationMeta.evaluationType = evaluationType;
|
||||
}
|
||||
|
||||
const finalMeta = includeMeta ? {
|
||||
...ruleMetaBase,
|
||||
outcomeReason: reasons.includes('cycle') ? 'cycle' : 'tuple_to_userset_evaluated',
|
||||
fusionMethod: 'owa',
|
||||
owaWeightsUsed: owaWeights,
|
||||
contributingPathMeta: fusionResult.meta,
|
||||
evaluation: evaluationMeta
|
||||
} : null;
|
||||
|
||||
return {
|
||||
possibility: fusionResult.value,
|
||||
reliability: fusedReliability,
|
||||
...(includeMeta && { meta: finalMeta }),
|
||||
...(collectValues && { collectedValues: fusionResult.value > 0 ? collectedValues : [] }),
|
||||
reason: reasons.includes('cycle') ? 'cycle' : (fusionResult.value > 0 ? 'tuple_to_userset_found' : 'no_sufficient_tuple_to_userset_path')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics
|
||||
* @returns {Object} Performance statistics
|
||||
*/
|
||||
getPerformanceStats() {
|
||||
return { ...this.performanceStats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset performance statistics
|
||||
*/
|
||||
resetPerformanceStats() {
|
||||
this.performanceStats = {
|
||||
totalChecks: 0,
|
||||
earlyExits: 0,
|
||||
maxIntermediatesHit: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user