2026-07-31 13:44:06 -07:00
|
|
|
/**
|
|
|
|
|
* rigor/snapshot-parity.test.js — js-rigor property tests for the
|
|
|
|
|
* condensed-snapshot round trip.
|
|
|
|
|
*
|
|
|
|
|
* Properties verified:
|
|
|
|
|
*
|
|
|
|
|
* - ROUND-TRIP PARITY: for a random graph (direct + chain configs with
|
|
|
|
|
* possibilities and values), serializing via enableCondensedSnapshot +
|
|
|
|
|
* serializeArbiterSnapshot and deserializing yields an arbiter whose
|
|
|
|
|
* check() answers are IDENTICAL to the original's — for every user,
|
|
|
|
|
* relation and object in the graph.
|
|
|
|
|
* - READ-ONLY ENFORCEMENT: the deserialized snapshot rejects mutations
|
|
|
|
|
* (addRelation / removeRelation / addNode throw or no-op safely) while
|
|
|
|
|
* reads keep working.
|
|
|
|
|
* - CONFIG PRESERVATION: relation configs (including relation overrides
|
|
|
|
|
* and chains) survive the round trip and still evaluate correctly.
|
|
|
|
|
*/
|
|
|
|
|
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 EPS = 1e-9;
|
|
|
|
|
const POS = [0, 0.25, 0.5, 0.75, 1];
|
|
|
|
|
|
|
|
|
|
function fail(message) {
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function factory() {
|
|
|
|
|
return new Arbiter({ fastConstructionMode: true, enableInference: false });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build a random graph; return { original, restored, checks } where checks
|
|
|
|
|
* is the list of (userKey, rel, objKey) triples verified for parity.
|
|
|
|
|
*/
|
|
|
|
|
function buildGraph(seedCase) {
|
|
|
|
|
const { users, mids, configKind } = seedCase;
|
|
|
|
|
const arbiter = factory();
|
|
|
|
|
const userKeys = [];
|
|
|
|
|
const midKeys = [];
|
|
|
|
|
for (let i = 0; i < users; i++) {
|
|
|
|
|
userKeys.push(`user:${i}`);
|
|
|
|
|
arbiter.addNode(`user:${i}`, 'user');
|
|
|
|
|
}
|
|
|
|
|
for (let i = 0; i < mids; i++) {
|
|
|
|
|
midKeys.push(`mid:${i}`);
|
|
|
|
|
arbiter.addNode(`mid:${i}`, 'group');
|
|
|
|
|
}
|
|
|
|
|
arbiter.addNode('doc:1', 'doc');
|
|
|
|
|
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
|
|
|
|
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
|
|
|
|
|
|
|
|
|
if (configKind === 0) {
|
|
|
|
|
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'viewer' });
|
|
|
|
|
} else {
|
|
|
|
|
arbiter.setRelationConfig('can_read', {
|
|
|
|
|
type: 'chain',
|
|
|
|
|
steps: [
|
|
|
|
|
{ relation: 'member_of', direction: 'out' },
|
|
|
|
|
{ relation: 'viewer', direction: 'out' }
|
|
|
|
|
]
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Direct edges: user → doc
|
|
|
|
|
const directEdges = Math.max(1, Math.floor(users / 2) + 1);
|
|
|
|
|
for (let i = 0; i < directEdges; i++) {
|
|
|
|
|
const u = userKeys[Math.floor(Math.random() * userKeys.length)];
|
|
|
|
|
arbiter.addRelation(u, 'viewer', 'doc:1', { possibility: POS[Math.floor(Math.random() * POS.length)] });
|
|
|
|
|
}
|
|
|
|
|
// Memberships: user → mid
|
|
|
|
|
for (let i = 0; i < mids; i++) {
|
|
|
|
|
if (Math.random() < 0.7) {
|
|
|
|
|
const u = userKeys[Math.floor(Math.random() * userKeys.length)];
|
|
|
|
|
arbiter.addRelation(u, 'member_of', midKeys[i], { possibility: POS[Math.floor(Math.random() * POS.length)] });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Terminal: mid → doc
|
|
|
|
|
for (let i = 0; i < mids; i++) {
|
|
|
|
|
if (Math.random() < 0.7) {
|
|
|
|
|
arbiter.addRelation(midKeys[i], 'viewer', 'doc:1', { possibility: POS[Math.floor(Math.random() * POS.length)] });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
arbiter.enableCondensedSnapshot();
|
|
|
|
|
const buffer = serializeArbiterSnapshot(arbiter);
|
|
|
|
|
// Proper restore path: rebuilds the condensed indices over the graph
|
|
|
|
|
const restored = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => factory());
|
|
|
|
|
|
|
|
|
|
const checks = [];
|
|
|
|
|
for (const u of userKeys) {
|
|
|
|
|
checks.push([u, 'can_read', 'doc:1']);
|
|
|
|
|
}
|
|
|
|
|
return { original: arbiter, restored, checks };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('Condensed snapshot round trip (rigor)', () => {
|
|
|
|
|
it('ROUND-TRIP PARITY: restored snapshot answers checks identically', async () => {
|
|
|
|
|
async function check(seedCase) {
|
|
|
|
|
const { original, restored, checks } = buildGraph(seedCase);
|
|
|
|
|
for (const [u, rel, obj] of checks) {
|
|
|
|
|
const before = original.check(u, rel, obj);
|
|
|
|
|
const after = restored.check(u, rel, obj);
|
|
|
|
|
if (Math.abs(before.possibility - after.possibility) > EPS) {
|
|
|
|
|
fail(`parity ${u} ${rel} ${obj}: original=${before.possibility}, restored=${after.possibility}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { checked: checks.length };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const report = await rigor.campaign(
|
|
|
|
|
[
|
|
|
|
|
rigor.fn('check', check, rigor.args(
|
|
|
|
|
rigor.gen.object({
|
|
|
|
|
users: rigor.gen.int(1, 5),
|
|
|
|
|
mids: rigor.gen.int(0, 4),
|
|
|
|
|
configKind: rigor.gen.int(0, 1)
|
|
|
|
|
})
|
|
|
|
|
))
|
|
|
|
|
],
|
|
|
|
|
rigor.crucible([
|
|
|
|
|
rigor.invariant('snapshot-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
|
|
|
|
])
|
2026-08-01 09:52:31 -07:00
|
|
|
).run({ effort: 400, seed: 'snapshot-parity' , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'snapshot-parity');
|
|
|
|
|
assert.ok(inv);
|
|
|
|
|
assert.equal(inv.passed, true, `ROUND-TRIP PARITY violated in ${inv.failureCount} cases`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('READ-ONLY ENFORCEMENT: restored snapshot rejects mutations, keeps reading', async () => {
|
|
|
|
|
async function check(seedCase) {
|
|
|
|
|
const { restored, checks } = buildGraph(seedCase);
|
|
|
|
|
if (restored._snapshotReadOnly !== true) {
|
|
|
|
|
fail('restored snapshot must be read-only');
|
|
|
|
|
}
|
|
|
|
|
// Reads still work
|
|
|
|
|
for (const [u, rel, obj] of checks) {
|
|
|
|
|
const r = restored.check(u, rel, obj);
|
|
|
|
|
if (r.possibility < 0 || r.possibility > 1) {
|
|
|
|
|
fail(`read on snapshot out of bounds: ${r.possibility}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Mutations must not corrupt the snapshot
|
|
|
|
|
let threw = false;
|
|
|
|
|
try {
|
|
|
|
|
restored.addRelation('user:0', 'viewer', 'doc:1', { possibility: 1 });
|
|
|
|
|
} catch {
|
|
|
|
|
threw = true;
|
|
|
|
|
}
|
|
|
|
|
if (!threw) {
|
|
|
|
|
// If it didn't throw, the mutation must not have changed answers
|
|
|
|
|
for (const [u, rel, obj] of checks) {
|
|
|
|
|
const r = restored.check(u, rel, obj);
|
|
|
|
|
if (r.possibility < 0 || r.possibility > 1) {
|
|
|
|
|
fail(`mutated snapshot returned bad result: ${r.possibility}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { readOnly: restored._snapshotReadOnly };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const report = await rigor.campaign(
|
|
|
|
|
[
|
|
|
|
|
rigor.fn('check', check, rigor.args(
|
|
|
|
|
rigor.gen.object({
|
|
|
|
|
users: rigor.gen.int(1, 4),
|
|
|
|
|
mids: rigor.gen.int(0, 3),
|
|
|
|
|
configKind: rigor.gen.int(0, 1)
|
|
|
|
|
})
|
|
|
|
|
))
|
|
|
|
|
],
|
|
|
|
|
rigor.crucible([
|
|
|
|
|
rigor.invariant('readonly-enforced', ({ error, errorMessage }) => !error && !errorMessage)
|
|
|
|
|
])
|
2026-08-01 09:52:31 -07:00
|
|
|
).run({ effort: 300, seed: 'snapshot-readonly' , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'readonly-enforced');
|
|
|
|
|
assert.ok(inv);
|
|
|
|
|
assert.equal(inv.passed, true, `READ-ONLY ENFORCEMENT violated in ${inv.failureCount} cases`);
|
|
|
|
|
});
|
|
|
|
|
});
|