@arbiter/value-graph (0.1.0)
Installation
@arbiter:registry=https://hub.kl1.tenere.ai/api/packages/Arbiter/npm/npm install @arbiter/value-graph@0.1.0"@arbiter/value-graph": "0.1.0"About this package
@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:
- call
cb(err, result)asynchronously and returnundefined, or - 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:
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)walksparentsinto 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 receivenullin deps and may handle it.) - Plan metadata:
size,recomputeCount(nodesrun()evaluates),prunedCount(nodes served from cache),ghostPruned(dropped ghosts).
- Cached-subtree pruning — a cache-safe (fresh) node becomes a single
trivial leaf with no parents, pruning its dependency subtree, so
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 noset/invalidateoccurred mid-run (run-epoch guard) — a slow read can never clobber an authoritativeset, and a pre-mutation snapshot is never cached after aninvalidate(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/setcompile 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;setmarks the set relation's trigger with novia, dependents withvia), 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 (aMap).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 freshValueGraphpointed at the same directory reads cached values straight from disk without recompute — proven by test. No JSON, no JS bigint on disk — large integers areUint8Array.- Any store implementing the synchronous contract is swappable — an mmap-backed store, a remote catalog, or (later) an Overlay-backed store.
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 → entrydump 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.
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 againstreturnType(untyped relations are backward-compatible and skip validation).- Resolver results are validated against
returnTypetoo — a wrong-typed resolver fails loudly instead of caching garbage. NaNis rejected at both thesetand 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
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/weightForinvariants (sum/avg/max/min/median/top2/ product/optimistic/pessimistic/custom, string fallbacks) + asumoracle. This caught and fixed two real edge cases:weightFor('top2'|'top3', n)over-produced weight vectors fornsmaller than the named positions, andweightFor(op, 0)returned[1]formax/mininstead 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
owafor 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:falseinstead 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)·kmatches 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+1bump. 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:0never expires);setpreserves 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 isO(n)resolver calls (no recomputation blowup),plan()compiles a deep chain inO(n), andweightForallocatesO(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.checkruns randomget/set/invalidate/mutatesequences against a reference model of the per-relation-version caching contract and the real graph; every value must match. Pins the exact semantics:setwrites fresh without bumping the relation version,invalidatebumps it and recomputes, and a backing mutation without invalidation stays cached until the next invalidation/TTL. - Query duplex lifecycle — a randomized
get/invalidatestream delivers only correct values and well-formed stale triggers;end()cleans up so post-end writes are silent no-ops (includinginvalidatepushes to the closed query); andabort()surfaces its error on the stream. - Store-fault robustness — a throwing store must never hang or crash the graph:
a store whose
setalways fails still serves correct values (best-effort uncached serve — this fixed a real hang where a run-timestore.setfailure was swallowed by the resolver guard), a flaky store recovers (later gets are cached), and a store whosegetfails 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
invalidateclears an eagersetvalue 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.objectprotocol campaign (withbefore/after/between) pins "afterset(v)everygetreturnsvuntilinvalidate"; 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 };setrejects 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,
setoverride, plan pruning, TTL expiry,blackboxdeps/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) ofcreateMapStoreandcreateFileStoreagainst a referenceMapunder randomset/get/delete/clear/deletePrefixsequences.
License
ISC
Dependencies
Dependencies
| ID | Version |
|---|---|
| @push-stream-std/push-pushable | * |
| @push-stream-std/push-stream-base | * |
| @rigor/probe | ^0.0.8 |
Development Dependencies
| ID | Version |
|---|---|
| @rigor/core | ^3.1.2 |