@ancestralize/bayesian-utils
v0.2.0
Published
Bayesian network utilities shared by KBE and Ancestra Health: loopy belief propagation inference, diagnostic values, continuous state transitions and Noisy-MAX conversion.
Readme
@ancestralize/bayesian-utils
Bayesian network utilities shared by KBE (where researchers author the networks) and Ancestra Health (where end users' health risks are computed from them): loopy belief propagation inference, diagnostic values, the continuous state-transition model, and Noisy-MAX → general CPT conversion.
A port of the inference path of the bayesian-api compute service, pinned to
produce the same numbers (parity tests hold at 1e-5 against captures from the
deployed engine). Optimization is not included — it stays on Modal.
ESM only, and zero runtime dependencies. No Node builtins, no DOM, no
process.env, so the same build runs in a browser worker, in Node and on Vercel.
Tree-shakeable: a consumer that only reads exports bundles 5.3 KB, one that only wants
the transition maths 7.8 KB, and neither pulls in the WASM kernel.
Install
npm install @ancestralize/bayesian-utilsInference
createEngine() is the one await: it compiles the WASM kernel, which a browser
refuses to do synchronously above 4 KB on the main thread. Everything on the engine
is synchronous — holding one is what proves the kernel is ready.
import {
createEngine,
type InferenceNode,
type NetworkNodes,
} from '@ancestralize/bayesian-utils'
// Two levels, because the two costs have different lifetimes: compiling the kernel is
// once per process, and rewriting a network into what the kernel consumes is once per
// network. Hold both for as long as they are valid.
const engine = await createEngine()
// A network is a Map keyed by node key. Keys are opaque strings — pass UUIDs or names,
// whichever you hold.
const nodes: NetworkNodes = new Map<string, InferenceNode>([
[
'Smoking',
{
parameterizationType: 'general',
stateCount: 2,
parentKeys: [],
probabilities: [0.7, 0.3],
upstreamEffectsDisabled: false,
},
],
[
'Lung cancer',
{
parameterizationType: 'general',
stateCount: 2,
parentKeys: ['Smoking'],
// The node's own state varies fastest, then the last parent.
probabilities: [0.99, 0.01, 0.85, 0.15],
upstreamEffectsDisabled: false,
},
],
])
const network = engine.prepareNetwork(nodes)
const [smoker] = network.calculatePosteriors([
{
// Node-key-keyed collections are Maps throughout, inputs and outputs alike.
assumptions: new Map([['Smoking', { stateIndex: 1 }]]),
targets: ['Lung cancer'],
},
])
// The two error kinds name different things — one target versus every impossible
// assumption — so narrowing is what tells you which you have.
if (smoker.error) throw new Error(smoker.error.type)
// ~[0.85, 0.15] — the kernel computes in f32, matching the reference engine, so
// expect agreement to about 1e-6 rather than exact decimals.
smoker.probabilities.get('Lung cancer')Results are an array parallel to the queries — same length, same order — so there are no ids to keep in step and two identical queries are answered twice.
A whole batch shares one factor graph, so passing many queries to one call is much cheaper than calling it many times.
Every node-key-keyed collection is a Map — nodes, assumptions, roles, and the
returned posteriors and diagnostic values alike. The reason is that a plain object carries
Object.prototype, so 'toString' in assumptions is true and a variable named
toString or constructor is silently treated as having evidence it does not have. That
is the class of quietly-wrong answer this library refuses, and it applies to inputs and
outputs equally, so they are consistent.
The shapes that mirror a JSON file — ExportedNetwork.nodes and .types — stay
Records, because they describe a document rather than an API collection;
exportedNetworkToInferenceNodes is the boundary that converts.
Every field on a node is one inference reads, and all are required except
measurementScale, which genuinely does not apply to a categorical variable.
upstreamEffectsDisabled is required rather than defaulted because omitting it would let
evidence reach a parent that should not see it — an answer changed without any sign that
one was.
createEngine() is cheap to call again — the compiled kernel is memoized for the
process — so a per-request engine in a serverless handler is fine. prepareNetwork() is
not: it does the per-network work, so hold the PreparedNetwork for as long as the
network's contents are unchanged. On the released 629-node network, reusing it takes a
single-query call from ~22 ms to ~18 ms. It holds the nodes you gave it, the kernel-ready
rewrite of them, and a WASM instance — so it is also only valid while those nodes are
unchanged; mutate a CPT in place and it is stale, with no way to detect that.
Assumptions
An assumption is what you assert about a variable; evidence is the form inference consumes internally, and the library is the only thing that ever sees it. Two assumption forms, one per kind of thing you actually hold — a state you know, or a number you measured:
new Map([['Smoking', { stateIndex: 1 }]]) // a hard observation
new Map([['Glucose', { measuredValue: 8.1 }]]) // a measurement in its own unitsBoth are tagged. A bare 1 is rejected, because a bare number is exactly the case where
a state index and a measured value are indistinguishable and mean wildly different
things.
A measured value is converted for you through that node's own transition model:
give the node a measurementScale (filled in for you by
exportedNetworkToInferenceNodes from a KBE export, or assembled from your own stored
transitions) and the library does the smoothing, so there is only ever one way a value
becomes a distribution. A measurement on a node without a scale throws rather than
guessing.
There is deliberately no way to pass a likelihood vector directly. It was the third form here, and removing it removed the only way to hand inference a distribution the library did not derive — the one shape whose meaning nothing in the network could check.
When an assumption is impossible
A query whose assumptions cannot hold comes back with
{ type: 'impossibleAssumptionError', nodeKeys } in place of probabilities — and
nodeKeys lists every variable you asserted something about that inference rejected,
hard observations first. Two contradictory assumptions both appear, so you can offer to
clear either one.
If it comes back empty, the network itself made the query impossible and no single assumption of yours is individually to blame.
It deliberately carries nothing else. The assumption itself is not echoed back, because
results are parallel to the queries: queries[i].assumptions.get(key) is what you
asserted, and that tells you whether it was a state or a measurement, so a message can
name the right thing. A variable you said nothing about never appears, even though the
impossibility propagates through the network and the engine flags it internally.
Analyses built on inference
calculateDiagnosticValues(network, roles, baseline) answers "how much would observing
this variable move the fault probabilities?" for every observable variable:
const roles: DiagnosticRoles = {
observableKeys: new Set(['Fasting glucose', 'HbA1c']),
faultStatesByNodeKey: new Map([['Type 2 diabetes', new Set([1])]]),
}
calculateDiagnosticValues(network, roles, new Map())Both fields are sets, because both answer a membership question — is this variable
observable, does this state count as a fault — so a repeat is meaningless.
(probabilities and a measurementScale's entries stay ordered for the opposite reason:
there the index is the state.)
The roles are an argument, not part of the network — the same nodes answer different
diagnostic questions depending on which variables you treat as observable, and a
consumer computing only posteriors never has to supply them. exportedNetworkDiagnosticRoles(export)
reads them out of a KBE export, where they live on each variable's type and its fault
rows.
It is a free function taking the handle rather than a method on it, because a diagnostic value is an analysis built out of posteriors rather than a primitive of the engine — and because a method could not be tree-shaken away for a consumer that only wants posteriors.
Errors
The rule is per-query failures are returned, whole-request failures throw.
A returned result is a discriminated union, so the check is not optional:
for (const result of network.calculatePosteriors(queries)) {
if (result.error) continue // or report it
result.probabilities.get('Type 2 diabetes') // narrowed, no `?.` needed
}Reading probabilities without checking error first does not compile. That is the
whole reason for the shape: the failing query is a rare one, so a consumer that treats
the field as always-present is right almost every time and wrong exactly when an
assumption turns out to be impossible.
So an impossible assumption or a target naming an absent node comes back as
result.error on that query alone, while a malformed request — evidence for an
unknown node, an out-of-range state, a vector that does not sum to 1, a measurement
on a node with no transition model — throws BayesianRequestError, matching what the
engine answers before it runs anything. calculateDiagnosticValues throws
DiagnosticBaselineError for a failed baseline by the same rule: every diagnostic
value is measured against the baseline, so without one there is no partial answer to
return.
Running it off the main thread
A large batch is far too slow for the main thread, so a browser consumer should run
this in a Web Worker. Measured on the released 629-node network (3.24.0, 9,533 factor
configs), on a 4-core 2.8 GHz Xeon, with npm run bench:
| | |
| -------------------------------------------------------- | ----------------------------------- |
| createEngine() | ~3 ms, once per process |
| prepareNetwork() | ~4 ms, once per network |
| one query on a reused handle | ~18 ms, essentially all propagation |
| 564 queries | ~1.2 s (~2.2 ms per query) |
| calculateDiagnosticValues (315 observables, 90 faults) | ~1.3 s |
Batch size is the lever, by an order of magnitude: a lone query still pays all 25 iterations, so 564 queries cost ~2.2 ms each against ~18 ms for one. Cost is close to linear in factor configs rather than node count — 3.12.0 is 355 nodes / 4,141 configs and runs the same batch in ~0.57 s, so 2.30x the configs costs 2.18x the time.
A batch that is not a multiple of four costs nothing extra: every batch is padded to a whole number of SIMD lanes internally. The reason that matters is not the ~1.09x it saves on a lone query but that a query answered alone comes back bit-identical to the same query inside a batch of 128.
Narrowing targets barely moves any of it — the cost is the belief propagation, not
assembling the answer, which is ~8% of a 128-query call. Note also that the
calculations being synchronous changes nothing here: they were always CPU-bound and
blocking, and async never made them yield — a worker is what gets the work off the UI
thread.
Several workers scale nearly linearly, and that is the largest speedup available to a
consumer. Queries in a batch are independent and results are parallel to them, so a
slice of the batch answered in another worker needs no coordination — the slices
concatenate. Measured on the released network, 564 queries, a pool created once and
reused, best of three interleaved rounds: 1295 ms in-process, 665 ms across two
workers (1.95x), 421 ms across four (3.07x) on a 4-core machine. Starting the pool —
cloning the nodes, compiling the kernel and preparing the network in each worker — is
146 ms for four workers, paid once rather than per request. calculateDiagnosticValues
splits the same way, by observable rather than by query.
Two things this does not buy: a single query is still a single query (~17 ms; nothing about one query is parallel), and it is throughput, not latency, for anything smaller than about a worker's worth of work.
The handles do not cross the worker boundary, so create them inside it: an engine
or a PreparedNetwork holds functions and a WASM instance, and neither is
structured-cloneable. What crosses is the data — nodes, queries, roles and results all
clone fine. So import the library eagerly inside your worker module, await
createEngine() once on worker startup, keep a PreparedNetwork per network in there,
and create the worker lazily on first request. Each worker realm compiles the kernel
once for itself.
The library stays worker-agnostic in the sense that matters: it does no worker plumbing
of its own, because new Worker(new URL(...)) resolution is bundler-specific.
Continuous transitions
Synchronous, so it is safe to call during render. A semi-continuous variable's states cover consecutive ranges of a measurement, and the transition between each adjacent pair is a shifted log-logistic curve passing through the researcher's 5% value, the 50% crossover, and the 95% value.
import { buildTransitionStateProbabilities } from '@ancestralize/bayesian-utils'
const scale = {
// One transition per boundary between adjacent states, in the same positional
// order as the node's states.
transitions: [
{ midpoint: 5.6, fivePercentValue: 5.32, ninetyFivePercentValue: 5.88 },
{ midpoint: 7, fivePercentValue: 6.65, ninetyFivePercentValue: 7.35 },
],
// Whether those states run from high measurement value to low.
descending: false,
}
// A distribution over the variable's states, positionally matching them.
// Inference can do this step for you — see `{ measuredValue }` above; call this
// directly when you want the curve itself, for a chart or a preview.
buildTransitionStateProbabilities(5.8, scale)A MeasurementScale is { transitions, descending }: a partition of N states has
exactly N-1 transitions, and prepareNetwork checks that per node when you prepare a
network rather than at the moment a measurement is asserted — a scale is only read when
something measures that node, so a mis-assembled one used to answer every other query
without complaint and fail on whichever one happened to touch it.
Four things to know:
- All three points are absolute, required, and stored as such. There is no separate stored shape and no arithmetic between what a researcher types and what inference reads. The midpoint is the 50% crossover and is deliberately not tied to where two states meet: a state boundary is often a fixed guideline threshold while the crossover belongs elsewhere.
- A narrower authoring form stays with the consumer. KBE lets a researcher enter the
5% point and mirror the 95% from it; that is a convenience in its editor, resolved
before anything reaches this package. What crosses the boundary is always the fitted
curve — the same reason
Assumptionrather thanEvidenceis what a caller asserts. descendingcannot be inferred from the transitions. A two-state variable has a single midpoint and therefore no ordering information at all, and two-state is the common case. A caller that holds an interval partition derives the flag from it.- A hard cutoff is a zero-width transition — all three points equal. A value landing exactly on it splits evenly between the two states, so place a hard cutoff between two representable measurements rather than on one.
isValidTransition is exported for a consumer that writes these: three finite points
with the outer two bracketing the middle. It is worth calling on the write path, because
absolute values can be entered in the wrong order and a curve that does not bracket its
own midpoint degrades silently to a hard cutoff rather than failing.
Reading a KBE release export
parseExportedNetwork validates the network.json shape KBE exports and
exportedNetworkToInferenceNodes turns it into engine nodes: resolving the fields
that live on the node's type (diagnosticType, upstreamEffectsDisabled),
and resolving each node's measurementScale against the network default so its nodes
accept { measuredValue } evidence. Diagnostic roles come out separately, via
exportedNetworkDiagnosticRoles.
import {
exportedNetworkToInferenceNodes,
parseExportedNetwork,
} from '@ancestralize/bayesian-utils'
const network = parseExportedNetwork(JSON.parse(fileContents))
const nodes = exportedNetworkToInferenceNodes(network)This targets the current export shape. A transition entry must state all three of its points; earlier shapes (distances from the midpoint, or absolute positions with no midpoint) are detected rather than misread, and are not converted — re-export instead.
Anything malformed throws a NetworkParseError naming the exact path that went wrong
(nodes.Fasting glucose.probabilities[3]: expected a finite number, received null).
That is deliberate: a network that half-parses produces confident wrong numbers, and on
a 629-node export "invalid input" is not a fixable report.
Contributing
See AGENTS.md — in particular the pinned numeric constants, the committed WASM kernel, and why the parity fixtures must not be hand-edited.
