Files

187 lines
6.7 KiB
JavaScript
Raw Permalink Normal View History

/**
* 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', ({ actual }) => actual !== undefined)
])
).run({ effort: 400, seed: 'snapshot-parity' , artifacts: { dir: '', persist: 'never' }});
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', ({ actual }) => actual !== undefined)
])
).run({ effort: 300, seed: 'snapshot-readonly' , artifacts: { dir: '', persist: 'never' }});
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`);
});
});