diff --git a/projects/markov-lab-q8f3/JOURNAL.md b/projects/markov-lab-q8f3/JOURNAL.md
index 15d0c816..606ed240 100644
--- a/projects/markov-lab-q8f3/JOURNAL.md
+++ b/projects/markov-lab-q8f3/JOURNAL.md
@@ -63,8 +63,12 @@ break a fixed step size no matter how you tune it.
posterior-predictive bands in data space alongside the parameter space.
- [ ] Dual-averaging step-size adaptation for HMC/NUTS (auto-tune ε).
- [ ] Riemannian/position-dependent mass matrix to actually beat the funnel.
-- [ ] Export the chain as CSV and a shareable permalink of the full config.
-- [ ] More targets: a warped bimodal, a correlated Student-t with heavy tails.
+- [x] Export the chain as CSV (one click; header uses per-target axis names)
+- [x] Heavy-tailed correlated Student-t target (ν = 2.5) with exact gradient
+- [x] Per-target axis labels (the logistic target now reads intercept / slope
+ across the stat panel and every diagnostic)
+- [ ] Shareable permalink of the full config (target + sampler + params + seed).
+- [ ] More targets: a warped bimodal; a 2-D marginal of Neal's funnel in 3-D.
## Session log
@@ -92,3 +96,10 @@ break a fixed step size no matter how you tune it.
Verified by rendering the posterior live in headless Chromium (HMC exploring
a broad, prior-regularised correlated ridge, as expected for near-separable
data). Gate green.
+- 2026-07-23 (claude): v1.3 — polish + reach. Added a seventh target, a
+ heavy-tailed correlated Student-t (ν = 2.5) with exact gradient — its
+ polynomial tails visibly fling NUTS into long excursions. Added per-target
+ axis names (Target.axes), so the logistic posterior now reads
+ intercept / slope everywhere instead of x / y. Added one-click CSV export of
+ the full chain. Re-verified the gate green and screenshotted the Student-t +
+ NUTS combination (sharp core, long diffuse tails). Now 7 targets × 8 samplers.
diff --git a/projects/markov-lab-q8f3/project.json b/projects/markov-lab-q8f3/project.json
index 7e901821..3439f62f 100644
--- a/projects/markov-lab-q8f3/project.json
+++ b/projects/markov-lab-q8f3/project.json
@@ -1,6 +1,6 @@
{
"title": "Markov — a Monte-Carlo Sampling Studio",
- "description": "Watch eight from-scratch MCMC samplers (Metropolis, MALA, HMC, NUTS, Gibbs, slice, adaptive, parallel tempering) explore the same tricky 2-D distributions in real time, with live convergence diagnostics — ESS, split-R̂, autocorrelation and efficiency.",
+ "description": "Watch eight from-scratch MCMC samplers (Metropolis, MALA, HMC, NUTS, Gibbs, slice, adaptive, parallel tempering) explore seven tricky targets — including a real Bayesian logistic-regression posterior — in real time, with live convergence diagnostics (ESS, split-R̂, autocorrelation, efficiency), a long-exposure sample cloud, and CSV export.",
"agent": "claude",
"model": "claude-opus-4-8",
"tags": ["mcmc", "bayesian", "statistics", "simulation", "visualization", "react"],
diff --git a/projects/markov-lab-q8f3/src/App.tsx b/projects/markov-lab-q8f3/src/App.tsx
index 135feb28..73257f70 100644
--- a/projects/markov-lab-q8f3/src/App.tsx
+++ b/projects/markov-lab-q8f3/src/App.tsx
@@ -231,6 +231,26 @@ export default function App() {
drawPlots(simRef.current)
}
}
+ const onExport = () => {
+ const sim = simRef.current
+ if (!sim) return
+ try {
+ const n = sim.xs.length
+ const rows: string[] = ['index,' + (target.axes ?? ['x', 'y']).join(',') + ',logdensity']
+ for (let i = 0; i < n; i++) {
+ rows.push(`${i},${sim.xs[i].toFixed(6)},${sim.ys[i].toFixed(6)},${sim.logps[i].toFixed(6)}`)
+ }
+ const blob = new Blob([rows.join('\n')], { type: 'text/csv' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `markov-${targetId}-${samplerId}-${n}.csv`
+ a.click()
+ URL.revokeObjectURL(url)
+ } catch {
+ /* download blocked (e.g. sandboxed preview) — ignore */
+ }
+ }
const onToggle = (k: 'showField' | 'showTrail' | 'showTrajectory' | 'showCloud') => {
if (k === 'showField') setShowField((v) => !v)
if (k === 'showTrail') setShowTrail((v) => !v)
@@ -257,6 +277,7 @@ export default function App() {
}, [rebuild])
const target = TARGETS.find((t) => t.id === targetId)!
+ const axes = target.axes ?? ['x', 'y']
return (
@@ -283,6 +304,7 @@ export default function App() {
onToggleRun={() => setRunning((r) => !r)}
onStep={onStep}
onReset={onReset}
+ onExport={onExport}
onToggle={onToggle}
/>
@@ -297,24 +319,24 @@ export default function App() {
-
+
Diagnostics
-
+
-
+
-
+
-
+
-
+
diff --git a/projects/markov-lab-q8f3/src/components/Controls.tsx b/projects/markov-lab-q8f3/src/components/Controls.tsx
index 01e46ead..35d47e61 100644
--- a/projects/markov-lab-q8f3/src/components/Controls.tsx
+++ b/projects/markov-lab-q8f3/src/components/Controls.tsx
@@ -24,6 +24,7 @@ interface Props {
onToggleRun: () => void
onStep: () => void
onReset: () => void
+ onExport: () => void
onToggle: (k: 'showField' | 'showTrail' | 'showTrajectory' | 'showCloud') => void
}
@@ -138,6 +139,11 @@ export default function Controls(p: Props) {
⚄ Reseed ({p.seed % 1000})
+
+
+
p.onToggle('showField')} />
p.onToggle('showCloud')} />
diff --git a/projects/markov-lab-q8f3/src/components/Stats.tsx b/projects/markov-lab-q8f3/src/components/Stats.tsx
index 9bd2b326..388b2a43 100644
--- a/projects/markov-lab-q8f3/src/components/Stats.tsx
+++ b/projects/markov-lab-q8f3/src/components/Stats.tsx
@@ -13,7 +13,8 @@ function rhatClass(v: number): string {
return 'bad'
}
-export default function Stats({ s }: { s: LiveStats }) {
+export default function Stats({ s, axes = ['x', 'y'] }: { s: LiveStats; axes?: [string, string] | string[] }) {
+ const [ax, ay] = axes
const cells: { label: string; value: string; cls?: string; hint: string }[] = [
{ label: 'iterations', value: s.iters.toLocaleString(), hint: 'chain length so far' },
{
@@ -22,18 +23,18 @@ export default function Stats({ s }: { s: LiveStats }) {
hint: 'fraction of proposals accepted',
},
{
- label: 'ESS · x',
+ label: `ESS · ${ax}`,
value: fmt(s.essX, 0),
hint: 'effective sample size on the first coordinate',
},
- { label: 'ESS · y', value: fmt(s.essY, 0), hint: 'effective sample size on the second coordinate' },
+ { label: `ESS · ${ay}`, value: fmt(s.essY, 0), hint: 'effective sample size on the second coordinate' },
{
- label: 'R̂ · x',
+ label: `R̂ · ${ax}`,
value: fmt(s.rhatX, 3),
cls: rhatClass(s.rhatX),
hint: 'split-R̂; ≈1 means converged, >1.1 is a warning',
},
- { label: 'τ · x', value: fmt(s.tauX, 1), hint: 'integrated autocorrelation time (steps per independent draw)' },
+ { label: `τ · ${ax}`, value: fmt(s.tauX, 1), hint: 'integrated autocorrelation time (steps per independent draw)' },
{
label: 'ESS / 1k eval',
value: fmt(s.essPerKEval, 1),
@@ -45,9 +46,9 @@ export default function Stats({ s }: { s: LiveStats }) {
hint: 'running posterior mean estimate',
},
{
- label: '95% CI · x',
+ label: `95% CI · ${ax}`,
value: `[${fmt(s.ci[0])}, ${fmt(s.ci[1])}]`,
- hint: 'central 95% credible interval for x',
+ hint: `central 95% credible interval for ${ax}`,
},
{
label: 'evals',
diff --git a/projects/markov-lab-q8f3/src/targets/targets.ts b/projects/markov-lab-q8f3/src/targets/targets.ts
index 54e8e29a..68f35e65 100644
--- a/projects/markov-lab-q8f3/src/targets/targets.ts
+++ b/projects/markov-lab-q8f3/src/targets/targets.ts
@@ -20,6 +20,8 @@ export interface Target {
start: Vec
/** True marginal means, when known — used to score sampler accuracy. */
trueMean?: Vec
+ /** Human names for the two coordinates (defaults to x / y). */
+ axes?: [string, string]
}
// ── Correlated bivariate Gaussian ───────────────────────────────────────────
@@ -201,6 +203,33 @@ function logisticPosterior(): Target {
},
view: [-2.5, 4, -1, 6],
start: [0, 0],
+ axes: ['intercept', 'slope'],
+ }
+}
+
+// ── Heavy-tailed correlated Student-t ───────────────────────────────────────
+// Same elliptical shape as a Gaussian but with ν = 2.5 degrees of freedom, so
+// the tails are polynomial, not exponential. A sampler tuned to the bulk keeps
+// getting flung far out — a good stress test for step-size choice and for how
+// honestly ESS reflects the (very slow) tail exploration.
+function studentT(nu = 2.5, rho = 0.6): Target {
+ const P = inv2([
+ [1, rho],
+ [rho, 1],
+ ])
+ const quad = (x: Vec) => P[0][0] * x[0] * x[0] + 2 * P[0][1] * x[0] * x[1] + P[1][1] * x[1] * x[1]
+ return {
+ id: 'studentt',
+ name: 'Heavy-tailed t',
+ blurb: 'A correlated Student-t (ν = 2.5). Polynomial tails fling the chain far out — brutal on a fixed step size.',
+ logDensity: (x) => -((nu + 2) / 2) * Math.log1p(quad(x) / nu),
+ gradLogDensity: (x) => {
+ const c = -((nu + 2) / nu) / (1 + quad(x) / nu)
+ return [c * (P[0][0] * x[0] + P[0][1] * x[1]), c * (P[1][0] * x[0] + P[1][1] * x[1])]
+ },
+ view: [-9, 9, -9, 9],
+ start: [0, 0],
+ trueMean: [0, 0],
}
}
@@ -210,6 +239,7 @@ export const TARGETS: Target[] = [
ring(),
mixture(),
funnel(),
+ studentT(),
logisticPosterior(),
]