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
@@ -0,0 +1,189 @@
/**
* rigor/snapshot-quantization-parity.test.js — condensed-snapshot round-trip
* parity for non-dyadic possibility values.
*
* The condensed snapshot encodes each edge possibility as a 16-bit uniform
* quantizer on the 65535 scale (_floatToBits = round(p * 65535),
* _bitsToFloat = bits / 65535). Contract:
* - endpoints 0 and 1 are exact; every other value round-trips with
* absolute error <= 0.5 / 65535 (~7.63e-6).
* - restored values never leave [0, 1].
* - binary-mode decisions can only flip when the live value sits inside
* the quantization band of the threshold; outside the band decisions
* must agree exactly.
* - restoring the same buffer twice is deterministic; snapshot-of-snapshot
* (serialize a restored arbiter, restore again) preserves values.
* - the graph binary round-trips self-consistently (toBinary(restored)
* is byte-stable across generations).
*
* The oracle is the live arbiter's own pre-enable check results.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';
import { ArbiterSnapshot } from '../../src/core/arbiter/ArbiterSnapshot.js';
import { serializeArbiterSnapshot } from '../../src/core/SnapshotBinary.js';
const QUANT_STEP = 0.5 / 65535; // max absolute quantization error
const TOL = QUANT_STEP + 1e-9;
const NONDYADIC = [0.1, 0.3, 0.7, 0.9, 0.111, 0.333, 0.999, 0.001, 0.8999999, 0.5000001];
function nodeKey(id) {
if (id < 2) return `u:${id}`;
if (id < 3) return 'g:0';
return `doc:${id - 3}`;
}
function buildGraph(edges, values) {
const arb = new Arbiter();
for (let i = 0; i < 6; i++) arb.addNode(nodeKey(i), i < 2 ? 'user' : i === 2 ? 'group' : 'doc');
arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
arb.setRelationConfig('can_access', {
type: 'chain',
steps: [
{ relation: 'member_of', direction: 'out' },
{ relation: 'reads', direction: 'out' }
]
});
for (let i = 0; i < edges.length; i++) {
arb.addRelation(nodeKey(edges[i][0]), edges[i][1], nodeKey(edges[i][2]), { possibility: values[i] });
}
return arb;
}
const QUERIES = [];
for (const u of [0, 1]) {
for (const d of [3, 4, 5]) {
QUERIES.push(['can_read', u, d]);
QUERIES.push(['can_access', u, d]);
}
}
function roundTripReport(edges, values) {
const live = buildGraph(edges, values);
const before = QUERIES.map(([rel, u, d]) => live.check(nodeKey(u), rel, nodeKey(d)).possibility);
live.enableCondensedSnapshot();
const buffer = serializeArbiterSnapshot(live);
const r1 = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => new Arbiter());
const after1 = QUERIES.map(([rel, u, d]) => r1.check(nodeKey(u), rel, nodeKey(d)).possibility);
const r1b = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => new Arbiter());
const after1b = QUERIES.map(([rel, u, d]) => r1b.check(nodeKey(u), rel, nodeKey(d)).possibility);
const buffer2 = serializeArbiterSnapshot(r1);
const r2 = ArbiterSnapshot.fromSnapshotBinary(buffer2, {}, () => new Arbiter());
const after2 = QUERIES.map(([rel, u, d]) => r2.check(nodeKey(u), rel, nodeKey(d)).possibility);
return { before, after1, after1b, after2, graphBytes: live.snapshotGraph.toBinary().byteLength };
}
describe('Condensed snapshot quantization parity (rigor)', () => {
it('FIXED VALUES: quantization error band, bounds, determinism, snapshot-of-snapshot', () => {
const edges = [
[0, 'owner', 3],
[1, 'owner', 4],
[0, 'member_of', 2],
[2, 'reads', 3],
[1, 'member_of', 2],
[2, 'reads', 4],
[0, 'reads', 5],
[1, 'owner', 5]
];
const values = [0.9, 0.1, 0.7, 0.333, 0.999, 0.111, 0.5000001, 0.001];
const r = roundTripReport(edges, values);
for (let i = 0; i < r.before.length; i++) {
const live = r.before[i];
assert.ok(
Math.abs(r.after1[i] - live) <= TOL,
`query ${i}: live=${live} restored=${r.after1[i]} exceeds tolerance ${TOL}`
);
assert.ok(r.after1[i] >= 0 && r.after1[i] <= 1, `restored value ${r.after1[i]} outside [0,1]`);
}
assert.deepEqual(r.after1, r.after1b, 'restoring the same buffer is deterministic');
assert.deepEqual(r.after1, r.after2, 'snapshot-of-snapshot preserves values');
const g1 = buildGraph(edges, values);
g1.enableCondensedSnapshot();
const b1 = g1.snapshotGraph.toBinary();
const g2 = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(g1), {}, () => new Arbiter());
const b2 = g2.snapshotGraph.toBinary();
const g3 = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(g2), {}, () => new Arbiter());
const b3 = g3.snapshotGraph.toBinary();
assert.equal(b2.byteLength, b3.byteLength, 'reserialized graph binary is byte-stable');
});
it('PROPERTY CAMPAIGN: random graphs keep parity under quantization and threshold decisions', async () => {
const edgeSet = rigor.gen.oneOf([
[[0, 'owner', 3]],
[[0, 'owner', 3], [1, 'owner', 4]],
[[0, 'owner', 3], [0, 'member_of', 2], [2, 'reads', 3]],
[[0, 'member_of', 2], [1, 'member_of', 2], [2, 'reads', 3], [2, 'reads', 4]],
[[0, 'owner', 3], [1, 'owner', 4], [0, 'member_of', 2], [1, 'member_of', 2], [2, 'reads', 3], [2, 'reads', 4], [0, 'reads', 5], [1, 'owner', 5]],
[[0, 'owner', 5], [2, 'reads', 5], [0, 'member_of', 2], [1, 'member_of', 2], [2, 'reads', 3]]
]);
const valuesGen = rigor.gen.array(rigor.gen.oneOf(NONDYADIC), 0, 12);
const result = await rigor.campaign(
[rigor.fn('roundtrip', (edges, values) => roundTripReport(edges, values),
rigor.args(edgeSet, valuesGen))],
rigor.crucible([
rigor.invariant('values within quantization tolerance', (ctx) => {
const { before, after1 } = ctx.actual;
for (let i = 0; i < before.length; i++) {
if (Math.abs(after1[i] - before[i]) > TOL) return false;
}
return true;
}),
rigor.invariant('restored values stay in [0,1]', (ctx) => {
const { after1 } = ctx.actual;
for (const v of after1) {
if (!(v >= 0 && v <= 1)) return false;
}
return true;
}),
rigor.invariant('deterministic restore', (ctx) => {
const { after1, after1b } = ctx.actual;
return after1.every((v, i) => Math.abs(v - after1b[i]) <= TOL);
}),
rigor.invariant('snapshot-of-snapshot preserves values', (ctx) => {
const { after1, after2 } = ctx.actual;
return after1.every((v, i) => Math.abs(v - after2[i]) <= TOL);
})
])
).run({ effort: 300, seed: 'snapshot-quantization-parity' });
const inv = result.crucibleVerdict;
assert.equal(inv.passed, true, [
`quantization parity violated in ${inv.failureCount} cases:`,
...result.failures.slice(0, 3).map((f) =>
` [${f.invariant}] args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)}`
)
].join('\n'));
});
it('THRESHOLD BAND: binary decisions only flip inside the quantization band', () => {
const thresholds = [0.2, 0.5, 0.8, 0.9];
const edges = [[0, 'owner', 3], [1, 'owner', 4]];
for (const t of thresholds) {
for (const v of NONDYADIC) {
const live = buildGraph([[0, 'owner', 3]], [v]);
const before = live.check('u:0', 'can_read', 'doc:3').possibility;
const beforeDecision = before >= t;
live.enableCondensedSnapshot();
const restored = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(live), {}, () => new Arbiter());
const after = restored.check('u:0', 'can_read', 'doc:3').possibility;
const afterDecision = after >= t;
const delta = Math.abs(before - t);
if (delta >= QUANT_STEP) {
assert.equal(afterDecision, beforeDecision,
`threshold ${t}: value ${v} (live ${before}, restored ${after}) flips outside the band`);
}
}
}
});
});