|
572 | 572 | } |
573 | 573 |
|
574 | 574 | // ========================================================================== |
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. |
576 | 593 | // ========================================================================== |
577 | 594 |
|
578 | 595 | function rhatSpark(root) { |
579 | 596 | 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); } |
581 | 609 |
|
582 | 610 | mount(root, { |
583 | 611 | height: 150, hz: 6, |
|
588 | 616 | }); |
589 | 617 |
|
590 | 618 | 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]; } |
597 | 626 | } |
598 | 627 | 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 | + } |
604 | 641 | } |
605 | 642 | 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); |
606 | 647 | } |
607 | 648 | 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; |
609 | 652 | 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]); |
611 | 654 | baseline(g, pad.l, w - pad.r, ys(0), c); |
612 | 655 | var roleFor = ["prior", "post", "flow"]; |
613 | | - for (var i = 0; i < 3; i++) { |
| 656 | + for (var i = 0; i < NCH; i++) { |
614 | 657 | var pts = [], v = S.ch[i], k; |
615 | 658 | for (k = 0; k < v.length; k++) pts.push([xs(k), ys(clamp(v[k], -5, 5))]); |
616 | 659 | FV.curve(g, pts, { color: c[roleFor[i]], width: 1.2 }); |
617 | 660 | } |
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); |
624 | 674 | 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(); |
625 | 697 | } |
626 | 698 | } |
627 | 699 |
|
|
0 commit comments