import { test, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { Arbiter } from '../../src/index.js'; import { isStringObject } from 'node:util/types'; // Helper to set up a test graph for relational comparator scenarios function setupRelationalComparatorTestGraph() { const arbiter = new Arbiter({ embeddingDimensions: 256 }); // Configure direct relation rules that RelationalComparatorRule will use arbiter.setRelationConfig('has_balance', { type: 'direct' }); arbiter.setRelationConfig('has_price', { type: 'direct' }); arbiter.setRelationConfig('last_2fa', { type: 'direct' }); arbiter.setRelationConfig('belongs_to_plan', { type: 'direct' }); // Create test entities arbiter.addNode('user:alice', 'user'); arbiter.addNode('user:bob', 'user'); arbiter.addNode('user:charlie', 'user'); arbiter.addNode('feature:premium', 'feature'); arbiter.addNode('feature:basic', 'feature'); arbiter.addNode('feature:enterprise', 'feature'); arbiter.addNode('plan:gold', 'plan'); arbiter.addNode('action:sensitive', 'action'); // Add target nodes for relations arbiter.addNode('account:checking', 'account'); arbiter.addNode('account:savings', 'account'); arbiter.addNode('price:basic', 'price'); arbiter.addNode('price:premium', 'price'); arbiter.addNode('price:enterprise', 'price'); arbiter.addNode('price:gold', 'price'); arbiter.addNode('2fa:recent', '2fa'); arbiter.addNode('2fa:old', '2fa'); arbiter.addNode('time:threshold', 'time'); // Add value-carrying relations (balances, prices, etc.) const now = Date.now(); const oneHourAgo = now - 60 * 60 * 1000; const twelveHoursAgo = now - 12 * 60 * 60 * 1000; const twoDaysAgo = now - 48 * 60 * 60 * 1000; // User balances (directly attached to feature targets) arbiter.addRelation('user:alice', 'has_balance', 'feature:basic', { value: 1500, updated_last_at: now, changed_last_at: now }); arbiter.addRelation('user:alice', 'has_balance', 'feature:premium', { value: 1500, updated_last_at: now, changed_last_at: now }); arbiter.addRelation('user:alice', 'has_balance', 'feature:enterprise', { value: 1500, updated_last_at: now, changed_last_at: now }); arbiter.addRelation('user:bob', 'has_balance', 'feature:basic', { value: 500, updated_last_at: now, changed_last_at: now }); arbiter.addRelation('user:bob', 'has_balance', 'feature:premium', { value: 500, updated_last_at: now, changed_last_at: now }); arbiter.addRelation('user:bob', 'has_balance', 'feature:enterprise', { value: 500, updated_last_at: now, changed_last_at: now }); // Feature prices arbiter.addRelation('feature:basic', 'has_price', 'feature:basic', { value: 100 }); arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', { value: 1000 }); arbiter.addRelation('feature:enterprise', 'has_price', 'feature:enterprise', { value: 5000 }); // 2FA timestamps arbiter.addRelation('user:alice', 'last_2fa', '2fa:recent', { value: now - 5 * 60 * 1000 // 5 minutes ago }); arbiter.addRelation('user:bob', 'last_2fa', '2fa:old', { value: now - 2 * 60 * 60 * 1000 // 2 hours ago }); // Plan relationships arbiter.addRelation('feature:enterprise', 'belongs_to_plan', 'plan:gold'); arbiter.addRelation('plan:gold', 'has_price', 'plan:gold', { value: 3000 // Discounted price through plan }); return arbiter; } describe('RelationalComparatorRule Tests', () => { describe('Basic Value Comparisons', () => { it('allows access when user balance exceeds feature price', () => { // Arbiter.DEBUG = true; const arbiter = setupRelationalComparatorTestGraph(); // No decay for this test arbiter.setRelationConfig('can_access', { type: 'relational_comparator', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, aggregation: 'max', decayRate: 0, decayFunction: 'rational' }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, aggregation: 'min', decayRate: 0, decayFunction: 'rational', evaluateFrom: 'object' }, comparator: '>=', marginOfSafety: 1.1, fallbackBehavior: 'deny' }); // Alice ($1500) should afford basic feature ($100) const result1 = arbiter.check('user:alice', 'can_access', 'feature:basic'); assert.ok(result1.possibility >= 0.999); assert.equal(result1.reason, 'allow_rule_matched'); // Alice ($1500) should afford premium feature ($1000) const result2 = arbiter.check('user:alice', 'can_access', 'feature:premium'); assert.ok(result2.possibility >= 0.999); assert.equal(result2.reason, 'allow_rule_matched'); // Bob ($500) should afford basic feature ($100) const result3 = arbiter.check('user:bob', 'can_access', 'feature:basic'); assert.ok(result3.possibility >= 0.999); assert.equal(result3.reason, 'allow_rule_matched'); }); it('denies access when user balance is insufficient', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('can_access', { type: 'relational_comparator', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, aggregation: 'max' }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '>=', fallbackBehavior: 'deny' }); // Bob ($500) should NOT afford enterprise feature ($5000) const result = arbiter.check('user:bob', 'can_access', 'feature:enterprise'); assert.equal(result.possibility, 0); }); it('handles missing values with fallback behavior', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('can_access', { type: 'relational_comparator', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 30 * 24 * 60 * 60 * 1000 }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '>=', fallbackBehavior: 'deny' }); // Unknown user should be denied (charlie has no balance relation) const result = arbiter.check('user:charlie', 'can_access', 'feature:basic'); assert.equal(result.possibility, 0); assert.equal(result.reason, 'no_matching_rule'); // No balance = deny rule matches }); }); describe('Value Extraction vs Possibility Comparison', () => { it('uses possibilities as values when extractValue is false', () => { const arbiter = setupRelationalComparatorTestGraph(); // Add relations from Alice to test extractValue=false logic arbiter.addRelation('user:alice', 'has_balance', 'feature:basic', { value: 1500 // This value should be ignored when extractValue: false }); arbiter.addRelation('user:alice', 'has_price', 'feature:basic', { value: 1000 // This value should also be ignored when extractValue: false }); arbiter.setRelationConfig('privilege_comparison', { type: 'relational_comparator', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: false, // Use possibility as value decayRate: 0 // No decay }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: false, // Use possibility as value decayRate: 0, // No decay evaluateFrom: 'object' }, comparator: '>=', fallbackBehavior: 'deny' }); // Both rules should return possibility 1.0, so 1.0 >= 1.0 = true const result = arbiter.check('user:alice', 'privilege_comparison', 'feature:basic'); assert.equal(result.possibility, 1); assert.equal(result.reason, 'allow_rule_matched'); }); it('extracts actual values when extractValue is true', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('value_comparison', { type: 'relational_comparator', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '>=', fallbackBehavior: 'deny' }); const result = arbiter.check('user:alice', 'value_comparison', 'feature:basic'); assert.ok(result.possibility >= 0.999); }); }); describe('Time-Based Decay', () => { it('ignores stale timestamps when no decay is applied', () => { const now = 1_000_000_000_000; const arbiter = setupRelationalComparatorTestGraph(); arbiter.addNode('user:decay-test', 'user'); arbiter.addNode('feature:decay-test', 'feature'); arbiter.addNode('account:decay', 'account'); arbiter.addNode('price:decay', 'price'); arbiter.addRelation('feature:decay-test', 'has_price', 'feature:decay-test', { value: 1000, updated_last_at: now, changed_last_at: now }); arbiter.addRelation('user:decay-test', 'has_balance', 'feature:decay-test', { value: 1000, updated_last_at: now - 24 * 60 * 60 * 1000, changed_last_at: now - 24 * 60 * 60 * 1000 }); arbiter.setRelationConfig('can_access_decay_test', { type: 'relational_comparator', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '>=', marginOfSafety: 1.0, fallbackBehavior: 'deny' }); const result = arbiter.check('user:decay-test', 'can_access_decay_test', 'feature:decay-test', { now }); assert.equal(result.possibility, 1); }); it('keeps comparison crisp even for older timestamps', () => { const arbiter = setupRelationalComparatorTestGraph(); const now = 1_000_000_000_000; arbiter.addNode('user:usage-test', 'user'); arbiter.addNode('service:limited', 'service'); arbiter.addNode('usage:current', 'usage'); arbiter.addNode('quota:monthly', 'quota'); arbiter.addRelation('user:usage-test', 'has_usage', 'service:limited', { value: 100, updated_last_at: now - 12 * 60 * 60 * 1000, changed_last_at: now - 12 * 60 * 60 * 1000 }); arbiter.addRelation('service:limited', 'has_quota', 'service:limited', { value: 500, updated_last_at: now, changed_last_at: now }); arbiter.setRelationConfig('has_usage', { type: 'direct' }); arbiter.setRelationConfig('has_quota', { type: 'direct' }); arbiter.setRelationConfig('within_quota', { type: 'relational_comparator', left: { rule: { type: 'direct', relation: 'has_usage' }, extractValue: true, ttl: 30 * 24 * 60 * 60 * 1000 }, right: { rule: { type: 'direct', relation: 'has_quota', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '<=', fallbackBehavior: 'deny' }); const result = arbiter.check('user:usage-test', 'within_quota', 'service:limited', { now }); assert.equal(result.possibility, 1); }); }); describe('Complex Nested Rules', () => { it('supports nested logical operations in operands', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('has_balance_savings', { type: 'direct' }); // Add a second balance for Alice arbiter.addRelation('user:alice', 'has_balance_savings', 'feature:enterprise', { value: 2000, updated_last_at: Date.now() - 12 * 60 * 60 * 1000, changed_last_at: Date.now() - 12 * 60 * 60 * 1000 }); arbiter.setRelationConfig('can_purchase', { type: 'relational_comparator', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance' }, { type: 'direct', relation: 'has_balance_savings' } ], aggregator: 'sum' } }, extractValue: true, aggregation: 'sum', decayRate: 0, decayDirection: 'down' }, right: { rule: { type: 'chain', steps: [ { relation: 'belongs_to_plan', direction: 'out' }, // feature → plan { relation: 'has_price', direction: 'out' } // plan → price ], collectValues: true, valueFilters: { steps: [1], relations: ['has_price'] }, valueAggregation: 'min', // Use minimum plan price if multiple evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object', // Evaluate from feature's perspective decayRate: 0, // No decay decayDirection: 'up' }, comparator: '>=', marginOfSafety: 1.0, fallbackBehavior: 'deny' }); // Alice should be able to purchase enterprise feature through plan pricing const result = arbiter.check('user:alice', 'can_purchase', 'feature:enterprise'); // Should work: Alice has $1500 + $2000 = $3500 total (with some decay) // Enterprise through plan costs $3000, so should be affordable assert.equal(result.possibility, 1.0); assert.equal(result.reason, 'allow_rule_matched'); }); }); describe('Aggregation Strategies', () => { it('uses max aggregation correctly', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('has_balance_savings', { type: 'direct' }); // Alice has two balances, we want the max arbiter.addRelation('user:alice', 'has_balance_savings', 'feature:premium', { value: 2000 // Higher than checking balance }); arbiter.setRelationConfig('max_balance_check', { type: 'relational_comparator', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance' }, { type: 'direct', relation: 'has_balance_savings' } ], aggregator: 'max' } }, extractValue: true, aggregation: 'max' // Use highest balance }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '>=', fallbackBehavior: 'deny' }); const result = arbiter.check('user:alice', 'max_balance_check', 'feature:premium', { includeMeta: true }); assert.equal(result.possibility, 1); // Should use the max balance (2000) not the first one (1500) assert.equal(result.meta.allow.leftValue, 2000); assert.equal(result.meta.allow.rightValue, 1000); }); it('uses sum aggregation correctly', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('has_balance_savings', { type: 'direct' }); arbiter.addRelation('user:alice', 'has_balance_savings', 'feature:enterprise', { value: 4000 // Increased from 2000 so total becomes 5500 > 5000 }); arbiter.setRelationConfig('total_balance_check', { type: 'relational_comparator', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance' }, { type: 'direct', relation: 'has_balance_savings' } ], aggregator: 'sum' } }, extractValue: true, aggregation: 'sum', decayRate: 0, // No decay for this test decayFunction: 'rational' }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object', decayRate: 0, decayFunction: 'rational' }, comparator: '>=', fallbackBehavior: 'deny' }); const result = arbiter.check('user:alice', 'total_balance_check', 'feature:enterprise', { includeMeta: true }); assert.equal(result.possibility, 1); // Should use sum of all balances (1500 + 4000 = 5500) assert.equal(result.meta.allow.leftValue, 5500); assert.equal(result.meta.allow.rightValue, 5000); }); it('checks balance on accounts with debit rights', () => { const arbiter = setupRelationalComparatorTestGraph(); // Set up multiple accounts with different balances arbiter.addNode('account:business', 'account'); arbiter.addNode('account:personal', 'account'); arbiter.addNode('account:restricted', 'account'); arbiter.addNode('unit:usd', 'currency'); // Create a fresh user to avoid conflicts with Alice's existing balance relations arbiter.addNode('user:test', 'user'); // Accounts have balances (not users) arbiter.addRelation('account:business', 'has_balance', 'unit:usd', { value: 3000 }); arbiter.addRelation('account:personal', 'has_balance', 'unit:usd', { value: 1500 }); arbiter.addRelation('account:restricted', 'has_balance', 'unit:usd', { value: 10000 }); // Large balance but no debit rights // Test user has debit rights to some accounts arbiter.setRelationConfig('can_debit', { type: 'direct' }); arbiter.addRelation('user:test', 'can_debit', 'account:business'); arbiter.addRelation('user:test', 'can_debit', 'account:personal'); // Note: No debit rights to account:restricted // Configure a rule that checks balance on accounts she can debit from arbiter.setRelationConfig('authorized_balance_check', { type: 'relational_comparator', left: { rule: { type: 'chain', steps: [ { relation: 'can_debit', direction: 'out' }, // Alice → accounts { relation: 'has_balance', direction: 'out' } // accounts → currency ], extractValues: true, extractFrom: 1, // Extract from step 1 (the accounts) extractRelation: 'has_balance', valueAggregation: 'sum', // Sum all authorized balances evaluateFrom: 'user' }, extractValue: true, aggregator: 'sum', evaluateFrom: 'user', // Evaluate from Alice's perspective decayRate: 0, decayFunction: 'rational' }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object', // Evaluate from feature's perspective decayRate: 0, decayFunction: 'rational' }, comparator: '>=', fallbackBehavior: 'deny' }); // Alice should be able to afford premium feature with authorized accounts // Total authorized balance: 3000 + 1500 = 4500 const result1 = arbiter.check('user:test', 'authorized_balance_check', 'feature:premium', { includeMeta: true }); assert.equal(result1.possibility, 1); // Sum of ALL authorized balances: business (3000) + personal (1500) = 4500. // (Previously only the best-path value was collected, dropping the // personal account — fixed in ChainRule value collection.) assert.equal(result1.meta.allow.leftValue, 4500); assert.equal(result1.meta.allow.rightValue, 1000); // But she should NOT be able to afford enterprise feature // $4500 < enterprise price ($5000) - so this should fail const result2 = arbiter.check('user:test', 'authorized_balance_check', 'feature:enterprise', { includeMeta: true }); assert.equal(result2.possibility, 0); // Should fail: 4500 < 5000 // When authorization fails, check deny metadata instead of allow if (result2.meta.deny) { assert.equal(result2.meta.deny.leftValue, 4500); assert.equal(result2.meta.deny.rightValue, 5000); } }); it('supports OWA Sum operator for scaling aggregation', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('has_balance_savings', { type: 'direct' }); // Alice has multiple balances, we want to sum them with scaling arbiter.addRelation('user:alice', 'has_balance_savings', 'feature:enterprise', { value: 4000 // Increased from 2000 to make total 5500 > enterprise price 5000 }); arbiter.setRelationConfig('scaled_sum_check', { type: 'relational_comparator', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance' }, { type: 'direct', relation: 'has_balance_savings' } ], aggregator: 'sum' } }, extractValue: true, aggregation: 'sum', // Add this for compatibility with RelationalComparatorRule aggregator: 'sum', // Sum operator - weights don't normalize to 1.0 decayRate: 0, // No decay for this test decayFunction: 'rational' }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object', decayRate: 0, decayFunction: 'rational' }, comparator: '>=', fallbackBehavior: 'deny' }); const result = arbiter.check('user:alice', 'scaled_sum_check', 'feature:enterprise', { includeMeta: true }); assert.equal(result.possibility, 1); // Should sum all balances (1500 + 4000 = 5500) assert.equal(result.meta.allow.leftValue, 5500); assert.equal(result.meta.allow.rightValue, 5000); }); it('supports majority aggregation', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('has_balance_primary', { type: 'direct' }); arbiter.setRelationConfig('has_balance_secondary', { type: 'direct' }); arbiter.setRelationConfig('has_balance_tertiary', { type: 'direct' }); // Add multiple balance relations to Alice arbiter.addRelation('user:alice', 'has_balance_primary', 'feature:premium', { value: 2500 }); arbiter.addRelation('user:alice', 'has_balance_secondary', 'feature:premium', { value: 800 }); arbiter.addRelation('user:alice', 'has_balance_tertiary', 'feature:premium', { value: 1200 }); arbiter.setRelationConfig('majority_balance_check', { type: 'relational_comparator', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance_primary' }, { type: 'direct', relation: 'has_balance_secondary' }, { type: 'direct', relation: 'has_balance_tertiary' } ], aggregator: 'majority' } }, extractValue: true, aggregator: 'majority', // Add this for RelationalComparatorRule aggregator: 'majority' // Majority aggregation - focuses on typical values }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '>=', fallbackBehavior: 'deny' }); const result = arbiter.check('user:alice', 'majority_balance_check', 'feature:premium', { includeMeta: true }); assert.equal(result.possibility, 1); // Should use majority-focused aggregation (not simply max or min) assert.ok(result.meta.allow.leftValue > 1000); assert.ok(result.meta.allow.leftValue < 3000); // Not the max value }); it('supports optimistic aggregation', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('has_balance_primary', { type: 'direct' }); arbiter.setRelationConfig('has_balance_secondary', { type: 'direct' }); arbiter.addRelation('user:alice', 'has_balance_primary', 'feature:premium', { value: 2000 }); arbiter.addRelation('user:alice', 'has_balance_secondary', 'feature:premium', { value: 500 }); arbiter.setRelationConfig('optimistic_balance_check', { type: 'relational_comparator', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance_primary' }, { type: 'direct', relation: 'has_balance_secondary' } ], aggregator: 'optimistic' } }, extractValue: true, aggregation: 'optimistic', // Add this for RelationalComparatorRule aggregator: 'optimistic', // Optimistic - weights higher values more decayRate: 0, decayFunction: 'rational' }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '>=', fallbackBehavior: 'deny' }); const result = arbiter.check('user:alice', 'optimistic_balance_check', 'feature:premium', { includeMeta: true }); assert.equal(result.possibility, 1); // Should bias toward higher values assert.ok(result.meta.allow.leftValue > 1250); // Weighted toward higher values }); it('supports custom OWA weights', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('has_balance_primary', { type: 'direct' }); arbiter.setRelationConfig('has_balance_secondary', { type: 'direct' }); arbiter.addRelation('user:alice', 'has_balance_primary', 'feature:premium', { value: 2000 }); arbiter.addRelation('user:alice', 'has_balance_secondary', 'feature:premium', { value: 1500 }); arbiter.setRelationConfig('custom_weighted_check', { type: 'relational_comparator', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance_primary' }, { type: 'direct', relation: 'has_balance_secondary' } ], aggregator: 'custom' } }, extractValue: true, aggregation: 'custom', // Add this for RelationalComparatorRule aggregator: 'custom', owaWeights: [0.8, 0.2] // 80% weight on highest, 20% on second }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '>=', fallbackBehavior: 'deny' }); const result = arbiter.check('user:alice', 'custom_weighted_check', 'feature:premium', { includeMeta: true }); assert.equal(result.possibility, 1); // Should calculate: (0.8 * 2000) + (0.2 * 1500) = 1900 assert.ok(Math.abs(result.meta.allow.leftValue - 1900) < 50); // Allow some precision variance }); it('includes aggregation metadata in results', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('has_balance_primary', { type: 'direct' }); arbiter.setRelationConfig('has_balance_secondary', { type: 'direct' }); arbiter.addRelation('user:alice', 'has_balance_primary', 'feature:basic', { value: 2000 }); arbiter.addRelation('user:alice', 'has_balance_secondary', 'feature:basic', { value: 800 }); arbiter.setRelationConfig('metadata_test_check', { type: 'relational_comparator', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance_primary' }, { type: 'direct', relation: 'has_balance_secondary' } ], aggregator: 'majority' } }, extractValue: true, aggregator: 'majority' }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '>=', fallbackBehavior: 'deny' }); const result = arbiter.check('user:alice', 'metadata_test_check', 'feature:basic', { includeMeta: true }); assert.equal(result.possibility, 1); // Check that aggregation metadata is included in the result assert.ok(result.meta.allow); assert.equal(result.meta.allow.type, 'relational_comparator'); // The aggregation metadata should be available in the internal processing // (though not necessarily exposed in the final result structure) }); }); describe('Comparison Operators', () => { it('supports all comparison operators', () => { const arbiter = setupRelationalComparatorTestGraph(); // Test exact equality arbiter.addNode('user:exact', 'user'); arbiter.addNode('feature:exact', 'feature'); arbiter.addNode('account:exact', 'account'); arbiter.addNode('price:exact', 'price'); arbiter.addRelation('user:exact', 'has_balance', 'feature:exact', { value: 1000 }); arbiter.addRelation('feature:exact', 'has_price', 'feature:exact', { value: 1000 }); // Test == operator arbiter.setRelationConfig('exact_match', { type: 'relational_comparator', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '==', fallbackBehavior: 'deny' }); const result1 = arbiter.check('user:exact', 'exact_match', 'feature:exact'); assert.ok(result1.possibility > 0.5, `Expected reasonable possibility for exact match, got ${result1.possibility}`); // Test != operator arbiter.setRelationConfig('not_equal', { type: 'relational_comparator', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '!=', fallbackBehavior: 'deny' }); const result2 = arbiter.check('user:alice', 'not_equal', 'feature:basic'); assert.equal(result2.possibility, 1); // 1500 != 100 // Test < operator arbiter.setRelationConfig('less_than', { type: 'relational_comparator', left: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true }, right: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true }, comparator: '<', fallbackBehavior: 'deny' }); const result3 = arbiter.check('user:alice', 'less_than', 'feature:basic'); assert.equal(result3.possibility, 1); // 100 < 1500 }); }); describe('OWA Sum Operator and Standardized Aggregation', () => { it('supports OWA Sum operator for scaling aggregation', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('has_balance_savings', { type: 'direct' }); // Alice has multiple balances, we want to sum them with scaling arbiter.addRelation('user:alice', 'has_balance_savings', 'feature:enterprise', { value: 4000 // Increased from 2000 to make total 5500 > enterprise price 5000 }); arbiter.setRelationConfig('scaled_sum_check', { type: 'relational_comparator', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance' }, { type: 'direct', relation: 'has_balance_savings' } ], aggregator: 'sum' } }, extractValue: true, aggregation: 'sum', // Add this for compatibility with RelationalComparatorRule aggregator: 'sum', // Sum operator - weights don't normalize to 1.0 decayRate: 0, // No decay for this test decayFunction: 'rational' }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object', decayRate: 0, decayFunction: 'rational' }, comparator: '>=', fallbackBehavior: 'deny' }); const result = arbiter.check('user:alice', 'scaled_sum_check', 'feature:enterprise', { includeMeta: true }); assert.equal(result.possibility, 1); // Should sum all balances (1500 + 4000 = 5500) assert.equal(result.meta.allow.leftValue, 5500); assert.equal(result.meta.allow.rightValue, 5000); }); it('supports majority aggregation', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('has_balance_primary', { type: 'direct' }); arbiter.setRelationConfig('has_balance_secondary', { type: 'direct' }); arbiter.setRelationConfig('has_balance_tertiary', { type: 'direct' }); // Add multiple balance relations to Alice arbiter.addRelation('user:alice', 'has_balance_primary', 'feature:premium', { value: 2500 }); arbiter.addRelation('user:alice', 'has_balance_secondary', 'feature:premium', { value: 800 }); arbiter.addRelation('user:alice', 'has_balance_tertiary', 'feature:premium', { value: 1200 }); arbiter.setRelationConfig('majority_balance_check', { type: 'relational_comparator', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance_primary' }, { type: 'direct', relation: 'has_balance_secondary' }, { type: 'direct', relation: 'has_balance_tertiary' } ], aggregator: 'majority' } }, extractValue: true, aggregator: 'majority', // Add this for RelationalComparatorRule aggregator: 'majority' // Majority aggregation - focuses on typical values }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '>=', fallbackBehavior: 'deny' }); const result = arbiter.check('user:alice', 'majority_balance_check', 'feature:premium', { includeMeta: true }); assert.equal(result.possibility, 1); // Should use majority-focused aggregation (not simply max or min) assert.ok(result.meta.allow.leftValue > 1000); assert.ok(result.meta.allow.leftValue < 3000); // Not the max value }); it('supports optimistic aggregation', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('has_balance_primary', { type: 'direct' }); arbiter.setRelationConfig('has_balance_secondary', { type: 'direct' }); arbiter.addRelation('user:alice', 'has_balance_primary', 'feature:premium', { value: 2000 }); arbiter.addRelation('user:alice', 'has_balance_secondary', 'feature:premium', { value: 500 }); arbiter.setRelationConfig('optimistic_balance_check', { type: 'relational_comparator', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance_primary' }, { type: 'direct', relation: 'has_balance_secondary' } ], aggregator: 'optimistic' } }, extractValue: true, aggregation: 'optimistic', // Add this for RelationalComparatorRule aggregator: 'optimistic', // Optimistic - weights higher values more decayRate: 0, decayFunction: 'rational' }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '>=', fallbackBehavior: 'deny' }); const result = arbiter.check('user:alice', 'optimistic_balance_check', 'feature:premium', { includeMeta: true }); assert.equal(result.possibility, 1); // Should bias toward higher values assert.ok(result.meta.allow.leftValue > 1250); // Weighted toward higher values }); it('supports custom OWA weights', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('has_balance_primary', { type: 'direct' }); arbiter.setRelationConfig('has_balance_secondary', { type: 'direct' }); arbiter.addRelation('user:alice', 'has_balance_primary', 'feature:premium', { value: 2000 }); arbiter.addRelation('user:alice', 'has_balance_secondary', 'feature:premium', { value: 1500 }); arbiter.setRelationConfig('custom_weighted_check', { type: 'relational_comparator', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance_primary' }, { type: 'direct', relation: 'has_balance_secondary' } ], aggregator: 'custom' } }, extractValue: true, aggregation: 'custom', // Add this for RelationalComparatorRule aggregator: 'custom', owaWeights: [0.8, 0.2] // 80% weight on highest, 20% on second }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '>=', fallbackBehavior: 'deny' }); const result = arbiter.check('user:alice', 'custom_weighted_check', 'feature:premium', { includeMeta: true }); assert.equal(result.possibility, 1); // Should calculate: (0.8 * 2000) + (0.2 * 1500) = 1900 assert.ok(Math.abs(result.meta.allow.leftValue - 1900) < 50); // Allow some precision variance }); it('includes aggregation metadata in results', () => { const arbiter = setupRelationalComparatorTestGraph(); arbiter.setRelationConfig('has_balance_primary', { type: 'direct' }); arbiter.setRelationConfig('has_balance_secondary', { type: 'direct' }); arbiter.addRelation('user:alice', 'has_balance_primary', 'feature:basic', { value: 2000 }); arbiter.addRelation('user:alice', 'has_balance_secondary', 'feature:basic', { value: 800 }); arbiter.setRelationConfig('metadata_test_check', { type: 'relational_comparator', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance_primary' }, { type: 'direct', relation: 'has_balance_secondary' } ], aggregator: 'majority' } }, extractValue: true, aggregator: 'majority' }, right: { rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' }, extractValue: true, evaluateFrom: 'object' }, comparator: '>=', fallbackBehavior: 'deny' }); const result = arbiter.check('user:alice', 'metadata_test_check', 'feature:basic', { includeMeta: true }); assert.equal(result.possibility, 1); // Check that aggregation metadata is included in the result assert.ok(result.meta.allow); assert.equal(result.meta.allow.type, 'relational_comparator'); // The aggregation metadata should be available in the internal processing // (though not necessarily exposed in the final result structure) }); }); });