concept-collection / stan-web-ide
Switch sampling to pure-WASI web workers; add results dashboard
Models compile on the server to plain WASI modules; sampling runs locally, one web worker per chain, through a small WASI shim — no cross-origin isolation needed, so the coi-serviceworker is gone. Finished runs open a results dashboard (summary, histograms, trace/scatter plots, draws, console).
Jeremy Magland <jmagland@flatironinstitute.org> committed commit b1dbd02a6b2f parent ad7d2c4 Browse files
23 changed files+1435−424
README.mdmodified+31−21View file
@@ -3,10 +3,9 @@
33 Run [Stan](https://mc-stan.org) sampling in your browser, inside a VS
44 Code-style IDE built on [minwebide](https://github.com/magland/minwebide).
55 Projects live in your browser's IndexedDB. Models compile on a remote
6-[stan-wasm-server](https://github.com/flatironinstitute/stan-playground/tree/main/backend);
7-sampling itself runs locally in a web worker with
8-[tinystan](https://github.com/WardBrian/tinystan), chains in parallel
9-threads.
6+[stan-wasm-wasi](https://github.com/magland/stan-wasm-wasi) server to pure
7+WASI modules; sampling itself runs locally, one web worker per chain, each
8+invoking the module CLI-style through a small WASI shim.
109
1110 **Live site:** https://concept-collection.github.io/stan-web-ide/
1211
@@ -18,7 +17,6 @@ A project holds `.stan` programs, `.json` data files, and `.sample` files. A
1817 ```yaml
1918 stan: linear.stan # the Stan program
2019 data: data.json # the data
21-output_dir: out/fit # results are written here (replaced on each run)
2220 num_chains: 4 # optional; defaults 4 / 1000 / 1000 / 2.0 / random
2321 num_warmup: 1000
2422 num_samples: 1000
@@ -34,15 +32,23 @@ tab bar's ▶/⏹ runs and stops the same way. Runs use current editor contents,
3432 saved or not.
3533
3634 A run compiles the program (server-side, cached by source hash), streams
37-Stan's console output to the **Output** panel, and writes into
38-`output_dir`:
35+Stan's console output to the **Output** panel, and writes into the output
36+directory — always `<name>.out` next to the `.sample` file (so
37+`fit.sample` → `fit.out`, replaced on each run):
3938
4039 - `chain_1.csv` … one CSV per chain, header = parameter names, one row per draw
4140 - `summary.csv` — mean, MCSE, sd, 5%/50%/95%, ESS, ESS/s, split-Rhat per
4241 parameter (via [mcmc-stats](https://github.com/flatironinstitute/mcmc-stats.js))
43-- `sampling_opts.json` — the exact configuration used (including the
44- resolved seed)
4542 - `console.txt` — the sampler's console output
43+- `run.json` — the exact configuration used (including the resolved seed);
44+ written last, so it doubles as the run's completion marker
45+
46+When a run finishes, a **results dashboard** opens on `run.json`: tabs for
47+the summary table (Rhat highlighted when > 1.01), histograms, trace plots,
48+scatter plots, a draws table, and the console — plots via lazily-loaded
49+[plotly](https://plotly.com/javascript/). It reads the sibling CSVs, so it
50+reopens in any later session: click **View results** on the `.sample` form,
51+or `run.json` in the output folder.
4652
4753 The `.stan` editor has syntax highlighting plus diagnostics, hover docs,
4854 completion, and auto-format from
@@ -53,21 +59,24 @@ completion, and auto-format from
5359
5460 Compiling Stan to WebAssembly needs a server; everything else is local. The
5561 status bar shows the configured server (click it to change; persisted in the
56-browser). The default is `http://localhost:8083` — run one with:
62+browser). The default is `https://stan-wasm-wasi.fly.dev`, a hosted
63+[stan-wasm-wasi](https://github.com/magland/stan-wasm-wasi) instance — it
64+allows any origin (CORS), caches compiled models by source hash, and
65+auto-stops when idle, so the first compile after an idle period pays a
66+~30 s cold start. To run one locally instead:
5767
5868 ```sh
59-docker run -p 8083:8080 -it ghcr.io/flatironinstitute/stan-wasm-server:latest
69+docker build -t stan-wasm-wasi https://github.com/magland/stan-wasm-wasi.git
70+docker run --rm -p 8083:8080 stan-wasm-wasi
6071 ```
6172
62-**CORS**: the server's allowlist must include the page's origin. The stock
63-image allows `http://127.0.0.1:3000` and `http://127.0.0.1:4173`, which
64-match this app's dev and preview ports — open the `127.0.0.1` URL, not
65-`localhost`. To serve other origins (like the live site above), host a
66-server whose allowlist includes them.
73+then set the server URL to `http://localhost:8083`.
6774
68-Threaded sampling requires cross-origin isolation (`SharedArrayBuffer`):
69-dev/preview send COOP/COEP headers; the GitHub Pages deployment uses
70-`coi-serviceworker.js`, injected at build time only.
75+The compiled models are pure-WASI command modules (`main.wasm`): each run of
76+the module executes one MCMC chain (CmdStan seed/chain-id convention), with
77+draws on stdout as CSV and Stan's console on stderr. Chains run in parallel
78+workers, so no threads, no `SharedArrayBuffer`, and no cross-origin
79+isolation are needed.
7180
7281 ## Development
7382
@@ -82,8 +91,9 @@ npm run dev # http://127.0.0.1:3000
8291
8392 - `npm run build` — static bundle in `dist/`
8493 - `npm run typecheck` — typechecks app code (vendor diagnostics suppressed)
85-- `npm run smoke` — headless end-to-end test against the built bundle; with
86- a compile server on `localhost:8083` it also compiles and samples for real
94+- `npm run smoke` — headless end-to-end test against the built bundle; when
95+ the default compile server is reachable it also compiles and samples for
96+ real
8797 - `node scripts/dev-check.mjs` — quick checks against a running dev server
8898
8999 CI checks out `magland/minwebide` next to this repo, installs both, builds,
package-lock.jsonmodified+7−7View file
@@ -10,8 +10,8 @@
1010 "dependencies": {
1111 "mcmc-stats": "^0.0.1",
1212 "minwebide": "file:../minwebide",
13+ "plotly.js-basic-dist-min": "^3.7.0",
1314 "stan-language-server": "^0.4.9",
14- "tinystan": "^0.3.3",
1515 "vscode-languageserver": "^9.0.1",
1616 "yaml": "^2.8.0"
1717 },
@@ -1101,6 +1101,12 @@
11011101 "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
11021102 }
11031103 },
1104+ "node_modules/plotly.js-basic-dist-min": {
1105+ "version": "3.7.0",
1106+ "resolved": "https://registry.npmjs.org/plotly.js-basic-dist-min/-/plotly.js-basic-dist-min-3.7.0.tgz",
1107+ "integrity": "sha512-84KPQhLTrO5IHu6DCte2KwA4bzSoVIdIs3uDPWid3sgeTX8L5RjpDARjCG3spgOwQ8MYE8PL+MeQ0DQRWA1Emw==",
1108+ "license": "MIT"
1109+ },
11041110 "node_modules/postcss": {
11051111 "version": "8.5.16",
11061112 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
@@ -1227,12 +1233,6 @@
12271233 "url": "https://github.com/sponsors/SuperchupuDev"
12281234 }
12291235 },
1230- "node_modules/tinystan": {
1231- "version": "0.3.3",
1232- "resolved": "https://registry.npmjs.org/tinystan/-/tinystan-0.3.3.tgz",
1233- "integrity": "sha512-qRvhT8t86q6Ac/MTcwf3jEifM3JHqJTEzspvLkld1SRErtKqsydJ14iB1DGyeQK6hegEsv0DREYoehKu+S1PWg==",
1234- "license": "BSD-3-Clause"
1235- },
12361236 "node_modules/trie-search": {
12371237 "version": "2.2.1",
12381238 "resolved": "https://registry.npmjs.org/trie-search/-/trie-search-2.2.1.tgz",
package.jsonmodified+1−1View file
@@ -13,8 +13,8 @@
1313 "dependencies": {
1414 "mcmc-stats": "^0.0.1",
1515 "minwebide": "file:../minwebide",
16+ "plotly.js-basic-dist-min": "^3.7.0",
1617 "stan-language-server": "^0.4.9",
17- "tinystan": "^0.3.3",
1818 "vscode-languageserver": "^9.0.1",
1919 "yaml": "^2.8.0"
2020 },
public/coi-serviceworker.jsdeleted+0−72View file
@@ -1,72 +0,0 @@
1-/*
2- * Cross-Origin Isolation Service Worker
3- *
4- * Adds COOP/COEP headers to responses so that SharedArrayBuffer is available
5- * on hosts that don't allow custom response headers (e.g. GitHub Pages).
6- *
7- * Based on https://github.com/niccokunzmann/coi-serviceworker (MIT).
8- */
9-
10-/* global self, caches, fetch, Response, clients */
11-
12-if (typeof window === "undefined") {
13- // --- Service Worker scope ---
14- self.addEventListener("install", () => self.skipWaiting());
15- self.addEventListener("activate", event =>
16- event.waitUntil(self.clients.claim())
17- );
18-
19- self.addEventListener("fetch", event => {
20- const request = event.request;
21- if (request.cache === "only-if-cached" && request.mode !== "same-origin") {
22- return; // Chrome bug workaround
23- }
24-
25- // Only add isolation headers to same-origin responses.
26- // Wrapping cross-origin responses in a new Response strips CORS
27- // internal flags, which breaks cross-origin fetch requests.
28- if (new URL(request.url).origin !== self.location.origin) {
29- return; // let the browser handle cross-origin requests normally
30- }
31-
32- event.respondWith(
33- fetch(request).then(response => {
34- if (response.status === 0) return response; // opaque response
35-
36- const headers = new Headers(response.headers);
37- // must match the value the dev/preview servers send — a document and
38- // its dedicated workers with mismatched COEP values fail to load
39- headers.set("Cross-Origin-Embedder-Policy", "require-corp");
40- headers.set("Cross-Origin-Opener-Policy", "same-origin");
41-
42- return new Response(response.body, {
43- status: response.status,
44- statusText: response.statusText,
45- headers,
46- });
47- })
48- );
49- });
50-} else {
51- // --- Window scope (registration) ---
52-
53- // Capture currentScript synchronously — it becomes null after script runs.
54- const scriptUrl = document.currentScript && document.currentScript.src;
55-
56- if (!window.crossOriginIsolated && navigator.serviceWorker) {
57- navigator.serviceWorker.register(scriptUrl || "/coi-serviceworker.js").then(
58- reg => {
59- if (reg.installing || reg.waiting) {
60- const sw = reg.installing || reg.waiting;
61- sw.addEventListener("statechange", () => {
62- if (sw.state === "activated") window.location.reload();
63- });
64- } else if (reg.active && !navigator.serviceWorker.controller) {
65- // Active but not yet controlling — reload to let it intercept.
66- window.location.reload();
67- }
68- },
69- err => console.error("COI service worker registration failed:", err)
70- );
71- }
72-}
scripts/dev-check.mjsmodified+3−3View file
@@ -13,13 +13,13 @@ const check = (name, ok) => console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`);
1313 try {
1414 await page.goto('http://127.0.0.1:3000/', { waitUntil: 'networkidle' });
1515 await page.waitForTimeout(2000);
16- check('dev: cross-origin isolated', await page.evaluate(() => window.crossOriginIsolated));
1716 await page.getByRole('button', { name: 'New sample project' }).click();
1817 await page.waitForTimeout(2500);
1918 check('dev: form view opens', await page.locator('.sample-editor').count() >= 1);
2019
21- // run first (the LSP check below leaves the model file edited)
22- const haveServer = await fetch('http://localhost:8083/probe').then(r => r.ok).catch(() => false);
20+ // run first (the LSP check below leaves the model file edited);
21+ // probing also wakes the fly.io machine if it was auto-stopped
22+ const haveServer = await fetch('https://stan-wasm-wasi.fly.dev/probe').then(r => r.ok).catch(() => false);
2323 if (haveServer) {
2424 await page.locator('.sample-run-button').click();
2525 let done = false;
scripts/live-check.mjsmodified+3−4View file
@@ -12,9 +12,8 @@ const check = (name, ok) => console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`);
1212
1313 try {
1414 await page.goto(base, { waitUntil: 'networkidle' });
15- // the coi-serviceworker registers and reloads once on first visit
15+ // returning visitors unregister the old coi-serviceworker and reload once
1616 await page.waitForTimeout(3500);
17- check('cross-origin isolated', await page.evaluate(() => window.crossOriginIsolated));
1817 check('landing renders', await page.locator('.landing-header').count() === 1);
1918 await page.screenshot({ path: out + '/live-landing.png' });
2019
@@ -37,8 +36,8 @@ try {
3736 check('LSP diagnostics live', sawMarker);
3837 await page.screenshot({ path: out + '/live-ide.png' });
3938
40- // the status bar should show the compile server as offline (localhost
41- // default doesn't apply to the Pages origin)
39+ // the status bar shows the compile server (default: the hosted
40+ // stan-wasm-wasi instance, reachable from any origin)
4241 const statusText = (await page.locator('.mw-statusbar').innerText()).replace(/\u00a0/g, ' ');
4342 check('server status item present', statusText.includes('Stan server:'));
4443
scripts/smoke.mjsmodified+52−22View file
@@ -1,7 +1,7 @@
11 // End-to-end smoke test: landing → sample project → .sample form view,
2-// Stan LSP diagnostics, server status. When a compile server is reachable
3-// at http://localhost:8083 (e.g. the stan-wasm-server docker image), also
4-// compiles + samples for real and checks the output files.
2+// Stan LSP diagnostics, server status. When the compile server (the app's
3+// default, the hosted stan-wasm-wasi instance) is reachable, also compiles
4+// + samples for real and checks the output files.
55 import { chromium } from 'playwright';
66 import { spawn } from 'node:child_process';
77
@@ -14,7 +14,8 @@ for (let i = 0; i < 60; i++) {
1414 await new Promise((r) => setTimeout(r, 500));
1515 }
1616
17-const serverUrl = 'http://localhost:8083';
17+// probing also wakes the fly.io machine if it was auto-stopped
18+const serverUrl = 'https://stan-wasm-wasi.fly.dev';
1819 const haveServer = await fetch(`${serverUrl}/probe`).then(r => r.ok).catch(() => false);
1920 console.log(haveServer ? `compile server detected at ${serverUrl} — running full e2e` : 'no compile server — UI checks only');
2021
@@ -38,7 +39,6 @@ const waitForOutput = async (needle, timeout = 20000) => {
3839 };
3940
4041 try {
41- // use 127.0.0.1: the compile server's CORS allowlist matches that origin
4242 await page.goto('http://127.0.0.1:4173/', { waitUntil: 'networkidle' });
4343 await page.waitForTimeout(1200);
4444 check('landing renders', await page.locator('.landing-empty').count() === 1);
@@ -98,24 +98,47 @@ try {
9898 await page.locator('.mw-tab-label', { hasText: 'fit.sample' }).click();
9999 await page.waitForTimeout(400);
100100 await page.locator('.sample-run-button').click();
101- // progress bars should appear while sampling (4 chains)
101+ // progress bars should appear while sampling (4 chains) — the
102+ // sampling phase is sub-second for this model, so poll fast and use
103+ // the cheap form status (not the output panel) to detect completion
102104 let sawBars = 0;
103105 const start = Date.now();
104106 let done = false;
105107 while (Date.now() - start < 360_000 && !done) {
106108 sawBars = Math.max(sawBars, await page.locator('.sample-chain').count());
107- done = (await outputText()).includes('sampling completed');
108- if (!done) await page.waitForTimeout(300);
109+ done = (await page.locator('.sample-run-status').innerText().catch(() => '')).includes('completed');
110+ if (!done) await page.waitForTimeout(50);
109111 }
110- check('fit.sample sampling completed', done);
112+ check('fit.sample sampling completed', done && await waitForOutput('sampling completed'));
111113 check('per-chain progress bars shown (4)', sawBars === 4);
112114 await page.screenshot({ path: out + '/s-run-done.png' });
113115
114- // output files in the explorer
115- await page.waitForTimeout(800);
116- await explorerItem('out').click();
116+ // the results dashboard auto-opens on completion
117+ let dashboardVisible = false;
118+ for (let i = 0; i < 40 && !dashboardVisible; i++) {
119+ await page.waitForTimeout(250);
120+ dashboardVisible = await page.locator('.results-view:visible').count() === 1;
121+ }
122+ check('results dashboard auto-opens', dashboardVisible);
123+ check('dashboard summary has beta row', await page.locator('.results-view:visible .results-table td', { hasText: /^beta$/ }).count() === 1);
124+ // plotly loads lazily on the first plot tab
125+ await page.locator('.results-tab', { hasText: 'Trace plots' }).click();
126+ let plotsRendered = 0;
127+ for (let i = 0; i < 60 && plotsRendered < 10; i++) {
128+ await page.waitForTimeout(250);
129+ plotsRendered = await page.locator('.results-view:visible .js-plotly-plot').count();
130+ }
131+ check('trace plots render (11 params)', plotsRendered === 11);
132+ await page.screenshot({ path: out + '/s-dashboard.png' });
133+
134+ // back on the form, the View results button now shows
135+ await page.locator('.mw-tab-label', { hasText: 'fit.sample' }).click();
117136 await page.waitForTimeout(400);
118- await explorerItem('fit').click();
137+ check('View results button shows', await page.locator('.sample-results-button:visible').count() === 1);
138+
139+ // output files in the explorer (fit.sample → fit.out/)
140+ await page.waitForTimeout(800);
141+ await explorerItem('fit.out').click();
119142 await page.waitForTimeout(400);
120143 check('chain_1.csv written', await explorerItem('chain_1.csv').count() === 1);
121144 check('summary.csv written', await explorerItem('summary.csv').count() === 1);
@@ -148,18 +171,25 @@ try {
148171 await page.waitForTimeout(300);
149172 check('form edit marks tab dirty', await page.locator('.mw-tab.dirty').count() >= 1);
150173 await quickForm.locator('.sample-run-button').click();
151- check('quick.sample sampling completed', await waitForOutput('files to /out/quick', 120_000));
152- await page.waitForTimeout(800);
153- await explorerItem('quick').click();
154- await page.waitForTimeout(400);
155- // both out/fit and out/quick hold one; /out/quick sorts last
156- await explorerItem('sampling_opts.json').last().click();
157- await page.waitForTimeout(800);
158- const opts = normalize(await page.locator('.view-lines').first().innerText());
159- check('sampling_opts records form-edited num_samples', opts.includes('"num_samples": 150'));
174+ check('quick.sample sampling completed', await waitForOutput('files to /quick.out', 120_000));
175+ // the quick.out dashboard auto-opens and reflects the form edit
176+ await page.waitForTimeout(1500);
177+ const quickSubtitle = normalize(await page.locator('.results-view:visible .results-subtitle').innerText());
178+ check('dashboard records form-edited num_samples', quickSubtitle.includes('150 samples'));
160179 await page.screenshot({ path: out + '/s-opts.png' });
161180 }
162181
182+ if (haveServer) {
183+ // after a reload, the dashboard reopens from the persisted output folder
184+ await page.reload({ waitUntil: 'networkidle' });
185+ await page.waitForTimeout(2500);
186+ await explorerItem('fit.out').click();
187+ await page.waitForTimeout(400);
188+ await explorerItem('run.json').first().click();
189+ await page.waitForTimeout(1500);
190+ check('dashboard reopens after reload', await page.locator('.results-view:visible .results-table td', { hasText: /^beta$/ }).count() === 1);
191+ }
192+
163193 // project lifecycle basics
164194 await page.getByTitle('Back to projects').click();
165195 await page.waitForTimeout(800);
src/stan/compile.tsmodified+17−16View file
@@ -1,12 +1,13 @@
1-// Client for the stan-wasm-server compile endpoint (the same protocol as
2-// stan-playground): POST the .stan source, get a model id, and reference the
3-// compiled emscripten module at /download/{model_id}/main.js. The server
4-// caches compilations by source hash; on top of that we keep a small
5-// in-session cache so re-running an unchanged model skips the round trip
6-// (validated with a HEAD request, since server redeploys invalidate ids).
1+// Client for the compile server (stan-wasm-wasi — stan-playground's
2+// compilation protocol, producing pure-WASI modules): POST the .stan source,
3+// get a model id, and reference the compiled module at
4+// /download/{model_id}/main.wasm. The server caches compilations by source
5+// hash; on top of that we keep a small in-session cache so re-running an
6+// unchanged model skips the round trip (validated with a HEAD request,
7+// since server redeploys invalidate ids).
78
89 export interface CompileResult {
9- mainJsUrl?: string;
10+ mainWasmUrl?: string;
1011 error?: string;
1112 }
1213
@@ -22,7 +23,7 @@ export async function compileStanProgram(
2223 const cached = cache.get(cacheKey);
2324 if (cached && await urlExists(cached)) {
2425 onStatus('compiled (cached)');
25- return { mainJsUrl: cached };
26+ return { mainWasmUrl: cached };
2627 }
2728
2829 try {
@@ -31,7 +32,7 @@ export async function compileStanProgram(
3132 method: 'POST',
3233 headers: {
3334 'Content-Type': 'text/plain',
34- // the stan-wasm-server passcode (fixed, same as stan-playground)
35+ // the compile server passcode (fixed, same as stan-playground)
3536 'Authorization': 'Bearer 1234',
3637 },
3738 body: stanProgram,
@@ -40,18 +41,18 @@ export async function compileStanProgram(
4041 return { error: `compilation failed: ${await messageOrStatus(response)}` };
4142 }
4243 const { model_id } = await response.json();
43- const mainJsUrl = `${serverUrl}/download/${model_id}/main.js`;
44+ const mainWasmUrl = `${serverUrl}/download/${model_id}/main.wasm`;
4445
45- onStatus('checking download of main.js');
46- if (!await urlExists(mainJsUrl)) {
47- return { error: `compiled, but main.js is not downloadable from ${mainJsUrl}` };
46+ onStatus('checking download of main.wasm');
47+ if (!await urlExists(mainWasmUrl)) {
48+ return { error: `compiled, but main.wasm is not downloadable from ${mainWasmUrl}` };
4849 }
4950
50- cache.set(cacheKey, mainJsUrl);
51+ cache.set(cacheKey, mainWasmUrl);
5152 onStatus('compiled');
52- return { mainJsUrl };
53+ return { mainWasmUrl };
5354 } catch (error) {
54- return { error: `compilation request failed: ${error} (is the compile server at ${serverUrl} running, and does its CORS allowlist include this origin?)` };
55+ return { error: `compilation request failed: ${error} (is the compile server at ${serverUrl} running? if it just woke from idle, try again)` };
5556 }
5657 }
5758
src/stan/outputs.tsmodified+5−3View file
@@ -11,14 +11,16 @@ import {
1111 //
1212 // <output_dir>/chain_1.csv ... one CSV per chain, header = parameter names
1313 // <output_dir>/summary.csv mean, MCSE, sd, percentiles, ESS, Rhat
14-// <output_dir>/sampling_opts.json the exact configuration used
1514 // <output_dir>/console.txt sampler console output
15+// <output_dir>/run.json the exact configuration used — written
16+// LAST, so it doubles as the completion
17+// marker and anchors the results dashboard
1618 //
1719 // (the per-chain CSV layout matches stan-playground's "download multiple
1820 // CSVs" export)
1921
2022 export interface RunOutputs {
21- /** draws[param][draw], chains concatenated along the draw axis (tinystan). */
23+ /** draws[param][draw], chains concatenated along the draw axis. */
2224 draws: number[][];
2325 paramNames: string[];
2426 numChains: number;
@@ -50,8 +52,8 @@ export async function writeRunOutputs(fs: WorkspaceFileSystem, outputDir: string
5052 }
5153
5254 await write('summary.csv', summaryCsv(run));
53- await write('sampling_opts.json', JSON.stringify(run.samplingOpts, null, 2) + '\n');
5455 await write('console.txt', run.consoleText);
56+ await write('run.json', JSON.stringify({ format: 'stan-web-ide.run/1', ...run.samplingOpts }, null, 2) + '\n');
5557
5658 return written;
5759 }
src/stan/protocol.tsmodified+22−15View file
@@ -1,19 +1,22 @@
1-// Message protocol between the app and the sampler web worker.
1+// Message protocol between the app and the per-chain sampler workers.
22
3-/** The config passed to tinystan's model.sample() (unset options use
4- * tinystan defaults: diagonal metric, adapt_delta 0.8, max_depth 10, ...). */
5-export interface StanSampleConfig {
3+/** One chain's run configuration — maps onto the compiled model's argv:
4+ * <data_json> <seed> <chain_id> <num_warmup> <num_samples> <init_radius>
5+ * <refresh>. Unset sampler options use the driver's defaults (diagonal
6+ * metric, adapt_delta 0.8, max_depth 10, ... — same as tinystan's). */
7+export interface ChainRunConfig {
68 /** Contents of the data JSON file. */
79 data: string;
8- num_chains: number;
9- num_warmup: number;
10- num_samples: number;
11- init_radius: number;
10+ /** Same seed for every chain; the chain id differentiates the streams
11+ * (CmdStan convention). */
1212 seed: number;
13+ /** 1-based. */
14+ chainId: number;
15+ numWarmup: number;
16+ numSamples: number;
17+ initRadius: number;
1318 /** Iterations between progress lines. */
1419 refresh: number;
15- /** One thread per chain runs chains in parallel (needs SharedArrayBuffer). */
16- num_threads: number;
1720 }
1821
1922 export interface Progress {
@@ -24,13 +27,17 @@ export interface Progress {
2427 warmup: boolean;
2528 }
2629
27-export type WorkerRequest =
28- | { type: 'load'; mainJsUrl: string }
29- | { type: 'sample'; config: StanSampleConfig };
30+export type WorkerRequest = {
31+ type: 'run';
32+ /** The compiled model (structured-cloned; workers share the compiled
33+ * code and instantiate their own 256 MiB memory). */
34+ module: WebAssembly.Module;
35+ config: ChainRunConfig;
36+};
3037
3138 export type WorkerResponse =
32- | { type: 'loaded'; stanVersion: string }
3339 | { type: 'progress'; report: Progress }
3440 | { type: 'console'; text: string; level: 'log' | 'error' }
35- | { type: 'done'; draws: number[][]; paramNames: string[] }
41+ /** draws[param][draw] for this worker's single chain. */
42+ | { type: 'done'; paramNames: string[]; draws: number[][] }
3643 | { type: 'error'; message: string };
src/stan/resultsView.cssadded+220−0View file
@@ -0,0 +1,220 @@
1+/* The sampling-results dashboard pane, themed with --vscode-* variables
2+ * (same spirit as the .sample form editor). */
3+
4+.results-view {
5+ height: 100%;
6+ overflow-y: auto;
7+ background-color: var(--vscode-editor-background);
8+ color: var(--vscode-foreground);
9+ font-family: system-ui, 'Ubuntu', 'Droid Sans', sans-serif;
10+ font-size: 13px;
11+}
12+
13+.results-inner {
14+ max-width: 1000px;
15+ margin: 0 auto;
16+ padding: 24px 32px 48px;
17+}
18+
19+.results-view h2 {
20+ margin: 0 0 2px;
21+ font-size: 20px;
22+ font-weight: 400;
23+}
24+
25+.results-subtitle {
26+ margin: 0 0 14px;
27+ color: var(--vscode-descriptionForeground);
28+ font-size: 12px;
29+}
30+
31+.results-tabs {
32+ display: flex;
33+ gap: 2px;
34+ margin-bottom: 14px;
35+ border-bottom: 1px solid var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.35));
36+}
37+
38+.results-tab {
39+ padding: 6px 12px;
40+ font-size: 13px;
41+ font-family: inherit;
42+ cursor: pointer;
43+ border: none;
44+ background: none;
45+ color: var(--vscode-descriptionForeground);
46+ border-bottom: 2px solid transparent;
47+ margin-bottom: -1px;
48+}
49+
50+.results-tab:hover {
51+ color: var(--vscode-foreground);
52+}
53+
54+.results-tab.active {
55+ color: var(--vscode-foreground);
56+ border-bottom-color: var(--vscode-focusBorder, #0e70c0);
57+}
58+
59+.results-error {
60+ padding: 8px 10px;
61+ border-left: 3px solid var(--vscode-editorWarning-foreground, #cca700);
62+ background-color: var(--vscode-inputValidation-warningBackground, rgba(90, 73, 29, 0.4));
63+ white-space: pre-wrap;
64+}
65+
66+.results-note {
67+ margin: 0 0 10px;
68+ color: var(--vscode-descriptionForeground);
69+ font-size: 12px;
70+}
71+
72+/* --- tables (summary, draws) --- */
73+
74+.results-table-scroll {
75+ overflow: auto;
76+ max-height: calc(100vh - 220px);
77+ border: 1px solid var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.35));
78+}
79+
80+.results-table {
81+ border-collapse: collapse;
82+ font-size: 12px;
83+ font-variant-numeric: tabular-nums;
84+ white-space: nowrap;
85+}
86+
87+.results-table th {
88+ position: sticky;
89+ top: 0;
90+ background-color: var(--vscode-editorWidget-background, var(--vscode-editor-background));
91+ text-align: right;
92+ font-weight: 600;
93+ padding: 4px 10px;
94+ border-bottom: 1px solid var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.35));
95+}
96+
97+.results-table th:first-child {
98+ text-align: left;
99+}
100+
101+.results-table td {
102+ padding: 3px 10px;
103+ border-bottom: 1px solid rgba(128, 128, 128, 0.12);
104+}
105+
106+.results-table td.num {
107+ text-align: right;
108+}
109+
110+.results-table td.name {
111+ color: var(--vscode-descriptionForeground);
112+}
113+
114+.results-table td.warn {
115+ color: var(--vscode-editorWarning-foreground, #cca700);
116+}
117+
118+.results-table td.bad {
119+ color: var(--vscode-editorError-foreground, #f48771);
120+ font-weight: 600;
121+}
122+
123+/* --- plots --- */
124+
125+.results-grid {
126+ display: grid;
127+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
128+ gap: 14px;
129+}
130+
131+.results-plot-card {
132+ border: 1px solid var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.25));
133+ border-radius: 3px;
134+ padding: 6px 6px 2px;
135+}
136+
137+.results-plot-card.wide {
138+ margin-bottom: 14px;
139+}
140+
141+.results-plot-title {
142+ font-size: 12px;
143+ font-weight: 600;
144+ padding: 2px 6px 4px;
145+}
146+
147+.results-plot {
148+ height: 200px;
149+}
150+
151+.results-plot-card.wide .results-plot {
152+ height: 180px;
153+}
154+
155+.results-plot-card.tall .results-plot {
156+ height: 420px;
157+}
158+
159+/* --- controls (scatter axes, draws chain) --- */
160+
161+.results-controls {
162+ display: flex;
163+ align-items: center;
164+ gap: 16px;
165+ margin-bottom: 10px;
166+}
167+
168+.results-controls .results-note {
169+ margin: 0;
170+}
171+
172+.results-control {
173+ display: flex;
174+ align-items: center;
175+ gap: 6px;
176+ font-weight: 600;
177+}
178+
179+.results-control select {
180+ padding: 3px 6px;
181+ font-size: 12px;
182+ font-family: inherit;
183+ color: var(--vscode-input-foreground);
184+ background-color: var(--vscode-input-background);
185+ border: 1px solid var(--vscode-input-border, transparent);
186+ border-radius: 2px;
187+ max-width: 220px;
188+}
189+
190+.results-chain-legend {
191+ display: flex;
192+ flex-wrap: wrap;
193+ gap: 12px;
194+ margin-bottom: 10px;
195+ font-size: 12px;
196+ color: var(--vscode-descriptionForeground);
197+}
198+
199+.results-chain-chip {
200+ display: inline-flex;
201+ align-items: center;
202+ gap: 5px;
203+}
204+
205+.results-chain-swatch {
206+ width: 10px;
207+ height: 10px;
208+ border-radius: 2px;
209+ display: inline-block;
210+}
211+
212+.results-console {
213+ margin: 0;
214+ padding: 10px;
215+ font-family: var(--monaco-monospace-font, monospace);
216+ font-size: 12px;
217+ white-space: pre-wrap;
218+ background-color: var(--vscode-editorWidget-background, rgba(128, 128, 128, 0.08));
219+ border: 1px solid var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.25));
220+}
src/stan/resultsView.tsadded+480−0View file
@@ -0,0 +1,480 @@
1+import type { CustomEditorProvider, WorkspaceFileSystem } from 'minwebide';
2+import { loadRunData, prettifyParamName, type RunData, type RunVariable } from './runData';
3+import { dirnameOf } from './sampleConfig';
4+import './resultsView.css';
5+
6+// The results dashboard: the default view for <name>.out/run.json (the
7+// manifest written last by a completed run). Tabs over the run's outputs —
8+// summary table, histograms, trace plots, scatter, draws, console — reading
9+// the sibling CSVs, so it works for any output folder in any session. The
10+// view watches the file system and reloads when the run is replaced.
11+//
12+// Plots use plotly (the basic bundle), imported lazily so the app doesn't
13+// pay for it until a dashboard renders a plot.
14+
15+const MAX_PLOTS = 24;
16+const MAX_TRACE_POINTS = 5000;
17+const MAX_SCATTER_POINTS = 5000;
18+const MAX_DRAWS_ROWS = 1000;
19+const RELOAD_DEBOUNCE_MS = 400;
20+
21+// one distinguishable color per chain (d3 category10), same in both themes
22+const CHAIN_COLORS = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f'];
23+
24+let plotlyPromise: Promise<typeof import('plotly.js-basic-dist-min')['default']> | undefined;
25+function loadPlotly() {
26+ plotlyPromise ??= import('plotly.js-basic-dist-min').then((module) => module.default);
27+ return plotlyPromise;
28+}
29+type PlotlyLib = Awaited<ReturnType<typeof loadPlotly>>;
30+
31+export function createResultsViewProvider(fs: WorkspaceFileSystem): CustomEditorProvider {
32+ return {
33+ viewType: 'stan.results',
34+ displayName: 'Sampling Results',
35+ selector: [{ filenamePattern: '**/*.out/run.json' }],
36+ priority: 'default',
37+ resolveCustomEditor(doc) {
38+ const outputDir = dirnameOf(doc.uri.path);
39+ const runName = outputDir.split('/').pop() ?? outputDir;
40+
41+ const element = el('div', 'results-view');
42+ const inner = el('div', 'results-inner');
43+ element.appendChild(inner);
44+ inner.appendChild(el('h2', undefined, runName));
45+ const subtitle = el('p', 'results-subtitle', 'loading...');
46+ inner.appendChild(subtitle);
47+ const tabsRow = el('div', 'results-tabs');
48+ const body = el('div', 'results-body');
49+ inner.append(tabsRow, body);
50+
51+ let disposed = false;
52+ let data: RunData | undefined;
53+ let plotly: PlotlyLib | undefined;
54+ const plotDivs = new Set<HTMLElement>();
55+
56+ const tabs: { id: string; label: string; render: (container: HTMLElement, run: RunData) => void }[] = [
57+ { id: 'summary', label: 'Summary', render: renderSummary },
58+ { id: 'histograms', label: 'Histograms', render: renderHistograms },
59+ { id: 'trace', label: 'Trace plots', render: renderTrace },
60+ { id: 'scatter', label: 'Scatter', render: renderScatter },
61+ { id: 'draws', label: 'Draws', render: renderDraws },
62+ { id: 'console', label: 'Console', render: renderConsole },
63+ ];
64+ let activeTab = 'summary';
65+ const renderedTabs = new Map<string, HTMLElement>();
66+
67+ const tabButtons = new Map<string, HTMLButtonElement>();
68+ for (const tab of tabs) {
69+ const button = el('button', 'results-tab', tab.label);
70+ button.addEventListener('click', () => selectTab(tab.id));
71+ tabButtons.set(tab.id, button);
72+ tabsRow.appendChild(button);
73+ }
74+
75+ function selectTab(id: string): void {
76+ activeTab = id;
77+ for (const [tabId, button] of tabButtons) {
78+ button.classList.toggle('active', tabId === id);
79+ }
80+ for (const [tabId, container] of renderedTabs) {
81+ container.style.display = tabId === id ? '' : 'none';
82+ }
83+ if (!renderedTabs.has(id) && data) {
84+ const container = el('div', 'results-tab-content');
85+ renderedTabs.set(id, container);
86+ body.appendChild(container);
87+ tabs.find((tab) => tab.id === id)?.render(container, data);
88+ }
89+ }
90+
91+ async function reload(): Promise<void> {
92+ let loaded: RunData | undefined;
93+ let error: string | undefined;
94+ try {
95+ loaded = await loadRunData(fs, outputDir);
96+ } catch (cause) {
97+ error = String(cause instanceof Error ? cause.message : cause);
98+ }
99+ if (disposed) {
100+ return;
101+ }
102+ data = loaded;
103+ for (const container of renderedTabs.values()) {
104+ container.remove();
105+ }
106+ renderedTabs.clear();
107+ plotDivs.clear();
108+ body.textContent = '';
109+ if (!data) {
110+ subtitle.textContent = 'no results';
111+ body.appendChild(el('div', 'results-error',
112+ `could not load results from ${outputDir}: ${error}\n\nResults appear here when a sampling run completes.`));
113+ return;
114+ }
115+ subtitle.textContent = describeRun(data);
116+ selectTab(activeTab);
117+ }
118+
119+ // reload when the run is replaced (writes are debounced into one
120+ // reload; run.json is written last, so the reload sees a complete run)
121+ let reloadTimer: ReturnType<typeof setTimeout> | undefined;
122+ const watcher = fs.fileService.onDidFilesChange(() => {
123+ clearTimeout(reloadTimer);
124+ reloadTimer = setTimeout(() => void reload(), RELOAD_DEBOUNCE_MS);
125+ });
126+ void reload();
127+
128+ return {
129+ element,
130+ layout(): void {
131+ for (const div of plotDivs) {
132+ if (div.isConnected && div.offsetParent !== null) {
133+ plotly?.Plots.resize(div);
134+ }
135+ }
136+ },
137+ dispose(): void {
138+ disposed = true;
139+ clearTimeout(reloadTimer);
140+ watcher.dispose();
141+ for (const div of plotDivs) {
142+ plotly?.purge(div);
143+ }
144+ },
145+ };
146+
147+ // --- tabs ----------------------------------------------------------
148+
149+ function renderSummary(container: HTMLElement, run: RunData): void {
150+ if (run.summary.length < 2) {
151+ container.appendChild(el('div', 'results-note', 'no summary available'));
152+ return;
153+ }
154+ const [header, ...rows] = run.summary;
155+ const rhatColumn = header.indexOf('rhat');
156+ const table = el('table', 'results-table');
157+ const head = el('tr');
158+ for (const cell of header) {
159+ head.appendChild(el('th', undefined, cell));
160+ }
161+ table.appendChild(head);
162+ for (const row of rows) {
163+ const tr = el('tr');
164+ row.forEach((cell, column) => {
165+ const td = el('td', column === 0 ? 'name' : 'num', column === 0 ? prettifyParamName(cell) : cell);
166+ if (column === rhatColumn) {
167+ const rhat = Number(cell);
168+ if (rhat > 1.05) {
169+ td.classList.add('bad');
170+ } else if (rhat > 1.01) {
171+ td.classList.add('warn');
172+ }
173+ }
174+ tr.appendChild(td);
175+ });
176+ table.appendChild(tr);
177+ }
178+ const scroll = el('div', 'results-table-scroll');
179+ scroll.appendChild(table);
180+ container.appendChild(scroll);
181+ }
182+
183+ function renderHistograms(container: HTMLElement, run: RunData): void {
184+ const variables = limited(container, run.variables);
185+ const grid = el('div', 'results-grid');
186+ container.appendChild(grid);
187+ const accent = accentColor(container);
188+ for (const variable of variables) {
189+ const { card, plot } = plotCard(variable.name);
190+ grid.appendChild(card);
191+ const pooled = variable.draws.flat();
192+ const bins = binize(pooled);
193+ void makePlot(plot, [{
194+ type: 'bar',
195+ x: bins.x,
196+ y: bins.y,
197+ width: bins.width,
198+ marker: { color: accent },
199+ hovertemplate: '%{x}: %{y:.3f}<extra></extra>',
200+ }], {
201+ bargap: 0.05,
202+ yaxis: { title: { text: 'probability', font: { size: 10 } } },
203+ });
204+ }
205+ }
206+
207+ function renderTrace(container: HTMLElement, run: RunData): void {
208+ container.appendChild(chainLegend(run.numChains));
209+ const variables = limited(container, run.variables);
210+ for (const variable of variables) {
211+ const { card, plot } = plotCard(variable.name, 'wide');
212+ container.appendChild(card);
213+ void makePlot(plot, variable.draws.map((draws, chain) => {
214+ const { x, y } = decimate(draws, MAX_TRACE_POINTS);
215+ return {
216+ type: 'scatter',
217+ mode: 'lines',
218+ name: `chain ${chain + 1}`,
219+ line: { color: CHAIN_COLORS[chain % CHAIN_COLORS.length], width: 1 },
220+ x,
221+ y,
222+ };
223+ }), {
224+ xaxis: { title: { text: 'draw', font: { size: 10 } } },
225+ });
226+ }
227+ }
228+
229+ function renderScatter(container: HTMLElement, run: RunData): void {
230+ const controls = el('div', 'results-controls');
231+ const xSelect = variableSelect(run.variables, 0);
232+ const ySelect = variableSelect(run.variables, Math.min(1, run.variables.length - 1));
233+ controls.append(labelFor('x', xSelect), labelFor('y', ySelect));
234+ container.appendChild(controls);
235+ container.appendChild(chainLegend(run.numChains));
236+ const { card, plot } = plotCard('', 'tall');
237+ container.appendChild(card);
238+
239+ const draw = () => {
240+ const x = run.variables[Number(xSelect.value)];
241+ const y = run.variables[Number(ySelect.value)];
242+ void makePlot(plot, x.draws.map((xDraws, chain) => {
243+ const stride = Math.max(1, Math.ceil(xDraws.length / MAX_SCATTER_POINTS));
244+ const xs: number[] = [], ys: number[] = [];
245+ for (let i = 0; i < xDraws.length; i += stride) {
246+ xs.push(xDraws[i]);
247+ ys.push(y.draws[chain][i]);
248+ }
249+ return {
250+ type: 'scatter',
251+ mode: 'markers',
252+ name: `chain ${chain + 1}`,
253+ marker: { color: CHAIN_COLORS[chain % CHAIN_COLORS.length], size: 3, opacity: 0.5 },
254+ x: xs,
255+ y: ys,
256+ };
257+ }), {
258+ xaxis: { title: { text: x.name, font: { size: 10 } } },
259+ yaxis: { title: { text: y.name, font: { size: 10 } } },
260+ });
261+ };
262+ xSelect.addEventListener('change', draw);
263+ ySelect.addEventListener('change', draw);
264+ draw();
265+ }
266+
267+ function renderDraws(container: HTMLElement, run: RunData): void {
268+ const controls = el('div', 'results-controls');
269+ const chainSelect = el('select');
270+ for (let chain = 1; chain <= run.numChains; chain++) {
271+ const option = el('option', undefined, `chain ${chain}`);
272+ option.value = String(chain - 1);
273+ chainSelect.appendChild(option);
274+ }
275+ controls.appendChild(labelFor('chain', chainSelect));
276+ const note = el('span', 'results-note', '');
277+ controls.appendChild(note);
278+ container.appendChild(controls);
279+ const scroll = el('div', 'results-table-scroll');
280+ container.appendChild(scroll);
281+
282+ const draw = () => {
283+ const chain = Number(chainSelect.value);
284+ const total = run.drawsPerChain;
285+ const shown = Math.min(total, MAX_DRAWS_ROWS);
286+ note.textContent = shown < total
287+ ? `showing ${shown.toLocaleString()} of ${total.toLocaleString()} draws — open chain_${chain + 1}.csv for all of them`
288+ : `${total.toLocaleString()} draws`;
289+ const table = el('table', 'results-table');
290+ const head = el('tr');
291+ head.appendChild(el('th', undefined, '#'));
292+ for (const variable of run.variables) {
293+ head.appendChild(el('th', undefined, variable.name));
294+ }
295+ table.appendChild(head);
296+ for (let row = 0; row < shown; row++) {
297+ const tr = el('tr');
298+ tr.appendChild(el('td', 'name', String(row + 1)));
299+ for (const variable of run.variables) {
300+ tr.appendChild(el('td', 'num', formatValue(variable.draws[chain][row])));
301+ }
302+ table.appendChild(tr);
303+ }
304+ scroll.textContent = '';
305+ scroll.appendChild(table);
306+ };
307+ chainSelect.addEventListener('change', draw);
308+ draw();
309+ }
310+
311+ function renderConsole(container: HTMLElement, run: RunData): void {
312+ const pre = el('pre', 'results-console');
313+ pre.textContent = run.consoleText || '(no console output)';
314+ container.appendChild(pre);
315+ }
316+
317+ // --- plot helpers ----------------------------------------------------
318+
319+ async function makePlot(div: HTMLElement, traces: unknown[], layout: Record<string, unknown>): Promise<void> {
320+ try {
321+ const lib = await loadPlotly();
322+ if (disposed || !div.isConnected) {
323+ return;
324+ }
325+ plotly = lib;
326+ await lib.newPlot(div, traces, { ...baseLayout(div), ...layout, ...mergeAxes(div, layout) }, {
327+ displaylogo: false,
328+ responsive: true,
329+ modeBarButtonsToRemove: ['lasso2d', 'select2d'],
330+ });
331+ plotDivs.add(div);
332+ } catch (error) {
333+ div.textContent = `plot failed: ${error}`;
334+ }
335+ }
336+
337+ function baseLayout(div: HTMLElement): Record<string, unknown> {
338+ const style = getComputedStyle(div);
339+ const fg = style.getPropertyValue('--vscode-foreground').trim() || '#cccccc';
340+ const bg = style.getPropertyValue('--vscode-editor-background').trim() || '#1f1f1f';
341+ return {
342+ paper_bgcolor: bg,
343+ plot_bgcolor: bg,
344+ font: { color: fg, size: 11, family: 'system-ui, sans-serif' },
345+ margin: { l: 55, r: 10, t: 10, b: 40 },
346+ showlegend: false,
347+ };
348+ }
349+
350+ /** Axis defaults (grid color, no zero line), merged under any
351+ * axis overrides the caller passed in `layout`. */
352+ function mergeAxes(div: HTMLElement, layout: Record<string, unknown>): Record<string, unknown> {
353+ const grid = 'rgba(128, 128, 128, 0.25)';
354+ const axis = { gridcolor: grid, zeroline: false };
355+ return {
356+ xaxis: { ...axis, ...(layout.xaxis as object | undefined) },
357+ yaxis: { ...axis, ...(layout.yaxis as object | undefined) },
358+ };
359+ }
360+
361+ function plotCard(title: string, kind?: 'wide' | 'tall'): { card: HTMLElement; plot: HTMLElement } {
362+ const card = el('div', `results-plot-card${kind ? ` ${kind}` : ''}`);
363+ if (title) {
364+ card.appendChild(el('div', 'results-plot-title', title));
365+ }
366+ const plot = el('div', 'results-plot');
367+ card.appendChild(plot);
368+ return { card, plot };
369+ }
370+
371+ /** Caps how many parameters get a plot; adds a note when truncated. */
372+ function limited(container: HTMLElement, variables: RunVariable[]): RunVariable[] {
373+ if (variables.length > MAX_PLOTS) {
374+ container.appendChild(el('div', 'results-note',
375+ `showing the first ${MAX_PLOTS} of ${variables.length} parameters`));
376+ return variables.slice(0, MAX_PLOTS);
377+ }
378+ return variables;
379+ }
380+
381+ function variableSelect(variables: RunVariable[], selected: number): HTMLSelectElement {
382+ const select = el('select');
383+ variables.forEach((variable, index) => {
384+ const option = el('option', undefined, variable.name);
385+ option.value = String(index);
386+ select.appendChild(option);
387+ });
388+ select.value = String(Math.max(0, selected));
389+ return select;
390+ }
391+ },
392+ };
393+}
394+
395+function describeRun(run: RunData): string {
396+ const info = run.info;
397+ const parts = [
398+ info.stan,
399+ info.data,
400+ `${run.numChains} chains × (${info.num_warmup ?? '?'} warmup + ${info.num_samples ?? '?'} samples)`,
401+ info.seed !== undefined ? `seed ${info.seed}` : undefined,
402+ info.compute_time_sec !== undefined ? `sampled in ${info.compute_time_sec} s` : undefined,
403+ ];
404+ return parts.filter(Boolean).join(' · ');
405+}
406+
407+function chainLegend(numChains: number): HTMLElement {
408+ const legend = el('div', 'results-chain-legend');
409+ for (let chain = 0; chain < numChains; chain++) {
410+ const chip = el('span', 'results-chain-chip', `chain ${chain + 1}`);
411+ const swatch = el('span', 'results-chain-swatch');
412+ swatch.style.backgroundColor = CHAIN_COLORS[chain % CHAIN_COLORS.length];
413+ chip.prepend(swatch);
414+ legend.appendChild(chip);
415+ }
416+ return legend;
417+}
418+
419+function labelFor(text: string, control: HTMLElement): HTMLElement {
420+ const label = el('label', 'results-control');
421+ label.append(el('span', undefined, text), control);
422+ return label;
423+}
424+
425+function accentColor(div: HTMLElement): string {
426+ return getComputedStyle(div).getPropertyValue('--vscode-progressBar-background').trim() || '#0e70c0';
427+}
428+
429+function binize(values: number[]): { x: number[]; y: number[]; width: number } {
430+ let min = Infinity;
431+ let max = -Infinity;
432+ for (const value of values) {
433+ if (value < min) min = value;
434+ if (value > max) max = value;
435+ }
436+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
437+ return { x: [], y: [], width: 1 };
438+ }
439+ if (min === max) {
440+ return { x: [min], y: [1], width: 1 };
441+ }
442+ const numBins = Math.max(5, Math.min(200, Math.ceil(1.5 * Math.sqrt(values.length))));
443+ const width = (max - min) / numBins;
444+ const counts = new Array<number>(numBins).fill(0);
445+ for (const value of values) {
446+ counts[Math.min(numBins - 1, Math.floor((value - min) / width))]++;
447+ }
448+ return {
449+ x: counts.map((_, bin) => min + (bin + 0.5) * width),
450+ y: counts.map((count) => count / values.length),
451+ width,
452+ };
453+}
454+
455+/** Every stride-th point so trace plots stay responsive on huge runs. */
456+function decimate(draws: number[], maxPoints: number): { x: number[]; y: number[] } {
457+ const stride = Math.max(1, Math.ceil(draws.length / maxPoints));
458+ const x: number[] = [];
459+ const y: number[] = [];
460+ for (let i = 0; i < draws.length; i += stride) {
461+ x.push(i + 1);
462+ y.push(draws[i]);
463+ }
464+ return { x, y };
465+}
466+
467+function formatValue(value: number): string {
468+ return Number.isFinite(value) ? String(Number(value.toPrecision(6))) : String(value);
469+}
470+
471+function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
472+ const node = document.createElement(tag);
473+ if (className) {
474+ node.className = className;
475+ }
476+ if (text !== undefined) {
477+ node.textContent = text;
478+ }
479+ return node;
480+}
src/stan/runData.tsadded+100−0View file
@@ -0,0 +1,100 @@
1+import type { WorkspaceFileSystem } from 'minwebide';
2+
3+// Loads a completed run from its output directory (the files written by
4+// outputs.ts) into the shape the results dashboard works with. The run.json
5+// manifest is written last, so its presence means the other files are
6+// complete.
7+
8+/** The run.json manifest (the sampling configuration plus format tag). */
9+export interface RunInfo {
10+ format?: string;
11+ stan?: string;
12+ data?: string;
13+ num_chains?: number;
14+ num_warmup?: number;
15+ num_samples?: number;
16+ init_radius?: number;
17+ seed?: number;
18+ compute_time_sec?: number;
19+}
20+
21+export interface RunVariable {
22+ /** Prettified name: 'beta.1' → 'beta[1]'. */
23+ name: string;
24+ /** Sampler diagnostics (lp__, divergent__, ...). */
25+ isDiagnostic: boolean;
26+ /** draws[chain][draw]. */
27+ draws: number[][];
28+}
29+
30+export interface RunData {
31+ info: RunInfo;
32+ /** Model parameters first, diagnostics last. */
33+ variables: RunVariable[];
34+ numChains: number;
35+ drawsPerChain: number;
36+ /** Parsed summary.csv: header row + data rows. */
37+ summary: string[][];
38+ consoleText: string;
39+}
40+
41+export async function loadRunData(fs: WorkspaceFileSystem, outputDir: string): Promise<RunData> {
42+ const info = JSON.parse(await readText(fs, `${outputDir}/run.json`)) as RunInfo;
43+ const numChains = info.num_chains ?? 1;
44+
45+ let paramNames: string[] = [];
46+ const perChain: number[][][] = []; // [chain][param][draw]
47+ for (let chain = 1; chain <= numChains; chain++) {
48+ const text = await readText(fs, `${outputDir}/chain_${chain}.csv`);
49+ const lines = text.split('\n').filter((line) => line.length > 0);
50+ if (lines.length < 2) {
51+ throw new Error(`chain_${chain}.csv has no draws`);
52+ }
53+ const names = lines[0].split(',');
54+ if (chain === 1) {
55+ paramNames = names;
56+ } else if (names.length !== paramNames.length) {
57+ throw new Error(`chain_${chain}.csv has a different parameter set than chain_1.csv`);
58+ }
59+ const draws: number[][] = names.map(() => new Array<number>(lines.length - 1));
60+ for (let row = 1; row < lines.length; row++) {
61+ const values = lines[row].split(',');
62+ for (let p = 0; p < names.length; p++) {
63+ draws[p][row - 1] = Number(values[p]);
64+ }
65+ }
66+ perChain.push(draws);
67+ }
68+
69+ const variables: RunVariable[] = paramNames.map((rawName, p) => ({
70+ name: prettifyParamName(rawName),
71+ isDiagnostic: rawName.endsWith('__'),
72+ draws: perChain.map((chain) => chain[p]),
73+ }));
74+ // model parameters first, sampler diagnostics last (stable within groups)
75+ variables.sort((a, b) => Number(a.isDiagnostic) - Number(b.isDiagnostic));
76+
77+ const summaryText = await readText(fs, `${outputDir}/summary.csv`).catch(() => '');
78+ const summary = summaryText.split('\n').filter((line) => line.length > 0).map((line) => line.split(','));
79+
80+ const consoleText = await readText(fs, `${outputDir}/console.txt`).catch(() => '');
81+
82+ return {
83+ info,
84+ variables,
85+ numChains,
86+ drawsPerChain: variables[0]?.draws[0]?.length ?? 0,
87+ summary,
88+ consoleText,
89+ };
90+}
91+
92+/** TinyStan flattens indices with dots: 'beta.1.2' → 'beta[1,2]'. */
93+export function prettifyParamName(name: string): string {
94+ const [base, ...indices] = name.split('.');
95+ return indices.length > 0 ? `${base}[${indices.join(',')}]` : name;
96+}
97+
98+async function readText(fs: WorkspaceFileSystem, path: string): Promise<string> {
99+ return (await fs.fileService.readFile(fs.root.with({ path }))).value.toString();
100+}
src/stan/runner.tsmodified+148−100View file
@@ -1,20 +1,21 @@
11 import { monaco, type FileRunner, type RunContext, type WorkspaceFileSystem } from 'minwebide';
22 import { compileStanProgram } from './compile';
33 import { writeRunOutputs } from './outputs';
4-import type { StanSampleConfig, WorkerResponse } from './protocol';
4+import type { ChainRunConfig, WorkerResponse } from './protocol';
55 import { setRunState, updateChainProgress } from './runEvents';
6-import { dirnameOf, parseSampleFile, resolveProjectPath, type SampleFileConfig } from './sampleConfig';
6+import { dirnameOf, outputDirFor, parseSampleFile, resolveProjectPath, type SampleFileConfig } from './sampleConfig';
77 import { getServerUrl } from './settings';
88
99 // The .sample runner: compile the referenced Stan program on the compile
10-// server, run NUTS-HMC sampling in a web worker (tinystan), stream progress
11-// to the output channel (and to the .sample view's progress bars via
10+// server (to a pure-WASI module), run NUTS-HMC sampling locally — one web
11+// worker per chain, each invoking the module CLI-style — stream progress to
12+// the output channel (and to the .sample view's progress bars via
1213 // runEvents), then write draws + summary into the output directory.
1314
1415 interface ActiveRun {
1516 uriKey: string;
16- worker: Worker;
17- /** Resolves the run() promise; the worker is terminated afterwards. */
17+ workers: Worker[];
18+ /** Resolves the run() promise; the workers are terminated afterwards. */
1819 finish: () => void;
1920 stopped: boolean;
2021 }
@@ -26,7 +27,7 @@ export interface StanRunner {
2627 dispose(): void;
2728 }
2829
29-export function createStanRunner(fs: WorkspaceFileSystem): StanRunner {
30+export function createStanRunner(fs: WorkspaceFileSystem, openFile: (path: string) => Promise<unknown>): StanRunner {
3031 let active: ActiveRun | undefined;
3132
3233 const run = async ({ uri, getText, output }: RunContext): Promise<void> => {
@@ -52,17 +53,15 @@ export function createStanRunner(fs: WorkspaceFileSystem): StanRunner {
5253 return;
5354 }
5455
55- // 2. referenced files
56+ // 2. referenced files; the output directory is derived from the
57+ // .sample file's name (fit.sample → fit.out next to it)
5658 const sampleDir = dirnameOf(uri.path);
5759 const stanPath = resolveProjectPath(sampleDir, config.stan!);
5860 const dataPath = resolveProjectPath(sampleDir, config.data!);
59- const outputDir = resolveProjectPath(sampleDir, config.output_dir!);
60- if (outputDir === '/') {
61- return fail("'output_dir' must not be the project root (its contents are replaced on each run)");
62- }
63- for (const [name, path] of [['.sample file', uri.path], ['stan file', stanPath], ['data file', dataPath]] as const) {
61+ const outputDir = outputDirFor(uri.path);
62+ for (const [name, path] of [['stan file', stanPath], ['data file', dataPath]] as const) {
6463 if (path === outputDir || path.startsWith(`${outputDir}/`)) {
65- return fail(`'output_dir' (${outputDir}) would overwrite the ${name} (${path})`);
64+ return fail(`the output directory (${outputDir}) would overwrite the ${name} (${path}) — move it out of ${outputDir}`);
6665 }
6766 }
6867
@@ -88,109 +87,158 @@ export function createStanRunner(fs: WorkspaceFileSystem): StanRunner {
8887 output.info(`[compile] ${status}`);
8988 setRunState(uriKey, { phase: 'compiling', message: status });
9089 });
91- if (!compiled.mainJsUrl) {
90+ if (!compiled.mainWasmUrl) {
9291 return fail(compiled.error ?? 'compilation failed');
9392 }
9493
95- // 4. sample in a fresh worker
94+ // 4. download + compile the wasm module once; WebAssembly.Module is
95+ // structured-cloneable, so the chain workers share the compiled code
96+ setRunState(uriKey, { phase: 'loading', message: 'loading model...' });
97+ let module: WebAssembly.Module;
98+ let moduleBytes = 0;
99+ try {
100+ const response = await fetch(compiled.mainWasmUrl);
101+ if (!response.ok) {
102+ return fail(`failed to download compiled model: ${response.status} ${response.statusText}`);
103+ }
104+ const buffer = await response.arrayBuffer();
105+ moduleBytes = buffer.byteLength;
106+ module = await WebAssembly.compile(buffer);
107+ } catch (error) {
108+ return fail(`failed to load compiled model: ${error}`);
109+ }
110+
111+ // 5. sample: one worker per chain (CmdStan convention — same seed,
112+ // chain ids 1..n differentiate the streams)
96113 const seed = config.seed ?? Math.floor(Math.random() * Math.pow(2, 32));
97- const sampleConfig: StanSampleConfig = {
98- data: dataText,
99- num_chains: config.num_chains,
100- num_warmup: config.num_warmup,
101- num_samples: config.num_samples,
102- init_radius: config.init_radius,
103- seed,
104- refresh: reasonableRefreshRate(config),
105- // one thread per chain: chains run in parallel (issue mirrors
106- // stan-playground's setting)
107- num_threads: config.num_chains,
108- };
114+ output.info(`model loaded (${(moduleBytes / 1024).toFixed(0)} kB wasm); sampling: ${config.num_chains} chains × (${config.num_warmup} warmup + ${config.num_samples} samples), seed ${seed}`);
115+ setRunState(uriKey, { phase: 'sampling', message: 'sampling...' });
109116
110- setRunState(uriKey, { phase: 'loading', message: 'loading model...' });
111- const worker = new Worker(new URL('./samplerWorker.ts', import.meta.url), { type: 'module' });
117+ const workers = Array.from({ length: config.num_chains }, () =>
118+ new Worker(new URL('./samplerWorker.ts', import.meta.url), { type: 'module' }));
119+ const chainResults: ({ paramNames: string[]; draws: number[][] } | undefined)[] = new Array(config.num_chains);
112120 const consoleLines: string[] = [];
113- let samplingStarted = 0;
114- let computeTimeSec = 0;
121+ let failed = false;
122+ const samplingStarted = performance.now();
115123
124+ const current: ActiveRun = { uriKey, workers, finish: () => {}, stopped: false };
116125 await new Promise<void>((resolve) => {
117- const current: ActiveRun = { uriKey, worker, finish: resolve, stopped: false };
126+ current.finish = resolve;
118127 active = current;
128+ let remaining = config.num_chains;
119129
120- worker.onmessage = async (event: MessageEvent<WorkerResponse>) => {
121- if (current.stopped) {
122- return;
123- }
124- const message = event.data;
125- switch (message.type) {
126- case 'loaded': {
127- output.info(`model loaded (Stan v${message.stanVersion}); sampling: ${config.num_chains} chains × (${config.num_warmup} warmup + ${config.num_samples} samples), seed ${seed}`);
128- setRunState(uriKey, { phase: 'sampling', message: 'sampling...' });
129- samplingStarted = performance.now();
130- worker.postMessage({ type: 'sample', config: sampleConfig });
131- break;
132- }
133- case 'progress': {
134- const r = message.report;
135- updateChainProgress(uriKey, config.num_chains, r);
136- const line = `Chain ${r.chain} Iteration: ${r.iteration} / ${r.totalIterations} [${String(r.percent).padStart(3)}%] (${r.warmup ? 'Warmup' : 'Sampling'})`;
137- consoleLines.push(line);
138- output.appendLine(line);
139- break;
130+ workers.forEach((worker, index) => {
131+ const chainId = index + 1;
132+ worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
133+ if (current.stopped || failed) {
134+ return;
140135 }
141- case 'console': {
142- consoleLines.push(message.text);
143- output.appendLine(message.text);
144- break;
145- }
146- case 'done': {
147- computeTimeSec = (performance.now() - samplingStarted) / 1000;
148- setRunState(uriKey, { phase: 'writing', message: 'writing outputs...' });
149- try {
150- const written = await writeRunOutputs(fs, outputDir, {
151- draws: message.draws,
152- paramNames: message.paramNames,
153- numChains: config.num_chains,
154- consoleText: consoleLines.join('\n') + '\n',
155- samplingOpts: {
156- stan: stanPath,
157- data: dataPath,
158- output_dir: outputDir,
159- num_chains: config.num_chains,
160- num_warmup: config.num_warmup,
161- num_samples: config.num_samples,
162- init_radius: config.init_radius,
163- seed,
164- compute_time_sec: Number(computeTimeSec.toFixed(3)),
165- },
166- computeTimeSec,
167- });
168- output.info(`sampling completed in ${computeTimeSec.toFixed(2)}s — wrote ${written.length} files to ${outputDir}`);
169- setRunState(uriKey, { phase: 'done', message: `completed in ${computeTimeSec.toFixed(2)}s → ${outputDir}`, computeTimeSec });
170- } catch (error) {
171- fail(`failed to write outputs: ${error}`);
136+ const message = event.data;
137+ switch (message.type) {
138+ case 'progress': {
139+ const r = message.report;
140+ updateChainProgress(uriKey, config.num_chains, r);
141+ const line = `Chain ${r.chain} Iteration: ${r.iteration} / ${r.totalIterations} [${String(r.percent).padStart(3)}%] (${r.warmup ? 'Warmup' : 'Sampling'})`;
142+ consoleLines.push(line);
143+ output.appendLine(line);
144+ break;
145+ }
146+ case 'console': {
147+ const line = config.num_chains > 1 ? `[chain ${chainId}] ${message.text}` : message.text;
148+ consoleLines.push(line);
149+ output.appendLine(line);
150+ break;
151+ }
152+ case 'done': {
153+ chainResults[index] = { paramNames: message.paramNames, draws: message.draws };
154+ remaining -= 1;
155+ if (remaining === 0) {
156+ resolve();
157+ }
158+ break;
159+ }
160+ case 'error': {
161+ failed = true;
162+ fail(message.message);
163+ resolve();
164+ break;
172165 }
173- resolve();
174- break;
175166 }
176- case 'error': {
177- fail(message.message);
167+ };
168+ worker.onerror = (event) => {
169+ if (!current.stopped && !failed) {
170+ failed = true;
171+ fail(`worker error: ${event.message ?? 'failed to load'}`);
178172 resolve();
179- break;
180173 }
181- }
182- };
183- worker.onerror = (event) => {
184- fail(`worker error: ${event.message ?? 'failed to load'}`);
185- resolve();
186- };
187- worker.postMessage({ type: 'load', mainJsUrl: compiled.mainJsUrl });
174+ };
175+ const chainConfig: ChainRunConfig = {
176+ data: dataText,
177+ seed,
178+ chainId,
179+ numWarmup: config.num_warmup,
180+ numSamples: config.num_samples,
181+ initRadius: config.init_radius,
182+ refresh: reasonableRefreshRate(config),
183+ };
184+ worker.postMessage({ type: 'run', module, config: chainConfig });
185+ });
188186 }).finally(() => {
189- worker.terminate();
190- if (active?.worker === worker) {
187+ for (const worker of workers) {
188+ worker.terminate();
189+ }
190+ if (active === current) {
191191 active = undefined;
192192 }
193193 });
194+
195+ if (current.stopped || failed) {
196+ return; // already reported
197+ }
198+ if (chainResults.some((result) => !result)) {
199+ return; // finished early without all chains (e.g. disposed mid-run)
200+ }
201+
202+ // 6. merge chains and write outputs: draws[param][draw], chains
203+ // concatenated along the draw axis (the layout outputs.ts expects)
204+ const computeTimeSec = (performance.now() - samplingStarted) / 1000;
205+ const results = chainResults as { paramNames: string[]; draws: number[][] }[];
206+ const paramNames = results[0].paramNames;
207+ const draws = paramNames.map((_, p) => {
208+ const merged: number[] = [];
209+ for (const chain of results) {
210+ merged.push(...chain.draws[p]);
211+ }
212+ return merged;
213+ });
214+
215+ setRunState(uriKey, { phase: 'writing', message: 'writing outputs...' });
216+ try {
217+ const written = await writeRunOutputs(fs, outputDir, {
218+ draws,
219+ paramNames,
220+ numChains: config.num_chains,
221+ consoleText: consoleLines.join('\n') + '\n',
222+ samplingOpts: {
223+ stan: stanPath,
224+ data: dataPath,
225+ output_dir: outputDir,
226+ num_chains: config.num_chains,
227+ num_warmup: config.num_warmup,
228+ num_samples: config.num_samples,
229+ init_radius: config.init_radius,
230+ seed,
231+ compute_time_sec: Number(computeTimeSec.toFixed(3)),
232+ },
233+ computeTimeSec,
234+ });
235+ output.info(`sampling completed in ${computeTimeSec.toFixed(2)}s — wrote ${written.length} files to ${outputDir}`);
236+ setRunState(uriKey, { phase: 'done', message: `completed in ${computeTimeSec.toFixed(2)}s → ${outputDir}`, computeTimeSec });
237+ // open (or refresh) the results dashboard
238+ openFile(`${outputDir}/run.json`).catch(() => {});
239+ } catch (error) {
240+ fail(`failed to write outputs: ${error}`);
241+ }
194242 };
195243
196244 const stop = (): void => {
@@ -216,9 +264,9 @@ export function createStanRunner(fs: WorkspaceFileSystem): StanRunner {
216264 };
217265 }
218266
219-/** Progress lines roughly every 2.5% of total iterations (min every 15). */
267+/** Progress lines roughly every 2.5% of a chain's iterations (min every 15). */
220268 function reasonableRefreshRate(config: SampleFileConfig): number {
221- const total = (config.num_samples + config.num_warmup) * config.num_chains;
269+ const total = config.num_samples + config.num_warmup;
222270 const nearestTen = Math.round(Math.floor(total / 40) / 10) * 10;
223271 return Math.max(15, nearestTen);
224272 }
src/stan/sampleConfig.tsmodified+16−10View file
@@ -4,7 +4,6 @@ import { parse, parseDocument } from 'yaml';
44 //
55 // stan: linear.stan # the Stan program
66 // data: data.json # the data file
7-// output_dir: out/fit1 # where results are written
87 // num_chains: 4 # optional, with stan-playground's defaults
98 // num_warmup: 1000
109 // num_samples: 1000
@@ -12,12 +11,12 @@ import { parse, parseDocument } from 'yaml';
1211 // seed: 42 # omit for a random seed
1312 //
1413 // File references are relative to the .sample file's directory; a leading
15-// '/' means the project root.
14+// '/' means the project root. Results go to a directory derived from the
15+// file's name (see outputDirFor), not configured in the YAML.
1616
1717 export interface SampleFileConfig {
1818 stan?: string;
1919 data?: string;
20- output_dir?: string;
2120 num_chains: number;
2221 num_warmup: number;
2322 num_samples: number;
@@ -32,7 +31,16 @@ export const samplingDefaults = {
3231 init_radius: 2.0,
3332 } as const;
3433
35-export const KNOWN_KEYS = ['stan', 'data', 'output_dir', 'num_chains', 'num_warmup', 'num_samples', 'init_radius', 'seed'] as const;
34+export const KNOWN_KEYS = ['stan', 'data', 'num_chains', 'num_warmup', 'num_samples', 'init_radius', 'seed'] as const;
35+
36+/** The run's output directory, derived from the .sample file's path:
37+ * /a/b/fit.sample → /a/b/fit.out (replaced on each run). */
38+export function outputDirFor(samplePath: string): string {
39+ const dir = dirnameOf(samplePath);
40+ const name = samplePath.split('/').pop() ?? '';
41+ const stem = name.endsWith('.sample') ? name.slice(0, -'.sample'.length) : name;
42+ return `${dir === '/' ? '' : dir}/${stem || 'run'}.out`;
43+}
3644
3745 export interface ParsedSampleFile {
3846 config: SampleFileConfig;
@@ -62,12 +70,14 @@ export function parseSampleFile(text: string): ParsedSampleFile {
6270 const record = raw as Record<string, unknown>;
6371
6472 for (const key of Object.keys(record)) {
65- if (!(KNOWN_KEYS as readonly string[]).includes(key)) {
73+ if (key === 'output_dir') {
74+ warnings.push("'output_dir' is no longer configurable (ignored) — results go to <sample-file-name>.out");
75+ } else if (!(KNOWN_KEYS as readonly string[]).includes(key)) {
6676 warnings.push(`unknown key '${key}' (ignored)`);
6777 }
6878 }
6979
70- const str = (key: 'stan' | 'data' | 'output_dir'): string | undefined => {
80+ const str = (key: 'stan' | 'data'): string | undefined => {
7181 const value = record[key];
7282 if (value === undefined || value === null) {
7383 return undefined;
@@ -80,7 +90,6 @@ export function parseSampleFile(text: string): ParsedSampleFile {
8090 };
8191 config.stan = str('stan');
8292 config.data = str('data');
83- config.output_dir = str('output_dir');
8493
8594 const num = (key: 'num_chains' | 'num_warmup' | 'num_samples' | 'init_radius' | 'seed', opts: { min: number; max?: number; integer: boolean }): number | undefined => {
8695 const value = record[key];
@@ -110,9 +119,6 @@ export function parseSampleFile(text: string): ParsedSampleFile {
110119 if (!config.data) {
111120 errors.push("missing 'data': the JSON data file");
112121 }
113- if (!config.output_dir) {
114- errors.push("missing 'output_dir': where results are written");
115- }
116122
117123 return { config, errors, warnings };
118124 }
src/stan/sampleEditor.cssmodified+27−2View file
@@ -97,7 +97,13 @@
9797 display: flex;
9898 align-items: center;
9999 gap: 12px;
100- margin: 18px 0 10px;
100+ margin: 4px 0 6px;
101+}
102+
103+.sample-output-note {
104+ margin: 0 0 18px;
105+ color: var(--vscode-descriptionForeground);
106+ font-size: 12px;
101107 }
102108
103109 .sample-run-button {
@@ -125,6 +131,21 @@
125131 color: var(--vscode-button-secondaryForeground);
126132 }
127133
134+.sample-results-button {
135+ padding: 6px 14px;
136+ font-size: 13px;
137+ font-family: inherit;
138+ cursor: pointer;
139+ border-radius: 3px;
140+ border: 1px solid var(--vscode-button-border, transparent);
141+ background-color: var(--vscode-button-secondaryBackground);
142+ color: var(--vscode-button-secondaryForeground);
143+}
144+
145+.sample-results-button:hover {
146+ background-color: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-secondaryBackground));
147+}
148+
128149 .sample-run-status {
129150 color: var(--vscode-descriptionForeground);
130151 }
@@ -142,7 +163,11 @@
142163 display: flex;
143164 flex-direction: column;
144165 gap: 6px;
145- margin-top: 8px;
166+ margin: 0 0 18px;
167+}
168+
169+.sample-chains:empty {
170+ display: none;
146171 }
147172
148173 .sample-chain {
src/stan/sampleEditor.tsmodified+31−27View file
@@ -1,6 +1,6 @@
11 import { monaco, type CustomEditorProvider, type Workbench, type WorkspaceFileSystem } from 'minwebide';
22 import { getRunState, onDidChangeRunState, type RunState } from './runEvents';
3-import { dirnameOf, parseSampleFile, samplingDefaults, updateSampleYaml } from './sampleConfig';
3+import { dirnameOf, outputDirFor, parseSampleFile, samplingDefaults, updateSampleYaml } from './sampleConfig';
44 import './sampleEditor.css';
55
66 // The default view for .sample files: a form over the YAML (shared text
@@ -29,12 +29,40 @@ export function createSampleEditorProvider(fs: WorkspaceFileSystem, workbench: W
2929
3030 const fileName = doc.uri.path.split('/').pop() ?? doc.uri.path;
3131 inner.appendChild(el('h2', undefined, fileName));
32- inner.appendChild(el('p', 'sample-editor-subtitle', 'A sampling run: the Stan program, the data, sampling parameters, and where results go. This form edits the underlying YAML (tab menu → Reopen as Text Editor).'));
32+ inner.appendChild(el('p', 'sample-editor-subtitle', 'A sampling run: the Stan program, the data, and sampling parameters. This form edits the underlying YAML (tab menu → Reopen as Text Editor).'));
3333
3434 const problems = el('div', 'sample-problems');
3535 problems.style.display = 'none';
3636 inner.appendChild(problems);
3737
38+ // --- run button + progress (at the top, above the fields) ---------
39+ const runRow = el('div', 'sample-run-row');
40+ const runButton = el('button', 'sample-run-button', 'Run sampling');
41+ runButton.addEventListener('click', () => {
42+ const state = getRunState(uriKey);
43+ if (isRunning(state)) {
44+ stopHandle.stop();
45+ } else {
46+ void workbench.runFile(doc.uri);
47+ }
48+ });
49+ // shown when the derived output dir holds a completed run
50+ const runJsonPath = `${outputDirFor(doc.uri.path)}/run.json`;
51+ const resultsButton = el('button', 'sample-results-button', 'View results');
52+ resultsButton.style.display = 'none';
53+ resultsButton.addEventListener('click', () => {
54+ void workbench.openFile(fs.root.with({ path: runJsonPath }));
55+ });
56+ const runStatus = el('span', 'sample-run-status', '');
57+ runRow.append(runButton, resultsButton, runStatus);
58+ inner.appendChild(runRow);
59+
60+ inner.appendChild(el('p', 'sample-output-note',
61+ `results are written to ${referenceFor(outputDirFor(doc.uri.path))} (replaced on each run)`));
62+
63+ const chainsBox = el('div', 'sample-chains');
64+ inner.appendChild(chainsBox);
65+
3866 // --- fields ------------------------------------------------------
3967 let applyingEdit = false;
4068 const setKey = (key: string, value: string | number | undefined) => {
@@ -53,12 +81,6 @@ export function createSampleEditorProvider(fs: WorkspaceFileSystem, workbench: W
5381 const stanField = fileSelect('stan', 'the Stan program', '.stan');
5482 const dataField = fileSelect('data', 'the data (JSON)', '.json');
5583
56- const outputField = el('input');
57- outputField.type = 'text';
58- outputField.placeholder = 'e.g. out/fit1';
59- outputField.addEventListener('change', () => setKey('output_dir', outputField.value.trim() || undefined));
60- inner.appendChild(field('output_dir', 'results are written here (replaced on each run)', outputField));
61-
6284 const params = el('div', 'sample-params');
6385 inner.appendChild(params);
6486 const numberField = (key: 'num_chains' | 'num_warmup' | 'num_samples' | 'init_radius' | 'seed', hint: string, opts: { min: number; max?: number; step?: string; optional?: boolean }) => {
@@ -92,24 +114,6 @@ export function createSampleEditorProvider(fs: WorkspaceFileSystem, workbench: W
92114 const radiusInput = numberField('init_radius', 'init radius', { min: 0, step: '0.1' });
93115 const seedInput = numberField('seed', 'random seed', { min: 0, optional: true });
94116
95- // --- run button + progress ---------------------------------------
96- const runRow = el('div', 'sample-run-row');
97- const runButton = el('button', 'sample-run-button', 'Run sampling');
98- runButton.addEventListener('click', () => {
99- const state = getRunState(uriKey);
100- if (isRunning(state)) {
101- stopHandle.stop();
102- } else {
103- void workbench.runFile(doc.uri);
104- }
105- });
106- const runStatus = el('span', 'sample-run-status', '');
107- runRow.append(runButton, runStatus);
108- inner.appendChild(runRow);
109-
110- const chainsBox = el('div', 'sample-chains');
111- inner.appendChild(chainsBox);
112-
113117 const renderRunState = (state: RunState) => {
114118 const running = isRunning(state);
115119 runButton.textContent = running ? 'Stop' : 'Run sampling';
@@ -151,7 +155,6 @@ export function createSampleEditorProvider(fs: WorkspaceFileSystem, workbench: W
151155
152156 setIfNotFocused(stanField.select, config.stan ?? '');
153157 setIfNotFocused(dataField.select, config.data ?? '');
154- setIfNotFocused(outputField, config.output_dir ?? '');
155158 setIfNotFocused(chainsInput, String(config.num_chains));
156159 setIfNotFocused(warmupInput, String(config.num_warmup));
157160 setIfNotFocused(samplesInput, String(config.num_samples));
@@ -170,6 +173,7 @@ export function createSampleEditorProvider(fs: WorkspaceFileSystem, workbench: W
170173 const all = await listProjectFiles(fs);
171174 stanField.setOptions(all.filter(path => path.endsWith('.stan')));
172175 dataField.setOptions(all.filter(path => path.endsWith('.json')));
176+ resultsButton.style.display = all.includes(runJsonPath) ? '' : 'none';
173177 refresh();
174178 };
175179 let fileListTimer: ReturnType<typeof setTimeout> | undefined;
src/stan/samplerWorker.tsmodified+112−83View file
@@ -1,107 +1,136 @@
1-// Web worker that loads a compiled Stan model (the emscripten module built
2-// by the compile server) via tinystan and runs NUTS-HMC sampling. Mirrors
3-// stan-playground's StanModelWorker: progress is parsed out of Stan's
4-// stdout lines; everything else streams back as console messages.
1+// Web worker that runs ONE MCMC chain: instantiates the compiled Stan model
2+// (a pure-WASI command module from the compile server) and runs it like a
3+// CLI. Stan's console output arrives on stderr — progress lines are parsed
4+// into structured reports, the rest streams back as console messages — and
5+// the draws arrive on stdout as CSV (param-name header + one row per draw).
56
6-import StanModel from 'tinystan';
7-import type { Progress, WorkerRequest, WorkerResponse } from './protocol';
8-
9-let model: StanModel | undefined;
7+import type { ChainRunConfig, Progress, WorkerRequest, WorkerResponse } from './protocol';
8+import { runWasiModule } from './wasi';
109
1110 function post(message: WorkerResponse): void {
1211 self.postMessage(message);
1312 }
1413
15-// The compiled models are threaded emscripten ES6 builds: they spawn their
16-// pthread pool with new Worker(new URL('main.js', import.meta.url)), which
17-// throws for a cross-origin script (the compile server). Workers cannot be
18-// *constructed* from a cross-origin URL, but a module worker may *import*
19-// one via CORS — so route cross-origin worker scripts through a same-origin
20-// blob trampoline.
21-const NativeWorker = Worker;
22-(self as { Worker: unknown }).Worker = class extends NativeWorker {
23- constructor(scriptUrl: string | URL, options?: WorkerOptions) {
24- const resolved = new URL(scriptUrl, self.location.href);
25- if (resolved.origin !== self.location.origin) {
26- const blob = new Blob([`import ${JSON.stringify(resolved.href)};`], { type: 'text/javascript' });
27- super(URL.createObjectURL(blob), options);
28- } else {
29- super(scriptUrl, options);
30- }
31- }
32-};
33-
3414 // Stan progress lines look like (spacing varies):
35-// Chain [1] Iteration: 2000 / 2000 [100%] (Sampling)
36-// Chain [2] Iteration: 800 / 2000 [ 40%] (Warmup)
37-// With a single chain the "Chain [x]" prefix is omitted.
38-function parseProgress(line: string): Progress {
39- if (line.startsWith('Iteration:')) {
40- line = 'Chain [1] ' + line;
15+// Iteration: 800 / 2000 [ 40%] (Warmup)
16+// There is no "Chain [n]" prefix — each module run is a single chain; the
17+// chain id comes from this worker's config.
18+function parseProgress(line: string, chainId: number): Progress | undefined {
19+ const match = line.match(/^Iteration:\s*(\d+)\s*\/\s*(\d+)\s*\[\s*(\d+)%\]\s*\((Warmup|Sampling)\)/);
20+ if (!match) {
21+ return undefined;
4122 }
42- line = line.replace(/\[|\]/g, '');
43- const parts = line.split(/\s+/);
4423 return {
45- chain: parseInt(parts[1], 10),
46- iteration: parseInt(parts[3], 10),
47- totalIterations: parseInt(parts[5], 10),
48- percent: parseInt(parts[6].slice(0, -1), 10),
49- warmup: parts[7] === '(Warmup)',
24+ chain: chainId,
25+ iteration: parseInt(match[1], 10),
26+ totalIterations: parseInt(match[2], 10),
27+ percent: parseInt(match[3], 10),
28+ warmup: match[4] === 'Warmup',
5029 };
5130 }
5231
53-function onPrint(text: string): void {
54- if (!text) {
55- return;
56- }
57- if (text.startsWith('Chain') || text.startsWith('Iteration:')) {
58- const report = parseProgress(text);
59- if (Number.isFinite(report.chain) && Number.isFinite(report.iteration)) {
60- post({ type: 'progress', report });
61- return;
32+/** Splits a byte stream into decoded lines (UTF-8-safe across chunks). */
33+function lineSplitter(onLine: (line: string) => void): { push(bytes: Uint8Array): void; flush(): void } {
34+ const decoder = new TextDecoder();
35+ let pending = '';
36+ const drain = () => {
37+ let index;
38+ while ((index = pending.indexOf('\n')) >= 0) {
39+ onLine(pending.slice(0, index));
40+ pending = pending.slice(index + 1);
6241 }
42+ };
43+ return {
44+ push(bytes) {
45+ pending += decoder.decode(bytes, { stream: true });
46+ drain();
47+ },
48+ flush() {
49+ pending += decoder.decode();
50+ drain();
51+ if (pending) {
52+ onLine(pending);
53+ pending = '';
54+ }
55+ },
56+ };
57+}
58+
59+function concatBytes(chunks: Uint8Array[]): Uint8Array {
60+ const result = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.length, 0));
61+ let offset = 0;
62+ for (const chunk of chunks) {
63+ result.set(chunk, offset);
64+ offset += chunk.length;
6365 }
64- post({ type: 'console', text, level: 'log' });
66+ return result;
6567 }
6668
67-function onPrintError(text: string): void {
68- if (text) {
69- post({ type: 'console', text, level: 'error' });
69+/** The driver's stdout: a param-name header, then one CSV row per draw.
70+ * Returns draws[param][draw]. */
71+function parseDrawsCsv(text: string): { paramNames: string[]; draws: number[][] } {
72+ const lines = text.split('\n').filter((line) => line.length > 0);
73+ if (lines.length < 2) {
74+ throw new Error('the model produced no draws');
7075 }
76+ const paramNames = lines[0].split(',');
77+ const draws: number[][] = paramNames.map(() => new Array<number>(lines.length - 1));
78+ for (let row = 1; row < lines.length; row++) {
79+ const values = lines[row].split(',');
80+ for (let p = 0; p < paramNames.length; p++) {
81+ draws[p][row - 1] = Number(values[p]);
82+ }
83+ }
84+ return { paramNames, draws };
7185 }
7286
73-self.onmessage = (event: MessageEvent<WorkerRequest>) => {
74- const message = event.data;
75- switch (message.type) {
76- case 'load': {
77- if (!self.crossOriginIsolated) {
78- post({
79- type: 'console',
80- text: 'warning: not cross-origin isolated — SharedArrayBuffer is unavailable and the threaded Stan module may fail to load',
81- level: 'error',
82- });
83- }
84- (async () => {
85- const js = await import(/* @vite-ignore */ message.mainJsUrl);
86- model = await StanModel.load(js.default, onPrint, onPrintError);
87- post({ type: 'loaded', stanVersion: model.stanVersion() });
88- })().catch((error) => {
89- post({ type: 'error', message: `failed to load compiled model: ${error}` });
90- });
91- break;
87+async function runChain(module: WebAssembly.Module, config: ChainRunConfig): Promise<void> {
88+ const stdoutChunks: Uint8Array[] = [];
89+ const stderrTail: string[] = [];
90+ const stderr = lineSplitter((line) => {
91+ const report = parseProgress(line, config.chainId);
92+ if (report) {
93+ post({ type: 'progress', report });
94+ return;
9295 }
93- case 'sample': {
94- if (!model) {
95- post({ type: 'error', message: 'model is not loaded' });
96- return;
97- }
98- try {
99- const { paramNames, draws } = model.sample(message.config);
100- post({ type: 'done', draws, paramNames });
101- } catch (error) {
102- post({ type: 'error', message: String(error) });
96+ if (line.trim()) {
97+ stderrTail.push(line);
98+ if (stderrTail.length > 5) {
99+ stderrTail.shift();
103100 }
104- break;
101+ post({ type: 'console', text: line, level: line.startsWith('error:') ? 'error' : 'log' });
105102 }
103+ });
104+
105+ const exitCode = await runWasiModule({
106+ module,
107+ args: [
108+ config.data,
109+ String(config.seed),
110+ String(config.chainId),
111+ String(config.numWarmup),
112+ String(config.numSamples),
113+ String(config.initRadius),
114+ String(config.refresh),
115+ ],
116+ onStdout: (bytes) => stdoutChunks.push(bytes),
117+ onStderr: (bytes) => stderr.push(bytes),
118+ });
119+ stderr.flush();
120+
121+ if (exitCode !== 0) {
122+ const detail = stderrTail.join('\n');
123+ post({ type: 'error', message: `chain ${config.chainId} failed (exit code ${exitCode})${detail ? `:\n${detail}` : ''}` });
124+ return;
106125 }
126+
127+ const { paramNames, draws } = parseDrawsCsv(new TextDecoder().decode(concatBytes(stdoutChunks)));
128+ post({ type: 'done', paramNames, draws });
129+}
130+
131+self.onmessage = (event: MessageEvent<WorkerRequest>) => {
132+ const { module, config } = event.data;
133+ runChain(module, config).catch((error) => {
134+ post({ type: 'error', message: `chain ${config.chainId} failed: ${error}` });
135+ });
107136 };
src/stan/serverDialog.tsmodified+2−2View file
@@ -22,7 +22,7 @@ export function showServerDialog(container: HTMLElement): void {
2222
2323 const description = document.createElement('p');
2424 description.append(
25- 'Compiling Stan programs to WebAssembly needs a stan-wasm-server; sampling then runs locally in your browser. Run one on your machine with:',
25+ 'Compiling Stan programs to WebAssembly needs a compilation server (stan-wasm-wasi); sampling then runs locally in your browser. The default is a hosted instance — the first compile after an idle period may take ~30 s while it wakes. To run one on your machine instead:',
2626 );
2727 box.appendChild(description);
2828
@@ -33,7 +33,7 @@ export function showServerDialog(container: HTMLElement): void {
3333 box.appendChild(command);
3434
3535 const note = document.createElement('p');
36- note.textContent = 'The server\'s CORS allowlist must include this page\'s origin.';
36+ note.textContent = 'then set the URL to http://localhost:8083.';
3737 box.appendChild(note);
3838
3939 const input = document.createElement('input');
src/stan/settings.tsmodified+7−8View file
@@ -1,17 +1,16 @@
11 // The Stan compilation server: compiling .stan source to WebAssembly needs a
2-// server (stan-playground's stan-wasm-server; see the README). The URL is a
3-// user setting persisted in localStorage, shared by all projects.
2+// server (stan-wasm-wasi; see the README). The URL is a user setting
3+// persisted in localStorage, shared by all projects.
44 //
5-// Note the server's CORS allowlist must include this app's origin. The stock
6-// docker image (ghcr.io/flatironinstitute/stan-wasm-server) allows
7-// http://127.0.0.1:3000 and http://127.0.0.1:4173 — which is why dev/preview
8-// run on those ports.
5+// The default is the hosted instance on fly.io. It allows any origin (CORS),
6+// caches compiled models by source hash, and auto-stops when idle — the
7+// first compile after an idle period pays a ~30 s cold start.
98
109 const SERVER_URL_KEY = 'stan-web-ide.compileServerUrl';
1110
12-export const DEFAULT_SERVER_URL = 'http://localhost:8083';
11+export const DEFAULT_SERVER_URL = 'https://stan-wasm-wasi.fly.dev';
1312 export const LOCAL_SERVER_DOCKER_COMMAND =
14- 'docker run -p 8083:8080 -it ghcr.io/flatironinstitute/stan-wasm-server:latest';
13+ 'docker build -t stan-wasm-wasi https://github.com/magland/stan-wasm-wasi.git && docker run --rm -p 8083:8080 stan-wasm-wasi';
1514
1615 type Listener = (url: string) => void;
1716 const listeners = new Set<Listener>();
src/stan/wasi.tsadded+129−0View file
@@ -0,0 +1,129 @@
1+// Minimal WASI preview1 host for the compiled Stan models: the compile
2+// server (stan-wasm-wasi) produces pure command modules — exported _start
3+// and memory, importing only wasi_snapshot_preview1 — that read argv, write
4+// stdio, and read the clock. No filesystem, no environment, no threads.
5+//
6+// The full import surface of a server-compiled model (verified):
7+// args_get, args_sizes_get, environ_get, environ_sizes_get,
8+// clock_time_get, fd_write, fd_read, fd_close, fd_seek, proc_exit
9+// Anything else the toolchain might add in the future is stubbed to ENOSYS
10+// so it fails with a readable error instead of a link error.
11+
12+export interface WasiRunOptions {
13+ module: WebAssembly.Module;
14+ /** argv, excluding argv[0] (the module name). */
15+ args: string[];
16+ /** Raw bytes written to stdout (fd 1). */
17+ onStdout: (bytes: Uint8Array) => void;
18+ /** Raw bytes written to stderr (fd 2). */
19+ onStderr: (bytes: Uint8Array) => void;
20+}
21+
22+const ERRNO_SUCCESS = 0;
23+const ERRNO_BADF = 8;
24+const ERRNO_NOSYS = 52;
25+const ERRNO_SPIPE = 70;
26+
27+/** Thrown by proc_exit to unwind out of _start. */
28+class ProcExit {
29+ constructor(readonly code: number) {}
30+}
31+
32+/** Instantiates the command module and runs it to completion (this blocks
33+ * the calling thread — run it in a worker). Resolves with the exit code. */
34+export async function runWasiModule({ module, args, onStdout, onStderr }: WasiRunOptions): Promise<number> {
35+ let memory: WebAssembly.Memory;
36+ const view = () => new DataView(memory.buffer);
37+ const mem = () => new Uint8Array(memory.buffer);
38+
39+ const encoder = new TextEncoder();
40+ const argv = ['main.wasm', ...args].map((arg) => encoder.encode(arg + '\0'));
41+
42+ const wasi: Record<string, (...args: never[]) => unknown> = {
43+ args_sizes_get(argcPtr: number, bufSizePtr: number): number {
44+ view().setUint32(argcPtr, argv.length, true);
45+ view().setUint32(bufSizePtr, argv.reduce((size, arg) => size + arg.length, 0), true);
46+ return ERRNO_SUCCESS;
47+ },
48+ args_get(argvPtr: number, bufPtr: number): number {
49+ for (const arg of argv) {
50+ view().setUint32(argvPtr, bufPtr, true);
51+ mem().set(arg, bufPtr);
52+ argvPtr += 4;
53+ bufPtr += arg.length;
54+ }
55+ return ERRNO_SUCCESS;
56+ },
57+ environ_sizes_get(countPtr: number, bufSizePtr: number): number {
58+ view().setUint32(countPtr, 0, true);
59+ view().setUint32(bufSizePtr, 0, true);
60+ return ERRNO_SUCCESS;
61+ },
62+ environ_get(): number {
63+ return ERRNO_SUCCESS;
64+ },
65+ clock_time_get(id: number, _precision: bigint, timePtr: number): number {
66+ // 0 = realtime, 1 = monotonic; nanoseconds as u64
67+ const nanos = id === 0
68+ ? BigInt(Date.now()) * 1_000_000n
69+ : BigInt(Math.round(performance.now() * 1e6));
70+ view().setBigUint64(timePtr, nanos, true);
71+ return ERRNO_SUCCESS;
72+ },
73+ fd_write(fd: number, iovsPtr: number, iovsLen: number, nwrittenPtr: number): number {
74+ if (fd !== 1 && fd !== 2) {
75+ return ERRNO_BADF;
76+ }
77+ let written = 0;
78+ for (let i = 0; i < iovsLen; i++) {
79+ const ptr = view().getUint32(iovsPtr + i * 8, true);
80+ const len = view().getUint32(iovsPtr + i * 8 + 4, true);
81+ if (len > 0) {
82+ // slice (not subarray): the receiver keeps the bytes
83+ (fd === 1 ? onStdout : onStderr)(mem().slice(ptr, ptr + len));
84+ written += len;
85+ }
86+ }
87+ view().setUint32(nwrittenPtr, written, true);
88+ return ERRNO_SUCCESS;
89+ },
90+ fd_read(_fd: number, _iovsPtr: number, _iovsLen: number, nreadPtr: number): number {
91+ view().setUint32(nreadPtr, 0, true); // EOF
92+ return ERRNO_SUCCESS;
93+ },
94+ fd_close(_fd: number): number {
95+ return ERRNO_SUCCESS;
96+ },
97+ fd_seek(_fd: number, _offset: bigint, _whence: number, _newOffsetPtr: number): number {
98+ return ERRNO_SPIPE; // stdio is not seekable
99+ },
100+ proc_exit(code: number): never {
101+ throw new ProcExit(code);
102+ },
103+ };
104+
105+ for (const imported of WebAssembly.Module.imports(module)) {
106+ if (imported.module === 'wasi_snapshot_preview1' && !(imported.name in wasi)) {
107+ const name = imported.name;
108+ wasi[name] = () => {
109+ onStderr(encoder.encode(`wasi: unimplemented syscall ${name}\n`));
110+ return ERRNO_NOSYS;
111+ };
112+ }
113+ }
114+
115+ // modules built with -sALLOW_MEMORY_GROWTH import this one benign
116+ // notification hook; everything else stays pure WASI preview1
117+ const env = { emscripten_notify_memory_growth: (_index: number) => {} };
118+ const instance = await WebAssembly.instantiate(module, { wasi_snapshot_preview1: wasi, env });
119+ memory = instance.exports.memory as WebAssembly.Memory;
120+ try {
121+ (instance.exports._start as () => void)();
122+ return 0;
123+ } catch (error) {
124+ if (error instanceof ProcExit) {
125+ return error.code;
126+ }
127+ throw error;
128+ }
129+}
src/types/plotly.d.tsadded+16−0View file
@@ -0,0 +1,16 @@
1+// Minimal typing for the prebuilt plotly bundle (loaded lazily by the
2+// results dashboard; only the surface we call).
3+declare module 'plotly.js-basic-dist-min' {
4+ interface PlotlyStatic {
5+ newPlot(
6+ root: HTMLElement,
7+ data: unknown[],
8+ layout?: Record<string, unknown>,
9+ config?: Record<string, unknown>,
10+ ): Promise<unknown>;
11+ purge(root: HTMLElement): void;
12+ Plots: { resize(root: HTMLElement): void };
13+ }
14+ const Plotly: PlotlyStatic;
15+ export default Plotly;
16+}
vite.config.tsmodified+6−28View file
@@ -2,43 +2,21 @@ import { fileURLToPath } from 'node:url';
22 import { defineConfig, mergeConfig } from 'vite';
33 import { minwebide } from 'minwebide/vite';
44
5-// Cross-origin isolation makes SharedArrayBuffer available — the compiled
6-// Stan modules are built with pthreads (chains run in parallel threads).
7-// Dev/preview get it from plain response headers — no service worker
8-// involved. Production builds are for GitHub Pages, which can't set headers,
9-// so only there the coi-serviceworker is injected.
10-const coiHeaders = {
11- 'Cross-Origin-Embedder-Policy': 'require-corp',
12- 'Cross-Origin-Opener-Policy': 'same-origin',
13-};
14-
15-const injectCoiServiceWorker = {
16- name: 'inject-coi-serviceworker',
17- apply: 'build' as const,
18- transformIndexHtml() {
19- // relative src so it resolves under the DEPLOY_BASE sub-path
20- return [{ tag: 'script', attrs: { src: 'coi-serviceworker.js' }, injectTo: 'head' as const }];
21- },
22-};
23-
245 // DEPLOY_BASE is set by CI when building for GitHub Pages
256 // (the site is served from /stan-web-ide/, not the domain root).
267 //
27-// Ports: the stock stan-wasm-server docker image only allows the origins
28-// http://127.0.0.1:3000 and http://127.0.0.1:4173 in its CORS config, so
29-// dev runs on 3000 (open the 127.0.0.1 URL, not localhost) and preview on
30-// Vite's default 4173.
8+// No special headers are needed: the compiled Stan models are pure-WASI
9+// modules run single-threaded, one worker per chain — no SharedArrayBuffer,
10+// so no cross-origin isolation. The compile server allows any origin.
3111 export default defineConfig(mergeConfig(minwebide(), {
3212 base: process.env.DEPLOY_BASE ?? '/',
33- plugins: [injectCoiServiceWorker],
3413 resolve: {
3514 alias: {
3615 // stan-language-server imports node's 'path' (join only)
3716 path: fileURLToPath(new URL('./src/stan/pathShim.ts', import.meta.url)),
3817 },
3918 },
40- // host 127.0.0.1 (not 'localhost', which may bind IPv6-only): the page
41- // origin must be exactly http://127.0.0.1:<port> for the server's CORS
42- server: { host: '127.0.0.1', port: 3000, headers: coiHeaders },
43- preview: { host: '127.0.0.1', port: 4173, headers: coiHeaders },
19+ // fixed host/port only so the check scripts know where to look
20+ server: { host: '127.0.0.1', port: 3000 },
21+ preview: { host: '127.0.0.1', port: 4173 },
4422 }));