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:
John Dvorak
2026-07-31 13:44:06 -07:00
commit 717ae1031e
373 changed files with 654131 additions and 0 deletions
+124
View File
@@ -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');
});
});
+107
View File
@@ -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');
});
});
+238
View File
@@ -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/);
});
});
+121
View File
@@ -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);
});
});
+76
View File
@@ -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);
});
});
+134
View File
@@ -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');
});
});
+79
View File
@@ -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)}`
);
});
});
});
+325
View File
@@ -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)');
});
});
+123
View File
@@ -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);
});
});
+73
View File
@@ -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 }
);
});
});
+88
View File
@@ -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 }
);
});
});
+28
View File
@@ -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 }
);
});
});
+89
View File
@@ -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);
});
});
+46
View File
@@ -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);
});
});
+162
View File
@@ -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`);
});
});
+66
View File
@@ -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);
});
});
+31
View File
@@ -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);
});
});
+214
View File
@@ -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);
});
});
+218
View File
@@ -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);
});
});
+114
View File
@@ -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 });
});
});
+134
View File
@@ -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 });
});
});
+175
View File
@@ -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 });
});
});
+66
View File
@@ -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);
});
});
+34
View File
@@ -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');
}
});
});