Skip to content

Commit df6c078

Browse files
alexnodelandclaude
andcommitted
docs: rhat-spark rewrite — real split-R̂ on trailing window, chains visibly converge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018yo1jdkckG7yQ9eBqLbaDB
1 parent 07179fa commit df6c078

1 file changed

Lines changed: 94 additions & 22 deletions

File tree

docs/viz/inline.js

Lines changed: 94 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -572,12 +572,40 @@
572572
}
573573

574574
// ==========================================================================
575-
// 4. rhat-spark — three chains, a live split-R̂ falling to 1.00 (or stuck).
575+
// 4. rhat-spark — three over-dispersed chains COLLAPSE onto a shared band and
576+
// the live split-R̂ falls past 1.1 (coral→green) as they merge — or, in
577+
// "bad" mode, stay trapped in three separate modes with R̂ pinned high.
578+
//
579+
// The convergence STORY has to be visible: chains start well separated with
580+
// a tight wiggle (three distinct threads), then relax toward one shared
581+
// stationary Normal(0,1) — mean pulled to 0 while the wiggle grows to full
582+
// scale — merging within roughly the first third of the window, then mixing
583+
// indistinguishably. That relaxation-to-a-shared-target is exactly what an
584+
// AR(1)/Langevin move toward N(0,1) does in expectation (mean decays
585+
// geometrically, variance settles), so we drive the mean-collapse and the
586+
// mixing wiggle with separate rates: a clean fall, a clean green flip.
587+
//
588+
// The readout R̂ is real split-R̂ (same rhatCore as everywhere) computed on
589+
// the recent (post-warmup) draws — a trailing window — so it falls to ~1.00
590+
// and turns green when the chains actually merge, instead of dragging the
591+
// over-dispersed warmup along forever. A thin R̂-over-time sparkline under
592+
// the traces re-tells the same fall, crossing the dashed 1.1 threshold.
576593
// ==========================================================================
577594

578595
function rhatSpark(root) {
579596
var mode = (root.getAttribute("data-mode") || "good").toLowerCase();
580-
var NCH = 3, MAXN = 140, PHI = 0.82;
597+
var GOOD = mode !== "bad";
598+
var NCH = 3, MAXN = 140;
599+
var MIXN = Math.round(MAXN / 3); // over-dispersed chains merge by ~here
600+
var NPHI = 0.2; // mixing autocorrelation (low → clean R̂)
601+
var SD0 = 0.65, SD1 = 1.0; // wiggle grows tight→full as chains merge
602+
var DWIN = 38; // trailing window for the live (post-warmup) R̂
603+
var BPHI = 0.82; // bad-mode within-mode autocorrelation
604+
605+
// smoothstep-eased schedules for the "good" collapse.
606+
function ease(u) { return u * u * (3 - 2 * u); }
607+
function collapse(k) { return k >= MIXN ? 0 : 1 - ease(k / MIXN); } // 1→0 mean scale
608+
function sdScale(k) { return k >= MIXN ? SD1 : SD0 + (SD1 - SD0) * ease(k / MIXN); }
581609

582610
mount(root, {
583611
height: 150, hz: 6,
@@ -588,40 +616,84 @@
588616
});
589617

590618
function resetR(S) {
591-
S.k = 0; S.hold = 0; S.ch = [[], [], []]; S.cur = [];
592-
// good: one shared target, dispersed starts -> chains mix, R̂ falls to ~1.
593-
// bad: chains trapped in three separate modes -> split-R̂ stuck ~1.4.
594-
var starts = mode === "good" ? [-3.2, 0.1, 3.0] : [-1.7, 0.1, 1.7];
595-
S.centers = mode === "good" ? [0, 0, 0] : [-1.0, 0.0, 1.0];
596-
for (var i = 0; i < NCH; i++) S.cur[i] = starts[i];
619+
S.k = 0; S.hold = 0; S.ch = [[], [], []]; S.rhat = 1; S.rhist = [];
620+
S.eta = []; S.cur = [];
621+
// good: dispersed starts, one shared Normal(0,1) target → chains mix.
622+
// bad: chains trapped in three separate modes → split-R̂ stuck ≫ 1.1.
623+
S.starts = GOOD ? [-2.2, 0.0, 2.2] : [-1.7, 0.1, 1.7];
624+
S.centers = GOOD ? [0, 0, 0] : [-1.5, 0.0, 1.5];
625+
for (var i = 0; i < NCH; i++) { S.eta[i] = FV.randn(S.rng); S.cur[i] = S.starts[i]; }
597626
}
598627
function stepR(S) {
599-
var sd = 1.0;
600-
for (var i = 0; i < NCH; i++) {
601-
var noise = FV.randn(S.rng) * Math.sqrt(1 - PHI * PHI);
602-
S.cur[i] = S.centers[i] + PHI * (S.cur[i] - S.centers[i]) + sd * noise;
603-
S.ch[i].push(S.cur[i]);
628+
var k = S.k, i;
629+
if (GOOD) {
630+
// x = (collapsing separated mean) + (growing stationary N(0,1) wiggle).
631+
for (i = 0; i < NCH; i++) {
632+
S.eta[i] = NPHI * S.eta[i] + Math.sqrt(1 - NPHI * NPHI) * FV.randn(S.rng);
633+
S.ch[i].push(S.starts[i] * collapse(k) + sdScale(k) * S.eta[i]);
634+
}
635+
} else {
636+
// AR(1) trapped in its own mode: never leaves, so chains never agree.
637+
for (i = 0; i < NCH; i++) {
638+
S.cur[i] = S.centers[i] + BPHI * (S.cur[i] - S.centers[i]) + FV.randn(S.rng) * Math.sqrt(1 - BPHI * BPHI);
639+
S.ch[i].push(S.cur[i]);
640+
}
604641
}
605642
S.k++;
643+
// live split-R̂ over the most recent DWIN draws (drop the warmup).
644+
var lo = Math.max(0, S.ch[0].length - DWIN);
645+
S.rhat = splitRhat([S.ch[0].slice(lo), S.ch[1].slice(lo), S.ch[2].slice(lo)]);
646+
S.rhist.push(S.rhat);
606647
}
607648
function drawR(g, S, w, h, c) {
608-
var pad = { l: 8, r: 8, t: 16, b: 8 };
649+
var pad = { l: 8, r: 8, t: 16 };
650+
var sparkH = 18, sparkGap = 6, sparkBot = h - 6, sparkTop = sparkBot - sparkH;
651+
var plotTop = pad.t, plotBot = sparkTop - sparkGap;
609652
var xs = FV.scale([0, MAXN], [pad.l, w - pad.r]);
610-
var ys = FV.scale([-5, 5], [h - pad.b, pad.t]);
653+
var ys = FV.scale([-5, 5], [plotBot, plotTop]);
611654
baseline(g, pad.l, w - pad.r, ys(0), c);
612655
var roleFor = ["prior", "post", "flow"];
613-
for (var i = 0; i < 3; i++) {
656+
for (var i = 0; i < NCH; i++) {
614657
var pts = [], v = S.ch[i], k;
615658
for (k = 0; k < v.length; k++) pts.push([xs(k), ys(clamp(v[k], -5, 5))]);
616659
FV.curve(g, pts, { color: c[roleFor[i]], width: 1.2 });
617660
}
618-
var rh = splitRhat(S.ch);
619-
var good = isFinite(rh) && rh < 1.1;
620-
label(g, "3 chains", pad.l, pad.t - 6, c);
621-
g.save(); g.font = "13px var(--mono-font, monospace)"; g.textAlign = "right"; g.textBaseline = "top";
622-
g.fillStyle = good ? c.post : c.hot;
623-
g.fillText("R̂ " + (isFinite(rh) ? rh.toFixed(2) : "—"), w - pad.r, pad.t - 8);
661+
var rh = S.rhat;
662+
var conv = isFinite(rh) && rh <= 1.1;
663+
// R̂ readout, top-right, drawn INSIDE the canvas and cleared of the pause
664+
// glyph (a ~20–30px DOM button top-right): reserve glyphClear px so the
665+
// number never hides under it at narrow (phone) widths.
666+
g.save();
667+
g.font = "13px var(--mono-font, monospace)"; g.textAlign = "right"; g.textBaseline = "top";
668+
var rtxt = "R̂ " + (isFinite(rh) ? rh.toFixed(2) : "—");
669+
var glyphClear = coarsePointer() ? 34 : 24;
670+
var rRight = w - pad.r - glyphClear;
671+
var rtxtW = g.measureText(rtxt).width;
672+
g.fillStyle = conv ? c.post : c.hot;
673+
g.fillText(rtxt, rRight, 3);
624674
g.restore();
675+
// "3 chains" label — only if it clears the readout at this width.
676+
g.save(); g.font = "11px var(--mono-font, monospace)";
677+
var labW = g.measureText("3 chains").width; g.restore();
678+
if (pad.l + labW + 10 < rRight - rtxtW) label(g, "3 chains", pad.l, plotTop - 8, c);
679+
drawSpark(g, S, w, sparkTop, sparkBot, c, conv);
680+
}
681+
// Thin R̂-over-time sparkline: fall past the dashed 1.1 threshold, segments
682+
// recolored coral→green so the crossing reads at a glance.
683+
function drawSpark(g, S, w, top, bot, c, conv) {
684+
var pl = 8, pr = 8, rLo = 1.0, rHi = 1.85;
685+
var sx = FV.scale([0, MAXN], [pl, w - pr]);
686+
var sy = FV.scale([rLo, rHi], [bot, top]);
687+
g.save(); g.strokeStyle = c.ink; g.globalAlpha = 0.18; g.lineWidth = 1; g.setLineDash([3, 3]);
688+
g.beginPath(); g.moveTo(pl, sy(1.1)); g.lineTo(w - pr, sy(1.1)); g.stroke(); g.restore();
689+
var hh = S.rhist; if (hh.length < 2) return;
690+
for (var i = 1; i < hh.length; i++) {
691+
var a = clamp(hh[i - 1], rLo, rHi), b = clamp(hh[i], rLo, rHi);
692+
g.save(); g.strokeStyle = hh[i] > 1.1 ? c.hot : c.post; g.globalAlpha = 0.85; g.lineWidth = 1.4;
693+
g.beginPath(); g.moveTo(sx(i - 1), sy(a)); g.lineTo(sx(i), sy(b)); g.stroke(); g.restore();
694+
}
695+
var last = clamp(hh[hh.length - 1], rLo, rHi);
696+
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();
625697
}
626698
}
627699

0 commit comments

Comments
 (0)