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:
John Dvorak
2026-07-31 13:44:06 -07:00
commit 717ae1031e
373 changed files with 654131 additions and 0 deletions
+888
View File
@@ -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
}
}
+806
View File
@@ -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;
}
}
+228
View File
@@ -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;
+37
View File
@@ -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;
}
}
+415
View File
@@ -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);
}
}
+450
View File
@@ -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
};
}
+17
View File
@@ -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.
+317
View File
@@ -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();
}
}
+67
View File
@@ -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.
+686
View File
@@ -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)
*/
+693
View File
@@ -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;
}
}
+88
View File
@@ -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
};
}
}
+78
View File
@@ -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;
}
}
+129
View File
@@ -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
+822
View File
@@ -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 };
}
}
+207
View File
@@ -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
};
}
}