Files
core/tests/rules/relational-comparator.test.js
John Dvorak 257c52ea91 tests: migrate Date.now patching to the pinned-clock parameter
The now parameter (and partialGraph.now) replaced the need for global
Date.now patching in tests. The ttl-expiry parity campaign's module-scope
patch and the relational-comparator Time-Based Decay tests' before/after
patches now pass the clock explicitly through check options — no global
mutation, no restore-order fragility, and the pinned-clock cache bypass
keeps every evaluation honest. No stale references to the removed
compiled evaluator or useCompiled option remain in the test suite.
2026-08-02 11:03:10 -07:00

1186 lines
42 KiB
JavaScript

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