{"_id":"@ancestralize/bayesian-utils","_rev":"5-5e58d246076eb1b5bee792444573c378","name":"@ancestralize/bayesian-utils","dist-tags":{"latest":"0.3.0"},"versions":{"0.1.0":{"name":"@ancestralize/bayesian-utils","version":"0.1.0","license":"UNLICENSED","_id":"@ancestralize/bayesian-utils@0.1.0","maintainers":[{"name":"gcsizmadia-ancestralize","email":"gcsizmadia@ancestralize.com"}],"homepage":"https://github.com/ancestralize/bayesian-utils#readme","bugs":{"url":"https://github.com/ancestralize/bayesian-utils/issues"},"dist":{"shasum":"d16b1009c3985428006ecb615474b3ee94ae3fc5","tarball":"https://registry.npmjs.org/@ancestralize/bayesian-utils/-/bayesian-utils-0.1.0.tgz","fileCount":29,"integrity":"sha512-bP+fCFr7SM8lMuu1mmD9jCuVvkUw37M2YIDte5fGbZDSZ2IkalIl9yR2L23hcAcFvtyfyFFex2nVADeEgylceg==","signatures":[{"sig":"MEUCIDXwJ0GiOJewCZetr6mz0rL04lQOyPE0ZkzjYLoapobWAiEA7BOgeFbHcA26M5qLXKfWPUTz/TIxF/iMXiYfGGdJ9Do=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":222737},"type":"module","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"gitHead":"a8b03f8db92d6662ae091606436ac22392a3512d","scripts":{"tsc":"tsc --noEmit","lint":"eslint --max-warnings 0 src tests scripts","test":"vitest run","bench":"node scripts/benchmarkInference.mjs","build":"tsc -p tsconfig.build.json","format":"prettier --write .","prepack":"npm run build","docs:check":"node scripts/checkReadmeExamples.mjs","test:watch":"vitest","format:check":"prettier --check .","kernel:build":"wasm/lbp/build.sh","kernel:check":"node scripts/checkKernel.mjs","package:check":"node scripts/checkPackedPackage.mjs"},"_npmUser":{"name":"gcsizmadia-ancestralize","email":"gcsizmadia@ancestralize.com"},"repository":{"url":"git+https://github.com/ancestralize/bayesian-utils.git","type":"git"},"_npmVersion":"11.12.1","description":"Bayesian network utilities shared by KBE and Ancestra Health: loopy belief propagation inference, diagnostic values, continuous state transitions and Noisy-MAX conversion.","directories":{},"sideEffects":false,"_nodeVersion":"24.15.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"eslint":"^9.39.1","vitest":"^3.2.4","prettier":"^3.6.2","@eslint/js":"^9.39.1","typescript":"^5.9.3","@types/node":"^22.19.1","typescript-eslint":"^8.46.4"},"_npmOperationalInternal":{"tmp":"tmp/bayesian-utils_0.1.0_1786627356237_0.4382476172183525","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@ancestralize/bayesian-utils","version":"0.1.1","license":"UNLICENSED","_id":"@ancestralize/bayesian-utils@0.1.1","maintainers":[{"name":"gcsizmadia-ancestralize","email":"gcsizmadia@ancestralize.com"}],"homepage":"https://github.com/ancestralize/bayesian-utils#readme","bugs":{"url":"https://github.com/ancestralize/bayesian-utils/issues"},"dist":{"shasum":"25ad76aa3da3b490719b8ba5cd8fd539ed87105a","tarball":"https://registry.npmjs.org/@ancestralize/bayesian-utils/-/bayesian-utils-0.1.1.tgz","fileCount":29,"integrity":"sha512-gSFfRGFWhR4k/OFcjgKnPYDHgQJa4kLVZqlFdUW3c49mFXGXFtioZt7SbUquO46xohJfNKOahretkIUJnGEIIw==","signatures":[{"sig":"MEUCIC9vxt0h0vRgvTY1JWa9NafVYssSVa5escuEctdePpd7AiEA7FSNzCm1T1vN6dNTOKqi5qLC7LiVRkk9tRckgyDhDdk=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":223799},"type":"module","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"gitHead":"6668902cc7632be6d38947431eaa1f6163168f90","scripts":{"tsc":"tsc --noEmit","lint":"eslint --max-warnings 0 src tests scripts","test":"vitest run","bench":"node scripts/benchmarkInference.mjs","build":"tsc -p tsconfig.build.json","format":"prettier --write .","prepack":"npm run build","docs:check":"node scripts/checkReadmeExamples.mjs","test:watch":"vitest","format:check":"prettier --check .","kernel:build":"wasm/lbp/build.sh","kernel:check":"node scripts/checkKernel.mjs","package:check":"node scripts/checkPackedPackage.mjs"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:40fdc109-83b9-4586-b886-1c316c18c796"}},"repository":{"url":"git+https://github.com/ancestralize/bayesian-utils.git","type":"git"},"_npmVersion":"12.0.2","description":"Bayesian network utilities shared by KBE and Ancestra Health: loopy belief propagation inference, diagnostic values, continuous state transitions and Noisy-MAX conversion.","directories":{},"sideEffects":false,"_nodeVersion":"22.23.1","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"eslint":"^9.39.1","vitest":"^3.2.4","prettier":"^3.6.2","@eslint/js":"^9.39.1","typescript":"^5.9.3","@types/node":"^22.19.1","typescript-eslint":"^8.46.4"},"_npmOperationalInternal":{"tmp":"tmp/bayesian-utils_0.1.1_1786632027165_0.13465138008873012","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@ancestralize/bayesian-utils","version":"0.2.0","license":"UNLICENSED","_id":"@ancestralize/bayesian-utils@0.2.0","maintainers":[{"name":"gkovacs","email":"gkovacs@ancestralize.com"},{"name":"gcsizmadia-ancestralize","email":"gcsizmadia@ancestralize.com"}],"homepage":"https://github.com/ancestralize/bayesian-utils#readme","bugs":{"url":"https://github.com/ancestralize/bayesian-utils/issues"},"dist":{"shasum":"af8e9e7429df03df62e604dc4ef963c1cc3820c2","tarball":"https://registry.npmjs.org/@ancestralize/bayesian-utils/-/bayesian-utils-0.2.0.tgz","fileCount":31,"integrity":"sha512-4ZRgOzFkN1sRBoJL0SlcNk74GeWSliJ0sYZJJBkYCL9h840DcfPkem30bUrI1jmnE4qKw6Nu2RKSM4GFnheqqw==","signatures":[{"sig":"MEUCIQCyvj7GbTnJetYoRgn0j+KP4q4Lh1t2//3nYUjRnm1URAIgbDa+AzV4T9/+b8R5cqw47tKNI0gHpTbRMgF6tWXOCYE=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":214072},"type":"module","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"gitHead":"3970d37c429444a097991b631b59434ed424a3c8","scripts":{"tsc":"tsc --noEmit","lint":"eslint --max-warnings 0 src tests scripts","test":"vitest run","bench":"node scripts/benchmarkInference.mjs","build":"tsc -p tsconfig.build.json","format":"prettier --write .","prepack":"npm run build","docs:check":"node scripts/checkReadmeExamples.mjs","test:watch":"vitest","format:check":"prettier --check .","kernel:build":"wasm/lbp/build.sh","kernel:check":"node scripts/checkKernel.mjs","package:check":"node scripts/checkPackedPackage.mjs"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:40fdc109-83b9-4586-b886-1c316c18c796"}},"repository":{"url":"git+https://github.com/ancestralize/bayesian-utils.git","type":"git"},"_npmVersion":"12.0.2","description":"Bayesian network utilities shared by KBE and Ancestra Health: loopy belief propagation inference, diagnostic values, continuous state transitions and Noisy-MAX conversion.","directories":{},"sideEffects":false,"_nodeVersion":"22.23.2","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"eslint":"^9.39.1","vitest":"^3.2.4","prettier":"^3.6.2","@eslint/js":"^9.39.1","typescript":"^5.9.3","@types/node":"^22.19.1","typescript-eslint":"^8.46.4"},"_npmOperationalInternal":{"tmp":"tmp/bayesian-utils_0.2.0_1787564260766_0.954265917879797","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@ancestralize/bayesian-utils","version":"0.3.0","description":"Bayesian network utilities shared by KBE and Ancestra Health: loopy belief propagation inference, diagnostic values, continuous state transitions and Noisy-MAX conversion.","license":"UNLICENSED","repository":{"type":"git","url":"git+https://github.com/ancestralize/bayesian-utils.git"},"type":"module","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"engines":{"node":">=22"},"sideEffects":false,"scripts":{"build":"tsc -p tsconfig.build.json","kernel:build":"wasm/lbp/build.sh","kernel:check":"node scripts/checkKernel.mjs","docs:check":"node scripts/checkReadmeExamples.mjs","package:check":"node scripts/checkPackedPackage.mjs","bench":"node scripts/benchmarkInference.mjs","test":"vitest run","test:watch":"vitest","tsc":"tsc --noEmit","lint":"eslint --max-warnings 0 src tests scripts","format":"prettier --write .","format:check":"prettier --check .","prepack":"npm run build"},"devDependencies":{"@eslint/js":"^9.39.1","@types/node":"^22.19.1","eslint":"^9.39.1","prettier":"^3.6.2","typescript":"^5.9.3","typescript-eslint":"^8.46.4","vitest":"^3.2.4"},"publishConfig":{"access":"public"},"gitHead":"2f66de66c9f3ec616e718e2242c20a90a022bb1a","_id":"@ancestralize/bayesian-utils@0.3.0","bugs":{"url":"https://github.com/ancestralize/bayesian-utils/issues"},"homepage":"https://github.com/ancestralize/bayesian-utils#readme","_nodeVersion":"22.23.2","_npmVersion":"12.0.2","dist":{"integrity":"sha512-UsGouoIjqGRAyTzT3sep7K47dZ2x3i9grCtzHvuQgbR6M4QXiHfNgP7kpiF0xtpKXl1fAOeziDJ3PID4DqLwpg==","shasum":"312c860370f46f29c21baaa023acf7933a23d15e","tarball":"https://registry.npmjs.org/@ancestralize/bayesian-utils/-/bayesian-utils-0.3.0.tgz","fileCount":31,"unpackedSize":216976,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCWNTN6fewxSU9fKUYhPTyQGNTY3jGCTkxsk9C+SXYdEQIhAJRScgDkJy1nonsadtjFuXR/pC7786Gba419aWu1Q8c4"}]},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:40fdc109-83b9-4586-b886-1c316c18c796"}},"directories":{},"maintainers":[{"name":"gkovacs","email":"gkovacs@ancestralize.com"},{"name":"gcsizmadia-ancestralize","email":"gcsizmadia@ancestralize.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/bayesian-utils_0.3.0_1787689184773_0.7556049105967058"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-13T13:22:36.071Z","modified":"2026-08-25T20:19:45.188Z","0.1.0":"2026-08-13T13:22:36.390Z","0.1.1":"2026-08-13T14:40:27.330Z","0.2.0":"2026-08-24T09:37:40.910Z","0.3.0":"2026-08-25T20:19:45.023Z"},"bugs":{"url":"https://github.com/ancestralize/bayesian-utils/issues"},"license":"UNLICENSED","homepage":"https://github.com/ancestralize/bayesian-utils#readme","repository":{"type":"git","url":"git+https://github.com/ancestralize/bayesian-utils.git"},"description":"Bayesian network utilities shared by KBE and Ancestra Health: loopy belief propagation inference, diagnostic values, continuous state transitions and Noisy-MAX conversion.","maintainers":[{"name":"gkovacs","email":"gkovacs@ancestralize.com"},{"name":"gcsizmadia-ancestralize","email":"gcsizmadia@ancestralize.com"}],"readme":"# @ancestralize/bayesian-utils\n\nBayesian network utilities shared by **KBE** (where researchers author the networks)\nand **Ancestra Health** (where end users' health risks are computed from them):\nloopy belief propagation inference, diagnostic values, the continuous\nstate-transition model, and Noisy-MAX → general CPT conversion.\n\nA port of the inference path of the `bayesian-api` compute service, pinned to\nproduce the same numbers (parity tests hold at 1e-5 against captures from the\ndeployed engine). **Optimization is not included** — it stays on Modal.\n\nESM only, and **zero runtime dependencies**. No Node builtins, no DOM, no\n`process.env`, so the same build runs in a browser worker, in Node and on Vercel.\nTree-shakeable: a consumer that only reads exports bundles 5.3 KB, one that only wants\nthe transition maths 7.8 KB, and neither pulls in the WASM kernel.\n\n## Install\n\n```bash\nnpm install @ancestralize/bayesian-utils\n```\n\n## Inference\n\n`createEngine()` is the one `await`: it compiles the WASM kernel, which a browser\nrefuses to do synchronously above 4 KB on the main thread. **Everything on the engine\nis synchronous** — holding one is what proves the kernel is ready.\n\n```ts\nimport {\n  createEngine,\n  type InferenceNode,\n  type NetworkNodes,\n} from '@ancestralize/bayesian-utils'\n\n// Two levels, because the two costs have different lifetimes: compiling the kernel is\n// once per process, and rewriting a network into what the kernel consumes is once per\n// network. Hold both for as long as they are valid.\nconst engine = await createEngine()\n\n// A network is a Map keyed by node key. Keys are opaque strings — pass UUIDs or names,\n// whichever you hold.\nconst nodes: NetworkNodes = new Map<string, InferenceNode>([\n  [\n    'Smoking',\n    {\n      parameterizationType: 'general',\n      stateCount: 2,\n      parentKeys: [],\n      probabilities: [0.7, 0.3],\n      upstreamEffectsDisabled: false,\n    },\n  ],\n  [\n    'Lung cancer',\n    {\n      parameterizationType: 'general',\n      stateCount: 2,\n      parentKeys: ['Smoking'],\n      // The node's own state varies fastest, then the last parent.\n      probabilities: [0.99, 0.01, 0.85, 0.15],\n      upstreamEffectsDisabled: false,\n    },\n  ],\n])\n\nconst network = engine.prepareNetwork(nodes)\nconst [smoker] = network.calculatePosteriors([\n  {\n    // Node-key-keyed collections are Maps throughout, inputs and outputs alike.\n    assumptions: new Map([['Smoking', { stateIndex: 1 }]]),\n    targets: ['Lung cancer'],\n  },\n])\n\n// The two error kinds name different things — one target versus every impossible\n// assumption — so narrowing is what tells you which you have.\nif (smoker.error) throw new Error(smoker.error.type)\n// ~[0.85, 0.15] — the kernel computes in f32, matching the reference engine, so\n// expect agreement to about 1e-6 rather than exact decimals.\nsmoker.probabilities.get('Lung cancer')\n```\n\nResults are an array **parallel to the queries** — same length, same order — so\nthere are no ids to keep in step and two identical queries are answered twice.\n\nA whole batch shares one factor graph, so passing many queries to one call is much\ncheaper than calling it many times.\n\n**Every node-key-keyed collection is a `Map`** — nodes, assumptions, roles, and the\nreturned posteriors and diagnostic values alike. The reason is that a plain object carries\n`Object.prototype`, so `'toString' in assumptions` is **true** and a variable named\n`toString` or `constructor` is silently treated as having evidence it does not have. That\nis the class of quietly-wrong answer this library refuses, and it applies to inputs and\noutputs equally, so they are consistent.\n\nThe shapes that mirror a JSON file — `ExportedNetwork.nodes` and `.types` — stay\n`Record`s, because they describe a document rather than an API collection;\n`exportedNetworkToInferenceNodes` is the boundary that converts.\n\nEvery field on a node is one inference reads, and all are required except\n`measurementScale`, which genuinely does not apply to a categorical variable.\n`upstreamEffectsDisabled` is required rather than defaulted because omitting it would let\nevidence reach a parent that should not see it — an answer changed without any sign that\none was.\n\n`createEngine()` is cheap to call again — the compiled kernel is memoized for the\nprocess — so a per-request engine in a serverless handler is fine. `prepareNetwork()` is\nnot: it does the per-network work, so hold the `PreparedNetwork` for as long as the\nnetwork's contents are unchanged. On the released 629-node network, reusing it takes a\nsingle-query call from ~22 ms to ~18 ms. It holds the nodes you gave it, the kernel-ready\nrewrite of them, and a WASM instance — so it is also only valid while those nodes are\nunchanged; mutate a CPT in place and it is stale, with no way to detect that.\n\n### Assumptions\n\nAn **assumption** is what you assert about a variable; **evidence** is the form inference\nconsumes internally, and the library is the only thing that ever sees it. Two assumption\nforms, one per kind of thing you actually hold — a state you know, or a number you\nmeasured:\n\n```ts\nnew Map([['Smoking', { stateIndex: 1 }]]) // a hard observation\nnew Map([['Glucose', { measuredValue: 8.1 }]]) // a measurement in its own units\n```\n\nBoth are tagged. A bare `1` is rejected, because a bare number is exactly the case where\na state index and a measured value are indistinguishable and mean wildly different\nthings.\n\nA **measured value** is converted for you through that node's own transition model:\ngive the node a `measurementScale` (filled in for you by\n`exportedNetworkToInferenceNodes` from a KBE export, or assembled from your own stored\ntransitions) and the library does the smoothing, so there is only ever one way a value\nbecomes a distribution. A measurement on a node without a scale throws rather than\nguessing.\n\nThere is deliberately **no** way to pass a likelihood vector directly. It was the third\nform here, and removing it removed the only way to hand inference a distribution the\nlibrary did not derive — the one shape whose meaning nothing in the network could\ncheck.\n\n### When an assumption is impossible\n\nA query whose assumptions cannot hold comes back with\n`{ type: 'impossibleAssumptionError', nodeKeys }` in place of probabilities — and\n`nodeKeys` lists **every variable you asserted something about** that inference rejected,\nhard observations first. Two contradictory assumptions both appear, so you can offer to\nclear either one.\n\nIf it comes back empty, the network itself made the query impossible and no single\nassumption of yours is individually to blame.\n\nIt deliberately carries nothing else. The assumption itself is not echoed back, because\nresults are parallel to the queries: `queries[i].assumptions.get(key)` is what you\nasserted, and that tells you whether it was a state or a measurement, so a message can\nname the right thing. A variable you said nothing about never appears, even though the\nimpossibility propagates through the network and the engine flags it internally.\n\n### Analyses built on inference\n\n`calculateDiagnosticValues(network, roles, baseline)` answers \"how much would observing\nthis variable move the fault probabilities?\" for every observable variable:\n\n```ts\nconst roles: DiagnosticRoles = {\n  observableKeys: new Set(['Fasting glucose', 'HbA1c']),\n  faultStatesByNodeKey: new Map([['Type 2 diabetes', new Set([1])]]),\n}\ncalculateDiagnosticValues(network, roles, new Map())\n```\n\nBoth fields are **sets**, because both answer a membership question — is this variable\nobservable, does this state count as a fault — so a repeat is meaningless.\n(`probabilities` and a `measurementScale`'s entries stay ordered for the opposite reason:\nthere the index _is_ the state.)\n\nThe roles are an **argument, not part of the network** — the same nodes answer different\ndiagnostic questions depending on which variables you treat as observable, and a\nconsumer computing only posteriors never has to supply them. `exportedNetworkDiagnosticRoles(export)`\nreads them out of a KBE export, where they live on each variable's type and its fault\nrows.\n\nIt is a free function taking the handle rather than a method on it, because a diagnostic\nvalue is an analysis built out of posteriors rather than a primitive of the engine — and\nbecause a method could not be tree-shaken away for a consumer that only wants\nposteriors.\n\n### Errors\n\nThe rule is **per-query failures are returned, whole-request failures throw**.\n\nA returned result is a **discriminated union**, so the check is not optional:\n\n```ts\nfor (const result of network.calculatePosteriors(queries)) {\n  if (result.error) continue // or report it\n  result.probabilities.get('Type 2 diabetes') // narrowed, no `?.` needed\n}\n```\n\nReading `probabilities` without checking `error` first does not compile. That is the\nwhole reason for the shape: the failing query is a rare one, so a consumer that treats\nthe field as always-present is right almost every time and wrong exactly when an\nassumption turns out to be impossible.\n\nSo an impossible assumption or a target naming an absent node comes back as\n`result.error` on that query alone, while a malformed request — evidence for an\nunknown node, an out-of-range state, a vector that does not sum to 1, a measurement\non a node with no transition model — throws `BayesianRequestError`, matching what the\nengine answers before it runs anything. `calculateDiagnosticValues` throws\n`DiagnosticBaselineError` for a failed baseline by the same rule: every diagnostic\nvalue is measured against the baseline, so without one there is no partial answer to\nreturn.\n\n### Running it off the main thread\n\nA large batch is far too slow for the main thread, so a browser consumer should run\nthis in a Web Worker. Measured on the released 629-node network (3.24.0, 9,533 factor\nconfigs), on a 4-core 2.8 GHz Xeon, with `npm run bench`:\n\n|                                                          |                                     |\n| -------------------------------------------------------- | ----------------------------------- |\n| `createEngine()`                                         | ~3 ms, once per process             |\n| `prepareNetwork()`                                       | ~4 ms, once per network             |\n| one query on a reused handle                             | ~18 ms, essentially all propagation |\n| 564 queries                                              | ~1.2 s (~2.2 ms per query)          |\n| `calculateDiagnosticValues` (315 observables, 90 faults) | ~1.3 s                              |\n\n**Batch size is the lever**, by an order of magnitude: a lone query still pays all 25\niterations, so 564 queries cost ~2.2 ms each against ~18 ms for one. Cost is close to\nlinear in **factor configs** rather than node count — 3.12.0 is 355 nodes / 4,141\nconfigs and runs the same batch in ~0.57 s, so 2.30x the configs costs 2.18x the time.\n\nA batch that is not a multiple of four costs nothing extra: every batch is padded to a\nwhole number of SIMD lanes internally. The reason that matters is not the ~1.09x it saves\non a lone query but that a query answered alone comes back bit-identical to the same query\ninside a batch of 128.\n\nNarrowing `targets` barely moves any of it — the cost is the belief propagation, not\nassembling the answer, which is ~8% of a 128-query call. Note also that the\ncalculations being synchronous changes nothing here: they were always CPU-bound and\nblocking, and `async` never made them yield — a worker is what gets the work off the UI\nthread.\n\n**Several workers scale nearly linearly, and that is the largest speedup available to a\nconsumer.** Queries in a batch are independent and results are parallel to them, so a\nslice of the batch answered in another worker needs no coordination — the slices\nconcatenate. Measured on the released network, 564 queries, a pool created once and\nreused, best of three interleaved rounds: **1295 ms in-process, 665 ms across two\nworkers (1.95x), 421 ms across four (3.07x)** on a 4-core machine. Starting the pool —\ncloning the nodes, compiling the kernel and preparing the network in each worker — is\n146 ms for four workers, paid once rather than per request. `calculateDiagnosticValues`\nsplits the same way, by observable rather than by query.\n\nTwo things this does not buy: a single query is still a single query (~17 ms; nothing\nabout one query is parallel), and it is throughput, not latency, for anything smaller\nthan about a worker's worth of work.\n\n**The handles do not cross the worker boundary**, so create them inside it: an engine\nor a `PreparedNetwork` holds functions and a WASM instance, and neither is\nstructured-cloneable. What crosses is the data — nodes, queries, roles and results all\nclone fine. So import the library eagerly inside your worker module, `await\ncreateEngine()` once on worker startup, keep a `PreparedNetwork` per network in there,\nand create the worker lazily on first request. Each worker realm compiles the kernel\nonce for itself.\n\nThe library stays worker-agnostic in the sense that matters: it does no worker plumbing\nof its own, because `new Worker(new URL(...))` resolution is bundler-specific.\n\n## Continuous transitions\n\n**Synchronous**, so it is safe to call during render. A semi-continuous variable's\nstates cover consecutive ranges of a measurement, and the transition between each\nadjacent pair is a shifted log-logistic curve passing through the researcher's 5%\nvalue, the 50% crossover, and the 95% value.\n\n```ts\nimport { buildTransitionStateProbabilities } from '@ancestralize/bayesian-utils'\n\nconst scale = {\n  // One transition per boundary between adjacent states, in the same positional\n  // order as the node's states.\n  transitions: [\n    { midpoint: 5.6, fivePercentValue: 5.32, ninetyFivePercentValue: 5.88 },\n    { midpoint: 7, fivePercentValue: 6.65, ninetyFivePercentValue: 7.35 },\n  ],\n  // Whether those states run from high measurement value to low.\n  descending: false,\n}\n\n// A distribution over the variable's states, positionally matching them.\n// Inference can do this step for you — see `{ measuredValue }` above; call this\n// directly when you want the curve itself, for a chart or a preview.\nbuildTransitionStateProbabilities(5.8, scale)\n```\n\nA `MeasurementScale` is `{ transitions, descending }`: a partition of N states has\nexactly N-1 transitions, and `prepareNetwork` checks that per node when you prepare a\nnetwork rather than at the moment a measurement is asserted — a scale is only read when\nsomething measures that node, so a mis-assembled one used to answer every other query\nwithout complaint and fail on whichever one happened to touch it.\n\nFour things to know:\n\n- **All three points are absolute, required, and stored as such.** There is no separate\n  stored shape and no arithmetic between what a researcher types and what inference\n  reads. The midpoint is the 50% crossover and is deliberately not tied to where two\n  states meet: a state boundary is often a fixed guideline threshold while the crossover\n  belongs elsewhere.\n- **A narrower authoring form stays with the consumer.** KBE lets a researcher enter the\n  5% point and mirror the 95% from it; that is a convenience in its editor, resolved\n  before anything reaches this package. What crosses the boundary is always the fitted\n  curve — the same reason `Assumption` rather than `Evidence` is what a caller asserts.\n- **`descending` cannot be inferred from the transitions.** A two-state variable has a\n  single midpoint and therefore no ordering information at all, and two-state is the\n  common case. A caller that holds an interval partition derives the flag from it.\n- **A hard cutoff is a zero-width transition** — all three points equal. A value\n  landing exactly on it splits evenly between the two states, so place a hard cutoff\n  _between_ two representable measurements rather than on one.\n\n`isValidTransition` is exported for a consumer that writes these: three finite points\nwith the outer two bracketing the middle. It is worth calling on the write path, because\nabsolute values can be entered in the wrong order and a curve that does not bracket its\nown midpoint degrades silently to a hard cutoff rather than failing.\n\n## Reading a KBE release export\n\n`parseExportedNetwork` validates the `network.json` shape KBE exports and\n`exportedNetworkToInferenceNodes` turns it into engine nodes: resolving the fields\nthat live on the node's _type_ (`diagnosticType`, `upstreamEffectsDisabled`),\nand resolving each node's `measurementScale` against the network default so its nodes\naccept `{ measuredValue }` evidence. Diagnostic roles come out separately, via\n`exportedNetworkDiagnosticRoles`.\n\n```ts\nimport {\n  exportedNetworkToInferenceNodes,\n  parseExportedNetwork,\n} from '@ancestralize/bayesian-utils'\n\nconst network = parseExportedNetwork(JSON.parse(fileContents))\nconst nodes = exportedNetworkToInferenceNodes(network)\n```\n\nThis targets the current export shape. A transition entry must state all three of its\npoints; earlier shapes (distances from the midpoint, or absolute positions with no\nmidpoint) are detected rather than misread, and are not converted — re-export instead.\n\nAnything malformed throws a `NetworkParseError` naming the exact path that went wrong\n(`nodes.Fasting glucose.probabilities[3]: expected a finite number, received null`).\nThat is deliberate: a network that half-parses produces confident wrong numbers, and on\na 629-node export \"invalid input\" is not a fixable report.\n\n## Contributing\n\nSee [AGENTS.md](AGENTS.md) — in particular the pinned numeric constants, the\ncommitted WASM kernel, and why the parity fixtures must not be hand-edited.\n","readmeFilename":"README.md"}