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').
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
artifacts/
|
||||||
|
*.log
|
||||||
|
*.snap
|
||||||
@@ -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}
|
||||||
@@ -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:<op>` | 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
|
||||||
@@ -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(); });
|
||||||
Generated
+292
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
+532
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
+1152
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user