1da6bc7842
- src/snapshot.js: compact portable wire (VGST store / VGGP graph), binary maps for params/pattern/objects (no JSON), buffer values (no JS bigint), CRC-32 guarded. - createFileStore: binary per-key files (encodeEntryBytes/decodeEntryBytes). - ValueGraph snapshot/snapshotFile/restore/loadFile; keys base64url(params-bytes). - 60 tests (node:test + rigor incl. 'flip ANY byte never decodes silently').
2018 lines
99 KiB
JavaScript
2018 lines
99 KiB
JavaScript
/**
|
||
* @arbiter/value-graph — js-rigor deep tests.
|
||
*
|
||
* Covers the same behavior as value-graph.test.js but through js-rigor's
|
||
* property / handler / model campaigns:
|
||
* - `owa` + `weightFor` (OWA, ADR-000) — pure-function properties + oracles
|
||
* - ValueGraph caching / TTL / invalidation / set / blackbox — independent
|
||
* property cases (fresh graph per call, synchronous facade)
|
||
* - the CALLBACK resolver contract — handler-based tests (sync return,
|
||
* async resolver, error, promise rejection, query duplex pull, stale push)
|
||
* - the store contract (map + disk-backed) — model-based conformance
|
||
*
|
||
* Resolver contract reminder: resolvers call cb(err, result) asynchronously or
|
||
* return synchronously; a returned Promise is rejected by the graph. The
|
||
* graph itself is promise-free; the test harness adapts the callback API.
|
||
*/
|
||
import { describe, it } from 'node:test';
|
||
import { mkdtempSync, rmSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { rigor, reducers } from '@rigor/core';
|
||
import { ValueGraph, createMapStore, createFileStore, owa, weightFor, encodeSnapshot, decodeSnapshot } from '../src/index.js';
|
||
|
||
function syncGet(vg, subject, rel, params = {}) {
|
||
let out = undefined;
|
||
let errOut = null;
|
||
vg.get(subject, rel, params, (err, result) => { errOut = err; out = result; });
|
||
if (errOut) throw errOut;
|
||
return out;
|
||
}
|
||
|
||
async function expectPass(name, actions, ...args) {
|
||
// Accepts MULTIPLE crucible arrays (they are collected into one campaign) plus
|
||
// an optional trailing config object: expectPass(name, actions, checksA, checksB, { effort }).
|
||
const checkArrays = [];
|
||
let config = {};
|
||
for (const a of args) {
|
||
if (Array.isArray(a)) checkArrays.push(a);
|
||
else if (a && typeof a === 'object') config = { ...config, ...a };
|
||
}
|
||
const report = await rigor.campaign(actions, rigor.crucible(...checkArrays))
|
||
.run({ effort: 300, seed: `vg-rigor-${name}`, ...config });
|
||
if (report.status !== 'passed') {
|
||
const detail = (report.failures || []).slice(0, 5).map((f) =>
|
||
JSON.stringify({ action: f.actionName || f.action, inv: f.name, args: f.args, msg: f.message, err: f.error && f.error.message }));
|
||
throw new Error(`rigor campaign '${name}' failed (${report.failures.length} failures).\n${report.toTAP()}\n${detail.join('\n')}`);
|
||
}
|
||
return report;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// OWA + weightFor — pure-function properties
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const intArr = rigor.gen.array(rigor.gen.int(-50, 50), 0, 12);
|
||
const twoInts = rigor.gen.array(rigor.gen.int(-20, 20), 2, 2);
|
||
const strArr = rigor.gen.array(rigor.gen.oneOf(['low', 'med', 'high']), 0, 8);
|
||
|
||
const OWA_ACTIONS = [
|
||
rigor.fn('owa_sum', (values) => owa(values, 'sum', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_avg', (values) => owa(values, 'average', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_max', (values) => owa(values, 'max', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_min', (values) => owa(values, 'min', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_median', (values) => owa(values, 'median', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_top2', (values) => owa(values, 'top2', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_product', (values) => owa(values, 'product', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_optimistic', (values) => owa(values, 'optimistic', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_pessimistic', (values) => owa(values, 'pessimistic', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_custom', (values) => owa(values, 'custom', { weights: [0.75, 0.25] }), rigor.args(twoInts)),
|
||
rigor.fn('owa_majority_str', (values) => owa(values, 'majority', {}), rigor.args(strArr)),
|
||
rigor.fn('owa_max_str', (values) => owa(values, 'max', {}), rigor.args(strArr)),
|
||
];
|
||
|
||
const OWA_CHECKS = [
|
||
rigor.after('owa_sum', (ctx) => ctx.actual === ctx.args[0].reduce((a, b) => a + b, 0)),
|
||
rigor.after('owa_avg', (ctx) => {
|
||
const v = ctx.args[0];
|
||
if (v.length === 0) return ctx.actual === 0;
|
||
return Math.abs(ctx.actual - v.reduce((a, b) => a + b, 0) / v.length) < 1e-9;
|
||
}),
|
||
rigor.after('owa_max', (ctx) => {
|
||
const v = ctx.args[0];
|
||
if (v.length === 0) return ctx.actual === 0;
|
||
return ctx.actual === Math.max(...v);
|
||
}),
|
||
rigor.after('owa_min', (ctx) => {
|
||
const v = ctx.args[0];
|
||
if (v.length === 0) return ctx.actual === 0;
|
||
return ctx.actual === Math.min(...v);
|
||
}),
|
||
rigor.after('owa_median', (ctx) => {
|
||
const v = ctx.args[0];
|
||
if (v.length === 0) return ctx.actual === 0;
|
||
const s = [...v].sort((a, b) => b - a);
|
||
return ctx.actual === s[Math.floor((s.length - 1) / 2)];
|
||
}),
|
||
rigor.after('owa_top2', (ctx) => {
|
||
const v = ctx.args[0];
|
||
if (v.length === 0) return ctx.actual === 0;
|
||
const s = [...v].sort((a, b) => b - a);
|
||
if (s.length === 1) return ctx.actual === s[0];
|
||
return Math.abs(ctx.actual - (s[0] + s[1]) / 2) < 1e-9;
|
||
}),
|
||
rigor.after('owa_product', (ctx) => {
|
||
const v = ctx.args[0];
|
||
if (v.length === 0) return ctx.actual === 0;
|
||
return ctx.actual === v.reduce((a, b) => a * b, 1);
|
||
}),
|
||
rigor.after('owa_optimistic', (ctx) => {
|
||
const v = ctx.args[0];
|
||
if (v.length === 0) return ctx.actual === 0;
|
||
return ctx.actual >= Math.min(...v) && ctx.actual <= Math.max(...v);
|
||
}),
|
||
rigor.after('owa_pessimistic', (ctx) => {
|
||
const v = ctx.args[0];
|
||
if (v.length === 0) return ctx.actual === 0;
|
||
return ctx.actual >= Math.min(...v) && ctx.actual <= Math.max(...v);
|
||
}),
|
||
rigor.after('owa_custom', (ctx) => {
|
||
const v = ctx.args[0];
|
||
const s = [...v].sort((a, b) => b - a);
|
||
return Math.abs(ctx.actual - (0.75 * s[0] + 0.25 * s[1])) < 1e-9;
|
||
}),
|
||
rigor.after('owa_majority_str', (ctx) => {
|
||
const v = ctx.args[0];
|
||
if (v.length === 0) return ctx.actual === 0;
|
||
return v.includes(ctx.actual);
|
||
}),
|
||
rigor.after('owa_max_str', (ctx) => {
|
||
const v = ctx.args[0];
|
||
if (v.length === 0) return ctx.actual === 0;
|
||
return v.includes(ctx.actual);
|
||
}),
|
||
];
|
||
|
||
describe('OWA math (ADR-000) — properties, weightFor, oracles, universal, algebraic', () => {
|
||
it('every OWA invariant in ONE campaign (many crucibles)', async () => {
|
||
const opGen = rigor.gen.oneOf(['max', 'min', 'average', 'sum', 'sum_unbounded', 'majority', 'median', 'optimistic', 'pessimistic', 'top2', 'top3']);
|
||
const weights = rigor.fn('weights', (op, n) => weightFor(op, n), rigor.args(opGen, rigor.gen.int(0, 12)));
|
||
const weightChecks = [
|
||
rigor.invariant('length = n', (ctx) => ctx.actual.length === ctx.args[1]),
|
||
rigor.invariant('sum/sum_unbounded all ones; others sum to 1', (ctx) => {
|
||
const op = ctx.args[0];
|
||
const w = ctx.actual;
|
||
if (op === 'sum' || op === 'sum_unbounded') return w.every((x) => x === 1);
|
||
if (w.length === 0) return true;
|
||
return Math.abs(w.reduce((a, b) => a + b, 0) - 1) < 1e-9;
|
||
}),
|
||
rigor.invariant('max puts all weight on head', (ctx) =>
|
||
ctx.args[0] !== 'max' || ctx.args[1] === 0 || ctx.actual[0] === 1),
|
||
rigor.invariant('min puts all weight on tail', (ctx) =>
|
||
ctx.args[0] !== 'min' || ctx.args[1] === 0 || ctx.actual[ctx.args[1] - 1] === 1),
|
||
rigor.invariant('median puts all weight on the middle', (ctx) =>
|
||
ctx.args[0] !== 'median' || ctx.args[1] === 0 || ctx.actual[Math.floor((ctx.args[1] - 1) / 2)] === 1),
|
||
rigor.invariant('optimistic is monotonically non-increasing', (ctx) => {
|
||
if (ctx.args[0] !== 'optimistic' || ctx.args[1] < 2) return true;
|
||
for (let i = 1; i < ctx.actual.length; i++) if (ctx.actual[i] > ctx.actual[i - 1]) return false;
|
||
return true;
|
||
}),
|
||
rigor.invariant('top2 splits 50/50', (ctx) => {
|
||
if (ctx.args[0] !== 'top2' || ctx.args[1] === 0) return true;
|
||
const w = ctx.actual;
|
||
if (w.length === 1) return w[0] === 1;
|
||
return Math.abs(w[0] - 0.5) < 1e-9 && Math.abs(w[1] - 0.5) < 1e-9;
|
||
}),
|
||
rigor.invariant('majority spreads over the top 60%', (ctx) => {
|
||
if (ctx.args[0] !== 'majority' || ctx.args[1] === 0) return true;
|
||
const w = ctx.actual;
|
||
const top = Math.ceil(ctx.args[1] * 0.6);
|
||
return w.slice(0, top).every((x) => Math.abs(x - 1 / top) < 1e-9) && w.slice(top).every((x) => x === 0);
|
||
}),
|
||
];
|
||
await expectPass('owa-math',
|
||
[
|
||
...OWA_ACTIONS,
|
||
weights,
|
||
rigor.fn('owa_sum_oracle', (values) => owa(values, 'sum', {}), rigor.args(intArr)),
|
||
...OWA_ORACLE_ACTIONS,
|
||
UNIVERSAL_ACTIONS[0], UNIVERSAL_ACTIONS[1],
|
||
ALGEBRAIC_ACTIONS[0], ALGEBRAIC_ACTIONS[1],
|
||
],
|
||
OWA_CHECKS,
|
||
weightChecks,
|
||
[rigor.oracle('owa_sum_oracle', (values) => values.reduce((a, b) => a + b, 0))],
|
||
OWA_ORACLE_CHECKS,
|
||
[UNIVERSAL_CHECKS[0], UNIVERSAL_CHECKS[1]],
|
||
[ALGEBRAIC_CHECKS[0], ALGEBRAIC_CHECKS[1]],
|
||
{ effort: 1200 });
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// ValueGraph — caching / TTL / invalidation / set / blackbox (fresh graph per case)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const GRAPH_ACTIONS = [
|
||
rigor.fn('get_defined', (subject, value) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', () => value);
|
||
const entry = syncGet(vg, subject, 'v', {});
|
||
return entry ? entry.value : 'NO_ENTRY';
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(-100, 100))),
|
||
|
||
rigor.fn('get_undefined', (subject) => {
|
||
const vg = new ValueGraph();
|
||
return syncGet(vg, subject, 'nope', {});
|
||
}, rigor.args(rigor.gen.string())),
|
||
|
||
rigor.fn('cache_once', (subject, value) => {
|
||
let calls = 0;
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', () => { calls++; return value; });
|
||
const a = syncGet(vg, subject, 'v', {}).value;
|
||
const b = syncGet(vg, subject, 'v', {}).value;
|
||
return { a, b, calls };
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100))),
|
||
|
||
rigor.fn('invalidate_recompute', (subject, a, b) => {
|
||
let base = a;
|
||
const vg = new ValueGraph();
|
||
vg.compute('base', () => base);
|
||
vg.compute('double', (s, p, { deps }) => deps.base * 2, { dependsOn: ['base'] });
|
||
const first = syncGet(vg, subject, 'double', {}).value;
|
||
base = b;
|
||
vg.invalidate(subject, 'base', {});
|
||
const second = syncGet(vg, subject, 'double', {}).value;
|
||
return { first, second };
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(1, 20), rigor.gen.int(1, 20))),
|
||
|
||
rigor.fn('set_overrides', (subject, value) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', () => 5);
|
||
vg.set(subject, 'v', {}, { value });
|
||
return syncGet(vg, subject, 'v', {}).value;
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(-50, 50))),
|
||
|
||
rigor.fn('plan_prunes_cached', (subject, value) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', () => value);
|
||
syncGet(vg, subject, 'v', {});
|
||
return vg.plan(subject, 'v', {}).size;
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100))),
|
||
|
||
rigor.fn('ttl_expiry', (subject, value) => {
|
||
let t = 0;
|
||
const vg = new ValueGraph({ clock: () => t, defaultTTL: 100 });
|
||
vg.compute('v', () => value);
|
||
syncGet(vg, subject, 'v', {});
|
||
const before = vg.plan(subject, 'v', {}).nodes.get('v').trivial;
|
||
t = 200;
|
||
const after = vg.plan(subject, 'v', {}).nodes.get('v').trivial;
|
||
return { before, after };
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100))),
|
||
|
||
rigor.fn('blackbox_over_edges', (subject, a, b, c) => {
|
||
const vg = new ValueGraph();
|
||
const edges = [a, b, c];
|
||
vg.define('related', { operator: 'pattern', pattern: { relation: 'r' }, fn: (s, p, ctx, cb) => cb(null, edges) });
|
||
vg.define('total', { operator: 'blackbox', parents: ['related'], fn: (s, p, { deps }) => deps.related.reduce((x, y) => x + y, 0) });
|
||
return syncGet(vg, subject, 'total', {}).value;
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 10), rigor.gen.int(0, 10), rigor.gen.int(0, 10))),
|
||
|
||
rigor.fn('blackbox_entries_full', (subject, value) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('base', { operator: 'source', fn: (s, p, ctx, cb) => cb(null, { value, unit: 'usd_cents' }) });
|
||
let seen = null;
|
||
vg.define('total', {
|
||
operator: 'blackbox',
|
||
parents: ['base'],
|
||
fn: (s, p, ctx, cb) => { seen = { value: ctx.deps.base, unit: ctx.entries.base.unit, fresh: ctx.entries.base.fresh }; cb(null, ctx.deps.base); }
|
||
});
|
||
syncGet(vg, subject, 'total', {});
|
||
return seen;
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100))),
|
||
];
|
||
|
||
const GRAPH_CHECKS = [
|
||
rigor.after('get_defined', (ctx) => ctx.actual === ctx.args[1]),
|
||
rigor.after('get_undefined', (ctx) => ctx.actual === null),
|
||
rigor.after('cache_once', (ctx) => ctx.actual.a === ctx.args[1] && ctx.actual.b === ctx.args[1] && ctx.actual.calls === 1),
|
||
rigor.after('invalidate_recompute', (ctx) =>
|
||
ctx.actual.first === ctx.args[1] * 2 && ctx.actual.second === ctx.args[2] * 2 &&
|
||
(ctx.args[1] === ctx.args[2] || ctx.actual.first !== ctx.actual.second)),
|
||
rigor.after('set_overrides', (ctx) => ctx.actual === ctx.args[1]),
|
||
rigor.after('plan_prunes_cached', (ctx) => ctx.actual === 1),
|
||
rigor.after('ttl_expiry', (ctx) => ctx.actual.before === true && ctx.actual.after === false),
|
||
rigor.after('blackbox_over_edges', (ctx) => ctx.actual === ctx.args[1] + ctx.args[2] + ctx.args[3]),
|
||
rigor.after('blackbox_entries_full', (ctx) => ctx.actual.value === ctx.args[1] && ctx.actual.unit === 'usd_cents' && ctx.actual.fresh === true),
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Callback resolver contract — handler-based
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const CB_ACTIONS = [
|
||
rigor.fn('cb_get_sync', (subject, value, cb) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', () => value);
|
||
vg.get(subject, 'v', {}, cb);
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100), rigor.handler(reducers.first()))),
|
||
|
||
rigor.fn('cb_get_async', (subject, value, cb) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, done) => { setTimeout(() => done(null, value), 1); });
|
||
vg.get(subject, 'v', {}, cb);
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100), rigor.handler(reducers.first()))),
|
||
|
||
rigor.fn('cb_error', (subject, cb) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, done) => { done(new Error('boom')); });
|
||
vg.get(subject, 'v', {}, cb);
|
||
}, rigor.args(rigor.gen.string(), rigor.handler(reducers.first()))),
|
||
|
||
rigor.fn('cb_reject_promise', (subject, cb) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', async () => 1);
|
||
vg.get(subject, 'v', {}, cb);
|
||
}, rigor.args(rigor.gen.string(), rigor.handler(reducers.first()))),
|
||
|
||
rigor.fn('cb_query_pull', (subject, value, cb) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, done) => done(null, value));
|
||
const q = vg.query(subject, 'v', {});
|
||
q.source.pipe(cb);
|
||
q.sink.write({ get: true });
|
||
q.sink.write({ get: true });
|
||
q.sink.end();
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100), rigor.handler(reducers.array()))),
|
||
|
||
rigor.fn('cb_query_stale', (subject, value, cb) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, done) => done(null, value));
|
||
const q = vg.query(subject, 'v', {});
|
||
q.source.pipe(cb);
|
||
q.sink.write({ get: true });
|
||
vg.invalidate(subject, 'v', {});
|
||
q.sink.write({ get: true });
|
||
q.sink.end();
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100), rigor.handler(reducers.array()))),
|
||
];
|
||
|
||
const CB_CHECKS = [
|
||
rigor.after('cb_get_sync', (ctx) => ctx.error == null && ctx.actual && ctx.actual.value === ctx.args[1]),
|
||
rigor.after('cb_get_async', (ctx) => ctx.error == null && ctx.actual && ctx.actual.value === ctx.args[1]),
|
||
rigor.after('cb_error', (ctx) => ctx.error != null && /boom/.test(ctx.error.message)),
|
||
rigor.after('cb_reject_promise', (ctx) => ctx.error != null && /Promise/.test(ctx.error.message)),
|
||
rigor.after('cb_query_pull', (ctx) =>
|
||
Array.isArray(ctx.actual) && ctx.actual.length === 2 &&
|
||
ctx.actual[0].value === ctx.args[1] && ctx.actual[1].value === ctx.args[1]),
|
||
rigor.after('cb_query_stale', (ctx) =>
|
||
Array.isArray(ctx.actual) && ctx.actual.length === 3 &&
|
||
ctx.actual[0].value === ctx.args[1] &&
|
||
ctx.actual[1] && ctx.actual[1].stale === true &&
|
||
ctx.actual[2].value === ctx.args[1]),
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Store contract — model-based conformance (map + disk-backed)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const STORE_OPS = [
|
||
{ name: 'set', args: rigor.gen.tuple(rigor.gen.string(), rigor.gen.int(0, 1000)), run: (m, k, v) => { m.map.set(k, v); return v; } },
|
||
{ name: 'get', args: rigor.gen.string(), run: (m, k) => (m.map.has(k) ? m.map.get(k) : null) },
|
||
{ name: 'delete', args: rigor.gen.string(), run: (m, k) => m.map.delete(k) },
|
||
{ name: 'clear', args: rigor.gen.constant(null), run: (m) => { m.map.clear(); return 'ok'; } },
|
||
{ name: 'deletePrefix', args: rigor.gen.string(), run: (m, p) => {
|
||
let n = 0;
|
||
for (const k of Array.from(m.map.keys())) if (k.includes(p)) { m.map.delete(k); n++; }
|
||
return n;
|
||
} },
|
||
];
|
||
|
||
function storeSut(createStore) {
|
||
const store = createStore();
|
||
return {
|
||
set(k, v) { store.set(k, v); return v; },
|
||
get(k) { const v = store.get(k); return v === undefined ? null : v; },
|
||
delete(k) { return store.delete(k); },
|
||
clear() { store.clear(); return 'ok'; },
|
||
keys() { return Array.from(store.keys()); },
|
||
deletePrefix(p) { return store.deletePrefix(p); },
|
||
clone() {
|
||
const c = storeSut(createStore);
|
||
for (const k of store.keys()) c.set(k, store.get(k));
|
||
return c;
|
||
}
|
||
};
|
||
}
|
||
|
||
async function expectModelPass(name, createStore) {
|
||
const sut = storeSut(createStore);
|
||
const result = rigor.model.check(`store-${name}`, { map: new Map() }, sut, {
|
||
operations: STORE_OPS,
|
||
effort: 200,
|
||
maxSequenceLength: 40,
|
||
seed: `store-${name}`,
|
||
});
|
||
if (result.status !== 'passed') {
|
||
const detail = (result.failures || []).slice(0, 5).map((f) =>
|
||
JSON.stringify({ seq: f.sequence, at: f.commandIndex, expected: f.expected, actual: f.actual, shrunk: f.shrunk }));
|
||
throw new Error(`model campaign 'store-${name}' failed.\n${detail.join('\n')}`);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
describe('Store contract (model-based conformance)', () => {
|
||
it('createMapStore matches the reference model', async () => {
|
||
await expectModelPass('map', () => createMapStore());
|
||
});
|
||
|
||
it('createFileStore matches the reference model (larger-than-memory store)', async () => {
|
||
const root = mkdtempSync(join(tmpdir(), 'vg-rigor-file-'));
|
||
let n = 0;
|
||
try {
|
||
await expectModelPass('file', () => createFileStore(join(root, `s${n++}`)));
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Oracle conformance — every operator + arbitrary operator combinations.
|
||
// refOwaExact is an INDEPENDENT reimplementation of the ADR-000 OWA formula
|
||
// (weight distribution + weighted sum, mirrored arithmetic so bit-exact `===`
|
||
// conformance holds). It is written separately from value-graph's `owa`/
|
||
// `weightFor`, so it guards against refactoring drift; the semantic meaning of
|
||
// each operator is already covered by the epsilon invariants above.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
function refWeightsExact(op, n, weights, priorities) {
|
||
const k = Math.max(0, n);
|
||
if (k === 0) return [];
|
||
const zeros = (m) => Array(Math.max(0, m)).fill(0);
|
||
const fill = (m, v) => Array(m).fill(v);
|
||
const norm = (w) => { const s = w.reduce((a, b) => a + b, 0); return s > 0 ? w.map((x) => x / s) : w; };
|
||
switch (op) {
|
||
case 'max': return [1, ...zeros(Math.max(0, k - 1))];
|
||
case 'min': return [...zeros(Math.max(0, k - 1)), 1];
|
||
case 'average': return fill(k, 1 / Math.max(1, k));
|
||
case 'sum':
|
||
case 'sum_unbounded': return fill(k, 1);
|
||
case 'majority': {
|
||
const top = Math.ceil(k * 0.6);
|
||
const w = zeros(k);
|
||
for (let i = 0; i < top; i++) w[i] = 1 / Math.max(1, top);
|
||
return w;
|
||
}
|
||
case 'median': { const w = zeros(k); w[Math.floor((k - 1) / 2)] = 1; return w; }
|
||
case 'optimistic': { const w = []; let x = 0.5; for (let i = 0; i < k; i++) { w.push(x); x /= 2; } return norm(w); }
|
||
case 'pessimistic': return norm(refWeightsExact('optimistic', k, weights, priorities).reverse());
|
||
case 'top2': return norm([0.5, 0.5, ...zeros(Math.max(0, k - 2))].slice(0, k));
|
||
case 'top3': return norm([1 / 3, 1 / 3, 1 / 3, ...zeros(Math.max(0, k - 3))].slice(0, k));
|
||
case 'priority': {
|
||
if (priorities && k > 0) {
|
||
const ws = fill(k, 0);
|
||
for (let i = 0; i < k; i++) ws[i] = priorities[i] ?? 1;
|
||
return norm(ws);
|
||
}
|
||
return fill(k, 1 / Math.max(1, k));
|
||
}
|
||
case 'custom': return norm(weights && weights.length ? weights.slice(0, k) : fill(k, 1 / Math.max(1, k)));
|
||
default: return fill(k, 1 / Math.max(1, k));
|
||
}
|
||
}
|
||
|
||
function refOwaExact(values, op, { weights, priorities, capSum } = {}) {
|
||
const n = values.length;
|
||
if (n === 0) return 0;
|
||
const allNumeric = values.every((v) => typeof v === 'number');
|
||
if (op === 'product') {
|
||
if (!allNumeric) throw new Error('refOwaExact: product requires numeric values');
|
||
return values.reduce((a, b) => a * b, 1);
|
||
}
|
||
if (!allNumeric) {
|
||
switch (op) {
|
||
case 'max': return values.reduce((a, b) => (a > b ? a : b));
|
||
case 'min': return values.reduce((a, b) => (a < b ? a : b));
|
||
case 'majority': {
|
||
const m = new Map();
|
||
for (const v of values) m.set(v, (m.get(v) || 0) + 1);
|
||
let best = values[0]; let bc = 0;
|
||
for (const [v, c] of m) if (c > bc) { bc = c; best = v; }
|
||
return best;
|
||
}
|
||
default: throw new Error(`refOwaExact: operator ${op} requires numeric values`);
|
||
}
|
||
}
|
||
const sorted = [...values].sort((a, b) => b - a);
|
||
const w = refWeightsExact(op, n, weights, priorities);
|
||
let result = 0;
|
||
for (let i = 0; i < n; i++) result += (w[i] ?? 0) * sorted[i];
|
||
if (op === 'sum' && capSum) result = Math.min(1, result);
|
||
return result;
|
||
}
|
||
|
||
const ANY_OPS = rigor.gen.frequency(
|
||
[3, rigor.gen.constant('sum')], [2, rigor.gen.constant('sum_unbounded')],
|
||
[2, rigor.gen.constant('max')], [2, rigor.gen.constant('min')],
|
||
[3, rigor.gen.constant('average')], [2, rigor.gen.constant('median')],
|
||
[2, rigor.gen.constant('majority')], [2, rigor.gen.constant('optimistic')],
|
||
[2, rigor.gen.constant('pessimistic')], [2, rigor.gen.constant('top2')],
|
||
[2, rigor.gen.constant('top3')], [3, rigor.gen.constant('custom')],
|
||
[2, rigor.gen.constant('priority')], [2, rigor.gen.constant('product')],
|
||
);
|
||
const weightArr = rigor.gen.array(rigor.gen.int(-10, 10), 0, 6);
|
||
|
||
const OWA_ORACLE_ACTIONS = [
|
||
rigor.fn('owa_sum_ref', (values) => owa(values, 'sum', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_sum_unbounded_ref', (values) => owa(values, 'sum_unbounded', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_max_ref', (values) => owa(values, 'max', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_min_ref', (values) => owa(values, 'min', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_product_ref', (values) => owa(values, 'product', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_median_ref', (values) => owa(values, 'median', {}), rigor.args(intArr)),
|
||
rigor.fn('owa_top2_ref', (values) => owa(values, 'top2', {}), rigor.args(twoInts)),
|
||
rigor.fn('owa_sum_capped', (values) => owa(values, 'sum', { capSum: true }), rigor.args(intArr)),
|
||
rigor.fn('owa_max_str_ref', (values) => owa(values, 'max', {}), rigor.args(strArr)),
|
||
rigor.fn('owa_min_str_ref', (values) => owa(values, 'min', {}), rigor.args(strArr)),
|
||
rigor.fn('owa_majority_str_ref', (values) => owa(values, 'majority', {}), rigor.args(strArr)),
|
||
rigor.fn('owa_any', (op, values, weights, priorities) => {
|
||
const opts = {};
|
||
if (op === 'custom') opts.weights = weights;
|
||
if (op === 'priority') opts.priorities = priorities;
|
||
return owa(values, op, opts);
|
||
}, rigor.args(ANY_OPS, intArr, weightArr, weightArr)),
|
||
];
|
||
|
||
const OWA_ORACLE_CHECKS = [
|
||
rigor.oracle('owa_sum_ref', (values) => values.reduce((a, b) => a + b, 0)),
|
||
rigor.oracle('owa_sum_unbounded_ref', (values) => values.reduce((a, b) => a + b, 0)),
|
||
rigor.oracle('owa_max_ref', (values) => (values.length === 0 ? 0 : Math.max(...values))),
|
||
rigor.oracle('owa_min_ref', (values) => (values.length === 0 ? 0 : Math.min(...values))),
|
||
rigor.oracle('owa_product_ref', (values) => (values.length === 0 ? 0 : values.reduce((a, b) => a * b, 1))),
|
||
rigor.oracle('owa_median_ref', (values) => {
|
||
if (values.length === 0) return 0;
|
||
const s = [...values].sort((a, b) => b - a);
|
||
return s[Math.floor((s.length - 1) / 2)];
|
||
}),
|
||
rigor.oracle('owa_top2_ref', (values) => {
|
||
const s = [...values].sort((a, b) => b - a);
|
||
return 0.5 * s[0] + 0.5 * s[1];
|
||
}),
|
||
rigor.after('owa_sum_capped', (ctx) => ctx.actual === Math.min(1, ctx.args[0].reduce((a, b) => a + b, 0))),
|
||
rigor.oracle('owa_max_str_ref', (values) => (values.length === 0 ? 0 : values.reduce((a, b) => (a > b ? a : b)))),
|
||
rigor.oracle('owa_min_str_ref', (values) => (values.length === 0 ? 0 : values.reduce((a, b) => (a < b ? a : b)))),
|
||
rigor.oracle('owa_majority_str_ref', (values) => {
|
||
if (values.length === 0) return 0;
|
||
const m = new Map();
|
||
for (const v of values) m.set(v, (m.get(v) || 0) + 1);
|
||
let best = values[0]; let bc = 0;
|
||
for (const [v, c] of m) if (c > bc) { bc = c; best = v; }
|
||
return best;
|
||
}),
|
||
rigor.after('owa_any', (ctx) => {
|
||
const [op, values, weights, priorities] = ctx.args;
|
||
const expected = refOwaExact(values, op, {
|
||
weights: op === 'custom' ? weights : undefined,
|
||
priorities: op === 'priority' ? priorities : undefined,
|
||
});
|
||
return ctx.actual === expected || (Number.isNaN(ctx.actual) && Number.isNaN(expected));
|
||
}),
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Fusion DAG oracles — the graph's fusion path must equal raw owa, and composed
|
||
// fusion-of-fusion DAGs must equal the bottom-up reference.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const FUSION_ACTIONS = [
|
||
rigor.fn('fusion_any', (op, values, weights, priorities) => {
|
||
const vg = new ValueGraph();
|
||
values.forEach((v, i) => vg.define(`p${i}`, { operator: 'source', fn: () => v }));
|
||
const spec = { operator: `fusion:${op}`, parents: values.map((_, i) => `p${i}`) };
|
||
if (op === 'custom') spec.weights = weights;
|
||
if (op === 'priority') spec.priorities = priorities;
|
||
vg.define('out', spec);
|
||
const entry = syncGet(vg, 't', 'out', {});
|
||
return entry ? entry.value : 'NO_ENTRY';
|
||
}, rigor.args(ANY_OPS, intArr, weightArr, weightArr)),
|
||
|
||
rigor.fn('dag_any', (op1, op2, op3, a, b, c, d) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('a', { operator: 'source', fn: () => a });
|
||
vg.define('b', { operator: 'source', fn: () => b });
|
||
vg.define('c', { operator: 'source', fn: () => c });
|
||
vg.define('d', { operator: 'source', fn: () => d });
|
||
vg.define('m1', { operator: `fusion:${op1}`, parents: ['a', 'b'] });
|
||
vg.define('m2', { operator: `fusion:${op2}`, parents: ['c', 'd'] });
|
||
vg.define('out', { operator: `fusion:${op3}`, parents: ['m1', 'm2'] });
|
||
const entry = syncGet(vg, 't', 'out', {});
|
||
return entry ? entry.value : 'NO_ENTRY';
|
||
}, rigor.args(ANY_OPS, ANY_OPS, ANY_OPS, rigor.gen.int(-50, 50), rigor.gen.int(-50, 50), rigor.gen.int(-50, 50), rigor.gen.int(-50, 50))),
|
||
|
||
rigor.fn('fusion_compute_chain', (a, b, op) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('a', { operator: 'source', fn: () => a });
|
||
vg.define('b', { operator: 'source', fn: () => b });
|
||
vg.define('ab', { operator: 'compute', parents: ['a', 'b'], fn: (s, p, { deps }) => deps.a * deps.b });
|
||
vg.define('out', { operator: `fusion:${op}`, parents: ['ab'] });
|
||
const entry = syncGet(vg, 't', 'out', {});
|
||
return entry ? entry.value : 'NO_ENTRY';
|
||
}, rigor.args(rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), ANY_OPS)),
|
||
];
|
||
|
||
const FUSION_CHECKS = [
|
||
rigor.after('fusion_any', (ctx) => {
|
||
const [op, values, weights, priorities] = ctx.args;
|
||
const expected = refOwaExact(values, op, {
|
||
weights: op === 'custom' ? weights : undefined,
|
||
priorities: op === 'priority' ? priorities : undefined,
|
||
});
|
||
return ctx.actual === expected || (Number.isNaN(ctx.actual) && Number.isNaN(expected));
|
||
}),
|
||
rigor.after('dag_any', (ctx) => {
|
||
const [op1, op2, op3, a, b, c, d] = ctx.args;
|
||
const m1 = refOwaExact([a, b], op1);
|
||
const m2 = refOwaExact([c, d], op2);
|
||
const expected = refOwaExact([m1, m2], op3);
|
||
return ctx.actual === expected || (Number.isNaN(ctx.actual) && Number.isNaN(expected));
|
||
}),
|
||
rigor.after('fusion_compute_chain', (ctx) => {
|
||
const [a, b, op] = ctx.args;
|
||
const expected = refOwaExact([a * b], op);
|
||
return ctx.actual === expected || (Number.isNaN(ctx.actual) && Number.isNaN(expected));
|
||
}),
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// More correctness oracles — mixed-topology DAGs, selective invalidation,
|
||
// cache-key isolation, stale-on-error fallback, value-type round-trips,
|
||
// blackbox-over-fusion.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const MORE_ACTIONS = [
|
||
// Mixed topology: m1 = fusion op1(a,b), m2 = compute (c+d), m3 = fusion op2(a,c),
|
||
// out = fusion op3(m1,m2,m3). Bottom-up reference must match.
|
||
rigor.fn('mixed_dag_ref', (a, b, c, d, op1, op2, op3) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('a', { operator: 'source', fn: () => a });
|
||
vg.define('b', { operator: 'source', fn: () => b });
|
||
vg.define('c', { operator: 'source', fn: () => c });
|
||
vg.define('d', { operator: 'source', fn: () => d });
|
||
vg.define('m1', { operator: `fusion:${op1}`, parents: ['a', 'b'] });
|
||
vg.define('m2', { operator: 'compute', parents: ['c', 'd'], fn: (s, p, { deps }) => deps.c + deps.d });
|
||
vg.define('m3', { operator: `fusion:${op2}`, parents: ['a', 'c'] });
|
||
vg.define('out', { operator: `fusion:${op3}`, parents: ['m1', 'm2', 'm3'] });
|
||
const entry = syncGet(vg, 't', 'out', {});
|
||
return entry ? entry.value : 'NO_ENTRY';
|
||
}, rigor.args(rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), ANY_OPS, ANY_OPS, ANY_OPS)),
|
||
|
||
// Selective invalidation: changing b and invalidating b must produce the
|
||
// correct new root value (dependent subtree recomputed, values correct).
|
||
rigor.fn('invalidate_correctness', (a, b1, b2, c, op, op2) => {
|
||
let b = b1;
|
||
const vg = new ValueGraph();
|
||
vg.define('a', { operator: 'source', fn: () => a });
|
||
vg.define('b', { operator: 'source', fn: () => b });
|
||
vg.define('c', { operator: 'source', fn: () => c });
|
||
vg.define('m', { operator: `fusion:${op}`, parents: ['a', 'b'] });
|
||
vg.define('r', { operator: `fusion:${op2}`, parents: ['m', 'c'] });
|
||
const first = syncGet(vg, 't', 'r', {}).value;
|
||
b = b2;
|
||
vg.invalidate('t', 'b', {});
|
||
const aAfter = syncGet(vg, 't', 'a', {}).value;
|
||
const second = syncGet(vg, 't', 'r', {}).value;
|
||
return { first, second, aAfter };
|
||
}, rigor.args(rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), ANY_OPS, ANY_OPS)),
|
||
|
||
// Cache keys include subject: A and B share no entries; A computes once.
|
||
rigor.fn('subject_isolation', (va, vb) => {
|
||
let callsA = 0;
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, cb) => { if (s === 'A') callsA++; cb(null, s === 'A' ? va : vb); });
|
||
const a1 = syncGet(vg, 'A', 'v', {}).value;
|
||
const b1 = syncGet(vg, 'B', 'v', {}).value;
|
||
const a2 = syncGet(vg, 'A', 'v', {}).value;
|
||
const b2 = syncGet(vg, 'B', 'v', {}).value;
|
||
return { a1, a2, b1, b2, callsA };
|
||
}, rigor.args(rigor.gen.int(-100, 100), rigor.gen.int(-100, 100))),
|
||
|
||
// Stale-on-error: after TTL expiry the entry is still present, so a failing
|
||
// resolver returns the cached value marked fresh:false instead of erroring.
|
||
rigor.fn('stale_on_error', (value) => {
|
||
let t = 0;
|
||
let mode = 'ok';
|
||
const vg = new ValueGraph({ clock: () => t, defaultTTL: 100 });
|
||
vg.compute('v', (s, p, ctx, cb) => { if (mode === 'err') return cb(new Error('resolver down')); cb(null, value); });
|
||
const first = syncGet(vg, 't', 'v', {});
|
||
t = 200;
|
||
mode = 'err';
|
||
const second = syncGet(vg, 't', 'v', {});
|
||
return { firstValue: first.value, firstFresh: first.fresh, secondValue: second.value, secondFresh: second.fresh };
|
||
}, rigor.args(rigor.gen.int(0, 100))),
|
||
|
||
// Value types (array / interval / buffer) round-trip through the disk-backed
|
||
// store into a brand-new graph instance.
|
||
rigor.fn('file_roundtrip_types', (a, b, c) => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'vg-rigor-rt-'));
|
||
try {
|
||
const vg1 = new ValueGraph({ store: createFileStore(dir) });
|
||
vg1.define('arr', { operator: 'source', fn: () => [a, b, c] });
|
||
vg1.define('interval', { operator: 'source', fn: () => ({ lower: a, upper: b }) });
|
||
vg1.define('buf', { operator: 'source', fn: () => new Uint8Array([a & 0xff, b & 0xff, c & 0xff]) });
|
||
const e1 = syncGet(vg1, 't', 'arr', {}).value;
|
||
const e2 = syncGet(vg1, 't', 'interval', {}).value;
|
||
const e3 = syncGet(vg1, 't', 'buf', {}).value;
|
||
const vg2 = new ValueGraph({ store: createFileStore(dir) });
|
||
const r1 = syncGet(vg2, 't', 'arr', {}).value;
|
||
const r2 = syncGet(vg2, 't', 'interval', {}).value;
|
||
const r3 = syncGet(vg2, 't', 'buf', {}).value;
|
||
return { e1, e2, e3, r1, r2, r3 };
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}, rigor.args(rigor.gen.int(-50, 50), rigor.gen.int(-50, 50), rigor.gen.int(-50, 50))),
|
||
|
||
// blackbox over a fusion parent: ctx.deps carries the fused value, so
|
||
// out = fusion(a,b) * k must equal the reference times k.
|
||
rigor.fn('blackbox_fusion', (a, b, op, k) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('a', { operator: 'source', fn: () => a });
|
||
vg.define('b', { operator: 'source', fn: () => b });
|
||
vg.define('m', { operator: `fusion:${op}`, parents: ['a', 'b'] });
|
||
vg.define('out', { operator: 'blackbox', parents: ['m'], fn: (s, p, { deps }) => deps.m * k });
|
||
const entry = syncGet(vg, 't', 'out', {});
|
||
return entry ? entry.value : 'NO_ENTRY';
|
||
}, rigor.args(rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), ANY_OPS, rigor.gen.int(-5, 5))),
|
||
|
||
// Selective invalidation: invalidating an UNRELATED relation must leave the
|
||
// cached entry untouched (per-relation version stamps).
|
||
rigor.fn('selective_keep_unrelated', (va, vb) => {
|
||
let aCalls = 0;
|
||
const vg = new ValueGraph();
|
||
vg.compute('a', (s, p, ctx, cb) => { aCalls++; cb(null, va); });
|
||
vg.compute('b', (s, p, ctx, cb) => cb(null, vb));
|
||
syncGet(vg, 't', 'a', {});
|
||
const before = vg.plan('t', 'a', {}).nodes.get('a').trivial;
|
||
vg.invalidate('t', 'b', {});
|
||
const after = vg.plan('t', 'a', {}).nodes.get('a').trivial;
|
||
return { before, after, aCalls, aValue: syncGet(vg, 't', 'a', {}).value };
|
||
}, rigor.args(rigor.gen.int(0, 100), rigor.gen.int(0, 100))),
|
||
|
||
// Selective invalidation down a chain: two independent subtrees share no
|
||
// versions — invalidating one branch leaves the sibling branch cached.
|
||
rigor.fn('selective_chain', (a1, b1, c1, a2, b2, op) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('a1', { operator: 'source', fn: () => a1 });
|
||
vg.define('b1', { operator: 'source', fn: () => b1 });
|
||
vg.define('c1', { operator: 'source', fn: () => c1 });
|
||
vg.define('a2', { operator: 'source', fn: () => a2 });
|
||
vg.define('b2', { operator: 'source', fn: () => b2 });
|
||
vg.define('m1', { operator: `fusion:${op}`, parents: ['a1', 'b1'] });
|
||
vg.define('m2', { operator: `fusion:${op}`, parents: ['a2', 'b2'] });
|
||
vg.define('r1', { operator: `fusion:${op}`, parents: ['m1', 'c1'] });
|
||
syncGet(vg, 't', 'r1', {});
|
||
syncGet(vg, 't', 'm2', {});
|
||
const m2Before = vg.plan('t', 'm2', {}).nodes.get('m2').trivial;
|
||
vg.invalidate('t', 'a1', {});
|
||
const m2After = vg.plan('t', 'm2', {}).nodes.get('m2').trivial;
|
||
const r1After = vg.plan('t', 'r1', {}).nodes.get('r1').trivial;
|
||
return { m2Before, m2After, r1After };
|
||
}, rigor.args(rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), ANY_OPS)),
|
||
|
||
// Selective set: setting an unrelated relation leaves the cached entry fresh.
|
||
rigor.fn('set_keep_unrelated', (va, vb) => {
|
||
let aCalls = 0;
|
||
const vg = new ValueGraph();
|
||
vg.compute('a', (s, p, ctx, cb) => { aCalls++; cb(null, va); });
|
||
vg.compute('b', (s, p, ctx, cb) => cb(null, vb));
|
||
syncGet(vg, 't', 'a', {});
|
||
vg.set('t', 'b', {}, { value: 999 });
|
||
const aCached = vg.plan('t', 'a', {}).nodes.get('a').trivial;
|
||
return { aCached, aCalls, aValue: syncGet(vg, 't', 'a', {}).value, bValue: syncGet(vg, 't', 'b', {}).value };
|
||
}, rigor.args(rigor.gen.int(0, 100), rigor.gen.int(0, 100))),
|
||
];
|
||
|
||
const MORE_CHECKS = [
|
||
rigor.after('mixed_dag_ref', (ctx) => {
|
||
const [a, b, c, d, op1, op2, op3] = ctx.args;
|
||
const m1 = refOwaExact([a, b], op1);
|
||
const m2 = c + d;
|
||
const m3 = refOwaExact([a, c], op2);
|
||
const expected = refOwaExact([m1, m2, m3], op3);
|
||
return ctx.actual === expected || (Number.isNaN(ctx.actual) && Number.isNaN(expected));
|
||
}),
|
||
rigor.after('invalidate_correctness', (ctx) => {
|
||
const [a, b1, b2, c, op, op2] = ctx.args;
|
||
const { first, second, aAfter } = ctx.actual;
|
||
const expectedFirst = refOwaExact([refOwaExact([a, b1], op), c], op2);
|
||
const expectedSecond = refOwaExact([refOwaExact([a, b2], op), c], op2);
|
||
return first === expectedFirst && second === expectedSecond && aAfter === a;
|
||
}),
|
||
rigor.after('subject_isolation', (ctx) => {
|
||
const [va, vb] = ctx.args;
|
||
const { a1, a2, b1, b2, callsA } = ctx.actual;
|
||
return a1 === va && a2 === va && b1 === vb && b2 === vb && callsA === 1;
|
||
}),
|
||
rigor.after('stale_on_error', (ctx) => {
|
||
const { firstValue, firstFresh, secondValue, secondFresh } = ctx.actual;
|
||
return firstValue === ctx.args[0] && firstFresh === true &&
|
||
secondValue === ctx.args[0] && secondFresh === false;
|
||
}),
|
||
rigor.after('file_roundtrip_types', (ctx) => {
|
||
const { e1, e2, e3, r1, r2, r3 } = ctx.actual;
|
||
return JSON.stringify(e1) === JSON.stringify(r1) &&
|
||
e2.lower === r2.lower && e2.upper === r2.upper &&
|
||
r3.length === e3.length && r3.every((b, i) => b === e3[i]);
|
||
}),
|
||
rigor.after('blackbox_fusion', (ctx) => {
|
||
const [a, b, op, k] = ctx.args;
|
||
const expected = refOwaExact([a, b], op) * k;
|
||
return ctx.actual === expected || (Number.isNaN(ctx.actual) && Number.isNaN(expected));
|
||
}),
|
||
rigor.after('selective_keep_unrelated', (ctx) => {
|
||
const { before, after, aCalls, aValue } = ctx.actual;
|
||
return before === true && after === true && aCalls === 1 && aValue === ctx.args[0];
|
||
}),
|
||
rigor.after('selective_chain', (ctx) => {
|
||
const { m2Before, m2After, r1After } = ctx.actual;
|
||
return m2Before === true && m2After === true && r1After === false;
|
||
}),
|
||
rigor.after('set_keep_unrelated', (ctx) => {
|
||
const { aCached, aCalls, aValue, bValue } = ctx.actual;
|
||
return aCached === true && aCalls === 1 && aValue === ctx.args[0] && bValue === 999;
|
||
}),
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Universal invariants — every property that should hold for ALL operators /
|
||
// graph behaviors, verified against generated inputs.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const MONO_OPS = rigor.gen.frequency(
|
||
[3, rigor.gen.constant('sum')], [2, rigor.gen.constant('sum_unbounded')],
|
||
[2, rigor.gen.constant('max')], [2, rigor.gen.constant('min')],
|
||
[3, rigor.gen.constant('average')], [2, rigor.gen.constant('median')],
|
||
[2, rigor.gen.constant('majority')], [2, rigor.gen.constant('optimistic')],
|
||
[2, rigor.gen.constant('pessimistic')], [2, rigor.gen.constant('top2')],
|
||
[2, rigor.gen.constant('top3')], [2, rigor.gen.constant('priority')],
|
||
);
|
||
const BOUNDED_OPS = ['max', 'min', 'average', 'median', 'majority', 'optimistic', 'pessimistic', 'top2', 'top3', 'priority', 'custom'];
|
||
|
||
const UNIVERSAL_ACTIONS = [
|
||
// Every operator: empty→0, singleton→element, sum/product exact, weighted
|
||
// operators stay within [min,max].
|
||
rigor.fn('owa_universal', (op, values) => owa(values, op, {}), rigor.args(ANY_OPS, intArr)),
|
||
|
||
// Monotonicity: bumping one element by +1 never decreases the result.
|
||
rigor.fn('owa_monotone', (op, values, idx) => {
|
||
const n = Math.max(1, values.length);
|
||
const bumped = values.map((v, i) => (i === idx % n ? v + 1 : v));
|
||
return { a: owa(values, op, {}), b: owa(bumped, op, {}) };
|
||
}, rigor.args(MONO_OPS, intArr, rigor.gen.int(0, 200))),
|
||
|
||
// Plan exactness: fully-cached plan is a single leaf; invalidation expands to
|
||
// exactly the affected closure (a,m,r recompute) while the unrelated fresh
|
||
// leaves b,c stay trivial in the plan.
|
||
rigor.fn('plan_exactness', (op, a, b, c) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('a', { operator: 'source', fn: () => a });
|
||
vg.define('b', { operator: 'source', fn: () => b });
|
||
vg.define('c', { operator: 'source', fn: () => c });
|
||
vg.define('m', { operator: `fusion:${op}`, parents: ['a', 'b'] });
|
||
vg.define('r', { operator: `fusion:${op}`, parents: ['m', 'c'] });
|
||
syncGet(vg, 't', 'r', {});
|
||
const allCached = vg.plan('t', 'r', {}).size;
|
||
const mCached = vg.plan('t', 'm', {}).size;
|
||
vg.invalidate('t', 'a', {});
|
||
const planAfter = vg.plan('t', 'r', {});
|
||
const aAfter = vg.plan('t', 'a', {}).size;
|
||
return {
|
||
allCached,
|
||
mCached,
|
||
rAfter: planAfter.size,
|
||
aAfter,
|
||
aTriv: planAfter.nodes.get('a').trivial,
|
||
mTriv: planAfter.nodes.get('m').trivial,
|
||
rTriv: planAfter.nodes.get('r').trivial,
|
||
bTriv: planAfter.nodes.get('b').trivial,
|
||
cTriv: planAfter.nodes.get('c').trivial
|
||
};
|
||
}, rigor.args(ANY_OPS, rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), rigor.gen.int(-20, 20))),
|
||
|
||
// Async resolver + two concurrent gets: serialized, both delivered, cached
|
||
// (resolver called once — encoded by value+calls).
|
||
rigor.fn('query_async_serialized', (subject, value, cb) => {
|
||
let calls = 0;
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, done) => { calls++; setTimeout(() => done(null, value + calls), 1); });
|
||
const q = vg.query(subject, 'v', {});
|
||
q.source.pipe(cb);
|
||
q.sink.write({ get: true });
|
||
q.sink.write({ get: true });
|
||
setTimeout(() => q.sink.end(), 25);
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100), rigor.handler(reducers.array()))),
|
||
|
||
// Params participate in the cache key: different params never share entries.
|
||
rigor.fn('params_isolation', (va, vb) => {
|
||
let callsA = 0;
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, cb) => { if (p.which === 'a') callsA++; cb(null, p.which === 'a' ? va : vb); });
|
||
const a1 = syncGet(vg, 't', 'v', { which: 'a' }).value;
|
||
const b1 = syncGet(vg, 't', 'v', { which: 'b' }).value;
|
||
const a2 = syncGet(vg, 't', 'v', { which: 'a' }).value;
|
||
return { a1, a2, b1, callsA };
|
||
}, rigor.args(rigor.gen.int(-100, 100), rigor.gen.int(-100, 100))),
|
||
|
||
// Cross-subject invalidation: relation-level versioning — B's dependents may
|
||
// recompute but must stay CORRECT.
|
||
rigor.fn('cross_subject_invalidate', (xa, xb) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('x', (s, p, ctx, cb) => cb(null, s === 'A' ? xa : xb));
|
||
vg.compute('m', (s, p, { deps }) => deps.x * 2, { dependsOn: ['x'] });
|
||
const aM = syncGet(vg, 'A', 'm', {}).value;
|
||
const bM = syncGet(vg, 'B', 'm', {}).value;
|
||
vg.invalidate('A', 'x', {});
|
||
const aM2 = syncGet(vg, 'A', 'm', {}).value;
|
||
const bM2 = syncGet(vg, 'B', 'm', {}).value;
|
||
return { aM, bM, aM2, bM2 };
|
||
}, rigor.args(rigor.gen.int(-50, 50), rigor.gen.int(-50, 50))),
|
||
|
||
// invalidateAll clears every subject's entry for the relation.
|
||
rigor.fn('invalidate_all_subjects', (va, vb) => {
|
||
let calls = 0;
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, cb) => { calls++; cb(null, s === 'A' ? va : vb); });
|
||
syncGet(vg, 'A', 'v', {});
|
||
syncGet(vg, 'B', 'v', {});
|
||
const callsBefore = calls;
|
||
vg.invalidateAll('v');
|
||
const callsAfterInvalidate = calls;
|
||
const a2 = syncGet(vg, 'A', 'v', {}).value;
|
||
const b2 = syncGet(vg, 'B', 'v', {}).value;
|
||
return { callsBefore, callsAfterInvalidate, callsAfter: calls, a2, b2 };
|
||
}, rigor.args(rigor.gen.int(0, 100), rigor.gen.int(0, 100))),
|
||
|
||
// An undefined (ghost) parent in a fusion is treated as missing: filtered out.
|
||
rigor.fn('ghost_parent_fusion', (a, op) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('a', { operator: 'source', fn: () => a });
|
||
vg.define('out', { operator: `fusion:${op}`, parents: ['a', 'ghost'] });
|
||
const entry = syncGet(vg, 't', 'out', {});
|
||
return entry ? entry.value : 'NO_ENTRY';
|
||
}, rigor.args(rigor.gen.int(-50, 50), ANY_OPS)),
|
||
|
||
// Attribute hook receives (nodeKey=subject, path, params).
|
||
rigor.fn('attribute_hook_args', (subject, balance) => {
|
||
const seen = [];
|
||
const graph = new Map([[subject, { balance }]]);
|
||
const vg = new ValueGraph({
|
||
resolveAttribute: (nodeKey, path, params, ctx, cb) => { seen.push({ nodeKey, path, params }); cb(null, graph.get(nodeKey)?.[path]); }
|
||
});
|
||
vg.define('balance', { operator: 'attribute', attribute: 'balance' });
|
||
const value = syncGet(vg, subject, 'balance', {}).value;
|
||
return { value, seen: seen[0] };
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(-100, 100))),
|
||
|
||
// Pattern hook receives (pattern, subject, params).
|
||
rigor.fn('pattern_hook_args', (subject, relation, count) => {
|
||
const seen = [];
|
||
const vg = new ValueGraph({
|
||
resolvePattern: (pattern, s, params, ctx, cb) => { seen.push({ pattern, s, params }); cb(null, count); }
|
||
});
|
||
vg.define('degree', { operator: 'pattern', pattern: { relation } });
|
||
const value = syncGet(vg, subject, 'degree', {}).value;
|
||
return { value, seen: seen[0] };
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.string(), rigor.gen.int(0, 10))),
|
||
|
||
// TTL boundary: fresh strictly inside the window, stale AT the window.
|
||
rigor.fn('ttl_boundary', (value) => {
|
||
let t = 0;
|
||
const vg = new ValueGraph({ clock: () => t, defaultTTL: 100 });
|
||
vg.compute('v', (s, p, ctx, cb) => cb(null, value));
|
||
syncGet(vg, 't', 'v', {});
|
||
const freshAt0 = vg.plan('t', 'v', {}).nodes.get('v').trivial;
|
||
t = 99;
|
||
const freshAt99 = vg.plan('t', 'v', {}).nodes.get('v').trivial;
|
||
t = 100;
|
||
const staleAt100 = vg.plan('t', 'v', {}).nodes.get('v').trivial;
|
||
return { freshAt0, freshAt99, staleAt100 };
|
||
}, rigor.args(rigor.gen.int(0, 100))),
|
||
|
||
// ttl: 0 means "never expires".
|
||
rigor.fn('ttl_zero_never_expires', (value) => {
|
||
let t = 0;
|
||
const vg = new ValueGraph({ clock: () => t });
|
||
vg.compute('v', (s, p, ctx, cb) => cb(null, value), { ttl: 0 });
|
||
syncGet(vg, 't', 'v', {});
|
||
t = 1e12;
|
||
return vg.plan('t', 'v', {}).nodes.get('v').trivial;
|
||
}, rigor.args(rigor.gen.int(0, 100))),
|
||
|
||
// set preserves the written unit and source on the returned entry.
|
||
rigor.fn('set_preserves_unit', (value) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, cb) => cb(null, 5));
|
||
vg.set('t', 'v', {}, { value, unit: 'usd_cents', source: 'ledger' });
|
||
const entry = syncGet(vg, 't', 'v', {});
|
||
return { value: entry.value, unit: entry.unit, source: entry.source };
|
||
}, rigor.args(rigor.gen.int(0, 100))),
|
||
|
||
// In-graph fusion over string sources: max/min/majority fallback.
|
||
rigor.fn('graph_string_fusion', (x, y, op) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('x', { operator: 'source', fn: () => x });
|
||
vg.define('y', { operator: 'source', fn: () => y });
|
||
vg.define('out', { operator: `fusion:${op}`, parents: ['x', 'y'] });
|
||
const entry = syncGet(vg, 't', 'out', {});
|
||
return entry ? entry.value : 'NO_ENTRY';
|
||
}, rigor.args(rigor.gen.oneOf(['low', 'med', 'high']), rigor.gen.oneOf(['low', 'med', 'high']), rigor.gen.oneOf(['max', 'min', 'majority']))),
|
||
|
||
// In-graph fusion:sum with capSum clamps at 1.0.
|
||
rigor.fn('graph_sum_capped', (a, b) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('a', { operator: 'source', fn: () => a });
|
||
vg.define('b', { operator: 'source', fn: () => b });
|
||
vg.define('out', { operator: 'fusion:sum', parents: ['a', 'b'], capSum: true });
|
||
const entry = syncGet(vg, 't', 'out', {});
|
||
return entry ? entry.value : 'NO_ENTRY';
|
||
}, rigor.args(rigor.gen.int(-10, 10), rigor.gen.int(-10, 10))),
|
||
|
||
// Duplicate parents are treated as a SINGLE dependency (parents are a set).
|
||
rigor.fn('fusion_dup_parents', (a, op) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('a', { operator: 'source', fn: () => a });
|
||
vg.define('out', { operator: `fusion:${op}`, parents: ['a', 'a'] });
|
||
const entry = syncGet(vg, 't', 'out', {});
|
||
return entry ? entry.value : 'NO_ENTRY';
|
||
}, rigor.args(rigor.gen.int(-50, 50), ANY_OPS)),
|
||
];
|
||
|
||
const UNIVERSAL_CHECKS = [
|
||
rigor.after('owa_universal', (ctx) => {
|
||
const [op, values] = ctx.args;
|
||
const actual = ctx.actual;
|
||
if (values.length === 0) return actual === 0;
|
||
if (values.length === 1) return actual === values[0];
|
||
if (values.every((v) => v === values[0]) && BOUNDED_OPS.includes(op)) return actual === values[0];
|
||
if (op === 'sum' || op === 'sum_unbounded') return actual === values.reduce((a, b) => a + b, 0);
|
||
if (op === 'product') return actual === values.reduce((a, b) => a * b, 1);
|
||
if (BOUNDED_OPS.includes(op)) {
|
||
return actual >= Math.min(...values) && actual <= Math.max(...values);
|
||
}
|
||
return true;
|
||
}),
|
||
rigor.after('owa_monotone', (ctx) => ctx.actual.a <= ctx.actual.b),
|
||
rigor.after('plan_exactness', (ctx) => {
|
||
const { allCached, mCached, rAfter, aAfter, aTriv, mTriv, rTriv, bTriv, cTriv } = ctx.actual;
|
||
return allCached === 1 && mCached === 1 &&
|
||
rAfter === 5 && aAfter === 1 &&
|
||
aTriv === false && mTriv === false && rTriv === false &&
|
||
bTriv === true && cTriv === true;
|
||
}),
|
||
rigor.after('query_async_serialized', (ctx) =>
|
||
Array.isArray(ctx.actual) && ctx.actual.length === 2 &&
|
||
ctx.actual[0].value === ctx.args[1] + 1 && ctx.actual[1].value === ctx.args[1] + 1),
|
||
rigor.after('params_isolation', (ctx) => {
|
||
const { a1, a2, b1, callsA } = ctx.actual;
|
||
return a1 === ctx.args[0] && a2 === ctx.args[0] && b1 === ctx.args[1] && callsA === 1;
|
||
}),
|
||
rigor.after('cross_subject_invalidate', (ctx) => {
|
||
const [xa, xb] = ctx.args;
|
||
const { aM, bM, aM2, bM2 } = ctx.actual;
|
||
return aM === xa * 2 && bM === xb * 2 && aM2 === xa * 2 && bM2 === xb * 2;
|
||
}),
|
||
rigor.after('invalidate_all_subjects', (ctx) => {
|
||
const [va, vb] = ctx.args;
|
||
const { callsBefore, callsAfterInvalidate, callsAfter, a2, b2 } = ctx.actual;
|
||
return callsBefore === 2 && callsAfterInvalidate === 2 && callsAfter === 4 && a2 === va && b2 === vb;
|
||
}),
|
||
rigor.after('ghost_parent_fusion', (ctx) => {
|
||
const [a, op] = ctx.args;
|
||
const expected = refOwaExact([a], op);
|
||
return ctx.actual === expected || (Number.isNaN(ctx.actual) && Number.isNaN(expected));
|
||
}),
|
||
rigor.after('attribute_hook_args', (ctx) => {
|
||
const { value, seen } = ctx.actual;
|
||
return value === ctx.args[1] && seen.nodeKey === ctx.args[0] && seen.path === 'balance';
|
||
}),
|
||
rigor.after('pattern_hook_args', (ctx) => {
|
||
const { value, seen } = ctx.actual;
|
||
return value === ctx.args[2] && seen.s === ctx.args[0] && seen.pattern.relation === ctx.args[1];
|
||
}),
|
||
rigor.after('ttl_boundary', (ctx) => {
|
||
const { freshAt0, freshAt99, staleAt100 } = ctx.actual;
|
||
return freshAt0 === true && freshAt99 === true && staleAt100 === false;
|
||
}),
|
||
rigor.after('ttl_zero_never_expires', (ctx) => ctx.actual === true),
|
||
rigor.after('set_preserves_unit', (ctx) =>
|
||
ctx.actual.value === ctx.args[0] && ctx.actual.unit === 'usd_cents' && ctx.actual.source === 'ledger'),
|
||
rigor.after('graph_string_fusion', (ctx) => {
|
||
const [x, y, op] = ctx.args;
|
||
if (op === 'max') return ctx.actual === (x > y ? x : y);
|
||
if (op === 'min') return ctx.actual === (x < y ? x : y);
|
||
return ctx.actual === x; // majority over {x, y} → first mode encountered
|
||
}),
|
||
rigor.after('graph_sum_capped', (ctx) => ctx.actual === Math.min(1, ctx.args[0] + ctx.args[1])),
|
||
rigor.after('fusion_dup_parents', (ctx) => {
|
||
const [a, op] = ctx.args;
|
||
const expected = refOwaExact([a], op);
|
||
return ctx.actual === expected || (Number.isNaN(ctx.actual) && Number.isNaN(expected));
|
||
}),
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Arbitrary random-topology DAGs vs the independent reference, complexity
|
||
// verification, and the error contract.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const TOPOLOGY_ACTIONS = [
|
||
// Three mid fusion nodes over randomly-picked source pairs, then a root
|
||
// fusion over all three mids + one source. Full random topology × operators
|
||
// (scalar args so shrinking can't collapse array lengths).
|
||
rigor.fn('random_dag_ref', (s0, s1, s2, op0, op1, op2, p0a, p0b, p1a, p1b, p2a, p2b, opRoot, extra) => {
|
||
const sources = [s0, s1, s2];
|
||
const ops = [op0, op1, op2];
|
||
const pairs = [[p0a, p0b], [p1a, p1b], [p2a, p2b]];
|
||
const vg = new ValueGraph();
|
||
vg.define('s0', { operator: 'source', fn: () => s0 });
|
||
vg.define('s1', { operator: 'source', fn: () => s1 });
|
||
vg.define('s2', { operator: 'source', fn: () => s2 });
|
||
const mids = [];
|
||
for (let i = 0; i < 3; i++) {
|
||
const rel = `m${i}`;
|
||
const [a, b] = pairs[i];
|
||
vg.define(rel, { operator: `fusion:${ops[i]}`, parents: [`s${a}`, `s${b}`] });
|
||
mids.push(rel);
|
||
}
|
||
vg.define('out', { operator: `fusion:${opRoot}`, parents: [...mids, `s${extra}`] });
|
||
const entry = syncGet(vg, 't', 'out', {});
|
||
return entry ? entry.value : 'NO_ENTRY';
|
||
}, rigor.args(rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), rigor.gen.int(-20, 20),
|
||
ANY_OPS, ANY_OPS, ANY_OPS,
|
||
rigor.gen.int(0, 2), rigor.gen.int(0, 2), rigor.gen.int(0, 2), rigor.gen.int(0, 2), rigor.gen.int(0, 2), rigor.gen.int(0, 2),
|
||
ANY_OPS, rigor.gen.int(0, 2))),
|
||
|
||
// A compute chain of depth n evaluates in O(n) resolver calls (source + n-1
|
||
// computes = exactly n, so the cost metric is offset-free for the e-process).
|
||
rigor.fn('chain_run', (n) => {
|
||
const vg = new ValueGraph();
|
||
let calls = 0;
|
||
let prev = 's';
|
||
vg.define(prev, { operator: 'source', fn: (s, p, ctx, cb) => { calls++; cb(null, 0); } });
|
||
for (let i = 1; i < n; i++) {
|
||
const rel = `c${i}`;
|
||
const parent = prev;
|
||
vg.define(rel, { operator: 'compute', parents: [parent], fn: (s, p, ctx, cb) => { calls++; cb(null, ctx.deps[parent] + 1); } });
|
||
prev = rel;
|
||
}
|
||
syncGet(vg, 't', prev, {});
|
||
return { ops: calls };
|
||
}, rigor.args(rigor.gen.int(1, 600)),
|
||
rigor.metric('n', ({ args }) => args[0]),
|
||
rigor.metric('cost', ({ result }) => result.ops)),
|
||
|
||
// plan() over a chain of depth n is O(n).
|
||
rigor.fn('plan_chain', (n) => {
|
||
const vg = new ValueGraph();
|
||
let prev = 's';
|
||
vg.define(prev, { operator: 'source', fn: () => 0 });
|
||
for (let i = 1; i < n; i++) {
|
||
const rel = `c${i}`;
|
||
vg.define(rel, { operator: 'compute', parents: [prev], fn: (s, p, { deps }) => deps[prev] + 1 });
|
||
prev = rel;
|
||
}
|
||
return { ops: vg.plan('t', prev, {}).size };
|
||
}, rigor.args(rigor.gen.int(1, 600)),
|
||
rigor.metric('n', ({ args }) => args[0]),
|
||
rigor.metric('cost', ({ result }) => result.ops)),
|
||
|
||
// weightFor allocates O(n) weights.
|
||
rigor.fn('weightFor_alloc', (op, n) => weightFor(op, n),
|
||
rigor.args(ANY_OPS, rigor.gen.int(0, 2000)),
|
||
rigor.metric('n', ({ args }) => args[1]),
|
||
rigor.metric('cost', ({ result }) => result.length)),
|
||
];
|
||
|
||
function tryGet(vg, subject, rel, params = {}) {
|
||
try {
|
||
const e = syncGet(vg, subject, rel, params);
|
||
return { ok: true, value: e ? e.value : null };
|
||
} catch (err) {
|
||
return { ok: false, error: err.message };
|
||
}
|
||
}
|
||
|
||
const ERROR_ACTIONS = [
|
||
rigor.fn('err_source_no_fn', () => {
|
||
const vg = new ValueGraph();
|
||
vg.define('x', { operator: 'source' });
|
||
return tryGet(vg, 't', 'x', {});
|
||
}, rigor.args()),
|
||
|
||
rigor.fn('err_unknown_op', () => {
|
||
const vg = new ValueGraph();
|
||
vg.define('x', { operator: 'bogus' });
|
||
return tryGet(vg, 't', 'x', {});
|
||
}, rigor.args()),
|
||
|
||
rigor.fn('err_product_nonnumeric', () => {
|
||
const vg = new ValueGraph();
|
||
vg.define('a', { operator: 'source', fn: () => 'x' });
|
||
vg.define('b', { operator: 'source', fn: () => 'y' });
|
||
vg.define('out', { operator: 'fusion:product', parents: ['a', 'b'] });
|
||
return tryGet(vg, 't', 'out', {});
|
||
}, rigor.args()),
|
||
|
||
rigor.fn('err_run_requires_cb', () => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('x', () => 1);
|
||
const plan = vg.plan('t', 'x', {});
|
||
try { vg.run(plan); return { ok: true }; } catch (err) { return { ok: false, error: err.message }; }
|
||
}, rigor.args()),
|
||
|
||
rigor.fn('err_get_requires_cb', () => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('x', () => 1);
|
||
try { vg.get('t', 'x', {}); return { ok: true }; } catch (err) { return { ok: false, error: err.message }; }
|
||
}, rigor.args()),
|
||
];
|
||
|
||
const TOPOLOGY_CHECKS = [
|
||
rigor.after('random_dag_ref', (ctx) => {
|
||
const [s0, s1, s2, op0, op1, op2, p0a, p0b, p1a, p1b, p2a, p2b, opRoot, extra] = ctx.args;
|
||
const sources = [s0, s1, s2];
|
||
const ops = [op0, op1, op2];
|
||
const pairs = [[p0a, p0b], [p1a, p1b], [p2a, p2b]];
|
||
const mids = [];
|
||
for (let i = 0; i < 3; i++) {
|
||
const [a, b] = pairs[i];
|
||
// Duplicate parents are a SET in the plan (deduplicated) — mirror that.
|
||
mids.push(refOwaExact([...new Set([a, b])].map((idx) => sources[idx]), ops[i]));
|
||
}
|
||
const expected = refOwaExact([...mids, sources[extra]], opRoot);
|
||
return ctx.actual === expected || (Number.isNaN(ctx.actual) && Number.isNaN(expected));
|
||
}),
|
||
rigor.after('err_source_no_fn', (ctx) => ctx.actual.ok === false && /needs an fn/.test(ctx.actual.error)),
|
||
rigor.after('err_unknown_op', (ctx) => ctx.actual.ok === false && /unknown operator/.test(ctx.actual.error)),
|
||
rigor.after('err_product_nonnumeric', (ctx) => ctx.actual.ok === false && /numeric/.test(ctx.actual.error)),
|
||
rigor.after('err_run_requires_cb', (ctx) => ctx.actual.ok === false && /requires a callback/.test(ctx.actual.error)),
|
||
rigor.after('err_get_requires_cb', (ctx) => ctx.actual.ok === false && /requires a callback/.test(ctx.actual.error)),
|
||
];
|
||
|
||
describe('Complexity verification (linear run/plan/weightFor)', () => {
|
||
it('run/plan/weightFor are linear (complexity campaign)', async () => {
|
||
await expectPass('complexity',
|
||
[TOPOLOGY_ACTIONS[1], TOPOLOGY_ACTIONS[2], TOPOLOGY_ACTIONS[3]],
|
||
[rigor.complexity('chain_run', 'O(n)'), rigor.complexity('plan_chain', 'O(n)'), rigor.complexity('weightFor_alloc', 'O(n)')],
|
||
{ effort: 300 });
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Compile-time optimization — plan metadata + ghost-parent elimination.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const OPTIMIZE_ACTIONS = [
|
||
// A fusion with three ghost parents: they are eliminated at compile time and
|
||
// the result is exactly the reference over the real parents.
|
||
rigor.fn('optimize_ghost_random', (a, b, c, op) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('a', { operator: 'source', fn: () => a });
|
||
vg.define('b', { operator: 'source', fn: () => b });
|
||
vg.define('c', { operator: 'source', fn: () => c });
|
||
vg.define('out', { operator: `fusion:${op}`, parents: ['a', 'ghost1', 'b', 'ghost2', 'c', 'ghost3'] });
|
||
const plan = vg.plan('t', 'out', {});
|
||
const value = syncGet(vg, 't', 'out', {}).value;
|
||
return { value, ghostPruned: plan.ghostPruned, size: plan.size, hasGhost: plan.nodes.has('ghost1') };
|
||
}, rigor.args(rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), ANY_OPS)),
|
||
|
||
// Fully-cached plan: one trivial leaf, zero recomputes.
|
||
rigor.fn('optimize_cached', (a, op) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('a', { operator: 'source', fn: () => a });
|
||
vg.define('out', { operator: `fusion:${op}`, parents: ['a'] });
|
||
syncGet(vg, 't', 'out', {});
|
||
const plan = vg.plan('t', 'out', {});
|
||
return { size: plan.size, recomputeCount: plan.recomputeCount, prunedCount: plan.prunedCount };
|
||
}, rigor.args(rigor.gen.int(-50, 50), ANY_OPS)),
|
||
|
||
// After invalidating a parent: plan recomputes exactly the affected closure and
|
||
// serves the untouched sibling from cache.
|
||
rigor.fn('optimize_invalidated', (a, b, op) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('a', { operator: 'source', fn: () => a });
|
||
vg.define('b', { operator: 'source', fn: () => b });
|
||
vg.define('m', { operator: `fusion:${op}`, parents: ['a', 'b'] });
|
||
syncGet(vg, 't', 'm', {});
|
||
vg.invalidate('t', 'a', {});
|
||
const plan = vg.plan('t', 'm', {});
|
||
return { size: plan.size, recomputeCount: plan.recomputeCount, prunedCount: plan.prunedCount, bTriv: plan.nodes.get('b').trivial, aTriv: plan.nodes.get('a').trivial };
|
||
}, rigor.args(rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), ANY_OPS)),
|
||
];
|
||
|
||
const OPTIMIZE_CHECKS = [
|
||
rigor.after('optimize_ghost_random', (ctx) => {
|
||
const [a, b, c, op] = ctx.args;
|
||
const { value, ghostPruned, size, hasGhost } = ctx.actual;
|
||
return ghostPruned === 3 && hasGhost === false && size === 4 &&
|
||
(value === refOwaExact([a, b, c], op) || (Number.isNaN(value) && Number.isNaN(refOwaExact([a, b, c], op))));
|
||
}),
|
||
rigor.after('optimize_cached', (ctx) => {
|
||
const { size, recomputeCount, prunedCount } = ctx.actual;
|
||
return size === 1 && recomputeCount === 0 && prunedCount === 1;
|
||
}),
|
||
rigor.after('optimize_invalidated', (ctx) => {
|
||
const { size, recomputeCount, prunedCount, bTriv, aTriv } = ctx.actual;
|
||
return size === 3 && recomputeCount === 2 && prunedCount === 1 && bTriv === true && aTriv === false;
|
||
}),
|
||
];
|
||
|
||
describe('Compile-time optimization', () => {
|
||
it('ghost elimination, cached-leaf pruning, and invalidated-closure plans (ONE campaign)', async () => {
|
||
await expectPass('optimize', OPTIMIZE_ACTIONS, OPTIMIZE_CHECKS, { effort: 900 });
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Model-based conformance of the graph's caching/versioning contract, and
|
||
// query-duplex lifecycle invariants.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
function makeGraphSut(shared) {
|
||
const vg = new ValueGraph({ defaultTTL: 0 });
|
||
vg.compute('v', (s, p, ctx, cb) => cb(null, shared.get(s) ?? 0));
|
||
return {
|
||
mutate(subject, value) { shared.set(subject, value); return value; },
|
||
get(subject) { const e = syncGet(vg, subject, 'v', {}); return e ? e.value : null; },
|
||
invalidate(subject) { vg.invalidate(subject, 'v', {}); return 'ok'; },
|
||
set(subject, value) { vg.set(subject, 'v', {}, { value }); return value; },
|
||
clone() { return makeGraphSut(shared); }
|
||
};
|
||
}
|
||
|
||
function graphCacheModelOps(shared) {
|
||
return [
|
||
{ name: 'mutate', args: rigor.gen.tuple(rigor.gen.string(), rigor.gen.int(-100, 100)), run: (m, subject, value) => { shared.set(subject, value); return value; } },
|
||
{ name: 'get', args: rigor.gen.string(), run: (m, subject) => {
|
||
const e = m.cache.get(subject);
|
||
if (e && e.version === m.version) return e.value;
|
||
const v = shared.get(subject) ?? 0;
|
||
m.cache.set(subject, { value: v, version: m.version });
|
||
return v;
|
||
} },
|
||
{ name: 'invalidate', args: rigor.gen.string(), run: (m, subject) => { m.version++; m.cache.delete(subject); return 'ok'; } },
|
||
{ name: 'set', args: rigor.gen.tuple(rigor.gen.string(), rigor.gen.int(-100, 100)), run: (m, subject, value) => { m.cache.set(subject, { value, version: m.version }); return value; } },
|
||
];
|
||
}
|
||
|
||
const QUERY_LIFECYCLE_ACTIONS = [
|
||
// A randomized get/invalidate stream: every delivered value is correct and
|
||
// every stale trigger is well-formed.
|
||
rigor.fn('query_stream', (subject, value, n, cb) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, done) => done(null, value));
|
||
const q = vg.query(subject, 'v', {});
|
||
q.source.pipe(cb);
|
||
for (let i = 0; i < n; i++) {
|
||
q.sink.write({ get: true });
|
||
if (i % 2 === 1) vg.invalidate(subject, 'v', {});
|
||
}
|
||
setTimeout(() => q.sink.end(), 30);
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100), rigor.gen.int(2, 6), rigor.handler(reducers.array()))),
|
||
|
||
// After end(), further writes are no-ops: exactly one value, then silence.
|
||
rigor.fn('query_after_end', (subject, value, cb) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, done) => done(null, value));
|
||
const q = vg.query(subject, 'v', {});
|
||
q.source.pipe(cb);
|
||
q.sink.write({ get: true });
|
||
q.sink.end();
|
||
q.sink.write({ get: true });
|
||
vg.invalidate(subject, 'v', {});
|
||
q.sink.write({ get: true });
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100), rigor.handler(reducers.array()))),
|
||
|
||
// abort() ends the source with the given error.
|
||
rigor.fn('query_abort', (subject, value, cb) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, done) => done(null, value));
|
||
const q = vg.query(subject, 'v', {});
|
||
q.source.pipe(cb);
|
||
q.sink.write({ get: true });
|
||
q.sink.write({ abort: 'gone' });
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100), rigor.handler(reducers.array()))),
|
||
];
|
||
|
||
const QUERY_LIFECYCLE_CHECKS = [
|
||
rigor.after('query_stream', (ctx) => {
|
||
const stream = ctx.actual;
|
||
if (!Array.isArray(stream) || stream.length === 0) return false;
|
||
const values = stream.filter((i) => i && i.value !== undefined);
|
||
const stales = stream.filter((i) => i && i.stale === true);
|
||
const errors = stream.filter((i) => i && i.error !== undefined);
|
||
return values.length > 0 &&
|
||
values.every((i) => i.value === ctx.args[1]) &&
|
||
stales.every((i) => i.relation === 'v') &&
|
||
errors.length === 0;
|
||
}),
|
||
rigor.after('query_after_end', (ctx) =>
|
||
Array.isArray(ctx.actual) && ctx.actual.length === 1 && ctx.actual[0].value === ctx.args[1]),
|
||
rigor.after('query_abort', (ctx) => ctx.error != null && /gone/.test(ctx.error.message)),
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Store-fault robustness — a throwing store must never hang or crash the graph.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
function failingStore(opts = {}) {
|
||
return {
|
||
get: () => { if (opts.failReads) throw new Error('io read'); return undefined; },
|
||
set: () => { if (opts.failWrites) throw new Error('disk full'); },
|
||
delete: () => false,
|
||
clear: () => {},
|
||
keys: () => [],
|
||
deletePrefix: () => 0,
|
||
};
|
||
}
|
||
|
||
const STORE_FAULT_ACTIONS = [
|
||
// Store writes always fail → every get still returns the correct value
|
||
// (uncached serve), never hangs, never throws synchronously.
|
||
rigor.fn('store_write_fails', (subject, value) => {
|
||
const vg = new ValueGraph({ store: failingStore({ failWrites: true }) });
|
||
vg.compute('v', (s, p, ctx, cb) => cb(null, value));
|
||
const a = syncGet(vg, subject, 'v', {});
|
||
const b = syncGet(vg, subject, 'v', {});
|
||
return { a: a.value, b: b.value };
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100))),
|
||
|
||
// Store writes fail the first time, then succeed → value is correct and the
|
||
// second get is served from cache.
|
||
rigor.fn('store_write_flaky', (subject, value) => {
|
||
let fail = true;
|
||
const vg = new ValueGraph({
|
||
store: { ...failingStore(), set: () => { if (fail) { fail = false; throw new Error('disk full'); } } }
|
||
});
|
||
vg.compute('v', (s, p, ctx, cb) => cb(null, value));
|
||
const a = syncGet(vg, subject, 'v', {});
|
||
const cachedAfterFirst = vg.plan(subject, 'v', {}).nodes.get('v').trivial;
|
||
const b = syncGet(vg, subject, 'v', {});
|
||
return { a: a.value, b: b.value, cachedAfterFirst };
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100))),
|
||
|
||
// Store reads fail → get surfaces the error via cb (not a hang/throw).
|
||
rigor.fn('store_read_fails', (subject, cb) => {
|
||
const vg = new ValueGraph({ store: failingStore({ failReads: true }) });
|
||
vg.compute('v', (s, p, ctx, c) => c(null, 1));
|
||
vg.get(subject, 'v', {}, cb);
|
||
}, rigor.args(rigor.gen.string(), rigor.handler(reducers.first()))),
|
||
];
|
||
|
||
const STORE_FAULT_CHECKS = [
|
||
rigor.after('store_write_fails', (ctx) => ctx.actual.a === ctx.args[1] && ctx.actual.b === ctx.args[1]),
|
||
rigor.after('store_write_flaky', (ctx) => ctx.actual.a === ctx.args[1] && ctx.actual.b === ctx.args[1] && ctx.actual.cachedAfterFirst === false),
|
||
rigor.after('store_read_fails', (ctx) => ctx.error != null && /io read/.test(ctx.error.message)),
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Algebraic OWA invariants, params-scoped invalidation, CSE, set→invalidate,
|
||
// and action-level fault tolerance.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const ALGEBRAIC_ACTIONS = [
|
||
// Order invariance: results depend only on the multiset (sort-based), so a
|
||
// deterministic shuffle of the inputs never changes the result — any operator.
|
||
rigor.fn('owa_order_invariant', (op, values, seed) => {
|
||
const shuffled = [...values];
|
||
let s = seed >>> 0;
|
||
const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 0xffffffff; };
|
||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||
const j = Math.floor(rnd() * (i + 1));
|
||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||
}
|
||
return { a: owa(values, op, {}), b: owa(shuffled, op, {}) };
|
||
}, rigor.args(ANY_OPS, intArr, rigor.gen.int(1, 1e9))),
|
||
|
||
// Duplication invariance: duplicating every element leaves max/min/average/
|
||
// median unchanged.
|
||
rigor.fn('owa_dup_invariant', (op, values) => {
|
||
const dup = values.concat(values);
|
||
return { a: owa(values, op, {}), b: owa(dup, op, {}) };
|
||
}, rigor.args(rigor.gen.oneOf(['max', 'min', 'average', 'median']), intArr)),
|
||
|
||
// Params-scoped invalidation: invalidating {which:'a'} deletes that params
|
||
// entry; every subject/params value stays correct (version bump is
|
||
// relation-level, so the sibling also recomputes — but always correctly).
|
||
rigor.fn('params_scoped_invalidate', (va, vb) => {
|
||
let calls = 0;
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, cb) => { calls++; cb(null, p.which === 'a' ? va : vb); });
|
||
const a1 = syncGet(vg, 't', 'v', { which: 'a' }).value;
|
||
const b1 = syncGet(vg, 't', 'v', { which: 'b' }).value;
|
||
const callsBefore = calls;
|
||
vg.invalidate('t', 'v', { which: 'a' });
|
||
const aCachedAfter = vg.plan('t', 'v', { which: 'a' }).nodes.get('v').trivial;
|
||
const bCachedAfter = vg.plan('t', 'v', { which: 'b' }).nodes.get('v').trivial;
|
||
const a2 = syncGet(vg, 't', 'v', { which: 'a' }).value;
|
||
const b2 = syncGet(vg, 't', 'v', { which: 'b' }).value;
|
||
return { a1, a2, b1, b2, callsBefore, aCachedAfter, bCachedAfter };
|
||
}, rigor.args(rigor.gen.int(-100, 100), rigor.gen.int(-100, 100))),
|
||
|
||
// Shared-subtree CSE: a diamond DAG where two roots share mid node m. Across
|
||
// the first two gets AND across the post-invalidation recompute, m is computed
|
||
// exactly once each (2 total calls, not 3).
|
||
rigor.fn('shared_subtree_cse', (a, b, op) => {
|
||
let mCalls = 0;
|
||
const vg = new ValueGraph();
|
||
vg.define('a', { operator: 'source', fn: () => a });
|
||
vg.define('b', { operator: 'source', fn: () => b });
|
||
vg.define('m', { operator: 'compute', parents: ['a', 'b'], fn: (s, p, ctx, cb) => { mCalls++; cb(null, ctx.deps.a + ctx.deps.b); } });
|
||
vg.define('r1', { operator: `fusion:${op}`, parents: ['m'] });
|
||
vg.define('r2', { operator: `fusion:${op}`, parents: ['m'] });
|
||
syncGet(vg, 't', 'r1', {});
|
||
syncGet(vg, 't', 'r2', {});
|
||
const callsAfterBoth = mCalls;
|
||
vg.invalidate('t', 'a', {});
|
||
const callsAfterInvalidate = mCalls;
|
||
const r1v = syncGet(vg, 't', 'r1', {}).value;
|
||
const r2v = syncGet(vg, 't', 'r2', {}).value;
|
||
return { callsAfterBoth, callsAfterInvalidate, callsAfter: mCalls, r1v, r2v };
|
||
}, rigor.args(rigor.gen.int(-20, 20), rigor.gen.int(-20, 20), ANY_OPS)),
|
||
|
||
// set→invalidate: invalidate clears the eager value; the next get recomputes
|
||
// from the resolver, not the set value.
|
||
rigor.fn('set_then_invalidate', (value) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, cb) => cb(null, 5));
|
||
vg.set('t', 'v', {}, { value });
|
||
const afterSet = syncGet(vg, 't', 'v', {}).value;
|
||
vg.invalidate('t', 'v', {});
|
||
const afterInvalidate = syncGet(vg, 't', 'v', {}).value;
|
||
return { afterSet, afterInvalidate };
|
||
}, rigor.args(rigor.gen.int(-50, 50))),
|
||
];
|
||
|
||
const ALGEBRAIC_CHECKS = [
|
||
rigor.after('owa_order_invariant', (ctx) => ctx.actual.a === ctx.actual.b || (Number.isNaN(ctx.actual.a) && Number.isNaN(ctx.actual.b))),
|
||
rigor.after('owa_dup_invariant', (ctx) => {
|
||
const a = ctx.actual.a;
|
||
const b = ctx.actual.b;
|
||
if (Number.isNaN(a) && Number.isNaN(b)) return true;
|
||
return typeof a === 'number' && typeof b === 'number' && Math.abs(a - b) < 1e-6;
|
||
}),
|
||
rigor.after('params_scoped_invalidate', (ctx) => {
|
||
const [va, vb] = ctx.args;
|
||
const { a1, a2, b1, b2, callsBefore, aCachedAfter, bCachedAfter } = ctx.actual;
|
||
return a1 === va && a2 === va && b1 === vb && b2 === vb &&
|
||
callsBefore === 2 && aCachedAfter === false && bCachedAfter === false;
|
||
}),
|
||
rigor.after('shared_subtree_cse', (ctx) => {
|
||
const { callsAfterBoth, callsAfterInvalidate, callsAfter, r1v, r2v } = ctx.actual;
|
||
return callsAfterBoth === 1 && callsAfterInvalidate === 1 && callsAfter === 2 &&
|
||
r1v === ctx.args[0] + ctx.args[1] && r2v === ctx.args[0] + ctx.args[1];
|
||
}),
|
||
rigor.after('set_then_invalidate', (ctx) => ctx.actual.afterSet === ctx.args[0] && ctx.actual.afterInvalidate === 5),
|
||
];
|
||
|
||
// Action-level fault injection: under injected faults the graph either serves the
|
||
// correct value or surfaces the error — never hangs, never corrupts.
|
||
const FAULT_ACTIONS = [
|
||
rigor.fn('cb_get_faults', (subject, value, cb) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, done) => done(null, value));
|
||
vg.get(subject, 'v', {}, cb);
|
||
}, rigor.args(rigor.gen.string(), rigor.gen.int(0, 100), rigor.handler(reducers.first()))),
|
||
];
|
||
|
||
describe('Action-level fault injection', () => {
|
||
it('under injected action faults the callback contract still holds (correct-or-error, never hang)', async () => {
|
||
const vocabulary = rigor.faults([rigor.fault.at('action:cb_get_faults', { kinds: ['throw'] })]);
|
||
await expectPass('cb-faults', FAULT_ACTIONS, [
|
||
rigor.after('cb_get_faults', (ctx) => ctx.error != null || (ctx.actual && ctx.actual.value === ctx.args[1])),
|
||
], { effort: 200, faults: { enabled: true, vocabulary, maxDepth: 2 } });
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Parallel firing of callback resolvers + shared-subtree CSE for ASYNC
|
||
// resolvers (the Overlay REST-call case).
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const PARALLEL_ACTIONS = [
|
||
// N independent async source resolvers must ALL be in flight simultaneously
|
||
// (maxInFlight === N) and each fired exactly once; the fused sum is exact.
|
||
rigor.fn('parallel_fanout', (n, value, cb) => {
|
||
let inFlight = 0;
|
||
let maxInFlight = 0;
|
||
const vg = new ValueGraph();
|
||
const parents = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const rel = `p${i}`;
|
||
vg.define(rel, { operator: 'source', fn: (s, p, ctx, done) => {
|
||
inFlight++; maxInFlight = Math.max(maxInFlight, inFlight);
|
||
setTimeout(() => { inFlight--; done(null, value + i); }, 2);
|
||
} });
|
||
parents.push(rel);
|
||
}
|
||
vg.define('root', { operator: 'fusion:sum_unbounded', parents });
|
||
vg.get('t', 'root', {}, (err, entry) => {
|
||
const expected = parents.reduce((a, r, i) => a + (value + i), 0);
|
||
cb(err, { maxInFlight, value: entry ? entry.value : null, expected });
|
||
});
|
||
}, rigor.args(rigor.gen.int(2, 6), rigor.gen.int(0, 100), rigor.handler(reducers.first()))),
|
||
|
||
// Diamond DAG with async resolvers: the shared node fires exactly once (CSE
|
||
// across paths within a single run — no duplicate REST calls).
|
||
rigor.fn('shared_async_cse', (value, cb) => {
|
||
let sCalls = 0;
|
||
const vg = new ValueGraph();
|
||
vg.define('s', { operator: 'source', fn: (x, p, ctx, done) => { sCalls++; setTimeout(() => done(null, value), 2); } });
|
||
vg.define('a', { operator: 'compute', parents: ['s'], fn: (x, p, ctx, done) => { setTimeout(() => done(null, ctx.deps.s + 1), 2); } });
|
||
vg.define('b', { operator: 'compute', parents: ['s'], fn: (x, p, ctx, done) => { setTimeout(() => done(null, ctx.deps.s + 2), 2); } });
|
||
vg.define('root', { operator: 'fusion:sum_unbounded', parents: ['a', 'b'] });
|
||
vg.get('t', 'root', {}, (err, entry) => cb(err, { sCalls, value: entry ? entry.value : null }));
|
||
}, rigor.args(rigor.gen.int(0, 100), rigor.handler(reducers.first()))),
|
||
|
||
// A shared subtree whose shared node is itself non-trivial with async parents:
|
||
// waiter fan-out must join, not re-fire, at every level.
|
||
rigor.fn('shared_async_deep', (value, cb) => {
|
||
let sCalls = 0;
|
||
let mCalls = 0;
|
||
const vg = new ValueGraph();
|
||
vg.define('s', { operator: 'source', fn: (x, p, ctx, done) => { sCalls++; setTimeout(() => done(null, value), 2); } });
|
||
vg.define('m', { operator: 'compute', parents: ['s'], fn: (x, p, ctx, done) => { mCalls++; setTimeout(() => done(null, ctx.deps.s * 2), 2); } });
|
||
vg.define('r1', { operator: 'compute', parents: ['m'], fn: (x, p, ctx, done) => { setTimeout(() => done(null, ctx.deps.m + 1), 2); } });
|
||
vg.define('r2', { operator: 'compute', parents: ['m'], fn: (x, p, ctx, done) => { setTimeout(() => done(null, ctx.deps.m + 2), 2); } });
|
||
vg.define('root', { operator: 'fusion:sum_unbounded', parents: ['r1', 'r2'] });
|
||
vg.get('t', 'root', {}, (err, entry) => cb(err, { sCalls, mCalls, value: entry ? entry.value : null }));
|
||
}, rigor.args(rigor.gen.int(0, 100), rigor.handler(reducers.first()))),
|
||
];
|
||
|
||
const PARALLEL_CHECKS = [
|
||
rigor.after('parallel_fanout', (ctx) =>
|
||
ctx.error == null && ctx.actual.maxInFlight === ctx.args[0] && ctx.actual.value === ctx.actual.expected),
|
||
rigor.after('shared_async_cse', (ctx) =>
|
||
ctx.error == null && ctx.actual.sCalls === 1 && ctx.actual.value === (ctx.args[0] + 1) + (ctx.args[0] + 2)),
|
||
rigor.after('shared_async_deep', (ctx) =>
|
||
ctx.error == null && ctx.actual.sCalls === 1 && ctx.actual.mCalls === 1 &&
|
||
ctx.actual.value === (ctx.args[0] * 2 + 1) + (ctx.args[0] * 2 + 2)),
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Typed values — a DSL-declared returnType is enforced on set and resolver
|
||
// results.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const TYPED_ACTIONS = [
|
||
rigor.fn('typed_set_ok', (value) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('n', { operator: 'source', returnType: 'number', fn: () => null });
|
||
vg.set('t', 'n', {}, { value });
|
||
return syncGet(vg, 't', 'n', {}).value;
|
||
}, rigor.args(rigor.gen.int(-1000, 1000))),
|
||
|
||
rigor.fn('typed_set_reject', (value) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('s', { operator: 'source', returnType: 'string', fn: () => null });
|
||
try { vg.set('t', 's', {}, { value }); return { ok: true }; }
|
||
catch (e) { return { ok: false, error: e.message }; }
|
||
}, rigor.args(rigor.gen.int(0, 100))),
|
||
|
||
rigor.fn('typed_resolver_reject', (value) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('s', { operator: 'source', returnType: 'string', fn: () => value });
|
||
return tryGet(vg, 't', 's', {});
|
||
}, rigor.args(rigor.gen.int(0, 100))),
|
||
];
|
||
|
||
const TYPED_CHECKS = [
|
||
rigor.after('typed_set_ok', (ctx) => ctx.actual === ctx.args[0]),
|
||
rigor.after('typed_set_reject', (ctx) => ctx.actual.ok === false && /must match declared type 'string'/.test(ctx.actual.error)),
|
||
rigor.after('typed_resolver_reject', (ctx) => ctx.actual.ok === false && /must match declared type 'string'/.test(ctx.actual.error)),
|
||
];
|
||
|
||
// NaN is not a meaningful derived value — it must be rejected at both the set
|
||
// and resolver-result boundaries (it would poison downstream OWA).
|
||
const NAN_ACTIONS = [
|
||
rigor.fn('nan_set_reject', () => {
|
||
const vg = new ValueGraph();
|
||
try { vg.set('t', 'v', {}, { value: Number.NaN }); return { ok: true }; }
|
||
catch (e) { return { ok: false, msg: e.message }; }
|
||
}, rigor.args()),
|
||
|
||
rigor.fn('nan_resolver_reject', () => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', () => Number.NaN);
|
||
return tryGet(vg, 't', 'v', {});
|
||
}, rigor.args()),
|
||
|
||
rigor.fn('nan_interval_ok', (a, b) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('est', { operator: 'source', returnType: 'interval', fn: () => null });
|
||
vg.set('t', 'est', {}, { value: { lower: a, upper: b } });
|
||
return syncGet(vg, 't', 'est', {}).value;
|
||
}, rigor.args(rigor.gen.int(-50, 50), rigor.gen.int(-50, 50))),
|
||
];
|
||
const NAN_CHECKS = [
|
||
rigor.after('nan_set_reject', (ctx) => ctx.actual.ok === false && /requires a value/.test(ctx.actual.msg)),
|
||
rigor.after('nan_resolver_reject', (ctx) => ctx.actual.ok === false && /must return a value/.test(ctx.actual.error)),
|
||
rigor.after('nan_interval_ok', (ctx) => ctx.actual.lower === ctx.args[0] && ctx.actual.upper === ctx.args[1]),
|
||
];
|
||
|
||
// Race safety: a slow read (async resolver) racing an authoritative set() or an
|
||
// invalidate() must not clobber the write or cache a pre-mutation snapshot.
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Stateful rigor.object protocol campaign, concurrent direct gets, stream
|
||
// error pushes, contract edges, and a benchmark smoke.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
function graphFacade(value) {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, cb) => cb(null, value));
|
||
return {
|
||
vg,
|
||
factoryValue: value,
|
||
lastSet: null,
|
||
get() { const e = syncGet(vg, 't', 'v', {}); return e ? e.value : null; },
|
||
set(v) { this.lastSet = v; vg.set('t', 'v', {}, { value: v }); return v; },
|
||
invalidate() { this.lastSet = null; vg.invalidate('t', 'v', {}); return 'ok'; },
|
||
clone() { const c = graphFacade(value); c.lastSet = this.lastSet; return c; }
|
||
};
|
||
}
|
||
|
||
const CONTRACT_EDGE_ACTIONS = [
|
||
// N concurrent direct gets on the SAME relation (async resolver): every get
|
||
// succeeds independently, each fires its own run, all values correct.
|
||
rigor.fn('concurrent_gets', (value, n, cb) => {
|
||
let calls = 0;
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, done) => { calls++; setTimeout(() => done(null, value), 1); });
|
||
const results = [];
|
||
let remaining = n;
|
||
for (let i = 0; i < n; i++) {
|
||
vg.get('t', 'v', {}, (err, e) => {
|
||
results.push(err ? 'ERR' : (e ? e.value : null));
|
||
if (--remaining === 0) cb(null, { calls, results });
|
||
});
|
||
}
|
||
}, rigor.args(rigor.gen.int(0, 100), rigor.gen.int(2, 5), rigor.handler(reducers.first()))),
|
||
|
||
// A run error inside a query duplex is pushed down as { error }.
|
||
rigor.fn('query_error_push', (subject, cb) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, done) => { done(new Error('boom')); });
|
||
const q = vg.query(subject, 'v', {});
|
||
q.source.pipe(cb);
|
||
q.sink.write({ get: true });
|
||
setTimeout(() => q.sink.end(), 20);
|
||
}, rigor.args(rigor.gen.string(), rigor.handler(reducers.array()))),
|
||
|
||
// set() rejects unsupported value shapes with the documented error.
|
||
rigor.fn('err_set_invalid', () => {
|
||
const vg = new ValueGraph();
|
||
try {
|
||
vg.set('t', 'v', {}, { value: { nested: true } });
|
||
return { ok: true };
|
||
} catch (e) {
|
||
return { ok: false, error: e.message };
|
||
}
|
||
}, rigor.args()),
|
||
|
||
// A resolver returning { value, unit, source } propagates the metadata onto
|
||
// the delivered entry, and does not mutate its input.
|
||
rigor.fn('resolver_meta', (value, cb) => {
|
||
const vg = new ValueGraph();
|
||
vg.define('v', { operator: 'source', fn: (s, p, ctx, done) => done(null, { value, unit: 'usd_cents', source: 'overlay:balances' }) });
|
||
vg.get('t', 'v', {}, (err, e) => cb(err, { value: e && e.value, unit: e && e.unit, source: e && e.source }));
|
||
}, rigor.args(rigor.gen.int(0, 100), rigor.handler(reducers.first()))),
|
||
|
||
// Benchmark smoke: get on a tiny graph is fast.
|
||
rigor.fn('bench_get', (value) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, cb) => cb(null, value));
|
||
const e = syncGet(vg, 't', 'v', {});
|
||
return e ? e.value : null;
|
||
}, rigor.args(rigor.gen.int(0, 100))),
|
||
];
|
||
|
||
const CONTRACT_EDGE_CHECKS = [
|
||
rigor.after('concurrent_gets', (ctx) =>
|
||
ctx.error == null && ctx.actual.results.length === ctx.args[1] &&
|
||
ctx.actual.calls === ctx.args[1] &&
|
||
ctx.actual.results.every((v) => v === ctx.args[0])),
|
||
rigor.after('query_error_push', (ctx) =>
|
||
Array.isArray(ctx.actual) && ctx.actual.some((i) => i && i.error && /boom/.test(i.error))),
|
||
rigor.after('err_set_invalid', (ctx) => ctx.actual.ok === false && /requires a value/.test(ctx.actual.error)),
|
||
rigor.after('resolver_meta', (ctx) =>
|
||
ctx.error == null && ctx.actual.value === ctx.args[0] &&
|
||
ctx.actual.unit === 'usd_cents' && ctx.actual.source === 'overlay:balances'),
|
||
rigor.benchmark('bench_get', { p50: { max: 5, unit: 'ms' }, p95: { max: 50, unit: 'ms' }, p99: { max: 200, unit: 'ms' } }),
|
||
];
|
||
|
||
describe('Stateful protocol (rigor.object) + benchmark', () => {
|
||
it('a rigor.object protocol campaign: after set(v) every get returns v until invalidate', async () => {
|
||
const obj = rigor.object('graph', () => graphFacade(7), [
|
||
rigor.method('get', function () { return this.get(); }),
|
||
rigor.method('set', function (v) { return this.set(v); }, rigor.args(rigor.gen.int(0, 100))),
|
||
rigor.method('invalidate', function () { return this.invalidate(); }),
|
||
]);
|
||
const checks = [
|
||
rigor.after('graph.get', (ctx) => ctx.error == null && typeof ctx.actual === 'number'),
|
||
rigor.after('graph.set', (ctx) => {
|
||
const g = ctx.objects.graph;
|
||
return g.get() === ctx.args[0];
|
||
}),
|
||
rigor.between('graph.set', 'graph.invalidate', (ctx) => {
|
||
if (ctx.action !== 'graph.get') return true;
|
||
const g = ctx.objects.graph;
|
||
return g.lastSet === null || ctx.actual === g.lastSet;
|
||
}),
|
||
rigor.before('graph.set', (ctx) => ctx.objects.graph !== undefined),
|
||
];
|
||
await expectPass('object-protocol', [obj], checks, { effort: 150 });
|
||
});
|
||
|
||
it('get on a tiny graph meets a loose latency bound (benchmark smoke)', async () => {
|
||
await expectPass('benchmark', [CONTRACT_EDGE_ACTIONS[4]], [CONTRACT_EDGE_CHECKS[4]], { effort: 60 });
|
||
});
|
||
});
|
||
|
||
describe('Graph caching model (rigor.model)', () => {
|
||
it('get/set/invalidate/mutate conformance with the reference caching model', async () => {
|
||
const shared = new Map();
|
||
const result = rigor.model.check('graph-cache', { version: 0, cache: new Map() }, makeGraphSut(shared), {
|
||
operations: graphCacheModelOps(shared),
|
||
effort: 300,
|
||
maxSequenceLength: 50,
|
||
seed: 'graph-cache',
|
||
});
|
||
if (result.status !== 'passed') {
|
||
const detail = (result.failures || []).slice(0, 5).map((f) =>
|
||
JSON.stringify({ seq: f.sequence, at: f.commandIndex, expected: f.expected, actual: f.actual, shrunk: f.shrunk }));
|
||
throw new Error(`graph-cache model failed.\n${detail.join('\n')}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Mega-campaigns — the remaining plain-invariant families consolidated into two
|
||
// combined campaigns (many crucible arrays each; every check action-gated).
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const RACE_ACTIONS = [
|
||
rigor.fn('race_set_preserved', (value, cb) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, done) => { setTimeout(() => done(null, 10), 5); });
|
||
const slow = new Promise((res) => vg.get('t', 'v', {}, (e, v) => res(e ? null : (v && v.value))));
|
||
vg.set('t', 'v', {}, { value });
|
||
slow.then(() => vg.get('t', 'v', {}, (e, v) => cb(e, v ? v.value : null)));
|
||
}, rigor.args(rigor.gen.int(0, 100), rigor.handler(reducers.first()))),
|
||
|
||
rigor.fn('race_invalidate_recomputes', (a, b, cb) => {
|
||
let state = a;
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', (s, p, ctx, done) => { const snap = state; setTimeout(() => done(null, snap), 5); });
|
||
const slow = new Promise((res) => vg.get('t', 'v', {}, (e, v) => res(e ? null : (v && v.value))));
|
||
state = b;
|
||
vg.invalidate('t', 'v', {});
|
||
vg.get('t', 'v', {}, (e, v) => {
|
||
const after = v ? v.value : null;
|
||
slow.then(() => vg.get('t', 'v', {}, (e2, v2) => cb(e2, { after, final: v2 ? v2.value : null })));
|
||
});
|
||
}, rigor.args(rigor.gen.int(0, 100), rigor.gen.int(0, 100), rigor.handler(reducers.first()))),
|
||
|
||
rigor.fn('set_notifies_watchers', (value, cb) => {
|
||
const vg = new ValueGraph();
|
||
vg.compute('v', () => 1);
|
||
const q = vg.query('t', 'v', {});
|
||
const events = [];
|
||
q.source.pipe({ write: (x) => events.push(x), paused: false, ended: false, source: null, end: () => {}, abort: () => {} });
|
||
q.sink.write({ get: true });
|
||
vg.set('t', 'v', {}, { value });
|
||
setTimeout(() => { q.sink.end(); cb(null, events); }, 20);
|
||
}, rigor.args(rigor.gen.int(0, 100), rigor.handler(reducers.first()))),
|
||
];
|
||
const RACE_CHECKS = [
|
||
rigor.after('race_set_preserved', (ctx) => ctx.error == null && ctx.actual === ctx.args[0]),
|
||
rigor.after('race_invalidate_recomputes', (ctx) =>
|
||
ctx.error == null && ctx.actual.after === ctx.args[1] && ctx.actual.final === ctx.args[1]),
|
||
rigor.after('set_notifies_watchers', (ctx) =>
|
||
ctx.error == null && ctx.actual.some((e) => e && e.stale === true && e.relation === 'v' && e.via === undefined)),
|
||
];
|
||
|
||
describe('Mega: graph semantics (sync)', () => {
|
||
it('semantics + oracles + universal + optimize + typed + NaN + algebraic + random-DAG + errors + fusion-DAGs (ONE campaign)', async () => {
|
||
await expectPass('mega-graph-sync',
|
||
[
|
||
...GRAPH_ACTIONS,
|
||
...MORE_ACTIONS,
|
||
...UNIVERSAL_ACTIONS.slice(2),
|
||
...OPTIMIZE_ACTIONS,
|
||
...TYPED_ACTIONS,
|
||
...NAN_ACTIONS,
|
||
ALGEBRAIC_ACTIONS[2], ALGEBRAIC_ACTIONS[3], ALGEBRAIC_ACTIONS[4],
|
||
TOPOLOGY_ACTIONS[0],
|
||
...ERROR_ACTIONS,
|
||
...FUSION_ACTIONS,
|
||
],
|
||
GRAPH_CHECKS,
|
||
MORE_CHECKS,
|
||
UNIVERSAL_CHECKS.slice(2),
|
||
OPTIMIZE_CHECKS,
|
||
TYPED_CHECKS,
|
||
NAN_CHECKS,
|
||
[ALGEBRAIC_CHECKS[2], ALGEBRAIC_CHECKS[3], ALGEBRAIC_CHECKS[4]],
|
||
TOPOLOGY_CHECKS,
|
||
FUSION_CHECKS,
|
||
{ effort: 2200 });
|
||
});
|
||
});
|
||
|
||
describe('Mega: graph async + handlers + store faults + streams', () => {
|
||
it('callback contract + parallel/async CSE + races + store faults + lifecycle + contract edges (ONE campaign)', async () => {
|
||
await expectPass('mega-graph-async',
|
||
[
|
||
...CB_ACTIONS,
|
||
...STORE_FAULT_ACTIONS,
|
||
...QUERY_LIFECYCLE_ACTIONS,
|
||
...PARALLEL_ACTIONS,
|
||
...RACE_ACTIONS,
|
||
CONTRACT_EDGE_ACTIONS[0], CONTRACT_EDGE_ACTIONS[1], CONTRACT_EDGE_ACTIONS[2], CONTRACT_EDGE_ACTIONS[3],
|
||
],
|
||
CB_CHECKS,
|
||
STORE_FAULT_CHECKS,
|
||
QUERY_LIFECYCLE_CHECKS,
|
||
PARALLEL_CHECKS,
|
||
RACE_CHECKS,
|
||
[CONTRACT_EDGE_CHECKS[0], CONTRACT_EDGE_CHECKS[1], CONTRACT_EDGE_CHECKS[2], CONTRACT_EDGE_CHECKS[3]],
|
||
{ effort: 1200 });
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Snapshot persistence & transport — compact portable binary round-trips.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const SNAPSHOT_VAL = rigor.gen.oneOf(
|
||
rigor.gen.int(-1000, 1000),
|
||
rigor.gen.float(0, 100, { fractionDigits: 3 }),
|
||
rigor.gen.string(0, 12),
|
||
rigor.gen.boolean()
|
||
);
|
||
const SNAPSHOT_VALUE = rigor.gen.oneOf(
|
||
SNAPSHOT_VAL,
|
||
rigor.gen.array(SNAPSHOT_VAL, 0, 5),
|
||
rigor.gen.record({ lower: rigor.gen.float(-100, 100), upper: rigor.gen.float(-100, 100) })
|
||
);
|
||
|
||
const SNAPSHOT_ACTIONS = [
|
||
// Wire format: any typed value survives encodeValue/decodeValue exactly.
|
||
// (Synchronous actions: NO rigor.handler — a handler would wait on a
|
||
// callback this action never calls. rigor now settles a sync-returning
|
||
// action with a declared handler instead of hanging, but we keep the
|
||
// actions honest: no handler on sync actions.)
|
||
rigor.fn('value_wire_roundtrip', (value) => {
|
||
const buf = encodeSnapshot([['k', { value, unit: 'u', at: 1234, source: 's', version: 2, deps: {} }]]);
|
||
const decoded = decodeSnapshot(buf);
|
||
return { input: value, output: decoded.entries[0][1].value };
|
||
}, rigor.args(SNAPSHOT_VALUE)),
|
||
|
||
// Graph transport: set → snapshot → restore into a FRESH graph → get.
|
||
rigor.fn('graph_snapshot_roundtrip', (value) => {
|
||
const vg = new ValueGraph({ defaultTTL: 0 });
|
||
vg.define('v', { operator: 'source', fn: () => null });
|
||
vg.set('t:1', 'v', { k: 1 }, { value });
|
||
const restored = ValueGraph.restore(vg.snapshot());
|
||
let out;
|
||
restored.get('t:1', 'v', { k: 1 }, (e, r) => { if (!e) out = r ? r.value : null; });
|
||
return { input: value, output: out };
|
||
}, rigor.args(SNAPSHOT_VALUE)),
|
||
|
||
// THE transportability invariant: flipping ANY byte of a snapshot must NEVER
|
||
// silently decode — parse-guard or CRC-guard rejects every corruption.
|
||
rigor.fn('snapshot_flip_never_silent', (index, bit) => {
|
||
const vg = new ValueGraph({ defaultTTL: 0 });
|
||
vg.set('t', 'v', {}, { value: 1234 });
|
||
vg.set('t', 's', {}, { value: 'x' });
|
||
const buf = vg.snapshot();
|
||
const flipped = Buffer.from(buf);
|
||
const i = index % flipped.length;
|
||
flipped[i] = flipped[i] ^ (1 << (bit % 8));
|
||
let silentlyDecoded = false;
|
||
try { decodeGraphSnapshot(flipped); silentlyDecoded = true; } catch (e) { /* expected */ }
|
||
return { silent: silentlyDecoded, index: i };
|
||
}, rigor.args(rigor.gen.int(0, 256), rigor.gen.int(0, 7))),
|
||
];
|
||
|
||
const SNAPSHOT_CHECKS = [
|
||
rigor.after('value_wire_roundtrip', (ctx) =>
|
||
ctx.error == null && deepEqualValues(ctx.actual.input, ctx.actual.output)),
|
||
rigor.after('graph_snapshot_roundtrip', (ctx) =>
|
||
ctx.error == null && deepEqualValues(ctx.actual.input, ctx.actual.output)),
|
||
rigor.after('snapshot_flip_never_silent', (ctx) => ctx.error == null && ctx.actual.silent === false),
|
||
];
|
||
|
||
// Deep equality across number/string/boolean/array/interval.
|
||
function deepEqualValues(a, b) {
|
||
if (typeof a === 'bigint' || typeof b === 'bigint') return a === b;
|
||
if (Array.isArray(a) && Array.isArray(b)) {
|
||
return a.length === b.length && a.every((x, i) => deepEqualValues(x, b[i]));
|
||
}
|
||
if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
|
||
const ka = Object.keys(a);
|
||
const kb = Object.keys(b);
|
||
return ka.length === kb.length && ka.every((k) => deepEqualValues(a[k], b[k]));
|
||
}
|
||
return a === b;
|
||
}
|
||
|
||
describe('Snapshot persistence & transport (rigor)', () => {
|
||
it('wire round-trips are exact; restored graphs serve stored values; corruption NEVER decodes silently', async () => {
|
||
await expectPass('snapshot-transport', SNAPSHOT_ACTIONS, SNAPSHOT_CHECKS, { effort: 800 });
|
||
});
|
||
});
|