257c52ea91
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.
1186 lines
42 KiB
JavaScript
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)
|
|
});
|
|
});
|
|
});
|