@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 +29 -0
- package/README.md +47 -0
- package/index.mjs +146 -0
- package/package.json +37 -0
- package/stancjs.bc.js +12038 -0
- package/stanli.js +2 -0
- package/stanli.wasm +0 -0
- package/worker.js +169 -0
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
|
+
}
|