commit 1da6bc78426587f2e63e992ad86d5886e996df6c Author: Dvorak Date: Tue Aug 4 17:37:00 2026 -0700 value-graph 0.1.0: JSON-free, bigint-free binary snapshot layer - 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'). diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..4cb8c2d --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [master, main] + tags: ['v*'] + pull_request: + branches: [master, main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Auth for Gitea npm registry + run: | + echo "@arbiter:registry=https://hub.kl1.tenere.ai/api/packages/Arbiter/npm/" > .npmrc + echo "//hub.kl1.tenere.ai/api/packages/Arbiter/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc + echo "@push-stream-std:registry=https://hub.kl1.tenere.ai/api/packages/push-stream-std/npm/" >> .npmrc + echo "//hub.kl1.tenere.ai/api/packages/push-stream-std/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc + echo "@rigor:registry=https://hub.kl1.tenere.ai/api/packages/Rigor/npm/" >> .npmrc + echo "//hub.kl1.tenere.ai/api/packages/Rigor/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc + + - run: npm ci + + - name: Full suite + run: npm test + + publish: + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + needs: test + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Auth for Gitea npm registry + run: | + echo "@arbiter:registry=https://hub.kl1.tenere.ai/api/packages/Arbiter/npm/" > .npmrc + echo "//hub.kl1.tenere.ai/api/packages/Arbiter/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc + echo "@push-stream-std:registry=https://hub.kl1.tenere.ai/api/packages/push-stream-std/npm/" >> .npmrc + echo "//hub.kl1.tenere.ai/api/packages/push-stream-std/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc + echo "@rigor:registry=https://hub.kl1.tenere.ai/api/packages/Rigor/npm/" >> .npmrc + echo "//hub.kl1.tenere.ai/api/packages/Rigor/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc + + - run: npm ci + + - run: npm publish diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29120f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +artifacts/ +*.log +*.snap diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..c82fa22 --- /dev/null +++ b/.npmrc @@ -0,0 +1,6 @@ +@push-stream-std:registry=https://hub.kl1.tenere.ai/api/packages/push-stream-std/npm/ +//hub.kl1.tenere.ai/api/packages/push-stream-std/npm/:_authToken=${PACKAGE_TOKEN} +@arbiter:registry=https://hub.kl1.tenere.ai/api/packages/Arbiter/npm/ +//hub.kl1.tenere.ai/api/packages/Arbiter/npm/:_authToken=${PACKAGE_TOKEN} +@rigor:registry=https://hub.kl1.tenere.ai/api/packages/Rigor/npm/ +//hub.kl1.tenere.ai/api/packages/Rigor/npm/:_authToken=${PACKAGE_TOKEN} diff --git a/README.md b/README.md new file mode 100644 index 0000000..005f1e3 --- /dev/null +++ b/README.md @@ -0,0 +1,299 @@ +# @arbiter/value-graph + +The "second graph": a graph-structured, lazy, TTL + version-invalidated store of +derived values, built on `@push-stream-std`. The authorization graph +(`@arbiter/core`) stores structural edges with possibility; the value graph +stores **value relations** computed lazily on demand — the substrate for DSL +`measure` lookups and ARRA balances. + +## Node operator model + +Each value relation is an **operator node** with `parents` (its `dependsOn`): + +| Operator | Kind | Behavior | +|----------|------|----------| +| `source` | leaf | external value via `fn(subject, params, ctx, cb)` (e.g. ARRA) | +| `attribute` | leaf | reads a graph node field via `resolveAttribute(nodeKey, path, params, ctx, cb)` | +| `pattern` | leaf | graph-traversal value via `resolvePattern(pattern, subject, params, ctx, cb)` | +| `compute` | interior | `fn(subject, params, ctx, cb)` over parent values (`ctx.deps`) | +| `blackbox` | interior | general function of its dependencies — `fn(subject, params, ctx, cb)` with `ctx.deps` (parent values) and `ctx.entries` (full parent results `{ value, unit, at, source, fresh }`). The seam for composing an external (e.g. Overlay) query from parent edges/values | +| `fusion:` | interior | combines parent values by an OWA operator | + +## Callback resolver contract (no async/await/promises) + +Every resolver — `source`/`compute` `fn`, `resolveAttribute`, `resolvePattern` — +is **callback oriented**, matching the push-stream discipline of `@overlay` +adapters. A resolver MUST either: + +1. call `cb(err, result)` asynchronously and return `undefined`, or +2. return the result synchronously (pure compute / memoized reads). + +`result` may be a value, `{ value, unit, source }`, an interval `{ lower, upper }`, +or `null`/`undefined` (→ uncached `null`). Returning a **Promise is rejected** +with a descriptive error — this library is promise-free. This contract is what +lets the future `value-graph-overlay` bridge implement resolvers over Overlay's +callback/push-stream `FederatedQueryEngine`. + +### Black-box nodes + +A `blackbox` node is an arbitrary function of its resolved dependencies. Inside +the resolver, `ctx.deps[parent]` is the parent's **value**, and `ctx.entries[parent]` +is the parent's **full result** (`{ value, unit, at, source, fresh }`). Use it to +compose an external query from the parent edges/values — e.g. key an Overlay query +off a `pattern` parent's edge set: + +```js +vg.define('risk', { + operator: 'blackbox', + parents: ['related_edges', 'base_balance'], + fn: (subject, params, ctx, cb) => { + const edges = ctx.deps.related_edges; // array of edges from the pattern node + const balance = ctx.entries.base_balance; // full { value, unit, at, source, fresh } + overlayQuery(buildAst(subject, edges, balance), cb); // callback-based, no promises + } +}); +``` + +**OWA fusion operators** (ADR-000): `max`, `min`, `average`, `sum` (capped 1.0), +`sum_unbounded`, `majority`, `median`, `optimistic`, `pessimistic`, `top2`, +`top3`, `priority`, `custom` (user weights), `product`. Numeric values use +position-based weights over descending order; non-numeric values fall back to +`max`/`min`/`majority`. + +## Compile → optimize → run (never fused) + +- `plan(subject, relation, params)` walks `parents` into a node DAG and OPTIMIZES + it (compile and optimize are one call; the graph is deliberately **never fused** — + each operator stays a separately-cacheable node): + - **Cached-subtree pruning** — a cache-safe (fresh) node becomes a **single + trivial leaf with no parents**, pruning its dependency subtree, so `run()` + reads its cache instead of re-evaluating. + - **Ghost-parent elimination** — a fusion parent that is undeclared AND uncached + is guaranteed `null`, which fusion filters anyway; it is dropped at compile time + to avoid a wasted node evaluation. (Not applied to compute/blackbox, which + receive `null` in deps and may handle it.) + - Plan metadata: `size`, `recomputeCount` (nodes `run()` evaluates), + `prunedCount` (nodes served from cache), `ghostPruned` (dropped ghosts). +- `run(plan, cb)` evaluates the DAG in dependency order, dispatching each node to + its operator and caching the result — all callback-driven, no promises. + **Parallelism**: all independent parents are fired as soon as they are reached + (one synchronous pass starts every resolver), so N independent async resolvers + (e.g. Overlay REST calls) are in flight simultaneously — verified by test + (max in-flight === N). A node reachable via multiple paths (a diamond/shared + subtree) is evaluated **exactly once per run**: later paths join the in-flight + evaluation via waiter fan-out instead of re-firing the resolver (this fixed a + real double-firing bug for async resolvers). + **Race safety**: a run only caches its results if no `set`/`invalidate` occurred + mid-run (run-epoch guard) — a slow read can never clobber an authoritative + `set`, and a pre-mutation snapshot is never cached after an `invalidate` (both + fixed + rigor-verified). +- `invalidate(subject, relation, params)` requires the relation — calling it with + only a subject now throws instead of silently clearing the whole store + (`invalidate()` with no args still clears everything). +- `invalidate`/`set` compile the **affected dependent DAG** (the relation plus + every transitive dependent), push `{ stale: true }` triggers DOWN to active + queries (both notify the affected relation's own watchers; `set` marks the + set relation's trigger with no `via`, dependents with `via`), and bump + **per-relation version stamps** — so invalidation is + **selective**: only the affected closure recomputes; unrelated relations keep + their cached entries (verified by js-rigor). + +## Pull on duplex push + +`query(subject, relation, params)` returns a duplex: push `{ get: true }` up +(compile + run, serialized), receive the value down. External sources push +`invalidate`/`set` to send back-channel stale triggers. + +## Backing store & larger-than-memory + +The graph object itself holds only its **schema** (`relations` — the operator +definitions) and **active streams**; every cached entry lives in the swappable +backing store (`get`/`set`/`delete`/`clear`/`keys`/`deletePrefix`). That is what +makes the value graph **larger-than-memory** — data volume is decoupled from RAM, +exactly the posture of the auth graph (`@arbiter/core`'s condensed typed-array +graph + planned sharded snapshots, ADR-003). + +- `createMapStore()` — in-memory default (a `Map`). +- `createFileStore(dir, { maxResident })` — disk-backed reference: one **binary** + file per entry (the same JSON-free wire encoding the snapshots use), with a + bounded LRU **hot-page cache** on top. A fresh `ValueGraph` pointed at the same + directory reads cached values straight from disk without recompute — proven by + test. **No JSON, no JS bigint** on disk — large integers are `Uint8Array`. +- Any store implementing the synchronous contract is swappable — an mmap-backed + store, a remote catalog, or (later) an Overlay-backed store. + +```js +import { ValueGraph, createFileStore } from '@arbiter/value-graph'; + +const vg = new ValueGraph({ store: createFileStore('./vg-data', { maxResident: 256 }) }); +``` + +### Compact snapshots, read from disk, transportable + +A snapshot is a **single compact, CRC-guarded binary file** — no paths, no +machine-dependent encoding, **no JSON and no JS bigint on the wire** (large +integers are raw `Uint8Array` bytes; params/pattern/objects are deterministic +binary maps) — so a graph moves between processes and machines byte-for-byte. +Two flavours share one wire format: + +- **Store snapshot** — opaque `key → entry` dump of the backing store. + `store.snapshotFile(path)` / `store.restoreFile(path)` on both store flavours; + `createFileStore(dir, { loadFrom })` constructs a store already loaded from a + snapshot. +- **Graph snapshot** — the seamless artifact: **schema metadata** (operator, + parents, weights, TTL, returnType, params — minus functions) **+ every entry**. + +```js +vg.snapshotFile('./graph.snap'); // stream to disk, bounded memory +const buf = vg.snapshot(); // or a portable Buffer +const vg2 = ValueGraph.restore(buf, { resolvers: { balance: () => loadBalance() } }); +const vg3 = ValueGraph.loadFile('./graph.snap', { resolvers: { balance: () => loadBalance() } }); +``` + +`restore`/`loadFile` re-declare the schema and load entries with their version +re-stamped, so a restored cache is served **without recompute** (subject to TTL — +old snapshots expire on the wall-clock like any other entry). Functions cannot be +serialized: re-register resolvers via `{ resolvers }`. A corrupt or truncated +snapshot **throws** (magic/version/CRC/parse guards) — it never silently mis-reads. + +## Typed values (DSL-declared schema) + +The graph carries **schema metadata** from the evidence DSL: `define(rel, { returnType, params })` +records the declared return type (a primitive like `number`/`string`/`boolean`/`buffer`/ +`timestamp`/`array`/`interval`, or an entity/Definition name treated as a reference key) +and parameter types. `relationSpec(rel)` introspects it. **There is no JS `bigint` +type** — large integers are `buffer` (`Uint8Array`), matching the JSON-free wire. + +- `set(...)` validates the value against `returnType` (untyped relations are + backward-compatible and skip validation). +- Resolver results are validated against `returnType` too — a wrong-typed resolver + fails loudly instead of caching garbage. +- **`NaN` is rejected** at both the `set` and resolver-result boundaries — it is + not a meaningful derived value and would poison downstream OWA. +- `validateValueType(value, type)` is exported for consumers (the DSLValueGraph + integration uses it for binding + value checks). + +This is the storage/typing substrate for `DSLValueGraph` in `@arbiter/evidence-dsl`: +measures declared in the DSL become typed value-graph nodes; attributes that are +neither cached nor computed are stored via `setValue`; `measure()`/`runtime.measure()` +retrieve through the graph. + +## Example + +```js +import { ValueGraph } from '@arbiter/value-graph'; + +const vg = new ValueGraph({ + resolveAttribute: (nodeKey, path, params, ctx, cb) => cb(null, graphNodes.get(nodeKey)?.[path]) +}); + +vg.define('base_balance', { operator: 'source', fn: (s, p, ctx, cb) => cb(null, ledgerBalance(s)) }); +vg.define('risk_adjusted', { operator: 'compute', parents: ['base_balance'], fn: (s, p, { deps }) => deps.base_balance * 0.9 }); +vg.define('effective_clearance', { operator: 'fusion:max', parents: ['base', 'role', 'group'] }); +vg.define('avg_score', { operator: 'fusion:custom', parents: ['rep', 'activity', 'verif'], weights: [0.6, 0.3, 0.1] }); + +const plan = vg.plan('tenant:acme', 'risk_adjusted', {}); +vg.run(plan, (err, value) => { /* { value, unit, at, source, fresh } */ }); +``` + +## Deep testing (js-rigor) + +`test/value-graph.rigor.test.js` (`npm run test:rigor`) is a js-rigor suite: +- **OWA properties** — `owa`/`weightFor` invariants (sum/avg/max/min/median/top2/ + product/optimistic/pessimistic/custom, string fallbacks) + a `sum` oracle. This + caught and fixed two real edge cases: `weightFor('top2'|'top3', n)` over-produced + weight vectors for `n` smaller than the named positions, and `weightFor(op, 0)` + returned `[1]` for `max`/`min` instead of `[]`. +- **OWA oracle conformance** — an *independent* reimplementation of the ADR-000 OWA + formula (`refOwaExact`, mirrored arithmetic for bit-exact `===`) confirms every + operator: exact oracles for sum/max/min/product/median/string fallbacks + capped + sum, and **arbitrary operator combinations** (a frequency-weighted pick over all + 14 operators × random arrays × random weights/priorities) must match the reference. +- **Fusion DAG oracles** — the graph's fusion path must equal raw `owa` for any + operator, and **composed DAGs** (fusion-of-fusion, compute→fusion chains) must + equal the bottom-up reference over the same operators. +- **More correctness oracles** — mixed fusion+compute topologies vs the bottom-up + reference; **invalidation correctness** (changing a source and invalidating it + yields the correct new root value across the dependent subtree); **cache-key + isolation** (subjects never share entries, each computes once); **stale-on-error + fallback** (a failing resolver after TTL expiry returns the cached value with + `fresh:false` instead of erroring); **value-type round-trips** (array/interval/ + buffer survive the disk-backed store into a fresh graph instance); **blackbox + over fusion** (`fusion(a,b)·k` matches the reference); and **selective + invalidation** (invalidating/setting an unrelated relation leaves its cached + entry untouched; a sibling independent subtree stays cached). +- **Universal invariants** — for *every* operator: empty→`0`, singleton→element, + sum/sum_unbounded/product exact, weighted operators bounded within `[min,max]`, + and monotone non-decreasing under a `+1` bump. Plus: **plan exactness** + (fully-cached plan is one leaf; invalidation expands to exactly the affected + closure with unrelated leaves still trivial); **async serialization** (two + concurrent `{get}` pushes with an async resolver deliver both values and the + second is served from cache — resolver called once); **params cache-key + isolation**; **cross-subject invalidation correctness**; **invalidateAll clears + every subject**; **ghost parents are filtered from fusions**; **attribute/ + pattern hooks receive the documented arguments**; **TTL boundary is exact** + (fresh inside, stale at the window, `ttl:0` never expires); **`set` preserves + unit/source**; in-graph string fusion fallbacks + `capSum`; and **duplicate + parents are a single dependency** (parents are a set). +- **Random-topology DAG oracles** — three mid fusion nodes over randomly-picked + source pairs plus a root fusion, all with random operators, must match the + independent bottom-up reference (`refOwaExact`), including the duplicate-parent + set semantics. +- **Complexity verification** — `run()` on a deep compute chain is `O(n)` resolver + calls (no recomputation blowup), `plan()` compiles a deep chain in `O(n)`, and + `weightFor` allocates `O(n)` weights (deterministic cost metrics). +- **Compile-time optimization** — ghost parents are eliminated at compile time + (semantics preserved for every operator vs `refOwaExact`), a fully-cached plan is + a single trivial leaf with zero recomputes, and an invalidated plan recomputes + exactly the affected closure while serving the untouched sibling from cache. +- **Graph caching model (formal conformance)** — `rigor.model.check` runs random + `get`/`set`/`invalidate`/`mutate` sequences against a reference model of the + per-relation-version caching contract and the real graph; every value must match. + Pins the exact semantics: `set` writes fresh without bumping the relation version, + `invalidate` bumps it and recomputes, and a backing mutation without invalidation + stays cached until the next invalidation/TTL. +- **Query duplex lifecycle** — a randomized `get`/`invalidate` stream delivers only + correct values and well-formed stale triggers; `end()` cleans up so post-end + writes are silent no-ops (including `invalidate` pushes to the closed query); and + `abort()` surfaces its error on the stream. +- **Store-fault robustness** — a throwing store must never hang or crash the graph: + a store whose `set` always fails still serves correct values (best-effort uncached + serve — this fixed a real hang where a run-time `store.set` failure was swallowed + by the resolver guard), a flaky store recovers (later gets are cached), and a + store whose `get` fails surfaces the error via callback. The universal OWA + invariants also pin the constant-array case (all-equal inputs → the value for + weighted operators). +- **Algebraic OWA invariants** — results are **invariant under input order** (any + operator: sort-based), and **max/min/average/median are invariant under multiset + duplication** (each element duplicated; FP-tolerance for average). +- **Params-scoped invalidation & CSE** — invalidating one params set deletes that + key while every subject/params value stays correct; a **shared subtree** in a + diamond DAG is computed once across two roots and once across the post- + invalidation recompute (no redundant evaluation); and `invalidate` clears an + eager `set` value so the next get recomputes from the resolver. +- **Action-level fault injection** — under injected `rigor.faults` (throw on the + get action) the callback contract still holds: correct value or surfaced error, + never a hang. +- **Parallel firing & async CSE** — N independent async resolvers fire + simultaneously (max in-flight === N), each exactly once; a diamond DAG with + async resolvers fires its shared node exactly once (no duplicate REST calls); + and waiter fan-out joins at every level of a deep shared subtree. +- **Stateful protocol, concurrency, edges, benchmark** — a `rigor.object` protocol + campaign (with `before`/`after`/`between`) pins "after `set(v)` every `get` + returns `v` until `invalidate`"; **concurrent direct gets** on one graph each + complete independently and correctly (N calls for N gets, no torn state); a run + error inside a query duplex is pushed down as `{ error }`; `set` rejects + unsupported value shapes; resolvers propagate `{ value, unit, source }` onto the + delivered entry; and a loose **latency benchmark** smoke guards the fast path. +- **Graph semantics** — caching (second get computes nothing), invalidation cascade, + `set` override, plan pruning, TTL expiry, `blackbox` deps/entries. +- **Callback contract** — handler-based: sync + async resolver delivery, resolver + error propagation, Promise-return rejection, duplex pull (two gets), and stale + trigger pushed between values on `invalidate`. +- **Store contract** — model-based conformance (`rigor.model.check`) of + `createMapStore` and `createFileStore` against a reference `Map` under random + `set`/`get`/`delete`/`clear`/`deletePrefix` sequences. + +## License + +ISC diff --git a/bench/hotpath.js b/bench/hotpath.js new file mode 100644 index 0000000..e3777a3 --- /dev/null +++ b/bench/hotpath.js @@ -0,0 +1,38 @@ +// Hotpath micro-benchmark: how fast is a value-graph `get` on the authorization +// hotpath? Compared against a plain Map lookup. +import { performance } from 'node:perf_hooks'; +import { ValueGraph } from '../src/index.js'; + +function bench(name, fn, iterations = 200_000) { + for (let i = 0; i < 10_000; i++) fn(); // warmup + const t0 = performance.now(); + for (let i = 0; i < iterations; i++) fn(); + const ms = performance.now() - t0; + const nsPerOp = (ms * 1e6) / iterations; + const opsPerSec = Math.round((iterations / ms) * 1000); + console.log(`${name.padEnd(46)} ${nsPerOp.toFixed(0).padStart(8)} ns/op ${String(opsPerSec).padStart(10)} ops/sec`); +} + +const vg = new ValueGraph(); +vg.compute('balance', () => 1250); +await new Promise((res) => vg.get('u:1', 'balance', {}, (e, v) => res(v))); +const plan = vg.plan('u:1', 'balance', {}); // cached plan for the hot loop + +const coldVg = new ValueGraph(); +coldVg.compute('balance', () => 42); + +vg.define('a', { operator: 'source', fn: () => 10 }); +vg.define('b', { operator: 'source', fn: () => 20 }); +vg.define('c', { operator: 'source', fn: () => 30 }); +vg.define('risk', { operator: 'fusion:custom', parents: ['a', 'b', 'c'], weights: [0.6, 0.3, 0.1] }); +await new Promise((res) => vg.get('u:1', 'risk', {}, (e, v) => res(v))); + +const baseline = new Map([['u:1|balance|{}', 1250]]); + +console.log('--- hotpath (authorization value lookup) ---'); +bench('Map.get baseline', () => baseline.get('u:1|balance|{}')); +bench('vg.get cached (plan/run)', () => vg.run(plan, () => {})); +bench('vg.get cached (get + compile)', () => vg.get('u:1', 'balance', {}, () => {})); +bench('vg.get cold (fresh compute)', () => coldVg.get('x', 'balance', {}, () => {})); +bench('vg.get fusion:custom DAG', () => vg.get('u:1', 'risk', {}, () => {})); +bench('vg.query duplex {get}', () => { const q = vg.query('u:1', 'balance', {}); q.sink.write({ get: true }); q.sink.end(); }); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..063afd9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,292 @@ +{ + "name": "@arbiter/value-graph", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@arbiter/value-graph", + "version": "0.1.0", + "license": "ISC", + "dependencies": { + "@push-stream-std/push-pushable": "*", + "@push-stream-std/push-stream-base": "*", + "@rigor/probe": "^0.0.8" + }, + "devDependencies": { + "@rigor/core": "^3.1.2" + } + }, + "node_modules/@push-stream-std/push-pushable": { + "version": "0.0.10", + "resolved": "https://hub.kl1.tenere.ai/api/packages/push-stream-std/npm/%40push-stream-std%2Fpush-pushable/-/0.0.10/push-pushable-0.0.10.tgz", + "integrity": "sha512-xf8nt50zckM8G+WRKV5RTNl4EEe6rhzuplNfqpdZ/b8kx0HnuTawDHlL0R449PKqG9X4DOQ5RzBrX2jYAnfY2A==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@push-stream-std/push-stream-base": "*" + } + }, + "node_modules/@push-stream-std/push-stream-base": { + "version": "0.0.9", + "resolved": "https://hub.kl1.tenere.ai/api/packages/push-stream-std/npm/%40push-stream-std%2Fpush-stream-base/-/0.0.9/push-stream-base-0.0.9.tgz", + "integrity": "sha512-RCbTwQz88XR0j0f/0NWIWgQ3NBQUUICu+CRHzOAvYfG2YQ8j+aTczYWTEedJJhGA+svcFrAQ8Oai/UnjtQ0l3Q==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/@rigor/analysis": { + "version": "0.0.7", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fanalysis/-/0.0.7/analysis-0.0.7.tgz", + "integrity": "sha512-CH9g9gU5P+aWZfneL7l91Q8wgZ0kwteYTpOdShqi6VFg2aoOSk4HMl8RTzkO4suxWa2VQ/mMnFJ/TAiHwJOobA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/instrument": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/artifact": { + "version": "0.1.3", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fartifact/-/0.1.3/artifact-0.1.3.tgz", + "integrity": "sha512-/SP5veJOpZ7y0dEBYOm+iBKQnBqZbTQzCXGd/LvQvmDtMGL7KJv2UbU7blnWp9CIwD8qU8t9U9c9oKO+qupUcA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/benchmark": { + "version": "0.0.6", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fbenchmark/-/0.0.6/benchmark-0.0.6.tgz", + "integrity": "sha512-uCCtEhCeY0t81aSgvV1Q7D3cgNU1WufEGJ/vcljhjXnx05hdMWnlK0NPdI/qkWRbRiz+4sSpcGOGO9NBFk7ExQ==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/artifact": "*", + "@rigor/complexity": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/complexity": { + "version": "0.0.7", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fcomplexity/-/0.0.7/complexity-0.0.7.tgz", + "integrity": "sha512-RfrkXLykb37WFwmB6OfZoXYMEZJLG78r2HqfDB27jWQmGt5l/4Hb9Zh77dEKl2CsUx2fgd5NafdDl9qFtTE6bg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/core": { + "version": "3.1.2", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fcore/-/3.1.2/core-3.1.2.tgz", + "integrity": "sha512-n/haUHyf+fm1PkQ9HYxMoE8YOEp4Gi7k1hfu+9kSqMOwSiEUUBHrAzyVHGn0i7wRym52vAWWe5gRjCQWvVQBBg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/analysis": "*", + "@rigor/artifact": "*", + "@rigor/benchmark": "*", + "@rigor/complexity": "*", + "@rigor/fault": "*", + "@rigor/fuzzer": "*", + "@rigor/gen": "*", + "@rigor/instrument": "*", + "@rigor/linearizability": "*", + "@rigor/model": "*", + "@rigor/network": "*", + "@rigor/probe": "*", + "@rigor/prop": "*", + "@rigor/reporters": "*", + "@rigor/rng": "*", + "@rigor/search": "*", + "@rigor/shrink": "*", + "@rigor/spec": "*", + "@rigor/storage": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/fault": { + "version": "0.0.5", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Ffault/-/0.0.5/fault-0.0.5.tgz", + "integrity": "sha512-MxzAEk8tx/Nc9CL3dQZZwKXj7eqBgnfTC9dI9TiU9b1u3zW7EIoL28vGqtrr1ucqSTeNwD8BGGAenan2tvb1ZQ==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/rng": "*", + "@rigor/shrink": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/fuzzer": { + "version": "0.0.4", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Ffuzzer/-/0.0.4/fuzzer-0.0.4.tgz", + "integrity": "sha512-cJu9bL0GqpkTsIne9PLMcsLtRfBlDz18DqPiarSi+MetH9R0joh7C+fp/e4guo0y+Q2RBJZwulA8gOh9AwgEaQ==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/artifact": "*", + "@rigor/rng": "*", + "@rigor/shrink": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/gen": { + "version": "0.0.11", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fgen/-/0.0.11/gen-0.0.11.tgz", + "integrity": "sha512-PJG40PtiRJRbNDajdpGaachLYYR+tkSK/0ToIbG5BlG+l/enw7g80hkH674Vw4jeWoftzuwe2NM4dC9rj9rHbg==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/rng": "*" + } + }, + "node_modules/@rigor/instrument": { + "version": "0.0.5", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Finstrument/-/0.0.5/instrument-0.0.5.tgz", + "integrity": "sha512-GXJa6kcEAbd86yM57hR+2g0vcxJlpBfBOoMldWk3mzLmAcCkyYXNSjJekTAwNovfUrtIcnYgQ187Zj3A2d8Tcw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/probe": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/linearizability": { + "version": "0.0.5", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Flinearizability/-/0.0.5/linearizability-0.0.5.tgz", + "integrity": "sha512-Ruw+/Zc1hiMfs6i/4qYxYuvUsBtCm5SUmpuXrrixSRglvJ9PYfL/H8xb/Pk3Ve1dFBj8TQN6M3AnwCeaJF2/tg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/@rigor/model": { + "version": "0.2.13", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fmodel/-/0.2.13/model-0.2.13.tgz", + "integrity": "sha512-eZ/888p1zGJGCBFVMA9suvS2mUNX+iHhZn+Uwl9lWa9CSHVRUWH9dTYYB+Qr1hNrCSGIDjV7rOC34wzT+QA4fw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/artifact": "*", + "@rigor/complexity": "*", + "@rigor/fault": "*", + "@rigor/gen": "*", + "@rigor/probe": "*", + "@rigor/rng": "*", + "@rigor/shrink": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/network": { + "version": "0.0.4", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fnetwork/-/0.0.4/network-0.0.4.tgz", + "integrity": "sha512-iNmiQA5QiCNkXnjAMPibGYzDk3Lf5hsgwXQGwRcSSVlZNBfVVw5fURzTsNen3TeAnqh9nozJnhD52yp3zdfP6Q==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/fault": "*", + "@rigor/rng": "*", + "@rigor/scheduler": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/probe": { + "version": "0.0.8", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fprobe/-/0.0.8/probe-0.0.8.tgz", + "integrity": "sha512-K+n9mV1RfhkaJBV46obUX4Ll2ddwyUVM+Sf/PydajtsQOydfBj+v2SxOya+PzEXNXXDoosUvEaUaAuTCX7Q1vw==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/fault": "*", + "@rigor/gen": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/prop": { + "version": "0.0.4", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fprop/-/0.0.4/prop-0.0.4.tgz", + "integrity": "sha512-z3+STJPeNpz080ZfFbw5h6LpNFjCNnSymoQPUsjyaSovq3euyJrGx9sqtosVgVVE7Rzld6K4QwchJSJOqIi73w==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/artifact": "*", + "@rigor/complexity": "*", + "@rigor/gen": "*", + "@rigor/probe": "*", + "@rigor/rng": "*", + "@rigor/shrink": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/reporters": { + "version": "0.0.5", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Freporters/-/0.0.5/reporters-0.0.5.tgz", + "integrity": "sha512-+YYfOMlYDsmGIi9rKiJm7ZAptQ0rTeTU1Sp2uVG9UJteuVKW2eKZIC6TeJuM1qvjQmh7/IzBkl3PZVX9pKnYaw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/artifact": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/rng": { + "version": "0.0.3", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Frng/-/0.0.3/rng-0.0.3.tgz", + "integrity": "sha512-OFERK5HlI6eV1yUadHoz9n88twqsd/lstAxMzBWFJiN1CCM0xG7IHUYMBCnYK6Di3TTcAarFNFL16Bchm3LrKg==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/@rigor/scheduler": { + "version": "0.0.4", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fscheduler/-/0.0.4/scheduler-0.0.4.tgz", + "integrity": "sha512-tY7KASEfufOw4L1vIGeD1bTArCv+zAEPqT/XqiXFz+fckGxY8Nux6zDhGcjkur3qWOUNoWap6KOjSZSORHhXiw==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/fault": "*", + "@rigor/rng": "*", + "@rigor/shrink": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/search": { + "version": "0.1.3", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fsearch/-/0.1.3/search-0.1.3.tgz", + "integrity": "sha512-NAq5zbUSc6QQO4YIUFq16+hPtrtkNrjAn75lH/4VDPpbCdQPbKvS6zsQ3v954u1u7I6x2jI5uilpX6/xfr69yA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/gen": "*", + "@rigor/shrink": "*" + } + }, + "node_modules/@rigor/shrink": { + "version": "0.0.7", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fshrink/-/0.0.7/shrink-0.0.7.tgz", + "integrity": "sha512-z0yaZqmyysn40BBqAnqTQlymLuaIIU5u2BijjayX0+XZxhhcPIyvs4PISzJ4nmfkx2yE6qKABe2HHub9wVVUQw==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/@rigor/spec": { + "version": "2.0.1", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fspec/-/2.0.1/spec-2.0.1.tgz", + "integrity": "sha512-0/lg19KenJy1e8D87+WqaMRh8Xad4h209/CgK6ofCQapwz/IcriMPHgOZZbHMA4C8G3B6/luVbSVT0EHZoJ8qg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/gen": "*" + } + }, + "node_modules/@rigor/storage": { + "version": "0.0.4", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Fstorage/-/0.0.4/storage-0.0.4.tgz", + "integrity": "sha512-7XapdgmvEg/C/kERldqFlw3mT1JkpjPMNK6fEcB2Ggx7+UR3ZeE9GyaodJ29my61b28SrQUWxLBHT5bm0bIVaA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@rigor/fault": "*", + "@rigor/rng": "*", + "@rigor/scheduler": "*", + "@rigor/trace": "*" + } + }, + "node_modules/@rigor/trace": { + "version": "0.0.7", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Rigor/npm/%40rigor%2Ftrace/-/0.0.7/trace-0.0.7.tgz", + "integrity": "sha512-vCLF+WTSdy0pwe9sTonPt8HnNeSkK9U5SAxNpdIeiT+IBP74t9K7JoHMmfo7ka+S0puWecDjjNtWCSqtvKkjNg==", + "license": "SEE LICENSE IN LICENSE" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7c1abac --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "@arbiter/value-graph", + "version": "0.1.0", + "description": "Lazy, TTL-cached, version-invalidated store of derived values — the second graph for measures and ARRA value lookups. ARRA (or any value source) registers compute functions; policies read fresh values through measures.", + "license": "ISC", + "type": "module", + "main": "src/index.js", + "exports": { + ".": "./src/index.js", + "./package.json": "./package.json" + }, + "files": [ + "src/" + ], + "scripts": { + "test": "node --test --test-force-exit \"test/**/*.test.js\"", + "test:rigor": "node --test --test-force-exit \"test/value-graph.rigor.test.js\"", + "bench": "node bench/hotpath.js" + }, + "dependencies": { + "@push-stream-std/push-pushable": "*", + "@push-stream-std/push-stream-base": "*", + "@rigor/probe": "^0.0.8" + }, + "devDependencies": { + "@rigor/core": "^3.1.2" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..3ede656 --- /dev/null +++ b/src/index.js @@ -0,0 +1,22 @@ +/** + * @arbiter/value-graph + * + * The "second graph": a graph-structured, lazy, TTL + version-invalidated store + * of derived values, built on @push-stream-std. Queries are duplex streams + * (push `{ get }` up, receive values down); invalidation pushes `{ stale }` + * triggers down the graph and cascades to dependents. Backing store is + * swappable (in-memory default; `createFileStore` disk-backed for graphs + * larger than memory). + * + * Persistence & transport: `ValueGraph.snapshot()` / `snapshotFile(path)` / + * `ValueGraph.restore(buf)` / `ValueGraph.loadFile(path)` move a graph between + * processes and machines as a single compact, CRC-guarded binary file — schema + * metadata plus every entry (functions re-registered via `{ resolvers }`). + * Stores expose `snapshotFile`/`restoreFile` (`createFileStore` also accepts + * `{ loadFrom }`) for raw key->entry dumps. + */ +export { ValueGraph, createMapStore, createFileStore, owa, weightFor, validateValueType } from './value-graph.js'; +export { + encodeSnapshot, decodeSnapshot, encodeGraphSnapshot, decodeGraphSnapshot, + encodeEntryBytes, decodeEntryBytes, encodeParams, decodeParams, crc32 +} from './snapshot.js'; diff --git a/src/snapshot.js b/src/snapshot.js new file mode 100644 index 0000000..416e9a3 --- /dev/null +++ b/src/snapshot.js @@ -0,0 +1,532 @@ +/** + * Compact, portable binary snapshots for @arbiter/value-graph. + * + * Two snapshot flavours share one value/entry wire format: + * + * STORE snapshot ('VGST') — an opaque dump of a backing store: key -> entry. + * Transportable between stores/processes/machines; the working per-key + * files of createFileStore are NOT portable, this is. Bounded-memory + * writing (record at a time) so a larger-than-memory graph can be moved. + * + * GRAPH snapshot ('VGGP') — schema (relation metadata, minus functions) + + * structured records { subject, relation, params, entry }. This is the + * seamless artifact: ValueGraph.restore(buf) / ValueGraph.loadFile(path) + * reconstruct a working graph (resolvers re-registered by the caller). + * + * DESIGN RULES (hard constraints, not preferences): + * - NO JSON anywhere on the wire. params/pattern/objects are encoded as + * deterministic BINARY MAPS (sorted keys, recursive value encoding) — + * JSON.stringify/parse is banned (slow, no zero-copy). + * - NO JS bigint. Large integers are Buffers/Uint8Array (tag 0x04, raw + * bytes) — JS bigints are not a value-graph type. + * - Everything is CRC-32 guarded and parse-guarded on load; a corrupt or + * truncated snapshot throws, it never silently mis-reads. + * + * Wire format (all integers little-endian, varints LEB128): + * + * HEADER: MAGIC(4 ASCII) VERSION(u8=1) FLAGS(u8) + * CREATED(f64 ms) [SCHEMA_COUNT(varint) ENTRY_COUNT(varint)] + * SCHEMA record (graph snapshots only): REL(varstr) OPERATOR(varstr) + * PARENTS(varint, varstr*) ATTR(varstr) PATTERN(value) + * WEIGHTS(varint, f64*) PRIORITIES(varint, f64*) CAPSUM(u8) + * HAS_TTL(u8[, f64]) RETURNTYPE(varstr) PARAMS(value) + * HAS_VALIDATE(u8) + * ENTRY record: KEY(varstr) [subject+relation+params for graph snapshots] + * VALUE(v) UNIT(u8 tag + payload) AT(f64) SOURCE(varstr) + * VERSION(varint) DEPS(varint, KEYVAR+VALUE*) + * TRAILER: CRC32(u32) over every byte before it. + * + * VALUE(v): tag u8 + payload: + * 0 null | 1 f64 | 2 string(varstr) | 3 bool(u8) | 4 buffer(bytes: varint + * len + raw) | 5 array(varint, v*) | 6 interval(v lower, v upper) | + * 7 object (binary map: varint count + sorted str-key + value per entry) + * + * Transportability: no paths, no machine-dependent floats beyond IEEE-754 + * doubles, big integers as raw bytes, no JSON parsing. + * + * This module is fs-free; the store / ValueGraph do the file I/O, streaming + * record-at-a-time via the header/record encoders + snapshotCrc32(). + */ +export const STORE_MAGIC = 'VGST'; +export const GRAPH_MAGIC = 'VGGP'; +export const SNAPSHOT_VERSION = 1; + +// ───────────────────────────────────────────────────────────────────────────── +// CRC-32 (IEEE 802.3 / zlib polynomial) — integrity guard for every snapshot. +// ───────────────────────────────────────────────────────────────────────────── + +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + t[i] = c >>> 0; + } + return t; +})(); + +export function crc32(bytes) { + let c = 0xFFFFFFFF; + for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xFFFFFFFF) >>> 0; +} + +/** + * Incremental CRC-32 for streaming writes: `update(chunk)` per chunk, `digest()` + * at the end. Keeps memory bounded for larger-than-memory snapshot files. + */ +export function snapshotCrc32() { + let c = 0xFFFFFFFF; + return { + update(bytes) { + for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); + }, + digest() { return (c ^ 0xFFFFFFFF) >>> 0; } + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Writer / Reader — little-endian byte accumulation and parse. +// ───────────────────────────────────────────────────────────────────────────── + +class Writer { + constructor(cap = 1024) { + this.buf = new Uint8Array(cap); + this.len = 0; + } + + ensure(n) { + if (this.len + n <= this.buf.length) return; + let cap = this.buf.length * 2; + while (cap < this.len + n) cap *= 2; + const nb = new Uint8Array(cap); + nb.set(this.buf.subarray(0, this.len)); + this.buf = nb; + } + + u8(v) { this.ensure(1); this.buf[this.len++] = v & 0xff; return this; } + u32(v) { + this.ensure(4); + const dv = new DataView(this.buf.buffer); + dv.setUint32(this.len, v, true); + this.len += 4; + return this; + } + f64(v) { + this.ensure(8); + const dv = new DataView(this.buf.buffer); + dv.setFloat64(this.len, v, true); + this.len += 8; + return this; + } + varint(v) { + let n = v >>> 0; + while (n >= 0x80) { this.u8((n & 0x7f) | 0x80); n >>>= 7; } + this.u8(n); + return this; + } + bytes(b) { + this.varint(b.length); + this.ensure(b.length); + this.buf.set(b, this.len); + this.len += b.length; + return this; + } + // Append already-encoded bytes verbatim (records/headers are self-describing). + raw(b) { + this.ensure(b.length); + this.buf.set(b, this.len); + this.len += b.length; + return this; + } + str(s) { return this.bytes(Buffer.from(s, 'utf8')); } + toBytes() { return this.buf.subarray(0, this.len); } +} + +class Reader { + constructor(buf) { + this.buf = buf; + this.dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + this.off = 0; + } + u8() { const v = this.dv.getUint8(this.off); this.off += 1; return v; } + u32() { const v = this.dv.getUint32(this.off, true); this.off += 4; return v; } + f64() { const v = this.dv.getFloat64(this.off, true); this.off += 8; return v; } + varint() { + let shift = 0; + let v = 0; + for (;;) { + const b = this.u8(); + v |= (b & 0x7f) << shift; + if (!(b & 0x80)) return v >>> 0; + shift += 7; + if (shift > 28) throw new Error('value-graph: snapshot malformed (varint overflow)'); + } + } + take(n) { + if (n > this.remaining()) throw new Error('value-graph: snapshot corrupt (length past end of buffer)'); + const v = this.buf.subarray(this.off, this.off + n); + this.off += n; + return v; + } + bytes() { return this.take(this.varint()); } + str() { return Buffer.from(this.bytes()).toString('utf8'); } + remaining() { return this.buf.length - this.off; } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Binary map + params — the JSON-free encoding for objects (params, pattern, +// nested object values). Keys are sorted by UTF-8 so identical objects always +// encode to identical bytes (determinism: same params → same store key). +// ───────────────────────────────────────────────────────────────────────────── + +function encodeMap(obj, w) { + const keys = Object.keys(obj).sort(); + w.varint(keys.length); + for (const k of keys) { w.str(k); encodeValue(obj[k], w); } +} + +function decodeMap(r) { + const count = r.varint(); + if (count > r.remaining()) throw new Error('value-graph: snapshot corrupt (map count past end of buffer)'); + const o = {}; + for (let i = 0; i < count; i++) o[r.str()] = decodeValue(r); + return o; +} + +/** + * Encode a params object to bytes for use as the key tail (JSON-free) — the + * same bytes every call for the same params, so the store key is stable. + */ +export function encodeParams(params) { + const w = new Writer(); + encodeValue(params ?? {}, w); + return w.toBytes(); +} + +/** Reverse of encodeParams — decodes the key tail back to a params object. */ +export function decodeParams(bytes) { + const r = new Reader(bytes); + const v = decodeValue(r); + return (v === null || v === undefined) ? {} : v; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Value encoding (recursive) — the stored `value` payload of every entry. +// ───────────────────────────────────────────────────────────────────────────── + +export function encodeValue(v, w) { + if (v === null || v === undefined) { w.u8(0); return; } + const t = typeof v; + if (t === 'number') { w.u8(1); w.f64(v); return; } + if (t === 'string') { w.u8(2); w.str(v); return; } + if (t === 'boolean') { w.u8(3); w.u8(v ? 1 : 0); return; } + if (v instanceof Uint8Array) { w.u8(4); w.bytes(v); return; } + if (Array.isArray(v)) { + w.u8(5); w.varint(v.length); + for (const x of v) encodeValue(x, w); + return; + } + if (typeof v === 'object' && 'lower' in v && 'upper' in v) { + w.u8(6); + encodeValue(v.lower, w); + encodeValue(v.upper, w); + return; + } + // Plain object (params, pattern, nested objects): deterministic binary map. + w.u8(7); + encodeMap(v, w); +} + +export function decodeValue(r) { + const tag = r.u8(); + switch (tag) { + case 0: return null; + case 1: return r.f64(); + case 2: return r.str(); + case 3: return r.u8() === 1; + case 4: return r.bytes(); // Uint8Array view (zero-copy, no JSON, no bigint) + case 5: { + const n = r.varint(); + if (n > r.remaining()) throw new Error('value-graph: snapshot corrupt (array count past end of buffer)'); + const a = new Array(n); + for (let i = 0; i < n; i++) a[i] = decodeValue(r); + return a; + } + case 6: { + const lower = decodeValue(r); + const upper = decodeValue(r); + return { lower, upper }; + } + case 7: return decodeMap(r); + default: throw new Error(`value-graph: snapshot unknown value tag ${tag}`); + } +} + +function encodeUnit(unit, w) { + if (unit === null || unit === undefined) { w.u8(0); return; } + if (typeof unit === 'number') { w.u8(1); w.f64(unit); return; } + w.u8(2); w.str(String(unit)); +} + +function decodeUnit(r) { + const t = r.u8(); + if (t === 0) return null; + if (t === 1) return r.f64(); + return r.str(); +} + +// Entry payload shared by both snapshot flavours: the value, its unit, the +// wall-clock it was computed at, provenance, version stamp and deps metadata. +function encodeEntryPayload(entry, w) { + encodeValue(entry.value, w); + encodeUnit(entry.unit, w); + w.f64(typeof entry.at === 'number' ? entry.at : 0); + w.str(entry.source || ''); + w.varint(typeof entry.version === 'number' ? entry.version : 0); + const deps = entry.deps || {}; + const dk = Object.keys(deps).sort(); + w.varint(dk.length); + for (const k of dk) { w.str(k); encodeValue(deps[k], w); } +} + +function decodeEntryPayload(r) { + const value = decodeValue(r); + const unit = decodeUnit(r); + const at = r.f64(); + const source = r.str(); + const version = r.varint(); + const depsCount = r.varint(); + if (depsCount > r.remaining()) throw new Error('value-graph: snapshot corrupt (deps count past end of buffer)'); + const deps = {}; + for (let i = 0; i < depsCount; i++) deps[r.str()] = decodeValue(r); + return { value, unit, at, source, version, deps }; +} + +/** + * Encode JUST the entry payload (no key) to bytes — the on-disk representation + * for createFileStore's binary per-key files (JSON-free). + */ +export function encodeEntryBytes(entry) { + const w = new Writer(); + encodeEntryPayload(entry, w); + return w.toBytes(); +} + +/** Reverse of encodeEntryBytes — decode a bare entry payload. */ +export function decodeEntryBytes(buffer) { + const r = new Reader(buffer); + return decodeEntryPayload(r); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Headers + record encoders — shared by the in-memory encoders and the +// streaming file writers in the store / ValueGraph. +// ───────────────────────────────────────────────────────────────────────────── + +export function storeHeader(entryCount, { createdAt } = {}) { + const w = new Writer(); + w.raw(Buffer.from(STORE_MAGIC, 'ascii')); + w.u8(SNAPSHOT_VERSION); + w.u8(0); // flags (unused) + w.f64(typeof createdAt === 'number' ? createdAt : Date.now()); + w.varint(entryCount); + return w.toBytes(); +} + +export function graphHeader(schemaCount, entryCount, { createdAt } = {}) { + const w = new Writer(); + w.raw(Buffer.from(GRAPH_MAGIC, 'ascii')); + w.u8(SNAPSHOT_VERSION); + w.u8(0); // flags (unused) + w.f64(typeof createdAt === 'number' ? createdAt : Date.now()); + w.varint(schemaCount); + w.varint(entryCount); + return w.toBytes(); +} + +export function encodeStoreRecord(key, entry) { + const w = new Writer(); + w.str(key); + encodeEntryPayload(entry, w); + return w.toBytes(); +} + +export function encodeSchemaRecord(rel, meta) { + const w = new Writer(); + w.str(rel); + w.str(meta.operator || ''); + const parents = meta.parents ? [...meta.parents] : []; + w.varint(parents.length); + for (const p of parents) w.str(p); + w.str(meta.attribute || ''); + encodeValue(meta.pattern ?? null, w); + const weights = meta.weights || []; + w.varint(weights.length); + for (const x of weights) w.f64(x); + const priorities = meta.priorities || []; + w.varint(priorities.length); + for (const x of priorities) w.f64(x); + w.u8(meta.capSum ? 1 : 0); + w.u8(meta.ttl === undefined || meta.ttl === null ? 0 : 1); + if (meta.ttl !== undefined && meta.ttl !== null) w.f64(meta.ttl); + w.str(meta.returnType || ''); + encodeValue(meta.params ?? null, w); + w.u8(meta.validate ? 1 : 0); + return w.toBytes(); +} + +export function encodeGraphRecord({ subject, relation, params, entry }) { + const w = new Writer(); + w.str(subject); + w.str(relation); + encodeValue(params ?? {}, w); + encodeEntryPayload(entry, w); + return w.toBytes(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// In-memory encoders (whole snapshot as one Buffer) and decoders (with +// integrity verification). File streaming lives in the store / ValueGraph. +// ───────────────────────────────────────────────────────────────────────────── + +export function encodeSnapshot(entries, { createdAt } = {}) { + const list = [...entries]; + const w = new Writer(4096); + w.raw(storeHeader(list.length, { createdAt })); + for (const [key, entry] of list) w.raw(encodeStoreRecord(key, entry)); + const body = w.toBytes(); + const tail = Buffer.alloc(4); + tail.writeUInt32LE(crc32(body)); + return Buffer.concat([body, tail]); +} + +export function encodeGraphSnapshot({ schema = [], records = [] } = {}, { createdAt } = {}) { + const slist = [...schema]; + const rlist = [...records]; + const w = new Writer(4096); + w.raw(graphHeader(slist.length, rlist.length, { createdAt })); + for (const [rel, meta] of slist) w.raw(encodeSchemaRecord(rel, meta)); + for (const rec of rlist) w.raw(encodeGraphRecord(rec)); + const body = w.toBytes(); + const tail = Buffer.alloc(4); + tail.writeUInt32LE(crc32(body)); + return Buffer.concat([body, tail]); +} + +function verifyCrc(buffer, r) { + if (r.remaining() !== 4) throw new Error('value-graph: snapshot truncated (missing CRC)'); + const stored = r.u32(); + const actual = crc32(buffer.subarray(0, buffer.length - 4)); + if (stored !== actual) throw new Error('value-graph: snapshot CRC mismatch (corrupt or truncated)'); +} + +function checkMagic(r, expected) { + const magic = Buffer.from(r.take(4)).toString('ascii'); + if (magic !== expected) { + throw new Error(`value-graph: not a value-graph snapshot (magic '${magic}')`); + } + const version = r.u8(); + if (version !== SNAPSHOT_VERSION) { + throw new Error(`value-graph: unsupported snapshot version ${version} (this build reads v${SNAPSHOT_VERSION})`); + } +} + +export function decodeSnapshot(buffer) { + const r = new Reader(buffer); + checkMagic(r, STORE_MAGIC); + return decodeBody(buffer, r, () => { + r.u8(); // flags + const createdAt = r.f64(); + const entryCount = r.varint(); + if (entryCount > r.remaining()) throw new Error('value-graph: snapshot corrupt (entry count past end of buffer)'); + const entries = new Array(entryCount); + for (let i = 0; i < entryCount; i++) entries[i] = decodeStoreRecord(r); + verifyCrc(buffer, r); + return { createdAt, entries }; + }); +} + +export function decodeGraphSnapshot(buffer) { + const r = new Reader(buffer); + checkMagic(r, GRAPH_MAGIC); + return decodeBody(buffer, r, () => { + r.u8(); // flags + const createdAt = r.f64(); + const schemaCount = r.varint(); + const entryCount = r.varint(); + if (schemaCount > r.remaining()) throw new Error('value-graph: snapshot corrupt (schema count past end of buffer)'); + const schema = new Array(schemaCount); + for (let i = 0; i < schemaCount; i++) schema[i] = [r.str(), decodeSchema(r)]; + if (entryCount > r.remaining()) throw new Error('value-graph: snapshot corrupt (entry count past end of buffer)'); + const records = new Array(entryCount); + for (let i = 0; i < entryCount; i++) records[i] = decodeGraphRecord(r); + verifyCrc(buffer, r); + return { createdAt, schema, records }; + }); +} + +/** + * A malformed record (corrupt bytes, truncated file, bad version) throws a + * coherent `value-graph: snapshot corrupt (...)` error rather than leaking a + * low-level RangeError — snapshots are CRC-guarded AND parse-guarded. Already- + * prefixed errors (magic/version/CRC) pass through unchanged. + */ +function decodeBody(buffer, r, bodyFn) { + try { + return bodyFn(); + } catch (e) { + if (e && typeof e.message === 'string' && e.message.startsWith('value-graph: snapshot')) throw e; + throw new Error(`value-graph: snapshot corrupt (${e && e.message ? e.message : e})`); + } +} + +function decodeStoreRecord(r) { + const key = r.str(); + return [key, decodeEntryPayload(r)]; +} + +function decodeGraphRecord(r) { + const subject = r.str(); + const relation = r.str(); + const params = decodeValue(r); + const entry = decodeEntryPayload(r); + return { subject, relation, params: (params === null || params === undefined) ? {} : params, entry }; +} + +function decodeSchema(r) { + const operator = r.str(); + const parentCount = r.varint(); + if (parentCount > r.remaining()) throw new Error('value-graph: snapshot corrupt (parent count past end of buffer)'); + const parents = new Array(parentCount); + for (let i = 0; i < parentCount; i++) parents[i] = r.str(); + const attribute = r.str() || null; + const pattern = decodeValue(r); + const weightCount = r.varint(); + if (weightCount > r.remaining() / 8) throw new Error('value-graph: snapshot corrupt (weights count past end of buffer)'); + const weights = new Array(weightCount); + for (let i = 0; i < weightCount; i++) weights[i] = r.f64(); + const priorityCount = r.varint(); + if (priorityCount > r.remaining() / 8) throw new Error('value-graph: snapshot corrupt (priorities count past end of buffer)'); + const priorities = new Array(priorityCount); + for (let i = 0; i < priorityCount; i++) priorities[i] = r.f64(); + const capSum = r.u8() === 1; + const hasTtl = r.u8() === 1; + const ttl = hasTtl ? r.f64() : undefined; + const returnType = r.str() || null; + const params = decodeValue(r); + const hasValidate = r.u8() === 1; + return { + operator, + parents, + attribute, + pattern: (pattern === undefined) ? null : pattern, + weights, + priorities, + capSum, + ttl, + returnType, + params: (params === null || params === undefined) ? null : params, + hasValidate + }; +} diff --git a/src/value-graph.js b/src/value-graph.js new file mode 100644 index 0000000..130da82 --- /dev/null +++ b/src/value-graph.js @@ -0,0 +1,1152 @@ +/** + * ValueGraph — the "second graph" for lazily computed, derived values, built on + * @push-stream-std. + * + * The authorization graph (@arbiter/core) stores structural edges with + * possibility. The value graph is a REAL graph (potentially larger than + * memory — the backing store is swappable) whose nodes are value relations and + * whose values are computed lazily on demand. + * + * Node model (fixed operator vocabulary, per ADR-000 OWA + the DSL measure + * grammar): + * + * source — leaf; external value via fn(subject, params, ctx, cb) (e.g. ARRA) + * attribute — leaf; reads a graph node field via resolveAttribute(nodeKey, path, params, ctx, cb) + * pattern — leaf; graph-traversal value via resolvePattern(pattern, subject, params, ctx, cb) + * compute — interior; fn(subject, params, ctx, cb) over parent VALUES (ctx.deps) + * blackbox — interior; general function of its dependencies — fn(subject, params, ctx, cb) + * with ctx.deps (parent values) AND ctx.entries (full parent results + * { value, unit, at, source, fresh }). The seam for composing an external + * (e.g. Overlay) query from parent edges/values. + * fusion:— interior; combines parent values by an OWA operator + * (max, min, average, sum, sum_unbounded, majority, median, + * optimistic, pessimistic, top2, top3, priority, custom, + * product) + * + * CALLBACK CONTRACT (no async/await/promises): + * Every resolver — source/compute `fn`, `resolveAttribute`, `resolvePattern` + * — is CALLBACK ORIENTED, matching the push-stream discipline imported from + * `@overlay` adapters (backpressure via the `paused` flag, never promises). + * A resolver MUST either + * (a) call `cb(err, result)` asynchronously and return `undefined`, or + * (b) return the result synchronously (pure compute / memoized reads). + * Returning a Promise is rejected with a descriptive error. `result` may be + * a value, `{ value, unit, source }`, an interval `{ lower, upper }`, or + * `null`/`undefined` (→ uncached null). This is what lets the future + * `value-graph-overlay` bridge implement resolvers over Overlay's + * callback/push-stream `FederatedQueryEngine` without breaking the model. + * + * Compile → optimize → run (never fused): + * COMPILE `plan(subject, relation, params)` walks `parents` into a node + * DAG; a cache-safe node becomes a single trivial leaf (no + * parents), pruning its subtree. + * RUN `run(plan, cb)` evaluates the DAG in dependency order, dispatching + * each node to its operator with resolved parent values, and caches + * the result — all callback-driven, no promises. + * + * Invalidations and eager updates compile the AFFECTED dependent DAG (the + * relation + every transitive dependent) and run it, pushing `{ stale }` + * triggers DOWN to active queries and cascading to dependents. + * + * The graph is NOT an in-memory pipe network: a query compiles a path through + * the graph on demand; entries live in the swappable backing store. + */ +import { pushable } from '@push-stream-std/push-pushable'; +import { + readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, + openSync, closeSync, writeSync, fsyncSync +} from 'node:fs'; +import { join } from 'node:path'; +import { + snapshotCrc32, storeHeader, graphHeader, + encodeStoreRecord, encodeSchemaRecord, encodeGraphRecord, + encodeEntryBytes, decodeEntryBytes, encodeParams, decodeParams, + encodeSnapshot, encodeGraphSnapshot, + decodeSnapshot, decodeGraphSnapshot +} from './snapshot.js'; + +export function createMapStore() { + const map = new Map(); + return { + get: (key) => map.get(key), + set: (key, value) => { map.set(key, value); }, + delete: (key) => map.delete(key), + clear: () => map.clear(), + keys: () => map.keys(), + deletePrefix(prefix) { + let removed = 0; + for (const key of map.keys()) if (key.includes(prefix)) { map.delete(key); removed++; } + return removed; + }, + snapshotFile(path, opts = {}) { + writeSnapshotFile(path, this.keys(), (key) => this.get(key), opts); + }, + restoreFile(path) { + const snap = decodeSnapshot(readFileSync(path)); + for (const [key, entry] of snap.entries) this.set(key, entry); + return snap.entries.length; + } + }; +} + +// Bounded hot-page cache (Map preserves insertion order; delete+set refreshes recency). +function lruCache(max) { + const map = new Map(); + return { + get(key) { + if (!map.has(key)) return undefined; + const value = map.get(key); + map.delete(key); + map.set(key, value); // most-recently-used at the tail + return value; + }, + set(key, value) { + if (map.has(key)) map.delete(key); + map.set(key, value); + while (map.size > max) { + const oldest = map.keys().next().value; + map.delete(oldest); + } + }, + delete(key) { map.delete(key); }, + clear() { map.clear(); } + }; +} + +/** + * Disk-backed store — the larger-than-memory reference implementation. + * One BINARY file per entry key (encodeEntryBytes/decodeEntryBytes — the same + * JSON-free wire encoding the snapshots use), with a bounded LRU hot-page cache + * on top, so the graph object itself holds only its schema (relations) + + * active streams; the data volume may exceed RAM. Any store implementing the + * same synchronous `get`/`set`/`delete`/`clear`/`keys`/`deletePrefix` contract + * is swappable. + * + * @param {string} dir — directory that persists entries (created if missing) + * @param {Object} [options] + * @param {number} [options.maxResident=256] — max entries held in the hot-page cache + */ +export function createFileStore(dir, options = {}) { + const maxResident = options.maxResident ?? 256; + mkdirSync(dir, { recursive: true }); + const cache = lruCache(maxResident); + const fileFor = (key) => join(dir, encodeURIComponent(key) + '.bin'); + const isEntryFile = (name) => name.endsWith('.bin'); + + const store = { + get(key) { + const cached = cache.get(key); + if (cached !== undefined) return cached; + const file = fileFor(key); + if (!existsSync(file)) return undefined; + const value = decodeEntryBytes(readFileSync(file)); + cache.set(key, value); + return value; + }, + set(key, value) { + cache.set(key, value); + writeFileSync(fileFor(key), encodeEntryBytes(value)); + }, + delete(key) { + cache.delete(key); + const file = fileFor(key); + if (existsSync(file)) { unlinkSync(file); return true; } + return false; + }, + clear() { + cache.clear(); + for (const name of readdirSync(dir)) if (isEntryFile(name)) unlinkSync(join(dir, name)); + }, + keys() { + return readdirSync(dir).filter(isEntryFile).map((name) => decodeURIComponent(name.slice(0, -'.bin'.length))); + }, + deletePrefix(prefix) { + let removed = 0; + for (const key of this.keys()) if (key.includes(prefix)) { this.delete(key); removed++; } + return removed; + }, + /** + * Write a COMPACT, portable snapshot of every entry to a single file — + * record at a time (bounded memory), CRC-guarded. The per-key binary files + * are the working representation and are NOT portable; this file is. + */ + snapshotFile(path, opts = {}) { + writeSnapshotFile(path, this.keys(), (key) => this.get(key), opts); + }, + /** + * Load a snapshot file written by snapshotFile() (any store flavour) and + * materialize its entries into this store. Returns the entry count. + */ + restoreFile(path) { + const snap = decodeSnapshot(readFileSync(path)); + for (const [key, entry] of snap.entries) this.set(key, entry); + return snap.entries.length; + } + }; + + // Seamless larger-than-memory restore: create the store already loaded from a + // snapshot file. Entries are materialized into the per-key working files. + if (options.loadFrom && existsSync(options.loadFrom)) { + store.restoreFile(options.loadFrom); + } + return store; +} + +/** + * Streaming snapshot file writer shared by both store flavours: writes the + * header, then one record per key (memory bounded by the largest single + * entry), CRC-guards the whole file. `iterate()` yields keys; `resolve(key)` + * returns the entry. For graphs larger than memory this is the portable move: + * snapshot while running on the LRU hot-page cache, without holding the graph + * in RAM. + */ +function writeSnapshotFile(path, iterate, resolve, opts = {}) { + const keys = [...iterate]; + const fd = openSync(path, 'w'); + const crc = snapshotCrc32(); + try { + const head = storeHeader(keys.length, { createdAt: opts.createdAt ?? Date.now() }); + crc.update(head); + writeSync(fd, head); + for (const key of keys) { + const entry = resolve(key); + if (entry === undefined) continue; + const rec = encodeStoreRecord(key, entry); + crc.update(rec); + writeSync(fd, rec); + } + const tail = Buffer.alloc(4); + tail.writeUInt32LE(crc.digest()); + writeSync(fd, tail); + if (opts.fsync !== false) fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +/** + * Streaming GRAPH snapshot writer (schema + structured entries, CRC-guarded), + * record-at-a-time so a larger-than-memory graph transports without buffering + * the whole store in RAM. Matches the format ValueGraph.loadFile reads. + */ +function writeGraphSnapshotFile(path, vg, opts = {}) { + const keys = [...vg.store.keys()]; + const fd = openSync(path, 'w'); + const crc = snapshotCrc32(); + try { + const head = graphHeader(vg.relations.size, keys.length, { + createdAt: opts.createdAt ?? vg.clock() + }); + crc.update(head); + writeSync(fd, head); + for (const [rel, meta] of vg.relations) { + const rec = encodeSchemaRecord(rel, meta); + crc.update(rec); + writeSync(fd, rec); + } + for (const key of keys) { + const entry = vg.store.get(key); + if (entry === undefined) continue; + const { subject, relation, params } = vg._parseKey(key); + const rec = encodeGraphRecord({ subject, relation, params, entry }); + crc.update(rec); + writeSync(fd, rec); + } + const tail = Buffer.alloc(4); + tail.writeUInt32LE(crc.digest()); + writeSync(fd, tail); + if (opts.fsync !== false) fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +// --------------------------------------------------------------------------- +// Value type validators — the DSL declares a measure's PROVIDES type (or an +// entity/Definition name); the graph enforces it on writes and resolver results. +// --------------------------------------------------------------------------- + +const PRIMITIVE_VALIDATORS = { + number: (v) => typeof v === 'number' && !Number.isNaN(v), + string: (v) => typeof v === 'string', + boolean: (v) => typeof v === 'boolean', + buffer: (v) => v instanceof Uint8Array, + timestamp: (v) => typeof v === 'number', + duration: (v) => typeof v === 'number' || typeof v === 'string', + array: (v) => Array.isArray(v), + interval: (v) => v !== null && typeof v === 'object' && 'lower' in v && 'upper' in v, + object: (v) => v !== null && typeof v === 'object', + any: () => true +}; + +/** + * Validate a value against a declared value type. `undefined`/`null`/'' means + * untyped (accept anything). Any non-primitive type name (e.g. a DSL + * Definition like `Employee`) is treated as an entity/reference key. + */ +export function validateValueType(value, type) { + if (type === undefined || type === null || type === '') return true; + const validator = PRIMITIVE_VALIDATORS[type]; + if (validator) return validator(value); + // Entity / reference type: the value is an entity key (string or numeric id). + return typeof value === 'string' || typeof value === 'number'; +} + +// --------------------------------------------------------------------------- +// OWA weight generators (ADR-000 §OWA) +// --------------------------------------------------------------------------- + +function zeros(n) { return Array(n).fill(0); } +function fill(n, v) { return Array(n).fill(v); } +function normalize(w) { + const s = w.reduce((a, b) => a + b, 0); + return s > 0 ? w.map(x => x / s) : w; +} + +export function weightFor(operator, n, weights, priorities) { + const k = Math.max(0, n); + if (k === 0) return []; // no values → no positions + switch (operator) { + 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 normalize(w); + } + case 'pessimistic': return normalize(weightFor('optimistic', k).reverse()); + case 'top2': return normalize([0.5, 0.5, ...zeros(Math.max(0, k - 2))].slice(0, k)); + case 'top3': return normalize([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 normalize(ws); + } + return fill(k, 1 / Math.max(1, k)); + } + case 'custom': return normalize(weights && weights.length ? weights.slice(0, k) : fill(k, 1 / Math.max(1, k))); + default: return fill(k, 1 / Math.max(1, k)); + } +} + +/** + * Combine parent values by an OWA operator. Numeric values use position-based + * weights over descending order; non-numeric fall back to max/min/majority. + */ +export function owa(values, operator, { weights, priorities, capSum = false } = {}) { + const n = values.length; + if (n === 0) return 0; + const allNumeric = values.every(v => typeof v === 'number'); + if (operator === 'product') { + if (!allNumeric) throw new Error(`fusion:product requires numeric values`); + return values.reduce((a, b) => a * b, 1); + } + if (!allNumeric) { + switch (operator) { + 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 counts = new Map(); + for (const v of values) counts.set(v, (counts.get(v) || 0) + 1); + let best = values[0]; let bestCount = 0; + for (const [v, c] of counts) if (c > bestCount) { bestCount = c; best = v; } + return best; + } + default: + throw new Error(`fusion:${operator} requires numeric values (got ${typeof values[0]})`); + } + } + const sorted = [...values].sort((a, b) => b - a); + const w = weightFor(operator, n, weights, priorities); + let result = 0; + for (let i = 0; i < n; i++) result += (w[i] ?? 0) * sorted[i]; + if (operator === 'sum' && capSum) result = Math.min(1, result); + return result; +} + +// --------------------------------------------------------------------------- +// ValueGraph +// --------------------------------------------------------------------------- + +export class ValueGraph { + /** + * @param {Object} [options] + * @param {Function} [options.clock] — () => number (ms) + * @param {number} [options.defaultTTL] — ms + * @param {Object} [options.store] — backing store + * @param {Function} [options.resolveAttribute] — callback (nodeKey, path, params, ctx, cb) => value + * @param {Function} [options.resolvePattern] — callback (pattern, subject, params, ctx, cb) => value + */ + constructor(options = {}) { + this.clock = typeof options.clock === 'function' ? options.clock : (() => Date.now()); + this.defaultTTL = options.defaultTTL ?? 30_000; + this.store = options.store || createMapStore(); + this.resolveAttribute = options.resolveAttribute || null; + this.resolvePattern = options.resolvePattern || null; + this.relations = new Map(); // valueRelation -> node spec + this.active = new Map(); // valueRelation -> Set + this.versions = new Map(); // valueRelation -> epoch (per-relation staleness stamp) + this._epoch = 0; // monotonic mutation counter (introspection only) + } + + /** + * Per-relation version stamp. An entry is fresh iff its stored version equals + * the CURRENT stamp of its relation — i.e. nothing in the relation's transitive + * closure was set/invalidated since it was computed. Unrelated relations keep + * their stamp, so invalidation is SELECTIVE: only the affected dependent DAG + * recomputes, everything else stays cached. + */ + _versionFor(valueRelation) { + const v = this.versions.get(valueRelation); + return v === undefined ? 0 : v; + } + + _bumpVersion(valueRelation) { + this.versions.set(valueRelation, this._versionFor(valueRelation) + 1); + } + + /** + * Define a value relation as an operator node. + * @param {string} valueRelation + * @param {Object} spec + * @param {string} spec.operator — 'source'|'attribute'|'pattern'|'compute'|'fusion:' + * @param {string[]} [spec.parents] — parent value relations this node reads + * @param {Function} [spec.fn] — resolver (source/compute/attribute/pattern escape hatch) + * @param {string} [spec.attribute] — node field path for the attribute operator + * @param {Object} [spec.pattern] — traversal spec for the pattern operator + * @param {number[]} [spec.weights] — custom OWA weights (fusion:custom) + * @param {number[]} [spec.priorities] — per-parent priorities (fusion:priority) + * @param {boolean} [spec.capSum] — cap fusion:sum at 1.0 (evidence semantics) + * @param {number} [spec.ttl] — freshness window override (ms) + */ + define(valueRelation, spec) { + if (!spec || typeof spec.operator !== 'string') { + throw new Error(`value-graph: define('${valueRelation}') requires an operator`); + } + this.relations.set(valueRelation, { + operator: spec.operator, + parents: new Set(spec.parents || []), + fn: spec.fn || null, + attribute: spec.attribute || null, + pattern: spec.pattern || null, + weights: spec.weights || null, + priorities: spec.priorities || null, + capSum: Boolean(spec.capSum), + ttl: spec.ttl, + returnType: spec.returnType || null, + params: spec.params || null, + validate: spec.validate || null + }); + return this; + } + + /** + * Introspect a defined relation's spec (schema metadata: operator, returnType, + * params, parents, ttl). Returns null for undeclared relations. + */ + relationSpec(valueRelation) { + const meta = this.relations.get(valueRelation); + if (!meta) return null; + return { + operator: meta.operator, + parents: [...meta.parents], + returnType: meta.returnType, + params: meta.params, + ttl: meta.ttl, + capSum: meta.capSum, + validate: meta.validate + }; + } + + /** + * Enforce a declared return type on a value destined for a relation. + * Untyped relations skip validation (backward compatible). + */ + _assertReturnType(valueRelation, value) { + const meta = this.relations.get(valueRelation); + const type = meta && meta.returnType; + if (!type) return; + const ok = meta.validate + ? meta.validate(value) + : validateValueType(value, type); + if (!ok) { + throw new Error(`value-graph: value for '${valueRelation}' must match declared type '${type}'`); + } + } + + /** + * Convenience for source/compute nodes: `compute(rel, fn, { dependsOn, ttl })`. + * With `dependsOn` the fn receives parent values; without, it's a source. + */ + compute(valueRelation, fn, opts = {}) { + return this.define(valueRelation, { + operator: opts.dependsOn ? 'compute' : 'source', + fn, + parents: opts.dependsOn, + ttl: opts.ttl + }); + } + + ttlFor(valueRelation) { + const rel = this.relations.get(valueRelation); + return rel && rel.ttl !== undefined ? rel.ttl : this.defaultTTL; + } + + _key(subject, valueRelation, params) { + // JSON-free key tail: the params object encoded through the binary value + // encoder then base64url'd (no '|' in the alphabet, so the two-split in + // _parseKey stays unambiguous). Deterministic: same params → same key. + const tail = Buffer.from(encodeParams(params ?? {})).toString('base64url'); + return `${subject}|${valueRelation}|${tail}`; + } + + _activeFor(valueRelation) { + let set = this.active.get(valueRelation); + if (!set) { set = new Set(); this.active.set(valueRelation, set); } + return set; + } + + /** + * COMPILE → OPTIMIZE a query plan. Compile walks `parents` into a node DAG; + * OPTIMIZE applies two compile-time passes (see _optimizePlan): cached-subtree + * pruning (fresh nodes become trivial leaves) and ghost-parent elimination. + */ + plan(subject, valueRelation, params = {}) { + const nodes = new Map(); + const visiting = new Set(); + + const visit = (rel) => { + if (visiting.has(rel)) throw new Error(`value-graph: dependency cycle at '${rel}'`); + const existing = nodes.get(rel); + if (existing) return existing; + + const meta = this.relations.get(rel); + const key = this._key(subject, rel, params); + const entry = this.store.get(key); + const now = this.clock(); + const ttl = this.ttlFor(rel); + const fresh = Boolean(entry) && + entry.version === this._versionFor(rel) && + (ttl === 0 || now - entry.at < ttl); + + const node = { + relation: rel, + subject, + params, + key, + cached: entry || null, + fresh, + trivial: fresh, + operator: meta?.operator || null, + fn: meta?.fn || null, + attribute: meta?.attribute || null, + pattern: meta?.pattern || null, + weights: meta?.weights || null, + priorities: meta?.priorities || null, + capSum: meta?.capSum || false, + parents: [] + }; + nodes.set(rel, node); + + visiting.add(rel); + if (!fresh && meta?.parents) { + for (const dep of meta.parents) { + const parent = visit(dep); + if (!node.parents.includes(parent.relation)) node.parents.push(parent.relation); + } + } + visiting.delete(rel); + return node; + }; + + const target = visit(valueRelation); + return this._optimizePlan({ target, nodes, size: nodes.size }, subject, params); + } + + /** + * OPTIMIZE a compiled plan. The graph is deliberately NEVER fused (each operator + * stays a separately-cacheable node), so the optimize step is small and safe: + * 1. Cached-subtree pruning (done at compile): a fresh node is a trivial leaf + * with no parents, so run() reads its cache instead of re-evaluating. + * 2. Ghost-parent elimination: a FUSION parent that is undeclared AND uncached + * is guaranteed to resolve to null, which fusion filters out anyway — drop + * it here to avoid a wasted node evaluation. (Not applied to compute/ + * blackbox, which receive null in deps and may handle it.) + * Plan metadata (observable for tests / telemetry): + * size — total nodes after optimization + * recomputeCount — nodes run() must evaluate (non-trivial) + * prunedCount — nodes served straight from cache (trivial leaves) + * ghostPruned — fusion parents dropped as guaranteed-null + */ + _optimizePlan(plan, subject, params) { + let ghostPruned = 0; + const isGhost = (rel) => + !this.relations.has(rel) && this.store.get(this._key(subject, rel, params)) === undefined; + + for (const node of plan.nodes.values()) { + if (!node.operator || !node.operator.startsWith('fusion:')) continue; + if (node.trivial) continue; + const kept = []; + for (const parent of node.parents) { + if (isGhost(parent)) { ghostPruned++; continue; } + kept.push(parent); + } + node.parents = kept; + } + + // Remove now-unreachable ghost nodes so run() never evaluates them. + if (ghostPruned > 0) { + const reachable = new Set(); + const mark = (rel) => { + if (reachable.has(rel)) return; + reachable.add(rel); + const n = plan.nodes.get(rel); + if (n) for (const p of n.parents) mark(p); + }; + mark(plan.target.relation); + for (const rel of [...plan.nodes.keys()]) { + if (!reachable.has(rel)) plan.nodes.delete(rel); + } + } + + let recomputeCount = 0; + let prunedCount = 0; + for (const node of plan.nodes.values()) { + if (node.trivial && node.cached) prunedCount++; + else recomputeCount++; + } + plan.recomputeCount = recomputeCount; + plan.prunedCount = prunedCount; + plan.ghostPruned = ghostPruned; + plan.size = plan.nodes.size; + return plan; + } + + /** + * RUN a compiled plan in dependency order, dispatching each node to its + * operator. Callback-driven: `run(plan, cb)` — no promises. Trivial nodes + * read their cache; on resolver error a cached entry is served stale. + * + * PARALLELISM: all independent parents are fired as soon as they are reached — + * evalNode starts every child in a synchronous pass, so N independent async + * resolvers (e.g. Overlay REST calls) are all in flight simultaneously. A node + * reachable via multiple paths (a shared subtree / diamond) is evaluated + * exactly ONCE per run: subsequent paths JOIN its in-flight evaluation via + * waiter fan-out instead of re-firing the resolver. + */ + run(plan, cb) { + if (typeof cb !== 'function') { + throw new TypeError('value-graph: run(plan, cb) requires a callback'); + } + const self = this; + const results = new Map(); + const inFlight = new Map(); // valueRelation -> Array (waiter fan-out) + const runEpoch = this._epoch; // a mutation mid-run must not be clobbered + + const evalNode = (node, done) => { + const prior = results.get(node.relation); + if (prior !== undefined) return done(null, prior); + + // Another path is already evaluating this node — join it, don't re-fire. + const waiters = inFlight.get(node.relation); + if (waiters) { waiters.push(done); return; } + + const list = [done]; + inFlight.set(node.relation, list); + let settled = false; + const settle = (err, r) => { + if (settled) return; + settled = true; + inFlight.delete(node.relation); + for (const w of list) w(err, r); + }; + + if (node.trivial && node.cached) { + const r = { value: node.cached.value, unit: node.cached.unit, at: node.cached.at, source: node.cached.source, fresh: true }; + results.set(node.relation, r); + return settle(null, r); + } + + const deps = {}; + const finish = () => { + if (settled) return; // a parent already errored — don't fire this resolver + const now = self.clock(); + self._applyOperator(node, deps, now, (err, raw) => { + if (settled) return; + if (err) { + if (node.cached) { + const stale = { value: node.cached.value, unit: node.cached.unit, at: node.cached.at, source: node.cached.source, fresh: false }; + results.set(node.relation, stale); + return settle(null, stale); + } + return settle(err); + } + if (raw === null || raw === undefined) { + results.set(node.relation, null); + return settle(null, null); + } + let normalized; + try { + normalized = self._normalize(raw, node.relation); + self._assertReturnType(node.relation, normalized.value); + } catch (e) { + return settle(e); + } + const cached = { + value: normalized.value, + unit: normalized.unit, + at: now, + source: normalized.source || node.operator, + version: self._versionFor(node.relation) + }; + // Best-effort caching guarded by the run epoch: if a set()/invalidate() + // raced this run, its results are from a pre-mutation snapshot — deliver + // them but DO NOT cache (a stale read must never clobber an authoritative + // write). The next get recomputes from the current source. + if (self._epoch === runEpoch) { + try { + self.store.set(node.key, cached); + } catch (e) { + /* uncached serve — next get recomputes */ + } + } + const r = { value: cached.value, unit: cached.unit, at: cached.at, source: cached.source, fresh: true }; + results.set(node.relation, r); + settle(null, r); + }); + }; + + if (node.parents.length === 0) return finish(); + let remaining = node.parents.length; + for (const pRel of node.parents) { + const pNode = plan.nodes.get(pRel); + evalNode(pNode, (err, pResult) => { + if (err) return settle(err); // propagate to every waiter + deps[pRel] = pResult || null; + if (--remaining === 0) finish(); + }); + } + }; + + const target = plan.nodes.get(plan.target.relation); + return evalNode(target, cb); + } + + _applyOperator(node, deps, at, cb) { + // deps: relation -> full resolved entry ({ value, unit, at, source, fresh }) | null. + // ctx.deps — parent VALUES (relation -> value) — the compute convenience. + // ctx.entries — full parent entries (relation -> { value, unit, at, source, fresh }) + // — what a black-box fn needs to key an external (Overlay) query + // on the parent edges/values. + const depsValues = {}; + for (const rel of node.parents) depsValues[rel] = deps[rel] ? deps[rel].value : null; + const ctx = { subject: node.subject, params: node.params, deps: depsValues, entries: deps, at, graph: this }; + switch (node.operator) { + case 'source': + if (!node.fn) return cb(new Error(`value-graph: source node '${node.relation}' needs an fn`)); + return this._invokeResolver(node.fn, [node.subject, node.params, ctx], cb, node.relation); + case 'compute': + case 'blackbox': + if (!node.fn) return cb(new Error(`value-graph: ${node.operator} node '${node.relation}' needs an fn`)); + return this._invokeResolver(node.fn, [node.subject, node.params, ctx], cb, node.relation); + case 'attribute': + if (node.fn) return this._invokeResolver(node.fn, [node.subject, node.params, ctx], cb, node.relation); + if (this.resolveAttribute) return this._invokeResolver(this.resolveAttribute, [node.subject, node.attribute, node.params, ctx], cb, node.relation); + return cb(new Error(`value-graph: attribute node '${node.relation}' needs a resolveAttribute hook or fn`)); + case 'pattern': + if (node.fn) return this._invokeResolver(node.fn, [node.subject, node.params, ctx], cb, node.relation); + if (this.resolvePattern) return this._invokeResolver(this.resolvePattern, [node.pattern, node.subject, node.params, ctx], cb, node.relation); + return cb(new Error(`value-graph: pattern node '${node.relation}' needs a resolvePattern hook or fn`)); + default: { + if (node.operator && node.operator.startsWith('fusion:')) { + const op = node.operator.slice('fusion:'.length); + const values = node.parents.map(p => depsValues[p]).filter(v => v !== null && v !== undefined); + let result; + try { + result = owa(values, op, { weights: node.weights, priorities: node.priorities, capSum: node.capSum }); + } catch (err) { + return cb(err); + } + return cb(null, result); + } + if (node.fn) return this._invokeResolver(node.fn, [node.subject, node.params, ctx], cb, node.relation); + if (!node.operator) return cb(null, null); // undeclared relation, nothing cached + return cb(new Error(`value-graph: unknown operator '${node.operator}' for '${node.relation}'`)); + } + } + } + + /** + * Invoke a callback-oriented resolver. The resolver may (a) call `cb(err, result)` + * asynchronously and return `undefined`, or (b) return the result synchronously. + * A returned Promise is rejected loudly — this library is promise-free. + */ + _invokeResolver(fn, args, cb, label) { + let called = false; + const done = (err, value) => { + if (called) return; + called = true; + cb(err, value); + }; + let ret; + try { + ret = fn(...args, done); + } catch (err) { + return done(err); + } + if (ret === undefined) return; // async resolver — done() is called later + if (ret && typeof ret.then === 'function') { + return done(new Error( + `value-graph: resolver for '${label}' returned a Promise — resolvers must be callback-based (call cb) or return synchronously, never async/await` + )); + } + return done(null, ret); + } + + /** + * Async-free convenience: compile then run, callback-style. + * `get(subject, valueRelation, params, cb)` → cb(err, { value, unit, at, source, fresh } | null) + */ + get(subject, valueRelation, params = {}, cb) { + if (typeof cb !== 'function') { + throw new TypeError('value-graph: get(subject, relation, params, cb) requires a callback'); + } + let plan; + try { + plan = this.plan(subject, valueRelation, params); + } catch (err) { + return cb(err); + } + return this.run(plan, cb); + } + + /** + * Open a query path as a duplex stream: push { get: true } up, receive values + * (and `{ stale: true }` triggers pushed down from outside) on the source. + * Repeated gets are serialized via a busy/pending callback queue (no promises). + */ + query(subject, valueRelation, params = {}) { + const source = pushable(); + this._activeFor(valueRelation).add(source); + const self = this; + + let busy = false; + let pending = false; + const resolveAndPush = () => { + if (busy) { pending = true; return; } + busy = true; + let plan; + try { + plan = self.plan(subject, valueRelation, params); + } catch (err) { + busy = false; + source.push({ error: err.message }); + if (pending) { pending = false; resolveAndPush(); } + return; + } + self.run(plan, (err, entry) => { + busy = false; + if (err) { + source.push({ error: err.message }); + } else { + source.push(entry ? { ...entry, planSize: plan.size } : { value: null, fresh: false, at: 0 }); + } + if (pending) { pending = false; resolveAndPush(); } + }); + }; + + const sink = { + paused: false, + ended: false, + source: null, + write(req) { + if (this.ended) return; // no-op after end/abort + if (!req || typeof req !== 'object') return; + if (req.get) resolveAndPush(); + else if (req.abort) this.end(req.abort); + }, + end(err) { + if (this.ended) return; + this.ended = err || true; + self._activeFor(valueRelation).delete(source); + if (err) source.abort(err); + else source.end(); + }, + abort(err) { this.end(err || true); } + }; + + return { source, sink }; + } + + /** + * Eager update: write a value directly, invalidating the dependent DAG. + * Only the relation's transitive DEPENDENTS lose their version stamp — the + * relation itself is overwritten (still fresh), and unrelated relations keep + * their cached entries. Watchers of the set relation are notified (a stale + * trigger, no `via`), so a push-consumer learns its value changed — matching + * `invalidate`'s notification behavior. + */ + set(subject, valueRelation, params = {}, { value, unit = null, source = 'set' } = {}) { + this._validateValue(value, valueRelation); + this._assertReturnType(valueRelation, value); + this._epoch++; + const affected = this._affectedRelations(valueRelation); + const now = this.clock(); + this.store.set(this._key(subject, valueRelation, params), { + value, unit, at: now, source, version: this._versionFor(valueRelation), deps: {} + }); + this._pushStale(valueRelation, { + stale: true, relation: valueRelation, subject, params, at: now + }); + for (const rel of affected) { + if (rel === valueRelation) continue; + this._bumpVersion(rel); + this.store.delete(this._key(subject, rel, params)); + this._pushStale(rel, { + stale: true, relation: rel, subject, params, + ...{ via: valueRelation, source }, + at: now + }); + } + return this; + } + + /** + * Invalidate: compile the affected dependent DAG, then run it (delete each + * affected entry, push stale triggers down, cascade to dependents). Bumps the + * version stamp of the relation AND its transitive dependents, so exactly the + * affected closure recomputes; unrelated relations stay cached. + * + * `invalidate()` with NO arguments clears the whole store. Providing a subject + * WITHOUT a relation is a call error (the relation was forgotten) — throwing + * prevents silently nuking the cache. + */ + invalidate(subject, valueRelation, params, opts = {}) { + if (valueRelation === undefined && subject !== undefined) { + throw new Error(`value-graph: invalidate(subject, relation, params) requires a valueRelation`); + } + this._epoch++; + const affected = this._affectedRelations(valueRelation); + for (const rel of affected) this._bumpVersion(rel); + const at = this.clock(); + if (subject && valueRelation) { + for (const rel of affected) this.store.delete(this._key(subject, rel, params)); + } else if (valueRelation) { + for (const rel of affected) this.store.deletePrefix(`|${rel}|`); + } else { + this.store.clear(); + } + for (const rel of affected) { + this._pushStale(rel, { + stale: true, relation: rel, subject, params, + ...(rel !== valueRelation ? { via: valueRelation } : {}), + at + }); + } + return this; + } + + invalidateAll(valueRelation) { + return this.invalidate(null, valueRelation); + } + + _affectedRelations(seed) { + const reverse = new Map(); + for (const [rel, meta] of this.relations) { + for (const dep of meta.parents) { + let set = reverse.get(dep); + if (!set) { set = new Set(); reverse.set(dep, set); } + set.add(rel); + } + } + const affected = new Set(); + const queue = [seed]; + while (queue.length) { + const cur = queue.shift(); + if (affected.has(cur)) continue; + affected.add(cur); + for (const child of reverse.get(cur) || []) queue.push(child); + } + return affected; + } + + /** + * Monotonic mutation counter (introspection / metrics). Freshness is per-relation + * (see _versionFor), so this is NOT the freshness authority. + */ + get version() { return this._epoch; } + + _pushStale(valueRelation, message) { + const set = this.active.get(valueRelation); + if (set) for (const src of set) src.push(message); + } + + // ------------------------------------------------------------------------- + // Persistence & transport — compact portable snapshots. + // + // Keys are `${subject}|${relation}|${base64url(params-bytes)}` — no JSON + // anywhere. `|` in a subject/relation already collides in the store, so the + // two-split below is unambiguous for every key this graph can form (the + // base64url alphabet has no '|'). + // ------------------------------------------------------------------------- + + _parseKey(key) { + const i1 = key.indexOf('|'); + const i2 = key.indexOf('|', i1 + 1); + return { + subject: key.slice(0, i1), + relation: key.slice(i1 + 1, i2), + params: decodeParams(Buffer.from(key.slice(i2 + 1), 'base64url')) + }; + } + + /** + * Re-declare a relation from decoded snapshot metadata (functions cannot be + * serialized — `fn` is attached only when the caller supplies a resolver). + * No-op when the relation is already declared. + */ + _defineFromMeta(rel, meta, fn) { + if (this.relations.has(rel)) return this; + return this.define(rel, { + operator: meta.operator, + parents: meta.parents, + fn: fn || null, + attribute: meta.attribute, + pattern: meta.pattern, + weights: meta.weights && meta.weights.length ? meta.weights : null, + priorities: meta.priorities && meta.priorities.length ? meta.priorities : null, + capSum: meta.capSum, + ttl: meta.ttl, + returnType: meta.returnType, + params: meta.params, + validate: null // functions are not serializable + }); + } + + /** + * Encode this graph as a compact, portable snapshot Buffer: schema metadata + * (operators/parents/types, minus functions) + every entry, CRC-guarded. + * Pass `includeSchema: false` for an opaque store dump (no relation metadata). + * Transportable: same bytes load on any machine/process (see ValueGraph.restore). + */ + snapshot({ includeSchema = true, createdAt } = {}) { + const records = []; + for (const key of this.store.keys()) { + const entry = this.store.get(key); + if (entry === undefined) continue; + const { subject, relation, params } = this._parseKey(key); + records.push({ subject, relation, params, entry }); + } + if (!includeSchema) { + return encodeSnapshot( + records.map(({ subject, relation, params, entry }) => [this._key(subject, relation, params), entry]), + { createdAt: createdAt ?? this.clock() } + ); + } + return encodeGraphSnapshot( + { schema: [...this.relations], records }, + { createdAt: createdAt ?? this.clock() } + ); + } + + /** + * Write a snapshot to a file, streaming record-at-a-time (bounded memory — + * larger-than-memory graphs can be transported without holding the graph in + * RAM). `snapshotFile`/`saveFile` write the same portable format loadFile reads. + */ + snapshotFile(path, opts = {}) { + writeGraphSnapshotFile(path, this, opts); + return this; + } + + saveFile(path, opts) { + return this.snapshotFile(path, opts); + } + + /** + * Merge a graph snapshot into THIS graph (schema relations declared if absent, + * entries loaded). Restored entries are re-stamped with the graph's CURRENT + * per-relation version, so a restored cache is treated as fresh (subject to TTL). + */ + restore(buffer) { + const snap = decodeGraphSnapshot(buffer); + for (const [rel, meta] of snap.schema) { + if (!this.relations.has(rel)) this._defineFromMeta(rel, meta, null); + } + for (const rec of snap.records) { + const entry = { ...rec.entry, version: this._versionFor(rec.relation) }; + this.store.set(this._key(rec.subject, rec.relation, rec.params ?? {}), entry); + } + return this; + } + + /** + * Reconstruct a graph from a snapshot Buffer. Schema metadata is restored + * verbatim (minus functions); pass `resolvers` ({ relation -> fn }) to attach + * source/compute/blackbox resolvers, and any other constructor options + * (store, clock, defaultTTL, resolveAttribute, resolvePattern). + */ + static restore(buffer, options = {}) { + const vg = new ValueGraph({ + clock: options.clock, + defaultTTL: options.defaultTTL, + store: options.store, + resolveAttribute: options.resolveAttribute, + resolvePattern: options.resolvePattern + }); + const snap = decodeGraphSnapshot(buffer); + const resolvers = options.resolvers || {}; + for (const [rel, meta] of snap.schema) { + vg._defineFromMeta(rel, meta, resolvers[rel] || null); + } + for (const rec of snap.records) { + const entry = { ...rec.entry, version: vg._versionFor(rec.relation) }; + vg.store.set(vg._key(rec.subject, rec.relation, rec.params ?? {}), entry); + } + return vg; + } + + /** Reconstruct a graph from a snapshot FILE (see ValueGraph.restore). */ + static loadFile(path, options = {}) { + return ValueGraph.restore(readFileSync(path), options); + } + + _validateValue(value, valueRelation) { + const isInterval = value !== null && typeof value === 'object' && + 'lower' in value && 'upper' in value; + const ok = value !== undefined && value !== null && + !Number.isNaN(value) && + (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean' || + value instanceof Uint8Array || Array.isArray(value) || isInterval); + if (!ok) throw new Error(`value-graph: set for '${valueRelation}' requires a value (number/string/boolean/buffer/array/interval)`); + } + + _normalize(result, valueRelation) { + const supported = (v) => + (typeof v === 'number' && !Number.isNaN(v)) || typeof v === 'string' || + typeof v === 'boolean' || + v instanceof Uint8Array || Array.isArray(v) || + (v && typeof v === 'object' && 'lower' in v && 'upper' in v); + if (supported(result)) return { value: result, unit: null }; + if (result && typeof result === 'object' && 'value' in result && supported(result.value)) { + return { value: result.value, unit: result.unit ?? null, source: result.source }; + } + throw new Error(`value-graph: operator for '${valueRelation}' must return a value, { value, unit }, or an interval`); + } +} diff --git a/test/value-graph.rigor.test.js b/test/value-graph.rigor.test.js new file mode 100644 index 0000000..d128100 --- /dev/null +++ b/test/value-graph.rigor.test.js @@ -0,0 +1,2017 @@ +/** + * @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 }); + }); +}); diff --git a/test/value-graph.test.js b/test/value-graph.test.js new file mode 100644 index 0000000..819e8c1 --- /dev/null +++ b/test/value-graph.test.js @@ -0,0 +1,778 @@ +/** + * @arbiter/value-graph tests — compile → run separation for BOTH queries and + * invalidations/eager updates, pull-on-duplex-push queries, TTL + version + * staleness, dependent-DAG cascade, all DSL value types, swappable stores. + * + * Resolver contract: CALLBACK ORIENTED (no async/await/promises). Resolvers may + * call `cb(err, result)` asynchronously (returning `undefined`) or return the + * result synchronously. A returned Promise is rejected. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readdirSync, existsSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + ValueGraph, createMapStore, createFileStore, + encodeSnapshot, decodeSnapshot, encodeGraphSnapshot, decodeGraphSnapshot, + encodeParams, decodeParams +} from '../src/index.js'; +import { createSink } from '@push-stream-std/push-stream-base'; + +// Promise-free library, promise-ful test harness: adapt the callback API. +function get(vg, subject, rel, params = {}) { + return new Promise((resolve, reject) => { + vg.get(subject, rel, params, (err, result) => err ? reject(err) : resolve(result)); + }); +} +function run(vg, plan) { + return new Promise((resolve, reject) => { + vg.run(plan, (err, result) => err ? reject(err) : resolve(result)); + }); +} + +function collect(stream, { until, timeout = 4000 } = {}) { + return new Promise((resolve, reject) => { + const items = []; + const timer = setTimeout(() => reject(new Error(`collect timeout: ${JSON.stringify(items)}`)), timeout); + stream.pipe(createSink( + (data) => { + items.push(data); + if (until && until(items)) { clearTimeout(timer); resolve(items); } + }, + (err) => { clearTimeout(timer); err ? reject(err) : resolve(items); } + )); + }); +} + +describe('ValueGraph compile → run (queries)', () => { + it('prunes cached subtrees into a single trivial leaf, then runs it', async () => { + let calls = 0; + const vg = new ValueGraph(); + vg.compute('double_balance', (s, p, { deps }) => { calls++; return { value: deps.base_balance * 2, unit: 'usd_cents' }; }, { dependsOn: ['base_balance'] }); + vg.compute('base_balance', (s, p, ctx, cb) => cb(null, 21)); + + const p1 = vg.plan('t:1', 'double_balance', {}); + assert.equal(p1.size, 2); + assert.deepEqual(p1.nodes.get('double_balance').parents, ['base_balance']); + assert.equal((await run(vg, p1)).value, 42); + + const p2 = vg.plan('t:1', 'double_balance', {}); + assert.equal(p2.nodes.get('double_balance').trivial, true); + assert.deepEqual(p2.nodes.get('double_balance').parents, []); + assert.equal(p2.size, 1, 'cached subtree pruned'); + assert.equal(calls, 1, 'no recompute'); + assert.equal((await run(vg, p2)).value, 42); + }); + + it('accepts synchronous resolver returns (pure compute)', async () => { + const vg = new ValueGraph(); + vg.compute('double_balance', (s, p, { deps }) => deps.base_balance * 2, { dependsOn: ['base_balance'] }); + vg.compute('base_balance', () => 21); + assert.equal((await get(vg, 't:1', 'double_balance', {})).value, 42); + }); + + it('rejects a Promise-returning resolver (promise-free contract)', async () => { + const vg = new ValueGraph(); + vg.compute('bad', async () => 42); + await assert.rejects(() => get(vg, 'u:1', 'bad', {}), /returned a Promise/); + }); + + it('recomputes after TTL expiry (plan marks the node non-trivial)', async () => { + let t = 0; + let value = 10; + const vg = new ValueGraph({ clock: () => t, defaultTTL: 1000 }); + vg.compute('quota', (s, p, ctx, cb) => cb(null, value)); + + assert.equal((await get(vg, 't:1', 'quota', {})).value, 10); + assert.equal(vg.plan('t:1', 'quota', {}).nodes.get('quota').trivial, true); + t = 1500; + value = 20; + assert.equal(vg.plan('t:1', 'quota', {}).nodes.get('quota').trivial, false, 'expired → non-trivial'); + assert.equal((await get(vg, 't:1', 'quota', {})).value, 20); + }); + + it('resolves a query duplex with repeated get pushes (pull on duplex push)', async () => { + let calls = 0; + const vg = new ValueGraph(); + vg.compute('balance', (s, p, ctx, cb) => { calls++; cb(null, { value: 42, unit: 'usd_cents' }); }); + + const q = vg.query('user:alice', 'balance', {}); + const events = collect(q.source, { until: (items) => items.filter(i => i.value !== undefined).length >= 2 }); + q.sink.write({ get: true }); + q.sink.write({ get: true }); + const items = await events; + + const values = items.filter(i => i.value !== undefined).map(i => i.value); + assert.deepEqual(values, [42, 42]); + assert.equal(calls, 1, 'second get served from the cached trivial plan'); + }); + + it('rejects a dependency cycle at compile time', async () => { + const vg = new ValueGraph(); + vg.compute('a', () => 1, { dependsOn: ['b'] }); + vg.compute('b', () => 2, { dependsOn: ['a'] }); + await assert.rejects(() => get(vg, 'x', 'a', {}), /cycle/); + }); +}); + +describe('ValueGraph compile → run (invalidation / eager updates)', () => { + it('invalidate compiles the dependent DAG and recomputes downstream', async () => { + let base = 10; + const vg = new ValueGraph(); + vg.compute('base_balance', () => base); + vg.compute('double_balance', (s, p, { deps }) => ({ value: deps.base_balance * 2, unit: 'usd_cents' }), { dependsOn: ['base_balance'] }); + vg.compute('quad_balance', (s, p, { deps }) => deps.double_balance * 2, { dependsOn: ['double_balance'] }); + + assert.equal((await get(vg, 't:1', 'quad_balance', {})).value, 40); + assert.equal(vg.plan('t:1', 'quad_balance', {}).size, 1, 'cached: single leaf'); + + base = 30; + vg.invalidate('t:1', 'base_balance', {}); // affects base + double + quad transitively + + const p = vg.plan('t:1', 'quad_balance', {}); + assert.equal(p.size, 3, 'invalidation expanded the plan to the full dependent DAG'); + assert.equal((await run(vg, p)).value, 120, 'recomputed down the chain'); + }); + + it('pushes a stale trigger down on invalidation and recomputes', async () => { + let value = 100; + const vg = new ValueGraph(); + vg.compute('available', () => value); + + const q = vg.query('tenant:acme', 'available', { feature: 'tokens_in:gpt-4' }); + const events = collect(q.source, { + until: (items) => items.some(i => i.stale) && items.filter(i => i.value !== undefined).length >= 2 + }); + q.sink.write({ get: true }); + value = 50; + vg.invalidate('tenant:acme', 'available', { feature: 'tokens_in:gpt-4' }); + q.sink.write({ get: true }); + const items = await events; + + assert.ok(items.some(i => i.stale === true), 'stale trigger pushed down'); + assert.ok(items.filter(i => i.value !== undefined).some(i => i.value === 50), 'recomputed to 50'); + }); + + it('set compiles the dependent DAG: writes the source, invalidates dependents', async () => { + let calls = 0; + const vg = new ValueGraph(); + vg.compute('base_balance', () => 10); + vg.compute('double_balance', (s, p, { deps }) => { calls++; return deps.base_balance * 2; }, { dependsOn: ['base_balance'] }); + + assert.equal((await get(vg, 't:1', 'double_balance', {})).value, 20); + // Eager update from outside (e.g. a ledger posting). + vg.set('t:1', 'base_balance', {}, { value: 50, unit: 'usd_cents' }); + + // double_balance is no longer cached — the set invalidated it. + assert.equal(vg.plan('t:1', 'double_balance', {}).nodes.get('double_balance').trivial, false); + const got = await get(vg, 't:1', 'double_balance', {}); + assert.equal(got.value, 100, 'dependent recomputed after eager set'); + }); + + it('cascades a stale trigger to dependent queries on set', async () => { + const vg = new ValueGraph(); + vg.compute('base_balance', () => 10); + vg.compute('double_balance', (s, p, { deps }) => deps.base_balance * 2, { dependsOn: ['base_balance'] }); + + const q = vg.query('t:1', 'double_balance', {}); + const events = collect(q.source, { until: (items) => items.some(i => i.stale) }); + q.sink.write({ get: true }); + vg.set('t:1', 'base_balance', {}, { value: 99, unit: 'usd_cents' }); + const items = await events; + assert.ok(items.some(i => i.stale === true && i.relation === 'double_balance' && i.via === 'base_balance')); + }); +}); + +describe('ValueGraph value types', () => { + it('supports number, string, boolean, timestamp, and arrays', async () => { + const vg = new ValueGraph(); + vg.compute('reputation', () => 7.5); + vg.compute('role', () => 'admin'); + vg.compute('active', () => true); + vg.compute('last_login', () => 1700000000000); + vg.compute('permissions', () => ['read', 'write']); + + assert.equal((await get(vg, 'u:1', 'reputation', {})).value, 7.5); + assert.equal((await get(vg, 'u:1', 'role', {})).value, 'admin'); + assert.equal((await get(vg, 'u:1', 'active', {})).value, true); + assert.equal((await get(vg, 'u:1', 'last_login', {})).value, 1700000000000); + assert.deepEqual((await get(vg, 'u:1', 'permissions', {})).value, ['read', 'write']); + }); + + it('supports interval values ({ lower, upper }) for possibilistic estimates', async () => { + const vg = new ValueGraph(); + vg.compute('estimated_tokens', () => ({ lower: 800, upper: 1400 })); + const entry = await get(vg, 't:1', 'estimated_tokens', {}); + assert.deepEqual(entry.value, { lower: 800, upper: 1400 }); + }); + + it('rejects an unregistered value type', async () => { + const vg = new ValueGraph(); + vg.compute('bad', () => ({ value: { nested: true } })); + await assert.rejects(() => get(vg, 'u:1', 'bad', {}), /must return a value/); + }); +}); + +describe('ValueGraph typed values (DSL-declared returnType)', () => { + it('exposes the declared spec via relationSpec', () => { + const vg = new ValueGraph(); + vg.define('balance', { operator: 'source', returnType: 'number', params: [{ name: 'tenant', type: 'Employee', isArray: false }], fn: () => 0 }); + const spec = vg.relationSpec('balance'); + assert.equal(spec.returnType, 'number'); + assert.equal(spec.params[0].name, 'tenant'); + assert.equal(spec.operator, 'source'); + assert.equal(vg.relationSpec('nope'), null); + }); + + it('set enforces the declared return type', () => { + const vg = new ValueGraph(); + vg.define('role', { operator: 'source', returnType: 'string', fn: () => null }); + vg.set('t:1', 'role', {}, { value: 'admin' }); + assert.throws(() => vg.set('t:1', 'role', {}, { value: 42 }), /must match declared type 'string'/); + }); + + it('resolver results are validated against the declared return type', async () => { + const vg = new ValueGraph(); + vg.define('role', { operator: 'source', returnType: 'string', fn: () => 42 }); + await assert.rejects(() => get(vg, 't:1', 'role', {}), /must match declared type 'string'/); + }); + + it('accepts interval values via set (consistent with the resolver path)', async () => { + const vg = new ValueGraph(); + vg.define('estimate', { operator: 'source', returnType: 'interval', fn: () => null }); + vg.set('t:1', 'estimate', {}, { value: { lower: 100, upper: 200 } }); + const e = await get(vg, 't:1', 'estimate', {}); + assert.deepEqual(e.value, { lower: 100, upper: 200 }); + }); + + it('entity-typed values accept keys (non-primitive type names)', () => { + const vg = new ValueGraph(); + vg.define('owner', { operator: 'source', returnType: 'Employee', fn: () => null }); + vg.set('t:1', 'owner', {}, { value: 'user:alice' }); // key accepted + assert.throws(() => vg.set('t:1', 'owner', {}, { value: { id: 1 } }), /requires a value/); // object not a valid key + }); +}); + +describe('ValueGraph invalidation guards', () => { + it('invalidate(subject) without a relation throws instead of clearing the whole store', () => { + const vg = new ValueGraph(); + vg.compute('a', () => 1); + assert.throws(() => vg.invalidate('t:1'), /requires a valueRelation/); + }); + + it('invalidate() with no arguments still clears the whole store', async () => { + const vg = new ValueGraph(); + vg.compute('a', () => 1); + await get(vg, 't:1', 'a', {}); + assert.equal(vg.plan('t:1', 'a', {}).nodes.get('a').trivial, true); + vg.invalidate(); + assert.equal(vg.plan('t:1', 'a', {}).nodes.get('a').trivial, false, 'cleared'); + }); +}); + +describe('ValueGraph backing store', () => { + it('uses a swappable backing store', async () => { + const store = createMapStore(); + let put = 0; + const tracked = { ...store, set(key, value) { put++; store.set(key, value); } }; + const vg = new ValueGraph({ store: tracked }); + vg.compute('score', () => 7); + assert.equal((await get(vg, 'u:1', 'score', {})).value, 7); + assert.equal(put, 1); + }); + + it('returns null when no compute is registered and nothing is cached', async () => { + const vg = new ValueGraph(); + assert.equal(await get(vg, 'u:1', 'nope', {}), null); + }); +}); + +describe('ValueGraph larger-than-memory (disk-backed store)', () => { + function tmpDir() { + return mkdtempSync(join(tmpdir(), 'value-graph-')); + } + + it('persists entries to disk and reads them back into a fresh graph instance', async () => { + const dir = tmpDir(); + try { + // First process: compute and cache against the disk store. + let calls = 0; + const vg1 = new ValueGraph({ store: createFileStore(dir) }); + vg1.compute('double_balance', (s, p, { deps }) => deps.base_balance * 2, { dependsOn: ['base_balance'] }); + vg1.compute('base_balance', (s, p, ctx, cb) => { calls++; cb(null, 21); }); + assert.equal((await get(vg1, 't:1', 'double_balance', {})).value, 42); + assert.equal(calls, 1); + assert.ok(readdirSync(dir).length > 0, 'entries written to disk'); + + // Second process: a brand-new graph over the SAME directory. + const vg2 = new ValueGraph({ store: createFileStore(dir) }); + vg2.compute('double_balance', (s, p, { deps }) => deps.base_balance * 2, { dependsOn: ['base_balance'] }); + vg2.compute('base_balance', () => { throw new Error('should not recompute from disk'); }); + const plan = vg2.plan('t:1', 'double_balance', {}); + assert.equal(plan.nodes.get('double_balance').trivial, true, 'cached leaf restored from disk'); + assert.equal((await run(vg2, plan)).value, 42, 'value read from disk, no recompute'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('keeps only a bounded number of entries resident (hot-page cache)', async () => { + const dir = tmpDir(); + try { + const vg = new ValueGraph({ store: createFileStore(dir, { maxResident: 2 }) }); + for (let i = 0; i < 5; i++) { + vg.compute(`v${i}`, () => i * 10); + } + for (let i = 0; i < 5; i++) { + assert.equal((await get(vg, 't', `v${i}`, {})).value, i * 10); + } + // All five entries persist on disk even though only 2 are resident. + assert.equal(readdirSync(dir).filter((f) => f.endsWith('.bin')).length, 5); + const again = await get(vg, 't', 'v0', {}); // evicted from cache → reloaded from disk + assert.equal(again.value, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('invalidate/set delete the persisted files', async () => { + const dir = tmpDir(); + try { + const vg = new ValueGraph({ store: createFileStore(dir) }); + vg.compute('balance', () => 100); + assert.equal((await get(vg, 't:1', 'balance', {})).value, 100); + assert.equal(readdirSync(dir).filter((f) => f.endsWith('.bin')).length, 1); + + vg.invalidate('t:1', 'balance', {}); + assert.equal(readdirSync(dir).filter((f) => f.endsWith('.bin')).length, 0, 'invalidate removed the file'); + + vg.set('t:1', 'balance', {}, { value: 42, unit: 'usd_cents' }); + assert.equal(readdirSync(dir).filter((f) => f.endsWith('.bin')).length, 1, 'set persisted the file'); + assert.equal((await get(vg, 't:1', 'balance', {})).value, 42); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('honors the store contract for a query duplex on a disk-backed graph', async () => { + const dir = tmpDir(); + try { + const vg = new ValueGraph({ store: createFileStore(dir) }); + vg.compute('available', () => 88); + const q = vg.query('tenant:acme', 'available', { feature: 'gpt-4' }); + const events = collect(q.source, { until: (items) => items.some((i) => i.value === 88) }); + q.sink.write({ get: true }); + await events; + assert.ok(readdirSync(dir).some((f) => f.endsWith('.bin')), 'query result persisted to disk'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('ValueGraph operator model (ADR-000 OWA)', () => { + function make(operators, parents) { + const vg = new ValueGraph(); + for (const [rel, value] of Object.entries(parents)) { + vg.define(rel, { operator: 'source', fn: () => value }); + } + for (const [rel, op] of Object.entries(operators)) { + vg.define(rel, { operator: `fusion:${op}`, parents: Object.keys(parents) }); + } + return vg; + } + + it('applies the OWA default weight distributions', async () => { + // values 10, 20, 30 + const vg = make({}, { a: 10, b: 20, c: 30 }); + vg.define('mx', { operator: 'fusion:max', parents: ['a', 'b', 'c'] }); + vg.define('mn', { operator: 'fusion:min', parents: ['a', 'b', 'c'] }); + vg.define('av', { operator: 'fusion:average', parents: ['a', 'b', 'c'] }); + vg.define('sm', { operator: 'fusion:sum_unbounded', parents: ['a', 'b', 'c'] }); + vg.define('md', { operator: 'fusion:median', parents: ['a', 'b', 'c'] }); + + assert.equal((await get(vg, 't', 'mx', {})).value, 30); + assert.equal((await get(vg, 't', 'mn', {})).value, 10); + assert.ok(Math.abs((await get(vg, 't', 'av', {})).value - 20) < 1e-9, 'average ≈ 20'); + assert.equal((await get(vg, 't', 'sm', {})).value, 60); + assert.equal((await get(vg, 't', 'md', {})).value, 20); + }); + + it('applies custom OWA weights', async () => { + const vg = make({}, { a: 100, b: 0 }); + vg.define('custom', { operator: 'fusion:custom', parents: ['a', 'b'], weights: [0.75, 0.25] }); + // sorted [100, 0]; 0.75*100 + 0.25*0 = 75 + assert.equal((await get(vg, 't', 'custom', {})).value, 75); + }); + + it('supports optimistic / pessimistic / top2', async () => { + const vg = make({}, { a: 10, b: 20, c: 30 }); + vg.define('opt', { operator: 'fusion:optimistic', parents: ['a', 'b', 'c'] }); + vg.define('pes', { operator: 'fusion:pessimistic', parents: ['a', 'b', 'c'] }); + vg.define('t2', { operator: 'fusion:top2', parents: ['a', 'b', 'c'] }); + // optimistic [0.5,0.25,0.125]→ normalized; pes reversed; top2 = 0.5*30+0.5*20 = 25 + assert.ok((await get(vg, 't', 'opt', {})).value > 20, 'optimistic favors high values'); + assert.ok((await get(vg, 't', 'pes', {})).value < 20, 'pessimistic favors low values'); + assert.equal((await get(vg, 't', 't2', {})).value, 25); + }); + + it('supports non-numeric max/min/majority (strings)', async () => { + const vg = make({}, { a: 'low', b: 'high', c: 'high' }); + vg.define('mx', { operator: 'fusion:max', parents: ['a', 'b', 'c'] }); + vg.define('maj', { operator: 'fusion:majority', parents: ['a', 'b', 'c'] }); + assert.equal((await get(vg, 't', 'mx', {})).value, 'low' > 'high' ? 'low' : 'high'); + assert.equal((await get(vg, 't', 'maj', {})).value, 'high'); + }); + + it('computes via an operator node with parents (deps passed via ctx)', async () => { + const vg = new ValueGraph(); + vg.define('base', { operator: 'source', fn: (s, p, ctx, cb) => cb(null, 7) }); + vg.define('twice', { operator: 'compute', parents: ['base'], fn: (subject, params, { deps }) => deps.base * 2 }); + assert.equal((await get(vg, 't', 'twice', {})).value, 14); + }); + + it('reads an attribute via the resolveAttribute callback hook', async () => { + const graph = new Map([['user:1', { balance: 500 }]]); + const vg = new ValueGraph({ + resolveAttribute: (nodeKey, path, params, ctx, cb) => { + const node = graph.get(nodeKey); + cb(null, node ? node[path] : undefined); + } + }); + vg.define('balance', { operator: 'attribute', attribute: 'balance' }); + assert.equal((await get(vg, 'user:1', 'balance', {})).value, 500); + }); + + it('resolves a pattern measure via the resolvePattern callback hook, lazily and cached', async () => { + let calls = 0; + const edges = [['u:1', 'r', 'x'], ['u:1', 'r', 'y'], ['u:1', 'r', 'z']]; + const vg = new ValueGraph({ + resolvePattern: (pattern, subject, params, ctx, cb) => { + calls++; + cb(null, edges.filter(e => e[0] === subject && e[1] === pattern.relation).length); + } + }); + vg.define('degree', { operator: 'pattern', pattern: { relation: 'r' } }); + assert.equal((await get(vg, 'u:1', 'degree', {})).value, 3); + assert.equal((await get(vg, 'u:1', 'degree', {})).value, 3); + assert.equal(calls, 1, 'pattern cached'); + }); + + it('invalidating a parent recomputes a fusion node', async () => { + let b = 10; + const vg = new ValueGraph(); + vg.define('a', { operator: 'source', fn: () => 5 }); + vg.define('base', { operator: 'source', fn: () => b }); + vg.define('sum', { operator: 'fusion:sum_unbounded', parents: ['a', 'base'] }); + assert.equal((await get(vg, 't', 'sum', {})).value, 15); + b = 100; + vg.invalidate('t', 'base', {}); + assert.equal((await get(vg, 't', 'sum', {})).value, 105, 'fusion recomputed after parent invalidation'); + }); +}); + +describe('ValueGraph blackbox node (function of its dependencies)', () => { + it('receives parent values (ctx.deps) and full entries (ctx.entries)', async () => { + const vg = new ValueGraph(); + vg.define('base', { operator: 'source', fn: (s, p, ctx, cb) => cb(null, { value: 21, unit: 'usd_cents' }) }); + let seen = null; + vg.define('total', { + operator: 'blackbox', + parents: ['base'], + fn: (subject, params, ctx, cb) => { + seen = ctx; + cb(null, ctx.deps.base * 2); + } + }); + + const entry = await get(vg, 't:1', 'total', {}); + assert.equal(entry.value, 42); + assert.equal(seen.deps.base, 21, 'ctx.deps exposes parent VALUES'); + assert.equal(seen.entries.base.value, 21, 'ctx.entries exposes the full parent result'); + assert.equal(seen.entries.base.unit, 'usd_cents'); + assert.equal(seen.entries.base.source, 'source'); + assert.equal(seen.entries.base.fresh, true); + }); + + it('queries an external service keyed on parent edges (callback style)', async () => { + const vg = new ValueGraph(); + const edges = [['u:1', 'r', 'x'], ['u:1', 'r', 'y']]; + vg.define('related', { + operator: 'pattern', + pattern: { relation: 'r' }, + fn: (s, p, ctx, cb) => cb(null, edges.filter(e => e[0] === s)) + }); + // Fake Overlay: given the related edges, score the subject asynchronously. + const overlayQuery = (edgeList, cb) => { + setTimeout(() => cb(null, { value: edgeList.length * 10, unit: 'score', source: 'overlay:evals' }), 5); + }; + vg.define('score', { + operator: 'blackbox', + parents: ['related'], + fn: (subject, params, ctx, cb) => overlayQuery(ctx.deps.related, cb) + }); + + const entry = await get(vg, 'u:1', 'score', {}); + assert.equal(entry.value, 20); + assert.equal(entry.source, 'overlay:evals'); + }); + + it('recomputes a blackbox node when a parent edge set changes', async () => { + const edges = [['u:1', 'r', 'x']]; + const vg = new ValueGraph(); + vg.define('related', { + operator: 'pattern', + pattern: { relation: 'r' }, + fn: (s, p, ctx, cb) => cb(null, edges.filter(e => e[0] === s)) + }); + vg.define('count', { + operator: 'blackbox', + parents: ['related'], + fn: (s, p, { deps }) => deps.related.length + }); + + assert.equal((await get(vg, 'u:1', 'count', {})).value, 1); + edges.push(['u:1', 'r', 'y'], ['u:1', 'r', 'z']); + vg.invalidate('u:1', 'related', {}); + assert.equal((await get(vg, 'u:1', 'count', {})).value, 3, 'blackbox re-run against the new edges'); + }); + + it('requires an fn', async () => { + const vg = new ValueGraph(); + vg.define('broken', { operator: 'blackbox', parents: [] }); + await assert.rejects(() => get(vg, 't', 'broken', {}), /blackbox node 'broken' needs an fn/); + }); +}); + +describe('ValueGraph compact snapshots (persistence & transport)', () => { + function tmpDir() { + return mkdtempSync(join(tmpdir(), 'value-graph-snap-')); + } + + // A graph whose entries exercise every value type: number, string, + // boolean, buffer (Uint8Array), array (with nested object + buffer), interval. + // NOTE: no JS bigint — large integers are Buffers (the wire is JSON-free). + function richGraph({ store, defaultTTL = 0 } = {}) { + const vg = new ValueGraph({ store, defaultTTL }); + vg.define('num', { operator: 'source', fn: () => 42 }); + vg.define('str', { operator: 'source', fn: () => 'hello' }); + vg.define('bool', { operator: 'source', fn: () => true }); + vg.define('buf', { operator: 'source', fn: () => new Uint8Array([1, 2, 3, 254, 255]) }); + vg.define('arr', { operator: 'source', fn: () => [1, 'two', new Uint8Array([3, 4]), { nested: true }] }); + vg.define('iv', { operator: 'source', fn: () => ({ lower: 0.5, upper: 1.5 }) }); + vg.define('fusion_risk', { + operator: 'fusion:custom', parents: ['num', 'buf'], weights: [0.25, 0.75], + capSum: true, returnType: 'number' + }); + return vg; + } + + it('buffer (Uint8Array) values round-trip byte-exactly through the wire', async () => { + const vg = richGraph(); + await get(vg, 't', 'buf', {}); + const buf = vg.snapshot(); + const snap = decodeGraphSnapshot(buf); + const value = snap.records.find((r) => r.relation === 'buf').entry.value; + assert.deepEqual([...value], [1, 2, 3, 254, 255]); + }); + + it('value-level round-trip: encodeSnapshot/decodeSnapshot is exact for all value types', async () => { + const vg = richGraph(); + // num with a nested-params key (params round-trip through _parseKey, JSON-free) + await get(vg, 't', 'num', { x: [1, { y: 2 }] }); + await get(vg, 't', 'str', {}); + await get(vg, 't', 'bool', {}); + await get(vg, 't', 'buf', {}); + await get(vg, 't', 'arr', {}); + await get(vg, 't', 'iv', {}); + + const buf = vg.snapshot(); + assert.ok(buf.length > 0); + assert.equal(Buffer.from(buf.subarray(0, 4)).toString('ascii'), 'VGGP', 'graph magic'); + const snap = decodeGraphSnapshot(buf); + assert.equal(snap.records.length, 6); + assert.equal(snap.schema.length, 7); + const byRel = new Map(snap.records.map((r) => [r.relation, r.entry])); + assert.equal(byRel.get('num').value, 42); + assert.equal(byRel.get('str').value, 'hello'); + assert.equal(byRel.get('bool').value, true); + assert.deepEqual([...byRel.get('buf').value], [1, 2, 3, 254, 255]); + assert.deepEqual(byRel.get('arr').value[0], 1); + assert.equal(byRel.get('arr').value[1], 'two'); + assert.deepEqual([...byRel.get('arr').value[2]], [3, 4]); + assert.deepEqual(byRel.get('arr').value[3], { nested: true }); + assert.deepEqual(byRel.get('iv').value, { lower: 0.5, upper: 1.5 }); + assert.deepEqual(snap.records.find((r) => r.relation === 'num').params, { x: [1, { y: 2 }] }, 'nested params round-trip'); + }); + + it('graph snapshot restores into a fresh graph: values served WITHOUT recompute', async () => { + let calls = 0; + const vg = richGraph(); + await get(vg, 't', 'num', {}); + await get(vg, 't', 'buf', {}); + const buf = vg.snapshot(); + + const restored = ValueGraph.restore(buf, { + resolvers: { num: () => { calls++; return -1; } } + }); + assert.equal(calls, 0); + const e = await get(restored, 't', 'num', {}); + assert.equal(e.value, 42); + assert.equal(e.fresh, true, 'restored entry treated as fresh (version re-stamped)'); + assert.deepEqual([...(await get(restored, 't', 'buf', {})).value], [1, 2, 3, 254, 255]); + assert.equal(restored.relationSpec('fusion_risk').operator, 'fusion:custom'); + assert.deepEqual(restored.relationSpec('fusion_risk').parents, ['num', 'buf']); + assert.equal(restored.relationSpec('fusion_risk').returnType, 'number'); + }); + + it('schema metadata round-trips: operator, parents, weights, capSum, returnType', async () => { + const vg = new ValueGraph({ defaultTTL: 0 }); + vg.define('s', { operator: 'source', fn: () => 1 }); + vg.define('f', { + operator: 'fusion:priority', parents: ['s'], priorities: [0.4], + ttl: 5000, returnType: 'number', params: [{ name: 'p', type: 'number' }] + }); + await get(vg, 't', 's', {}); + const restored = ValueGraph.restore(vg.snapshot(), { resolvers: { s: () => 1 } }); + const spec = restored.relationSpec('f'); + assert.equal(spec.operator, 'fusion:priority'); + assert.deepEqual(spec.parents, ['s']); + assert.equal(spec.ttl, 5000); + assert.equal(spec.returnType, 'number'); + assert.deepEqual(spec.params, [{ name: 'p', type: 'number' }]); + }); + + it('loadFile/snapshotFile: portable single-file transport between "processes"', async () => { + const dir = tmpDir(); + try { + // Process 1: disk-backed graph computes values, writes ONE portable file. + const vg1 = new ValueGraph({ store: createFileStore(dir), defaultTTL: 0 }); + vg1.compute('double_balance', (s, p, { deps }) => deps.base_balance * 2, { dependsOn: ['base_balance'] }); + vg1.compute('base_balance', (s, p, ctx, cb) => cb(null, 21)); + assert.equal((await get(vg1, 't:1', 'double_balance', {})).value, 42); + const snapPath = join(dir, 'graph.snap'); + vg1.snapshotFile(snapPath); + assert.ok(existsSync(snapPath)); + + // Process 2: brand-new graph, EMPTY store, reads the portable file. + // Resolvers re-registered; the resolver would throw if recomputed. + const loaded = ValueGraph.loadFile(snapPath, { + resolvers: { base_balance: () => { throw new Error('recompute!'); } } + }); + assert.equal(loaded.relations.size, 2, 'schema restored'); + const e = await get(loaded, 't:1', 'double_balance', {}); + assert.equal(e.value, 42); + assert.equal(e.fresh, true, 'no recompute — cache restored from disk'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('store-level snapshot: opaque key→entry dump round-trips through any store', async () => { + const vg = new ValueGraph({ defaultTTL: 0 }); + vg.compute('balance', (s, p, { deps }) => deps.rate * deps.units, { dependsOn: ['rate', 'units'] }); + vg.compute('rate', () => 3); + vg.compute('units', () => 4); + assert.equal((await get(vg, 'a', 'balance', {})).value, 12); + + const storeBuf = vg.snapshot({ includeSchema: false }); + const snap = decodeSnapshot(storeBuf); + assert.equal(snap.entries.length, 3); + assert.equal(Buffer.from(storeBuf.subarray(0, 4)).toString('ascii'), 'VGST', 'store magic'); + + // Feed the raw dump into a fresh map store, then serve from it. + const fresh = new ValueGraph({ store: createMapStore(), defaultTTL: 0 }); + for (const [key, entry] of snap.entries) fresh.store.set(key, entry); + assert.equal((await get(fresh, 'a', 'balance', {})).value, 12, 'opaque dump replays into a new store'); + }); + + it('createFileStore loadFrom: a store constructed already loaded from a snapshot', async () => { + const dirA = tmpDir(); + const dirB = tmpDir(); + try { + const vg = new ValueGraph({ store: createFileStore(dirA), defaultTTL: 0 }); + vg.compute('balance', () => 99); + assert.equal((await get(vg, 'u:1', 'balance', {})).value, 99); + const snapPath = join(dirA, 'store.snap'); + vg.store.snapshotFile(snapPath); + + // New store in a DIFFERENT directory, constructed with loadFrom. + const store = createFileStore(dirB, { loadFrom: snapPath }); + assert.ok(store.get(vg._key('u:1', 'balance', {})) !== undefined, 'snapshot preloaded'); + const vg2 = new ValueGraph({ store, defaultTTL: 0 }); + vg2.compute('balance', () => { throw new Error('recompute!'); }); + assert.equal((await get(vg2, 'u:1', 'balance', {})).value, 99, 'served from the loaded snapshot'); + assert.ok(readdirSync(dirB).filter((f) => f.endsWith('.bin')).length >= 1, 'materialized into working dir'); + } finally { + rmSync(dirA, { recursive: true, force: true }); + rmSync(dirB, { recursive: true, force: true }); + } + }); + + it('corrupt or truncated snapshots are rejected (CRC guard), never silently mis-read', async () => { + const vg = new ValueGraph({ defaultTTL: 0 }); + vg.compute('balance', () => 7); + await get(vg, 'u', 'balance', {}); + const buf = vg.snapshot(); + assert.doesNotThrow(() => decodeGraphSnapshot(buf)); + + // Flip a byte inside createdAt: parse succeeds, CRC must reject it. + const flipped = Buffer.from(buf); + flipped[9] ^= 0xff; + assert.throws(() => decodeGraphSnapshot(flipped), /CRC mismatch/); + + // Flip a byte inside a record payload: the parser may desync — the result + // must be a coherent corruption error, never a silent wrong answer. + const payloadFlip = Buffer.from(buf); + payloadFlip[payloadFlip.length - 8] ^= 0xff; + assert.throws(() => decodeGraphSnapshot(payloadFlip), /CRC mismatch|corrupt/); + + const truncated = Buffer.from(buf.subarray(0, buf.length - 4)); // missing CRC + assert.throws(() => decodeGraphSnapshot(truncated), /truncated/); + + const badMagic = Buffer.from(buf); + badMagic[0] = 0x00; + assert.throws(() => decodeGraphSnapshot(badMagic), /not a value-graph snapshot/); + + // Store flavour gets the same guards. + const storeBuf = vg.snapshot({ includeSchema: false }); + assert.doesNotThrow(() => decodeSnapshot(storeBuf)); + assert.throws(() => decodeSnapshot(Buffer.from(storeBuf.subarray(0, storeBuf.length - 5))), /truncated|CRC|corrupt/); + }); + + it('instance restore merges a snapshot into a live graph (schema declared if absent)', async () => { + const src = new ValueGraph({ defaultTTL: 0 }); + src.compute('rate', () => 5); + assert.equal((await get(src, 'u', 'rate', {})).value, 5); + + const dst = new ValueGraph({ defaultTTL: 0 }); + dst.compute('quota', () => 100); + await get(dst, 'u', 'quota', {}); + dst.restore(src.snapshot()); + + assert.equal(dst.relations.has('rate'), true, 'foreign schema declared'); + assert.equal((await get(dst, 'u', 'rate', {})).value, 5, 'foreign entries loaded'); + assert.equal((await get(dst, 'u', 'quota', {})).value, 100, 'local entries untouched'); + }); + + it('disk-backed store snapshot is byte-identical to the in-memory encode', async () => { + const dir = tmpDir(); + try { + const vg = new ValueGraph({ store: createFileStore(dir), defaultTTL: 0 }); + vg.compute('balance', () => 11); + vg.compute('tag', () => 'x'); + await get(vg, 'u:1', 'balance', {}); + await get(vg, 'u:1', 'tag', {}); + + const snapPath = join(dir, 'g.snap'); + vg.snapshotFile(snapPath, { createdAt: 12345 }); + const fromFile = readFileSync(snapPath); + const fromBuffer = vg.snapshot({ createdAt: 12345 }); + // Same schema+records, deterministic createdAt + CRC → identical bytes. + assert.deepEqual([...fromFile], [...fromBuffer], 'file snapshot == buffer snapshot'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});