2026-07-31 13:44:06 -07:00
|
|
|
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'
|
|
|
|
|
});
|
2026-08-02 11:03:10 -07:00
|
|
|
const result = arbiter.check('user:decay-test', 'can_access_decay_test', 'feature:decay-test', { now });
|
2026-07-31 13:44:06 -07:00
|
|
|
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'
|
|
|
|
|
});
|
2026-08-02 11:03:10 -07:00
|
|
|
const result = arbiter.check('user:usage-test', 'within_quota', 'service:limited', { now });
|
2026-07-31 13:44:06 -07:00
|
|
|
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)
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|