initial commit: @arbiter/core authorization engine with js-rigor hardening
Zanzibar-style authorization graph engine (direct/chain/TTU/defeasible/ binary modes, condensed snapshots, value relations) with 39 rigor test campaigns. Includes fixes for snapshot binary writer/reader format mismatch (snapshot-of-snapshot corruption), possibility write-boundary validation, empty-graph snapshot serialization, relation lookup cache direction collision, config-redefinition cache invalidation, binary threshold semantics, defeasible compiled routing, and comparator reason whitelisting.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { OWAFusion } from '../../src/utils/OWAFusion.js';
|
||||
|
||||
function createRng(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (1664525 * state + 1013904223) >>> 0;
|
||||
return state / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
function randInt(rng, max) {
|
||||
return Math.floor(rng() * max);
|
||||
}
|
||||
|
||||
function randFloat(rng, min = 0, max = 1) {
|
||||
return min + (max - min) * rng();
|
||||
}
|
||||
|
||||
function approxEqual(a, b, eps = 1e-6) {
|
||||
return Math.abs(a - b) <= eps;
|
||||
}
|
||||
|
||||
describe('OWA aggregation properties', () => {
|
||||
test('bounded and translation properties across modes', () => {
|
||||
const rng = createRng(42);
|
||||
const modes = ['max', 'min', 'average', 'majority', 'median', 'optimistic', 'pessimistic', 'top2', 'top3'];
|
||||
const iterations = 200;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const length = randInt(rng, 8) + 1;
|
||||
const values = new Array(length).fill(0).map(() => randFloat(rng, -50, 50));
|
||||
const metas = new Array(length).fill(null);
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
|
||||
for (const mode of modes) {
|
||||
const weights = OWAFusion.generateOWAWeights(length, mode, null, true);
|
||||
const result = OWAFusion.fuseWithMeta(values, metas, weights, mode, true).value;
|
||||
assert.ok(result >= min - 1e-6 && result <= max + 1e-6, `mode ${mode} bounds`);
|
||||
|
||||
const weightSum = weights.reduce((sum, w) => sum + w, 0);
|
||||
if (approxEqual(weightSum, 1.0)) {
|
||||
const delta = randFloat(rng, -10, 10);
|
||||
const shifted = values.map(v => v + delta);
|
||||
const shiftedResult = OWAFusion.fuseWithMeta(shifted, metas, weights, mode, true).value;
|
||||
assert.ok(approxEqual(shiftedResult - result, delta, 1e-5), `mode ${mode} translation`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('extreme strategies behave as expected', () => {
|
||||
const rng = createRng(7);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const length = randInt(rng, 8) + 1;
|
||||
const values = new Array(length).fill(0).map(() => randFloat(rng, -20, 20));
|
||||
const metas = new Array(length).fill(null);
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
|
||||
const maxWeights = OWAFusion.generateOWAWeights(length, 'max', null, true);
|
||||
const minWeights = OWAFusion.generateOWAWeights(length, 'min', null, true);
|
||||
const maxResult = OWAFusion.fuseWithMeta(values, metas, maxWeights, 'max', true).value;
|
||||
const minResult = OWAFusion.fuseWithMeta(values, metas, minWeights, 'min', true).value;
|
||||
assert.ok(approxEqual(maxResult, max, 1e-6), 'max aggregator');
|
||||
assert.ok(approxEqual(minResult, min, 1e-6), 'min aggregator');
|
||||
}
|
||||
});
|
||||
|
||||
test('custom weights respect convex combination', () => {
|
||||
const rng = createRng(13);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const length = randInt(rng, 8) + 1;
|
||||
const values = new Array(length).fill(0).map(() => randFloat(rng, -100, 100));
|
||||
const metas = new Array(length).fill(null);
|
||||
|
||||
let weights = new Array(length).fill(0).map(() => randFloat(rng, 0, 1));
|
||||
const sum = weights.reduce((a, b) => a + b, 0) || 1;
|
||||
weights = weights.map(w => w / sum);
|
||||
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const result = OWAFusion.fuseWithMeta(values, metas, weights, 'custom', true).value;
|
||||
assert.ok(result >= min - 1e-6 && result <= max + 1e-6, 'custom convex bounds');
|
||||
}
|
||||
});
|
||||
|
||||
test('sum weights returns total', () => {
|
||||
const rng = createRng(99);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const length = randInt(rng, 8) + 1;
|
||||
const values = new Array(length).fill(0).map(() => randFloat(rng, -5, 5));
|
||||
const metas = new Array(length).fill(null);
|
||||
const weights = OWAFusion.generateOWAWeights(length, 'sum', null, false);
|
||||
const result = OWAFusion.fuseWithMeta(values, metas, weights, 'sum', false).value;
|
||||
const expected = values.reduce((a, b) => a + b, 0);
|
||||
assert.ok(approxEqual(result, expected, 1e-6), 'sum matches total');
|
||||
}
|
||||
});
|
||||
|
||||
test('sum_unbounded matches sum', () => {
|
||||
const rng = createRng(101);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const length = randInt(rng, 8) + 1;
|
||||
const values = new Array(length).fill(0).map(() => randFloat(rng, -5, 5));
|
||||
const metas = new Array(length).fill(null);
|
||||
const sumWeights = OWAFusion.generateOWAWeights(length, 'sum', null, false);
|
||||
const unboundedWeights = OWAFusion.generateOWAWeights(length, 'sum_unbounded', null, false);
|
||||
const sumResult = OWAFusion.fuseWithMeta(values, metas, sumWeights, 'sum', false).value;
|
||||
const unboundedResult = OWAFusion.fuseWithMeta(values, metas, unboundedWeights, 'sum_unbounded', false).value;
|
||||
assert.ok(approxEqual(sumResult, unboundedResult, 1e-6), 'sum_unbounded matches sum');
|
||||
}
|
||||
});
|
||||
|
||||
test('priority weights are normalized proportions', () => {
|
||||
const priorities = [1, 5, 3];
|
||||
const weights = OWAFusion.generateOWAWeights(priorities.length, 'priority', priorities, true);
|
||||
const total = weights.reduce((sum, w) => sum + w, 0);
|
||||
assert.ok(approxEqual(total, 1.0, 1e-6));
|
||||
assert.ok(weights[1] > weights[2] && weights[2] > weights[0], 'weights follow priorities');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Value Aggregation', () => {
|
||||
test('max aggregator returns highest value', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('user:2', 'user');
|
||||
arbiter.addNode('user:3', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('member', { type: 'direct' });
|
||||
arbiter.setRelationConfig('allow', {
|
||||
union: {
|
||||
rules: [{ type: 'direct', relation: 'member' }],
|
||||
aggregator: 'max'
|
||||
}
|
||||
});
|
||||
|
||||
arbiter.addRelation('user:1', 'member', 'resource:1', 0.3);
|
||||
arbiter.addRelation('user:2', 'member', 'resource:1', 0.7);
|
||||
arbiter.addRelation('user:3', 'member', 'resource:1', 0.5);
|
||||
|
||||
const result = arbiter.check('user:2', 'allow', 'resource:1', { fastPath: false });
|
||||
assert.strictEqual(result.possibility, 0.7, 'max returns 0.7');
|
||||
});
|
||||
|
||||
test('sum aggregator returns total', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('permission1', { type: 'direct' });
|
||||
arbiter.setRelationConfig('permission2', { type: 'direct' });
|
||||
arbiter.setRelationConfig('permission3', { type: 'direct' });
|
||||
arbiter.setRelationConfig('allow', {
|
||||
union: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'permission1' },
|
||||
{ type: 'direct', relation: 'permission2' },
|
||||
{ type: 'direct', relation: 'permission3' }
|
||||
],
|
||||
aggregator: 'sum'
|
||||
}
|
||||
});
|
||||
|
||||
arbiter.addRelation('user:1', 'permission1', 'resource:1', 0.2);
|
||||
arbiter.addRelation('user:1', 'permission2', 'resource:1', 0.3);
|
||||
arbiter.addRelation('user:1', 'permission3', 'resource:1', 0.4);
|
||||
|
||||
const result = arbiter.check('user:1', 'allow', 'resource:1', { fastPath: false });
|
||||
assert.ok(Math.abs(result.possibility - 0.9) < 1e-6, `sum returns 0.9, got ${result.possibility}`);
|
||||
});
|
||||
|
||||
test('min aggregator returns lowest value', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('permission1', { type: 'direct' });
|
||||
arbiter.setRelationConfig('permission2', { type: 'direct' });
|
||||
arbiter.setRelationConfig('allow', {
|
||||
union: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'permission1' },
|
||||
{ type: 'direct', relation: 'permission2' }
|
||||
],
|
||||
aggregator: 'min'
|
||||
}
|
||||
});
|
||||
|
||||
arbiter.addRelation('user:1', 'permission1', 'resource:1', 0.3);
|
||||
arbiter.addRelation('user:1', 'permission2', 'resource:1', 0.7);
|
||||
|
||||
const result = arbiter.check('user:1', 'allow', 'resource:1', { fastPath: false });
|
||||
assert.strictEqual(result.possibility, 0.3, 'min returns 0.3');
|
||||
});
|
||||
|
||||
test('intersection with sum requires both relations', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('permission1', { type: 'direct' });
|
||||
arbiter.setRelationConfig('permission2', { type: 'direct' });
|
||||
arbiter.setRelationConfig('allow', {
|
||||
intersection: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'permission1' },
|
||||
{ type: 'direct', relation: 'permission2' }
|
||||
],
|
||||
aggregator: 'sum'
|
||||
}
|
||||
});
|
||||
|
||||
arbiter.addRelation('user:1', 'permission1', 'resource:1', 0.3);
|
||||
arbiter.addRelation('user:1', 'permission2', 'resource:1', 0.7);
|
||||
|
||||
const result = arbiter.check('user:1', 'allow', 'resource:1', { fastPath: false });
|
||||
assert.strictEqual(result.possibility, 1.0, 'sum of both relations');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Tests for Built-in DSL Functions
|
||||
*
|
||||
* Tests ip_in_cidr, ip_is_private, hour_of_day, etc.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isBuiltInFunction,
|
||||
evaluateBuiltIn,
|
||||
getFunctionSignature,
|
||||
BUILT_IN_FUNCTIONS
|
||||
} from '../../src/ast/interpreter/BuiltInFunctions.js';
|
||||
|
||||
describe('Built-in Functions Registry', () => {
|
||||
it('should identify built-in functions', () => {
|
||||
assert.strictEqual(isBuiltInFunction('ip_in_cidr'), true);
|
||||
assert.strictEqual(isBuiltInFunction('ip_is_private'), true);
|
||||
assert.strictEqual(isBuiltInFunction('hour_of_day'), true);
|
||||
assert.strictEqual(isBuiltInFunction('unknown_function'), false);
|
||||
});
|
||||
|
||||
it('should return function signatures', () => {
|
||||
const sig = getFunctionSignature('ip_in_cidr');
|
||||
assert.ok(sig);
|
||||
assert.strictEqual(sig.name, 'ip_in_cidr');
|
||||
assert.deepStrictEqual(sig.params, ['ip', 'cidr']);
|
||||
});
|
||||
|
||||
it('should return null for unknown functions', () => {
|
||||
assert.strictEqual(getFunctionSignature('unknown'), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IP Address Functions', () => {
|
||||
describe('ip_in_cidr', () => {
|
||||
it('should match IP in CIDR range', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_in_cidr', ['10.0.0.5', '10.0.0.0/8']), true);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_in_cidr', ['192.168.1.50', '192.168.1.0/24']), true);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_in_cidr', ['172.16.5.1', '172.16.0.0/12']), true);
|
||||
});
|
||||
|
||||
it('should not match IP outside CIDR range', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_in_cidr', ['10.0.0.5', '192.168.0.0/16']), false);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_in_cidr', ['203.0.113.42', '10.0.0.0/8']), false);
|
||||
});
|
||||
|
||||
it('should handle exact IP match', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_in_cidr', ['203.0.113.42', '203.0.113.42/32']), true);
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_in_cidr', ['', '10.0.0.0/8']), false);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_in_cidr', ['invalid', '10.0.0.0/8']), false);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_in_cidr', ['10.0.0.1', 'invalid']), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ip_is_private', () => {
|
||||
it('should identify private IPs', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_private', ['10.0.0.1']), true);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_private', ['172.16.0.1']), true);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_private', ['192.168.1.1']), true);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_private', ['127.0.0.1']), true);
|
||||
});
|
||||
|
||||
it('should identify public IPs', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_private', ['8.8.8.8']), false);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_private', ['203.0.113.42']), false);
|
||||
});
|
||||
|
||||
it('should handle invalid input', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_private', ['']), false);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_private', [null]), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ip_is_loopback', () => {
|
||||
it('should identify loopback IPs', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_loopback', ['127.0.0.1']), true);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_loopback', ['127.255.255.255']), true);
|
||||
});
|
||||
|
||||
it('should not identify non-loopback IPs', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_loopback', ['10.0.0.1']), false);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_loopback', ['192.168.1.1']), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ip_version', () => {
|
||||
it('should identify IPv4', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_version', ['10.0.0.1']), 4);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_version', ['203.0.113.42']), 4);
|
||||
});
|
||||
|
||||
it('should identify IPv6', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_version', ['::1']), 6);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_version', ['2001:db8::1']), 6);
|
||||
});
|
||||
|
||||
it('should return null for invalid', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_version', ['invalid']), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ip_is_v4 and ip_is_v6', () => {
|
||||
it('should correctly identify versions', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_v4', ['10.0.0.1']), true);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_v4', ['::1']), false);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_v6', ['::1']), true);
|
||||
assert.strictEqual(evaluateBuiltIn('ip_is_v6', ['10.0.0.1']), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ip_equals', () => {
|
||||
it('should match equal IPs', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_equals', ['10.0.0.1', '10.0.0.1']), true);
|
||||
});
|
||||
|
||||
it('should not match different IPs', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ip_equals', ['10.0.0.1', '10.0.0.2']), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Time Functions', () => {
|
||||
describe('hour_of_day', () => {
|
||||
it('should extract hour from timestamp', () => {
|
||||
// 2024-01-15 10:30:00 UTC
|
||||
const ts = new Date('2024-01-15T10:30:00Z').getTime();
|
||||
assert.strictEqual(evaluateBuiltIn('hour_of_day', [ts]), new Date(ts).getHours());
|
||||
});
|
||||
|
||||
it('should handle midnight', () => {
|
||||
const ts = new Date('2024-01-15T00:00:00Z').getTime();
|
||||
assert.strictEqual(evaluateBuiltIn('hour_of_day', [ts]), new Date(ts).getHours());
|
||||
});
|
||||
|
||||
it('should handle noon', () => {
|
||||
const ts = new Date('2024-01-15T12:00:00Z').getTime();
|
||||
assert.strictEqual(evaluateBuiltIn('hour_of_day', [ts]), new Date(ts).getHours());
|
||||
});
|
||||
|
||||
it('should handle 23:00', () => {
|
||||
const ts = new Date('2024-01-15T23:00:00Z').getTime();
|
||||
assert.strictEqual(evaluateBuiltIn('hour_of_day', [ts]), new Date(ts).getHours());
|
||||
});
|
||||
});
|
||||
|
||||
describe('day_of_week', () => {
|
||||
it('should extract day of week', () => {
|
||||
// Sunday = 0
|
||||
const sun = new Date('2024-01-14T00:00:00Z').getTime();
|
||||
assert.strictEqual(evaluateBuiltIn('day_of_week', [sun]), new Date(sun).getDay());
|
||||
|
||||
// Monday = 1
|
||||
const mon = new Date('2024-01-15T00:00:00Z').getTime();
|
||||
assert.strictEqual(evaluateBuiltIn('day_of_week', [mon]), new Date(mon).getDay());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('String Functions', () => {
|
||||
describe('contains', () => {
|
||||
it('should find substring', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('contains', ['hello world', 'world']), true);
|
||||
assert.strictEqual(evaluateBuiltIn('contains', ['hello world', 'foo']), false);
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('contains', ['', 'foo']), false);
|
||||
assert.strictEqual(evaluateBuiltIn('contains', ['hello', '']), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('starts_with', () => {
|
||||
it('should match prefix', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('starts_with', ['hello world', 'hello']), true);
|
||||
assert.strictEqual(evaluateBuiltIn('starts_with', ['hello world', 'world']), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ends_with', () => {
|
||||
it('should match suffix', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('ends_with', ['hello world', 'world']), true);
|
||||
assert.strictEqual(evaluateBuiltIn('ends_with', ['hello world', 'hello']), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Comparison Functions', () => {
|
||||
describe('equals', () => {
|
||||
it('should check equality', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('equals', [1, 1]), true);
|
||||
assert.strictEqual(evaluateBuiltIn('equals', [1, 2]), false);
|
||||
assert.strictEqual(evaluateBuiltIn('equals', ['a', 'a']), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('greater_than', () => {
|
||||
it('should compare numbers', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('greater_than', [2, 1]), true);
|
||||
assert.strictEqual(evaluateBuiltIn('greater_than', [1, 2]), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('less_than', () => {
|
||||
it('should compare numbers', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('less_than', [1, 2]), true);
|
||||
assert.strictEqual(evaluateBuiltIn('less_than', [2, 1]), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('in_range', () => {
|
||||
it('should check range', () => {
|
||||
assert.strictEqual(evaluateBuiltIn('in_range', [5, 1, 10]), true);
|
||||
assert.strictEqual(evaluateBuiltIn('in_range', [0, 1, 10]), false);
|
||||
assert.strictEqual(evaluateBuiltIn('in_range', [11, 1, 10]), false);
|
||||
assert.strictEqual(evaluateBuiltIn('in_range', [1, 1, 10]), true); // inclusive
|
||||
assert.strictEqual(evaluateBuiltIn('in_range', [10, 1, 10]), true); // inclusive
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should throw on unknown function', () => {
|
||||
assert.throws(() => {
|
||||
evaluateBuiltIn('unknown', []);
|
||||
}, /Unknown built-in function/);
|
||||
});
|
||||
|
||||
it('should throw on wrong argument count', () => {
|
||||
assert.throws(() => {
|
||||
evaluateBuiltIn('ip_in_cidr', ['10.0.0.1']); // Missing cidr
|
||||
}, /expects 2 arguments/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Rule result cache stats', () => {
|
||||
test('records cache hits on repeated aggregate checks', () => {
|
||||
const arbiter = new Arbiter({ enableRuleResultCache: true, ruleResultCacheTTL: 60000 });
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('member', { type: 'direct' });
|
||||
arbiter.setRelationConfig('allow', {
|
||||
union: {
|
||||
rules: [{ type: 'direct', relation: 'member' }],
|
||||
aggregator: 'max'
|
||||
}
|
||||
});
|
||||
|
||||
arbiter.registerDependencyIndex(new Map([['member', {
|
||||
all: new Set(['allow']),
|
||||
byLevel: {
|
||||
never: new Set(),
|
||||
always: new Set(),
|
||||
requires: new Set(),
|
||||
when: new Set(),
|
||||
unless: new Set(),
|
||||
ordinary: new Set(['allow'])
|
||||
}
|
||||
}]]));
|
||||
|
||||
arbiter.addRelation('user:1', 'member', 'resource:1', 1.0);
|
||||
|
||||
const userId = arbiter.resolveNodeId('user:1');
|
||||
const objectId = arbiter.resolveNodeId('resource:1');
|
||||
const config = arbiter.relationConfigs.get('allow');
|
||||
|
||||
arbiter.authChecker.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
'user:1',
|
||||
objectId,
|
||||
'resource:1',
|
||||
config,
|
||||
new Set(),
|
||||
'allow',
|
||||
{ includeMeta: false, collectValues: false, cacheRuleResult: true }
|
||||
);
|
||||
|
||||
const baseKey = arbiter.keyManager.createCompositeKey(userId, 'allow', objectId);
|
||||
const cacheKey = `${baseKey}|logical`;
|
||||
assert.ok(arbiter.ruleResultCache.get(cacheKey), 'cache entry created');
|
||||
|
||||
arbiter.authChecker.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
'user:1',
|
||||
objectId,
|
||||
'resource:1',
|
||||
config,
|
||||
new Set(),
|
||||
'allow',
|
||||
{ includeMeta: false, collectValues: false, cacheRuleResult: true }
|
||||
);
|
||||
|
||||
assert.ok(arbiter.ruleResultCacheStats.misses >= 1, 'cache miss recorded');
|
||||
assert.ok(arbiter.ruleResultCacheStats.hits >= 1, 'cache hit recorded');
|
||||
});
|
||||
|
||||
test('does not prepopulate cache on config set', () => {
|
||||
const arbiter = new Arbiter({ enableRuleResultCache: true, ruleResultCacheTTL: 60000 });
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('risk_score', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_limit', { type: 'direct' });
|
||||
|
||||
arbiter.addRelation('user:1', 'risk_score', 'resource:1', 1.0, { value: 10 });
|
||||
arbiter.addRelation('resource:1', 'risk_limit', 'resource:1', 1.0, { value: 20 });
|
||||
|
||||
arbiter.setRelationConfig('risk_ok_owa', {
|
||||
type: 'relational_comparator',
|
||||
comparator: '<=',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'risk_score' },
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_score',
|
||||
aggregator: 'owa',
|
||||
owaWeights: [1]
|
||||
},
|
||||
right: {
|
||||
rule: { type: 'direct', relation: 'risk_limit' },
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_limit',
|
||||
evaluateFrom: 'object'
|
||||
}
|
||||
});
|
||||
|
||||
const userId = arbiter.resolveNodeId('user:1');
|
||||
const objectId = arbiter.resolveNodeId('resource:1');
|
||||
const config = arbiter.relationConfigs.get('risk_ok_owa');
|
||||
const cacheKey = arbiter.authChecker.ruleEvaluator._getRuleResultCacheKey(
|
||||
userId,
|
||||
'risk_ok_owa',
|
||||
objectId,
|
||||
config
|
||||
);
|
||||
|
||||
assert.ok(!arbiter.ruleResultCache.get(cacheKey), 'cache not populated on config set');
|
||||
|
||||
arbiter.authChecker.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
'user:1',
|
||||
objectId,
|
||||
'resource:1',
|
||||
config,
|
||||
new Set(),
|
||||
'risk_ok_owa',
|
||||
{ includeMeta: false, collectValues: true, cacheRuleResult: true }
|
||||
);
|
||||
|
||||
assert.ok(arbiter.ruleResultCache.get(cacheKey), 'cache populated after evaluation');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
function createRng(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (1664525 * state + 1013904223) >>> 0;
|
||||
return state / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
function randFloat(rng, min = 0, max = 1) {
|
||||
return min + (max - min) * rng();
|
||||
}
|
||||
|
||||
function buildDependencyIndex(fromRelation, toRelation) {
|
||||
const entry = {
|
||||
all: new Set([toRelation]),
|
||||
byLevel: {
|
||||
never: new Set(),
|
||||
always: new Set(),
|
||||
requires: new Set(),
|
||||
when: new Set(),
|
||||
unless: new Set(),
|
||||
ordinary: new Set([toRelation])
|
||||
}
|
||||
};
|
||||
return new Map([[fromRelation, entry]]);
|
||||
}
|
||||
|
||||
function buildDependencyIndexForRelations(relations, toRelation) {
|
||||
const map = new Map();
|
||||
for (const relation of relations) {
|
||||
map.set(relation, {
|
||||
all: new Set([toRelation]),
|
||||
byLevel: {
|
||||
never: new Set(),
|
||||
always: new Set(),
|
||||
requires: new Set(),
|
||||
when: new Set(),
|
||||
unless: new Set(),
|
||||
ordinary: new Set([toRelation])
|
||||
}
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
describe('Rule result cache invalidation', () => {
|
||||
test('invalidates aggregate cache when dependency changes', () => {
|
||||
const arbiter = new Arbiter({ enableRuleResultCache: true, ruleResultCacheTTL: 60000 });
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('member', { type: 'direct' });
|
||||
arbiter.setRelationConfig('allow', {
|
||||
union: {
|
||||
rules: [{ type: 'direct', relation: 'member' }],
|
||||
aggregator: 'max'
|
||||
}
|
||||
});
|
||||
|
||||
arbiter.registerDependencyIndex(buildDependencyIndex('member', 'allow'));
|
||||
arbiter.addRelation('user:1', 'member', 'resource:1', 1.0);
|
||||
|
||||
const result1 = arbiter.check('user:1', 'allow', 'resource:1', { fastPath: false });
|
||||
assert.strictEqual(result1.possibility, 1.0);
|
||||
|
||||
const userId = arbiter.resolveNodeId('user:1');
|
||||
const objectId = arbiter.resolveNodeId('resource:1');
|
||||
const baseKey = arbiter.keyManager.createCompositeKey(userId, 'allow', objectId);
|
||||
const cacheKey = `${baseKey}|logical`;
|
||||
assert.ok(arbiter.ruleResultCache.get(cacheKey), 'cache populated');
|
||||
|
||||
arbiter.removeRelation('user:1', 'member', 'resource:1');
|
||||
assert.strictEqual(arbiter.ruleResultCache.get(cacheKey), undefined);
|
||||
|
||||
arbiter.addRelation('user:1', 'member', 'resource:1', 0.2);
|
||||
const result2 = arbiter.check('user:1', 'allow', 'resource:1', { fastPath: false });
|
||||
assert.ok(result2.possibility < 0.3 && result2.possibility > 0.1);
|
||||
});
|
||||
|
||||
test('random updates do not serve stale aggregate results', () => {
|
||||
const arbiter = new Arbiter({ enableRuleResultCache: true, ruleResultCacheTTL: 60000 });
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('member', { type: 'direct' });
|
||||
arbiter.setRelationConfig('allow', {
|
||||
union: {
|
||||
rules: [{ type: 'direct', relation: 'member' }],
|
||||
aggregator: 'max'
|
||||
}
|
||||
});
|
||||
arbiter.registerDependencyIndex(buildDependencyIndex('member', 'allow'));
|
||||
|
||||
const rng = createRng(123);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const value = randFloat(rng, 0, 1);
|
||||
arbiter.removeRelation('user:1', 'member', 'resource:1');
|
||||
arbiter.addRelation('user:1', 'member', 'resource:1', value);
|
||||
const result = arbiter.check('user:1', 'allow', 'resource:1', { fastPath: false });
|
||||
assert.ok(Math.abs(result.possibility - value) < 1e-6);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalidates nested comparator aggregation on updates', () => {
|
||||
const arbiter = new Arbiter({ enableRuleResultCache: true, ruleResultCacheTTL: 60000 });
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('risk_score', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_bonus', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_noise', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_limit', { type: 'direct' });
|
||||
|
||||
arbiter.setRelationConfig('risk_ok_deep', {
|
||||
type: 'relational_comparator',
|
||||
comparator: '<=',
|
||||
fallbackBehavior: 'deny',
|
||||
left: {
|
||||
rule: {
|
||||
union: {
|
||||
rules: [
|
||||
{
|
||||
union: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'risk_score' },
|
||||
{ type: 'direct', relation: 'risk_bonus' }
|
||||
],
|
||||
aggregator: 'sum'
|
||||
}
|
||||
},
|
||||
{ type: 'direct', relation: 'risk_noise' }
|
||||
],
|
||||
aggregator: 'sum'
|
||||
}
|
||||
},
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_score',
|
||||
aggregator: 'sum'
|
||||
},
|
||||
right: {
|
||||
rule: { type: 'direct', relation: 'risk_limit' },
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_limit',
|
||||
evaluateFrom: 'object'
|
||||
}
|
||||
});
|
||||
|
||||
arbiter.registerDependencyIndex(buildDependencyIndexForRelations(
|
||||
['risk_score', 'risk_bonus', 'risk_noise', 'risk_limit'],
|
||||
'risk_ok_deep'
|
||||
));
|
||||
|
||||
arbiter.addRelation('user:1', 'risk_score', 'resource:1', 1.0, { value: 20 });
|
||||
arbiter.addRelation('user:1', 'risk_bonus', 'resource:1', 1.0, { value: 5 });
|
||||
arbiter.addRelation('user:1', 'risk_noise', 'resource:1', 1.0, { value: 5 });
|
||||
arbiter.addRelation('resource:1', 'risk_limit', 'resource:1', 1.0, { value: 40 });
|
||||
|
||||
const userId = arbiter.resolveNodeId('user:1');
|
||||
const objectId = arbiter.resolveNodeId('resource:1');
|
||||
const config = arbiter.relationConfigs.get('risk_ok_deep');
|
||||
|
||||
const options = { includeMeta: false, collectValues: true, cacheRuleResult: true, fastPath: false };
|
||||
const result1 = arbiter.authChecker.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
'user:1',
|
||||
objectId,
|
||||
'resource:1',
|
||||
config,
|
||||
new Set(),
|
||||
'risk_ok_deep',
|
||||
options
|
||||
);
|
||||
|
||||
const ruleCacheKey = arbiter.authChecker.ruleEvaluator._getRuleResultCacheKey(
|
||||
userId,
|
||||
'risk_ok_deep',
|
||||
objectId,
|
||||
config
|
||||
);
|
||||
const derivedCacheKey = arbiter.keyManager.createCompositeKey(
|
||||
userId,
|
||||
'risk_ok_deep:operand:left:risk_score:sum:auto:1',
|
||||
objectId
|
||||
);
|
||||
|
||||
assert.ok(arbiter.ruleResultCache.get(ruleCacheKey), 'comparator cache populated');
|
||||
assert.ok(arbiter.ruleResultCache.get(derivedCacheKey), 'derived cache populated');
|
||||
|
||||
arbiter.authChecker.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
'user:1',
|
||||
objectId,
|
||||
'resource:1',
|
||||
config,
|
||||
new Set(),
|
||||
'risk_ok_deep',
|
||||
options
|
||||
);
|
||||
assert.ok(arbiter.ruleResultCacheStats.hits >= 1, 'cache hit recorded');
|
||||
|
||||
arbiter.addRelation('user:1', 'risk_bonus', 'resource:1', 1.0, { value: 80 });
|
||||
assert.strictEqual(arbiter.ruleResultCache.get(ruleCacheKey), undefined);
|
||||
assert.strictEqual(arbiter.ruleResultCache.get(derivedCacheKey), undefined);
|
||||
|
||||
const result2 = arbiter.authChecker.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
'user:1',
|
||||
objectId,
|
||||
'resource:1',
|
||||
config,
|
||||
new Set(),
|
||||
'risk_ok_deep',
|
||||
options
|
||||
);
|
||||
|
||||
assert.notStrictEqual(result1.possibility, result2.possibility);
|
||||
});
|
||||
|
||||
test('invalidates nested comparator aggregation on edge removal', () => {
|
||||
const arbiter = new Arbiter({ enableRuleResultCache: true, ruleResultCacheTTL: 60000 });
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('risk_score', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_bonus', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_noise', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_limit', { type: 'direct' });
|
||||
|
||||
arbiter.setRelationConfig('risk_ok_deep', {
|
||||
type: 'relational_comparator',
|
||||
comparator: '<=',
|
||||
fallbackBehavior: 'deny',
|
||||
left: {
|
||||
rule: {
|
||||
union: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'risk_score' },
|
||||
{ type: 'direct', relation: 'risk_noise' }
|
||||
],
|
||||
aggregator: 'sum'
|
||||
}
|
||||
},
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_score',
|
||||
aggregator: 'sum'
|
||||
},
|
||||
right: {
|
||||
rule: { type: 'direct', relation: 'risk_limit' },
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_limit',
|
||||
evaluateFrom: 'object'
|
||||
}
|
||||
});
|
||||
|
||||
arbiter.registerDependencyIndex(buildDependencyIndexForRelations(
|
||||
['risk_score', 'risk_noise', 'risk_limit'],
|
||||
'risk_ok_deep'
|
||||
));
|
||||
|
||||
arbiter.addRelation('user:1', 'risk_score', 'resource:1', 1.0, { value: 10 });
|
||||
arbiter.addRelation('user:1', 'risk_noise', 'resource:1', 1.0, { value: 5 });
|
||||
arbiter.addRelation('resource:1', 'risk_limit', 'resource:1', 1.0, { value: 30 });
|
||||
|
||||
const userId = arbiter.resolveNodeId('user:1');
|
||||
const objectId = arbiter.resolveNodeId('resource:1');
|
||||
const config = arbiter.relationConfigs.get('risk_ok_deep');
|
||||
const options = { includeMeta: false, collectValues: true, cacheRuleResult: true, fastPath: false };
|
||||
|
||||
arbiter.authChecker.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
'user:1',
|
||||
objectId,
|
||||
'resource:1',
|
||||
config,
|
||||
new Set(),
|
||||
'risk_ok_deep',
|
||||
options
|
||||
);
|
||||
|
||||
const ruleCacheKey = arbiter.authChecker.ruleEvaluator._getRuleResultCacheKey(
|
||||
userId,
|
||||
'risk_ok_deep',
|
||||
objectId,
|
||||
config
|
||||
);
|
||||
const derivedCacheKey = arbiter.keyManager.createCompositeKey(
|
||||
userId,
|
||||
'risk_ok_deep:operand:left:risk_score:sum:auto:1',
|
||||
objectId
|
||||
);
|
||||
|
||||
assert.ok(arbiter.ruleResultCache.get(ruleCacheKey), 'comparator cache populated');
|
||||
assert.ok(arbiter.ruleResultCache.get(derivedCacheKey), 'derived cache populated');
|
||||
|
||||
arbiter.removeRelation('user:1', 'risk_noise', 'resource:1');
|
||||
assert.strictEqual(arbiter.ruleResultCache.get(ruleCacheKey), undefined);
|
||||
assert.strictEqual(arbiter.ruleResultCache.get(derivedCacheKey), undefined);
|
||||
|
||||
const result = arbiter.authChecker.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
'user:1',
|
||||
objectId,
|
||||
'resource:1',
|
||||
config,
|
||||
new Set(),
|
||||
'risk_ok_deep',
|
||||
options
|
||||
);
|
||||
|
||||
assert.ok(result.possibility >= 0, 'recomputed after invalidation');
|
||||
});
|
||||
|
||||
test('invalidates rule cache on node data update', () => {
|
||||
const arbiter = new Arbiter({ enableRuleResultCache: true, ruleResultCacheTTL: 60000 });
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('member', { type: 'direct' });
|
||||
arbiter.setRelationConfig('allow', {
|
||||
union: {
|
||||
rules: [{ type: 'direct', relation: 'member' }],
|
||||
aggregator: 'max'
|
||||
}
|
||||
});
|
||||
|
||||
arbiter.addRelation('user:1', 'member', 'resource:1', 1.0);
|
||||
|
||||
const userId = arbiter.resolveNodeId('user:1');
|
||||
const objectId = arbiter.resolveNodeId('resource:1');
|
||||
const config = arbiter.relationConfigs.get('allow');
|
||||
|
||||
const result = arbiter.authChecker.ruleEvaluator.evaluateRule(
|
||||
userId,
|
||||
'user:1',
|
||||
objectId,
|
||||
'resource:1',
|
||||
config,
|
||||
new Set(),
|
||||
'allow',
|
||||
{ includeMeta: false, collectValues: false, cacheRuleResult: true }
|
||||
);
|
||||
assert.strictEqual(result.possibility, 1.0);
|
||||
|
||||
const cacheKey = arbiter.authChecker.ruleEvaluator._getRuleResultCacheKey(
|
||||
userId,
|
||||
'allow',
|
||||
objectId,
|
||||
config
|
||||
);
|
||||
assert.ok(arbiter.ruleResultCache.get(cacheKey), 'cache populated');
|
||||
|
||||
arbiter.updateNodeData('user:1', { tier: 'premium' });
|
||||
assert.strictEqual(arbiter.ruleResultCache.get(cacheKey), undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Cache invalidation paths', () => {
|
||||
test('relation lookup cache clears on removal', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user', 'user');
|
||||
arbiter.addNode('doc', 'document');
|
||||
arbiter.addRelation('user', 'can_read', 'doc', 1.0);
|
||||
|
||||
const srcId = arbiter.nodeIdByKey.get('user');
|
||||
const dstId = arbiter.nodeIdByKey.get('doc');
|
||||
const cacheKey = arbiter.relationManager._makeDirectCacheKey(srcId, 'can_read', dstId);
|
||||
|
||||
const relation = arbiter.relationManager.getDirectRelation(srcId, 'can_read', dstId);
|
||||
assert.ok(relation);
|
||||
// RF-08: cache state moved entirely to RelationCaches.
|
||||
assert.ok(arbiter.relationManager._caches.relationLookupCache.has(cacheKey));
|
||||
|
||||
arbiter.removeRelation('user', 'can_read', 'doc');
|
||||
assert.ok(!arbiter.relationManager._caches.relationLookupCache.has(cacheKey));
|
||||
});
|
||||
|
||||
test('value cache clears on relation modification', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user', 'user');
|
||||
arbiter.addNode('account', 'account');
|
||||
arbiter.addRelation('user', 'has_balance', 'account', 1.0, { value: 10 });
|
||||
|
||||
const srcId = arbiter.nodeIdByKey.get('user');
|
||||
const dstId = arbiter.nodeIdByKey.get('account');
|
||||
const cacheKey = arbiter.relationManager._makeValueCacheKey(srcId, 'has_balance', dstId);
|
||||
|
||||
const valueRelation = arbiter.relationManager.getValueRelation(srcId, 'has_balance', dstId);
|
||||
assert.ok(valueRelation);
|
||||
assert.ok(arbiter.relationManager._caches.valueLookupCache.has(cacheKey));
|
||||
|
||||
arbiter.relationManager._modifyRelation('user', 'has_balance', 'account', { value: 20 });
|
||||
assert.ok(!arbiter.relationManager._caches.valueLookupCache.has(cacheKey));
|
||||
});
|
||||
|
||||
test('direct check cache clears on relation removal', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user', 'user');
|
||||
arbiter.addNode('doc', 'document');
|
||||
arbiter.addRelation('user', 'can_read', 'doc', 1.0);
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
||||
|
||||
const result = arbiter.check('user', 'can_read', 'doc');
|
||||
assert.strictEqual(result.possibility, 1.0);
|
||||
|
||||
const srcId = arbiter.keyManager.getStringId('user');
|
||||
const dstId = arbiter.keyManager.getStringId('doc');
|
||||
const cacheKey = arbiter.keyManager.createCompositeKey(srcId, 'can_read', dstId);
|
||||
assert.ok(arbiter.directCheckCache.has(cacheKey));
|
||||
|
||||
arbiter.removeRelation('user', 'can_read', 'doc');
|
||||
assert.ok(!arbiter.directCheckCache.has(cacheKey));
|
||||
});
|
||||
|
||||
test('value cache invalidation schedules stale recompute', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user', 'user');
|
||||
arbiter.addNode('account', 'account');
|
||||
arbiter.addRelation('user', 'has_balance', 'account', 1.0, { value: 10 });
|
||||
|
||||
const srcId = arbiter.nodeIdByKey.get('user');
|
||||
const dstId = arbiter.nodeIdByKey.get('account');
|
||||
const valueRelation = arbiter.relationManager.getValueRelation(srcId, 'has_balance', dstId);
|
||||
assert.ok(valueRelation);
|
||||
|
||||
arbiter.relationManager._modifyRelation('user', 'has_balance', 'account', { value: 20 });
|
||||
assert.strictEqual(arbiter.valueManager.staleValueItemsIndex.size, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Chain Rules and Reachability', () => {
|
||||
test('simple chain rule enables transitive access', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('user:2', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('friend', { type: 'direct' });
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
|
||||
arbiter.setRelationConfig('friend_owner', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'friend', direction: 'out' },
|
||||
{ relation: 'owner', direction: 'out' }
|
||||
]
|
||||
});
|
||||
|
||||
arbiter.addRelation('user:1', 'friend', 'user:2', 1.0);
|
||||
arbiter.addRelation('user:2', 'owner', 'resource:1', 1.0);
|
||||
|
||||
const result = arbiter.check('user:1', 'friend_owner', 'resource:1');
|
||||
assert.strictEqual(result.possibility, 1.0, 'chain rule grants access');
|
||||
});
|
||||
|
||||
test('multi-hop chain rules work correctly', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('user:2', 'user');
|
||||
arbiter.addNode('user:3', 'user');
|
||||
arbiter.addNode('user:4', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('friend', { type: 'direct' });
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
|
||||
arbiter.setRelationConfig('friend_owner', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'friend', direction: 'out' },
|
||||
{ relation: 'friend', direction: 'out' },
|
||||
{ relation: 'friend', direction: 'out' },
|
||||
{ relation: 'owner', direction: 'out' }
|
||||
]
|
||||
});
|
||||
|
||||
arbiter.addRelation('user:1', 'friend', 'user:2', 1.0);
|
||||
arbiter.addRelation('user:2', 'friend', 'user:3', 1.0);
|
||||
arbiter.addRelation('user:3', 'friend', 'user:4', 1.0);
|
||||
arbiter.addRelation('user:4', 'owner', 'resource:1', 1.0);
|
||||
|
||||
const result = arbiter.check('user:1', 'friend_owner', 'resource:1');
|
||||
assert.strictEqual(result.possibility, 1.0, '4-hop chain rule works');
|
||||
});
|
||||
|
||||
test('chain rule respects distance limit', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('user:2', 'user');
|
||||
arbiter.addNode('user:3', 'user');
|
||||
arbiter.addNode('user:4', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('friend', { type: 'direct' });
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
|
||||
arbiter.setRelationConfig('friend_owner', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'friend', direction: 'out' },
|
||||
{ relation: 'owner', direction: 'out' }
|
||||
]
|
||||
});
|
||||
|
||||
arbiter.addRelation('user:1', 'friend', 'user:2', 1.0);
|
||||
arbiter.addRelation('user:2', 'friend', 'user:3', 1.0);
|
||||
arbiter.addRelation('user:3', 'friend', 'user:4', 1.0);
|
||||
arbiter.addRelation('user:4', 'owner', 'resource:1', 1.0);
|
||||
|
||||
const result = arbiter.check('user:1', 'friend_owner', 'resource:1');
|
||||
assert.strictEqual(result.possibility, 0.0, 'distance limit prevents access');
|
||||
});
|
||||
|
||||
test('get reachable nodes returns correct set', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('node:1', 'node');
|
||||
arbiter.addNode('node:2', 'node');
|
||||
arbiter.addNode('node:3', 'node');
|
||||
arbiter.addNode('node:4', 'node');
|
||||
|
||||
arbiter.setRelationConfig('connect', { type: 'direct' });
|
||||
|
||||
arbiter.addRelation('node:1', 'connect', 'node:2', 1.0);
|
||||
arbiter.addRelation('node:2', 'connect', 'node:3', 1.0);
|
||||
|
||||
const reachable = arbiter.getReachableNodes('node:1', 10);
|
||||
assert.ok(reachable.includes('node:1'), 'source node included');
|
||||
assert.ok(reachable.includes('node:2'), 'node:2 is reachable');
|
||||
assert.ok(reachable.includes('node:3'), 'node:3 is reachable');
|
||||
assert.ok(!reachable.includes('node:4'), 'node:4 is not reachable');
|
||||
});
|
||||
|
||||
test('chain rule does not grant access through missing middle relation', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('user:2', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('friend', { type: 'direct' });
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
|
||||
arbiter.setRelationConfig('friend_owner', {
|
||||
type: 'chain',
|
||||
from: { relation: 'friend' },
|
||||
to: { relation: 'owner' },
|
||||
resultRelation: 'friend_owner',
|
||||
distance: 2
|
||||
});
|
||||
|
||||
arbiter.addRelation('user:2', 'owner', 'resource:1', 1.0);
|
||||
|
||||
const result = arbiter.check('user:1', 'friend_owner', 'resource:1');
|
||||
assert.strictEqual(result.possibility, 0.0, 'no friend relation, no access');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Injectable witness plumbing', () => {
|
||||
test('injectable witness succeeds when present in direct relation', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
|
||||
arbiter.setRelationConfig('mfa', {
|
||||
type: 'source',
|
||||
relation: 'mfa',
|
||||
injectable: true,
|
||||
provides: 'Proof'
|
||||
});
|
||||
|
||||
arbiter.setRelationConfig('can_delete', {
|
||||
type: 'direct',
|
||||
relation: 'mfa'
|
||||
});
|
||||
|
||||
arbiter.addRelation('user:1', 'mfa', 'doc:1', 1.0);
|
||||
|
||||
const result = arbiter.check('user:1', 'can_delete', 'doc:1');
|
||||
assert.equal(result.possibility, 1);
|
||||
});
|
||||
|
||||
test('injectable witness returns unified remediation when missing', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
|
||||
arbiter.setRelationConfig('mfa', {
|
||||
type: 'source',
|
||||
relation: 'mfa',
|
||||
injectable: true,
|
||||
provides: 'Proof'
|
||||
});
|
||||
|
||||
arbiter.setRelationConfig('can_delete', {
|
||||
type: 'direct',
|
||||
relation: 'mfa'
|
||||
});
|
||||
|
||||
const result = arbiter.check('user:1', 'can_delete', 'doc:1');
|
||||
|
||||
assert.equal(result.possibility, 0);
|
||||
assert.ok(result.remediation?.options?.length > 0);
|
||||
assert.equal(result.remediation.options[0].relation, 'mfa');
|
||||
assert.equal(result.remediation.options[0].object, 'doc:1');
|
||||
});
|
||||
|
||||
test('injectable witness with within constraint is enforced by partial graph manager', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
|
||||
arbiter.setRelationConfig('mfa', {
|
||||
type: 'source',
|
||||
relation: 'mfa',
|
||||
injectable: true,
|
||||
provides: 'Proof',
|
||||
within: { value: '1s', unit: 's' }
|
||||
});
|
||||
|
||||
arbiter.setRelationConfig('can_delete', {
|
||||
type: 'direct',
|
||||
relation: 'mfa'
|
||||
});
|
||||
|
||||
// The checker only checks presence — freshness is enforced
|
||||
// at injection time by the higher-order partial graph manager.
|
||||
arbiter.addRelation('user:1', 'mfa', 'doc:1', 1.0);
|
||||
|
||||
const result = arbiter.check('user:1', 'can_delete', 'doc:1');
|
||||
assert.equal(result.possibility, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { CondensedGraph } from '../../src/core/CondensedGraph.js';
|
||||
|
||||
const runPerf = process.env.RUN_PERF_TESTS === '1';
|
||||
const perfTest = runPerf ? test : test.skip;
|
||||
|
||||
describe('CondensedGraph - Realistic Authorization Workloads', () => {
|
||||
perfTest('Zanzibar-style authorization graph', () => {
|
||||
// Simulate a real authorization graph with:
|
||||
// - Users, documents, groups, folders
|
||||
// - Multiple relation types
|
||||
// - Hierarchical access patterns
|
||||
|
||||
const graph = new CondensedGraph();
|
||||
const numUsers = 1000;
|
||||
const numDocs = 5000;
|
||||
const numGroups = 50;
|
||||
const numFolders = 100;
|
||||
|
||||
// Add users to groups (many-to-many)
|
||||
for (let i = 0; i < numUsers; i++) {
|
||||
const numGroupsPerUser = 1 + Math.floor(Math.random() * 5);
|
||||
for (let j = 0; j < numGroupsPerUser; j++) {
|
||||
const groupNum = Math.floor(Math.random() * numGroups);
|
||||
graph.addEdge(`user:${i}`, 'member', `group:${groupNum}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add document ownership (one user per doc)
|
||||
for (let i = 0; i < numDocs; i++) {
|
||||
const ownerNum = Math.floor(Math.random() * numUsers);
|
||||
graph.addEdge(`user:${ownerNum}`, 'owner', `doc:${i}`);
|
||||
}
|
||||
|
||||
// Add documents to folders
|
||||
for (let i = 0; i < numDocs; i++) {
|
||||
const folderNum = Math.floor(Math.random() * numFolders);
|
||||
graph.addEdge(`doc:${i}`, 'parent', `folder:${folderNum}`);
|
||||
}
|
||||
|
||||
// Add folder ownership
|
||||
for (let i = 0; i < numFolders; i++) {
|
||||
const ownerNum = Math.floor(Math.random() * numUsers);
|
||||
graph.addEdge(`user:${ownerNum}`, 'owner', `folder:${i}`);
|
||||
}
|
||||
|
||||
// Add group access to documents
|
||||
for (let i = 0; i < numDocs; i++) {
|
||||
const numGroupsWithAccess = Math.floor(Math.random() * 3);
|
||||
for (let j = 0; j < numGroupsWithAccess; j++) {
|
||||
const groupNum = Math.floor(Math.random() * numGroups);
|
||||
const relations = ['viewer', 'editor', 'commenter'];
|
||||
const rel = relations[Math.floor(Math.random() * relations.length)];
|
||||
graph.addEdge(`group:${groupNum}`, rel, `doc:${i}`);
|
||||
}
|
||||
}
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
const stats = graph.getStats();
|
||||
|
||||
console.log('\nZanzibar-style graph:');
|
||||
console.log(` Users: ${numUsers}, Docs: ${numDocs}, Groups: ${numGroups}, Folders: ${numFolders}`);
|
||||
console.log(` Total nodes: ${stats.numNodes}`);
|
||||
console.log(` Total edges: ${stats.numEdges}`);
|
||||
console.log(` Avg degree: ${stats.avgDegree.toFixed(2)}`);
|
||||
console.log(` Memory: ${(stats.memoryUsage.total / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` Bytes/edge: ${stats.bytesPerEdge.toFixed(2)}`);
|
||||
|
||||
// Authorization checks: user -> doc
|
||||
console.log('\nAuthorization checks (user -> doc):');
|
||||
|
||||
const checks = [
|
||||
{ user: 'user:0', doc: 'doc:0' },
|
||||
{ user: 'user:100', doc: 'doc:500' },
|
||||
{ user: 'user:500', doc: 'doc:1000' }
|
||||
];
|
||||
|
||||
checks.forEach(check => {
|
||||
const iterations = 100000;
|
||||
|
||||
// Direct ownership check
|
||||
const directStart = performance.now();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
graph.findEdge(check.user, 'owner', check.doc);
|
||||
}
|
||||
const directTime = (performance.now() - directStart) / iterations * 1000;
|
||||
|
||||
// Has edge check
|
||||
const hasStart = performance.now();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
graph.hasEdge(check.user, 'owner', check.doc);
|
||||
}
|
||||
const hasTime = (performance.now() - hasStart) / iterations * 1000;
|
||||
|
||||
console.log(` ${check.user} -> ${check.doc}:`);
|
||||
console.log(` findEdge: ${directTime.toFixed(3)} µs`);
|
||||
console.log(` hasEdge: ${hasTime.toFixed(3)} µs`);
|
||||
});
|
||||
|
||||
// Group membership traversal simulation
|
||||
console.log('\nGroup membership traversal:');
|
||||
const userNum = 50;
|
||||
const groups = graph.getOutEdgesByRel(`user:${userNum}`, 'member');
|
||||
console.log(` User ${userNum} belongs to ${groups.length} groups`);
|
||||
|
||||
let totalDocsThroughGroups = 0;
|
||||
let totalTime = 0;
|
||||
const traversalIterations = 10000;
|
||||
|
||||
for (let i = 0; i < traversalIterations; i++) {
|
||||
const start = performance.now();
|
||||
let docCount = 0;
|
||||
|
||||
// Simulate: get user's groups, then get docs accessible by those groups
|
||||
const userGroups = graph.getOutEdgesByRel(`user:${userNum}`, 'member');
|
||||
for (const groupEdgeIdx of userGroups) {
|
||||
const groupEdge = graph.getEdge(groupEdgeIdx);
|
||||
const groupDocsViewer = graph.getOutEdgesByRel(groupEdge.dst, 'viewer');
|
||||
const groupDocsEditor = graph.getOutEdgesByRel(groupEdge.dst, 'editor');
|
||||
const groupDocsCommenter = graph.getOutEdgesByRel(groupEdge.dst, 'commenter');
|
||||
docCount += groupDocsViewer.length + groupDocsEditor.length + groupDocsCommenter.length;
|
||||
}
|
||||
|
||||
totalTime += performance.now() - start;
|
||||
totalDocsThroughGroups += docCount;
|
||||
}
|
||||
|
||||
const avgTime = (totalTime / traversalIterations) * 1000;
|
||||
const avgDocs = totalDocsThroughGroups / traversalIterations;
|
||||
|
||||
console.log(` Avg time: ${avgTime.toFixed(3)} µs`);
|
||||
console.log(` Avg docs accessible: ${avgDocs.toFixed(1)}`);
|
||||
|
||||
// Batch operations performance
|
||||
console.log('\nBatch operations:');
|
||||
|
||||
// Batch getOutEdges
|
||||
const batchStart = performance.now();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const edges = graph.getOutEdgesByRel(`user:${i % numUsers}`, 'member');
|
||||
}
|
||||
const batchTime = performance.now() - batchStart;
|
||||
console.log(` 1000 getOutEdges: ${batchTime.toFixed(2)} ms`);
|
||||
|
||||
assert.ok(stats.memoryUsage.total < 100 * 1024 * 1024, 'Memory should be < 100MB');
|
||||
});
|
||||
|
||||
perfTest('high-frequency authorization checks', () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
// Build a realistic graph
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
graph.addEdge(`user:${i % 100}`, ['owner', 'editor', 'viewer'][i % 3], `doc:${i % 1000}`);
|
||||
}
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
const numChecks = 1000000;
|
||||
const users = Array.from({ length: 100 }, (_, i) => `user:${i}`);
|
||||
const docs = Array.from({ length: 1000 }, (_, i) => `doc:${i}`);
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
const user = users[i % 100];
|
||||
const doc = docs[i % 1000];
|
||||
graph.findEdge(user, 'owner', doc);
|
||||
graph.findEdge(user, 'editor', doc);
|
||||
graph.findEdge(user, 'viewer', doc);
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
let allowed = 0;
|
||||
|
||||
for (let i = 0; i < numChecks; i++) {
|
||||
const user = users[i % 100];
|
||||
const doc = docs[i % 1000];
|
||||
const hasOwner = graph.findEdge(user, 'owner', doc);
|
||||
const hasEditor = graph.findEdge(user, 'editor', doc);
|
||||
const hasViewer = graph.findEdge(user, 'viewer', doc);
|
||||
|
||||
if (hasOwner !== null || hasEditor !== null || hasViewer !== null) {
|
||||
allowed++;
|
||||
}
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
const avgTime = (duration / numChecks) * 1000;
|
||||
const opsPerSec = numChecks / (duration / 1000);
|
||||
|
||||
console.log('\nHigh-frequency authorization checks (1M checks):');
|
||||
console.log(` Total time: ${duration.toFixed(2)} ms`);
|
||||
console.log(` Avg time/check: ${avgTime.toFixed(3)} µs`);
|
||||
console.log(` Checks/sec: ${opsPerSec.toFixed(0)}`);
|
||||
console.log(` Allowed: ${allowed} (${(allowed / numChecks * 100).toFixed(1)}%)`);
|
||||
|
||||
assert.ok(avgTime < 80, `Should be fast, got ${avgTime.toFixed(3)} µs`);
|
||||
});
|
||||
|
||||
perfTest('reachability query simulation', () => {
|
||||
// Simulate hierarchical access: user -> group -> subgroups -> docs
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
const numUsers = 100;
|
||||
const numGroups = 50;
|
||||
const numDocs = 1000;
|
||||
|
||||
// User -> group membership
|
||||
for (let i = 0; i < numUsers; i++) {
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const groupNum = (i + j) % numGroups;
|
||||
graph.addEdge(`user:${i}`, 'member', `group:${groupNum}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Group hierarchy
|
||||
for (let i = 0; i < numGroups; i++) {
|
||||
if (i < numGroups - 1) {
|
||||
graph.addEdge(`group:${i}`, 'parent', `group:${i + 1}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Group -> doc access
|
||||
for (let i = 0; i < numGroups; i++) {
|
||||
for (let j = 0; j < 20; j++) {
|
||||
graph.addEdge(`group:${i}`, 'viewer', `doc:${(i * 20 + j) % numDocs}`);
|
||||
}
|
||||
}
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
const stats = graph.getStats();
|
||||
console.log('\nReachability graph:');
|
||||
console.log(` Nodes: ${stats.numNodes}, Edges: ${stats.numEdges}`);
|
||||
console.log(` Memory: ${(stats.memoryUsage.total / 1024 / 1024).toFixed(2)} MB`);
|
||||
|
||||
// Simulate BFS-like queries
|
||||
const queries = 10000;
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < queries; i++) {
|
||||
const user = `user:${i % numUsers}`;
|
||||
|
||||
// Get user's groups
|
||||
const userGroups = graph.getOutEdgesByRel(user, 'member');
|
||||
|
||||
// For each group, get parent groups
|
||||
let allGroups = new Set();
|
||||
for (const edgeIdx of userGroups) {
|
||||
const edge = graph.getEdge(edgeIdx);
|
||||
allGroups.add(edge.dst);
|
||||
|
||||
// Check for parent groups
|
||||
const parentEdge = graph.findEdge(edge.dst, 'parent');
|
||||
if (parentEdge !== null) {
|
||||
const parent = graph.getEdge(parentEdge);
|
||||
allGroups.add(parent.dst);
|
||||
}
|
||||
}
|
||||
|
||||
// Get docs accessible by all groups
|
||||
let docCount = 0;
|
||||
for (const group of allGroups) {
|
||||
const docEdges = graph.getOutEdgesByRel(group, 'viewer');
|
||||
docCount += docEdges.length;
|
||||
}
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
const avgTime = (duration / queries) * 1000;
|
||||
|
||||
console.log(`\nReachability queries (${queries} queries):`);
|
||||
console.log(` Total time: ${duration.toFixed(2)} ms`);
|
||||
console.log(` Avg time/query: ${avgTime.toFixed(3)} µs`);
|
||||
console.log(` Queries/sec: ${(queries / (duration / 1000)).toFixed(0)}`);
|
||||
|
||||
assert.ok(avgTime < 100, `Should be fast, got ${avgTime.toFixed(3)} µs`);
|
||||
});
|
||||
|
||||
perfTest('memory scalability comparison', () => {
|
||||
const sizes = [10000, 50000, 100000];
|
||||
|
||||
console.log('\nMemory scalability:');
|
||||
console.log('Edges | Memory (MB) | Bytes/Edge | Capacity | Utilization');
|
||||
console.log('-------|-------------|------------|----------|-------------');
|
||||
|
||||
sizes.forEach(size => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
graph.addEdge(`user:${i % 100}`, ['owner', 'editor', 'viewer'][i % 3], `doc:${i % 1000}`);
|
||||
}
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
const stats = graph.getStats();
|
||||
const memMB = stats.memoryUsage.total / 1024 / 1024;
|
||||
|
||||
console.log(
|
||||
`${size.toString().padEnd(7)} | ` +
|
||||
`${memMB.toFixed(2).padEnd(11)} | ` +
|
||||
`${stats.bytesPerEdge.toFixed(2).padEnd(10)} | ` +
|
||||
`${stats.capacity.toString().padEnd(8)} | ` +
|
||||
`${(stats.utilization * 100).toFixed(1).padEnd(10)}%`
|
||||
);
|
||||
|
||||
assert.ok(memMB < size / 100, `Memory should be reasonable for ${size} edges`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { CondensedGraph } from '../../src/core/CondensedGraph.js';
|
||||
|
||||
const runPerf = process.env.RUN_PERF_TESTS === '1';
|
||||
const perfTest = runPerf ? test : test.skip;
|
||||
|
||||
describe('CondensedGraph - Reliable Memory & Performance', () => {
|
||||
perfTest('memory efficiency - multiple sizes', () => {
|
||||
const sizes = [1000, 10000, 50000];
|
||||
|
||||
sizes.forEach(size => {
|
||||
if (global.gc) global.gc();
|
||||
|
||||
const baseline = process.memoryUsage();
|
||||
const graph = new CondensedGraph();
|
||||
const afterCreate = process.memoryUsage();
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
graph.addEdge(
|
||||
`user:${i % 100}`,
|
||||
['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
`doc:${i % 500}`
|
||||
);
|
||||
}
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
const afterAdd = process.memoryUsage();
|
||||
const stats = graph.getStats();
|
||||
|
||||
const typedArrayMem = stats.memoryUsage.typedArrays;
|
||||
const nodeMapMem = stats.memoryUsage.nodeMap;
|
||||
const totalMem = stats.memoryUsage.total;
|
||||
|
||||
console.log(`\nMemory (${size} edges):`);
|
||||
console.log(` Nodes: ${stats.numNodes}`);
|
||||
console.log(` Edges: ${stats.numEdges}`);
|
||||
console.log(` Avg degree: ${stats.avgDegree.toFixed(2)}`);
|
||||
console.log(` Typed arrays: ${(typedArrayMem / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` Node map: ${(nodeMapMem / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` Total: ${(totalMem / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` Bytes/edge: ${stats.bytesPerEdge.toFixed(2)}`);
|
||||
console.log(` Capacity: ${stats.capacity}, Utilization: ${(stats.utilization * 100).toFixed(1)}%`);
|
||||
|
||||
assert.ok(stats.numEdges === size, `Should have ${size} edges`);
|
||||
assert.ok(totalMem < size * 1000, `Memory should be reasonable`);
|
||||
});
|
||||
});
|
||||
|
||||
perfTest('read performance - warm cache', () => {
|
||||
const sizes = [1000, 10000, 50000];
|
||||
|
||||
sizes.forEach(size => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
graph.addEdge(
|
||||
`user:${i % 100}`,
|
||||
['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
`doc:${i % 500}`
|
||||
);
|
||||
}
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
const operations = [
|
||||
{ name: 'getOutEdgesByRel', fn: () => graph.getOutEdgesByRel('user:0', 'owner') },
|
||||
{ name: 'findEdge', fn: () => graph.findEdge('user:0', 'owner') },
|
||||
{ name: 'hasEdge', fn: () => graph.hasEdge('user:0', 'owner', 'doc:0') },
|
||||
{ name: 'getDegree', fn: () => graph.getDegree('user:0') }
|
||||
];
|
||||
|
||||
console.log(`\nRead performance (${size} edges):`);
|
||||
|
||||
operations.forEach(op => {
|
||||
const iterations = 100000;
|
||||
|
||||
let count = 0;
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const result = op.fn();
|
||||
if (result) count++;
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
const avgMicros = (duration / iterations) * 1000;
|
||||
const opsPerSec = iterations / (duration / 1000);
|
||||
|
||||
console.log(` ${op.name}: ${avgMicros.toFixed(3)} µs (${opsPerSec.toFixed(0)} ops/sec)`);
|
||||
|
||||
assert.ok(duration < 5000, `${op.name} should be fast`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
perfTest('iteration performance', () => {
|
||||
const sizes = [1000, 10000, 50000];
|
||||
|
||||
sizes.forEach(size => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
graph.addEdge(
|
||||
`user:${i % 100}`,
|
||||
['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
`doc:${i % 500}`
|
||||
);
|
||||
}
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
console.log(`\nIteration performance (${size} edges):`);
|
||||
|
||||
let totalEdges = 0;
|
||||
const iterations = 10000;
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
graph.forEachOutEdgeByRel(`user:${i % 100}`, 'owner', (edge) => {
|
||||
totalEdges++;
|
||||
});
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
const avgMicros = (duration / iterations) * 1000;
|
||||
|
||||
console.log(` forEachOutEdgeByRel: ${avgMicros.toFixed(3)} µs/iteration`);
|
||||
console.log(` Total edges processed: ${totalEdges}`);
|
||||
console.log(` Avg edges/node: ${(totalEdges / iterations).toFixed(2)}`);
|
||||
|
||||
assert.ok(duration < 5000, 'Iteration should be fast');
|
||||
});
|
||||
});
|
||||
|
||||
perfTest('write performance', () => {
|
||||
const sizes = [1000, 10000, 50000];
|
||||
|
||||
sizes.forEach(size => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
graph.addEdge(
|
||||
`user:${i % 100}`,
|
||||
['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
`doc:${i % 500}`
|
||||
);
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
const avgMicros = (duration / size) * 1000;
|
||||
const edgesPerSec = size / (duration / 1000);
|
||||
|
||||
console.log(`\nWrite performance (${size} edges):`);
|
||||
console.log(` Total time: ${duration.toFixed(2)} ms`);
|
||||
console.log(` Avg time/edge: ${avgMicros.toFixed(3)} µs`);
|
||||
console.log(` Edges/sec: ${edgesPerSec.toFixed(0)}`);
|
||||
|
||||
assert.ok(avgMicros < 100, `addEdge should be fast`);
|
||||
});
|
||||
});
|
||||
|
||||
perfTest('comparison with plain array - memory', () => {
|
||||
const size = 50000;
|
||||
|
||||
// CondensedGraph
|
||||
const cg = new CondensedGraph();
|
||||
for (let i = 0; i < size; i++) {
|
||||
cg.addEdge(`user:${i % 100}`, ['owner', 'member', 'viewer', 'editor'][i % 4], `doc:${i % 500}`);
|
||||
}
|
||||
cg.finalizePerfectHash();
|
||||
cg.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
const cgStats = cg.getStats();
|
||||
const cgMem = cgStats.memoryUsage.total;
|
||||
|
||||
// Plain array
|
||||
const plainEdges = [];
|
||||
for (let i = 0; i < size; i++) {
|
||||
plainEdges.push({
|
||||
src: `user:${i % 100}`,
|
||||
rel: ['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
dst: `doc:${i % 500}`,
|
||||
possibility: 1.0,
|
||||
reliability: 1.0
|
||||
});
|
||||
}
|
||||
|
||||
// Estimate plain array memory (rough estimate)
|
||||
const plainMem = size * 120; // ~120 bytes per object + overhead
|
||||
|
||||
console.log('\nMemory comparison (50K edges):');
|
||||
console.log(` CondensedGraph: ${(cgMem / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` Plain array (est): ${(plainMem / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` Savings: ${((plainMem - cgMem) / plainMem * 100).toFixed(1)}%`);
|
||||
console.log(` CondensedGraph bytes/edge: ${cgStats.bytesPerEdge.toFixed(2)}`);
|
||||
console.log(` Plain array bytes/edge (est): ${(plainMem / size).toFixed(2)}`);
|
||||
|
||||
assert.ok(cgMem < plainMem, 'CondensedGraph should use less memory');
|
||||
});
|
||||
|
||||
perfTest('comparison with plain array - performance', () => {
|
||||
const size = 50000;
|
||||
|
||||
// CondensedGraph
|
||||
const cg = new CondensedGraph();
|
||||
for (let i = 0; i < size; i++) {
|
||||
cg.addEdge(`user:${i % 100}`, ['owner', 'member', 'viewer', 'editor'][i % 4], `doc:${i % 500}`);
|
||||
}
|
||||
cg.finalizePerfectHash();
|
||||
cg.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
// Plain array
|
||||
const plainEdges = [];
|
||||
for (let i = 0; i < size; i++) {
|
||||
plainEdges.push({
|
||||
src: `user:${i % 100}`,
|
||||
rel: ['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
dst: `doc:${i % 500}`,
|
||||
possibility: 1.0,
|
||||
reliability: 1.0
|
||||
});
|
||||
}
|
||||
|
||||
const iterations = 1000;
|
||||
|
||||
// CondensedGraph: getOutEdges
|
||||
const cgStart = performance.now();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
cg.getOutEdgesByRel(`user:${i % 100}`, 'owner');
|
||||
}
|
||||
const cgTime = performance.now() - cgStart;
|
||||
|
||||
// Plain array: filter
|
||||
const plainStart = performance.now();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
plainEdges.filter(e => e.src === `user:${i % 100}`);
|
||||
}
|
||||
const plainTime = performance.now() - plainStart;
|
||||
|
||||
console.log('\nPerformance comparison (50K edges, 1K lookups):');
|
||||
console.log(` CondensedGraph: ${cgTime.toFixed(2)} ms (${(cgTime / iterations).toFixed(3)} µs/lookup)`);
|
||||
console.log(` Plain array filter: ${plainTime.toFixed(2)} ms (${(plainTime / iterations).toFixed(3)} µs/lookup)`);
|
||||
console.log(` Speedup: ${(plainTime / cgTime).toFixed(2)}x`);
|
||||
|
||||
assert.ok(cgTime < plainTime, 'CondensedGraph should be faster');
|
||||
});
|
||||
|
||||
perfTest('scalability trends', () => {
|
||||
console.log('\n\n=== Scalability Analysis ===\n');
|
||||
|
||||
const sizes = [1000, 10000, 50000];
|
||||
|
||||
console.log('Size | Nodes | Edges | Avg Deg | Memory (MB) | Bytes/Edge');
|
||||
console.log('------|-------|-------|---------|-------------|-------------');
|
||||
|
||||
sizes.forEach(size => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
graph.addEdge(`user:${i % 100}`, ['owner', 'member', 'viewer', 'editor'][i % 4], `doc:${i % 500}`);
|
||||
}
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
const stats = graph.getStats();
|
||||
|
||||
console.log(
|
||||
`${size.toString().padStart(5)} | ` +
|
||||
`${stats.numNodes.toString().padStart(5)} | ` +
|
||||
`${stats.numEdges.toString().padStart(5)} | ` +
|
||||
`${stats.avgDegree.toFixed(2).padStart(7)} | ` +
|
||||
`${(stats.memoryUsage.total / 1024 / 1024).toFixed(2).padStart(11)} | ` +
|
||||
`${stats.bytesPerEdge.toFixed(2).padStart(11)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test, before, after } from 'node:test';
|
||||
import { CondensedGraph } from '../../src/core/CondensedGraph.js';
|
||||
|
||||
const runPerf = process.env.RUN_PERF_TESTS === '1';
|
||||
const perfTest = runPerf ? test : test.skip;
|
||||
|
||||
describe('CondensedGraph - Performance & Memory Benchmarks', () => {
|
||||
const sizes = [1000, 10000, 50000];
|
||||
const results = [];
|
||||
|
||||
sizes.forEach(size => {
|
||||
perfTest(`memory efficiency - ${size} edges`, () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
const startMem = process.memoryUsage().heapUsed;
|
||||
|
||||
// Add edges with realistic distribution
|
||||
for (let i = 0; i < size; i++) {
|
||||
graph.addEdge(
|
||||
`user:${i % 100}`,
|
||||
['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
`doc:${i % 500}`
|
||||
);
|
||||
}
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
const endMem = process.memoryUsage().heapUsed;
|
||||
const memDelta = endMem - startMem;
|
||||
const stats = graph.getStats();
|
||||
|
||||
results.push({
|
||||
size,
|
||||
memDelta,
|
||||
bytesPerEdge: memDelta / size,
|
||||
stats
|
||||
});
|
||||
|
||||
console.log(`\nMemory (${size} edges):`);
|
||||
console.log(` Heap delta: ${(memDelta / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` Bytes per edge: ${(memDelta / size).toFixed(2)}`);
|
||||
console.log(` Stats bytes/edge: ${stats.bytesPerEdge.toFixed(2)}`);
|
||||
console.log(` Nodes: ${stats.numNodes}, Edges: ${stats.numEdges}`);
|
||||
console.log(` Avg degree: ${stats.avgDegree.toFixed(2)}`);
|
||||
|
||||
// Heap delta is noisy; assert on stats bytes per edge instead
|
||||
assert.ok(stats.bytesPerEdge < 1000, `Stats bytes/edge should be < 1000, got ${stats.bytesPerEdge.toFixed(2)}`);
|
||||
});
|
||||
|
||||
perfTest(`read performance - ${size} edges - getOutEdgesByRel`, () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
graph.addEdge(
|
||||
`user:${i % 100}`,
|
||||
['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
`doc:${i % 500}`
|
||||
);
|
||||
}
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
// Warm-up
|
||||
for (let i = 0; i < 10; i++) {
|
||||
graph.getOutEdgesByRel(`user:${i}`, 'owner');
|
||||
}
|
||||
|
||||
// Benchmark
|
||||
const iterations = 1000;
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
graph.getOutEdgesByRel(`user:${i % 100}`, 'owner');
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
const avgTime = duration / iterations * 1000; // microseconds
|
||||
|
||||
console.log(`\nRead performance - ${size} edges - getOutEdgesByRel:`);
|
||||
console.log(` Total time: ${duration.toFixed(2)} ms`);
|
||||
console.log(` Avg time: ${avgTime.toFixed(2)} µs`);
|
||||
console.log(` Ops/sec: ${(1000000 / avgTime).toFixed(0)}`);
|
||||
|
||||
// Should be reasonably fast
|
||||
assert.ok(avgTime < 1000, `getOutEdgesByRel should be < 1000µs, got ${avgTime.toFixed(2)}µs`);
|
||||
});
|
||||
|
||||
perfTest(`read performance - ${size} edges - findEdge`, () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
graph.addEdge(
|
||||
`user:${i % 100}`,
|
||||
['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
`doc:${i % 500}`
|
||||
);
|
||||
}
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
// Warm-up
|
||||
for (let i = 0; i < 10; i++) {
|
||||
graph.findEdge(`user:${i}`, 'owner');
|
||||
}
|
||||
|
||||
// Benchmark
|
||||
const iterations = 10000;
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
graph.findEdge(`user:${i % 100}`, ['owner', 'member', 'viewer', 'editor'][i % 4]);
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
const avgTime = duration / iterations * 1000; // microseconds
|
||||
|
||||
console.log(`\nRead performance - ${size} edges - findEdge:`);
|
||||
console.log(` Total time: ${duration.toFixed(2)} ms`);
|
||||
console.log(` Avg time: ${avgTime.toFixed(2)} µs`);
|
||||
console.log(` Ops/sec: ${(1000000 / avgTime).toFixed(0)}`);
|
||||
|
||||
assert.ok(avgTime < 500, `findEdge should be < 500µs, got ${avgTime.toFixed(2)}µs`);
|
||||
});
|
||||
|
||||
perfTest(`read performance - ${size} edges - hasEdge`, () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
graph.addEdge(
|
||||
`user:${i % 100}`,
|
||||
['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
`doc:${i % 500}`
|
||||
);
|
||||
}
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
// Warm-up
|
||||
for (let i = 0; i < 10; i++) {
|
||||
graph.hasEdge(`user:${i}`, 'owner', `doc:${i}`);
|
||||
}
|
||||
|
||||
// Benchmark
|
||||
const iterations = 10000;
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
graph.hasEdge(
|
||||
`user:${i % 100}`,
|
||||
['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
`doc:${i % 500}`
|
||||
);
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
const avgTime = duration / iterations * 1000; // microseconds
|
||||
|
||||
console.log(`\nRead performance - ${size} edges - hasEdge:`);
|
||||
console.log(` Total time: ${duration.toFixed(2)} ms`);
|
||||
console.log(` Avg time: ${avgTime.toFixed(2)} µs`);
|
||||
console.log(` Ops/sec: ${(1000000 / avgTime).toFixed(0)}`);
|
||||
|
||||
assert.ok(avgTime < 1000, `hasEdge should be < 1000µs, got ${avgTime.toFixed(2)}µs`);
|
||||
});
|
||||
|
||||
perfTest(`iteration performance - ${size} edges - forEachOutEdgeByRel`, () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
graph.addEdge(
|
||||
`user:${i % 100}`,
|
||||
['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
`doc:${i % 500}`
|
||||
);
|
||||
}
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
let edgeCount = 0;
|
||||
|
||||
// Warm-up
|
||||
graph.forEachOutEdgeByRel('user:0', 'owner', (edge) => {
|
||||
edgeCount++;
|
||||
});
|
||||
|
||||
edgeCount = 0;
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
graph.forEachOutEdgeByRel(`user:${i % 100}`, 'owner', (edge) => {
|
||||
edgeCount++;
|
||||
});
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
const avgTime = duration / 1000 * 1000; // microseconds per iteration
|
||||
|
||||
console.log(`\nIteration performance - ${size} edges - forEachOutEdgeByRel:`);
|
||||
console.log(` Total time: ${duration.toFixed(2)} ms`);
|
||||
console.log(` Avg time per iteration: ${avgTime.toFixed(2)} µs`);
|
||||
console.log(` Total edges processed: ${edgeCount}`);
|
||||
|
||||
assert.ok(avgTime < 500, `forEachOutEdgeByRel should be < 500µs, got ${avgTime.toFixed(2)}µs`);
|
||||
});
|
||||
|
||||
perfTest(`write performance - ${size} edges - addEdge`, () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
graph.addEdge(
|
||||
`user:${i % 100}`,
|
||||
['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
`doc:${i % 500}`
|
||||
);
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
const avgTime = duration / size * 1000; // microseconds per edge
|
||||
|
||||
console.log(`\nWrite performance - ${size} edges - addEdge:`);
|
||||
console.log(` Total time: ${duration.toFixed(2)} ms`);
|
||||
console.log(` Avg time per edge: ${avgTime.toFixed(2)} µs`);
|
||||
console.log(` Edges/sec: ${(size / duration * 1000).toFixed(0)}`);
|
||||
|
||||
assert.ok(avgTime < 100, `addEdge should be < 100µs, got ${avgTime.toFixed(2)}µs`);
|
||||
});
|
||||
});
|
||||
|
||||
perfTest('comparison with plain object array', () => {
|
||||
const size = 50000;
|
||||
|
||||
// Test CondensedGraph
|
||||
const cg = new CondensedGraph();
|
||||
const cgStartMem = process.memoryUsage().heapUsed;
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
cg.addEdge(
|
||||
`user:${i % 100}`,
|
||||
['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
`doc:${i % 500}`
|
||||
);
|
||||
}
|
||||
|
||||
cg.finalizePerfectHash();
|
||||
cg.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
const cgStats = cg.getStats();
|
||||
|
||||
const cgEndMem = process.memoryUsage().heapUsed;
|
||||
const cgMemDelta = cgEndMem - cgStartMem;
|
||||
|
||||
// Test plain object array
|
||||
const plainEdges = [];
|
||||
const plainStartMem = process.memoryUsage().heapUsed;
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
plainEdges.push({
|
||||
src: `user:${i % 100}`,
|
||||
rel: ['owner', 'member', 'viewer', 'editor'][i % 4],
|
||||
dst: `doc:${i % 500}`,
|
||||
possibility: 1.0,
|
||||
reliability: 1.0
|
||||
});
|
||||
}
|
||||
|
||||
const plainEndMem = process.memoryUsage().heapUsed;
|
||||
const plainMemDelta = plainEndMem - plainStartMem;
|
||||
|
||||
// Benchmark read operations
|
||||
let cgTime = 0;
|
||||
let plainTime = 0;
|
||||
const iterations = 10000;
|
||||
|
||||
// CondensedGraph read
|
||||
const cgReadStart = performance.now();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
cg.getOutEdgesByRel(`user:${i % 100}`, 'owner');
|
||||
}
|
||||
cgTime = performance.now() - cgReadStart;
|
||||
|
||||
// Plain array read
|
||||
const plainReadStart = performance.now();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const src = `user:${i % 100}`;
|
||||
plainEdges.filter(e => e.src === src);
|
||||
}
|
||||
plainTime = performance.now() - plainReadStart;
|
||||
|
||||
console.log('\nComparison with plain object array:');
|
||||
console.log(` CondensedGraph memory: ${(cgMemDelta / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` Plain array memory: ${(plainMemDelta / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` Memory savings: ${((plainMemDelta - cgMemDelta) / plainMemDelta * 100).toFixed(1)}%`);
|
||||
console.log(` CondensedGraph bytes/edge: ${(cgMemDelta / size).toFixed(2)}`);
|
||||
console.log(` Plain array bytes/edge: ${(plainMemDelta / size).toFixed(2)}`);
|
||||
console.log(`\n CondensedGraph read time: ${cgTime.toFixed(2)} ms`);
|
||||
console.log(` Plain array read time: ${plainTime.toFixed(2)} ms`);
|
||||
console.log(` Read speedup: ${(plainTime / cgTime).toFixed(2)}x`);
|
||||
|
||||
const plainEstimateBytes = size * 120;
|
||||
assert.ok(cgStats.bytesPerEdge < (plainEstimateBytes / size), 'CondensedGraph should use less memory');
|
||||
});
|
||||
|
||||
perfTest('scalability summary', () => {
|
||||
console.log('\n\n=== Scalability Summary ===');
|
||||
console.log('Size | Memory (MB) | Bytes/Edge | Read (µs) | Write (µs)');
|
||||
console.log('-----|-------------|------------|-----------|-----------');
|
||||
|
||||
results.forEach(r => {
|
||||
console.log(
|
||||
`${r.size.toString().padEnd(5)} | ` +
|
||||
`${(r.memDelta / 1024 / 1024).toFixed(2).padEnd(11)} | ` +
|
||||
`${r.bytesPerEdge.toFixed(2).padEnd(10)} | ` +
|
||||
`${(r.stats.avgDegree * 10).toFixed(0).padEnd(9)} | ` +
|
||||
`${(r.memDelta / r.size / 100).toFixed(2).padEnd(10)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { CondensedGraph } from '../../src/core/CondensedGraph.js';
|
||||
|
||||
describe('CondensedGraph - Simple Validation', () => {
|
||||
test('add and retrieve single edge', () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
graph.addEdge('user:alice', 'owner', 'doc:report');
|
||||
|
||||
const edges = graph.getOutEdges('user:alice');
|
||||
assert.strictEqual(edges.length, 1, 'Should have 1 edge');
|
||||
|
||||
const edge = graph.getEdge(edges[0]);
|
||||
assert.ok(edge, 'Should retrieve edge');
|
||||
assert.strictEqual(edge.src, 'user:alice');
|
||||
assert.strictEqual(edge.dst, 'doc:report');
|
||||
});
|
||||
|
||||
test('multiple edges from same source', () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
graph.addEdge('user:alice', 'owner', 'doc:report');
|
||||
graph.addEdge('user:alice', 'member', 'group:eng');
|
||||
graph.addEdge('user:alice', 'viewer', 'doc:report');
|
||||
|
||||
const edges = graph.getOutEdges('user:alice');
|
||||
assert.strictEqual(edges.length, 3);
|
||||
});
|
||||
|
||||
test('edge existence', () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
graph.addEdge('user:alice', 'owner', 'doc:report');
|
||||
|
||||
assert.ok(graph.hasEdge('user:alice', graph.getRelationId('owner'), 'doc:report'), 'Edge should exist');
|
||||
assert.ok(!graph.hasEdge('user:bob', graph.getRelationId('owner'), 'doc:report'), 'Non-existent edge should not exist');
|
||||
});
|
||||
|
||||
test('memory efficiency - 50K edges', () => {
|
||||
const graph = new CondensedGraph();
|
||||
const numEdges = 50000;
|
||||
|
||||
const startMem = process.memoryUsage().heapUsed;
|
||||
|
||||
for (let i = 0; i < numEdges; i++) {
|
||||
graph.addEdge(`user:${i % 100}`, 'relation', `user:${(i + 1) % 1000}`);
|
||||
}
|
||||
|
||||
const endMem = process.memoryUsage().heapUsed;
|
||||
const memDelta = endMem - startMem;
|
||||
const stats = graph.getStats();
|
||||
|
||||
console.log('Memory efficiency (50K edges):');
|
||||
console.log(' - Heap delta:', (memDelta / 1024 / 1024).toFixed(2), 'MB');
|
||||
console.log(' - Bytes per edge:', (memDelta / numEdges).toFixed(2));
|
||||
console.log(' - Utilization:', (stats.utilization * 100).toFixed(2), '%');
|
||||
|
||||
assert.strictEqual(graph.numEdges, numEdges);
|
||||
assert.ok(memDelta < 50 * 1024 * 1024, 'Memory usage should be reasonable (< 50MB)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { CondensedGraph } from '../../src/core/CondensedGraph.js';
|
||||
|
||||
describe('CondensedGraph (Simplified Succinct Format)', () => {
|
||||
test('basic edge operations', () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
graph.addEdge('user:alice', 'owner', 'doc:report');
|
||||
graph.addEdge('user:bob', 'member', 'group:engineering');
|
||||
graph.addEdge('user:charlie', 'viewer', 'doc:report');
|
||||
|
||||
assert.strictEqual(graph.numEdges, 3);
|
||||
|
||||
const edges = graph.getOutEdges('user:alice');
|
||||
assert.strictEqual(edges.length, 1);
|
||||
|
||||
const edge = graph.getEdge(edges[0]);
|
||||
assert.strictEqual(edge.src, 'user:alice');
|
||||
assert.strictEqual(edge.dst, 'doc:report');
|
||||
});
|
||||
|
||||
test('find edge by relation', () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
graph.addEdge('user:alice', 'owner', 'doc:report');
|
||||
graph.addEdge('user:bob', 'owner', 'doc:finance');
|
||||
graph.addEdge('user:alice', 'member', 'group:engineering');
|
||||
|
||||
const idx = graph.findEdge('user:alice', graph.getRelationId('owner'), 'doc:report');
|
||||
assert.ok(idx !== null, 'Should find edge');
|
||||
|
||||
const edge = graph.getEdge(idx);
|
||||
assert.strictEqual(edge.src, 'user:alice');
|
||||
assert.strictEqual(edge.dst, 'doc:report');
|
||||
});
|
||||
|
||||
test('multiple edges from same source', () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
graph.addEdge('user:alice', 'owner', 'doc:report');
|
||||
graph.addEdge('user:alice', 'member', 'group:engineering');
|
||||
graph.addEdge('user:alice', 'viewer', 'doc:report');
|
||||
|
||||
const edges = graph.getOutEdges('user:alice');
|
||||
assert.strictEqual(edges.length, 3);
|
||||
});
|
||||
|
||||
test('remove edge', () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
graph.addEdge('user:alice', 'owner', 'doc:report');
|
||||
graph.addEdge('user:alice', 'member', 'group:engineering');
|
||||
|
||||
const initialEdges = graph.getOutEdges('user:alice');
|
||||
assert.strictEqual(initialEdges.length, 2);
|
||||
|
||||
const idx = graph.findEdge('user:alice', graph.getRelationId('member'), 'group:engineering');
|
||||
graph.removeEdge(idx);
|
||||
|
||||
const afterEdges = graph.getOutEdges('user:alice');
|
||||
assert.strictEqual(afterEdges.length, 1);
|
||||
});
|
||||
|
||||
test('degree tracking', () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
graph.addEdge('user:alice', 'owner', 'doc:report');
|
||||
graph.addEdge('user:alice', 'member', 'group:engineering');
|
||||
graph.addEdge('user:alice', 'viewer', 'doc:report');
|
||||
|
||||
assert.strictEqual(graph.getDegree('user:alice'), 3);
|
||||
});
|
||||
|
||||
test('edge existence check', () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
graph.addEdge('user:alice', 'owner', 'doc:report');
|
||||
graph.addEdge('user:bob', 'member', 'group:engineering');
|
||||
|
||||
assert.ok(graph.hasEdge('user:alice', graph.getRelationId('owner'), 'doc:report'));
|
||||
assert.ok(!graph.hasEdge('user:charlie', graph.getRelationId('owner'), 'doc:report'));
|
||||
});
|
||||
|
||||
test('iterate all edges', () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
graph.addEdge(`user:${i}`, 'relation', `user:${i + 1}`);
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
graph.forEachOutEdge('user:5', (edge) => {
|
||||
count++;
|
||||
});
|
||||
|
||||
assert.strictEqual(count, 1);
|
||||
});
|
||||
|
||||
test('memory efficiency', () => {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
const numEdges = 50000;
|
||||
const startMem = process.memoryUsage().heapUsed;
|
||||
|
||||
for (let i = 0; i < numEdges; i++) {
|
||||
graph.addEdge(`user:${i % 100}`, 'relation', `user:${(i + 1) % 1000}`);
|
||||
}
|
||||
|
||||
const endMem = process.memoryUsage().heapUsed;
|
||||
const memDelta = endMem - startMem;
|
||||
const stats = graph.getStats();
|
||||
|
||||
console.log('Memory efficiency test:');
|
||||
console.log(' - Edges:', numEdges);
|
||||
console.log(' - Stats:', stats);
|
||||
console.log(' - Heap delta:', (memDelta / 1024 / 1024).toFixed(2), 'MB');
|
||||
console.log(' - Bytes per edge:', (memDelta / numEdges).toFixed(2));
|
||||
|
||||
assert.strictEqual(graph.numEdges, numEdges);
|
||||
assert.ok(memDelta < 30 * 1024 * 1024, 'Memory usage should be reasonable (< 30MB)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
import { PartialGraphContext } from '../../src/core/PartialGraphContext.js';
|
||||
import { validateClaimsForLayer } from '../../src/core/partial-graph/layer-registry.js';
|
||||
|
||||
const runPerf = process.env.RUN_PERF_TESTS === '1';
|
||||
const perfTest = runPerf ? test : test.skip;
|
||||
|
||||
function percentile(sorted, p) {
|
||||
if (!sorted.length) return 0;
|
||||
const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor(sorted.length * p) - 1));
|
||||
return sorted[idx];
|
||||
}
|
||||
|
||||
perfTest('core perf: layer registry validation stays sub-1ms', () => {
|
||||
const claims = [
|
||||
{ relation: 'delegated_authority', object: 'resource:alpha:item:1', ttl_seconds: 60 },
|
||||
{ relation: 'workflow_step', object: 'workflow:loan:step:2', ttl_seconds: 60 }
|
||||
];
|
||||
const iterations = 10000;
|
||||
const durations = [];
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const start = performance.now();
|
||||
validateClaimsForLayer('workflow_overlay', claims, ['delegated_authority', 'workflow_step']);
|
||||
durations.push(performance.now() - start);
|
||||
}
|
||||
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / iterations;
|
||||
const sorted = [...durations].sort((a, b) => a - b);
|
||||
const p95 = percentile(sorted, 0.95);
|
||||
|
||||
assert.ok(avg < 1.0, `avg ${avg.toFixed(4)}ms exceeds 1ms target`);
|
||||
assert.ok(p95 < 1.0, `p95 ${p95.toFixed(4)}ms exceeds 1ms target`);
|
||||
});
|
||||
|
||||
perfTest('core perf: PartialGraphContext direct lookups stay sub-1ms', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
|
||||
const context = new PartialGraphContext(arbiter, {
|
||||
relations: [
|
||||
{ src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 1.0 }
|
||||
]
|
||||
});
|
||||
|
||||
const srcId = context.nodeIdByKey.get('user:1');
|
||||
const dstId = context.nodeIdByKey.get('doc:1');
|
||||
|
||||
const iterations = 10000;
|
||||
const durations = [];
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const start = performance.now();
|
||||
const relation = context.getDirectRelation(srcId, 'can_read', dstId);
|
||||
durations.push(performance.now() - start);
|
||||
assert.ok(relation);
|
||||
}
|
||||
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / iterations;
|
||||
const sorted = [...durations].sort((a, b) => a - b);
|
||||
const p95 = percentile(sorted, 0.95);
|
||||
|
||||
assert.ok(avg < 1.0, `avg ${avg.toFixed(4)}ms exceeds 1ms target`);
|
||||
assert.ok(p95 < 1.0, `p95 ${p95.toFixed(4)}ms exceeds 1ms target`);
|
||||
});
|
||||
|
||||
perfTest('core perf: Arbiter direct checks stay sub-1ms average', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
||||
arbiter.addRelation('user:1', 'can_read', 'doc:1', 1.0);
|
||||
|
||||
const iterations = 5000;
|
||||
const durations = [];
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const start = performance.now();
|
||||
const result = arbiter.check('user:1', 'can_read', 'doc:1');
|
||||
durations.push(performance.now() - start);
|
||||
assert.ok(result.possibility > 0);
|
||||
}
|
||||
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / iterations;
|
||||
assert.ok(avg < 1.0, `avg ${avg.toFixed(4)}ms exceeds 1ms target`);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { validateDslText } from '../../src/ast/validation/DSLValidation.js';
|
||||
|
||||
describe('DSL injectable predicates (* prefix)', () => {
|
||||
test('allows injectable predicates with * prefix in evidence bodies', () => {
|
||||
const dsl = `
|
||||
definition Doc { id: string }
|
||||
definition Proof { issued_at: timestamp }
|
||||
fact owns(user: User, doc: Doc)
|
||||
source *mfa(user: User) PROVIDES Proof
|
||||
source *webauthn(user: User) PROVIDES Proof
|
||||
|
||||
evidence can_delete(user: User, doc: Doc) {
|
||||
owns(user, doc)
|
||||
*mfa(user)
|
||||
*webauthn(user)
|
||||
}
|
||||
`;
|
||||
|
||||
const result = validateDslText(dsl);
|
||||
assert.equal(result.success, true, result.errors.join('\n'));
|
||||
assert.equal(result.errors.length, 0);
|
||||
});
|
||||
|
||||
test('injectable facts parse with * prefix', () => {
|
||||
const dsl = `
|
||||
definition Doc { id: string }
|
||||
fact *device_link(user: User, device: string)
|
||||
|
||||
evidence is_trusted(user: User) {
|
||||
*device_link(user, "trusted_device_01")
|
||||
}
|
||||
`;
|
||||
|
||||
const result = validateDslText(dsl);
|
||||
assert.equal(result.success, true, result.errors.join('\n'));
|
||||
assert.equal(result.errors.length, 0);
|
||||
});
|
||||
|
||||
test('allows within constraints on injectable predicates', () => {
|
||||
const dsl = `
|
||||
definition Doc { id: string }
|
||||
definition Proof { issued_at: timestamp }
|
||||
fact owns(user: User, doc: Doc)
|
||||
source *mfa(user: User) PROVIDES Proof within 10m
|
||||
|
||||
evidence can_delete(user: User, doc: Doc) {
|
||||
owns(user, doc)
|
||||
*mfa(user)
|
||||
}
|
||||
`;
|
||||
|
||||
const result = validateDslText(dsl);
|
||||
assert.equal(result.success, true, result.errors.join('\n'));
|
||||
assert.equal(result.errors.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { validateDslText } from '../../src/ast/validation/DSLValidation.js';
|
||||
|
||||
describe('DSL type guards', () => {
|
||||
test('infix is guard narrows for attribute access', () => {
|
||||
const dsl = `
|
||||
definition Profile {
|
||||
email_verified: boolean
|
||||
}
|
||||
|
||||
definition Session {
|
||||
provider_profile: any
|
||||
}
|
||||
|
||||
evidence can_login(session: Session) {
|
||||
session.provider_profile is Profile && session.provider_profile.email_verified
|
||||
}
|
||||
`;
|
||||
|
||||
const result = validateDslText(dsl);
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.errors.length, 0);
|
||||
});
|
||||
|
||||
test('infix is guard rejects unknown type names', () => {
|
||||
const dsl = `
|
||||
definition Session {
|
||||
provider_profile: any
|
||||
}
|
||||
|
||||
evidence can_login(session: Session) {
|
||||
session.provider_profile is MissingType
|
||||
}
|
||||
`;
|
||||
|
||||
const result = validateDslText(dsl);
|
||||
assert.equal(result.success, false);
|
||||
assert.ok(result.errors.some(err => err.includes('Type guard requires a known type name')));
|
||||
});
|
||||
|
||||
test('infix is guard rejects non-type expressions', () => {
|
||||
const dsl = `
|
||||
definition Session {
|
||||
provider_profile: any
|
||||
}
|
||||
|
||||
evidence can_login(session: Session) {
|
||||
session.provider_profile is "Profile"
|
||||
}
|
||||
`;
|
||||
|
||||
const result = validateDslText(dsl);
|
||||
assert.equal(result.success, false);
|
||||
assert.ok(result.errors.some(err => err.includes('Type guard requires a known type name')));
|
||||
});
|
||||
|
||||
test('infix is guard exposes readable error text', () => {
|
||||
const dsl = `
|
||||
definition Session {
|
||||
provider_profile: any
|
||||
}
|
||||
|
||||
evidence can_login(session: Session) {
|
||||
session.provider_profile is TypoedProfile
|
||||
}
|
||||
`;
|
||||
|
||||
const result = validateDslText(dsl);
|
||||
assert.equal(result.success, false);
|
||||
assert.ok(result.errors.some(err => err.includes('Type guard requires a known type name')));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Fast-check: chain rule monotonicity', () => {
|
||||
test('adding edges does not reduce access', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 5 }),
|
||||
fc.integer({ min: 1, max: 5 }),
|
||||
fc.integer({ min: 1, max: 5 }),
|
||||
fc.array(
|
||||
fc.record({
|
||||
user: fc.integer({ min: 0, max: 4 }),
|
||||
group: fc.integer({ min: 0, max: 4 })
|
||||
}),
|
||||
{ minLength: 1, maxLength: 20 }
|
||||
),
|
||||
fc.array(
|
||||
fc.record({
|
||||
group: fc.integer({ min: 0, max: 4 }),
|
||||
doc: fc.integer({ min: 0, max: 4 })
|
||||
}),
|
||||
{ minLength: 1, maxLength: 20 }
|
||||
),
|
||||
(userCount, groupCount, docCount, memberships, viewers) => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.setRelationConfig('member', { type: 'direct' });
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_view', {
|
||||
type: 'chain',
|
||||
tuple: 'member',
|
||||
computed: 'viewer'
|
||||
});
|
||||
|
||||
for (let u = 0; u < userCount; u++) arbiter.addNode(`user:${u}`, 'user');
|
||||
for (let g = 0; g < groupCount; g++) arbiter.addNode(`group:${g}`, 'group');
|
||||
for (let d = 0; d < docCount; d++) arbiter.addNode(`doc:${d}`, 'doc');
|
||||
|
||||
for (const edge of memberships) {
|
||||
const u = edge.user % userCount;
|
||||
const g = edge.group % groupCount;
|
||||
arbiter.addRelation(`user:${u}`, 'member', `group:${g}`, 1.0);
|
||||
}
|
||||
for (const edge of viewers) {
|
||||
const g = edge.group % groupCount;
|
||||
const d = edge.doc % docCount;
|
||||
arbiter.addRelation(`group:${g}`, 'viewer', `doc:${d}`, 1.0);
|
||||
}
|
||||
|
||||
const baseline = [];
|
||||
for (let u = 0; u < userCount; u++) {
|
||||
for (let d = 0; d < docCount; d++) {
|
||||
const result = arbiter.check(`user:${u}`, 'can_view', `doc:${d}`);
|
||||
baseline.push(result.possibility);
|
||||
}
|
||||
}
|
||||
|
||||
for (let u = 0; u < userCount; u++) {
|
||||
for (let g = 0; g < groupCount; g++) {
|
||||
arbiter.addRelation(`user:${u}`, 'member', `group:${g}`, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
let idx = 0;
|
||||
for (let u = 0; u < userCount; u++) {
|
||||
for (let d = 0; d < docCount; d++) {
|
||||
const result = arbiter.check(`user:${u}`, 'can_view', `doc:${d}`);
|
||||
assert.ok(result.possibility >= baseline[idx]);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Fast-check: injectable witness invariants', () => {
|
||||
test('injectable witness present/absent determinism', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 5 }),
|
||||
fc.boolean(),
|
||||
(userCount, hasProof) => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.setRelationConfig('mfa', {
|
||||
type: 'source',
|
||||
relation: 'mfa',
|
||||
injectable: true,
|
||||
provides: 'Proof'
|
||||
});
|
||||
arbiter.setRelationConfig('secure_action', {
|
||||
type: 'direct',
|
||||
relation: 'mfa'
|
||||
});
|
||||
|
||||
for (let u = 0; u < userCount; u++) {
|
||||
arbiter.addNode(`user:${u}`, 'user');
|
||||
}
|
||||
arbiter.addNode('resource:0', 'resource');
|
||||
|
||||
if (hasProof) {
|
||||
arbiter.addRelation('user:0', 'mfa', 'resource:0', 1.0);
|
||||
}
|
||||
|
||||
const result = arbiter.check('user:0', 'secure_action', 'resource:0');
|
||||
|
||||
assert.strictEqual(result.possibility > 0, hasProof);
|
||||
if (!hasProof) {
|
||||
assert.ok(result.remediation?.options?.length > 0);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 40 }
|
||||
);
|
||||
});
|
||||
|
||||
test('direct injectable witness remediation when missing', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(),
|
||||
fc.boolean(),
|
||||
(hasMfa, hasWebauthn) => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.setRelationConfig('mfa', {
|
||||
type: 'direct', relation: 'mfa', injectable: true, provides: 'Proof'
|
||||
});
|
||||
arbiter.setRelationConfig('webauthn', {
|
||||
type: 'direct', relation: 'webauthn', injectable: true, provides: 'Proof'
|
||||
});
|
||||
|
||||
arbiter.addNode('user:0', 'user');
|
||||
arbiter.addNode('resource:0', 'resource');
|
||||
|
||||
if (hasMfa) arbiter.addRelation('user:0', 'mfa', 'resource:0', 1.0);
|
||||
if (hasWebauthn) arbiter.addRelation('user:0', 'webauthn', 'resource:0', 1.0);
|
||||
|
||||
const mfaResult = arbiter.check('user:0', 'mfa', 'resource:0');
|
||||
const webResult = arbiter.check('user:0', 'webauthn', 'resource:0');
|
||||
|
||||
assert.strictEqual(mfaResult.possibility > 0, hasMfa);
|
||||
assert.strictEqual(webResult.possibility > 0, hasWebauthn);
|
||||
|
||||
if (!hasMfa) {
|
||||
assert.ok(Array.isArray(mfaResult.remediation?.options), 'mfa missing should give remediation');
|
||||
assert.strictEqual(mfaResult.remediation.options[0].relation, 'mfa');
|
||||
}
|
||||
if (!hasWebauthn) {
|
||||
assert.ok(Array.isArray(webResult.remediation?.options), 'webauthn missing should give remediation');
|
||||
assert.strictEqual(webResult.remediation.options[0].relation, 'webauthn');
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 40 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Fast-check: defeasible unless invariants', () => {
|
||||
test('blocked edge defeats direct access', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 5 }),
|
||||
fc.integer({ min: 1, max: 5 }),
|
||||
fc.array(
|
||||
fc.record({
|
||||
user: fc.integer({ min: 0, max: 4 }),
|
||||
doc: fc.integer({ min: 0, max: 4 })
|
||||
}),
|
||||
{ minLength: 0, maxLength: 20 }
|
||||
),
|
||||
fc.array(
|
||||
fc.record({
|
||||
user: fc.integer({ min: 0, max: 4 }),
|
||||
doc: fc.integer({ min: 0, max: 4 })
|
||||
}),
|
||||
{ minLength: 0, maxLength: 20 }
|
||||
),
|
||||
(userCount, docCount, viewers, blocked) => {
|
||||
const arbiter = new Arbiter();
|
||||
for (let u = 0; u < userCount; u++) arbiter.addNode(`user:${u}`, 'user');
|
||||
for (let d = 0; d < docCount; d++) arbiter.addNode(`doc:${d}`, 'doc');
|
||||
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
arbiter.setRelationConfig('blocked', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_view', {
|
||||
when: {
|
||||
union: [
|
||||
{ type: 'direct', relation: 'viewer' }
|
||||
]
|
||||
},
|
||||
unless: {
|
||||
union: [
|
||||
{ type: 'direct', relation: 'blocked' }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const viewerSet = new Set();
|
||||
const blockedSet = new Set();
|
||||
for (const edge of viewers) {
|
||||
const u = edge.user % userCount;
|
||||
const d = edge.doc % docCount;
|
||||
arbiter.addRelation(`user:${u}`, 'viewer', `doc:${d}`, 1.0);
|
||||
viewerSet.add(`${u}:${d}`);
|
||||
}
|
||||
for (const edge of blocked) {
|
||||
const u = edge.user % userCount;
|
||||
const d = edge.doc % docCount;
|
||||
arbiter.addRelation(`user:${u}`, 'blocked', `doc:${d}`, 1.0);
|
||||
blockedSet.add(`${u}:${d}`);
|
||||
}
|
||||
|
||||
for (let u = 0; u < userCount; u++) {
|
||||
for (let d = 0; d < docCount; d++) {
|
||||
const result = arbiter.check(`user:${u}`, 'can_view', `doc:${d}`);
|
||||
const key = `${u}:${d}`;
|
||||
const expected = viewerSet.has(key) && !blockedSet.has(key);
|
||||
assert.strictEqual(result.possibility > 0, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { CondensedGraph } from '../../src/core/CondensedGraph.js';
|
||||
import { ShardedSnapshotBuilder } from '../../src/core/shards/ShardedSnapshotBuilder.js';
|
||||
import { ShardedSnapshot } from '../../src/core/shards/ShardedSnapshot.js';
|
||||
import { FileShardStorage } from '../../src/core/shards/FileShardStorage.js';
|
||||
import { DeltaShardBinary } from '../../src/core/shards/DeltaShardBinary.js';
|
||||
import { WaveletShardBinary } from '../../src/core/shards/WaveletShardBinary.js';
|
||||
|
||||
function buildSnapshot(bucketSize = 4) {
|
||||
const graph = new CondensedGraph();
|
||||
const nodes = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
nodes.push(graph._ensureNode(`node:${i}`));
|
||||
}
|
||||
graph.addEdge(nodes[0], 'owner', nodes[1]);
|
||||
graph.addEdge(nodes[0], 'owner', nodes[2]);
|
||||
graph.addEdge(nodes[3], 'owner', nodes[4]);
|
||||
graph.addEdge(nodes[5], 'owner', nodes[0]);
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sharded-delta-fc-'));
|
||||
const builder = new ShardedSnapshotBuilder({ bucketSize, includeDirections: ['out', 'in'] });
|
||||
const manifest = builder.build(graph, dir);
|
||||
const storage = new FileShardStorage(dir);
|
||||
const snapshot = new ShardedSnapshot(manifest, storage, { cacheLimit: 8 });
|
||||
snapshot.initializeSync();
|
||||
return { snapshot, manifest, dir, nodes };
|
||||
}
|
||||
|
||||
function buildBaseEdges(nodes) {
|
||||
const edges = new Map();
|
||||
const add = (srcIdx, dstIdx) => {
|
||||
const srcId = nodes[srcIdx];
|
||||
const dstId = nodes[dstIdx];
|
||||
const set = edges.get(srcId) || new Set();
|
||||
set.add(dstId);
|
||||
edges.set(srcId, set);
|
||||
};
|
||||
add(0, 1);
|
||||
add(0, 2);
|
||||
add(3, 4);
|
||||
add(5, 0);
|
||||
return edges;
|
||||
}
|
||||
|
||||
function cloneEdges(edges) {
|
||||
const next = new Map();
|
||||
for (const [src, set] of edges.entries()) {
|
||||
next.set(src, new Set(set));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyOps(edges, ops, mode = 'override') {
|
||||
const next = cloneEdges(edges);
|
||||
if (mode !== 'union') {
|
||||
for (const op of ops) {
|
||||
if (op.op !== 'remove') continue;
|
||||
const set = next.get(op.srcId) || new Set();
|
||||
set.delete(op.dstId);
|
||||
if (set.size > 0) next.set(op.srcId, set);
|
||||
}
|
||||
}
|
||||
for (const op of ops) {
|
||||
if (op.op !== 'add') continue;
|
||||
const set = next.get(op.srcId) || new Set();
|
||||
set.add(op.dstId);
|
||||
if (set.size > 0) next.set(op.srcId, set);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function hasEdge(edges, srcId, dstId) {
|
||||
const set = edges.get(srcId);
|
||||
return set ? set.has(dstId) : false;
|
||||
}
|
||||
|
||||
function writeDeltaLayer(snapshot, dir, entries, mode = 'override') {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const merged = new Map();
|
||||
for (const entry of entries) {
|
||||
const shardMeta = snapshot._selectShardMeta(entry.relationId, entry.direction, entry.srcId);
|
||||
assert.ok(shardMeta, 'Missing shard meta for delta entry');
|
||||
const localSource = snapshot._localSource(entry.srcId, shardMeta);
|
||||
const key = shardMeta.cacheKey;
|
||||
let bucket = merged.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { shardMeta, additions: [], removals: [] };
|
||||
merged.set(key, bucket);
|
||||
}
|
||||
for (const add of entry.additions) {
|
||||
bucket.additions.push({
|
||||
srcLocal: localSource,
|
||||
otherId: add.dstId,
|
||||
possBits: add.possBits,
|
||||
relBits: add.relBits
|
||||
});
|
||||
}
|
||||
for (const rem of entry.removals) {
|
||||
bucket.removals.push({
|
||||
srcLocal: localSource,
|
||||
otherId: rem.dstId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const shards = [];
|
||||
for (const bucket of merged.values()) {
|
||||
const shardMeta = bucket.shardMeta;
|
||||
const buffer = DeltaShardBinary.serialize({
|
||||
relationId: shardMeta.relationId,
|
||||
direction: shardMeta.direction,
|
||||
rangeStart: shardMeta.rangeStart,
|
||||
rangeEnd: shardMeta.rangeEnd,
|
||||
nodeCount: snapshot.nodeCount,
|
||||
additions: bucket.additions,
|
||||
removals: bucket.removals
|
||||
});
|
||||
const shardKey = `delta-${shardMeta.key}`;
|
||||
fs.writeFileSync(path.join(dir, shardKey), new Uint8Array(buffer));
|
||||
shards.push({
|
||||
key: shardKey,
|
||||
relationId: shardMeta.relationId,
|
||||
direction: shardMeta.direction,
|
||||
rangeStart: shardMeta.rangeStart,
|
||||
rangeEnd: shardMeta.rangeEnd,
|
||||
cacheKey: shardMeta.cacheKey
|
||||
});
|
||||
}
|
||||
|
||||
return { shards, storage: new FileShardStorage(dir), mode };
|
||||
}
|
||||
|
||||
function compactLayer(snapshot, layer, manifest, outputDir, baseDir) {
|
||||
const sameDir = path.resolve(outputDir) === path.resolve(baseDir);
|
||||
if (!sameDir) {
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
if (!sameDir) {
|
||||
if (manifest.nodeTableKey) {
|
||||
const source = path.join(baseDir, manifest.nodeTableKey);
|
||||
const dest = path.join(outputDir, manifest.nodeTableKey);
|
||||
if (fs.existsSync(source)) fs.copyFileSync(source, dest);
|
||||
}
|
||||
if (manifest.componentKey) {
|
||||
const source = path.join(baseDir, manifest.componentKey);
|
||||
const dest = path.join(outputDir, manifest.componentKey);
|
||||
if (fs.existsSync(source)) fs.copyFileSync(source, dest);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sameDir) {
|
||||
for (const shard of manifest.shards || []) {
|
||||
fs.copyFileSync(path.join(baseDir, shard.key), path.join(outputDir, shard.key));
|
||||
}
|
||||
}
|
||||
|
||||
for (const shardMeta of layer.shards) {
|
||||
const base = snapshot._cacheIndex.get(shardMeta.cacheKey);
|
||||
if (!base) continue;
|
||||
const shard = snapshot._loadShardSync(base.relationId, base.direction, base.rangeStart);
|
||||
if (!shard) continue;
|
||||
|
||||
const deltaBuffer = layer.storage.getSync(shardMeta.key);
|
||||
if (!deltaBuffer) continue;
|
||||
const deltaShard = DeltaShardBinary.deserialize(deltaBuffer);
|
||||
const rangeSize = shard.rangeEnd - shard.rangeStart;
|
||||
const sources = new Array(rangeSize);
|
||||
for (let localSource = 0; localSource < rangeSize; localSource++) {
|
||||
const range = snapshot._rangeForSource(shard, localSource);
|
||||
const list = [];
|
||||
if (range) {
|
||||
for (let pos = range.start; pos < range.end; pos++) {
|
||||
list.push({ otherId: shard.dstIds[pos], possBits: shard.possBits[pos], relBits: shard.relBits[pos] });
|
||||
}
|
||||
}
|
||||
sources[localSource] = list;
|
||||
}
|
||||
|
||||
for (const removal of deltaShard.removals) {
|
||||
const list = sources[removal.srcLocal];
|
||||
if (!list) continue;
|
||||
let idx = list.findIndex((item) => item.otherId === removal.otherId);
|
||||
while (idx !== -1) {
|
||||
list.splice(idx, 1);
|
||||
idx = list.findIndex((item) => item.otherId === removal.otherId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const addition of deltaShard.additions) {
|
||||
const list = sources[addition.srcLocal] || (sources[addition.srcLocal] = []);
|
||||
const idx = list.findIndex((item) => item.otherId === addition.otherId);
|
||||
if (idx === -1) {
|
||||
list.push({ otherId: addition.otherId, possBits: addition.possBits, relBits: addition.relBits });
|
||||
} else {
|
||||
list[idx] = { otherId: addition.otherId, possBits: addition.possBits, relBits: addition.relBits };
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = WaveletShardBinary.serialize({
|
||||
relationId: shard.relationId,
|
||||
direction: shard.direction,
|
||||
rangeStart: shard.rangeStart,
|
||||
rangeEnd: shard.rangeEnd,
|
||||
nodeCount: snapshot.nodeCount,
|
||||
sources
|
||||
});
|
||||
fs.writeFileSync(path.join(outputDir, base.key), new Uint8Array(buffer));
|
||||
}
|
||||
}
|
||||
|
||||
describe.skip('Fast-check: delta layer equivalence', () => {
|
||||
test('multi-layer override equals compacted base', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
src: fc.integer({ min: 0, max: 5 }),
|
||||
dst: fc.integer({ min: 0, max: 5 }),
|
||||
op: fc.constantFrom('add', 'remove')
|
||||
}),
|
||||
{ minLength: 1, maxLength: 20 }
|
||||
),
|
||||
fc.array(
|
||||
fc.record({
|
||||
src: fc.integer({ min: 0, max: 5 }),
|
||||
dst: fc.integer({ min: 0, max: 5 }),
|
||||
op: fc.constantFrom('add', 'remove')
|
||||
}),
|
||||
{ minLength: 1, maxLength: 20 }
|
||||
),
|
||||
(layerA, layerB) => {
|
||||
const { snapshot, manifest, dir, nodes } = buildSnapshot(4);
|
||||
const relId = snapshot.relationIdToName.indexOf('owner');
|
||||
|
||||
const toEntries = (ops) => ops.map((edge) => ({
|
||||
relationId: relId,
|
||||
direction: 'out',
|
||||
srcId: nodes[edge.src % nodes.length],
|
||||
additions: edge.op === 'add' ? [{ dstId: nodes[edge.dst % nodes.length], possBits: 65535, relBits: 65535 }] : [],
|
||||
removals: edge.op === 'remove' ? [{ dstId: nodes[edge.dst % nodes.length] }] : []
|
||||
}));
|
||||
|
||||
const layer1 = writeDeltaLayer(snapshot, path.join(dir, 'l1'), toEntries(layerA), 'override');
|
||||
const layer2 = writeDeltaLayer(snapshot, path.join(dir, 'l2'), toEntries(layerB), 'override');
|
||||
snapshot.setDeltaLayers([layer1, layer2]);
|
||||
|
||||
const base = buildBaseEdges(nodes);
|
||||
const layerAOps = layerA.map((edge) => ({
|
||||
srcId: nodes[edge.src % nodes.length],
|
||||
dstId: nodes[edge.dst % nodes.length],
|
||||
op: edge.op
|
||||
}));
|
||||
const layerBOps = layerB.map((edge) => ({
|
||||
srcId: nodes[edge.src % nodes.length],
|
||||
dstId: nodes[edge.dst % nodes.length],
|
||||
op: edge.op
|
||||
}));
|
||||
const expected = applyOps(applyOps(base, layerAOps, 'override'), layerBOps, 'override');
|
||||
|
||||
const compactDir = path.join(dir, 'compact');
|
||||
compactLayer(snapshot, layer1, manifest, compactDir, dir);
|
||||
const compactSnapshot = new ShardedSnapshot(manifest, new FileShardStorage(compactDir), { cacheLimit: 8 });
|
||||
compactSnapshot.initializeSync();
|
||||
|
||||
compactLayer(compactSnapshot, layer2, manifest, compactDir, compactDir);
|
||||
const compactSnapshot2 = new ShardedSnapshot(manifest, new FileShardStorage(compactDir), { cacheLimit: 8 });
|
||||
compactSnapshot2.initializeSync();
|
||||
|
||||
for (const src of nodes) {
|
||||
for (const dst of nodes) {
|
||||
const overlayEdge = snapshot.findEdgeSync(src, relId, dst);
|
||||
const compactEdge = compactSnapshot2.findEdgeSync(src, relId, dst);
|
||||
const expectedEdge = hasEdge(expected, src, dst);
|
||||
assert.strictEqual(!!overlayEdge, !!compactEdge);
|
||||
assert.strictEqual(!!overlayEdge, expectedEdge);
|
||||
}
|
||||
}
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
function keyFor(srcId, dstId) {
|
||||
return `${srcId}->${dstId}`;
|
||||
}
|
||||
|
||||
describe('Fast-check: direct relations invariants', () => {
|
||||
test('direct checks match add/remove sequence', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 5 }),
|
||||
fc.integer({ min: 1, max: 5 }),
|
||||
fc.array(
|
||||
fc.record({
|
||||
src: fc.integer({ min: 0, max: 4 }),
|
||||
dst: fc.integer({ min: 0, max: 4 }),
|
||||
op: fc.constantFrom('add', 'remove')
|
||||
}),
|
||||
{ minLength: 1, maxLength: 50 }
|
||||
),
|
||||
(userCount, docCount, ops) => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
for (let u = 0; u < userCount; u++) {
|
||||
arbiter.addNode(`user:${u}`, 'user');
|
||||
}
|
||||
for (let d = 0; d < docCount; d++) {
|
||||
arbiter.addNode(`doc:${d}`, 'doc');
|
||||
}
|
||||
|
||||
const model = new Set();
|
||||
for (const op of ops) {
|
||||
const src = op.src % userCount;
|
||||
const dst = op.dst % docCount;
|
||||
if (op.op === 'add') {
|
||||
arbiter.addRelation(`user:${src}`, 'owner', `doc:${dst}`, 1.0);
|
||||
model.add(keyFor(src, dst));
|
||||
} else {
|
||||
arbiter.removeRelation(`user:${src}`, 'owner', `doc:${dst}`);
|
||||
model.delete(keyFor(src, dst));
|
||||
}
|
||||
}
|
||||
|
||||
for (let u = 0; u < userCount; u++) {
|
||||
for (let d = 0; d < docCount; d++) {
|
||||
const result = arbiter.check(`user:${u}`, 'owner', `doc:${d}`);
|
||||
const hasEdge = model.has(keyFor(u, d));
|
||||
assert.strictEqual(result.possibility > 0, hasEdge);
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
function hasPathWithin(adj, src, dst, maxDepth) {
|
||||
const visited = new Set([src]);
|
||||
let frontier = [src];
|
||||
let depth = 0;
|
||||
while (frontier.length && depth < maxDepth) {
|
||||
const next = [];
|
||||
for (const node of frontier) {
|
||||
const neighbors = adj.get(node) || [];
|
||||
for (const n of neighbors) {
|
||||
if (n === dst) return true;
|
||||
if (!visited.has(n)) {
|
||||
visited.add(n);
|
||||
next.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
depth++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe('Fast-check: multi-hop invariants', () => {
|
||||
test('zero-hop is denied unless explicitly enabled', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('node:0', 'node');
|
||||
arbiter.setRelationConfig('link', { type: 'direct' });
|
||||
|
||||
arbiter.setRelationConfig('reachable', { type: 'multi_hop', relation: 'link', maxDepth: 2 });
|
||||
const defaultResult = arbiter.check('node:0', 'reachable', 'node:0');
|
||||
assert.strictEqual(defaultResult.possibility > 0, false);
|
||||
|
||||
arbiter.setRelationConfig('reachable_allow', {
|
||||
type: 'multi_hop',
|
||||
relation: 'link',
|
||||
maxDepth: 2,
|
||||
allowZeroHop: true
|
||||
});
|
||||
const allowedResult = arbiter.check('node:0', 'reachable_allow', 'node:0');
|
||||
assert.strictEqual(allowedResult.possibility > 0, true);
|
||||
});
|
||||
|
||||
test('multi-hop reachability matches bounded BFS', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 2, max: 6 }),
|
||||
fc.integer({ min: 1, max: 4 }),
|
||||
fc.array(
|
||||
fc.record({
|
||||
src: fc.integer({ min: 0, max: 5 }),
|
||||
dst: fc.integer({ min: 0, max: 5 })
|
||||
}),
|
||||
{ minLength: 1, maxLength: 20 }
|
||||
),
|
||||
(nodeCount, maxDepth, edges) => {
|
||||
const arbiter = new Arbiter();
|
||||
for (let i = 0; i < nodeCount; i++) arbiter.addNode(`node:${i}`, 'node');
|
||||
arbiter.setRelationConfig('link', { type: 'direct' });
|
||||
arbiter.setRelationConfig('reachable', { type: 'multi_hop', relation: 'link', maxDepth, allowZeroHop: false });
|
||||
|
||||
const adj = new Map();
|
||||
for (const edge of edges) {
|
||||
const src = edge.src % nodeCount;
|
||||
const dst = edge.dst % nodeCount;
|
||||
arbiter.addRelation(`node:${src}`, 'link', `node:${dst}`, 1.0);
|
||||
const list = adj.get(src) || [];
|
||||
list.push(dst);
|
||||
adj.set(src, list);
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
for (let j = 0; j < nodeCount; j++) {
|
||||
const expected = hasPathWithin(adj, i, j, maxDepth);
|
||||
const result = arbiter.check(`node:${i}`, 'reachable', `node:${j}`);
|
||||
assert.strictEqual(result.possibility > 0, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Fast-check: node id mapping invariants', () => {
|
||||
test('resolveNodeId/resolveKey round-trip', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.set(fc.string({ minLength: 1, maxLength: 12 }), { minLength: 1, maxLength: 20 }),
|
||||
(keys) => {
|
||||
const arbiter = new Arbiter();
|
||||
for (const key of keys) {
|
||||
arbiter.addNode(key, 'generic');
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const id = arbiter.resolveNodeId(key);
|
||||
assert.ok(id !== undefined && id !== null);
|
||||
const roundTrip = arbiter.resolveKey(id);
|
||||
assert.strictEqual(roundTrip, key);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Fast-check: partial overlay vs union overlays', () => {
|
||||
test('union overlays cannot revoke access', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 5 }),
|
||||
fc.integer({ min: 1, max: 5 }),
|
||||
fc.array(
|
||||
fc.record({
|
||||
user: fc.integer({ min: 0, max: 4 }),
|
||||
doc: fc.integer({ min: 0, max: 4 })
|
||||
}),
|
||||
{ minLength: 1, maxLength: 20 }
|
||||
),
|
||||
fc.array(
|
||||
fc.record({
|
||||
user: fc.integer({ min: 0, max: 4 }),
|
||||
doc: fc.integer({ min: 0, max: 4 })
|
||||
}),
|
||||
{ minLength: 1, maxLength: 20 }
|
||||
),
|
||||
(userCount, docCount, baseEdges, unionRemovals) => {
|
||||
const arbiter = new Arbiter();
|
||||
for (let u = 0; u < userCount; u++) arbiter.addNode(`user:${u}`, 'user');
|
||||
for (let d = 0; d < docCount; d++) arbiter.addNode(`doc:${d}`, 'doc');
|
||||
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
const baseSet = new Set();
|
||||
for (const edge of baseEdges) {
|
||||
const u = edge.user % userCount;
|
||||
const d = edge.doc % docCount;
|
||||
arbiter.addRelation(`user:${u}`, 'viewer', `doc:${d}`, 1.0);
|
||||
baseSet.add(`${u}:${d}`);
|
||||
}
|
||||
|
||||
const partialGraph = {
|
||||
overlayMode: 'union',
|
||||
relations: unionRemovals.map((edge) => ({
|
||||
src: `user:${edge.user % userCount}`,
|
||||
relation: 'viewer',
|
||||
dst: `doc:${edge.doc % docCount}`,
|
||||
possibility: 1.0
|
||||
}))
|
||||
};
|
||||
|
||||
for (let u = 0; u < userCount; u++) {
|
||||
for (let d = 0; d < docCount; d++) {
|
||||
const result = arbiter.check(`user:${u}`, 'viewer', `doc:${d}`, { partialGraph });
|
||||
const key = `${u}:${d}`;
|
||||
if (baseSet.has(key)) {
|
||||
assert.ok(result.possibility > 0, 'Union overlay must not revoke base access');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Fast-check: tuple-to-userset invariants', () => {
|
||||
test('tuple_to_userset matches model', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 5 }),
|
||||
fc.integer({ min: 1, max: 5 }),
|
||||
fc.integer({ min: 1, max: 5 }),
|
||||
fc.array(
|
||||
fc.record({
|
||||
doc: fc.integer({ min: 0, max: 4 }),
|
||||
group: fc.integer({ min: 0, max: 4 })
|
||||
}),
|
||||
{ minLength: 0, maxLength: 20 }
|
||||
),
|
||||
fc.array(
|
||||
fc.record({
|
||||
user: fc.integer({ min: 0, max: 4 }),
|
||||
group: fc.integer({ min: 0, max: 4 })
|
||||
}),
|
||||
{ minLength: 0, maxLength: 20 }
|
||||
),
|
||||
(userCount, groupCount, docCount, owners, members) => {
|
||||
const arbiter = new Arbiter();
|
||||
for (let u = 0; u < userCount; u++) arbiter.addNode(`user:${u}`, 'user');
|
||||
for (let g = 0; g < groupCount; g++) arbiter.addNode(`group:${g}`, 'group');
|
||||
for (let d = 0; d < docCount; d++) arbiter.addNode(`doc:${d}`, 'doc');
|
||||
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
arbiter.setRelationConfig('member', { type: 'direct' });
|
||||
arbiter.setRelationConfig('access', {
|
||||
type: 'tuple_to_userset',
|
||||
tuplesetRelation: 'owner',
|
||||
computedRelation: 'member'
|
||||
});
|
||||
|
||||
const ownersByDoc = new Map();
|
||||
const membersByGroup = new Map();
|
||||
|
||||
for (const edge of owners) {
|
||||
const doc = edge.doc % docCount;
|
||||
const group = edge.group % groupCount;
|
||||
arbiter.addRelation(`doc:${doc}`, 'owner', `group:${group}`, 1.0);
|
||||
const set = ownersByDoc.get(doc) || new Set();
|
||||
set.add(group);
|
||||
ownersByDoc.set(doc, set);
|
||||
}
|
||||
|
||||
for (const edge of members) {
|
||||
const user = edge.user % userCount;
|
||||
const group = edge.group % groupCount;
|
||||
arbiter.addRelation(`user:${user}`, 'member', `group:${group}`, 1.0);
|
||||
const set = membersByGroup.get(group) || new Set();
|
||||
set.add(user);
|
||||
membersByGroup.set(group, set);
|
||||
}
|
||||
|
||||
for (let u = 0; u < userCount; u++) {
|
||||
for (let d = 0; d < docCount; d++) {
|
||||
const result = arbiter.check(`user:${u}`, 'access', `doc:${d}`);
|
||||
const groups = ownersByDoc.get(d) || new Set();
|
||||
let expected = false;
|
||||
for (const group of groups) {
|
||||
const membersSet = membersByGroup.get(group) || new Set();
|
||||
if (membersSet.has(u)) {
|
||||
expected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.strictEqual(result.possibility > 0, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Graph Structure and Queries', () => {
|
||||
test('basic node and relation creation', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
const nodeId1 = arbiter.resolveNodeId('user:1');
|
||||
const nodeId2 = arbiter.resolveNodeId('resource:1');
|
||||
|
||||
assert.ok(nodeId1 !== undefined, 'user node ID resolved');
|
||||
assert.ok(nodeId2 !== undefined, 'resource node ID resolved');
|
||||
assert.strictEqual(arbiter.resolveKey(nodeId1), 'user:1', 'reverse key lookup works');
|
||||
});
|
||||
|
||||
test('direct relation authorization', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
|
||||
arbiter.addRelation('user:1', 'owner', 'resource:1', 1.0);
|
||||
|
||||
const result = arbiter.check('user:1', 'owner', 'resource:1');
|
||||
assert.strictEqual(result.possibility, 1.0, 'owner relation grants access');
|
||||
assert.strictEqual(result.reason, 'direct_match', 'reason is direct_match');
|
||||
});
|
||||
|
||||
test('absence of relation denies access', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
arbiter.addNode('user:2', 'user');
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
|
||||
arbiter.addRelation('user:1', 'owner', 'resource:1', 1.0);
|
||||
|
||||
const result = arbiter.check('user:2', 'owner', 'resource:1');
|
||||
assert.strictEqual(result.possibility, 0.0, 'non-owner denied');
|
||||
assert.strictEqual(result.reason, 'no_relation', 'reason is no_relation');
|
||||
});
|
||||
|
||||
test('node data storage and retrieval', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('user:1', 'user', { tier: 'premium', level: 5 });
|
||||
const data = arbiter.getNodeData('user:1');
|
||||
|
||||
assert.strictEqual(data.tier, 'premium');
|
||||
assert.strictEqual(data.level, 5);
|
||||
});
|
||||
|
||||
test('node data update', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('user:1', 'user', { tier: 'premium' });
|
||||
arbiter.updateNodeData('user:1', { tier: 'enterprise', level: 10 });
|
||||
|
||||
const data = arbiter.getNodeData('user:1');
|
||||
assert.strictEqual(data.tier, 'enterprise');
|
||||
assert.strictEqual(data.level, 10);
|
||||
});
|
||||
|
||||
test('multiple relations between different node types', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('group:1', 'group');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('member', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_view', { type: 'direct' });
|
||||
|
||||
arbiter.addRelation('user:1', 'member', 'group:1', 1.0);
|
||||
arbiter.addRelation('group:1', 'can_view', 'resource:1', 1.0);
|
||||
|
||||
const memberResult = arbiter.check('user:1', 'member', 'group:1');
|
||||
const viewResult = arbiter.check('group:1', 'can_view', 'resource:1');
|
||||
|
||||
assert.strictEqual(memberResult.possibility, 1.0);
|
||||
assert.strictEqual(viewResult.possibility, 1.0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { UnifiedKeyManager } from '../../src/core/UnifiedKeyManager.js';
|
||||
|
||||
describe('UnifiedKeyManager key uniqueness', () => {
|
||||
test('composite keys differ for distinct small tuples', () => {
|
||||
const keyManager = new UnifiedKeyManager();
|
||||
const keyA = keyManager.createCompositeKey(1, 'rel', 2);
|
||||
const keyB = keyManager.createCompositeKey(2, 'rel', 2);
|
||||
const keyC = keyManager.createCompositeKey(1, 'rel', 3);
|
||||
assert.notStrictEqual(keyA, keyB);
|
||||
assert.notStrictEqual(keyA, keyC);
|
||||
});
|
||||
|
||||
test('composite keys remain unique for large ids', () => {
|
||||
const keyManager = new UnifiedKeyManager();
|
||||
const relation = 'rel';
|
||||
const srcIdA = 1;
|
||||
const srcIdB = 1 + (1 << 18);
|
||||
const dstId = 1;
|
||||
const keyA = keyManager.createCompositeKey(srcIdA, relation, dstId);
|
||||
const keyB = keyManager.createCompositeKey(srcIdB, relation, dstId);
|
||||
assert.notStrictEqual(keyA, keyB);
|
||||
});
|
||||
|
||||
test('source-relation keys remain unique for large ids', () => {
|
||||
const keyManager = new UnifiedKeyManager();
|
||||
const relation = 'rel';
|
||||
const srcIdA = 1;
|
||||
const srcIdB = 1 + (1 << 16);
|
||||
const keyA = keyManager.createSrcRelKey(srcIdA, relation);
|
||||
const keyB = keyManager.createSrcRelKey(srcIdB, relation);
|
||||
assert.notStrictEqual(keyA, keyB);
|
||||
});
|
||||
|
||||
test('chain keys remain unique for large ids', () => {
|
||||
const keyManager = new UnifiedKeyManager();
|
||||
const steps = [{ relation: 'rel', direction: 'out' }];
|
||||
const userIdA = 1;
|
||||
const userIdB = 1 + (1 << 18);
|
||||
const objectId = 42;
|
||||
const keyA = keyManager.createChainKey(userIdA, objectId, steps);
|
||||
const keyB = keyManager.createChainKey(userIdB, objectId, steps);
|
||||
assert.notStrictEqual(keyA, keyB);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
import { UnifiedKeyManager } from '../../src/core/UnifiedKeyManager.js';
|
||||
|
||||
describe('Key manager consistency across subsystems', () => {
|
||||
test('relation ids align between arbiter and indices', () => {
|
||||
const arbiter = new Arbiter();
|
||||
const arbiterRelId = arbiter.keyManager._getRelationId('can_read');
|
||||
const indicesRelId = arbiter.indices._getRelationId('can_read');
|
||||
assert.strictEqual(arbiterRelId, indicesRelId);
|
||||
});
|
||||
|
||||
test('relation cache keys use the same relation id source', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user', 'user');
|
||||
arbiter.addNode('doc', 'document');
|
||||
const srcId = arbiter.nodeManager.getNodeId('user');
|
||||
const dstId = arbiter.nodeManager.getNodeId('doc');
|
||||
|
||||
const directKeyFromIds = arbiter.relationManager._makeDirectCacheKey(srcId, 'can_read', dstId);
|
||||
const directKeyFromStrings = arbiter.relationManager._makeDirectCacheKeyFromStrings('user', 'can_read', 'doc');
|
||||
|
||||
assert.strictEqual(directKeyFromIds, directKeyFromStrings);
|
||||
});
|
||||
|
||||
test('value manager and arbiter key managers agree on composite keys', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user', 'user');
|
||||
arbiter.addNode('doc', 'document');
|
||||
|
||||
const srcId = arbiter.keyManager.getStringId('user');
|
||||
const dstId = arbiter.keyManager.getStringId('doc');
|
||||
const arbiterKey = arbiter.keyManager.createCompositeKey(srcId, 'can_read', dstId);
|
||||
|
||||
const valueManagerKeyManager = new UnifiedKeyManager();
|
||||
const valueKey = valueManagerKeyManager.createCompositeKey(srcId, 'can_read', dstId);
|
||||
|
||||
assert.strictEqual(arbiterKey, valueKey);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
const runPerf = process.env.RUN_PERF_TESTS === '1';
|
||||
const perfTest = runPerf ? test : test.skip;
|
||||
|
||||
describe('Memory Usage and Performance', () => {
|
||||
perfTest('memory usage grows linearly with node count', () => {
|
||||
const arbiter = new Arbiter();
|
||||
const numNodes = 10000;
|
||||
|
||||
for (let i = 0; i < numNodes; i++) {
|
||||
arbiter.addNode(`node:${i}`, 'node');
|
||||
}
|
||||
|
||||
const memoryUsage = process.memoryUsage();
|
||||
const memoryMB = memoryUsage.heapUsed / (1024 * 1024);
|
||||
|
||||
console.log(`Memory for ${numNodes} nodes: ${memoryMB.toFixed(2)} MB`);
|
||||
|
||||
assert.ok(memoryMB < 100, `Memory usage (${memoryMB.toFixed(2)} MB) should be reasonable`);
|
||||
});
|
||||
|
||||
perfTest('memory usage for graph with edges', () => {
|
||||
const arbiter = new Arbiter();
|
||||
const numNodes = 5000;
|
||||
const edgesPerNode = 3;
|
||||
|
||||
for (let i = 0; i < numNodes; i++) {
|
||||
arbiter.addNode(`node:${i}`, 'node');
|
||||
}
|
||||
|
||||
arbiter.setRelationConfig('connect', { type: 'direct' });
|
||||
|
||||
for (let i = 0; i < numNodes; i++) {
|
||||
for (let j = 1; j <= edgesPerNode && i + j < numNodes; j++) {
|
||||
arbiter.addRelation(`node:${i}`, 'connect', `node:${i + j}`, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
const memoryUsage = process.memoryUsage();
|
||||
const memoryMB = memoryUsage.heapUsed / (1024 * 1024);
|
||||
|
||||
console.log(`Memory for ${numNodes} nodes with ${numNodes * edgesPerNode} edges: ${memoryMB.toFixed(2)} MB`);
|
||||
|
||||
assert.ok(memoryMB < 200, `Memory usage (${memoryMB.toFixed(2)} MB) should be reasonable`);
|
||||
});
|
||||
|
||||
perfTest('large graph stays within 128MB limit', () => {
|
||||
const arbiter = new Arbiter();
|
||||
const numNodes = 30000;
|
||||
const edgesPerNode = 3;
|
||||
|
||||
arbiter.setRelationConfig('connect', { type: 'direct' });
|
||||
|
||||
for (let i = 0; i < numNodes; i++) {
|
||||
arbiter.addNode(`node:${i}`, 'node');
|
||||
}
|
||||
|
||||
for (let i = 0; i < numNodes; i++) {
|
||||
for (let j = 1; j <= edgesPerNode && i + j < numNodes; j++) {
|
||||
arbiter.addRelation(`node:${i}`, 'connect', `node:${i + j}`, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
const memoryUsage = process.memoryUsage();
|
||||
const memoryMB = memoryUsage.heapUsed / (1024 * 1024);
|
||||
|
||||
console.log(`Memory for ${numNodes} nodes: ${memoryMB.toFixed(2)} MB`);
|
||||
|
||||
assert.ok(memoryMB < 250, `Memory usage (${memoryMB.toFixed(2)} MB) must be reasonable`);
|
||||
});
|
||||
|
||||
perfTest('direct check cache improves performance', () => {
|
||||
const arbiter = new Arbiter();
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
arbiter.addNode(`user:${i}`, 'user');
|
||||
arbiter.addNode(`resource:${i}`, 'resource');
|
||||
}
|
||||
|
||||
arbiter.setRelationConfig('member', { type: 'direct' });
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
arbiter.addRelation(`user:${i}`, 'member', `resource:${i}`, 1.0);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 200; i++) {
|
||||
arbiter.check(`user:${i % 100}`, 'member', `resource:${i % 100}`);
|
||||
}
|
||||
|
||||
const runBatch = () => {
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 500; i++) {
|
||||
arbiter.check(`user:${i % 100}`, 'member', `resource:${i % 100}`);
|
||||
}
|
||||
return performance.now() - start;
|
||||
};
|
||||
|
||||
const time1 = runBatch();
|
||||
const time2 = runBatch();
|
||||
const time3 = runBatch();
|
||||
const time4 = runBatch();
|
||||
const time5 = runBatch();
|
||||
|
||||
console.log(`First batch: ${time1}ms, Second batch: ${time2}ms, Third batch: ${time3}ms, Fourth batch: ${time4}ms, Fifth batch: ${time5}ms`);
|
||||
|
||||
if (arbiter.directCheckCache) {
|
||||
const warmTimes = [time2, time3, time4, time5].sort((a, b) => a - b);
|
||||
const medianWarm = (warmTimes[1] + warmTimes[2]) / 2;
|
||||
assert.ok(medianWarm <= time1 * 1.5, `Cached queries should be at least as fast (${medianWarm}ms <= ${time1}ms)`);
|
||||
} else {
|
||||
console.log('Direct check cache is disabled');
|
||||
}
|
||||
});
|
||||
|
||||
perfTest('query performance scales linearly with graph size', () => {
|
||||
const arbiter = new Arbiter();
|
||||
const numNodes = 5000;
|
||||
|
||||
for (let i = 0; i < numNodes; i++) {
|
||||
arbiter.addNode(`node:${i}`, 'node');
|
||||
}
|
||||
|
||||
arbiter.setRelationConfig('connect', { type: 'direct' });
|
||||
|
||||
for (let i = 0; i < numNodes - 1; i++) {
|
||||
arbiter.addRelation(`node:${i}`, 'connect', `node:${i + 1}`, 1.0);
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const result = arbiter.check('node:0', 'connect', 'node:1');
|
||||
const duration = Date.now() - start;
|
||||
|
||||
console.log(`Query time: ${duration}ms`);
|
||||
|
||||
assert.strictEqual(result.possibility, 1.0);
|
||||
assert.ok(duration < 1000, `Query should be fast, took ${duration}ms`);
|
||||
});
|
||||
|
||||
perfTest('batch relation addition is efficient', () => {
|
||||
const arbiter = new Arbiter();
|
||||
const numNodes = 20000;
|
||||
|
||||
arbiter.setRelationConfig('connect', { type: 'direct' });
|
||||
|
||||
for (let i = 0; i < numNodes; i++) {
|
||||
arbiter.addNode(`node:${i}`, 'node');
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
for (let i = 0; i < numNodes - 1; i++) {
|
||||
arbiter.addRelation(`node:${i}`, 'connect', `node:${i + 1}`, 1.0);
|
||||
}
|
||||
const duration = Date.now() - start;
|
||||
|
||||
console.log(`Batch addition time: ${duration}ms for ${numNodes - 1} relations`);
|
||||
|
||||
assert.ok(duration < 5000, `Batch addition should be fast, took ${duration}ms`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('OWA relational comparator', () => {
|
||||
test('nested OWA aggregation compares values', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.addRelation('user:1', 'risk_score', 'resource:1', 1.0, { value: 40 });
|
||||
arbiter.addRelation('user:1', 'risk_bonus', 'resource:1', 1.0, { value: 10 });
|
||||
arbiter.addRelation('user:1', 'risk_noise', 'resource:1', 1.0, { value: 0 });
|
||||
arbiter.addRelation('resource:1', 'risk_limit', 'resource:1', 1.0, { value: 50 });
|
||||
arbiter.addRelation('resource:1', 'risk_cap', 'resource:1', 1.0, { value: 80 });
|
||||
|
||||
arbiter.setRelationConfig('risk_score', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_bonus', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_noise', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_limit', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_cap', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_ok_owa', {
|
||||
type: 'relational_comparator',
|
||||
comparator: '<=',
|
||||
fallbackBehavior: 'deny',
|
||||
left: {
|
||||
rule: {
|
||||
union: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'risk_score' },
|
||||
{ type: 'direct', relation: 'risk_bonus' },
|
||||
{ type: 'direct', relation: 'risk_noise' }
|
||||
],
|
||||
aggregator: 'owa',
|
||||
owaWeights: [0.5, 0.3, 0.2]
|
||||
}
|
||||
},
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_score',
|
||||
aggregator: 'owa',
|
||||
owaWeights: [0.5, 0.3, 0.2]
|
||||
},
|
||||
right: {
|
||||
rule: {
|
||||
union: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'risk_limit' },
|
||||
{ type: 'direct', relation: 'risk_cap' }
|
||||
],
|
||||
aggregator: 'owa',
|
||||
owaWeights: [0.6, 0.4]
|
||||
}
|
||||
},
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_limit',
|
||||
evaluateFrom: 'object',
|
||||
aggregator: 'owa',
|
||||
owaWeights: [0.6, 0.4]
|
||||
}
|
||||
});
|
||||
|
||||
const result = arbiter.check('user:1', 'risk_ok_owa', 'resource:1', { fastPath: false });
|
||||
assert.ok(result);
|
||||
assert.ok(result.possibility > 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('OWA union aggregation', () => {
|
||||
test('union uses OWA weights for possibilities', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.addRelation('user:1', 'viewer', 'resource:1', 0.9);
|
||||
arbiter.addRelation('user:1', 'owner', 'resource:1', 0.5);
|
||||
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_view', {
|
||||
union: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'viewer' },
|
||||
{ type: 'direct', relation: 'owner' }
|
||||
],
|
||||
aggregator: 'owa',
|
||||
owaWeights: [0.7, 0.3]
|
||||
}
|
||||
});
|
||||
|
||||
const result = arbiter.check('user:1', 'can_view', 'resource:1');
|
||||
assert.ok(result);
|
||||
assert.ok(Math.abs(result.possibility - 0.78) < 0.01);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Partial graph overlay', () => {
|
||||
test('direct relation resolves from partial graph', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
||||
|
||||
const missing = arbiter.check('user:1', 'can_read', 'doc:1');
|
||||
assert.strictEqual(missing.possibility, 0);
|
||||
|
||||
const partialGraph = {
|
||||
relations: [
|
||||
{ src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 1.0 }
|
||||
]
|
||||
};
|
||||
|
||||
const result = arbiter.check('user:1', 'can_read', 'doc:1', { partialGraph });
|
||||
assert.strictEqual(result.possibility, 1.0);
|
||||
});
|
||||
|
||||
test('persistent relation wins when partial conflicts on same triple', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
||||
|
||||
arbiter.addRelation('user:1', 'can_read', 'doc:1', 0.2);
|
||||
const baseline = arbiter.check('user:1', 'can_read', 'doc:1');
|
||||
assert.strictEqual(baseline.possibility, 0.2);
|
||||
|
||||
const partialGraph = {
|
||||
relations: [
|
||||
{ src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 0.9 }
|
||||
]
|
||||
};
|
||||
|
||||
const result = arbiter.check('user:1', 'can_read', 'doc:1', { partialGraph });
|
||||
assert.strictEqual(result.possibility, 0.2);
|
||||
});
|
||||
|
||||
test('mixed overlay precedence reports persistent source and audit conflict', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
||||
|
||||
arbiter.addRelation('user:1', 'can_read', 'doc:1', 0.2);
|
||||
|
||||
const partialGraph = {
|
||||
relations: [
|
||||
{ src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 0.9 }
|
||||
]
|
||||
};
|
||||
|
||||
const result = arbiter.explain('user:1', 'can_read', 'doc:1', { partialGraph });
|
||||
assert.strictEqual(result.decision.possibility, 0.2);
|
||||
assert.strictEqual(result.trace.path[0].source, 'persistent');
|
||||
assert.strictEqual(result.audit.provenance.partial_fact_used, false);
|
||||
assert.strictEqual(result.audit.provenance.provenance_conflicts.length, 1);
|
||||
});
|
||||
|
||||
test('explain marks partial provenance', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
||||
|
||||
const partialGraph = {
|
||||
relations: [
|
||||
{ src: 'user:1', relation: 'can_read', dst: 'doc:1', possibility: 1.0 }
|
||||
]
|
||||
};
|
||||
|
||||
const result = arbiter.explain('user:1', 'can_read', 'doc:1', { partialGraph });
|
||||
assert.strictEqual(result.trace.path[0].source, 'partial');
|
||||
});
|
||||
|
||||
test('partial nodes participate in chain rules', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('account:1', 'account');
|
||||
|
||||
arbiter.setRelationConfig('device_link', { type: 'direct' });
|
||||
arbiter.setRelationConfig('logged_in_as', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_login', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'device_link', direction: 'out' },
|
||||
{ relation: 'logged_in_as', direction: 'out' }
|
||||
]
|
||||
});
|
||||
|
||||
const partialGraph = {
|
||||
nodes: [
|
||||
{ key: 'device:abc', type: 'device' }
|
||||
],
|
||||
relations: [
|
||||
{ src: 'user:1', relation: 'device_link', dst: 'device:abc', possibility: 1.0 },
|
||||
{ src: 'device:abc', relation: 'logged_in_as', dst: 'account:1', possibility: 1.0 }
|
||||
]
|
||||
};
|
||||
|
||||
const result = arbiter.check('user:1', 'can_login', 'account:1', { partialGraph });
|
||||
assert.ok(result.possibility > 0);
|
||||
});
|
||||
|
||||
test('multi-hop explain includes partial path sources', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
arbiter.setRelationConfig('path', { type: 'multi_hop', relation: 'link', maxDepth: 3 });
|
||||
|
||||
const partialGraph = {
|
||||
nodes: [
|
||||
{ key: 'mid:1', type: 'group' }
|
||||
],
|
||||
relations: [
|
||||
{ src: 'user:1', relation: 'link', dst: 'mid:1', possibility: 1.0 },
|
||||
{ src: 'mid:1', relation: 'link', dst: 'doc:1', possibility: 1.0 }
|
||||
]
|
||||
};
|
||||
|
||||
const result = arbiter.explain('user:1', 'path', 'doc:1', { partialGraph });
|
||||
const pathSteps = result.trace.rulePaths[0]?.pathSteps || [];
|
||||
assert.ok(pathSteps.length > 0);
|
||||
for (const step of pathSteps) {
|
||||
assert.strictEqual(step.source, 'partial');
|
||||
}
|
||||
});
|
||||
|
||||
test('chain collected values include source', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('account:1', 'account');
|
||||
|
||||
arbiter.setRelationConfig('device_link', { type: 'direct' });
|
||||
arbiter.setRelationConfig('logged_in_as', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_login', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'device_link', direction: 'out' },
|
||||
{ relation: 'logged_in_as', direction: 'out' }
|
||||
]
|
||||
});
|
||||
|
||||
const partialGraph = {
|
||||
nodes: [
|
||||
{ key: 'device:abc', type: 'device' }
|
||||
],
|
||||
relations: [
|
||||
{ src: 'user:1', relation: 'device_link', dst: 'device:abc', possibility: 1.0, value: 1 },
|
||||
{ src: 'device:abc', relation: 'logged_in_as', dst: 'account:1', possibility: 1.0, value: 1 }
|
||||
]
|
||||
};
|
||||
|
||||
const result = arbiter.explain('user:1', 'can_login', 'account:1', { partialGraph });
|
||||
const pathSteps = result.trace.rulePaths[0]?.pathSteps || [];
|
||||
assert.ok(pathSteps.length > 0);
|
||||
let sawPartial = false;
|
||||
for (const step of pathSteps) {
|
||||
assert.ok(step.source === 'partial' || step.source === 'persistent');
|
||||
if (step.source === 'partial') sawPartial = true;
|
||||
}
|
||||
assert.ok(sawPartial);
|
||||
const collectedValues = result.trace.values || [];
|
||||
assert.ok(collectedValues.length > 0);
|
||||
for (const cv of collectedValues) {
|
||||
assert.strictEqual(cv.metadata.source, 'partial');
|
||||
}
|
||||
});
|
||||
|
||||
test('relational comparator uses partial value provenance', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('account:1', 'account');
|
||||
|
||||
arbiter.setRelationConfig('device_risk', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_limit', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_ok', {
|
||||
type: 'relational_comparator',
|
||||
left: {
|
||||
rule: { type: 'direct', relation: 'device_risk' },
|
||||
extractValue: true,
|
||||
valueRelation: 'device_risk',
|
||||
evaluateFrom: 'auto'
|
||||
},
|
||||
right: {
|
||||
rule: { type: 'direct', relation: 'risk_limit' },
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_limit',
|
||||
evaluateFrom: 'auto'
|
||||
},
|
||||
comparator: '<='
|
||||
});
|
||||
|
||||
const partialGraph = {
|
||||
relations: [
|
||||
{ src: 'user:1', relation: 'device_risk', dst: 'account:1', possibility: 1.0, value: 0.2 },
|
||||
{ src: 'user:1', relation: 'risk_limit', dst: 'account:1', possibility: 1.0, value: 0.8 }
|
||||
]
|
||||
};
|
||||
|
||||
const result = arbiter.explain('user:1', 'risk_ok', 'account:1', { partialGraph });
|
||||
assert.ok(result.decision.possibility > 0);
|
||||
const comparatorNode = result.trace.path.find(node => node.type === 'relational_comparator');
|
||||
const details = comparatorNode?.details || {};
|
||||
assert.strictEqual(details.leftSource, 'partial');
|
||||
assert.strictEqual(details.rightSource, 'partial');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Reachability edge cases', () => {
|
||||
test('reachability detects path after initialization', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('a', 'node');
|
||||
arbiter.addNode('b', 'node');
|
||||
arbiter.addNode('c', 'node');
|
||||
arbiter.addRelation('a', 'links', 'b');
|
||||
arbiter.addRelation('b', 'links', 'c');
|
||||
|
||||
arbiter.graphManager.initializeReachabilityChecker();
|
||||
|
||||
assert.strictEqual(arbiter.isReachable('a', 'c'), true);
|
||||
assert.strictEqual(arbiter.isReachable('c', 'a'), false);
|
||||
});
|
||||
|
||||
test('reachability returns false for missing nodes', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('a', 'node');
|
||||
arbiter.graphManager.initializeReachabilityChecker();
|
||||
assert.strictEqual(arbiter.isReachable('a', 'missing'), false);
|
||||
});
|
||||
|
||||
test('backward reachability checks reverse direction', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('a', 'node');
|
||||
arbiter.addNode('b', 'node');
|
||||
arbiter.addRelation('a', 'links', 'b');
|
||||
|
||||
arbiter.graphManager.initializeReachabilityChecker({ enableBackwardIndex: true });
|
||||
|
||||
const srcId = arbiter.nodeIdByKey.get('b');
|
||||
const dstId = arbiter.nodeIdByKey.get('a');
|
||||
const backwardReachable = arbiter.reachabilityChecker.isReachable(srcId, dstId, { direction: 'backward' });
|
||||
|
||||
assert.strictEqual(backwardReachable, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { RelationStore } from '../../src/core/RelationStore.js';
|
||||
|
||||
const runPerf = process.env.RUN_PERF_TESTS === '1';
|
||||
const perfTest = runPerf ? test : test.skip;
|
||||
|
||||
describe('RelationStore Read Performance', () => {
|
||||
perfTest('sequential read speed comparison', () => {
|
||||
const numRelations = 100000;
|
||||
const store = new RelationStore(numRelations);
|
||||
|
||||
// Add relations
|
||||
for (let i = 0; i < numRelations; i++) {
|
||||
store.add(i, 'relation', (i + numRelations) % numRelations);
|
||||
}
|
||||
|
||||
// Benchmark sequential reads
|
||||
const iterations = 10000;
|
||||
|
||||
const start1 = Date.now();
|
||||
let sum1 = 0;
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const idx = i % store.size();
|
||||
const rel = store.get(idx);
|
||||
sum1 += rel.possibility;
|
||||
}
|
||||
const time1 = Date.now() - start1;
|
||||
|
||||
const start2 = Date.now();
|
||||
let sum2 = 0;
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const idx = i % store.size();
|
||||
const rel = store.getRaw(idx);
|
||||
sum2 += rel.possibility;
|
||||
}
|
||||
const time2 = Date.now() - start2;
|
||||
|
||||
console.log(`Sequential read speed (${iterations} iterations):`);
|
||||
console.log(` - get() (full object): ${time1}ms (${(time1/iterations*1000).toFixed(4)} µs/op)`);
|
||||
console.log(` - getRaw() (typed arrays): ${time2}ms (${(time2/iterations*1000).toFixed(4)} µs/op)`);
|
||||
console.log(` - Speedup: ${(time1/time2).toFixed(2)}x ${time2 < time1 ? '(faster)' : '(slower)'}`);
|
||||
|
||||
// Both should give same result
|
||||
assert.ok(Math.abs(sum1 - sum2) < 1e-6);
|
||||
});
|
||||
|
||||
perfTest('random access speed', () => {
|
||||
const numRelations = 100000;
|
||||
const store = new RelationStore(numRelations);
|
||||
|
||||
for (let i = 0; i < numRelations; i++) {
|
||||
store.add(i, 'relation', (i + numRelations) % numRelations);
|
||||
}
|
||||
|
||||
const iterations = 10000;
|
||||
const indices = Array.from({length: iterations}, () =>
|
||||
Math.floor(Math.random() * store.size())
|
||||
);
|
||||
|
||||
const start1 = Date.now();
|
||||
let count1 = 0;
|
||||
for (const idx of indices) {
|
||||
const rel = store.get(idx);
|
||||
if (rel.possibility > 0.5) count1++;
|
||||
}
|
||||
const time1 = Date.now() - start1;
|
||||
|
||||
const start2 = Date.now();
|
||||
let count2 = 0;
|
||||
for (const idx of indices) {
|
||||
const rel = store.getRaw(idx);
|
||||
if (rel.possibility > 0.5) count2++;
|
||||
}
|
||||
const time2 = Date.now() - start2;
|
||||
|
||||
console.log(`Random access speed (${iterations} iterations):`);
|
||||
console.log(` - get() (full object): ${time1}ms (${(time1/iterations*1000).toFixed(4)} µs/op)`);
|
||||
console.log(` - getRaw() (typed arrays): ${time2}ms (${(time2/iterations*1000).toFixed(4)} µs/op)`);
|
||||
console.log(` - Speedup: ${(time1/time2).toFixed(2)}x ${time2 < time1 ? '(faster)' : '(slower)'}`);
|
||||
|
||||
assert.strictEqual(count1, count2);
|
||||
});
|
||||
|
||||
perfTest('findMatches performance', () => {
|
||||
const numRelations = 50000;
|
||||
const store = new RelationStore(numRelations);
|
||||
|
||||
for (let i = 0; i < numRelations; i++) {
|
||||
store.add(i % 1000, 'rel', (i + 1) % 1000);
|
||||
}
|
||||
|
||||
const srcId = 500;
|
||||
const relId = store.getRelationId('rel');
|
||||
|
||||
const iterations = 1000;
|
||||
|
||||
const start = Date.now();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const matches = store.findMatches(srcId, relId, null);
|
||||
}
|
||||
const time = Date.now() - start;
|
||||
|
||||
console.log(`findMatches performance (${iterations} iterations):`);
|
||||
console.log(` - Time: ${time}ms (${(time/iterations).toFixed(4)} ms/op)`);
|
||||
console.log(` - Matches found: ${store.findMatches(srcId, relId, null).length}`);
|
||||
console.log(` - Avg matches/query: ${(time/iterations).toFixed(4)}ms`);
|
||||
|
||||
assert.ok(time < iterations * 10, 'findMatches should be reasonably fast');
|
||||
});
|
||||
|
||||
perfTest('forEach iteration speed', () => {
|
||||
const numRelations = 100000;
|
||||
const store = new RelationStore(numRelations);
|
||||
|
||||
for (let i = 0; i < numRelations; i++) {
|
||||
store.add(i, 'relation', (i + numRelations) % numRelations, {
|
||||
possibility: Math.random()
|
||||
});
|
||||
}
|
||||
|
||||
const start1 = Date.now();
|
||||
let sum1 = 0;
|
||||
store.forEach((rel) => {
|
||||
sum1 += rel.possibility;
|
||||
});
|
||||
const time1 = Date.now() - start1;
|
||||
|
||||
const start2 = Date.now();
|
||||
let sum2 = 0;
|
||||
store.forEachRaw((rel) => {
|
||||
sum2 += rel.possibility;
|
||||
});
|
||||
const time2 = Date.now() - start2;
|
||||
|
||||
console.log(`forEach iteration speed (${store.size()} relations):`);
|
||||
console.log(` - forEach() (full object): ${time1}ms`);
|
||||
console.log(` - forEachRaw() (typed arrays): ${time2}ms`);
|
||||
console.log(` - Speedup: ${(time1/time2).toFixed(2)}x ${time2 < time1 ? '(faster)' : '(slower)'}`);
|
||||
|
||||
assert.ok(Math.abs(sum1 - sum2) < 1e-6);
|
||||
});
|
||||
|
||||
perfTest('cache-friendly access pattern', () => {
|
||||
const numRelations = 100000;
|
||||
const store = new RelationStore(numRelations);
|
||||
|
||||
for (let i = 0; i < numRelations; i++) {
|
||||
store.add(i, 'relation', (i + numRelations) % numRelations, {
|
||||
possibility: Math.random(),
|
||||
reliability: Math.random()
|
||||
});
|
||||
}
|
||||
|
||||
// Access all src fields first (cache-friendly)
|
||||
const start1 = Date.now();
|
||||
let totalSrc = 0;
|
||||
for (let i = 0; i < store.size(); i++) {
|
||||
totalSrc += store.src[i];
|
||||
}
|
||||
const time1 = Date.now() - start1;
|
||||
|
||||
// Access possibility fields (different typed array)
|
||||
const start2 = Date.now();
|
||||
let totalPoss = 0;
|
||||
for (let i = 0; i < store.size(); i++) {
|
||||
totalPoss += store.possibility[i];
|
||||
}
|
||||
const time2 = Date.now() - start2;
|
||||
|
||||
console.log(`Cache-friendly field access (${store.size()} relations):`);
|
||||
console.log(` - src field access: ${time1}ms`);
|
||||
console.log(` - possibility field access: ${time2}ms`);
|
||||
console.log(` - Each operation: ${((time1+time2)/(store.size()*1000)).toFixed(4)} µs/element`);
|
||||
});
|
||||
|
||||
perfTest('pattern matching queries', () => {
|
||||
const numRelations = 50000;
|
||||
const store = new RelationStore(numRelations);
|
||||
|
||||
// Create different relation types
|
||||
const relationTypes = ['owner', 'member', 'viewer', 'editor'];
|
||||
relationTypes.forEach(rel => store.getRelationId(rel));
|
||||
|
||||
for (let i = 0; i < numRelations; i++) {
|
||||
const relType = relationTypes[i % relationTypes.length];
|
||||
store.add(i, relType, (i + 1) % 1000, {
|
||||
possibility: Math.random()
|
||||
});
|
||||
}
|
||||
|
||||
const queries = 1000;
|
||||
|
||||
// Query: Find all relations from a source
|
||||
const srcId = 100;
|
||||
const start1 = Date.now();
|
||||
let count1 = 0;
|
||||
for (let i = 0; i < queries; i++) {
|
||||
const matches = store.findMatches(srcId, null, null);
|
||||
count1 += matches.length;
|
||||
}
|
||||
const time1 = Date.now() - start1;
|
||||
|
||||
// Query: Find all relations of a specific type
|
||||
const ownerRelId = store.getRelationId('owner');
|
||||
const start2 = Date.now();
|
||||
let count2 = 0;
|
||||
for (let i = 0; i < queries; i++) {
|
||||
const matches = store.findMatches(null, ownerRelId, null);
|
||||
count2 += matches.length;
|
||||
}
|
||||
const time2 = Date.now() - start2;
|
||||
|
||||
console.log(`Pattern matching queries (${queries} queries):`);
|
||||
console.log(` - Find by source: ${time1}ms (${(time1/queries).toFixed(4)} ms/query)`);
|
||||
console.log(` - Find by relation type: ${time2}ms (${(time2/queries).toFixed(4)} ms/query)`);
|
||||
console.log(` - Results: source=${count1/queries} avg, type=${count2/queries} avg`);
|
||||
|
||||
assert.ok(count1 > 0, 'Should find relations by source');
|
||||
assert.ok(count2 > 0, 'Should find relations by type');
|
||||
});
|
||||
|
||||
perfTest('update operation speed', () => {
|
||||
const numRelations = 50000;
|
||||
const store = new RelationStore(numRelations);
|
||||
|
||||
for (let i = 0; i < numRelations; i++) {
|
||||
store.add(i, 'relation', (i + numRelations) % numRelations, {
|
||||
possibility: 0.5,
|
||||
reliability: 0.7
|
||||
});
|
||||
}
|
||||
|
||||
const updates = 10000;
|
||||
const start = Date.now();
|
||||
for (let i = 0; i < updates; i++) {
|
||||
const idx = i % store.size();
|
||||
store.update(idx, { possibility: 0.9 });
|
||||
}
|
||||
const time = Date.now() - start;
|
||||
|
||||
console.log(`Update operation speed (${updates} updates):`);
|
||||
console.log(` - Time: ${time}ms`);
|
||||
console.log(` - Avg per update: ${(time/updates).toFixed(4)} ms`);
|
||||
console.log(` - Updates/sec: ${(updates/time*1000).toFixed(0)}`);
|
||||
|
||||
// Verify updates worked - check that value changed
|
||||
const checkIdx = Math.floor(store.size() / 2);
|
||||
const rel = store.get(checkIdx);
|
||||
const beforeUpdate = store.get(0);
|
||||
assert.ok(rel.possibility !== beforeUpdate.possibility || store.size() < 2, 'Update should change value');
|
||||
});
|
||||
|
||||
perfTest('remove operation speed', () => {
|
||||
const numRelations = 50000;
|
||||
const store = new RelationStore(numRelations);
|
||||
|
||||
for (let i = 0; i < numRelations; i++) {
|
||||
store.add(i, 'relation', (i + numRelations) % numRelations);
|
||||
}
|
||||
|
||||
const initialSize = store.size();
|
||||
const removes = 10000;
|
||||
const start = Date.now();
|
||||
for (let i = 0; i < removes; i++) {
|
||||
const idx = Math.floor(Math.random() * store.size());
|
||||
store.remove(idx);
|
||||
}
|
||||
const time = Date.now() - start;
|
||||
const finalSize = store.size();
|
||||
const actualRemoves = initialSize - finalSize;
|
||||
|
||||
console.log(`Remove operation speed (${removes} attempted):`);
|
||||
console.log(` - Time: ${time}ms`);
|
||||
console.log(` - Avg per remove: ${(time/removes).toFixed(4)} ms`);
|
||||
console.log(` - Actual removes: ${actualRemoves} (deduplication)`);
|
||||
console.log(` - Removes/sec: ${(actualRemoves/time*1000).toFixed(0)}`);
|
||||
|
||||
assert.strictEqual(finalSize, initialSize - removes);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { RelationStore } from '../../src/core/RelationStore.js';
|
||||
|
||||
const runPerf = process.env.RUN_PERF_TESTS === '1';
|
||||
const perfTest = runPerf ? test : test.skip;
|
||||
|
||||
describe.skip('RelationStore (SoA Memory Optimization)', () => {
|
||||
test('basic add and retrieve', () => {
|
||||
const store = new RelationStore();
|
||||
|
||||
const idx1 = store.add(1, 'owner', 2, { possibility: 1.0 });
|
||||
const idx2 = store.add(1, 'member', 3, { possibility: 0.5 });
|
||||
|
||||
assert.strictEqual(store.size(), 2);
|
||||
|
||||
const rel1 = store.get(idx1);
|
||||
assert.strictEqual(rel1.rel, 'owner');
|
||||
assert.ok(Math.abs(rel1.possibility - 1.0) < 1e-6);
|
||||
|
||||
const rel2 = store.get(idx2);
|
||||
assert.strictEqual(rel2.rel, 'member');
|
||||
assert.ok(Math.abs(rel2.possibility - 0.5) < 1e-6);
|
||||
});
|
||||
|
||||
test('update relation', () => {
|
||||
const store = new RelationStore();
|
||||
|
||||
const idx = store.add(1, 'owner', 2, { possibility: 0.5 });
|
||||
assert.strictEqual(store.possibility[idx], 0.5);
|
||||
|
||||
store.update(idx, { possibility: 0.9 });
|
||||
assert.ok(Math.abs(store.possibility[idx] - 0.9) < 1e-6, `possibility should be ~0.9, got ${store.possibility[idx]}`);
|
||||
|
||||
const rel = store.get(idx);
|
||||
assert.ok(Math.abs(rel.possibility - 0.9) < 1e-6);
|
||||
});
|
||||
|
||||
test('remove relation', () => {
|
||||
const store = new RelationStore();
|
||||
|
||||
const idx1 = store.add(1, 'owner', 2);
|
||||
const idx2 = store.add(1, 'member', 3);
|
||||
const idx3 = store.add(2, 'owner', 4);
|
||||
|
||||
assert.strictEqual(store.size(), 3);
|
||||
|
||||
store.remove(idx2);
|
||||
assert.strictEqual(store.size(), 2);
|
||||
|
||||
const rel1 = store.get(idx1);
|
||||
assert.strictEqual(rel1.rel, 'owner');
|
||||
|
||||
// After removal, idx2's data was moved from idx3
|
||||
const rel3 = store.get(idx3);
|
||||
assert.strictEqual(rel3, null);
|
||||
|
||||
const remaining = store.findMatches(null, null, null);
|
||||
assert.strictEqual(remaining.length, 2);
|
||||
});
|
||||
|
||||
test('find matches', () => {
|
||||
const store = new RelationStore();
|
||||
|
||||
store.add(1, 'owner', 2);
|
||||
store.add(1, 'member', 3);
|
||||
store.add(1, 'owner', 4);
|
||||
store.add(2, 'owner', 3);
|
||||
|
||||
const ownerFrom1 = store.findMatches(1, store.getRelationId('owner'), null);
|
||||
assert.strictEqual(ownerFrom1.length, 2);
|
||||
|
||||
const allOwner = store.findMatches(null, store.getRelationId('owner'), null);
|
||||
assert.strictEqual(allOwner.length, 3);
|
||||
});
|
||||
|
||||
test('raw access for performance', () => {
|
||||
const store = new RelationStore();
|
||||
|
||||
const idx = store.add(1, 'owner', 2, { possibility: 0.75 });
|
||||
|
||||
const raw = store.getRaw(idx);
|
||||
assert.ok(raw !== null, 'raw should not be null');
|
||||
assert.strictEqual(raw.src, 1);
|
||||
const relId = store.getRelationId('owner');
|
||||
assert.strictEqual(raw.relId, relId);
|
||||
assert.strictEqual(raw.dst, 2);
|
||||
assert.ok(Math.abs(raw.possibility - 0.75) < 1e-6);
|
||||
assert.strictEqual(raw.reliability, 1.0);
|
||||
assert.strictEqual('value' in raw, true);
|
||||
});
|
||||
|
||||
test('metadata storage', () => {
|
||||
const store = new RelationStore();
|
||||
|
||||
const customValue = { tier: 'premium', level: 5 };
|
||||
const decay = { rate: 0.1, interval: 3600000 };
|
||||
|
||||
const idx = store.add(1, 'owner', 2, {
|
||||
possibility: 1.0,
|
||||
value: customValue,
|
||||
decayConfig: decay,
|
||||
stateId: 'test-state-123'
|
||||
});
|
||||
|
||||
const rel = store.get(idx);
|
||||
assert.deepStrictEqual(rel.value, customValue);
|
||||
assert.deepStrictEqual(rel.decayConfig, decay);
|
||||
assert.strictEqual(rel.stateId, 'test-state-123');
|
||||
});
|
||||
|
||||
test('capacity expansion', () => {
|
||||
const store = new RelationStore(4);
|
||||
|
||||
assert.strictEqual(store.capacity(), 4);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
store.add(i, 'rel', i + 1);
|
||||
}
|
||||
|
||||
assert.strictEqual(store.size(), 10);
|
||||
assert.ok(store.capacity() >= 10);
|
||||
});
|
||||
|
||||
test('forEach iteration', () => {
|
||||
const store = new RelationStore();
|
||||
|
||||
store.add(1, 'owner', 2);
|
||||
store.add(1, 'member', 3);
|
||||
store.add(2, 'owner', 4);
|
||||
|
||||
let count = 0;
|
||||
store.forEach((rel) => {
|
||||
count++;
|
||||
assert.ok(rel.src >= 1);
|
||||
assert.ok(rel.dst >= 2);
|
||||
});
|
||||
|
||||
assert.strictEqual(count, 3);
|
||||
});
|
||||
|
||||
perfTest('memory efficiency compared to AoS', () => {
|
||||
const numRelations = 100000;
|
||||
const store = new RelationStore(numRelations);
|
||||
|
||||
const startMem = process.memoryUsage().heapUsed;
|
||||
|
||||
for (let i = 0; i < numRelations; i++) {
|
||||
store.add(i % 1000, 'relation', (i + 1) % 1000, {
|
||||
possibility: Math.random(),
|
||||
reliability: Math.random()
|
||||
});
|
||||
}
|
||||
|
||||
const endMem = process.memoryUsage().heapUsed;
|
||||
const memDelta = endMem - startMem;
|
||||
const memMB = memDelta / (1024 * 1024);
|
||||
|
||||
const stats = store.getStats();
|
||||
console.log(`RelationStore memory for ${numRelations} relations:`);
|
||||
console.log(` - Typed arrays: ${(stats.memoryUsage.typedArrays / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` - Metadata: ${(stats.memoryUsage.metadata / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` - Total heap delta: ${memMB.toFixed(2)} MB`);
|
||||
console.log(` - Bytes per relation: ${(memDelta / numRelations).toFixed(2)}`);
|
||||
console.log(` - Capacity: ${stats.capacity}, Utilization: ${(stats.size / stats.capacity).toFixed(2)}`);
|
||||
|
||||
assert.strictEqual(store.size(), numRelations);
|
||||
assert.ok(memMB < 50, `Memory usage should be efficient (< 50MB), got ${memMB.toFixed(2)} MB`);
|
||||
});
|
||||
|
||||
perfTest('comparison with traditional object array', () => {
|
||||
const numRelations = 50000;
|
||||
|
||||
const start1 = process.memoryUsage().heapUsed;
|
||||
|
||||
const traditional = [];
|
||||
for (let i = 0; i < numRelations; i++) {
|
||||
traditional.push({
|
||||
src: i % 1000,
|
||||
rel: 'relation',
|
||||
dst: (i + 1) % 1000,
|
||||
possibility: Math.random(),
|
||||
reliability: Math.random(),
|
||||
updated_last_at: Date.now(),
|
||||
changed_last_at: Date.now(),
|
||||
stateId: 'state-' + i
|
||||
});
|
||||
}
|
||||
|
||||
const end1 = process.memoryUsage().heapUsed;
|
||||
const traditionalMem = end1 - start1;
|
||||
|
||||
const start2 = process.memoryUsage().heapUsed;
|
||||
|
||||
const store = new RelationStore(numRelations);
|
||||
for (let i = 0; i < numRelations; i++) {
|
||||
store.add(i % 1000, 'relation', (i + 1) % 1000, {
|
||||
possibility: Math.random(),
|
||||
reliability: Math.random()
|
||||
});
|
||||
}
|
||||
|
||||
const end2 = process.memoryUsage().heapUsed;
|
||||
const soaMem = end2 - start2;
|
||||
|
||||
const savingsMB = (traditionalMem - soaMem) / (1024 * 1024);
|
||||
const savingsPercent = ((traditionalMem - soaMem) / traditionalMem * 100);
|
||||
|
||||
console.log(`Memory comparison for ${numRelations} relations:`);
|
||||
console.log(` - Traditional AoS: ${(traditionalMem / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` - SoA RelationStore: ${(soaMem / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` - Savings: ${savingsMB.toFixed(2)} MB (${savingsPercent.toFixed(1)}%)`);
|
||||
console.log(` - Bytes per relation (AoS): ${(traditionalMem / numRelations).toFixed(2)}`);
|
||||
console.log(` - Bytes per relation (SoA): ${(soaMem / numRelations).toFixed(2)}`);
|
||||
|
||||
assert.ok(soaMem <= traditionalMem, 'SoA should use less memory than AoS');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
import { OWAFusion } from '../../src/utils/OWAFusion.js';
|
||||
|
||||
const EPS = 1e-6;
|
||||
|
||||
function approxEqual(actual, expected, epsilon = EPS) {
|
||||
assert.ok(Math.abs(actual - expected) <= epsilon, `expected ${expected} but got ${actual}`);
|
||||
}
|
||||
|
||||
function addValueRelation(arbiter, src, relation, dst, value, possibility, reliability) {
|
||||
arbiter.addRelation(src, relation, dst, { value, possibility, reliability });
|
||||
arbiter.setRelationConfig(relation, { type: 'direct' });
|
||||
}
|
||||
|
||||
function buildComparatorRule(leftRelations, rightRelations, weights) {
|
||||
return {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>=',
|
||||
fallbackBehavior: 'deny',
|
||||
left: {
|
||||
rule: {
|
||||
union: {
|
||||
rules: leftRelations.map((relation) => ({ type: 'direct', relation })),
|
||||
aggregator: 'owa',
|
||||
owaWeights: weights
|
||||
}
|
||||
},
|
||||
extractValue: true,
|
||||
aggregator: 'owa',
|
||||
owaWeights: weights
|
||||
},
|
||||
right: {
|
||||
rule: {
|
||||
union: {
|
||||
rules: rightRelations.map((relation) => ({ type: 'direct', relation })),
|
||||
aggregator: 'owa',
|
||||
owaWeights: weights
|
||||
}
|
||||
},
|
||||
extractValue: true,
|
||||
aggregator: 'owa',
|
||||
owaWeights: weights,
|
||||
evaluateFrom: 'object'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function fuseTriples(values, possibilities, reliabilities, weights) {
|
||||
const metas = values.map(() => ({}));
|
||||
return OWAFusion.fuseTriplesWithMeta(values, possibilities, reliabilities, metas, weights, 'owa');
|
||||
}
|
||||
|
||||
function computeComparatorOutcome(left, right, weights) {
|
||||
const fusedLeft = fuseTriples(left.values, left.possibilities, left.reliabilities, weights);
|
||||
const fusedRight = fuseTriples(right.values, right.possibilities, right.reliabilities, weights);
|
||||
const comparison = fusedLeft.value >= fusedRight.value ? 1 : 0;
|
||||
const averageOperandPossibility = (fusedLeft.possibility + fusedRight.possibility) / 2;
|
||||
const confidenceWeight = Math.min(averageOperandPossibility * 2, 1.0);
|
||||
return {
|
||||
possibility: comparison * confidenceWeight,
|
||||
reliability: Math.min(fusedLeft.reliability, fusedRight.reliability)
|
||||
};
|
||||
}
|
||||
|
||||
describe('Relational comparator uncertainty', () => {
|
||||
test('OWA value aggregation drives comparator possibility', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
|
||||
const weights = [0.6, 0.3, 0.1];
|
||||
const left = {
|
||||
relations: ['risk_low', 'risk_mid', 'risk_high'],
|
||||
values: [10, 20, 40],
|
||||
possibilities: [0.9, 0.6, 0.4],
|
||||
reliabilities: [0.8, 0.9, 0.7]
|
||||
};
|
||||
const right = {
|
||||
relations: ['limit_low', 'limit_mid', 'limit_high'],
|
||||
values: [15, 25, 30],
|
||||
possibilities: [0.8, 0.5, 0.9],
|
||||
reliabilities: [0.9, 0.8, 0.95]
|
||||
};
|
||||
|
||||
left.relations.forEach((relation, idx) => {
|
||||
addValueRelation(arbiter, 'user:alice', relation, 'doc:1', left.values[idx], left.possibilities[idx], left.reliabilities[idx]);
|
||||
});
|
||||
right.relations.forEach((relation, idx) => {
|
||||
addValueRelation(arbiter, 'doc:1', relation, 'doc:1', right.values[idx], right.possibilities[idx], right.reliabilities[idx]);
|
||||
});
|
||||
|
||||
arbiter.setRelationConfig('risk_ok', buildComparatorRule(left.relations, right.relations, weights));
|
||||
|
||||
const expected = computeComparatorOutcome(left, right, weights);
|
||||
const result = arbiter.check('user:alice', 'risk_ok', 'doc:1', { fastPath: false, explain: true });
|
||||
|
||||
approxEqual(result.possibility, expected.possibility);
|
||||
approxEqual(result.reliability, expected.reliability);
|
||||
});
|
||||
|
||||
test('low operand confidence scales comparator output', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
|
||||
const weights = [0.5, 0.3, 0.2];
|
||||
const left = {
|
||||
relations: ['risk_a', 'risk_b', 'risk_c'],
|
||||
values: [50, 30, 10],
|
||||
possibilities: [0.1, 0.1, 0.1],
|
||||
reliabilities: [0.9, 0.9, 0.9]
|
||||
};
|
||||
const right = {
|
||||
relations: ['limit_a', 'limit_b', 'limit_c'],
|
||||
values: [20, 15, 5],
|
||||
possibilities: [0.1, 0.1, 0.1],
|
||||
reliabilities: [0.9, 0.9, 0.9]
|
||||
};
|
||||
|
||||
left.relations.forEach((relation, idx) => {
|
||||
addValueRelation(arbiter, 'user:alice', relation, 'doc:1', left.values[idx], left.possibilities[idx], left.reliabilities[idx]);
|
||||
});
|
||||
right.relations.forEach((relation, idx) => {
|
||||
addValueRelation(arbiter, 'doc:1', relation, 'doc:1', right.values[idx], right.possibilities[idx], right.reliabilities[idx]);
|
||||
});
|
||||
|
||||
arbiter.setRelationConfig('risk_ok', buildComparatorRule(left.relations, right.relations, weights));
|
||||
|
||||
const expected = computeComparatorOutcome(left, right, weights);
|
||||
const result = arbiter.check('user:alice', 'risk_ok', 'doc:1', { fastPath: false, explain: true });
|
||||
|
||||
approxEqual(result.possibility, expected.possibility);
|
||||
approxEqual(result.reliability, expected.reliability);
|
||||
assert.ok(result.possibility < 1, 'expected confidence scaling to reduce possibility');
|
||||
});
|
||||
|
||||
test('OWA union aggregates comparator results with uncertainty', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
|
||||
const weights = [0.6, 0.3, 0.1];
|
||||
const unionWeights = [0.7, 0.3];
|
||||
|
||||
const leftA = {
|
||||
relations: ['risk_a1', 'risk_a2', 'risk_a3'],
|
||||
values: [12, 18, 25],
|
||||
possibilities: [0.9, 0.7, 0.5],
|
||||
reliabilities: [0.9, 0.8, 0.7]
|
||||
};
|
||||
const rightA = {
|
||||
relations: ['limit_a1', 'limit_a2', 'limit_a3'],
|
||||
values: [10, 15, 20],
|
||||
possibilities: [0.8, 0.6, 0.7],
|
||||
reliabilities: [0.9, 0.9, 0.8]
|
||||
};
|
||||
const leftB = {
|
||||
relations: ['risk_b1', 'risk_b2', 'risk_b3'],
|
||||
values: [8, 9, 11],
|
||||
possibilities: [0.4, 0.5, 0.6],
|
||||
reliabilities: [0.8, 0.8, 0.8]
|
||||
};
|
||||
const rightB = {
|
||||
relations: ['limit_b1', 'limit_b2', 'limit_b3'],
|
||||
values: [9, 10, 12],
|
||||
possibilities: [0.7, 0.6, 0.5],
|
||||
reliabilities: [0.9, 0.9, 0.9]
|
||||
};
|
||||
|
||||
leftA.relations.forEach((relation, idx) => {
|
||||
addValueRelation(arbiter, 'user:alice', relation, 'doc:1', leftA.values[idx], leftA.possibilities[idx], leftA.reliabilities[idx]);
|
||||
});
|
||||
rightA.relations.forEach((relation, idx) => {
|
||||
addValueRelation(arbiter, 'doc:1', relation, 'doc:1', rightA.values[idx], rightA.possibilities[idx], rightA.reliabilities[idx]);
|
||||
});
|
||||
leftB.relations.forEach((relation, idx) => {
|
||||
addValueRelation(arbiter, 'user:alice', relation, 'doc:1', leftB.values[idx], leftB.possibilities[idx], leftB.reliabilities[idx]);
|
||||
});
|
||||
rightB.relations.forEach((relation, idx) => {
|
||||
addValueRelation(arbiter, 'doc:1', relation, 'doc:1', rightB.values[idx], rightB.possibilities[idx], rightB.reliabilities[idx]);
|
||||
});
|
||||
|
||||
const comparatorA = buildComparatorRule(leftA.relations, rightA.relations, weights);
|
||||
const comparatorB = buildComparatorRule(leftB.relations, rightB.relations, weights);
|
||||
|
||||
arbiter.setRelationConfig('risk_union', {
|
||||
union: {
|
||||
rules: [comparatorA, comparatorB],
|
||||
aggregator: 'owa',
|
||||
owaWeights: unionWeights
|
||||
}
|
||||
});
|
||||
|
||||
const outcomeA = computeComparatorOutcome(leftA, rightA, weights);
|
||||
const outcomeB = computeComparatorOutcome(leftB, rightB, weights);
|
||||
const expectedUnion = OWAFusion.fuseWithMeta(
|
||||
[outcomeA.possibility, outcomeB.possibility],
|
||||
[{}, {}],
|
||||
unionWeights,
|
||||
'owa',
|
||||
true
|
||||
).value;
|
||||
|
||||
const result = arbiter.check('user:alice', 'risk_union', 'doc:1', { fastPath: false });
|
||||
approxEqual(result.possibility, expectedUnion);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { CondensedGraph } from '../../src/core/CondensedGraph.js';
|
||||
import { ShardedSnapshotBuilder } from '../../src/core/shards/ShardedSnapshotBuilder.js';
|
||||
import { ShardedSnapshot } from '../../src/core/shards/ShardedSnapshot.js';
|
||||
import { FileShardStorage } from '../../src/core/shards/FileShardStorage.js';
|
||||
|
||||
function buildShardedSnapshot(bucketSize = 2) {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
const user0 = graph._ensureNode('user:0');
|
||||
const user1 = graph._ensureNode('user:1');
|
||||
const user2 = graph._ensureNode('user:2');
|
||||
const user3 = graph._ensureNode('user:3');
|
||||
const group0 = graph._ensureNode('group:0');
|
||||
const group1 = graph._ensureNode('group:1');
|
||||
const doc0 = graph._ensureNode('doc:0');
|
||||
|
||||
graph.addEdge(user0, 'member', group0);
|
||||
graph.addEdge(user3, 'member', group0);
|
||||
graph.addEdge(user2, 'member', group1);
|
||||
|
||||
graph.addEdge(group0, 'viewer', doc0);
|
||||
graph.addEdge(group1, 'viewer', doc0);
|
||||
|
||||
graph.addEdge(user0, 'risk', doc0, { value: 0.2, possibility: 1, reliability: 1 });
|
||||
graph.addEdge(user3, 'risk', doc0, { value: 0.9, possibility: 1, reliability: 1 });
|
||||
graph.addEdge(user2, 'risk', doc0, { value: 0.6, possibility: 1, reliability: 1 });
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sharded-cross-chain-'));
|
||||
const builder = new ShardedSnapshotBuilder({ bucketSize, includeDirections: ['out', 'in'] });
|
||||
const manifest = builder.build(graph, dir);
|
||||
const storage = new FileShardStorage(dir);
|
||||
const snapshot = new ShardedSnapshot(manifest, storage, { cacheLimit: 8 });
|
||||
snapshot.initializeSync();
|
||||
|
||||
return { graph, snapshot, dir, ids: { user0, user1, user2, user3, group0, group1, doc0 } };
|
||||
}
|
||||
|
||||
function collectRiskPaths(snapshot, relationIds, docId) {
|
||||
const viewerRel = relationIds.viewer;
|
||||
const memberRel = relationIds.member;
|
||||
const riskRel = relationIds.risk;
|
||||
|
||||
const paths = [];
|
||||
const viewerEdges = snapshot.executeGetInEdgesSync(docId, viewerRel, new Set()) || [];
|
||||
for (const viewer of viewerEdges) {
|
||||
const groupId = viewer.src;
|
||||
const memberEdges = snapshot.executeGetInEdgesSync(groupId, memberRel, new Set()) || [];
|
||||
for (const member of memberEdges) {
|
||||
const userId = member.src;
|
||||
const riskEdge = snapshot.executeFindEdgeSync(userId, riskRel, docId, new Set());
|
||||
if (riskEdge) {
|
||||
paths.push({ userId, docId });
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
// ADR-003: sharded snapshots are stubs in src/core/shards/ — tests are the spec for when the subsystem is implemented.
|
||||
describe.skip('Sharded snapshot cross-chain aggregation', () => {
|
||||
test('aggregates risk values across userset paths', () => {
|
||||
const { graph, snapshot, dir, ids } = buildShardedSnapshot(2);
|
||||
const relationIds = {
|
||||
member: graph.getRelationId('member'),
|
||||
viewer: graph.getRelationId('viewer'),
|
||||
risk: graph.getRelationId('risk')
|
||||
};
|
||||
|
||||
const plan = new Set();
|
||||
snapshot.planInEdges(relationIds.viewer, ids.doc0, plan);
|
||||
snapshot.planInEdges(relationIds.member, ids.group0, plan);
|
||||
snapshot.planInEdges(relationIds.member, ids.group1, plan);
|
||||
snapshot.planOutEdges(relationIds.risk, ids.user0, plan);
|
||||
snapshot.planOutEdges(relationIds.risk, ids.user2, plan);
|
||||
snapshot.planOutEdges(relationIds.risk, ids.user3, plan);
|
||||
snapshot.prefetchPlanSync(plan);
|
||||
|
||||
const paths = collectRiskPaths(snapshot, relationIds, ids.doc0);
|
||||
assert.strictEqual(paths.length, 3);
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('cross-bucket paths resolve with inbound and outbound shards', () => {
|
||||
const { graph, snapshot, dir, ids } = buildShardedSnapshot(2);
|
||||
const relationIds = {
|
||||
member: graph.getRelationId('member'),
|
||||
viewer: graph.getRelationId('viewer'),
|
||||
risk: graph.getRelationId('risk')
|
||||
};
|
||||
|
||||
const plan = new Set();
|
||||
snapshot.planInEdges(relationIds.viewer, ids.doc0, plan);
|
||||
snapshot.planInEdges(relationIds.member, ids.group0, plan);
|
||||
snapshot.planInEdges(relationIds.member, ids.group1, plan);
|
||||
snapshot.planOutEdges(relationIds.risk, ids.user0, plan);
|
||||
snapshot.planOutEdges(relationIds.risk, ids.user2, plan);
|
||||
snapshot.planOutEdges(relationIds.risk, ids.user3, plan);
|
||||
snapshot.prefetchPlanSync(plan);
|
||||
|
||||
const paths = collectRiskPaths(snapshot, relationIds, ids.doc0);
|
||||
assert.strictEqual(paths.length, 3);
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { CondensedGraph } from '../../src/core/CondensedGraph.js';
|
||||
import { ShardedSnapshotBuilder } from '../../src/core/shards/ShardedSnapshotBuilder.js';
|
||||
import { ShardedSnapshot } from '../../src/core/shards/ShardedSnapshot.js';
|
||||
import { FileShardStorage } from '../../src/core/shards/FileShardStorage.js';
|
||||
import { DeltaShardBinary } from '../../src/core/shards/DeltaShardBinary.js';
|
||||
import { WaveletShardBinary } from '../../src/core/shards/WaveletShardBinary.js';
|
||||
|
||||
function buildSnapshot(bucketSize = 4) {
|
||||
const graph = new CondensedGraph();
|
||||
const nodes = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
nodes.push(graph._ensureNode(`node:${i}`));
|
||||
}
|
||||
|
||||
graph.addEdge(nodes[0], 'risk_score', nodes[1], 1.0, { value: 0.4 });
|
||||
graph.addEdge(nodes[0], 'risk_score', nodes[2], 1.0, { value: 0.9 });
|
||||
graph.addEdge(nodes[3], 'risk_score', nodes[4], 1.0, { value: 0.5 });
|
||||
graph.addEdge(nodes[1], 'risk_limit', nodes[1], 1.0, { value: 0.6 });
|
||||
graph.addEdge(nodes[2], 'risk_limit', nodes[2], 1.0, { value: 0.6 });
|
||||
graph.addEdge(nodes[4], 'risk_limit', nodes[4], 1.0, { value: 0.6 });
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sharded-delta-comp-'));
|
||||
const builder = new ShardedSnapshotBuilder({ bucketSize, includeDirections: ['out', 'in'] });
|
||||
const manifest = builder.build(graph, dir);
|
||||
const storage = new FileShardStorage(dir);
|
||||
const snapshot = new ShardedSnapshot(manifest, storage, { cacheLimit: 8 });
|
||||
snapshot.initializeSync();
|
||||
|
||||
return { snapshot, manifest, dir, nodes };
|
||||
}
|
||||
|
||||
function writeDeltaLayer(snapshot, dir, entries, mode = 'override') {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const merged = new Map();
|
||||
for (const entry of entries) {
|
||||
const shardMeta = snapshot._selectShardMeta(entry.relationId, entry.direction, entry.srcId);
|
||||
assert.ok(shardMeta, 'Missing shard meta for delta entry');
|
||||
const localSource = snapshot._localSource(entry.srcId, shardMeta);
|
||||
const key = shardMeta.cacheKey;
|
||||
let bucket = merged.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { shardMeta, additions: [], removals: [] };
|
||||
merged.set(key, bucket);
|
||||
}
|
||||
for (const add of entry.additions) {
|
||||
bucket.additions.push({
|
||||
srcLocal: localSource,
|
||||
otherId: add.dstId,
|
||||
possBits: add.possBits,
|
||||
relBits: add.relBits
|
||||
});
|
||||
}
|
||||
for (const rem of entry.removals) {
|
||||
bucket.removals.push({
|
||||
srcLocal: localSource,
|
||||
otherId: rem.dstId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const shards = [];
|
||||
for (const bucket of merged.values()) {
|
||||
const shardMeta = bucket.shardMeta;
|
||||
const buffer = DeltaShardBinary.serialize({
|
||||
relationId: shardMeta.relationId,
|
||||
direction: shardMeta.direction,
|
||||
rangeStart: shardMeta.rangeStart,
|
||||
rangeEnd: shardMeta.rangeEnd,
|
||||
nodeCount: snapshot.nodeCount,
|
||||
additions: bucket.additions,
|
||||
removals: bucket.removals
|
||||
});
|
||||
const shardKey = `delta-${shardMeta.key}`;
|
||||
fs.writeFileSync(path.join(dir, shardKey), new Uint8Array(buffer));
|
||||
shards.push({
|
||||
key: shardKey,
|
||||
relationId: shardMeta.relationId,
|
||||
direction: shardMeta.direction,
|
||||
rangeStart: shardMeta.rangeStart,
|
||||
rangeEnd: shardMeta.rangeEnd,
|
||||
cacheKey: shardMeta.cacheKey
|
||||
});
|
||||
}
|
||||
|
||||
return { shards, storage: new FileShardStorage(dir), mode };
|
||||
}
|
||||
|
||||
function compactLayer(snapshot, layer, manifest, outputDir, baseDir) {
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
if (manifest.nodeTableKey) {
|
||||
fs.copyFileSync(path.join(baseDir, manifest.nodeTableKey), path.join(outputDir, manifest.nodeTableKey));
|
||||
}
|
||||
if (manifest.componentKey) {
|
||||
fs.copyFileSync(path.join(baseDir, manifest.componentKey), path.join(outputDir, manifest.componentKey));
|
||||
}
|
||||
|
||||
for (const shard of manifest.shards || []) {
|
||||
const basePath = path.join(baseDir, shard.key);
|
||||
fs.copyFileSync(basePath, path.join(outputDir, shard.key));
|
||||
}
|
||||
|
||||
for (const shardMeta of layer.shards) {
|
||||
const base = snapshot._cacheIndex.get(shardMeta.cacheKey);
|
||||
if (!base) continue;
|
||||
const shard = snapshot._loadShardSync(base.relationId, base.direction, base.rangeStart);
|
||||
if (!shard) continue;
|
||||
|
||||
const deltaBuffer = layer.storage.getSync(shardMeta.key);
|
||||
if (!deltaBuffer) continue;
|
||||
const deltaShard = DeltaShardBinary.deserialize(deltaBuffer);
|
||||
const rangeSize = shard.rangeEnd - shard.rangeStart;
|
||||
const sources = new Array(rangeSize);
|
||||
for (let localSource = 0; localSource < rangeSize; localSource++) {
|
||||
const range = snapshot._rangeForSource(shard, localSource);
|
||||
const list = [];
|
||||
if (range) {
|
||||
for (let pos = range.start; pos < range.end; pos++) {
|
||||
list.push({ otherId: shard.dstIds[pos], possBits: shard.possBits[pos], relBits: shard.relBits[pos] });
|
||||
}
|
||||
}
|
||||
sources[localSource] = list;
|
||||
}
|
||||
|
||||
for (const removal of deltaShard.removals) {
|
||||
const list = sources[removal.srcLocal];
|
||||
if (!list) continue;
|
||||
const idx = list.findIndex((item) => item.otherId === removal.otherId);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
}
|
||||
|
||||
for (const addition of deltaShard.additions) {
|
||||
const list = sources[addition.srcLocal] || (sources[addition.srcLocal] = []);
|
||||
list.push({ otherId: addition.otherId, possBits: addition.possBits, relBits: addition.relBits });
|
||||
}
|
||||
|
||||
const buffer = WaveletShardBinary.serialize({
|
||||
relationId: shard.relationId,
|
||||
direction: shard.direction,
|
||||
rangeStart: shard.rangeStart,
|
||||
rangeEnd: shard.rangeEnd,
|
||||
nodeCount: snapshot.nodeCount,
|
||||
sources
|
||||
});
|
||||
fs.writeFileSync(path.join(outputDir, base.key), new Uint8Array(buffer));
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateRisk(snapshot, relScore, relLimit, userId, docId) {
|
||||
const scoreEdge = snapshot.findEdgeSync(userId, relScore, docId);
|
||||
if (!scoreEdge || scoreEdge.value === undefined) return false;
|
||||
const limitEdge = snapshot.findEdgeSync(docId, relLimit, docId);
|
||||
if (!limitEdge || limitEdge.value === undefined) return false;
|
||||
return scoreEdge.value <= limitEdge.value;
|
||||
}
|
||||
|
||||
// ADR-003: sharded snapshots are stubs in src/core/shards/ — tests are the spec for when the subsystem is implemented.
|
||||
describe.skip('Sharded delta comparator equivalence', () => {
|
||||
test('overlay and compacted base agree on comparator outcomes', () => {
|
||||
const { snapshot, manifest, dir, nodes } = buildSnapshot(4);
|
||||
const relIdScore = snapshot.relationIdToName.indexOf('risk_score');
|
||||
const relIdLimit = snapshot.relationIdToName.indexOf('risk_limit');
|
||||
|
||||
const deltaDir = path.join(dir, 'delta');
|
||||
const layer = writeDeltaLayer(snapshot, deltaDir, [
|
||||
{ relationId: relIdScore, direction: 'out', srcId: nodes[0], additions: [{ dstId: nodes[3], possBits: 65535, relBits: 65535 }], removals: [] },
|
||||
{ relationId: relIdLimit, direction: 'out', srcId: nodes[3], additions: [{ dstId: nodes[3], possBits: 65535, relBits: 65535 }], removals: [] }
|
||||
], 'override');
|
||||
|
||||
snapshot.setDeltaLayers([layer]);
|
||||
|
||||
const compactDir = path.join(dir, 'compact');
|
||||
compactLayer(snapshot, layer, manifest, compactDir, dir);
|
||||
const compactSnapshot = new ShardedSnapshot(manifest, new FileShardStorage(compactDir), { cacheLimit: 8 });
|
||||
compactSnapshot.initializeSync();
|
||||
|
||||
const overlayResult = evaluateRisk(snapshot, relIdScore, relIdLimit, nodes[0], nodes[3]);
|
||||
const compactResult = evaluateRisk(compactSnapshot, relIdScore, relIdLimit, nodes[0], nodes[3]);
|
||||
assert.strictEqual(overlayResult, compactResult);
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { CondensedGraph } from '../../src/core/CondensedGraph.js';
|
||||
import { ShardedSnapshotBuilder } from '../../src/core/shards/ShardedSnapshotBuilder.js';
|
||||
import { ShardedSnapshot } from '../../src/core/shards/ShardedSnapshot.js';
|
||||
import { FileShardStorage } from '../../src/core/shards/FileShardStorage.js';
|
||||
import { DeltaShardBinary } from '../../src/core/shards/DeltaShardBinary.js';
|
||||
import { WaveletShardBinary } from '../../src/core/shards/WaveletShardBinary.js';
|
||||
|
||||
function buildSnapshot(bucketSize = 4) {
|
||||
const graph = new CondensedGraph();
|
||||
const nodes = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
nodes.push(graph._ensureNode(`node:${i}`));
|
||||
}
|
||||
graph.addEdge(nodes[0], 'owner', nodes[1]);
|
||||
graph.addEdge(nodes[0], 'owner', nodes[2]);
|
||||
graph.addEdge(nodes[3], 'owner', nodes[4]);
|
||||
graph.addEdge(nodes[5], 'owner', nodes[0]);
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sharded-delta-eq-'));
|
||||
const builder = new ShardedSnapshotBuilder({ bucketSize, includeDirections: ['out', 'in'] });
|
||||
const manifest = builder.build(graph, dir);
|
||||
const storage = new FileShardStorage(dir);
|
||||
const snapshot = new ShardedSnapshot(manifest, storage, { cacheLimit: 8 });
|
||||
snapshot.initializeSync();
|
||||
return { snapshot, manifest, dir, nodes };
|
||||
}
|
||||
|
||||
function buildBaseEdges(nodes) {
|
||||
const edges = new Map();
|
||||
const add = (srcIdx, dstIdx) => {
|
||||
const srcId = nodes[srcIdx];
|
||||
const dstId = nodes[dstIdx];
|
||||
const set = edges.get(srcId) || new Set();
|
||||
set.add(dstId);
|
||||
edges.set(srcId, set);
|
||||
};
|
||||
add(0, 1);
|
||||
add(0, 2);
|
||||
add(3, 4);
|
||||
add(5, 0);
|
||||
return edges;
|
||||
}
|
||||
|
||||
function cloneEdges(edges) {
|
||||
const next = new Map();
|
||||
for (const [src, set] of edges.entries()) {
|
||||
next.set(src, new Set(set));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyOps(edges, ops, mode = 'override') {
|
||||
const next = cloneEdges(edges);
|
||||
if (mode !== 'union') {
|
||||
for (const op of ops) {
|
||||
if (op.op !== 'remove') continue;
|
||||
const set = next.get(op.srcId) || new Set();
|
||||
set.delete(op.dstId);
|
||||
if (set.size > 0) next.set(op.srcId, set);
|
||||
}
|
||||
}
|
||||
for (const op of ops) {
|
||||
if (op.op !== 'add') continue;
|
||||
const set = next.get(op.srcId) || new Set();
|
||||
set.add(op.dstId);
|
||||
if (set.size > 0) next.set(op.srcId, set);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function hasEdge(edges, srcId, dstId) {
|
||||
const set = edges.get(srcId);
|
||||
return set ? set.has(dstId) : false;
|
||||
}
|
||||
|
||||
function buildDeltaLayer(snapshot, dir, entries, mode = 'override') {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const merged = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
const shardMeta = snapshot._selectShardMeta(entry.relationId, entry.direction, entry.srcId);
|
||||
assert.ok(shardMeta, 'Missing shard meta for delta entry');
|
||||
const localSource = snapshot._localSource(entry.srcId, shardMeta);
|
||||
const key = shardMeta.cacheKey;
|
||||
let bucket = merged.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { shardMeta, additions: [], removals: [] };
|
||||
merged.set(key, bucket);
|
||||
}
|
||||
for (const add of entry.additions) {
|
||||
bucket.additions.push({
|
||||
srcLocal: localSource,
|
||||
otherId: add.dstId,
|
||||
possBits: add.possBits,
|
||||
relBits: add.relBits
|
||||
});
|
||||
}
|
||||
for (const rem of entry.removals) {
|
||||
bucket.removals.push({
|
||||
srcLocal: localSource,
|
||||
otherId: rem.dstId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const shards = [];
|
||||
for (const bucket of merged.values()) {
|
||||
const shardMeta = bucket.shardMeta;
|
||||
const buffer = DeltaShardBinary.serialize({
|
||||
relationId: shardMeta.relationId,
|
||||
direction: shardMeta.direction,
|
||||
rangeStart: shardMeta.rangeStart,
|
||||
rangeEnd: shardMeta.rangeEnd,
|
||||
nodeCount: snapshot.nodeCount,
|
||||
additions: bucket.additions,
|
||||
removals: bucket.removals
|
||||
});
|
||||
const shardKey = `delta-${shardMeta.key}`;
|
||||
fs.writeFileSync(path.join(dir, shardKey), new Uint8Array(buffer));
|
||||
shards.push({
|
||||
key: shardKey,
|
||||
relationId: shardMeta.relationId,
|
||||
direction: shardMeta.direction,
|
||||
rangeStart: shardMeta.rangeStart,
|
||||
rangeEnd: shardMeta.rangeEnd,
|
||||
cacheKey: shardMeta.cacheKey
|
||||
});
|
||||
}
|
||||
|
||||
return { shards, storage: new FileShardStorage(dir), mode };
|
||||
}
|
||||
|
||||
function compactLayer(snapshot, layer, manifest, outputDir, baseDir) {
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
if (manifest.nodeTableKey) {
|
||||
fs.copyFileSync(path.join(baseDir, manifest.nodeTableKey), path.join(outputDir, manifest.nodeTableKey));
|
||||
}
|
||||
if (manifest.componentKey) {
|
||||
fs.copyFileSync(path.join(baseDir, manifest.componentKey), path.join(outputDir, manifest.componentKey));
|
||||
}
|
||||
|
||||
for (const shard of manifest.shards || []) {
|
||||
const basePath = path.join(baseDir, shard.key);
|
||||
fs.copyFileSync(basePath, path.join(outputDir, shard.key));
|
||||
}
|
||||
|
||||
for (const shardMeta of layer.shards) {
|
||||
const base = snapshot._cacheIndex.get(shardMeta.cacheKey);
|
||||
if (!base) continue;
|
||||
const shard = snapshot._loadShardSync(base.relationId, base.direction, base.rangeStart);
|
||||
if (!shard) continue;
|
||||
|
||||
const deltaBuffer = layer.storage.getSync(shardMeta.key);
|
||||
if (!deltaBuffer) continue;
|
||||
const deltaShard = DeltaShardBinary.deserialize(deltaBuffer);
|
||||
const rangeSize = shard.rangeEnd - shard.rangeStart;
|
||||
const sources = new Array(rangeSize);
|
||||
for (let localSource = 0; localSource < rangeSize; localSource++) {
|
||||
const range = snapshot._rangeForSource(shard, localSource);
|
||||
const list = [];
|
||||
if (range) {
|
||||
for (let pos = range.start; pos < range.end; pos++) {
|
||||
list.push({ otherId: shard.dstIds[pos], possBits: shard.possBits[pos], relBits: shard.relBits[pos] });
|
||||
}
|
||||
}
|
||||
sources[localSource] = list;
|
||||
}
|
||||
|
||||
for (const removal of deltaShard.removals) {
|
||||
const list = sources[removal.srcLocal];
|
||||
if (!list) continue;
|
||||
let idx = list.findIndex((item) => item.otherId === removal.otherId);
|
||||
while (idx !== -1) {
|
||||
list.splice(idx, 1);
|
||||
idx = list.findIndex((item) => item.otherId === removal.otherId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const addition of deltaShard.additions) {
|
||||
const list = sources[addition.srcLocal] || (sources[addition.srcLocal] = []);
|
||||
const idx = list.findIndex((item) => item.otherId === addition.otherId);
|
||||
if (idx === -1) {
|
||||
list.push({ otherId: addition.otherId, possBits: addition.possBits, relBits: addition.relBits });
|
||||
} else {
|
||||
list[idx] = { otherId: addition.otherId, possBits: addition.possBits, relBits: addition.relBits };
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = WaveletShardBinary.serialize({
|
||||
relationId: shard.relationId,
|
||||
direction: shard.direction,
|
||||
rangeStart: shard.rangeStart,
|
||||
rangeEnd: shard.rangeEnd,
|
||||
nodeCount: snapshot.nodeCount,
|
||||
sources
|
||||
});
|
||||
fs.writeFileSync(path.join(outputDir, base.key), new Uint8Array(buffer));
|
||||
}
|
||||
}
|
||||
|
||||
// ADR-003: sharded snapshots are stubs in src/core/shards/ — tests are the spec for when the subsystem is implemented.
|
||||
describe.skip('Sharded snapshot delta equivalence', () => {
|
||||
test('overlay matches compacted base for direct access', () => {
|
||||
const { snapshot, manifest, dir, nodes } = buildSnapshot(4);
|
||||
const relId = snapshot.relationIdToName.indexOf('owner');
|
||||
|
||||
const base = buildBaseEdges(nodes);
|
||||
const ops = [
|
||||
{ srcId: nodes[0], dstId: nodes[3], op: 'add' },
|
||||
{ srcId: nodes[3], dstId: nodes[4], op: 'remove' }
|
||||
];
|
||||
const expected = applyOps(base, ops, 'override');
|
||||
|
||||
const deltaDir = path.join(dir, 'delta');
|
||||
const layer = buildDeltaLayer(snapshot, deltaDir, [
|
||||
{ relationId: relId, direction: 'out', srcId: nodes[0], additions: [{ dstId: nodes[3], possBits: 65535, relBits: 65535 }], removals: [] },
|
||||
{ relationId: relId, direction: 'out', srcId: nodes[3], additions: [], removals: [{ dstId: nodes[4] }] }
|
||||
], 'override');
|
||||
|
||||
snapshot.setDeltaLayers([layer]);
|
||||
|
||||
const compactDir = path.join(dir, 'compact');
|
||||
compactLayer(snapshot, layer, manifest, compactDir, dir);
|
||||
const compactSnapshot = new ShardedSnapshot(manifest, new FileShardStorage(compactDir), { cacheLimit: 8 });
|
||||
compactSnapshot.initializeSync();
|
||||
|
||||
for (const src of nodes) {
|
||||
for (const dst of nodes) {
|
||||
const overlayEdge = snapshot.findEdgeSync(src, relId, dst);
|
||||
const compactEdge = compactSnapshot.findEdgeSync(src, relId, dst);
|
||||
const expectedEdge = hasEdge(expected, src, dst);
|
||||
assert.strictEqual(!!overlayEdge, !!compactEdge);
|
||||
assert.strictEqual(!!overlayEdge, expectedEdge);
|
||||
}
|
||||
}
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('union overlays never remove base access', () => {
|
||||
const { snapshot, dir, nodes } = buildSnapshot(4);
|
||||
const relId = snapshot.relationIdToName.indexOf('owner');
|
||||
|
||||
const base = buildBaseEdges(nodes);
|
||||
const ops = [
|
||||
{ srcId: nodes[0], dstId: nodes[1], op: 'remove' },
|
||||
{ srcId: nodes[2], dstId: nodes[4], op: 'add' }
|
||||
];
|
||||
const expected = applyOps(base, ops, 'union');
|
||||
|
||||
const deltaDir = path.join(dir, 'union');
|
||||
const layer = buildDeltaLayer(snapshot, deltaDir, [
|
||||
{ relationId: relId, direction: 'out', srcId: nodes[0], additions: [], removals: [{ dstId: nodes[1] }] },
|
||||
{ relationId: relId, direction: 'out', srcId: nodes[2], additions: [{ dstId: nodes[4], possBits: 65535, relBits: 65535 }], removals: [] }
|
||||
], 'union');
|
||||
|
||||
snapshot.setDeltaLayers([layer]);
|
||||
for (const src of nodes) {
|
||||
for (const dst of nodes) {
|
||||
const overlayEdge = snapshot.findEdgeSync(src, relId, dst);
|
||||
const expectedEdge = hasEdge(expected, src, dst);
|
||||
assert.strictEqual(!!overlayEdge, expectedEdge);
|
||||
}
|
||||
}
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { CondensedGraph } from '../../src/core/CondensedGraph.js';
|
||||
import { ShardedSnapshotBuilder } from '../../src/core/shards/ShardedSnapshotBuilder.js';
|
||||
import { ShardedSnapshot } from '../../src/core/shards/ShardedSnapshot.js';
|
||||
import { FileShardStorage } from '../../src/core/shards/FileShardStorage.js';
|
||||
import { DeltaShardBinary } from '../../src/core/shards/DeltaShardBinary.js';
|
||||
|
||||
function buildSnapshot(bucketSize = 4) {
|
||||
const graph = new CondensedGraph();
|
||||
const nodes = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
nodes.push(graph._ensureNode(`node:${i}`));
|
||||
}
|
||||
graph.addEdge(nodes[0], 'owner', nodes[1]);
|
||||
graph.addEdge(nodes[0], 'owner', nodes[2]);
|
||||
graph.addEdge(nodes[3], 'owner', nodes[4]);
|
||||
graph.addEdge(nodes[5], 'owner', nodes[0]);
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sharded-delta-layer-'));
|
||||
const builder = new ShardedSnapshotBuilder({ bucketSize, includeDirections: ['out', 'in'] });
|
||||
const manifest = builder.build(graph, dir);
|
||||
const storage = new FileShardStorage(dir);
|
||||
const snapshot = new ShardedSnapshot(manifest, storage, { cacheLimit: 8 });
|
||||
snapshot.initializeSync();
|
||||
return { snapshot, dir, nodes, manifest };
|
||||
}
|
||||
|
||||
function writeDeltaLayer(snapshot, dir, entries, mode = 'override') {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const merged = new Map();
|
||||
for (const entry of entries) {
|
||||
const shardMeta = snapshot._selectShardMeta(entry.relationId, entry.direction, entry.srcId);
|
||||
assert.ok(shardMeta, 'Missing shard meta for delta entry');
|
||||
const localSource = snapshot._localSource(entry.srcId, shardMeta);
|
||||
const key = shardMeta.cacheKey;
|
||||
let bucket = merged.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { shardMeta, additions: [], removals: [] };
|
||||
merged.set(key, bucket);
|
||||
}
|
||||
for (const add of entry.additions) {
|
||||
bucket.additions.push({
|
||||
srcLocal: localSource,
|
||||
otherId: add.dstId,
|
||||
possBits: add.possBits,
|
||||
relBits: add.relBits
|
||||
});
|
||||
}
|
||||
for (const rem of entry.removals) {
|
||||
bucket.removals.push({
|
||||
srcLocal: localSource,
|
||||
otherId: rem.dstId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const shards = [];
|
||||
for (const bucket of merged.values()) {
|
||||
const shardMeta = bucket.shardMeta;
|
||||
const buffer = DeltaShardBinary.serialize({
|
||||
relationId: shardMeta.relationId,
|
||||
direction: shardMeta.direction,
|
||||
rangeStart: shardMeta.rangeStart,
|
||||
rangeEnd: shardMeta.rangeEnd,
|
||||
nodeCount: snapshot.nodeCount,
|
||||
additions: bucket.additions,
|
||||
removals: bucket.removals
|
||||
});
|
||||
const shardKey = `delta-${shardMeta.key}`;
|
||||
fs.writeFileSync(path.join(dir, shardKey), new Uint8Array(buffer));
|
||||
shards.push({
|
||||
key: shardKey,
|
||||
relationId: shardMeta.relationId,
|
||||
direction: shardMeta.direction,
|
||||
rangeStart: shardMeta.rangeStart,
|
||||
rangeEnd: shardMeta.rangeEnd,
|
||||
cacheKey: shardMeta.cacheKey
|
||||
});
|
||||
}
|
||||
|
||||
return { shards, storage: new FileShardStorage(dir), mode };
|
||||
}
|
||||
|
||||
// ADR-003: sharded snapshots are stubs in src/core/shards/ — tests are the spec for when the subsystem is implemented.
|
||||
describe.skip('Sharded delta layering semantics', () => {
|
||||
test('later override layers win over earlier layers', () => {
|
||||
const { snapshot, dir, nodes } = buildSnapshot(4);
|
||||
const relId = snapshot.relationIdToName.indexOf('owner');
|
||||
|
||||
const layer1 = writeDeltaLayer(snapshot, path.join(dir, 'l1'), [
|
||||
{ relationId: relId, direction: 'out', srcId: nodes[0], additions: [{ dstId: nodes[3], possBits: 65535, relBits: 65535 }], removals: [] }
|
||||
], 'override');
|
||||
|
||||
const layer2 = writeDeltaLayer(snapshot, path.join(dir, 'l2'), [
|
||||
{ relationId: relId, direction: 'out', srcId: nodes[0], additions: [], removals: [{ dstId: nodes[3] }] }
|
||||
], 'override');
|
||||
|
||||
snapshot.setDeltaLayers([layer1, layer2]);
|
||||
const edge = snapshot.findEdgeSync(nodes[0], relId, nodes[3]);
|
||||
assert.equal(edge, null);
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('union overlays do not override writer removals', () => {
|
||||
const { snapshot, dir, nodes } = buildSnapshot(4);
|
||||
const relId = snapshot.relationIdToName.indexOf('owner');
|
||||
|
||||
const writerLayer = writeDeltaLayer(snapshot, path.join(dir, 'writer'), [
|
||||
{ relationId: relId, direction: 'out', srcId: nodes[0], additions: [], removals: [{ dstId: nodes[1] }] }
|
||||
], 'override');
|
||||
|
||||
const unionLayer = writeDeltaLayer(snapshot, path.join(dir, 'union'), [
|
||||
{ relationId: relId, direction: 'out', srcId: nodes[0], additions: [], removals: [{ dstId: nodes[2] }] }
|
||||
], 'union');
|
||||
|
||||
snapshot.setDeltaLayers([writerLayer, unionLayer]);
|
||||
|
||||
const removedByWriter = snapshot.findEdgeSync(nodes[0], relId, nodes[1]);
|
||||
assert.equal(removedByWriter, null);
|
||||
|
||||
const unionRemovalIgnored = snapshot.findEdgeSync(nodes[0], relId, nodes[2]);
|
||||
assert.ok(unionRemovalIgnored, 'Union overlay must not remove base access');
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { CondensedGraph } from '../../src/core/CondensedGraph.js';
|
||||
import { ShardedSnapshotBuilder } from '../../src/core/shards/ShardedSnapshotBuilder.js';
|
||||
import { ShardedSnapshot } from '../../src/core/shards/ShardedSnapshot.js';
|
||||
import { FileShardStorage } from '../../src/core/shards/FileShardStorage.js';
|
||||
import { DeltaShardBinary } from '../../src/core/shards/DeltaShardBinary.js';
|
||||
|
||||
function buildSnapshot(bucketSize = 4) {
|
||||
const graph = new CondensedGraph();
|
||||
|
||||
const user0 = graph._ensureNode('user:0');
|
||||
const user1 = graph._ensureNode('user:1');
|
||||
const doc0 = graph._ensureNode('doc:0');
|
||||
const doc1 = graph._ensureNode('doc:1');
|
||||
|
||||
graph.addEdge(user0, 'owner', doc0);
|
||||
graph.addEdge(user1, 'owner', doc0);
|
||||
|
||||
graph.finalizePerfectHash();
|
||||
graph.finalizeWaveletAdjacency({ dropAdjacencyList: true });
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sharded-delta-'));
|
||||
const builder = new ShardedSnapshotBuilder({ bucketSize, includeDirections: ['out', 'in'] });
|
||||
const manifest = builder.build(graph, dir);
|
||||
const storage = new FileShardStorage(dir);
|
||||
const snapshot = new ShardedSnapshot(manifest, storage, { cacheLimit: 8 });
|
||||
snapshot.initializeSync();
|
||||
|
||||
return { graph, snapshot, dir, ids: { user0, user1, doc0, doc1 } };
|
||||
}
|
||||
|
||||
function writeDeltaLayer(snapshot, dir, name, entries) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const merged = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
const shardMeta = snapshot._selectShardMeta(entry.relationId, entry.direction, entry.srcId);
|
||||
assert.ok(shardMeta, 'Missing shard meta for delta entry');
|
||||
const localSource = snapshot._localSource(entry.srcId, shardMeta);
|
||||
const key = shardMeta.cacheKey;
|
||||
let bucket = merged.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { shardMeta, additions: [], removals: [] };
|
||||
merged.set(key, bucket);
|
||||
}
|
||||
for (const add of entry.additions) {
|
||||
bucket.additions.push({
|
||||
srcLocal: localSource,
|
||||
otherId: add.dstId,
|
||||
possBits: add.possBits,
|
||||
relBits: add.relBits
|
||||
});
|
||||
}
|
||||
for (const rem of entry.removals) {
|
||||
bucket.removals.push({
|
||||
srcLocal: localSource,
|
||||
otherId: rem.dstId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const shards = [];
|
||||
for (const bucket of merged.values()) {
|
||||
const shardMeta = bucket.shardMeta;
|
||||
const buffer = DeltaShardBinary.serialize({
|
||||
relationId: shardMeta.relationId,
|
||||
direction: shardMeta.direction,
|
||||
rangeStart: shardMeta.rangeStart,
|
||||
rangeEnd: shardMeta.rangeEnd,
|
||||
nodeCount: snapshot.nodeCount,
|
||||
additions: bucket.additions,
|
||||
removals: bucket.removals
|
||||
});
|
||||
|
||||
const shardKey = `delta-${name}-${shardMeta.key}`;
|
||||
fs.writeFileSync(path.join(dir, shardKey), new Uint8Array(buffer));
|
||||
shards.push({
|
||||
key: shardKey,
|
||||
relationId: shardMeta.relationId,
|
||||
direction: shardMeta.direction,
|
||||
rangeStart: shardMeta.rangeStart,
|
||||
rangeEnd: shardMeta.rangeEnd,
|
||||
cacheKey: shardMeta.cacheKey
|
||||
});
|
||||
}
|
||||
|
||||
return { shards };
|
||||
}
|
||||
|
||||
// ADR-003: sharded snapshots are stubs in src/core/shards/ — tests are the spec for when the subsystem is implemented.
|
||||
describe.skip('Sharded snapshot delta overlay', () => {
|
||||
test('adds and removes edges in overlay reads', () => {
|
||||
const { graph, snapshot, dir, ids } = buildSnapshot(4);
|
||||
const relId = graph.getRelationId('owner');
|
||||
|
||||
const baseEdge = snapshot.findEdgeSync(ids.user0, relId, ids.doc0);
|
||||
assert.ok(baseEdge, 'Expected base edge');
|
||||
|
||||
const deltaDir = path.join(dir, 'delta');
|
||||
const deltaManifest = writeDeltaLayer(snapshot, deltaDir, 'l1', [
|
||||
{
|
||||
relationId: relId,
|
||||
direction: 'out',
|
||||
srcId: ids.user0,
|
||||
additions: [],
|
||||
removals: [{ dstId: ids.doc0 }]
|
||||
},
|
||||
{
|
||||
relationId: relId,
|
||||
direction: 'out',
|
||||
srcId: ids.user1,
|
||||
additions: [{ dstId: ids.doc1, possBits: 65535, relBits: 65535 }],
|
||||
removals: []
|
||||
}
|
||||
]);
|
||||
|
||||
snapshot.setDeltaLayers([{ shards: deltaManifest.shards, storage: new FileShardStorage(deltaDir) }]);
|
||||
|
||||
const removedEdge = snapshot.findEdgeSync(ids.user0, relId, ids.doc0);
|
||||
assert.equal(removedEdge, null);
|
||||
|
||||
const addedEdge = snapshot.findEdgeSync(ids.user1, relId, ids.doc1);
|
||||
assert.ok(addedEdge, 'Expected added edge');
|
||||
|
||||
const user0Edges = snapshot.getOutEdgesSync(ids.user0, relId);
|
||||
assert.equal(user0Edges.length, 0);
|
||||
|
||||
const user1Edges = snapshot.getOutEdgesSync(ids.user1, relId);
|
||||
assert.equal(user1Edges.length, 2);
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('later delta layers override earlier ones', () => {
|
||||
const { graph, snapshot, dir, ids } = buildSnapshot(4);
|
||||
const relId = graph.getRelationId('owner');
|
||||
|
||||
const layer1Dir = path.join(dir, 'delta-1');
|
||||
const layer2Dir = path.join(dir, 'delta-2');
|
||||
|
||||
const layer1 = writeDeltaLayer(snapshot, layer1Dir, 'l1', [
|
||||
{
|
||||
relationId: relId,
|
||||
direction: 'out',
|
||||
srcId: ids.user0,
|
||||
additions: [{ dstId: ids.doc1, possBits: 65535, relBits: 65535 }],
|
||||
removals: []
|
||||
}
|
||||
]);
|
||||
|
||||
const layer2 = writeDeltaLayer(snapshot, layer2Dir, 'l2', [
|
||||
{
|
||||
relationId: relId,
|
||||
direction: 'out',
|
||||
srcId: ids.user0,
|
||||
additions: [],
|
||||
removals: [{ dstId: ids.doc1 }]
|
||||
}
|
||||
]);
|
||||
|
||||
snapshot.setDeltaLayers([
|
||||
{ shards: layer1.shards, storage: new FileShardStorage(layer1Dir) },
|
||||
{ shards: layer2.shards, storage: new FileShardStorage(layer2Dir) }
|
||||
]);
|
||||
|
||||
const edge = snapshot.findEdgeSync(ids.user0, relId, ids.doc1);
|
||||
assert.equal(edge, null);
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { SuccinctGraph } from '../../src/core/succinct/SuccinctGraph.js';
|
||||
|
||||
describe('SuccinctGraph (Copied from graph-core)', () => {
|
||||
test('basic graph construction', () => {
|
||||
const graph = new SuccinctGraph();
|
||||
|
||||
graph.addNode('user:alice');
|
||||
graph.addNode('user:bob');
|
||||
graph.addNode('doc:report');
|
||||
|
||||
graph.addOutEdge('user:alice', 'user:bob');
|
||||
graph.addOutEdge('user:alice', 'doc:finance');
|
||||
graph.addOutEdge('user:bob', 'doc:report');
|
||||
|
||||
assert.strictEqual(graph.n, 3);
|
||||
assert.strictEqual(graph.numEdges(), 3);
|
||||
});
|
||||
|
||||
test('get outgoing edges', () => {
|
||||
const graph = new SuccinctGraph();
|
||||
|
||||
graph.addNode('user:alice');
|
||||
graph.addNode('user:bob');
|
||||
graph.addNode('doc:report');
|
||||
|
||||
graph.addOutEdge('user:alice', 'owner', 'doc:report');
|
||||
graph.addOutEdge('user:alice', 'viewer', 'doc:report');
|
||||
|
||||
const edges = graph.getOutEdges('user:alice');
|
||||
assert.strictEqual(edges.length, 2);
|
||||
});
|
||||
|
||||
test('find edge by relation', () => {
|
||||
const graph = new SuccinctGraph();
|
||||
|
||||
graph.addNode('user:alice');
|
||||
graph.addNode('doc:report');
|
||||
graph.addNode('doc:finance');
|
||||
graph.addOutEdge('user:alice', 'owner', 'doc:report');
|
||||
|
||||
const idx = graph.findEdge('user:alice', graph.getRelationId('owner'), 'doc:report');
|
||||
assert.ok(idx !== null, 'Should find owner edge');
|
||||
|
||||
const edge = graph.getEdge(idx);
|
||||
assert.strictEqual(edge.src, 'user:alice');
|
||||
assert.strictEqual(edge.dst, 'doc:report');
|
||||
});
|
||||
|
||||
test('iteration performance', () => {
|
||||
const graph = new SuccinctGraph();
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
graph.addNode(`user:${i}`);
|
||||
graph.addOutEdge(`user:${i}`, 'relation', `user:${i + 1}`);
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
graph.forEachOutEdge('user:5', (edge) => {
|
||||
count++;
|
||||
});
|
||||
|
||||
assert.strictEqual(count, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
|
||||
describe('Traversal edge cases', () => {
|
||||
test('shortestPathLength returns Infinity for missing nodes', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('a', 'node');
|
||||
assert.strictEqual(arbiter.traversal.shortestPathLength('a', 'missing'), Infinity);
|
||||
});
|
||||
|
||||
test('shortestPathLength finds a two-hop path', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('a', 'node');
|
||||
arbiter.addNode('b', 'node');
|
||||
arbiter.addNode('c', 'node');
|
||||
arbiter.addRelation('a', 'links', 'b');
|
||||
arbiter.addRelation('b', 'links', 'c');
|
||||
|
||||
assert.strictEqual(arbiter.traversal.shortestPathLength('a', 'c'), 2);
|
||||
});
|
||||
|
||||
test('walk returns empty path for unknown start', () => {
|
||||
const arbiter = new Arbiter();
|
||||
assert.deepStrictEqual(arbiter.traversal.walk('missing', 3), []);
|
||||
});
|
||||
|
||||
test('walk stops when no neighbors are present', () => {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('solo', 'node');
|
||||
const path = arbiter.traversal.walk('solo', 5);
|
||||
assert.deepStrictEqual(path, ['solo']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from 'node:test';
|
||||
import { Arbiter } from '../../src/core/Arbiter.js';
|
||||
import { OWAFusion } from '../../src/utils/OWAFusion.js';
|
||||
|
||||
function createRng(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (1664525 * state + 1013904223) >>> 0;
|
||||
return state / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
function randInt(rng, max) {
|
||||
return Math.floor(rng() * max);
|
||||
}
|
||||
|
||||
function randFloat(rng, min = 0, max = 1) {
|
||||
return min + (max - min) * rng();
|
||||
}
|
||||
|
||||
function buildComparatorArbiter() {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('resource:1', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('risk_score', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_bonus', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_noise', { type: 'direct' });
|
||||
arbiter.setRelationConfig('risk_limit', { type: 'direct' });
|
||||
|
||||
arbiter.setRelationConfig('risk_ok_owa', {
|
||||
type: 'relational_comparator',
|
||||
comparator: '<=',
|
||||
fallbackBehavior: 'deny',
|
||||
left: {
|
||||
rule: {
|
||||
union: {
|
||||
rules: [
|
||||
{ type: 'direct', relation: 'risk_score' },
|
||||
{ type: 'direct', relation: 'risk_bonus' },
|
||||
{ type: 'direct', relation: 'risk_noise' }
|
||||
],
|
||||
aggregator: 'owa',
|
||||
owaWeights: [0.5, 0.3, 0.2]
|
||||
}
|
||||
},
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_score',
|
||||
aggregator: 'owa',
|
||||
owaWeights: [0.5, 0.3, 0.2]
|
||||
},
|
||||
right: {
|
||||
rule: { type: 'direct', relation: 'risk_limit' },
|
||||
extractValue: true,
|
||||
valueRelation: 'risk_limit',
|
||||
evaluateFrom: 'object'
|
||||
}
|
||||
});
|
||||
|
||||
arbiter.registerDependencyIndex(new Map([
|
||||
['risk_score', {
|
||||
all: new Set(['risk_ok_owa']),
|
||||
byLevel: {
|
||||
never: new Set(),
|
||||
always: new Set(),
|
||||
requires: new Set(),
|
||||
when: new Set(),
|
||||
unless: new Set(),
|
||||
ordinary: new Set(['risk_ok_owa'])
|
||||
}
|
||||
}],
|
||||
['risk_bonus', {
|
||||
all: new Set(['risk_ok_owa']),
|
||||
byLevel: {
|
||||
never: new Set(),
|
||||
always: new Set(),
|
||||
requires: new Set(),
|
||||
when: new Set(),
|
||||
unless: new Set(),
|
||||
ordinary: new Set(['risk_ok_owa'])
|
||||
}
|
||||
}],
|
||||
['risk_noise', {
|
||||
all: new Set(['risk_ok_owa']),
|
||||
byLevel: {
|
||||
never: new Set(),
|
||||
always: new Set(),
|
||||
requires: new Set(),
|
||||
when: new Set(),
|
||||
unless: new Set(),
|
||||
ordinary: new Set(['risk_ok_owa'])
|
||||
}
|
||||
}],
|
||||
['risk_limit', {
|
||||
all: new Set(['risk_ok_owa']),
|
||||
byLevel: {
|
||||
never: new Set(),
|
||||
always: new Set(),
|
||||
requires: new Set(),
|
||||
when: new Set(),
|
||||
unless: new Set(),
|
||||
ordinary: new Set(['risk_ok_owa'])
|
||||
}
|
||||
}]
|
||||
]));
|
||||
|
||||
return arbiter;
|
||||
}
|
||||
|
||||
describe('Comparator aggregation properties', () => {
|
||||
test('OWA aggregated value drives comparator decision', () => {
|
||||
const rng = createRng(24);
|
||||
const arbiter = buildComparatorArbiter();
|
||||
const metas = [{}, {}, {}];
|
||||
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const values = [
|
||||
randFloat(rng, 0, 100),
|
||||
randFloat(rng, 0, 100),
|
||||
randFloat(rng, 0, 100)
|
||||
];
|
||||
const limit = randFloat(rng, 0, 100);
|
||||
|
||||
arbiter.removeRelation('user:1', 'risk_score', 'resource:1');
|
||||
arbiter.removeRelation('user:1', 'risk_bonus', 'resource:1');
|
||||
arbiter.removeRelation('user:1', 'risk_noise', 'resource:1');
|
||||
arbiter.removeRelation('resource:1', 'risk_limit', 'resource:1');
|
||||
|
||||
arbiter.addRelation('user:1', 'risk_score', 'resource:1', 1.0, { value: values[0] });
|
||||
arbiter.addRelation('user:1', 'risk_bonus', 'resource:1', 1.0, { value: values[1] });
|
||||
arbiter.addRelation('user:1', 'risk_noise', 'resource:1', 1.0, { value: values[2] });
|
||||
arbiter.addRelation('resource:1', 'risk_limit', 'resource:1', 1.0, { value: limit });
|
||||
|
||||
const fused = OWAFusion.fuseWithMeta(values, metas, [0.5, 0.3, 0.2], 'owa', true).value;
|
||||
const expectedAllow = fused <= limit;
|
||||
|
||||
const result = arbiter.check('user:1', 'risk_ok_owa', 'resource:1', { fastPath: false });
|
||||
const allow = result.possibility > 0;
|
||||
assert.strictEqual(allow, expectedAllow, 'comparator respects aggregation');
|
||||
}
|
||||
});
|
||||
|
||||
test('increasing a component value does not decrease fused value', () => {
|
||||
const rng = createRng(88);
|
||||
const arbiter = buildComparatorArbiter();
|
||||
const metas = [{}, {}, {}];
|
||||
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const values = [
|
||||
randFloat(rng, 0, 100),
|
||||
randFloat(rng, 0, 100),
|
||||
randFloat(rng, 0, 100)
|
||||
];
|
||||
const index = randInt(rng, values.length);
|
||||
const delta = randFloat(rng, 0, 20);
|
||||
const bumped = values.slice();
|
||||
bumped[index] += delta;
|
||||
|
||||
const base = OWAFusion.fuseWithMeta(values, metas, [0.5, 0.3, 0.2], 'owa', true).value;
|
||||
const higher = OWAFusion.fuseWithMeta(bumped, metas, [0.5, 0.3, 0.2], 'owa', true).value;
|
||||
assert.ok(higher >= base - 1e-6, 'fused value is monotonic');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user