@seantalts/stanli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Sean Talts
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice,
9
+ this list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright
12
+ notice, this list of conditions and the following disclaimer in the
13
+ documentation and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
22
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
23
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
24
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
25
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
27
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
28
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29
+ POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # stanli
2
+
3
+ Full [Stan](https://mc-stan.org) in the browser. stanc3 (the real Stan
4
+ compiler, compiled to JavaScript) turns Stan source into its intermediate
5
+ representation; a WebAssembly build of the stanli runtime lowers it to an
6
+ op graph over precompiled stan-math kernels and samples with NUTS. No
7
+ server, no C++ toolchain, everything in the tab. Live demo:
8
+ <https://seantalts.github.io/stanli/>.
9
+
10
+ ```js
11
+ import { sample } from "@seantalts/stanli";
12
+
13
+ const fit = await sample({
14
+ code: `
15
+ data { int N; array[N] real y; }
16
+ parameters { real mu; real<lower=0> sigma; }
17
+ model { y ~ normal(mu, sigma); }`,
18
+ data: { N: 3, y: [1.1, 0.4, 2.2] },
19
+ seed: 1,
20
+ onProgress: (s) => console.log(s),
21
+ });
22
+
23
+ fit.columns["mu"]; // Float64Array, one entry per draw
24
+ fit.names; // every CSV column CmdStan would write
25
+ fit.ms; // {stanc, lower, sample, total} in milliseconds
26
+ ```
27
+
28
+ Columns cover the full CmdStan CSV: constrained parameters, transformed
29
+ parameters, and generated quantities (RNG draws stream from `seed`).
30
+ The heavy work runs in a worker the package owns, so the page never
31
+ blocks; calls queue and run one at a time.
32
+
33
+ The payload is ~9 MB installed (~1.9 MB over the wire with gzip): the WASM
34
+ runtime plus the stanc3 compiler. The compiler loads lazily, only when a
35
+ call passes Stan source. `preload()` starts both loads in the background
36
+ -- call it at page idle and the user's first `sample()` begins at full
37
+ speed instead of paying the fetch and parse on their click; an app that ships a fixed model can precompile
38
+ it at build time (`stanc --debug-transformed-mir model.stan`) and pass
39
+ `mir` instead of `code`, and the runtime alone is ~1.5 MB gzipped. 118 of 119 posteriordb corpus models
40
+ verify against CmdStan's log density, gradients, and write_array values
41
+ from inside this WASM build; see the
42
+ [repository](https://github.com/seantalts/stanli) for the verification
43
+ policy and numbers.
44
+
45
+ Not yet here: variational inference, optimization, multi-chain
46
+ threading. `wasm32` caps memory at 4 GB, which one 79,000-parameter
47
+ corpus model exceeds; everything typical fits.
package/index.mjs ADDED
@@ -0,0 +1,146 @@
1
+ // stanli: full Stan in the browser. stanc3 (compiled to JS) turns Stan
2
+ // source into MIR, and a WASM build of the stanli runtime lowers it to an
3
+ // op graph and runs NUTS. Everything happens client side, off the main
4
+ // thread, in workers this module owns.
5
+ //
6
+ // import { compile, sample } from "@seantalts/stanli";
7
+ // const { mir } = await compile({ code });
8
+ // const fits = await Promise.all([1, 2, 3, 4].map((c) =>
9
+ // sample({ mir, data, seed: c })));
10
+ //
11
+ // Workers pool up to the hardware's concurrency, so independent calls --
12
+ // one chain each -- run simultaneously. Compiling once and passing `mir`
13
+ // keeps the 2.8 MB compiler in a single worker (or out of the page
14
+ // entirely, if the model was precompiled at build time with
15
+ // stanc --debug-transformed-mir).
16
+
17
+ const pool = [];
18
+ const waiters = [];
19
+ const MAX_WORKERS = Math.max(
20
+ 2, Math.min(8, ((typeof navigator !== "undefined" &&
21
+ navigator.hardwareConcurrency) || 4)));
22
+
23
+ function acquire() {
24
+ let slot = pool.find((s) => !s.busy);
25
+ if (!slot && pool.length < MAX_WORKERS) {
26
+ slot = { worker: new Worker(new URL("./worker.js", import.meta.url)),
27
+ busy: false };
28
+ pool.push(slot);
29
+ }
30
+ if (slot) {
31
+ slot.busy = true;
32
+ return Promise.resolve(slot);
33
+ }
34
+ return new Promise((res) => waiters.push(res));
35
+ }
36
+
37
+ function release(slot) {
38
+ const next = waiters.shift();
39
+ if (next) next(slot); // handed over still busy
40
+ else slot.busy = false;
41
+ }
42
+
43
+ function request(msg, opts) {
44
+ return acquire().then((slot) => {
45
+ const w = slot.worker;
46
+ return new Promise((resolve, reject) => {
47
+ w.onmessage = (e) => {
48
+ const m = e.data;
49
+ if (m.status) {
50
+ if (opts.onProgress) opts.onProgress(m.status);
51
+ return;
52
+ }
53
+ if (m.liveMeta || m.live) {
54
+ if (opts.onLive) opts.onLive(m);
55
+ return;
56
+ }
57
+ if (m.error) reject(new Error(m.error));
58
+ else resolve(m.done);
59
+ };
60
+ w.onerror = (e) => {
61
+ reject(new Error("stanli worker: " + (e.message ||
62
+ "failed to load")));
63
+ };
64
+ w.postMessage(msg);
65
+ }).finally(() => {
66
+ w.onmessage = null;
67
+ w.onerror = null;
68
+ release(slot);
69
+ });
70
+ });
71
+ }
72
+
73
+ /** Load the heavy artifacts before they are needed. Resolves when one
74
+ * worker holds the parsed stanc3 compiler and `chains - 1` more hold the
75
+ * instantiated wasm runtime, so a later compile() or sample() starts at
76
+ * full speed instead of paying the fetch+parse on the user's click.
77
+ * Safe to call more than once; the pool reuses warmed workers.
78
+ * @param {object} [opts]
79
+ * @param {number} [opts.chains=2] Workers to warm (first gets stanc3).
80
+ * @param {function} [opts.onProgress]
81
+ */
82
+ export function preload(opts) {
83
+ const o = opts || {};
84
+ const n = Math.max(1, Math.min(MAX_WORKERS, o.chains || 2));
85
+ const jobs = [request({ cmd: "preload", stanc: true }, o)];
86
+ for (let k = 1; k < n; ++k)
87
+ jobs.push(request({ cmd: "preload", stanc: false }, o));
88
+ return Promise.all(jobs);
89
+ }
90
+
91
+ /** Compile Stan source to transformed MIR (one worker loads stanc3).
92
+ * @returns {Promise<{mir: string, ms: {stanc: number}}>} */
93
+ export function compile(opts) {
94
+ return request({ cmd: "compile", code: opts.code }, opts);
95
+ }
96
+
97
+ /** Compile (unless `mir` is given) and draw from the posterior.
98
+ *
99
+ * @param {Object} opts
100
+ * @param {string} [opts.code] Stan source (compiled in the worker by
101
+ * stanc3, which loads lazily on first use).
102
+ * @param {string} [opts.mir] Precompiled transformed MIR (from
103
+ * `compile()` here, or `stanc --debug-transformed-mir` at build time).
104
+ * When given, the 2.8 MB compiler never loads: the runtime alone is
105
+ * ~1.3 MB gzipped.
106
+ * @param {Object|string} [opts.data] Data as an object or JSON text.
107
+ * @param {number} [opts.seed=1] Chain seed (sampler and GQ RNG).
108
+ * @param {number} [opts.warmup=1000]
109
+ * @param {number} [opts.samples=1000]
110
+ * @param {number} [opts.delta=0.8] Adaptation target acceptance.
111
+ * @param {function(string)} [opts.onProgress] Stage announcements.
112
+ * @param {function(Object)} [opts.onLive] Streaming draws while NUTS
113
+ * runs: {liveMeta: {names, warmup, samples}} once per call, then
114
+ * {live: {phase: "warmup"|"sampling", i, nCon?, rows?}} where rows is
115
+ * a transferred ArrayBuffer of constrained draws, nCon wide.
116
+ * @returns {Promise<{names: string[], samples: number,
117
+ * columns: Object<string, Float64Array>,
118
+ * exactLp: boolean,
119
+ * ms: {stanc: number, lower: number, sample: number,
120
+ * total: number}}>}
121
+ * One column per CSV column CmdStan would write: constrained
122
+ * parameters, transformed parameters, and generated quantities.
123
+ */
124
+ export function sample(opts) {
125
+ return request({
126
+ cmd: "run",
127
+ code: opts.code,
128
+ mir: opts.mir,
129
+ live: !!opts.onLive,
130
+ dataJson: typeof opts.data === "string"
131
+ ? opts.data
132
+ : JSON.stringify(opts.data || {}),
133
+ seed: opts.seed == null ? 1 : opts.seed,
134
+ warmup: opts.warmup == null ? 1000 : opts.warmup,
135
+ samples: opts.samples == null ? 1000 : opts.samples,
136
+ delta: opts.delta == null ? 0.8 : opts.delta,
137
+ }, opts).then((done) => {
138
+ const { names, samples, ms, exactLp } = done;
139
+ const flat = new Float64Array(done.columns);
140
+ const columns = {};
141
+ names.forEach((name, i) => {
142
+ columns[name] = flat.subarray(i * samples, (i + 1) * samples);
143
+ });
144
+ return { names, samples, columns, ms, exactLp };
145
+ });
146
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@seantalts/stanli",
3
+ "version": "0.1.0",
4
+ "description": "Full Stan in the browser: stanc3 compiles the model in JS, a WASM runtime lowers it to an op graph and runs NUTS. No server, no C++ toolchain.",
5
+ "type": "module",
6
+ "main": "index.mjs",
7
+ "exports": {
8
+ ".": "./index.mjs"
9
+ },
10
+ "files": [
11
+ "index.mjs",
12
+ "worker.js",
13
+ "stanli.js",
14
+ "stanli.wasm",
15
+ "stancjs.bc.js",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/seantalts/stanli.git"
22
+ },
23
+ "homepage": "https://seantalts.github.io/stanli/",
24
+ "license": "BSD-3-Clause",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "keywords": [
29
+ "stan",
30
+ "bayesian",
31
+ "mcmc",
32
+ "nuts",
33
+ "statistics",
34
+ "wasm",
35
+ "webassembly"
36
+ ]
37
+ }