@seantalts/stanli 0.10.0 → 0.11.1

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/README.md CHANGED
@@ -23,11 +23,51 @@ const fit = await sample({
23
23
 
24
24
  fit.columns["mu"]; // Float64Array, one entry per draw
25
25
  fit.names; // every CSV column CmdStan would write
26
+ fit.generatedStart; // index where generated-quantity columns begin
26
27
  fit.ms; // {stanc, lower, sample, total} in milliseconds
27
28
  ```
28
29
 
30
+ NUTS can take its starting point from single-path Pathfinder. The same seed
31
+ controls initialization and sampling, and an empty options object uses the
32
+ defaults:
33
+
34
+ ```js
35
+ const fit = await sample({
36
+ code,
37
+ data,
38
+ seed: 303,
39
+ pathfinderInit: { numIterations: 500, numElboDraws: 25 },
40
+ });
41
+ ```
42
+
43
+ `historySize` and Pathfinder's own `initRadius` are also supported. This mode
44
+ does not perform PSIS resampling.
45
+
46
+ For NUTS, `await diagnose(fit)` returns the same text report as the R and
47
+ Python bindings: divergences, maximum-treedepth saturation, E-BFMI,
48
+ rank-normalized R-hat, and bulk/tail ESS. Pass an array of fits from the
49
+ same model and configuration to diagnose all chains together:
50
+
51
+ ```js
52
+ import { compile, diagnose, sample } from "@seantalts/stanli";
53
+ const { mir } = await compile({ code });
54
+ const fits = await Promise.all([1, 2, 3, 4].map((seed) =>
55
+ sample({ mir, data, seed })));
56
+ console.log(await diagnose(fits));
57
+ ```
58
+
59
+ `fit.samplerStats` contains seven doubles per post-warmup draw, in order:
60
+ `lp__`, `accept_stat__`, `stepsize__`, `treedepth__`, `n_leapfrog__`,
61
+ `divergent__`, `energy__`. `fit.maxDepth` records the sampling limit (10).
62
+ WALNUTS and Pathfinder return `null` for `samplerStats`; `diagnose()` rejects
63
+ these methods rather than treating missing statistics as successful checks.
64
+ The demo displays the report after NUTS runs, including comparisons, and
65
+ explicitly marks WALNUTS sampler diagnostics as unavailable. Pathfinder keeps
66
+ its existing importance-weight k-hat diagnostic.
67
+
29
68
  Columns cover the full CmdStan CSV: constrained parameters, transformed
30
69
  parameters, and generated quantities (RNG draws stream from `seed`).
70
+ When there are no generated quantities, `generatedStart === fit.names.length`.
31
71
  The heavy work runs in a worker the package owns, so the page never
32
72
  blocks; calls queue and run one at a time.
33
73
 
package/index.mjs CHANGED
@@ -95,6 +95,29 @@ export function compile(opts) {
95
95
  return request({ cmd: "compile", code: opts.code }, opts);
96
96
  }
97
97
 
98
+ function pathfinderInitOptions(value) {
99
+ if (value == null) return null;
100
+ if (typeof value !== "object" || Array.isArray(value))
101
+ throw new TypeError("pathfinderInit must be an options object");
102
+ const defaults = { numIterations: 1000, numElboDraws: 25,
103
+ historySize: 5, initRadius: 2 };
104
+ const unknown = Object.keys(value).filter((key) => !(key in defaults));
105
+ if (unknown.length)
106
+ throw new RangeError("unknown pathfinderInit option" +
107
+ (unknown.length > 1 ? "s" : "") + ": " +
108
+ unknown.join(", "));
109
+ const out = { ...defaults, ...value };
110
+ for (const name of ["numIterations", "numElboDraws", "historySize"])
111
+ if (!Number.isInteger(out[name]) || out[name] <= 0 ||
112
+ out[name] > 2147483647)
113
+ throw new RangeError(`pathfinderInit ${name} must be a positive integer`);
114
+ if (typeof out.initRadius !== "number" || !Number.isFinite(out.initRadius) ||
115
+ out.initRadius < 0)
116
+ throw new RangeError(
117
+ "pathfinderInit initRadius must be finite and nonnegative");
118
+ return out;
119
+ }
120
+
98
121
  /** Compile (unless `mir` is given) and draw from the posterior.
99
122
  *
100
123
  * @param {Object} opts
@@ -108,6 +131,10 @@ export function compile(opts) {
108
131
  * @param {number} [opts.warmup=1000]
109
132
  * @param {number} [opts.samples=1000]
110
133
  * @param {number} [opts.delta=0.8] Adaptation target acceptance (NUTS).
134
+ * @param {Object} [opts.pathfinderInit] Generate the NUTS start with
135
+ * single-path Pathfinder. `{}` uses defaults; supported keys are
136
+ * `numIterations`, `numElboDraws`, `historySize`, and `initRadius`.
137
+ * The sampling seed controls both stages. NUTS only; no PSIS resampling.
111
138
  * @param {string} [opts.sampler="nuts"] "nuts", "walnuts" (within-orbit
112
139
  * adaptive step-length NUTS, arXiv:2506.18746), or "pathfinder"
113
140
  * (a normal approximation fitted along an L-BFGS path). Pathfinder
@@ -123,9 +150,11 @@ export function compile(opts) {
123
150
  * a transferred ArrayBuffer of constrained draws, nCon wide.
124
151
  * Pathfinder streams {live: {phase: "path", iter, lp}} instead, one
125
152
  * message per L-BFGS iterate.
126
- * @returns {Promise<{names: string[], samples: number,
153
+ * @returns {Promise<{names: string[], samples: number, generatedStart: number,
127
154
  * columns: Object<string, Float64Array>,
128
155
  * exactLp: boolean,
156
+ * sampler: string, maxDepth: number|null,
157
+ * samplerStats: Float64Array|null,
129
158
  * pathfinder?: {path: {iter, lp}[], khat: number,
130
159
  * selectedIter: number,
131
160
  * selectedElbo: number,
@@ -134,8 +163,16 @@ export function compile(opts) {
134
163
  * total: number}}>}
135
164
  * One column per CSV column CmdStan would write: constrained
136
165
  * parameters, transformed parameters, and generated quantities.
166
+ * NUTS also returns post-warmup samplerStats in draw-major order:
167
+ * lp__, accept_stat__, stepsize__, treedepth__, n_leapfrog__,
168
+ * divergent__, energy__. Other methods return null for samplerStats.
137
169
  */
138
170
  export function sample(opts) {
171
+ const sampler = opts.sampler === "walnuts" || opts.sampler === "pathfinder"
172
+ ? opts.sampler : "nuts";
173
+ const pathfinderInit = pathfinderInitOptions(opts.pathfinderInit);
174
+ if (pathfinderInit && sampler !== "nuts")
175
+ throw new RangeError("pathfinderInit is available only with NUTS");
139
176
  return request({
140
177
  cmd: "run",
141
178
  code: opts.code,
@@ -148,16 +185,50 @@ export function sample(opts) {
148
185
  warmup: opts.warmup == null ? 1000 : opts.warmup,
149
186
  samples: opts.samples == null ? 1000 : opts.samples,
150
187
  delta: opts.delta == null ? 0.8 : opts.delta,
151
- sampler: opts.sampler === "walnuts" || opts.sampler === "pathfinder"
152
- ? opts.sampler : "nuts",
188
+ sampler,
153
189
  maxError: opts.maxError == null ? 0 : opts.maxError,
190
+ pathfinderInit,
154
191
  }, opts).then((done) => {
155
- const { names, samples, ms, exactLp, pathfinder } = done;
192
+ const { names, samples, generatedStart, ms, exactLp, pathfinder,
193
+ sampler, maxDepth } = done;
156
194
  const flat = new Float64Array(done.columns);
157
195
  const columns = {};
158
196
  names.forEach((name, i) => {
159
197
  columns[name] = flat.subarray(i * samples, (i + 1) * samples);
160
198
  });
161
- return { names, samples, columns, ms, exactLp, pathfinder };
199
+ const samplerStats = done.samplerStats
200
+ ? new Float64Array(done.samplerStats) : null;
201
+ return { names, samples, generatedStart, columns, ms, exactLp, pathfinder,
202
+ sampler, maxDepth, samplerStats };
162
203
  });
163
204
  }
205
+
206
+ /** Diagnose one NUTS fit, or an array of chains from the same model and
207
+ * configuration. Returns the native R/Python diagnostic report as text,
208
+ * using only post-warmup draws. Inputs are copied, never transferred away.
209
+ * WALNUTS and Pathfinder do not expose the required sampler statistics.
210
+ * @returns {Promise<string>} */
211
+ export async function diagnose(fits) {
212
+ const chains = Array.isArray(fits) ? fits : [fits];
213
+ const first = chains[0];
214
+ if (!first || !Number.isInteger(first.samples) || first.samples < 1 ||
215
+ !Array.isArray(first.names) || !first.names.length)
216
+ throw new Error("diagnose requires nonempty NUTS draws");
217
+ for (const fit of chains) {
218
+ if (!fit || fit.sampler !== "nuts" || fit.samples !== first.samples ||
219
+ fit.maxDepth !== first.maxDepth || !Number.isInteger(fit.maxDepth) ||
220
+ fit.maxDepth < 1 || !Array.isArray(fit.names) ||
221
+ fit.names.length !== first.names.length ||
222
+ fit.names.some((name, j) => name !== first.names[j]) ||
223
+ !(fit.samplerStats instanceof Float64Array) ||
224
+ fit.samplerStats.length !== first.samples * 7 ||
225
+ !fit.columns || first.names.some((name) =>
226
+ !(fit.columns[name] instanceof Float64Array) ||
227
+ fit.columns[name].length !== first.samples))
228
+ throw new Error("diagnose requires matching NUTS chains with sampler statistics");
229
+ }
230
+ return request({ cmd: "diagnose", names: first.names, samples: first.samples,
231
+ maxDepth: first.maxDepth,
232
+ chains: chains.map((fit) => fit.columns),
233
+ stats: chains.map((fit) => fit.samplerStats) }, {});
234
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seantalts/stanli",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
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
5
  "type": "module",
6
6
  "main": "index.mjs",