initial commit: @arbiter/core authorization engine with js-rigor hardening
Zanzibar-style authorization graph engine (direct/chain/TTU/defeasible/ binary modes, condensed snapshots, value relations) with 39 rigor test campaigns. Includes fixes for snapshot binary writer/reader format mismatch (snapshot-of-snapshot corruption), possibility write-boundary validation, empty-graph snapshot serialization, relation lookup cache direction collision, config-redefinition cache invalidation, binary threshold semantics, defeasible compiled routing, and comparator reason whitelisting.
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
# Authorization Rules API Specification
|
||||
|
||||
This document defines the standardized API that all authorization rules must implement to ensure consistency, performance, and maintainability across the zanzibar-graph system.
|
||||
|
||||
## Overview
|
||||
|
||||
All authorization rules must extend the `BaseRule` class and implement the required abstract methods. This ensures:
|
||||
|
||||
- **Consistent Interface**: All rules have the same public API
|
||||
- **Performance Tracking**: Built-in performance monitoring
|
||||
- **Error Handling**: Standardized error responses
|
||||
- **Batch Processing**: Optional but encouraged for performance
|
||||
- **Early Exit**: Threshold-based optimization support
|
||||
- **Input Validation**: Automatic parameter validation
|
||||
|
||||
## Core Interface
|
||||
|
||||
### Constructor
|
||||
|
||||
```javascript
|
||||
constructor(arbiter)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `arbiter` (Arbiter): The main Arbiter instance
|
||||
|
||||
**Requirements:**
|
||||
- Must call `super(arbiter)` first
|
||||
- Should initialize any rule-specific state
|
||||
- Must not throw exceptions
|
||||
|
||||
### Primary Evaluation Method
|
||||
|
||||
```javascript
|
||||
evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {})
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `userId` (number): Internal user node ID
|
||||
- `userKey` (string): User key (e.g., "user:alice")
|
||||
- `objectId` (number): Internal object node ID
|
||||
- `objectKey` (string): Object key (e.g., "doc:secret")
|
||||
- `rule` (Object): Rule configuration object
|
||||
- `visited` (Set): Set of visited nodes for cycle detection
|
||||
- `currentRelation` (string): Current relation being evaluated
|
||||
- `options` (Object): Evaluation options
|
||||
|
||||
**Returns:** `RuleEvaluationResult` object
|
||||
|
||||
**Standard Options:**
|
||||
```javascript
|
||||
{
|
||||
// Performance options
|
||||
fastPath: false, // Enable early termination
|
||||
minAllowPossibility: null, // Threshold for allow early exit (0-1)
|
||||
maxDenyPossibility: null, // Threshold for deny early exit (0-1)
|
||||
|
||||
// Inference options
|
||||
noInfer: false, // Disable inference
|
||||
allowInference: true, // Allow inference (opposite of noInfer)
|
||||
|
||||
// Binary mode
|
||||
binary: false, // Return binary allow/deny decisions
|
||||
|
||||
// Evaluation tracking
|
||||
trackEvaluation: true, // Include evaluation metadata
|
||||
|
||||
// Rule-specific options (passed through)
|
||||
// ... additional options specific to rule type
|
||||
}
|
||||
```
|
||||
|
||||
### Batch Evaluation Method (Optional)
|
||||
|
||||
```javascript
|
||||
batchEvaluate(queries, batchOptions = {})
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `queries` (Array): Array of query objects with same structure as `evaluate` parameters
|
||||
- `batchOptions` (Object): Batch-specific options
|
||||
|
||||
**Returns:** Array of `RuleEvaluationResult` objects
|
||||
|
||||
**Query Object Structure:**
|
||||
```javascript
|
||||
{
|
||||
userId: number,
|
||||
userKey: string,
|
||||
objectId: number,
|
||||
objectKey: string,
|
||||
rule: Object,
|
||||
visited: Set,
|
||||
currentRelation: string,
|
||||
options: Object
|
||||
}
|
||||
```
|
||||
|
||||
### Batch Support Check
|
||||
|
||||
```javascript
|
||||
canBatchProcess()
|
||||
```
|
||||
|
||||
**Returns:** `boolean` - Whether this rule supports efficient batch processing
|
||||
|
||||
## Standard Result Structure
|
||||
|
||||
All evaluation methods must return a `RuleEvaluationResult` object:
|
||||
|
||||
```javascript
|
||||
{
|
||||
// Core evaluation results (required)
|
||||
possibility_allow: number, // 0-1, possibility of allowing access
|
||||
possibility_deny: number, // 0-1, possibility of denying access
|
||||
reliability: number, // 0-1, reliability of the evaluation
|
||||
|
||||
// Metadata (required, can be null)
|
||||
meta_allow: Object | null, // Metadata for allow decision
|
||||
meta_deny: Object | null, // Metadata for deny decision
|
||||
|
||||
// Optional fields
|
||||
reason: string, // Reason for the result
|
||||
error: boolean, // Whether this is an error result
|
||||
details: Object, // Additional details
|
||||
evaluation: Object // Evaluation tracking data
|
||||
}
|
||||
```
|
||||
|
||||
### Standard Metadata Structure
|
||||
|
||||
```javascript
|
||||
// meta_allow / meta_deny structure
|
||||
{
|
||||
ruleType: string, // Type of rule (e.g., 'direct', 'tuple_to_userset')
|
||||
reason: string, // Reason for decision
|
||||
rule: Object, // Original rule configuration
|
||||
|
||||
// Performance metadata
|
||||
earlyExit: boolean, // Whether early exit was used
|
||||
earlyExitReason: string, // Reason for early exit
|
||||
|
||||
// Rule-specific metadata
|
||||
// ... additional fields specific to rule type
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Requirements
|
||||
|
||||
### Abstract Methods (Must Implement)
|
||||
|
||||
```javascript
|
||||
// Core evaluation logic
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options)
|
||||
|
||||
// Batch evaluation logic (optional, only if canBatchProcess() returns true)
|
||||
_batchEvaluateRule(queries, batchOptions)
|
||||
```
|
||||
|
||||
### Performance Tracking
|
||||
|
||||
All rules automatically track:
|
||||
- Individual evaluation count and timing
|
||||
- Batch evaluation count and timing
|
||||
- Average evaluation times
|
||||
|
||||
Access via:
|
||||
```javascript
|
||||
rule.getPerformanceStats()
|
||||
rule.resetPerformanceStats()
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Rules should handle errors gracefully:
|
||||
- Input validation is automatic
|
||||
- Exceptions are caught and converted to error results
|
||||
- Batch operations fall back to individual evaluations on error
|
||||
|
||||
## Rule-Specific Configurations
|
||||
|
||||
### Common Rule Properties
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: string, // Rule type identifier
|
||||
ruleType: string, // 'defeasible', 'defeater', 'strict'
|
||||
priority: number, // Rule priority (higher = more important)
|
||||
weight: number, // Rule weight for aggregation
|
||||
|
||||
// Performance options
|
||||
no_infer: boolean, // Disable inference for this rule
|
||||
allowInference: boolean, // Allow inference
|
||||
|
||||
// Aggregation options
|
||||
owaWeights: Array<number>, // OWA aggregation weights
|
||||
aggregator: string, // Aggregation method ('max', 'min', 'avg', etc.)
|
||||
|
||||
// Rule-specific properties
|
||||
// ... varies by rule type
|
||||
}
|
||||
```
|
||||
|
||||
### DirectRule Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'direct',
|
||||
relation: string, // Relation to check (optional, uses currentRelation)
|
||||
reverse: boolean // Check in reverse direction
|
||||
}
|
||||
```
|
||||
|
||||
### TupleToUsersetRule Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'tuple_to_userset',
|
||||
tuplesetRelation: string, // Relation from object to intermediate
|
||||
computedRelation: string, // Relation from user to intermediate
|
||||
reverse: boolean // Check in reverse direction
|
||||
}
|
||||
```
|
||||
|
||||
### ParentRule Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'parent',
|
||||
parentRelation: string, // Relation defining parent-child
|
||||
relation: string, // Relation to check on parent
|
||||
reverse: boolean // Check in reverse direction
|
||||
}
|
||||
```
|
||||
|
||||
### SimilarityRule Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'similar_to',
|
||||
relation: string, // Relation to find similarities for
|
||||
k: number, // Number of similar entities to consider
|
||||
similarityThreshold: number, // Minimum similarity threshold (0-1)
|
||||
reverse: boolean, // Check in reverse direction
|
||||
fallbackToBasicSimilarity: boolean // Use basic similarity when embeddings unavailable
|
||||
}
|
||||
```
|
||||
|
||||
### MultiHopRule Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'multi_hop',
|
||||
relation: string, // Relation to traverse
|
||||
maxDepth: number, // Maximum path depth
|
||||
minReliability: number, // Minimum path reliability
|
||||
decayFactor: number, // Reliability decay per hop
|
||||
pathAggregation: string, // How to aggregate multiple paths ('max', 'sum', 'owa')
|
||||
reverse: boolean, // Search in reverse direction
|
||||
allowInference: boolean, // Allow inference for missing edges
|
||||
fallbackToBasicPaths: boolean // Use basic path finding as fallback
|
||||
}
|
||||
```
|
||||
|
||||
### ComputedRule Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'computed',
|
||||
relation: string // Relation to recursively evaluate
|
||||
}
|
||||
```
|
||||
|
||||
## Logical Operators
|
||||
|
||||
### Union (OR) Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
union: Array<RuleConfig>, // Array of rules to OR together
|
||||
aggregator: string, // Aggregation method
|
||||
owaWeights: Array<number>, // OWA weights for fusion
|
||||
reliabilityWeighting: boolean // Weight by reliability
|
||||
}
|
||||
```
|
||||
|
||||
### Intersection (AND) Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
intersection: Array<RuleConfig>, // Array of rules to AND together
|
||||
aggregator: string, // Aggregation method (defaults to 'min')
|
||||
owaWeights: Array<number> // OWA weights for fusion
|
||||
}
|
||||
```
|
||||
|
||||
### Exclusion (A AND NOT B) Configuration
|
||||
|
||||
```javascript
|
||||
{
|
||||
exclusion: [RuleConfig, RuleConfig] // [base rule, exclusion rule]
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimization Guidelines
|
||||
|
||||
### Early Exit Support
|
||||
|
||||
Rules should support early termination when:
|
||||
- `options.fastPath` is true
|
||||
- `options.minAllowPossibility` threshold is met
|
||||
- `options.maxDenyPossibility` threshold is met
|
||||
|
||||
### Batch Processing
|
||||
|
||||
Rules that can benefit from batch processing should:
|
||||
- Override `canBatchProcess()` to return `true`
|
||||
- Implement `_batchEvaluateRule()` method
|
||||
- Group similar operations for efficiency
|
||||
- Use indexed lookups instead of linear scans
|
||||
|
||||
### Caching
|
||||
|
||||
Rules should leverage:
|
||||
- Arbiter's built-in caching mechanisms
|
||||
- Batch caches for intermediate results
|
||||
- Embedding caches for similarity calculations
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
All rules must include:
|
||||
- Unit tests for individual evaluation
|
||||
- Batch evaluation tests (if supported)
|
||||
- Performance benchmarks
|
||||
- Error handling tests
|
||||
- Edge case coverage
|
||||
|
||||
## Migration Guide
|
||||
|
||||
To migrate existing rules to the new API:
|
||||
|
||||
1. **Extend BaseRule**: Change `export class MyRule` to `export class MyRule extends BaseRule`
|
||||
2. **Rename evaluate**: Rename `evaluate()` to `_evaluateRule()`
|
||||
3. **Update constructor**: Call `super(arbiter)` first
|
||||
4. **Handle options**: Use normalized options parameter
|
||||
5. **Update tests**: Test against new interface
|
||||
|
||||
## Example Implementation
|
||||
|
||||
```javascript
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
|
||||
export class ExampleRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
// Rule-specific initialization
|
||||
}
|
||||
|
||||
canBatchProcess() {
|
||||
return true; // This rule supports batching
|
||||
}
|
||||
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
// Implement rule-specific logic
|
||||
const result = {
|
||||
possibility_allow: 0.8,
|
||||
possibility_deny: 0.1,
|
||||
reliability: 0.9,
|
||||
meta_allow: {
|
||||
ruleType: 'example',
|
||||
reason: 'example_evaluation',
|
||||
rule
|
||||
},
|
||||
meta_deny: null,
|
||||
reason: 'example_result'
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
_batchEvaluateRule(queries, batchOptions) {
|
||||
// Implement efficient batch processing
|
||||
return queries.map(query =>
|
||||
this._evaluateRule(
|
||||
query.userId,
|
||||
query.userKey,
|
||||
query.objectId,
|
||||
query.objectKey,
|
||||
query.rule,
|
||||
query.visited,
|
||||
query.currentRelation,
|
||||
query.options
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This standardized API ensures all rules work consistently while allowing for rule-specific optimizations and features.
|
||||
@@ -0,0 +1,686 @@
|
||||
import { OWAFusion } from '../../utils/OWAFusion.js';
|
||||
import { BilatticeOrderings } from '../../qualitative/BilatticeOrderings.js';
|
||||
import { QualitativeCapacity } from '../../qualitative/QualitativeCapacity.js';
|
||||
import { QualitativeScale } from '../../qualitative/QualitativeScale.js';
|
||||
import { UnifiedEvidenceFusion } from '../../qualitative/UnifiedEvidenceFusion.js';
|
||||
|
||||
/**
|
||||
* BaseRule - Standard interface for all authorization rules
|
||||
*
|
||||
* This abstract base class defines the standard API that all rule implementations
|
||||
* must follow to ensure consistency, performance, and maintainability.
|
||||
*
|
||||
* STANDARDIZED RESULT STRUCTURE:
|
||||
* All rules return both authorization results AND collected values:
|
||||
* {
|
||||
* // AUTHORIZATION (core purpose)
|
||||
* possibility_allow: number, // 0-1, aggregated possibility across paths
|
||||
* possibility_deny: number, // 0-1, denial confidence
|
||||
* reliability: number, // 0-1, confidence in evaluation
|
||||
*
|
||||
* // VALUE COLLECTION (optional metadata)
|
||||
* collectedValues: [ // Array of individual values with paths
|
||||
* {
|
||||
* value: number, // The actual value
|
||||
* possibility: number, // Individual confidence in this value
|
||||
* path: string[], // Full traversal path to this value
|
||||
* source: { // Where value came from
|
||||
* entityKey: string,
|
||||
* relation: string,
|
||||
* step: number
|
||||
* },
|
||||
* metadata: { // Rich metadata
|
||||
* timestamp: number,
|
||||
* reliability: number,
|
||||
* decay: object
|
||||
* }
|
||||
* }
|
||||
* ],
|
||||
*
|
||||
* // EXISTING FIELDS
|
||||
* meta_allow: object,
|
||||
* meta_deny: object,
|
||||
* reason: string
|
||||
* }
|
||||
*/
|
||||
export class BaseRule {
|
||||
constructor(arbiter) {
|
||||
if (new.target === BaseRule) {
|
||||
throw new Error('BaseRule is abstract and cannot be instantiated directly');
|
||||
}
|
||||
|
||||
this.arbiter = arbiter;
|
||||
this.ruleType = this.constructor.name.replace('Rule', '').toLowerCase();
|
||||
|
||||
// Performance tracking
|
||||
this.evaluationCount = 0;
|
||||
this.totalEvaluationTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard rule evaluation interface
|
||||
*
|
||||
* @param {number} userId - Internal user node ID
|
||||
* @param {string} userKey - User key (e.g., "user:alice")
|
||||
* @param {number} objectId - Internal object node ID
|
||||
* @param {string} objectKey - Object key (e.g., "doc:secret")
|
||||
* @param {Object} rule - Rule configuration object
|
||||
* @param {Set} visited - Set of visited nodes for cycle detection
|
||||
* @param {string} currentRelation - Current relation being evaluated
|
||||
* @param {Object} options - Evaluation options
|
||||
* @returns {RuleEvaluationResult} Standardized result object
|
||||
*/
|
||||
evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
const startTime = Date.now();
|
||||
this.evaluationCount++;
|
||||
|
||||
try {
|
||||
// Validate inputs
|
||||
const validation = this._validateInputs(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options);
|
||||
if (!validation.valid) {
|
||||
return this._createErrorResult(validation.reason, validation.details);
|
||||
}
|
||||
|
||||
// Normalize options
|
||||
const normalizedOptions = this._normalizeOptions(options, rule);
|
||||
|
||||
// Check for early termination conditions
|
||||
const earlyExit = this._checkEarlyExit(normalizedOptions, rule);
|
||||
if (earlyExit) {
|
||||
return earlyExit;
|
||||
}
|
||||
|
||||
// Delegate to concrete implementation
|
||||
const result = this._evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, normalizedOptions);
|
||||
|
||||
// Post-process result
|
||||
const finalResult = this._postProcessResult(result, rule, normalizedOptions);
|
||||
|
||||
// Track performance
|
||||
this.totalEvaluationTime += Date.now() - startTime;
|
||||
|
||||
return finalResult;
|
||||
|
||||
} catch (error) {
|
||||
this.totalEvaluationTime += Date.now() - startTime;
|
||||
return this._createErrorResult('evaluation_error', { error: error.message, stack: error.stack });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics for this rule
|
||||
* @returns {Object} Performance metrics
|
||||
*/
|
||||
getPerformanceStats() {
|
||||
return {
|
||||
ruleType: this.ruleType,
|
||||
evaluationCount: this.evaluationCount,
|
||||
totalEvaluationTime: this.totalEvaluationTime,
|
||||
averageEvaluationTime: this.evaluationCount > 0 ? this.totalEvaluationTime / this.evaluationCount : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset performance counters
|
||||
*/
|
||||
resetPerformanceStats() {
|
||||
this.evaluationCount = 0;
|
||||
this.totalEvaluationTime = 0;
|
||||
}
|
||||
|
||||
// ========== HELPER METHODS FOR STANDARDIZED RESULTS ==========
|
||||
|
||||
/**
|
||||
* Create standardized rule result with collected values
|
||||
* @protected
|
||||
*/
|
||||
_createStandardResult(authResult, collectedValues = []) {
|
||||
const result = {
|
||||
// AUTHORIZATION
|
||||
possibility: authResult.possibility || 0,
|
||||
reliability: authResult.reliability !== undefined ? authResult.reliability : 1.0,
|
||||
|
||||
// BINARY MODE FIELDS
|
||||
possibility_allow: authResult.possibility_allow !== undefined ? authResult.possibility_allow : (authResult.possibility || 0),
|
||||
possibility_deny: authResult.possibility_deny !== undefined ? authResult.possibility_deny : 0,
|
||||
|
||||
// VALUE COLLECTION
|
||||
collectedValues: collectedValues,
|
||||
|
||||
// EXISTING FIELDS
|
||||
meta: authResult.meta || null,
|
||||
meta_allow: authResult.meta_allow,
|
||||
meta_deny: authResult.meta_deny,
|
||||
remediation: authResult.remediation,
|
||||
reason: authResult.reason
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a collected value entry with full metadata
|
||||
* @protected
|
||||
*/
|
||||
_createCollectedValue(value, possibility, path, source, metadata = {}) {
|
||||
return {
|
||||
value: value,
|
||||
possibility: possibility ?? 1.0,
|
||||
path: Array.isArray(path) ? [...path] : [path],
|
||||
source: {
|
||||
...source,
|
||||
entityKey: source.entityKey,
|
||||
relation: source.relation,
|
||||
step: source.step !== undefined ? source.step : 0
|
||||
},
|
||||
metadata: {
|
||||
timestamp: metadata.timestamp || Date.now(),
|
||||
reliability: metadata.reliability !== undefined ? metadata.reliability : 1.0,
|
||||
decay: metadata.decay || null,
|
||||
...metadata
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick reachability failure check for early termination
|
||||
* @protected
|
||||
* @param {string} sourceKey - Source entity key
|
||||
* @param {string} targetKey - Target entity key
|
||||
* @param {Object} options - Options (direction: 'forward' | 'backward')
|
||||
* @returns {boolean|null} - true if reachable, false if not reachable, null if no reachability checker
|
||||
*/
|
||||
_quickReachabilityCheck(sourceKey, targetKey, options = {}) {
|
||||
if (!this.arbiter.reachabilityChecker) {
|
||||
return null; // No reachability checker available
|
||||
}
|
||||
|
||||
try {
|
||||
// Get node IDs for PLTC query
|
||||
const sourceId = this.arbiter.resolveNodeId(sourceKey, options);
|
||||
const targetId = this.arbiter.resolveNodeId(targetKey, options);
|
||||
|
||||
if (sourceId === undefined || targetId === undefined) {
|
||||
return false; // Nodes don't exist
|
||||
}
|
||||
|
||||
// Use reachability checker with direction option for PLTC
|
||||
return this.arbiter.reachabilityChecker.isReachable(sourceId, targetId, options);
|
||||
} catch (error) {
|
||||
console.warn(`Reachability check failed for ${sourceKey} -> ${targetKey}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick reachability failure check with early termination
|
||||
* Returns a standardized result for unreachable cases
|
||||
* @protected
|
||||
* @param {string} sourceKey - Source entity key
|
||||
* @param {string} targetKey - Target entity key
|
||||
* @param {string} reason - Reason for the check
|
||||
* @returns {Object|null} - Standardized result if unreachable, null if reachable or no checker
|
||||
*/
|
||||
_quickReachabilityFailure(sourceKey, targetKey, reason = 'Not reachable via reachability index', options = {}) {
|
||||
const isReachable = this._quickReachabilityCheck(sourceKey, targetKey, options);
|
||||
|
||||
if (isReachable === false) {
|
||||
// Quick failure - not reachable
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
possibility_allow: 0,
|
||||
possibility_deny: 1.0,
|
||||
reliability: 1.0,
|
||||
reason: reason,
|
||||
meta: {
|
||||
method: 'reachability_quick_fail',
|
||||
sourceKey,
|
||||
targetKey,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return null; // Reachable or no checker available
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch reachability check for multiple source-target pairs
|
||||
* @protected
|
||||
* @param {Array} pairs - Array of {sourceKey, targetKey} objects
|
||||
* @returns {Object} - Map of reachability results
|
||||
*/
|
||||
_batchReachabilityCheck(pairs) {
|
||||
if (!this.arbiter.reachabilityChecker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = {};
|
||||
|
||||
for (const {sourceKey, targetKey} of pairs) {
|
||||
try {
|
||||
results[`${sourceKey}->${targetKey}`] = this.arbiter.isReachable(sourceKey, targetKey);
|
||||
} catch (error) {
|
||||
console.warn(`Batch reachability check failed for ${sourceKey} -> ${targetKey}:`, error);
|
||||
results[`${sourceKey}->${targetKey}`] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reachable nodes for a source with quick failure check
|
||||
* @protected
|
||||
* @param {string} sourceKey - Source entity key
|
||||
* @param {number} maxResults - Maximum number of results
|
||||
* @returns {Array|null} - Array of reachable node keys or null if no checker
|
||||
*/
|
||||
_getReachableNodes(sourceKey, maxResults = 1000) {
|
||||
if (!this.arbiter.reachabilityChecker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.arbiter.getReachableNodes(sourceKey, maxResults);
|
||||
} catch (error) {
|
||||
console.warn(`Get reachable nodes failed for ${sourceKey}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nodes that can reach a target with quick failure check
|
||||
* @protected
|
||||
* @param {string} targetKey - Target entity key
|
||||
* @param {number} maxResults - Maximum number of results
|
||||
* @returns {Array|null} - Array of reaching node keys or null if no checker
|
||||
*/
|
||||
_getReachingNodes(targetKey, maxResults = 1000) {
|
||||
if (!this.arbiter.reachabilityChecker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.arbiter.getReachingNodes(targetKey, maxResults);
|
||||
} catch (error) {
|
||||
console.warn(`Get reaching nodes failed for ${targetKey}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate collected values for consumption by other rules using OWAFusion
|
||||
* @protected
|
||||
*/
|
||||
_aggregateCollectedValues(collectedValues, method = 'sum', options = {}) {
|
||||
if (!collectedValues || collectedValues.length === 0) {
|
||||
return { value: null, hasValue: false };
|
||||
}
|
||||
|
||||
const values = collectedValues.map(cv => cv.value);
|
||||
const possibilities = collectedValues.map(cv => cv.possibility);
|
||||
const metas = collectedValues.map(cv => ({
|
||||
path: cv.path,
|
||||
source: cv.source,
|
||||
reliability: cv.metadata?.reliability || 1.0,
|
||||
timestamp: cv.metadata?.timestamp,
|
||||
originalValue: cv.value,
|
||||
originalPossibility: cv.possibility
|
||||
}));
|
||||
|
||||
// Use OWAFusion for aggregation
|
||||
const valueResult = OWAFusion.fuseWithMeta(values, metas, null, method);
|
||||
const possibilityResult = OWAFusion.fuseWithMeta(possibilities, metas, null, method);
|
||||
|
||||
return {
|
||||
value: valueResult.value,
|
||||
possibility: possibilityResult.value,
|
||||
hasValue: true,
|
||||
aggregationMeta: {
|
||||
method: method,
|
||||
sourceCount: collectedValues.length,
|
||||
sourcePaths: collectedValues.map(cv => cv.path),
|
||||
selectedSource: valueResult.meta,
|
||||
valueContribution: valueResult.value,
|
||||
possibilityContribution: possibilityResult.value,
|
||||
allValues: values,
|
||||
allPossibilities: possibilities
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bilattice-enhanced evidence combination for rigorous epistemic reasoning
|
||||
*
|
||||
* This method provides sophisticated evidence fusion using bilattice orderings
|
||||
* while maintaining compatibility with existing possibilistic infrastructure.
|
||||
*
|
||||
* @param {Array} collectedValues - Array of collected values with metadata
|
||||
* @param {Object} options - Combination options
|
||||
* @param {string} options.method - Fusion method ('max', 'min', 'majority', etc.)
|
||||
* @param {boolean} options.useBilattice - Whether to use bilattice reasoning (default: false)
|
||||
* @param {QualitativeCapacity} options.capacity - Capacity for bilattice analysis
|
||||
* @param {QualitativeScale} options.scale - Qualitative scale for bilattice operations
|
||||
* @param {string} options.epistemicMode - 'information', 'truth', or 'hybrid' (default: 'hybrid')
|
||||
* @returns {Object} Enhanced fusion result with epistemic analysis
|
||||
* @protected
|
||||
*/
|
||||
_combineEvidenceWithBilattice(collectedValues, options = {}) {
|
||||
const {
|
||||
method = 'max',
|
||||
useBilattice = false,
|
||||
capacity = null,
|
||||
scale = null,
|
||||
epistemicMode = 'hybrid',
|
||||
reconciliationMethod = 'bilattice',
|
||||
capacityType = 'simple_support',
|
||||
mode = 'qualitative'
|
||||
} = options;
|
||||
|
||||
if (!useBilattice) {
|
||||
return this._aggregateCollectedValues(collectedValues, method, options);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the unified evidence fusion system
|
||||
const fusionResult = UnifiedEvidenceFusion.fuse(collectedValues, {
|
||||
mode,
|
||||
aggregationMethod: method,
|
||||
reconciliationMethod: useBilattice ? reconciliationMethod : 'none',
|
||||
epistemicMode,
|
||||
capacityType,
|
||||
scale: scale || QualitativeScale.fivePoint(),
|
||||
useReconciliation: useBilattice,
|
||||
reliabilityWeighting: options.reliabilityWeighting || false
|
||||
});
|
||||
|
||||
return {
|
||||
value: fusionResult.value,
|
||||
possibility: fusionResult.possibility,
|
||||
hasValue: fusionResult.hasValue,
|
||||
aggregationMeta: {
|
||||
method: fusionResult.fusionMethod,
|
||||
aggregationMethod: fusionResult.aggregationMethod,
|
||||
reconciliationMethod: fusionResult.reconciliationMethod,
|
||||
selectedSource: {
|
||||
type: 'unified_evidence_fusion',
|
||||
mode,
|
||||
epistemicMode,
|
||||
capacityType
|
||||
}
|
||||
},
|
||||
epistemicAnalysis: fusionResult.epistemicAnalysis
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Unified evidence fusion failed, falling back to standard aggregation:', error);
|
||||
return this._aggregateCollectedValues(collectedValues, method, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a qualitative capacity from collected values for bilattice analysis
|
||||
*
|
||||
* @param {Array} collectedValues - Array of collected values
|
||||
* @param {QualitativeScale} scale - Qualitative scale to use
|
||||
* @param {string} capacityType - Type of capacity ('simple_support', 'possibility', 'necessity')
|
||||
* @returns {QualitativeCapacity} Capacity for bilattice analysis
|
||||
* @protected
|
||||
*/
|
||||
_createCapacityFromValues(collectedValues, scale, capacityType = 'simple_support') {
|
||||
if (!collectedValues || collectedValues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create state space from unique values
|
||||
const uniqueValues = [...new Set(collectedValues.map(cv => cv.value))];
|
||||
const stateSpace = uniqueValues.map((_, index) => `evidence_${index}`);
|
||||
|
||||
switch (capacityType) {
|
||||
case 'simple_support':
|
||||
// Create simple support capacity for each piece of evidence
|
||||
const qmt = new Map();
|
||||
collectedValues.forEach((cv, index) => {
|
||||
const evidenceSet = new Set([`evidence_${index}`]);
|
||||
qmt.set(evidenceSet, cv.possibility);
|
||||
});
|
||||
return new QualitativeCapacity(stateSpace, scale, qmt);
|
||||
|
||||
case 'possibility':
|
||||
// Create possibility measure (all focal sets are singletons)
|
||||
const possibilityQmt = new Map();
|
||||
collectedValues.forEach((cv, index) => {
|
||||
const singletonSet = new Set([`evidence_${index}`]);
|
||||
possibilityQmt.set(singletonSet, cv.possibility);
|
||||
});
|
||||
return new QualitativeCapacity(stateSpace, scale, possibilityQmt);
|
||||
|
||||
case 'necessity':
|
||||
// Create necessity measure (nested focal sets)
|
||||
const necessityQmt = new Map();
|
||||
const sortedValues = collectedValues
|
||||
.map((cv, index) => ({ value: cv.value, possibility: cv.possibility, index }))
|
||||
.sort((a, b) => b.possibility - a.possibility);
|
||||
|
||||
sortedValues.forEach((item, rank) => {
|
||||
const nestedSet = new Set(sortedValues.slice(0, rank + 1).map(sv => `evidence_${sv.index}`));
|
||||
necessityQmt.set(nestedSet, item.possibility);
|
||||
});
|
||||
return new QualitativeCapacity(stateSpace, scale, necessityQmt);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown capacity type: ${capacityType}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== PROTECTED METHODS (to be implemented by subclasses) ==========
|
||||
|
||||
/**
|
||||
* Concrete rule evaluation implementation
|
||||
* @protected
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
throw new Error(`${this.constructor.name} must implement _evaluateRule()`);
|
||||
}
|
||||
|
||||
// ========== PRIVATE HELPER METHODS ==========
|
||||
|
||||
/**
|
||||
* Validate input parameters
|
||||
* @private
|
||||
*/
|
||||
_validateInputs(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
// Check for required parameters
|
||||
if (userId === undefined || userId === null) {
|
||||
return { valid: false, reason: 'missing_user_id', details: { userKey } };
|
||||
}
|
||||
|
||||
if (objectId === undefined || objectId === null) {
|
||||
return { valid: false, reason: 'missing_object_id', details: { objectKey } };
|
||||
}
|
||||
|
||||
if (!userKey || typeof userKey !== 'string') {
|
||||
return { valid: false, reason: 'invalid_user_key', details: { userKey } };
|
||||
}
|
||||
|
||||
if (!objectKey || typeof objectKey !== 'string') {
|
||||
return { valid: false, reason: 'invalid_object_key', details: { objectKey } };
|
||||
}
|
||||
|
||||
if (!rule || typeof rule !== 'object') {
|
||||
return { valid: false, reason: 'invalid_rule', details: { rule } };
|
||||
}
|
||||
|
||||
if (!visited || typeof visited.has !== 'function') {
|
||||
return { valid: false, reason: 'invalid_visited_set', details: { visited } };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and validate options
|
||||
* @private
|
||||
*/
|
||||
_normalizeOptions(options, rule) {
|
||||
const normalized = {
|
||||
// Performance options
|
||||
fastPath: options.fastPath || false,
|
||||
minPossibility: options.minPossibility || null,
|
||||
maxPossibility: options.maxPossibility || null,
|
||||
|
||||
// Inference options
|
||||
noInfer: options.noInfer || options.no_infer || rule.no_infer || false,
|
||||
allowInference: options.allowInference !== false && !options.noInfer && !options.no_infer && !rule.no_infer,
|
||||
|
||||
// Binary mode
|
||||
binary: options.binary || false,
|
||||
|
||||
// Evaluation tracking
|
||||
trackEvaluation: options.trackEvaluation !== false,
|
||||
|
||||
// Rule-specific options (pass through)
|
||||
...options
|
||||
};
|
||||
|
||||
// Validate threshold values
|
||||
if (normalized.minPossibility !== null) {
|
||||
normalized.minPossibility = Math.max(0, Math.min(1, normalized.minPossibility));
|
||||
}
|
||||
|
||||
if (normalized.maxPossibility !== null) {
|
||||
normalized.maxPossibility = Math.max(0, Math.min(1, normalized.maxPossibility));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for early exit conditions
|
||||
* @private
|
||||
*/
|
||||
_checkEarlyExit(options, rule) {
|
||||
// Cycle detection is handled at a higher level
|
||||
// Rule-specific early exits should be implemented in _evaluateRule
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process evaluation result
|
||||
* @private
|
||||
*/
|
||||
_postProcessResult(result, rule, options) {
|
||||
const includeMeta = options.includeMeta !== undefined ? options.includeMeta : true;
|
||||
const includeValues = options.collectValues !== undefined ? options.collectValues : true;
|
||||
const { meta, meta_allow, meta_deny, collectedValues, ...rest } = result;
|
||||
|
||||
// Ensure result has required fields with standardized structure
|
||||
const processed = {
|
||||
// AUTHORIZATION
|
||||
possibility: result.possibility || 0,
|
||||
reliability: result.reliability !== undefined ? result.reliability : 1.0,
|
||||
|
||||
// EXISTING FIELDS
|
||||
reason: result.reason,
|
||||
|
||||
// PRESERVE OTHER FIELDS (for backward compatibility)
|
||||
...rest
|
||||
};
|
||||
|
||||
if (includeValues) {
|
||||
processed.collectedValues = collectedValues || [];
|
||||
}
|
||||
|
||||
if (includeMeta) {
|
||||
processed.meta = meta || null;
|
||||
if (meta_allow !== undefined) {
|
||||
processed.meta_allow = meta_allow;
|
||||
}
|
||||
if (meta_deny !== undefined) {
|
||||
processed.meta_deny = meta_deny;
|
||||
}
|
||||
}
|
||||
|
||||
// Add evaluation metadata if tracking is enabled
|
||||
if (options.trackEvaluation && includeMeta && result.evaluation) {
|
||||
processed.evaluation = {
|
||||
ruleType: this.ruleType,
|
||||
evaluationTime: Date.now() - (result.evaluation.evaluationStarted || Date.now()),
|
||||
...result.evaluation
|
||||
};
|
||||
}
|
||||
|
||||
// Apply early exit metadata if applicable
|
||||
if (options.fastPath && includeMeta && this._shouldMarkEarlyExit(processed, options)) {
|
||||
if (processed.meta) {
|
||||
processed.meta.earlyExit = true;
|
||||
processed.meta.earlyExitReason = this._getEarlyExitReason(processed, options);
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standardized error result
|
||||
* @private
|
||||
*/
|
||||
_createErrorResult(reason, details = {}) {
|
||||
return {
|
||||
possibility_allow: 0,
|
||||
possibility_deny: 0,
|
||||
reliability: 1.0,
|
||||
meta_allow: null,
|
||||
meta_deny: null,
|
||||
reason,
|
||||
error: true,
|
||||
details
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if result should be marked as early exit
|
||||
* @private
|
||||
*/
|
||||
_shouldMarkEarlyExit(result, options) {
|
||||
if (options.minAllowPossibility !== null && result.possibility_allow >= options.minAllowPossibility) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.maxDenyPossibility !== null && result.possibility_deny >= options.maxDenyPossibility) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get early exit reason
|
||||
* @private
|
||||
*/
|
||||
_getEarlyExitReason(result, options) {
|
||||
if (options.minAllowPossibility !== null && result.possibility_allow >= options.minAllowPossibility) {
|
||||
return 'allow_threshold_met';
|
||||
}
|
||||
|
||||
if (options.maxDenyPossibility !== null && result.possibility_deny >= options.maxDenyPossibility) {
|
||||
return 'deny_threshold_met';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard result structure for rule evaluations
|
||||
* @typedef {Object} RuleEvaluationResult
|
||||
* @property {number} possibility_allow - Possibility of allowing access (0-1)
|
||||
* @property {number} possibility_deny - Possibility of denying access (0-1)
|
||||
* @property {number} reliability - Reliability of the evaluation (0-1)
|
||||
* @property {Object|null} meta_allow - Metadata for allow decision
|
||||
* @property {Object|null} meta_deny - Metadata for deny decision
|
||||
* @property {string} reason - Reason for the result (optional)
|
||||
* @property {boolean} error - Whether this is an error result (optional)
|
||||
* @property {Object} details - Additional details (optional)
|
||||
* @property {Object} evaluation - Evaluation tracking data (optional)
|
||||
*/
|
||||
@@ -0,0 +1,693 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { Arbiter } from '../../core/Arbiter.js';
|
||||
import { OWAFusion } from '../../utils/OWAFusion.js';
|
||||
import { BilatticeOrderings } from '../../qualitative/BilatticeOrderings.js';
|
||||
import { QualitativeCapacity } from '../../qualitative/QualitativeCapacity.js';
|
||||
import { QualitativeScale } from '../../qualitative/QualitativeScale.js';
|
||||
|
||||
/**
|
||||
* ChainRule - Evaluates access by following a chain of relations and collecting values along the path
|
||||
*
|
||||
* This rule enables traversing through multiple entities via relations and collects
|
||||
* values from each step in the path. Perfect for scenarios like:
|
||||
* "Sum balances from all accounts user can debit from"
|
||||
*
|
||||
* CLEAN SEMANTICS:
|
||||
* - Authorization: Based on path reachability to target entity (returns raw possibility)
|
||||
* - Value Collection: Collects values along the path with full path tracking
|
||||
* - Uses ValueContext for efficient caching and aggregation
|
||||
*
|
||||
* Path Semantics:
|
||||
* - Along chain: MIN operator (possibilistic conjunction)
|
||||
* - Across paths: MAX/OWA operator (disjunctive)
|
||||
* - Values: Collected with full path metadata for aggregation at logical level
|
||||
*
|
||||
* Configuration:
|
||||
* {
|
||||
* type: 'chain',
|
||||
* steps: [
|
||||
* { relation: 'can_debit', direction: 'out' }, // user → accounts
|
||||
* { relation: 'has_balance', direction: 'out' } // accounts → currency
|
||||
* ],
|
||||
* // Value collection (optional - defaults to enabled)
|
||||
* collectValues: true, // Whether to collect values (default: true)
|
||||
* valueFilters: { // Optional filters for value collection
|
||||
* steps: [0, 1], // Which steps to collect from (default: all)
|
||||
* relations: ['has_balance'], // Which relations to collect from (default: step relations)
|
||||
* minValue: 0, // Minimum value threshold
|
||||
* maxValue: 1000 // Maximum value threshold
|
||||
* },
|
||||
* valueAggregation: 'sum', // How to pre-aggregate VALUES ('sum', 'max', 'min', 'average')
|
||||
*
|
||||
* // Standard rule fields
|
||||
* reverse: false // Reverse traversal direction (default: false)
|
||||
* }
|
||||
*/
|
||||
export class ChainRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
|
||||
// Chain-specific caching with HyperbolicLRUCache for better memory management
|
||||
this.maxCacheSize = 2000;
|
||||
this.cacheTTL = 600000; // 10 minutes
|
||||
this.pathCacheTTL = 300000; // 5 minutes
|
||||
|
||||
// Only create caches if caching is not disabled
|
||||
if (!arbiter.disableCaching && !arbiter.disableChainCaching) {
|
||||
// Chain result cache with HyperbolicLRUCache
|
||||
this.chainResultCache = arbiter.cacheFactory(this.maxCacheSize, {
|
||||
onEvict: (key, value) => {
|
||||
// Optional: track evictions for debugging
|
||||
this.stats = this.stats || {};
|
||||
this.stats.evictedResults = (this.stats.evictedResults || 0) + 1;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.chainResultCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate chain rule with smart reachability optimization
|
||||
* Uses reachability check as a hint, but doesn't fail fast if TreeCover index is uncertain
|
||||
*/
|
||||
evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
// Normalize rule to get reverse flag
|
||||
const normalizedRule = this._normalizeRule(rule);
|
||||
const { reverse = false } = normalizedRule;
|
||||
|
||||
// Check if PLTC bypass is requested (for testing/ground truth computation)
|
||||
// Also skip when a partial graph is present — PLTC is built from persistent data only
|
||||
const { bypassPLTC = false } = options;
|
||||
const hasPartialGraph = !!options.partialGraphContext;
|
||||
|
||||
if (!bypassPLTC && !hasPartialGraph) {
|
||||
// Smart reachability check: PLTC is 100% accurate, so we can fail fast on false
|
||||
// Use backward index for reverse chains
|
||||
const direction = reverse ? 'backward' : 'forward';
|
||||
const reachabilityResult = this._quickReachabilityCheck(userKey, objectKey, { direction });
|
||||
|
||||
if (reachabilityResult === false) {
|
||||
// PLTC is 100% accurate - if it says false, definitely no path exists
|
||||
// Fast fail for unreachable cases
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
reliability: 1.0,
|
||||
...(options.includeMeta && {
|
||||
meta: {
|
||||
method: 'pltc_fast_fail',
|
||||
reason: 'not_reachable',
|
||||
sourceKey: userKey,
|
||||
targetKey: objectKey,
|
||||
direction
|
||||
}
|
||||
}),
|
||||
reason: 'not_reachable'
|
||||
}, []);
|
||||
}
|
||||
|
||||
// If reachabilityResult is true, path exists but we still need to check relation types
|
||||
// If null, PLTC not initialized, proceed with chain evaluation
|
||||
}
|
||||
|
||||
// Proceed with chain evaluation (either PLTC said true/null, or bypassPLTC is enabled)
|
||||
return this._evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate chain traversal and value collection using ValueContext
|
||||
* @protected
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
const { fastPath, minPossibility, valueContext, includeMeta = true } = options;
|
||||
// Normalize rule configuration
|
||||
const normalizedRule = this._normalizeRule(rule);
|
||||
const {
|
||||
steps,
|
||||
collectValues = true, // Default to true
|
||||
valueFilters = {},
|
||||
valueAggregation = 'sum',
|
||||
reverse = false
|
||||
} = normalizedRule;
|
||||
const collectValuesEnabled = collectValues && !!valueContext;
|
||||
|
||||
if (!steps || steps.length === 0) {
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
reliability: 1.0,
|
||||
...(includeMeta && { meta: null }),
|
||||
reason: 'no_chain_steps_defined'
|
||||
}, []);
|
||||
}
|
||||
|
||||
// Convert string keys to numeric IDs if needed
|
||||
const userIdNum = typeof userId === 'string' ? this.arbiter.resolveNodeId(userId, options) : userId;
|
||||
const objectIdNum = typeof objectId === 'string' ? this.arbiter.resolveNodeId(objectId, options) : objectId;
|
||||
|
||||
const hasPartialGraph = !!(options.partialGraphContext);
|
||||
|
||||
// Threshold-mode (binary / fastPath-with-threshold) evaluations collapse
|
||||
// sub-threshold paths to 0 and early-exit; their results are NOT
|
||||
// interchangeable with full-mode values. The shared chain result cache
|
||||
// must be neither consulted nor populated in threshold mode, or binary
|
||||
// checks get served full-mode values (and vice versa).
|
||||
const isThresholdEval = options.binary === true || (options.fastPath === true && options.minPossibility != null);
|
||||
|
||||
// Check for cached chain result (use numeric IDs) - only if caching is enabled
|
||||
// Skip cache when a partial graph is present to prevent cross-request leakage
|
||||
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval) {
|
||||
const cachedResult = this._getCachedChainResult(userIdNum, objectIdNum, steps);
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine starting point
|
||||
let startId, startKey;
|
||||
if (reverse) {
|
||||
startId = objectIdNum;
|
||||
startKey = objectKey;
|
||||
} else {
|
||||
startId = userIdNum;
|
||||
startKey = userKey;
|
||||
}
|
||||
|
||||
// Track all paths through the chain with their accumulated possibilities
|
||||
let currentPaths = [{
|
||||
id: startId,
|
||||
key: startKey,
|
||||
possibility: 1.0,
|
||||
path: [startKey], // Track the full path
|
||||
pathEntities: [{ id: startId, key: startKey, source: 'persistent' }]
|
||||
}];
|
||||
|
||||
let allCollectedValues = [];
|
||||
|
||||
// Traverse through each step
|
||||
for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
|
||||
// Normalize string steps (emitted by the DSL compiler as
|
||||
// ['works_in','has_access']) to the object form the traversal expects.
|
||||
const rawStep = steps[stepIndex];
|
||||
const step = typeof rawStep === 'string'
|
||||
? { relation: rawStep, direction: 'out' }
|
||||
: rawStep;
|
||||
const { relation: stepRelation, direction } = step;
|
||||
|
||||
if (!stepRelation || !direction) {
|
||||
currentPaths = [];
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
// For each current path, extend it via the relation
|
||||
const pathMap = new Map();
|
||||
const MAX_PATHS_PER_STEP = 100;
|
||||
|
||||
for (const currentPath of currentPaths) {
|
||||
if (pathMap.size >= MAX_PATHS_PER_STEP) break;
|
||||
|
||||
const relations = this._getRelationsForStep(currentPath.id, stepRelation, direction, options);
|
||||
|
||||
for (const rel of relations) {
|
||||
const nextId = direction === 'in' ? rel.src : rel.dst;
|
||||
const nextKey = this.arbiter.resolveKey(nextId, options);
|
||||
|
||||
if (nextKey) {
|
||||
// Calculate path possibility (MIN along chain)
|
||||
const nextPossibility = Math.min(currentPath.possibility, rel.possibility ?? 1.0);
|
||||
|
||||
if (fastPath && nextPossibility < minPossibility) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect values from this step BEFORE path deduplication:
|
||||
// a weaker parallel path is still a valid value source, and
|
||||
// dropping it first would silently discard its contribution.
|
||||
if (collectValuesEnabled && this._shouldCollectFromStep(stepIndex, valueFilters)) {
|
||||
const stepValues = this._collectValuesFromStep(
|
||||
currentPath,
|
||||
rel,
|
||||
stepIndex,
|
||||
stepRelation,
|
||||
direction,
|
||||
valueFilters,
|
||||
valueContext,
|
||||
options
|
||||
);
|
||||
allCollectedValues.push(...stepValues);
|
||||
}
|
||||
|
||||
// Deduplicate: keep best path per node
|
||||
const existing = pathMap.get(nextId);
|
||||
if (existing && existing.possibility >= nextPossibility) continue;
|
||||
|
||||
// Create extended path
|
||||
const extendedPath = {
|
||||
id: nextId,
|
||||
key: nextKey,
|
||||
possibility: nextPossibility,
|
||||
path: [...currentPath.path, nextKey],
|
||||
pathEntities: [...currentPath.pathEntities, { id: nextId, key: nextKey, source: rel.source || 'persistent' }]
|
||||
};
|
||||
|
||||
pathMap.set(nextId, extendedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentPaths = Array.from(pathMap.values());
|
||||
|
||||
|
||||
// Early exit if no paths found
|
||||
if (currentPaths.length === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if target was reached and calculate final possibility
|
||||
const targetKey = reverse ? userKey : objectKey;
|
||||
const targetId = reverse ? userIdNum : objectIdNum;
|
||||
const targetPaths = currentPaths.filter(path => path.id === targetId);
|
||||
let finalPossibility = 0;
|
||||
|
||||
if (targetPaths.length > 0) {
|
||||
// Use MAX across paths (disjunctive)
|
||||
finalPossibility = Math.max(...targetPaths.map(path => path.possibility));
|
||||
}
|
||||
|
||||
if (fastPath && finalPossibility < minPossibility) {
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
reliability: 1.0,
|
||||
meta: null,
|
||||
reason: 'no_chain_path_found'
|
||||
}, []);
|
||||
}
|
||||
// Add collected values to ValueContext if available
|
||||
if (valueContext && allCollectedValues.length > 0) {
|
||||
valueContext.addCollectedValues(allCollectedValues, 'chain', rule);
|
||||
}
|
||||
|
||||
// Apply value aggregation with optional bilattice reasoning
|
||||
const useBilattice = normalizedRule.useBilattice || false;
|
||||
const epistemicMode = normalizedRule.epistemicMode || 'hybrid';
|
||||
const capacityType = normalizedRule.capacityType || 'simple_support';
|
||||
|
||||
let aggregatedCollectedValues;
|
||||
let epistemicAnalysis = null;
|
||||
|
||||
if (useBilattice && allCollectedValues.length > 0) {
|
||||
const scale = QualitativeScale.fivePoint(); // Default scale for bilattice analysis
|
||||
const capacity = this._createCapacityFromValues(allCollectedValues, scale, capacityType);
|
||||
|
||||
const bilatticeResult = this._combineEvidenceWithBilattice(allCollectedValues, {
|
||||
method: valueAggregation,
|
||||
useBilattice: true,
|
||||
capacity: capacity,
|
||||
scale: scale,
|
||||
epistemicMode: epistemicMode
|
||||
});
|
||||
|
||||
// Convert bilattice result back to collected values format
|
||||
aggregatedCollectedValues = [{
|
||||
value: bilatticeResult.value,
|
||||
possibility: bilatticeResult.possibility,
|
||||
path: ['bilattice_aggregated'],
|
||||
source: {
|
||||
entityKey: 'chain_aggregation',
|
||||
relation: 'bilattice_fusion',
|
||||
step: -1
|
||||
},
|
||||
metadata: {
|
||||
timestamp: Date.now(),
|
||||
reliability: 1.0,
|
||||
aggregationMethod: `bilattice_${epistemicMode}`,
|
||||
epistemicAnalysis: bilatticeResult.epistemicAnalysis
|
||||
}
|
||||
}];
|
||||
|
||||
epistemicAnalysis = bilatticeResult.epistemicAnalysis;
|
||||
} else {
|
||||
aggregatedCollectedValues = this._aggregateCollectedValues(
|
||||
allCollectedValues,
|
||||
valueAggregation
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Build authorization result with single possibility value
|
||||
const authResult = {
|
||||
possibility: finalPossibility,
|
||||
reliability: 1.0,
|
||||
...(includeMeta && {
|
||||
meta: finalPossibility > 0 ? {
|
||||
ruleType: 'chain',
|
||||
reason: 'chain_path_found',
|
||||
rule,
|
||||
chainLength: steps.length,
|
||||
pathsToTarget: targetPaths.length,
|
||||
totalPaths: currentPaths.length,
|
||||
bestPath: targetPaths.length > 0 ? targetPaths[0].path : null,
|
||||
bestPathEntities: targetPaths.length > 0 ? targetPaths[0].pathEntities : null,
|
||||
pathSteps: targetPaths.length > 0 ? targetPaths[0].pathEntities : null,
|
||||
useBilattice: useBilattice,
|
||||
epistemicMode: useBilattice ? epistemicMode : undefined,
|
||||
epistemicAnalysis: epistemicAnalysis
|
||||
} : null
|
||||
}),
|
||||
...(includeMeta && {
|
||||
meta_allow: finalPossibility > 0 ? {
|
||||
ruleType: 'chain',
|
||||
reason: 'chain_path_found',
|
||||
rule,
|
||||
chainLength: steps.length,
|
||||
pathsToTarget: targetPaths.length,
|
||||
totalPaths: currentPaths.length,
|
||||
bestPath: targetPaths.length > 0 ? targetPaths[0].path : null,
|
||||
bestPathEntities: targetPaths.length > 0 ? targetPaths[0].pathEntities : null,
|
||||
pathSteps: targetPaths.length > 0 ? targetPaths[0].pathEntities : null
|
||||
} : null
|
||||
}),
|
||||
reason: finalPossibility > 0 ? 'chain_path_found' : 'no_chain_path_found'
|
||||
};
|
||||
|
||||
const result = this._createStandardResult(authResult, aggregatedCollectedValues);
|
||||
|
||||
// Cache the chain result (use numeric IDs) - only if caching is enabled
|
||||
// Do not cache when a partial graph is present to prevent cross-request leakage
|
||||
// Do not cache threshold-mode results (see isThresholdEval above)
|
||||
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval) {
|
||||
this._cacheChainResult(userIdNum, objectIdNum, steps, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relations for a step based on direction
|
||||
* @private
|
||||
*/
|
||||
_getRelationsForStep(entityId, relation, direction, options = null) {
|
||||
if (direction === 'out') {
|
||||
return this.arbiter.relationManager.getRelationsFromSrc(entityId, relation, options);
|
||||
} else if (direction === 'in') {
|
||||
return this.arbiter.relationManager.getRelationsToDst(entityId, relation, options);
|
||||
} else {
|
||||
// Default to 'out' for backward compatibility
|
||||
return this.arbiter.relationManager.getRelationsFromSrc(entityId, relation, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for chain result using numeric IDs
|
||||
* @private
|
||||
*/
|
||||
_getChainResultCacheKey(userId, objectId, steps) {
|
||||
return this.arbiter.keyManager.createChainKey(userId, objectId, steps);
|
||||
}
|
||||
|
||||
_getCachedChainResult(userId, objectId, steps) {
|
||||
if (!this.chainResultCache) return null;
|
||||
|
||||
const key = this._getChainResultCacheKey(userId, objectId, steps);
|
||||
const entry = this.chainResultCache.get(key);
|
||||
|
||||
if (entry && Date.now() - entry.timestamp < this.cacheTTL) {
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
// HyperbolicLRUCache handles eviction automatically, no need to manually delete
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache chain result
|
||||
* @private
|
||||
*/
|
||||
_cacheChainResult(userId, objectId, steps, result) {
|
||||
if (!this.chainResultCache) return;
|
||||
|
||||
const key = this._getChainResultCacheKey(userId, objectId, steps);
|
||||
|
||||
// HyperbolicLRUCache handles eviction automatically based on frequency and recency
|
||||
this.chainResultCache.set(key, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
_invalidateAllChainCaches() {
|
||||
if (this.chainResultCache) {
|
||||
this.chainResultCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should collect values from this step
|
||||
* @private
|
||||
*/
|
||||
_shouldCollectFromStep(stepIndex, valueFilters) {
|
||||
if (!valueFilters) return true;
|
||||
|
||||
// Check step filter
|
||||
if (valueFilters.steps && Array.isArray(valueFilters.steps)) {
|
||||
return valueFilters.steps.includes(stepIndex);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect values from a single step in the chain
|
||||
* @private
|
||||
*/
|
||||
_collectValuesFromStep(currentPath, relation, stepIndex, stepRelation, direction, valueFilters, valueContext, options = null) {
|
||||
const collectedValues = [];
|
||||
|
||||
// Check if this relation should be collected based on filters
|
||||
if (valueFilters.relations && Array.isArray(valueFilters.relations)) {
|
||||
if (!valueFilters.relations.includes(stepRelation)) {
|
||||
return collectedValues;
|
||||
}
|
||||
}
|
||||
|
||||
// If relation has a value, collect it as a blurred interval
|
||||
if (relation.value !== undefined && relation.value !== null) {
|
||||
// Apply value filters
|
||||
if (valueFilters.minValue !== undefined && relation.value < valueFilters.minValue) {
|
||||
return collectedValues;
|
||||
}
|
||||
if (valueFilters.maxValue !== undefined && relation.value > valueFilters.maxValue) {
|
||||
return collectedValues;
|
||||
}
|
||||
|
||||
// Let ValueManager handle age-based blurring instead of hard TTL filtering
|
||||
// The ValueManager will apply appropriate blurring based on relation age
|
||||
|
||||
// Get blurred interval from ValueManager
|
||||
if (!relation) {
|
||||
return collectedValues;
|
||||
}
|
||||
|
||||
if (!this.arbiter.valueManager) {
|
||||
return collectedValues;
|
||||
}
|
||||
|
||||
const blurred = this.arbiter.valueManager.getBlurredValue(relation);
|
||||
|
||||
if (blurred.interval) {
|
||||
const sourceEntity = direction === 'in' ?
|
||||
this.arbiter.resolveKey(relation.dst, options) :
|
||||
this.arbiter.resolveKey(relation.src, options);
|
||||
const targetEntity = direction === 'in' ?
|
||||
this.arbiter.resolveKey(relation.src, options) :
|
||||
this.arbiter.resolveKey(relation.dst, options);
|
||||
|
||||
const collectedValue = this._createCollectedValue(
|
||||
blurred.interval, // Pass interval instead of point value
|
||||
blurred.possibility, // Use decayed possibility
|
||||
currentPath.path,
|
||||
{
|
||||
entityKey: sourceEntity,
|
||||
relation: stepRelation,
|
||||
step: stepIndex,
|
||||
direction: direction,
|
||||
fullPath: currentPath.path,
|
||||
stepPosition: stepIndex,
|
||||
originalValue: relation.value, // Keep original point value for reference
|
||||
source: relation.source || 'persistent'
|
||||
},
|
||||
{
|
||||
timestamp: relation.changed_last_at || relation.updated_last_at || Date.now(),
|
||||
reliability: blurred.reliability,
|
||||
pathPossibility: currentPath.possibility,
|
||||
relationPossibility: relation.possibility ?? 1.0,
|
||||
currentPossibility: blurred.possibility, // Add current (decayed) possibility
|
||||
interval: blurred.interval, // Include interval in metadata
|
||||
source: relation.source || 'persistent'
|
||||
}
|
||||
);
|
||||
|
||||
collectedValues.push(collectedValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Also try to get values from ValueContext if available
|
||||
if (valueContext && currentPath.pathEntities.length > stepIndex) {
|
||||
const entity = currentPath.pathEntities[stepIndex];
|
||||
const contextValues = valueContext.getValues(entity.id, stepRelation);
|
||||
|
||||
for (const contextValue of contextValues) {
|
||||
// Apply value filters
|
||||
if (valueFilters.minValue !== undefined && contextValue.value < valueFilters.minValue) {
|
||||
continue;
|
||||
}
|
||||
if (valueFilters.maxValue !== undefined && contextValue.value > valueFilters.maxValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Let ValueManager handle age-based blurring for ValueContext values too
|
||||
|
||||
// Create a temporary relation object for ValueManager
|
||||
const tempRelation = {
|
||||
src: entity.id,
|
||||
dst: entity.id, // Self-relation for value storage
|
||||
rel: stepRelation,
|
||||
value: contextValue.value,
|
||||
possibility: contextValue.possibility,
|
||||
reliability: contextValue.reliability,
|
||||
changed_last_at: contextValue.timestamp
|
||||
};
|
||||
|
||||
if (!tempRelation) {
|
||||
Arbiter.DEBUG && Arbiter.log('ChainRule: tempRelation is undefined, skipping value collection');
|
||||
return collectedValues;
|
||||
}
|
||||
|
||||
if (!this.arbiter.relationManager || !this.arbiter.relationManager.valueManager) {
|
||||
Arbiter.DEBUG && Arbiter.log('ChainRule: relationManager or valueManager is undefined');
|
||||
return collectedValues;
|
||||
}
|
||||
const blurred = this.arbiter.relationManager.valueManager.getBlurredValue(tempRelation);
|
||||
|
||||
if (blurred.interval) {
|
||||
const collectedValue = this._createCollectedValue(
|
||||
blurred.interval,
|
||||
Math.min(currentPath.possibility, blurred.possibility),
|
||||
currentPath.path,
|
||||
{
|
||||
entityKey: entity.key,
|
||||
relation: stepRelation,
|
||||
step: stepIndex,
|
||||
direction: direction,
|
||||
fullPath: currentPath.path,
|
||||
stepPosition: stepIndex,
|
||||
fromValueContext: true,
|
||||
originalValue: contextValue.value
|
||||
},
|
||||
{
|
||||
timestamp: contextValue.timestamp,
|
||||
reliability: blurred.reliability,
|
||||
pathPossibility: currentPath.possibility,
|
||||
relationPossibility: contextValue.possibility,
|
||||
currentPossibility: blurred.possibility,
|
||||
interval: blurred.interval,
|
||||
source: contextValue.source || 'persistent'
|
||||
}
|
||||
);
|
||||
|
||||
collectedValues.push(collectedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collectedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate collected values based on aggregation method using interval arithmetic
|
||||
* @private
|
||||
*/
|
||||
_aggregateCollectedValues(collectedValues, aggregationMethod) {
|
||||
if (collectedValues.length === 0) return [];
|
||||
if (collectedValues.length === 1) return collectedValues;
|
||||
|
||||
// Group values by path for aggregation
|
||||
const valuesByPath = new Map();
|
||||
|
||||
for (const cv of collectedValues) {
|
||||
const pathKey = cv.source?.fullPath?.join('->') || 'unknown_path';
|
||||
if (!valuesByPath.has(pathKey)) {
|
||||
valuesByPath.set(pathKey, []);
|
||||
}
|
||||
valuesByPath.get(pathKey).push(cv);
|
||||
}
|
||||
|
||||
const aggregatedValues = [];
|
||||
|
||||
for (const [pathKey, pathValues] of valuesByPath) {
|
||||
if (pathValues.length === 1) {
|
||||
aggregatedValues.push(pathValues[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract intervals and metadata for OWA fusion
|
||||
const intervals = pathValues.map(cv => cv.value); // cv.value is already an interval
|
||||
const possibilities = pathValues.map(cv => cv.possibility);
|
||||
const metas = pathValues.map(cv => ({
|
||||
...cv.source,
|
||||
...cv.metadata,
|
||||
originalInterval: cv.value
|
||||
}));
|
||||
|
||||
// Use OWAFusion's interval arithmetic
|
||||
const intervalResult = OWAFusion.fuseIntervalsWithMeta(
|
||||
intervals,
|
||||
metas,
|
||||
null, // Use default weights
|
||||
aggregationMethod
|
||||
);
|
||||
|
||||
// Aggregate possibilities
|
||||
const possibilityResult = OWAFusion.fuseWithMeta(
|
||||
possibilities,
|
||||
metas,
|
||||
null,
|
||||
aggregationMethod === 'sum' ? 'average' : aggregationMethod // For sum, average possibilities
|
||||
);
|
||||
|
||||
// Create aggregated collected value
|
||||
const aggregatedCV = this._createCollectedValue(
|
||||
intervalResult.interval,
|
||||
possibilityResult.value,
|
||||
pathValues[0].path,
|
||||
{
|
||||
...pathValues[0].source,
|
||||
aggregationMethod,
|
||||
aggregatedFromCount: pathValues.length
|
||||
},
|
||||
{
|
||||
...pathValues[0].metadata,
|
||||
reliability: intervalResult.meta?.reliability ||
|
||||
Math.min(...pathValues.map(cv => cv.metadata?.reliability || 1.0)),
|
||||
aggregatedFromIntervals: intervals,
|
||||
interval: intervalResult.interval
|
||||
}
|
||||
);
|
||||
|
||||
aggregatedValues.push(aggregatedCV);
|
||||
}
|
||||
|
||||
return aggregatedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize rule configuration (for future extensibility)
|
||||
* @private
|
||||
*/
|
||||
_normalizeRule(rule) {
|
||||
if (rule.chain) return { ...rule, ...rule.chain, chain: undefined };
|
||||
return rule;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { buildRemediationFromChallenges } from '../remediation.js';
|
||||
|
||||
export class ChallengeRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
}
|
||||
|
||||
evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
const { includeMeta = true } = options;
|
||||
const subjectKey = this._resolveSubjectKey(rule, userKey, objectKey, options);
|
||||
const partialContext = options.partialGraphContext || null;
|
||||
const challenge = rule.challenge || rule.name || rule.relation || currentRelation;
|
||||
const withinMs = this._resolveWithinMs(rule);
|
||||
|
||||
if (!partialContext || !subjectKey || !challenge) {
|
||||
const required = [this._buildRequirement(challenge, subjectKey, withinMs, 'missing_context')];
|
||||
const remediation = buildRemediationFromChallenges(required);
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
remediation,
|
||||
meta_allow: {
|
||||
ruleType: 'challenge',
|
||||
remediation
|
||||
},
|
||||
reason: 'challenge_missing_context'
|
||||
});
|
||||
}
|
||||
|
||||
const subjectId = this.arbiter.resolveNodeId(subjectKey, { partialGraphContext: partialContext });
|
||||
const now = Date.now();
|
||||
const proof = partialContext.getChallengeProof(challenge, subjectId, withinMs, now);
|
||||
|
||||
if (!proof) {
|
||||
const required = [this._buildRequirement(challenge, subjectKey, withinMs, 'missing')];
|
||||
const remediation = buildRemediationFromChallenges(required);
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
remediation,
|
||||
meta_allow: {
|
||||
ruleType: 'challenge',
|
||||
remediation
|
||||
},
|
||||
reason: 'challenge_missing'
|
||||
});
|
||||
}
|
||||
|
||||
return this._createStandardResult({
|
||||
possibility: 1,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
ruleType: 'challenge',
|
||||
reason: 'challenge_satisfied',
|
||||
challenge,
|
||||
subject: subjectKey,
|
||||
issuedAt: proof.issuedAt,
|
||||
expiresAt: proof.expiresAt || null
|
||||
}
|
||||
}),
|
||||
reason: 'challenge_satisfied'
|
||||
});
|
||||
}
|
||||
|
||||
_resolveSubjectKey(rule, userKey, objectKey, options) {
|
||||
if (rule.subjectKey) return rule.subjectKey;
|
||||
const subject = rule.subject || 'user';
|
||||
if (subject === 'object') return objectKey;
|
||||
if (subject === 'session') return options.sessionKey || userKey;
|
||||
return userKey;
|
||||
}
|
||||
|
||||
_resolveWithinMs(rule) {
|
||||
if (rule.withinMs !== undefined && rule.withinMs !== null) return rule.withinMs;
|
||||
if (rule.withinSeconds !== undefined && rule.withinSeconds !== null) return rule.withinSeconds * 1000;
|
||||
if (rule.withinMinutes !== undefined && rule.withinMinutes !== null) return rule.withinMinutes * 60 * 1000;
|
||||
if (rule.withinHours !== undefined && rule.withinHours !== null) return rule.withinHours * 60 * 60 * 1000;
|
||||
return null;
|
||||
}
|
||||
|
||||
_buildRequirement(challenge, subjectKey, withinMs, status) {
|
||||
return {
|
||||
name: challenge,
|
||||
subject: subjectKey,
|
||||
withinMs: withinMs || null,
|
||||
status
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { extractRemediation } from '../remediation.js';
|
||||
|
||||
/**
|
||||
* ComputedRule - Recursively evaluates a relation through the authorization checker
|
||||
*
|
||||
* This rule delegates to the main authorization checker to recursively evaluate
|
||||
* a computed relation. It's essentially a way to invoke the full authorization
|
||||
* logic as part of a rule evaluation.
|
||||
*
|
||||
* Configuration:
|
||||
* {
|
||||
* type: 'computed',
|
||||
* relation: string // Relation to recursively evaluate
|
||||
* }
|
||||
*/
|
||||
export class ComputedRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate computed relation by delegating to authorization checker
|
||||
* @protected
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
const computedRelation = rule.relation;
|
||||
|
||||
// Track evaluation details if requested
|
||||
const evaluation = options.trackEvaluation ? {
|
||||
type: 'computed',
|
||||
userKey,
|
||||
objectKey,
|
||||
computedRelation,
|
||||
evaluationStarted: Date.now()
|
||||
} : null;
|
||||
|
||||
// Delegate to authorization checker with proper context (including valueContext)
|
||||
const res = this.arbiter.authChecker.check(userKey, computedRelation, objectKey, {
|
||||
...options,
|
||||
_visited: visited,
|
||||
_currentRelation: currentRelation
|
||||
});
|
||||
|
||||
const remediation = extractRemediation(res);
|
||||
|
||||
if (evaluation) {
|
||||
evaluation.evaluationCompleted = Date.now();
|
||||
evaluation.evaluationDuration = evaluation.evaluationCompleted - evaluation.evaluationStarted;
|
||||
evaluation.delegatedResult = res;
|
||||
}
|
||||
|
||||
// Convert the check result to standardized rule evaluation format
|
||||
const result = {
|
||||
possibility: res.possibility || 0,
|
||||
reliability: res.reliability !== undefined ? res.reliability : 1.0,
|
||||
// Propagate collected values from delegated result
|
||||
collectedValues: res.collectedValues || [],
|
||||
...(options.includeMeta && {
|
||||
meta: {
|
||||
...(res.meta || {}),
|
||||
...(remediation ? { remediation } : {}),
|
||||
ruleType: 'computed',
|
||||
computedRelation,
|
||||
delegated: true
|
||||
}
|
||||
}),
|
||||
...(remediation ? { remediation } : {}),
|
||||
reason: res.reason || 'computed_delegation'
|
||||
};
|
||||
|
||||
if (evaluation) {
|
||||
result.evaluation = evaluation;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { Arbiter } from '../../core/Arbiter.js';
|
||||
|
||||
/**
|
||||
* DirectRule - Checks for direct relationships between user and object
|
||||
*
|
||||
* This rule evaluates direct relations in the graph, optionally in reverse direction.
|
||||
* It supports efficient batch processing and early exit optimizations.
|
||||
*
|
||||
* Returns standardized results with raw possibility values:
|
||||
* - possibility_allow: The strength/possibility of the relation (0.0 to 1.0)
|
||||
* - possibility_deny: Always 0 (DirectRule only reports relation strength, not polarity)
|
||||
* - Collected Values: Values from relations with full path metadata
|
||||
*
|
||||
* Note: This rule returns raw relation strength. The logical context (defeater, strict,
|
||||
* defeasible, etc.) determines how this strength is interpreted as positive or negative evidence.
|
||||
*
|
||||
* Configuration:
|
||||
* {
|
||||
* type: 'direct',
|
||||
* relation: string, // Optional: relation to check (uses currentRelation if not specified)
|
||||
* reverse: boolean, // Optional: check in reverse direction (default: false)
|
||||
* collectValues: boolean // Optional: collect values from relations (default: true if relation has values)
|
||||
* }
|
||||
*/
|
||||
export class DirectRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate method returns raw relation strength
|
||||
*/
|
||||
evaluate(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
const { fastPath = false, minPossibility = null, collectValues: collectValuesOption, includeMeta = true } = options;
|
||||
|
||||
const relName = rule.relation || rule.rel || rule.label || rule.name || currentRelation;
|
||||
const reverse = rule.reverse;
|
||||
const collectValues = collectValuesOption !== undefined ? collectValuesOption : rule.collectValues !== false;
|
||||
|
||||
let directRel;
|
||||
if (reverse) {
|
||||
directRel = this.arbiter.relationManager.getDirectRelation(objectId, relName, userId, options);
|
||||
} else {
|
||||
directRel = this.arbiter.relationManager.getDirectRelation(userId, relName, objectId, options);
|
||||
}
|
||||
|
||||
if (!directRel) {
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
ruleType: 'direct',
|
||||
reason: 'no_relation'
|
||||
}
|
||||
})
|
||||
}, []);
|
||||
}
|
||||
|
||||
const relationStrength = directRel.possibility;
|
||||
const _source = directRel.source || 'persistent';
|
||||
const _allowMeta = {
|
||||
ruleType: 'direct',
|
||||
reason: 'direct',
|
||||
source: _source,
|
||||
layer_name: directRel.layer_name || null,
|
||||
source_class: directRel.source_class || null,
|
||||
reducer_applied: directRel.reducer_applied || null
|
||||
};
|
||||
|
||||
const authResult = {
|
||||
possibility: relationStrength,
|
||||
possibility_allow: relationStrength, // For binary mode
|
||||
possibility_deny: 0, // DirectRule doesn't deny
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
ruleType: 'direct',
|
||||
reason: 'relation_exists',
|
||||
rule,
|
||||
relation: relName,
|
||||
reverse: reverse || false,
|
||||
strength: relationStrength,
|
||||
source: _source,
|
||||
allow: _allowMeta
|
||||
},
|
||||
meta_allow: _allowMeta
|
||||
}),
|
||||
reason: 'exists'
|
||||
};
|
||||
|
||||
// Collect values if relation has them and collection is enabled
|
||||
let collectedValues = [];
|
||||
if (collectValues && directRel.value !== undefined) {
|
||||
const sourceEntity = reverse ? objectKey : userKey;
|
||||
const targetEntity = reverse ? userKey : objectKey;
|
||||
const path = [sourceEntity, targetEntity];
|
||||
|
||||
collectedValues.push(this._createCollectedValue(
|
||||
directRel.value,
|
||||
directRel.possibility,
|
||||
path,
|
||||
{
|
||||
entityKey: sourceEntity,
|
||||
relation: relName,
|
||||
step: 0
|
||||
},
|
||||
{
|
||||
timestamp: directRel.changed_last_at || directRel.updated_last_at || Date.now(),
|
||||
reliability: 1.0,
|
||||
source: directRel.source || 'persistent'
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
// Apply early exit logic if thresholds are enabled
|
||||
if (fastPath) {
|
||||
// Early exit based on relation strength threshold
|
||||
if (minPossibility !== null && authResult.possibility >= minPossibility) {
|
||||
|
||||
if (authResult.meta) {
|
||||
authResult.meta.earlyExit = true;
|
||||
authResult.meta.earlyExitReason = 'strength_threshold_met';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this._createStandardResult(authResult, collectedValues);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,822 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { Arbiter } from '../../core/Arbiter.js';
|
||||
import { OWAFusion } from '../../utils/OWAFusion.js';
|
||||
|
||||
/**
|
||||
* MultiHopRule - Evaluates access through multi-hop path finding and collects values along paths
|
||||
*
|
||||
* This rule implements path-based access control where access is granted if there exists
|
||||
* a valid path of relations from user to object (or vice versa) within specified constraints.
|
||||
* Simultaneously collects values along discovered paths.
|
||||
*
|
||||
* Supports multiple path aggregation strategies,
|
||||
* and fallback to basic connectivity when sophisticated search fails.
|
||||
*
|
||||
* Returns raw possibility values - polarity determined by logical context.
|
||||
*
|
||||
* Configuration:
|
||||
* {
|
||||
* type: 'multi_hop',
|
||||
* relation: string, // Relation to traverse
|
||||
* maxDepth: number, // Maximum path depth (default: 5)
|
||||
* pathAggregation: string, // 'max', 'sum', 'owa' (default: 'max')
|
||||
* reverse: boolean, // Search in reverse direction
|
||||
* fallbackToBasicPaths: boolean, // Use basic BFS fallback (default: true)
|
||||
* owaWeights: Array<number>, // OWA weights for path aggregation
|
||||
*
|
||||
* // Value collection (optional)
|
||||
* collectValues: boolean, // Whether to collect values along paths (default: true)
|
||||
* valueFilters: { // Optional filters for value collection
|
||||
* relations: ['has_balance'], // Which relations to collect from (default: path relation)
|
||||
* minValue: 0, // Minimum value threshold
|
||||
* maxValue: 1000, // Maximum value threshold
|
||||
* },
|
||||
* valueAggregation: 'sum' // How to aggregate values ('sum', 'max', 'min', 'average')
|
||||
* }
|
||||
*/
|
||||
export class MultiHopRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate multi-hop path-based access with value collection and reachability optimization
|
||||
* @protected
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options = {}) {
|
||||
const { fastPath = false, minPossibility = 0, valueContext, includeMeta = true } = options;
|
||||
// Normalize rule configuration
|
||||
const normalizedRule = this._normalizeRule(rule);
|
||||
const {
|
||||
relation,
|
||||
maxDepth = 5,
|
||||
pathAggregation = 'max',
|
||||
reverse = false,
|
||||
fallbackToBasicPaths = true,
|
||||
owaWeights,
|
||||
collectValues = true,
|
||||
valueFilters = {},
|
||||
valueAggregation = 'sum',
|
||||
trackPaths = true,
|
||||
skipReachabilityCheck = false,
|
||||
allowZeroHop = false
|
||||
} = normalizedRule;
|
||||
const shouldSkipReachability = skipReachabilityCheck || !!options.partialGraphContext || (allowZeroHop && userId === objectId);
|
||||
const collectValuesEnabled = collectValues && !!valueContext;
|
||||
const shouldFastExit = fastPath && pathAggregation === 'max' && !collectValuesEnabled && !trackPaths;
|
||||
const stopSignal = shouldFastExit ? { stop: false } : null;
|
||||
|
||||
if (!shouldSkipReachability) {
|
||||
// Quick reachability failure check before expensive path-finding
|
||||
const quickFailure = this._quickReachabilityFailure(
|
||||
userKey,
|
||||
objectKey,
|
||||
'Multi-hop path not reachable via reachability index',
|
||||
reverse ? { direction: 'backward' } : undefined
|
||||
);
|
||||
if (quickFailure) {
|
||||
return quickFailure;
|
||||
}
|
||||
}
|
||||
|
||||
// Early exit for unknown relation
|
||||
if (!relation) {
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
reliability: 1.0,
|
||||
meta: null,
|
||||
reason: 'no_relation_specified'
|
||||
}, []);
|
||||
}
|
||||
|
||||
Arbiter.DEBUG && Arbiter.log('MultiHopRule evaluating:', {
|
||||
userKey,
|
||||
objectKey,
|
||||
relation,
|
||||
maxDepth,
|
||||
pathAggregation,
|
||||
reverse,
|
||||
collectValues,
|
||||
hasValueContext: !!valueContext
|
||||
});
|
||||
|
||||
// Track evaluation details
|
||||
const evaluation = {
|
||||
type: 'multi_hop',
|
||||
userKey,
|
||||
objectKey,
|
||||
relation,
|
||||
maxDepth,
|
||||
searchStarted: Date.now(),
|
||||
pathsFound: 0,
|
||||
fallbackUsed: false
|
||||
};
|
||||
|
||||
// Find paths and collect values in single traversal
|
||||
const pathsWithValues = this._findPathsAndCollectValues(
|
||||
userId,
|
||||
objectId,
|
||||
relation,
|
||||
maxDepth,
|
||||
new Set(),
|
||||
[],
|
||||
1.0,
|
||||
reverse,
|
||||
collectValuesEnabled,
|
||||
trackPaths,
|
||||
stopSignal,
|
||||
valueFilters,
|
||||
valueContext,
|
||||
evaluation,
|
||||
fastPath,
|
||||
minPossibility,
|
||||
options,
|
||||
allowZeroHop
|
||||
);
|
||||
|
||||
evaluation.searchCompleted = Date.now();
|
||||
evaluation.searchDuration = evaluation.searchCompleted - evaluation.searchStarted;
|
||||
evaluation.pathsFound = pathsWithValues.length;
|
||||
|
||||
// If no paths found and fallback enabled, try basic connectivity
|
||||
if (pathsWithValues.length === 0 && fallbackToBasicPaths) {
|
||||
const fallbackResult = this._findBasicPathWithValues(
|
||||
userId, objectId, relation, maxDepth, minPossibility,
|
||||
reverse, collectValuesEnabled, trackPaths, valueFilters, valueContext, evaluation, options, allowZeroHop
|
||||
);
|
||||
if (fallbackResult) {
|
||||
pathsWithValues.push(fallbackResult);
|
||||
evaluation.fallbackUsed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (pathsWithValues.length === 0) {
|
||||
evaluation.outcome = 'no_path_found';
|
||||
|
||||
return this._createStandardResult({
|
||||
possibility: 0,
|
||||
reliability: 1.0,
|
||||
...(includeMeta && { meta: null }),
|
||||
reason: undefined
|
||||
}, []);
|
||||
}
|
||||
|
||||
// Aggregate paths to get final possibility
|
||||
const { finalPossibility, bestPath } = this._aggregatePaths(
|
||||
pathsWithValues, pathAggregation, owaWeights, evaluation, options.trackEvaluation
|
||||
);
|
||||
|
||||
// Collect all values from all paths
|
||||
let allCollectedValues = [];
|
||||
for (const pathResult of pathsWithValues) {
|
||||
if (pathResult.collectedValues && pathResult.collectedValues.length > 0) {
|
||||
allCollectedValues.push(...pathResult.collectedValues);
|
||||
}
|
||||
}
|
||||
|
||||
// Add collected values to ValueContext if available
|
||||
if (valueContext && allCollectedValues.length > 0) {
|
||||
valueContext.addCollectedValues(allCollectedValues, 'multi_hop', rule);
|
||||
}
|
||||
|
||||
// Apply value aggregation if specified
|
||||
const aggregatedCollectedValues = this._aggregateCollectedValues(
|
||||
allCollectedValues,
|
||||
valueAggregation
|
||||
);
|
||||
|
||||
evaluation.outcome = 'path_found';
|
||||
evaluation.finalPossibility = finalPossibility;
|
||||
evaluation.bestPath = bestPath;
|
||||
evaluation.totalValuesCollected = aggregatedCollectedValues.length;
|
||||
|
||||
Arbiter.DEBUG && Arbiter.log('MultiHopRule evaluation complete:', {
|
||||
pathsFound: pathsWithValues.length,
|
||||
finalPossibility,
|
||||
valuesCollected: aggregatedCollectedValues.length,
|
||||
fallbackUsed: evaluation.fallbackUsed
|
||||
});
|
||||
|
||||
// Build authorization result (raw possibility values)
|
||||
const resolvedPathSteps = bestPath && bestPath.pathSteps
|
||||
? bestPath.pathSteps
|
||||
: (pathsWithValues[0] && pathsWithValues[0].pathSteps ? pathsWithValues[0].pathSteps : null);
|
||||
|
||||
const allowMeta = finalPossibility > 0 ? {
|
||||
ruleType: 'multi_hop',
|
||||
reason: 'multi_hop_path_found',
|
||||
rule,
|
||||
pathsFound: pathsWithValues.length,
|
||||
bestPath: bestPath,
|
||||
pathSteps: resolvedPathSteps,
|
||||
fallbackUsed: evaluation.fallbackUsed,
|
||||
evaluation
|
||||
} : null;
|
||||
|
||||
const authResult = {
|
||||
possibility: finalPossibility,
|
||||
reliability: 1.0,
|
||||
...(includeMeta && {
|
||||
meta: allowMeta
|
||||
}),
|
||||
...(includeMeta && { meta_allow: allowMeta }),
|
||||
reason: undefined
|
||||
};
|
||||
|
||||
return this._createStandardResult(authResult, aggregatedCollectedValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find paths and collect values in a single traversal
|
||||
* @private
|
||||
*/
|
||||
_findPathsAndCollectValues(startId, endId, relation, maxDepth,
|
||||
visited, currentPath = [], currentPoss = 1.0,
|
||||
reverse = false, collectValues = true,
|
||||
trackPaths = true,
|
||||
stopSignal = null,
|
||||
valueFilters = {}, valueContext = null, evaluation, fastPath = false, minPossibility = 0, options = null,
|
||||
allowZeroHop = false) {
|
||||
if (stopSignal?.stop) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (startId === endId && (allowZeroHop || currentPath.length > 0)) {
|
||||
const pathKeys = trackPaths
|
||||
? [...currentPath.map(step => step.nodeKey), this.arbiter.resolveKey(endId, options)]
|
||||
: null;
|
||||
const pathResult = {
|
||||
nodes: pathKeys,
|
||||
nodeIds: trackPaths ? [...currentPath.map(step => step.nodeId), endId] : [endId],
|
||||
hops: currentPath.length,
|
||||
possibility: currentPoss,
|
||||
collectedValues: [],
|
||||
pathSteps: trackPaths ? [...currentPath] : null
|
||||
};
|
||||
|
||||
// Collect values from the complete path if enabled
|
||||
if (collectValues) {
|
||||
pathResult.collectedValues = this._collectValuesFromPath(
|
||||
currentPath, relation, valueFilters, valueContext
|
||||
);
|
||||
}
|
||||
|
||||
Arbiter.DEBUG && Arbiter.log('Found path with values:', {
|
||||
path: pathKeys,
|
||||
possibility: currentPoss,
|
||||
valuesCollected: pathResult.collectedValues.length
|
||||
});
|
||||
|
||||
if (stopSignal) {
|
||||
stopSignal.stop = true;
|
||||
}
|
||||
return [pathResult];
|
||||
}
|
||||
|
||||
if (maxDepth <= 0 || currentPoss < minPossibility || visited.has(startId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
visited.add(startId);
|
||||
const pathsWithValues = [];
|
||||
|
||||
// Find direct edges using indexed lookups
|
||||
let directEdges;
|
||||
if (reverse) {
|
||||
directEdges = this.arbiter.relationManager.getRelationsToDst(startId, relation, options);
|
||||
} else {
|
||||
directEdges = this.arbiter.relationManager.getRelationsFromSrc(startId, relation, options);
|
||||
}
|
||||
|
||||
Arbiter.DEBUG && Arbiter.log('MultiHop exploring from', this.arbiter.resolveKey(startId, options), 'found direct edges:', directEdges.length);
|
||||
|
||||
// Process direct edges
|
||||
for (const edge of directEdges) {
|
||||
if (stopSignal?.stop) {
|
||||
break;
|
||||
}
|
||||
const nextId = reverse ? edge.src : edge.dst;
|
||||
const nextKey = this.arbiter.resolveKey(nextId, options);
|
||||
if (!nextKey) continue;
|
||||
|
||||
const nextPoss = Math.min(currentPoss, edge.possibility ?? 1.0);
|
||||
if (fastPath && nextPoss < minPossibility) continue;
|
||||
|
||||
const pathStep = (collectValues || trackPaths) ? {
|
||||
nodeId: startId,
|
||||
nodeKey: this.arbiter.resolveKey(startId, options),
|
||||
relation: relation,
|
||||
edge: edge,
|
||||
inferred: false,
|
||||
possibility: edge.possibility ?? 1.0,
|
||||
source: edge.source || 'persistent'
|
||||
} : null;
|
||||
const nextPath = (collectValues || trackPaths) ? [...currentPath, pathStep] : currentPath;
|
||||
|
||||
const subPaths = this._findPathsAndCollectValues(
|
||||
nextId,
|
||||
endId,
|
||||
relation,
|
||||
maxDepth - 1,
|
||||
visited,
|
||||
nextPath,
|
||||
nextPoss,
|
||||
reverse,
|
||||
collectValues,
|
||||
trackPaths,
|
||||
stopSignal,
|
||||
valueFilters,
|
||||
valueContext,
|
||||
evaluation,
|
||||
fastPath,
|
||||
minPossibility,
|
||||
options
|
||||
);
|
||||
|
||||
pathsWithValues.push(...subPaths);
|
||||
}
|
||||
|
||||
visited.delete(startId);
|
||||
return pathsWithValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect values from a complete path
|
||||
* @private
|
||||
*/
|
||||
_collectValuesFromPath(pathSteps, defaultRelation, valueFilters, valueContext) {
|
||||
const collectedValues = [];
|
||||
|
||||
for (let stepIndex = 0; stepIndex < pathSteps.length; stepIndex++) {
|
||||
const step = pathSteps[stepIndex];
|
||||
|
||||
if (step.inferred) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine which relations to collect from
|
||||
const relationsToCollect = valueFilters.relations || [step.relation || defaultRelation];
|
||||
|
||||
for (const relationName of relationsToCollect) {
|
||||
// Collect from direct edge if available
|
||||
if (step.edge && step.edge.value !== undefined && step.edge.value !== null) {
|
||||
if (this._passesValueFilters(step.edge.value, valueFilters)) {
|
||||
// Check TTL
|
||||
const ttl = valueFilters.ttl || 24 * 60 * 60 * 1000;
|
||||
const timestamp = step.edge.changed_last_at || step.edge.updated_last_at || Date.now();
|
||||
|
||||
if (!OWAFusion.isWithinTTL(timestamp, ttl)) {
|
||||
Arbiter.DEBUG && Arbiter.log('MultiHop skipping value due to TTL:', {
|
||||
value: step.edge.value,
|
||||
timestamp,
|
||||
ttl,
|
||||
age: Date.now() - timestamp
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get blurred interval from ValueManager
|
||||
const blurred = this.arbiter.relationManager.valueManager.getBlurredValue(step.edge);
|
||||
|
||||
if (blurred.interval) {
|
||||
const collectedValue = this._createCollectedValue(
|
||||
blurred.interval,
|
||||
blurred.possibility,
|
||||
pathSteps.map(s => s.nodeKey),
|
||||
{
|
||||
entityKey: step.nodeKey,
|
||||
relation: relationName,
|
||||
step: stepIndex,
|
||||
inferred: false,
|
||||
fullPath: pathSteps.map(s => s.nodeKey),
|
||||
stepPosition: stepIndex,
|
||||
originalValue: step.edge.value
|
||||
},
|
||||
{
|
||||
timestamp,
|
||||
pathPossibility: step.possibility,
|
||||
relationPossibility: step.edge.possibility ?? 1.0,
|
||||
currentPossibility: blurred.possibility,
|
||||
interval: blurred.interval
|
||||
}
|
||||
);
|
||||
|
||||
collectedValues.push(collectedValue);
|
||||
|
||||
Arbiter.DEBUG && Arbiter.log('MultiHop collected blurred value from edge:', {
|
||||
originalValue: step.edge.value,
|
||||
interval: blurred.interval,
|
||||
step: stepIndex,
|
||||
relation: relationName,
|
||||
node: step.nodeKey,
|
||||
inferred: false,
|
||||
decayedPossibility: blurred.possibility
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also collect from ValueContext if available
|
||||
if (valueContext) {
|
||||
const contextValues = valueContext.getValues(step.nodeId, relationName);
|
||||
|
||||
for (const contextValue of contextValues) {
|
||||
if (this._passesValueFilters(contextValue.value, valueFilters)) {
|
||||
// Check TTL
|
||||
const ttl = valueFilters.ttl || 24 * 60 * 60 * 1000;
|
||||
if (!OWAFusion.isWithinTTL(contextValue.timestamp, ttl)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create temporary relation for ValueManager
|
||||
const tempRelation = {
|
||||
src: step.nodeId,
|
||||
dst: step.nodeId,
|
||||
rel: relationName,
|
||||
value: contextValue.value,
|
||||
possibility: contextValue.possibility,
|
||||
reliability: contextValue.reliability,
|
||||
changed_last_at: contextValue.timestamp
|
||||
};
|
||||
|
||||
const blurred = this.arbiter.relationManager.valueManager.getBlurredValue(tempRelation);
|
||||
|
||||
if (blurred.interval) {
|
||||
const collectedValue = this._createCollectedValue(
|
||||
blurred.interval,
|
||||
Math.min(step.possibility, blurred.possibility),
|
||||
pathSteps.map(s => s.nodeKey),
|
||||
{
|
||||
entityKey: step.nodeKey,
|
||||
relation: relationName,
|
||||
step: stepIndex,
|
||||
inferred: false,
|
||||
fullPath: pathSteps.map(s => s.nodeKey),
|
||||
stepPosition: stepIndex,
|
||||
fromValueContext: true,
|
||||
originalValue: contextValue.value
|
||||
},
|
||||
{
|
||||
timestamp: contextValue.timestamp,
|
||||
pathPossibility: step.possibility,
|
||||
relationPossibility: contextValue.possibility,
|
||||
currentPossibility: blurred.possibility,
|
||||
interval: blurred.interval
|
||||
}
|
||||
);
|
||||
|
||||
collectedValues.push(collectedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collectedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value passes the filters
|
||||
* @private
|
||||
*/
|
||||
_passesValueFilters(value, valueFilters) {
|
||||
if (valueFilters.minValue !== undefined && value < valueFilters.minValue) {
|
||||
return false;
|
||||
}
|
||||
if (valueFilters.maxValue !== undefined && value > valueFilters.maxValue) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate multiple paths to get final possibility using OWAFusion
|
||||
* @private
|
||||
*/
|
||||
_aggregatePaths(pathsWithValues, pathAggregation, owaWeights, evaluation, trackOwa = false) {
|
||||
if (pathsWithValues.length === 0) {
|
||||
return { finalPossibility: 0, bestPath: null };
|
||||
}
|
||||
|
||||
if (pathsWithValues.length === 1) {
|
||||
const path = pathsWithValues[0];
|
||||
return {
|
||||
finalPossibility: path.possibility,
|
||||
bestPath: path
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare data for OWAFusion
|
||||
const possibilities = pathsWithValues.map(p => p.possibility);
|
||||
const metas = pathsWithValues.map(p => ({
|
||||
path: p.nodes,
|
||||
hops: p.hops,
|
||||
inferred: false,
|
||||
fallback: p.fallback || false,
|
||||
nodeIds: p.nodeIds,
|
||||
collectedValuesCount: p.collectedValues ? p.collectedValues.length : 0,
|
||||
pathPossibility: p.possibility,
|
||||
pathSteps: p.pathSteps || null
|
||||
}));
|
||||
|
||||
// Use OWAFusion for path aggregation
|
||||
let possibilityResult;
|
||||
|
||||
const owaTraceOptions = trackOwa ? { includeTrace: true } : null;
|
||||
|
||||
if (pathAggregation === 'owa' && owaWeights) {
|
||||
// Use custom OWA weights
|
||||
possibilityResult = OWAFusion.fuseWithMeta(possibilities, metas, owaWeights, 'owa', true, owaTraceOptions);
|
||||
|
||||
evaluation.aggregation = {
|
||||
method: 'owa',
|
||||
weights: owaWeights,
|
||||
fusedPossibility: possibilityResult.value,
|
||||
selectedPath: possibilityResult.meta,
|
||||
...(trackOwa && possibilityResult.trace ? {
|
||||
owa: {
|
||||
level: null,
|
||||
aggregator: 'owa',
|
||||
weights: possibilityResult.trace.weights,
|
||||
sortedValues: possibilityResult.trace.sortedValues,
|
||||
contributions: possibilityResult.trace.contributions,
|
||||
selectedIndex: possibilityResult.trace.selectedIndex
|
||||
}
|
||||
} : {})
|
||||
};
|
||||
} else {
|
||||
// Use standard aggregation methods
|
||||
possibilityResult = OWAFusion.fuseWithMeta(possibilities, metas, null, pathAggregation, true, owaTraceOptions);
|
||||
|
||||
evaluation.aggregation = {
|
||||
method: pathAggregation,
|
||||
fusedPossibility: possibilityResult.value,
|
||||
selectedPath: possibilityResult.meta,
|
||||
pathCount: pathsWithValues.length,
|
||||
...(trackOwa && possibilityResult.trace ? {
|
||||
owa: {
|
||||
level: null,
|
||||
aggregator: pathAggregation,
|
||||
weights: possibilityResult.trace.weights,
|
||||
sortedValues: possibilityResult.trace.sortedValues,
|
||||
contributions: possibilityResult.trace.contributions,
|
||||
selectedIndex: possibilityResult.trace.selectedIndex
|
||||
}
|
||||
} : {})
|
||||
};
|
||||
}
|
||||
|
||||
// Find the best path based on the selected metadata
|
||||
let bestPath = possibilityResult.meta;
|
||||
|
||||
// If meta doesn't contain the full path object, find it in the original paths
|
||||
if (!bestPath || !bestPath.nodes) {
|
||||
// Fall back to finding the path that contributed most to the result
|
||||
const selectedIndex = metas.findIndex(m =>
|
||||
m.path === possibilityResult.meta?.path ||
|
||||
m.pathPossibility === possibilityResult.meta?.pathPossibility
|
||||
);
|
||||
|
||||
if (selectedIndex >= 0) {
|
||||
bestPath = pathsWithValues[selectedIndex];
|
||||
} else {
|
||||
// Ultimate fallback: use the first path
|
||||
bestPath = pathsWithValues[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (bestPath && !bestPath.pathSteps) {
|
||||
const targetPath = bestPath.path || bestPath.nodes;
|
||||
if (Array.isArray(targetPath)) {
|
||||
const match = pathsWithValues.find(p => Array.isArray(p.nodes) && p.nodes.join('|') === targetPath.join('|'));
|
||||
if (match) {
|
||||
bestPath = match;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
finalPossibility: possibilityResult.value,
|
||||
bestPath
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate collected values based on aggregation method using OWAFusion interval arithmetic
|
||||
* @private
|
||||
*/
|
||||
_aggregateCollectedValues(collectedValues, aggregationMethod) {
|
||||
if (collectedValues.length === 0) return [];
|
||||
if (collectedValues.length === 1) return collectedValues;
|
||||
|
||||
// Group values by path for aggregation
|
||||
const valuesByPath = new Map();
|
||||
|
||||
for (const cv of collectedValues) {
|
||||
const pathKey = cv.source?.fullPath?.join('->') || 'unknown_path';
|
||||
if (!valuesByPath.has(pathKey)) {
|
||||
valuesByPath.set(pathKey, []);
|
||||
}
|
||||
valuesByPath.get(pathKey).push(cv);
|
||||
}
|
||||
|
||||
const aggregatedValues = [];
|
||||
|
||||
for (const [pathKey, pathValues] of valuesByPath) {
|
||||
if (pathValues.length === 1) {
|
||||
aggregatedValues.push(pathValues[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use OWAFusion for interval aggregation
|
||||
const intervals = pathValues.map(cv => cv.value); // cv.value is already an interval
|
||||
const possibilities = pathValues.map(cv => cv.possibility);
|
||||
const metas = pathValues.map(cv => ({
|
||||
...cv.source,
|
||||
timestamp: cv.metadata?.timestamp,
|
||||
originalInterval: cv.value
|
||||
}));
|
||||
|
||||
// Aggregate intervals using OWAFusion
|
||||
const intervalResult = OWAFusion.fuseIntervalsWithMeta(
|
||||
intervals, metas, null, aggregationMethod
|
||||
);
|
||||
|
||||
const possibilityResult = OWAFusion.fuseWithMeta(
|
||||
possibilities, metas, null, aggregationMethod
|
||||
);
|
||||
|
||||
// Create aggregated collected value
|
||||
const aggregatedCV = this._createCollectedValue(
|
||||
intervalResult.interval,
|
||||
possibilityResult.value,
|
||||
pathValues[0].path,
|
||||
{
|
||||
...pathValues[0].source,
|
||||
aggregationMethod,
|
||||
aggregatedFromCount: pathValues.length,
|
||||
selectedMeta: intervalResult.meta
|
||||
},
|
||||
{
|
||||
...pathValues[0].metadata,
|
||||
aggregatedFromIntervals: intervals,
|
||||
interval: intervalResult.interval,
|
||||
aggregationMeta: {
|
||||
method: aggregationMethod,
|
||||
sourceCount: pathValues.length,
|
||||
intervalContribution: intervalResult.interval,
|
||||
possibilityContribution: possibilityResult.value,
|
||||
selectedSource: intervalResult.meta
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
aggregatedValues.push(aggregatedCV);
|
||||
}
|
||||
|
||||
return aggregatedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a basic path using simple BFS when sophisticated multi-hop fails
|
||||
* @private
|
||||
*/
|
||||
_findBasicPathWithValues(startId, endId, relation, maxDepth, minPossibility = 0,
|
||||
reverse = false, collectValues = true, trackPaths = true, valueFilters = {},
|
||||
valueContext = null, evaluation, options = null, allowZeroHop = false) {
|
||||
if (startId === endId && allowZeroHop) {
|
||||
const startKey = this.arbiter.resolveKey(startId, options);
|
||||
return {
|
||||
nodes: trackPaths ? [startKey] : null,
|
||||
nodeIds: [startId],
|
||||
hops: 0,
|
||||
possibility: 1.0,
|
||||
fallback: true,
|
||||
basic: true,
|
||||
collectedValues: []
|
||||
};
|
||||
}
|
||||
|
||||
const visited = new Set();
|
||||
const queue = [{
|
||||
nodeId: startId,
|
||||
path: [],
|
||||
pathSteps: [],
|
||||
possibility: 1.0,
|
||||
depth: 0
|
||||
}];
|
||||
let queueIndex = 0;
|
||||
|
||||
while (queueIndex < queue.length) {
|
||||
const current = queue[queueIndex++];
|
||||
|
||||
if (current.depth >= maxDepth || visited.has(current.nodeId) || current.possibility < minPossibility) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(current.nodeId);
|
||||
|
||||
// Look for direct connections
|
||||
let edges;
|
||||
const useRelationGraph = !options?.partialGraphContext;
|
||||
const useGraphNeighbors = useRelationGraph &&
|
||||
this.arbiter.relationManager.shouldUseRelationGraphTraversal(current.nodeId, relation, reverse);
|
||||
const relationGraphNeighbors = useGraphNeighbors
|
||||
? this.arbiter.relationManager.getRelationGraphNeighbors(current.nodeId, relation, reverse)
|
||||
: null;
|
||||
|
||||
if (relationGraphNeighbors) {
|
||||
edges = [];
|
||||
for (const neighborId of relationGraphNeighbors) {
|
||||
const edge = reverse
|
||||
? this.arbiter.relationManager.getDirectRelation(neighborId, relation, current.nodeId, options)
|
||||
: this.arbiter.relationManager.getDirectRelation(current.nodeId, relation, neighborId, options);
|
||||
if (edge) edges.push(edge);
|
||||
}
|
||||
} else if (reverse) {
|
||||
edges = this.arbiter.relationManager.getRelationsToDst(current.nodeId, relation, options);
|
||||
} else {
|
||||
edges = this.arbiter.relationManager.getRelationsFromSrc(current.nodeId, relation, options);
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
const nextId = reverse ? edge.src : edge.dst;
|
||||
const nextKey = this.arbiter.resolveKey(nextId, options);
|
||||
|
||||
if (!nextKey || visited.has(nextId)) continue;
|
||||
|
||||
const nextPath = trackPaths
|
||||
? [...current.path, this.arbiter.resolveKey(current.nodeId, options)]
|
||||
: current.path;
|
||||
const nextPossibility = Math.min(current.possibility, edge.possibility ?? 1.0);
|
||||
|
||||
const pathStep = (collectValues || trackPaths) ? {
|
||||
nodeId: current.nodeId,
|
||||
nodeKey: this.arbiter.resolveKey(current.nodeId, options),
|
||||
relation: relation,
|
||||
edge: edge,
|
||||
inferred: false,
|
||||
possibility: edge.possibility ?? 1.0,
|
||||
source: edge.source || 'persistent'
|
||||
} : null;
|
||||
|
||||
const nextPathSteps = (collectValues || trackPaths)
|
||||
? [...current.pathSteps, pathStep]
|
||||
: current.pathSteps;
|
||||
|
||||
// Check if we reached the target
|
||||
if (nextId === endId) {
|
||||
const finalPath = trackPaths ? [...nextPath, nextKey] : null;
|
||||
|
||||
const pathResult = {
|
||||
nodes: finalPath,
|
||||
nodeIds: trackPaths ? [...nextPath.map(key => this.arbiter.resolveNodeId(key, options)), nextId] : [startId, nextId],
|
||||
hops: finalPath.length - 1,
|
||||
possibility: nextPossibility,
|
||||
fallback: true,
|
||||
basic: true,
|
||||
collectedValues: [],
|
||||
pathSteps: trackPaths ? nextPathSteps : null
|
||||
};
|
||||
|
||||
// Collect values from the path if enabled
|
||||
if (collectValues) {
|
||||
pathResult.collectedValues = this._collectValuesFromPath(
|
||||
nextPathSteps, relation, valueFilters, valueContext
|
||||
);
|
||||
}
|
||||
|
||||
Arbiter.DEBUG && Arbiter.log('Basic fallback found path with values:', {
|
||||
path: finalPath,
|
||||
valuesCollected: pathResult.collectedValues.length
|
||||
});
|
||||
|
||||
return pathResult;
|
||||
}
|
||||
|
||||
// Continue searching if possibility is still acceptable
|
||||
if (nextPossibility >= minPossibility ) {
|
||||
queue.push({
|
||||
nodeId: nextId,
|
||||
path: nextPath,
|
||||
pathSteps: nextPathSteps,
|
||||
possibility: nextPossibility,
|
||||
depth: current.depth + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Arbiter.DEBUG && Arbiter.log('Basic fallback: no path found within constraints');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize rule configuration
|
||||
* @private
|
||||
*/
|
||||
_normalizeRule(rule) {
|
||||
return { ...rule };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { Arbiter } from '../../core/Arbiter.js';
|
||||
import { OWAFusion } from '../../utils/OWAFusion.js';
|
||||
|
||||
/**
|
||||
* ParentRule - Evaluates access through parent-child relationships (hierarchical inheritance)
|
||||
*
|
||||
* This rule implements hierarchical access where access is granted if:
|
||||
* 1. Object has a parent relationship to another entity (e.g., file -> folder)
|
||||
* 2. A direct edge exists from the user to that parent entity for the target relation
|
||||
*
|
||||
* Access is checked via direct edge lookup on the parent (not recursive authorization).
|
||||
* This prevents infinite recursion when parent entities also use ParentRule definitions.
|
||||
* Full transitive parent evaluation requires multi-hop configurations (MultiHopRule).
|
||||
*
|
||||
* Returns single possibility value representing the likelihood that user has access
|
||||
* through the parent hierarchy. Uses minPossibility as a cutoff threshold.
|
||||
*
|
||||
* Configuration:
|
||||
* {
|
||||
* type: 'parent',
|
||||
* parentRelation: string, // Relation defining parent-child (default: 'parent')
|
||||
* relation: string, // Relation to check on parent (uses currentRelation if not specified)
|
||||
* reverse: boolean, // Check in reverse direction (default: false)
|
||||
* aggregator?: string, // OWA aggregation method for multiple parents
|
||||
* owaWeights?: Array<number>, // Custom OWA weights for fusion
|
||||
* reliabilityWeighting?: boolean // Weight by reliability
|
||||
* }
|
||||
*/
|
||||
export class ParentRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate parent relationship with reachability optimization
|
||||
* @protected
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
const { minPossibility = 0.0, includeMeta = true, trackEvaluation = false } = options;
|
||||
const includeOwaTrace = trackEvaluation && includeMeta;
|
||||
const owaTraceOptions = includeOwaTrace ? { includeTrace: true } : null;
|
||||
|
||||
const reverse = rule.reverse || false;
|
||||
const parentRelName = rule.parentRelation || 'parent';
|
||||
|
||||
// Get parent relationships
|
||||
let parentRels;
|
||||
if (reverse) {
|
||||
parentRels = this.arbiter.relationManager.getRelationsFromSrc(objectId, parentRelName, options);
|
||||
} else {
|
||||
parentRels = this.arbiter.relationManager.getRelationsToDst(objectId, parentRelName, options);
|
||||
}
|
||||
|
||||
// Filter parent relationships by reachability
|
||||
const reachableParents = [];
|
||||
for (const rel of parentRels) {
|
||||
const parentId = reverse ? rel.dst : rel.src;
|
||||
const parentKey = this.arbiter.resolveKey(parentId, options);
|
||||
|
||||
if (!parentKey) continue;
|
||||
|
||||
// Quick reachability check for parent
|
||||
const isReachable = this._quickReachabilityCheck(userKey, parentKey);
|
||||
if (isReachable !== false) { // Include reachable or unknown
|
||||
reachableParents.push(rel);
|
||||
}
|
||||
}
|
||||
|
||||
// Use reachable parents for evaluation
|
||||
parentRels = reachableParents;
|
||||
|
||||
|
||||
let possibilities = [], metas = [], reasons = [];
|
||||
|
||||
// Track if there were any direct parent relationships (regardless of threshold)
|
||||
let anyDirectParent = parentRels.length > 0;
|
||||
|
||||
// Check for circular dependencies first
|
||||
for (const rel of parentRels) {
|
||||
const parentId = reverse ? rel.dst : rel.src;
|
||||
const parentKey = this.arbiter.resolveKey(parentId, options);
|
||||
|
||||
if (!parentKey) continue;
|
||||
|
||||
// Check if this would create a cycle: if parentKey is the same as userKey
|
||||
if (parentKey === userKey) {
|
||||
return {
|
||||
possibility: 0,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
parentRule: {
|
||||
type: 'cycle_detected',
|
||||
parentRelation: parentRelName,
|
||||
cyclePath: [userKey, objectKey, userKey]
|
||||
}
|
||||
}
|
||||
}),
|
||||
reason: 'cycle'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Process direct parent relationships
|
||||
const targetRelation = rule.relation || currentRelation;
|
||||
for (const rel of parentRels) {
|
||||
const parentId = reverse ? rel.dst : rel.src;
|
||||
const parentKey = this.arbiter.resolveKey(parentId, options);
|
||||
|
||||
if (!parentKey) continue;
|
||||
|
||||
if (parentKey === userKey) continue;
|
||||
|
||||
// Check direct edges from user to parent entity, bypassing the relation config
|
||||
// (which would re-enter the parent rule). This implements the contract:
|
||||
// "User has access to that parent entity" via direct relation check.
|
||||
const parentResult = this.arbiter.indices.getDirectRelation(userId, targetRelation, parentId);
|
||||
const directRelFromPartial = options.partialGraphContext
|
||||
? options.partialGraphContext.getDirectRelation(userId, targetRelation, parentId)
|
||||
: null;
|
||||
const directRel = parentResult || directRelFromPartial;
|
||||
const parentPossibility = directRel ? (directRel.possibility ?? 1.0) : 0;
|
||||
|
||||
// Apply cutoff threshold
|
||||
const finalPossibility = parentPossibility >= minPossibility ? parentPossibility : 0;
|
||||
|
||||
if (finalPossibility > 0) {
|
||||
possibilities.push(finalPossibility);
|
||||
metas.push(includeMeta ? {
|
||||
parentRule: {
|
||||
type: 'parent_access_checked',
|
||||
parentKey,
|
||||
parentRelation: parentRelName,
|
||||
targetRelation,
|
||||
parentAccessPossibility: parentPossibility
|
||||
}
|
||||
} : null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!possibilities.length) {
|
||||
// If there were any direct parents, but all were below threshold, cutoff was applied
|
||||
const cutoffApplied = anyDirectParent;
|
||||
return {
|
||||
possibility: 0,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
parentRule: {
|
||||
type: 'no_parent_relationship_found',
|
||||
parentRelation: parentRelName,
|
||||
directParentsSearched: parentRels.length,
|
||||
threshold: minPossibility,
|
||||
cutoffAppliedPostFusion: cutoffApplied
|
||||
}
|
||||
}
|
||||
}),
|
||||
reason: 'no_parent_relationship_path_above_threshold'
|
||||
};
|
||||
}
|
||||
|
||||
// Apply OWA fusion to combine possibilities of multiple parent relationships existing
|
||||
let result;
|
||||
const aggregator = rule.aggregator || 'max'; // Default to max: if any parent relationship exists strongly, that's enough
|
||||
|
||||
if (rule.aggregator || rule.owaWeights) {
|
||||
const weights = rule.owaWeights || OWAFusion.generateOWAWeights(possibilities.length, aggregator);
|
||||
result = OWAFusion.fuseWithMeta(possibilities, metas, weights, aggregator, true, owaTraceOptions);
|
||||
} else {
|
||||
result = OWAFusion.fuseWithMeta(possibilities, metas, null, aggregator, true, owaTraceOptions);
|
||||
}
|
||||
|
||||
// Final cutoff check after fusion
|
||||
const finalFusedPossibility = result.value >= minPossibility ? result.value : 0;
|
||||
|
||||
// Always set cutoffAppliedPostFusion in meta
|
||||
const cutoffApplied = result.value !== finalFusedPossibility;
|
||||
const parentRuleMeta = includeMeta ? {
|
||||
...(finalFusedPossibility > 0 ? result.meta?.parentRule : {}),
|
||||
finalOutcomeType: finalFusedPossibility > 0 ? 'parent_relationship_possible' : 'no_strong_parent_relationship',
|
||||
fusionMethod: aggregator,
|
||||
pathsConsidered: possibilities.length,
|
||||
cutoffAppliedPostFusion: cutoffApplied,
|
||||
threshold: minPossibility,
|
||||
...(includeOwaTrace && result.trace ? {
|
||||
owa: {
|
||||
level: null,
|
||||
aggregator,
|
||||
weights: result.trace.weights,
|
||||
sortedValues: result.trace.sortedValues,
|
||||
contributions: result.trace.contributions,
|
||||
selectedIndex: result.trace.selectedIndex
|
||||
}
|
||||
} : {})
|
||||
} : null;
|
||||
|
||||
return {
|
||||
possibility: finalFusedPossibility,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
...(finalFusedPossibility > 0 ? result.meta : {}),
|
||||
parentRule: parentRuleMeta
|
||||
}
|
||||
}),
|
||||
reason: finalFusedPossibility > 0 ? 'parent_relationship_path_found' : 'no_parent_relationship_path_above_threshold'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { Arbiter } from '../../core/Arbiter.js';
|
||||
import { OWAQualitativeFusion, getOWAQualitativeWeights } from '../../qualitative/OWAQualitativeFusion.js';
|
||||
import { QualitativeScale, DEFAULT_QUALITATIVE_SCALE } from '../../qualitative/QualitativeScale.js';
|
||||
import { BilatticeOrderings } from '../../qualitative/BilatticeOrderings.js';
|
||||
import { QualitativeCapacity } from '../../qualitative/QualitativeCapacity.js';
|
||||
|
||||
/**
|
||||
* QualitativeRelationalComparatorRule - Evaluates access by comparing qualitative values from relations,
|
||||
* treating values as qualitative intervals that "blur" over time based on decaying possibility.
|
||||
*
|
||||
* This implementation uses qualitative scales and possibility theory instead of numeric intervals.
|
||||
*
|
||||
* Configuration:
|
||||
* {
|
||||
* type: 'relational_comparator',
|
||||
* qualitative: true, // Flag to indicate qualitative mode
|
||||
* scaleName: string, // Name of the qualitative scale to use (e.g., 'five-point', 'ternary')
|
||||
* leftOperand: {
|
||||
* rule: {...}, // Any rule configuration
|
||||
* extractValue: true, // Extract value from relation (if false, rule's possibility is used as value)
|
||||
* valueRelation: string, // Optional: specific relation for value
|
||||
* aggregator: string, // 'max', 'min', 'majority', 'priority', 'optimistic', etc.
|
||||
* owaWeights: number[], // Optional: custom OWA weights for aggregation
|
||||
* decaySteps: number, // Number of steps to decay per period on the qualitative scale
|
||||
* decayPeriod: string, // 'MINUTE', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR' (default 'HOUR')
|
||||
* possibilityDecayDirection: string, // 'down' (towards 0), 'neutral' (towards 0.5), 'up' (towards 1), 'stable' (no decay)
|
||||
* valueBlurDirection: string, // 'neutral' (symmetric), 'down' (expands lower bound), 'up' (expands upper bound), 'stable' (minimal blur)
|
||||
* baseBlurSteps: number, // Number of steps to blur per possibility decay step
|
||||
* minOperandPossibility: number, // If operand's decayed possibility < this, considered no value
|
||||
* evaluateFrom: string // 'user', 'object', or 'auto' (for rule evaluation perspective)
|
||||
* },
|
||||
* rightOperand: {...}, // Same structure as leftOperand
|
||||
* comparator: string, // '>', '>=', '<', '<=', '==', '!='
|
||||
* marginSteps: number, // Number of steps to shift right operand on the scale before blurring
|
||||
* minRulePossibility: number, // Optional: if final rule possibility < this, considered 0
|
||||
* fallbackBehavior: string // 'allow' or 'deny' if values/operands are insufficient
|
||||
* }
|
||||
*/
|
||||
export class QualitativeRelationalComparatorRule extends BaseRule {
|
||||
constructor(arbiter, ruleEvaluator) {
|
||||
super(arbiter);
|
||||
this.ruleEvaluator = ruleEvaluator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate qualitative relational comparison
|
||||
* @protected
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
const {
|
||||
left: leftOperand,
|
||||
right: rightOperand,
|
||||
comparator,
|
||||
marginSteps = 0,
|
||||
fallbackBehavior = 'deny',
|
||||
minRulePossibility = 0,
|
||||
scaleName = 'five-point'
|
||||
} = rule;
|
||||
|
||||
try {
|
||||
// Get the qualitative scale
|
||||
const scale = this._getQualitativeScale(scaleName);
|
||||
|
||||
const ruleMetaBase = {
|
||||
ruleType: 'QualitativeRelationalComparatorRule',
|
||||
userKey,
|
||||
objectKey,
|
||||
comparator,
|
||||
scaleName,
|
||||
marginStepsApplied: rightOperand ? marginSteps : 0,
|
||||
fallbackBehavior,
|
||||
minRulePossibilityUsed: minRulePossibility,
|
||||
evaluationStarted: Date.now()
|
||||
};
|
||||
|
||||
let evaluationMeta = options.trackEvaluation ? {
|
||||
...ruleMetaBase,
|
||||
leftOperandDetails: {},
|
||||
rightOperandDetails: {}
|
||||
} : null;
|
||||
|
||||
// Evaluate left operand
|
||||
const leftOpResult = this._evaluateOperand(
|
||||
userId, userKey, objectId, objectKey,
|
||||
leftOperand, visited, currentRelation, options, 'left', 0, evaluationMeta, scale // No margin for left
|
||||
);
|
||||
if (evaluationMeta) evaluationMeta.leftOperandDetails = leftOpResult.meta || {};
|
||||
|
||||
// Evaluate right operand
|
||||
const rightOpResult = this._evaluateOperand(
|
||||
userId, userKey, objectId, objectKey,
|
||||
rightOperand, visited, currentRelation, options, 'right', marginSteps, evaluationMeta, scale // Apply margin for right
|
||||
);
|
||||
if (evaluationMeta) evaluationMeta.rightOperandDetails = rightOpResult.meta || {};
|
||||
|
||||
// Perform comparison of qualitative intervals
|
||||
let comparisonOutput = this._compareBlurredValues(
|
||||
leftOpResult, rightOpResult, comparator, fallbackBehavior, rule, options, ruleMetaBase, scale
|
||||
);
|
||||
|
||||
// Apply minimum rule possibility threshold
|
||||
if (scale.compare(comparisonOutput.possibility, minRulePossibility) < 0) {
|
||||
if (evaluationMeta && evaluationMeta.comparisonStep) {
|
||||
evaluationMeta.comparisonStep.finalPossibilityBeforeMinRule = comparisonOutput.possibility;
|
||||
}
|
||||
comparisonOutput.possibility = scale.bottom;
|
||||
comparisonOutput.reason = comparisonOutput.reason + '_belowMinRulePossibility';
|
||||
if (evaluationMeta && evaluationMeta.comparisonStep) {
|
||||
evaluationMeta.comparisonStep.adjustedToZeroByMinRule = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (evaluationMeta) {
|
||||
evaluationMeta.finalResult = {
|
||||
possibility: comparisonOutput.possibility,
|
||||
reliability: comparisonOutput.reliability,
|
||||
reason: comparisonOutput.reason
|
||||
};
|
||||
evaluationMeta.evaluationCompleted = Date.now();
|
||||
evaluationMeta.totalEvaluationTime = evaluationMeta.evaluationCompleted - evaluationMeta.evaluationStarted;
|
||||
}
|
||||
|
||||
return {
|
||||
possibility: comparisonOutput.possibility,
|
||||
reliability: comparisonOutput.reliability,
|
||||
reason: comparisonOutput.reason,
|
||||
meta: evaluationMeta
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('QualitativeRelationalComparatorRule evaluation error:', error);
|
||||
return {
|
||||
possibility: fallbackBehavior === 'allow' ? 1 : 0,
|
||||
reliability: 0,
|
||||
reason: 'error',
|
||||
meta: { error: error.message, fallbackBehavior }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the qualitative scale by name
|
||||
* @private
|
||||
*/
|
||||
_getQualitativeScale(scaleName) {
|
||||
switch (scaleName) {
|
||||
case 'binary':
|
||||
return QualitativeScale.binary();
|
||||
case 'ternary':
|
||||
return QualitativeScale.ternary();
|
||||
case 'five-point':
|
||||
return QualitativeScale.fivePoint();
|
||||
case 'ten-point':
|
||||
return QualitativeScale.tenPoint();
|
||||
default:
|
||||
console.warn(`Unknown scale name: ${scaleName}, using default five-point scale`);
|
||||
return DEFAULT_QUALITATIVE_SCALE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single operand and return qualitative interval and possibility
|
||||
* @private
|
||||
*/
|
||||
_evaluateOperand(userId, userKey, objectId, objectKey, operandConfig, visited, currentRelation, options, side, marginSteps, evaluationMeta, scale) {
|
||||
if (!operandConfig) {
|
||||
return {
|
||||
values: [],
|
||||
possibility: scale.bottom,
|
||||
meta: { error: `No ${side} operand configuration` }
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const operandMeta = options.trackEvaluation ? {
|
||||
side,
|
||||
operandConfig: { ...operandConfig },
|
||||
marginStepsApplied: marginSteps,
|
||||
evaluationStarted: Date.now()
|
||||
} : null;
|
||||
|
||||
const evaluateFrom = operandConfig.evaluateFrom || 'auto';
|
||||
let evalUserId = userId;
|
||||
let evalUserKey = userKey;
|
||||
let evalObjectId = objectId;
|
||||
let evalObjectKey = objectKey;
|
||||
const nestedRuleConfig = operandConfig.rule;
|
||||
if (evaluateFrom === 'user') {
|
||||
if (nestedRuleConfig.type === 'direct' || nestedRuleConfig.type === 'computed') {
|
||||
evalUserId = userId;
|
||||
evalUserKey = userKey;
|
||||
evalObjectId = userId;
|
||||
evalObjectKey = userKey;
|
||||
} else if (!nestedRuleConfig.extractValues) {
|
||||
evalUserId = objectId;
|
||||
evalUserKey = objectKey;
|
||||
evalObjectId = userId;
|
||||
evalObjectKey = userKey;
|
||||
}
|
||||
} else if (evaluateFrom === 'object') {
|
||||
if (nestedRuleConfig.type === 'direct' || nestedRuleConfig.type === 'computed') {
|
||||
evalUserId = objectId;
|
||||
evalUserKey = objectKey;
|
||||
evalObjectId = objectId;
|
||||
evalObjectKey = objectKey;
|
||||
} else {
|
||||
evalUserId = objectId;
|
||||
evalUserKey = objectKey;
|
||||
evalObjectId = userId;
|
||||
evalObjectKey = userKey;
|
||||
}
|
||||
}
|
||||
if (operandMeta) {
|
||||
operandMeta.evaluateFrom = evaluateFrom;
|
||||
operandMeta.evalUserKey = evalUserKey;
|
||||
operandMeta.evalObjectKey = evalObjectKey;
|
||||
}
|
||||
|
||||
// Evaluate the underlying rule
|
||||
const ruleResult = this.ruleEvaluator.evaluateRule(
|
||||
evalUserId, evalUserKey, evalObjectId, evalObjectKey, operandConfig.rule, visited, currentRelation, options
|
||||
);
|
||||
|
||||
if (operandMeta) {
|
||||
operandMeta.ruleResult = {
|
||||
possibility: ruleResult.possibility,
|
||||
reliability: ruleResult.reliability,
|
||||
reason: ruleResult.reason
|
||||
};
|
||||
}
|
||||
|
||||
// Extract values from the rule result
|
||||
const extractedValues = this._extractValuesFromRuleResult(ruleResult, operandConfig, scale);
|
||||
|
||||
if (operandMeta) {
|
||||
operandMeta.extractedValues = extractedValues;
|
||||
}
|
||||
|
||||
// Apply margin of safety (shift on the scale)
|
||||
const adjustedValues = this._applyMarginSteps(extractedValues, marginSteps, scale);
|
||||
|
||||
if (operandMeta) {
|
||||
operandMeta.adjustedValues = adjustedValues;
|
||||
}
|
||||
|
||||
// Extract blurred values with qualitative decay and blur
|
||||
const blurredValues = this._extractBlurredValues(adjustedValues, operandConfig, scale);
|
||||
|
||||
if (operandMeta) {
|
||||
operandMeta.blurredValues = blurredValues;
|
||||
operandMeta.evaluationCompleted = Date.now();
|
||||
operandMeta.totalEvaluationTime = operandMeta.evaluationCompleted - operandMeta.evaluationStarted;
|
||||
}
|
||||
|
||||
// Aggregate the blurred values using qualitative OWA
|
||||
const aggregatedResult = this._aggregateCrispValues(blurredValues, operandConfig, scale);
|
||||
|
||||
return {
|
||||
values: blurredValues,
|
||||
possibility: aggregatedResult.possibility,
|
||||
meta: operandMeta
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error evaluating ${side} operand:`, error);
|
||||
return {
|
||||
values: [],
|
||||
possibility: scale.bottom,
|
||||
meta: { error: error.message, side }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract values from rule result and convert to qualitative scale
|
||||
* @private
|
||||
*/
|
||||
_extractValuesFromRuleResult(ruleResult, operandConfig, scale) {
|
||||
const values = [];
|
||||
|
||||
if (operandConfig.extractValue !== false && (ruleResult.values || ruleResult.collectedValues)) {
|
||||
const collected = ruleResult.values || ruleResult.collectedValues;
|
||||
// Extract actual values from relations
|
||||
for (const valueObj of collected) {
|
||||
if (valueObj.value !== undefined) {
|
||||
// Convert numeric value to closest qualitative scale value
|
||||
const qualitativeValue = this._convertToQualitativeValue(valueObj.value, scale);
|
||||
values.push({
|
||||
value: qualitativeValue,
|
||||
possibility: this._convertToQualitativeValue(valueObj.possibility || 1, scale),
|
||||
timestamp: valueObj.timestamp,
|
||||
relation: valueObj.relation,
|
||||
meta: valueObj.meta
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use rule's possibility as the value
|
||||
const qualitativeValue = this._convertToQualitativeValue(ruleResult.possibility, scale);
|
||||
values.push({
|
||||
value: qualitativeValue,
|
||||
possibility: qualitativeValue,
|
||||
timestamp: Date.now(),
|
||||
relation: 'rule_result',
|
||||
meta: { source: 'rule_possibility' }
|
||||
});
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a numeric value to the closest qualitative scale value
|
||||
* @private
|
||||
*/
|
||||
_convertToQualitativeValue(numericValue, scale) {
|
||||
// Find the closest value in the scale
|
||||
let closestValue = scale.bottom;
|
||||
let minDistance = Math.abs(numericValue - scale.bottom);
|
||||
|
||||
for (const scaleValue of scale.values) {
|
||||
const distance = Math.abs(numericValue - scaleValue);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestValue = scaleValue;
|
||||
}
|
||||
}
|
||||
|
||||
return closestValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply margin steps to shift values on the qualitative scale
|
||||
* @private
|
||||
*/
|
||||
_applyMarginSteps(values, marginSteps, scale) {
|
||||
if (marginSteps === 0) return values;
|
||||
|
||||
return values.map(valueObj => {
|
||||
const currentIndex = scale.indexOf(valueObj.value);
|
||||
const newIndex = Math.max(0, Math.min(scale.size - 1, currentIndex + marginSteps));
|
||||
const newValue = scale.at(newIndex);
|
||||
|
||||
return {
|
||||
...valueObj,
|
||||
value: newValue
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract blurred values with qualitative decay and blur
|
||||
* @private
|
||||
*/
|
||||
_extractBlurredValues(values, operandConfig, scale) {
|
||||
const {
|
||||
decaySteps = 1,
|
||||
decayPeriod = 'HOUR',
|
||||
possibilityDecayDirection = 'down',
|
||||
valueBlurDirection = 'neutral',
|
||||
baseBlurSteps = 1,
|
||||
minOperandPossibility = 0
|
||||
} = operandConfig;
|
||||
|
||||
const blurredValues = [];
|
||||
|
||||
for (const valueObj of values) {
|
||||
const pointValue = valueObj.value;
|
||||
const initialPossibility = valueObj.possibility;
|
||||
const timestamp = valueObj.timestamp || Date.now();
|
||||
|
||||
// Calculate periods elapsed
|
||||
const periodsElapsed = this._calculatePeriodsElapsed(timestamp, decayPeriod);
|
||||
|
||||
// Calculate decayed possibility
|
||||
const decayedPossibility = this._calculateDecayedPossibility(
|
||||
initialPossibility, periodsElapsed, decaySteps, possibilityDecayDirection, scale
|
||||
);
|
||||
|
||||
// Skip if possibility is too low
|
||||
if (scale.compare(decayedPossibility, minOperandPossibility) < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate blur amount based on possibility loss
|
||||
const possibilityLossSteps = this._calculatePossibilityLossSteps(
|
||||
initialPossibility, decayedPossibility, scale
|
||||
);
|
||||
const blurSteps = Math.floor(possibilityLossSteps * baseBlurSteps);
|
||||
|
||||
// Create qualitative interval
|
||||
const blurredInterval = this._createQualitativeInterval(
|
||||
pointValue, blurSteps, valueBlurDirection, scale
|
||||
);
|
||||
|
||||
blurredValues.push({
|
||||
interval: blurredInterval,
|
||||
possibility: decayedPossibility,
|
||||
originalValue: pointValue,
|
||||
originalPossibility: initialPossibility,
|
||||
timestamp,
|
||||
relation: valueObj.relation,
|
||||
meta: {
|
||||
...valueObj.meta,
|
||||
periodsElapsed,
|
||||
possibilityLossSteps,
|
||||
blurSteps
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return blurredValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate periods elapsed since timestamp
|
||||
* @private
|
||||
*/
|
||||
_calculatePeriodsElapsed(timestamp, decayPeriod) {
|
||||
const now = Date.now();
|
||||
const elapsed = now - timestamp;
|
||||
|
||||
const periodMs = {
|
||||
'MINUTE': 60 * 1000,
|
||||
'HOUR': 60 * 60 * 1000,
|
||||
'DAY': 24 * 60 * 60 * 1000,
|
||||
'WEEK': 7 * 24 * 60 * 60 * 1000,
|
||||
'MONTH': 30 * 24 * 60 * 60 * 1000,
|
||||
'YEAR': 365 * 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
return Math.floor(elapsed / (periodMs[decayPeriod] || periodMs['HOUR']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate decayed possibility using qualitative scale steps
|
||||
* @private
|
||||
*/
|
||||
_calculateDecayedPossibility(initialPossibility, periodsElapsed, decaySteps, direction, scale) {
|
||||
const initialIndex = scale.indexOf(initialPossibility);
|
||||
const totalDecaySteps = periodsElapsed * decaySteps;
|
||||
|
||||
let newIndex;
|
||||
switch (direction) {
|
||||
case 'down':
|
||||
newIndex = Math.max(0, initialIndex - totalDecaySteps);
|
||||
break;
|
||||
case 'up':
|
||||
newIndex = Math.min(scale.size - 1, initialIndex + totalDecaySteps);
|
||||
break;
|
||||
case 'neutral':
|
||||
const targetIndex = Math.floor(scale.size / 2); // Middle of scale
|
||||
if (initialIndex > targetIndex) {
|
||||
newIndex = Math.max(targetIndex, initialIndex - totalDecaySteps);
|
||||
} else {
|
||||
newIndex = Math.min(targetIndex, initialIndex + totalDecaySteps);
|
||||
}
|
||||
break;
|
||||
case 'stable':
|
||||
default:
|
||||
newIndex = initialIndex;
|
||||
break;
|
||||
}
|
||||
|
||||
return scale.at(newIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the number of steps of possibility loss
|
||||
* @private
|
||||
*/
|
||||
_calculatePossibilityLossSteps(initialPossibility, decayedPossibility, scale) {
|
||||
const initialIndex = scale.indexOf(initialPossibility);
|
||||
const decayedIndex = scale.indexOf(decayedPossibility);
|
||||
return Math.abs(initialIndex - decayedIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a qualitative interval by blurring around a point value
|
||||
* @private
|
||||
*/
|
||||
_createQualitativeInterval(pointValue, blurSteps, direction, scale) {
|
||||
const pointIndex = scale.indexOf(pointValue);
|
||||
|
||||
let lowerIndex, upperIndex;
|
||||
switch (direction) {
|
||||
case 'down':
|
||||
lowerIndex = Math.max(0, pointIndex - blurSteps);
|
||||
upperIndex = pointIndex;
|
||||
break;
|
||||
case 'up':
|
||||
lowerIndex = pointIndex;
|
||||
upperIndex = Math.min(scale.size - 1, pointIndex + blurSteps);
|
||||
break;
|
||||
case 'neutral':
|
||||
default:
|
||||
lowerIndex = Math.max(0, pointIndex - blurSteps);
|
||||
upperIndex = Math.min(scale.size - 1, pointIndex + blurSteps);
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
lower: scale.at(lowerIndex),
|
||||
upper: scale.at(upperIndex)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate blurred values using qualitative OWA fusion with optional bilattice reasoning
|
||||
* @private
|
||||
*/
|
||||
_aggregateCrispValues(blurredValues, operandConfig, scale) {
|
||||
if (blurredValues.length === 0) {
|
||||
return { possibility: scale.bottom };
|
||||
}
|
||||
|
||||
if (blurredValues.length === 1) {
|
||||
return { possibility: blurredValues[0].possibility };
|
||||
}
|
||||
|
||||
const aggregator = operandConfig.aggregator || 'max';
|
||||
const useBilattice = operandConfig.useBilattice || false;
|
||||
const epistemicMode = operandConfig.epistemicMode || 'hybrid';
|
||||
const capacityType = operandConfig.capacityType || 'simple_support';
|
||||
|
||||
// Convert blurred values to collected values format for bilattice analysis
|
||||
const collectedValues = blurredValues.map((bv, index) => ({
|
||||
value: bv.possibility, // Use possibility as the value for bilattice analysis
|
||||
possibility: bv.possibility,
|
||||
path: [`blurred_value_${index}`],
|
||||
source: {
|
||||
entityKey: 'qualitative_operand',
|
||||
relation: 'blurred_value',
|
||||
step: index
|
||||
},
|
||||
metadata: {
|
||||
timestamp: bv.timestamp || Date.now(),
|
||||
reliability: 1.0,
|
||||
originalValue: bv.originalValue,
|
||||
originalPossibility: bv.originalPossibility,
|
||||
interval: bv.interval,
|
||||
blurSteps: bv.meta?.blurSteps,
|
||||
periodsElapsed: bv.meta?.periodsElapsed
|
||||
}
|
||||
}));
|
||||
|
||||
// Use bilattice-enhanced evidence combination if enabled
|
||||
if (useBilattice) {
|
||||
const capacity = this._createCapacityFromValues(collectedValues, scale, capacityType);
|
||||
const bilatticeResult = this._combineEvidenceWithBilattice(collectedValues, {
|
||||
method: aggregator,
|
||||
useBilattice: true,
|
||||
capacity: capacity,
|
||||
scale: scale,
|
||||
epistemicMode: epistemicMode
|
||||
});
|
||||
|
||||
return {
|
||||
possibility: bilatticeResult.value,
|
||||
epistemicAnalysis: bilatticeResult.epistemicAnalysis,
|
||||
aggregationMethod: `bilattice_${epistemicMode}`
|
||||
};
|
||||
}
|
||||
|
||||
// Standard qualitative OWA fusion
|
||||
const values = blurredValues.map(v => v.possibility);
|
||||
const metas = blurredValues.map(v => v.meta);
|
||||
|
||||
// Generate OWA weights
|
||||
const weights = getOWAQualitativeWeights(aggregator, values.length, null, scale);
|
||||
|
||||
// Perform qualitative OWA fusion
|
||||
const result = OWAQualitativeFusion.fuseWithMeta(values, weights, weights, aggregator, scale);
|
||||
|
||||
return {
|
||||
possibility: result.value,
|
||||
aggregationMethod: `owa_${aggregator}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use _aggregateCrispValues instead. Removal after Stage 2.
|
||||
*/
|
||||
_aggregateBlurredValues(blurredValues, operandConfig, scale) {
|
||||
if (!this._warnedAggregateBlurredValues) {
|
||||
this._warnedAggregateBlurredValues = true;
|
||||
console.warn('[QualitativeRelationalComparatorRule] _aggregateBlurredValues is deprecated; use _aggregateCrispValues instead.');
|
||||
}
|
||||
return this._aggregateCrispValues(blurredValues, operandConfig, scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare blurred qualitative intervals
|
||||
* @private
|
||||
*/
|
||||
_compareBlurredValues(leftResult, rightResult, comparator, fallbackBehavior, rule, options, ruleMetaBase, scale) {
|
||||
const leftValues = leftResult.values || [];
|
||||
const rightValues = rightResult.values || [];
|
||||
|
||||
if (leftValues.length === 0 || rightValues.length === 0) {
|
||||
return {
|
||||
possibility: fallbackBehavior === 'allow' ? scale.top : scale.bottom,
|
||||
reliability: 0,
|
||||
reason: 'insufficient_values'
|
||||
};
|
||||
}
|
||||
|
||||
// Compare all combinations of left and right intervals
|
||||
const comparisonResults = [];
|
||||
|
||||
for (const leftValue of leftValues) {
|
||||
for (const rightValue of rightValues) {
|
||||
const comparisonPossibility = this._calculateQualitativeIntervalComparison(
|
||||
leftValue.interval, rightValue.interval, comparator, scale
|
||||
);
|
||||
|
||||
// Combine with confidence weights (min operation in qualitative logic)
|
||||
const combinedPossibility = scale.min(comparisonPossibility, scale.min(leftValue.possibility, rightValue.possibility));
|
||||
|
||||
comparisonResults.push({
|
||||
possibility: combinedPossibility,
|
||||
leftInterval: leftValue.interval,
|
||||
rightInterval: rightValue.interval,
|
||||
leftPossibility: leftValue.possibility,
|
||||
rightPossibility: rightValue.possibility
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Take the maximum possibility across all comparisons
|
||||
const maxPossibility = scale.maxAll(comparisonResults.map(r => r.possibility));
|
||||
|
||||
return {
|
||||
possibility: maxPossibility,
|
||||
reliability: 1, // Qualitative comparisons are considered fully reliable
|
||||
reason: 'qualitative_interval_comparison'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate qualitative interval comparison using possibility theory
|
||||
* @private
|
||||
*/
|
||||
_calculateQualitativeIntervalComparison(leftInterval, rightInterval, comparator, scale) {
|
||||
const { lower: lLower, upper: lUpper } = leftInterval;
|
||||
const { lower: rLower, upper: rUpper } = rightInterval;
|
||||
|
||||
switch (comparator) {
|
||||
case '>':
|
||||
// Possibility(L > R): Is it possible that a value from L is greater than a value from R?
|
||||
// This is true if the top of L is greater than the bottom of R
|
||||
return scale.compare(lUpper, rLower) > 0 ? scale.top : scale.bottom;
|
||||
|
||||
case '>=':
|
||||
// Possibility(L >= R): Is it possible that a value from L is >= a value from R?
|
||||
return scale.compare(lUpper, rLower) >= 0 ? scale.top : scale.bottom;
|
||||
|
||||
case '<':
|
||||
// Possibility(L < R): Is it possible that a value from L is less than a value from R?
|
||||
// This is true if the bottom of L is less than the top of R
|
||||
return scale.compare(lLower, rUpper) < 0 ? scale.top : scale.bottom;
|
||||
|
||||
case '<=':
|
||||
// Possibility(L <= R): Is it possible that a value from L is <= a value from R?
|
||||
return scale.compare(lLower, rUpper) <= 0 ? scale.top : scale.bottom;
|
||||
|
||||
case '==':
|
||||
// Possibility(L == R): Is it possible that intervals overlap?
|
||||
// This is true if there's any overlap between the intervals
|
||||
return (scale.compare(lUpper, rLower) >= 0 && scale.compare(lLower, rUpper) <= 0) ? scale.top : scale.bottom;
|
||||
|
||||
case '!=':
|
||||
// Possibility(L != R): Is it possible that intervals don't overlap?
|
||||
// This is true if there's no overlap between the intervals
|
||||
return (scale.compare(lUpper, rLower) < 0 || scale.compare(lLower, rUpper) > 0) ? scale.top : scale.bottom;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown comparator: ${comparator}`);
|
||||
return scale.bottom;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { RelationalComparatorRule } from './RelationalComparatorRule.js';
|
||||
import { QualitativeRelationalComparatorRule } from './QualitativeRelationalComparatorRule.js';
|
||||
|
||||
/**
|
||||
* RelationalComparatorRouter - Routes relational comparator rules to either
|
||||
* numeric or qualitative implementations based on rule configuration.
|
||||
*
|
||||
* This router acts as the public entry point for relational comparator rules,
|
||||
* automatically delegating to the appropriate implementation:
|
||||
* - Numeric: Traditional numeric intervals with OWA fusion
|
||||
* - Qualitative: Qualitative scales with possibility theory
|
||||
*
|
||||
* Configuration Detection:
|
||||
* - If rule.qualitative === true, uses qualitative implementation
|
||||
* - If leftOperand.scaleName or rightOperand.scaleName is present, uses qualitative
|
||||
* - Otherwise, uses numeric implementation (default)
|
||||
*/
|
||||
export class RelationalComparatorRouter extends BaseRule {
|
||||
constructor(arbiter, ruleEvaluator) {
|
||||
super(arbiter);
|
||||
this.ruleEvaluator = ruleEvaluator;
|
||||
|
||||
// Instantiate both concrete implementations
|
||||
this.numericRule = new RelationalComparatorRule(arbiter, ruleEvaluator);
|
||||
this.qualitativeRule = new QualitativeRelationalComparatorRule(arbiter, ruleEvaluator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the rule evaluation to the appropriate implementation
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
// Decide which rule to use based on the configuration
|
||||
if (this._isQualitativeRule(rule)) {
|
||||
return this.qualitativeRule._evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options);
|
||||
} else {
|
||||
return this.numericRule._evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if this rule should use qualitative implementation
|
||||
* @param {Object} rule - The rule configuration
|
||||
* @returns {boolean} True if qualitative implementation should be used
|
||||
*/
|
||||
_isQualitativeRule(rule) {
|
||||
// Check explicit qualitative flag
|
||||
if (rule.qualitative === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for scale names in operands
|
||||
if (rule.left?.scaleName || rule.right?.scaleName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for qualitative-specific properties (must be defined and not null)
|
||||
if (this._hasValidQualitativeProperty(rule.left?.decaySteps) ||
|
||||
this._hasValidQualitativeProperty(rule.right?.decaySteps) ||
|
||||
this._hasValidQualitativeProperty(rule.left?.baseBlurSteps) ||
|
||||
this._hasValidQualitativeProperty(rule.right?.baseBlurSteps) ||
|
||||
this._hasValidQualitativeProperty(rule.marginSteps)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate rule implementation for debugging/logging
|
||||
* @param {Object} rule - The rule configuration
|
||||
* @returns {string} 'qualitative' or 'numeric'
|
||||
*/
|
||||
getImplementationType(rule) {
|
||||
return this._isQualitativeRule(rule) ? 'qualitative' : 'numeric';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a property value is a valid qualitative property (defined and not null)
|
||||
* @private
|
||||
*/
|
||||
_hasValidQualitativeProperty(value) {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,542 @@
|
||||
import { BaseRule } from './BaseRule.js';
|
||||
import { Arbiter } from '../../core/Arbiter.js';
|
||||
import { OWAFusion } from '../../utils/OWAFusion.js';
|
||||
|
||||
/**
|
||||
* TupleToUsersetRule - Evaluates access through intermediate entities (group membership pattern)
|
||||
*
|
||||
* This rule implements the tuple-to-userset pattern where access is granted if:
|
||||
* 1. Object has a tupleset relation to an intermediate entity (e.g., doc -> group)
|
||||
* 2. User has a computed relation to that same intermediate entity (e.g., user -> group)
|
||||
*
|
||||
* Supports sophisticated batch processing and OWA fusion for combining multiple paths.
|
||||
*
|
||||
* Configuration:
|
||||
* {
|
||||
* type: 'tuple_to_userset',
|
||||
* tuplesetRelation: string, // Relation from object to intermediate (e.g., 'owner')
|
||||
* computedRelation: string, // Relation from user to intermediate (e.g., 'member_of')
|
||||
* reverse: boolean, // Check in reverse direction (default: false)
|
||||
* minPossibility: number, // Minimum possibility for a path to be considered (0-1, default: 0)
|
||||
* owaWeights: Array<number>, // OWA weights for fusion (default: 'max' like behavior [1,0,...])
|
||||
* earlyExitThreshold: number, // Stop when path exceeds this threshold (default: 0.95)
|
||||
* maxIntermediates: number // Maximum intermediates to check (default: 20)
|
||||
* }
|
||||
*/
|
||||
export class TupleToUsersetRule extends BaseRule {
|
||||
constructor(arbiter) {
|
||||
super(arbiter);
|
||||
// Performance tracking
|
||||
this.performanceStats = {
|
||||
totalChecks: 0,
|
||||
earlyExits: 0,
|
||||
maxIntermediatesHit: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate tuple-to-userset relationship with aggressive early exit optimization
|
||||
* @protected
|
||||
*/
|
||||
_evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, options) {
|
||||
this.performanceStats.totalChecks++;
|
||||
|
||||
const { fastPath, includeMeta = true } = options;
|
||||
const collectValues = options.collectValues !== undefined ? options.collectValues : true;
|
||||
const minPossibility = rule.minPossibility !== undefined ? rule.minPossibility : (options.minPossibility !== undefined ? options.minPossibility : 0);
|
||||
const earlyExitThreshold = rule.earlyExitThreshold !== undefined ? rule.earlyExitThreshold : 0.95;
|
||||
const maxIntermediates = rule.maxIntermediates !== undefined ? rule.maxIntermediates : 20;
|
||||
const resolvePossibility = (value) => value !== undefined ? value : 1.0;
|
||||
const resolveReliability = (value) => value !== undefined ? value : 1.0;
|
||||
|
||||
const reverse = rule.reverse || false;
|
||||
if (!rule._computedRelationId) {
|
||||
rule._computedRelationId = this.arbiter.keyManager._getRelationId(rule.computedRelation);
|
||||
}
|
||||
if (!rule.computedRelationConfig) {
|
||||
rule.computedRelationConfig = this.arbiter.relationConfigs.get(rule.computedRelation);
|
||||
}
|
||||
if (!rule.tuplesetRelationConfig) {
|
||||
rule.tuplesetRelationConfig = this.arbiter.relationConfigs.get(rule.tuplesetRelation);
|
||||
}
|
||||
|
||||
const ruleMetaBase = includeMeta ? {
|
||||
ruleType: 'TupleToUsersetRule',
|
||||
userKey,
|
||||
objectKey,
|
||||
tuplesetRelation: rule.tuplesetRelation,
|
||||
computedRelation: rule.computedRelation,
|
||||
reverse,
|
||||
minPossibilityUsed: minPossibility,
|
||||
earlyExitThreshold,
|
||||
maxIntermediates,
|
||||
evaluationStarted: Date.now()
|
||||
} : null;
|
||||
|
||||
let evaluationMeta = options.trackEvaluation && includeMeta ? { ...ruleMetaBase } : null;
|
||||
|
||||
// Get tuples (intermediate entities) with early circuit breaker
|
||||
// tuplesetDirection: 'out' = object has relation TO intermediates (document → owner → group)
|
||||
// tuplesetDirection: 'in' = intermediates have relation TO object (group → belongs_to → org)
|
||||
const tuplesetDirection = rule.tuplesetDirection || 'out'; // Default to 'out' for backward compatibility
|
||||
|
||||
let tuples;
|
||||
const useRelationGraph = !options?.partialGraphContext;
|
||||
if (reverse) {
|
||||
const useGraphNeighbors = useRelationGraph &&
|
||||
this.arbiter.relationManager.shouldUseRelationGraphTraversal(userId, rule.tuplesetRelation, false);
|
||||
const neighbors = useGraphNeighbors
|
||||
? this.arbiter.relationManager.getRelationGraphNeighbors(userId, rule.tuplesetRelation, false)
|
||||
: null;
|
||||
if (neighbors) {
|
||||
tuples = [];
|
||||
for (const neighborId of neighbors) {
|
||||
const edge = this.arbiter.relationManager.getDirectRelation(userId, rule.tuplesetRelation, neighborId, options);
|
||||
if (edge) tuples.push(edge);
|
||||
}
|
||||
} else {
|
||||
tuples = this.arbiter.relationManager.getRelationsFromSrc(userId, rule.tuplesetRelation, options);
|
||||
}
|
||||
} else {
|
||||
const reverseLookup = tuplesetDirection === 'in';
|
||||
const useGraphNeighbors = useRelationGraph &&
|
||||
this.arbiter.relationManager.shouldUseRelationGraphTraversal(objectId, rule.tuplesetRelation, reverseLookup);
|
||||
const neighbors = useGraphNeighbors
|
||||
? this.arbiter.relationManager.getRelationGraphNeighbors(objectId, rule.tuplesetRelation, reverseLookup)
|
||||
: null;
|
||||
if (neighbors) {
|
||||
tuples = [];
|
||||
for (const neighborId of neighbors) {
|
||||
const srcId = reverseLookup ? neighborId : objectId;
|
||||
const dstId = reverseLookup ? objectId : neighborId;
|
||||
const edge = this.arbiter.relationManager.getDirectRelation(srcId, rule.tuplesetRelation, dstId, options);
|
||||
if (edge) tuples.push(edge);
|
||||
}
|
||||
} else if (reverseLookup) {
|
||||
tuples = this.arbiter.relationManager.getRelationsToDst(objectId, rule.tuplesetRelation, options);
|
||||
} else {
|
||||
tuples = this.arbiter.relationManager.getRelationsFromSrc(objectId, rule.tuplesetRelation, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Circuit breaker: if too many intermediates, limit and warn
|
||||
if (tuples.length > maxIntermediates * 3) {
|
||||
// Sort by possibility and take top N
|
||||
tuples.sort((a, b) => resolvePossibility(b.possibility) - resolvePossibility(a.possibility));
|
||||
tuples = tuples.slice(0, maxIntermediates);
|
||||
if (evaluationMeta) evaluationMeta.circuitBreakerTriggered = true;
|
||||
}
|
||||
|
||||
const checkingId = reverse ? this.arbiter.resolveNodeId(objectKey, options) : userId;
|
||||
|
||||
const useDirectJoin = rule.computedRelation && rule.computedRelationConfig?.type === 'direct';
|
||||
const computedEdges = useDirectJoin
|
||||
? this.arbiter.relationManager.getRelationsFromSrc(checkingId, rule.computedRelation, options)
|
||||
: null;
|
||||
const joinMode = useDirectJoin && computedEdges && computedEdges.length < tuples.length
|
||||
? 'computed'
|
||||
: 'tuples';
|
||||
|
||||
const tuplesWithKeys = [];
|
||||
if (includeMeta || joinMode === 'tuples') {
|
||||
for (const t of tuples) {
|
||||
const srcKey = this.arbiter.resolveKey(t.src, options);
|
||||
const dstKey = this.arbiter.resolveKey(t.dst, options);
|
||||
const intermediateId = tuplesetDirection === 'in' ? t.src : t.dst;
|
||||
const intermediateKey = tuplesetDirection === 'in' ? srcKey : dstKey;
|
||||
tuplesWithKeys.push({ tuple: t, srcKey, dstKey, intermediateId, intermediateKey });
|
||||
}
|
||||
}
|
||||
|
||||
if (evaluationMeta) {
|
||||
evaluationMeta.directTuplesFound = tuplesWithKeys.length;
|
||||
evaluationMeta.directTuples = tuplesWithKeys.map(({ tuple, srcKey, dstKey }) => ({
|
||||
from: srcKey,
|
||||
to: dstKey,
|
||||
relation: tuple.rel,
|
||||
possibility: tuple.possibility,
|
||||
reliability: tuple.reliability,
|
||||
source: tuple.source || 'persistent'
|
||||
}));
|
||||
}
|
||||
|
||||
const useLightweightPaths = !includeMeta && !collectValues;
|
||||
let bestPath = null;
|
||||
let allValidPaths = [];
|
||||
let reasons = [];
|
||||
let intermediateEvaluationDetails = includeMeta ? [] : null;
|
||||
let processedCount = 0;
|
||||
|
||||
// Process direct tuples with early exit optimization
|
||||
const computedRelationCache = new Map();
|
||||
const computedByIntermediate = joinMode === 'tuples' && computedEdges
|
||||
? new Map(computedEdges.map(edge => [edge.dst, edge]))
|
||||
: null;
|
||||
|
||||
if (joinMode === 'computed' && computedEdges) {
|
||||
for (const edge of computedEdges) {
|
||||
if (processedCount >= maxIntermediates) {
|
||||
if (evaluationMeta) evaluationMeta.maxIntermediatesReached = true;
|
||||
this.performanceStats.maxIntermediatesHit++;
|
||||
break;
|
||||
}
|
||||
|
||||
const intermediateId = edge.dst;
|
||||
const intermediateKey = this.arbiter.resolveKey(intermediateId, options);
|
||||
if (!intermediateKey) continue;
|
||||
|
||||
processedCount++;
|
||||
|
||||
const tupleEdge = reverse
|
||||
? this.arbiter.relationManager.getDirectRelation(userId, rule.tuplesetRelation, intermediateId, options)
|
||||
: (tuplesetDirection === 'in'
|
||||
? this.arbiter.relationManager.getDirectRelation(intermediateId, rule.tuplesetRelation, objectId, options)
|
||||
: this.arbiter.relationManager.getDirectRelation(objectId, rule.tuplesetRelation, intermediateId, options));
|
||||
|
||||
if (!tupleEdge) continue;
|
||||
|
||||
let res;
|
||||
if (visited && visited.size) {
|
||||
const visitKey = `${checkingId}|${rule._computedRelationId}|${intermediateId}`;
|
||||
if (visited.has(visitKey)) {
|
||||
res = { possibility: 0, reliability: 1.0, reason: 'cycle' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!res) {
|
||||
res = {
|
||||
possibility: edge.possibility,
|
||||
reliability: edge.reliability !== undefined ? edge.reliability : 1.0,
|
||||
reason: 'direct_match'
|
||||
};
|
||||
}
|
||||
|
||||
if (includeMeta && intermediateEvaluationDetails) {
|
||||
const pathDetail = {
|
||||
intermediateKey,
|
||||
type: 'direct_tuple',
|
||||
tuplesetRelationPossibility: resolvePossibility(tupleEdge.possibility),
|
||||
tuplesetRelationReliability: resolveReliability(tupleEdge.reliability),
|
||||
computedRelationResult: {
|
||||
possibility: res.possibility,
|
||||
reliability: res.reliability,
|
||||
reason: res.reason
|
||||
},
|
||||
metaFromCheck: res.meta
|
||||
};
|
||||
if (evaluationMeta) intermediateEvaluationDetails.push(pathDetail);
|
||||
}
|
||||
|
||||
if (res.reason === 'cycle') reasons.push('cycle');
|
||||
|
||||
const combinedPossibility = Math.min(resolvePossibility(tupleEdge.possibility), res.possibility);
|
||||
const combinedReliability = resolveReliability(tupleEdge.reliability) * (res.reliability !== undefined ? res.reliability : 1.0);
|
||||
|
||||
if (combinedPossibility >= minPossibility) {
|
||||
const path = {
|
||||
intermediateKey,
|
||||
tuplesetPossibility: resolvePossibility(tupleEdge.possibility),
|
||||
computedPossibility: res.possibility,
|
||||
combinedPossibility,
|
||||
combinedReliability
|
||||
};
|
||||
allValidPaths.push(path);
|
||||
if (!bestPath || combinedPossibility > bestPath.combinedPossibility) {
|
||||
bestPath = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (joinMode === 'tuples') for (const entry of tuplesWithKeys) {
|
||||
const t = entry.tuple;
|
||||
if (processedCount >= maxIntermediates) {
|
||||
if (evaluationMeta) evaluationMeta.maxIntermediatesReached = true;
|
||||
this.performanceStats.maxIntermediatesHit++;
|
||||
break;
|
||||
}
|
||||
|
||||
// For 'out' direction: intermediate is destination (document → owner → group)
|
||||
// For 'in' direction: intermediate is source (group → belongs_to → org)
|
||||
const intermediateKey = entry.intermediateKey;
|
||||
if (!intermediateKey) continue;
|
||||
|
||||
processedCount++;
|
||||
|
||||
// In reverse mode: check if the "object" (3rd param) has computed relation to intermediate
|
||||
// In normal mode: check if the "user" (1st param) has computed relation to intermediate
|
||||
const checkingEntityKey = reverse ? objectKey : userKey;
|
||||
|
||||
|
||||
let res = computedRelationCache.get(entry.intermediateId);
|
||||
if (!res) {
|
||||
if (useDirectJoin) {
|
||||
if (visited && visited.size) {
|
||||
const visitKey = `${checkingId}|${rule._computedRelationId}|${entry.intermediateId}`;
|
||||
if (visited.has(visitKey)) {
|
||||
res = {
|
||||
possibility: 0,
|
||||
reliability: 1.0,
|
||||
reason: 'cycle'
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!res) {
|
||||
const directRel = computedByIntermediate
|
||||
? computedByIntermediate.get(entry.intermediateId)
|
||||
: this.arbiter.relationManager.getDirectRelation(checkingId, rule.computedRelation, entry.intermediateId, options);
|
||||
if (directRel && (minPossibility === null || directRel.possibility >= minPossibility)) {
|
||||
res = {
|
||||
possibility: directRel.possibility,
|
||||
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
|
||||
reason: 'direct_match'
|
||||
};
|
||||
} else {
|
||||
res = {
|
||||
possibility: 0,
|
||||
reliability: 1.0,
|
||||
reason: 'no_direct_match'
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
res = this.arbiter.authChecker.check(checkingEntityKey, rule.computedRelation, intermediateKey, {
|
||||
...options,
|
||||
minPossibility,
|
||||
fastPath: true, // Enable fast path for intermediate checks
|
||||
_visited: visited,
|
||||
_currentRelation: rule.computedRelation
|
||||
});
|
||||
}
|
||||
computedRelationCache.set(entry.intermediateId, res);
|
||||
}
|
||||
|
||||
|
||||
if (includeMeta && intermediateEvaluationDetails) {
|
||||
const pathDetail = {
|
||||
intermediateKey,
|
||||
type: 'direct_tuple',
|
||||
tuplesetRelationPossibility: resolvePossibility(t.possibility),
|
||||
tuplesetRelationReliability: resolveReliability(t.reliability),
|
||||
computedRelationResult: {
|
||||
possibility: res.possibility,
|
||||
reliability: res.reliability,
|
||||
reason: res.reason
|
||||
},
|
||||
metaFromCheck: res.meta
|
||||
};
|
||||
if (evaluationMeta) intermediateEvaluationDetails.push(pathDetail);
|
||||
}
|
||||
|
||||
if (res.reason === 'cycle') reasons.push('cycle');
|
||||
|
||||
const combinedPossibility = Math.min(resolvePossibility(t.possibility), res.possibility);
|
||||
const combinedReliability = resolveReliability(t.reliability) * (res.reliability !== undefined ? res.reliability : 1.0);
|
||||
|
||||
if (combinedPossibility >= minPossibility) {
|
||||
const pathData = {
|
||||
possibility: combinedPossibility,
|
||||
reliability: combinedReliability,
|
||||
...(!useLightweightPaths && includeMeta && {
|
||||
meta: {
|
||||
intermediateKey,
|
||||
pathType: 'direct',
|
||||
tuplesetRelation: { relation: rule.tuplesetRelation, possibility: resolvePossibility(t.possibility), reliability: resolveReliability(t.reliability) },
|
||||
computedRelation: { relation: rule.computedRelation, possibility: res.possibility, reliability: res.reliability, meta: res.meta }
|
||||
}
|
||||
}),
|
||||
...(!useLightweightPaths && collectValues && { collectedValue: intermediateKey })
|
||||
};
|
||||
|
||||
allValidPaths.push(pathData);
|
||||
|
||||
// Update best path
|
||||
if (!bestPath || combinedPossibility > bestPath.possibility) {
|
||||
bestPath = pathData;
|
||||
}
|
||||
|
||||
// AGGRESSIVE EARLY EXIT: Stop if we found a very good path
|
||||
if (combinedPossibility >= earlyExitThreshold) {
|
||||
if (evaluationMeta) {
|
||||
evaluationMeta.earlyExitTriggered = true;
|
||||
evaluationMeta.earlyExitReason = 'direct_path_threshold_exceeded';
|
||||
evaluationMeta.earlyExitPossibility = combinedPossibility;
|
||||
evaluationMeta.intermediatesProcessed = processedCount;
|
||||
}
|
||||
this.performanceStats.earlyExits++;
|
||||
|
||||
// Return immediately with the excellent path
|
||||
return this._buildFinalResult(
|
||||
[pathData],
|
||||
reasons,
|
||||
ruleMetaBase,
|
||||
evaluationMeta,
|
||||
'early_exit_direct_path',
|
||||
includeMeta,
|
||||
collectValues,
|
||||
options.trackEvaluation
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (evaluationMeta) {
|
||||
evaluationMeta.evaluationCompleted = Date.now();
|
||||
evaluationMeta.evaluationDuration = evaluationMeta.evaluationCompleted - (evaluationMeta.evaluationStarted || evaluationMeta.evaluationCompleted);
|
||||
evaluationMeta.intermediateEvaluationDetails = intermediateEvaluationDetails;
|
||||
evaluationMeta.totalValidPathsFound = allValidPaths.length;
|
||||
evaluationMeta.intermediatesProcessed = processedCount;
|
||||
}
|
||||
|
||||
return this._buildFinalResult(allValidPaths, reasons, ruleMetaBase, evaluationMeta, 'complete_evaluation', includeMeta, collectValues, options.trackEvaluation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final result from valid paths
|
||||
* @private
|
||||
*/
|
||||
_buildFinalResult(validPathData, reasons, ruleMetaBase, evaluationMeta, evaluationType, includeMeta = true, collectValues = true, trackEvaluation = false) {
|
||||
if (!validPathData.length) {
|
||||
const reason = reasons.includes('cycle') ? 'cycle' : 'no_valid_intermediate_paths';
|
||||
if (evaluationMeta) {
|
||||
evaluationMeta.outcome = reason;
|
||||
evaluationMeta.finalPossibility = 0;
|
||||
evaluationMeta.finalReliability = 1.0;
|
||||
evaluationMeta.evaluationType = evaluationType;
|
||||
}
|
||||
|
||||
return {
|
||||
possibility: 0,
|
||||
reliability: 1.0,
|
||||
...(includeMeta && { meta: { ...ruleMetaBase, outcomeReason: reason, evaluation: evaluationMeta } }),
|
||||
...(collectValues && { collectedValues: [] }),
|
||||
reason
|
||||
};
|
||||
}
|
||||
|
||||
// For single path (common with early exit), skip OWA fusion overhead
|
||||
if (validPathData.length === 1) {
|
||||
const singlePath = validPathData[0];
|
||||
if (evaluationMeta) {
|
||||
evaluationMeta.outcome = 'single_path_found';
|
||||
evaluationMeta.finalPossibility = singlePath.possibility;
|
||||
evaluationMeta.finalReliability = singlePath.reliability;
|
||||
evaluationMeta.evaluationType = evaluationType;
|
||||
evaluationMeta.fusionSkipped = 'single_path_optimization';
|
||||
}
|
||||
|
||||
return {
|
||||
possibility: singlePath.possibility,
|
||||
reliability: singlePath.reliability,
|
||||
...(includeMeta && {
|
||||
meta: {
|
||||
...ruleMetaBase,
|
||||
outcomeReason: reasons.includes('cycle') ? 'cycle' : 'tuple_to_userset_evaluated',
|
||||
pathMeta: singlePath.meta,
|
||||
evaluation: evaluationMeta
|
||||
}
|
||||
}),
|
||||
...(collectValues && { collectedValues: [singlePath.collectedValue] }),
|
||||
reason: reasons.includes('cycle') ? 'cycle' : 'tuple_to_userset_found'
|
||||
};
|
||||
}
|
||||
|
||||
if (!includeMeta && !collectValues) {
|
||||
let maxPossibility = 0;
|
||||
let maxReliability = 1.0;
|
||||
for (const path of validPathData) {
|
||||
if (path.possibility > maxPossibility) {
|
||||
maxPossibility = path.possibility;
|
||||
maxReliability = path.reliability;
|
||||
}
|
||||
}
|
||||
return {
|
||||
possibility: maxPossibility,
|
||||
reliability: maxReliability,
|
||||
reason: reasons.includes('cycle') ? 'cycle' : (maxPossibility > 0 ? 'tuple_to_userset_found' : 'no_sufficient_tuple_to_userset_path')
|
||||
};
|
||||
}
|
||||
|
||||
// Multiple paths - use OWA fusion
|
||||
const possibilities = validPathData.map(p => p.possibility);
|
||||
const reliabilities = validPathData.map(p => p.reliability);
|
||||
const metaObjects = includeMeta ? validPathData.map(p => p.meta) : [];
|
||||
const collectedValues = collectValues ? validPathData.map(p => p.collectedValue) : [];
|
||||
|
||||
const owaWeights = [1, ...Array(possibilities.length - 1).fill(0)]; // Default to MAX for performance
|
||||
const fusionResult = OWAFusion.fuseWithMeta(possibilities, metaObjects, owaWeights, 'max', true, trackEvaluation ? { includeTrace: true } : null);
|
||||
|
||||
let fusedReliability = 1.0;
|
||||
if (fusionResult.meta && fusionResult.meta.intermediateKey) {
|
||||
const winningPath = validPathData.find(p => p.meta.intermediateKey === fusionResult.meta.intermediateKey && p.meta.pathType === fusionResult.meta.pathType);
|
||||
if (winningPath) {
|
||||
fusedReliability = winningPath.reliability;
|
||||
} else if (reliabilities.length > 0) {
|
||||
fusedReliability = Math.max(...reliabilities); // Use max reliability for performance
|
||||
}
|
||||
} else if (reliabilities.length > 0) {
|
||||
fusedReliability = Math.max(...reliabilities);
|
||||
}
|
||||
|
||||
if (evaluationMeta) {
|
||||
evaluationMeta.outcome = 'paths_evaluated_and_fused';
|
||||
evaluationMeta.fusion = {
|
||||
method: 'owa',
|
||||
weightsUsed: owaWeights,
|
||||
fusedPossibility: fusionResult.value,
|
||||
fusedReliability: fusedReliability,
|
||||
winningPathMeta: fusionResult.meta,
|
||||
...(trackEvaluation && fusionResult.trace ? {
|
||||
owa: {
|
||||
level: null,
|
||||
aggregator: 'max',
|
||||
weights: fusionResult.trace.weights,
|
||||
sortedValues: fusionResult.trace.sortedValues,
|
||||
contributions: fusionResult.trace.contributions,
|
||||
selectedIndex: fusionResult.trace.selectedIndex
|
||||
}
|
||||
} : {})
|
||||
};
|
||||
evaluationMeta.finalPossibility = fusionResult.value;
|
||||
evaluationMeta.finalReliability = fusedReliability;
|
||||
evaluationMeta.evaluationType = evaluationType;
|
||||
}
|
||||
|
||||
const finalMeta = includeMeta ? {
|
||||
...ruleMetaBase,
|
||||
outcomeReason: reasons.includes('cycle') ? 'cycle' : 'tuple_to_userset_evaluated',
|
||||
fusionMethod: 'owa',
|
||||
owaWeightsUsed: owaWeights,
|
||||
contributingPathMeta: fusionResult.meta,
|
||||
evaluation: evaluationMeta
|
||||
} : null;
|
||||
|
||||
return {
|
||||
possibility: fusionResult.value,
|
||||
reliability: fusedReliability,
|
||||
...(includeMeta && { meta: finalMeta }),
|
||||
...(collectValues && { collectedValues: fusionResult.value > 0 ? collectedValues : [] }),
|
||||
reason: reasons.includes('cycle') ? 'cycle' : (fusionResult.value > 0 ? 'tuple_to_userset_found' : 'no_sufficient_tuple_to_userset_path')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics
|
||||
* @returns {Object} Performance statistics
|
||||
*/
|
||||
getPerformanceStats() {
|
||||
return { ...this.performanceStats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset performance statistics
|
||||
*/
|
||||
resetPerformanceStats() {
|
||||
this.performanceStats = {
|
||||
totalChecks: 0,
|
||||
earlyExits: 0,
|
||||
maxIntermediatesHit: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user