1import type { CustomEditorProvider, WorkspaceFileSystem } from 'minwebide';
2import { loadRunData, prettifyParamName, type RunData, type RunVariable } from './runData';
3import { dirnameOf } from './sampleConfig';
4import './resultsView.css';
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.
15const MAX_PLOTS = 24;
16const MAX_TRACE_POINTS = 5000;
17const MAX_SCATTER_POINTS = 5000;
18const MAX_DRAWS_ROWS = 1000;
19const RELOAD_DEBOUNCE_MS = 400;
21// one distinguishable color per chain (d3 category10), same in both themes
22const CHAIN_COLORS = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f'];
24let plotlyPromise: Promise<typeof import('plotly.js-basic-dist-min')['default']> | undefined;
25function loadPlotly() {
26 plotlyPromise ??= import('plotly.js-basic-dist-min').then((module) => module.default);
27 return plotlyPromise;
28}
29type PlotlyLib = Awaited<ReturnType<typeof loadPlotly>>;
31export 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;
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);
51 let disposed = false;
52 let data: RunData | undefined;
53 let plotly: PlotlyLib | undefined;
54 const plotDivs = new Set<HTMLElement>();
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>();
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 }
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 }
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 }
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();
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 };
147 // --- tabs ----------------------------------------------------------
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 }
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 }
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 }
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);
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 }
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);
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 }
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 }
317 // --- plot helpers ----------------------------------------------------
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 }
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 }
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 }
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 }
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 }
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}
395function 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}
407function 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}
419function 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}
425function accentColor(div: HTMLElement): string {
426 return getComputedStyle(div).getPropertyValue('--vscode-progressBar-background').trim() || '#0e70c0';
427}
429function 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}
455/** Every stride-th point so trace plots stay responsive on huge runs. */
456function 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}
467function formatValue(value: number): string {
468 return Number.isFinite(value) ? String(Number(value.toPrecision(6))) : String(value);
469}
471function 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}