$sloprun.dev

Monte Carlo Retirement Simulator

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 7 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

Most retirement calculators draw one smooth line, as if the market returned exactly 7% every year. This one rolls 5,000 possible futures for a mixed stock/bond portfolio in numpy — geometric Brownian motion, one-year steps, correlated assets, rebalanced annually — and sets the whole fan of outcomes beside the number that actually matters: the share of those futures that funds every year of a 30-year retirement. It opens on a plan clearing around 85%, so drag annual spending from $40k to $50k and watch that fall about ten points — a 25% raise in spending takes the futures that go broke from roughly 700 to roughly 1,200. Then hit reset and push stocks vs bonds to 100% instead: the median ending balance leaps from about $2.3M to nearly $4M while the success rate barely twitches, because ruin is decided by the order the returns arrive in, not by their average.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/monte-carlo" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Monte Carlo Retirement Simulator — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/monte-carlo

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1138 lines 48.4 KB index.html
demos/monte-carlo/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<link rel="icon" href="data:,">
<title>Monte Carlo Retirement Simulator</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
   Identity: instrument-panel. Machine facts in mono; human words in sans.
   Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }

/* ---------- base ---------- */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
  line-height: 1.5;
  overflow-x: hidden;
  -webkit-text-size-adjust: 100%;
  font-size: 15px;
}
.wrap { max-width: 1180px; margin: 0 auto; padding: 18px 14px 32px; }
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.sr {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }

/* ---------- header ---------- */
.head {
  display: flex; flex-wrap: wrap; gap: 12px 16px;
  align-items: flex-start; justify-content: space-between;
  padding-bottom: 14px; margin-bottom: 14px;
  border-bottom: 1px solid var(--line);
}
.head h1 { font-size: 1.32rem; }
.lede { margin: 5px 0 0; color: var(--muted); max-width: 62ch; font-size: 0.95rem; }
.status {
  display: inline-flex; align-items: center; gap: 8px;
  font-family: var(--font-mono); font-size: 0.74rem; letter-spacing: 0.02em;
  padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px;
  background: var(--surface); color: var(--muted); white-space: nowrap;
  flex-shrink: 0;
}
.status .dot {
  width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none;
}
.status[data-state="load"] .dot { background: var(--accent); animation: pulse 1.1s ease-in-out infinite; }
.status[data-state="ok"]   .dot { background: var(--run); }
.status[data-state="ok"]   { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.status[data-state="warn"] .dot { background: var(--accent); }
.status[data-state="warn"] { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }

/* ---------- layout ---------- */
.grid { display: grid; gap: 14px; grid-template-columns: 320px minmax(0, 1fr); align-items: start; }
.col { display: grid; gap: 14px; min-width: 0; }
@media (max-width: 900px) {
  .grid { grid-template-columns: minmax(0, 1fr); }
  .controls { order: 2; }
  .col { order: 1; }
}
.panel {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 14px;
  min-width: 0;
}
.panel > h2, .chart-head h2 {
  font-family: var(--font-mono); font-size: 0.7rem; text-transform: uppercase;
  letter-spacing: 0.09em; color: var(--muted); font-weight: 600; margin-bottom: 12px;
}

/* ---------- controls ---------- */
.presets { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
button {
  font: inherit; color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 7px;
  padding: 7px 11px; min-height: 34px; cursor: pointer;
  font-family: var(--font-mono); font-size: 0.75rem;
  transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
button:hover { border-color: var(--accent); color: var(--accent); }
button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.ctl { margin-bottom: 15px; }
.ctl:last-child { margin-bottom: 0; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 4px; }
.ctl-top label { font-size: 0.88rem; }
.ctl-val {
  font-family: var(--font-mono); font-variant-numeric: tabular-nums;
  font-size: 0.88rem; font-weight: 600; color: var(--accent); white-space: nowrap;
}
.ctl-hint { font-family: var(--font-mono); font-size: 0.68rem; color: var(--muted); margin-top: 2px; }
input[type=range] {
  -webkit-appearance: none; appearance: none;
  width: 100%; height: 24px; background: transparent; margin: 0; display: block; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; background: var(--line); border-radius: 2px; }
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; width: 16px; height: 16px; margin-top: -6px; border-radius: 50%;
  background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]::-moz-range-track { height: 4px; background: var(--line); border-radius: 2px; }
input[type=range]::-moz-range-thumb {
  width: 16px; height: 16px; border-radius: 50%;
  background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]:focus-visible { outline: none; }
input[type=range]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent); }
input[type=range]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--accent); }
.mix { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 0.68rem; color: var(--muted); }

/* ---------- dial ---------- */
.verdict { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
.dial { position: relative; width: 148px; height: 148px; flex: none; }
.dial svg { width: 100%; height: 100%; display: block; transform: rotate(-90deg); }
.dial .arc-bg { stroke: var(--line); }
.dial .arc-fg { stroke: var(--accent); transition: stroke-dashoffset 0.45s cubic-bezier(.4,0,.2,1), stroke 0.25s ease; }
.dial[data-tone="good"] .arc-fg { stroke: var(--run); }
.dial[data-tone="bad"]  .arc-fg { stroke: var(--danger); }
.dial-txt {
  position: absolute; inset: 0; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 1px; pointer-events: none;
}
.dial-num {
  font-family: var(--font-mono); font-variant-numeric: tabular-nums;
  font-size: 2.15rem; font-weight: 600; line-height: 1; letter-spacing: -0.03em;
}
.dial[data-tone="good"] .dial-num { color: var(--run); }
.dial[data-tone="bad"]  .dial-num { color: var(--danger); }
.dial[data-tone="mid"]  .dial-num { color: var(--accent); }
.dial-sub { font-family: var(--font-mono); font-size: 0.62rem; color: var(--muted); letter-spacing: 0.06em; }
.verdict-txt { min-width: 0; flex: 1 1 210px; }
.verdict-txt h3 { font-size: 1rem; margin-bottom: 4px; }
.verdict-txt p { margin: 0; color: var(--muted); font-size: 0.87rem; }
.verdict-txt .err { font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); margin-top: 7px; display: block; }

/* ---------- stats ---------- */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.stat { background: var(--surface); padding: 11px 12px; min-width: 0; }
.stat dt { font-family: var(--font-mono); font-size: 0.63rem; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); margin-bottom: 3px; }
.stat dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 1.18rem; font-weight: 600; letter-spacing: -0.02em; }
.stat dd small { font-size: 0.62rem; font-weight: 400; color: var(--muted); display: block; letter-spacing: 0.03em; margin-top: 2px; }

/* ---------- chart ---------- */
.chart-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; }
.seg button { border: 0; border-radius: 0; padding: 5px 10px; min-height: 30px; font-size: 0.7rem; }
.seg button + button { border-left: 1px solid var(--line); }
.seg button:focus-visible { outline-offset: -2px; }
.canvas-box { position: relative; width: 100%; }
canvas { display: block; width: 100%; height: 260px; border-radius: 6px; touch-action: pan-y; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 9px 0 0; font-family: var(--font-mono); font-size: 0.68rem; color: var(--muted); }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.swatch { width: 12px; height: 9px; border-radius: 2px; display: inline-block; }
.readout {
  margin-top: 9px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 7px;
  background: var(--bg); font-family: var(--font-mono); font-size: 0.72rem;
  font-variant-numeric: tabular-nums; color: var(--ink);
  display: flex; flex-wrap: wrap; gap: 3px 14px; min-height: 34px; align-items: center;
}
.readout b { font-weight: 600; color: var(--accent); }
.readout .k { color: var(--muted); }
.note { margin: 10px 0 0; font-family: var(--font-mono); font-size: 0.66rem; color: var(--muted); line-height: 1.6; }
.banner {
  display: none; gap: 9px; align-items: flex-start;
  border: 1px solid var(--accent); background: var(--accent-soft); color: var(--ink);
  border-radius: var(--radius); padding: 10px 12px; margin-bottom: 14px; font-size: 0.85rem;
}
.banner.show { display: flex; }
.banner strong { font-family: var(--font-mono); font-size: 0.72rem; color: var(--accent); text-transform: uppercase; letter-spacing: 0.06em; }
.banner p { margin: 2px 0 0; color: var(--muted); }

/* ---------- footer ---------- */
.foot {
  margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted);
  display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
}
@media (max-width: 420px) {
  .wrap { padding: 14px 11px 26px; }
  .dial { width: 124px; height: 124px; }
  .dial-num { font-size: 1.8rem; }
  .head h1 { font-size: 1.15rem; }
}
</style>
</head>
<body>
<div class="wrap">

  <header class="head">
    <div>
      <h1>Monte Carlo Retirement Simulator</h1>
      <p class="lede">Roll 5,000 possible market futures for your savings, then count how many of them still have money left at the end.</p>
    </div>
    <div class="status" id="status" data-state="load">
      <span class="dot" aria-hidden="true"></span>
      <span id="statusText">booting python…</span>
    </div>
  </header>

  <div class="banner" id="banner" role="status">
    <div>
      <strong id="bannerTitle">note</strong>
      <p id="bannerBody"></p>
    </div>
  </div>

  <main class="grid">

    <section class="panel controls" aria-labelledby="ctlH">
      <h2 id="ctlH">Your plan</h2>

      <div class="presets" role="group" aria-label="Example scenarios">
        <button type="button" data-preset="steady" aria-pressed="true">steady saver</button>
        <button type="button" data-preset="late" aria-pressed="false">late starter</button>
        <button type="button" data-preset="coast" aria-pressed="false">coasting</button>
      </div>

      <div class="ctl">
        <div class="ctl-top">
          <label for="start">Savings today</label>
          <span class="ctl-val" id="v-start">$120,000</span>
        </div>
        <input type="range" id="start" min="0" max="2000000" step="5000" value="120000">
      </div>

      <div class="ctl">
        <div class="ctl-top">
          <label for="monthly">Monthly contribution</label>
          <span class="ctl-val" id="v-monthly">$1,500</span>
        </div>
        <input type="range" id="monthly" min="0" max="10000" step="50" value="1500">
        <div class="ctl-hint" id="h-monthly">$18,000 / year</div>
      </div>

      <div class="ctl">
        <div class="ctl-top">
          <label for="years">Years until retirement</label>
          <span class="ctl-val" id="v-years">25 yr</span>
        </div>
        <input type="range" id="years" min="1" max="45" step="1" value="25">
        <div class="ctl-hint" id="h-years">then a 30-year retirement</div>
      </div>

      <div class="ctl">
        <div class="ctl-top">
          <label for="stock">Stocks vs bonds</label>
          <span class="ctl-val" id="v-stock">70 / 30</span>
        </div>
        <input type="range" id="stock" min="0" max="100" step="5" value="70">
        <div class="mix" aria-hidden="true"><span>all bonds</span><span>all stocks</span></div>
      </div>

      <div class="ctl">
        <div class="ctl-top">
          <label for="wd">Annual spending in retirement</label>
          <span class="ctl-val" id="v-wd">$40,000</span>
        </div>
        <input type="range" id="wd" min="0" max="300000" step="1000" value="40000">
        <div class="ctl-hint" id="h-wd">$3,333 / month</div>
      </div>

      <div class="ctl" style="display:flex;gap:6px;flex-wrap:wrap;padding-top:2px">
        <button type="button" id="resample" title="Draw a fresh set of 5,000 random futures">resample</button>
        <button type="button" id="reset">reset</button>
      </div>
    </section>

    <div class="col">

      <section class="panel" aria-labelledby="verdictH">
        <h2 id="verdictH">Verdict</h2>
        <div class="verdict">
          <div class="dial" id="dial" data-tone="mid">
            <svg viewBox="0 0 120 120" aria-hidden="true" focusable="false">
              <circle class="arc-bg" cx="60" cy="60" r="52" fill="none" stroke-width="11"></circle>
              <circle class="arc-fg" id="arc" cx="60" cy="60" r="52" fill="none" stroke-width="11"
                      stroke-linecap="round" stroke-dasharray="326.73" stroke-dashoffset="326.73"></circle>
            </svg>
            <div class="dial-txt">
              <span class="dial-num" id="pct">—</span>
              <span class="dial-sub">SUCCESS</span>
            </div>
          </div>
          <div class="verdict-txt">
            <h3 id="verdictTitle">Simulating…</h3>
            <p id="verdictBody">Preparing 5,000 market futures.</p>
            <span class="err mono" id="verdictErr"></span>
          </div>
        </div>
        <p class="sr" id="live" role="status" aria-live="polite"></p>
      </section>

      <dl class="stats">
        <div class="stat"><dt>Median at retirement</dt><dd id="s-ret">—</dd></div>
        <div class="stat"><dt>Median at the end</dt><dd id="s-end">—</dd></div>
        <div class="stat"><dt>Unlucky 5% end with</dt><dd id="s-p5">—</dd></div>
        <div class="stat"><dt>When money runs out</dt><dd id="s-fail">—</dd></div>
      </dl>

      <section class="panel" aria-labelledby="chartH">
        <div class="chart-head">
          <h2 id="chartH" style="margin:0">Balance over time · 5,000 paths</h2>
          <div class="seg" role="group" aria-label="Vertical scale">
            <button type="button" id="sc-lin" aria-pressed="true">linear</button>
            <button type="button" id="sc-log" aria-pressed="false">log</button>
          </div>
        </div>
        <div class="canvas-box">
          <canvas id="chart" tabindex="0" role="img"
                  aria-label="Fan chart of simulated portfolio balance. Focus it and use left and right arrow keys to read each year."></canvas>
        </div>
        <p class="legend" aria-hidden="true">
          <span><i class="swatch" id="sw95"></i>5th–95th</span>
          <span><i class="swatch" id="sw75"></i>25th–75th</span>
          <span><i class="swatch" id="sw50" style="height:3px"></i>median</span>
          <span><i class="swatch" id="swRet" style="height:3px"></i>retirement</span>
        </p>
        <div class="readout mono" id="readout"></div>
        <p class="note" id="assumptions"></p>
        <table class="sr" id="srTable"><caption>Simulated balance percentiles every five years</caption></table>
      </section>

    </div>
  </main>

  <footer class="foot">
    <span>demo · sloprun.dev</span>
    <span id="perf">—</span>
  </footer>
</div>

<script type="text/x-python" id="pysrc">
import json
import numpy as np

# Long-run REAL (inflation-adjusted) assumptions, annual.
MU_S, SIG_S = 0.070, 0.180      # broad equities
MU_B, SIG_B = 0.020, 0.060      # investment-grade bonds
RHO = 0.15                      # stock/bond correlation

def run_sim(cfg_json):
    c = json.loads(cfg_json)
    n     = int(c["paths"])
    acc   = int(c["years"])
    ret   = int(c["retire_years"])
    T     = acc + ret
    w     = float(c["stock_pct"]) / 100.0
    start = float(c["start"])
    add   = float(c["monthly"]) * 12.0
    draw  = float(c["withdrawal"])

    rng = np.random.default_rng(int(c["seed"]))
    z1 = rng.standard_normal((T, n))
    z2 = RHO * z1 + np.sqrt(1.0 - RHO * RHO) * rng.standard_normal((T, n))

    # Geometric Brownian motion, one-year steps -> simple annual returns.
    rs = np.exp((MU_S - 0.5 * SIG_S ** 2) + SIG_S * z1) - 1.0
    rb = np.exp((MU_B - 0.5 * SIG_B ** 2) + SIG_B * z2) - 1.0
    rp = w * rs + (1.0 - w) * rb          # annually rebalanced portfolio

    paths = np.empty((T + 1, n), dtype=np.float64)
    bal = np.full(n, start, dtype=np.float64)
    paths[0] = bal

    # A path fails in the year a withdrawal cannot be funded — the year the
    # balance would go negative. Scoring "ended at zero" instead would call a
    # plan that never spends a penny a total failure, which it plainly is not.
    ruin_at = np.zeros(n, dtype=np.int32)          # 0 = still solvent
    for t in range(T):
        bal = bal * (1.0 + rp[t])
        bal += add if t < acc else -draw
        short = bal < 0.0
        if short.any():
            np.copyto(ruin_at, t + 1, where=short & (ruin_at == 0))
            np.maximum(bal, 0.0, out=bal)          # ruin is absorbing
        paths[t + 1] = bal

    q = np.percentile(paths, [5, 25, 50, 75, 95], axis=1)
    failed = ruin_at > 0
    n_fail = int(np.count_nonzero(failed))
    success = float(n - n_fail) / n
    ruin = float(np.median(ruin_at[failed])) if n_fail else None

    return json.dumps({
        "years": acc, "retire_years": ret, "paths": n,
        "bands": [[round(v, 2) for v in row] for row in q.tolist()],
        "success": success,
        "at_retirement": float(q[2][acc]),
        "final_median": float(q[2][-1]),
        "final_p5": float(q[0][-1]),
        "ruin_year": ruin,
        "engine": "pyodide + numpy " + np.__version__,
    })
</script>

<script>
(function () {
  "use strict";

  /* ------------------------------------------------------------------ *
   * Constants — kept identical in the Python and JavaScript engines.
   * ------------------------------------------------------------------ */
  var MU_S = 0.070, SIG_S = 0.180;
  var MU_B = 0.020, SIG_B = 0.060;
  var RHO = 0.15;
  var PATHS = 5000;
  var RETIRE_YEARS = 30;

  var DEFAULTS = { start: 120000, monthly: 1500, years: 25, stock: 70, wd: 40000 };
  var PRESETS = {
    steady: { start: 120000, monthly: 1500, years: 25, stock: 70, wd: 40000 },
    late:   { start: 40000,  monthly: 2500, years: 15, stock: 85, wd: 45000 },
    coast:  { start: 600000, monthly: 0,    years: 12, stock: 55, wd: 45000 }
  };

  var state = {
    start: DEFAULTS.start, monthly: DEFAULTS.monthly, years: DEFAULTS.years,
    stock: DEFAULTS.stock, wd: DEFAULTS.wd, seed: 20260813, scale: "linear"
  };

  var $ = function (id) { return document.getElementById(id); };
  var engine = "js";        // "js" until numpy is up
  var jsLabel = "javascript preview";   // becomes plain "javascript" if pyodide never arrives
  var pyRun = null;
  var pyodide = null;
  var lastRes = null;
  var scrub = -1;           // hovered/keyboard-selected year, -1 = none

  /* ------------------------------------------------------------------ *
   * Formatting
   * ------------------------------------------------------------------ */
  function money(v) {
    if (!isFinite(v)) return "—";
    var s = v < 0 ? "-" : "";
    v = Math.abs(v);
    if (v >= 1e9) return s + "$" + (v / 1e9).toFixed(v >= 1e10 ? 1 : 2) + "B";
    if (v >= 1e6) return s + "$" + (v / 1e6).toFixed(v >= 1e7 ? 1 : 2) + "M";
    if (v >= 1e4) return s + "$" + Math.round(v / 1e3) + "k";
    if (v >= 1e3) return s + "$" + (v / 1e3).toFixed(1) + "k";
    return s + "$" + Math.round(v);
  }
  function full(v) {
    return "$" + Math.round(v).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  }

  /* ------------------------------------------------------------------ *
   * JavaScript engine — the instant preview, and the standalone fallback.
   * Same model as run_sim() in the Python block above.
   * ------------------------------------------------------------------ */
  function mulberry32(a) {
    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;
    };
  }
  function normals(rand, n) {
    var out = new Float64Array(n);
    for (var i = 0; i < n; i += 2) {
      var u1 = rand() || 1e-12, u2 = rand();
      var r = Math.sqrt(-2 * Math.log(u1)), th = 2 * Math.PI * u2;
      out[i] = r * Math.cos(th);
      if (i + 1 < n) out[i + 1] = r * Math.sin(th);
    }
    return out;
  }
  function quantile(sorted, p) {
    var n = sorted.length;
    var h = (n - 1) * p;
    var lo = Math.floor(h), hi = Math.ceil(h);
    return sorted[lo] + (sorted[hi] - sorted[lo]) * (h - lo);
  }

  function simulateJS(cfg) {
    var n = cfg.paths, acc = cfg.years, T = acc + cfg.retire_years;
    var w = cfg.stock_pct / 100, add = cfg.monthly * 12, draw = cfg.withdrawal;
    var rand = mulberry32(cfg.seed >>> 0);
    var driftS = MU_S - 0.5 * SIG_S * SIG_S, driftB = MU_B - 0.5 * SIG_B * SIG_B;
    var rhoC = Math.sqrt(1 - RHO * RHO);

    var paths = new Float64Array((T + 1) * n);
    var bal = new Float64Array(n);
    // Year each path first failed to fund a withdrawal; 0 = still solvent.
    // Mirrors ruin_at in run_sim() — see the note there for why "ended at zero"
    // is the wrong test.
    var ruinAt = new Int32Array(n);
    var i, t;
    for (i = 0; i < n; i++) { bal[i] = cfg.start; paths[i] = cfg.start; }

    for (t = 0; t < T; t++) {
      var z1 = normals(rand, n), z3 = normals(rand, n);
      var off = (t + 1) * n, saving = t < acc;
      for (i = 0; i < n; i++) {
        var a = z1[i], b = RHO * a + rhoC * z3[i];
        var rp = w * (Math.exp(driftS + SIG_S * a) - 1) +
                 (1 - w) * (Math.exp(driftB + SIG_B * b) - 1);
        var v = bal[i] * (1 + rp) + (saving ? add : -draw);
        if (v < 0) { v = 0; if (ruinAt[i] === 0) ruinAt[i] = t + 1; }
        bal[i] = v; paths[off + i] = v;
      }
    }

    var ps = [0.05, 0.25, 0.5, 0.75, 0.95];
    var bands = [[], [], [], [], []];
    var col = new Float64Array(n);
    for (t = 0; t <= T; t++) {
      col.set(paths.subarray(t * n, t * n + n));
      col.sort();
      for (var k = 0; k < 5; k++) bands[k].push(quantile(col, ps[k]));
    }

    var ok = 0, ruinSum = [];
    for (i = 0; i < n; i++) {
      if (ruinAt[i] === 0) ok++; else ruinSum.push(ruinAt[i]);
    }
    var ruin = null;
    if (ruinSum.length) {
      ruinSum.sort(function (x, y) { return x - y; });
      ruin = quantile(Float64Array.from(ruinSum), 0.5);
    }

    return {
      years: acc, retire_years: cfg.retire_years, paths: n, bands: bands,
      success: ok / n,
      at_retirement: bands[2][acc],
      final_median: bands[2][T],
      final_p5: bands[0][T],
      ruin_year: ruin,
      engine: jsLabel
    };
  }

  /* ------------------------------------------------------------------ *
   * Run orchestration
   * ------------------------------------------------------------------ */
  function config() {
    return {
      paths: PATHS, years: state.years, retire_years: RETIRE_YEARS,
      stock_pct: state.stock, start: state.start, monthly: state.monthly,
      withdrawal: state.wd, seed: state.seed
    };
  }

  var pending = 0;
  function schedule() {
    clearTimeout(pending);
    pending = setTimeout(run, 90);
  }

  function run() {
    var cfg = config(), t0 = performance.now(), res;
    try {
      if (engine === "py") {
        res = JSON.parse(pyRun(JSON.stringify(cfg)));
      } else {
        res = simulateJS(cfg);
      }
    } catch (err) {
      if (engine === "py") {            // never leave the user stranded
        engine = "js"; jsLabel = "javascript";
        setStatus("warn", "python failed · javascript engine");
        banner("python engine stopped",
          "numpy raised an error mid-run (" + (err && err.message ? err.message : err) +
          "), so the identical model has been handed back to JavaScript. Everything below is live.");
        if (window.console && console.info) console.info("[monte-carlo] numpy failed mid-run; using the JS engine");
        res = simulateJS(cfg);
      } else {
        // Final safety net: this fires on the very first synchronous run(),
        // before boot() is even reached, so it must not throw the page away.
        setStatus("warn", "simulation failed");
        $("verdictTitle").textContent = "The simulation could not run.";
        $("verdictBody").textContent =
          "Nothing was computed, so the numbers below are not showing a result. " +
          "Reloading the page usually clears it.";
        $("verdictErr").textContent = String(err && err.message ? err.message : err);
        return;
      }
    }
    var ms = performance.now() - t0;
    lastRes = res;
    $("perf").textContent = res.engine + " · " + PATHS.toLocaleString() + " paths · " +
      (ms < 1 ? "<1" : Math.round(ms)) + " ms";
    render(res);
  }

  /* ------------------------------------------------------------------ *
   * Rendering
   * ------------------------------------------------------------------ */
  var C = 2 * Math.PI * 52;

  function render(res) {
    var p = res.success;
    var tone = p >= 0.85 ? "good" : (p >= 0.6 ? "mid" : "bad");
    var dial = $("dial");
    dial.setAttribute("data-tone", tone);
    $("arc").style.strokeDashoffset = (C * (1 - p)).toFixed(2);
    $("pct").textContent = (p * 100).toFixed(1) + "%";

    var endYear = res.years + res.retire_years;
    var se = Math.sqrt(p * (1 - p) / res.paths) * 100;
    var titles = {
      good: "This plan holds up.",
      mid:  "Workable, but it can break.",
      bad:  "This plan runs out of money."
    };
    $("verdictTitle").textContent = titles[tone];
    $("verdictBody").textContent =
      Math.round(p * res.paths).toLocaleString() + " of " + res.paths.toLocaleString() +
      " simulated futures never ran out over " + res.retire_years +
      " years of spending " + full(state.wd) + " a year.";
    $("verdictErr").textContent = "± " + se.toFixed(2) + " pt sampling error · seed " + state.seed;
    $("live").textContent = "Success probability " + (p * 100).toFixed(1) + " percent.";

    $("s-ret").innerHTML = money(res.at_retirement) + "<small>year " + res.years + "</small>";
    $("s-end").innerHTML = money(res.final_median) + "<small>year " + endYear + "</small>";
    $("s-p5").innerHTML  = money(res.final_p5) + "<small>5th percentile</small>";
    if (res.ruin_year == null) {
      $("s-fail").innerHTML = "never<small>in any path</small>";
    } else {
      $("s-fail").innerHTML = "year " + Math.round(res.ruin_year) +
        "<small>median of the " + Math.round((1 - p) * res.paths).toLocaleString() + " that fail</small>";
    }

    $("assumptions").textContent =
      "Real (inflation-adjusted) dollars. Stocks " + (MU_S * 100).toFixed(1) + "% ± " +
      (SIG_S * 100).toFixed(0) + "%, bonds " + (MU_B * 100).toFixed(1) + "% ± " +
      (SIG_B * 100).toFixed(0) + "%, correlation " + RHO.toFixed(2) +
      ". Geometric Brownian motion, one-year steps, rebalanced annually. " +
      "Contributions until year " + res.years + ", then withdrawals. No taxes, fees or state pension.";

    // The years slider can shorten the timeline under a scrub position that was
    // set while the chart was longer; without this the readout indexes past the
    // end of every band and prints five em-dashes.
    if (scrub > endYear) scrub = endYear;

    buildTable(res);
    draw();
    updateReadout();
  }

  function buildTable(res) {
    var rows = ["<caption>Simulated balance percentiles every five years</caption>",
      "<tr><th scope=\"col\">Year</th><th scope=\"col\">5th</th><th scope=\"col\">25th</th>" +
      "<th scope=\"col\">Median</th><th scope=\"col\">75th</th><th scope=\"col\">95th</th></tr>"];
    var T = res.years + res.retire_years;
    for (var t = 0; t <= T; t += 5) {
      rows.push("<tr><th scope=\"row\">" + t + "</th>" +
        [0, 1, 2, 3, 4].map(function (k) { return "<td>" + money(res.bands[k][t]) + "</td>"; }).join("") +
        "</tr>");
    }
    $("srTable").innerHTML = rows.join("");
  }

  function readoutYear() {
    var T = lastRes.years + lastRes.retire_years;
    // scrub is clamped everywhere it is written, but a stale index here would
    // silently print five em-dashes, so pin it to the band length one last time.
    return scrub >= 0 ? Math.max(0, Math.min(T, scrub)) : lastRes.years;
  }

  function updateReadout() {
    if (!lastRes) return;
    var t = readoutYear();
    var b = lastRes.bands, tag = scrub >= 0 ? "year" : "at retirement · year";
    $("readout").innerHTML =
      "<b>" + tag + " " + t + "</b>" +
      "<span><span class=\"k\">p05</span> " + money(b[0][t]) + "</span>" +
      "<span><span class=\"k\">p25</span> " + money(b[1][t]) + "</span>" +
      "<span><span class=\"k\">p50</span> " + money(b[2][t]) + "</span>" +
      "<span><span class=\"k\">p75</span> " + money(b[3][t]) + "</span>" +
      "<span><span class=\"k\">p95</span> " + money(b[4][t]) + "</span>";
  }

  // The readout itself is NOT a live region: it repaints on every pointermove,
  // which would make a screen reader chatter through a mouse gesture nobody
  // asked it to narrate. Keyboard scrubbing announces deliberately instead.
  function announceScrub() {
    if (!lastRes) return;
    var t = readoutYear(), b = lastRes.bands;
    $("live").textContent = "Year " + t + ". Median " + money(b[2][t]) +
      ", 5th percentile " + money(b[0][t]) + ", 95th percentile " + money(b[4][t]) + ".";
  }

  /* ------------------------------------------------------------------ *
   * Canvas fan chart
   * ------------------------------------------------------------------ */
  var cv = $("chart"), ctx = cv.getContext("2d");
  var plot = null;   // geometry cached for hit-testing

  function css(name) {
    return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
  }
  function rgba(hex, a) {
    var h = hex.replace("#", "");
    if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
    var n = parseInt(h, 16);
    return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a + ")";
  }
  function niceTicks(max, count) {
    var raw = max / count, mag = Math.pow(10, Math.floor(Math.log10(raw || 1)));
    var norm = raw / mag, step;
    step = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10;
    step *= mag;
    var out = [];
    for (var v = 0; v <= max + step * 0.5; v += step) out.push(v);
    return out;
  }

  var LOG_FLOOR = 1000;

  function axisMoney(v) {
    if (v === 0) return "$0";
    if (v >= 1e9) { var g = v / 1e9; return "$" + (g >= 10 ? Math.round(g) : +g.toFixed(1)) + "B"; }
    if (v >= 1e6) { var m = v / 1e6; return "$" + (m >= 10 ? Math.round(m) : +m.toFixed(1)) + "M"; }
    if (v >= 1e3) { var k = v / 1e3; return "$" + (k >= 10 ? Math.round(k) : +k.toFixed(1)) + "k"; }
    return "$" + Math.round(v);
  }

  function draw() {
    var box = cv.parentNode.getBoundingClientRect();
    var dpr = window.devicePixelRatio || 1;
    var w = Math.max(240, Math.floor(box.width));
    var h = cv.clientHeight || 260;
    cv.width = Math.floor(w * dpr);
    cv.height = Math.floor(h * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, w, h);
    if (!lastRes) return;

    var res = lastRes, b = res.bands, T = res.years + res.retire_years;
    var inkC = css("--ink"), mutedC = css("--muted"), lineC = css("--line");
    var accentC = css("--accent");
    var padL = w < 420 ? 44 : 54, padR = 10, padT = 10, padB = 26;
    var pw = w - padL - padR, ph = h - padT - padB;

    var log = state.scale === "log";
    // A lognormal 95th percentile runs away over 55 years and flattens everything
    // else against the floor. In linear mode frame the readable middle and let the
    // outer band clip, with the true 95th annotated so nothing is hidden.
    var peak = Math.max(b[4][T], b[4][res.years], LOG_FLOOR * 10);
    var top = log ? peak
                  : Math.max(b[3][T], b[4][res.years], b[2][T] * 1.6, LOG_FLOOR * 10);
    var ticks;
    if (log) {
      // Frame the data, not the next round decade: rounding $16M up to $100M
      // would push the whole fan into the bottom two thirds of the panel.
      top = peak * 1.06;
      var e0 = Math.floor(Math.log10(LOG_FLOOR)), e1 = Math.floor(Math.log10(top));
      ticks = [];
      for (var e = e0; e <= e1; e++) ticks.push(Math.pow(10, e));
    } else {
      ticks = niceTicks(top, w < 420 ? 3 : 4);
      top = ticks[ticks.length - 1];
    }
    var lo = log ? Math.log10(LOG_FLOOR) : 0, hi = log ? Math.log10(top) : top;

    function X(t) { return padL + (t / T) * pw; }
    function Y(v) {
      var u = log ? Math.log10(Math.max(v, LOG_FLOOR)) : v;
      return padT + ph - ((u - lo) / (hi - lo)) * ph;
    }
    plot = { X: X, Y: Y, padL: padL, pw: pw, T: T, padT: padT, ph: ph };

    // grid + y labels
    ctx.font = "500 10px " + css("--font-mono");
    ctx.textAlign = "right"; ctx.textBaseline = "middle";
    ctx.lineWidth = 1;
    for (var i = 0; i < ticks.length; i++) {
      var y = Math.round(Y(ticks[i])) + 0.5;
      ctx.strokeStyle = lineC; ctx.beginPath();
      ctx.moveTo(padL, y); ctx.lineTo(padL + pw, y); ctx.stroke();
      ctx.fillStyle = mutedC;
      ctx.fillText(axisMoney(ticks[i]), padL - 7, y);
    }

    // bands (clipped to the frame — the 95th may exceed it in linear mode)
    ctx.save();
    ctx.beginPath(); ctx.rect(padL, padT, pw, ph); ctx.clip();
    function band(loArr, hiArr, alpha) {
      ctx.beginPath();
      for (var t = 0; t <= T; t++) { var x = X(t), y = Y(hiArr[t]); t ? ctx.lineTo(x, y) : ctx.moveTo(x, y); }
      for (var s = T; s >= 0; s--) ctx.lineTo(X(s), Y(loArr[s]));
      ctx.closePath();
      ctx.fillStyle = rgba(accentC, alpha);
      ctx.fill();
    }
    band(b[0], b[4], 0.16);
    band(b[1], b[3], 0.28);

    // median
    ctx.beginPath();
    for (var t2 = 0; t2 <= T; t2++) { var x2 = X(t2), y2 = Y(b[2][t2]); t2 ? ctx.lineTo(x2, y2) : ctx.moveTo(x2, y2); }
    ctx.strokeStyle = accentC; ctx.lineWidth = 2; ctx.lineJoin = "round"; ctx.stroke();
    ctx.restore();

    // honest note when the top band runs off the frame
    if (!log && peak > top * 1.02) {
      ctx.textAlign = "right"; ctx.textBaseline = "top"; ctx.fillStyle = mutedC;
      ctx.fillText("95th peaks at " + axisMoney(peak), padL + pw - 3, padT + 14);
    }

    // retirement marker
    var xr = Math.round(X(res.years)) + 0.5;
    ctx.save();
    ctx.setLineDash([3, 3]); ctx.lineWidth = 1;
    ctx.strokeStyle = inkC;
    ctx.beginPath(); ctx.moveTo(xr, padT); ctx.lineTo(xr, padT + ph); ctx.stroke();
    ctx.restore();
    ctx.textAlign = xr > padL + pw - 60 ? "right" : "left";
    ctx.textBaseline = "top";
    ctx.fillStyle = mutedC;
    ctx.fillText("retire", xr + (ctx.textAlign === "left" ? 4 : -4), padT + 1);

    // x axis
    ctx.strokeStyle = lineC; ctx.beginPath();
    ctx.moveTo(padL, padT + ph + 0.5); ctx.lineTo(padL + pw, padT + ph + 0.5); ctx.stroke();
    ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillStyle = mutedC;
    var stepX = T <= 20 ? 5 : T <= 45 ? 10 : 15;
    var xEnd = X(T);
    // The final "yT" label always gets drawn, so drop any regular tick that would
    // land on top of it (T = 31 at 360px puts y30 and y31 eight pixels apart).
    for (var t3 = 0; t3 <= T && xEnd - X(t3) > 26; t3 += stepX) {
      ctx.fillText("y" + t3, X(t3), padT + ph + 6);
    }
    ctx.fillText("y" + T, xEnd, padT + ph + 6);

    // scrub
    if (scrub >= 0 && scrub <= T) {
      var xs = Math.round(X(scrub)) + 0.5;
      ctx.strokeStyle = inkC; ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(xs, padT); ctx.lineTo(xs, padT + ph); ctx.stroke();
      for (var k = 0; k < 5; k++) {
        ctx.beginPath();
        ctx.arc(xs, Y(b[k][scrub]), k === 2 ? 3.5 : 2.5, 0, 2 * Math.PI);
        ctx.fillStyle = k === 2 ? accentC : rgba(accentC, 0.65);
        ctx.fill();
      }
    }

    if (log) {
      ctx.textAlign = "left"; ctx.textBaseline = "bottom"; ctx.fillStyle = mutedC;
      ctx.fillText("log · empty paths rest on the floor", padL + 3, padT + ph - 3);
    }
  }

  /* ------------------------------------------------------------------ *
   * Interaction
   * ------------------------------------------------------------------ */
  function yearAt(clientX) {
    if (!plot) return -1;
    var r = cv.getBoundingClientRect();
    var t = Math.round(((clientX - r.left - plot.padL) / plot.pw) * plot.T);
    return Math.max(0, Math.min(plot.T, t));
  }
  cv.addEventListener("pointermove", function (e) {
    var t = yearAt(e.clientX);
    if (t !== scrub) { scrub = t; draw(); updateReadout(); }
  });
  cv.addEventListener("pointerleave", function () {
    if (scrub !== -1) { scrub = -1; draw(); updateReadout(); }
  });
  var SCRUB_KEYS = ["ArrowRight", "ArrowLeft", "PageUp", "PageDown", "Home", "End", "Escape"];
  cv.addEventListener("keydown", function (e) {
    if (!lastRes) return;
    var k = e.key;
    // Bail before touching scrub: seeding it for a key we do not handle moved the
    // cursor invisibly, and the next repaint would jump to a year nobody chose.
    if (SCRUB_KEYS.indexOf(k) < 0) return;
    var T = lastRes.years + lastRes.retire_years;
    var at = scrub < 0 ? lastRes.years : scrub;
    if (k === "ArrowRight") at = Math.min(T, at + 1);
    else if (k === "ArrowLeft") at = Math.max(0, at - 1);
    else if (k === "PageUp") at = Math.min(T, at + 5);
    else if (k === "PageDown") at = Math.max(0, at - 5);
    else if (k === "Home") at = 0;
    else if (k === "End") at = T;
    else if (k === "Escape") at = -1;
    scrub = at;
    e.preventDefault();
    draw(); updateReadout();
    if (scrub >= 0) announceScrub();
  });
  cv.addEventListener("focus", function () {
    if (scrub < 0 && lastRes) { scrub = lastRes.years; draw(); updateReadout(); announceScrub(); }
  });
  cv.addEventListener("blur", function () { scrub = -1; draw(); updateReadout(); });

  function labels() {
    $("v-start").textContent = full(state.start);
    $("v-monthly").textContent = full(state.monthly);
    $("h-monthly").textContent = full(state.monthly * 12) + " / year";
    $("v-years").textContent = state.years + " yr";
    $("h-years").textContent = "then a " + RETIRE_YEARS + "-year retirement · ends at year " + (state.years + RETIRE_YEARS);
    $("v-stock").textContent = state.stock + " / " + (100 - state.stock);
    $("v-wd").textContent = full(state.wd);
    $("h-wd").textContent = full(Math.round(state.wd / 12)) + " / month";
    $("start").setAttribute("aria-valuetext", full(state.start));
    $("monthly").setAttribute("aria-valuetext", full(state.monthly) + " per month");
    $("years").setAttribute("aria-valuetext", state.years + " years");
    $("stock").setAttribute("aria-valuetext", state.stock + " percent stocks, " + (100 - state.stock) + " percent bonds");
    $("wd").setAttribute("aria-valuetext", full(state.wd) + " per year");
  }

  var MAP = { start: "start", monthly: "monthly", years: "years", stock: "stock", wd: "wd" };
  Object.keys(MAP).forEach(function (id) {
    $(id).addEventListener("input", function () {
      state[MAP[id]] = Number(this.value);
      labels(); markPreset(); schedule();
    });
  });

  function apply(p) {
    state.start = p.start; state.monthly = p.monthly; state.years = p.years;
    state.stock = p.stock; state.wd = p.wd;
    $("start").value = p.start; $("monthly").value = p.monthly; $("years").value = p.years;
    $("stock").value = p.stock; $("wd").value = p.wd;
    labels(); markPreset(); schedule();
  }
  function markPreset() {
    document.querySelectorAll("[data-preset]").forEach(function (btn) {
      var p = PRESETS[btn.dataset.preset];
      var on = p.start === state.start && p.monthly === state.monthly && p.years === state.years &&
               p.stock === state.stock && p.wd === state.wd;
      btn.setAttribute("aria-pressed", on ? "true" : "false");
    });
  }
  document.querySelectorAll("[data-preset]").forEach(function (btn) {
    btn.addEventListener("click", function () { apply(PRESETS[btn.dataset.preset]); });
  });
  $("reset").addEventListener("click", function () { state.seed = 20260813; apply(DEFAULTS); });
  $("resample").addEventListener("click", function () {
    state.seed = (Math.random() * 1e9) | 0; schedule();
  });
  $("sc-lin").addEventListener("click", function () { setScale("linear"); });
  $("sc-log").addEventListener("click", function () { setScale("log"); });
  function setScale(s) {
    state.scale = s;
    $("sc-lin").setAttribute("aria-pressed", s === "linear" ? "true" : "false");
    $("sc-log").setAttribute("aria-pressed", s === "log" ? "true" : "false");
    draw();
  }

  /* ------------------------------------------------------------------ *
   * Theme + resize
   * ------------------------------------------------------------------ */
  function repaintTheme() { paintLegend(); draw(); }
  // Canvas colours are sampled from the tokens at draw time, so any theme flip —
  // postMessage, or the platform setting the attribute straight on <html> — has
  // to force a repaint or the chart keeps the old palette.
  var themeObs = window.MutationObserver ? new MutationObserver(repaintTheme) : null;
  if (themeObs) {
    themeObs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
  }
  window.addEventListener("message", function (e) {
    var d = e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      document.documentElement.setAttribute("data-theme", d.theme);
      if (!themeObs) repaintTheme();
    }
  });
  var mq = window.matchMedia("(prefers-color-scheme: dark)");
  (mq.addEventListener ? mq.addEventListener.bind(mq, "change") : mq.addListener.bind(mq))(repaintTheme);
  function paintLegend() {
    var a = css("--accent");
    $("sw95").style.background = rgba(a, 0.16);
    $("sw75").style.background = rgba(a, 0.28);
    $("sw50").style.background = a;
    // the marker is a dashed --ink rule, so the swatch has to be one too
    var ink = css("--ink");
    $("swRet").style.background =
      "repeating-linear-gradient(90deg," + ink + " 0 3px, transparent 3px 6px)";
  }
  var ro = window.ResizeObserver ? new ResizeObserver(function () { draw(); }) : null;
  if (ro) ro.observe(cv.parentNode); else window.addEventListener("resize", draw);

  /* ------------------------------------------------------------------ *
   * Pyodide boot — real loading state, graceful standalone failure
   * ------------------------------------------------------------------ */
  function setStatus(st, txt) {
    $("status").setAttribute("data-state", st);
    $("statusText").textContent = txt;
  }
  function banner(title, body) {
    $("bannerTitle").textContent = title;
    $("bannerBody").textContent = body;
    $("banner").classList.add("show");
  }
  function loadScript(src, ms) {
    return new Promise(function (resolve, reject) {
      var s = document.createElement("script"), settled = false;
      function end(ok, err) {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        s.onload = s.onerror = null;
        if (!ok && s.parentNode) s.parentNode.removeChild(s);   // no late arrivals
        if (ok) { resolve(); } else { reject(err); }
      }
      var timer = setTimeout(function () {
        end(false, new Error("timed out after " + Math.round(ms / 1000) + "s"));
      }, ms);
      s.onload = function () { end(true); };
      s.onerror = function () { end(false, new Error("cannot load " + src)); };
      s.src = src;
      document.head.appendChild(s);
    });
  }

  /**
   * Hand the page back to the JavaScript engine and say so plainly. The model is
   * the same either way, so this is a note, never an error.
   */
  function fallback(title, body, logLine) {
    engine = "js";
    jsLabel = "javascript";
    setStatus("warn", "javascript engine");
    $("perf").textContent = $("perf").textContent.replace("javascript preview", "javascript");
    banner(title, body);
    if (window.console && console.info) console.info("[monte-carlo] " + logLine);
  }

  /**
   * Pyodide is WebAssembly. A page served under a CSP without 'wasm-unsafe-eval'
   * cannot compile ANY wasm — and in that case loadPyodide() neither resolves nor
   * rejects, it just stops, which would leave the header pinned on "starting
   * python…" forever. Probe with an eight-byte empty module (legal wasm, compiled
   * synchronously) so a blocked sandbox is answered instantly and honestly.
   */
  function wasmUsable() {
    try {
      return typeof WebAssembly === "object" &&
        new WebAssembly.Module(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]))
          instanceof WebAssembly.Module;
    } catch (err) {
      return false;
    }
  }

  /** Nothing in the boot chain is allowed to hang without an answer. */
  function deadline(promise, ms, what) {
    return new Promise(function (resolve, reject) {
      var t = setTimeout(function () {
        reject(new Error(what + " did not finish within " + Math.round(ms / 1000) + "s"));
      }, ms);
      promise.then(
        function (v) { clearTimeout(t); resolve(v); },
        function (e) { clearTimeout(t); reject(e); }
      );
    });
  }

  function boot() {
    if (!wasmUsable()) {
      fallback("running in javascript",
        "Python and numpy reach a browser as WebAssembly, and this page is sandboxed by a policy " +
        "that will not compile it. The identical model — same drift, same volatility, the same " +
        "5,000 paths — is running in JavaScript instead, so everything below is live.",
        "WebAssembly is blocked here; staying on the JS engine");
      return;
    }
    setStatus("load", "loading pyodide…");
    loadScript("/vendor/pyodide/pyodide.js", 30000)
      .then(function () {
        if (typeof loadPyodide !== "function") throw new Error("pyodide.js did not define loadPyodide");
        setStatus("load", "starting python…");
        return deadline(loadPyodide({ indexURL: "/vendor/pyodide/" }), 45000, "the python runtime");
      })
      .then(function (py) {
        pyodide = py;
        setStatus("load", "loading numpy…");
        return deadline(py.loadPackage("numpy"), 30000, "numpy");
      })
      .then(function () {
        pyodide.runPython($("pysrc").textContent);
        pyRun = pyodide.globals.get("run_sim");
        engine = "py";
        setStatus("ok", "numpy ready");
        run();
      })
      .catch(function (err) {
        fallback("pyodide unavailable",
          "This demo normally runs its 5,000 paths in Python with numpy, loaded from the sloprun " +
          "bundle at /vendor/pyodide/. That bundle did not come up here (" + err.message +
          "), so the identical model is running in JavaScript instead. Everything below is live.",
          "falling back to the JS engine: " + err.message);
      });
  }

  /* ------------------------------------------------------------------ *
   * Go — instant first result, then upgrade to numpy
   * ------------------------------------------------------------------ */
  labels();
  paintLegend();
  run();          // synchronous JS pass: a full chart before Pyodide is even requested
  boot();
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

1 total
singhpratech2026-08-16

dfefewewfwe

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong