4fd4e20bd0
Systemic reliability gap found by the probe sweep: the compiled evaluation paths never emitted the reliability the engine computes. - Compiled _evaluateDirect omitted the relation's reliability, and the chain/multi_hop rules hardcoded reliability: 1.0 — so check() results reported 1.0 for any rule whose decision came through a chain, multi_hop, union, intersection, exclusion, or defeasible combination. - The chain and multi_hop traversals now track per-path reliability (product of edge reliabilities) and report the winning path's value; the compiled and fallback logical operators (union/intersection/exclusion, direct_list fast path, early exits) report the selected child's reliability (max/min child or OWA trace index; exclusion multiplies both legs), and normal-mode defeasible combines base x requires x defeater reliabilities. - The checker's logical fast path dropped collectedValues from union/ intersection/exclusion results; it now passes them through. - MultiHopRule.valueManager was read off relationManager where the real arbiter keeps it on the arbiter — collectValues: true on a multi_hop rule with a value-carrying edge crashed the evaluation (error result, silent denial). Now resolved at the arbiter level with a relationManager fallback for stubs. Campaign pins: reliability per kind (chain/multi_hop product, union/intersection selected child, exclusion/defeasible product), and multi_hop value collection through persistent and partial contexts.
190 lines
7.9 KiB
JavaScript
190 lines
7.9 KiB
JavaScript
/**
|
|
* 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' , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
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`);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|