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