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
+814
View File
@@ -0,0 +1,814 @@
import { Arbiter } from '../src/core/Arbiter.js';
import { OWAFusion } from '../src/utils/OWAFusion.js';
function parseArgs(argv) {
const args = new Map();
for (let i = 2; i < argv.length; i++) {
const value = argv[i];
if (!value.startsWith('--')) continue;
if (value.startsWith('--no-')) {
const key = value.slice(5);
args.set(key, false);
continue;
}
const [key, inline] = value.slice(2).split('=');
if (inline !== undefined) {
args.set(key, inline);
continue;
}
const next = argv[i + 1];
if (next && !next.startsWith('--')) {
args.set(key, next);
i++;
} else {
args.set(key, true);
}
}
return args;
}
function createRng(seed) {
let state = seed >>> 0;
return () => {
state = (1664525 * state + 1013904223) >>> 0;
return state / 0x100000000;
};
}
function parseSizes(value) {
if (!value) return null;
return value.split(',').map(item => Number(item.trim())).filter(Number.isFinite);
}
function percentile(sorted, p) {
if (sorted.length === 0) return 0;
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
return sorted[index];
}
function randInt(rng, max) {
return Math.floor(rng() * max);
}
function randRange(rng, min, max) {
return min + Math.floor(rng() * (max - min));
}
function buildUsers(arbiter, count, prefix) {
const ids = [];
for (let i = 0; i < count; i++) {
ids.push(arbiter.addNode(`${prefix}:${i}`, prefix));
}
return ids;
}
function setupOwaUnionScenario(arbiter, size, rng, relationName, unionConfig) {
buildUsers(arbiter, size, 'user');
buildUsers(arbiter, size, 'resource');
const trueIds = [];
const falseIds = [];
for (let i = 0; i < size; i++) {
if (i % 3 === 0) {
arbiter.addRelation(`user:${i}`, 'viewer', `resource:${i}`, 0.6);
}
if (i % 5 === 0) {
arbiter.addRelation(`user:${i}`, 'owner', `resource:${i}`, 0.9);
}
if (i % 3 === 0 || i % 5 === 0) {
trueIds.push(i);
} else {
falseIds.push(i);
}
}
arbiter.setRelationConfig('viewer', { type: 'direct' });
arbiter.setRelationConfig('owner', { type: 'direct' });
arbiter.setRelationConfig(relationName, {
union: {
rules: [
{ type: 'direct', relation: 'viewer' },
{ type: 'direct', relation: 'owner' }
],
...unionConfig
}
});
const queries = createQueries(
Math.min(2000, size),
() => {
const userId = trueIds.length > 0 ? trueIds[randInt(rng, trueIds.length)] : 0;
return { user: `user:${userId}`, object: `resource:${userId}` };
},
() => {
const userId = falseIds.length > 0 ? falseIds[randInt(rng, falseIds.length)] : 0;
return { user: `user:${userId}`, object: `resource:${userId}` };
}
);
return { queries: shuffleWithRng(queries, rng) };
}
function createQueries(count, pickTrue, pickFalse) {
const queries = [];
const half = Math.floor(count / 2);
for (let i = 0; i < half; i++) {
queries.push({ ...pickTrue(), expected: true });
}
for (let i = 0; i < count - half; i++) {
queries.push({ ...pickFalse(), expected: false });
}
return queries;
}
function createChallengeQueries(count, pickTrue, pickFalse) {
const queries = [];
const half = Math.floor(count / 2);
for (let i = 0; i < half; i++) {
const entry = pickTrue();
queries.push({ ...entry, expected: true });
}
for (let i = 0; i < count - half; i++) {
const entry = pickFalse();
queries.push({ ...entry, expected: false });
}
return queries;
}
function shuffle(items) {
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
return items;
}
function shuffleWithRng(items, rng) {
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
return items;
}
const scenarios = [
{
name: 'owner_direct',
relation: 'owner',
setup: (arbiter, size, rng) => {
buildUsers(arbiter, size, 'user');
buildUsers(arbiter, size, 'resource');
for (let i = 0; i < size; i++) {
arbiter.addRelation(`user:${i}`, 'owner', `resource:${i}`);
}
arbiter.setRelationConfig('owner', { type: 'direct' });
const queries = createQueries(
Math.min(2000, size * 2),
() => {
const id = randInt(rng, size);
return { user: `user:${id}`, object: `resource:${id}` };
},
() => {
const id = randInt(rng, size);
return { user: `user:${id}`, object: `resource:${(id + 1) % size}` };
}
);
return { queries: shuffleWithRng(queries, rng) };
}
},
{
name: 'viewer_direct',
relation: 'viewer',
setup: (arbiter, size, rng) => {
buildUsers(arbiter, size, 'user');
buildUsers(arbiter, size, 'resource');
for (let i = 0; i < size; i++) {
arbiter.addRelation(`user:${i}`, 'viewer', `resource:${i}`);
}
arbiter.setRelationConfig('viewer', { type: 'direct' });
const queries = createQueries(
Math.min(2000, size * 2),
() => {
const id = randInt(rng, size);
return { user: `user:${id}`, object: `resource:${id}` };
},
() => {
const id = randInt(rng, size);
return { user: `user:${id}`, object: `resource:${(id + 1) % size}` };
}
);
return { queries: shuffleWithRng(queries, rng) };
}
},
{
name: 'group_tuple_to_userset',
relation: 'can_view',
setup: (arbiter, size, rng) => {
const groupCount = Math.max(1, Math.floor(size / 10));
buildUsers(arbiter, size, 'user');
buildUsers(arbiter, groupCount, 'group');
buildUsers(arbiter, groupCount, 'resource');
for (let i = 0; i < size; i++) {
const groupId = i % groupCount;
arbiter.addRelation(`user:${i}`, 'member', `group:${groupId}`);
}
for (let i = 0; i < groupCount; i++) {
arbiter.addRelation(`group:${i}`, 'group_access', `resource:${i}`);
}
arbiter.setRelationConfig('member', { type: 'direct' });
arbiter.setRelationConfig('group_access', { type: 'direct' });
arbiter.setRelationConfig('can_view', {
type: 'tuple_to_userset',
tuplesetRelation: 'group_access',
tuplesetDirection: 'in',
computedRelation: 'member'
});
const queries = createQueries(
Math.min(2000, size),
() => {
const userId = randInt(rng, size);
const groupId = userId % groupCount;
return { user: `user:${userId}`, object: `resource:${groupId}` };
},
() => {
const userId = randInt(rng, size);
const groupId = (userId + 1) % groupCount;
return { user: `user:${userId}`, object: `resource:${groupId}` };
}
);
return { queries: shuffleWithRng(queries, rng) };
}
},
{
name: 'plan_chain',
relation: 'can_use',
setup: (arbiter, size, rng) => {
const planCount = Math.max(1, Math.floor(size / 10));
buildUsers(arbiter, size, 'user');
buildUsers(arbiter, planCount, 'plan');
buildUsers(arbiter, planCount, 'resource');
for (let i = 0; i < size; i++) {
const planId = i % planCount;
arbiter.addRelation(`user:${i}`, 'has_plan', `plan:${planId}`);
}
for (let i = 0; i < planCount; i++) {
arbiter.addRelation(`plan:${i}`, 'plan_grants', `resource:${i}`);
}
arbiter.setRelationConfig('has_plan', { type: 'direct' });
arbiter.setRelationConfig('plan_grants', { type: 'direct' });
arbiter.setRelationConfig('can_use', {
type: 'chain',
steps: [
{ relation: 'has_plan', direction: 'out' },
{ relation: 'plan_grants', direction: 'out' }
],
collectValues: false
});
const queries = createQueries(
Math.min(2000, size),
() => {
const userId = randInt(rng, size);
const planId = userId % planCount;
return { user: `user:${userId}`, object: `resource:${planId}` };
},
() => {
const userId = randInt(rng, size);
const planId = (userId + 1) % planCount;
return { user: `user:${userId}`, object: `resource:${planId}` };
}
);
return { queries: shuffleWithRng(queries, rng) };
}
},
{
name: 'account_admin_chain',
relation: 'can_admin',
setup: (arbiter, size, rng) => {
const accountCount = Math.max(1, Math.floor(size / 10));
buildUsers(arbiter, size, 'user');
buildUsers(arbiter, accountCount, 'account');
buildUsers(arbiter, accountCount, 'resource');
for (let i = 0; i < size; i++) {
const accountId = i % accountCount;
arbiter.addRelation(`user:${i}`, 'admin_of', `account:${accountId}`);
}
for (let i = 0; i < accountCount; i++) {
arbiter.addRelation(`account:${i}`, 'account_owns', `resource:${i}`);
}
arbiter.setRelationConfig('admin_of', { type: 'direct' });
arbiter.setRelationConfig('account_owns', { type: 'direct' });
arbiter.setRelationConfig('can_admin', {
type: 'chain',
steps: [
{ relation: 'admin_of', direction: 'out' },
{ relation: 'account_owns', direction: 'out' }
],
collectValues: false
});
const queries = createQueries(
Math.min(2000, size),
() => {
const userId = randInt(rng, size);
const accountId = userId % accountCount;
return { user: `user:${userId}`, object: `resource:${accountId}` };
},
() => {
const userId = randInt(rng, size);
const accountId = (userId + 1) % accountCount;
return { user: `user:${userId}`, object: `resource:${accountId}` };
}
);
return { queries: shuffleWithRng(queries, rng) };
}
},
{
name: 'blocked_unless',
relation: 'can_view_blocked',
setup: (arbiter, size, rng) => {
buildUsers(arbiter, size, 'user');
buildUsers(arbiter, size, 'resource');
for (let i = 0; i < size; i++) {
arbiter.addRelation(`user:${i}`, 'viewer', `resource:${i}`);
}
for (let i = 0; i < Math.floor(size / 4); i++) {
arbiter.addRelation(`user:${i}`, 'blocked', `resource:${i}`);
}
arbiter.setRelationConfig('viewer', { type: 'direct' });
arbiter.setRelationConfig('blocked', { type: 'direct' });
arbiter.setRelationConfig('can_view_blocked', {
exclusion: [
{ type: 'direct', relation: 'viewer' },
{ type: 'direct', relation: 'blocked' }
]
});
const queries = createQueries(
Math.min(2000, size),
() => {
const blockedRange = Math.floor(size / 4);
const userId = randRange(rng, blockedRange, size);
return { user: `user:${userId}`, object: `resource:${userId}` };
},
() => {
const userId = randInt(rng, Math.floor(size / 4));
return { user: `user:${userId}`, object: `resource:${userId}` };
}
);
return { queries: shuffleWithRng(queries, rng) };
}
},
{
name: 'age_abac',
relation: 'age_ok',
setup: (arbiter, size, rng) => {
buildUsers(arbiter, size, 'user');
buildUsers(arbiter, size, 'resource');
for (let i = 0; i < size; i++) {
arbiter.addRelation(`user:${i}`, 'age', `user:${i}`, 1.0, { value: 18 + (i % 30) });
arbiter.addRelation(`resource:${i}`, 'min_age', `resource:${i}`, 1.0, { value: 18 + (i % 15) });
}
arbiter.setRelationConfig('age', { type: 'direct' });
arbiter.setRelationConfig('min_age', { type: 'direct' });
arbiter.setRelationConfig('age_ok', {
type: 'relational_comparator',
comparator: '>=',
fallbackBehavior: 'deny',
left: {
rule: { type: 'direct', relation: 'age' },
extractValue: true,
valueRelation: 'age'
},
right: {
rule: { type: 'direct', relation: 'min_age' },
extractValue: true,
valueRelation: 'min_age',
evaluateFrom: 'object'
}
});
const queries = createQueries(
Math.min(2000, size),
() => {
const userId = randInt(rng, size);
const resourceId = userId % size;
return { user: `user:${userId}`, object: `resource:${resourceId}` };
},
() => {
const userId = randInt(rng, size);
const resourceId = (userId + Math.floor(size / 2)) % size;
return { user: `user:${userId}`, object: `resource:${resourceId}` };
}
);
const withExpected = queries.map(q => {
const userId = Number(q.user.split(':')[1]);
const resourceId = Number(q.object.split(':')[1]);
const age = 18 + (userId % 30);
const minAge = 18 + (resourceId % 15);
return { ...q, expected: age >= minAge };
});
return { queries: shuffleWithRng(withExpected, rng) };
}
},
{
name: 'risk_limit',
relation: 'risk_ok',
setup: (arbiter, size, rng) => {
buildUsers(arbiter, size, 'user');
buildUsers(arbiter, size, 'resource');
for (let i = 0; i < size; i++) {
const score = i % 100;
arbiter.addRelation(`user:${i}`, 'risk_score', `resource:${i}`, 1.0, { value: score });
arbiter.addRelation(`resource:${i}`, 'risk_limit', `resource:${i}`, 1.0, { value: 50 });
}
arbiter.setRelationConfig('risk_score', { type: 'direct' });
arbiter.setRelationConfig('risk_limit', { type: 'direct' });
arbiter.setRelationConfig('risk_ok', {
type: 'relational_comparator',
comparator: '<=',
fallbackBehavior: 'deny',
left: {
rule: { type: 'direct', relation: 'risk_score' },
extractValue: true,
valueRelation: 'risk_score'
},
right: {
rule: { type: 'direct', relation: 'risk_limit' },
extractValue: true,
valueRelation: 'risk_limit',
evaluateFrom: 'object'
}
});
const queries = createQueries(
Math.min(2000, size),
() => {
const userId = randInt(rng, Math.min(size, 51));
return { user: `user:${userId}`, object: `resource:${userId}` };
},
() => {
const base = Math.min(size - 51, 49);
const userId = base + randInt(rng, Math.max(1, size - base));
return { user: `user:${userId}`, object: `resource:${userId}` };
}
);
const withExpected = queries.map(q => {
const userId = Number(q.user.split(':')[1]);
const score = userId % 100;
return { ...q, expected: score <= 50 };
});
return { queries: shuffleWithRng(withExpected, rng) };
}
},
{
name: 'feature_flag',
relation: 'can_feature',
setup: (arbiter, size, rng) => {
const flagCount = Math.max(1, Math.floor(size / 10));
buildUsers(arbiter, size, 'user');
buildUsers(arbiter, size, 'resource');
buildUsers(arbiter, flagCount, 'flag');
for (let i = 0; i < size; i++) {
const flagId = i % flagCount;
arbiter.addRelation(`user:${i}`, 'has_flag', `flag:${flagId}`);
if (i % 2 === 0) {
arbiter.addRelation(`resource:${i}`, 'feature_flag', `flag:${flagId}`);
}
}
arbiter.setRelationConfig('has_flag', { type: 'direct' });
arbiter.setRelationConfig('feature_flag', { type: 'direct' });
arbiter.setRelationConfig('can_feature', {
type: 'tuple_to_userset',
tuplesetRelation: 'feature_flag',
tuplesetDirection: 'out',
computedRelation: 'has_flag'
});
const queries = createQueries(
Math.min(2000, size),
() => {
const userId = randInt(rng, Math.floor(size / 2)) * 2;
return { user: `user:${userId}`, object: `resource:${userId}` };
},
() => {
const userId = randInt(rng, Math.floor(size / 2)) * 2 + 1;
return { user: `user:${userId}`, object: `resource:${userId}` };
}
);
return { queries: shuffleWithRng(queries, rng) };
}
}
,{
name: 'owa_union',
relation: 'can_view_owa',
setup: (arbiter, size, rng) => {
return setupOwaUnionScenario(arbiter, size, rng, 'can_view_owa', {
aggregator: 'owa',
owaWeights: [0.7, 0.3]
});
}
}
,{
name: 'owa_union_max',
relation: 'can_view_owa_max',
setup: (arbiter, size, rng) => {
return setupOwaUnionScenario(arbiter, size, rng, 'can_view_owa_max', {
aggregator: 'max'
});
}
}
,{
name: 'owa_union_min',
relation: 'can_view_owa_min',
setup: (arbiter, size, rng) => {
return setupOwaUnionScenario(arbiter, size, rng, 'can_view_owa_min', {
aggregator: 'min'
});
}
}
,{
name: 'owa_union_top2',
relation: 'can_view_owa_top2',
setup: (arbiter, size, rng) => {
return setupOwaUnionScenario(arbiter, size, rng, 'can_view_owa_top2', {
aggregator: 'top2'
});
}
}
,{
name: 'owa_union_median',
relation: 'can_view_owa_median',
setup: (arbiter, size, rng) => {
return setupOwaUnionScenario(arbiter, size, rng, 'can_view_owa_median', {
aggregator: 'median'
});
}
}
,{
name: 'owa_comparator_nested',
relation: 'risk_ok_owa',
setup: (arbiter, size, rng) => {
buildUsers(arbiter, size, 'user');
buildUsers(arbiter, size, 'resource');
const trueIds = [];
const falseIds = [];
for (let i = 0; i < size; i++) {
const isSafe = i % 2 === 0;
const score = isSafe ? 20 : 80;
const bonus = isSafe ? 5 : 15;
const noise = isSafe ? 0 : 10;
arbiter.addRelation(`user:${i}`, 'risk_score', `resource:${i}`, 1.0, { value: score });
arbiter.addRelation(`user:${i}`, 'risk_bonus', `resource:${i}`, 1.0, { value: bonus });
arbiter.addRelation(`user:${i}`, 'risk_noise', `resource:${i}`, 1.0, { value: noise });
arbiter.addRelation(`resource:${i}`, 'risk_limit', `resource:${i}`, 1.0, { value: 40 });
arbiter.addRelation(`resource:${i}`, 'risk_cap', `resource:${i}`, 1.0, { value: 45 });
if (isSafe) {
trueIds.push(i);
} else {
falseIds.push(i);
}
}
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 computeExpected = (userId, resourceId) => {
const valuesLeft = [
userId % 2 === 0 ? 20 : 80,
userId % 2 === 0 ? 5 : 15,
userId % 2 === 0 ? 0 : 10
];
const leftIntervals = valuesLeft.map(value => ({ min: value, max: value }));
const leftMeta = valuesLeft.map(() => ({ ruleType: 'defeasible', rule: {} }));
const leftFused = OWAFusion.fuseIntervalsWithMeta(leftIntervals, leftMeta, [0.5, 0.3, 0.2], 'owa');
const rightValues = [40, 45];
const rightIntervals = rightValues.map(value => ({ min: value, max: value }));
const rightMeta = rightValues.map(() => ({ ruleType: 'defeasible', rule: {} }));
const rightFused = OWAFusion.fuseIntervalsWithMeta(rightIntervals, rightMeta, [0.6, 0.4], 'owa');
return leftFused.interval.min <= rightFused.interval.min;
};
const pickWithExpectation = (expected) => {
const pool = expected ? trueIds : falseIds;
if (pool.length === 0) {
const userId = randInt(rng, size);
return { user: `user:${userId}`, object: `resource:${userId}` };
}
const userId = pool[randInt(rng, pool.length)];
return { user: `user:${userId}`, object: `resource:${userId}` };
};
const queries = createQueries(
Math.min(2000, size),
() => pickWithExpectation(true),
() => pickWithExpectation(false)
);
return { queries: shuffleWithRng(queries, rng) };
}
},
{
name: 'challenge_mfa_within',
relation: 'secure_action',
setup: (arbiter, size, rng) => {
buildUsers(arbiter, size, 'user');
buildUsers(arbiter, size, 'resource');
arbiter.setRelationConfig('secure_action', {
type: 'challenge',
challenge: 'mfa',
subject: 'user',
withinMs: 5 * 60 * 1000
});
const now = Date.now();
const queries = createChallengeQueries(
Math.min(2000, size * 2),
() => {
const id = randInt(rng, size);
return {
user: `user:${id}`,
object: `resource:${id}`,
options: {
partialGraph: {
challenges: [
{ name: 'mfa', subject: `user:${id}`, issuedAt: now }
]
}
}
};
},
() => {
const id = randInt(rng, size);
return {
user: `user:${id}`,
object: `resource:${id}`,
options: {
partialGraph: {
challenges: [
{ name: 'mfa', subject: `user:${id}`, issuedAt: now - 10 * 60 * 1000 }
]
}
}
};
}
);
return { queries: shuffleWithRng(queries, rng) };
}
},
{
name: 'challenge_composed_union',
relation: 'can_delete',
setup: (arbiter, size, rng) => {
buildUsers(arbiter, size, 'user');
buildUsers(arbiter, size, 'resource');
arbiter.setRelationConfig('secure_connection', {
union: {
rules: [
{ type: 'challenge', challenge: 'mfa', subject: 'user', withinMs: 5 * 60 * 1000 },
{ type: 'challenge', challenge: 'webauthn', subject: 'user', withinMs: 5 * 60 * 1000 }
]
}
});
arbiter.setRelationConfig('can_delete', { type: 'computed', relation: 'secure_connection' });
const now = Date.now();
const queries = createChallengeQueries(
Math.min(2000, size * 2),
() => {
const id = randInt(rng, size);
return {
user: `user:${id}`,
object: `resource:${id}`,
options: {
partialGraph: {
challenges: [
{ name: 'mfa', subject: `user:${id}`, issuedAt: now }
]
}
}
};
},
() => {
const id = randInt(rng, size);
return {
user: `user:${id}`,
object: `resource:${id}`,
options: {
partialGraph: {
challenges: []
}
}
};
}
);
return { queries: shuffleWithRng(queries, rng) };
}
}
];
const args = parseArgs(process.argv);
const config = {
sizes: parseSizes(args.get('sizes')) || [1000, 5000, 10000],
samples: Number(args.get('samples') || 2000),
seed: Number(args.get('seed') || 42),
maxSeconds: Number(args.get('max-seconds') || 60),
verify: args.has('verify'),
snapshot: args.has('snapshot')
};
const rng = createRng(config.seed);
const startTime = Date.now();
console.log('b2c_auth_bench');
console.log('scenario,nodes,edges,build_ms,auth_ms,auth_qps,precision,recall,f1,p95_ms,p99_ms');
for (const scenario of scenarios) {
for (const size of config.sizes) {
if ((Date.now() - startTime) / 1000 > config.maxSeconds) break;
const arbiter = new Arbiter();
const buildStart = Date.now();
const { queries } = scenario.setup(arbiter, size, rng);
let buildMs = Date.now() - buildStart;
if (config.snapshot) {
const snapshotStart = Date.now();
arbiter.enableCondensedSnapshot();
buildMs += Date.now() - snapshotStart;
}
const actualQueries = queries.slice(0, config.samples);
let tp = 0;
let tn = 0;
let fp = 0;
let fn = 0;
const latencies = [];
const authStart = process.hrtime.bigint();
for (const query of actualQueries) {
const start = process.hrtime.bigint();
const options = query.options ? { fastPath: true, ...query.options } : { fastPath: true };
const result = arbiter.check(query.user, scenario.relation, query.object, options);
const predicted = result && result.possibility > 0;
if (predicted && query.expected) tp++;
else if (!predicted && !query.expected) tn++;
else if (predicted && !query.expected) fp++;
else if (!predicted && query.expected) fn++;
latencies.push(Number(process.hrtime.bigint() - start) / 1e6);
}
const authMs = Number(process.hrtime.bigint() - authStart) / 1e6;
const authQps = authMs > 0 ? Math.round((actualQueries.length / authMs) * 1000) : 0;
const precision = tp + fp > 0 ? tp / (tp + fp) : 0;
const recall = tp + fn > 0 ? tp / (tp + fn) : 0;
const f1 = precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : 0;
const sorted = latencies.sort((a, b) => a - b);
const p95 = percentile(sorted, 95).toFixed(3);
const p99 = percentile(sorted, 99).toFixed(3);
console.log([
scenario.name,
size,
arbiter.relations.length,
buildMs,
Math.round(authMs),
authQps,
precision.toFixed(3),
recall.toFixed(3),
f1.toFixed(3),
p95,
p99
].join(','));
if (config.verify && (fp + fn) > 0) {
throw new Error(`Verification failed for ${scenario.name} size ${size}: fp=${fp} fn=${fn}`);
}
}
}