npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-utils

Inference

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 units

Both 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 Assumption rather than Evidence is what a caller asserts.
  • descending cannot 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.