$sloprun.dev

Empirical Big-O Profiler

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

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

▶ 9 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

Complexity is usually something you argue about at a whiteboard; this measures it instead. It opens already profiling a linear scan across a dozen geometrically growing input sizes — auto-calibrating the repetition count so every batch runs at least 12 ms — then fits the timing curve against O(1), O(log n), O(n), O(n log n), O(n²) and O(bⁿ) and reports the winner with its R² and the empirical exponent. Try fib (recursive) next: it comes back as O(1.62ⁿ), the profiler rediscovering the golden ratio from nothing but a stopwatch. Then hit runaway loop — your code runs in a Web Worker built from a blob: URL, so a function that never returns is simply killed by the 3-second watchdog while the page stays responsive.

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/bigo-profiler" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Empirical Big-O Profiler — 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/bigo-profiler

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1236 lines 56.7 KB index.html
demos/bigo-profiler/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Empirical Big-O Profiler</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ─────────────── */
/* 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); }
/* ── end token block ───────────────────────────────────────────────────── */

/* derived tokens (theme-paired, defined in all three states above by role) */
:root { --on-accent: #FFFFFF; --grid: #E4E3DA; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --on-accent: #14161A; --grid: #272D36; }
}
:root[data-theme="dark"] { --on-accent: #14161A; --grid: #272D36; }

*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; font-size: 15px; line-height: 1.5; }
main { max-width: 1220px; margin: 0 auto; padding: 22px 16px 34px; }

h1 { font-size: clamp(1.25rem, 3.4vw, 1.55rem); margin: 0 0 5px; letter-spacing: -0.015em; }
.sub { margin: 0; color: var(--muted); max-width: 64ch; font-size: 14.5px; }
h2 { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase;
     letter-spacing: .1em; color: var(--muted); margin: 0 0 10px; font-weight: 600; }

.note { display: flex; gap: 10px; align-items: flex-start; margin: 16px 0 18px;
        padding: 10px 12px; border: 1px solid var(--line); border-left: 3px solid var(--accent);
        background: var(--accent-soft); border-radius: 8px; font-size: 13.5px; }
.note svg { flex: none; margin-top: 2px; }
.note b { font-weight: 650; }
.note code { font-family: var(--font-mono); font-size: 12.5px; }

.grid { display: grid; gap: 16px; align-items: start;
        grid-template-columns: minmax(0, 1fr) minmax(318px, 400px); }
@media (max-width: 940px) { .grid { grid-template-columns: minmax(0, 1fr); } }

.panel { background: var(--surface); border: 1px solid var(--line);
         border-radius: var(--radius); padding: 14px; min-width: 0; }

/* verdict ---------------------------------------------------------------- */
.verdict { border: 1px solid var(--line); border-radius: 8px; background: var(--bg);
           padding: 12px 14px; display: flex; flex-wrap: wrap; gap: 12px 26px; align-items: center; }
.v-left { min-width: 132px; }
.eyebrow { display: block; font-family: var(--font-mono); font-size: 9.5px;
           text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
#vBig { font-family: var(--font-mono); font-size: clamp(1.45rem, 5.2vw, 1.95rem);
        font-weight: 600; letter-spacing: -.02em; line-height: 1.15; display: block; }
.verdict.weak #vBig { color: var(--muted); }
.verdict.nofit #vBig { font-size: clamp(1.05rem, 4vw, 1.25rem); letter-spacing: 0; }
.v-stats { display: flex; flex-wrap: wrap; gap: 10px 22px; margin: 0; }
.v-stats > div { min-width: 62px; }
.v-stats dt { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase;
              letter-spacing: .12em; color: var(--muted); }
.v-stats dd { margin: 0; font-family: var(--font-mono); font-size: 14px; }
.match { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
.match.ok { color: var(--run); }

.prog { height: 3px; background: var(--line); border-radius: 2px; overflow: hidden; margin: 10px 0 0; }
.prog i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .16s linear; }
.statusrow { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 10px; }
.chip { font-family: var(--font-mono); font-size: 11.5px; padding: 3px 8px; border-radius: 999px;
        border: 1px solid var(--line); color: var(--muted); background: var(--surface); }
.chip.busy { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.chip.ok   { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.chip.bad  { color: var(--danger); border-color: var(--danger); background: var(--surface); }

/* chart ------------------------------------------------------------------ */
.charthead { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center;
             justify-content: space-between; margin: 16px 0 8px; }
.charthead h2 { margin: 0; }
.opts { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; }
.swi { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px;
       font-family: var(--font-mono); color: var(--muted); cursor: pointer; }
.swi input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; }
canvas { display: block; width: 100%; height: clamp(216px, 30vw, 306px); touch-action: pan-y;
         border: 1px solid var(--line); border-radius: 8px; background: var(--bg); }
.readout { font-family: var(--font-mono); font-size: 12px; color: var(--muted);
           margin: 8px 0 0; min-height: 18px; }
.readout b { color: var(--ink); font-weight: 600; }
.legend { display: flex; gap: 14px; flex-wrap: wrap; font-family: var(--font-mono);
          font-size: 11px; color: var(--muted); margin-top: 6px; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.k-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.k-line { width: 16px; height: 0; border-top: 2px dashed var(--muted); }

/* tables ----------------------------------------------------------------- */
.tabs { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 14px; margin-top: 16px; }
@media (max-width: 620px) { .tabs { grid-template-columns: minmax(0, 1fr); } }
.tabs > div { display: flex; flex-direction: column; }
.tabs .hint { margin-top: auto; padding-top: 8px; }
.scroll { overflow: auto; max-height: 208px; border: 1px solid var(--line); border-radius: 8px; }
table { border-collapse: collapse; width: 100%; font-size: 12.5px; }
thead th { position: sticky; top: 0; background: var(--surface); z-index: 1;
           font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase;
           letter-spacing: .1em; color: var(--muted); font-weight: 600; text-align: right;
           padding: 6px 9px; border-bottom: 1px solid var(--line); }
thead th:first-child { text-align: left; }
thead th .u { text-transform: none; letter-spacing: .04em; }
td { padding: 4px 9px; border-bottom: 1px solid var(--line); text-align: right;
     font-family: var(--font-mono); white-space: nowrap; }
td:first-child { text-align: left; }
tbody tr:last-child td { border-bottom: 0; }
tr.sel td { background: var(--accent-soft); }
.rowbtn { font: inherit; font-family: var(--font-mono); background: none; border: 0; padding: 2px 4px;
          margin: -2px -4px; color: var(--ink); cursor: pointer; border-radius: 5px; text-align: left; }
.rowbtn:hover { background: var(--accent-soft); }
.rowbtn[aria-pressed="true"] { color: var(--accent); font-weight: 650; }
.tick { color: var(--run); }
.tick.off { visibility: hidden; }
.dim { color: var(--muted); }
@media (max-width: 540px) { .fcol { display: none; } }

/* editor ----------------------------------------------------------------- */
fieldset { border: 0; margin: 0 0 10px; padding: 0; }
legend { padding: 0; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; position: relative; }
/* Absolutely-positioned flex children all resolve to the container's start
   corner, so these five 1px radios stack on top of the first chip. Without
   pointer-events:none a click on the top-left pixel of "linear scan" lands on
   the hidden "runaway loop" radio instead. The labels drive them anyway. */
.chips input { position: absolute; top: 0; left: 0; opacity: 0; width: 1px; height: 1px;
               margin: 0; pointer-events: none; }
.chips label { display: inline-block; padding: 5px 10px; border: 1px solid var(--line);
               border-radius: 999px; font-size: 12.5px; cursor: pointer; background: var(--bg);
               color: var(--muted); user-select: none; }
.chips label:hover { border-color: var(--accent); color: var(--ink); }
.chips input:checked + label { background: var(--accent-soft); border-color: var(--accent);
                               color: var(--ink); font-weight: 600; }
.chips input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: 2px; }

.lbl { display: block; font-family: var(--font-mono); font-size: 11px; color: var(--muted);
       margin: 0 0 6px; line-height: 1.45; }
.lbl b { color: var(--ink); font-weight: 600; }
textarea { width: 100%; min-height: 216px; height: 34vh; max-height: 430px; resize: vertical;
           font-family: var(--font-mono); font-size: 11.6px; line-height: 1.6; padding: 10px;
           border: 1px solid var(--line); border-radius: 8px; background: var(--bg);
           color: var(--ink); tab-size: 2; }
.nrow { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 10px 0; }
.nrow label { display: block; font-family: var(--font-mono); font-size: 10px;
              text-transform: uppercase; letter-spacing: .09em; color: var(--muted); }
.nrow input { width: 100%; margin-top: 4px; padding: 6px 8px; font-family: var(--font-mono);
              font-size: 13px; border: 1px solid var(--line); border-radius: 6px;
              background: var(--bg); color: var(--ink); }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
button { font-family: var(--font-sans); font-size: 13.5px; padding: 8px 14px; border-radius: 8px;
         border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer; }
button:hover:not(:disabled) { border-color: var(--accent); }
button:disabled { opacity: .45; cursor: not-allowed; }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent);
                 font-weight: 600; }
.hint { font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin: 10px 0 0; }
.err { display: none; margin: 10px 0 0; padding: 8px 10px; border: 1px solid var(--danger);
       border-radius: 8px; color: var(--danger); font-family: var(--font-mono);
       font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
.err.on { display: block; }

footer { margin-top: 22px; font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); }

:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important;
                           transition-duration: .001ms !important; scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<main>
  <header>
    <h1>Empirical Big-O Profiler</h1>
    <p class="sub">Don't reason about the complexity of a function — measure it. This runs your
      code at geometrically growing input sizes, times each one, and fits the curve.</p>
  </header>

  <p class="note">
    <svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true" fill="none"
         stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M8 4.6v4.2M8 11.2v.2"/></svg>
    <span><b>Your code runs on your machine.</b> It is executed inside a Web&nbsp;Worker built from a
      <code>blob:</code> URL, on a separate thread from this page. Nothing is uploaded — this demo makes
      no network requests at all. A runaway function is killed by a 3&nbsp;s watchdog.</span>
  </p>

  <div class="grid">
    <!-- results (left on desktop, first on mobile: DOM order == visual order) -->
    <section class="panel" aria-label="Results">
      <div class="verdict" id="verdict">
        <div class="v-left">
          <span class="eyebrow">best fit</span>
          <span id="vBig">—</span>
          <span class="match" id="vMatch">&nbsp;</span>
        </div>
        <dl class="v-stats">
          <div><dt>R²</dt><dd id="vR2">—</dd></div>
          <div><dt>exponent p</dt><dd id="vP">—</dd></div>
          <div><dt>runner-up</dt><dd id="vNext">—</dd></div>
          <div><dt>points</dt><dd id="vPts">0</dd></div>
        </dl>
      </div>
      <div class="prog"><i id="prog"></i></div>
      <div class="statusrow">
        <span class="chip" id="status" role="status">idle</span>
        <span class="chip" id="engine">worker</span>
        <span class="chip" id="sep">Δ —</span>
      </div>
      <p class="hint" id="ambig" hidden></p>

      <div class="charthead">
        <h2>time per call vs n</h2>
        <div class="opts">
          <label class="swi"><input type="checkbox" id="logToggle" checked> log–log axes</label>
          <label class="swi"><input type="checkbox" id="fitToggle" checked> fit curve</label>
        </div>
      </div>
      <canvas id="chart" tabindex="0" role="img" aria-describedby="readout"
              aria-label="Plot of measured time per call against input size n."></canvas>
      <p class="readout" id="readout" aria-live="polite">Hover the chart — or focus it and use ← → — to inspect a measurement.</p>
      <p class="legend"><span><i class="k-dot"></i> measured</span><span><i class="k-line"></i> fitted model</span></p>

      <div class="tabs">
        <div>
          <h2>candidate models</h2>
          <div class="scroll">
            <table>
              <thead><tr><th>model</th><th class="fcol">fitted form (ms)</th><th>R²</th></tr></thead>
              <tbody id="mtBody"></tbody>
            </table>
          </div>
          <p class="hint">Relative-error weighted least squares, so small n counts as much
            as large n. Pick a row to overlay its curve.</p>
        </div>
        <div>
          <h2>measurements</h2>
          <div class="scroll">
            <table>
              <thead><tr><th>n</th><th id="tHead">t</th><th>reps × trials</th></tr></thead>
              <tbody id="msBody"></tbody>
            </table>
          </div>
          <p class="hint">t is the fastest of k trials; reps are auto-calibrated so every
            batch runs ≥ 12 ms.</p>
        </div>
      </div>
    </section>

    <!-- editor -->
    <section class="panel" aria-label="Function under test">
      <h2>function under test</h2>
      <fieldset class="chips-wrap">
        <legend class="lbl">Examples — pick one and it profiles itself.</legend>
        <div class="chips" id="chips"></div>
      </fieldset>
      <label class="lbl" for="code">
        <b>fn(n, data, run)</b> is timed. <b>setup(n)</b> is optional and untimed —
        build inputs there so allocation doesn't pollute the measurement.
      </label>
      <textarea id="code" spellcheck="false" autocapitalize="off" autocorrect="off"
                aria-label="JavaScript source for fn(n, data, run)"></textarea>
      <div class="nrow">
        <label>n from <input type="number" id="nMin" min="1" step="1"></label>
        <label>n to <input type="number" id="nMax" min="2" step="1"></label>
        <label>points <input type="number" id="nPts" min="4" max="24" step="1"></label>
      </div>
      <div class="actions">
        <button class="primary" id="run">Run profile</button>
        <button id="stop" disabled>Stop</button>
      </div>
      <p class="hint">watchdog 3.0 s · worker terminated on overrun</p>
      <p class="err" id="err" role="alert"></p>
    </section>
  </div>

  <footer>demo · sloprun.dev</footer>
</main>

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

  /* ── measurement engine ────────────────────────────────────────────────
     Shared source: runs inside the Worker normally, or on the main thread
     as an explicit fallback if blob: workers are blocked by policy.        */
  var ENGINE = [
    'function __profile(spec, emit) {',
    '  var now = function () { return performance.now(); };',
    '  var RET = "\\n;return { fn: (typeof fn === \\"function\\") ? fn : null, setup: (typeof setup === \\"function\\") ? setup : null };";',
    '  var mod = (new Function(spec.src + RET))();',
    '  if (!mod.fn) throw new Error("No function named fn was found. Define: function fn(n, data, run) { ... }");',
    '  var SINK = 0;',
    '  for (var i = 0; i < spec.ns.length; i++) {',
    '    var n = spec.ns[i], t0 = now();',
    '    var data = mod.setup ? mod.setup(n) : null;',
    '    SINK = mod.fn(n, data, 0);',
    '    var reps = 1, dt = 0, a = 0, k = 0;',
    '    for (;;) {',
    '      a = now();',
    '      for (k = 0; k < reps; k++) SINK = mod.fn(n, data, k);',
    '      dt = now() - a;',
    '      if (dt >= spec.minBatch || reps >= spec.maxReps) break;',
    '      var grow = (dt <= 0.05) ? 8 : Math.min(8, Math.max(2, Math.ceil(spec.minBatch / dt)));',
    '      reps = Math.min(spec.maxReps, reps * grow);',
    '    }',
    '    var s = [dt / reps];',
    '    while (s.length < spec.trials && (now() - t0) < spec.perN) {',
    '      a = now();',
    '      for (k = 0; k < reps; k++) SINK = mod.fn(n, data, k);',
    '      s.push((now() - a) / reps);',
    '    }',
    '    s.sort(function (x, y) { return x - y; });',
    '    emit({ type: "point", n: n, t: s[0], reps: reps, trials: s.length,',
    '           sink: (typeof SINK === "number" && isFinite(SINK)) ? SINK : 0 });',
    '    data = null;',
    '  }',
    '  emit({ type: "done" });',
    '}'
  ].join('\n');

  var WORKER_SRC = ENGINE + '\n' + [
    'self.onmessage = function (e) {',
    '  try { __profile(e.data, function (m) { self.postMessage(m); }); }',
    '  catch (err) { self.postMessage({ type: "error", message: String((err && err.message) || err) }); }',
    '};'
  ].join('\n');

  var workerURL = null;
  try {
    workerURL = URL.createObjectURL(new Blob([WORKER_SRC], { type: 'text/javascript' }));
  } catch (e) { workerURL = null; }

  /* ── presets ───────────────────────────────────────────────────────────── */
  var PRESETS = [
    { id: 'scan', name: 'linear scan', expect: 'lin', nMin: 1000, nMax: 1000000, pts: 12, code:
'// Sum every element of an n-element array.\n' +
'// setup() builds it untimed, so what we\n' +
'// measure is the scan, not the allocation.\n' +
'function setup(n) {\n' +
'  const a = new Int32Array(n);\n' +
'  for (let i = 0; i < n; i++)\n' +
'    a[i] = (i * 2654435761) % 1000;\n' +
'  return a;\n' +
'}\n\n' +
'function fn(n, a, run) {\n' +
'  let sum = 0;\n' +
'  for (let i = 0; i < n; i++) sum += a[i];\n' +
'  return sum;\n' +
'}\n' },

    { id: 'bsearch', name: 'binary search', expect: 'log', nMin: 500, nMax: 2000000, pts: 14, code:
'// Binary search in a sorted array. It is\n' +
'// built untimed in setup(), so only the\n' +
'// probing is measured. `run` moves the target.\n' +
'function setup(n) {\n' +
'  const a = new Int32Array(n);\n' +
'  for (let i = 0; i < n; i++) a[i] = i * 2;\n' +
'  return a;\n' +
'}\n\n' +
'function fn(n, a, run) {\n' +
'  const target = (run * 7919) % (2 * n);\n' +
'  let lo = 0, hi = n - 1;\n' +
'  while (lo <= hi) {\n' +
'    const mid = (lo + hi) >> 1;\n' +
'    if (a[mid] === target) return mid;\n' +
'    if (a[mid] < target) lo = mid + 1;\n' +
'    else hi = mid - 1;\n' +
'  }\n' +
'  return -1;\n' +
'}\n' },

    { id: 'bubble', name: 'bubble sort', expect: 'quad', nMin: 60, nMax: 1400, pts: 10, code:
'// Bubble sort. Each timed call sorts a fresh\n' +
'// copy of the same random array. The O(n) copy\n' +
'// is dwarfed by the sort: the fit stays clean.\n' +
'function setup(n) {\n' +
'  const a = new Int32Array(n);\n' +
'  let s = 12345;\n' +
'  for (let i = 0; i < n; i++) {\n' +
'    s = (s * 1103515 + 12345) & 0x7fffffff;\n' +
'    a[i] = s % 1000;\n' +
'  }\n' +
'  return a;\n' +
'}\n\n' +
'function fn(n, src, run) {\n' +
'  const a = src.slice();\n' +
'  for (let i = 0; i < n; i++)\n' +
'    for (let j = 0; j < n - i - 1; j++)\n' +
'      if (a[j] > a[j + 1]) {\n' +
'        const t = a[j];\n' +
'        a[j] = a[j + 1];\n' +
'        a[j + 1] = t;\n' +
'      }\n' +
'  return a[0];\n' +
'}\n' },

    { id: 'fib', name: 'fib (recursive)', expect: 'exp', nMin: 12, nMax: 27, pts: 10, code:
'// The classic naive Fibonacci: two recursive\n' +
'// calls per level. Small n only -- the work\n' +
'// multiplies by ~1.618 with every extra n.\n' +
'function fn(n, data, run) {\n' +
'  function fib(k) {\n' +
'    if (k < 2) return k;\n' +
'    return fib(k - 1) + fib(k - 2);\n' +
'  }\n' +
'  return fib(n);\n' +
'}\n' },

    { id: 'runaway', name: 'runaway loop', expect: null, nMin: 8, nMax: 64, pts: 6, code:
'// Proof the sandbox works: it never returns.\n' +
'// The worker is terminated after the 3 s\n' +
'// budget; this page stays fully responsive.\n' +
'// Try scrolling while it hangs.\n' +
'function fn(n, data, run) {\n' +
'  let x = 0;\n' +
'  while (true) x = (x + 1) % 1000;\n' +
'  return x;\n' +
'}\n' }
  ];

  /* ── model library ─────────────────────────────────────────────────────── */
  var MODELS = [
    { id: 'const', label: 'O(1)',       term: '1',        cx: 0, basis: function (n) { return 1; } },
    { id: 'log',   label: 'O(log n)',   term: 'log₂n', cx: 1, basis: function (n) { return Math.log2(n); } },
    { id: 'lin',   label: 'O(n)',       term: 'n',        cx: 2, basis: function (n) { return n; } },
    { id: 'nlogn', label: 'O(n log n)', term: 'n·log₂n', cx: 3, basis: function (n) { return n * Math.log2(n); } },
    { id: 'quad',  label: 'O(n²)', term: 'n²',  cx: 4, basis: function (n) { return n * n; } },
    { id: 'exp',   label: 'O(bⁿ)', term: 'b^n',      cx: 5, kind: 'exp' }
  ];

  /* ── dom ───────────────────────────────────────────────────────────────── */
  var $ = function (s) { return document.querySelector(s); };
  var elVerdict = $('#verdict'), elChips = $('#chips'), elCode = $('#code'), elMin = $('#nMin'), elMax = $('#nMax'),
      elPts = $('#nPts'), elRun = $('#run'), elStop = $('#stop'), elErr = $('#err'),
      elStatus = $('#status'), elEngine = $('#engine'), elProg = $('#prog'),
      elBig = $('#vBig'), elR2 = $('#vR2'), elP = $('#vP'), elNext = $('#vNext'),
      elCount = $('#vPts'), elMatch = $('#vMatch'), elMt = $('#mtBody'), elMs = $('#msBody'),
      elTHead = $('#tHead'), elReadout = $('#readout'), elLog = $('#logToggle'),
      elSep = $('#sep'), elAmbig = $('#ambig'),
      elFit = $('#fitToggle'), cv = $('#chart'), ctx = cv.getContext('2d');

  /* ── state ─────────────────────────────────────────────────────────────── */
  var points = [], fits = [], best = null, expectId = null, selPoint = -1, selModel = null,
      userPicked = false, lastPick = null, inlineAck = false, curPreset = null,
      unit = { u: 'ms', k: 1 }, worker = null, watchdog = null, running = false,
      expected = 0, allowInline = false, usedInline = false;
  var BUDGET = 3000;

  /* ── helpers ───────────────────────────────────────────────────────────── */
  function fmtN(v) {
    if (v >= 1e6) return trim((v / 1e6).toFixed(v >= 1e7 ? 0 : 1)) + 'M';
    if (v >= 1e3) return trim((v / 1e3).toFixed(v >= 1e4 ? 0 : 1)) + 'k';
    return String(v);
  }
  function trim(s) { return s.indexOf('.') < 0 ? s : s.replace(/\.?0+$/, ''); }
  function sig(v) {
    var a = Math.abs(v);
    if (a >= 1000) return v.toFixed(0);
    if (a >= 100) return v.toFixed(1);
    if (a >= 10) return v.toFixed(2);
    if (a >= 1) return v.toFixed(3);
    return v.toFixed(4);
  }
  function expo(v) {
    if (v === 0) return '0';
    var a = Math.abs(v);
    if (a >= 0.001 && a < 10000) return trim(v.toPrecision(3));
    return v.toExponential(2);
  }
  function unitFor(maxT) {
    if (!(maxT > 0)) return { u: 'ms', k: 1 };
    if (maxT >= 1) return { u: 'ms', k: 1 };
    if (maxT >= 1e-3) return { u: 'µs', k: 1e3 };
    return { u: 'ns', k: 1e6 };
  }
  function geom(a, b, k) {
    var out = [], r = Math.pow(b / a, 1 / (k - 1)), i, v;
    for (i = 0; i < k; i++) {
      v = Math.max(1, Math.round(a * Math.pow(r, i)));
      if (out.length === 0 || out[out.length - 1] !== v) out.push(v);
    }
    if (out[out.length - 1] !== b) out.push(b);
    return out;
  }
  function css(name) {
    return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888';
  }

  /* ── fitting ───────────────────────────────────────────────────────────── */
  function fitAll(pts) {
    var n = pts.map(function (p) { return p.n; });
    var t = pts.map(function (p) { return Math.max(p.t, 1e-7); });
    var i, w, sw = 0, st = 0;
    for (i = 0; i < t.length; i++) { w = 1 / (t[i] * t[i]); sw += w; st += w * t[i]; }
    var tbar = st / sw, sst = 0;
    for (i = 0; i < t.length; i++) { w = 1 / (t[i] * t[i]); sst += w * (t[i] - tbar) * (t[i] - tbar); }

    var out = MODELS.map(function (m) {
      var at, form, ok = true;
      if (m.kind === 'exp') {
        var N = n.length, sx = 0, sy = 0, sxx = 0, sxy = 0, x, y;
        for (i = 0; i < N; i++) { x = n[i]; y = Math.log(t[i]); sx += x; sy += y; sxx += x * x; sxy += x * y; }
        var den = N * sxx - sx * sx;
        var B = den ? (N * sxy - sx * sy) / den : 0;
        var A = (sy - B * sx) / N;
        var aa = Math.exp(A), bb = Math.exp(B);
        if (!(B > 1e-9) || !isFinite(aa)) ok = false;
        at = function (v) { return aa * Math.pow(bb, v); };
        form = 't ≈ ' + expo(aa) + '·' + (isFinite(bb) ? bb.toFixed(3) : '?') + 'ⁿ';
        m.base = bb;
      } else if (m.id === 'const') {
        var c = tbar;
        at = function () { return c; };
        form = 't ≈ ' + expo(c);
      } else {
        var sf = 0, sff = 0, sft = 0, f;
        for (i = 0; i < n.length; i++) {
          w = 1 / (t[i] * t[i]); f = m.basis(n[i]);
          sf += w * f; sff += w * f * f; sft += w * f * t[i];
        }
        var det = sw * sff - sf * sf;
        var a1 = det ? (sw * sft - sf * st) / det : 0;
        var b1 = det ? (sff * st - sf * sft) / det : tbar;
        if (!(a1 > 0) || !isFinite(a1)) { a1 = 0; b1 = tbar; ok = false; }
        at = function (v) { return a1 * m.basis(v) + b1; };
        form = 't ≈ ' + expo(a1) + '·' + m.term +
               (b1 >= 0 ? ' + ' + expo(b1) : ' − ' + expo(-b1));
      }
      var ssr = 0, finite = true, pr;
      for (i = 0; i < n.length; i++) {
        pr = at(n[i]);
        if (!isFinite(pr)) { finite = false; break; }
        w = 1 / (t[i] * t[i]);
        ssr += w * (t[i] - pr) * (t[i] - pr);
      }
      var r2 = (!ok || !finite || !(sst > 0)) ? 0 : 1 - ssr / sst;
      if (!isFinite(r2) || r2 < 0) r2 = 0;
      return { model: m, r2: r2, form: form, at: at, ok: ok && finite };
    });

    out.sort(function (x, y) { return y.r2 - x.r2 || x.model.cx - y.model.cx; });
    return out;
  }

  // empirical exponent p from log-log OLS: t ~ n^p
  function exponent(pts) {
    var N = pts.length, sx = 0, sy = 0, sxx = 0, sxy = 0, i, x, y;
    for (i = 0; i < N; i++) {
      x = Math.log(pts[i].n); y = Math.log(Math.max(pts[i].t, 1e-7));
      sx += x; sy += y; sxx += x * x; sxy += x * y;
    }
    var den = N * sxx - sx * sx;
    return den ? (N * sxy - sx * sy) / den : 0;
  }

  // Constant time is the one case least-squares cannot see: when t never grows,
  // every model degenerates and the "winner" is whichever one best traces the
  // noise. So test it directly — compare the median of the smallest third of n
  // against the median of the largest third. Medians shrug off the odd spike
  // from a GC pause or a busy core.
  function flatness(pts) {
    if (pts.length < 4) return null;
    var span = pts[pts.length - 1].n / pts[0].n;
    if (!(span > 20)) return null;
    var k = Math.max(1, Math.floor(pts.length / 3));
    var med = function (arr) {
      var v = arr.map(function (p) { return p.t; }).sort(function (a, b) { return a - b; });
      return v[v.length >> 1];
    };
    var head = med(pts.slice(0, k)), tail = med(pts.slice(pts.length - k));
    if (!(head > 0)) return null;
    var ratio = tail / head;
    // A band, not a ceiling. A series that gets *faster* as n grows is a
    // measurement artifact (JIT warm-up, a cold cache on the first points),
    // not constant time — let the normal fitter call that "no clear fit"
    // instead of claiming a flat line the data does not show.
    if (!(ratio > 0.65)) return null;
    // And "barely grew" is relative to the range. Over n = 1e6 … 1e8 an honest
    // O(log n) only grows ×1.33, so a fixed ceiling would report it as O(1).
    // Compare against what log n would have done over *this* span instead, and
    // only claim flat when the curve stayed nearer 1 than that.
    var logGrowth = Math.log(pts[pts.length - 1].n) / Math.log(Math.max(2, pts[0].n));
    var ceil = Math.min(1.5, 1 + Math.max(0.06, (logGrowth - 1) * 0.5));
    if (!(ratio < ceil)) return null;
    return { ratio: ratio, span: span };
  }

  function pickBest(list, pts) {
    var i;
    var f = flatness(pts);
    if (f) {
      for (i = 0; i < list.length; i++)
        if (list[i].model.id === 'const') return { fit: list[i], flat: true, span: f.span, ratio: f.ratio };
    }
    var top = list[0], cand = top;
    for (i = 1; i < list.length; i++) {
      if (list[i].r2 >= top.r2 - 0.0015 && list[i].r2 > 0.9 &&
          list[i].model.cx < cand.model.cx) cand = list[i];
    }
    return { fit: cand, flat: false, top: top };
  }

  /* ── rendering ─────────────────────────────────────────────────────────── */
  function setStatus(text, kind) {
    elStatus.textContent = text;
    elStatus.className = 'chip' + (kind ? ' ' + kind : '');
  }
  function showErr(msg) {
    if (!msg) { elErr.className = 'err'; elErr.textContent = ''; return; }
    elErr.className = 'err on';
    elErr.textContent = msg;
  }

  function renderTables() {
    var i, tr, td, btn, r, html = '';
    // This table is rebuilt after every measurement, so a keyboard user who is
    // sitting on a model row would otherwise lose focus mid-run.
    var act = document.activeElement;
    var refocus = (act && act.className === 'rowbtn') ? act.getAttribute('data-id') : null;
    unit = unitFor(points.length ? Math.max.apply(null, points.map(function (p) { return p.t; })) : 0);
    // unit.u is one of 'ms' | 'µs' | 'ns' — literals, never user input.
    elTHead.innerHTML = 't <span class="u">(' + unit.u + '/call)</span>';

    for (i = 0; i < points.length; i++) {
      html += '<tr><td>' + points[i].n.toLocaleString('en-US') + '</td><td>' +
              sig(points[i].t * unit.k) + '</td><td class="dim">' +
              fmtN(points[i].reps) + '×' + points[i].trials + '</td></tr>';
    }
    elMs.innerHTML = html || '<tr><td colspan="3" class="dim">' +
      (running ? 'measuring…' : 'no measurements yet') + '</td></tr>';

    elMt.innerHTML = '';
    if (!fits.length) {
      elMt.innerHTML = '<tr><td colspan="3" class="dim">' +
        (points.length ? 'need 4 or more sizes of n to fit' : 'run to fit models') + '</td></tr>';
      return;
    }
    for (i = 0; i < fits.length; i++) {
      r = fits[i];
      tr = document.createElement('tr');
      if (selModel === r.model.id) tr.className = 'sel';
      td = document.createElement('td');
      btn = document.createElement('button');
      btn.className = 'rowbtn';
      btn.type = 'button';
      btn.setAttribute('aria-pressed', selModel === r.model.id ? 'true' : 'false');
      btn.textContent = (best && best.model.id === r.model.id ? '✓ ' : '  ') + r.model.label;
      if (best && best.model.id === r.model.id) {
        btn.innerHTML = '<span class="tick">✓</span> ' + r.model.label;
        btn.setAttribute('aria-label', r.model.label + ' — best fit');
      }
      btn.setAttribute('data-id', r.model.id);
      btn.addEventListener('click', function () {
        userPicked = true;
        selModel = this.getAttribute('data-id');
        // renderTables() rebuilds these rows and puts focus back on the
        // replacement node, so a keyboard user is not dumped to the top.
        renderTables(); draw();
      });
      td.appendChild(btn); tr.appendChild(td);
      td = document.createElement('td');
      td.className = 'fcol dim'; td.textContent = r.ok ? r.form : 'no growth fit';
      tr.appendChild(td);
      td = document.createElement('td');
      td.textContent = r.ok ? r.r2.toFixed(4) : '—';
      tr.appendChild(td);
      elMt.appendChild(tr);
    }
    if (refocus) {
      var back = elMt.querySelector('.rowbtn[data-id="' + refocus + '"]');
      if (back) { try { back.focus({ preventScroll: true }); } catch (e) { back.focus(); } }
    }
  }

  function renderVerdict(flat, info) {
    elCount.textContent = String(points.length);
    if (!best) {
      elBig.textContent = '—'; elR2.textContent = '—';
      elP.textContent = '—'; elNext.textContent = '—';
      elMatch.textContent = ' '; elMatch.className = 'match';
      elSep.textContent = 'Δ —'; elSep.hidden = false;
      if (points.length && !running) {
        elAmbig.hidden = false;
        elAmbig.textContent = 'Only ' + points.length + ' distinct value' +
          (points.length === 1 ? '' : 's') + ' of n came back — the fitter needs at least 4. ' +
          'Widen the gap between “n from” and “n to”, or raise “points”.';
      } else { elAmbig.hidden = true; elAmbig.textContent = ''; }
      elVerdict.className = 'verdict';
      // Without this the canvas keeps describing the *previous* run's verdict
      // to a screen reader for the whole of the next one.
      cv.setAttribute('aria-label', points.length
        ? 'Plot of ' + points.length + ' measurements of time per call against input size n. No fit yet.'
        : 'Plot of time per call against input size n. No measurements yet.');
      return;
    }
    var label = best.model.label;
    if (best.model.id === 'exp' && isFinite(best.model.base)) {
      label = 'O(' + best.model.base.toFixed(2) + 'ⁿ)';
    }
    var noFit = !flat && best.r2 < 0.2;
    elBig.textContent = noFit ? 'no clear fit' : label;
    elR2.textContent = flat ? 'flat' : best.r2.toFixed(4);
    elP.textContent = exponent(points).toFixed(2);
    var next = null, i;
    for (i = 0; i < fits.length; i++) if (fits[i].model.id !== best.model.id) { next = fits[i]; break; }
    elNext.textContent = next ? next.model.label + ' ' + next.r2.toFixed(3) : '—';

    if (next && !flat) {
      var gap = Math.abs(best.r2 - next.r2);
      elSep.hidden = false;
      elSep.textContent = 'Δ ' + gap.toFixed(3) + ' · ' + (gap < 0.02 ? 'close call' : 'clear');
      if (best.r2 < 0.75) {
        elAmbig.hidden = false;
        elAmbig.textContent = 'Best R² is only ' + best.r2.toFixed(3) + ' — nothing in this library ' +
          'really fits. The timings are probably dominated by something else: cache behaviour, ' +
          'garbage collection, JIT warm-up, or plain noise from other tabs.';
      } else if (best.r2 < 0.9 && gap >= 0.02) {
        elAmbig.hidden = false;
        elAmbig.textContent = best.model.label + ' still wins clearly, but at R² ' + best.r2.toFixed(3) +
          ' the curve is noisy — something is disturbing the timings (garbage collection, cache ' +
          'effects, or a busy machine). Run it again for a cleaner reading.';
      } else if (gap < 0.02) {
        elAmbig.hidden = false;
        elAmbig.textContent = best.model.label + ' and ' + next.model.label + ' fit within ' +
          gap.toFixed(3) + ' R² of each other — over this range of n they are not distinguishable. ' +
          'Widen the range of n, or add points.';
      } else { elAmbig.hidden = true; elAmbig.textContent = ''; }
    } else if (flat && info && info.span) {
      elSep.hidden = false;
      elSep.textContent = 'flat · ' + fmtN(Math.round(info.span)) + '× range';
      elAmbig.hidden = false;
      elAmbig.textContent = 'n grew ' + fmtN(Math.round(info.span)) + '× and the time per call changed by only ×' +
        info.ratio.toFixed(2) + '. That is what constant time looks like — no curve fit can beat a flat line here.';
    } else { elSep.hidden = true; elAmbig.hidden = true; elAmbig.textContent = ''; }
    elVerdict.className = 'verdict' + (!flat && best.r2 < 0.75 ? ' weak' : '') +
                          (noFit ? ' nofit' : '');

    if (expectId) {
      if (expectId === best.model.id) {
        elMatch.textContent = '✓ matches expected';
        elMatch.className = 'match ok';
      } else {
        var exp = MODELS.filter(function (m) { return m.id === expectId; })[0];
        elMatch.textContent = 'expected ' + (exp ? exp.label : expectId);
        elMatch.className = 'match';
      }
    } else {
      elMatch.textContent = ' ';
      elMatch.className = 'match';
    }
    cv.setAttribute('aria-label',
      'Chart of ' + points.length + ' measurements of time per call against input size n, ' +
      (elLog.checked ? 'log-log axes' : 'linear axes') + '. ' +
      (noFit ? 'No model in the library fits' : 'Best fit ' + label) +
      (flat ? ', times flat across the range.' : ', R squared ' + best.r2.toFixed(4) + '.'));
  }

  /* ── chart ─────────────────────────────────────────────────────────────── */
  function ticksLin(lo, hi, count) {
    var span = hi - lo;
    if (!(span > 0)) return [lo];
    var raw = span / count, mag = Math.pow(10, Math.floor(Math.log10(raw))), norm = raw / mag;
    var step = (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10) * mag;
    var out = [], v;
    for (v = Math.ceil(lo / step) * step; v <= hi + step * 1e-9; v += step) out.push(v);
    return out;
  }
  function ticksLog(lo, hi) {
    var d0 = Math.floor(Math.log10(lo)), d1 = Math.ceil(Math.log10(hi)), out = [], d, m;
    var decades = d1 - d0;
    var mults = decades <= 2 ? [1, 2, 5] : decades <= 5 ? [1, 3] : [1];
    for (d = d0; d <= d1; d++) {
      for (m = 0; m < mults.length; m++) {
        var v = mults[m] * Math.pow(10, d);
        if (v >= lo * 0.999 && v <= hi * 1.001) out.push(v);
      }
    }
    return out.length ? out : [lo, hi];
  }

  var lastX = null;
  function draw() {
    var w = cv.clientWidth, h = cv.clientHeight;
    if (!w || !h) return;
    var dpr = Math.min(2, window.devicePixelRatio || 1);
    if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) {
      cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, w, h);
    // draw() runs after every state change, so this is the one place guaranteed
    // to see the current point count.
    if (selPoint < 0) elReadout.textContent = readoutHint();

    var ink = css('--ink'), muted = css('--muted'), line = css('--line'),
        accent = css('--accent'), surface = css('--surface'), grid = css('--grid');
    var mono = '10px ' + css('--font-mono');
    var narrow = w < 380;
    var padL = narrow ? 48 : 54, padR = 12, padT = 12, padB = 26;
    var pw = Math.max(10, w - padL - padR), ph = Math.max(10, h - padT - padB);

    if (!points.length) {
      ctx.fillStyle = muted; ctx.font = mono; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
      ctx.fillText(running ? 'measuring…' : 'no measurements yet', w / 2, h / 2);
      return;
    }

    var logMode = elLog.checked;
    var ns = points.map(function (p) { return p.n; });
    var ts = points.map(function (p) { return Math.max(p.t, 1e-9) * unit.k; });
    var nLo = Math.min.apply(null, ns), nHi = Math.max.apply(null, ns);
    var tLo = Math.min.apply(null, ts), tHi = Math.max.apply(null, ts);

    var xLo, xHi, yLo, yHi, xt, yt;
    if (logMode) {
      xLo = nLo / 1.25; xHi = nHi * 1.25;
      yLo = tLo / 1.6; yHi = tHi * 1.6;
      xt = ticksLog(xLo, xHi); yt = ticksLog(yLo, yHi);
    } else {
      xLo = 0; xHi = nHi * 1.04;
      yLo = 0; yHi = tHi * 1.1 || 1;
      xt = ticksLin(xLo, xHi, narrow ? 3 : 5); yt = ticksLin(yLo, yHi, 4);
    }
    var X = lastX = function (v) {
      if (logMode) return padL + (Math.log10(Math.max(v, 1e-12)) - Math.log10(xLo)) / (Math.log10(xHi) - Math.log10(xLo)) * pw;
      return padL + (v - xLo) / (xHi - xLo) * pw;
    };
    var Y = function (v) {
      if (logMode) return padT + ph - (Math.log10(Math.max(v, 1e-12)) - Math.log10(yLo)) / (Math.log10(yHi) - Math.log10(yLo)) * ph;
      return padT + ph - (v - yLo) / (yHi - yLo) * ph;
    };

    // grid + ticks
    ctx.font = mono; ctx.lineWidth = 1;
    ctx.strokeStyle = grid;
    ctx.beginPath();
    yt.forEach(function (v) { var y = Math.round(Y(v)) + 0.5; ctx.moveTo(padL, y); ctx.lineTo(padL + pw, y); });
    xt.forEach(function (v) { var x = Math.round(X(v)) + 0.5; ctx.moveTo(x, padT); ctx.lineTo(x, padT + ph); });
    ctx.stroke();

    ctx.fillStyle = muted; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
    yt.forEach(function (v) {
      var y = Y(v);
      if (y < padT - 2 || y > padT + ph + 2) return;
      ctx.fillText(logMode ? fmtNum(v) : trim(v.toPrecision(3)), padL - 6, y);
    });
    ctx.textAlign = 'center'; ctx.textBaseline = 'top';
    xt.forEach(function (v) {
      var x = X(v);
      if (x < padL - 2 || x > padL + pw + 2) return;
      ctx.fillText(fmtN(Math.round(v)), x, padT + ph + 7);
    });

    // axis frame
    ctx.strokeStyle = line;
    ctx.beginPath();
    ctx.moveTo(padL + 0.5, padT); ctx.lineTo(padL + 0.5, padT + ph + 0.5); ctx.lineTo(padL + pw, padT + ph + 0.5);
    ctx.stroke();

    // axis titles
    ctx.fillStyle = muted; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
    ctx.fillText(unit.u + '/call', padL + 3, padT - 1);
    ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
    ctx.fillText('n', padL + pw, padT + ph - 2);

    // fitted curve
    var cur = null, i;
    if (fits.length) {
      for (i = 0; i < fits.length; i++) if (fits[i].model.id === selModel) cur = fits[i];
      if (!cur) cur = best;
    }
    if (cur && cur.ok && elFit.checked) {
      ctx.strokeStyle = muted; ctx.lineWidth = 1.6; ctx.setLineDash([5, 4]);
      ctx.beginPath();
      var steps = 140, started = false;
      for (i = 0; i <= steps; i++) {
        var f = i / steps;
        var nv = logMode ? Math.pow(10, Math.log10(xLo) + f * (Math.log10(xHi) - Math.log10(xLo)))
                         : xLo + f * (xHi - xLo);
        if (nv < 1) continue;
        var tv = cur.at(nv) * unit.k;
        if (!isFinite(tv) || tv <= 0) { started = false; continue; }
        var px = X(nv), py = Y(tv);
        if (py < padT - 400 || py > padT + ph + 400) { started = false; continue; }
        if (!started) { ctx.moveTo(px, py); started = true; } else ctx.lineTo(px, py);
      }
      ctx.stroke(); ctx.setLineDash([]);
    }

    // points
    for (i = 0; i < points.length; i++) {
      var px2 = X(ns[i]), py2 = Y(ts[i]);
      if (i === selPoint) {
        ctx.strokeStyle = accent; ctx.lineWidth = 1; ctx.setLineDash([2, 3]);
        ctx.beginPath();
        ctx.moveTo(padL, py2); ctx.lineTo(px2, py2); ctx.lineTo(px2, padT + ph);
        ctx.stroke(); ctx.setLineDash([]);
      }
      ctx.beginPath(); ctx.arc(px2, py2, i === selPoint ? 5.5 : 3.6, 0, Math.PI * 2);
      ctx.fillStyle = accent; ctx.fill();
      ctx.lineWidth = 1.5; ctx.strokeStyle = surface; ctx.stroke();
    }
    ctx.fillStyle = ink;
  }

  function fmtNum(v) {
    if (v >= 1000) return fmtN(Math.round(v));
    if (v >= 1) return trim(v.toPrecision(3));
    return trim(v.toPrecision(2));
  }

  /* ── chart interaction ─────────────────────────────────────────────────── */
  function readoutHint() {
    if (points.length) return 'Hover the chart — or focus it and use ← → — to inspect a measurement.';
    return running ? 'Measuring…' : 'No measurements yet — press Run profile.';
  }
  function setSel(i) {
    selPoint = i;
    if (i < 0 || !points[i]) {
      elReadout.textContent = readoutHint();
    } else {
      var p = points[i];
      var predTxt = '';
      var cur = null, j;
      for (j = 0; j < fits.length; j++) if (fits[j].model.id === (selModel || (best && best.model.id))) cur = fits[j];
      if (cur && cur.ok) {
        var pv = cur.at(p.n);
        if (isFinite(pv) && pv > 0) {
          predTxt = ' · ' + cur.model.label + ' predicts ' + sig(pv * unit.k) + ' ' + unit.u +
                    ' (' + (p.t > 0 ? ((p.t / pv - 1) * 100 >= 0 ? '+' : '') + ((p.t / pv - 1) * 100).toFixed(1) : '0') + '%)';
        }
      }
      elReadout.innerHTML = 'n = <b>' + p.n.toLocaleString('en-US') + '</b> · <b>' +
        sig(p.t * unit.k) + ' ' + unit.u + '</b>/call · ' + fmtN(p.reps) + ' reps × ' +
        p.trials + ' trials' + predTxt;
    }
    draw();
  }
  function nearest(clientX) {
    if (!points.length || !lastX) return -1;
    var r = cv.getBoundingClientRect();
    var x = (clientX - r.left) * (cv.clientWidth / Math.max(1, r.width));
    var bestI = 0, bestD = Infinity, i, d;
    for (i = 0; i < points.length; i++) {
      d = Math.abs(lastX(points[i].n) - x);
      if (d < bestD) { bestD = d; bestI = i; }
    }
    return bestI;
  }
  var scrubbing = false;
  cv.addEventListener('pointerdown', function (e) {
    scrubbing = true;
    if (e.pointerType !== 'mouse') { try { cv.focus(); } catch (err) {} }
    setSel(nearest(e.clientX));
  });
  cv.addEventListener('pointermove', function (e) {
    if (e.pointerType === 'mouse' || scrubbing) setSel(nearest(e.clientX));
  });
  cv.addEventListener('pointerleave', function (e) {
    if (e.pointerType === 'mouse' && !scrubbing) setSel(-1);
  });
  cv.addEventListener('pointercancel', function () { scrubbing = false; });
  window.addEventListener('pointerup', function () { scrubbing = false; });
  cv.addEventListener('keydown', function (e) {
    if (!points.length) return;
    var k = e.key, i = selPoint;
    if (k === 'ArrowRight' || k === 'ArrowUp') i = (i < 0 ? -1 : i) + 1;
    else if (k === 'ArrowLeft' || k === 'ArrowDown') i = (i < 0 ? points.length : i) - 1;
    else if (k === 'Home') i = 0;
    else if (k === 'End') i = points.length - 1;
    else if (k === 'Escape') i = -1;
    else return;
    e.preventDefault();
    setSel(Math.max(-1, Math.min(points.length - 1, i)));
  });

  /* ── run control ───────────────────────────────────────────────────────── */
  function clampInputs() {
    var a = Math.max(1, Math.round(Number(elMin.value) || 1));
    var b = Math.max(a + 1, Math.round(Number(elMax.value) || a + 1));
    var k = Math.max(4, Math.min(24, Math.round(Number(elPts.value) || 10)));
    elMin.value = a; elMax.value = b; elPts.value = k;
    return { a: a, b: b, k: k };
  }

  function finish(kind, text) {
    running = false;
    clearTimeout(watchdog); watchdog = null;
    if (worker) { try { worker.terminate(); } catch (e) {} worker = null; }
    elRun.disabled = false; elStop.disabled = true;
    setStatus(text, kind);
  }

  function analyse() {
    if (points.length >= 4) {
      fits = fitAll(points);
      var pick = pickBest(fits, points);
      best = pick.fit; lastPick = pick;
      // The overlay follows the verdict as it firms up, unless the visitor
      // has explicitly picked a model to compare against.
      if (!userPicked || !fits.some(function (f) { return f.model.id === selModel; })) selModel = best.model.id;
      renderVerdict(pick.flat, pick);
    } else {
      fits = []; best = null; selModel = null; lastPick = null;
      renderVerdict(false);
    }
    renderTables();
    draw();
  }

  function handle(msg) {
    if (msg.type === 'point') {
      points.push({ n: msg.n, t: msg.t, reps: msg.reps, trials: msg.trials });
      elProg.style.width = Math.round(points.length / Math.max(1, expected) * 100) + '%';
      setStatus('profiling · ' + points.length + '/' + expected + ' · n=' + fmtN(msg.n), 'busy');
      unit = unitFor(Math.max.apply(null, points.map(function (p) { return p.t; })));
      // analyse() handles the <4 case itself, and it is what keeps the
      // "points" counter in the verdict in step with the table.
      analyse();
    } else if (msg.type === 'done') {
      // finish() first: it settles `running`, which the verdict reads when it
      // has to explain why too few points came back.
      finish('ok', '✓ ' + points.length + ' points measured' + (usedInline ? ' (main thread)' : ''));
      analyse();
    } else if (msg.type === 'error') {
      showErr(msg.message);
      finish('bad', 'error in your function');
      analyse();
    }
  }

  function onOverrun() {
    var got = points.length;
    finish('bad', 'watchdog · worker terminated at 3.0 s');
    showErr('Time budget exceeded: fn() did not return within 3.0 s.\n' +
            'The worker was terminated' + (got ? ' after ' + got + ' point(s).' : '.') +
            ' This page was never blocked.');
    analyse();
  }

  function startInline(spec) {
    usedInline = true;
    elEngine.textContent = 'main thread';
    running = true; elRun.disabled = true; elStop.disabled = false;
    setStatus('running on main thread…', 'busy');
    // The profile is synchronous and blocks the page, so hand the browser one
    // frame to paint that status before it locks up.
    setTimeout(function () {
      if (!running) return;
      var batch = [];
      try {
        var fnp = new Function(ENGINE + '\n;return __profile;')();
        fnp(spec, function (m) { batch.push(m); });
      } catch (err) {
        batch.push({ type: 'error', message: String((err && err.message) || err) });
      }
      for (var i = 0; i < batch.length; i++) handle(batch[i]);
      if (running) finish('ok', '✓ ' + points.length + ' points measured (main thread)');
    }, 30);
  }

  // Main-thread execution has no watchdog: a runaway loop freezes the tab.
  // That is fine for a preset we shipped and know terminates (expectId is set
  // only while the editor still holds unmodified preset source); for anything
  // the visitor typed, make them ask for it once, explicitly.
  function inlineGuard(spec) {
    if (curPreset === 'runaway') {
      setStatus('needs a worker', 'bad');
      showErr('The runaway-loop preset exists to show the watchdog killing a Web Worker, and ' +
              'Workers are unavailable on this page. On the page’s own thread it would hang this ' +
              'tab with nothing able to stop it, so it is not run. Pick another example.');
      return false;
    }
    if (expectId) { startInline(spec); return true; }
    if (inlineAck) { startInline(spec); return true; }
    inlineAck = true;
    setStatus('no worker here — press Run again', 'bad');
    showErr('Web Workers are unavailable on this page, so your function would have to run on ' +
            'the page’s own thread.\nPress Run again to do that — but note the 3.0 s watchdog can ' +
            'only terminate a worker, so a function that never returns will freeze this tab.');
    return false;
  }

  function run() {
    if (running) finish(null, 'restarting…');
    showErr('');
    var c = clampInputs();
    var ns = geom(c.a, c.b, c.k);
    points = []; fits = []; best = null; selPoint = -1; selModel = null; lastPick = null;
    userPicked = false; expected = ns.length;
    elProg.style.width = '0%';
    renderVerdict(false); renderTables(); setSel(-1); draw();

    var spec = { src: elCode.value, ns: ns, minBatch: 12, maxReps: 1 << 25, trials: 7, perN: 420 };

    if (!workerURL || allowInline) { inlineGuard(spec); return; }

    try {
      worker = new Worker(workerURL);
    } catch (err) {
      // A platform CSP of default-src 'none' blocks blob: workers outright.
      // Falling straight through to the main thread keeps the demo alive
      // instead of opening on a dead error state.
      allowInline = true;
      showErr('');
      if (!inlineGuard(spec)) {
        setStatus('blob: workers blocked here — press Run again', 'bad');
      }
      return;
    }
    usedInline = false;
    elEngine.textContent = 'worker';
    running = true; elRun.disabled = true; elStop.disabled = false;
    setStatus('starting worker…', 'busy');

    worker.onmessage = function (e) { arm(); handle(e.data); };
    worker.onerror = function (e) {
      if (e && e.preventDefault) e.preventDefault();
      if (!points.length) {
        allowInline = true;
        finish(null, 'worker unavailable');
        showErr('');
        if (!inlineGuard(spec)) setStatus('worker unavailable — press Run again', 'bad');
      } else {
        finish('bad', 'worker error');
        showErr(String((e && e.message) || 'worker error'));
      }
    };
    arm();
    worker.postMessage(spec);
  }

  function arm() {
    clearTimeout(watchdog);
    watchdog = setTimeout(onOverrun, BUDGET);
  }

  elRun.addEventListener('click', run);
  elStop.addEventListener('click', function () {
    if (!running) return;
    finish(null, 'stopped · ' + points.length + ' points kept');
    analyse();
  });
  elLog.addEventListener('change', function () {
    renderVerdict(lastPick ? lastPick.flat : false, lastPick); draw();
  });
  elFit.addEventListener('change', draw);
  [elMin, elMax, elPts].forEach(function (el) {
    el.addEventListener('change', clampInputs);
  });
  elCode.addEventListener('input', function () {
    expectId = null; curPreset = null;
    var checked = elChips.querySelector('input:checked');
    if (checked) checked.checked = false;
    elMatch.textContent = ' '; elMatch.className = 'match';
  });

  /* ── presets ui ────────────────────────────────────────────────────────── */
  PRESETS.forEach(function (p, i) {
    var input = document.createElement('input');
    input.type = 'radio'; input.name = 'preset'; input.id = 'p-' + p.id; input.value = p.id;
    var label = document.createElement('label');
    label.setAttribute('for', 'p-' + p.id);
    label.textContent = p.name;
    input.addEventListener('change', function () { load(p); run(); });
    elChips.appendChild(input); elChips.appendChild(label);
  });

  function load(p) {
    curPreset = p.id;
    elCode.value = p.code;
    elMin.value = p.nMin; elMax.value = p.nMax; elPts.value = p.pts;
    expectId = p.expect;
    var el = document.getElementById('p-' + p.id);
    if (el) el.checked = true;
  }

  /* ── theme + resize ────────────────────────────────────────────────────── */
  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);
      draw();
    }
  });
  try {
    var mq = window.matchMedia('(prefers-color-scheme: dark)');
    if (mq.addEventListener) mq.addEventListener('change', draw);
    else if (mq.addListener) mq.addListener(draw);
  } catch (e) {}
  if (window.MutationObserver) {
    new MutationObserver(function () { draw(); })
      .observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
  }
  if (window.ResizeObserver) new ResizeObserver(function () { draw(); }).observe(cv);
  else window.addEventListener('resize', draw);

  /* ── boot ──────────────────────────────────────────────────────────────── */
  renderTables();
  load(PRESETS[0]);
  draw();
  setStatus('warming up…', 'busy');
  setTimeout(run, 90);
})();
</script>
</body>
</html>

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

Comments

0 total

No comments yet. If you ran it, say what happened.

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