diff --git a/docs/book.toml b/docs/book.toml index c9ac192..8aa3d0d 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -10,11 +10,15 @@ text-direction = "ltr" create-missing = true [output.html] -default-theme = "dark" +# "navy" is mdbook's dark theme; the previous value "dark" is not a real +# mdbook theme name, so light-OS visitors silently got the light CSS fallback +# while the html class still claimed dark. +default-theme = "navy" +preferred-dark-theme = "navy" git-repository-url = "https://github.com/alexnodeland/fugue" site-url = "/fugue/" -additional-css = ["./mdbook-admonish.css"] -additional-js = ["mermaid.min.js", "mermaid-init.js"] +additional-css = ["./mdbook-admonish.css", "fugue-viz.css"] +additional-js = ["mermaid.min.js", "mermaid-init.js", "fugue-viz.js", "viz/anatomy.js", "viz/monad.js", "viz/metropolis.js", "viz/hmc.js", "viz/smc.js", "viz/distributions.js", "viz/inline.js", "viz/minis.js"] [output.html.fold] enable = true diff --git a/docs/fugue-viz.css b/docs/fugue-viz.css new file mode 100644 index 0000000..add8ffb --- /dev/null +++ b/docs/fugue-viz.css @@ -0,0 +1,403 @@ +/* + * fugue-viz.css — styling for the Fugue Explorables shared infrastructure. + * + * Defines the semantic color algebra (blue x yellow = green) as CSS custom + * properties, theme-switched via mdbook's theme class: + * light family -> html.light, html.rust + * dark family -> html.coal, html.navy, html.ayu, html.dark, and ALSO the + * default (no theme class), since the book default is dark. + * The dark palette is the :root default so the absent-class case is dark. + */ + +:root { + --fv-prior: #58a6ff; + --fv-data: #f2cc60; + --fv-post: #56d364; + --fv-hot: #ff7b72; + --fv-flow: #bc8cff; + --fv-ink: rgba(230, 237, 243, 0.9); + --fv-grid: rgba(230, 237, 243, 0.08); + --fv-panel: rgba(110, 118, 129, 0.08); +} + +/* Light theme families */ +html.light, +html.rust { + --fv-prior: #0969da; + --fv-data: #9a6700; + --fv-post: #1a7f37; + --fv-hot: #cf222e; + --fv-flow: #8250df; + --fv-ink: rgba(31, 35, 40, 0.9); + --fv-grid: rgba(31, 35, 40, 0.08); + --fv-panel: rgba(175, 184, 193, 0.12); +} + +/* Explicit dark families (redundant with :root default, but future-proof) */ +html.coal, +html.navy, +html.ayu, +html.dark { + --fv-prior: #58a6ff; + --fv-data: #f2cc60; + --fv-post: #56d364; + --fv-hot: #ff7b72; + --fv-flow: #bc8cff; + --fv-ink: rgba(230, 237, 243, 0.9); + --fv-grid: rgba(230, 237, 243, 0.08); + --fv-panel: rgba(110, 118, 129, 0.08); +} + +/* ---- The widget panel (full-width) ---------------------------------------- */ + +.fugue-explorable { + border: 1px solid var(--fv-grid); + border-radius: 8px; + background: var(--fv-panel); + padding: 12px 14px; + margin: 1.4rem 0; + font-size: 0.8rem; +} + +.fv-canvas { + display: block; + width: 100%; + border-radius: 6px; + /* Default: allow the page to scroll when a thumb swipes the canvas. Ambient + micros keep this so they never eat scroll. A widget that owns the whole + canvas for interaction opts into full gesture capture via .fv-touch-none + (added automatically by FugueViz.drag with its default fullCapture). */ + touch-action: pan-y; +} + +/* Belt-and-braces: any canvas inside an explorable defaults to pan-y even if a + widget forgot the .fv-canvas class. */ +.fugue-explorable canvas { + touch-action: pan-y; +} + +/* Full-capture opt-in: the whole canvas is interactive, so no gesture scrolls + the page (FugueViz.drag adds/removes this class around a fullCapture drag). */ +.fv-canvas.fv-touch-none, +.fugue-explorable canvas.fv-touch-none { + touch-action: none; +} + +/* Cursor while a FugueViz.drag grab is active (paired with an on-canvas halo). */ +.fv-canvas.fv-grabbing, +.fugue-explorable canvas.fv-grabbing { + cursor: grabbing; +} + +/* ---- Controls row --------------------------------------------------------- */ + +.fv-controls { + display: flex; + flex-flow: row wrap; + gap: 10px; + align-items: flex-end; + margin-bottom: 10px; +} + +.fv-control { + display: inline-flex; + flex-direction: column; + gap: 3px; + font-size: 0.8rem; +} + +.fv-control-label { + text-transform: uppercase; + letter-spacing: 0.08em; + opacity: 0.65; + font-size: 0.68rem; +} + +.fv-control-value { + font-family: var(--mono-font, "Source Code Pro", monospace); + font-variant-numeric: tabular-nums; + opacity: 0.9; +} + +.fv-range { + width: 140px; + accent-color: var(--fv-prior); + cursor: pointer; +} + +.fv-range:focus-visible { + outline: 2px solid var(--fv-prior); + outline-offset: 2px; +} + +/* Toggle */ +.fv-toggle { + flex-direction: row; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.fv-checkbox { + accent-color: var(--fv-post); + cursor: pointer; +} + +/* Buttons */ +.fv-buttons { + display: inline-flex; + gap: 6px; + align-items: center; +} + +.fv-btn { + font-size: 0.75rem; + padding: 4px 10px; + border-radius: 6px; + border: 1px solid var(--fv-grid); + background: transparent; + color: var(--fv-ink); + cursor: pointer; + font-family: inherit; + line-height: 1.4; +} + +.fv-btn:hover { + border-color: var(--fv-prior); +} + +.fv-btn:focus-visible { + outline: 2px solid var(--fv-prior); + outline-offset: 2px; +} + +.fv-btn.fv-primary { + background: var(--fv-prior); + border-color: var(--fv-prior); + color: #0d1117; + font-weight: 600; +} + +html.light .fv-btn.fv-primary, +html.rust .fv-btn.fv-primary { + color: #ffffff; +} + +/* ---- Readouts row --------------------------------------------------------- */ + +.fv-readouts { + display: flex; + flex-flow: row wrap; + gap: 16px; + margin-top: 10px; + align-items: baseline; +} + +.fv-readout { + display: inline-flex; + flex-direction: column; + gap: 1px; +} + +.fv-readout-label { + text-transform: uppercase; + letter-spacing: 0.08em; + opacity: 0.65; + font-size: 0.66rem; +} + +.fv-readout-value { + font-family: var(--mono-font, "Source Code Pro", monospace); + font-variant-numeric: tabular-nums; + font-size: 0.95rem; +} + +/* ---- The Victor scrub number (in prose) ----------------------------------- */ + +.fv-scrub { + border-bottom: 1px dashed var(--fv-prior); + cursor: ew-resize; + font-family: var(--mono-font, "Source Code Pro", monospace); + font-variant-numeric: tabular-nums; + color: var(--fv-prior); + user-select: none; + -webkit-user-select: none; + padding: 0 1px; + white-space: nowrap; + /* A horizontal thumb-drag scrubs the number; never let it scroll the page. */ + touch-action: none; +} + +.fv-scrub:focus-visible { + outline: 2px solid var(--fv-prior); + outline-offset: 2px; +} + +.fv-scrub-active { + color: var(--fv-hot); + border-bottom-color: var(--fv-hot); +} + +/* ---- Inline prose color classes (the color algebra in text) --------------- */ + +.fv-c-prior { color: var(--fv-prior); } +.fv-c-data { color: var(--fv-data); } +.fv-c-post { color: var(--fv-post); } +.fv-c-hot { color: var(--fv-hot); } +.fv-c-flow { color: var(--fv-flow); } + +/* ---- The "try this" hint -------------------------------------------------- */ + +.fv-hint { + font-style: italic; + opacity: 0.7; + font-size: 0.78rem; + margin-top: 8px; +} + +.fv-hint::before { + content: "try: "; + font-style: normal; + text-transform: uppercase; + letter-spacing: 0.08em; + opacity: 0.7; + font-size: 0.66rem; +} + +/* Canvas instruction line (drag/paint affordances) */ +.fv-instruction { + font-size: 0.72rem; + opacity: 0.6; + margin-top: 4px; + text-align: center; +} + +/* ---- Reduced motion ------------------------------------------------------- */ + +@media (prefers-reduced-motion: reduce) { + .fugue-explorable * { + transition: none !important; + animation: none !important; + } +} + +/* ---- The micro-widget family (docs/viz/inline.js) ------------------------- */ +/* Ambient inline figures embeddable on any page. Tighter chrome than the + full-width hero explorables; a single pause/play glyph; an optional caption. */ + +.fv-inline { + position: relative; /* anchor the pause/play glyph */ + padding: 8px 10px; + border-radius: 6px; + margin: 1.1rem 0; +} + +.fv-inline .fv-canvas { + border-radius: 5px; +} + +/* The single unobtrusive pause/play glyph, top-right of the panel. */ +.fv-glyph { + position: absolute; + top: 6px; + right: 8px; + z-index: 2; + width: 20px; + height: 20px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--fv-ink); + opacity: 0.35; + cursor: pointer; + font-size: 0.72rem; + line-height: 1; + font-family: inherit; + transition: opacity 0.15s ease; +} + +.fv-glyph:hover { + opacity: 1; +} + +.fv-glyph:focus-visible { + opacity: 1; + outline: 2px solid var(--fv-prior); + outline-offset: 2px; + border-radius: 4px; +} + +/* Caption rendered under a micro-widget's canvas (data-caption). */ +.fv-caption { + font-style: italic; + opacity: 0.7; + font-size: 0.76rem; + margin-top: 6px; + line-height: 1.4; +} + +/* ---- Coarse-pointer (touch) sizing bumps ---------------------------------- */ +/* On touch devices grow the tap targets without changing their layout on + mouse/trackpad. Native range inputs get a taller hit box; buttons, checkbox, + glyph and scrub all reach the ~44px comfortable-touch neighbourhood. */ +@media (pointer: coarse) { + .fv-range { + height: 30px; + width: 160px; + } + .fv-btn { + padding: 8px 14px; + font-size: 0.8rem; + line-height: 1.5; + } + .fv-checkbox { + width: 20px; + height: 20px; + } + .fv-toggle { + gap: 8px; + } + /* Enlarge the scrub's tap target vertically without shifting the text. */ + .fv-scrub { + padding: 3px 4px; + } + .fv-glyph { + width: 30px; + height: 30px; + opacity: 0.5; + font-size: 0.85rem; + } +} + +/* ---- Phone-width layout (~390px and the 320px floor) ---------------------- */ +/* Controls and readouts already wrap (flex-wrap); here we tighten padding and + shrink the range width so nothing overflows the panel at narrow widths. */ +@media (max-width: 420px) { + .fugue-explorable { + padding: 10px 10px; + } + .fv-controls { + gap: 8px 12px; + } + .fv-readouts { + gap: 10px 14px; + } + .fv-range { + width: 130px; + } +} + +@media (max-width: 340px) { + /* Last resort at the 320px floor: let a range take the full row so it never + pushes the panel wider than the viewport. */ + .fv-control { + flex: 1 1 100%; + } + .fv-range { + width: 100%; + min-width: 0; + } +} diff --git a/docs/fugue-viz.js b/docs/fugue-viz.js new file mode 100644 index 0000000..733a876 --- /dev/null +++ b/docs/fugue-viz.js @@ -0,0 +1,1456 @@ +/* + * fugue-viz.js — shared infrastructure for the Fugue Explorables. + * + * Attaches a single global `window.FugueViz`. ES5-compatible IIFE: no modules, + * no build step, no external dependencies, works from file://. Widget scripts + * (docs/viz/*.js) call FugueViz.register("name", fn) and consume this API; they + * MUST NOT duplicate what lives here. + * + * The distribution math mirrors fugue's src/core/distribution.rs EXACTLY + * (parameterizations, support, boundary limits). See the RETURN contract in the + * foundation agent's report for the full per-distribution parameter list. + */ +(function () { + "use strict"; + + if (typeof window !== "undefined" && window.FugueViz) { + return; // already loaded (guard against double-inclusion) + } + + // ========================================================================== + // Special functions + // ========================================================================== + + // Lanczos approximation to ln Γ(x) (g = 7, n = 9). Matches libm::lgamma to + // ~1e-13 relative over the range the explorables use. Reflection for x < 0.5. + var LANCZOS_G = 7; + var LANCZOS_C = [ + 0.99999999999980993, + 676.5203681218851, + -1259.1392167224028, + 771.32342877765313, + -176.61502916214059, + 12.507343278686905, + -0.13857109526572012, + 9.9843695780195716e-6, + 1.5056327351493116e-7 + ]; + var LN_2PI = 1.8378770664093454589; // ln(2π) + var LN_PI = 1.1447298858494001742; // ln(π) + var LN_2 = 0.6931471805599453094; // ln(2) + + function lgamma(x) { + if (x !== x) return NaN; + if (x < 0.5) { + // Reflection: Γ(x)Γ(1-x) = π / sin(πx) + var sinpix = Math.sin(Math.PI * x); + if (sinpix === 0) return Infinity; + return LN_PI - Math.log(Math.abs(sinpix)) - lgamma(1.0 - x); + } + x -= 1.0; + var a = LANCZOS_C[0]; + var t = x + LANCZOS_G + 0.5; + for (var i = 1; i < LANCZOS_G + 2; i++) { + a += LANCZOS_C[i] / (x + i); + } + return 0.5 * LN_2PI + (x + 0.5) * Math.log(t) - t + Math.log(a); + } + + function logsumexp(arr) { + var n = arr.length; + if (n === 0) return -Infinity; + var m = -Infinity; + for (var i = 0; i < n; i++) { + if (arr[i] > m) m = arr[i]; + } + if (m === -Infinity) return -Infinity; + if (m === Infinity) return Infinity; + var s = 0.0; + for (var j = 0; j < n; j++) { + s += Math.exp(arr[j] - m); + } + return m + Math.log(s); + } + + // log(1 + x), accurate for small x (Math.log1p may be absent under ES5). + function log1p(x) { + if (Math.log1p) return Math.log1p(x); + if (x <= -1) return x === -1 ? -Infinity : NaN; + var u = 1 + x; + if (u === 1) return x; + return Math.log(u) * (x / (u - 1)); + } + + // ========================================================================== + // Deterministic RNG (reproducibility is a fugue value) + // ========================================================================== + + // mulberry32: seed (uint32) -> function returning float in [0, 1). + function rng(seed) { + var a = seed >>> 0; + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + var t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + + // Standard normal via Box–Muller, consuming an rng fn. No caching, so a given + // rng stream deterministically yields the same normal stream. + function randn(rand) { + var u1 = rand(); + var u2 = rand(); + if (u1 < 1e-300) u1 = 1e-300; + return Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2); + } + + // Marsaglia–Tsang gamma with unit rate (shape > 0). Used by gamma/beta/ + // chi-squared/student-t/inverse-gamma samplers. + function gammaStd(rand, shape) { + if (shape < 1.0) { + var u = rand(); + if (u < 1e-300) u = 1e-300; + return gammaStd(rand, shape + 1.0) * Math.pow(u, 1.0 / shape); + } + var d = shape - 1.0 / 3.0; + var c = 1.0 / Math.sqrt(9.0 * d); + for (;;) { + var x, v; + do { + x = randn(rand); + v = 1.0 + c * x; + } while (v <= 0.0); + v = v * v * v; + var uu = rand(); + var x2 = x * x; + if (uu < 1.0 - 0.0331 * x2 * x2) return d * v; + if (Math.log(uu) < 0.5 * x2 + d * (1.0 - v + Math.log(v))) return d * v; + } + } + + // ========================================================================== + // Distribution math — parameterizations match fugue exactly. + // logpdf/logpmf are pure log-space (no exp), finite for every finite input. + // ========================================================================== + + var dist = { + // Normal(mu, sigma): sigma is the standard deviation (> 0). Returns f64. + normal: { + logpdf: function (x, mu, sigma) { + if (!(sigma > 0) || !isFinite(sigma) || !isFinite(mu) || !isFinite(x)) return -Infinity; + var z = (x - mu) / sigma; + return -0.5 * z * z - Math.log(sigma) - 0.5 * LN_2PI; + }, + sample: function (rand, mu, sigma) { + return mu + sigma * randn(rand); + } + }, + + // Uniform(low, high): support [low, high). Returns f64. + uniform: { + logpdf: function (x, low, high) { + if (!(low < high) || !isFinite(low) || !isFinite(high) || !isFinite(x)) return -Infinity; + if (x < low || x >= high) return -Infinity; + return -Math.log(high - low); + }, + sample: function (rand, low, high) { + return low + rand() * (high - low); + } + }, + + // LogNormal(mu, sigma): mu, sigma are the mean/sd of ln(X). Support (0, ∞). + lognormal: { + logpdf: function (x, mu, sigma) { + if (!(sigma > 0) || !isFinite(sigma) || !isFinite(mu)) return -Infinity; + if (!(x > 0) || !isFinite(x)) return -Infinity; + var lx = Math.log(x); + var z = (lx - mu) / sigma; + return -0.5 * z * z - lx - Math.log(sigma) - 0.5 * LN_2PI; + }, + sample: function (rand, mu, sigma) { + return Math.exp(mu + sigma * randn(rand)); + } + }, + + // Exponential(rate): rate = λ (> 0). Support [0, ∞). Mean 1/λ. Returns f64. + exponential: { + logpdf: function (x, rate) { + if (!(rate > 0) || !isFinite(rate) || !isFinite(x)) return -Infinity; + if (x < 0) return -Infinity; + return Math.log(rate) - rate * x; + }, + sample: function (rand, rate) { + var u = rand(); + if (u < 1e-300) u = 1e-300; + return -Math.log(u) / rate; + } + }, + + // Bernoulli(p): logpmf takes a boolean OR 0/1. sample returns a boolean. + bernoulli: { + logpmf: function (k, p) { + if (!(p >= 0) || !(p <= 1) || !isFinite(p)) return -Infinity; + var t = k === true || k === 1; + if (t) return p <= 0 ? -Infinity : Math.log(p); + return p >= 1 ? -Infinity : Math.log(1 - p); + }, + sample: function (rand, p) { + return rand() < p; + } + }, + + // Categorical(ps): support {0..k-1}. logpmf(index, ps). sample returns usize. + categorical: { + logpmf: function (k, ps) { + if (k < 0 || k >= ps.length) return -Infinity; + var p = ps[k]; + return p > 0 ? Math.log(p) : -Infinity; + }, + sample: function (rand, ps) { + var u = rand(); + var acc = 0.0; + for (var i = 0; i < ps.length; i++) { + acc += ps[i]; + if (u < acc) return i; + } + return ps.length - 1; + } + }, + + // Beta(a, b): shape params (> 0). Support [0, 1]. logpdf matches scipy + // boundary limits (±∞ at the edges depending on the shape). + beta: { + logpdf: function (x, a, b) { + if (!(a > 0) || !(b > 0) || !isFinite(a) || !isFinite(b) || !isFinite(x)) return -Infinity; + if (x < 0 || x > 1) return -Infinity; + var logB = lgamma(a) + lgamma(b) - lgamma(a + b); + if (x === 0) { + if (a > 1) return -Infinity; + if (a < 1) return Infinity; + return -logB; // a == 1 -> ln(b) + } + if (x === 1) { + if (b > 1) return -Infinity; + if (b < 1) return Infinity; + return -logB; // b == 1 -> ln(a) + } + return (a - 1) * Math.log(x) + (b - 1) * Math.log(1 - x) - logB; + }, + sample: function (rand, a, b) { + var ga = gammaStd(rand, a); + var gb = gammaStd(rand, b); + var s = ga + gb; + return s > 0 ? ga / s : 0.5; + } + }, + + // Gamma(shape, rate): fugue is RATE-parameterized (2nd arg = λ, NOT scale). + // Mean = shape/rate. Support (0, ∞). Returns f64. + gamma: { + logpdf: function (x, shape, rate) { + if (!(shape > 0) || !(rate > 0) || !isFinite(shape) || !isFinite(rate) || !isFinite(x)) return -Infinity; + if (x <= 0) return -Infinity; + return shape * Math.log(rate) + (shape - 1) * Math.log(x) - rate * x - lgamma(shape); + }, + sample: function (rand, shape, rate) { + return gammaStd(rand, shape) / rate; + } + }, + + // Binomial(n, p): support {0..n}. logpmf(k, n, p). sample returns u64. + binomial: { + logpmf: function (k, n, p) { + if (!(p >= 0) || !(p <= 1) || !isFinite(p)) return -Infinity; + if (k < 0 || k > n) return -Infinity; + if (p === 0) return k === 0 ? 0.0 : -Infinity; + if (p === 1) return k === n ? 0.0 : -Infinity; + var logC = lgamma(n + 1) - lgamma(k + 1) - lgamma(n - k + 1); + return logC + k * Math.log(p) + (n - k) * Math.log(1 - p); + }, + sample: function (rand, n, p) { + var c = 0; + for (var i = 0; i < n; i++) { + if (rand() < p) c++; + } + return c; + } + }, + + // Poisson(lambda): rate λ (> 0). Support {0,1,...}. logpmf(k, lambda). + // sample returns u64 (Knuth; adequate for the widgets' modest λ). + poisson: { + logpmf: function (k, lambda) { + if (!(lambda > 0) || !isFinite(lambda)) return -Infinity; + if (k < 0) return -Infinity; + if (lambda > 700 && k === 0) return -lambda; + return k * Math.log(lambda) - lambda - lgamma(k + 1); + }, + sample: function (rand, lambda) { + if (lambda < 30) { + var L = Math.exp(-lambda); + var k = 0; + var pp = 1.0; + do { + k++; + pp *= rand(); + } while (pp > L); + return k - 1; + } + // Normal approximation for large λ (rounded, clamped ≥ 0). + var g = Math.round(lambda + Math.sqrt(lambda) * randn(rand)); + return g < 0 ? 0 : g; + } + }, + + // StudentT(df, loc, scale): ν = df (> 0), location-scale. Support ℝ. + studentt: { + logpdf: function (x, df, loc, scale) { + if (!(df > 0) || !(scale > 0) || !isFinite(df) || !isFinite(scale) || !isFinite(loc) || !isFinite(x)) return -Infinity; + var z = (x - loc) / scale; + return lgamma((df + 1) / 2) - lgamma(df / 2) - 0.5 * (Math.log(df) + LN_PI) - Math.log(scale) - 0.5 * (df + 1) * log1p((z * z) / df); + }, + sample: function (rand, df, loc, scale) { + var z = randn(rand); + var g = gammaStd(rand, df / 2) * 2.0; // chi-squared(df) + return loc + scale * (z / Math.sqrt(g / df)); + } + }, + + // Cauchy(loc, scale): median loc, half-width scale (> 0). Support ℝ. + cauchy: { + logpdf: function (x, loc, scale) { + if (!(scale > 0) || !isFinite(scale) || !isFinite(loc) || !isFinite(x)) return -Infinity; + var z = (x - loc) / scale; + return -LN_PI - Math.log(scale) - log1p(z * z); + }, + sample: function (rand, loc, scale) { + return loc + scale * Math.tan(Math.PI * (rand() - 0.5)); + } + }, + + // Laplace(loc, scale): mean loc, scale b (> 0). Support ℝ. Variance 2b². + laplace: { + logpdf: function (x, loc, scale) { + if (!(scale > 0) || !isFinite(scale) || !isFinite(loc) || !isFinite(x)) return -Infinity; + return -Math.log(2 * scale) - Math.abs(x - loc) / scale; + }, + sample: function (rand, loc, scale) { + var u = rand() - 0.5; + var s = u < 0 ? -1 : u > 0 ? 1 : 0; + return loc - scale * s * Math.log(1 - 2 * Math.abs(u)); + } + }, + + // Weibull(shape, scale): k = shape (> 0), λ = scale (> 0). Support [0, ∞). + weibull: { + logpdf: function (x, shape, scale) { + if (!(shape > 0) || !(scale > 0) || !isFinite(shape) || !isFinite(scale) || !isFinite(x)) return -Infinity; + if (x < 0) return -Infinity; + if (x === 0) { + if (shape > 1) return -Infinity; + if (shape < 1) return Infinity; + return -Math.log(scale); + } + return Math.log(shape) - shape * Math.log(scale) + (shape - 1) * Math.log(x) - Math.pow(x / scale, shape); + }, + sample: function (rand, shape, scale) { + var u = rand(); + if (u < 1e-300) u = 1e-300; + return scale * Math.pow(-Math.log(u), 1.0 / shape); + } + }, + + // ChiSquared(k): df k (> 0). Special case Gamma(k/2, rate 1/2). Support (0,∞). + chisquared: { + logpdf: function (x, k) { + if (!(k > 0) || !isFinite(k) || !isFinite(x)) return -Infinity; + if (x <= 0) return -Infinity; + var hk = k / 2; + return -hk * LN_2 - lgamma(hk) + (hk - 1) * Math.log(x) - x / 2; + }, + sample: function (rand, k) { + return gammaStd(rand, k / 2) * 2.0; + } + }, + + // InverseGamma(shape, rate): α = shape, β = rate (> 0). 1/X ~ Gamma(α, β). + // Support (0, ∞). Matches scipy invgamma(a=α, scale=β). + inversegamma: { + logpdf: function (x, shape, rate) { + if (!(shape > 0) || !(rate > 0) || !isFinite(shape) || !isFinite(rate) || !isFinite(x)) return -Infinity; + if (x <= 0) return -Infinity; + return shape * Math.log(rate) - lgamma(shape) - (shape + 1) * Math.log(x) - rate / x; + }, + sample: function (rand, shape, rate) { + return 1.0 / (gammaStd(rand, shape) / rate); + } + }, + + // DiscreteUniform(low, high): inclusive integer range [low, high]. i64. + discreteuniform: { + logpmf: function (k, low, high) { + if (high < low) return -Infinity; + if (k < low || k > high) return -Infinity; + return -Math.log(high - low + 1); + }, + sample: function (rand, low, high) { + return low + Math.floor(rand() * (high - low + 1)); + } + } + }; + + // ========================================================================== + // Theming + // ========================================================================== + + var LIGHT_THEMES = { light: 1, rust: 1 }; + + var DARK_COLORS = { + prior: "#58A6FF", + data: "#F2CC60", + post: "#56D364", + hot: "#FF7B72", + flow: "#BC8CFF", + ink: "rgba(230,237,243,0.9)", + grid: "rgba(230,237,243,0.08)", + panel: "rgba(110,118,129,0.08)" + }; + var LIGHT_COLORS = { + prior: "#0969DA", + data: "#9A6700", + post: "#1A7F37", + hot: "#CF222E", + flow: "#8250DF", + ink: "rgba(31,35,40,0.9)", + grid: "rgba(31,35,40,0.08)", + panel: "rgba(175,184,193,0.12)" + }; + + function isDark() { + if (typeof document === "undefined") return true; + // Trust the page's actual rendered ground, not the theme class name: + // mdbook stamps whatever default-theme names, valid or not (a bogus name + // falls back to light CSS while the class still says otherwise), so class + // whitelists mislabel exactly the broken case. Luminance can't. + try { + // mdbook's own ground token; hex like #ffffff (light) / #161923 (navy). + var bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim(); + var m = bg.match(/^#([0-9a-f]{6})$/i); + if (m) { + var n = parseInt(m[1], 16); + var lum = 0.2126 * (n >> 16 & 255) + 0.7152 * (n >> 8 & 255) + 0.0722 * (n & 255); + return lum < 128; + } + m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (m) { + return 0.2126 * m[1] + 0.7152 * m[2] + 0.0722 * m[3] < 128; + } + m = bg.match(/hsla?\(\s*[\d.]+\s*,\s*[\d.]+%\s*,\s*([\d.]+)%/); + if (m) { + return parseFloat(m[1]) < 50; + } + } catch (e) { /* fall through to class heuristic */ } + var cls = document.documentElement.className || ""; + var names = cls.split(/\s+/); + for (var i = 0; i < names.length; i++) { + if (LIGHT_THEMES[names[i]]) return false; + } + return true; + } + + function readColor(name, fallback) { + if (typeof getComputedStyle === "undefined") return fallback; + try { + var v = getComputedStyle(document.documentElement).getPropertyValue("--fv-" + name); + v = v && v.trim(); + return v || fallback; + } catch (e) { + return fallback; + } + } + + function theme() { + var dark = isDark(); + var base = dark ? DARK_COLORS : LIGHT_COLORS; + return { + dark: dark, + colors: { + prior: readColor("prior", base.prior), + data: readColor("data", base.data), + post: readColor("post", base.post), + hot: readColor("hot", base.hot), + flow: readColor("flow", base.flow), + ink: readColor("ink", base.ink), + grid: readColor("grid", base.grid), + panel: readColor("panel", base.panel) + } + }; + } + + var themeListeners = []; + var themeObserver = null; + function onThemeChange(fn) { + themeListeners.push(fn); + if (!themeObserver && typeof MutationObserver !== "undefined" && typeof document !== "undefined") { + var last = isDark(); + themeObserver = new MutationObserver(function () { + var now = isDark(); + if (now !== last) { + last = now; + var t = theme(); + for (var i = 0; i < themeListeners.length; i++) { + try { + themeListeners[i](t); + } catch (e) {} + } + } + }); + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); + } + } + + // ========================================================================== + // Canvas scaffolding + // ========================================================================== + + function canvas(parentEl, opts) { + opts = opts || {}; + var height = opts.height || 300; + var el = document.createElement("canvas"); + el.className = "fv-canvas"; + el.style.display = "block"; + el.style.width = "100%"; + el.style.height = height + "px"; + parentEl.appendChild(el); + var ctx = el.getContext("2d"); + + var api = { ctx: ctx, el: el, w: 0, h: 0, dpr: 1, clear: clear }; + + function resize() { + var dpr = window.devicePixelRatio || 1; + var rect = el.getBoundingClientRect(); + var cssW = Math.max(1, rect.width || parentEl.clientWidth || 300); + var cssH = height; + el.width = Math.round(cssW * dpr); + el.height = Math.round(cssH * dpr); + api.w = cssW; + api.h = cssH; + api.dpr = dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + if (opts.onResize) opts.onResize(api); + } + + function clear() { + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, el.width, el.height); + ctx.setTransform(api.dpr, 0, 0, api.dpr, 0, 0); + } + + resize(); + if (typeof ResizeObserver !== "undefined") { + var ro = new ResizeObserver(function () { + resize(); + }); + ro.observe(parentEl); + api._ro = ro; + } else if (typeof window !== "undefined") { + window.addEventListener("resize", resize); + } + return api; + } + + // Linear scale: maps domain [d0,d1] -> range [r0,r1]. Returns fn with + // .invert, .domain, .range. + function scale(domain, range) { + var d0 = domain[0], d1 = domain[1], r0 = range[0], r1 = range[1]; + var dspan = d1 - d0 || 1; + var f = function (x) { + return r0 + ((x - d0) / dspan) * (r1 - r0); + }; + f.invert = function (y) { + return d0 + ((y - r0) / (r1 - r0 || 1)) * dspan; + }; + f.domain = domain; + f.range = range; + return f; + } + + function niceTicks(lo, hi, count) { + count = count || 5; + var span = hi - lo; + if (span <= 0 || !isFinite(span)) return [lo]; + var step = Math.pow(10, Math.floor(Math.log(span / count) / Math.LN10)); + var err = (span / count) / step; + if (err >= 7.5) step *= 10; + else if (err >= 3.5) step *= 5; + else if (err >= 1.5) step *= 2; + var start = Math.ceil(lo / step) * step; + var out = []; + for (var v = start; v <= hi + step * 1e-6; v += step) { + out.push(Math.abs(v) < step * 1e-6 ? 0 : v); + } + return out; + } + + function fmtTick(v) { + if (v === 0) return "0"; + var a = Math.abs(v); + if (a >= 1e5 || a < 1e-3) return v.toExponential(0); + return String(Math.round(v * 1000) / 1000); + } + + // Draws axes/gridlines/labels. opts: {x, y, w, h, xscale, yscale, + // xlabel, ylabel, theme}. x,y = pixel origin of the plot's bottom-left area + // (defaults to a sensible inset). If xscale/yscale given, ticks are drawn. + function axes(ctx, opts) { + var t = opts.theme || theme(); + var c = t.colors; + var x0 = opts.x != null ? opts.x : 0; + var y0 = opts.y != null ? opts.y : 0; + var w = opts.w, h = opts.h; + ctx.save(); + ctx.lineWidth = 1; + ctx.strokeStyle = c.grid; + ctx.fillStyle = c.ink; + ctx.font = "11px var(--mono-font, monospace)"; + ctx.textBaseline = "top"; + ctx.textAlign = "center"; + + if (opts.xscale) { + var xt = niceTicks(opts.xscale.domain[0], opts.xscale.domain[1], 6); + for (var i = 0; i < xt.length; i++) { + var px = opts.xscale(xt[i]); + ctx.beginPath(); + ctx.moveTo(px, y0); + ctx.lineTo(px, y0 + h); + ctx.stroke(); + ctx.fillText(fmtTick(xt[i]), px, y0 + h + 4); + } + } + if (opts.yscale) { + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + var yt = niceTicks(opts.yscale.domain[0], opts.yscale.domain[1], 5); + for (var j = 0; j < yt.length; j++) { + var py = opts.yscale(yt[j]); + ctx.beginPath(); + ctx.moveTo(x0, py); + ctx.lineTo(x0 + w, py); + ctx.stroke(); + ctx.fillText(fmtTick(yt[j]), x0 - 4, py); + } + } + // Axis frame + ctx.strokeStyle = c.ink; + ctx.globalAlpha = 0.35; + ctx.strokeRect(x0, y0, w, h); + ctx.globalAlpha = 1; + + if (opts.xlabel) { + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText(opts.xlabel, x0 + w / 2, y0 + h + 24); + } + if (opts.ylabel) { + ctx.save(); + ctx.translate(x0 - 30, y0 + h / 2); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(opts.ylabel, 0, 0); + ctx.restore(); + } + ctx.restore(); + } + + // Polyline through pts (array of [px, py] in PIXEL coords). opts: {color, + // width, dash}. + function curve(ctx, pts, opts) { + opts = opts || {}; + if (!pts || pts.length === 0) return; + ctx.save(); + ctx.strokeStyle = opts.color || (theme().colors.ink); + ctx.lineWidth = opts.width || 2; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + if (opts.dash) ctx.setLineDash(opts.dash); + ctx.beginPath(); + var started = false; + for (var i = 0; i < pts.length; i++) { + var p = pts[i]; + if (!p || !isFinite(p[0]) || !isFinite(p[1])) { + started = false; + continue; + } + if (!started) { + ctx.moveTo(p[0], p[1]); + started = true; + } else { + ctx.lineTo(p[0], p[1]); + } + } + ctx.stroke(); + ctx.restore(); + } + + // Histogram of `samples` (numbers). opts: {bins, xscale, yscale, color, + // alpha}. Bars are drawn as a DENSITY (area = 1) so they compare directly to + // a pdf drawn with the same yscale. Bin edges span xscale.domain. + function histogram(ctx, samples, opts) { + opts = opts || {}; + var bins = opts.bins || 30; + var xs = opts.xscale, ys = opts.yscale; + if (!xs || !ys || !samples || samples.length === 0) return; + var lo = xs.domain[0], hi = xs.domain[1]; + var width = (hi - lo) / bins; + if (width <= 0) return; + var counts = new Array(bins); + for (var b = 0; b < bins; b++) counts[b] = 0; + var n = 0; + for (var i = 0; i < samples.length; i++) { + var v = samples[i]; + if (v < lo || v >= hi || !isFinite(v)) continue; + var idx = Math.floor((v - lo) / width); + if (idx < 0) idx = 0; + if (idx >= bins) idx = bins - 1; + counts[idx]++; + n++; + } + if (n === 0) return; + var baseline = ys(0); + ctx.save(); + ctx.globalAlpha = opts.alpha != null ? opts.alpha : 0.55; + ctx.fillStyle = opts.color || theme().colors.post; + for (var k = 0; k < bins; k++) { + var density = counts[k] / (n * width); + var xa = xs(lo + k * width); + var xb = xs(lo + (k + 1) * width); + var yTop = ys(density); + ctx.fillRect(xa, yTop, xb - xa, baseline - yTop); + } + ctx.restore(); + } + + function hexToRgb(hex) { + hex = (hex || "").trim(); + var m = /^#?([0-9a-f]{6})$/i.exec(hex); + if (m) { + var n = parseInt(m[1], 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + } + var rgb = /rgba?\(([^)]+)\)/.exec(hex); + if (rgb) { + var parts = rgb[1].split(","); + return [parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10)]; + } + return [128, 128, 128]; + } + + // Heatmap of scalar field f(x, y) (data coords). opts: {xscale, yscale, w, h, + // colormap}. colormap 'post'|'flow' (or a hex): transparent -> color ramp, + // normalized to the field's max over the sampled grid. + function heatmap(ctx, f, opts) { + opts = opts || {}; + var xs = opts.xscale, ys = opts.yscale; + var w = opts.w, h = opts.h; + var t = theme(); + var colHex = opts.colormap === "flow" ? t.colors.flow : opts.colormap === "post" ? t.colors.post : (opts.colormap || t.colors.post); + var rgb = hexToRgb(colHex); + var step = opts.step || 4; // pixel block size + var cols = Math.ceil(w / step); + var rows = Math.ceil(h / step); + // sample field + var vals = new Array(cols * rows); + var maxv = -Infinity; + for (var iy = 0; iy < rows; iy++) { + for (var ix = 0; ix < cols; ix++) { + var dx = xs.invert(ix * step + step / 2); + var dy = ys.invert(iy * step + step / 2); + var v = f(dx, dy); + if (!isFinite(v)) v = 0; + vals[iy * cols + ix] = v; + if (v > maxv) maxv = v; + } + } + if (!isFinite(maxv) || maxv <= 0) maxv = 1; + ctx.save(); + for (var jy = 0; jy < rows; jy++) { + for (var jx = 0; jx < cols; jx++) { + var a = vals[jy * cols + jx] / maxv; + if (a <= 0.002) continue; + if (a > 1) a = 1; + ctx.fillStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + a + ")"; + ctx.fillRect(jx * step, jy * step, step, step); + } + } + ctx.restore(); + } + + // ========================================================================== + // Controls (all keyboard-accessible; return the root element) + // ========================================================================== + + function el(tag, cls, parent) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (parent) parent.appendChild(e); + return e; + } + + function slider(parentEl, o) { + o = o || {}; + var root = el("label", "fv-control", parentEl); + var lab = el("span", "fv-control-label", root); + lab.textContent = o.label || ""; + var input = el("input", "fv-range", root); + input.type = "range"; + input.min = o.min; + input.max = o.max; + input.step = o.step != null ? o.step : "any"; + input.value = o.value != null ? o.value : o.min; + var out = el("span", "fv-control-value", root); + var fmt = o.fmt || function (v) { return String(v); }; + function render(v) { + out.textContent = fmt(v); + } + render(parseFloat(input.value)); + input.addEventListener("input", function () { + var v = parseFloat(input.value); + render(v); + if (o.onInput) o.onInput(v); + }); + root.fvSet = function (v) { + input.value = v; + render(parseFloat(input.value)); + }; + root.fvGet = function () { + return parseFloat(input.value); + }; + return root; + } + + function buttons(parentEl, specs) { + var root = el("div", "fv-buttons", parentEl); + root.fvButtons = {}; + for (var i = 0; i < specs.length; i++) { + (function (spec) { + var b = el("button", "fv-btn" + (spec.primary ? " fv-primary" : ""), root); + b.type = "button"; + b.textContent = spec.label; + if (spec.title) b.title = spec.title; + b.addEventListener("click", function () { + if (spec.onClick) spec.onClick(); + }); + root.fvButtons[spec.label] = b; + })(specs[i]); + } + return root; + } + + function toggle(parentEl, o) { + o = o || {}; + var root = el("label", "fv-control fv-toggle", parentEl); + var input = el("input", "fv-checkbox", root); + input.type = "checkbox"; + input.checked = !!o.value; + var lab = el("span", "fv-control-label", root); + lab.textContent = o.label || ""; + input.addEventListener("change", function () { + if (o.onChange) o.onChange(input.checked); + }); + root.fvSet = function (v) { + input.checked = !!v; + }; + root.fvGet = function () { + return input.checked; + }; + return root; + } + + function readout(parentEl, o) { + o = o || {}; + var root = el("div", "fv-readout", parentEl); + var lab = el("span", "fv-readout-label", root); + lab.textContent = o.label || ""; + var val = el("span", "fv-readout-value", root); + val.textContent = "—"; + return { + el: root, + set: function (txt, colorRole) { + val.textContent = txt; + val.style.color = colorRole ? "var(--fv-" + colorRole + ")" : ""; + } + }; + } + + // Victor-style draggable number. Binds to a . Drag horizontally to + // change (ew-resize cursor, coral while active); also arrow-key steppable when + // focused. onInput(value) fires on change. Returns the span, with .fvSet. + function scrub(spanEl, o) { + o = o || {}; + var min = o.min != null ? o.min : parseFloat(spanEl.getAttribute("data-min")); + var max = o.max != null ? o.max : parseFloat(spanEl.getAttribute("data-max")); + var step = o.step != null ? o.step : (parseFloat(spanEl.getAttribute("data-step")) || 1); + var value = o.value != null ? o.value : (parseFloat(spanEl.getAttribute("data-value")) || min || 0); + var fmt = o.fmt || function (v) { return String(v); }; + var decimals = (String(step).split(".")[1] || "").length; + + spanEl.className = (spanEl.className ? spanEl.className + " " : "") + "fv-scrub"; + spanEl.setAttribute("tabindex", "0"); + spanEl.setAttribute("role", "slider"); + spanEl.setAttribute("aria-valuemin", min); + spanEl.setAttribute("aria-valuemax", max); + + function clamp(v) { + if (min != null && v < min) v = min; + if (max != null && v > max) v = max; + var q = Math.round(v / step) * step; + return decimals ? parseFloat(q.toFixed(decimals)) : q; + } + function render() { + spanEl.textContent = fmt(value); + spanEl.setAttribute("aria-valuenow", value); + } + function emit() { + render(); + if (o.onInput) o.onInput(value); + } + + var dragging = false, startX = 0, startVal = 0, pid = null; + var range = (max - min) || 1; + // Prefer Pointer Events: setPointerCapture keeps move/up on the span itself + // (no window listeners), and `.fv-scrub` sets touch-action:none, so a thumb + // drag scrubs cleanly and never scroll-fights the page. Legacy fallback keeps + // the old mouse+touch path for browsers without PointerEvent. + var usePointer = typeof window !== "undefined" && !!window.PointerEvent; + + function coordX(e) { + if (e.touches && e.touches[0]) return e.touches[0].clientX; + if (e.changedTouches && e.changedTouches[0]) return e.changedTouches[0].clientX; + return e.clientX; + } + function onDown(e) { + dragging = true; + startX = coordX(e); + startVal = value; + spanEl.classList.add("fv-scrub-active"); + if (usePointer) { + pid = e.pointerId; + if (spanEl.setPointerCapture && pid != null) { + try { spanEl.setPointerCapture(pid); } catch (err) {} + } + } else { + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + window.addEventListener("touchmove", onMove, { passive: false }); + window.addEventListener("touchend", onUp); + } + if (e.cancelable) e.preventDefault(); + } + function onMove(e) { + if (!dragging) return; + if (usePointer && pid != null && e.pointerId != null && e.pointerId !== pid) return; + var dx = coordX(e) - startX; + // ~200px of drag traverses the full range + value = clamp(startVal + (dx / 200) * range); + emit(); + if (e.cancelable) e.preventDefault(); + } + function onUp(e) { + if (!dragging) return; + dragging = false; + spanEl.classList.remove("fv-scrub-active"); + if (usePointer) { + if (spanEl.releasePointerCapture && pid != null) { + try { spanEl.releasePointerCapture(pid); } catch (err) {} + } + pid = null; + } else { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + window.removeEventListener("touchmove", onMove); + window.removeEventListener("touchend", onUp); + } + } + if (usePointer) { + spanEl.addEventListener("pointerdown", onDown); + spanEl.addEventListener("pointermove", onMove); + spanEl.addEventListener("pointerup", onUp); + spanEl.addEventListener("pointercancel", onUp); + } else { + spanEl.addEventListener("mousedown", onDown); + spanEl.addEventListener("touchstart", onDown, { passive: false }); + } + spanEl.addEventListener("keydown", function (e) { + var d = 0; + if (e.key === "ArrowLeft" || e.key === "ArrowDown") d = -1; + else if (e.key === "ArrowRight" || e.key === "ArrowUp") d = 1; + else if (e.key === "Home") { value = clamp(min); emit(); e.preventDefault(); return; } + else if (e.key === "End") { value = clamp(max); emit(); e.preventDefault(); return; } + if (d !== 0) { + var m = e.shiftKey ? 10 : 1; + value = clamp(value + d * step * m); + emit(); + e.preventDefault(); + } + }); + + value = clamp(value); + render(); + spanEl.fvSet = function (v) { + value = clamp(v); + render(); + }; + spanEl.fvGet = function () { + return value; + }; + return spanEl; + } + + // ========================================================================== + // Animation loop + // ========================================================================== + + var reduceMotion = false; + if (typeof window !== "undefined" && window.matchMedia) { + try { + var mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + reduceMotion = mq.matches; + if (mq.addEventListener) mq.addEventListener("change", function (e) { reduceMotion = e.matches; }); + } catch (e) {} + } + + // loop(widgetRootEl, tickFn, opts) -> {play(), pause(), step(), playing, reduced}. + // + // Drives a rAF animation of tickFn(dt). Auto-pauses when the widget scrolls + // offscreen (IntersectionObserver) or the tab is hidden, and resumes on return. + // + // opts.autoplay (boolean): when true, the widget begins playing the moment it + // initializes — init is already lazy on scroll-into-view, so nobody lands on a + // dead canvas. Autoplay routes through play(), which is a no-op under + // prefers-reduced-motion and under the offscreen/hidden guards; so a + // reduced-motion visitor never gets autoplaying animation (the widget should + // render a fully-formed static frame instead), and an offscreen autoplay simply + // resumes once scrolled into view. The returned API is identical with or without + // opts — autoplay only changes whether play() is invoked once at the end of setup. + function loop(widgetRootEl, tickFn, opts) { + opts = opts || {}; + var raf = null; + var playing = false; + var onscreen = true; + var lastT = 0; + var api = { play: play, pause: pause, step: step, playing: false, get reduced() { return reduceMotion; } }; + + function frame(now) { + if (!playing) return; + var dt = lastT ? (now - lastT) / 1000 : 0; + lastT = now; + try { + tickFn(dt); + } catch (e) { + pause(); + throw e; + } + raf = window.requestAnimationFrame(frame); + } + function play() { + if (reduceMotion) return; // no autoplaying animation under reduced-motion + if (playing) return; + if (!onscreen || (typeof document !== "undefined" && document.hidden)) return; + playing = true; + api.playing = true; + lastT = 0; + raf = window.requestAnimationFrame(frame); + } + function pause() { + playing = false; + api.playing = false; + if (raf) window.cancelAnimationFrame(raf); + raf = null; + } + function step() { + try { + tickFn(0); + } catch (e) { + throw e; + } + } + + // Auto-pause offscreen. + if (typeof IntersectionObserver !== "undefined" && widgetRootEl) { + var io = new IntersectionObserver(function (entries) { + for (var i = 0; i < entries.length; i++) { + onscreen = entries[i].isIntersecting; + if (!onscreen && playing) { + var wasPlaying = true; + pause(); + api._wasPlaying = wasPlaying; + } else if (onscreen && api._wasPlaying && !reduceMotion) { + api._wasPlaying = false; + play(); + } + } + }, { threshold: 0.01 }); + io.observe(widgetRootEl); + } + // Auto-pause when tab hidden. + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", function () { + if (document.hidden && playing) { + pause(); + api._wasPlaying = true; + } else if (!document.hidden && api._wasPlaying && onscreen && !reduceMotion) { + api._wasPlaying = false; + play(); + } + }); + } + // Autoplay on init (respects reduced-motion / offscreen guards inside play()). + if (opts.autoplay) play(); + return api; + } + + // ========================================================================== + // Touch & smoothness helpers (see §A of the explorables spec) + // + // These exist so every widget handles a thumb the same, correct way: + // - a canvas drag NEVER scroll-fights the page (claim the gesture only on a + // real hit; otherwise let the page scroll), + // - coarse pointers get inflated hit targets (>=22 CSS px), + // - state advances on its own clock while render tweens between states. + // ========================================================================== + + // True on touch/stylus-primary devices. Checked live (not cached) so hybrid + // laptops that gain/lose a touchscreen answer correctly per gesture. + function isCoarsePointer() { + if (typeof window === "undefined" || !window.matchMedia) return false; + try { + return window.matchMedia("(pointer: coarse)").matches; + } catch (e) { + return false; + } + } + + // drag(canvasEl, opts) -> handle. An OPT-IN pointer drag manager for a canvas. + // + // The whole point is scroll-fight avoidance: on pointerdown it runs your + // hitTest; ONLY when that returns a target does it claim the gesture + // (setPointerCapture + preventDefault) so the drag can never scroll the page. + // A miss is ignored entirely, so a thumb on empty canvas still scrolls. + // + // opts: + // hitTest(x, y, slop) -> target REQUIRED. x,y are CSS px from the canvas + // top-left; `slop` is the current hit-inflation (>=22 on coarse pointers, + // else opts.inflate). Return any truthy target (an index, an object) the + // pointer is over, or a "miss" sentinel: null / undefined / false / -1. + // onStart(target, x, y, ev) optional; once when a grab begins. + // onDrag(target, x, y, ev) called on every move while grabbing. + // onEnd(target, ev) optional; when the grab releases/cancels. + // inflate (number, default 0) base hit slop on FINE pointers; coarse + // pointers always get at least 22. + // fullCapture (bool, default true) true adds `.fv-touch-none` to the canvas + // (touch-action:none) — the whole canvas is treated as interactive, so a + // drag is perfectly smooth but a plain swipe over it won't scroll the + // page. Set false for a mostly-ambient canvas that should still scroll on + // a swipe (a hit is still claimed best-effort). Ambient-only micros + // should simply NOT call drag(): their canvas stays pan-y and scrolls. + // + // Returns { grabbed, target, slop, isCoarse, destroy() } where `grabbed` (bool) + // and `target` are live getters — draw your grab halo (see halo()) while + // `grabbed` is true. destroy() removes every listener and drops the class. + function drag(canvasEl, opts) { + opts = opts || {}; + var hitTest = opts.hitTest || function () { return null; }; + var baseInflate = opts.inflate || 0; + var fullCapture = opts.fullCapture !== false; // default true + var usePointer = typeof window !== "undefined" && !!window.PointerEvent; + + if (fullCapture) canvasEl.classList.add("fv-touch-none"); + + var state = { grabbed: false, target: null, slop: baseInflate, isCoarse: false }; + var activeId = null; + + function pt(e) { + var t = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]); + if (t) return { x: t.clientX, y: t.clientY, id: t.identifier }; + return { x: e.clientX, y: e.clientY, id: e.pointerId != null ? e.pointerId : 0 }; + } + function local(p) { + var r = canvasEl.getBoundingClientRect(); + return [p.x - r.left, p.y - r.top]; + } + function isHit(t) { + return t !== null && t !== undefined && t !== false && t !== -1; + } + function slopFor() { + var c = isCoarsePointer(); + state.isCoarse = c; + return c ? Math.max(22, baseInflate) : baseInflate; + } + // For a pointer event, only the captured pointer drives move/end. + function otherPointer(e) { + return usePointer && e.pointerId != null && activeId != null && e.pointerId !== activeId; + } + + function begin(e) { + if (state.grabbed) return; + var slop = slopFor(); + state.slop = slop; + var p = pt(e), xy = local(p); + var target = hitTest(xy[0], xy[1], slop); + if (!isHit(target)) return; // miss: don't claim — page scrolls, others run + state.grabbed = true; + state.target = target; + activeId = p.id; + canvasEl.classList.add("fv-grabbing"); + if (usePointer && canvasEl.setPointerCapture && e.pointerId != null) { + try { canvasEl.setPointerCapture(e.pointerId); } catch (err) {} + } else if (!usePointer) { + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", end); + window.addEventListener("touchmove", move, { passive: false }); + window.addEventListener("touchend", end); + window.addEventListener("touchcancel", end); + } + if (e.cancelable) e.preventDefault(); + if (opts.onStart) opts.onStart(target, xy[0], xy[1], e); + } + function move(e) { + if (!state.grabbed || otherPointer(e)) return; + var xy = local(pt(e)); + if (opts.onDrag) opts.onDrag(state.target, xy[0], xy[1], e); + if (e.cancelable) e.preventDefault(); + } + function end(e) { + if (!state.grabbed || otherPointer(e)) return; + state.grabbed = false; + canvasEl.classList.remove("fv-grabbing"); + if (usePointer && canvasEl.releasePointerCapture && e.pointerId != null) { + try { canvasEl.releasePointerCapture(e.pointerId); } catch (err) {} + } else if (!usePointer) { + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", end); + window.removeEventListener("touchmove", move); + window.removeEventListener("touchend", end); + window.removeEventListener("touchcancel", end); + } + var t = state.target; + state.target = null; + activeId = null; + if (opts.onEnd) opts.onEnd(t, e); + } + function hover(e) { + if (state.grabbed) return; + var xy = local(pt(e)); + canvasEl.style.cursor = isHit(hitTest(xy[0], xy[1], state.slop || baseInflate)) ? "grab" : ""; + } + + if (usePointer) { + canvasEl.addEventListener("pointerdown", begin); + canvasEl.addEventListener("pointermove", move); + canvasEl.addEventListener("pointerup", end); + canvasEl.addEventListener("pointercancel", end); + canvasEl.addEventListener("pointermove", hover); + } else { + canvasEl.addEventListener("mousedown", begin); + canvasEl.addEventListener("touchstart", begin, { passive: false }); + canvasEl.addEventListener("mousemove", hover); + } + + return { + get grabbed() { return state.grabbed; }, + get target() { return state.target; }, + get slop() { return state.slop; }, + get isCoarse() { return state.isCoarse; }, + destroy: function () { + if (usePointer) { + canvasEl.removeEventListener("pointerdown", begin); + canvasEl.removeEventListener("pointermove", move); + canvasEl.removeEventListener("pointerup", end); + canvasEl.removeEventListener("pointercancel", end); + canvasEl.removeEventListener("pointermove", hover); + } else { + canvasEl.removeEventListener("mousedown", begin); + canvasEl.removeEventListener("touchstart", begin); + canvasEl.removeEventListener("mousemove", hover); + window.removeEventListener("mousemove", move); + window.removeEventListener("mouseup", end); + window.removeEventListener("touchmove", move); + window.removeEventListener("touchend", end); + window.removeEventListener("touchcancel", end); + } + if (fullCapture) canvasEl.classList.remove("fv-touch-none"); + } + }; + } + + // halo(ctx, x, y, r, color, alpha) — draw a soft grab-halo ring at pixel (x,y). + // Call from render while a drag handle's api.grabbed is true (§A.2: "show a + // subtle halo on the grabbed point while dragging"). Defaults to the hot color. + function halo(ctx, x, y, r, color, alpha) { + if (!isFinite(x) || !isFinite(y)) return; + var col = color || theme().colors.hot; + var a = alpha != null ? alpha : 0.35; + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, r, 0, 2 * Math.PI); + ctx.globalAlpha = a * 0.4; + ctx.fillStyle = col; + ctx.fill(); + ctx.globalAlpha = a; + ctx.lineWidth = 2; + ctx.strokeStyle = col; + ctx.stroke(); + ctx.restore(); + } + + // pace(hz, maxPerFrame) -> step(dt) -> integer count. + // + // Tick/render decoupling (§A.3): advance logical state at a FIXED `hz` + // steps/sec regardless of the render frame rate. Each frame call step(dt) with + // that frame's dt (seconds); it returns how many logical ticks to run now, + // carrying the sub-tick remainder to the next frame — so no stutter when rAF + // drops frames. After a long stall (backgrounded tab) the backlog is capped at + // maxPerFrame (default 5) and the remainder dropped, avoiding a spiral of death. + // + // var pacer = FV.pace(60); + // ... inside loop tick(dt): for (var n = pacer(dt); n-- > 0; ) advance(); + function pace(hz, maxPerFrame) { + var acc = 0; + var cap = maxPerFrame > 0 ? maxPerFrame : 5; + return function (dt) { + if (!(dt > 0) || !(hz > 0)) return 0; + acc += dt * hz; + var n = Math.floor(acc); + if (n > cap) { n = cap; acc = 0; } + else acc -= n; + return n; + }; + } + + // ========================================================================== + // Widget lifecycle — lazy init via IntersectionObserver + // ========================================================================== + + var registry = {}; + var pending = []; + var lifecycleObserver = null; + + function ensureObserver() { + if (lifecycleObserver || typeof IntersectionObserver === "undefined") return; + lifecycleObserver = new IntersectionObserver(function (entries) { + for (var i = 0; i < entries.length; i++) { + var entry = entries[i]; + if (entry.isIntersecting) { + maybeInit(entry.target); + } + } + }, { rootMargin: "200px" }); + } + + function maybeInit(root) { + if (!root || root._fvInited) return; + var name = root.getAttribute("data-viz"); + var initFn = registry[name]; + if (!initFn) return; // widget script not loaded yet + root._fvInited = true; + if (lifecycleObserver) lifecycleObserver.unobserve(root); + try { + initFn(root, FugueViz); + } catch (e) { + root._fvInited = false; + if (typeof console !== "undefined") console.error("[FugueViz] init failed for '" + name + "'", e); + } + } + + function scan() { + if (typeof document === "undefined") return; + var nodes = document.querySelectorAll(".fugue-explorable[data-viz]"); + ensureObserver(); + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + if (node._fvInited || node._fvObserved) continue; + if (lifecycleObserver) { + node._fvObserved = true; + lifecycleObserver.observe(node); + } else { + maybeInit(node); // no IO support: init eagerly + } + } + } + + function register(name, initFn) { + registry[name] = initFn; + // A matching element may already be scrolled into view; try to init it. + if (typeof document !== "undefined") { + var nodes = document.querySelectorAll('.fugue-explorable[data-viz="' + name + '"]'); + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + if (node._fvObserved || node._fvInited) { + // already tracked; the observer will fire, but init now if visible + maybeInit(node); + } else { + ensureObserver(); + if (lifecycleObserver) { + node._fvObserved = true; + lifecycleObserver.observe(node); + } else { + maybeInit(node); + } + } + } + } + } + + if (typeof document !== "undefined") { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", scan); + } else { + scan(); + } + // Re-scan on load in case widget scripts registered after first scan. + if (typeof window !== "undefined") { + window.addEventListener("load", scan); + } + } + + // ========================================================================== + // Public API + // ========================================================================== + + var FugueViz = { + register: register, + theme: theme, + onThemeChange: onThemeChange, + rng: rng, + randn: randn, + dist: dist, + lgamma: lgamma, + logsumexp: logsumexp, + log1p: log1p, + gammaStd: gammaStd, + canvas: canvas, + scale: scale, + axes: axes, + curve: curve, + histogram: histogram, + heatmap: heatmap, + slider: slider, + buttons: buttons, + toggle: toggle, + readout: readout, + scrub: scrub, + loop: loop, + drag: drag, + halo: halo, + pace: pace, + isCoarsePointer: isCoarsePointer, + _registry: registry, + _scan: scan + }; + + if (typeof window !== "undefined") window.FugueViz = FugueViz; + if (typeof module !== "undefined" && module.exports) module.exports = FugueViz; +})(); diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 0aeade3..7209ecc 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -11,6 +11,13 @@ - [Your First Model](./getting-started/your-first-model.md) - [Understanding Models](./getting-started/understanding-models.md) - [Basic Inference](./getting-started/basic-inference.md) +- [Explorables](./explorables/README.md) + - [Anatomy of a Probabilistic Program](./explorables/anatomy.md) + - [The Model Is a Score](./explorables/monad.md) + - [Random Walks in Posterior Space](./explorables/metropolis.md) + - [Rolling, Not Guessing: HMC](./explorables/hmc.md) + - [Particles That Tell Stories](./explorables/smc.md) + - [A Field Guide to Distributions](./explorables/distributions.md) - [How-To](./how-to/README.md) - [Working with Distributions](./how-to/working-with-distributions.md) - [Building Complex Models](./how-to/building-complex-models.md) diff --git a/docs/src/explorables/README.md b/docs/src/explorables/README.md new file mode 100644 index 0000000..ae5baff --- /dev/null +++ b/docs/src/explorables/README.md @@ -0,0 +1,50 @@ +# Explorables + +Most explanations of probabilistic programming ask you to read first and understand later. +These pages invert that. Every figure here is a small machine: drag the numbers, click the +data, step the algorithms, and watch the mathematics respond. You will have *felt* why a +sampler stalls or a particle filter collapses before you meet the equation that says so. +The genre is borrowed with gratitude from Bret Victor's explorable explanations and +3Blue1Brown's visual mathematics. + +Two habits worth forming: + +- **Touch everything.** If a number looks interesting, try dragging it — dashed underlines + mark the ones that respond. Canvases are clickable more often than not. +- **Mind the seed.** Wherever randomness appears, a scrubbable seed appears with it. The same + seed always replays the same run — which is not a gimmick, it is fugue's worldview: a + recorded run is a trace, and traces can be replayed. You will meet this idea everywhere. + +One color language runs through every page: +**prior** × +**likelihood** = +**posterior**, with +**coral** for the current sample and +**violet** for momentum and structure. +Once you know it, every canvas and every equation on this site reads at a glance. + +## The six machines + +1. **[Anatomy of a Probabilistic Program](./anatomy.md)** — a coin, a prior you can bend, + and data you can click into existence. Bayes' rule as something your hands learn first. +2. **[The Model Is a Score](./monad.md)** — five observations you can drag along an axis, + a prior, and an exact posterior that follows your hand. Underneath, fugue's `Model` monad + performed one effect at a time — then handed to a different performer. The page that + explains fugue itself. +3. **[Random Walks in Posterior Space](./metropolis.md)** — a real regression: drag the data + points and watch the posterior heatmap deform under the sampler's feet, while accepted + samples paint fit-lines through your data. Live split-R̂ and ESS referee the whole thing. +4. **[Rolling, Not Guessing: Hamiltonian Monte Carlo](./hmc.md)** — the same regression, + but the sample gets momentum and rolls. Leapfrog trajectories, divergences, and a + side-by-side race against the random walk at a matched budget — visible in the data, + not just the parameters. +5. **[Particles That Tell Stories](./smc.md)** — Sequential Monte Carlo as a population: + propagate, weight, resample. Watch lineages go extinct and understand degeneracy by + witnessing it. +6. **[A Field Guide to Distributions](./distributions.md)** — every distribution fugue ships, + with real parameterizations, a sampler racing its own density, and the natural return + types that make fugue's models type-safe. + +Read them in order for a course, or jump to what you came for — each page stands alone and +links onward. When a page convinces you, the matching tutorial turns the intuition into +working Rust; the code on every explorable compiles against `fugue-ppl 0.2.0` as written. diff --git a/docs/src/explorables/anatomy.md b/docs/src/explorables/anatomy.md new file mode 100644 index 0000000..95e4b3d --- /dev/null +++ b/docs/src/explorables/anatomy.md @@ -0,0 +1,130 @@ +# Anatomy of a Probabilistic Program + +A probabilistic program has three moving parts: a **prior** (what you believe +before data), a **likelihood** (how data is explained), and a **posterior** +(what you believe after). Here they are, side by side, for the oldest question +in statistics: is this coin fair? Flip a chip, drag the prior, and watch the +green curve reshape itself in real time — each change leaving a fading ghost of +the belief it just replaced. + +
+ +You start with a prior belief about the coin's +bias `p`: Beta(α = 2, +β = 2). +The blue curve is that belief. Each coin chip is one +observation; click any chip to flip it between +heads and tails. The posterior — the green curve — +is the prior *times* the likelihood, renormalized. Press **Replay** to watch the +flips arrive one at a time and the green curve walk from the prior to the +posterior — Bayesian updating, animated — or press **Step** to take that walk one +flip at a time yourself. Press **Deal** to draw a fresh dozen flips from seed +11; +the same seed always deals the same coins, because in fugue a seeded run is a +replayable recording. + +## Things to try + +1. Add tails until the green curve's peak crosses + left of `p = 0.5` — that is the moment the data outvotes a "fair coin" prior. +2. Drag **β** up to 20 with only a few flips: a stubborn prior barely moves. Now + add twenty flips — data eventually wins, no matter the prior. +3. Set **α = 0.5, β = 0.5** (the Jeffreys prior). The blue curve bends up at both + ends: it says "this coin is probably rigged one way or the other." +4. Turn **show likelihood** on and watch the yellow curve. The green posterior + always sits *between* blue and yellow — pulled toward whichever is sharper. +5. Press **Replay**: the posterior starts *as* the blue prior, then each flip + nudges it — heads pull right, tails pull left. The fading green ghosts are the + beliefs it held along the way. This is Bayesian updating, one datum at a time. +6. Scrub the **seed** while **Deal**ing: every value gives a different but fully + reproducible dataset. That reproducibility is the whole point of a trace. + +## What you just saw + +The green curve is not drawn by a formula you have to trust — it is the +literal product of the other two, normalized so its area is one: + +$$\textcolor{#56D364}{p(p \mid \mathcal{D})} \;\propto\; \textcolor{#58A6FF}{p(p)}\;\times\;\textcolor{#F2CC60}{p(\mathcal{D} \mid p)}$$ + +For a coin this product has a closed form. A Beta prior multiplied by +`h` heads and `t` tails of Bernoulli likelihood is again a Beta — the two are +**conjugate** — so the posterior is exact: + +$$\underbrace{\textcolor{#58A6FF}{\mathrm{Beta}(\alpha,\beta)}}_{\text{prior}} \times \underbrace{\textcolor{#F2CC60}{p^{\,h}(1-p)^{\,t}}}_{\text{likelihood}} \;\propto\; \underbrace{\textcolor{#56D364}{\mathrm{Beta}(\alpha+h,\ \beta+t)}}_{\text{posterior}}$$ + +
+ +Every heads slides one unit of belief into α; every tails, into β. That is why +**Replay** works: feeding the flips one at a time, using each posterior as the +next prior, lands on exactly the same green curve as folding them in all at +once. Bayesian updating is associative. The posterior mean is the updated ratio, +and the readouts track it live: + +$$\mathbb{E}[\textcolor{#56D364}{p \mid \mathcal{D}}] = \frac{\alpha+h}{\alpha+\beta+h+t}$$ + +The coral marker is the **MAP** — the posterior's most probable bias, its mode. +The shaded band is the **90% credible interval**: the model's honest "I'm 90% +sure the bias is in here." Conjugacy is a lucky gift of the coin; most models +have no such shortcut. That is why fugue exists. + +## The fugue code + +The widget uses conjugacy because it can. fugue does not need to — it runs +Metropolis–Hastings on the *same* model and lands on the *same* number. + +```rust +use fugue::*; +use fugue::inference::mh::adaptive_mcmc_chain; +use rand::{SeedableRng, rngs::StdRng}; + +// The score: a prior over the bias, then one observe per flip. +fn coin(data: Vec) -> Model { + prob!( + // PRIOR — the blue curve: belief about the bias p before any flip. + let p <- sample(addr!("p"), Beta::new(2.0, 2.0).unwrap()); + + // LIKELIHOOD — each yellow chip is one `observe`: a flip explained by p. + let _obs <- plate!(i in 0..data.len() => { + observe(addr!("flip", i), Bernoulli::new(p).unwrap(), data[i]) + }); + + pure(p) // return the inferred bias + ) +} + +fn main() { + // 7 heads, 3 tails — the widget's starting data. + let data = vec![true, false, true, true, false, true, true, false, true, true]; + let mut rng = StdRng::seed_from_u64(11); + + // Metropolis–Hastings — no conjugacy assumed, adaptive step size. + let samples = adaptive_mcmc_chain(&mut rng, || coin(data.clone()), 4000, 1000); + let ps: Vec = samples + .iter() + .filter_map(|(_, t)| t.get_f64(&addr!("p"))) + .collect(); + let mean = ps.iter().sum::() / ps.len() as f64; + + // Conjugacy says the posterior is Beta(2+7, 2+3) = Beta(9, 5), + // whose mean is 9/14 ≈ 0.643. MH agrees without ever knowing that. + println!("MH posterior mean ≈ {mean:.3} (analytic 9/14 = {:.3})", 9.0 / 14.0); +} +``` + +`sample` records a choice at an address; `observe` scores the data against the +current bias and folds a log-weight into the trace; `pure` returns a value. The +`prob!` and `plate!` macros are sugar over the `Model` monad — the score is +written once, then *performed* by a handler. That separation is the next +explorable. + +## Go deeper + +- **Tutorial:** [Bayesian Coin Flip](../tutorials/foundation/bayesian-coin-flip.md) + walks the same model without the pictures. +- **Next explorable:** [The Model Is a Score](./monad.md) — step through the + interpreter that actually runs this program. +- **API:** [`Beta`](https://docs.rs/fugue-ppl/latest/fugue/), [`adaptive_mcmc_chain`](https://docs.rs/fugue-ppl/latest/fugue/inference/mh/fn.adaptive_mcmc_chain.html). + +--- + +Next: [The Model Is a Score](./monad.md) diff --git a/docs/src/explorables/distributions.md b/docs/src/explorables/distributions.md new file mode 100644 index 0000000..f330d5f --- /dev/null +++ b/docs/src/explorables/distributions.md @@ -0,0 +1,159 @@ +# A Field Guide to Distributions + +A distribution is a shape and a rule for drawing from it. The shape is its +density — where values are likely to land. The rule is its sampler — how a +stream of random numbers becomes draws. Every distribution below is one note in +fugue's vocabulary; pick one and hear it. + +The blue curve is the exact law. The green bars are samples piling up. Watch +them converge — that is the law of large numbers, live. + +
+ +## Things to try + +1. Start on **Normal** and drag σ up to 3 — the bell flattens and spreads, but + the green samples still trace it. +2. Switch to **Beta** and set α and β both below 1 — the density turns into a + U, piling mass at the two edges. +3. Open **Cauchy**. Its `mean` and `variance` readouts both read `—`: the tails + are so heavy that neither integral converges. The samples wander wildly. +4. Pick **Bernoulli** and read the `sample →` badge: it says `bool`. Fugue gives + you a real boolean, not a float you have to compare against `1.0`. +5. Drag **Uniform**'s `high` below its `low`. The canvas turns red — this is + exactly the `Err` that `Uniform::new` returns for an invalid interval. +6. On any distribution, drop the **seed** back to a value you already used. The + green histogram redraws the identical run: a seeded stream is a replayable + trace. + +## What you just saw + +For a continuous distribution the blue curve is the **probability density** +f(x). It is not a probability — it is a +density, so it can exceed 1. What integrates to 1 is the area: + +$$\int_{\mathcal{S}} \textcolor{#58A6FF}{f(x)}\, dx = 1$$ + +For a discrete distribution the blue stems are the **probability mass** +P(x), one bar per outcome, and the bars sum to 1: + +$$\sum_{x \in \mathcal{S}} \textcolor{#58A6FF}{P(x)} = 1$$ + +The green bars are an empirical estimate of that +same law built from samples. As the sample count grows they converge on the +blue — the **law of large numbers**. The coral +line is a single query: it reports $\log \textcolor{#58A6FF}{f(x)}$ at the x you +drag to. Inference works in log-space because a product of thousands of these +densities underflows to zero in ordinary floating point; a sum of their logs +does not. + +The markers name three summaries of the shape: the +mean (the balance point), the median (half the +mass on each side), and the mode (the peak). For a skewed law like LogNormal +they sit in different places; for a symmetric one they coincide. + +### Fugue's type story + +Most PPLs make every draw a `f64`, so a coin flip comes back as `1.0` and you +compare floats; a count comes back as `4.0` and you round it. Fugue draws return +their **natural type**, checked at compile time. + +
+ +```rust +use fugue::*; +use rand::thread_rng; + +fn main() { + let mut rng = thread_rng(); + + // Bernoulli -> bool. The coral query line is `log_prob` on one outcome. + let coin = Bernoulli::new(0.5).unwrap(); + let heads: bool = coin.sample(&mut rng); + let lp_true: f64 = coin.log_prob(&true); // ln(0.5) + if heads { /* no `== 1.0`; it's already a bool */ } + + // Poisson -> u64. Counts are counts, not rounded floats. + let arrivals: u64 = Poisson::new(4.0).unwrap().sample(&mut rng); + let total_wait = arrivals * 10; // integer arithmetic, no cast + + // Categorical -> usize. Safe to index an array with, by construction. + let pick: usize = Categorical::new(vec![0.5, 0.3, 0.2]).unwrap().sample(&mut rng); + let labels = ["a", "b", "c"]; + let chosen = labels[pick]; + + // Continuous laws return f64, as expected. + let x: f64 = Normal::new(0.0, 1.0).unwrap().sample(&mut rng); + let density: f64 = Normal::new(0.0, 1.0).unwrap().log_prob(&x); + + println!("{heads} {arrivals} {total_wait} {chosen} {x:.3} {density:.3} {lp_true:.3}"); +} +``` + +Every constructor is fallible: `Normal::new`, `Beta::new`, `Uniform::new` and the +rest return `Result`, so an invalid parameter (a negative σ, a `low ≥ high`) is +an `Err` you handle, never a silent `NaN`. That is the red screen in try #5. + +Inside a model, a draw is a `sample` at a named address; its distribution rides +along and fugue scores it for you: + +```rust +use fugue::*; + +// Estimate a coin's bias, then predict the next flip's count over 10 tosses. +let model = prob!( + let p <- sample(addr!("bias"), Beta::new(2.0, 2.0).unwrap()); + let hits <- sample(addr!("hits"), Binomial::new(10, p).unwrap()); + pure(hits) +); +``` + +## The full field guide + +Every distribution in fugue. The `sample →` column is the natural return type. + +### Continuous + +| Distribution | Support | Parameters | `sample →` | Reach for it when | +|---|---|---|---|---| +| `Normal` | (−∞, ∞) | `mu`, `sigma` > 0 | `f64` | you want a symmetric bell — noise, error, a CLT limit. | +| `Uniform` | [low, high) | `low` < `high` | `f64` | you want a flat prior on a bounded interval. | +| `LogNormal` | (0, ∞) | `mu`, `sigma` > 0 | `f64` | a positive, right-skewed quantity whose log is Normal. | +| `Exponential` | [0, ∞) | `rate` > 0 | `f64` | the wait until the next memoryless event; mean = 1/rate. | +| `Beta` | [0, 1] | `alpha`, `beta` > 0 | `f64` | a probability about a probability — the coin-bias prior. | +| `Gamma` | (0, ∞) | `shape`, `rate` > 0 | `f64` | positive quantities; **rate**-parameterized, mean = shape/rate. | +| `StudentT` | (−∞, ∞) | `df`, `loc`, `scale` > 0 | `f64` | a heavier-tailed Normal that tolerates outliers. | +| `Cauchy` | (−∞, ∞) | `loc`, `scale` > 0 | `f64` | pathological tails — no mean, no variance. `StudentT(df=1)`. | +| `Laplace` | (−∞, ∞) | `loc`, `scale` > 0 | `f64` | a sharp peak with exponential tails; the L1 / lasso prior. | +| `Weibull` | [0, ∞) | `shape`, `scale` > 0 | `f64` | time-to-failure and survival modeling. | +| `ChiSquared` | (0, ∞) | `k` > 0 | `f64` | sums of squared Normals; = `Gamma(k/2, ½)`. | +| `InverseGamma` | (0, ∞) | `shape`, `rate` > 0 | `f64` | the conjugate prior for a Normal's variance. | + +### Discrete + +| Distribution | Support | Parameters | `sample →` | Reach for it when | +|---|---|---|---|---| +| `Bernoulli` | {0, 1} | `p` ∈ [0, 1] | `bool` | one yes/no trial — and you want a real `bool`. | +| `Categorical` | {0 … K−1} | `probs` sum to 1 | `usize` | picking one of K labels; index arrays safely. | +| `Binomial` | {0 … n} | `n`, `p` ∈ [0, 1] | `u64` | successes in n independent trials. | +| `Poisson` | {0, 1, 2, …} | `lambda` > 0 | `u64` | rare-event counts; mean = variance = λ. | +| `DiscreteUniform` | {low … high} | `low` ≤ `high` | `i64` | a fair die over an integer range. | + +```admonish note title="Gamma is rate-parameterized" +Fugue's `Gamma::new(shape, rate)` uses **rate** (λ), not scale, so the mean is +`shape / rate`. `Exponential`, `InverseGamma`, and `ChiSquared` follow the same +rate convention. If a value looks inverted, check whether you meant scale = 1/rate. +``` + +## Go deeper + +- Tutorial: [Working with Distributions](../how-to/working-with-distributions.md) — + the same catalog in prose, with modeling patterns. +- API: [`fugue::core::distribution`](https://docs.rs/fugue-ppl/latest/fugue/core/distribution/index.html) — + every constructor, its constraints, and its `log_prob`. +- Next explorable: [Anatomy of a Probabilistic Program](anatomy.md) — put a + `Beta` prior and `Bernoulli` data together and watch Bayes multiply them. + +--- + +Next: [Explorables](./README.md) diff --git a/docs/src/explorables/hmc.md b/docs/src/explorables/hmc.md new file mode 100644 index 0000000..7ca50f7 --- /dev/null +++ b/docs/src/explorables/hmc.md @@ -0,0 +1,180 @@ +# Rolling, Not Guessing: Hamiltonian Monte Carlo + +Random-walk Metropolis proposes blindly and hopes. Hamiltonian Monte Carlo does +something smarter: it gives the sampler *momentum* and lets it **roll** across the +posterior like a ball on a landscape, following the slope instead of guessing +against it. One good roll crosses ground that a random walk needs hundreds of +timid steps to cover. + +Same problem as the [Metropolis explorable](./metropolis.md): fit a straight line +`y = a·x + b` to twelve noisy points. **Left** is data space — the yellow points and +a fan of candidate fit lines. **Right** is parameter space — the posterior over +`(slope, intercept)`, with the sampler rolling through it. A point on the right **is** +a line on the left: when the coral ball moves right, its coral line swings on the left. + +
+ +These are the *same* twelve seeded points as the Metropolis page — run them next to +each other. Every run is a replayable trace: fix the 11 +seed and you get the exact same momenta and trajectories every time. + +## Things to try + +1. Watch the violet **leapfrog trajectory** roll across parameter space — a single + proposal travelling much farther than a random-walk hop — while its coral **fit + line** swings across the data on the left. (It is already rolling; the controls + let you pause, step, and steer.) +2. **Drag the rightmost yellow point far up.** The posterior heatmap tilts toward + steeper slopes and the coral ball rolls after it within a few transitions. This + linked deformation is the whole point of the page. +3. Turn on **MH side-by-side**. Both samplers get the same number of gradient-budget + evaluations; the random walk's fits are the *dim* green spaghetti, HMC's the bright + green — HMC fans across the plausible lines noticeably faster. +4. Push **STEP ε** up past `0.25`. Trajectories start glowing coral and the + **divergence** counter ticks — the integrator has gone unstable and every such + proposal is rejected. +5. Set **LEAPFROG L** to `1`. HMC collapses toward a random walk — the momentum never + gets to carry the ball anywhere. Watch the energy strip lurch. + +## What you just saw + +
+ +The target is an honest Bayesian linear regression — no banana, no toy density. Each +observation is Gaussian around the line, with **fixed** noise `σ_obs = 0.8`, and both +parameters get a `Normal(0, 2.5)` prior: + +$$\log \pi(\textcolor{#56D364}{a,b}) = \sum_i \textcolor{#F2CC60}{\log \mathcal N\!\big(y_i \mid a x_i + b,\ 0.8\big)} \;+\; \textcolor{#58A6FF}{\log \mathcal N(a \mid 0, 2.5)} \;+\; \textcolor{#58A6FF}{\log \mathcal N(b \mid 0, 2.5)}$$ + +The likelihood (yellow) pulls the line through the +points; the prior (blue) keeps the coefficients +sane; together they make the posterior (green) — the +heatmap on the right. + +HMC augments the position `q = (a, b)` with a fresh **momentum** `p` drawn from a +Gaussian, then simulates a physical system whose total energy is the **Hamiltonian**: + +$$H(q,p) = \textcolor{#56D364}{U(q)} + \textcolor{#BC8CFF}{K(p)}, \qquad +\textcolor{#56D364}{U(q)} = -\log \pi(q), \qquad +\textcolor{#BC8CFF}{K(p)} = \tfrac{1}{2}\,p^\top M^{-1} p$$ + +The potential energy **is** the negative log-posterior +— the green surface the ball rolls on. The kinetic energy +is the momentum you flick it with. Low posterior density means high potential, so the +ball is pulled toward the high-probability valley, exactly where you want samples. + +The trajectory is integrated by the **leapfrog** scheme — a half-kick to momentum, a +full drift in position, another half-kick: + +$$\textcolor{#BC8CFF}{p_{t+\frac12}} = \textcolor{#BC8CFF}{p_t} + \tfrac{\varepsilon}{2}\,\nabla_{\!q}\log\pi(\textcolor{#56D364}{q_t})$$ +$$\textcolor{#56D364}{q_{t+1}} = \textcolor{#56D364}{q_t} + \varepsilon\,M^{-1}\,\textcolor{#BC8CFF}{p_{t+\frac12}}$$ +$$\textcolor{#BC8CFF}{p_{t+1}} = \textcolor{#BC8CFF}{p_{t+\frac12}} + \tfrac{\varepsilon}{2}\,\nabla_{\!q}\log\pi(\textcolor{#56D364}{q_{t+1}})$$ + +Here the gradient `∇ log π` is available in closed form (a Gaussian model), and the +widget uses it exactly — that is why the leapfrog force points straight at the +posterior mode. Leapfrog is **reversible** and **volume-preserving**, so the +Metropolis correction at the end of the trajectory has no Jacobian term. It reduces to +a comparison of total energy at the endpoints: + +$$\alpha = \min\!\Big(1,\ \exp\big(H(q,p) - H(q',p')\big)\Big)$$ + +If the integrator were exact, `H` would be conserved and every proposal accepted. It +is not exact, so a small **energy error** `ΔH` remains — that is the number on the +strip chart. Keep it small (tune `ε`) and acceptance stays high. Let it explode and +the proposal **diverges**: the trajectory shoots off, `ΔH` blows past any sane bound, +and the sample is thrown away. Divergences are not a bug to hide — real samplers count +and report them as a warning that the geometry is too sharp for the current step size. + +
+ +## The fugue code + +Fugue ships HMC as `hmc_chain`. Models are ordinary Rust closures with no autodiff, so +the force `∇ log π` is computed by **central finite differences** — an approximate +*force*, but an exact accept/reject against the true log-density, so the stationary +distribution is exactly the posterior (the finite-difference step only costs +efficiency, never correctness). + +```rust,ignore +use fugue::*; +use rand::rngs::StdRng; +use rand::SeedableRng; + +// The widget's model: fit y = a·x + b with fixed observation noise. The green +// heatmap is exactly this posterior over (slope, intercept); the coral ball is +// the current (a, b) rolling toward the values the yellow points support. +fn regression(xs: Vec, ys: Vec) -> Model<(f64, f64)> { + prob! { + let a <- sample(addr!("slope"), Normal::new(0.0, 2.5).unwrap()); // blue prior + let b <- sample(addr!("intercept"), Normal::new(0.0, 2.5).unwrap()); // blue prior + // one yellow datum per point; sigma_obs fixed at 0.8 + let _obs <- plate!(i in xs.iter().zip(ys.iter()).enumerate() => { + let (k, (x, y)) = i; + observe(addr!("y", k), Normal::new(a * x + b, 0.8).unwrap(), *y) + }); + pure((a, b)) + } +} + +fn main() { + // The same twelve seeded points the widget draws. + let xs: Vec = (0..12).map(|i| -3.0 + 6.0 * i as f64 / 11.0).collect(); + let ys: Vec = xs.iter().map(|&x| 0.8 * x - 0.4).collect(); // + noise in practice + + let mut rng = StdRng::seed_from_u64(11); // a seed is a replayable trace + + // Mirror the widget's sliders: L leapfrog steps, an initial step size eps. + let config = HMCConfig { + n_leapfrog: 25, // the LEAPFROG L slider + init_step_size: Some(0.08), // the STEP ε slider (warmup still tunes it + // by dual averaging toward target_accept) + target_accept: 0.8, // Hoffman & Gelman's recommended target + ..HMCConfig::default() // finite_diff_eps = 1e-5, adapt_mass = false + }; + + // hmc_chain(rng, model_fn, n_samples, n_warmup, config) -> Vec<(A, Trace)> + let model_fn = move || regression(xs.clone(), ys.clone()); + let samples = hmc_chain(&mut rng, model_fn, 1000, 500, config); + + let slopes: Vec = samples.iter() + .filter_map(|(_, trace)| trace.get_f64(&addr!("slope"))) + .collect(); + let mean = slopes.iter().sum::() / slopes.len() as f64; + println!("posterior mean slope ≈ {mean:.3}"); // near the true 0.8 +} +``` + +The same **ESS** you watch converge in the widget is a real diagnostic. Run a few +chains and combine them with the multi-chain estimators: + +```rust,ignore +use fugue::*; + +// After collecting `chains: Vec>` from several hmc_chain runs: +let slope_series: Vec> = chains.iter() + .map(|c| c.iter().filter_map(|t| t.get_f64(&addr!("slope"))).collect()) + .collect(); + +let ess = effective_sample_size(&slope_series[0]); // per-chain ESS +let rhat = r_hat_f64(&chains, &addr!("slope")); // split-R̂: want < 1.01 +println!("ESS = {ess:.0}, R̂ = {rhat:.3}"); +``` + +`hmc_chain` holds any discrete sites fixed during the roll (a Metropolis-within-Gibbs +treatment), so compose it with `adaptive_mcmc_chain` when a model mixes continuous and +discrete latents. + +## Go deeper + +- **Compare by hand:** [Random Walks in Posterior Space](./metropolis.md) — the same + twelve points, sampled the slow way. +- **Tutorial:** [Basic Inference](../getting-started/basic-inference.md) covers when + to reach for HMC over MH or SMC. +- **API docs:** [`hmc_chain`](https://docs.rs/fugue-ppl/latest/fugue/inference/hmc/fn.hmc_chain.html) + and [`HMCConfig`](https://docs.rs/fugue-ppl/latest/fugue/inference/hmc/struct.HMCConfig.html). +- **Next:** [Particles That Tell Stories](./smc.md) — inference that moves through + time instead of space. + +--- + +Next: [Particles That Tell Stories](./smc.md) diff --git a/docs/src/explorables/metropolis.md b/docs/src/explorables/metropolis.md new file mode 100644 index 0000000..9b67670 --- /dev/null +++ b/docs/src/explorables/metropolis.md @@ -0,0 +1,189 @@ +# Random Walks in Posterior Space + +You have data and a line you want to fit through it — but you want the *whole +posterior* over slope and intercept, not a single answer. That posterior has no +formula you can read off. So you walk. Stand at some (slope, intercept), propose a +small random step, and keep it more often when it explains the data better. Do this +long enough and the places you linger *are* the posterior. + +That walk is Metropolis-Hastings. The score names a shape — here, the posterior over +two numbers; the walker is a performer who never sees the whole shape at once, only +whether the next fit sounds better or worse than the last. + +
+ +Two linked spaces. On the **left**, the data — twelve +points you can grab and drag. Every green line is a +recently accepted fit; the coral line is where each chain +stands now; rejected proposals flash coral-dashed and vanish. On the **right**, the +same walk seen in parameter space: a live posterior +heatmap over (slope, intercept), with the coral dot +threading it and blue proposal arrows firing each tick. +A point in the right panel *is* a line in the left panel — watch them move together. + +The model is honest Bayesian linear regression: +$y \sim \mathcal{N}(a\,x + b,\ \sigma_{\text{obs}})$ with $\sigma_{\text{obs}}$ fixed at +$0.8$, and priors $a, b \sim \mathcal{N}(0, 2.5)$. The +split-R̂ and ESS readouts are fugue's real +convergence diagnostics, computed live on the samples accruing on screen. + +## Things to try + +1. **The chains are already walking — drag a point far off the line.** The right-hand + heatmap morphs and the whole chain migrates to the new best fit — same frame. This + is the moment: the data *is* the posterior, and you are reshaping it with your cursor. +2. **Drag `PROPOSAL σ` down to `0.02`.** Acceptance climbs toward 100% — every tiny + step is safe — yet the coral dot barely crawls and R̂ stays stubbornly above 1. High + acceptance is not the goal. +3. **Now drag σ up to `4`.** Steps overshoot the tight posterior ridge and almost every + proposal is rejected. The chain freezes, twitching in place. Too bold is as stuck as + too timid. +4. **Find the Goldilocks band** (σ near `0.3`–`0.6`). Acceptance settles around 25–45%, + the green spaghetti fans tightly around the true line, and R̂ falls toward 1.0. +5. **Set `CHAINS` to 4.** They start from dispersed corners of parameter space. When σ is + small, watch them stay marooned apart — R̂ stays high because they *disagree*. One + chain alone could never have told you it was stuck. + +## What you just saw + +Metropolis-Hastings builds a Markov chain whose stationary distribution *is* the +posterior. Write the parameters as $\textcolor{#FF7B72}{\theta} = (a, b)$. From the +current state you draw a proposal $\textcolor{#58A6FF}{\theta'}$ from a symmetric +Gaussian kernel, $\textcolor{#58A6FF}{\theta'} \sim +\mathcal{N}(\textcolor{#FF7B72}{\theta}, \sigma^2 I)$, and accept it with probability + +$$\alpha = \min\!\left(1,\ \frac{\textcolor{#58A6FF}{p(\theta')}\ \textcolor{#F2CC60}{p(\mathcal{D}\mid\theta')}}{\textcolor{#58A6FF}{p(\theta)}\ \textcolor{#F2CC60}{p(\mathcal{D}\mid\theta)}}\right).$$ + +The ratio is prior times +likelihood, new over old — the evidence cancels, which +is the whole trick: you never need the intractable normalizer. Because the proposal is +symmetric, $q(\theta'\mid\theta)=q(\theta\mid\theta')$ drops out too. Here the prior is +$\mathcal{N}(0,2.5)$ on each of $a,b$ and the likelihood is the product of the twelve +$\mathcal{N}(a x_i + b,\ 0.8)$ terms — one per yellow point. Computed in log space, +where the widget lives, acceptance is a subtraction: + +$$\log\alpha = \min\!\big(0,\ \log p(\textcolor{#58A6FF}{\theta'}) - \log p(\textcolor{#FF7B72}{\theta})\big).$$ + +Uphill moves ($\log\alpha = 0$) are always taken; downhill moves are taken with +probability $e^{\log\alpha}$. That occasional downhill step is what lets the chain +explore the full spread of plausible lines instead of collapsing onto the single best +fit — which is exactly the green spaghetti you see fanning around the data. + +**Why σ is a dial, not a detail.** The proposal scale trades off two failures. Too small +and consecutive samples are nearly identical — high autocorrelation, so your 2000 draws +carry the information of a handful. Too large and you reject constantly, so the chain +sits still — again few effective draws. The +effective sample size (ESS) measures exactly this: how +many *independent* draws your correlated chain is worth. + +
+ +$$\mathrm{ESS} = \frac{mn}{\hat\tau}, \qquad \hat\tau = 1 + 2\sum_{k\ge 1}\hat\rho_k,$$ + +where $\hat\rho_k$ is the autocorrelation at lag $k$ and $\hat\tau$ the integrated +autocorrelation time. Fugue estimates $\hat\tau$ with Geyer's initial positive sequence, +pooled across chains (Vehtari et al. 2021). + +
+ +**Why more than one chain.** A single walker stuck in one corner of parameter space looks +perfectly converged from the inside. Split-R̂ compares the +variance *between* chains to the variance *within* them, after splitting each chain in +half so a slow within-chain drift can't hide: + +$$\hat R = \sqrt{\frac{\widehat{\mathrm{var}}^{+}}{W}}, \qquad +\widehat{\mathrm{var}}^{+} = \frac{n-1}{n}W + \frac{1}{n}B.$$ + +$W$ is the within-chain variance, $B$ the between-chain variance. When the chains agree, +$B \to 0$ and $\hat R \to 1$. Anything above $1.01$ means they haven't mixed. This is the +same split statistic fugue reports from +[`r_hat_f64`](https://docs.rs/fugue-ppl/latest/fugue/fn.r_hat_f64.html) — the widget +ports its arithmetic verbatim. + +## The fugue code + +The widget tunes σ by hand. Fugue's `adaptive_mcmc_chain` does it for you, nudging each +site's proposal scale toward the 0.44 acceptance rate you just discovered is healthy — +then you referee convergence with the very diagnostics on screen. This is the same +regression model as the widget: `slope` and `intercept` sampled from priors, one +`observe` per data point. + +```rust,ignore +use fugue::*; +use fugue::inference::mh::adaptive_mcmc_chain; +use fugue::inference::diagnostics::r_hat_f64; +use fugue::inference::mcmc_utils::effective_sample_size_mcmc; +use rand::{rngs::StdRng, SeedableRng}; + +// Bayesian linear regression — the same model the widget samples. +// slope (a) and intercept (b) each get a Normal(0, 2.5) prior; every data +// point contributes one yellow observation y_i ~ Normal(a*x_i + b, 0.8). +fn regression(x: Vec, y: Vec) -> impl Fn() -> Model<(f64, f64)> { + move || { + let x = x.clone(); + let y = y.clone(); + prob! { + let a <- sample(addr!("slope"), Normal::new(0.0, 2.5).unwrap()); + let b <- sample(addr!("intercept"), Normal::new(0.0, 2.5).unwrap()); + // One observe per point — this loop IS the yellow data panel. + let _obs <- plate!(i in 0..x.len() => { + observe(addr!("y", i), Normal::new(a * x[i] + b, 0.8).unwrap(), y[i]) + }); + pure((a, b)) + } + } +} + +fn main() { + // Synthetic data from the true line y = 0.8x - 0.4 + noise, same as the widget. + let x: Vec = (0..12).map(|i| -3.0 + 6.0 * i as f64 / 11.0).collect(); + let mut dgen = StdRng::seed_from_u64(11); + let y: Vec = x + .iter() + .map(|&xi| Normal::new(0.8 * xi - 0.4, 0.8).unwrap().sample(&mut dgen)) + .collect(); + + // Reproducibility is a fugue value: a seed *is* the chain. Run four chains + // and let split-R-hat referee whether they agree. + let mut chains: Vec> = Vec::new(); + for seed in [11u64, 12, 13, 14] { + let mut rng = StdRng::seed_from_u64(seed); + // 2000 kept draws, 500 warmup steps of proposal adaptation. + let draws = adaptive_mcmc_chain(&mut rng, regression(x.clone(), y.clone()), 2_000, 500); + chains.push(draws.into_iter().map(|(_, trace)| trace).collect()); + } + + // Split-R-hat (Vehtari et al. 2021) and ESS — the same convergence + // diagnostics the widget shows. + let rhat = r_hat_f64(&chains, &addr!("slope")); + let slope_chain0: Vec = chains[0] + .iter() + .filter_map(|t| t.get_f64(&addr!("slope"))) + .collect(); + let ess = effective_sample_size_mcmc(&slope_chain0); + + println!("split-R-hat(slope) = {rhat:.3} ESS(slope, chain 0) = {ess:.0}"); +} +``` + +`adaptive_mcmc_chain(&mut rng, model_fn, 2_000, 500)` runs single-site Metropolis with a +diminishing-adaptation schedule targeting 0.44 acceptance — the middle of the Goldilocks +band from **Things to try #4**. It returns `Vec<(A, Trace)>`: the returned value and the +full recording behind it. `r_hat_f64` is the coral/green R̂ readout; +`effective_sample_size_mcmc` is the ESS readout. + +## Go deeper + +- **Next explorable:** [Rolling, Not Guessing: HMC](./hmc.md) — the same regression, the + same twelve points, but the walker rolls downhill with momentum instead of guessing, + and covers the posterior in a fraction of the steps. +- **Tutorial:** [Basic Inference](../getting-started/basic-inference.md) — running MCMC + end to end on a real model. +- **How-to:** [Debugging Models](../how-to/debugging-models.md) — reading R̂ and ESS when + a chain misbehaves. +- **API:** [`adaptive_mcmc_chain`](https://docs.rs/fugue-ppl/latest/fugue/fn.adaptive_mcmc_chain.html) + · [`r_hat_f64`](https://docs.rs/fugue-ppl/latest/fugue/fn.r_hat_f64.html) + +--- + +Next: [Rolling, Not Guessing: Hamiltonian Monte Carlo](./hmc.md) diff --git a/docs/src/explorables/monad.md b/docs/src/explorables/monad.md new file mode 100644 index 0000000..8f8dc18 --- /dev/null +++ b/docs/src/explorables/monad.md @@ -0,0 +1,209 @@ +# The Model Is a Score + +A `Model` in fugue is not a running program. It is a **score** — pure notation +that describes what *could* happen, note by note, but makes no sound on its own. +A **handler** is the performer that reads the score and decides what each note +means: improvise a fresh value, or replay one from a recording. + +Below is a real inference problem. Five data +points say where the world landed; a prior +says what you believed about the mean μ beforehand; the +posterior is the answer — updated live. Grab a +yellow dot and drag it. The green curve follows in real time. The strip below the +picture is the machinery fugue actually steps through to get there. + +This run uses seed 11. A seeded run is a replayable trace — the same seed always draws the same μ. + +
+ +## Things to try + +1. Drag any yellow dot to the right. The + green posterior slides after it and the + posterior mean readout climbs — the data pulled your belief. +2. Press **Step** five times. Watch the SampleF64 + chip fire first (it sets μ), then each ObserveF64 + chip light up **and its data dot pulse in the picture** — chip and dot are the + same event, seen twice. +3. Press **Perform ×200** with the **PriorHandler** active: 200 fresh μ draws + rain down as a blue cloud that traces the prior + curve. The handler is improvising. +4. Flip on the **Replay handler** and press **Perform ×200** again: all 200 draws + stack on one value — a single coral spike. The + handler is performing a fixed recording, not drawing. +5. Scrub the **seed** in prior mode — the coral μ tick + jumps to a new draw. Scrub it in replay mode — it does not move. Same score, + different performer. + +## What you just saw + +The score is a tiny probabilistic program: sample a mean, then observe five data +points drawn from it. + +$$ +\textcolor{#58A6FF}{\mu \sim \mathrm{Normal}(0,\,2)}, \qquad +\textcolor{#F2CC60}{y_i \sim \mathrm{Normal}(\mu,\,1)} \quad i = 1,\dots,5. +$$ + +Because both the prior and the likelihood are Gaussian, the +posterior over μ is Gaussian too, in closed form — +this is the exact green curve you were dragging: + +$$ +\textcolor{#56D364}{\mu \mid y \sim \mathrm{Normal}(\mu_n,\,\sigma_n^2)}, \qquad +\sigma_n^2 = \left(\tfrac{1}{2^2} + \tfrac{n}{1^2}\right)^{-1}, \qquad +\mu_n = \sigma_n^2\left(\tfrac{\textcolor{#58A6FF}{0}}{2^2} + \tfrac{\textcolor{#F2CC60}{\sum_i y_i}}{1^2}\right). +$$ + +With the five default points (∑yᵢ = 6.0), that is +Normal(1.143, 0.436²) — the numbers in the +readout. The score never computes this. The handler does, one node at a time, +accumulating a log-weight: + +$$ +\underbrace{\textcolor{#56D364}{\log p(\mu, y)}}_{\text{total log-weight}} += \underbrace{\textcolor{#58A6FF}{\log p(\mu)}}_{\texttt{log\_prior}} ++ \underbrace{\textcolor{#F2CC60}{\textstyle\sum_i \log p(y_i \mid \mu)}}_{\texttt{log\_likelihood}}. +$$ + +The key idea: **effects are interpreted, not performed.** The score names a +sample site; it does not draw. Whether a value is improvised +(PriorHandler) or replayed +(ReplayHandler) is the handler's decision, made +later. That is what the Perform ×200 button shows: the *same score* becomes a +cloud of guesses or a single fixed spike, depending only on who performs it. This +separation is the machinery every inference algorithm in fugue is built from — +and fugue's MCMC reaches that same green curve *without* knowing the conjugate +formula, by walking this chain over and over. + +### Monads, demystified in one paragraph + +A `Model` is built from two operations. `bind` is **"and then"**: run this node, +then feed its value to a function that produces the next node. `pure` is +**"done"**: wrap a plain value as a finished model. That is the entire monad — +`and then` and `done`. Each node stores its continuation `k`, the rest of the +score. The score is a linked list of "and then"s ending in a "done", and it does +not play itself. + +
+ +## The fugue code + +Here is the exact score from the widget. The `sample` line is the +SampleF64 chip; each `observe` in the fold is an +ObserveF64 chip lighting its yellow dot; +`.map(move |_| mu)` is the trailing Pure(μ). + +```rust +use fugue::*; +use rand::rngs::StdRng; +use rand::SeedableRng; + +// The score: sample the mean, then observe five data points given it. +fn model(data: Vec) -> Model { + sample(addr!("mu"), Normal::new(0.0, 2.0).unwrap()).bind(move |mu| { + // Fold the observations into the chain: each one is an ObserveF64 node. + let mut m = pure(mu); + for (i, y) in data.into_iter().enumerate() { + m = m.bind(move |mu| { + observe(addr!("y", i), Normal::new(mu, 1.0).unwrap(), y) + .map(move |_| mu) + }); + } + m // ends in Pure(mu): the score terminates, returning mu + }) +} + +fn main() { + let data = vec![1.3, 0.7, 2.1, 0.4, 1.5]; + + // PriorHandler improvises: it draws a fresh mu from the RNG. + let mut rng = StdRng::seed_from_u64(11); + let (mu, recording) = runtime::handler::run( + PriorHandler { rng: &mut rng, trace: Trace::default() }, + model(data.clone()), + ); + println!("prior draw: mu = {mu:.3}, total log-weight = {:.3}", + recording.total_log_weight()); + + // ReplayHandler performs that recording: the same mu, no fresh draw. + let (mu_again, _) = runtime::handler::run( + ReplayHandler { rng: &mut rng, base: recording, trace: Trace::default() }, + model(data), + ); + assert_eq!(mu, mu_again); // same score, replayed exactly +} +``` + +Under the hood, the score is the `Model` enum. Every effect variant carries its +address, its distribution, and a boxed continuation `k` — the rest of the score +(abridged from `src/core/model.rs`): + +```rust,ignore +pub enum Model { + Pure(A), + SampleF64 { + addr: Address, + dist: Box>, + k: Box Model + Send + 'static>, // "and then" + }, + ObserveF64 { + addr: Address, + dist: Box>, + value: f64, + k: Box Model + Send + 'static>, + }, + Factor { logw: f64, k: Box Model + Send + 'static> }, + // ... plus SampleBool/U64/Usize/I64 and their Observe twins, one per + // return type — this is fugue's type-safety story: a Bernoulli site yields + // a bool, a Poisson site a u64, never an untyped float. +} +``` + +The handler you toggled is any type that answers those effects. `run` is the +interpreter — a flat trampoline, not recursion (abridged from +`src/runtime/handler.rs`): + +```rust,ignore +pub fn run(mut h: impl Handler, m: Model) -> (A, Trace) { + let mut m = m; + let a = loop { + m = match m { + Model::Pure(a) => break a, // "done": return the value + Model::SampleF64 { addr, dist, k } => { + let x = h.on_sample_f64(&addr, &*dist); // ask the handler + k(x) // advance to the rest + } + Model::ObserveF64 { addr, dist, value, k } => { + h.on_observe_f64(&addr, &*dist, value); // score the data + k(()) + } + Model::Factor { logw, k } => { h.on_factor(logw); k(()) } + // ... one arm per variant ... + }; + }; + (a, h.finish()) +} +``` + +Each node hands back its continuation `k(value)` and the loop goes around again. +Nothing recurses, so the interpreter runs in **constant stack depth** — a model +with 100 000 sample sites is interpreted without overflowing the stack. + +## Go deeper + +- [Understanding Models](../getting-started/understanding-models.md) — the + conceptual tour of `Model`, `bind`, and `pure`. +- [Custom Handlers](../how-to/custom-handlers.md) — write your own performer. +- [Trace Manipulation](../tutorials/foundation/trace-manipulation.md) — read and + edit the recordings a handler produces. +- [`Model`](https://docs.rs/fugue-ppl/latest/fugue/enum.Model.html) and + [`Handler`](https://docs.rs/fugue-ppl/latest/fugue/trait.Handler.html) on + docs.rs. +- Next explorable: [Random Walks in Posterior Space](./metropolis.md) — now that + the score has a value and a weight, how do we *listen* our way back to the + posterior you just watched form? + +--- + +Next: [Random Walks in Posterior Space](./metropolis.md) diff --git a/docs/src/explorables/smc.md b/docs/src/explorables/smc.md new file mode 100644 index 0000000..fd4dfa4 --- /dev/null +++ b/docs/src/explorables/smc.md @@ -0,0 +1,206 @@ +# Particles That Tell Stories + +A hidden thing moves through time. You never see it directly — only noisy +glimpses, one per tick. How do you track it? You send a swarm of guesses forward, +reward the ones that fit each new glimpse, and let the rest die. That swarm is a +**particle filter**, and every guess is a little story about where the truth went. + +The score is a state-space model; the particles are performers improvising the +hidden path; each observation is a critic that reweights them. Watch time flow +left to right. + +
+ +The yellow dots are what you observe. The dashed +ink line is the truth (you never get to see it during real inference). The +blue circles are particles — each radius is that +particle's weight. The particles are the machinery; the +green is the answer. At the current time step, the +green violin is the **filtering distribution** — +the weighted swarm's full belief about \\( x_t \\) right now, drawn as a +weighted-particle density. The green line traces the +filtered mean over time, and the green band around +it is that belief's ±1σ spread — how sure the filter is, tick by tick. + +## Things to try + +1. Press **Step** a few times. Each tick does three things: **propagate** the + particles forward, **weight** them against the new yellow dot, then + **resample** if they have grown too uneven. +2. Drag **particles** down to 10 and press **Play**. The cloud collapses onto one + or two lineages within a few steps — *degeneracy*, the failure mode SMC exists + to fight. +3. Turn **adaptive resample** off and play. With no resampling, a single particle + swallows almost all the weight; ESS / N crashes toward 1/N and the green + estimate goes deaf. +4. Push **obs noise** up. Flatter likelihoods mean gentler reweighting, so ESS + stays high and lineages survive longer — and watch the green + band fatten: looser data means a less certain filter, and the filtering + violin spreads to match. +5. Change the **seed** scrub, then reset. A seeded run is a replayable trace: same + seed, same particles, same story, every time. + +
+ +## What you just saw + +The hidden path is a **random walk**; each observation is a **noisy read** of it: + +$$ +\textcolor{#58A6FF}{x_t \sim \mathcal{N}(x_{t-1},\, \sigma)} +\qquad +\textcolor{#F2CC60}{y_t \sim \mathcal{N}(x_t,\, \tau)} +$$ + +The blue line is the **transition** — how a +particle proposes its next position. The yellow +line is the **likelihood** — how well that position explains the observation. + +**Propagate.** Every particle draws its next state from the transition. On the +canvas this is the drift step: the whole cloud slides one column right. + +**Weight.** Each particle's weight is multiplied by how likely the new +observation was under it: + +$$ +\tilde{w}_t^{(i)} \;\propto\; W_{t-1}^{(i)}\;\textcolor{#F2CC60}{p\!\left(y_t \mid x_t^{(i)}\right)} +$$ + +Particles near the yellow dot fatten; particles far from it shrink. Normalize and +you have the new weights. + +**The filtering distribution.** The weighted swarm *is* an estimate of one +distribution: \\( p(x_t \mid y_{1:t}) \\), your belief about the hidden state given +every observation so far. That is what the green violin +draws — a weighted-particle kernel density of \\( \{x_t^{(i)}, W_t^{(i)}\} \\) at the +current column. Its mean and ±1σ +band are the one-number summaries you actually report: + +$$ +\hat{\mu}_t = \sum_i W_t^{(i)}\, x_t^{(i)} +\qquad +\hat{\sigma}_t^2 = \sum_i W_t^{(i)}\left(x_t^{(i)} - \hat{\mu}_t\right)^2 +$$ + +**Measure health.** The **effective sample size** counts how many particles are +really doing work: + +$$ +\mathrm{ESS} \;=\; \frac{1}{\sum_i \left(W_t^{(i)}\right)^2} +$$ + +All weight on one particle gives ESS = 1; perfectly even weights give ESS = N. +The readout shows ESS / N, green above 0.5, +coral below. + +**Resample.** When ESS / N falls under the threshold, draw a fresh population by +sampling parents in proportion to weight, then reset weights to uniform. Fat +particles spawn duplicates (the violet fan); starved particles go +extinct. This is the moment of the page: the swarm +forgets its dead ends and concentrates where the evidence is. fugue's default +threshold is exactly this — resample when ESS / N < 0.5. + +**Evidence, for free.** Each weighting step also hands you a piece of the marginal +likelihood. Summing the log of each step's mean weight gives an unbiased estimate +of the log-evidence: + +$$ +\log \textcolor{#56D364}{p(y_{1:T})} +\;=\; +\sum_{t} \log \sum_i W_{t-1}^{(i)}\;\textcolor{#F2CC60}{p\!\left(y_t \mid x_t^{(i)}\right)} +$$ + +That number — the `log-evidence` readout — is what makes SMC a model-comparison +tool, not merely a sampler. + +## The fugue code + +fugue expresses the same state-space model as one `Model`. Each latent state +depends on the previous one; each `observe` is a yellow +dot. + +```rust,ignore +use fugue::*; + +const STEP: f64 = 0.7; // latent random-walk step σ +const OBS: f64 = 0.6; // observation noise τ + +// x_0 ~ N(0,1); x_t ~ N(x_{t-1}, STEP); observe each y_t ~ N(x_t, OBS). +// Returns the whole latent path. +fn state_space(ys: Vec) -> Model> { + let y0 = ys[0]; + let mut m: Model> = sample(addr!("x", 0), Normal::new(0.0, 1.0).unwrap()) + .bind(move |x0| { + observe(addr!("y", 0), Normal::new(x0, OBS).unwrap(), y0).map(move |_| vec![x0]) + }); + for t in 1..ys.len() { + let yt = ys[t]; + m = m.bind(move |xs| { + let prev = *xs.last().unwrap(); // depend on the previous state + sample(addr!("x", t), Normal::new(prev, STEP).unwrap()).bind(move |xt| { + observe(addr!("y", t), Normal::new(xt, OBS).unwrap(), yt) // the yellow dot + .map(move |_| { + let mut xs = xs; + xs.push(xt); + xs + }) + }) + }); + } + m +} +``` + +Run Sequential Monte Carlo over it. The config is the same knobs you just played +with: + +```rust,ignore +use fugue::*; +use rand::rngs::StdRng; +use rand::SeedableRng; + +fn main() { + let ys: Vec = vec![/* your observation series */]; + + let mut rng = StdRng::seed_from_u64(42); + let config = SMCConfig { + resampling_method: ResamplingMethod::Systematic, // fugue's default + ess_threshold: 0.5, // resample when ESS / N < 0.5 — the green/coral band + rejuvenation_steps: 1, // MCMC moves that restore diversity after a resample + }; + + let result = adaptive_smc(&mut rng, 200, || state_space(ys.clone()), config); + + println!("particles: {}", result.particles.len()); + println!("ESS: {:.1}", effective_sample_size(&result)); // the ESS readout + println!("log-evidence: {:.3}", result.log_evidence); // unbiased log p(y_1:T) +} +``` + +Every name here is real: `adaptive_smc`, `SMCConfig`, `ResamplingMethod::Systematic`, +`effective_sample_size`, and `SMCResult::log_evidence` all live in +`src/inference/smc.rs`. `SMCResult` dereferences to `Vec`, so +`effective_sample_size(&result)` works directly on the returned population. + +```admonish note title="Tempering vs. time" +The widget is a textbook **bootstrap filter**: it steps through the sequence of +observations in *time*. fugue's `adaptive_smc` reaches the same posterior along a +different ladder — it **tempers the likelihood**, targeting +\\( \pi_\beta(\theta) \propto p(\theta)\,p(y\mid\theta)^\beta \\) for \\( \beta \\) +climbing 0 → 1. The machinery is identical either way: weight, watch ESS, resample +below a threshold, and accumulate an unbiased log-evidence. What you learned by +dragging sliders is exactly what the library does under the hood. +``` + +## Go deeper + +- Tutorial: [Sequential Monte Carlo](../tutorials/advanced-inference/sequential-monte-carlo.md) + — the full API, resampling methods, and rejuvenation. +- API: [`adaptive_smc`](https://docs.rs/fugue-ppl/latest/fugue/inference/smc/fn.adaptive_smc.html) + and [`effective_sample_size`](https://docs.rs/fugue-ppl/latest/fugue/inference/smc/fn.effective_sample_size.html). +- Next explorable: [A Field Guide to Distributions](./distributions.md) — the + building blocks every model above is made of. + +--- + +Next: [A Field Guide to Distributions](./distributions.md) diff --git a/docs/src/getting-started/README.md b/docs/src/getting-started/README.md index 7a45afd..554e017 100644 --- a/docs/src/getting-started/README.md +++ b/docs/src/getting-started/README.md @@ -78,7 +78,7 @@ Separate model specification from execution strategy through handlers. ### 📊 **Diagnostics Built In** -R-hat, effective sample size, memory-optimized traces, and a structured error taxonomy. +Split-R̂, autocorrelation-based and multi-chain effective sample size, and a structured error taxonomy. ## Architecture Overview @@ -94,11 +94,12 @@ graph TB subgraph "Core System" C[Distributions & Types] H[Handlers & Interpreters] - T[Traces & Memory] + T[Traces] end subgraph "Inference Engines" MCMC[MCMC Sampling] + HMC[Hamiltonian Monte Carlo] SMC[Particle Filtering] VI[Variational Inference] ABC[ABC Methods] @@ -109,6 +110,7 @@ graph TB C --> H H --> T T --> MCMC + T --> HMC T --> SMC T --> VI T --> ABC @@ -125,6 +127,8 @@ graph TB **Fugue** makes this safe, fast, and composable in Rust. +
+ ## Next Steps Ready to dive in? diff --git a/docs/src/getting-started/basic-inference.md b/docs/src/getting-started/basic-inference.md index c6807d8..a22aca1 100644 --- a/docs/src/getting-started/basic-inference.md +++ b/docs/src/getting-started/basic-inference.md @@ -11,13 +11,17 @@ Learning Goals In 5 minutes, you'll understand: - What inference is and why you need it -- Fugue's main inference algorithms (MCMC, SMC, VI, ABC) +- Fugue's main inference algorithms (MCMC, HMC, SMC, VI, ABC) - When to use each algorithm - How to run inference and interpret results **Time**: ~5 minutes ``` +```admonish tip title="Try it live" +Feel the difference between algorithms with your own hands: **[Random Walks in Posterior Space](../explorables/metropolis.md)** (Metropolis-Hastings) and **[Rolling, Not Guessing](../explorables/hmc.md)** (Hamiltonian Monte Carlo) run the same 2D posterior side by side so you can see why gradients help. +``` + ## What is Inference? **Inference** is the process of learning about model parameters after seeing data. In Bayesian terms: @@ -104,7 +108,49 @@ fn main() { - ✅ Can afford computation time - ✅ Model evaluation is reasonably fast -### 2. SMC (Sequential Monte Carlo) 🎯 +### 2. HMC (Hamiltonian Monte Carlo) 🎢 + +**Best for**: Continuous, correlated, or higher-dimensional posteriors where single-site MCMC mixes slowly + +**How it works**: Treats the negative log-posterior as a landscape and rolls a simulated ball across it using its gradient, moving every continuous parameter together instead of one at a time + +```rust,ignore +use fugue::*; +use rand::rngs::StdRng; +use rand::SeedableRng; + +fn main() { + let mut rng = StdRng::seed_from_u64(42); + + // Same conjugate model, but HMC moves `bias` using gradient information + // instead of a random-walk proposal. + let samples = inference::hmc::hmc_chain( + &mut rng, + || coin_bias_model(7, 10), + 1000, // number of samples + 500, // warmup iterations (step-size adaptation) + HMCConfig::default(), // 16 leapfrog steps, target 80% acceptance + ); + + let bias_samples: Vec = samples.iter() + .filter_map(|(_, trace)| trace.get_f64(&addr!("bias"))) + .collect(); + + let mean_bias = bias_samples.iter().sum::() / bias_samples.len() as f64; + println!("HMC estimated bias: {:.3}", mean_bias); +} +``` + +Fugue's HMC computes the gradient with central finite differences (models are plain Rust closures, not auto-diff traces), then uses a leapfrog integrator and an exact Metropolis correction against the true log-density — the finite-difference force only affects efficiency, never correctness. Step size is tuned automatically during warmup via dual averaging toward an 80% target acceptance rate (Hoffman & Gelman 2014); discrete sites are held fixed for the duration of the HMC update (Metropolis-within-Gibbs), so compose with `adaptive_mcmc_chain` when a model mixes continuous and discrete latents. + +**When to use HMC:** + +- ✅ All (or most) latent variables are continuous +- ✅ Parameters are correlated (HMC exploits gradient direction; single-site MH can't) +- ✅ You want fewer iterations to reach the same effective sample size +- ⚠️ Purely discrete models get no benefit — HMC degenerates to prior draws when there are no continuous sites + +### 3. SMC (Sequential Monte Carlo) 🎯 **Best for**: Sequential data and online learning @@ -152,7 +198,11 @@ fn main() { - ✅ Many discrete latent variables - ✅ Want to visualize inference process -### 3. Variational Inference (VI) ⚡ +```admonish note title="Beyond prior particles" +`smc_prior_particles` above shows the mechanics with plain importance sampling. For a real particle filter — likelihood tempering, adaptive resampling, and rejuvenation — use `inference::smc::adaptive_smc(&mut rng, num_particles, model_fn, SMCConfig::default())`. Its result also carries `log_evidence`: an unbiased estimate of the log marginal likelihood, useful for model comparison. See `examples/smc_inference.rs`. +``` + +### 4. Variational Inference (VI) ⚡ **Best for**: Fast approximate inference with many parameters @@ -187,7 +237,11 @@ fn main() { - ✅ Can accept approximation error - ✅ Want predictable runtime -### 4. ABC (Approximate Bayesian Computation) 🎲 +```admonish note title="Fitting a guide for real" +`estimate_elbo` above uses the prior itself as the variational guide — a zero-setup bound, but usually loose. For a fitted guide, build a `MeanFieldGuide` (support-matched Normal/LogNormal/Beta factors per latent) and call `inference::vi::optimize_meanfield_vi_with_config`, which optimizes both location and scale via common-random-numbers gradients. See `examples/vi_inference.rs`. +``` + +### 5. ABC (Approximate Bayesian Computation) 🎲 **Best for**: Models where likelihood is intractable or expensive @@ -231,11 +285,16 @@ fn main() { - ✅ Have good summary statistics - ✅ Can tolerate approximation error +```admonish note title="ABC-SMC for harder problems" +`abc_scalar_summary` above is plain rejection ABC — simple, but wasteful when the tolerance is tight. `inference::abc::abc_smc_weighted` anneals the tolerance across rounds with importance-weighted particles (replacing the biased prior-replacement heuristic older ABC-SMC implementations used), giving much better acceptance at tight tolerances. See `examples/abc_inference.rs`. +``` + ## Algorithm Comparison | Method | Speed | Accuracy | Use Case | | -------- | --------- | -------------- | ---------------------------------------- | | **MCMC** | 🐌 Slow | 🎯 Exact | General-purpose, exact inference | +| **HMC** | 🐌 Slow | 🎯 Exact | Continuous, correlated, higher-dimensional posteriors | | **SMC** | 🏃 Medium | 🎯 Good | Sequential data, online learning | | **VI** | 🚀 Fast | ⚠️ Approximate | Large models, fast approximate inference | | **ABC** | 🐌 Slow | ⚠️ Approximate | Intractable likelihoods | @@ -279,9 +338,11 @@ fn inference_workflow() { println!(" Mean: {:.3}", mean); println!(" Std Dev: {:.3}", std_dev); - // 5. Check convergence (effective sample size) + // 5. Check convergence (autocorrelation-based effective sample size) let ess = inference::diagnostics::effective_sample_size(&bias_samples); println!(" Effective Sample Size: {:.1}", ess); + // For multiple chains, also check split-R̂ (inference::diagnostics::r_hat_f64) + // and inference::mcmc_utils::effective_sample_size_multichain. if ess > 100.0 { println!(" ✅ Good mixing!"); @@ -296,6 +357,8 @@ fn inference_workflow() { } ``` +
+ ## Choosing the Right Algorithm ### Decision Tree @@ -314,23 +377,27 @@ graph TD D -->|Yes > 100| VI[Variational Inference] D -->|No < 100| E[Need exact samples?] - E -->|Yes| MCMC[MCMC] + E -->|Yes| F[Mostly continuous & correlated?] E -->|No| VI2[VI for speed] + + F -->|Yes| HMC[HMC] + F -->|No, mostly discrete| MCMC[MCMC] ``` ### Rules of Thumb 1. **Start with MCMC** for most problems - it's the most general -2. **Use SMC** if you have sequential/streaming data -3. **Use VI** if you need speed and can accept approximation -4. **Use ABC** only when likelihood is truly intractable +2. **Reach for HMC** when parameters are continuous and correlated - it mixes far faster than single-site MCMC by using the gradient +3. **Use SMC** if you have sequential/streaming data +4. **Use VI** if you need speed and can accept approximation +5. **Use ABC** only when likelihood is truly intractable ## Key Takeaways You now know how to extract insights from your models: ✅ **Inference Purpose**: Learn parameters from data using Bayesian updating -✅ **Algorithm Options**: MCMC, SMC, VI, ABC each have their strengths +✅ **Algorithm Options**: MCMC, HMC, SMC, VI, ABC each have their strengths ✅ **Practical Workflow**: Define model → Run inference → Extract parameters → Check diagnostics ✅ **Algorithm Selection**: Choose based on problem characteristics and requirements diff --git a/docs/src/getting-started/installation.md b/docs/src/getting-started/installation.md index f56b44c..2899901 100644 --- a/docs/src/getting-started/installation.md +++ b/docs/src/getting-started/installation.md @@ -166,14 +166,14 @@ Fugue comes with comprehensive examples to explore: ```bash # Clone the repository to access examples -git clone https://github.com/your-org/fugue-ppl -cd fugue-ppl +git clone https://github.com/alexnodeland/fugue +cd fugue # List available examples ls examples/ # Run a simple example -cargo run --example gaussian_mean -- --obs 2.5 --seed 42 +cargo run --example bayesian_coin_flip # Try a more complex one cargo run --example working_with_distributions @@ -213,8 +213,8 @@ ls examples/ If you encounter issues: -1. Check the [GitHub Issues](https://github.com/alexnodeland/fugue-ppl/issues) -2. Review the [examples](https://github.com/alexnodeland/fugue-ppl/tree/main/examples) for working code +1. Check the [GitHub Issues](https://github.com/alexnodeland/fugue/issues) +2. Review the [examples](https://github.com/alexnodeland/fugue/tree/main/examples) for working code 3. Read the [API documentation](https://docs.rs/fugue-ppl) ## Next Steps diff --git a/docs/src/getting-started/understanding-models.md b/docs/src/getting-started/understanding-models.md index e4abbde..7159902 100644 --- a/docs/src/getting-started/understanding-models.md +++ b/docs/src/getting-started/understanding-models.md @@ -18,6 +18,10 @@ In 8 minutes, you'll understand: **Time**: ~8 minutes ``` +```admonish tip title="Try it live" +**[The Model Is a Score](../explorables/monad.md)** is a step-debugger for the Model monad: step through the real `SampleF64`/`ObserveF64`/`Pure` chain and swap handlers (PriorHandler vs. ReplayHandler) to see execution and specification separate before your eyes. +``` + ## The Big Picture: Models vs Execution One of Fugue's key insights is **separating model specification from execution**: @@ -44,6 +48,8 @@ graph LR - **Replay** (MCMC proposals) - **Scoring** (compute probabilities) +
+ ## Addresses: The Key to Advanced Inference Every `sample` and `observe` site needs a **unique address**: diff --git a/docs/src/getting-started/your-first-model.md b/docs/src/getting-started/your-first-model.md index f92c139..c841cae 100644 --- a/docs/src/getting-started/your-first-model.md +++ b/docs/src/getting-started/your-first-model.md @@ -18,6 +18,10 @@ In 5 minutes, you'll understand: **Time**: ~5 minutes ``` +```admonish tip title="Try it live" +Before diving into code, play with **[Anatomy of a Probabilistic Program](../explorables/anatomy.md)** — a touchable coin-flip Bayes loop that shows prior, data, and posterior updating in real time. +``` + ## Step 1: The Simplest Model Let's start with the simplest possible model - one that always returns the same value: @@ -99,6 +103,8 @@ Understanding the Output - **`log_probability`** - How likely this particular execution was ``` +
+ ## Step 4: Type Safety in Action Fugue's type safety really shines with discrete distributions: diff --git a/docs/src/home.md b/docs/src/home.md index 5da715e..de8a78b 100644 --- a/docs/src/home.md +++ b/docs/src/home.md @@ -26,6 +26,19 @@ --- +
+ +```admonish tip title="🎮 New: Fugue Explorables" +Learn Fugue by **playing**, not just reading. The [Explorables](./explorables/README.md) are interactive, touchable diagrams — drag a slider and watch a posterior re-form, step an interpreter one effect at a time, roll a Hamiltonian trajectory across a density. In the tradition of Bret Victor's explorable explanations and 3Blue1Brown. + +- [**Anatomy of a Probabilistic Program**](./explorables/anatomy.md) — the coin-flip Bayes loop, fully touchable +- [**The Model Is a Score**](./explorables/monad.md) — a step-debugger for the `Model` monad +- [**Random Walks in Posterior Space**](./explorables/metropolis.md) — Metropolis–Hastings by hand +- [**Rolling, Not Guessing: HMC**](./explorables/hmc.md) — Hamiltonian Monte Carlo, rolling a ball across the posterior landscape +- [**Particles That Tell Stories**](./explorables/smc.md) — sequential Monte Carlo, resampling made visible +- [**A Field Guide to Distributions**](./explorables/distributions.md) — all 17 distributions, sampled live +``` + ```admonish info title="👋 Welcome" Check out these resources to get started: @@ -38,22 +51,23 @@ Check out these resources to get started: ## About Fugue -- 🧩 **Monadic PPL**: Compose probabilistic programs using pure functional abstractions -- 🔒 **Type-Safe Distributions**: 17 built-in probability distributions with natural return types -- 📊 **Multiple Inference Methods**: MCMC, HMC, SMC, Variational Inference, ABC (see [Advanced Inference](./tutorials/advanced-inference/README.md)) -- 🔍 **Comprehensive Diagnostics**: R-hat convergence, effective sample size, validation -- ⚡ **Numerically Stable**: Log-space computations throughout for robust probability arithmetic +- 🧩 **Monadic PPL**: Compose probabilistic programs as pure `Model` values, then interpret them with pluggable handlers — effects are *interpreted*, never performed by the model itself +- 🔒 **Type-Safe Distributions**: 17 built-in distributions with natural return types (`Bernoulli` → `bool`, `Poisson` → `u64`, `Categorical` → `usize`) — the type system tracks what each draw *is* +- 📊 **Multiple Inference Methods**: adaptive Metropolis–Hastings, **Hamiltonian Monte Carlo**, Sequential Monte Carlo (with an unbiased log-evidence estimate), Variational Inference (support-matched guide families), and importance-weighted ABC-SMC (see [Advanced Inference](./tutorials/advanced-inference/README.md)) +- 🔍 **Comprehensive Diagnostics**: split-R̂ convergence, autocorrelation-based effective sample size, Geweke, and closed-form validation +- 🌀 **Stack-Safe Interpreter**: a trampolined runtime evaluates models in O(1) stack depth — 100 000-site programs run without overflowing +- ⚡ **Numerically Stable**: log-space computations throughout for robust probability arithmetic - ✨ **Ergonomic Macros**: Do-notation (`prob!`), vectorization (`plate!`), addressing (`addr!`) ```admonish note title="🧪 Where Fugue stands today" -Fugue is 0.1.x: pre-1.0, actively developed, with no SemVer stability guarantee yet and a single primary maintainer. It's extensively tested (unit, integration, and statistical regression tests against closed-form posteriors), but that's a different claim from "production-ready" — pin an exact version and expect breaking API changes between 0.1.x releases as the design settles. +Fugue is 0.2.x: pre-1.0, actively developed, with no SemVer stability guarantee yet and a single primary maintainer. It's extensively tested (unit, integration, and statistical regression tests against closed-form posteriors), but that's a different claim from "production-ready" — pin an exact version and expect breaking API changes between 0.x releases as the design settles. ``` ## Installation ```toml [dependencies] -fugue-ppl = "0.1.0" +fugue-ppl = "0.2.0" ``` --- diff --git a/docs/src/how-to/README.md b/docs/src/how-to/README.md index 165cb6a..d9b6442 100644 --- a/docs/src/how-to/README.md +++ b/docs/src/how-to/README.md @@ -51,8 +51,7 @@ These guides are designed to be **example-first** and **immediately actionable** **What you'll learn**: - Numerical stability with log-space computations -- Choosing an efficient inference algorithm for large-scale workloads -- Batch processing patterns +- Batch processing and vectorized model patterns - Performance monitoring and measurement **Key patterns**: Numerical stability, batch processing diff --git a/docs/src/how-to/building-complex-models.md b/docs/src/how-to/building-complex-models.md index e4515c7..6fa3f94 100644 --- a/docs/src/how-to/building-complex-models.md +++ b/docs/src/how-to/building-complex-models.md @@ -15,6 +15,10 @@ Fugue models form a **monad** $\mathcal{M}$ with: This categorical structure ensures that model composition is **mathematically sound** and **computationally tractable**. ``` +```admonish tip title="Try it live" +The regression and hierarchical models built below are exactly what [Random Walks in Posterior Space](../explorables/metropolis.md) samples from — watch a chain explore a posterior like the ones you're about to compose. +``` + ## Do-Notation with `prob!` The `prob!` macro implements **monadic do-notation** for probabilistic computations, providing a natural syntax for sequential dependence. Formally, it translates: @@ -132,7 +136,7 @@ The computational challenge lies in maintaining **state consistency** while enab - Mixed probabilistic and deterministic updates ```admonish warning -Sequential models can create large traces. Consider using memory-efficient handlers for long sequences. +Sequential models can create large traces — every site's `Choice` lives in the returned `Trace`'s `BTreeMap` for the life of the run. For very long sequences, consider a custom `Handler` that summarizes or streams state instead of retaining every choice (see [Custom Handlers](./custom-handlers.md)). ``` ## Composable Model Functions @@ -207,6 +211,8 @@ $$\begin{align} y_{ij} \mid \mu_j, \sigma_j &\sim \mathcal{N}(\mu_j, \sigma_j^2) \end{align}$$ +
+ ```rust,ignore {{#include ../../../examples/building_complex_models.rs:multilevel_hierarchy}} ``` diff --git a/docs/src/how-to/custom-handlers.md b/docs/src/how-to/custom-handlers.md index 04e84dc..4e22d23 100644 --- a/docs/src/how-to/custom-handlers.md +++ b/docs/src/how-to/custom-handlers.md @@ -15,6 +15,10 @@ Fugue models effects through an **algebra** $(\mathcal{E}, \Sigma)$ where: This algebraic structure ensures **compositional semantics** and **modular interpretation**. ``` +```admonish tip title="Try it live" +[The Model Is a Score](../explorables/monad.md) makes this algebra visible: step a model through PriorHandler vs. ReplayHandler and watch the same score produce different recordings depending only on which handler answers each `sample`. +``` + ## Understanding the Handler Trait The `Handler` trait provides the **algebraic signature** for probabilistic effects. Each method represents an **effect operation** with its **semantic interpretation**: @@ -56,6 +60,12 @@ where the **carrier type** varies by handler implementation. - **Trace construction**: Build execution traces with choices and log-weights - **Resource cleanup**: Properly finalize and return traces +
+ +```admonish note title="i64 sample sites" +The `Handler` trait also has `on_sample_i64`/`on_observe_i64` for signed discrete distributions (`DiscreteUniform`). Both have default implementations that panic with a precise message, so handlers written against the four types above keep compiling unchanged — override them only if your model actually samples an `i64`-valued distribution. +``` + ## Decorator Pattern for Handler Composition The **decorator pattern** implements **handler composition** through **effect forwarding** with **computational augmentation**. This pattern follows the mathematical principle of **function composition**: diff --git a/docs/src/how-to/debugging-models.md b/docs/src/how-to/debugging-models.md index fba20f5..dcec93f 100644 --- a/docs/src/how-to/debugging-models.md +++ b/docs/src/how-to/debugging-models.md @@ -16,6 +16,10 @@ Model debugging operates on multiple **abstraction levels**: Each level requires specialized diagnostic techniques and validation criteria. ``` +```admonish tip title="Try it live" +[The Model Is a Score](../explorables/monad.md) is the interactive version of trace inspection below — step through a model node by node and watch the trace fill in, address by address, exactly like `trace.choices` here. +``` + ## Trace Inspection and Analysis **Execution traces** form the foundation of probabilistic model debugging. Each trace $\mathcal{T}$ contains a complete record of the program's stochastic execution: @@ -103,7 +107,7 @@ Fugue provides both strict (fail-fast) and safe (error-resilient) execution mode **Markov Chain Monte Carlo** convergence assessment requires **statistical hypothesis testing** and **diagnostic metrics**. The fundamental question is whether the chain has reached its **stationary distribution** $\pi(\theta)$. -### Gelman-Rubin Diagnostic +### Split-R-hat Diagnostic The **potential scale reduction factor** $\hat{R}$ compares **within-chain** and **between-chain** variance: @@ -115,12 +119,18 @@ where: - $B = \frac{n}{m-1}\sum_{j=1}^m (\bar{\theta}_{j\cdot} - \bar{\theta}_{\cdot\cdot})^2$ (between-chain variance) - $\hat{V} = \frac{n-1}{n}W + \frac{1}{n}B$ (marginal posterior variance estimate) +```admonish note title="Split-R-hat" +`r_hat_f64` (in `fugue::inference::diagnostics`) computes **split-R-hat** (Vehtari et al. 2021), not the 1992 Gelman & Rubin statistic: each chain is split in half first, and the halves are treated as independent chains before the formula above is applied. This catches within-chain non-stationarity (a chain that drifts) that whole-chain R-hat misses. The classic, non-split statistic is still available as `classic_r_hat_f64` for comparison. +``` + ```admonish important title="Convergence Criterion" **Theoretical Result**: As $n \to \infty$, if the chain has converged, then $\hat{R} \to 1$. **Practical Threshold**: $\hat{R} < 1.1$ indicates approximate convergence for most applications. **Statistical Interpretation**: $\hat{R} > 1$ suggests the chain hasn't explored the full posterior distribution. ``` +
+ ### Effective Sample Size The **effective sample size** accounts for **autocorrelation** in MCMC samples: @@ -133,9 +143,11 @@ where $\rho_t$ is the lag-$t$ autocorrelation and $mn$ is the total number of sa {{#include ../../../examples/debugging_models.rs:mcmc_diagnostics}} ``` +`effective_sample_size_mcmc` (in `fugue::inference::mcmc_utils`, used above) computes ESS for a single chain. Fugue also provides `effective_sample_size_multichain`, which pools autocorrelation and between-chain variance across all chains at once — prefer it over averaging single-chain ESS values when you have more than one chain. + **Convergence Indicators:** -- **R-hat < 1.1**: Chains have converged +- **Split-R-hat < 1.1**: Chains have converged - **High ESS**: Efficient sampling without excessive correlation - **Multiple chains**: Essential for reliable convergence assessment - **Visual inspection**: Always examine trace plots when possible diff --git a/docs/src/how-to/optimizing-performance.md b/docs/src/how-to/optimizing-performance.md index 40c8603..b41aed4 100644 --- a/docs/src/how-to/optimizing-performance.md +++ b/docs/src/how-to/optimizing-performance.md @@ -73,6 +73,8 @@ $$S_{\text{max}} = \frac{1}{f_{\text{seq}} + \frac{1-f_{\text{seq}}}{p}}$$ where $f_{\text{seq}}$ is the fraction of sequential computation and $p$ is the number of processors. ``` +
+ ```rust,ignore {{#include ../../../examples/optimizing_performance.rs:performance_monitoring}} ``` diff --git a/docs/src/how-to/working-with-distributions.md b/docs/src/how-to/working-with-distributions.md index 2a4ef17..7cdc75d 100644 --- a/docs/src/how-to/working-with-distributions.md +++ b/docs/src/how-to/working-with-distributions.md @@ -10,6 +10,12 @@ Fugue's type-safe distribution system represents a principled approach to probab Fugue's distribution system is grounded in **dependent type theory**, where each distribution $D$ is parameterized not just by its parameters $\theta$, but by its **support type** $\mathcal{S}$. This ensures that $\text{sample}(D_\theta) : \mathcal{S}$ and eliminates the need for runtime type checking or unsafe casting operations. ``` +Fugue ships **17 distributions** in `fugue::core::distribution`: 12 continuous (`Normal`, `Uniform`, `LogNormal`, `Exponential`, `Beta`, `Gamma`, `StudentT`, `Cauchy`, `Laplace`, `Weibull`, `ChiSquared`, `InverseGamma`) and 5 discrete/other (`Bernoulli`, `Categorical`, `Binomial`, `Poisson`, `DiscreteUniform`). + +```admonish tip title="Try it live" +Play with every one of these — parameter sliders, a live sampling histogram racing the true curve — in the [Field Guide to Distributions](../explorables/distributions.md) explorable. +``` + ## Type Safety in Practice Traditional probabilistic programming libraries return `f64` for everything, leading to casting overhead and runtime errors. Fugue distributions return their natural types: @@ -26,6 +32,8 @@ Continuous distributions in Fugue model phenomena over uncountable domains $\mat $$\int_{\mathcal{S}} f_X(x) \, dx = 1$$ +
+ For computational stability, Fugue operates in **log-space** by default, computing $\log f_X(x)$ to avoid numerical underflow: ```admonish important title="Log-Space Computation" @@ -52,6 +60,8 @@ Discrete distributions operate over countable support sets $\mathcal{S} \subsete $$\sum_{x \in \mathcal{S}} P(X = x) = 1$$ +
+ Fugue enforces this constraint at construction time and leverages natural integer types to eliminate precision loss from floating-point representation: ```admonish note title="Integer Precision Preservation" @@ -99,6 +109,8 @@ graph TD - **Beta Distribution**: $\text{Beta}(\alpha, \beta)$ requires $\alpha, \beta > 0$ - **Categorical Distribution**: $\sum_i p_i = 1$ and $p_i \geq 0 \, \forall i$ +
+ ```rust,ignore {{#include ../../../examples/working_with_distributions.rs:parameter_validation}} ``` diff --git a/docs/src/tutorials/advanced-inference/approximate-bayesian-computation.md b/docs/src/tutorials/advanced-inference/approximate-bayesian-computation.md index 3cccdd1..81ccf8d 100644 --- a/docs/src/tutorials/advanced-inference/approximate-bayesian-computation.md +++ b/docs/src/tutorials/advanced-inference/approximate-bayesian-computation.md @@ -6,6 +6,8 @@ Approximate Bayesian Computation (ABC) is what you reach for when the likelihood $p(y \mid \theta)$ is intractable (or you simply don't want to write it down), but you *can* simulate synthetic data from the model. ABC replaces likelihood evaluation with simulate-and-compare: draw $\theta$ from the prior, simulate $y_{\text{sim}}$, and accept $\theta$ if $y_{\text{sim}}$ is close enough to the real observation $y_{\text{obs}}$. +
+ ```admonish warning title="This example's likelihood isn't actually intractable" To keep this tutorial directly comparable to the [SMC](./sequential-monte-carlo.md) and [VI](./variational-inference.md) pages, it reuses the conjugate Normal-Normal model — whose likelihood is very much tractable. ABC's real value is for simulators where no such closed form (or even numerical likelihood) exists at all; using a tractable model here is purely so we have a known target to check the approximation against. ``` diff --git a/docs/src/tutorials/advanced-inference/sequential-monte-carlo.md b/docs/src/tutorials/advanced-inference/sequential-monte-carlo.md index 03b5c5a..92ba84f 100644 --- a/docs/src/tutorials/advanced-inference/sequential-monte-carlo.md +++ b/docs/src/tutorials/advanced-inference/sequential-monte-carlo.md @@ -6,6 +6,10 @@ Sequential Monte Carlo (SMC) maintains a *population* of weighted particles and moves them through a sequence of intermediate target distributions, resampling and rejuvenating along the way, until the population approximates the posterior. Unlike MCMC's single evolving chain, SMC gives you many (weakly correlated) draws per run *and* an unbiased estimate of the log marginal likelihood — useful for model comparison, which no single-chain MCMC method provides directly. +```admonish tip title="Try it live" +The **[Particles That Tell Stories](../../explorables/smc.md)** explorable animates propagate → weight → resample on a 1D state-space model. Drop the particle count to 10 and watch degeneracy happen before you read a line of the theory below. +``` + ## The model ```rust,ignore diff --git a/docs/src/tutorials/advanced-inference/variational-inference.md b/docs/src/tutorials/advanced-inference/variational-inference.md index 2b80b1e..ac09054 100644 --- a/docs/src/tutorials/advanced-inference/variational-inference.md +++ b/docs/src/tutorials/advanced-inference/variational-inference.md @@ -10,6 +10,8 @@ $$\text{ELBO}(\phi) = \mathbb{E}_{z \sim q_\phi}\left[\log p(x, z) - \log q_\phi This trades exactness for speed: a converged VI fit is one optimization run, not thousands of MCMC iterations — at the cost of being only as good as the chosen family $q_\phi$ lets it be. +
+ ## The model ```rust,ignore diff --git a/docs/src/tutorials/foundation/README.md b/docs/src/tutorials/foundation/README.md index 4178fc2..6bad8fc 100644 --- a/docs/src/tutorials/foundation/README.md +++ b/docs/src/tutorials/foundation/README.md @@ -79,7 +79,7 @@ Explore Fugue's revolutionary type system that eliminates runtime errors while p **Key Concepts:** -- Natural return types for distributions (`bool`, `u64`, `f64`, `usize`) +- Natural return types for distributions (`bool`, `u64`, `f64`, `usize`, `i64`) - Compile-time safety guarantees - Safe array indexing with categorical distributions - Parameter validation at construction time diff --git a/docs/src/tutorials/foundation/bayesian-coin-flip.md b/docs/src/tutorials/foundation/bayesian-coin-flip.md index 769a5ef..8894e26 100644 --- a/docs/src/tutorials/foundation/bayesian-coin-flip.md +++ b/docs/src/tutorials/foundation/bayesian-coin-flip.md @@ -6,6 +6,12 @@ A comprehensive introduction to Bayesian inference through the classic coin flip problem. This tutorial demonstrates core Bayesian concepts including prior beliefs, likelihood functions, posterior distributions, and conjugate analysis using Fugue's type-safe probabilistic programming framework. +```admonish tip title="Try it live" +[**Anatomy of a Probabilistic Program**](../../explorables/anatomy.md) is this +exact prior/likelihood/posterior loop, fully touchable — drag the prior's +$\alpha, \beta$, flip coins, and watch the Beta posterior update in real time. +``` + ```admonish info title="Learning Objectives" By the end of this tutorial, you will understand: - **Bayesian Inference**: How to combine prior beliefs with data @@ -68,6 +74,8 @@ For the Beta-Bernoulli model, the posterior is: $$p \mid \mathbf{x} \sim \text{Beta}(\alpha_0 + k, \beta_0 + n - k)$$ +
+ ```admonish important title="Conjugate Prior Theorem" The **Beta distribution** is **conjugate** to the **Bernoulli likelihood**, meaning: - **Prior**: $\text{Beta}(\alpha_0, \beta_0)$ @@ -148,6 +156,10 @@ The **Effective Sample Size (ESS)** measures how many independent samples we hav - **ESS > 400**: Generally adequate for inference - **ESS < 100**: May indicate poor mixing or autocorrelation - **ESS/Total < 0.1**: Consider increasing chain length or improving proposals + +`fugue::inference::diagnostics::effective_sample_size` (used below) computes +this from the *normalized* autocorrelation, so it's dimensionless and +comparable across parameters regardless of their scale. ``` ## Diagnostics & Validation diff --git a/docs/src/tutorials/foundation/trace-manipulation.md b/docs/src/tutorials/foundation/trace-manipulation.md index de4b264..2885a55 100644 --- a/docs/src/tutorials/foundation/trace-manipulation.md +++ b/docs/src/tutorials/foundation/trace-manipulation.md @@ -6,6 +6,12 @@ A deep exploration of Fugue's runtime system and trace manipulation capabilities. This tutorial demonstrates how traces enable sophisticated probabilistic programming techniques including replay, scoring, custom inference, and debugging. Learn how Fugue's execution history recording makes advanced inference algorithms possible while maintaining full type safety. +```admonish tip title="Try it live" +[**The Model Is a Score**](../../explorables/monad.md) is a step-debugger for +the exact CPS chain / handler / trace loop this tutorial describes — step +through a real model node by node and watch the trace fill in. +``` + ```admonish info title="Learning Objectives" By the end of this tutorial, you will understand: - **Trace System Architecture**: How execution history is recorded and structured @@ -20,6 +26,8 @@ By the end of this tutorial, you will understand: Traditional programming languages execute once and discard their execution history. In probabilistic programming, we need to **record, manipulate, and reason about random choices** to enable sophisticated inference algorithms. Fugue's trace system solves this fundamental challenge. +
+ ```mermaid graph TD A["Model Specification"] --> B["Handler Selection"] @@ -165,6 +173,7 @@ graph TD B --> D["on_sample_bool()"] B --> E["on_sample_u64()"] B --> F["on_sample_usize()"] + B --> M["on_sample_i64()"] B --> G["on_observe_*()"] B --> H["on_factor()"] @@ -172,6 +181,7 @@ graph TD D --> I E --> I F --> I + M --> I G --> I H --> I @@ -192,6 +202,15 @@ graph TD | `SafeReplayHandler` | Error-resilient replay | Production MCMC | | `SafeScoreGivenTrace` | Safe scoring | Robust inference | +```admonish tip title="Handler trait: on_sample_i64 / on_observe_i64" +`Handler` has default implementations for `on_sample_i64` and `on_observe_i64` +(the signed-discrete path used by distributions like `DiscreteUniform`) that +**panic** unless overridden — this keeps handlers written before the i64 path +existed compiling unchanged. Every built-in handler above overrides both; a +custom handler (like `DebugHandler` below) only needs to as well if its model +samples or observes an i64-typed site. +``` + ## Trace Scoring Scoring computes the log-probability of a specific execution path, essential for importance sampling and model comparison: @@ -267,6 +286,19 @@ Where: - $\hat{V}$: Estimated marginal posterior variance - $W$: Within-chain variance +```admonish note title="Split-R-hat (Vehtari et al. 2021)" +`r_hat_f64` (and the `r_hat` field on `ParameterSummary`) now compute +**split**-R-hat: each chain is first split in half and the halves are treated +as separate chains before the formula above is applied. Splitting catches +within-chain non-stationarity (e.g. a slow drift) that the classic 1992 +statistic misses when every chain drifts the same way — that classic +statistic is still available as `classic_r_hat_f64` if you specifically want +it. `effective_sample_size` and the `ess` field are likewise routed through a +single, dimensionless (variance-normalized) autocorrelation estimator; the +`ess` reported by `summarize_f64_parameter` pools all chains together +(Vehtari et al.'s multi-chain ESS), not just the first one. +``` + **Interpretation**: - $\hat{R} \approx 1.0$: Good convergence @@ -278,8 +310,8 @@ Where: For each parameter, compute: - **Mean and Standard Deviation**: Central tendency and spread -- **Quantiles**: 5%, 25%, 50%, 75%, 95% for uncertainty intervals -- **Effective Sample Size**: Accounting for autocorrelation +- **Quantiles**: 2.5%, 25%, 50%, 75%, 97.5% for uncertainty intervals +- **Effective Sample Size**: The multi-chain, autocorrelation-adjusted estimate described above ## Advanced Debugging @@ -368,7 +400,7 @@ impl CustomMCMC { // Score proposal let (_, scored_trace) = runtime::handler::run( - ScoreGivenTrace::new(proposal_trace), + ScoreGivenTrace { base: proposal_trace, trace: Trace::default() }, model_fn() ); diff --git a/docs/src/tutorials/foundation/type-safety-features.md b/docs/src/tutorials/foundation/type-safety-features.md index b51c7de..80a7999 100644 --- a/docs/src/tutorials/foundation/type-safety-features.md +++ b/docs/src/tutorials/foundation/type-safety-features.md @@ -6,6 +6,12 @@ A comprehensive exploration of Fugue's revolutionary type-safe distribution system and its practical implications for probabilistic programming. This tutorial demonstrates how dependent type theory principles eliminate runtime errors while preserving full statistical expressiveness, making probabilistic programs both safer and more performant. +```admonish tip title="Try it live" +[**A Field Guide to Distributions**](../../explorables/distributions.md) lets +you page through every distribution Fugue ships and see each one's natural +return type (`bool`, `u64`, `usize`, `f64`) next to its live pdf/pmf. +``` + ```admonish info title="Learning Objectives" By the end of this tutorial, you will understand: - **Natural Return Types**: How distributions return mathematically appropriate types @@ -71,9 +77,17 @@ This ensures that sampling operations return values in their natural mathematica | Mathematical Object | Support $\mathcal{S}$ | Fugue Type | Example | |-------------------|---------------------|------------|---------| | Bernoulli($p$) | $\{0, 1\}$ | `bool` | `true`/`false` | -| Poisson($\lambda$) | $\mathbb{N}_0$ | `u64` | `0, 1, 2, ...` | +| Poisson($\lambda$), Binomial($n,p$) | $\mathbb{N}_0$ | `u64` | `0, 1, 2, ...` | | Categorical($\mathbf{p}$) | $\{0, 1, ..., k-1\}$ | `usize` | Array indices | -| Normal($\mu, \sigma^2$) | $\mathbb{R}$ | `f64` | Continuous values | +| DiscreteUniform($a,b$) | $\{a, a{+}1, ..., b\} \subset \mathbb{Z}$ | `i64` | Signed integers | +| Normal($\mu, \sigma^2$), Beta, Gamma, ... | $\mathbb{R}$ (or a subset) | `f64` | Continuous values | + +```admonish note title="17 distributions, 5 natural return types" +Fugue ships 17 distributions total, every one returning its mathematically +natural type — `bool`, `u64`, `usize`, `i64`, or `f64`. `DiscreteUniform` is +the one distribution in the crate with `i64` support, since it's the only +distribution whose domain can go negative. +``` ### Type-Theoretic Properties @@ -109,12 +123,21 @@ Fugue eliminates the `f64`-everything problem by returning mathematically approp - **Benefit**: Direct arithmetic without casting or precision loss - **Performance**: Integer operations are faster than float conversions +
+ ### Categorical Distributions - **Returns**: `usize` - natural array indices - **Benefit**: Guaranteed bounds safety for array indexing - **Performance**: No runtime bounds checking required +### Signed Discrete Distributions (DiscreteUniform) + +- **Returns**: `i64` - natural signed integers +- **Benefit**: Represents ranges that cross zero (e.g. `[-5, 5]`) without an + unsigned workaround +- **Performance**: Direct integer arithmetic, same as the `u64` path + ### Continuous Distributions - **Returns**: `f64` - unchanged for appropriate domains diff --git a/docs/src/tutorials/statistical-modeling/README.md b/docs/src/tutorials/statistical-modeling/README.md index 971b458..b3a91ee 100644 --- a/docs/src/tutorials/statistical-modeling/README.md +++ b/docs/src/tutorials/statistical-modeling/README.md @@ -76,13 +76,15 @@ let model = prob! { let intercept <- sample(addr!("intercept"), Normal::new(0.0, 10.0).unwrap()); let slope <- sample(addr!("slope"), Normal::new(0.0, 10.0).unwrap()); let sigma <- sample(addr!("sigma"), Gamma::new(1.0, 1.0).unwrap()); - - // Observations with uncertainty - for (i, (x_i, y_i)) in x_data.iter().zip(y_data.iter()).enumerate() { - let mu_i = intercept + slope * x_i; - observe(addr!("y", i), Normal::new(mu_i, sigma).unwrap(), *y_i); - } - + + // Observations with uncertainty. `prob!`'s `<-` bind syntax only munches + // top-level `let` statements, so a per-observation loop must go through + // `plate!` (it sequences one `observe` per address via `traverse_vec`). + let _observations <- plate!(i in 0..x_data.len() => { + let mu_i = intercept + slope * x_data[i]; + observe(addr!("y", i), Normal::new(mu_i, sigma).unwrap(), y_data[i]) + }); + pure((intercept, slope, sigma)) }; ``` @@ -156,17 +158,22 @@ let model = prob! { let mu_alpha <- sample(addr!("mu_alpha"), Normal::new(0.0, 5.0).unwrap()); let sigma_alpha <- sample(addr!("sigma_alpha"), Gamma::new(1.0, 1.0).unwrap()); let beta <- sample(addr!("beta"), Normal::new(0.0, 2.0).unwrap()); - - // Group-specific intercepts via partial pooling + let sigma_y <- sample(addr!("sigma_y"), Gamma::new(1.0, 1.0).unwrap()); + + // One intercept per group, sampled once each (partial pooling). Sampling + // it again inside the per-observation loop below would hit the same + // address twice and panic — sample it here, then reuse the value. + let alphas <- plate!(g in 0..n_groups => { + sample(addr!("alpha", g), Normal::new(mu_alpha, sigma_alpha).unwrap()) + }); + + // Observations reuse their group's already-sampled intercept. let _observations <- plate!(i in 0..x_data.len() => { - let group_j = group_ids[i]; - sample(addr!("alpha", group_j), Normal::new(mu_alpha, sigma_alpha).unwrap()) - .bind(move |alpha_j| { - let mu_i = alpha_j + beta * x_data[i]; - observe(addr!("y", i), Normal::new(mu_i, sigma_y).unwrap(), y_data[i]) - }) + let alpha_j = alphas[group_ids[i]]; + let mu_i = alpha_j + beta * x_data[i]; + observe(addr!("y", i), Normal::new(mu_i, sigma_y).unwrap(), y_data[i]) }); - + pure((mu_alpha, sigma_alpha, beta, sigma_y)) }; ``` @@ -210,12 +217,12 @@ graph LR let model = prob! { // 1. Prior specification let parameter <- sample(addr!("param"), Normal::new(0.0, 1.0).unwrap()); - - // 2. Likelihood specification - for (i, observation) in data.iter().enumerate() { - observe(addr!("obs", i), distribution, *observation); - } - + + // 2. Likelihood specification — plate! sequences one `observe` per point + let _observations <- plate!(i in 0..data.len() => { + observe(addr!("obs", i), Normal::new(parameter, 1.0).unwrap(), data[i]) + }); + // 3. Return parameters of interest pure(parameter) }; @@ -434,9 +441,12 @@ let value: f64 = normal.sample(&mut rng); // Explicit numeric type! ```rust,ignore # use fugue::runtime::handler::run; -# use fugue::runtime::interpreters::PriorHandler; +# use fugue::runtime::interpreters::{PriorHandler, ReplayHandler, ScoreGivenTrace}; +# use fugue::runtime::trace::Trace; -// Seamless integration with Fugue's runtime system: +// Seamless integration with Fugue's runtime system. Handlers are plain +// structs run through the same `run(handler, model)` entry point — there is +// no `.score()`/`.replay()` method, and no `::new()` constructor. // 1. Prior sampling for model validation let (result, trace) = run( @@ -444,12 +454,19 @@ let (result, trace) = run( your_model() ); -// 2. Scoring for model comparison -let scored_trace = ScoreGivenTrace::new(trace).score(&mut rng, your_model()); +// 2. Scoring for model comparison — replays `trace`'s choices and rescores +// them under (possibly different) model parameters +let (_, scored_trace) = run( + ScoreGivenTrace { base: trace.clone(), trace: Trace::default() }, + your_model() +); -// 3. Replay for debugging -let replay_trace = ReplayHandler::new(previous_trace) - .replay(&mut rng, your_model()); +// 3. Replay for debugging — reuses recorded values where present, samples +// fresh at any address not in `previous_trace` +let (_, replay_trace) = run( + ReplayHandler { rng: &mut rng, base: previous_trace, trace: Trace::default() }, + your_model() +); ``` ## Common Statistical Tasks diff --git a/docs/src/tutorials/statistical-modeling/classification.md b/docs/src/tutorials/statistical-modeling/classification.md index 2279823..7643b3c 100644 --- a/docs/src/tutorials/statistical-modeling/classification.md +++ b/docs/src/tutorials/statistical-modeling/classification.md @@ -51,6 +51,8 @@ Traditional machine learning gives you point predictions. Bayesian classificatio The foundation of Bayesian classification is **logistic regression**, which models the probability of binary outcomes. +
+ ### Mathematical Model For binary classification, we model: @@ -219,6 +221,23 @@ For ordered categorical outcomes (e.g., ratings, severity levels): ```rust,ignore # use fugue::*; +// Ordered cutpoints via a monotone stick of `Gamma`-distributed increments. +// This is a recursive `.bind()` chain rather than a `for` loop: each cutpoint +// depends on the previous one's *sampled value*, and `prob!`'s `<-` syntax +// (like `plate!`) only sequences INDEPENDENT per-index draws — it can't +// thread a running value through a loop body. +fn sample_cutpoints(k: usize, n_categories: usize, cutpoints: Vec) -> Model> { + if k >= n_categories - 1 { + pure(cutpoints) + } else { + sample(addr!("delta", k), Gamma::new(1.0, 1.0).unwrap()).bind(move |delta| { + let mut next = cutpoints.clone(); + next.push(cutpoints[k - 1] + delta); + sample_cutpoints(k + 1, n_categories, next) + }) + } +} + // Ordinal logistic regression with proportional odds fn ordinal_classification_model( features: Vec>, @@ -230,22 +249,21 @@ fn ordinal_classification_model( let coefficients <- plate!(i in 0..features[0].len() => { sample(addr!("beta", i), fugue::Normal::new(0.0, 2.0).unwrap()) }); - - // Cutpoints (must be ordered) - let mut cutpoints = Vec::new(); + + // Cutpoints (must be ordered): first cutpoint free, the rest built by + // adding positive increments so they stay strictly increasing. let first_cut <- sample(addr!("cutpoint", 0), fugue::Normal::new(0.0, 5.0).unwrap()); - cutpoints.push(first_cut); - - for k in 1..(n_categories-1) { - let delta <- sample(addr!("delta", k), Gamma::new(1.0, 1.0).unwrap()); - cutpoints.push(cutpoints[k-1] + delta); - } - - // Likelihood using cumulative logits + let cutpoints <- sample_cutpoints(1, n_categories, vec![first_cut]); + + // Likelihood using cumulative logits. Clone first: `plate!`'s closure + // is `move`, and both `coefficients` and `cutpoints` are still needed + // below in `pure((coefficients, cutpoints))`. + let coefficients_for_obs = coefficients.clone(); + let cutpoints_for_obs = cutpoints.clone(); let _observations <- plate!(obs_idx in features.iter().zip(outcomes.iter()).enumerate() => { let (idx, (x_vec, &y)) = obs_idx; let mut linear_pred = 0.0; - for (coef, &x_val) in coefficients.iter().zip(x_vec.iter()) { + for (coef, &x_val) in coefficients_for_obs.iter().zip(x_vec.iter()) { linear_pred += coef * x_val; } @@ -253,12 +271,12 @@ fn ordinal_classification_model( let mut probs = Vec::new(); for k in 0..n_categories { let prob = if k == 0 { - 1.0 / (1.0 + (-(cutpoints[0] - linear_pred)).exp()) + 1.0 / (1.0 + (-(cutpoints_for_obs[0] - linear_pred)).exp()) } else if k == n_categories - 1 { - 1.0 - (1.0 / (1.0 + (-(cutpoints[k-1] - linear_pred)).exp())) + 1.0 - (1.0 / (1.0 + (-(cutpoints_for_obs[k-1] - linear_pred)).exp())) } else { - let p_le_k = 1.0 / (1.0 + (-(cutpoints[k] - linear_pred)).exp()); - let p_le_k_minus_1 = 1.0 / (1.0 + (-(cutpoints[k-1] - linear_pred)).exp()); + let p_le_k = 1.0 / (1.0 + (-(cutpoints_for_obs[k] - linear_pred)).exp()); + let p_le_k_minus_1 = 1.0 / (1.0 + (-(cutpoints_for_obs[k-1] - linear_pred)).exp()); p_le_k - p_le_k_minus_1 }; probs.push(prob.max(1e-10).min(1.0 - 1e-10)); @@ -293,24 +311,32 @@ fn robust_classification_model( // Degrees of freedom for robustness let nu <- sample(addr!("nu"), Gamma::new(2.0, 0.1).unwrap()); - // Robust likelihood using latent variables + // Robust likelihood using latent variables. The closure body below is + // NOT itself expanded by `prob!`, so its own `sample`/`observe` bind + // (`z`, then the observation) must be chained explicitly through + // another `prob! { ... }` (or `.bind()`) rather than a second `<-`. + // Clone first: `plate!`'s closure is `move`, and `coefficients` is + // still needed below in `pure((coefficients, nu))`. + let coefficients_for_obs = coefficients.clone(); let _observations <- plate!(obs_idx in features.iter().zip(labels.iter()).enumerate() => { let (idx, (x_vec, &y)) = obs_idx; - + // Linear predictor let mut eta = 0.0; - for (coef, &x_val) in coefficients.iter().zip(x_vec.iter()) { + for (coef, &x_val) in coefficients_for_obs.iter().zip(x_vec.iter()) { eta += coef * x_val; } - - // Latent variable for robustness - let z <- sample(addr!("z", idx), fugue::Normal::new(eta, 1.0).unwrap()); - - // Robust transformation - let p = 1.0 / (1.0 + (-z).exp()); - let bounded_p = p.max(1e-10).min(1.0 - 1e-10); - - observe(addr!("y", idx), Bernoulli::new(bounded_p).unwrap(), y) + + prob! { + // Latent variable for robustness + let z <- sample(addr!("z", idx), fugue::Normal::new(eta, 1.0).unwrap()); + + // Robust transformation + let p = 1.0 / (1.0 + (-z).exp()); + let bounded_p = p.max(1e-10).min(1.0 - 1e-10); + + observe(addr!("y", idx), Bernoulli::new(bounded_p).unwrap(), y) + } }); pure((coefficients, nu)) diff --git a/docs/src/tutorials/statistical-modeling/hierarchical-models.md b/docs/src/tutorials/statistical-modeling/hierarchical-models.md index 6a307dd..cf58089 100644 --- a/docs/src/tutorials/statistical-modeling/hierarchical-models.md +++ b/docs/src/tutorials/statistical-modeling/hierarchical-models.md @@ -20,6 +20,12 @@ After completing this tutorial, you will be able to: - Handle partial pooling vs complete pooling trade-offs ``` +```admonish tip title="Try it live" +[**Random Walks in Posterior Space**](../../explorables/metropolis.md) shows the sampler behind +every model on this page hunting a 2D posterior by hand — watch acceptance rate, split-R̂, and +ESS respond as you change the proposal step size. +``` + ## Introduction **Hierarchical models** (also called multi-level or mixed-effects models) are essential for analyzing **grouped or clustered data** where observations within groups are more similar to each other than to observations in other groups. Examples include: @@ -61,6 +67,8 @@ Hierarchical models provide **partial pooling**, where: - Groups with **less data** → estimates shrink toward population mean - **Automatic regularization** prevents overfitting to small groups +
+ ## Mathematical Foundation ### Basic Hierarchical Structure diff --git a/docs/src/tutorials/statistical-modeling/linear-regression.md b/docs/src/tutorials/statistical-modeling/linear-regression.md index 3c36a53..34d67dd 100644 --- a/docs/src/tutorials/statistical-modeling/linear-regression.md +++ b/docs/src/tutorials/statistical-modeling/linear-regression.md @@ -6,6 +6,12 @@ A comprehensive guide to Bayesian linear regression using Fugue. This tutorial demonstrates how to build, analyze, and extend linear models for real-world data analysis, showcasing the power of probabilistic programming for uncertainty quantification and model comparison. +```admonish tip title="Try it live" +[**Random Walks in Posterior Space**](../../explorables/metropolis.md) lets you drive the +Metropolis-Hastings sampler these regressions run on by hand — tune the proposal step size and +watch split-R̂ and ESS respond before you read the numbers below. +``` + ```admonish info title="Learning Objectives" By the end of this tutorial, you will understand: - **Bayesian Linear Regression**: Prior specification and posterior inference for regression parameters @@ -21,6 +27,8 @@ By the end of this tutorial, you will understand: Linear regression is the cornerstone of statistical modeling. In the Bayesian framework, we treat regression parameters as random variables with prior distributions, allowing us to quantify uncertainty in our estimates and make probabilistic predictions. +
+ ```mermaid graph TB A["Data: (x₁,y₁), (x₂,y₂), ..., (xₙ,yₙ)"] --> B["Linear Model
y = β₀ + β₁x + ε"] @@ -301,25 +309,22 @@ fn hierarchical_regression_model( let sigma_y <- sample(addr!("sigma_y"), Gamma::new(2.0, 0.5).unwrap()); let sigma_group <- sample(addr!("sigma_group"), Gamma::new(2.0, 1.0).unwrap()); - // Group-specific intercepts - let mut group_intercepts = Vec::new(); - for g in 0..n_groups { - let intercept_g <- sample( - addr!("intercept", g), - Normal::new(0.0, sigma_group).unwrap() - ); - group_intercepts.push(intercept_g); - } - - // Likelihood - for (i, ((x_i, y_i), group_i)) in x_data.iter() - .zip(y_data.iter()) - .zip(group_ids.iter()) - .enumerate() - { - let mean_i = group_intercepts[*group_i] + global_slope * x_i; - let _obs <- observe(addr!("y", i), Normal::new(mean_i, sigma_y).unwrap(), *y_i); - } + // Group-specific intercepts. `prob!`'s `<-` bind only munches + // top-level `let` statements, so a per-group loop goes through + // `plate!` (one `sample` per address, sequenced via `traverse_vec`) + // rather than a raw `for`. + let group_intercepts <- plate!(g in 0..n_groups => { + sample(addr!("intercept", g), Normal::new(0.0, sigma_group).unwrap()) + }); + + // Likelihood — same reasoning: one `observe` per row via `plate!`. + // Clone before the closure: `plate!`'s closure is `move`, and + // `group_intercepts` is still needed below in `pure(...)`. + let intercepts_for_obs = group_intercepts.clone(); + let _observations <- plate!(i in 0..x_data.len() => { + let mean_i = intercepts_for_obs[group_ids[i]] + global_slope * x_data[i]; + observe(addr!("y", i), Normal::new(mean_i, sigma_y).unwrap(), y_data[i]) + }); pure((global_slope, sigma_y, group_intercepts)) ) @@ -343,33 +348,34 @@ fn spline_regression_model( // Smoothness prior let precision <- sample(addr!("precision"), Gamma::new(1.0, 0.1).unwrap()); - // Spline coefficients with smoothness penalty - let mut coefficients = Vec::new(); - for j in 0..n_basis { - let coef_j <- sample( - addr!("coef", j), - Normal::new(0.0, 1.0 / precision.sqrt()).unwrap() - ); - coefficients.push(coef_j); - } + // Spline coefficients with smoothness penalty — one `sample` per + // basis function via `plate!` (a raw `for` can't wrap a `<-` bind). + let coefficients <- plate!(j in 0..n_basis => { + sample(addr!("coef", j), Normal::new(0.0, 1.0 / precision.sqrt()).unwrap()) + }); let sigma <- sample(addr!("sigma"), Gamma::new(2.0, 0.5).unwrap()); - // Likelihood (basis functions would be computed here) - for (i, (x_i, y_i)) in x_data.iter().zip(y_data.iter()).enumerate() { - // Compute basis function values at x_i + // Likelihood (basis functions would be computed here); one `observe` + // per row via `plate!`. The inner loop over basis functions is plain + // Rust arithmetic, not a model bind, so it's fine inside the closure. + // Clone first: `plate!`'s closure is `move`, and `coefficients` is + // still needed below in `pure(coefficients)`. + let coefficients_for_obs = coefficients.clone(); + let _observations <- plate!(i in 0..x_data.len() => { + let x_i = x_data[i]; let mut mean_i = 0.0; - for (j, coef_j) in coefficients.iter().enumerate() { + for (j, coef_j) in coefficients_for_obs.iter().enumerate() { // basis_function(x_i, j, knots) would compute B-spline basis let basis_val = if j < knots.len() { (x_i - knots[j]).max(0.0).powi(3) } else { - x_i.powi(j - knots.len()) + x_i.powi((j - knots.len()) as i32) }; mean_i += coef_j * basis_val; } - let _obs <- observe(addr!("y", i), Normal::new(mean_i, sigma).unwrap(), *y_i); - } + observe(addr!("y", i), Normal::new(mean_i, sigma).unwrap(), y_data[i]) + }); pure(coefficients) ) @@ -389,11 +395,10 @@ For large datasets: ### Model Diagnostics -Essential checks for regression models: +Essential checks for regression models: residual analysis, and — since MCMC samples are +correlated draws, not i.i.d. ones — convergence diagnostics on the chain itself. ```rust,ignore -# use fugue::inference::diagnostics::*; - fn regression_diagnostics(samples: &[(f64, f64, f64)], x_data: &[f64], y_data: &[f64]) { // Residual analysis let predictions: Vec = samples.iter().map(|(intercept, slope, _)| { @@ -414,6 +419,33 @@ fn regression_diagnostics(samples: &[(f64, f64, f64)], x_data: &[f64], y_data: & } ``` +````admonish note title="Convergence diagnostics" +Residuals check the *model*; `fugue::inference::diagnostics` checks the *sampler*. Run at +least two chains and pass their traces to [`r_hat_f64`](https://docs.rs/fugue-ppl/latest/fugue/inference/diagnostics/fn.r_hat_f64.html) +(split-R̂, Vehtari et al. 2021 — splits each chain in half so it also catches within-chain +drift that the classic 1992 statistic misses) and +[`summarize_f64_parameter`](https://docs.rs/fugue-ppl/latest/fugue/inference/diagnostics/fn.summarize_f64_parameter.html) +(mean, std, quantiles, split-R̂, and multi-chain effective sample size together): + +```rust,ignore +# use fugue::*; // r_hat_f64 and summarize_f64_parameter are re-exported at the crate root +# use rand::{SeedableRng, rngs::StdRng}; +let chains: Vec> = (0..4).map(|seed| { + let mut rng = StdRng::seed_from_u64(seed); + let model_fn = move || basic_linear_regression_model(x_data.clone(), y_data.clone()); + adaptive_mcmc_chain(&mut rng, model_fn, 1000, 200) + .into_iter() + .map(|(_, trace)| trace) + .collect() +}).collect(); + +let r_hat = r_hat_f64(&chains, &addr!("slope")); +let summary = summarize_f64_parameter(&chains, &addr!("slope")); +assert!(r_hat < 1.1, "chains haven't mixed: split-R̂ = {r_hat:.3}"); +println!("slope: {:.3} ± {:.3}, ESS = {:.0}", summary.mean, summary.std, summary.ess); +``` +```` + ### Cross-Validation ```rust,ignore @@ -490,15 +522,16 @@ let gdp_model = prob!( let sigma <- sample(addr!("sigma"), Gamma::new(2.0, 0.5).unwrap()); - // Quarterly GDP growth predictions - for (i, (inflation, unemployment, interest_rate, gdp_growth)) in economic_data.iter().enumerate() { + // Quarterly GDP growth predictions — one `observe` per quarter via `plate!` + let _observations <- plate!(i in 0..economic_data.len() => { + let (inflation, unemployment, interest_rate, gdp_growth) = economic_data[i]; let expected_growth = intercept + beta_inflation * inflation + beta_unemployment * unemployment + beta_interest_rate * interest_rate; - let _obs <- observe(addr!("gdp", i), Normal::new(expected_growth, sigma).unwrap(), *gdp_growth); - } + observe(addr!("gdp", i), Normal::new(expected_growth, sigma).unwrap(), gdp_growth) + }); pure((intercept, beta_inflation, beta_unemployment, beta_interest_rate)) ); @@ -517,13 +550,15 @@ let dose_response_model = prob!( let sigma <- sample(addr!("sigma"), Gamma::new(2.0, 0.5).unwrap()); - for (i, (log_dose, response)) in dose_response_data.iter().enumerate() { + // One `observe` per dose via `plate!` + let _observations <- plate!(i in 0..dose_response_data.len() => { + let (log_dose, response) = dose_response_data[i]; // Hill equation: E = baseline + (max_effect - baseline) / (1 + 10^(hill_slope * (log_ic50 - log_dose))) let hill_term = hill_slope * (log_ic50 - log_dose); let expected_response = baseline + (max_effect - baseline) / (1.0 + (10.0_f64).powf(hill_term)); - let _obs <- observe(addr!("response", i), Normal::new(expected_response, sigma).unwrap(), *response); - } + observe(addr!("response", i), Normal::new(expected_response, sigma).unwrap(), response) + }); pure((log_ic50, hill_slope, baseline, max_effect)) ); diff --git a/docs/src/tutorials/statistical-modeling/mixture-models.md b/docs/src/tutorials/statistical-modeling/mixture-models.md index fc6f61f..a4fd51f 100644 --- a/docs/src/tutorials/statistical-modeling/mixture-models.md +++ b/docs/src/tutorials/statistical-modeling/mixture-models.md @@ -6,6 +6,12 @@ A comprehensive guide to Bayesian mixture modeling using Fugue. This tutorial demonstrates how to build, analyze, and extend mixture models for complex data structures, showcasing advanced probabilistic programming techniques for unsupervised learning and heterogeneous populations. +```admonish tip title="Try it live" +[**Particles That Tell Stories**](../../explorables/smc.md) plays out the same latent-variable +idea this page uses for cluster assignments — a population of weighted particles standing in +for a distribution — with resampling and degeneracy made visible. +``` + ```admonish info title="Learning Objectives" By the end of this tutorial, you will understand: - **Gaussian Mixture Models**: Foundation of mixture modeling for continuous data @@ -79,6 +85,8 @@ This **data augmentation** approach enables efficient MCMC inference. The most common mixture model uses Gaussian components, ideal for continuous data clustering. +
+ ### Mathematical Model For $K$ Gaussian components: @@ -262,38 +270,74 @@ $$\text{WAIC} = -2(\text{lppd} - p_{\text{WAIC}})$$ Components with different regression relationships: +```admonish note title="No Dirichlet distribution" +Fugue's 17 distributions (`src/core/distribution/`) do not include `Dirichlet`. For symmetric +mixing weights over more than two components, sample a **stick-breaking** decomposition +instead: `n_components - 1` independent `Beta(1, α)` draws, each carving its share off what's +left of the stick. This is the same construction the Dirichlet Process section below uses for +infinitely many components, truncated to a fixed `n_components`. +``` + ```rust,ignore # use fugue::*; +// Stick-breaking weights from `n_components - 1` Beta(1, alpha) draws. Each +// `v_k` claims a `v_k` fraction of whatever stick remains; the last component +// gets what's left. This is a recursive `.bind()` chain, not a `for` loop, +// because each draw depends on the previous step's *sampled* remaining length +// — `prob!`'s `<-` and `plate!` only sequence independent per-index draws. +fn stick_breaking_weights( + k: usize, + n_components: usize, + alpha: f64, + remaining: f64, + mut weights: Vec, +) -> Model> { + if k == n_components - 1 { + weights.push(remaining); // last share: whatever is left of the stick + pure(weights) + } else { + sample(addr!("v", k), Beta::new(1.0, alpha).unwrap()).bind(move |v| { + let w_k = v * remaining; + weights.push(w_k); + stick_breaking_weights(k + 1, n_components, alpha, remaining - w_k, weights) + }) + } +} + fn mixture_regression_model( x_data: Vec, y_data: Vec, n_components: usize ) -> Model<(Vec, Vec<(f64, f64)>, Vec)> { prob! { - // Mixing weights - let alpha_prior = vec![1.0; n_components]; - let mixing_weights <- sample(addr!("pi"), Dirichlet::new(alpha_prior).unwrap()); - - // Component-specific regression parameters - let mut component_params = Vec::new(); - for k in 0..n_components { - let intercept <- sample(addr!("intercept", k), fugue::Normal::new(0.0, 5.0).unwrap()); - let slope <- sample(addr!("slope", k), fugue::Normal::new(0.0, 5.0).unwrap()); - let sigma <- sample(addr!("sigma", k), Gamma::new(1.0, 1.0).unwrap()); - component_params.push((intercept, slope, sigma)); - } + // Mixing weights via stick-breaking (alpha = 1.0: uniform over the simplex) + let mixing_weights <- stick_breaking_weights(0, n_components, 1.0, 1.0, Vec::new()); + + // Component-specific regression parameters — independent per component, + // so a `plate!` (not a dependent recursion) is enough here. + let component_params <- plate!(k in 0..n_components => { + sample(addr!("intercept", k), fugue::Normal::new(0.0, 5.0).unwrap()).bind(move |intercept| { + sample(addr!("slope", k), fugue::Normal::new(0.0, 5.0).unwrap()).bind(move |slope| { + sample(addr!("sigma", k), Gamma::new(1.0, 1.0).unwrap()) + .map(move |sigma| (intercept, slope, sigma)) + }) + }) + }); // Latent cluster assignments and observations - let mut cluster_assignments = Vec::new(); - for i in 0..x_data.len() { - let z_i <- sample(addr!("z", i), Categorical::new(mixing_weights.clone()).unwrap()); - cluster_assignments.push(z_i); - - let (intercept, slope, sigma) = component_params[z_i]; - let mean_y = intercept + slope * x_data[i]; - let _obs <- observe(addr!("y", i), fugue::Normal::new(mean_y, sigma).unwrap(), y_data[i]); - } + let weights_for_obs = mixing_weights.clone(); + let params_for_obs = component_params.clone(); + let _observations <- plate!(i in 0..x_data.len() => { + let weights = weights_for_obs.clone(); + let params = params_for_obs.clone(); + let (x_i, y_i) = (x_data[i], y_data[i]); + sample(addr!("z", i), Categorical::new(weights).unwrap()).bind(move |z_i| { + let (intercept, slope, sigma) = params[z_i]; + let mean_y = intercept + slope * x_i; + observe(addr!("y", i), fugue::Normal::new(mean_y, sigma).unwrap(), y_i) + }) + }); let regression_params: Vec<(f64, f64)> = component_params.iter() .map(|(int, slope, _)| (*int, *slope)).collect(); @@ -312,33 +356,41 @@ Use heavy-tailed distributions for outlier resistance: ```rust,ignore # use fugue::*; +// Fugue provides `StudentT` directly — no Normal +// approximation needed. `StudentT::new(df, loc, scale)`, see +// `src/core/distribution.rs`. fn robust_mixture_model( data: Vec, n_components: usize ) -> Model<(Vec, Vec<(f64, f64, f64)>)> { prob! { - // Mixing weights - let alpha_prior = vec![1.0; n_components]; - let mixing_weights <- sample(addr!("pi"), Dirichlet::new(alpha_prior).unwrap()); - - // t-distribution components for robustness - let mut component_params = Vec::new(); - for k in 0..n_components { - let mu <- sample(addr!("mu", k), fugue::Normal::new(0.0, 10.0).unwrap()); - let sigma <- sample(addr!("sigma", k), Gamma::new(1.0, 1.0).unwrap()); - let nu <- sample(addr!("nu", k), Gamma::new(2.0, 0.1).unwrap()); // Degrees of freedom - component_params.push((mu, sigma, nu)); - } - - // Observations with t-distribution likelihood - for i in 0..data.len() { - let z_i <- sample(addr!("z", i), Categorical::new(mixing_weights.clone()).unwrap()); - let (mu, sigma, nu) = component_params[z_i]; - - // Use Normal approximation for t-distribution (simplified) - let effective_sigma = sigma * (nu / (nu - 2.0)).sqrt(); // t-distribution variance adjustment - let _obs <- observe(addr!("x", i), fugue::Normal::new(mu, effective_sigma).unwrap(), data[i]); - } + // Mixing weights via stick-breaking (see `stick_breaking_weights` above; + // Fugue has no `Dirichlet`) + let mixing_weights <- stick_breaking_weights(0, n_components, 1.0, 1.0, Vec::new()); + + // t-distribution components for robustness — independent per component + let component_params <- plate!(k in 0..n_components => { + sample(addr!("mu", k), fugue::Normal::new(0.0, 10.0).unwrap()).bind(move |mu| { + sample(addr!("sigma", k), Gamma::new(1.0, 1.0).unwrap()).bind(move |sigma| { + // Degrees of freedom: lower = heavier tails, more outlier-resistant + sample(addr!("nu", k), Gamma::new(2.0, 0.1).unwrap()) + .map(move |nu| (mu, sigma, nu)) + }) + }) + }); + + // Observations with a genuine t-distribution likelihood + let weights_for_obs = mixing_weights.clone(); + let params_for_obs = component_params.clone(); + let _observations <- plate!(i in 0..data.len() => { + let weights = weights_for_obs.clone(); + let params = params_for_obs.clone(); + let x_i = data[i]; + sample(addr!("z", i), Categorical::new(weights).unwrap()).bind(move |z_i| { + let (mu, sigma, nu) = params[z_i]; + observe(addr!("x", i), StudentT::new(nu, mu, sigma).unwrap(), x_i) + }) + }); pure((mixing_weights, component_params)) } diff --git a/docs/viz/anatomy.js b/docs/viz/anatomy.js new file mode 100644 index 0000000..ae913f1 --- /dev/null +++ b/docs/viz/anatomy.js @@ -0,0 +1,628 @@ +/* + * docs/viz/anatomy.js — "Anatomy of a Probabilistic Program" + * + * The coin-flip Bayes loop, fully touchable. Prior Beta(alpha, beta) in blue, + * data as clickable H/T coin chips in yellow, posterior Beta(alpha+h, beta+t) + * in green — the literal product blue x yellow = green, normalizing as you + * play. Conjugacy is what the widget exploits; the page's Rust runs fugue's MH + * and lands on the same number. + * + * v2 upgrades (data-first law): + * - Bigger hero (400px). + * - Posterior GHOST TRAIL: when the data or prior changes, the previous + * green curve is kept as a fading ghost, so "updating in real time" leaves + * a visible trail of learning. + * - Sequential-updating REPLAY: press Replay (or Step) and the flips enter + * one at a time, the posterior morphing per flip — Bayesian updating, live. + * - Coin chips FLIP with a subtle squash animation on toggle. + * + * Self-contained IIFE. Consumes window.FugueViz (loaded first per book.toml). + */ +(function () { + "use strict"; + if (typeof window === "undefined" || !window.FugueViz) return; + var FV = window.FugueViz; + + // ---- Math beyond FugueViz: regularized incomplete beta + Beta quantiles ---- + // I_x(a,b) via the Lentz continued fraction (Numerical Recipes betacf/betai). + // Needed for the 90% credible interval readout; FugueViz has no CDF/quantile. + function betacf(x, a, b) { + var MAXIT = 200, EPS = 3e-12, FPMIN = 1e-300; + var qab = a + b, qap = a + 1, qam = a - 1; + var c = 1, d = 1 - (qab * x) / qap; + if (Math.abs(d) < FPMIN) d = FPMIN; + d = 1 / d; + var h = d, m, m2, aa, del; + for (m = 1; m <= MAXIT; m++) { + m2 = 2 * m; + aa = (m * (b - m) * x) / ((qam + m2) * (a + m2)); + d = 1 + aa * d; if (Math.abs(d) < FPMIN) d = FPMIN; + c = 1 + aa / c; if (Math.abs(c) < FPMIN) c = FPMIN; + d = 1 / d; h *= d * c; + aa = -((a + m) * (qab + m) * x) / ((a + m2) * (qap + m2)); + d = 1 + aa * d; if (Math.abs(d) < FPMIN) d = FPMIN; + c = 1 + aa / c; if (Math.abs(c) < FPMIN) c = FPMIN; + d = 1 / d; del = d * c; h *= del; + if (Math.abs(del - 1) < EPS) break; + } + return h; + } + function betainc(x, a, b) { + if (x <= 0) return 0; + if (x >= 1) return 1; + var lbeta = FV.lgamma(a) + FV.lgamma(b) - FV.lgamma(a + b); + var front = Math.exp(a * Math.log(x) + b * Math.log(1 - x) - lbeta); + if (x < (a + 1) / (a + b + 2)) return (front * betacf(x, a, b)) / a; + return 1 - (front * betacf(1 - x, b, a)) / b; + } + function betaQuantile(p, a, b) { + if (p <= 0) return 0; + if (p >= 1) return 1; + var lo = 0, hi = 1, mid; + for (var i = 0; i < 80; i++) { + mid = 0.5 * (lo + hi); + if (betainc(mid, a, b) < p) lo = mid; else hi = mid; + } + return 0.5 * (lo + hi); + } + function betaMode(a, b) { + // Interior mode exists only for a>1 and b>1; otherwise mass piles at an edge. + if (a > 1 && b > 1) return (a - 1) / (a + b - 2); + if (a <= 1 && b > 1) return 0; + if (a > 1 && b <= 1) return 1; + return -1; // a<=1 && b<=1: bimodal at both edges — no single interior mode + } + + var START_DATA = [true, false, true, true, false, true, true, false, true, true]; + + FV.register("anatomy", function (root, FV) { + // ---- DOM shell: controls / canvas / readouts / instruction / hint -------- + var controls = document.createElement("div"); + controls.className = "fv-controls"; + root.appendChild(controls); + + var canvasWrap = document.createElement("div"); + root.appendChild(canvasWrap); + + var instruction = document.createElement("div"); + instruction.className = "fv-instruction"; + instruction.textContent = "Click a coin to flip it. Press Replay to watch the posterior update one flip at a time. Scrub α and β in the text below."; + root.appendChild(instruction); + + var readouts = document.createElement("div"); + readouts.className = "fv-readouts"; + root.appendChild(readouts); + + var hint = document.createElement("div"); + hint.className = "fv-hint"; + hint.textContent = "press Replay — the green curve walks from the prior to the posterior, one flip at a time, leaving a fading trail of every belief it held on the way."; + root.appendChild(hint); + + // ---- State --------------------------------------------------------------- + var seed = parseInt(root.getAttribute("data-seed"), 10); + if (!isFinite(seed)) seed = 11; + var alpha = 2, beta = 2; + // Curated starting data: 7 heads, 3 tails (mirrors the page's Rust example). + var data = START_DATA.slice(); + var revealCount = data.length; // how many flips are currently "live" + var showLik = true; + var chips = []; // hit-test rects, CSS px, filled each draw + + var ghosts = []; // {a, b, life} fading previous posteriors + var chipAnims = {}; // idx -> {kind:'flip'|'enter', t, dur, from} + var playMode = false; // auto sequential-updating replay running + var revealTimer = 0; + // Cached 90% credible bounds — recomputed only when the posterior TARGET + // changes (updateReadouts), NOT per animation frame. betaQuantile is a + // Lentz continued fraction inside an 80-step bisection; recomputing it twice + // every rAF during a replay tween was a needless per-frame hot spot (§A.3). + var credLo = 0, credHi = 1; + + function activeN() { var n = Math.min(revealCount, data.length); return n < 0 ? 0 : n; } + function activeHeads() { var n = 0, m = activeN(); for (var i = 0; i < m; i++) if (data[i]) n++; return n; } + function activeTails() { return activeN() - activeHeads(); } + + // tweened posterior params + their targets + var targetA = alpha + activeHeads(), targetB = beta + activeTails(); + var animA = targetA, animB = targetB; + + // ---- Canvas (bigger hero per v2) ----------------------------------------- + var cv = FV.canvas(canvasWrap, { height: 400, onResize: function () { if (cv) draw(); } }); + + // ---- Loop: drives posterior tween, ghost fade, chip flips, replay -------- + var loopApi = FV.loop(root, tick); + + function tweenSettled() { + return Math.abs(targetA - animA) < 1e-3 && Math.abs(targetB - animB) < 1e-3; + } + + function tick(dt) { + if (dt > 0.1) dt = 0.1; // clamp long frames (tab refocus etc.) + var active = false; + + // --- Sequential replay: reveal one flip on a timer ------------------- + if (playMode) { + revealTimer -= dt; + if (revealTimer <= 0 && revealCount < data.length) { + revealCount++; + chipAnims[revealCount - 1] = { kind: "enter", t: 0, dur: 0.4 }; + revealTimer = 0.5; + setTarget(true, true); // ghost the previous posterior, tween to new + } + if (revealCount >= data.length && tweenSettled() && ghosts.length === 0) { + playMode = false; + syncPlayLabel(); + } else { + active = true; + } + } + + // --- Posterior tween ------------------------------------------------- + var rate = 1 - Math.exp(-dt * 11); + animA += (targetA - animA) * rate; + animB += (targetB - animB) * rate; + if (!tweenSettled()) active = true; + else { animA = targetA; animB = targetB; } + + // --- Ghost fade ------------------------------------------------------ + if (ghosts.length) { + for (var gi = ghosts.length - 1; gi >= 0; gi--) { + ghosts[gi].life -= dt * 0.9; + if (ghosts[gi].life <= 0) ghosts.splice(gi, 1); + } + if (ghosts.length) active = true; + } + + // --- Chip flip / enter animations ------------------------------------ + for (var key in chipAnims) { + if (!chipAnims.hasOwnProperty(key)) continue; + var ca = chipAnims[key]; + ca.t += dt / (ca.dur || 0.4); + if (ca.t >= 1) delete chipAnims[key]; + else active = true; + } + + draw(); + if (!active) loopApi.pause(); + } + + function pushGhost() { + ghosts.push({ a: targetA, b: targetB, life: 1 }); + while (ghosts.length > 6) ghosts.shift(); + } + + // Recompute the posterior target from the currently-live flips. `animate` + // tweens (unless reduced motion); `ghost` leaves the old curve fading. + function setTarget(animate, ghost) { + if (ghost) pushGhost(); + targetA = alpha + activeHeads(); + targetB = beta + activeTails(); + updateReadouts(); + if (!animate || loopApi.reduced) { + animA = targetA; animB = targetB; + draw(); + } else { + loopApi.play(); + } + } + + // ---- Readouts ------------------------------------------------------------ + var roData = FV.readout(readouts, { label: "Data (H / T)" }); + var roMean = FV.readout(readouts, { label: "Posterior mean" }); + var roCI = FV.readout(readouts, { label: "90% credible" }); + var roMap = FV.readout(readouts, { label: "MAP" }); + function updateReadouts() { + var h = activeHeads(), t = activeTails(); + var a = alpha + h, b = beta + t; + roData.set(h + " H / " + t + " T", "data"); + roMean.set((a / (a + b)).toFixed(3), "post"); + var lo = betaQuantile(0.05, a, b), hi = betaQuantile(0.95, a, b); + credLo = lo; credHi = hi; // cache for draw()'s credible band (§A.3) + roCI.set("[" + lo.toFixed(2) + ", " + hi.toFixed(2) + "]", "post"); + var mode = betaMode(a, b); + roMap.set(mode >= 0 ? mode.toFixed(3) : "—", "hot"); + } + + // ---- Replay controls ----------------------------------------------------- + function startReplay() { + if (loopApi.reduced) { + // Reduced motion: no autoplay — just show the full posterior. + playMode = false; + revealCount = data.length; + ghosts = []; chipAnims = {}; + setTarget(false, false); + return; + } + playMode = true; + ghosts = []; chipAnims = {}; + revealCount = 0; + animA = alpha; animB = beta; targetA = alpha; targetB = beta; // start at prior + revealTimer = 0.35; + updateReadouts(); + loopApi.play(); + syncPlayLabel(); + } + function pauseReplay() { + playMode = false; + loopApi.pause(); + syncPlayLabel(); + } + function toggleReplay() { + if (playMode && loopApi.playing) pauseReplay(); + else startReplay(); + } + // Manual sequential updating — one flip per press (also the reduced-motion path). + function stepReveal() { + if (revealCount >= data.length) { + // Restart the walk from the prior. + ghosts = []; chipAnims = {}; + revealCount = 0; + animA = alpha; animB = beta; targetA = alpha; targetB = beta; + updateReadouts(); + } + playMode = false; + revealCount++; + chipAnims[revealCount - 1] = { kind: "enter", t: 0, dur: 0.4 }; + setTarget(true, true); + syncPlayLabel(); + } + + // ---- Controls ------------------------------------------------------------ + var btnRoot = FV.buttons(controls, [ + { label: "Replay", title: "Replay the flips one at a time — watch the posterior update", onClick: toggleReplay, primary: true }, + { label: "Step", title: "Reveal one more flip (one Bayesian update)", onClick: stepReveal }, + { label: "+ Heads", title: "Add a heads flip", onClick: function () { playMode = false; data.push(true); revealCount = data.length; setTarget(true, true); syncPlayLabel(); } }, + { label: "+ Tails", title: "Add a tails flip", onClick: function () { playMode = false; data.push(false); revealCount = data.length; setTarget(true, true); syncPlayLabel(); } }, + { label: "Remove", title: "Remove the last flip", onClick: function () { if (data.length) { playMode = false; data.pop(); revealCount = data.length; setTarget(true, true); syncPlayLabel(); } } }, + { label: "Deal", title: "Deal 12 fresh flips from the current seed (reproducible)", onClick: function () { deal(seedScrub ? seedScrub.fvGet() : seed); } }, + { label: "Reset", title: "Restore the prior and the starting data", onClick: reset } + ]); + var playBtn = btnRoot.fvButtons.Replay; + function syncPlayLabel() { + if (playBtn) playBtn.textContent = (playMode && loopApi.playing) ? "Pause" : "Replay"; + } + + FV.toggle(controls, { + label: "show likelihood", + value: showLik, + onChange: function (v) { showLik = v; draw(); } + }); + + function deal(s) { + playMode = false; + var rand = FV.rng((s >>> 0) || 11); + var bias = 0.62, n = 12, out = []; + for (var i = 0; i < n; i++) out.push(rand() < bias); + data = out; + revealCount = data.length; + chipAnims = {}; + setTarget(true, true); + syncPlayLabel(); + } + function reset() { + playMode = false; + alpha = 2; beta = 2; + data = START_DATA.slice(); + revealCount = data.length; + ghosts = []; chipAnims = {}; + if (alphaScrub) alphaScrub.fvSet(2); + if (betaScrub) betaScrub.fvSet(2); + setTarget(false, false); + syncPlayLabel(); + } + + // ---- Prose scrubs (alpha, beta, seed live inside the sentences) ---------- + var alphaScrub = null, betaScrub = null, seedScrub = null; + var aEl = document.getElementById("fv-anatomy-alpha"); + var bEl = document.getElementById("fv-anatomy-beta"); + var sEl = document.getElementById("fv-anatomy-seed"); + if (aEl) alphaScrub = FV.scrub(aEl, { + min: 0.5, max: 20, step: 0.5, value: alpha, + fmt: function (v) { return String(v); }, + onInput: function (v) { playMode = false; alpha = v; setTarget(false, false); syncPlayLabel(); } // direct manipulation → snap + }); + if (bEl) betaScrub = FV.scrub(bEl, { + min: 0.5, max: 20, step: 0.5, value: beta, + fmt: function (v) { return String(v); }, + onInput: function (v) { playMode = false; beta = v; setTarget(false, false); syncPlayLabel(); } + }); + if (sEl) seedScrub = FV.scrub(sEl, { + min: 1, max: 99, step: 1, value: seed, + fmt: function (v) { return String(v); }, + onInput: function (v) { seed = v; deal(v); } // reproducible re-deal + }); + + // ---- Pointer: tap a coin chip to flip it (shared drag manager) ----------- + // Chips are tap-targets, not drags, but FV.drag gives us exactly the touch + // semantics §A wants: it claims the gesture (setPointerCapture + preventDefault) + // ONLY when the pointerdown actually lands on a chip — so a thumb on a chip + // flips it and never scrolls, while a thumb anywhere else (the whole 400px + // plot region) still scrolls the page. fullCapture:false keeps the canvas at + // touch-action:pan-y so that ambient plot area stays scrollable (§A.1). The + // hit radius comes from `slop`, which the manager inflates to >=22 CSS px on + // coarse pointers (§A.2) while the drawn chip stays a crisp 12px. + function chipHitTest(x, y, slop) { + var best = null, bestD = Infinity; + for (var i = 0; i < chips.length; i++) { + var ch = chips[i]; + var dx = x - ch.x, dy = y - ch.y, d = dx * dx + dy * dy; + var rr = Math.max(ch.r, slop); // coarse pointers -> >=22px pick radius + if (d <= rr * rr && d < bestD) { best = ch; bestD = d; } + } + return best; // truthy chip object on a hit, null on a miss (index 0 is a + // valid chip, so we must return the object, never the index) + } + function flipChip(ch) { + playMode = false; + var was = data[ch.i]; + data[ch.i] = !was; + chipAnims[ch.i] = { kind: "flip", t: 0, dur: 0.4, from: was }; + setTarget(true, true); + syncPlayLabel(); + } + FV.drag(cv.el, { + fullCapture: false, // plot area must still scroll on a swipe + hitTest: chipHitTest, + onStart: function (ch) { flipChip(ch); } // tap = flip on pointerdown + }); + + // ---- Drawing ------------------------------------------------------------- + function pdf(x, a, b) { + var lp = FV.dist.beta.logpdf(x, a, b); + return lp === -Infinity ? 0 : Math.exp(lp); + } + function sampleCurve(a, b, xs) { + var pts = []; + for (var i = 0; i < xs.length; i++) pts.push([xs[i], pdf(xs[i], a, b)]); + return pts; + } + + function draw() { + var w = cv.w, h = cv.h, ctx = cv.ctx; + var colors = FV.theme().colors; + cv.clear(); + + var padL = 8, padR = 8; + var plotLeft = padL, plotRight = w - padR; + var plotW = plotRight - plotLeft; + + // --- DATA strip: coin chips (yellow H, hollow T), clickable ------------- + chips = []; + ctx.save(); + ctx.font = "600 10px var(--mono-font, monospace)"; + ctx.fillStyle = colors.ink; + ctx.globalAlpha = 0.65; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillText("DATA", plotLeft, 6); + ctx.restore(); + + var r = 12, gap = 6, rowH = 2 * r + gap; + var perRow = Math.max(1, Math.floor((plotW + gap) / (2 * r + gap))); + var n = data.length; + var shown = activeN(); + var rowsNeeded = Math.max(1, Math.ceil(n / perRow)); + var rowsShown = Math.min(rowsNeeded, 3); + var chipsTop = 22; + for (var idx = 0; idx < shown; idx++) { + var rIdx = Math.floor(idx / perRow); + if (rIdx >= 3) break; // clip overflow beyond 3 rows + var cIdx = idx % perRow; + var cx = plotLeft + r + cIdx * (2 * r + gap); + var cy = chipsTop + r + rIdx * rowH; + var ca = chipAnims[idx]; + var sx = 1, sy = 1, alph = 1, face = data[idx]; + if (ca) { + if (ca.kind === "flip") { + sx = Math.abs(Math.cos(Math.PI * ca.t)); // squash through the flip + face = ca.t < 0.5 ? ca.from : data[idx]; + } else if (ca.kind === "enter") { + var e = ca.t; + alph = e; + sx = sy = 0.5 + 0.5 * e; // pop in + } + } + drawChip(ctx, cx, cy, r, face, colors, sx, sy, alph); + chips.push({ x: cx, y: cy, r: r + 2, i: idx }); + } + if (rowsNeeded > 3) { + ctx.save(); + ctx.fillStyle = colors.ink; ctx.globalAlpha = 0.55; + ctx.font = "11px var(--mono-font, monospace)"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + ctx.fillText("+" + (n - 3 * perRow) + " more", plotLeft, chipsTop + r + 3 * rowH - rowH / 2); + ctx.restore(); + } + + // --- Plot region ------------------------------------------------------- + var plotTop = chipsTop + rowsShown * rowH + 12; + var plotBottom = h - 22; + if (plotBottom - plotTop < 60) plotTop = plotBottom - 60; + + var xs = []; + var N = 180; + for (var i = 0; i <= N; i++) xs.push(i / N); + + var h0 = activeHeads(), t0 = activeTails(); + var priorPts = sampleCurve(alpha, beta, xs); + var likPts = sampleCurve(h0 + 1, t0 + 1, xs); // normalized likelihood = Beta(h+1,t+1) + var postPts = sampleCurve(animA, animB, xs); + + var ymax = 0; + function accum(pts) { for (var i = 0; i < pts.length; i++) { var v = pts[i][1]; if (isFinite(v) && v > ymax) ymax = v; } } + accum(priorPts); accum(postPts); + if (showLik && h0 + t0 > 0) accum(likPts); + if (!(ymax > 0)) ymax = 1; + ymax *= 1.12; + + var xsc = FV.scale([0, 1], [plotLeft, plotRight]); + var ysc = FV.scale([0, ymax], [plotBottom, plotTop]); + + FV.axes(ctx, { x: plotLeft, y: plotTop, w: plotW, h: plotBottom - plotTop, xscale: xsc, theme: FV.theme() }); + + function toPix(pts) { + var out = []; + for (var i = 0; i < pts.length; i++) { + var v = pts[i][1]; + out.push([xsc(pts[i][0]), isFinite(v) ? ysc(Math.min(v, ymax)) : NaN]); + } + return out; + } + function fillUnder(pts, color, alpha) { + var px = toPix(pts); + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(px[0][0], ysc(0)); + for (var i = 0; i < px.length; i++) if (isFinite(px[i][1])) ctx.lineTo(px[i][0], px[i][1]); + ctx.lineTo(px[px.length - 1][0], ysc(0)); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + + var a2 = animA, b2 = animB; + + // Posterior credible band (green, faint) under the posterior curve. + // Bounds are cached (updateReadouts) — targetA/targetB only change on a + // user action, so there is no need to re-solve the quantile every frame. + var qLo = credLo, qHi = credHi; + ctx.save(); + ctx.globalAlpha = 0.16; + ctx.fillStyle = colors.post; + ctx.beginPath(); + var started = false; + for (var i = 0; i < postPts.length; i++) { + var xv = postPts[i][0]; + if (xv < qLo || xv > qHi) continue; + var pxx = xsc(xv), pyy = ysc(Math.min(postPts[i][1], ymax)); + if (!started) { ctx.moveTo(pxx, ysc(0)); ctx.lineTo(pxx, pyy); started = true; } + else ctx.lineTo(pxx, pyy); + } + if (started) { ctx.lineTo(xsc(Math.min(qHi, 1)), ysc(0)); ctx.closePath(); ctx.fill(); } + ctx.restore(); + + // Posterior GHOST TRAIL: fading green curves of prior beliefs, behind. + for (var gi = 0; gi < ghosts.length; gi++) { + var g = ghosts[gi]; + ctx.save(); + ctx.globalAlpha = 0.18 * Math.max(0, g.life); + FV.curve(ctx, toPix(sampleCurve(g.a, g.b, xs)), { color: colors.post, width: 1.5 }); + ctx.restore(); + } + + // Prior (blue), likelihood (yellow, dashed), posterior (green) overlaid. + fillUnder(priorPts, colors.prior, 0.10); + FV.curve(ctx, toPix(priorPts), { color: colors.prior, width: 2 }); + if (showLik && h0 + t0 > 0) { + FV.curve(ctx, toPix(likPts), { color: colors.data, width: 2, dash: [5, 4] }); + } + fillUnder(postPts, colors.post, 0.20); + FV.curve(ctx, toPix(postPts), { color: colors.post, width: 2.5 }); + + // Posterior mean tick + MAP marker (coral vertical line to the mode). + var meanX = targetA / (targetA + targetB); + drawVLine(ctx, xsc(meanX), plotTop, plotBottom, colors.post, 1.5, [2, 3]); + var mode = betaMode(a2, b2); + if (mode > 0 && mode < 1) { + var modePx = xsc(mode); + var modePy = ysc(Math.min(pdf(mode, a2, b2), ymax)); + drawVLine(ctx, modePx, modePy, plotBottom, colors.hot, 2, null); + ctx.save(); + ctx.fillStyle = colors.hot; + ctx.beginPath(); ctx.arc(modePx, modePy, 3.5, 0, 2 * Math.PI); ctx.fill(); + ctx.restore(); + } + + // Colored legend: prior x likelihood -> posterior (the color algebra). + drawLegend(ctx, plotLeft + 4, plotTop + 4, colors); + + // x-axis label + ctx.save(); + ctx.fillStyle = colors.ink; ctx.globalAlpha = 0.6; + ctx.font = "11px var(--mono-font, monospace)"; + ctx.textAlign = "center"; ctx.textBaseline = "bottom"; + ctx.fillText("bias p", (plotLeft + plotRight) / 2, h - 4); + ctx.restore(); + } + + function drawChip(ctx, cx, cy, r, isHead, colors, sx, sy, alph) { + sx = sx == null ? 1 : sx; + sy = sy == null ? 1 : sy; + alph = alph == null ? 1 : alph; + ctx.save(); + ctx.globalAlpha *= alph; + ctx.translate(cx, cy); + ctx.scale(sx, sy); + ctx.beginPath(); + ctx.arc(0, 0, r, 0, 2 * Math.PI); + if (isHead) { + ctx.fillStyle = colors.data; + ctx.fill(); + ctx.fillStyle = "#1f2328"; + ctx.font = "600 12px var(--mono-font, monospace)"; + } else { + ctx.lineWidth = 1.5; + ctx.strokeStyle = colors.data; + ctx.globalAlpha = 0.7 * alph; + ctx.stroke(); + ctx.globalAlpha = alph; + ctx.fillStyle = colors.data; + ctx.font = "12px var(--mono-font, monospace)"; + } + // Only render the glyph when the chip isn't edge-on (avoids stretched text). + if (Math.abs(sx) > 0.25) { + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(isHead ? "H" : "T", 0, 0.5); + } + ctx.restore(); + } + + function drawVLine(ctx, x, y0, y1, color, width, dash) { + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = width; + if (dash) ctx.setLineDash(dash); + ctx.beginPath(); + ctx.moveTo(x, y0); + ctx.lineTo(x, y1); + ctx.stroke(); + ctx.restore(); + } + + function drawLegend(ctx, x, y, colors) { + ctx.save(); + ctx.font = "600 11px var(--mono-font, monospace)"; + ctx.textBaseline = "top"; + ctx.textAlign = "left"; + var parts = [ + ["prior", colors.prior], + [" × ", colors.ink], + ["likelihood", colors.data], + [" → ", colors.ink], + ["posterior", colors.post] + ]; + var cx = x; + for (var i = 0; i < parts.length; i++) { + ctx.fillStyle = parts[i][1]; + ctx.globalAlpha = parts[i][1] === colors.ink ? 0.6 : 1; + ctx.fillText(parts[i][0], cx, y); + cx += ctx.measureText(parts[i][0]).width; + } + ctx.restore(); + } + + // ---- Theme + init -------------------------------------------------------- + FV.onThemeChange(function () { draw(); }); + updateReadouts(); + // Pre-warm: dealt data with a settled posterior painted synchronously — this is + // also the reduced-motion static frame (never an empty axis). + draw(); + // autoplay: replay the sequential Bayesian updating the moment the widget scrolls + // into view. startReplay no-ops the animation under reduced motion — it just + // re-shows the settled posterior we already painted — so it is safe either way. + startReplay(); + }); +})(); diff --git a/docs/viz/distributions.js b/docs/viz/distributions.js new file mode 100644 index 0000000..24670b7 --- /dev/null +++ b/docs/viz/distributions.js @@ -0,0 +1,767 @@ +// docs/viz/distributions.js — "A Field Guide to Distributions" explorable. +// Self-contained IIFE. Consumes window.FugueViz (loaded first via book.toml). +// Every distribution here maps 1:1 onto a fugue constructor; the pdf/pmf +// and sampler come straight from FugueViz.dist so the widget and the crate +// agree by construction. +(function () { + "use strict"; + if (typeof window === "undefined" || !window.FugueViz) return; + var FV = window.FugueViz; + + // Gamma function via the library's lgamma (needed for Weibull moments). + function G(z) { return Math.exp(FV.lgamma(z)); } + + // -------------------------------------------------------------------------- + // The field guide. group: 'cont' | 'disc' | 'new'. kind: 'cont' | 'disc'. + // Every `logpdf`/`sample` delegates to FV.dist (fugue's parameterization). + // stats() returns {mean, variance, median, mode}; null = undefined/no-simple. + // -------------------------------------------------------------------------- + var D = { + normal: { + name: "Normal(μ, σ)", group: "cont", kind: "cont", ret: "f64", isNew: false, + support: "ℝ = (−∞, ∞)", reach: "a symmetric bell around a known center — noise, measurement error, CLT limits.", + params: [ + { key: "mu", label: "μ (mean)", min: -5, max: 5, step: 0.1, value: 0 }, + { key: "sigma", label: "σ (std dev > 0)", min: 0.2, max: 3, step: 0.05, value: 1 } + ], + domain: function (p) { return [p.mu - 4 * p.sigma, p.mu + 4 * p.sigma]; }, + logpdf: function (x, p) { return FV.dist.normal.logpdf(x, p.mu, p.sigma); }, + sample: function (r, p) { return FV.dist.normal.sample(r, p.mu, p.sigma); }, + stats: function (p) { return { mean: p.mu, variance: p.sigma * p.sigma, median: p.mu, mode: p.mu }; } + }, + uniform: { + name: "Uniform(low, high)", group: "cont", kind: "cont", ret: "f64", isNew: false, + support: "[low, high)", reach: "total ignorance on a bounded interval — a flat prior.", + params: [ + { key: "low", label: "low", min: -5, max: 4, step: 0.25, value: -1 }, + { key: "high", label: "high", min: -4, max: 5, step: 0.25, value: 2 } + ], + validate: function (p) { return p.low < p.high ? null : "Uniform::new requires low < high"; }, + domain: function (p) { var pad = (p.high - p.low) * 0.15 || 0.5; return [p.low - pad, p.high + pad]; }, + logpdf: function (x, p) { return FV.dist.uniform.logpdf(x, p.low, p.high); }, + sample: function (r, p) { return FV.dist.uniform.sample(r, p.low, p.high); }, + stats: function (p) { + var w = p.high - p.low, m = (p.low + p.high) / 2; + return { mean: m, variance: w * w / 12, median: m, mode: null }; + } + }, + lognormal: { + name: "LogNormal(μ, σ)", group: "cont", kind: "cont", ret: "f64", isNew: false, + support: "(0, ∞)", reach: "positive, right-skewed quantities whose logarithm is Normal — incomes, concentrations.", + params: [ + { key: "mu", label: "μ of ln X", min: -1.5, max: 1.5, step: 0.1, value: 0 }, + { key: "sigma", label: "σ of ln X > 0", min: 0.2, max: 1.6, step: 0.05, value: 0.5 } + ], + domain: function (p) { return [0, Math.exp(p.mu + 3 * p.sigma)]; }, + logpdf: function (x, p) { return FV.dist.lognormal.logpdf(x, p.mu, p.sigma); }, + sample: function (r, p) { return FV.dist.lognormal.sample(r, p.mu, p.sigma); }, + stats: function (p) { + var s2 = p.sigma * p.sigma; + return { + mean: Math.exp(p.mu + s2 / 2), + variance: (Math.exp(s2) - 1) * Math.exp(2 * p.mu + s2), + median: Math.exp(p.mu), + mode: Math.exp(p.mu - s2) + }; + } + }, + exponential: { + name: "Exponential(rate)", group: "cont", kind: "cont", ret: "f64", isNew: false, + support: "[0, ∞)", reach: "waiting time until the next event of a memoryless process; mean = 1/rate.", + params: [ + { key: "rate", label: "rate λ > 0", min: 0.2, max: 3, step: 0.05, value: 1 } + ], + domain: function (p) { return [0, 6 / p.rate]; }, + logpdf: function (x, p) { return FV.dist.exponential.logpdf(x, p.rate); }, + sample: function (r, p) { return FV.dist.exponential.sample(r, p.rate); }, + stats: function (p) { + return { mean: 1 / p.rate, variance: 1 / (p.rate * p.rate), median: Math.LN2 / p.rate, mode: 0 }; + } + }, + beta: { + name: "Beta(α, β)", group: "cont", kind: "cont", ret: "f64", isNew: false, + support: "[0, 1]", reach: "a probability about a probability — the conjugate prior for a coin's bias.", + params: [ + { key: "a", label: "α > 0", min: 0.5, max: 6, step: 0.1, value: 2 }, + { key: "b", label: "β > 0", min: 0.5, max: 6, step: 0.1, value: 2 } + ], + domain: function () { return [0, 1]; }, + logpdf: function (x, p) { return FV.dist.beta.logpdf(x, p.a, p.b); }, + sample: function (r, p) { return FV.dist.beta.sample(r, p.a, p.b); }, + stats: function (p) { + var s = p.a + p.b; + return { + mean: p.a / s, + variance: (p.a * p.b) / (s * s * (s + 1)), + median: null, + mode: (p.a > 1 && p.b > 1) ? (p.a - 1) / (s - 2) : null + }; + } + }, + gamma: { + name: "Gamma(shape, rate)", group: "cont", kind: "cont", ret: "f64", isNew: false, + support: "(0, ∞)", reach: "positive quantities and waiting times for `shape` events; RATE-parameterized (mean = shape/rate).", + params: [ + { key: "shape", label: "shape k > 0", min: 0.5, max: 6, step: 0.1, value: 2 }, + { key: "rate", label: "rate λ > 0", min: 0.3, max: 3, step: 0.05, value: 1 } + ], + domain: function (p) { + var m = p.shape / p.rate, sd = Math.sqrt(p.shape) / p.rate; + return [0, m + 5 * sd]; + }, + logpdf: function (x, p) { return FV.dist.gamma.logpdf(x, p.shape, p.rate); }, + sample: function (r, p) { return FV.dist.gamma.sample(r, p.shape, p.rate); }, + stats: function (p) { + return { + mean: p.shape / p.rate, + variance: p.shape / (p.rate * p.rate), + median: null, + mode: p.shape >= 1 ? (p.shape - 1) / p.rate : 0 + }; + } + }, + // ---- discrete ----------------------------------------------------------- + bernoulli: { + name: "Bernoulli(p)", group: "disc", kind: "disc", ret: "bool", isNew: false, + support: "{0, 1} → bool", reach: "a single yes/no trial; fugue hands you a real `bool`, no `== 1.0` dance.", + params: [{ key: "p", label: "p ∈ [0, 1]", min: 0, max: 1, step: 0.02, value: 0.5 }], + ints: function () { return [0, 1]; }, + logpmf: function (k, p) { return FV.dist.bernoulli.logpmf(k, p.p); }, + sample: function (r, p) { return FV.dist.bernoulli.sample(r, p.p) ? 1 : 0; }, + stats: function (p) { return { mean: p.p, variance: p.p * (1 - p.p), median: null, mode: p.p >= 0.5 ? 1 : 0 }; } + }, + categorical: { + name: "Categorical(probs)", group: "disc", kind: "disc", ret: "usize", isNew: false, + support: "{0 … K−1} → usize", reach: "picking one of K labeled outcomes; returns a `usize` you index arrays with safely.", + params: [ + { key: "w0", label: "weight 0", min: 0, max: 5, step: 0.5, value: 3 }, + { key: "w1", label: "weight 1", min: 0, max: 5, step: 0.5, value: 5 }, + { key: "w2", label: "weight 2", min: 0, max: 5, step: 0.5, value: 2 }, + { key: "w3", label: "weight 3", min: 0, max: 5, step: 0.5, value: 1 } + ], + probs: function (p) { + var w = [p.w0, p.w1, p.w2, p.w3], s = w[0] + w[1] + w[2] + w[3]; + if (s <= 0) return null; + return [w[0] / s, w[1] / s, w[2] / s, w[3] / s]; + }, + validate: function (p) { return this.probs(p) ? null : "Categorical::new needs weights that sum to > 0"; }, + ints: function () { return [0, 1, 2, 3]; }, + logpmf: function (k, p) { var ps = this.probs(p); return ps ? FV.dist.categorical.logpmf(k, ps) : -Infinity; }, + sample: function (r, p) { var ps = this.probs(p); return ps ? FV.dist.categorical.sample(r, ps) : 0; }, + stats: function (p) { + var ps = this.probs(p); if (!ps) return { mean: null, variance: null, median: null, mode: null }; + var m = 0, m2 = 0, best = 0, bi = 0; + for (var i = 0; i < ps.length; i++) { m += i * ps[i]; m2 += i * i * ps[i]; if (ps[i] > best) { best = ps[i]; bi = i; } } + return { mean: m, variance: m2 - m * m, median: null, mode: bi }; + } + }, + binomial: { + name: "Binomial(n, p)", group: "disc", kind: "disc", ret: "u64", isNew: false, + support: "{0 … n} → u64", reach: "the count of successes in n independent trials.", + params: [ + { key: "n", label: "n (trials)", min: 1, max: 40, step: 1, value: 15, int: true }, + { key: "p", label: "p ∈ [0, 1]", min: 0, max: 1, step: 0.02, value: 0.4 } + ], + ints: function (p) { var a = [], n = Math.round(p.n); for (var i = 0; i <= n; i++) a.push(i); return a; }, + logpmf: function (k, p) { return FV.dist.binomial.logpmf(k, Math.round(p.n), p.p); }, + sample: function (r, p) { return FV.dist.binomial.sample(r, Math.round(p.n), p.p); }, + stats: function (p) { + var n = Math.round(p.n); + return { mean: n * p.p, variance: n * p.p * (1 - p.p), median: null, mode: Math.floor((n + 1) * p.p) }; + } + }, + poisson: { + name: "Poisson(λ)", group: "disc", kind: "disc", ret: "u64", isNew: false, + support: "{0, 1, 2, …} → u64", reach: "the count of rare events in a fixed window; mean = variance = λ.", + params: [{ key: "lambda", label: "λ > 0", min: 0.3, max: 15, step: 0.1, value: 4 }], + ints: function (p) { + var hi = Math.ceil(p.lambda + 4 * Math.sqrt(p.lambda) + 5), a = []; + for (var i = 0; i <= hi; i++) a.push(i); return a; + }, + logpmf: function (k, p) { return FV.dist.poisson.logpmf(k, p.lambda); }, + sample: function (r, p) { return FV.dist.poisson.sample(r, p.lambda); }, + stats: function (p) { return { mean: p.lambda, variance: p.lambda, median: null, mode: Math.floor(p.lambda) }; } + }, + // ---- additional families ------------------------------------------------ + studentt: { + name: "StudentT(df, loc, scale)", group: "cont", kind: "cont", ret: "f64", isNew: true, + support: "ℝ", reach: "a heavier-tailed Normal; small df tolerates outliers. Robust regression noise.", + params: [ + { key: "df", label: "df ν > 0", min: 1, max: 30, step: 0.5, value: 3 }, + { key: "loc", label: "loc", min: -3, max: 3, step: 0.1, value: 0 }, + { key: "scale", label: "scale > 0", min: 0.3, max: 3, step: 0.05, value: 1 } + ], + domain: function (p) { return [p.loc - 7 * p.scale, p.loc + 7 * p.scale]; }, + logpdf: function (x, p) { return FV.dist.studentt.logpdf(x, p.df, p.loc, p.scale); }, + sample: function (r, p) { return FV.dist.studentt.sample(r, p.df, p.loc, p.scale); }, + stats: function (p) { + return { + mean: p.df > 1 ? p.loc : null, + variance: p.df > 2 ? p.scale * p.scale * p.df / (p.df - 2) : null, + median: p.loc, + mode: p.loc + }; + } + }, + cauchy: { + name: "Cauchy(loc, scale)", group: "cont", kind: "cont", ret: "f64", isNew: true, + support: "ℝ", reach: "pathologically heavy tails — no mean, no variance. StudentT with df = 1.", + params: [ + { key: "loc", label: "loc (median)", min: -3, max: 3, step: 0.1, value: 0 }, + { key: "scale", label: "scale > 0", min: 0.3, max: 3, step: 0.05, value: 1 } + ], + domain: function (p) { return [p.loc - 10 * p.scale, p.loc + 10 * p.scale]; }, + logpdf: function (x, p) { return FV.dist.cauchy.logpdf(x, p.loc, p.scale); }, + sample: function (r, p) { return FV.dist.cauchy.sample(r, p.loc, p.scale); }, + stats: function (p) { return { mean: null, variance: null, median: p.loc, mode: p.loc }; } + }, + laplace: { + name: "Laplace(loc, scale)", group: "cont", kind: "cont", ret: "f64", isNew: true, + support: "ℝ", reach: "a sharp peak with exponential tails; the prior behind L1 / lasso shrinkage.", + params: [ + { key: "loc", label: "loc", min: -3, max: 3, step: 0.1, value: 0 }, + { key: "scale", label: "scale b > 0", min: 0.3, max: 3, step: 0.05, value: 1 } + ], + domain: function (p) { return [p.loc - 8 * p.scale, p.loc + 8 * p.scale]; }, + logpdf: function (x, p) { return FV.dist.laplace.logpdf(x, p.loc, p.scale); }, + sample: function (r, p) { return FV.dist.laplace.sample(r, p.loc, p.scale); }, + stats: function (p) { return { mean: p.loc, variance: 2 * p.scale * p.scale, median: p.loc, mode: p.loc }; } + }, + weibull: { + name: "Weibull(shape, scale)", group: "cont", kind: "cont", ret: "f64", isNew: true, + support: "[0, ∞)", reach: "time-to-failure and survival; shape < 1 ages out early, shape > 1 wears out late.", + params: [ + { key: "shape", label: "shape k > 0", min: 0.5, max: 5, step: 0.1, value: 1.5 }, + { key: "scale", label: "scale λ > 0", min: 0.3, max: 3, step: 0.05, value: 1 } + ], + domain: function (p) { + return [0, p.scale * Math.pow(-Math.log(0.004), 1 / p.shape) * 1.05]; + }, + logpdf: function (x, p) { return FV.dist.weibull.logpdf(x, p.shape, p.scale); }, + sample: function (r, p) { return FV.dist.weibull.sample(r, p.shape, p.scale); }, + stats: function (p) { + var k = p.shape, l = p.scale; + var g1 = G(1 + 1 / k), g2 = G(1 + 2 / k); + return { + mean: l * g1, + variance: l * l * (g2 - g1 * g1), + median: l * Math.pow(Math.LN2, 1 / k), + mode: k > 1 ? l * Math.pow((k - 1) / k, 1 / k) : 0 + }; + } + }, + chisquared: { + name: "ChiSquared(k)", group: "cont", kind: "cont", ret: "f64", isNew: true, + support: "(0, ∞)", reach: "sums of k squared standard Normals; goodness-of-fit and variance tests. = Gamma(k/2, ½).", + params: [{ key: "k", label: "df k > 0", min: 1, max: 15, step: 0.5, value: 4 }], + domain: function (p) { return [0, p.k + 4 * Math.sqrt(2 * p.k) + 2]; }, + logpdf: function (x, p) { return FV.dist.chisquared.logpdf(x, p.k); }, + sample: function (r, p) { return FV.dist.chisquared.sample(r, p.k); }, + stats: function (p) { + var med = p.k * Math.pow(1 - 2 / (9 * p.k), 3); + return { mean: p.k, variance: 2 * p.k, median: med > 0 ? med : null, mode: Math.max(p.k - 2, 0) }; + } + }, + inversegamma: { + name: "InverseGamma(shape, rate)", group: "cont", kind: "cont", ret: "f64", isNew: true, + support: "(0, ∞)", reach: "the conjugate prior for a Normal's variance; α = shape, β = rate.", + params: [ + { key: "shape", label: "shape α > 0", min: 1.5, max: 6, step: 0.1, value: 3 }, + { key: "rate", label: "rate β > 0", min: 0.5, max: 4, step: 0.1, value: 2 } + ], + domain: function (p) { + var hi = p.shape > 1 ? p.rate / (p.shape - 1) : p.rate / (p.shape + 1); + return [0, hi * 6]; + }, + logpdf: function (x, p) { return FV.dist.inversegamma.logpdf(x, p.shape, p.rate); }, + sample: function (r, p) { return FV.dist.inversegamma.sample(r, p.shape, p.rate); }, + stats: function (p) { + return { + mean: p.shape > 1 ? p.rate / (p.shape - 1) : null, + variance: p.shape > 2 ? (p.rate * p.rate) / ((p.shape - 1) * (p.shape - 1) * (p.shape - 2)) : null, + median: null, + mode: p.rate / (p.shape + 1) + }; + } + }, + discreteuniform: { + name: "DiscreteUniform(low, high)", group: "disc", kind: "disc", ret: "i64", isNew: true, + support: "{low … high} inclusive → i64", reach: "a fair die over an integer range; every value equally likely.", + params: [ + { key: "low", label: "low", min: 0, max: 6, step: 1, value: 1, int: true }, + { key: "high", label: "high", min: 1, max: 12, step: 1, value: 6, int: true } + ], + validate: function (p) { return Math.round(p.low) <= Math.round(p.high) ? null : "DiscreteUniform::new requires low ≤ high"; }, + ints: function (p) { + var lo = Math.round(p.low), hi = Math.round(p.high), a = []; + for (var i = lo; i <= hi; i++) a.push(i); return a; + }, + logpmf: function (k, p) { return FV.dist.discreteuniform.logpmf(k, Math.round(p.low), Math.round(p.high)); }, + sample: function (r, p) { return FV.dist.discreteuniform.sample(r, Math.round(p.low), Math.round(p.high)); }, + stats: function (p) { + var lo = Math.round(p.low), hi = Math.round(p.high), n = hi - lo + 1; + return { mean: (lo + hi) / 2, variance: (n * n - 1) / 12, median: (lo + hi) / 2, mode: null }; + } + } + }; + + // Menu order, grouped for the s. + var GROUPS = [ + { label: "Continuous", keys: ["normal", "uniform", "lognormal", "exponential", "beta", "gamma", "studentt", "cauchy", "laplace", "weibull", "chisquared", "inversegamma"] }, + { label: "Discrete", keys: ["bernoulli", "categorical", "binomial", "poisson", "discreteuniform"] } + ]; + + var MAX_SAMPLES = 60000; // cap the continuous sample buffer + + FV.register("distributions", function (root, FV) { + // ---- shell ------------------------------------------------------------ + var controls = document.createElement("div"); + controls.className = "fv-controls"; + root.appendChild(controls); + + // selector + meta line + var selWrap = document.createElement("label"); + selWrap.className = "fv-control"; + var selLab = document.createElement("span"); + selLab.className = "fv-control-label"; + selLab.textContent = "distribution"; + selWrap.appendChild(selLab); + var sel = document.createElement("select"); + sel.className = "fv-select"; + sel.style.cssText = "font-size:0.8rem;color:var(--fv-ink,inherit);background:var(--fv-panel,transparent);" + + "border:1px solid var(--fv-grid,#8884);border-radius:6px;padding:4px 6px;margin-top:4px;"; + for (var gi = 0; gi < GROUPS.length; gi++) { + var og = document.createElement("optgroup"); + og.label = GROUPS[gi].label; + for (var ki = 0; ki < GROUPS[gi].keys.length; ki++) { + var key = GROUPS[gi].keys[ki]; + var opt = document.createElement("option"); + opt.value = key; + opt.textContent = D[key].name; + og.appendChild(opt); + } + sel.appendChild(og); + } + selWrap.appendChild(sel); + controls.appendChild(selWrap); + + // dynamic parameter sliders live here + var paramBox = document.createElement("div"); + paramBox.className = "fv-controls fv-param-box"; + paramBox.style.margin = "0"; + root.appendChild(paramBox); + + // transport + seed + var transport = document.createElement("div"); + transport.className = "fv-controls"; + root.appendChild(transport); + + var playBtns = FV.buttons(transport, [ + { label: "Play", primary: true, title: "Stream samples", onClick: togglePlay }, + { label: "Step", title: "Draw one batch of samples", onClick: function () { lp.step(); } }, + { label: "Reset", title: "Clear samples, keep params", onClick: function () { resetSamples(); render(); } } + ]); + var playBtn = playBtns.fvButtons.Play; + + var seedWrap = document.createElement("label"); + seedWrap.className = "fv-control"; + var seedLab = document.createElement("span"); + seedLab.className = "fv-control-label"; + seedLab.textContent = "seed"; + seedWrap.appendChild(seedLab); + var seedSpan = document.createElement("span"); + seedWrap.appendChild(seedSpan); + transport.appendChild(seedWrap); + + var seed = parseInt(root.getAttribute("data-seed"), 10); + if (!(seed >= 0)) seed = 11; + FV.scrub(seedSpan, { + min: 1, max: 9999, step: 1, value: seed, + fmt: function (v) { return String(v); }, + onInput: function (v) { seed = v; resetSamples(); render(); } + }); + + // canvas + var cv = FV.canvas(root, { height: 340, onResize: function () { render(); } }); + + var instr = document.createElement("div"); + instr.className = "fv-instruction"; + instr.textContent = "Drag across the canvas to query the log-density at any point."; + root.appendChild(instr); + + // readouts + var readBox = document.createElement("div"); + readBox.className = "fv-readouts"; + root.appendChild(readBox); + var rLogf = FV.readout(readBox, { label: "log f(x)" }); + var rMean = FV.readout(readBox, { label: "mean" }); + var rVar = FV.readout(readBox, { label: "variance" }); + var rRet = FV.readout(readBox, { label: "sample →" }); + var rN = FV.readout(readBox, { label: "samples" }); + + var hint = document.createElement("div"); + hint.className = "fv-hint"; + hint.textContent = "watch the green samples race to cover the blue law, then drop the seed back to replay the exact same run."; + root.appendChild(hint); + + // ---- state ------------------------------------------------------------ + var defKey = "normal"; + var def = D[defKey]; + var params = {}; + var rand = FV.rng(seed); + var samples = []; // continuous buffer + var counts = {}; // discrete: integer -> count + var total = 0; + var qx = 0; // query-x for the coral line + var qxSet = false; + + function currentParams() { + var p = {}; + for (var i = 0; i < def.params.length; i++) { + var sp = def.params[i]; + p[sp.key] = sp._slider ? sp._slider.fvGet() : sp.value; + } + return p; + } + + function resetSamples() { + rand = FV.rng(seed); + samples = []; + counts = {}; + total = 0; + } + + function buildParamSliders() { + // clear + while (paramBox.firstChild) paramBox.removeChild(paramBox.firstChild); + for (var i = 0; i < def.params.length; i++) { + (function (sp) { + var decimals = (String(sp.step).split(".")[1] || "").length; + sp._slider = FV.slider(paramBox, { + label: sp.label, min: sp.min, max: sp.max, step: sp.step, value: sp.value, + fmt: function (v) { return sp.int ? String(Math.round(v)) : v.toFixed(Math.max(decimals, 0)); }, + onInput: function () { params = currentParams(); resetSamples(); qxSet = false; render(); } + }); + })(def.params[i]); + } + } + + function selectDist(key) { + def = D[key]; + defKey = key; + buildParamSliders(); + params = currentParams(); + resetSamples(); + qxSet = false; + render(); + } + + sel.addEventListener("change", function () { selectDist(sel.value); }); + + // ---- sampling --------------------------------------------------------- + function addBatch(n) { + if (def.validate && def.validate(params)) return; + for (var i = 0; i < n; i++) { + var v = def.sample(rand, params); + if (def.kind === "disc") { + var iv = Math.round(v); + counts[iv] = (counts[iv] || 0) + 1; + total++; + } else { + if (samples.length < MAX_SAMPLES) samples.push(v); + total++; + } + } + } + + // ---- drawing ---------------------------------------------------------- + var ML = 46, MR = 16, MT = 16, MB = 34; + + function fmtNum(v) { + if (v == null || !isFinite(v)) return "—"; + var a = Math.abs(v); + if (a !== 0 && (a >= 1e4 || a < 1e-3)) return v.toExponential(2); + return (Math.round(v * 1000) / 1000).toString(); + } + + function clampY(py, top, bot) { return py < top ? top : py > bot ? bot : py; } + + function render() { + if (!def || !params) return; // canvas() fires onResize before state is ready + var t = FV.theme(); + var c = t.colors; + var ctx = cv.ctx; + cv.clear(); + + var W = cv.w, H = cv.h; + var plotL = ML, plotR = W - MR, plotT = MT, plotB = H - MB; + var plotW = plotR - plotL, plotH = plotB - plotT; + + // return-type badge (fugue's natural sample type) + rRet.set(def.ret); + + // invalid parameters: show fugue's validation story, no draw + if (def.validate) { + var msg = def.validate(params); + if (msg) { + ctx.save(); + ctx.fillStyle = c.hot; + ctx.font = "13px var(--mono-font, monospace)"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(msg, W / 2, H / 2 - 8); + ctx.globalAlpha = 0.7; + ctx.font = "11px var(--mono-font, monospace)"; + ctx.fillStyle = c.ink; + ctx.fillText("(the constructor returns Err — nothing to sample)", W / 2, H / 2 + 12); + ctx.restore(); + rLogf.set("—"); rMean.set("—"); rVar.set("—"); rN.set(String(total)); + return; + } + } + + var st = def.stats(params); + var dom = def.kind === "disc" + ? (function () { var ks = def.ints(params); return [ks[0] - 0.5, ks[ks.length - 1] + 0.5]; })() + : def.domain(params); + var xlo = dom[0], xhi = dom[1]; + var xscale = FV.scale([xlo, xhi], [plotL, plotR]); + + // default query-x at the mean (or domain centre) + if (!qxSet) { + qx = (st && st.mean != null && isFinite(st.mean)) ? st.mean : (xlo + xhi) / 2; + if (qx < xlo) qx = xlo; if (qx > xhi) qx = xhi; + qxSet = true; + } + + // ---- theoretical values + ymax ---- + var ymax, theo; + if (def.kind === "disc") { + var ks = def.ints(params); + theo = []; + ymax = 1e-9; + for (var i = 0; i < ks.length; i++) { + var pm = Math.exp(def.logpmf(ks[i], params)); + theo.push(pm); + if (pm > ymax) ymax = pm; + } + // empirical bars may exceed theory a touch + for (var kk in counts) { var e = counts[kk] / (total || 1); if (e > ymax) ymax = e; } + ymax *= 1.2; + } else { + var N = 240, dens = []; + var eps = (xhi - xlo) * 1e-4; + theo = []; + for (var g = 0; g <= N; g++) { + var x = xlo + (xhi - xlo) * (g / N); + // nudge off exact support edges (Beta/Weibull can be +∞ there) + if (g === 0) x += eps; if (g === N) x -= eps; + var d = Math.exp(def.logpdf(x, params)); + theo.push([x, d]); + if (isFinite(d)) dens.push(d); + } + // robust ymax: 97th percentile so a spike doesn't flatten the rest + dens.sort(function (a, b) { return a - b; }); + var q = dens.length ? dens[Math.min(dens.length - 1, Math.floor(dens.length * 0.97))] : 1; + ymax = (q > 0 ? q : 1) * 1.3; + } + var yscale = FV.scale([0, ymax], [plotB, plotT]); + + // ---- axes ---- + FV.axes(ctx, { + x: plotL, y: plotT, w: plotW, h: plotH, + xscale: xscale, yscale: yscale, + xlabel: "x", ylabel: def.kind === "disc" ? "P(x)" : "density", theme: t + }); + + // support baseline (ink) along y = 0 + ctx.save(); + ctx.strokeStyle = c.ink; + ctx.globalAlpha = 0.5; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(plotL, yscale(0)); + ctx.lineTo(plotR, yscale(0)); + ctx.stroke(); + ctx.restore(); + + // ---- empirical samples (green) ---- + if (def.kind === "disc") { + var ks2 = def.ints(params); + ctx.save(); + ctx.globalAlpha = 0.5; + ctx.fillStyle = c.post; + for (var b = 0; b < ks2.length; b++) { + var kv = ks2[b]; + var emp = (counts[kv] || 0) / (total || 1); + if (emp <= 0) continue; + var bx = xscale(kv); + var halfw = Math.min(14, (xscale(1) - xscale(0)) * 0.34); + ctx.fillRect(bx - halfw, yscale(emp), halfw * 2, yscale(0) - yscale(emp)); + } + ctx.restore(); + } else { + FV.histogram(ctx, samples, { bins: 46, xscale: xscale, yscale: yscale, color: c.post, alpha: 0.5 }); + } + + // ---- theoretical law (blue) ---- + if (def.kind === "disc") { + var ks3 = def.ints(params); + ctx.save(); + ctx.strokeStyle = c.prior; + ctx.fillStyle = c.prior; + ctx.lineWidth = 2; + for (var s = 0; s < ks3.length; s++) { + var sx = xscale(ks3[s]); + var sy = yscale(theo[s]); + ctx.globalAlpha = 0.9; + ctx.beginPath(); ctx.moveTo(sx, yscale(0)); ctx.lineTo(sx, sy); ctx.stroke(); + ctx.globalAlpha = 1; + ctx.beginPath(); ctx.arc(sx, sy, 3.2, 0, 2 * Math.PI); ctx.fill(); + } + ctx.restore(); + } else { + var pts = []; + for (var pj = 0; pj < theo.length; pj++) { + var xv = theo[pj][0], dv = theo[pj][1]; + if (!isFinite(dv)) { pts.push(null); continue; } + pts.push([xscale(xv), clampY(yscale(dv), plotT, plotB)]); + } + FV.curve(ctx, pts, { color: c.prior, width: 2.2 }); + } + + // ---- mean / median / mode markers ---- + function marker(val, color, dash, label, labY) { + if (val == null || !isFinite(val) || val < xlo || val > xhi) return; + var mx = xscale(val); + ctx.save(); + ctx.strokeStyle = color; + ctx.globalAlpha = 0.9; + ctx.lineWidth = 1.5; + if (dash) ctx.setLineDash(dash); + ctx.beginPath(); ctx.moveTo(mx, plotB); ctx.lineTo(mx, plotT + 2); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = color; + ctx.font = "10px var(--mono-font, monospace)"; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(label, mx, labY); + ctx.restore(); + } + if (st) { + marker(st.mean, c.flow, null, "mean", plotT + 3); + marker(st.median, c.ink, [3, 3], "med", plotT + 15); + marker(st.mode, c.data, [1, 3], "mode", plotT + 27); + } + + // ---- coral query line ---- + var qxc = qx; + var logv; + if (def.kind === "disc") { + var ksq = def.ints(params); + var nearest = ksq[0]; + for (var qi = 0; qi < ksq.length; qi++) if (Math.abs(ksq[qi] - qx) < Math.abs(nearest - qx)) nearest = ksq[qi]; + qxc = nearest; + logv = def.logpmf(nearest, params); + } else { + logv = def.logpdf(qx, params); + } + var lx = xscale(qxc); + ctx.save(); + ctx.strokeStyle = c.hot; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(lx, plotT); ctx.lineTo(lx, plotB); ctx.stroke(); + // dot on the law + var dot = def.kind === "disc" ? Math.exp(def.logpmf(qxc, params)) : Math.exp(def.logpdf(qxc, params)); + if (isFinite(dot)) { + var dotY = clampY(yscale(dot), plotT, plotB); + // §A.2: soft grab-halo on the queried point while actively dragging. + if (dragging && FV.halo) FV.halo(ctx, lx, dotY, 11, c.hot, 0.3); + ctx.fillStyle = c.hot; + ctx.beginPath(); ctx.arc(lx, dotY, 4, 0, 2 * Math.PI); ctx.fill(); + } + ctx.restore(); + + // ---- readouts ---- + var xlabel = def.kind === "disc" ? String(qxc) : (Math.round(qxc * 100) / 100).toString(); + rLogf.set(fmtNum(logv) + " @ x=" + xlabel, "hot"); + rMean.set(st ? fmtNum(st.mean) : "—", "flow"); + rVar.set(st ? fmtNum(st.variance) : "—"); + rN.set(String(total)); + } + + // ---- pointer: drag the query line (horizontal scrub) ----------------- + // §A.1 scroll-fight fix: the query line is a purely HORIZONTAL scrub, so + // the canvas gets touch-action:pan-y — a vertical thumb-swipe scrolls the + // PAGE, a horizontal drag queries the density. We never preventDefault + // (the old code did, unconditionally, on every touchmove — that ate page + // scroll over this 340px canvas, the phone "bugginess"). Pointer capture + // keeps a fast drag tracking even off the canvas edge without any window + // listeners; if the browser claims the gesture for a vertical scroll it + // fires pointercancel and we stop cleanly. + cv.el.style.touchAction = "pan-y"; + cv.el.style.cursor = "ew-resize"; + + function pointerX(clientX) { + var rect = cv.el.getBoundingClientRect(); + var cx = clientX - rect.left; + var plotL = ML, plotR = cv.w - MR; + var frac = (cx - plotL) / (plotR - plotL || 1); + if (frac < 0) frac = 0; if (frac > 1) frac = 1; + var dom = def.kind === "disc" + ? (function () { var ks = def.ints(params); return [ks[0] - 0.5, ks[ks.length - 1] + 0.5]; })() + : def.domain(params); + return dom[0] + frac * (dom[1] - dom[0]); + } + var dragging = false; + function beginQuery(clientX) { dragging = true; qx = pointerX(clientX); qxSet = true; render(); } + function moveQuery(clientX) { if (!dragging) return; qx = pointerX(clientX); render(); } + function endQuery() { if (!dragging) return; dragging = false; render(); } + + if (typeof window.PointerEvent !== "undefined") { + cv.el.addEventListener("pointerdown", function (ev) { + if (ev.pointerType === "mouse" && ev.button !== 0) return; + try { cv.el.setPointerCapture(ev.pointerId); } catch (e) {} + beginQuery(ev.clientX); + }); + cv.el.addEventListener("pointermove", function (ev) { moveQuery(ev.clientX); }); + cv.el.addEventListener("pointerup", endQuery); + cv.el.addEventListener("pointercancel", endQuery); + } else { + // Legacy fallback (no Pointer Events): touch-action:pan-y still blocks + // horizontal page-pan, so no preventDefault is needed to scrub. + cv.el.addEventListener("mousedown", function (ev) { beginQuery(ev.clientX); }); + window.addEventListener("mousemove", function (ev) { moveQuery(ev.clientX); }); + window.addEventListener("mouseup", endQuery); + cv.el.addEventListener("touchstart", function (ev) { if (ev.touches[0]) beginQuery(ev.touches[0].clientX); }); + cv.el.addEventListener("touchmove", function (ev) { if (ev.touches[0]) moveQuery(ev.touches[0].clientX); }); + window.addEventListener("touchend", endQuery); + } + + // ---- animation -------------------------------------------------------- + // autoplay: samples start streaming the moment the widget scrolls into view. + // Under reduced motion the loop no-ops and the pre-warmed histogram (below) stands in. + var lp = FV.loop(root, function () { + var batch = def.kind === "disc" ? 24 : 40; + addBatch(batch); + render(); + }, { autoplay: true }); + function togglePlay() { + if (lp.playing) { lp.pause(); playBtn.textContent = "Play"; } + else { lp.play(); playBtn.textContent = lp.playing ? "Pause" : "Play"; } + } + + FV.onThemeChange(function () { render(); }); + + // ---- go --------------------------------------------------------------- + sel.value = defKey; + selectDist(defKey); + // Pre-warm ~200 samples so the green histogram is already forming at first paint + // (and so the reduced-motion frame shows a partly-built histogram, not a bare curve). + addBatch(200); + render(); + // The loop autoplays itself (FV.loop {autoplay:true}); reflect that on the button. + if (lp.reduced) { playBtn.textContent = "Play"; hint.textContent = "reduced-motion is on — tap Step to draw a batch of samples and watch them accumulate."; } + else { playBtn.textContent = "Pause"; } + }); +})(); diff --git a/docs/viz/hmc.js b/docs/viz/hmc.js new file mode 100644 index 0000000..467f7cc --- /dev/null +++ b/docs/viz/hmc.js @@ -0,0 +1,726 @@ +// docs/viz/hmc.js — "Rolling, Not Guessing: Hamiltonian Monte Carlo" (v2, data-first). +// Self-contained IIFE; consumes window.FugueViz (loaded first via book.toml). +// +// Twin panel over a real Bayesian linear regression — the SAME seeded dataset and +// model as the Metropolis explorable, so a reader flowing metropolis -> hmc meets +// the same problem instantly: +// y_i ~ Normal(a*x_i + b, sigma_obs), a ~ Normal(0, 2.5), b ~ Normal(0, 2.5) +// sigma_obs fixed at 0.8. +// +// LEFT (data space): 12 draggable yellow points; posterior SPAGHETTI — the last +// accepted (slope,intercept) samples as thin green lines through +// the cloud; the current sample's line coral and thick; on a +// rejected/divergent proposal the proposed line flashes coral-dashed. +// RIGHT (param space): live 2D posterior heatmap over (slope, intercept), recomputed +// whenever a data point moves; the HMC state rolls through it with +// a violet leapfrog trajectory + momentum arrow; divergences glow +// coral; a compact energy strip-chart sits below. +// +// The link is the lesson: a point in parameter space (right) IS a line in data space +// (left). MH side-by-side runs a random-walk chain at a MATCHED gradient budget and +// paints its spaghetti in DIMMER green, so HMC's coverage advantage shows in data space. +(function () { + "use strict"; + + // ------------------------------------------------------------------ + // The regression problem — dataset + model. Hardcoded IDENTICALLY to the + // metropolis page: seed 11, 12 points, x evenly spaced in [-3, 3], true + // slope 0.8, intercept -0.4, observation noise 0.8. + // ------------------------------------------------------------------ + var N = 12; + var A_TRUE = 0.8, B_TRUE = -0.4, NOISE = 0.8, DATA_SEED = 11; + var SIGMA_OBS = 0.8; // fixed observation noise (stated on the page) + var PRIOR_SD = 2.5; // Normal(0, 2.5) prior on both params + var INV_S2 = 1 / (SIGMA_OBS * SIGMA_OBS); + var INV_T2 = 1 / (PRIOR_SD * PRIOR_SD); + + // Parameter-space window over (slope a, intercept b). + var DOM_A = [-0.2, 2.0]; + var DOM_B = [-2.5, 1.2]; + // Data-space window. + var DAT_X = [-3.6, 3.6]; + var DAT_Y = [-4.8, 3.2]; + var DIV_THRESHOLD = 1000; // |dH| beyond this => divergent (Stan's default) + var SPAG_CAP = 60; // spaghetti lines retained per sampler + + function makeSeedData(seed) { + var rng = FugueViz.rng(seed >>> 0); + var xs = new Array(N), ys = new Array(N); + for (var i = 0; i < N; i++) { + var x = -3 + 6 * i / (N - 1); + xs[i] = x; + ys[i] = A_TRUE * x + B_TRUE + NOISE * FugueViz.randn(rng); + } + return { xs: xs, ys: ys }; + } + + // Log-posterior over (a, b) for the current data. Gaussian likelihood + + // Gaussian priors — proper, honest math (no conjugacy shortcut in the sampler). + function logpost(a, b, D) { + var xs = D.xs, ys = D.ys, s = 0; + for (var i = 0; i < N; i++) { + var r = ys[i] - (a * xs[i] + b); + s += -0.5 * INV_S2 * r * r; + } + s += -0.5 * INV_T2 * (a * a + b * b); + return s - N * (Math.log(SIGMA_OBS) + 0.918938533204673); // + const (0.5 ln 2pi) + } + // Analytic gradient of the log-posterior (verified vs finite differences). + function gradLogpost(a, b, D) { + var xs = D.xs, ys = D.ys, ga = 0, gb = 0; + for (var i = 0; i < N; i++) { + var r = (ys[i] - (a * xs[i] + b)) * INV_S2; + ga += r * xs[i]; + gb += r; + } + ga -= a * INV_T2; + gb -= b * INV_T2; + return [ga, gb]; + } + + // ------------------------------------------------------------------ + // Effective sample size — ported from src/inference/mcmc_utils.rs + // (single-chain Geyer initial-positive + monotone sequence estimator). + // ------------------------------------------------------------------ + function autocov(x, maxLag) { + var n = x.length, i, lag; + var mean = 0; + for (i = 0; i < n; i++) mean += x[i]; + mean /= n; + var c = new Array(n); + for (i = 0; i < n; i++) c[i] = x[i] - mean; + var out = new Array(maxLag + 1); + for (lag = 0; lag <= maxLag; lag++) { + var s = 0; + for (i = 0; i < n - lag; i++) s += c[i] * c[i + lag]; + out[lag] = s / n; + } + return out; + } + function essSingle(x) { + var n = x.length; + if (n < 4) return n; + var maxLag = Math.min(n - 1, 2048); + var acov = autocov(x, maxLag); + var W = acov[0] * n / (n - 1); + if (!(W > 0)) return n; + var varPlus = W * (n - 1) / n; + function rho(t) { return 1 - (W - acov[t]) / varPlus; } + var rhoHat = new Array(maxLag + 1), k; + for (k = 0; k <= maxLag; k++) rhoHat[k] = 0; + rhoHat[0] = 1; + if (maxLag >= 1) rhoHat[1] = rho(1); + var t = 1, maxT = Math.min(1, maxLag); + while (t + 2 <= maxLag) { + var re = rho(t + 1), ro = rho(t + 2); + if (re + ro < 0) break; + rhoHat[t + 1] = re; rhoHat[t + 2] = ro; + maxT = t + 2; t += 2; + } + k = 1; + while (k + 2 <= maxT) { + var prev = rhoHat[k - 1] + rhoHat[k]; + var cur = rhoHat[k + 1] + rhoHat[k + 2]; + if (cur > prev) { var avg = prev / 2; rhoHat[k + 1] = avg; rhoHat[k + 2] = avg; } + k += 2; + } + var sum = 0; + for (k = 0; k <= maxT; k++) sum += rhoHat[k]; + var tau = Math.max(1, -1 + 2 * sum); + return n / tau; + } + + // Expose the math for the node gate to import. + if (typeof module !== "undefined" && module.exports) { + module.exports = { + makeSeedData: makeSeedData, logpost: logpost, gradLogpost: gradLogpost, + essSingle: essSingle, N: N, SIGMA_OBS: SIGMA_OBS, PRIOR_SD: PRIOR_SD + }; + } + + // ------------------------------------------------------------------ + if (typeof FugueViz === "undefined" || !FugueViz.register) return; + + FugueViz.register("hmc", function (root, FV) { + var seed0 = parseInt(root.getAttribute("data-seed"), 10); + if (!isFinite(seed0)) seed0 = DATA_SEED; + + // ---- tunables / chain state ---- + var eps = 0.08, L = 25, speed = 14, sideBySide = false; + var curSeed = seed0 >>> 0; + var data = makeSeedData(DATA_SEED); // dataset is fixed to seed 11 + var rng = FV.rng(curSeed); + function nrm() { return FV.randn(rng); } + + var q, logpCur, trail, samplesA, spag, accepts, total, divergences, lastDH; + var active = null, reveal = 0, applied = false, flash = null, rejLine = null; + + // MH side chain (matched budget: one proposal per leapfrog step) + var MH_SIGMA = 0.12; + var mhQ, mhLogp, mhTrail, mhSpag, mhAccepts, mhTotal; + + function pushCapped(arr, v, cap) { arr.push(v); if (arr.length > cap) arr.shift(); } + + function resetChain() { + rng = FV.rng(curSeed); + q = [0.0, 0.0]; + logpCur = logpost(q[0], q[1], data); + trail = [[q[0], q[1]]]; + samplesA = [q[0]]; + spag = []; + accepts = 0; total = 0; divergences = 0; lastDH = 0; + mhQ = [0.0, 0.0]; mhLogp = logpost(mhQ[0], mhQ[1], data); + mhTrail = [[mhQ[0], mhQ[1]]]; mhSpag = []; mhAccepts = 0; mhTotal = 0; + active = null; reveal = 0; applied = false; flash = null; rejLine = null; + nextTransition(); + } + + // The data changed (a point was dragged): recompute densities but keep the + // chain where it is so you SEE it migrate onto the new posterior. + function onDataChanged() { + logpCur = logpost(q[0], q[1], data); + mhLogp = logpost(mhQ[0], mhQ[1], data); + nextTransition(); + bgDirty = true; + } + + // Simulate a full leapfrog trajectory from the current q using the analytic force. + function nextTransition() { + var p = [nrm(), nrm()]; // identity mass => N(0,1) momentum + var U0 = -logpost(q[0], q[1], data); + var H0 = U0 + 0.5 * (p[0] * p[0] + p[1] * p[1]); + var qa = q[0], qb = q[1], pa = p[0], pb = p[1]; + var qs = [[qa, qb]], Hs = [H0]; + var grad = gradLogpost(qa, qb, data); + var divergent = false; + for (var i = 0; i < L; i++) { + pa += 0.5 * eps * grad[0]; pb += 0.5 * eps * grad[1]; + qa += eps * pa; qb += eps * pb; + grad = gradLogpost(qa, qb, data); + pa += 0.5 * eps * grad[0]; pb += 0.5 * eps * grad[1]; + var Hh = -logpost(qa, qb, data) + 0.5 * (pa * pa + pb * pb); + qs.push([qa, qb]); Hs.push(Hh); + if (!isFinite(Hh) || Math.abs(Hh - H0) > DIV_THRESHOLD) { divergent = true; break; } + } + var last = Hs.length - 1; + var dH = Hs[last] - H0; + var acceptProb = divergent ? 0 : Math.min(1, Math.exp(-dH)); + var accept = !divergent && (rng() < acceptProb); + active = { + qs: qs, Hs: Hs, H0: H0, p0: p, start: [q[0], q[1]], + divergent: divergent, dH: dH, accept: accept, + qEnd: [qa, qb], logpEnd: logpost(qa, qb, data) + }; + reveal = 0; applied = false; + } + + function applyDecision() { + total++; + if (active.divergent) { + divergences++; + flash = { kind: "div", a: 1, at: [active.qEnd[0], active.qEnd[1]] }; + rejLine = { a: active.qEnd[0], b: active.qEnd[1], life: 1 }; + } else if (active.accept) { + q = [active.qEnd[0], active.qEnd[1]]; + logpCur = active.logpEnd; + accepts++; + flash = { kind: "accept", a: 1, at: [q[0], q[1]] }; + } else { + flash = { kind: "reject", a: 1, at: [active.qEnd[0], active.qEnd[1]] }; + rejLine = { a: active.qEnd[0], b: active.qEnd[1], life: 1 }; + } + lastDH = active.dH; + pushCapped(trail, [q[0], q[1]], 240); + pushCapped(samplesA, q[0], 4000); + pushCapped(spag, [q[0], q[1]], SPAG_CAP); + if (sideBySide) mhAdvance(active.qs.length - 1); + updateReadouts(); + } + + // Random-walk Metropolis: one proposal per leapfrog step => matched budget. + function mhAdvance(steps) { + for (var i = 0; i < steps; i++) { + var na = mhQ[0] + nrm() * MH_SIGMA; + var nb = mhQ[1] + nrm() * MH_SIGMA; + var lp = logpost(na, nb, data); + if (Math.log(rng() + 1e-300) < lp - mhLogp) { mhQ = [na, nb]; mhLogp = lp; mhAccepts++; } + mhTotal++; + pushCapped(mhTrail, [mhQ[0], mhQ[1]], 1200); + } + pushCapped(mhSpag, [mhQ[0], mhQ[1]], SPAG_CAP); + } + + // ------------------------------------------------------------------ + // DOM shell + // ------------------------------------------------------------------ + var controls = document.createElement("div"); + controls.className = "fv-controls"; + root.appendChild(controls); + + var canvasHost = document.createElement("div"); + root.appendChild(canvasHost); + var stripHost = document.createElement("div"); + root.appendChild(stripHost); + + var readouts = document.createElement("div"); + readouts.className = "fv-readouts"; + root.appendChild(readouts); + + var instr = document.createElement("div"); + instr.className = "fv-instruction"; + instr.textContent = "Left: drag a yellow data point. Right: violet = one leapfrog roll; coral dot = the current (slope, intercept)."; + root.appendChild(instr); + + var hint = document.createElement("div"); + hint.className = "fv-hint"; + hint.textContent = "try: drag the rightmost point far up — the heatmap tilts and the coral ball rolls after it within a few transitions."; + root.appendChild(hint); + + // ---- canvases ---- + var mainApi = FV.canvas(canvasHost, { height: 400, onResize: onMainResize }); + var stripApi = FV.canvas(stripHost, { height: 92, onResize: function () { scheduleDraw(); } }); + + var dataView = null, paramView = null, bgDirty = true; + var PADL = 40, PADR = 12, PADT = 12, PADB = 26, GAP = 26; + + function makeView(x0, y0, w, h, domx, domy) { + return { + x0: x0, y0: y0, w: w, h: h, + xs: FV.scale(domx, [x0, x0 + w]), + ys: FV.scale(domy, [y0 + h, y0]), + bg: null + }; + } + function onMainResize(api) { + var gap = api.w < 380 ? 16 : GAP; // tighten the panel gutter on phones (§A.4) + var plotW = api.w - PADL - PADR - gap; + var plotH = api.h - PADT - PADB; + if (plotW < 40 || plotH < 20) return; + var dataW = plotW * 0.56; + var paramW = plotW - dataW; + dataView = makeView(PADL, PADT, dataW, plotH, DAT_X, DAT_Y); + paramView = makeView(PADL + dataW + gap, PADT, paramW, plotH, DOM_A, DOM_B); + bgDirty = true; + scheduleDraw(); + } + + function buildBg(view, dpr) { + var off = document.createElement("canvas"); + off.width = Math.max(1, Math.round(view.w * dpr)); + off.height = Math.max(1, Math.round(view.h * dpr)); + var octx = off.getContext("2d"); + octx.setTransform(dpr, 0, 0, dpr, 0, 0); + var xsL = FV.scale(DOM_A, [0, view.w]); + var ysL = FV.scale(DOM_B, [view.h, 0]); + FV.heatmap(octx, function (a, b) { return Math.exp(logpost(a, b, data)); }, + { xscale: xsL, yscale: ysL, w: view.w, h: view.h, colormap: "post", step: 4 }); + view.bg = off; + } + + // ------------------------------------------------------------------ + // Drawing primitives + // ------------------------------------------------------------------ + function arrow(ctx, x1, y1, x2, y2, color, width) { + var dx = x2 - x1, dy = y2 - y1; + var len = Math.sqrt(dx * dx + dy * dy); + if (len < 0.5) return; + var ux = dx / len, uy = dy / len; + ctx.save(); + ctx.strokeStyle = color; ctx.fillStyle = color; + ctx.lineWidth = width || 2; ctx.lineCap = "round"; + ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); + var ah = 7; + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - ah * ux + ah * 0.55 * uy, y2 - ah * uy - ah * 0.55 * ux); + ctx.lineTo(x2 - ah * ux - ah * 0.55 * uy, y2 - ah * uy + ah * 0.55 * ux); + ctx.closePath(); ctx.fill(); + ctx.restore(); + } + function dot(ctx, x, y, r, color) { + ctx.save(); ctx.fillStyle = color; + ctx.beginPath(); ctx.arc(x, y, r, 0, 2 * Math.PI); ctx.fill(); ctx.restore(); + } + function ring(ctx, x, y, r, color, alpha, width) { + ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; + ctx.lineWidth = width || 3; + ctx.beginPath(); ctx.arc(x, y, r, 0, 2 * Math.PI); ctx.stroke(); ctx.restore(); + } + // A regression line a*x+b drawn across the data panel, clipped to it. + function regLine(ctx, view, a, b, color, alpha, width, dash) { + var xLo = DAT_X[0], xHi = DAT_X[1]; + ctx.save(); + ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = width; + if (dash) ctx.setLineDash(dash); + ctx.beginPath(); + ctx.moveTo(view.xs(xLo), view.ys(a * xLo + b)); + ctx.lineTo(view.xs(xHi), view.ys(a * xHi + b)); + ctx.stroke(); + ctx.restore(); + } + + // ------------------------------------------------------------------ + // Data-space panel (the hero): spaghetti through draggable points. + // ------------------------------------------------------------------ + function drawDataPanel(ctx, colors, th) { + var view = dataView; + FV.axes(ctx, { + x: view.x0, y: view.y0, w: view.w, h: view.h, + xscale: view.xs, yscale: view.ys, xlabel: "x", theme: th + }); + ctx.save(); + ctx.beginPath(); ctx.rect(view.x0, view.y0, view.w, view.h); ctx.clip(); + + var i; + // MH spaghetti — dimmer green (only when comparing) + if (sideBySide) { + for (i = 0; i < mhSpag.length; i++) { + var age = i / Math.max(1, mhSpag.length - 1); + regLine(ctx, view, mhSpag[i][0], mhSpag[i][1], colors.post, 0.04 + 0.06 * age, 1); + } + } + // HMC spaghetti — bright green, opacity ramps with recency + for (i = 0; i < spag.length; i++) { + var a2 = i / Math.max(1, spag.length - 1); + regLine(ctx, view, spag[i][0], spag[i][1], colors.post, 0.08 + 0.28 * a2, 1); + } + // rejected/divergent proposal flashes coral-dashed then vanishes + if (rejLine && rejLine.life > 0) { + regLine(ctx, view, rejLine.a, rejLine.b, colors.hot, 0.5 * rejLine.life, 1.5, [5, 4]); + } + // current sample line — coral, thick + regLine(ctx, view, q[0], q[1], colors.hot, 0.95, 2.4); + ctx.restore(); + + // data points — yellow, draggable + for (i = 0; i < N; i++) { + var px = view.xs(data.xs[i]), py = view.ys(data.ys[i]); + var isHot = (dragIdx === i); + // subtle grab-halo on the point being dragged (§A.2) + if (isHot && FV.halo) FV.halo(ctx, px, py, dragHandle.isCoarse ? 22 : 15, colors.hot, 0.3); + ctx.save(); + ctx.globalAlpha = 0.22; dot(ctx, px, py, isHot ? 11 : 8, colors.data); ctx.restore(); + dot(ctx, px, py, isHot ? 6 : 5, colors.data); + ctx.save(); + ctx.strokeStyle = th.dark ? "rgba(13,17,23,0.9)" : "rgba(255,255,255,0.9)"; + ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.arc(px, py, isHot ? 6 : 5, 0, 2 * Math.PI); ctx.stroke(); + ctx.restore(); + } + // label + ctx.save(); + ctx.fillStyle = colors.ink; ctx.globalAlpha = 0.8; + ctx.font = "600 11px var(--mono-font, monospace)"; + ctx.textAlign = "left"; ctx.textBaseline = "top"; + ctx.fillText("DATA SPACE y = a·x + b", view.x0 + 6, view.y0 + 5); + ctx.restore(); + } + + // ------------------------------------------------------------------ + // Parameter-space panel: heatmap + leapfrog roll. + // ------------------------------------------------------------------ + function drawParamPanel(ctx, colors, th) { + var view = paramView; + if (!view.bg || bgDirty) buildBg(view, mainApi.dpr); + ctx.drawImage(view.bg, view.x0, view.y0, view.w, view.h); + FV.axes(ctx, { + x: view.x0, y: view.y0, w: view.w, h: view.h, + xscale: view.xs, yscale: view.ys, xlabel: "slope a", theme: th + }); + ctx.save(); + ctx.beginPath(); ctx.rect(view.x0, view.y0, view.w, view.h); ctx.clip(); + var xs = view.xs, ys = view.ys, i, pts; + + // MH chain trail (faint blue) when comparing + if (sideBySide) { + pts = []; + for (i = 0; i < mhTrail.length; i++) pts.push([xs(mhTrail[i][0]), ys(mhTrail[i][1])]); + ctx.globalAlpha = 0.4; + FV.curve(ctx, pts, { color: colors.prior, width: 1 }); + ctx.globalAlpha = 1; + dot(ctx, xs(mhQ[0]), ys(mhQ[1]), 3.5, colors.prior); + } + + // HMC chain trail (ink) + pts = []; + for (i = 0; i < trail.length; i++) pts.push([xs(trail[i][0]), ys(trail[i][1])]); + ctx.globalAlpha = 0.5; + FV.curve(ctx, pts, { color: colors.ink, width: 1 }); + ctx.globalAlpha = 1; + + if (active) { + var traj = active.qs; + var idx = Math.floor(reveal); + if (idx > traj.length - 1) idx = traj.length - 1; + var frac = reveal - idx; + var trajColor = active.divergent ? colors.hot : colors.flow; + pts = []; + for (i = 0; i <= idx; i++) pts.push([xs(traj[i][0]), ys(traj[i][1])]); + var cx, cy; + if (idx < traj.length - 1 && frac > 0) { + cx = traj[idx][0] + frac * (traj[idx + 1][0] - traj[idx][0]); + cy = traj[idx][1] + frac * (traj[idx + 1][1] - traj[idx][1]); + pts.push([xs(cx), ys(cy)]); + } else { cx = traj[idx][0]; cy = traj[idx][1]; } + FV.curve(ctx, pts, { color: trajColor, width: 2 }); + for (i = 1; i <= idx; i++) dot(ctx, xs(traj[i][0]), ys(traj[i][1]), 2, trajColor); + // momentum arrow at the start, fading as the trajectory reveals + var af = Math.max(0, 1 - reveal / 3); + if (af > 0.02) { + ctx.globalAlpha = af; + var s = active.start; + arrow(ctx, xs(s[0]), ys(s[1]), + xs(s[0] + active.p0[0] * 0.18), ys(s[1] + active.p0[1] * 0.18), + colors.flow, 2.5); + ctx.globalAlpha = 1; + } + dot(ctx, xs(cx), ys(cy), 4, trajColor); + if (active.divergent && idx >= traj.length - 1) { + ring(ctx, xs(cx), ys(cy), 10, colors.hot, 0.9, 2); + } + } + // current state (coral) + dot(ctx, xs(q[0]), ys(q[1]), 5, colors.hot); + if (flash && flash.at) { + var fc = flash.kind === "accept" ? colors.post : colors.hot; + ring(ctx, xs(flash.at[0]), ys(flash.at[1]), 8 + (1 - flash.a) * 12, fc, flash.a, 3); + } + ctx.restore(); + + ctx.save(); + ctx.fillStyle = colors.ink; ctx.globalAlpha = 0.8; + ctx.font = "600 11px var(--mono-font, monospace)"; + ctx.textAlign = "left"; ctx.textBaseline = "top"; + var paramTitle = "PARAMETER SPACE (a, b)"; + if (ctx.measureText(paramTitle).width > view.w - 12) paramTitle = "PARAMS (a, b)"; + ctx.fillText(paramTitle, view.x0 + 6, view.y0 + 5); + ctx.restore(); + } + + function draw() { + if (!dataView || !paramView || !mainApi || !stripApi) return; + var th = FV.theme(); + var colors = th.colors; + var ctx = mainApi.ctx; + mainApi.clear(); + drawDataPanel(ctx, colors, th); + drawParamPanel(ctx, colors, th); + bgDirty = false; + drawStrip(colors, th); + } + + function drawStrip(colors, th) { + var ctx = stripApi.ctx; + stripApi.clear(); + var w = stripApi.w, h = stripApi.h; + var pl = PADL, pr = 12, pt = 14, pb = 14; + var pw = w - pl - pr, ph = h - pt - pb; + if (pw < 10 || ph < 10) return; + ctx.save(); + ctx.fillStyle = colors.ink; ctx.globalAlpha = 0.7; + ctx.font = "10px var(--mono-font, monospace)"; + ctx.textAlign = "left"; ctx.textBaseline = "top"; + ctx.fillText("ENERGY H(q,p) ALONG TRAJECTORY — flat = conserved", pl, 2); + ctx.restore(); + if (!active) return; + var Hs = active.Hs, n = Hs.length; + var lo = Infinity, hi = -Infinity, i; + for (i = 0; i < n; i++) { if (Hs[i] < lo) lo = Hs[i]; if (Hs[i] > hi) hi = Hs[i]; } + if (!isFinite(lo) || !isFinite(hi)) return; + var pad = (hi - lo) * 0.15 || 0.5; + lo -= pad; hi += pad; + var xs = FV.scale([0, Math.max(1, n - 1)], [pl, pl + pw]); + var ys = FV.scale([lo, hi], [pt + ph, pt]); + ctx.save(); + ctx.strokeStyle = colors.ink; ctx.globalAlpha = 0.3; ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.beginPath(); ctx.moveTo(pl, ys(active.H0)); ctx.lineTo(pl + pw, ys(active.H0)); ctx.stroke(); + ctx.restore(); + var pts = []; + for (i = 0; i < n; i++) pts.push([xs(i), ys(Hs[i])]); + var col = active.divergent ? colors.hot : colors.flow; + FV.curve(ctx, pts, { color: col, width: 2 }); + var idx = Math.min(Math.floor(reveal), n - 1); + dot(ctx, xs(idx), ys(Hs[idx]), 3, col); + ctx.save(); + ctx.strokeStyle = colors.ink; ctx.globalAlpha = 0.25; ctx.lineWidth = 1; + ctx.strokeRect(pl, pt, pw, ph); ctx.restore(); + } + + // Coalesced redraw (keeps drags to one heatmap rebuild per frame). + var rafPending = false; + function scheduleDraw() { + if (rafPending) return; + rafPending = true; + (window.requestAnimationFrame || function (f) { setTimeout(f, 16); })(function () { + rafPending = false; + draw(); + }); + } + + // ------------------------------------------------------------------ + // Readouts + // ------------------------------------------------------------------ + var roAccept = FV.readout(readouts, { label: "ACCEPT" }); + var roDH = FV.readout(readouts, { label: "ΔH (last)" }); + var roEss = FV.readout(readouts, { label: "ESS (slope)" }); + var roDiv = FV.readout(readouts, { label: "DIVERGENCES" }); + + function updateReadouts() { + var ar = total > 0 ? accepts / total : 0; + roAccept.set((ar * 100).toFixed(0) + "%", ar >= 0.6 ? "post" : "hot"); + var adH = Math.abs(lastDH); + roDH.set((lastDH >= 0 ? "+" : "") + lastDH.toFixed(2), adH > 1 ? "hot" : "flow"); + roEss.set(samplesA.length > 3 ? essSingle(samplesA).toFixed(1) : "—", "post"); + roDiv.set(String(divergences), divergences > 0 ? "hot" : null); + } + + // ------------------------------------------------------------------ + // Controls + // ------------------------------------------------------------------ + FV.slider(controls, { + label: "STEP ε", min: 0.01, max: 0.4, step: 0.005, value: eps, + fmt: function (v) { return v.toFixed(3); }, + onInput: function (v) { eps = v; nextTransition(); scheduleDraw(); } + }); + FV.slider(controls, { + label: "LEAPFROG L", min: 1, max: 50, step: 1, value: L, + fmt: function (v) { return String(v); }, + onInput: function (v) { L = v | 0; nextTransition(); scheduleDraw(); } + }); + FV.slider(controls, { + label: "SPEED", min: 2, max: 40, step: 1, value: speed, + fmt: function (v) { return String(v); }, + onInput: function (v) { speed = v; } + }); + FV.toggle(controls, { + label: "MH SIDE-BY-SIDE", value: false, + onChange: function (on) { sideBySide = on; scheduleDraw(); } + }); + + // ------------------------------------------------------------------ + // Pointer: drag the yellow data points — via the shared FV.drag manager + // (§A.1 scroll-fight / §A.2 coarse hit targets). The drag is VERTICAL, + // i.e. the SAME axis the page scrolls on, so a "claim-on-hit best-effort" + // (pan-y) gesture would race the browser's own scroll and feel buggy — + // exactly the phone complaint. We therefore take fullCapture + // (touch-action:none on this canvas): a hit drags smoothly, a miss is + // ignored and does nothing. The ambient energy strip is a SEPARATE canvas + // with no drag, so it stays pan-y and the page scrolls when swiped there. + // ------------------------------------------------------------------ + var dragIdx = -1; + function hitIndex(x, y, slop) { + if (!dataView) return -1; + var r = Math.max(12, slop), best = -1, bestD = r * r; + for (var i = 0; i < N; i++) { + var dx = x - dataView.xs(data.xs[i]); + var dy = y - dataView.ys(data.ys[i]); + var d = dx * dx + dy * dy; + if (d <= bestD) { bestD = d; best = i; } // nearest point within slop + } + return best; + } + var dragHandle = FV.drag(mainApi.el, { + inflate: 12, // fine-pointer slop; coarse pointers get >=22 (§A.2) + fullCapture: true, // vertical drag == scroll axis: capture for smoothness + // Return a TRUTHY target on a hit. NB: a bare index 0 is falsy and would + // read as a miss, so wrap the index in an object. + hitTest: function (x, y, slop) { + var i = hitIndex(x, y, slop); + return i >= 0 ? { i: i } : -1; + }, + onStart: function (t) { dragIdx = t.i; scheduleDraw(); }, + onDrag: function (t, x, y) { + var yv = dataView.ys.invert(y); + if (yv < DAT_Y[0]) yv = DAT_Y[0]; + if (yv > DAT_Y[1]) yv = DAT_Y[1]; + data.ys[t.i] = yv; // x stays fixed; drag adjusts the response + onDataChanged(); + scheduleDraw(); + }, + onEnd: function () { dragIdx = -1; scheduleDraw(); } + }); + + // ------------------------------------------------------------------ + // Animation engine + // ------------------------------------------------------------------ + // autoplay: begin rolling the moment the widget scrolls into view. The loop + // no-ops under reduced motion, where the pre-warmed static frame (below) stands in. + var loopApi = FV.loop(root, function (dt) { + if (!active) nextTransition(); + reveal += dt * speed; + var end = active.qs.length - 1; + if (reveal >= end) { + reveal = end; + if (!applied) { applyDecision(); applied = true; } + nextTransition(); + } + if (flash) { flash.a -= dt * 1.6; if (flash.a <= 0) flash = null; } + if (rejLine) { rejLine.life -= dt * 1.4; if (rejLine.life <= 0) rejLine = null; } + draw(); + }, { autoplay: true }); + + // Advance N full transitions with no animation — used to pre-warm the chain so + // first paint already carries a trail + spaghetti. + function prewarm(nSteps) { + for (var i = 0; i < nSteps; i++) { + if (!active) nextTransition(); + if (applied) nextTransition(); + reveal = active.qs.length - 1; + applyDecision(); + applied = true; + } + nextTransition(); // a fresh trajectory ready for the first visible roll + reveal = 0; applied = false; + } + + function stepOnce() { + if (!active) nextTransition(); + if (applied) nextTransition(); + reveal = active.qs.length - 1; + applyDecision(); + applied = true; + draw(); + } + + var btns = FV.buttons(controls, [ + { label: "Play", title: "Play / pause the sampler", primary: true, onClick: togglePlay }, + { label: "Step", title: "Run one full HMC transition", onClick: function () { stepOnce(); } }, + { label: "Reset", title: "Restore the seeded data and restart the chain", onClick: function () { data = makeSeedData(DATA_SEED); resetChain(); prewarm(40); bgDirty = true; draw(); } } + ]); + var playBtn = btns.fvButtons["Play"]; + if (loopApi.reduced) { + playBtn.disabled = true; + playBtn.title = "Reduced motion is on — use Step"; + instr.textContent = "Reduced motion: drag the yellow points; press Step to run one transition."; + } + function togglePlay() { + if (loopApi.playing) { loopApi.pause(); playBtn.textContent = "Play"; } + else { loopApi.play(); if (loopApi.playing) playBtn.textContent = "Pause"; } + } + + // ------------------------------------------------------------------ + // Seed scrub (in prose) + theme + init + // ------------------------------------------------------------------ + var seedSpan = document.getElementById("hmc-seed"); + if (seedSpan && FV.scrub) { + FV.scrub(seedSpan, { + min: 1, max: 40, step: 1, value: curSeed, + fmt: function (v) { return String(v); }, + onInput: function (v) { curSeed = v >>> 0; resetChain(); prewarm(40); scheduleDraw(); } + }); + } + + FV.onThemeChange(function () { bgDirty = true; draw(); }); + + // First paint: pre-warm ~40 transitions so the param-space trail and the + // data-space spaghetti already exist (and so reduced-motion, which never + // animates, shows a rich converged frame instead of an empty axis). + resetChain(); + prewarm(40); + draw(); + // The loop autoplays itself (FV.loop {autoplay:true}); reflect it on the button + // unless reduced motion (where play() was a no-op and Play is disabled below). + if (loopApi.playing) playBtn.textContent = "Pause"; + }); +})(); diff --git a/docs/viz/inline.js b/docs/viz/inline.js new file mode 100644 index 0000000..603e231 --- /dev/null +++ b/docs/viz/inline.js @@ -0,0 +1,1038 @@ +// docs/viz/inline.js — the micro-widget family (ambient, "alive everywhere"). +// +// Ten small, param-driven visualizations embeddable on ANY docs page via +//
+// Shared traits: autoplay ambient loop that advances discrete STATE at 2–6 Hz +// with tweened rendering between states; one unobtrusive pause/play glyph +// (top-right); optional one-line caption from data-caption; seeded via +// data-seed (default 11); all math via FugueViz; theme-aware; the color algebra +// everywhere (data = yellow, prior = blue, posterior = green, current = coral, +// structure = violet). +// +// The math is real: split-R̂ is ported line-for-line from +// src/inference/diagnostics.rs (Vehtari et al. 2021 split variant); EM +// responsibilities, Beta/Normal conjugate updates, and the logistic likelihood +// are the genuine formulas (known-value checks live in the inline agent report). +// +// Self-contained IIFE; assumes fugue-viz.js has loaded first (book.toml order). +(function () { + "use strict"; + if (typeof window === "undefined" || !window.FugueViz) return; + var FV = window.FugueViz; + + // ========================================================================== + // Small generic helpers + // ========================================================================== + + function clamp(v, a, b) { return v < a ? a : v > b ? b : v; } + function lerp(a, b, t) { return a + (b - a) * t; } + function clone(o) { var r = {}; for (var k in o) if (o.hasOwnProperty(k)) r[k] = o[k]; return r; } + function nextSeed(s) { return (Math.imul(s, 1664525) + 1013904223) >>> 0; } + + // Coarse (touch) pointers get inflated hit targets (§A.2: ≥22 CSS px). Cached. + var _coarse = null; + function coarsePointer() { + if (_coarse === null) _coarse = !!(window.matchMedia && window.matchMedia("(pointer: coarse)").matches); + return _coarse; + } + + // Numerically-stable softplus: log(1 + e^z) = max(z,0) + log1p(e^-|z|). + function softplus(z) { var az = Math.abs(z); return Math.max(z, 0) + FV.log1p(Math.exp(-az)); } + + function parseRGB(col) { + col = (col || "").trim(); + var m = /^#?([0-9a-f]{6})$/i.exec(col); + if (m) { var n = parseInt(m[1], 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } + var r = /rgba?\(([^)]+)\)/.exec(col); + if (r) { var p = r[1].split(","); return [parseInt(p[0], 10), parseInt(p[1], 10), parseInt(p[2], 10)]; } + return [128, 128, 128]; + } + function blend(c1, c2, t) { + var a = parseRGB(c1), b = parseRGB(c2); + return "rgb(" + Math.round(lerp(a[0], b[0], t)) + "," + Math.round(lerp(a[1], b[1], t)) + "," + Math.round(lerp(a[2], b[2], t)) + ")"; + } + function fmtNum(v) { + if (v == null || !isFinite(v)) return "—"; + var a = Math.abs(v); + if (a >= 100) return v.toFixed(0); + if (a >= 10) return v.toFixed(1); + return v.toFixed(2); + } + + // ---- canvas drawing primitives (all take resolved theme colors) ----------- + + function baseline(g, x0, x1, y, c) { + g.save(); g.strokeStyle = c.ink; g.globalAlpha = 0.22; g.lineWidth = 1; + g.beginPath(); g.moveTo(x0, y); g.lineTo(x1, y); g.stroke(); g.restore(); + } + function label(g, txt, x, y, c) { + g.save(); g.fillStyle = c.ink; g.globalAlpha = 0.7; + g.font = "11px var(--mono-font, monospace)"; g.textBaseline = "top"; g.textAlign = "left"; + g.fillText(txt, x, y); g.restore(); + } + function labelRight(g, txt, x, y, c, role) { + g.save(); g.fillStyle = role ? c[role] : c.ink; g.globalAlpha = role ? 0.95 : 0.7; + g.font = "11px var(--mono-font, monospace)"; g.textBaseline = "top"; g.textAlign = "right"; + g.fillText(txt, x, y); g.restore(); + } + function fillUnder(g, pts, y0, col, alpha) { + if (!pts.length) return; + g.save(); g.globalAlpha = alpha; g.fillStyle = col; g.beginPath(); + var started = false, i; + for (i = 0; i < pts.length; i++) { + if (!isFinite(pts[i][1])) continue; + if (!started) { g.moveTo(pts[i][0], y0); g.lineTo(pts[i][0], pts[i][1]); started = true; } + else g.lineTo(pts[i][0], pts[i][1]); + } + if (started) { g.lineTo(pts[pts.length - 1][0], y0); g.closePath(); g.fill(); } + g.restore(); + } + function densCurve(g, dom, f, xs, ys, n) { + var pts = [], i, x; + for (i = 0; i <= n; i++) { x = dom[0] + (dom[1] - dom[0]) * i / n; pts.push([xs(x), ys(f(x))]); } + return pts; + } + + // ========================================================================== + // Shared statistical math (real formulas; verified in the agent report) + // ========================================================================== + + // Split-R̂ — ported from src/inference/diagnostics.rs (split_f64_chains + + // r_hat_from_f64_chains). Each chain is halved before the between/within + // comparison (Vehtari et al. 2021), so within-chain trends inflate R̂. + function splitChains(chs) { + var out = [], i; + for (i = 0; i < chs.length; i++) { + var c = chs[i], half = Math.floor(c.length / 2); + if (half === 0) { out.push(c.slice()); continue; } + out.push(c.slice(0, half)); + out.push(c.slice(half, 2 * half)); + } + return out; + } + function rhatCore(ch) { + if (ch.length < 2) return 1.0; + var m = ch.length, i, k; + for (i = 0; i < m; i++) if (ch[i].length === 0) return NaN; + var n = ch[0].length; + var means = [], overall = 0; + for (i = 0; i < m; i++) { var s = 0, v = ch[i]; for (k = 0; k < v.length; k++) s += v[k]; means[i] = s / v.length; overall += means[i]; } + overall /= m; + var b = 0; for (i = 0; i < m; i++) { var d = means[i] - overall; b += d * d; } b *= n / (m - 1); + var w = 0; + for (i = 0; i < m; i++) { var vv = ch[i], mu = means[i], s2 = 0; for (k = 0; k < vv.length; k++) { var e = vv[k] - mu; s2 += e * e; } w += s2 / (n - 1); } + w /= m; + if (!(w > 0)) return NaN; + var varPlus = ((n - 1) / n) * w + (1 / n) * b; + return Math.sqrt(varPlus / w); + } + function splitRhat(chs) { return rhatCore(splitChains(chs)); } + + // Normal–Normal conjugate update, known observation variance. Prior + // μ ~ Normal(m0, s0); observations y_i ~ Normal(μ, sigma). Returns the exact + // posterior {mean, sd} over μ. + function normalPosterior(m0, s0, sigma, ys) { + var prec = 1 / (s0 * s0), num = m0 / (s0 * s0), i, iv = 1 / (sigma * sigma); + for (i = 0; i < ys.length; i++) { prec += iv; num += ys[i] * iv; } + var pv = 1 / prec; + return { mean: num * pv, sd: Math.sqrt(pv) }; + } + + // One EM step for a 2-component 1-D Gaussian mixture. Returns updated params + // and the per-point responsibilities r_i (of component 2), computed in + // log-space. This IS the E-step responsibility formula and the weighted M-step. + function emStep(xs, p) { + var n = xs.length, r = new Array(n), i, g = FV.dist.normal; + for (i = 0; i < n; i++) { + var l1 = Math.log(p.pi) + g.logpdf(xs[i], p.m1, p.s1); + var l2 = Math.log(1 - p.pi) + g.logpdf(xs[i], p.m2, p.s2); + var mx = l1 > l2 ? l1 : l2; + var e1 = Math.exp(l1 - mx), e2 = Math.exp(l2 - mx); + r[i] = e2 / (e1 + e2); + } + var n2 = 0; for (i = 0; i < n; i++) n2 += r[i]; var n1 = n - n2; + var m1 = 0, m2 = 0; for (i = 0; i < n; i++) { m1 += (1 - r[i]) * xs[i]; m2 += r[i] * xs[i]; } + m1 /= (n1 || 1e-9); m2 /= (n2 || 1e-9); + var v1 = 0, v2 = 0; for (i = 0; i < n; i++) { v1 += (1 - r[i]) * (xs[i] - m1) * (xs[i] - m1); v2 += r[i] * (xs[i] - m2) * (xs[i] - m2); } + v1 /= (n1 || 1e-9); v2 /= (n2 || 1e-9); + return { pi: n1 / n, m1: m1, s1: Math.sqrt(Math.max(v1, 0.04)), m2: m2, s2: Math.sqrt(Math.max(v2, 0.04)), r: r }; + } + + // Logistic-regression log-likelihood + Normal(0,3) prior over weights + // w = [w0, w1x, w1y]. Stable via softplus: log σ(z) = −softplus(−z), + // log(1−σ(z)) = −softplus(z). + function logisticLogPost(w, pts) { + var s = FV.dist.normal.logpdf(w[0], 0, 3) + FV.dist.normal.logpdf(w[1], 0, 3) + FV.dist.normal.logpdf(w[2], 0, 3), i; + for (i = 0; i < pts.length; i++) { + var z = w[0] + w[1] * pts[i].x + w[2] * pts[i].y; + s += pts[i].c ? -softplus(-z) : -softplus(z); + } + return s; + } + + // ========================================================================== + // The mount scaffold — one ambient loop, glyph, caption, reduced-motion frame + // ========================================================================== + + function evtXY(elm, e) { + var r = elm.getBoundingClientRect(); + // On touchend/touchcancel e.touches is empty; the released point lives in + // changedTouches. Reading e.touches[0] there threw (undefined.clientX), + // which crashed the "up" handler and left drag state stuck. + var t = null; + if (e.touches && e.touches.length) t = e.touches[0]; + else if (e.changedTouches && e.changedTouches.length) t = e.changedTouches[0]; + var cx = t ? t.clientX : e.clientX; + var cy = t ? t.clientY : e.clientY; + return [cx - r.left, cy - r.top]; + } + + function mount(root, spec) { + var seed = parseInt(root.getAttribute("data-seed"), 10); + if (!(seed >= 0)) seed = 11; + // A resize resets the canvas backing store (clearing it). When the ambient + // loop is running it repaints every frame so the clear is invisible; but a + // paused or reduced-motion widget renders exactly once, so without repainting + // on resize its static frame would be wiped to an empty canvas. Repaint here. + var ready = false; + var cv = FV.canvas(root, { height: spec.height || 150, onResize: function () { if (ready) renderFrame(); } }); + var g = cv.ctx; + + // optional caption + var capText = root.getAttribute("data-caption"); + if (capText) { + var cap = document.createElement("div"); + cap.className = "fv-caption"; + cap.textContent = capText; + root.appendChild(cap); + } + + var S = { seed: seed, reloopSeed: seed, rng: FV.rng(seed) }; + spec.build(S, FV); + + var hz = spec.hz || 4, interval = 1 / hz, acc = 0, T = 1; + function colors() { return FV.theme().colors; } + function renderFrame() { + cv.clear(); + try { spec.render(g, S, cv.w, cv.h, T, colors(), FV); } catch (e) { /* keep the page quiet */ } + } + + var loopApi = FV.loop(root, function (dt) { + if (dt > 0.1) dt = 0.1; + acc += dt; + while (acc >= interval) { acc -= interval; spec.advance(S, FV); } + T = acc / interval; if (T > 1) T = 1; + renderFrame(); + }); + + // pause/play glyph + var glyph = document.createElement("button"); + glyph.type = "button"; + glyph.className = "fv-glyph"; + glyph.setAttribute("aria-label", "Pause or play this animation"); + function glyphUpdate() { + glyph.textContent = loopApi.playing ? "‖" : "▶"; // ‖ / ▶ + glyph.title = loopApi.playing ? "Pause" : "Play"; + } + glyph.addEventListener("click", function () { + if (loopApi.playing) loopApi.pause(); else loopApi.play(); + glyphUpdate(); renderFrame(); + }); + root.appendChild(glyph); + + // pointer interactions (mouse + touch). Ambient micros must NEVER eat page + // scroll (§A.1): touch gestures are claimed only when they actually engage a + // draggable. Two modes: + // • spec.scrub — the whole canvas is a horizontal control. touch-action + // pan-y keeps native vertical page scrolling; the browser then only + // delivers *cancelable* horizontal-intent moves to us, so a vertical + // swipe scrolls the page and a horizontal drag scrubs. + // • point drag — claim the gesture only when pointerdown actually hits a + // target (spec.pointer returns truthy on "down"); a miss (empty canvas) + // is left untouched so the page scrolls normally. + if (spec.pointer) { + var coarse = coarsePointer(); + var down = false, claimed = false; + function fire(e, phase) { + var p = evtXY(cv.el, e); + var r = spec.pointer(S, p[0], p[1], cv.w, cv.h, phase, FV, coarse); + renderFrame(); + return r; + } + // Mouse never fights page scroll — wire it straight through. + cv.el.addEventListener("mousedown", function (e) { down = true; fire(e, "down"); }); + window.addEventListener("mousemove", function (e) { if (down) fire(e, "move"); }); + window.addEventListener("mouseup", function (e) { if (down) { down = false; fire(e, "up"); } }); + if (spec.scrub) { + // The whole canvas is a horizontal control, yet a *vertical* swipe must + // still scroll the page (§A.1). touch-action:pan-y asks the browser to + // own vertical scrolling, but with a non-passive touchmove listener the + // pan-y moves can still arrive `cancelable` (they do under some engines + // and headless/emulated touch), so keying off `e.cancelable` alone would + // let this control eat the page scroll. Instead latch the gesture axis on + // the first move past a small dead-zone: only a horizontally-dominant + // drag is claimed for scrubbing; a vertical one is left to the page. + cv.el.style.touchAction = "pan-y"; + var sx0 = 0, sy0 = 0, axis = 0; // 0 undecided, 1 scrub (horizontal), -1 scroll (vertical) + cv.el.addEventListener("touchstart", function (e) { + down = true; axis = 0; + var t = e.touches && e.touches[0]; + if (t) { sx0 = t.clientX; sy0 = t.clientY; } + fire(e, "down"); + }, { passive: true }); + cv.el.addEventListener("touchmove", function (e) { + if (!down) return; + var t = e.touches && e.touches[0]; if (!t) return; + if (axis === 0) { + var dx = Math.abs(t.clientX - sx0), dy = Math.abs(t.clientY - sy0); + if (dx < 6 && dy < 6) return; // below dead-zone: wait, let native scroll begin + axis = dx > dy ? 1 : -1; // decide once, then latch for the gesture + } + if (axis === 1 && e.cancelable) { fire(e, "move"); e.preventDefault(); } + // axis === -1 (vertical intent): never preventDefault — the page scrolls + }, { passive: false }); + cv.el.addEventListener("touchend", function (e) { if (down) { down = false; fire(e, "up"); } axis = 0; }); + cv.el.style.cursor = "ew-resize"; + } else { + cv.el.addEventListener("touchstart", function (e) { + claimed = !!fire(e, "down"); down = claimed; + if (claimed && e.cancelable) e.preventDefault(); + }, { passive: false }); + cv.el.addEventListener("touchmove", function (e) { + if (down && claimed) { fire(e, "move"); if (e.cancelable) e.preventDefault(); } + }, { passive: false }); + cv.el.addEventListener("touchend", function (e) { if (down) { down = false; fire(e, "up"); } claimed = false; }); + cv.el.style.cursor = "grab"; + } + } + + FV.onThemeChange(function () { renderFrame(); }); + + ready = true; // future resizes may now repaint the current frame + if (loopApi.reduced) { + // reduced motion: render a fully-formed static frame, never an empty axis. + if (spec.staticFrame) spec.staticFrame(S, FV); + else { var n = spec.settleN || 30; for (var i = 0; i < n; i++) spec.advance(S, FV); } + T = 1; renderFrame(); + glyph.style.display = "none"; + } else { + renderFrame(); + loopApi.play(); + glyphUpdate(); + } + } + + // ========================================================================== + // 1. dist-strip — a distribution, forever raining samples into a histogram. + // ========================================================================== + + var DISCRETE = { bernoulli: 1, binomial: 1, poisson: 1, categorical: 1 }; + function defaultParams(name) { + return ({ normal: [0, 1], lognormal: [0, 0.5], beta: [2, 2], gamma: [2, 1], exponential: [1], uniform: [0, 1], bernoulli: [0.5], binomial: [10, 0.5], poisson: [4], categorical: [0.2, 0.3, 0.3, 0.2] })[name] || [0, 1]; + } + function firstParamLabel(name) { + return ({ normal: "μ", lognormal: "μ", beta: "α", gamma: "k", exponential: "λ", uniform: "lo", bernoulli: "p", binomial: "n", poisson: "λ", categorical: "" })[name] || ""; + } + function p0Range(name) { + return ({ normal: 8, lognormal: 3, beta: 12, gamma: 12, exponential: 6, uniform: 4, bernoulli: 1.2, binomial: 34, poisson: 24 })[name] || 6; + } + function clampP0(name, v) { + switch (name) { + case "beta": case "gamma": return clamp(v, 0.2, 20); + case "exponential": return clamp(v, 0.1, 10); + case "poisson": return clamp(v, 0.2, 40); + case "bernoulli": return clamp(v, 0.02, 0.98); + case "binomial": return Math.round(clamp(v, 1, 40)); + default: return v; + } + } + function distDomain(name, p) { + switch (name) { + case "normal": return [p[0] - 4 * p[1], p[0] + 4 * p[1]]; + case "lognormal": return [0, Math.exp(p[0] + 3 * p[1])]; + case "beta": return [0, 1]; + case "gamma": { var m = p[0] / p[1], sd = Math.sqrt(p[0]) / p[1]; return [0, Math.max(m + 4 * sd, 0.01)]; } + case "exponential": return [0, 6 / p[0]]; + case "uniform": { var lo = Math.min(p[0], p[1] - 0.1), hi = Math.max(p[1], lo + 0.1), s = (hi - lo) * 0.12; return [lo - s, hi + s]; } + case "bernoulli": return [-0.6, 1.6]; + case "binomial": return [-0.6, p[0] + 0.6]; + case "poisson": return [-0.6, Math.ceil(p[0] + 4 * Math.sqrt(p[0]) + 1) + 0.6]; + case "categorical": return [-0.6, p.length - 0.4]; + } + return [0, 1]; + } + function distSample(name, rand, p) { + var d = FV.dist[name]; + switch (name) { + case "exponential": return d.sample(rand, p[0]); + case "poisson": return d.sample(rand, p[0]); + case "bernoulli": return d.sample(rand, p[0]) ? 1 : 0; + case "categorical": return d.sample(rand, p); + default: return d.sample(rand, p[0], p[1]); + } + } + function distDensity(name, x, p) { + var d = FV.dist[name]; + switch (name) { + case "normal": case "lognormal": case "beta": case "gamma": return Math.exp(d.logpdf(x, p[0], p[1])); + case "exponential": return Math.exp(d.logpdf(x, p[0])); + case "uniform": return Math.exp(d.logpdf(x, p[0], p[1])); + case "bernoulli": return Math.exp(d.logpmf(x, p[0])); + case "binomial": return Math.exp(d.logpmf(x, p[0], p[1])); + case "poisson": return Math.exp(d.logpmf(x, p[0])); + case "categorical": return Math.exp(d.logpmf(x, p)); + } + return 0; + } + + function distStrip(root) { + var name = (root.getAttribute("data-dist") || "normal").toLowerCase(); + var praw = root.getAttribute("data-params"); + var params = praw ? praw.split(",").map(function (s) { return parseFloat(s); }) : defaultParams(name); + var isDisc = !!DISCRETE[name]; + var CAP = 480, BATCH = 6, RANGE = p0Range(name); + + mount(root, { + height: 150, hz: 5, settleN: 70, scrub: true, + build: function (S) { S.params = params.slice(); S.buf = []; S.dragX = null; }, + advance: function (S) { + for (var i = 0; i < BATCH; i++) S.buf.push(distSample(name, S.rng, S.params)); + while (S.buf.length > CAP) S.buf.shift(); + }, + render: function (g, S, w, h, T, c) { + var pad = { l: 10, r: 10, t: 15, b: 12 }; + var dom = distDomain(name, S.params); + var xs = FV.scale(dom, [pad.l, w - pad.r]); + var ymax = 1e-6, k, x, d; + if (isDisc) { for (k = Math.max(0, Math.ceil(dom[0])); k <= Math.floor(dom[1]); k++) { d = distDensity(name, k, S.params); if (d > ymax) ymax = d; } } + else { for (k = 0; k <= 120; k++) { x = dom[0] + (dom[1] - dom[0]) * k / 120; d = distDensity(name, x, S.params); if (isFinite(d) && d > ymax) ymax = d; } } + var ys = FV.scale([0, ymax * 1.25], [h - pad.b, pad.t]); + baseline(g, pad.l, w - pad.r, ys(0), c); + if (isDisc) drawDiscHist(g, S.buf, xs, ys, c.post); + else FV.histogram(g, S.buf, { bins: 28, xscale: xs, yscale: ys, color: c.post, alpha: 0.5 }); + if (isDisc) drawStems(g, name, S.params, dom, xs, ys, c.prior); + else { var pts = densCurve(g, dom, function (xx) { return distDensity(name, xx, S.params); }, xs, ys, 140); fillUnder(g, pts, ys(0), c.prior, 0.08); FV.curve(g, pts, { color: c.prior, width: 2 }); } + if (firstParamLabel(name)) label(g, firstParamLabel(name) + " " + fmtNum(S.params[0]), pad.l, pad.t - 4, c); + label(g, isDisc ? "" : "n=" + S.buf.length, pad.l, pad.t - 4, c); + if (firstParamLabel(name)) labelRight(g, name, w - pad.r, pad.t - 4, c); + }, + pointer: (name === "categorical") ? null : function (S, x, y, w, h, phase) { + if (phase === "down") { S.dragX = x; S.drag0 = S.params[0]; } + else if (phase === "move" && S.dragX != null) { + var frac = (x - S.dragX) / w; + S.params[0] = clampP0(name, S.drag0 + frac * RANGE); + if (name === "uniform" && S.params[0] > S.params[1] - 0.2) S.params[0] = S.params[1] - 0.2; + S.buf.length = 0; + } else if (phase === "up") { S.dragX = null; } + } + }); + + function drawDiscHist(g, buf, xs, ys, col) { + if (!buf.length) return; + var counts = {}, n = 0, i, v; + for (i = 0; i < buf.length; i++) { v = Math.round(buf[i]); counts[v] = (counts[v] || 0) + 1; n++; } + var bw = Math.max(4, (xs(1) - xs(0)) * 0.6); + g.save(); g.globalAlpha = 0.5; g.fillStyle = col; + for (var kk in counts) if (counts.hasOwnProperty(kk)) { + var kv = parseInt(kk, 10), prob = counts[kk] / n, px = xs(kv), py = ys(prob); + g.fillRect(px - bw / 2, py, bw, ys(0) - py); + } + g.restore(); + } + function drawStems(g, nm, p, dom, xs, ys, col) { + var lo = Math.max(0, Math.ceil(dom[0])), hi = Math.floor(dom[1]), k; + g.save(); g.strokeStyle = col; g.fillStyle = col; g.lineWidth = 2; + for (k = lo; k <= hi; k++) { + var d = distDensity(nm, k, p); if (!isFinite(d) || d <= 0) continue; + var px = xs(k), py = ys(d), y0 = ys(0); + g.beginPath(); g.moveTo(px, y0); g.lineTo(px, py); g.stroke(); + g.beginPath(); g.arc(px, py, 2.6, 0, 6.2832); g.fill(); + } + g.restore(); + } + } + + // ========================================================================== + // 2. posterior-morph — observations arrive one at a time; posterior sharpens. + // ========================================================================== + + function posteriorMorph(root) { + var kind = (root.getAttribute("data-kind") || "beta").toLowerCase(); + var NOBS = 12; + + mount(root, { + height: 160, hz: 3, settleN: NOBS, + staticFrame: function (S) { reset(S); for (var i = 0; i < NOBS; i++) obs(S); }, + build: function (S) { reset(S); }, + advance: function (S) { + if (S.phase === "obs") { obs(S); if (S.n >= NOBS) { S.phase = "hold"; S.hold = 0; } } + else { S.hold++; if (S.hold > 4) { S.reloopSeed = nextSeed(S.reloopSeed); S.rng = FV.rng(S.reloopSeed); reset(S); } } + }, + render: function (g, S, w, h, T, c) { drawMorph(g, S, w, h, T, c); } + }); + + function reset(S) { + S.phase = "obs"; S.n = 0; S.hold = 0; S.lastObs = null; + if (kind === "beta") { S.a0 = 2; S.b0 = 2; S.hh = 0; S.tt = 0; S.ptrue = 0.2 + 0.6 * S.rng(); } + else { S.m0 = 0; S.s0 = 2; S.sigma = 1; S.ys = []; S.mtrue = -1.5 + 3 * S.rng(); } + S.toA = null; S.toB = null; snap(S); S.fromA = S.toA; S.fromB = S.toB; + } + function obs(S) { + if (kind === "beta") { var f = S.rng() < S.ptrue ? 1 : 0; if (f) S.hh++; else S.tt++; S.lastObs = f; } + else { var y = FV.dist.normal.sample(S.rng, S.mtrue, 1); S.ys.push(y); S.lastObs = y; } + S.n++; snap(S); + } + function snap(S) { + var toA, toB; + if (kind === "beta") { toA = S.a0 + S.hh; toB = S.b0 + S.tt; } + else { var p = normalPosterior(S.m0, S.s0, S.sigma, S.ys); toA = p.mean; toB = p.sd; } + S.fromA = (S.toA == null) ? toA : S.toA; + S.fromB = (S.toB == null) ? toB : S.toB; + S.toA = toA; S.toB = toB; + } + function drawMorph(g, S, w, h, T, c) { + var pad = { l: 10, r: 10, t: 15, b: 14 }; + var dom = kind === "beta" ? [0, 1] : [-4.2, 4.2]; + var xs = FV.scale(dom, [pad.l, w - pad.r]); + var A = lerp(S.fromA, S.toA, T), B = lerp(S.fromB, S.toB, T); + function post(x) { return kind === "beta" ? Math.exp(FV.dist.beta.logpdf(x, A, B)) : Math.exp(FV.dist.normal.logpdf(x, A, B)); } + function prior(x) { return kind === "beta" ? Math.exp(FV.dist.beta.logpdf(x, S.a0, S.b0)) : Math.exp(FV.dist.normal.logpdf(x, S.m0, S.s0)); } + var ymax = 1e-6, k, x; + for (k = 1; k < 120; k++) { x = dom[0] + (dom[1] - dom[0]) * k / 120; var pv = post(x); if (isFinite(pv) && pv > ymax) ymax = pv; var qv = prior(x); if (isFinite(qv) && qv > ymax) ymax = qv; } + var ys = FV.scale([0, ymax * 1.15], [h - pad.b, pad.t]); + baseline(g, pad.l, w - pad.r, ys(0), c); + FV.curve(g, densCurve(g, dom, prior, xs, ys, 120), { color: c.prior, width: 1.5, dash: [4, 3] }); + var qq = densCurve(g, dom, post, xs, ys, 120); + fillUnder(g, qq, ys(0), c.post, 0.15); FV.curve(g, qq, { color: c.post, width: 2 }); + if (S.lastObs != null) { + var ox = kind === "beta" ? (S.lastObs ? xs(1) : xs(0)) : xs(clamp(S.lastObs, dom[0], dom[1])); + g.save(); g.globalAlpha = 0.35 + 0.6 * (1 - T); g.fillStyle = c.data; + g.beginPath(); g.arc(ox, ys(0) - 5, 3 + 4 * (1 - T), 0, 6.2832); g.fill(); g.restore(); + } + label(g, "n " + S.n, pad.l, pad.t - 4, c); + var meanTxt = kind === "beta" ? (S.toA / (S.toA + S.toB)) : S.toA; + labelRight(g, "post μ " + fmtNum(meanTxt), w - pad.r, pad.t - 4, c, "post"); + } + } + + // ========================================================================== + // 3. trace-ticker — a 3-site model runs, trace rows type in, tally, reloop. + // ========================================================================== + + function traceTicker(root) { + mount(root, { + height: 150, hz: 2.6, + staticFrame: function (S) { buildRun(S); S.row = 3; S.phase = "hold"; S.hold = 0; }, + build: function (S) { buildRun(S); S.row = 0; S.phase = "type"; S.hold = 0; }, + advance: function (S) { + if (S.phase === "type") { S.row++; if (S.row >= 3) { S.phase = "hold"; S.hold = 0; } } + else { S.hold++; if (S.hold > 3) { S.reloopSeed = nextSeed(S.reloopSeed); S.rng = FV.rng(S.reloopSeed); buildRun(S); S.row = 0; S.phase = "type"; } } + }, + render: function (g, S, w, h, T, c) { drawTrace(g, S, w, h, T, c); } + }); + + function buildRun(S) { + var g = FV.dist.normal; + var mu = Math.round(g.sample(S.rng, 0, 1) * 100) / 100; + var d1 = Math.round(g.sample(S.rng, mu, 1) * 100) / 100; + var d2 = Math.round(g.sample(S.rng, mu, 1) * 100) / 100; + S.rows = [ + { addr: "mu", val: mu, logw: g.logpdf(mu, 0, 1), role: "prior" }, + { addr: "y1", val: d1, logw: g.logpdf(d1, mu, 1), role: "data" }, + { addr: "y2", val: d2, logw: g.logpdf(d2, mu, 1), role: "data" } + ]; + } + function drawTrace(g, S, w, h, T, c) { + var pad = 12, headH = 16, rowH = (h - pad * 2 - headH) / 3; + var shown = S.phase === "type" ? S.row : 3; + g.save(); g.font = "12px var(--mono-font, monospace)"; g.textBaseline = "middle"; + // header + g.textAlign = "left"; g.fillStyle = c.ink; g.globalAlpha = 0.5; + g.fillText("addr", pad + 8, pad + headH / 2); + g.textAlign = "center"; g.fillText("value", w * 0.52, pad + headH / 2); + g.textAlign = "right"; g.fillText("log w", w - pad, pad + headH / 2); + var total = 0; + for (var i = 0; i < 3; i++) { + var r = S.rows[i], yy = pad + headH + i * rowH + rowH / 2; + var vis = i < shown ? 1 : (i === shown && S.phase === "type" ? T : 0); + if (vis <= 0) continue; + total += r.logw * vis; + if (i === shown - 1 && S.phase === "type") { g.globalAlpha = 0.9 * vis; g.fillStyle = c.hot; g.fillRect(pad, yy - rowH / 2 + 2, 3, rowH - 4); } + g.globalAlpha = 0.6 * vis; g.textAlign = "left"; g.fillStyle = c.ink; g.fillText(r.addr, pad + 8, yy); + g.globalAlpha = vis; g.textAlign = "center"; g.fillStyle = c[r.role]; g.fillText(fmtNum(r.val), w * 0.52, yy); + g.globalAlpha = 0.85 * vis; g.textAlign = "right"; g.fillStyle = c.ink; g.fillText(fmtNum(r.logw), w - pad, yy); + } + // running tally + g.globalAlpha = 0.5; g.strokeStyle = c.ink; g.lineWidth = 1; + g.beginPath(); g.moveTo(pad, h - pad - 2); g.lineTo(w - pad, h - pad - 2); g.stroke(); + g.globalAlpha = 0.85; g.textAlign = "left"; g.fillStyle = c.ink; g.fillText("Σ log w", pad + 8, h - pad + 6); + g.globalAlpha = 1; g.textAlign = "right"; g.fillStyle = c.post; g.fillText(fmtNum(total), w - pad, h - pad + 6); + g.restore(); + } + } + + // ========================================================================== + // 4. rhat-spark — three over-dispersed chains COLLAPSE onto a shared band and + // the live split-R̂ falls past 1.1 (coral→green) as they merge — or, in + // "bad" mode, stay trapped in three separate modes with R̂ pinned high. + // + // The convergence STORY has to be visible, which means the vertical + // ENVELOPE must shrink: three thin threads far apart (dark space between + // them), a held separated phase, then a crisp funnel onto one shared + // stationary Normal(0,1) band. Over-dispersion only reads if the start + // separation is several stationary SDs and the warmup wiggle is tight — + // otherwise "before" and "after" span the same pixels and nothing + // collapses. That relaxation-to-a-shared-target is what an AR(1)/Langevin + // move toward N(0,1) does in expectation (mean decays, variance settles); + // we drive mean-collapse and wiggle-growth on one eased schedule. + // + // The readout R̂ is real split-R̂ (same rhatCore as everywhere) computed on + // the recent (post-warmup) draws — a trailing window — so it falls to ~1.00 + // and turns green when the chains actually merge, instead of dragging the + // over-dispersed warmup along forever. A thin R̂-over-time sparkline under + // the traces re-tells the same fall, crossing the dashed 1.1 threshold. + // ========================================================================== + + function rhatSpark(root) { + var mode = (root.getAttribute("data-mode") || "good").toLowerCase(); + var GOOD = mode !== "bad"; + var NCH = 3, MAXN = 140; + var SEPN = Math.round(MAXN * 0.22); // fully separated threads until here… + var MIXN = Math.round(MAXN * 0.42); // …then funnel; fully merged by here + var NPHI = 0.2; // mixing autocorrelation (low → clean R̂) + var SD0 = 0.32, SD1 = 1.0; // thin warmup threads → full stationary band + var DWIN = 38; // trailing window for the live (post-warmup) R̂ + var BPHI = 0.82; // bad-mode within-mode autocorrelation + + // Hold separated, then smoothstep-funnel onto the shared band. + function ease(u) { return u * u * (3 - 2 * u); } + function phase01(k) { return k <= SEPN ? 0 : k >= MIXN ? 1 : (k - SEPN) / (MIXN - SEPN); } + function collapse(k) { return 1 - ease(phase01(k)); } // 1→0 mean scale + function sdScale(k) { return SD0 + (SD1 - SD0) * ease(phase01(k)); } + + mount(root, { + height: 150, hz: 6, + staticFrame: function (S) { resetR(S); for (var i = 0; i < MAXN; i++) stepR(S); }, + build: function (S) { resetR(S); }, + advance: function (S) { if (S.k < MAXN) stepR(S); else { S.hold = (S.hold || 0) + 1; if (S.hold > 12) { S.reloopSeed = nextSeed(S.reloopSeed); S.rng = FV.rng(S.reloopSeed); resetR(S); } } }, + render: function (g, S, w, h, T, c) { drawR(g, S, w, h, c); } + }); + + function resetR(S) { + S.k = 0; S.hold = 0; S.ch = [[], [], []]; S.rhat = 1; S.rhist = []; + S.eta = []; S.cur = []; + // good: dispersed starts, one shared Normal(0,1) target → chains mix. + // bad: chains trapped in three separate modes → split-R̂ stuck ≫ 1.1. + S.starts = GOOD ? [-3.4, 0.0, 3.4] : [-1.7, 0.1, 1.7]; + S.centers = GOOD ? [0, 0, 0] : [-1.5, 0.0, 1.5]; + for (var i = 0; i < NCH; i++) { S.eta[i] = FV.randn(S.rng); S.cur[i] = S.starts[i]; } + } + function stepR(S) { + var k = S.k, i; + if (GOOD) { + // x = (collapsing separated mean) + (growing stationary N(0,1) wiggle). + for (i = 0; i < NCH; i++) { + S.eta[i] = NPHI * S.eta[i] + Math.sqrt(1 - NPHI * NPHI) * FV.randn(S.rng); + S.ch[i].push(S.starts[i] * collapse(k) + sdScale(k) * S.eta[i]); + } + } else { + // AR(1) trapped in its own mode: never leaves, so chains never agree. + for (i = 0; i < NCH; i++) { + S.cur[i] = S.centers[i] + BPHI * (S.cur[i] - S.centers[i]) + FV.randn(S.rng) * Math.sqrt(1 - BPHI * BPHI); + S.ch[i].push(S.cur[i]); + } + } + S.k++; + // live split-R̂ over the most recent DWIN draws (drop the warmup). + var lo = Math.max(0, S.ch[0].length - DWIN); + S.rhat = splitRhat([S.ch[0].slice(lo), S.ch[1].slice(lo), S.ch[2].slice(lo)]); + S.rhist.push(S.rhat); + } + function drawR(g, S, w, h, c) { + var pad = { l: 8, r: 8, t: 16 }; + var sparkH = 18, sparkGap = 6, sparkBot = h - 6, sparkTop = sparkBot - sparkH; + var plotTop = pad.t, plotBot = sparkTop - sparkGap; + var xs = FV.scale([0, MAXN], [pad.l, w - pad.r]); + var ys = FV.scale([-5, 5], [plotBot, plotTop]); + baseline(g, pad.l, w - pad.r, ys(0), c); + var roleFor = ["prior", "post", "flow"]; + for (var i = 0; i < NCH; i++) { + var pts = [], v = S.ch[i], k; + for (k = 0; k < v.length; k++) pts.push([xs(k), ys(clamp(v[k], -5, 5))]); + FV.curve(g, pts, { color: c[roleFor[i]], width: 1.2 }); + } + var rh = S.rhat; + var conv = isFinite(rh) && rh <= 1.1; + // R̂ readout, top-right, drawn INSIDE the canvas and cleared of the pause + // glyph (a ~20–30px DOM button top-right): reserve glyphClear px so the + // number never hides under it at narrow (phone) widths. + g.save(); + g.font = "13px var(--mono-font, monospace)"; g.textAlign = "right"; g.textBaseline = "top"; + var rtxt = "R̂ " + (isFinite(rh) ? rh.toFixed(2) : "—"); + var glyphClear = coarsePointer() ? 34 : 24; + var rRight = w - pad.r - glyphClear; + var rtxtW = g.measureText(rtxt).width; + g.fillStyle = conv ? c.post : c.hot; + g.fillText(rtxt, rRight, 3); + g.restore(); + // "3 chains" label — only if it clears the readout at this width. + g.save(); g.font = "11px var(--mono-font, monospace)"; + var labW = g.measureText("3 chains").width; g.restore(); + if (pad.l + labW + 10 < rRight - rtxtW) label(g, "3 chains", pad.l, plotTop - 8, c); + drawSpark(g, S, w, sparkTop, sparkBot, c, conv); + } + // Thin R̂-over-time sparkline: fall past the dashed 1.1 threshold, segments + // recolored coral→green so the crossing reads at a glance. + function drawSpark(g, S, w, top, bot, c, conv) { + var pl = 8, pr = 8, rLo = 1.0, rHi = 1.85; + var sx = FV.scale([0, MAXN], [pl, w - pr]); + var sy = FV.scale([rLo, rHi], [bot, top]); + g.save(); g.strokeStyle = c.ink; g.globalAlpha = 0.18; g.lineWidth = 1; g.setLineDash([3, 3]); + g.beginPath(); g.moveTo(pl, sy(1.1)); g.lineTo(w - pr, sy(1.1)); g.stroke(); g.restore(); + var hh = S.rhist; if (hh.length < 2) return; + for (var i = 1; i < hh.length; i++) { + var a = clamp(hh[i - 1], rLo, rHi), b = clamp(hh[i], rLo, rHi); + g.save(); g.strokeStyle = hh[i] > 1.1 ? c.hot : c.post; g.globalAlpha = 0.85; g.lineWidth = 1.4; + g.beginPath(); g.moveTo(sx(i - 1), sy(a)); g.lineTo(sx(i), sy(b)); g.stroke(); g.restore(); + } + var last = clamp(hh[hh.length - 1], rLo, rHi); + g.save(); g.fillStyle = conv ? c.post : c.hot; g.beginPath(); g.arc(sx(hh.length - 1), sy(last), 2, 0, 6.2832); g.fill(); g.restore(); + } + } + + // ========================================================================== + // 5. shrinkage — hierarchical partial pooling; a violet τ slides the estimates. + // ========================================================================== + + function shrinkage(root) { + var NG = 8; + mount(root, { + height: 170, hz: 4, scrub: true, + staticFrame: function (S) { S.tau = 1.0; }, + build: function (S) { + S.y = []; S.se = []; var i; + for (i = 0; i < NG; i++) { S.y.push(FV.randn(S.rng) * 1.7); S.se.push(0.5 + 0.6 * S.rng()); } + S.mu = 0; for (i = 0; i < NG; i++) S.mu += S.y[i]; S.mu /= NG; + S.phase = 0; S.tau = 1.0; S.touched = false; + }, + advance: function (S) { if (!S.touched) { S.phase += 0.16; S.tau = Math.exp(Math.sin(S.phase) * 1.5 - 0.4); } }, + render: function (g, S, w, h, T, c) { drawShrink(g, S, w, h, c); }, + // Only commit on an actual drag ("move"). Under scrub/pan-y a scroll-intent + // touch still fires "down"; acting on it would jump τ and permanently kill + // the ambient breathing just from swiping past the widget. + pointer: function (S, x, y, w, h, phase) { if (phase === "move") { S.touched = true; var frac = clamp((x - 12) / (w - 24), 0, 1); S.tau = Math.exp(lerp(-3.2, 1.8, frac)); } } + }); + + function drawShrink(g, S, w, h, c) { + var pad = { l: 14, r: 14, t: 16, b: 14 }; + var ys = FV.scale([-4.5, 4.5], [h - pad.b, pad.t]); + var colW = (w - pad.l - pad.r) / NG; + // grand mean line + g.save(); g.strokeStyle = c.ink; g.globalAlpha = 0.3; g.setLineDash([4, 4]); g.lineWidth = 1; + g.beginPath(); g.moveTo(pad.l, ys(S.mu)); g.lineTo(w - pad.r, ys(S.mu)); g.stroke(); g.restore(); + var t2 = S.tau * S.tau; + for (var j = 0; j < NG; j++) { + var px = pad.l + colW * (j + 0.5); + var iv = 1 / (S.se[j] * S.se[j]), pr = iv + 1 / t2; + var theta = (S.y[j] * iv + S.mu / t2) / pr; + var psd = Math.sqrt(1 / pr); + // raw observed (faint hollow) + g.save(); g.strokeStyle = c.data; g.globalAlpha = 0.3; g.lineWidth = 1; + g.beginPath(); g.arc(px, ys(S.y[j]), 3, 0, 6.2832); g.stroke(); g.restore(); + // CI + g.save(); g.strokeStyle = c.data; g.globalAlpha = 0.55; g.lineWidth = 1.5; + g.beginPath(); g.moveTo(px, ys(theta - psd)); g.lineTo(px, ys(theta + psd)); g.stroke(); g.restore(); + // shrunk estimate + g.save(); g.fillStyle = c.data; g.beginPath(); g.arc(px, ys(theta), 3.4, 0, 6.2832); g.fill(); g.restore(); + } + labelRight(g, "τ " + fmtNum(S.tau), w - pad.r, pad.t - 6, c, "flow"); + label(g, S.touched ? "your τ" : "τ breathing", pad.l, pad.t - 6, c); + } + } + + // ========================================================================== + // 6. regression-mini — draggable points + ambient posterior spaghetti. + // ========================================================================== + + function regressionMini(root) { + var N = 10, SIG = 0.8, PSD = 2.5, TRAIL = 34, DX = [-3.4, 3.4], DY = [-4.2, 4.2]; + mount(root, { + height: 170, hz: 4, settleN: 100, + build: function (S) { + S.pts = []; var i; + for (i = 0; i < N; i++) { var x = DX[0] + 0.4 + (DX[1] - DX[0] - 0.8) * i / (N - 1); S.pts.push({ x: x, y: 1.0 * x - 0.2 + FV.randn(S.rng) * 0.7 }); } + S.a = 0; S.b = 0; S.trail = []; S.drag = -1; + }, + advance: function (S) { for (var s = 0; s < 3; s++) mh(S); }, + render: function (g, S, w, h, T, c) { drawReg(g, S, w, h, c); }, + pointer: function (S, x, y, w, h, phase, FV, coarse) { return regPtr(S, x, y, w, h, phase, coarse); } + }); + + function xsOf(w) { return FV.scale(DX, [16, w - 10]); } + function ysOf(h) { return FV.scale(DY, [h - 12, 12]); } + function logp(S, a, b) { + var lp = FV.dist.normal.logpdf(a, 0, PSD) + FV.dist.normal.logpdf(b, 0, PSD), i; + for (i = 0; i < S.pts.length; i++) lp += FV.dist.normal.logpdf(S.pts[i].y, a * S.pts[i].x + b, SIG); + return lp; + } + function mh(S) { + var pa = S.a + FV.randn(S.rng) * 0.14, pb = S.b + FV.randn(S.rng) * 0.14; + if (Math.log(S.rng()) < logp(S, pa, pb) - logp(S, S.a, S.b)) { S.a = pa; S.b = pb; } + S.trail.push([S.a, S.b]); while (S.trail.length > TRAIL) S.trail.shift(); + } + function drawReg(g, S, w, h, c) { + var xs = xsOf(w), ys = ysOf(h); + baseline(g, 16, w - 10, ys(0), c); + var i, tr = S.trail; + for (i = 0; i < tr.length; i++) { + var age = (i + 1) / tr.length, last = i === tr.length - 1; + var a = tr[i][0], b = tr[i][1]; + g.save(); g.globalAlpha = last ? 1 : 0.1 + 0.35 * age; g.strokeStyle = last ? c.hot : c.post; g.lineWidth = last ? 2 : 1; + g.beginPath(); g.moveTo(xs(DX[0]), ys(a * DX[0] + b)); g.lineTo(xs(DX[1]), ys(a * DX[1] + b)); g.stroke(); g.restore(); + } + for (i = 0; i < S.pts.length; i++) { g.save(); g.fillStyle = c.data; g.beginPath(); g.arc(xs(S.pts[i].x), ys(S.pts[i].y), 4, 0, 6.2832); g.fill(); g.restore(); } + // grabbed-point halo (§A.2) + if (S.drag >= 0 && S.drag < S.pts.length) { + var pd = S.pts[S.drag]; + g.save(); g.strokeStyle = c.hot; g.globalAlpha = 0.55; g.lineWidth = 2; + g.beginPath(); g.arc(xs(pd.x), ys(pd.y), coarsePointer() ? 11 : 9, 0, 6.2832); g.stroke(); g.restore(); + } + labelRight(g, "slope " + fmtNum(S.a), w - 10, 2, c, "hot"); + } + function regPtr(S, x, y, w, h, phase, coarse) { + var xs = xsOf(w), ys = ysOf(h), i; + if (phase === "down") { + S.drag = -1; + // Inflate the hit target on coarse pointers so a thumb can grab a point + // (§A.2: ≥22 CSS px). Visual dots stay 4px; only the test is generous. + var hitR = coarse ? 26 : 15, best = hitR * hitR; + for (i = 0; i < S.pts.length; i++) { var dx = xs(S.pts[i].x) - x, dy = ys(S.pts[i].y) - y, d = dx * dx + dy * dy; if (d < best) { best = d; S.drag = i; } } + return S.drag >= 0; // claim the gesture only on an actual hit + } + else if (phase === "move" && S.drag >= 0) { S.pts[S.drag].x = clamp(xs.invert(x), DX[0], DX[1]); S.pts[S.drag].y = clamp(ys.invert(y), DY[0], DY[1]); return true; } + else if (phase === "up") S.drag = -1; + return false; + } + } + + // ========================================================================== + // 7. mixture-resp — 2-Gaussian mixture; points colored by EM responsibility. + // ========================================================================== + + function mixtureResp(root) { + var N = 42, DOM = [-4.6, 4.6], STEPS = 16; + mount(root, { + height: 170, hz: 2.8, + staticFrame: function (S) { newRun(S); for (var i = 0; i < 14; i++) emAdv(S, true); }, + build: function (S) { newRun(S); }, + advance: function (S) { emAdv(S, false); }, + render: function (g, S, w, h, T, c) { drawMix(g, S, w, h, T, c); } + }); + + function newRun(S) { + var tm1 = -1.7 + (S.rng() - 0.5), tm2 = 1.7 + (S.rng() - 0.5), i; + S.xs = []; + for (i = 0; i < N; i++) S.xs.push((S.rng() < 0.5 ? tm1 : tm2) + FV.randn(S.rng) * 0.7); + S.p = { pi: 0.5, m1: -0.6 + S.rng(), s1: 1.0, m2: 0.6 + S.rng(), s2: 1.0 }; + S.r = null; S.toP = clone(S.p); S.fromP = clone(S.p); S.steps = 0; + } + function emAdv(S, force) { + if (S.steps >= STEPS && !force) { S.reloopSeed = nextSeed(S.reloopSeed); S.rng = FV.rng(S.reloopSeed); newRun(S); return; } + var np = emStep(S.xs, S.p); + S.fromP = clone(S.p); + S.p = { pi: np.pi, m1: np.m1, s1: np.s1, m2: np.m2, s2: np.s2 }; + S.toP = clone(S.p); S.r = np.r; S.steps++; + } + function drawMix(g, S, w, h, T, c) { + var pad = { l: 10, r: 10, t: 14, b: 16 }; + var xs = FV.scale(DOM, [pad.l, w - pad.r]); + var p = { pi: lerp(S.fromP.pi, S.toP.pi, T), m1: lerp(S.fromP.m1, S.toP.m1, T), s1: lerp(S.fromP.s1, S.toP.s1, T), m2: lerp(S.fromP.m2, S.toP.m2, T), s2: lerp(S.fromP.s2, S.toP.s2, T) }; + function c1(x) { return p.pi * Math.exp(FV.dist.normal.logpdf(x, p.m1, p.s1)); } + function c2(x) { return (1 - p.pi) * Math.exp(FV.dist.normal.logpdf(x, p.m2, p.s2)); } + var ymax = 1e-6, k, x; + for (k = 0; k <= 120; k++) { x = DOM[0] + (DOM[1] - DOM[0]) * k / 120; var s = c1(x) + c2(x); if (s > ymax) ymax = s; } + var ys = FV.scale([0, ymax * 1.25], [h - pad.b, pad.t]); + baseline(g, pad.l, w - pad.r, ys(0), c); + FV.curve(g, densCurve(g, DOM, c1, xs, ys, 120), { color: c.prior, width: 1.6 }); + FV.curve(g, densCurve(g, DOM, c2, xs, ys, 120), { color: c.post, width: 1.6 }); + var by = ys(0) + 6; + for (var i = 0; i < S.xs.length; i++) { + var r = S.r ? S.r[i] : 0.5; + g.save(); g.globalAlpha = 0.85; g.fillStyle = blend(c.prior, c.post, r); + g.beginPath(); g.arc(xs(S.xs[i]), by, 3, 0, 6.2832); g.fill(); g.restore(); + } + label(g, "EM step " + S.steps, pad.l, pad.t - 4, c); + } + } + + // ========================================================================== + // 8. logistic-boundary — Bayesian logistic regression; wobbling green lines. + // ========================================================================== + + function logisticBoundary(root) { + var N = 34, SPA = 14, DOM = [-3.2, 3.2]; + mount(root, { + height: 175, hz: 5, settleN: 90, + build: function (S) { + S.pts = []; var i; + for (i = 0; i < N; i++) { var cls = S.rng() < 0.5 ? 1 : 0, cx = cls ? 1.1 : -1.1, cy = cls ? 0.9 : -0.9; S.pts.push({ x: cx + FV.randn(S.rng) * 0.9, y: cy + FV.randn(S.rng) * 0.9, c: cls }); } + S.w = [0, 0.6, 0.6]; S.draws = []; + }, + advance: function (S) { for (var s = 0; s < 2; s++) wStep(S); }, + render: function (g, S, w, h, T, c) { drawLogi(g, S, w, h, c); } + }); + + function wStep(S) { + var pw = [S.w[0] + FV.randn(S.rng) * 0.25, S.w[1] + FV.randn(S.rng) * 0.25, S.w[2] + FV.randn(S.rng) * 0.25]; + if (Math.log(S.rng()) < logisticLogPost(pw, S.pts) - logisticLogPost(S.w, S.pts)) S.w = pw; + S.draws.push(S.w.slice()); while (S.draws.length > SPA) S.draws.shift(); + } + function drawLogi(g, S, w, h, c) { + var pad = { l: 10, r: 10, t: 12, b: 10 }; + var xs = FV.scale(DOM, [pad.l, w - pad.r]); + var ys = FV.scale(DOM, [h - pad.b, pad.t]); + var i, dr = S.draws; + for (i = 0; i < dr.length; i++) { + var wi = dr[i], last = i === dr.length - 1, age = (i + 1) / dr.length; + if (Math.abs(wi[2]) < 1e-6) continue; + function yb(x) { return -(wi[0] + wi[1] * x) / wi[2]; } + g.save(); g.globalAlpha = last ? 1 : 0.12 + 0.4 * age; g.strokeStyle = c.post; g.lineWidth = last ? 2 : 1; + g.beginPath(); g.moveTo(xs(DOM[0]), ys(clamp(yb(DOM[0]), DOM[0] - 4, DOM[1] + 4))); g.lineTo(xs(DOM[1]), ys(clamp(yb(DOM[1]), DOM[0] - 4, DOM[1] + 4))); g.stroke(); g.restore(); + } + for (i = 0; i < S.pts.length; i++) { + g.save(); g.fillStyle = S.pts[i].c ? c.data : c.prior; g.globalAlpha = 0.9; + g.beginPath(); g.arc(xs(S.pts[i].x), ys(S.pts[i].y), 3.2, 0, 6.2832); g.fill(); g.restore(); + } + label(g, "posterior boundaries", pad.l, pad.t - 2, c); + } + } + + // ========================================================================== + // 9. elbo-climb — a Gaussian guide climbs a skewed target; ELBO plateaus. + // ========================================================================== + + function elboClimb(root) { + var HALF = 0.5 * Math.log(2 * Math.PI * Math.E), DOM = [-4, 4], MAXK = 70; + function U(x) { var s = x < 0 ? 0.7 : 1.5, z = x / s; return 0.5 * z * z; } + function dU(x) { var s = x < 0 ? 0.7 : 1.5; return x / (s * s); } + + mount(root, { + height: 175, hz: 6, + staticFrame: function (S) { resetE(S); for (var i = 0; i < MAXK; i++) stepE(S); }, + build: function (S) { resetE(S); }, + advance: function (S) { if (S.k < MAXK) stepE(S); else { S.hold = (S.hold || 0) + 1; if (S.hold > 12) { S.reloopSeed = nextSeed(S.reloopSeed); S.rng = FV.rng(S.reloopSeed); resetE(S); } } }, + render: function (g, S, w, h, T, c) { drawElbo(g, S, w, h, c); } + }); + + function resetE(S) { S.mu = -2.5 + 5 * S.rng(); S.L = Math.log(0.3 + 1.4 * S.rng()); S.k = 0; S.hold = 0; S.elbos = []; } + function stepE(S) { + var M = 10, gm = 0, gl = 0, el = 0, i, sig = Math.exp(S.L); + for (i = 0; i < M; i++) { var eps = FV.randn(S.rng), x = S.mu + sig * eps, du = dU(x); gm += -du; gl += -du * sig * eps; el += -U(x); } + gm /= M; gl = gl / M + 1; el = el / M + HALF + S.L; + var lr = 0.08; + S.mu += lr * gm; S.L += lr * gl; + S.L = clamp(S.L, -2.5, 1.5); + S.elbos.push(el); while (S.elbos.length > 160) S.elbos.shift(); + S.k++; + } + function drawElbo(g, S, w, h, c) { + var splitY = h * 0.64, pad = { l: 10, r: 10, t: 12 }; + var xs = FV.scale(DOM, [pad.l, w - pad.r]); + var sig = Math.exp(S.L); + function tgt(x) { return Math.exp(-U(x)); } + function guide(x) { return Math.exp(FV.dist.normal.logpdf(x, S.mu, sig)); } + var ymax = 1e-6, k, x; + for (k = 0; k <= 120; k++) { x = DOM[0] + (DOM[1] - DOM[0]) * k / 120; var a = tgt(x); if (a > ymax) ymax = a; var b = guide(x); if (b > ymax) ymax = b; } + var ys = FV.scale([0, ymax * 1.15], [splitY - 8, pad.t]); + baseline(g, pad.l, w - pad.r, ys(0), c); + FV.curve(g, densCurve(g, DOM, tgt, xs, ys, 120), { color: c.ink, width: 1.4 }); + var gg = densCurve(g, DOM, guide, xs, ys, 120); fillUnder(g, gg, ys(0), c.post, 0.14); FV.curve(g, gg, { color: c.post, width: 2 }); + // ELBO sparkline + var e = S.elbos; + if (e.length > 1) { + var lo = Infinity, hi = -Infinity, i; + for (i = 0; i < e.length; i++) { if (e[i] < lo) lo = e[i]; if (e[i] > hi) hi = e[i]; } + if (hi - lo < 1e-6) hi = lo + 1; + var sx = FV.scale([0, MAXK], [pad.l, w - pad.r]); + var sy = FV.scale([lo, hi], [h - 8, splitY + 6]); + var pts = []; for (i = 0; i < e.length; i++) pts.push([sx(i), sy(e[i])]); + FV.curve(g, pts, { color: c.flow, width: 1.5 }); + } + label(g, "guide vs target", pad.l, pad.t - 2, c); + labelRight(g, "ELBO", w - pad.r, splitY + 4, c, "flow"); + } + } + + // ========================================================================== + // 10. abc-eps — prior draws fall; ε shrinks; the accepted cloud tightens. + // ========================================================================== + + function abcEps(root) { + var DOM = [-6, 6], MAXK = 26; + mount(root, { + height: 175, hz: 3, + staticFrame: function (S) { resetA(S); S.eps = 0.3; for (var i = 0; i < 40; i++) genAcc(S); }, + build: function (S) { resetA(S); }, + advance: function (S) { stepA(S); }, + render: function (g, S, w, h, T, c) { drawAbc(g, S, w, h, T, c); } + }); + + function resetA(S) { S.yobs = -1.5 + 3 * S.rng(); S.eps = 3.0; S.accepted = []; S.drops = []; S.k = 0; } + function stepA(S) { + if (S.k > MAXK) { S.reloopSeed = nextSeed(S.reloopSeed); S.rng = FV.rng(S.reloopSeed); resetA(S); return; } + S.drops = []; + for (var i = 0; i < 10; i++) { + var th = FV.dist.normal.sample(S.rng, 0, 2.5), ok = Math.abs(th - S.yobs) <= S.eps; + S.drops.push({ th: th, ok: ok, off: S.rng() }); + if (ok) S.accepted.push(th); + } + S.eps = Math.max(0.3, S.eps * 0.86); S.k++; + while (S.accepted.length > 400) S.accepted.shift(); + } + function genAcc(S) { for (var i = 0; i < 10; i++) { var th = FV.dist.normal.sample(S.rng, 0, 2.5); if (Math.abs(th - S.yobs) <= 0.3) S.accepted.push(th); } } + function drawAbc(g, S, w, h, T, c) { + var pad = { l: 10, r: 10 }, splitY = h * 0.56; + var xs = FV.scale(DOM, [pad.l, w - pad.r]); + // epsilon band (violet) around observed + g.save(); g.globalAlpha = 0.14; g.fillStyle = c.flow; + g.fillRect(xs(S.yobs - S.eps), 12, xs(S.yobs + S.eps) - xs(S.yobs - S.eps), splitY - 12); g.restore(); + // observed marker + g.save(); g.strokeStyle = c.flow; g.globalAlpha = 0.7; g.lineWidth = 1.5; g.setLineDash([3, 3]); + g.beginPath(); g.moveTo(xs(S.yobs), 12); g.lineTo(xs(S.yobs), splitY); g.stroke(); g.restore(); + // falling drops + for (var i = 0; i < S.drops.length; i++) { + var d = S.drops[i], y = lerp(14, splitY - 8, Math.min(1, T + d.off * 0.4)); + g.save(); g.globalAlpha = d.ok ? 0.95 : 0.35 * (1 - T) + 0.2; g.fillStyle = d.ok ? c.post : c.hot; + g.beginPath(); g.arc(xs(clamp(d.th, DOM[0], DOM[1])), y, 3, 0, 6.2832); g.fill(); g.restore(); + } + // accepted posterior histogram + var ys = FV.scale([0, 1], [h - 10, splitY + 8]); + if (S.accepted.length) { + var bins = 40, counts = new Array(bins), b, n = 0, lo = DOM[0], hi = DOM[1], bw = (hi - lo) / bins; + for (b = 0; b < bins; b++) counts[b] = 0; + for (i = 0; i < S.accepted.length; i++) { var idx = Math.floor((S.accepted[i] - lo) / bw); if (idx >= 0 && idx < bins) { counts[idx]++; n++; } } + var mx = 1; for (b = 0; b < bins; b++) if (counts[b] > mx) mx = counts[b]; + g.save(); g.globalAlpha = 0.6; g.fillStyle = c.post; + for (b = 0; b < bins; b++) { if (!counts[b]) continue; var xa = xs(lo + b * bw), xb = xs(lo + (b + 1) * bw), hh = (counts[b] / mx) * (h - 18 - splitY); g.fillRect(xa, h - 10 - hh, xb - xa, hh); } + g.restore(); + } + label(g, "ε " + fmtNum(S.eps), pad.l, 2, c); + labelRight(g, "accepted " + S.accepted.length, w - pad.r, 2, c, "post"); + } + } + + // ========================================================================== + // Registration + // ========================================================================== + + FV.register("dist-strip", function (root) { distStrip(root); }); + FV.register("posterior-morph", function (root) { posteriorMorph(root); }); + FV.register("trace-ticker", function (root) { traceTicker(root); }); + FV.register("rhat-spark", function (root) { rhatSpark(root); }); + FV.register("shrinkage", function (root) { shrinkage(root); }); + FV.register("regression-mini", function (root) { regressionMini(root); }); + FV.register("mixture-resp", function (root) { mixtureResp(root); }); + FV.register("logistic-boundary", function (root) { logisticBoundary(root); }); + FV.register("elbo-climb", function (root) { elboClimb(root); }); + FV.register("abc-eps", function (root) { abcEps(root); }); +})(); diff --git a/docs/viz/metropolis.js b/docs/viz/metropolis.js new file mode 100644 index 0000000..eac4bbe --- /dev/null +++ b/docs/viz/metropolis.js @@ -0,0 +1,585 @@ +// docs/viz/metropolis.js — "Random Walks in Posterior Space" +// v2 DATA-FIRST rebuild: Bayesian linear regression, twin-panel. +// LEFT (data space): ~12 draggable (x,y) points; posterior spaghetti — the +// last ~60 accepted (slope,intercept) drawn as thin green +// lines through the data; current fit coral; rejected +// proposals flash coral-dashed and vanish. +// RIGHT (param space): live 2-D posterior heatmap over (slope, intercept), +// recomputed whenever a data point moves (offscreen grid, +// rebuilt only when dirty, so the sampler stays butter- +// smooth); chain trails, proposal arrows, accept/reject. +// Model: y ~ Normal(a*x + b, 0.8), priors a,b ~ Normal(0, 2.5). σ_obs fixed 0.8. +// Diagnostics (acceptance / split-R̂ / ESS) ported from src/inference/{diagnostics, +// mcmc_utils}.rs. Self-contained IIFE; assumes fugue-viz.js has loaded first. +(function () { + "use strict"; + if (typeof window === "undefined" || !window.FugueViz) return; + var FV = window.FugueViz; + + // ---- The model. logp(a,b) is THE log posterior over (slope, intercept). ---- + var SIGMA_OBS = 0.8; // fixed observation noise (stated on the page) + var PRIOR_SD = 2.5; // Normal(0, 2.5) prior on both params + + // Fixed plot domains (kept stable so dragging never makes the axes jump). + // Windows shared verbatim with viz/hmc.js — the two pages present the SAME + // regression problem so the MH→HMC comparison is apples-to-apples. + var DX = [-3.6, 3.6], DY = [-4.8, 3.2]; // data space + var PA = [-0.2, 2.0], PB = [-2.5, 1.2]; // param space: slope × intercept + + // Dispersed chain starts so several chains reveal (or fail to reveal) mixing. + var STARTA = [0.0, 1.8, 1.2, 0.3]; + var STARTB = [1.0, -2.2, 0.2, -1.5]; + + var SPAGHETTI = 60; // accepted fits retained as green lines + var MAXSAMP = 1200; // capped sample history per chain used for diagnostics + var MAXTRAIL = 320; // capped param-space trail length for drawing + var LAGCAP = 256; // autocorrelation lag cap (Rust uses 2048; see report) + + // ===== Diagnostics ported from src/inference/diagnostics.rs + mcmc_utils.rs === + function splitChains(chs) { + var out = []; + for (var i = 0; i < chs.length; i++) { + var c = chs[i], half = Math.floor(c.length / 2); + if (half === 0) { out.push(c.slice()); continue; } + out.push(c.slice(0, half)); + out.push(c.slice(half, 2 * half)); + } + return out; + } + function rhatFrom(ch) { + if (ch.length < 2) return 1.0; + for (var i = 0; i < ch.length; i++) if (ch[i].length === 0) return NaN; + var m = ch.length, n = ch[0].length, k; + var means = ch.map(function (v) { var s = 0; for (k = 0; k < v.length; k++) s += v[k]; return s / v.length; }); + var overall = 0; for (i = 0; i < m; i++) overall += means[i]; overall /= m; + var b = 0; for (i = 0; i < m; i++) { var d = means[i] - overall; b += d * d; } b *= n / (m - 1); + var w = 0; + for (i = 0; i < m; i++) { + var v = ch[i], mu = means[i], s = 0; + for (k = 0; k < v.length; k++) { var e = v[k] - mu; s += e * e; } + w += s / (n - 1); + } + w /= m; + var varplus = ((n - 1) / n) * w + (1 / n) * b; + return Math.sqrt(varplus / w); + } + function splitRhat(chs) { return rhatFrom(splitChains(chs)); } + + function autocov(x, maxLag) { + var n = x.length, mean = 0, i; + for (i = 0; i < n; i++) mean += x[i]; mean /= n; + var c = new Array(n); for (i = 0; i < n; i++) c[i] = x[i] - mean; + var acov = new Array(maxLag + 1); + for (var lag = 0; lag <= maxLag; lag++) { + var s = 0; for (i = 0; i < n - lag; i++) s += c[i] * c[i + lag]; + acov[lag] = s / n; + } + return acov; + } + // Multi-chain ESS (Vehtari et al. 2021 / Stan), Geyer initial positive sequence. + function essFromChains(chains) { + var m = chains.length; if (m === 0) return 0; + var n = chains[0].length, i; + var uneq = false; for (i = 0; i < m; i++) if (chains[i].length !== n) uneq = true; + if (n < 4 || uneq) { var tot = 0; for (i = 0; i < m; i++) tot += chains[i].length; return Math.max(tot, 1); } + var maxLag = Math.min(n - 1, LAGCAP); + var acovs = chains.map(function (c) { return autocov(c, maxLag); }); + var nf = n, mf = m; + var means = chains.map(function (c) { var s = 0; for (var k = 0; k < c.length; k++) s += c[k]; return s / nf; }); + var vars = acovs.map(function (a) { return a[0] * nf / (nf - 1); }); + var meanVar = 0; for (i = 0; i < m; i++) meanVar += vars[i]; meanVar /= mf; + if (meanVar <= 0) return m * n; + var varplus = meanVar * (nf - 1) / nf; + if (m > 1) { + var overall = 0; for (i = 0; i < m; i++) overall += means[i]; overall /= mf; + var between = 0; for (i = 0; i < m; i++) { var d = means[i] - overall; between += d * d; } between /= (mf - 1); + varplus += between; + } + var rho = function (t) { var s = 0; for (var j = 0; j < m; j++) s += acovs[j][t]; s /= mf; return 1 - (meanVar - s) / varplus; }; + var rhoHat = new Array(maxLag + 1); for (i = 0; i <= maxLag; i++) rhoHat[i] = 0; rhoHat[0] = 1; + if (maxLag >= 1) rhoHat[1] = rho(1); + var t = 1, maxT = Math.min(1, maxLag); + while (t + 2 <= maxLag) { + var re = rho(t + 1), ro = rho(t + 2); + if (re + ro < 0) break; + rhoHat[t + 1] = re; rhoHat[t + 2] = ro; maxT = t + 2; t += 2; + } + var kk = 1; + while (kk + 2 <= maxT) { + var prev = rhoHat[kk - 1] + rhoHat[kk], cur = rhoHat[kk + 1] + rhoHat[kk + 2]; + if (cur > prev) { var avg = prev / 2; rhoHat[kk + 1] = avg; rhoHat[kk + 2] = avg; } + kk += 2; + } + var sum = 0; for (i = 0; i <= maxT; i++) sum += rhoHat[i]; + var tau = Math.max(-1 + 2 * sum, 1); + return (m * n) / tau; + } + + // small hex/rgb parser (FugueViz keeps its own private; we need one for the ramp) + function toRgb(col) { + col = (col || "").trim(); + var m = /^#?([0-9a-f]{6})$/i.exec(col); + if (m) { var n = parseInt(m[1], 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } + var r = /rgba?\(([^)]+)\)/.exec(col); + if (r) { var p = r[1].split(","); return [parseInt(p[0], 10), parseInt(p[1], 10), parseInt(p[2], 10)]; } + return [86, 211, 100]; + } + + FV.register("metropolis", function (root, FV) { + // ------------------------------------------------------------------ state + var seed0 = parseInt(root.getAttribute("data-seed") || "11", 10); + var params = { sigma: 0.35, nChains: 3, speed: 8, seed: seed0 }; + var pts = []; // draggable data points {x, y} + var chains = []; + var spaghetti = []; // recent accepted [a, b] across all chains + var accProp = 0, accAcc = 0; + var rand = FV.rng(seed0 >>> 0); + var diag = { rhat: NaN, ess: 0, acc: 0 }; + var acc = 0; // fractional step accumulator for play mode + var lastDiag = 0; + + // The regression log posterior over (slope a, intercept b). + function logp(a, b) { + var lp = FV.dist.normal.logpdf(a, 0, PRIOR_SD) + FV.dist.normal.logpdf(b, 0, PRIOR_SD); + for (var i = 0; i < pts.length; i++) { + lp += FV.dist.normal.logpdf(pts[i].y, a * pts[i].x + b, SIGMA_OBS); + } + return lp; + } + + // Seeded default dataset, identical to viz/hmc.js makeSeedData(11): + // true line y = 0.8·x − 0.4 + Normal(0, 0.8), 12 points evenly on [-3, 3]. + function makeData() { + var dr = FV.rng(11); + pts = []; + var N = 12; + for (var i = 0; i < N; i++) { + var x = -3 + 6 * i / (N - 1); + var y = 0.8 * x - 0.4 + 0.8 * FV.randn(dr); + if (y < DY[0] + 0.3) y = DY[0] + 0.3; + if (y > DY[1] - 0.3) y = DY[1] - 0.3; + pts.push({ x: x, y: y }); + } + } + + function newChains() { + rand = FV.rng(params.seed >>> 0); + chains = []; + spaghetti = []; + for (var i = 0; i < params.nChains; i++) { + var a = STARTA[i % 4], b = STARTB[i % 4]; + chains.push({ a: a, b: b, lp: logp(a, b), as: [a], bs: [b], trail: [[a, b]], flash: null }); + } + accProp = 0; accAcc = 0; acc = 0; + diag = { rhat: NaN, ess: 0, acc: 0 }; + lastDiag = 0; + } + + // Data changed (drag / reseed): posterior moved, so every chain's cached + // log-density is stale — recompute it and mark the heatmap dirty. + function reweightChains() { + for (var i = 0; i < chains.length; i++) chains[i].lp = logp(chains[i].a, chains[i].b); + heatDirty = true; + } + + function doStep() { + var s = params.sigma; + for (var i = 0; i < chains.length; i++) { + var ch = chains[i]; + var pa = ch.a + s * FV.randn(rand); + var pb = ch.b + s * FV.randn(rand); + var plp = logp(pa, pb); + var logA = plp - ch.lp; // symmetric proposal: ratio of targets + var accept = Math.log(rand() + 1e-300) < logA; + accProp++; + ch.flash = { oa: ch.a, ob: ch.b, pa: pa, pb: pb, accepted: accept, life: 1 }; + if (accept) { + ch.a = pa; ch.b = pb; ch.lp = plp; accAcc++; + spaghetti.push([pa, pb]); + if (spaghetti.length > SPAGHETTI) spaghetti.shift(); + } + ch.as.push(ch.a); ch.bs.push(ch.b); + if (ch.as.length > MAXSAMP) { ch.as.shift(); ch.bs.shift(); } + ch.trail.push([ch.a, ch.b]); + if (ch.trail.length > MAXTRAIL) ch.trail.shift(); + } + } + + function refreshDiag(now) { + if (now - lastDiag < 250) return; + lastDiag = now; + var as = [], bs = [], i; + for (i = 0; i < chains.length; i++) { as.push(chains[i].as); bs.push(chains[i].bs); } + var ra = splitRhat(as), rb = splitRhat(bs); + var rhat = Math.max(isFinite(ra) ? ra : -Infinity, isFinite(rb) ? rb : -Infinity); + if (!isFinite(rhat)) rhat = NaN; + var ess = Math.min(essFromChains(as), essFromChains(bs)); // worst coordinate + diag = { rhat: rhat, ess: ess, acc: accProp ? accAcc / accProp : 0 }; + renderReadouts(); + } + + // --------------------------------------------------------------- DOM shell + var controls = document.createElement("div"); + controls.className = "fv-controls"; + root.appendChild(controls); + + // proposal sigma on a log scale (0.01 .. 5) + var LOGLO = Math.log(0.01), LOGHI = Math.log(5); + function sigmaOf(t) { return Math.exp(LOGLO + t * (LOGHI - LOGLO)); } + FV.slider(controls, { + label: "PROPOSAL σ", min: 0, max: 1, step: 0.001, value: (Math.log(params.sigma) - LOGLO) / (LOGHI - LOGLO), + fmt: function (t) { return sigmaOf(t).toFixed(2); }, + onInput: function (t) { params.sigma = sigmaOf(t); requestDraw(); } + }); + + FV.slider(controls, { + label: "CHAINS", min: 1, max: 4, step: 1, value: params.nChains, + fmt: function (v) { return String(v | 0); }, + onInput: function (v) { params.nChains = v | 0; newChains(); renderReadouts(); requestDraw(); } + }); + + FV.slider(controls, { + label: "SPEED", min: 1, max: 40, step: 1, value: params.speed, + fmt: function (v) { return (v | 0) + "/s"; }, + onInput: function (v) { params.speed = v | 0; } + }); + + FV.slider(controls, { + label: "SEED", min: 1, max: 99, step: 1, value: params.seed, + fmt: function (v) { return String(v | 0); }, + onInput: function (v) { params.seed = v | 0; newChains(); renderReadouts(); requestDraw(); } + }); + + var btns = FV.buttons(controls, [ + { label: "Play", title: "Run the chains", primary: true, onClick: function () { togglePlay(); } }, + { label: "Step", title: "One proposal per chain", onClick: function () { loopApi.step(); } }, + { label: "Reset", title: "Restart chains from dispersed seeds", onClick: function () { newChains(); renderReadouts(); requestDraw(); } } + ]); + + var cv = FV.canvas(root, { height: 400, onResize: function () { heatDirty = true; draw(); } }); + var ctx = cv.ctx; + + var instr = document.createElement("div"); + instr.className = "fv-instruction"; + instr.textContent = "drag a yellow point — the posterior heatmap (right) and the chain react instantly · coral = current fit · green = recent accepted fits"; + root.appendChild(instr); + + var readouts = document.createElement("div"); + readouts.className = "fv-readouts"; + root.appendChild(readouts); + var rAcc = FV.readout(readouts, { label: "ACCEPTANCE" }); + var rRhat = FV.readout(readouts, { label: "SPLIT-R̂" }); + var rEss = FV.readout(readouts, { label: "ESS" }); + + var hint = document.createElement("div"); + hint.className = "fv-hint"; + hint.textContent = "try: the chains are already walking — drag a point far from the line and the heatmap morphs and the whole chain migrates to the new best fit, live."; + root.appendChild(hint); + + function renderReadouts() { + var a = diag.acc; + rAcc.set((a * 100).toFixed(1) + "%", (a >= 0.2 && a <= 0.5) ? "post" : "hot"); + if (isFinite(diag.rhat)) rRhat.set(diag.rhat.toFixed(3), diag.rhat < 1.1 ? "post" : "hot"); + else rRhat.set("—"); + rEss.set(diag.ess >= 1 ? diag.ess.toFixed(0) : "—"); + } + + // -------------------------------------------------------------- heatmap + var heatDirty = true; + var heatCanvas = document.createElement("canvas"); + var heatCtx = heatCanvas.getContext("2d"); + // Rebuild the offscreen posterior heatmap over (slope, intercept). Called + // only when the data changed / panel resized — never every animation frame, + // so the sampler runs smoothly and a drag still updates same-frame. + function rebuildHeat(iw, ih, col) { + iw = Math.max(1, Math.round(iw)); ih = Math.max(1, Math.round(ih)); + heatCanvas.width = iw; heatCanvas.height = ih; + // §A.3: while a point is being dragged the grid rebuilds every frame, so + // compute it at half resolution (8px cells) during the drag and full + // resolution (4px) once the point is released (onEnd forces heatDirty). + var step = dragIdx >= 0 ? 8 : 4; + var cols = Math.ceil(iw / step), rows = Math.ceil(ih / step); + var vals = new Array(cols * rows), maxv = -Infinity, ix, iy; + for (iy = 0; iy < rows; iy++) { + var b = PB[1] - ((iy * step + step / 2) / ih) * (PB[1] - PB[0]); + for (ix = 0; ix < cols; ix++) { + var a = PA[0] + ((ix * step + step / 2) / iw) * (PA[1] - PA[0]); + var v = logp(a, b); + if (!isFinite(v)) v = -Infinity; + vals[iy * cols + ix] = v; + if (v > maxv) maxv = v; + } + } + var rgb = toRgb(col); + heatCtx.clearRect(0, 0, iw, ih); + if (isFinite(maxv)) { + for (iy = 0; iy < rows; iy++) { + for (ix = 0; ix < cols; ix++) { + var lv = vals[iy * cols + ix]; + var al = lv === -Infinity ? 0 : Math.exp(lv - maxv); + if (al <= 0.004) continue; + if (al > 1) al = 1; + heatCtx.fillStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + al + ")"; + heatCtx.fillRect(ix * step, iy * step, step, step); + } + } + } + heatDirty = false; + } + + // ---------------------------------------------------------------- layout + // Returns {data:{...plot}, param:{...plot}} where each plot carries its inner + // rect + scales. Side-by-side (~55/45) on wide, stacked on narrow. + var dataPlot = null, paramPlot = null; + function layout(w, h) { + var gap = 16, stacked = w < 560; + var dRect, pRect; + if (stacked) { + var ph = (h - gap) / 2; + dRect = { x: 0, y: 0, w: w, h: ph }; + pRect = { x: 0, y: ph + gap, w: w, h: ph }; + } else { + var dw = (w - gap) * 0.55; + dRect = { x: 0, y: 0, w: dw, h: h }; + pRect = { x: dw + gap, y: 0, w: (w - gap) - dw, h: h }; + } + dataPlot = mkPlot(dRect, DX, DY); + paramPlot = mkPlot(pRect, PA, PB); + } + function mkPlot(rect, domX, domY) { + var pad = { l: 38, r: 10, t: 12, b: 26 }; + var iw = Math.max(10, rect.w - pad.l - pad.r); + var ih = Math.max(10, rect.h - pad.t - pad.b); + var ix = rect.x + pad.l, iy = rect.y + pad.t; + return { + rect: rect, ix: ix, iy: iy, iw: iw, ih: ih, + sx: FV.scale(domX, [ix, ix + iw]), + sy: FV.scale(domY, [iy + ih, iy]) + }; + } + + // ------------------------------------------------------------------- draw + function drawArrow(x0, y0, x1, y1, color, alpha, dash) { + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 1.5; + if (dash) ctx.setLineDash(dash); + ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); + ctx.setLineDash([]); + var ang = Math.atan2(y1 - y0, x1 - x0), hl = 6; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x1 - hl * Math.cos(ang - 0.4), y1 - hl * Math.sin(ang - 0.4)); + ctx.lineTo(x1 - hl * Math.cos(ang + 0.4), y1 - hl * Math.sin(ang + 0.4)); + ctx.closePath(); ctx.fill(); + ctx.restore(); + } + function dotRaw(x, y, r, color) { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, r, 0, 2 * Math.PI); ctx.fill(); } + function dot(x, y, r, color, alpha) { ctx.save(); ctx.globalAlpha = alpha; dotRaw(x, y, r, color); ctx.restore(); } + + // a fit-line y = a*x + b clipped to the data panel + function fitLine(p, a, b, color, width, alpha, dash) { + var y0 = a * DX[0] + b, y1 = a * DX[1] + b; + ctx.save(); + ctx.beginPath(); + ctx.rect(p.ix, p.iy, p.iw, p.ih); ctx.clip(); + ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = width; + ctx.lineCap = "round"; + if (dash) ctx.setLineDash(dash); + ctx.beginPath(); + ctx.moveTo(p.sx(DX[0]), p.sy(y0)); + ctx.lineTo(p.sx(DX[1]), p.sy(y1)); + ctx.stroke(); + ctx.restore(); + } + + function draw() { + if (!cv) return; // onResize can fire during canvas() before cv is assigned + var w = cv.w, h = cv.h; + cv.clear(); + var th = FV.theme(), C = th.colors; + // §A.7: the per-tick proposal arrows/flashes are informative at low speed + // but strobe when many steps land per frame — fade that layer as speed + // climbs instead of flickering it. + var propVis = params.speed <= 16 ? 1 : Math.max(0.15, 1 - (params.speed - 16) / 30); + layout(w, h); + var dp = dataPlot, pp = paramPlot; + + // ---- PARAM panel: posterior heatmap ---- + if (heatDirty || heatCanvas.width !== Math.round(pp.iw) || heatCanvas.height !== Math.round(pp.ih)) { + rebuildHeat(pp.iw, pp.ih, C.post); + } + ctx.drawImage(heatCanvas, pp.ix, pp.iy, pp.iw, pp.ih); + FV.axes(ctx, { x: pp.ix, y: pp.iy, w: pp.iw, h: pp.ih, xscale: pp.sx, yscale: pp.sy, xlabel: "slope a", ylabel: "intercept b", theme: th }); + + // param trails (the "recording" so far) — ink, faded + ctx.save(); + ctx.globalAlpha = 0.45; + for (var i = 0; i < chains.length; i++) { + var tr = chains[i].trail, ptsPix = new Array(tr.length); + for (var j = 0; j < tr.length; j++) ptsPix[j] = [pp.sx(tr[j][0]), pp.sy(tr[j][1])]; + FV.curve(ctx, ptsPix, { color: C.ink, width: 1.1 }); + } + ctx.restore(); + + // param proposal flashes: blue ghost arrow + green/coral outcome + for (i = 0; i < chains.length; i++) { + var fl = chains[i].flash; + if (!fl || fl.life <= 0) continue; + var ax0 = pp.sx(fl.oa), ay0 = pp.sy(fl.ob), ax1 = pp.sx(fl.pa), ay1 = pp.sy(fl.pb); + drawArrow(ax0, ay0, ax1, ay1, C.prior, 0.5 * fl.life * propVis, [4, 3]); + if (fl.accepted) dot(ax1, ay1, 4, C.post, 0.9 * fl.life * propVis); + else { dot(ax1, ay1, 3.5, C.hot, 0.85 * fl.life * propVis); drawArrow(ax1, ay1, ax0, ay0, C.hot, 0.35 * fl.life * propVis, [2, 3]); } + } + + // param current states — coral with a glow + for (i = 0; i < chains.length; i++) { + var ch = chains[i], cx = pp.sx(ch.a), cy = pp.sy(ch.b); + ctx.save(); ctx.globalAlpha = 0.28; dotRaw(cx, cy, 9, C.hot); ctx.restore(); + dot(cx, cy, 4.5, C.hot, 1); + ctx.save(); + ctx.strokeStyle = th.dark ? "rgba(13,17,23,0.9)" : "rgba(255,255,255,0.9)"; + ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.arc(cx, cy, 4.5, 0, 2 * Math.PI); ctx.stroke(); + ctx.restore(); + } + + // ---- DATA panel: spaghetti + points ---- + FV.axes(ctx, { x: dp.ix, y: dp.iy, w: dp.iw, h: dp.ih, xscale: dp.sx, yscale: dp.sy, xlabel: "x", ylabel: "y", theme: th }); + + // posterior spaghetti: recent accepted fits, oldest faint -> newest bright + var N = spaghetti.length; + for (i = 0; i < N; i++) { + var age = (i + 1) / N; + fitLine(dp, spaghetti[i][0], spaghetti[i][1], C.post, 1.1, 0.10 + 0.42 * age); + } + + // rejected-proposal flashes in data space: coral dashed, vanishing + for (i = 0; i < chains.length; i++) { + var f2 = chains[i].flash; + if (f2 && f2.life > 0 && !f2.accepted) { + fitLine(dp, f2.pa, f2.pb, C.hot, 1.2, 0.5 * f2.life * propVis, [5, 4]); + } + } + + // current fit(s) — coral, thicker + for (i = 0; i < chains.length; i++) { + fitLine(dp, chains[i].a, chains[i].b, C.hot, 2.2, 0.95); + } + + // data points — yellow, draggable + for (i = 0; i < pts.length; i++) { + var px = dp.sx(pts[i].x), py = dp.sy(pts[i].y); + var active = (dragIdx === i); + // §A.2: soft grab-halo on the point being dragged (coral, subtle). + if (active) FV.halo(ctx, px, py, 14, C.hot, 0.4); + ctx.save(); + ctx.globalAlpha = active ? 0.35 : 0.22; dotRaw(px, py, active ? 11 : 8, C.data); ctx.restore(); + dot(px, py, active ? 5.5 : 4.5, C.data, 1); + ctx.save(); + ctx.strokeStyle = th.dark ? "rgba(13,17,23,0.9)" : "rgba(255,255,255,0.9)"; + ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.arc(px, py, active ? 5.5 : 4.5, 0, 2 * Math.PI); ctx.stroke(); + ctx.restore(); + } + // NOTE: flash decay is time-based in the loop tick (§A.7), NOT per-frame + // here — a per-frame decrement strobes at low fps and freezes on Step. + } + + // ------------------------------------------------------- pointer / dragging + // Migrated to the shared FV.drag manager (§A.1/§A.2): it claims the gesture + // (setPointerCapture + preventDefault) ONLY when a pointerdown actually lands + // on a point, and inflates the hit radius to >=22 CSS px on coarse pointers. + // The whole canvas is meaningfully interactive (points move in 2-D, so a + // vertical drag must not page-scroll), so we keep fullCapture:true — the + // manager sets touch-action:none on the canvas; the page still has ample + // scroll surface around this 400px hero. + var dragIdx = -1; + function hitTestXY(x, y, slop) { + if (!dataPlot) return -1; + var dp = dataPlot, best = -1; + var r = Math.max(15, slop); // coarse pointers pass slop>=22 + var bestD = r * r; + for (var i = 0; i < pts.length; i++) { + var dx = dp.sx(pts[i].x) - x, dy = dp.sy(pts[i].y) - y; + var d = dx * dx + dy * dy; + if (d < bestD) { bestD = d; best = i; } + } + return best; + } + var dragHandle = FV.drag(cv.el, { + inflate: 15, + hitTest: hitTestXY, + onStart: function (i) { dragIdx = i; requestDraw(); }, + onDrag: function (i, x, y) { + if (i < 0 || !dataPlot) return; + var dp = dataPlot; + var nx = dp.sx.invert(x), ny = dp.sy.invert(y); + if (nx < DX[0]) nx = DX[0]; if (nx > DX[1]) nx = DX[1]; + if (ny < DY[0]) ny = DY[0]; if (ny > DY[1]) ny = DY[1]; + pts[i].x = nx; pts[i].y = ny; + reweightChains(); // posterior moved -> heatDirty + stale lp fixed + requestDraw(); // heatmap rebuilds at HALF res while dragging + }, + onEnd: function () { dragIdx = -1; heatDirty = true; requestDraw(); } + }); + + // schedule a single draw when paused (during play the loop already draws) + var drawQueued = false; + function requestDraw() { + if (loopApi.playing) return; + if (drawQueued) return; + drawQueued = true; + window.requestAnimationFrame(function () { drawQueued = false; draw(); }); + } + + // ------------------------------------------------------------------- loop + // autoplay: start walking the moment the widget scrolls into view (the loop + // honors reduced-motion internally — no animation there, just the pre-warmed + // static frame below). + var FLASH_DUR = 0.35; // proposal-flash lifetime in seconds (time-based, §A.7) + var loopApi = FV.loop(root, function (dt) { + if (dt === 0) { doStep(); } // Step button (or reduced-motion) + else { + acc += dt * params.speed; + var n = Math.floor(acc); + if (n > 0) { acc -= n; if (n > 60) n = 60; for (var i = 0; i < n; i++) doStep(); } + // Decay proposal flashes by wall-clock time, not frame count, so they + // last the same ~0.35s whether rAF runs at 60fps or drops to 30. + var dl = dt / FLASH_DUR; + for (var ci = 0; ci < chains.length; ci++) if (chains[ci].flash) chains[ci].flash.life -= dl; + } + refreshDiag(nowMs()); + draw(); + }, { autoplay: true }); + + function nowMs() { return (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now(); } + + function togglePlay() { + if (loopApi.playing) { loopApi.pause(); btns.fvButtons["Play"].textContent = "Play"; } + else { loopApi.play(); if (loopApi.playing) btns.fvButtons["Play"].textContent = "Pause"; } + } + + if (loopApi.reduced) { + btns.fvButtons["Play"].textContent = "Play"; + btns.fvButtons["Play"].disabled = true; + btns.fvButtons["Play"].title = "Reduced motion is on — use Step"; + btns.fvButtons["Step"].classList.add("fv-primary"); + } + + FV.onThemeChange(function () { heatDirty = true; draw(); }); + + // first paint: pre-warm ~40 burn-in steps so the posterior spaghetti and the + // param-space trails already exist at first paint (and so reduced-motion, which + // never animates, still shows a rich converged frame — never an empty axis). + makeData(); + newChains(); + for (var pw = 0; pw < 40; pw++) doStep(); + refreshDiag(nowMs()); + renderReadouts(); + draw(); + // The loop autoplays itself (see FV.loop {autoplay:true}); reflect that on the + // button. play() already ran and is a no-op under reduced motion. + if (loopApi.playing) btns.fvButtons["Play"].textContent = "Pause"; + }); +})(); diff --git a/docs/viz/minis.js b/docs/viz/minis.js new file mode 100644 index 0000000..462d97e --- /dev/null +++ b/docs/viz/minis.js @@ -0,0 +1,867 @@ +// docs/viz/minis.js — page-support mini-figures (ambient, "clear & smooth"). +// +// Eight small, param-driven figures woven into the six explorable hero pages' +// explanation sections, embeddable via +//
+// Same conventions as inline.js: an ambient autoplay loop that advances discrete +// STATE at a few Hz with tweened rendering between states; one unobtrusive +// pause/play glyph (top-right); optional one-line caption from data-caption; +// seeded via data-seed (default 11); all math via FugueViz; theme-aware; the +// color algebra everywhere (data = yellow, prior = blue, posterior = green, +// current = coral, structure = violet). +// +// SMOOTHNESS (v4 §A): these are AMBIENT figures. They must never eat page +// scroll. Only `sigma-sweep` has a draggable control (a coral σ marker), and it +// claims the gesture ONLY when a pointerdown actually lands on the marker +// (coarse-pointer hit radius >= 22px) via setPointerCapture; its canvas uses +// touch-action:pan-y so a vertical swipe always scrolls the page. Every other +// mini attaches no pointer handlers at all, leaving the canvas fully scrollable. +// +// The math is real: real random-walk Metropolis + autocorrelation/ESS, real +// leapfrog (velocity Verlet) energy accounting, real Beta conjugate updates, +// real systematic-resample-style ESS. Known-value checks live in the agent +// report (verified with docs/viz/minis.verify.js against fugue-viz.js). +// +// Self-contained IIFE; assumes fugue-viz.js has loaded first (book.toml order). +// The mount scaffold is COPIED from inline.js's pattern (not imported), then +// hardened for touch per §A. +(function () { + "use strict"; + if (typeof window === "undefined" || !window.FugueViz) return; + var FV = window.FugueViz; + + // ========================================================================== + // Small generic helpers + // ========================================================================== + + function clamp(v, a, b) { return v < a ? a : v > b ? b : v; } + function lerp(a, b, t) { return a + (b - a) * t; } + function nextSeed(s) { return (Math.imul(s, 1664525) + 1013904223) >>> 0; } + function fmtNum(v) { + if (v == null || !isFinite(v)) return "—"; + var a = Math.abs(v); + if (a >= 100) return v.toFixed(0); + if (a >= 10) return v.toFixed(1); + return v.toFixed(2); + } + + // ---- canvas drawing primitives (all take resolved theme colors) ----------- + + function baseline(g, x0, x1, y, c) { + g.save(); g.strokeStyle = c.ink; g.globalAlpha = 0.22; g.lineWidth = 1; + g.beginPath(); g.moveTo(x0, y); g.lineTo(x1, y); g.stroke(); g.restore(); + } + function label(g, txt, x, y, c) { + g.save(); g.fillStyle = c.ink; g.globalAlpha = 0.7; + g.font = "11px var(--mono-font, monospace)"; g.textBaseline = "top"; g.textAlign = "left"; + g.fillText(txt, x, y); g.restore(); + } + function labelRight(g, txt, x, y, c, role) { + g.save(); g.fillStyle = role ? c[role] : c.ink; g.globalAlpha = role ? 0.95 : 0.7; + g.font = "11px var(--mono-font, monospace)"; g.textBaseline = "top"; g.textAlign = "right"; + g.fillText(txt, x, y); g.restore(); + } + function fillUnder(g, pts, y0, col, alpha) { + if (!pts.length) return; + g.save(); g.globalAlpha = alpha; g.fillStyle = col; g.beginPath(); + var started = false, i; + for (i = 0; i < pts.length; i++) { + if (!isFinite(pts[i][1])) continue; + if (!started) { g.moveTo(pts[i][0], y0); g.lineTo(pts[i][0], pts[i][1]); started = true; } + else g.lineTo(pts[i][0], pts[i][1]); + } + if (started) { g.lineTo(pts[pts.length - 1][0], y0); g.closePath(); g.fill(); } + g.restore(); + } + function densCurve(dom, f, xs, ys, n) { + var pts = [], i, x; + for (i = 0; i <= n; i++) { x = dom[0] + (dom[1] - dom[0]) * i / n; pts.push([xs(x), ys(f(x))]); } + return pts; + } + function roundRect(g, x, y, w, h, r) { + g.beginPath(); + g.moveTo(x + r, y); + g.arcTo(x + w, y, x + w, y + h, r); + g.arcTo(x + w, y + h, x, y + h, r); + g.arcTo(x, y + h, x, y, r); + g.arcTo(x, y, x + w, y, r); + g.closePath(); + } + + // ========================================================================== + // Shared statistical math (real formulas; verified in the agent report) + // ========================================================================== + + // Random-walk Metropolis targeting the standard normal N(0,1). Returns the + // chain and the empirical acceptance rate. Deterministic given `rng`. + function mhChain(rng, sigma, n, x0) { + var x = (x0 == null) ? 0 : x0, out = [x], accepts = 0, i; + for (i = 1; i < n; i++) { + var prop = x + sigma * FV.randn(rng); + // log target ratio: -0.5(prop^2 - x^2) + if (Math.log(rng()) < -0.5 * (prop * prop - x * x)) { x = prop; accepts++; } + out.push(x); + } + return { chain: out, accept: accepts / (n - 1) }; + } + + // Sample autocorrelation rho_k, k = 0..K (biased/1-over-n estimator, the one + // used for ESS). rho_0 = 1 by construction. + function autocorr(x, K) { + var n = x.length, mean = 0, i, k; + for (i = 0; i < n; i++) mean += x[i]; + mean /= n; + var c0 = 0; + for (i = 0; i < n; i++) { var d = x[i] - mean; c0 += d * d; } + c0 /= n; + var rho = []; + for (k = 0; k <= K; k++) { + var ck = 0; + for (i = 0; i < n - k; i++) ck += (x[i] - mean) * (x[i + k] - mean); + ck /= n; + rho[k] = c0 > 0 ? ck / c0 : (k === 0 ? 1 : 0); + } + return rho; + } + + // Effective sample size via the initial-positive-sequence truncation of the + // autocorrelation sum: ESS = N / (1 + 2 * sum_{k>=1, rho_k>0} rho_k). + function essFromChain(x) { + var K = Math.min(x.length - 1, 120); + var rho = autocorr(x, K), s = 0, k; + for (k = 1; k <= K; k++) { if (rho[k] <= 0) break; s += rho[k]; } + var tau = 1 + 2 * s; + return x.length / (tau > 1e-9 ? tau : 1e-9); + } + + // One full leapfrog / velocity-Verlet step for Hamiltonian H = U(q) + p^2/2. + function vstep(q, p, eps, dU) { + var ph = p - 0.5 * eps * dU(q); + var qn = q + eps * ph; + var pn = ph - 0.5 * eps * dU(qn); + return [qn, pn]; + } + // |ΔH| after L leapfrog steps of size eps from (q0,p0) on Hamiltonian (U,dU). + function leapfrogDeltaH(q0, p0, eps, L, U, dU) { + var q = q0, p = p0, i, H0 = U(q0) + 0.5 * p0 * p0; + for (i = 0; i < L; i++) { var r = vstep(q, p, eps, dU); q = r[0]; p = r[1]; } + var H1 = U(q) + 0.5 * p * p; + return Math.abs(H1 - H0); + } + + // Normalized effective sample fraction of a positive weight vector: + // ESS/N = (sum w)^2 / (N * sum w^2). Uniform -> 1; degenerate -> 1/N. + function essFraction(w) { + var n = w.length, s1 = 0, s2 = 0, i; + for (i = 0; i < n; i++) { s1 += w[i]; s2 += w[i] * w[i]; } + if (s2 <= 0) return 0; + return (s1 * s1) / (n * s2); + } + + // ========================================================================== + // The mount scaffold — one ambient loop, glyph, caption, reduced-motion frame. + // COPIED from inline.js's pattern; pointer handling hardened for touch (§A): + // hit-gated pointer capture, pan-y touch-action, coarse hit radius. + // ========================================================================== + + function mount(root, spec) { + var seed = parseInt(root.getAttribute("data-seed"), 10); + if (!(seed >= 0)) seed = 11; + + // A resize resets the canvas backing store. The ambient loop repaints every + // frame so the clear is invisible while playing; a paused / reduced-motion + // widget renders once, so we repaint on resize to avoid a blank canvas. + var ready = false; + var cv = FV.canvas(root, { height: spec.height || 150, onResize: function () { if (ready) renderFrame(); } }); + var g = cv.ctx; + + var capText = root.getAttribute("data-caption"); + if (capText) { + var cap = document.createElement("div"); + cap.className = "fv-caption"; + cap.textContent = capText; + root.appendChild(cap); + } + + var S = { seed: seed, reloopSeed: seed, rng: FV.rng(seed) }; + spec.build(S, FV); + + var hz = spec.hz || 4, interval = 1 / hz, acc = 0, T = 1; + function colors() { return FV.theme().colors; } + function renderFrame() { + cv.clear(); + try { spec.render(g, S, cv.w, cv.h, T, colors(), FV); } catch (e) { /* keep the page quiet */ } + } + + var loopApi = FV.loop(root, function (dt) { + if (dt > 0.1) dt = 0.1; + acc += dt; + while (acc >= interval) { acc -= interval; spec.advance(S, FV); } + T = acc / interval; if (T > 1) T = 1; + renderFrame(); + }); + + // pause/play glyph + var glyph = document.createElement("button"); + glyph.type = "button"; + glyph.className = "fv-glyph"; + glyph.setAttribute("aria-label", "Pause or play this animation"); + function glyphUpdate() { + glyph.textContent = loopApi.playing ? "‖" : "▶"; // ‖ / ▶ + glyph.title = loopApi.playing ? "Pause" : "Play"; + } + glyph.addEventListener("click", function () { + if (loopApi.playing) loopApi.pause(); else loopApi.play(); + glyphUpdate(); renderFrame(); + }); + root.appendChild(glyph); + + // Pointer interaction — ONLY when the widget declares spec.pointer. Ambient + // minis omit it entirely and never touch the canvas's default scroll. + if (spec.pointer) { + var coarse = false; + try { coarse = !!(window.matchMedia && window.matchMedia("(pointer: coarse)").matches); } catch (e) { coarse = false; } + // pan-y: a vertical swipe always scrolls the page; horizontal motion is + // ours to interpret (the σ marker rides a horizontal axis). + cv.el.style.touchAction = "pan-y"; + var dragging = false; + function localXY(e) { var r = cv.el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; } + cv.el.addEventListener("pointerdown", function (e) { + var p = localXY(e); + // Claim the gesture only if the pointerdown actually grabs something. + var hit = spec.hitTest ? spec.hitTest(S, p[0], p[1], cv.w, cv.h, coarse) : true; + if (!hit) return; // let the page scroll / do nothing + dragging = true; + try { cv.el.setPointerCapture(e.pointerId); } catch (_) {} + spec.pointer(S, p[0], p[1], cv.w, cv.h, "down", FV); + renderFrame(); + if (e.cancelable) e.preventDefault(); + }); + cv.el.addEventListener("pointermove", function (e) { + if (!dragging) return; + var p = localXY(e); + spec.pointer(S, p[0], p[1], cv.w, cv.h, "move", FV); + renderFrame(); + if (e.cancelable) e.preventDefault(); + }); + function endDrag(e) { + if (!dragging) return; + dragging = false; + try { cv.el.releasePointerCapture(e.pointerId); } catch (_) {} + var p = localXY(e); + spec.pointer(S, p[0], p[1], cv.w, cv.h, "up", FV); + renderFrame(); + } + cv.el.addEventListener("pointerup", endDrag); + cv.el.addEventListener("pointercancel", endDrag); + cv.el.style.cursor = "ew-resize"; + } + + FV.onThemeChange(function () { renderFrame(); }); + + ready = true; // future resizes may now repaint the current frame + if (loopApi.reduced) { + // reduced motion: render a fully-formed static frame, never an empty axis. + if (spec.staticFrame) spec.staticFrame(S, FV); + else { var n = spec.settleN || 30; for (var i = 0; i < n; i++) spec.advance(S, FV); } + T = 1; renderFrame(); + glyph.style.display = "none"; + } else { + renderFrame(); + loopApi.play(); + glyphUpdate(); + } + } + + // ========================================================================== + // 1. acf-decay — one MH trace (top) + its autocorrelation bars ρ_k (bottom). + // data-sigma "0.05" | "0.4" | "3": small σ → slow ACF decay; good → fast. + // ========================================================================== + + function acfDecay(root) { + var sigma = parseFloat(root.getAttribute("data-sigma")); + if (!(sigma > 0)) sigma = 0.4; + var N = 130, K = 16, BATCH = 3; + + mount(root, { + height: 150, hz: 6, + staticFrame: function (S) { resetC(S); while (S.chain.length < N) grow(S); recompute(S); }, + build: function (S) { resetC(S); }, + advance: function (S) { + if (S.chain.length < N) { for (var b = 0; b < BATCH; b++) if (S.chain.length < N) grow(S); recompute(S); } + else { S.hold = (S.hold || 0) + 1; if (S.hold > 14) { S.reloopSeed = nextSeed(S.reloopSeed); S.rng = FV.rng(S.reloopSeed); resetC(S); } } + }, + render: function (g, S, w, h, T, c) { drawAcf(g, S, w, h, c); } + }); + + function resetC(S) { S.chain = [0]; S.hold = 0; S.rho = [1]; } + function grow(S) { + var x = S.chain[S.chain.length - 1]; + var prop = x + sigma * FV.randn(S.rng); + if (Math.log(S.rng()) < -0.5 * (prop * prop - x * x)) x = prop; + S.chain.push(x); + } + function recompute(S) { S.rho = autocorr(S.chain, Math.min(K, S.chain.length - 1)); } + + function drawAcf(g, S, w, h, c) { + var pad = { l: 10, r: 10, t: 14, b: 8 }; + var splitY = h * 0.5; + // ---- top: the trace ---- + var xs = FV.scale([0, N], [pad.l, w - pad.r]); + var ys = FV.scale([-3.4, 3.4], [splitY - 6, pad.t]); + baseline(g, pad.l, w - pad.r, ys(0), c); + var pts = [], i, ch = S.chain; + for (i = 0; i < ch.length; i++) pts.push([xs(i), ys(clamp(ch[i], -3.4, 3.4))]); + FV.curve(g, pts, { color: c.post, width: 1.2 }); + if (ch.length) { + g.save(); g.fillStyle = c.hot; + g.beginPath(); g.arc(xs(ch.length - 1), ys(clamp(ch[ch.length - 1], -3.4, 3.4)), 2.6, 0, 6.2832); g.fill(); g.restore(); + } + label(g, "σ " + fmtNum(sigma) + " trace", pad.l, 1, c); + // ---- bottom: ACF bars ρ_k (structure = violet) ---- + var bx = FV.scale([0, K], [pad.l + 4, w - pad.r]); + var by = FV.scale([-0.25, 1], [h - pad.b, splitY + 8]); + g.save(); g.strokeStyle = c.ink; g.globalAlpha = 0.22; g.lineWidth = 1; + g.beginPath(); g.moveTo(pad.l, by(0)); g.lineTo(w - pad.r, by(0)); g.stroke(); g.restore(); + var bw = Math.max(3, (bx(1) - bx(0)) * 0.55); + for (i = 0; i <= K && i < S.rho.length; i++) { + var rv = S.rho[i], px = bx(i), py = by(clamp(rv, -0.25, 1)); + g.save(); g.globalAlpha = i === 0 ? 0.35 : 0.85; g.fillStyle = c.flow; + g.fillRect(px - bw / 2, Math.min(py, by(0)), bw, Math.abs(py - by(0))); + g.restore(); + } + labelRight(g, "ρ_k", w - pad.r, splitY + 4, c, "flow"); + } + } + + // ========================================================================== + // 2. sigma-sweep — acceptance (green) & ESS (violet) over a log-σ sweep, the + // Goldilocks band shaded, a draggable coral σ marker. THE gesture mini. + // ========================================================================== + + function sigmaSweep(root) { + var NPTS = 26, LOSIG = 0.02, HISIG = 20, CHAINLEN = 520; + + mount(root, { + height: 175, hz: 4, + staticFrame: function (S) { buildSweep(S); S.reveal = NPTS; S.mi = Math.floor(NPTS * 0.62); }, + build: function (S) { buildSweep(S); S.reveal = 1; S.mi = NPTS * 0.62; S.phase = 0; }, + advance: function (S) { + if (S.reveal < NPTS) S.reveal += 1; + if (!S.touched) { S.phase += 0.05; S.mi = (NPTS - 1) * (0.5 + 0.42 * Math.sin(S.phase)); } + }, + render: function (g, S, w, h, T, c) { drawSweep(g, S, w, h, c); }, + hitTest: function (S, x, y, w, h, coarse) { + var mx = markerX(S, w); + var r = coarse ? 24 : 14; + return Math.abs(x - mx) <= r; + }, + pointer: function (S, x, y, w, h, phase) { + S.touched = true; + S.dragging = (phase !== "up"); + var pad = geom(w); + var frac = clamp((x - pad.l) / (pad.r - pad.l), 0, 1); + S.mi = frac * (NPTS - 1); + } + }); + + function geom(w) { return { l: 30, r: w - 12 }; } + function markerX(S, w) { + var pad = geom(w); + return lerp(pad.l, pad.r, clamp(S.mi, 0, NPTS - 1) / (NPTS - 1)); + } + function buildSweep(S) { + S.sig = []; S.acc = []; S.ess = []; S.touched = false; S.dragging = false; S.hold = 0; + var i, maxE = 1e-9; + for (i = 0; i < NPTS; i++) { + var f = i / (NPTS - 1); + var sg = LOSIG * Math.pow(HISIG / LOSIG, f); // log-spaced + var r = mhChain(S.rng, sg, CHAINLEN, 0); + var e = essFromChain(r.chain) / CHAINLEN; // ESS fraction + S.sig.push(sg); S.acc.push(r.accept); S.ess.push(e); + if (e > maxE) maxE = e; + } + S.maxE = maxE; + // Goldilocks band: contiguous σ where ESS >= 0.7 * peak. + var lo = -1, hi = -1; + for (i = 0; i < NPTS; i++) if (S.ess[i] >= 0.7 * maxE) { if (lo < 0) lo = i; hi = i; } + S.bandLo = lo; S.bandHi = hi; + } + function drawSweep(g, S, w, h, c) { + var pad = geom(w), top = 16, bot = h - 22; + var xs = FV.scale([0, NPTS - 1], [pad.l, pad.r]); + var ya = FV.scale([0, 1], [bot, top]); // acceptance 0..1 + var ye = FV.scale([0, S.maxE * 1.1], [bot, top]); // ESS fraction + // Goldilocks band + if (S.bandLo >= 0) { + g.save(); g.globalAlpha = 0.12; g.fillStyle = c.post; + var xa = xs(Math.max(0, S.bandLo - 0.5)), xb = xs(Math.min(NPTS - 1, S.bandHi + 0.5)); + g.fillRect(xa, top, xb - xa, bot - top); g.restore(); + } + baseline(g, pad.l, pad.r, bot, c); + // curves, revealed left-to-right + var nrev = Math.min(S.reveal, NPTS), i; + var accPts = [], essPts = []; + for (i = 0; i < nrev; i++) { accPts.push([xs(i), ya(S.acc[i])]); essPts.push([xs(i), ye(S.ess[i])]); } + FV.curve(g, accPts, { color: c.post, width: 2 }); + FV.curve(g, essPts, { color: c.flow, width: 2 }); + // σ ticks (log): 0.1, 1, 10 + g.save(); g.font = "10px var(--mono-font, monospace)"; g.fillStyle = c.ink; g.globalAlpha = 0.5; + g.textAlign = "center"; g.textBaseline = "top"; + var ticks = [0.1, 1, 10], t; + for (t = 0; t < ticks.length; t++) { + var fr = Math.log(ticks[t] / LOSIG) / Math.log(HISIG / LOSIG); + if (fr < 0 || fr > 1) continue; + var px = lerp(pad.l, pad.r, fr); + g.fillText("σ=" + ticks[t], px, bot + 5); + } + g.restore(); + // marker + var mi = clamp(S.mi, 0, nrev - 1 < 0 ? 0 : nrev - 1); + var mx = markerX(S, w); + var sgV = S.sig[Math.round(mi)] || S.sig[0]; + var accV = S.acc[Math.round(mi)], essV = S.ess[Math.round(mi)]; + if (S.dragging) { // subtle halo while grabbed + g.save(); g.globalAlpha = 0.16; g.fillStyle = c.hot; + g.beginPath(); g.arc(mx, (top + bot) / 2, 16, 0, 6.2832); g.fill(); g.restore(); + } + g.save(); g.strokeStyle = c.hot; g.globalAlpha = 0.85; g.lineWidth = 1.5; + g.beginPath(); g.moveTo(mx, top); g.lineTo(mx, bot); g.stroke(); + g.fillStyle = c.hot; g.globalAlpha = 1; + g.beginPath(); g.arc(mx, bot, 4, 0, 6.2832); g.fill(); g.restore(); + // readouts + label(g, "accept", pad.l, 1, c); + labelRight(g, "ESS", pad.r, 1, c, "flow"); + g.save(); g.font = "11px var(--mono-font, monospace)"; g.textAlign = "center"; g.textBaseline = "top"; + g.fillStyle = c.hot; + g.fillText("σ " + fmtNum(sgV) + " · acc " + Math.round(accV * 100) + "% · ESS " + Math.round(essV * 100) + "%", (pad.l + pad.r) / 2, top - 14 < 0 ? 0 : 1); + g.restore(); + } + } + + // ========================================================================== + // 3. well-1d — a 1D double-well U(q); a ball driven by momentum kicks (violet + // arrows) + leapfrog crosses the barrier; a random-walk ghost cannot. + // ========================================================================== + + function well1d(root) { + var DOM = [-2.1, 2.1], EPS = 0.09, L = 46, KICK = 1.15, GSIG = 0.16; + function U(q) { var t = q * q - 1; return 0.6 * t * t; } + function dU(q) { return 0.6 * 4 * q * (q * q - 1); } + + mount(root, { + height: 155, hz: 20, + staticFrame: function (S) { resetW(S); for (var i = 0; i < 80; i++) stepW(S); }, + build: function (S) { resetW(S); }, + advance: function (S) { stepW(S); }, + render: function (g, S, w, h, T, c) { drawWell(g, S, w, h, T, c); } + }); + + function kick(S) { + S.p = KICK * FV.randn(S.rng); + S.step = 0; S.epiStartQ = S.q; S.p0 = S.p; S.epiCount = (S.epiCount || 0) + 1; + } + function resetW(S) { + S.q = -1; S.prevQ = -1; S.ghost = -1; S.epiCount = 0; kick(S); + } + function stepW(S) { + S.prevQ = S.q; + var r = vstep(S.q, S.p, EPS, dU); S.q = clamp(r[0], DOM[0], DOM[1]); S.p = r[1]; + S.step++; + if (S.step >= L) { + // random-walk ghost takes one Metropolis step per episode (target ∝ e^-U) + var gp = S.ghost + GSIG * FV.randn(S.rng); + if (gp > DOM[0] && gp < DOM[1] && Math.log(S.rng()) < U(S.ghost) - U(gp)) S.ghost = gp; + if (S.epiCount >= 10) { S.reloopSeed = nextSeed(S.reloopSeed); S.rng = FV.rng(S.reloopSeed); resetW(S); } + else kick(S); + } + } + function drawWell(g, S, w, h, T, c) { + var pad = { l: 12, r: 12, t: 10, b: 12 }; + var xs = FV.scale(DOM, [pad.l, w - pad.r]); + var umax = U(DOM[0]); + var ys = FV.scale([-0.25 * umax, umax * 1.05], [h - pad.b, pad.t]); + // potential curve + FV.curve(g, densCurve(DOM, U, xs, ys, 120), { color: c.ink, width: 1.6 }); + // ghost (random walk, trapped) — faint + var gq = S.ghost; + g.save(); g.globalAlpha = 0.4; g.strokeStyle = c.data; g.fillStyle = c.data; g.lineWidth = 1.4; + g.beginPath(); g.arc(xs(gq), ys(U(gq)) - 5, 3.4, 0, 6.2832); g.stroke(); g.restore(); + // ball (leapfrog) — coral, tween between steps + var q = lerp(S.prevQ, S.q, T); + var bx = xs(q), byv = ys(U(q)) - 5; + // momentum kick arrow (violet), time-based fade over the episode + var age = clamp((S.step + T) / L, 0, 1); + if (age < 0.7) { + var a = 1 - age / 0.7; + var dir = S.p0 >= 0 ? 1 : -1, len = clamp(Math.abs(S.p0) * 16 + 8, 10, 34); + g.save(); g.globalAlpha = 0.35 + 0.5 * a; g.strokeStyle = c.flow; g.fillStyle = c.flow; g.lineWidth = 2; + var ax0 = bx, ax1 = bx + dir * len, ay = byv - 12; + g.beginPath(); g.moveTo(ax0, ay); g.lineTo(ax1, ay); g.stroke(); + g.beginPath(); g.moveTo(ax1, ay); g.lineTo(ax1 - dir * 5, ay - 4); g.lineTo(ax1 - dir * 5, ay + 4); g.closePath(); g.fill(); + g.restore(); + } + g.save(); g.fillStyle = c.hot; + g.beginPath(); g.arc(bx, byv, 4.5, 0, 6.2832); g.fill(); g.restore(); + label(g, "leapfrog + momentum", pad.l, 0, c); + labelRight(g, "random-walk ghost", w - pad.r, 0, c, "data"); + } + } + + // ========================================================================== + // 4. eps-divergence — energy error |ΔH| vs step size ε as a live log-log + // scatter; a coral divergence line. data-L = leapfrog steps. + // ========================================================================== + + function epsDivergence(root) { + var L = parseInt(root.getAttribute("data-L"), 10); + if (!(L > 0)) L = 25; + var EPS_LO = 0.05, EPS_HI = 3.2, DH_LO = 1e-4, DH_HI = 1e6, DIVLINE = 1e3, CAP = 220, BATCH = 5; + function U(q) { return 0.5 * q * q; } + function dU(q) { return q; } + + mount(root, { + height: 175, hz: 5, + staticFrame: function (S) { resetE(S); for (var i = 0; i < 40; i++) addPts(S); }, + build: function (S) { resetE(S); }, + advance: function (S) { + if (S.pts.length < CAP) addPts(S); + else { S.hold = (S.hold || 0) + 1; if (S.hold > 16) { S.reloopSeed = nextSeed(S.reloopSeed); S.rng = FV.rng(S.reloopSeed); resetE(S); } } + }, + render: function (g, S, w, h, T, c) { drawEps(g, S, w, h, c); } + }); + + function resetE(S) { S.pts = []; S.hold = 0; } + function addPts(S) { + for (var b = 0; b < BATCH; b++) { + var f = S.rng(); + var eps = EPS_LO * Math.pow(EPS_HI / EPS_LO, f); + var q0 = FV.randn(S.rng), p0 = FV.randn(S.rng); + var dh = leapfrogDeltaH(q0, p0, eps, L, U, dU); + if (!(dh > 0)) dh = DH_LO; + S.pts.push([eps, dh]); + } + } + function drawEps(g, S, w, h, c) { + var pad = { l: 30, r: 10, t: 16, b: 20 }; + var lx = function (e) { return pad.l + (Math.log(e / EPS_LO) / Math.log(EPS_HI / EPS_LO)) * (w - pad.l - pad.r); }; + var ly = function (d) { var dd = clamp(d, DH_LO, DH_HI); return (h - pad.b) - (Math.log(dd / DH_LO) / Math.log(DH_HI / DH_LO)) * (h - pad.b - pad.t); }; + // divergence threshold line (coral) + g.save(); g.strokeStyle = c.hot; g.globalAlpha = 0.8; g.lineWidth = 1.5; g.setLineDash([5, 4]); + g.beginPath(); g.moveTo(pad.l, ly(DIVLINE)); g.lineTo(w - pad.r, ly(DIVLINE)); g.stroke(); g.restore(); + labelRight(g, "|ΔH|=10³ divergence", w - pad.r, ly(DIVLINE) - 12, c, "hot"); + // ε ticks + g.save(); g.font = "10px var(--mono-font, monospace)"; g.fillStyle = c.ink; g.globalAlpha = 0.5; + g.textAlign = "center"; g.textBaseline = "top"; + var et = [0.1, 0.5, 1, 2], i; + for (i = 0; i < et.length; i++) { if (et[i] < EPS_LO || et[i] > EPS_HI) continue; g.fillText(String(et[i]), lx(et[i]), h - pad.b + 4); } + g.textAlign = "left"; g.fillText("ε", w - pad.r - 8, h - pad.b + 4); + g.restore(); + // scatter — green when stable, coral when diverged + for (i = 0; i < S.pts.length; i++) { + var e = S.pts[i][0], d = S.pts[i][1], div = d >= DIVLINE; + g.save(); g.globalAlpha = 0.7; g.fillStyle = div ? c.hot : c.post; + g.beginPath(); g.arc(lx(e), ly(d), 2.4, 0, 6.2832); g.fill(); g.restore(); + } + label(g, "L=" + L + " leapfrog · |ΔH| vs ε", pad.l, 1, c); + } + } + + // ========================================================================== + // 5. bind-chain — .map (transform in one lane) vs .bind (a new effect node + // appears); a coral value pulse flows through a growing pipeline. + // ========================================================================== + + function bindChain(root) { + var NB = 3; // three .bind links after pure(a) + var OPS = [["+1", function (x) { return x + 1; }], ["×2", function (x) { return x * 2; }], ["−3", function (x) { return x - 3; }]]; + + mount(root, { + height: 170, hz: 3, + staticFrame: function (S) { resetB(S); S.grown = NB; S.pulse = NB + 1; }, + build: function (S) { resetB(S); }, + advance: function (S) { + if (S.grown < NB) { S.grown++; return; } + S.pulse++; + if (S.pulse > NB + 3) { S.a0 = 1 + Math.floor(S.rng() * 4); S.pulse = 0; S.grown = 0; } + }, + render: function (g, S, w, h, T, c) { drawBind(g, S, w, h, T, c); } + }); + + function resetB(S) { S.a0 = 1 + Math.floor(S.rng() * 4); S.grown = 0; S.pulse = 0; } + + function drawBind(g, S, w, h, T, c) { + var pad = 12, ncell = NB + 1; + var cellW = (w - pad * 2) / ncell; + var mapY = h * 0.30, bindY = h * 0.72, boxH = 26, boxW = Math.min(cellW - 14, 58); + // pulse position along the pipeline (0..NB+1), tweened + var pulseF = clamp(S.pulse + T, 0, ncell); + // running value at the pulse + function valAt(k) { var v = S.a0, i; for (i = 0; i < k && i < NB; i++) v = OPS[i][1](v); return v; } + + // ---------- map lane (top): ONE node, value transforms in-flight -------- + label(g, ".map — transform stays in one lane", pad, mapY - boxH / 2 - 14, c); + var mx = pad + cellW * 0.5; + // input wire + g.save(); g.strokeStyle = c.ink; g.globalAlpha = 0.4; g.lineWidth = 1.5; + g.beginPath(); g.moveTo(pad, mapY); g.lineTo(w - pad, mapY); g.stroke(); g.restore(); + // the single node + g.save(); g.strokeStyle = c.prior; g.lineWidth = 2; g.globalAlpha = 0.9; + roundRect(g, mx - boxW / 2, mapY - boxH / 2, boxW, boxH, 6); g.stroke(); g.restore(); + g.save(); g.font = "12px var(--mono-font, monospace)"; g.fillStyle = c.prior; g.textAlign = "center"; g.textBaseline = "middle"; + g.fillText("map f", mx, mapY); g.restore(); + // coral pulse gliding across, value shown + var mpx = lerp(pad, w - pad, clamp(pulseF / ncell, 0, 1)); + var mv = (mpx > mx) ? OPS[0][1](S.a0) : S.a0; + drawPulse(g, mpx, mapY, mv, c); + + // ---------- bind lane (bottom): nodes APPEAR, effects compose ----------- + label(g, ".bind — each bind spawns a new effect node", pad, bindY - boxH / 2 - 14, c); + var visible = 1 + Math.min(S.grown, NB); // pure(a) + grown binds + var k; + // wires + nodes + for (k = 0; k < visible; k++) { + var cx = pad + cellW * (k + 0.5); + if (k > 0) { + var px0 = pad + cellW * (k - 0.5) + boxW / 2, px1 = cx - boxW / 2; + g.save(); g.strokeStyle = c.ink; g.globalAlpha = 0.4; g.lineWidth = 1.5; + g.beginPath(); g.moveTo(px0, bindY); g.lineTo(px1, bindY); g.stroke(); g.restore(); + } + var isPure = k === 0; + var col = isPure ? c.post : c.flow; // structure nodes = violet; pure value = green + g.save(); g.strokeStyle = col; g.lineWidth = 2; g.globalAlpha = 0.9; + roundRect(g, cx - boxW / 2, bindY - boxH / 2, boxW, boxH, 6); g.stroke(); g.restore(); + g.save(); g.font = "11px var(--mono-font, monospace)"; g.fillStyle = col; g.textAlign = "center"; g.textBaseline = "middle"; + g.fillText(isPure ? ("pure " + S.a0) : ("bind " + OPS[k - 1][0]), cx, bindY); g.restore(); + } + // pulse through the bind pipeline (only after fully grown) + if (S.grown >= NB) { + var bf = clamp(pulseF, 0, ncell); + var seg = Math.floor(bf), frac = bf - seg; + var x0 = pad + cellW * (Math.min(seg, NB) + 0.5); + var x1 = pad + cellW * (Math.min(seg + 1, NB) + 0.5); + var bpx = lerp(x0, x1, frac); + drawPulse(g, bpx, bindY, valAt(Math.min(seg + 1, NB)), c); + } + } + function drawPulse(g, x, y, v, c) { + g.save(); + g.globalAlpha = 0.25; g.fillStyle = c.hot; + g.beginPath(); g.arc(x, y, 9, 0, 6.2832); g.fill(); + g.globalAlpha = 1; g.fillStyle = c.hot; + g.beginPath(); g.arc(x, y, 5, 0, 6.2832); g.fill(); + g.font = "10px var(--mono-font, monospace)"; g.fillStyle = c.hot; g.textAlign = "center"; g.textBaseline = "bottom"; + g.fillText(String(v), x, y - 11); + g.restore(); + } + } + + // ========================================================================== + // 6. seq-update — Beta posterior after n = 0,1,2,4,8,16 flips as a small- + // multiples strip sharpening left→right; loops with fresh data. + // ========================================================================== + + function seqUpdate(root) { + var NS = [0, 1, 2, 4, 8, 16]; + + mount(root, { + height: 150, hz: 2.5, + staticFrame: function (S) { resetS(S); S.shown = NS.length; }, + build: function (S) { resetS(S); }, + advance: function (S) { + if (S.shown < NS.length) S.shown++; + else { S.hold = (S.hold || 0) + 1; if (S.hold > 5) { S.reloopSeed = nextSeed(S.reloopSeed); S.rng = FV.rng(S.reloopSeed); resetS(S); } } + }, + render: function (g, S, w, h, T, c) { drawSeq(g, S, w, h, T, c); } + }); + + function resetS(S) { + S.hold = 0; S.shown = 1; S.p = 0.25 + 0.5 * S.rng(); + // one shared stream of 16 flips; panel n uses the first n of them + S.flips = []; var i; + for (i = 0; i < 16; i++) S.flips.push(S.rng() < S.p ? 1 : 0); + } + // posterior params after n flips: Beta(1 + heads, 1 + tails) + function ab(S, n) { var hh = 0, i; for (i = 0; i < n; i++) hh += S.flips[i]; return [1 + hh, 1 + (n - hh)]; } + + function drawSeq(g, S, w, h, T, c) { + var pad = { l: 8, r: 8, t: 20, b: 14 }; + var m = NS.length, cw = (w - pad.l - pad.r) / m; + for (var j = 0; j < m; j++) { + var vis = j < S.shown ? 1 : 0; + if (!vis) continue; + var grow = (j === S.shown - 1) ? T : 1; // newest panel eases in + var x0 = pad.l + cw * j + 3, x1 = pad.l + cw * (j + 1) - 3; + var xs = FV.scale([0, 1], [x0, x1]); + var pr = ab(S, NS[j]), A = pr[0], B = pr[1]; + function post(x) { return Math.exp(FV.dist.beta.logpdf(x, A, B)); } + var ymax = 1e-6, k, xx; + for (k = 1; k < 40; k++) { xx = k / 40; var pv = post(xx); if (isFinite(pv) && pv > ymax) ymax = pv; } + var ys = FV.scale([0, ymax * 1.15], [h - pad.b, pad.t + (1 - grow) * 20]); + // baseline + g.save(); g.strokeStyle = c.ink; g.globalAlpha = 0.18 * grow; g.lineWidth = 1; + g.beginPath(); g.moveTo(x0, ys(0)); g.lineTo(x1, ys(0)); g.stroke(); g.restore(); + var pts = densCurve([0, 1], post, xs, ys, 48); + g.save(); g.globalAlpha = grow; + fillUnder(g, pts, ys(0), c.post, 0.13 * grow); + FV.curve(g, pts, { color: c.post, width: 1.6 }); + g.restore(); + // n label + posterior mean + var mean = A / (A + B); + g.save(); g.globalAlpha = grow; g.font = "10px var(--mono-font, monospace)"; g.fillStyle = c.ink; + g.textAlign = "center"; g.textBaseline = "top"; + g.globalAlpha = 0.6 * grow; g.fillText("n=" + NS[j], (x0 + x1) / 2, pad.t - 16); + g.restore(); + // mean tick (coral) at posterior mean + g.save(); g.globalAlpha = 0.7 * grow; g.strokeStyle = c.hot; g.lineWidth = 1; + g.beginPath(); g.moveTo(xs(mean), ys(0)); g.lineTo(xs(mean), ys(0) + 4); g.stroke(); g.restore(); + } + label(g, "Beta(1+k, 1+n−k) sharpening", pad.l, 2, c); + } + } + + // ========================================================================== + // 7. ess-timeline — SMC ESS/N over time as a live area chart, the 0.5 + // threshold line, resampling events as violet ticks. data-adaptive on|off. + // ========================================================================== + + function essTimeline(root) { + var adaptive = (root.getAttribute("data-adaptive") || "on").toLowerCase() !== "off"; + var NPART = 60, MAXT = 46, THRESH = 0.5; + + mount(root, { + height: 155, hz: 5, + staticFrame: function (S) { resetT(S); while (S.hist.length < MAXT) stepT(S); }, + build: function (S) { resetT(S); }, + advance: function (S) { + if (S.hist.length < MAXT) stepT(S); + else { S.hold = (S.hold || 0) + 1; if (S.hold > 16) { S.reloopSeed = nextSeed(S.reloopSeed); S.rng = FV.rng(S.reloopSeed); resetT(S); } } + }, + render: function (g, S, w, h, T, c) { drawEss(g, S, w, h, c); } + }); + + function resetT(S) { + S.w = []; var i; for (i = 0; i < NPART; i++) S.w.push(1 / NPART); + S.hist = [1]; S.resampleAt = []; S.hold = 0; S.t = 0; + } + function stepT(S) { + // reweight: each particle's incremental likelihood factor (heavy-tailed → + // weights spread → ESS falls). lognormal-ish factor keeps weights positive. + var i, s = 0; + for (i = 0; i < NPART; i++) { var lf = Math.exp(0.9 * FV.randn(S.rng)); S.w[i] *= lf; s += S.w[i]; } + for (i = 0; i < NPART; i++) S.w[i] /= s; // normalize + var frac = essFraction(S.w); + if (adaptive && frac < THRESH) { + for (i = 0; i < NPART; i++) S.w[i] = 1 / NPART; // resample → weights reset + frac = 1; + S.resampleAt.push(S.hist.length); + } + S.hist.push(frac); S.t++; + } + function drawEss(g, S, w, h, c) { + var pad = { l: 26, r: 10, t: 14, b: 16 }; + var xs = FV.scale([0, MAXT], [pad.l, w - pad.r]); + var ys = FV.scale([0, 1], [h - pad.b, pad.t]); + // threshold line + g.save(); g.strokeStyle = c.hot; g.globalAlpha = 0.7; g.lineWidth = 1.2; g.setLineDash([5, 4]); + g.beginPath(); g.moveTo(pad.l, ys(THRESH)); g.lineTo(w - pad.r, ys(THRESH)); g.stroke(); g.restore(); + labelRight(g, "0.5", pad.l - 4, ys(THRESH) - 6, c, "hot"); + // area + line + var pts = [], i, hst = S.hist; + for (i = 0; i < hst.length; i++) pts.push([xs(i), ys(hst[i])]); + fillUnder(g, pts, ys(0), c.post, 0.16); + FV.curve(g, pts, { color: c.post, width: 2 }); + // resample ticks (violet) + for (i = 0; i < S.resampleAt.length; i++) { + var rx = xs(S.resampleAt[i]); + g.save(); g.strokeStyle = c.flow; g.globalAlpha = 0.8; g.lineWidth = 1.5; + g.beginPath(); g.moveTo(rx, pad.t); g.lineTo(rx, h - pad.b); g.stroke(); g.restore(); + } + // y axis label + g.save(); g.font = "10px var(--mono-font, monospace)"; g.fillStyle = c.ink; g.globalAlpha = 0.5; + g.textAlign = "right"; g.textBaseline = "middle"; + g.fillText("1", pad.l - 4, ys(1)); g.fillText("0", pad.l - 4, ys(0)); g.restore(); + label(g, "ESS/N · resample " + (adaptive ? "adaptive" : "off"), pad.l, 1, c); + } + } + + // ========================================================================== + // 8. type-flow — four sampling lanes, each pulsing a sample of its natural + // return type into a typed slot (mono type names). + // ========================================================================== + + function typeFlow(root) { + var LANES = [ + { dist: "Normal", type: "f64", role: "prior", draw: function (r) { return (FV.dist.normal.sample(r, 0, 1)).toFixed(2); } }, + { dist: "Bernoulli", type: "bool", role: "post", draw: function (r) { return FV.dist.bernoulli.sample(r, 0.5) ? "true" : "false"; } }, + { dist: "Poisson", type: "u64", role: "data", draw: function (r) { return String(FV.dist.poisson.sample(r, 3)); } }, + { dist: "Categorical", type: "usize", role: "flow", draw: function (r) { return String(FV.dist.categorical.sample(r, [0.25, 0.25, 0.25, 0.25])); } } + ]; + + mount(root, { + height: 180, hz: 2.4, + staticFrame: function (S) { newSamples(S); S.slotFilled = [1, 1, 1, 1]; }, + build: function (S) { newSamples(S); S.slotFilled = [0, 0, 0, 0]; S.tick = 0; }, + advance: function (S) { + S.tick++; + // each lane fills on its own staggered beat, then all refresh + var k; for (k = 0; k < 4; k++) if (S.tick === k + 1) S.slotFilled[k] = 1; + if (S.tick > 7) { S.reloopSeed = nextSeed(S.reloopSeed); S.rng = FV.rng(S.reloopSeed); newSamples(S); S.slotFilled = [0, 0, 0, 0]; S.tick = 0; } + }, + render: function (g, S, w, h, T, c) { drawFlow(g, S, w, h, T, c); } + }); + + function newSamples(S) { S.vals = []; for (var k = 0; k < 4; k++) S.vals.push(LANES[k].draw(S.rng)); } + + function drawFlow(g, S, w, h, T, c) { + var padL = 12, padR = 12, padT = 10, padB = 10; + var laneH = (h - padT - padB) / 4; + for (var k = 0; k < 4; k++) { + var yc = padT + laneH * (k + 0.5); + var lane = LANES[k], col = c[lane.role]; + var x0 = padL, x1 = w - padR; + var slotW = 62, slotX = x1 - slotW; + // lane rail + g.save(); g.strokeStyle = c.ink; g.globalAlpha = 0.25; g.lineWidth = 1; + g.beginPath(); g.moveTo(x0 + 66, yc); g.lineTo(slotX, yc); g.stroke(); g.restore(); + // source label (dist name) + g.save(); g.font = "12px var(--mono-font, monospace)"; g.fillStyle = col; g.globalAlpha = 0.95; + g.textAlign = "left"; g.textBaseline = "middle"; g.fillText(lane.dist, x0, yc); g.restore(); + // typed slot + g.save(); g.strokeStyle = col; g.globalAlpha = 0.7; g.lineWidth = 1.5; + roundRect(g, slotX, yc - laneH * 0.34, slotW, laneH * 0.68, 5); g.stroke(); g.restore(); + // pulse travelling into the slot (each lane staggered by its beat) + var beat = clamp((S.tick + T - k) / 1.0, 0, 1.4); + if (beat > 0 && beat < 1) { + var px = lerp(x0 + 66, slotX, beat); + g.save(); g.globalAlpha = 0.3; g.fillStyle = c.hot; + g.beginPath(); g.arc(px, yc, 7, 0, 6.2832); g.fill(); + g.globalAlpha = 1; g.beginPath(); g.arc(px, yc, 4, 0, 6.2832); g.fill(); g.restore(); + } + // value in slot + type name (mono) + if (S.slotFilled[k]) { + g.save(); g.font = "12px var(--mono-font, monospace)"; g.textBaseline = "middle"; + g.fillStyle = c.hot; g.globalAlpha = 0.95; g.textAlign = "center"; + g.fillText(S.vals[k], slotX + slotW / 2, yc); g.restore(); + } + // type annotation under the slot + g.save(); g.font = "10px var(--mono-font, monospace)"; g.fillStyle = c.ink; g.globalAlpha = 0.55; + g.textAlign = "right"; g.textBaseline = "top"; + g.fillText(": " + lane.type, slotX + slotW, yc + laneH * 0.34 + 1); g.restore(); + } + } + } + + // ========================================================================== + // Registration + // ========================================================================== + + FV.register("acf-decay", function (root) { acfDecay(root); }); + FV.register("sigma-sweep", function (root) { sigmaSweep(root); }); + FV.register("well-1d", function (root) { well1d(root); }); + FV.register("eps-divergence", function (root) { epsDivergence(root); }); + FV.register("bind-chain", function (root) { bindChain(root); }); + FV.register("seq-update", function (root) { seqUpdate(root); }); + FV.register("ess-timeline", function (root) { essTimeline(root); }); + FV.register("type-flow", function (root) { typeFlow(root); }); +})(); diff --git a/docs/viz/monad.js b/docs/viz/monad.js new file mode 100644 index 0000000..21ab1a4 --- /dev/null +++ b/docs/viz/monad.js @@ -0,0 +1,540 @@ +// docs/viz/monad.js — "The Model Is a Score", rebuilt DATA-FIRST. +// +// HERO: a live distribution over the mean mu — blue prior density, green exact +// conjugate posterior, five large draggable yellow data dots, a coral tick at +// the current run's mu. Drag a dot and the green curve slides in real time. +// +// MACHINERY STRIP (below, compact): the CPS chain as small chips +// (SampleF64 mu -> ObserveF64 y1..y5 -> Pure); Step/Play walk it and each +// Observe chip lights ITS data dot in the hero. "Perform x200" rains prior +// draws (blue cloud, PriorHandler) or stacks them on one mu (coral spike, +// ReplayHandler) — improvising vs replaying, told as distribution mass. +// +// Model: mu ~ Normal(0, 2), y_i ~ Normal(mu, 1) for i = 1..5. +// Self-contained IIFE; consumes window.FugueViz (loaded first). +(function () { + "use strict"; + if (typeof window === "undefined" || !window.FugueViz) return; + + window.FugueViz.register("monad", function (root, FV) { + var MONO = 'ui-monospace, "Source Code Pro", SFMono-Regular, Menlo, monospace'; + var SANS = "system-ui, sans-serif"; + var SUB = ["₁", "₂", "₃", "₄", "₅"]; // y1..y5 + + // ---- model constants ---- + var PRIOR_MU = 0.0, PRIOR_SD = 2.0, OBS_SD = 1.0; + var PRIOR_VAR = PRIOR_SD * PRIOR_SD, OBS_VAR = OBS_SD * OBS_SD; + + // ---- data (draggable) ---- + var DATA0 = [1.3, 0.7, 2.1, 0.4, 1.5]; // seeded default; sum = 6.0 + var data = DATA0.slice(); + var N = data.length; + + // ---- seeds ---- + var baseSeed = parseInt(root.getAttribute("data-seed"), 10); + if (!isFinite(baseSeed)) baseSeed = 11; + baseSeed = baseSeed >>> 0; + var liveSeed = baseSeed; + // The ReplayHandler's fixed recording: the canonical prior draw at the page + // seed. It never changes, whatever the live seed does. + var recordMu = FV.dist.normal.sample(FV.rng(baseSeed), PRIOR_MU, PRIOR_SD); + + // ---- run state ---- + var mode = "prior"; // "prior" | "replay" + var muCurrent = drawMu(); // the current run's sampled mu (coral tick) + + // ---- machinery walk state ---- + // nodes: 0 = SampleF64 mu, 1..5 = ObserveF64 y_i, 6 = Pure(mu) + var NODES = 7, PURE = 6; + var committed = newBoolArray(NODES); + var cursor = 0; // next node to interpret + var walkActive = -1; // node currently highlighted (persists after commit) + var autoPlay = false; + var stepClock = 0; // seconds since last auto-advance + var STEP_GAP = 0.55; + var holdClock = 0; // seconds resting on a finished performance + var HOLD = 1.4; // pause on Pure before the ambient reloop + + // ---- rain (Perform x200) ---- + var rainTicks = null; // array of mu draws, or null + var rainRole = "prior"; + + // ---- posterior (exact conjugate; variance is constant) ---- + var POST_VAR = 1 / (1 / PRIOR_VAR + N / OBS_VAR); + var POST_SD = Math.sqrt(POST_VAR); + var POST_PEAK = 1 / (POST_SD * Math.sqrt(2 * Math.PI)); + function postMean() { + var sy = 0; + for (var i = 0; i < N; i++) sy += data[i]; + return POST_VAR * (PRIOR_MU / PRIOR_VAR + sy / OBS_VAR); + } + + function drawMu() { + return mode === "replay" + ? recordMu + : FV.dist.normal.sample(FV.rng(liveSeed), PRIOR_MU, PRIOR_SD); + } + function newBoolArray(n) { var a = []; for (var i = 0; i < n; i++) a.push(false); return a; } + + // ------------------------------------------------------------------ tallies + function logPrior() { + return committed[0] ? FV.dist.normal.logpdf(muCurrent, PRIOR_MU, PRIOR_SD) : null; + } + function logLike() { + var s = 0, any = false; + for (var k = 1; k <= 5; k++) { + if (committed[k]) { s += FV.dist.normal.logpdf(data[k - 1], muCurrent, OBS_SD); any = true; } + } + return any ? s : null; + } + + // ------------------------------------------------------------------ walking + function applyStep(i) { committed[i] = true; } + function runAllInstant() { + committed = newBoolArray(NODES); + muCurrent = drawMu(); + for (var i = 0; i < NODES; i++) applyStep(i); + cursor = NODES; walkActive = -1; + autoPlay = false; updatePlayLabel(); + updateReadouts(); render(); + } + function resetWalk() { + committed = newBoolArray(NODES); + cursor = 0; walkActive = -1; + autoPlay = false; stepClock = 0; updatePlayLabel(); + muCurrent = drawMu(); + rainTicks = null; // §A.6: a Perform ×200 cloud must not survive Reset + if (loopApi) loopApi.pause(); + updateReadouts(); render(); + } + function stepOnce() { + if (cursor >= NODES) return; + applyStep(cursor); + walkActive = cursor; + cursor++; + updateReadouts(); render(); + } + function reseed(seed) { + liveSeed = seed >>> 0; + rainTicks = null; + runAllInstant(); + } + function setMode(replayOn) { + mode = replayOn ? "replay" : "prior"; + rainTicks = null; + runAllInstant(); + } + function performRain() { + var r = FV.rng((liveSeed ^ 0x9e3779b9) >>> 0); + rainTicks = []; + rainRole = mode === "replay" ? "hot" : "prior"; + for (var i = 0; i < 200; i++) { + rainTicks.push(mode === "replay" ? recordMu : FV.dist.normal.sample(r, PRIOR_MU, PRIOR_SD)); + } + render(); + } + + // ------------------------------------------------------------------ tick + function tick(dt) { + if (autoPlay) { + if (cursor >= NODES) { + // A full performance is on the strip — rest on it, then reloop with a + // fresh improvisation so the widget stays quietly alive on the page. + holdClock += dt; + if (holdClock >= HOLD) { + holdClock = 0; + committed = newBoolArray(NODES); + cursor = 0; walkActive = -1; stepClock = 0; + muCurrent = drawMu(); + updateReadouts(); + } + } else { + stepClock += dt; + while (stepClock >= STEP_GAP && cursor < NODES) { + stepClock -= STEP_GAP; + stepOnce(); + } + } + } + render(); + } + + // ------------------------------------------------------------------ DOM + function elem(tag, cls, parent) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (parent) parent.appendChild(e); + return e; + } + + var controls = elem("div", "fv-controls", root); + var btnRoot = FV.buttons(controls, [ + { label: "Step", title: "Interpret the next node", onClick: onStep }, + { label: "Play", title: "Walk the whole chain", primary: true, onClick: onPlay }, + { label: "Reset", title: "Rewind to the first node", onClick: resetWalk }, + { label: "Perform ×200", title: "Run the handler 200 times", onClick: performRain } + ]); + var playBtn = btnRoot.fvButtons["Play"]; + FV.toggle(controls, { + label: "Replay handler", value: false, + onChange: function (on) { setMode(on); } + }); + + // Hero canvas: the live distribution over mu. + var hero = FV.canvas(root, { height: 400, onResize: function () { render(); } }); + var hctx = hero.ctx; + + var instr = elem("div", "fv-instruction", root); + instr.textContent = "Drag a yellow data point left or right — the green posterior slides to follow it."; + + // Machinery strip: a second, short canvas. + var strip = FV.canvas(root, { height: 116, onResize: function () { render(); } }); + var sctx = strip.ctx; + + var readouts = elem("div", "fv-readouts", root); + var roMu = FV.readout(readouts, { label: "mu (current run)" }); + var roMean = FV.readout(readouts, { label: "posterior mean" }); + var roSd = FV.readout(readouts, { label: "posterior sd" }); + var roTot = FV.readout(readouts, { label: "total log-weight" }); + + var hint = elem("div", "fv-hint", root); + hint.textContent = "try: press Perform ×200 in PriorHandler mode (a blue cloud of guesses), then flip on Replay and press it again (one coral spike)."; + + // Seed scrub lives in the prose (id=monad-seed). Bind it if present. + var seedSpan = document.getElementById("monad-seed"); + if (seedSpan && FV.scrub) { + FV.scrub(seedSpan, { + min: 1, max: 40, step: 1, value: liveSeed, + fmt: function (v) { return String(v); }, + onInput: function (v) { reseed(v >>> 0); } + }); + } + + function updateReadouts() { + roMu.set(muCurrent == null ? "—" : muCurrent.toFixed(3), "hot"); + roMean.set(postMean().toFixed(3), "post"); + roSd.set(POST_SD.toFixed(3), "post"); + var lp = logPrior(), ll = logLike(); + if (lp == null) roTot.set("—", "post"); + else roTot.set((lp + (ll || 0)).toFixed(3), "post"); + } + function updatePlayLabel() { if (playBtn) playBtn.textContent = autoPlay ? "Pause" : "Play"; } + + function onStep() { + autoPlay = false; updatePlayLabel(); + if (cursor >= NODES) resetWalk(); + stepOnce(); + if (loopApi) loopApi.pause(); + } + function onPlay() { + if (autoPlay) { autoPlay = false; updatePlayLabel(); if (loopApi) loopApi.pause(); return; } + if (cursor >= NODES) resetWalk(); + if (loopApi.reduced) { while (cursor < NODES) stepOnce(); return; } + autoPlay = true; stepClock = 0; updatePlayLabel(); + loopApi.play(); + } + + // ---------------------------------------------------------- hero geometry + var heroLayout = null; + function computeHeroLayout() { + var w = hero.w, h = hero.h; + var padL = 30, padR = 16, padT = 16, padB = 34; + var plot = { x: padL, y: padT, w: w - padL - padR, h: h - padT - padB }; + var xs = FV.scale([-4, 6], [plot.x, plot.x + plot.w]); + var ymax = POST_PEAK * 1.14; + var baseY = plot.y + plot.h; + var ys = FV.scale([0, ymax], [baseY, plot.y]); + return { plot: plot, xs: xs, ys: ys, baseY: baseY, dotY: baseY }; + } + + // ---------------------------------------------------------- hero drawing + function pdfCurve(xs, ys, mu, sd) { + var pts = [], x0 = xs.domain[0], x1 = xs.domain[1], steps = 180; + for (var i = 0; i <= steps; i++) { + var xv = x0 + (x1 - x0) * (i / steps); + var d = Math.exp(FV.dist.normal.logpdf(xv, mu, sd)); + pts.push([xs(xv), ys(d)]); + } + return pts; + } + + function renderHero() { + if (!hero) return; + var col = FV.theme().colors, now = Date.now(); + hero.clear(); + var L = heroLayout = computeHeroLayout(); + var xs = L.xs, ys = L.ys, ctx = hctx; + + // baseline + x ticks + axis label + ctx.save(); + ctx.strokeStyle = col.grid; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(L.plot.x, L.baseY); ctx.lineTo(L.plot.x + L.plot.w, L.baseY); ctx.stroke(); + ctx.fillStyle = col.ink; ctx.font = "11px " + MONO; + ctx.textAlign = "center"; ctx.textBaseline = "top"; + for (var t = -4; t <= 6; t += 2) { + var px = xs(t); + ctx.globalAlpha = 0.5; + ctx.beginPath(); ctx.moveTo(px, L.baseY); ctx.lineTo(px, L.baseY + 4); ctx.stroke(); + ctx.globalAlpha = 0.75; + ctx.fillText(String(t), px, L.baseY + 6); + } + ctx.globalAlpha = 0.6; ctx.textAlign = "right"; + ctx.fillText("μ / y", L.plot.x + L.plot.w, L.baseY + 6); + ctx.restore(); + + // rain (Perform x200): a mini-histogram under the curves + if (rainTicks) { + FV.histogram(ctx, rainTicks, { + bins: 48, xscale: xs, yscale: ys, + color: rainRole === "hot" ? col.hot : col.prior, alpha: 0.28 + }); + } + + // prior (blue) — broad and low + FV.curve(ctx, pdfCurve(xs, ys, PRIOR_MU, PRIOR_SD), { color: col.prior, width: 2 }); + // posterior (green) — the answer, exact conjugate + var pm = postMean(); + var postPts = pdfCurve(xs, ys, pm, POST_SD); + // translucent fill under posterior + ctx.save(); + ctx.beginPath(); + ctx.moveTo(postPts[0][0], L.baseY); + for (var i = 0; i < postPts.length; i++) ctx.lineTo(postPts[i][0], postPts[i][1]); + ctx.lineTo(postPts[postPts.length - 1][0], L.baseY); + ctx.closePath(); + ctx.globalAlpha = 0.12; ctx.fillStyle = col.post; ctx.fill(); + ctx.restore(); + FV.curve(ctx, postPts, { color: col.post, width: 2.4 }); + + // legend + legend(ctx, L, col); + + // current mu coral tick (from baseline up to posterior height at mu) + if (muCurrent != null) { + var mx = xs(muCurrent); + var mdens = Math.exp(FV.dist.normal.logpdf(muCurrent, pm, POST_SD)); + var topPix = ys(Math.min(mdens, ys.domain[1])); + ctx.save(); + ctx.strokeStyle = col.hot; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(mx, L.baseY); ctx.lineTo(mx, topPix - 4); ctx.stroke(); + // marker triangle at top + ctx.fillStyle = col.hot; + ctx.beginPath(); + ctx.moveTo(mx, topPix - 4); ctx.lineTo(mx - 4, topPix - 11); ctx.lineTo(mx + 4, topPix - 11); + ctx.closePath(); ctx.fill(); + ctx.font = "600 10px " + MONO; ctx.textAlign = "center"; ctx.textBaseline = "bottom"; + ctx.fillText("μ", mx, topPix - 12); + ctx.restore(); + } + + // data dots (yellow), draggable; pulse when their Observe chip is active + for (var d = 0; d < N; d++) { + var dx = xs(data[d]), dy = L.dotY; + var activeObs = (walkActive === d + 1); + var grabbing = dragHandle && dragHandle.grabbed && dragHandle.target === d; + var pulse = (activeObs && !loopApi.reduced) ? 0.5 + 0.5 * Math.sin(now / 200) : 0; + var rad = 7 + (activeObs ? 2 : 0) + (grabbing ? 2 : 0); + if (grabbing) FV.halo(ctx, dx, dy, rad + 8); // §A.2 grab halo + ctx.save(); + if (activeObs) { + ctx.globalAlpha = 0.25 + 0.35 * pulse; + ctx.beginPath(); ctx.arc(dx, dy, rad + 6, 0, 6.2832); + ctx.fillStyle = col.hot; ctx.fill(); + ctx.globalAlpha = 1; + } + ctx.beginPath(); ctx.arc(dx, dy, rad, 0, 6.2832); + ctx.fillStyle = col.data; ctx.fill(); + ctx.lineWidth = 1.5; ctx.strokeStyle = activeObs ? col.hot : col.data; ctx.stroke(); + ctx.restore(); + } + } + + function legend(ctx, L, col) { + var items = [["prior μ~Normal(0,2)", col.prior], ["posterior", col.post], ["data y", col.data]]; + ctx.save(); + ctx.font = "11px " + SANS; ctx.textBaseline = "middle"; ctx.textAlign = "left"; + var lx = L.plot.x + 8, ly = L.plot.y + 10; + for (var i = 0; i < items.length; i++) { + ctx.fillStyle = items[i][1]; + ctx.beginPath(); ctx.arc(lx + 4, ly, 4, 0, 6.2832); ctx.fill(); + ctx.fillStyle = col.ink; ctx.globalAlpha = 0.85; + ctx.fillText(items[i][0], lx + 13, ly + 0.5); + ctx.globalAlpha = 1; + ly += 16; + } + ctx.restore(); + } + + // ---------------------------------------------------------- strip drawing + var chipRects = []; + function renderStrip() { + if (!strip) return; + var col = FV.theme().colors, now = Date.now(); + strip.clear(); + var ctx = sctx, w = strip.w, h = strip.h; + var narrow = w < 520; + var padX = 8; + var chipY = 26, chipH = 40; + var gap = narrow ? 7 : 13; + var inner = w - 2 * padX; + var chipW = (inner - gap * (NODES - 1)) / NODES; + var fs = narrow ? 9 : 10.5; + + // title + ctx.fillStyle = col.ink; ctx.globalAlpha = 0.6; + ctx.font = "10px " + SANS; ctx.textAlign = "left"; ctx.textBaseline = "top"; + ctx.fillText("MACHINERY — the CPS chain fugue steps through", padX, 6); + ctx.globalAlpha = 1; + + chipRects = []; + for (var i = 0; i < NODES; i++) { + var x = padX + i * (chipW + gap); + var q = { x: x, y: chipY, w: chipW, h: chipH }; + chipRects.push(q); + drawChip(ctx, i, q, col, fs, now); + if (i < NODES - 1) drawArrow(ctx, x + chipW, chipY + chipH / 2, gap, col, committed[i]); + } + + // tallies row + var ty = chipY + chipH + 20; + var lp = logPrior(), ll = logLike(); + var tot = lp == null ? null : lp + (ll || 0); + ctx.font = "600 " + (narrow ? 10 : 11.5) + "px " + MONO; + ctx.textBaseline = "middle"; ctx.textAlign = "left"; + var segs = [ + ["log_prior ", lp, col.prior], + ["log_lik ", ll, col.data], + ["total ", tot, col.post] + ]; + var cx = padX; + for (var s = 0; s < segs.length; s++) { + var lab = segs[s][0], val = segs[s][1], c = segs[s][2]; + var txt = lab + (val == null ? "—" : val.toFixed(2)); + ctx.fillStyle = c; ctx.globalAlpha = val == null ? 0.5 : 1; + ctx.fillText(txt, cx, ty); + cx += ctx.measureText(txt).width + (narrow ? 14 : 26); + ctx.globalAlpha = 1; + } + } + + function chipRole(i) { return i === 0 ? "prior" : i === PURE ? "post" : "data"; } + function chipLines(i) { + if (i === 0) return ["SampleF64", "μ"]; + if (i === PURE) return ["Pure", "μ"]; + return ["ObserveF64", "y" + SUB[i - 1]]; + } + function roleColor(col, role) { + return role === "prior" ? col.prior : role === "data" ? col.data : + role === "post" ? col.post : col.hot; + } + function drawChip(ctx, i, q, col, fs, now) { + var role = chipRole(i), lines = chipLines(i); + var isActive = (i === walkActive) || (i === cursor && cursor < NODES && !autoPlay && walkActive === -1); + var pulse = (isActive && !loopApi.reduced) ? 0.5 + 0.5 * Math.sin(now / 200) : 1; + var alpha = committed[i] ? 1 : (i === cursor ? 0.9 : 0.4); + + ctx.save(); + ctx.globalAlpha = alpha; + rr(ctx, q.x, q.y, q.w, q.h, 6); + ctx.fillStyle = col.panel; ctx.fill(); + var border = committed[i] ? roleColor(col, role) : col.grid; + if (isActive) border = col.hot; + ctx.lineWidth = isActive ? 2 : 1; + ctx.strokeStyle = border; ctx.stroke(); + if (isActive && !loopApi.reduced) { + ctx.globalAlpha = alpha * 0.5 * pulse; + rr(ctx, q.x - 2, q.y - 2, q.w + 4, q.h + 4, 8); + ctx.strokeStyle = col.hot; ctx.lineWidth = 1.5; ctx.stroke(); + ctx.globalAlpha = alpha; + } + var cx = q.x + q.w / 2; + ctx.textAlign = "center"; + ctx.fillStyle = col.ink; ctx.globalAlpha = alpha * 0.8; + ctx.font = (fs - 1.5) + "px " + MONO; ctx.textBaseline = "alphabetic"; + ctx.fillText(lines[0], cx, q.y + q.h / 2 - 1); + ctx.globalAlpha = alpha; + ctx.fillStyle = roleColor(col, role); + ctx.font = "600 " + (fs + 1) + "px " + MONO; + ctx.fillText(lines[1], cx, q.y + q.h / 2 + 13); + ctx.restore(); + } + function drawArrow(ctx, x, cy, gap, col, on) { + ctx.save(); + ctx.globalAlpha = on ? 0.85 : 0.4; + ctx.strokeStyle = col.flow; ctx.lineWidth = 1.3; + var x1 = x + 2, x2 = x + gap - 2; + ctx.beginPath(); ctx.moveTo(x1, cy); ctx.lineTo(x2, cy); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(x2 - 3, cy - 3); ctx.lineTo(x2, cy); ctx.lineTo(x2 - 3, cy + 3); ctx.stroke(); + ctx.restore(); + } + + function rr(ctx, x, y, w, h, r) { + if (w <= 0 || h <= 0) { ctx.beginPath(); return; } + r = Math.max(0, Math.min(r, w / 2, h / 2)); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + } + + function render() { + if (!hero || !strip || !loopApi) return; + renderHero(); + renderStrip(); + } + + // ---------------------------------------------------------- interactions + // Drag a data dot in the hero, via the shared pointer manager. fullCapture is + // false: the hero is a tall (≈400px) chart whose bulk is non-interactive, so + // the canvas stays touch-action:pan-y — a thumb swiping the chart body scrolls + // the page, and only a pointerdown that actually lands on a dot (generous + // coarse-pointer slop ≥22px, §A.2) is claimed so the drag never scrolls. + var dragHandle = FV.drag(hero.el, { + inflate: 14, + fullCapture: false, + hitTest: function (x, y, slop) { + if (!heroLayout) return -1; + var xs = heroLayout.xs, dy = heroLayout.dotY; + var r = Math.max(14, slop), best = -1, bestD = r * r; + for (var d = 0; d < N; d++) { + var dx = xs(data[d]); + var dd = (x - dx) * (x - dx) + (y - dy) * (y - dy); + if (dd <= bestD) { bestD = dd; best = d; } + } + return best; // -1 = miss (page scrolls, rain untouched) + }, + onStart: function () { rainTicks = null; }, + onDrag: function (idx, x) { + if (!heroLayout) return; + var v = heroLayout.xs.invert(x); + v = Math.max(-3.5, Math.min(5.5, v)); + data[idx] = v; + updateReadouts(); render(); + } + }); + + // Tap (not swipe) the strip to Step. A plain `click` fires only on a tap and + // is suppressed by the browser after a scroll, so a thumb swiping over the + // strip scrolls the page instead of stepping (§A.1 — no preventDefault here). + strip.el.addEventListener("click", function () { onStep(); }); + + // ---------------------------------------------------------------- boot + var loopApi = FV.loop(root, tick); + FV.onThemeChange(function () { render(); }); + + // Pre-warm: start fully interpreted — one full performance already on the strip + // (filled tallies, coral tick present). This is also the reduced-motion frame. + runAllInstant(); + // autoplay: gently reloop the walk when motion is allowed. runAllInstant left + // the chain at Pure, so the first thing the loop does is hold on the completed + // performance, then walk a fresh one — never a dead canvas. + if (!loopApi.reduced) { + autoPlay = true; holdClock = 0; stepClock = 0; + updatePlayLabel(); + loopApi.play(); + } + }); +})(); diff --git a/docs/viz/smc.js b/docs/viz/smc.js new file mode 100644 index 0000000..506da4c --- /dev/null +++ b/docs/viz/smc.js @@ -0,0 +1,734 @@ +// docs/viz/smc.js — "Particles That Tell Stories" +// A 1-D bootstrap particle filter (Sequential Monte Carlo) you can watch +// breathe: propagate -> weight -> resample, time flowing left to right. +// Self-contained IIFE; assumes fugue-viz.js (window.FugueViz) loaded first. +(function () { + "use strict"; + if (!window.FugueViz) return; + + window.FugueViz.register("smc", function (root, FV) { + // ---- fixed model constants -------------------------------------------- + var T = 22; // number of time steps (observations) + var SIG_STEP = 0.7; // latent random-walk step std (x_t | x_{t-1}) + var SIG_GEN = 0.6; // TRUE generating observation noise + var PRIOR_SIG = 1.6; // spread of the x_0 prior particle cloud + + // ---- mutable widget state --------------------------------------------- + var st = { + N: 80, // particle count + sigObs: 0.6, // the FILTER's assumed observation noise (slider) + adaptive: true, // adaptive resampling (ESS/N < 0.5) vs never resample + seed: parseInt(root.getAttribute("data-seed") || "11", 10) >>> 0, + }; + + var colors = FV.theme().colors; + + // ---- DOM shell --------------------------------------------------------- + var controls = document.createElement("div"); + controls.className = "fv-controls"; + root.appendChild(controls); + + var canvasHost = document.createElement("div"); + root.appendChild(canvasHost); + + var readouts = document.createElement("div"); + readouts.className = "fv-readouts"; + root.appendChild(readouts); + + var hint = document.createElement("div"); + hint.className = "fv-hint"; + hint.appendChild( + document.createTextNode( + "drop the particle count to 10 and watch the cloud collapse onto a single lineage." + ) + ); + root.appendChild(hint); + + // ---- canvas ------------------------------------------------------------ + var cv = FV.canvas(canvasHost, { + height: 340, + onResize: function () { + draw(); + }, + }); + + // ---- data + particle bookkeeping -------------------------------------- + var truth = []; // true latent path + var ys = []; // observations + var yDomain = [-4, 4]; + var rand = null; // the single deterministic rng stream (a replayable trace) + + // particle population at rest (belongs to time `curT`) + var s = []; // states + var w = []; // normalized weights (sum = 1) + var curT = -1; // last observed time index (-1 = prior, nothing seen) + var est = []; // filtered mean estimate per observed time + var sd = []; // filtered std (sqrt weighted variance) per observed time + var logEv = 0; // cumulative log-evidence log p(y_1:t) + var lastEss = 1; // ESS/N of the most recent step (for the readout band) + + // in-flight step animation + var anim = { active: false, time: 0, phases: [], durs: [], stp: null }; + var mode = "idle"; // 'idle' | 'playing' | 'stepping' + + var DUR = { propagate: 0.5, weight: 0.6, resample: 0.7 }; + + // ----------------------------------------------------------------------- + function genData() { + truth = []; + ys = []; + var x = FV.randn(rand) * 1.2; + for (var t = 0; t < T; t++) { + if (t > 0) x += FV.randn(rand) * SIG_STEP; + truth.push(x); + ys.push(x + FV.randn(rand) * SIG_GEN); + } + // fixed y-domain from the whole series (so the frame doesn't jump) + var lo = Infinity, + hi = -Infinity, + i; + for (i = 0; i < T; i++) { + lo = Math.min(lo, truth[i], ys[i]); + hi = Math.max(hi, truth[i], ys[i]); + } + var pad = 0.18 * (hi - lo) + 1.2; + yDomain = [lo - pad, hi + pad]; + } + + function reset() { + rand = FV.rng(st.seed); + genData(); // consumes a fixed number of draws (independent of N) + s = []; + w = []; + var uni = 1 / st.N; + for (var i = 0; i < st.N; i++) { + s.push(FV.randn(rand) * PRIOR_SIG); // x_0 prior cloud + w.push(uni); + } + curT = -1; + est = []; + sd = []; + logEv = 0; + lastEss = 1; + anim.active = false; + anim.time = 0; + mode = "idle"; + setPlayLabel(false); + updateReadouts(); + draw(); + } + + // radius so that AREA is proportional to weight; uniform weight -> r0. + function radius(wi) { + var r0 = Math.max(2.2, Math.min(9, 55 / Math.sqrt(st.N))); + var r = r0 * Math.sqrt(Math.max(wi * st.N, 0.02)); + return Math.min(r, r0 * 3.2); + } + function uniformR() { + return radius(1 / st.N); + } + + // systematic resampling -> parent indices (mirrors fugue's default method) + function systematic(weights) { + var n = weights.length, + idx = new Array(n), + u = rand() / n, + cw = 0, + i = 0, + j; + for (j = 0; j < n; j++) { + var thr = u + j / n; + while (cw < thr && i < n) { + cw += weights[i]; + i++; + } + idx[j] = Math.max(0, Math.min(i - 1, n - 1)); + } + return idx; + } + + // ----------------------------------------------------------------------- + // Compute one full time-step of the filter (pure-ish; commits on finalize). + function beginStep() { + var toT = curT + 1; + if (toT >= T) return false; + var n = st.N, + i; + var hasProp = curT >= 0; + + // 1. PROPAGATE: x_t ~ N(x_{t-1}, SIG_STEP) (first step: x_0 is the prior) + var newS = new Array(n); + for (i = 0; i < n; i++) { + newS[i] = hasProp ? s[i] + FV.randn(rand) * SIG_STEP : s[i]; + } + + // 2. WEIGHT by the likelihood of the new observation + var logw = new Array(n); + for (i = 0; i < n; i++) { + logw[i] = + Math.log(w[i]) + FV.dist.normal.logpdf(ys[toT], newS[i], st.sigObs); + } + var logZ = FV.logsumexp(logw); // log Σ W_i · p(y_t | x_i) + var newW = new Array(n), + sumsq = 0, + mean = 0; + for (i = 0; i < n; i++) { + newW[i] = Math.exp(logw[i] - logZ); + sumsq += newW[i] * newW[i]; + mean += newW[i] * newS[i]; + } + var ess = 1 / sumsq; // effective sample size + var essN = ess / n; + // weighted variance of the filtering distribution at this step + var vari = 0; + for (i = 0; i < n; i++) { + var dm = newS[i] - mean; + vari += newW[i] * dm * dm; + } + + // 3. RESAMPLE (adaptive: only when ESS/N drops below 0.5) + var doResample = st.adaptive && essN < 0.5; + var parents = null, + postS = newS, + postW = newW, + jitter = null, + chosen = null; + if (doResample) { + parents = systematic(newW); + chosen = {}; + for (i = 0; i < n; i++) chosen[parents[i]] = (chosen[parents[i]] || 0) + 1; + // deterministic small vertical fan so duplicated children separate + jitter = new Array(n); + var seen = {}; + var span = 0.28 * (yDomain[1] - yDomain[0]) * 0.06; + for (i = 0; i < n; i++) { + var p = parents[i]; + var k = seen[p] || 0; + var cnt = chosen[p]; + jitter[i] = cnt > 1 ? (k - (cnt - 1) / 2) * span : 0; + seen[p] = k + 1; + } + postS = new Array(n); + for (i = 0; i < n; i++) postS[i] = newS[parents[i]]; + postW = new Array(n); + for (i = 0; i < n; i++) postW[i] = 1 / n; + } + + anim.stp = { + fromT: curT, + toT: toT, + hasProp: hasProp, + prevS: s.slice(), + newS: newS, + newW: newW, + doResample: doResample, + parents: parents, + chosen: chosen, + jitter: jitter, + postS: postS, + postW: postW, + essN: essN, + logZ: logZ, + estMean: mean, + estVar: vari, + }; + anim.phases = []; + anim.durs = []; + if (hasProp) { + anim.phases.push("propagate"); + anim.durs.push(DUR.propagate); + } + anim.phases.push("weight"); + anim.durs.push(DUR.weight); + if (doResample) { + anim.phases.push("resample"); + anim.durs.push(DUR.resample); + } + anim.time = 0; + anim.active = true; + return true; + } + + function finalizeStep() { + var stp = anim.stp; + if (!stp) return; + s = stp.postS.slice(); + w = stp.postW.slice(); + curT = stp.toT; + est[stp.toT] = stp.estMean; + sd[stp.toT] = Math.sqrt(Math.max(stp.estVar, 0)); + logEv += stp.logZ; + lastEss = stp.essN; + anim.active = false; + anim.stp = null; + updateReadouts(); + } + + // Which phase are we in, and 0..1 progress within it? + function phaseState() { + var acc = 0; + for (var i = 0; i < anim.phases.length; i++) { + if (anim.time < acc + anim.durs[i] || i === anim.phases.length - 1) { + var p = (anim.time - acc) / anim.durs[i]; + return { name: anim.phases[i], p: Math.max(0, Math.min(1, p)) }; + } + acc += anim.durs[i]; + } + return { name: anim.phases[anim.phases.length - 1], p: 1 }; + } + + function totalDur() { + var s2 = 0; + for (var i = 0; i < anim.durs.length; i++) s2 += anim.durs[i]; + return s2; + } + + function ease(t) { + return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + } + + // ----------------------------------------------------------------------- + function tick(dt) { + if (!anim.active) { + if (mode === "playing") { + if (curT >= T - 1) { + pause(); + return; + } + beginStep(); + } else { + return; + } + } + anim.time += dt; + if (anim.time >= totalDur()) { + finalizeStep(); + if (mode === "stepping") { + mode = "idle"; + loopApi.pause(); + } + } + draw(); + } + + // ---- rendering --------------------------------------------------------- + function layout() { + var padL = 44, + padR = 14, + padT = 14, + padB = 30; + return { + x: padL, + y: padT, + w: Math.max(10, cv.w - padL - padR), + h: Math.max(10, cv.h - padT - padB), + }; + } + + function circle(ctx, x, y, r, fill, alpha) { + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.arc(x, y, r, 0, 6.28318530718); + ctx.fillStyle = fill; + ctx.fill(); + ctx.globalAlpha = 1; + } + + function draw() { + if (!cv) return; // canvas() fires onResize before cv is assigned + var ctx = cv.ctx; + cv.clear(); + colors = FV.theme().colors; + var L = layout(); + var colX = FV.scale([0, T - 1], [L.x, L.x + L.w]); + var stateY = FV.scale(yDomain, [L.y + L.h, L.y]); + + FV.axes(ctx, { + x: L.x, + y: L.y, + w: L.w, + h: L.h, + xscale: colX, + yscale: stateY, + xlabel: "time t", + ylabel: "state x", + theme: FV.theme(), + }); + + // true latent path (ink, dashed) — the ground truth the filter chases + var tp = []; + for (var t = 0; t < T; t++) tp.push([colX(t), stateY(truth[t])]); + FV.curve(ctx, tp, { color: colors.ink, width: 1, dash: [4, 4] }); + + // how far time has been revealed (mid-weight the new obs fades in) + var ps = anim.active ? phaseState() : null; + var revealT = curT; + var obsAlphaNew = 1; + if (anim.active) { + var toT = anim.stp.toT; + if (ps.name === "weight") { + revealT = toT; + obsAlphaNew = ease(ps.p); + } else if (ps.name === "resample") { + revealT = toT; + } else { + revealT = anim.stp.fromT; // propagate: obs not yet shown + } + } + + // observations up to the revealed time (yellow data dots) + for (t = 0; t <= revealT && t < T; t++) { + var a = t === (anim.active ? anim.stp.toT : -99) ? obsAlphaNew : 1; + circle(ctx, colX(t), stateY(ys[t]), 3.2, colors.data, 0.9 * a); + } + + // filtered mean ±1σ band over time — the answer the swarm computes, + // drawn faint and behind the mean line + var top = [], + bot = []; + for (t = 0; t < est.length; t++) { + if (est[t] !== undefined && sd[t] !== undefined) { + top.push([colX(t), stateY(est[t] + sd[t])]); + bot.push([colX(t), stateY(est[t] - sd[t])]); + } + } + if (anim.active && ps.name !== "propagate" && anim.stp.estVar != null) { + var sdNow = Math.sqrt(Math.max(anim.stp.estVar, 0)); + top.push([colX(anim.stp.toT), stateY(anim.stp.estMean + sdNow)]); + bot.push([colX(anim.stp.toT), stateY(anim.stp.estMean - sdNow)]); + } + if (top.length > 1) { + ctx.save(); + ctx.globalAlpha = 0.13; + ctx.fillStyle = colors.post; + ctx.beginPath(); + ctx.moveTo(top[0][0], top[0][1]); + for (var i = 1; i < top.length; i++) ctx.lineTo(top[i][0], top[i][1]); + for (i = bot.length - 1; i >= 0; i--) ctx.lineTo(bot[i][0], bot[i][1]); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + + // posterior-mean estimate (green) through completed steps + var ep = []; + for (t = 0; t < est.length; t++) { + if (est[t] !== undefined) ep.push([colX(t), stateY(est[t])]); + } + if (anim.active && ps.name !== "propagate") + ep.push([colX(anim.stp.toT), stateY(anim.stp.estMean)]); + if (ep.length > 1) FV.curve(ctx, ep, { color: colors.post, width: 2 }); + + // particle cloud + if (!anim.active) { + drawRest(ctx, colX, stateY); + } else { + drawAnim(ctx, colX, stateY, ps); + } + + // filtering distribution — a live weighted-particle KDE violin at the + // current column, translucent green over the cloud that produced it + if (!anim.active) { + if (curT >= 0) drawRibbon(ctx, colX, stateY, curT, s, w, 0.22); + } else if (ps.name === "weight") { + drawRibbon(ctx, colX, stateY, anim.stp.toT, anim.stp.newS, anim.stp.newW, 0.22 * obsAlphaNew); + } else if (ps.name === "resample") { + drawRibbon(ctx, colX, stateY, anim.stp.toT, anim.stp.newS, anim.stp.newW, 0.22); + } else if (anim.stp.fromT >= 0) { + drawRibbon(ctx, colX, stateY, anim.stp.fromT, anim.stp.prevS, w, 0.15); + } + } + + // Weighted-particle kernel density (Gaussian kernel, Silverman-ish + // bandwidth) drawn as a symmetric violin centered on time column `tIdx`. + function drawRibbon(ctx, colX, stateY, tIdx, states, weights, alpha) { + var n = states.length; + if (n === 0) return; + var i, + sw = 0, + m = 0; + for (i = 0; i < n; i++) { + sw += weights[i]; + m += weights[i] * states[i]; + } + if (!(sw > 0)) return; + m /= sw; + var v = 0; + for (i = 0; i < n; i++) { + var d = states[i] - m; + v += weights[i] * d * d; + } + v /= sw; + var span = yDomain[1] - yDomain[0]; + var h = 1.06 * Math.sqrt(Math.max(v, 0)) * Math.pow(n, -0.2); + if (!(h > 0.02 * span)) h = 0.02 * span; // bandwidth floor + var M = 64, + dens = new Array(M), + maxD = 0, + k, + y; + for (k = 0; k < M; k++) { + y = yDomain[0] + (span * k) / (M - 1); + var ds = 0; + for (i = 0; i < n; i++) { + var z = (y - states[i]) / h; + ds += weights[i] * Math.exp(-0.5 * z * z); + } + dens[k] = ds; + if (ds > maxD) maxD = ds; + } + if (!(maxD > 0)) return; + var cx = colX(tIdx); + var maxHalf = Math.min(38, 0.44 * (colX(1) - colX(0))); + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = colors.post; + ctx.beginPath(); + for (k = 0; k < M; k++) { + var yp = stateY(yDomain[0] + (span * k) / (M - 1)); + var half = (dens[k] / maxD) * maxHalf; + if (k === 0) ctx.moveTo(cx + half, yp); + else ctx.lineTo(cx + half, yp); + } + for (k = M - 1; k >= 0; k--) { + var yp2 = stateY(yDomain[0] + (span * k) / (M - 1)); + var half2 = (dens[k] / maxD) * maxHalf; + ctx.lineTo(cx - half2, yp2); + } + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + + function drawRest(ctx, colX, stateY) { + var cx = colX(curT < 0 ? 0 : curT); + for (var i = 0; i < st.N; i++) { + circle(ctx, cx, stateY(s[i]), radius(w[i]), colors.prior, 0.6); + } + } + + function drawAnim(ctx, colX, stateY, ps) { + var stp = anim.stp, + n = st.N, + i; + var fromX = colX(stp.fromT < 0 ? 0 : stp.fromT); + var toX = colX(stp.toT); + var uR = uniformR(); + + if (ps.name === "propagate") { + var e = ease(ps.p); + for (i = 0; i < n; i++) { + var x0 = fromX, + y0 = stateY(stp.prevS[i]); + var x1 = toX, + y1 = stateY(stp.newS[i]); + var cx = x0 + (x1 - x0) * e, + cy = y0 + (y1 - y0) * e; + // faint drift line: the proposal being drawn + ctx.globalAlpha = 0.18; + ctx.strokeStyle = colors.prior; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(cx, cy); + ctx.stroke(); + ctx.globalAlpha = 1; + circle(ctx, cx, cy, uR, colors.prior, 0.6); + } + } else if (ps.name === "weight") { + var e2 = ease(ps.p); + var oy = stateY(ys[stp.toT]); + for (i = 0; i < n; i++) { + var py = stateY(stp.newS[i]); + var r = uR + (radius(stp.newW[i]) - uR) * e2; + // thin yellow tie to the observation, brighter for better fits + var lik = Math.min(1, stp.newW[i] * n); + ctx.globalAlpha = 0.12 + 0.25 * lik * e2; + ctx.strokeStyle = colors.data; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(toX, py); + ctx.lineTo(toX, oy); + ctx.stroke(); + ctx.globalAlpha = 1; + circle(ctx, toX, py, r, colors.prior, 0.62); + } + } else { + // resample: lineage lines converge; extinct fade coral, survivors fan + var e3 = ease(ps.p); + // dying particles (never chosen) flash coral and fade + for (i = 0; i < n; i++) { + if (!stp.chosen[i]) { + circle( + ctx, + toX, + stateY(stp.newS[i]), + radius(stp.newW[i]), + colors.hot, + 0.5 * (1 - e3) + ); + } + } + // survivors + duplicates emerge from their parent with a violet thread + for (i = 0; i < n; i++) { + var parent = stp.parents[i]; + var sy = stateY(stp.newS[parent]); + var ty = stateY(stp.postS[i]) + stp.jitter[i]; + var yy = sy + (ty - sy) * e3; + var rr = radius(stp.newW[parent]) + (uR - radius(stp.newW[parent])) * e3; + ctx.globalAlpha = 0.3 * (1 - e3); + ctx.strokeStyle = colors.flow; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(toX, sy); + ctx.lineTo(toX, yy); + ctx.stroke(); + ctx.globalAlpha = 1; + circle(ctx, toX, yy, rr, colors.prior, 0.6); + } + } + } + + // ---- readouts ---------------------------------------------------------- + var essRead = FV.readout(readouts, { label: "ESS / N" }); + var evRead = FV.readout(readouts, { label: "log-evidence" }); + var stepRead = FV.readout(readouts, { label: "step" }); + + function updateReadouts() { + essRead.set(lastEss.toFixed(2), lastEss > 0.5 ? "post" : "hot"); + evRead.set(curT < 0 ? "—" : logEv.toFixed(2), "flow"); + stepRead.set((curT + 1) + " / " + T); + } + + // ---- controls ---------------------------------------------------------- + FV.slider(controls, { + label: "PARTICLES", + min: 10, + max: 500, + step: 10, + value: st.N, + fmt: function (v) { + return String(v | 0); + }, + onInput: function (v) { + st.N = v | 0; + reset(); + }, + }); + + FV.slider(controls, { + label: "OBS NOISE", + min: 0.2, + max: 3, + step: 0.05, + value: st.sigObs, + fmt: function (v) { + return v.toFixed(2); + }, + onInput: function (v) { + st.sigObs = v; + reset(); + }, + }); + + FV.toggle(controls, { + label: "ADAPTIVE RESAMPLE", + value: st.adaptive, + onChange: function (v) { + st.adaptive = v; + reset(); + }, + }); + + var btns = FV.buttons(controls, [ + { label: "Play", primary: true, title: "Advance the filter", onClick: onPlay }, + { label: "Step", title: "One observation", onClick: onStep }, + { label: "Reset", title: "Replay from the seed", onClick: reset }, + ]); + var playBtn = btns.fvButtons["Play"]; + + function setPlayLabel(playing) { + playBtn.textContent = playing ? "Pause" : "Play"; + } + + function onPlay() { + if (loopApi.reduced) return; // reduced motion: use Step + if (mode === "playing") { + pause(); + return; + } + if (curT >= T - 1 && !anim.active) reset(); + mode = "playing"; + setPlayLabel(true); + loopApi.play(); + } + + function pause() { + mode = "idle"; + setPlayLabel(false); + loopApi.pause(); + } + + function onStep() { + if (mode === "playing") { + pause(); + return; + } + if (anim.active) return; + if (curT >= T - 1) return; + if (loopApi.reduced) { + // instant: no tween + if (beginStep()) finalizeStep(); + draw(); + return; + } + mode = "stepping"; + beginStep(); + loopApi.play(); + } + + // seed scrub (a seeded run is a replayable trace) + var seedRow = document.createElement("div"); + seedRow.className = "fv-readouts"; + var seedLbl = document.createElement("span"); + seedLbl.className = "fv-readout-label"; + seedLbl.textContent = "SEED"; + var seedSpan = document.createElement("span"); + seedSpan.className = "fv-scrub"; + seedRow.appendChild(seedLbl); + seedRow.appendChild(seedSpan); + readouts.appendChild(seedRow); + FV.scrub(seedSpan, { + min: 1, + max: 9999, + step: 1, + value: st.seed, + fmt: function (v) { + return String(v | 0); + }, + onInput: function (v) { + st.seed = v >>> 0; + reset(); + }, + }); + + // ---- loop + theme ------------------------------------------------------ + var loopApi = FV.loop(root, tick); + if (loopApi.reduced) playBtn.style.display = "none"; + + FV.onThemeChange(function () { + colors = FV.theme().colors; + draw(); + }); + + reset(); + // Pre-warm: run 3 filter steps with no tween so the swarm is already partway + // through its story at first paint — and so the reduced-motion frame is rich + // (three columns of particles + a filtered mean), never an empty axis. + for (var pw = 0; pw < 3; pw++) { if (beginStep()) finalizeStep(); } + draw(); + // autoplay the filter (onPlay is a no-op under reduced motion, which keeps the + // pre-warmed frame and leaves stepping to the Step button). + onPlay(); + }); +})();