diff --git a/CHANGELOG.md b/CHANGELOG.md index c73aac5..24b3acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Interactive comparison session + +`betterbench compare` with no arguments scans every run under `$BETTERBENCH_HOME/runs/` and opens an interactive comparison session — a gallery of all saved runs, each with its timestamp, note chips, and phase; pick two and their pair page opens with a comparison band (paired decode CIs, latency/prefill/concurrency median deltas, combined decode). The session is a loopback-only temporary server — 127.0.0.1, a kernel-picked free port, **Ctrl-C to stop** — and it writes no files of its own; it only reads run directories and serves them. + +### The compare band's stat honesty + +The band's *decode by category* rows are **paired CIs at 95%** — the per-pass `decode_tps` series paired by pass index, truncated to the shorter side. Latency, prefill, and concurrency deltas are **medians-only**: pass-level series from two uninterleaved runs have no shared trial identity, so BetterBench won't manufacture an interval. A banner on every pair page is always on: cross-file compare is unpaired in time, drift is indistinguishable from the change under test, and the verdict path is `betterbench ab`. Mismatch chips (corpus version, sampling, host, GPU, differing `--note` values) flag *which* deltas not to trust; a phase missing on one side renders "not measured", never zero. + ## 0.6.0 **Upgrading:** prefill throughput may read *lower* than it did on 0.5.0, and diff --git a/README.md b/README.md index 269236f..d5ca38c 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,41 @@ betterbench report ~/.betterbench/runs/20260211-091234-qwen3-8/results.json --ht base directory. (`betterbench report`/`compare` read whatever path you point them at and write nothing unless told to.) +## Compare your runs + +Two forms of `betterbench compare`, one keyword: + +```bash +# two saved results side by side — a per-category decode table in the terminal +betterbench compare results.json another.json + +# no args — an interactive session: it scans $BETTERBENCH_HOME/runs, +# opens a browser at a gallery of every saved run, and stops on Ctrl-C +betterbench compare +``` + +The no-args form serves every run directory under `$BETTERBENCH_HOME/runs/` +(`~/.betterbench/runs/` when the variable is unset) from a loopback-only +server that stops on Ctrl-C; the URL it prints is the entry point and the +server writes no files. Pick a run to open its report; pick two to open the +pair page. + +The pair page's comparison band shows the paired decode statistics — the +per-pass `decode_tps` series paired by pass index, truncated to the shorter +side, with a paired-t 95% CI and a SIG/noise verdict per category — plus the combined (weighted, each side's own +weights) decode, and median deltas for TTFT/ITL +(stream updates when batched), prefill per depth, and the concurrency +medians per level. + +The orange banner is always on, for a reason: cross-file comparisons are +**unpaired in time**, so drift between the two runs — thermal, cache- +warmth, minutes or days apart — is indistinguishable from the change under +test. For a verdict: run `betterbench ab`. The mismatch chips next to the +banner — corpus version, sampling, host, GPU — flag which of the deltas +not to trust (a corpus-version mismatch means the prompts differed, so +every Δ in that page is apples-to-oranges); a phase missing on one side +shows as "not measured", never as zero. + ## Authentication A request goes out unauthorised by default — right for a local vLLM or diff --git a/betterbench/cli.py b/betterbench/cli.py index 783231f..fe9325d 100644 --- a/betterbench/cli.py +++ b/betterbench/cli.py @@ -1,4 +1,4 @@ -"""BetterBench command line: run · report · compare · ab.""" +"""BetterBench command line: run · report · compare (files | no args = interactive session) · ab.""" from __future__ import annotations import argparse @@ -17,7 +17,7 @@ from .html_report import render_html from .report import render_ab_markdown, render_markdown, sample_gate from .runner import concurrency_sweep, paired_ab, prefill_sweep, single_stream -from .runs import allocate_run_dir +from .runs import allocate_run_dir, betterbench_home, RUNS_SUBDIR from . import update @@ -286,8 +286,14 @@ def cmd_compare(args): """Offline paired compare of two results.json (per-category decode-tps). Note: only valid if both were collected on the same warm box / interleaved — for a rigorous comparison use `ab`.""" - A = json.loads(Path(args.a).read_text()) - B = json.loads(Path(args.b).read_text()) + if len(args.results) == 0: + from . import session + session.start(betterbench_home() / RUNS_SUBDIR) + return + if len(args.results) != 2: + sys.exit(f"expected 2 results files — or none, for the interactive session — got {len(args.results)}") + A = json.loads(Path(args.results[0]).read_text()) + B = json.loads(Path(args.results[1]).read_text()) print("# BetterBench compare (offline, per-category decode t/s)\n") print("| category | A med | B med | Δ% | 95% CI | verdict |") print("|---|--:|--:|--:|---|---|") @@ -404,7 +410,9 @@ def main(argv=None): ab.set_defaults(func=cmd_ab) cmp = sub.add_parser("compare", help="offline compare two results.json", parents=[common]) - cmp.add_argument("a"); cmp.add_argument("b") + cmp.add_argument("results", nargs="*", metavar="RESULT_JSON", + help="two results.json to compare in the terminal; with " + "none, opens the interactive compare session in a browser") cmp.set_defaults(func=cmd_compare) args = p.parse_args(argv) diff --git a/betterbench/gallery.py b/betterbench/gallery.py new file mode 100644 index 0000000..ddce52f --- /dev/null +++ b/betterbench/gallery.py @@ -0,0 +1,844 @@ +"""List the runs, summarize each one, and render the all-runs gallery page +and the pair page between two chosen runs. + +A *reportable* run is a subdirectory of the runs dir whose `results.json` +parses. Its headline numbers are recomputed through the same row builders +(:mod:`betterbench.report`) the markdown and HTML reports use, so the +gallery can never drift from them. The pair page reuses the A/B command's +stat machinery: a paired-t on the per-pass decode difference +(``paired_compare``, pass-index pairing truncated to the shorter series) +plus unpaired medians for the phases that don't pair without an +interleaved `betterbench ab`. Everything here renders from a directory — +the serving layer runs on top. +""" +from __future__ import annotations + +import html +import json +import math +import statistics +import urllib.parse +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + +from .html_report import _pretty_ts +from .metrics import paired_compare +from .report import (combined_score, concurrency_rows, phases_present, + prefill_rows, report_is_batched, single_rows) + + +@dataclass(frozen=True) +class RunEntry: + """One subdirectory of the runs directory, classified.""" + slug: str + path: Path + results: dict | None # None when results.json is missing or corrupt + error: str | None # why, when not reportable + + +def list_runs(runs_dir: Path) -> tuple[list[RunEntry], list[RunEntry]]: + """``(reportable, skipped)`` over the immediate subdirectories of + ``runs_dir``, slug-descending (name order — 'newest first' for the + tool's timestamp-prefixed run-dir names). + + An ``ab.json``-only directory is not reportable but never silently + dropped: it lands in ``skipped`` with the reason, so the page can show + it. A missing runs directory is not an error — the caller decides. + """ + entries: list[RunEntry] = [] + if runs_dir.is_dir(): + for d in sorted(runs_dir.iterdir(), key=lambda p: p.name, reverse=True): + if not d.is_dir(): + continue + try: + obj = json.loads((d / "results.json").read_text()) + except FileNotFoundError: + entries.append(RunEntry(d.name, d, None, + "no results.json — maybe an ab-only run dir")) + except (json.JSONDecodeError, OSError) as e: + entries.append(RunEntry(d.name, d, None, str(e))) + else: + entries.append(RunEntry(d.name, d, obj, None)) + reportable = [e for e in entries if e.results is not None] + skipped = [e for e in entries if e.results is None] + return reportable, skipped + + +def gpu_label(env: dict) -> str | None: + """The GPU label, normalized: the first non-empty among the smi keys and + ``vendor``. The two ``*_smi*`` keys are smi output *lines — lists in + real files* — so the last non-empty line (the card line) is the label; + ``vendor`` is a plain string. Missing → ``None`` (chip omitted). + ``mismatch_chips`` (pair page) compares this same normalized value. + """ + gpu = env.get("gpu") or {} + for key in ("nvidia_smi", "rocm_smi_productname"): + val = gpu.get(key) + if not val: + continue + lines = val if isinstance(val, list) else [val] + for line in reversed(lines): + text = str(line).strip() + if text: + return text + v = gpu.get("vendor") + if v is None: + return None + text = str(v).strip() + return text or None + + +def _fmt(x, d=1) -> str: + """A headline number, or a dash. JSON NaN/inf never renders.""" + v = None + if x is not None: + try: + f = float(x) + except (TypeError, ValueError): + pass + else: + v = f if math.isfinite(f) else None + if v is None: + return "—" + return f"{v:,.{d}f}" + + +def run_manifest(e: RunEntry) -> dict: + """Headline numbers for one reportable run, from the report row builders. + + `.get`-safe: must never raise on an odd dict (missing `env`, a + non-dict `notes`, a non-numeric `max_model_len`, a phase entry the + row builders can't decode) and must never fabricate numbers — what's + missing is simply `None` / omitted. The row-builder calls are + guarded: a builder that can't decode a side's data means that number + is absent, not an error. + + Keys: name, model, endpoint, timestamp (the report's ``_pretty_ts`` + render, so a run with a colonless ``+0200`` timestamp shows the way its + report does), chips, phases, combined_decode (weighted; None when there + are no single-stream rows), ttft_p50 (median of the per-category + single-stream ttft p50 values), aggregate_top_conc (last concurrency + level's aggregate t/s), passes_per_category (the run header's own + number, not guessed from a row). + """ + if e.results is None: + raise ValueError(f"run {e.slug!r} has no parseable results — nothing to manifest") + if not isinstance(e.results, dict): + raise ValueError(f"run {e.slug!r} results are not an object — nothing to manifest") + res = e.results + env = res.get("env") or {} + if not isinstance(env, dict): + env = {} + cfg = res.get("config") or {} + if not isinstance(cfg, dict): + cfg = {} + try: + rows = single_rows(res) + except Exception: + rows = [] + try: + comb = combined_score(res, rows) + except Exception: + comb = None + try: + ttfts = [r["ttft_p50"] for r in rows if r.get("ttft_p50") is not None] + except Exception: + ttfts = [] + try: + conc = concurrency_rows(res) + except Exception: + conc = [] + chips = [str(env.get("model", "?")), + str(env.get("endpoint", "?")), + "corpus v" + str(res.get("corpus_version", "?"))] + chips.append("greedy" if cfg.get("greedy") + else f"temp {cfg.get('temperature')}") + chips.append("cold prefix cache (nonce)" if cfg.get("unique_nonce") + else "warm prefix cache") + notes = env.get("notes") + if isinstance(notes, dict): + for k, v in notes.items(): + chips.append(f"{k}: {v}") + gl = gpu_label(env) + if gl: + chips.append(gl) + ctx = env.get("max_model_len") + if ctx: + try: + ctx = int(ctx) + except (TypeError, ValueError): + ctx = None + if ctx: + chips.append(f"{ctx:,} tok context") + return { + "name": e.slug, + "model": env.get("model"), + "endpoint": env.get("endpoint"), + "timestamp": _pretty_ts(env.get("timestamp")), + "chips": chips, + "phases": phases_present(res), + "combined_decode": comb["decode"] if comb else None, + "ttft_p50": statistics.median(ttfts) if ttfts else None, + "aggregate_top_conc": conc[-1]["aggregate_tps"] if conc else None, + "passes_per_category": cfg.get("runs_per_category"), + } + + +def _esc(x) -> str: + return html.escape(str(x), quote=True) + + +def _or_dash(v) -> str: + """A value, or the missing marker. JSON null/empty never renders.""" + return "—" if v in (None, "") else str(v) + + +def _num(v) -> str: + """A number, or a dash. Unmeasured values never render the bare datum + (``None`` is the ``not measured`` path, handled by the caller).""" + return "—" if v is None else f"{v:,.2f}" + + +def _sampling_label(res: dict) -> str: + """The sampling chip, read the way the report header reads it: greedy + truthy vs not, temperature via ``config.get("temperature")``.""" + cfg = res.get("config") or {} + return "greedy" if cfg.get("greedy") else f"temp {cfg.get('temperature')}" + + +def mismatch_chips(a: dict, b: dict) -> list[str]: + """The pair page's comparability band: what differs between the two + `results.json` files, in this order — corpus version, sampling + (greedy vs temperature), host, the normalized GPU label (the same + `gpu_label` `run_manifest` uses), and each `env.notes` key of the + union of both files whose value differs or is present on only one + side. Missing sides render as —. `[]` when everything matches. + Total: any pair of `results.json` dicts returns a list without + raising (all lookups are `.get`). + """ + chips: list[str] = [] + ca = a.get("corpus_version") + cb = b.get("corpus_version") + if ca != cb: + chips.append(f"corpus v{ca} vs v{cb}") + sa = _sampling_label(a) + sb = _sampling_label(b) + if sa != sb: + chips.append(f"{sa} vs {sb}") + ha = (a.get("env") or {}).get("host") + hb = (b.get("env") or {}).get("host") + if ha != hb: + chips.append(f"{_or_dash(ha)} vs {_or_dash(hb)}") + ga = gpu_label(a.get("env") or {}) + gb = gpu_label(b.get("env") or {}) + if ga != gb: + chips.append(f"{_or_dash(ga)} vs {_or_dash(gb)}") + na = (a.get("env") or {}).get("notes") or {} + nb = (b.get("env") or {}).get("notes") or {} + for k in sorted(set(na) | set(nb)): + va = na.get(k) + vb = nb.get(k) + if va != vb: + chips.append(f"{k}: {_or_dash(va)} vs {_or_dash(vb)}") + return chips + + +def _reportable_row(e: RunEntry) -> str: + """One card row: the pick checkbox first (far left), then the slug + link, model, endpoint, when, the headline stats, and the chips. + ``.run`` is flexed with ``min-width:0`` so the chips wrap instead of + widening the page — no horizontal scroll.""" + m = run_manifest(e) + chips = "".join(f'{_esc(c)}' for c in m["chips"]) + val = urllib.parse.quote(str(e.slug), safe="") + stats = (f'combined decode {_fmt(m["combined_decode"])}' + f'TTFT p50 {_fmt(m["ttft_p50"])}' + f'top-conc aggregate {_fmt(m["aggregate_top_conc"])}' + f'phases {_esc(", ".join(m["phases"]) or "—")}') + return (f'
' + f'' + f'
' + f'
' + f'{_esc(e.slug)}' + f'{_or_dash(m["model"])}' + f'{_or_dash(m["endpoint"])}' + f'{_esc(m["timestamp"])}' + f'
' + f'
{stats}
' + f'
{chips}
' + f'
') + + +def _degraded_row(e: RunEntry, exc: Exception) -> str: + """The muted fallback for one reportable run whose row can't be + built: the same skip-row look, the exception in the error span, so + the bad run stays visible and the rest of the list survives.""" + return (f'') + + +def _skipped_row(e: RunEntry) -> str: + return (f'') + + +_PAGE_CSS = """ :root { + color-scheme: light; + --page:#f9f9f7; --surface:#fcfcfb; + --ink:#0b0b0b; --ink-2:#52514e; --muted:#898781; + --grid:#e1e0d9; + --s1:#2a78d6; --s2:#eb6834; + --sans:system-ui,-apple-system,"Segoe UI",sans-serif; + --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; + } + @media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --page:#0d0d0d; --surface:#1a1a19; + --ink:#ffffff; --ink-2:#c3c2b7; --muted:#898781; + --grid:#2c2c2a; + --s1:#3987e5; --s2:#d95926; + } + } + * { box-sizing:border-box; } + body { background:var(--page); color:var(--ink); font-family:var(--sans); + line-height:1.55; margin:0; padding:32px 20px 72px; } + .wrap { max-width:1100px; margin:0 auto; display:flex; flex-direction:column; gap:26px; } + header { display:flex; flex-direction:column; gap:13px; } + .eyebrow { font-family:var(--mono); font-size:11px; letter-spacing:.13em; + text-transform:uppercase; color:var(--muted); } + h1 { font-size:clamp(26px,4vw,36px); line-height:1.12; margin:0; + letter-spacing:-.02em; text-wrap:balance; } + code { font-family:var(--mono); font-size:.95em; } + .pickbar { display:flex; align-items:center; flex-wrap:wrap; gap:12px; } + .pickbar button { font-family:var(--mono); font-size:13px; + color:var(--page); background:var(--ink); border:0; + border-radius:7px; padding:8px 15px; cursor:pointer; } + .pickbar button:hover { background:var(--ink-2); } + .pickbar .hint { font-family:var(--mono); font-size:12px; color:var(--muted); } + .runs { background:var(--surface); border:1px solid var(--grid); + border-radius:10px; padding:14px 16px; } + .row, .skip { display:flex; gap:16px; padding:14px 0; + border-bottom:1px solid var(--grid); } + .pick { flex:0 0 auto; padding-top:3px; display:inline-flex; } + .run { flex:1 1 auto; min-width:0; } + .line1 { display:flex; flex-wrap:wrap; gap:6px 14px; + font-family:var(--mono); font-size:13px; } + .line1 .slug { font-weight:650; word-break:break-word; } + .line1 a.slug { font-weight:650; word-break:break-word; } + .line1 .m { color:var(--ink-2); } + .line1 .x, .line1 .t { color:var(--muted); } + .stats { display:flex; flex-wrap:wrap; gap:6px 18px; margin-top:8px; + font-size:13px; color:var(--ink-2); font-family:var(--mono); + font-variant-numeric:tabular-nums; } + .stats b { color:var(--ink); font-weight:600; } + .chips { display:flex; flex-wrap: wrap; gap:6px; margin-top:8px; } + .chip { max-width:100%; overflow:hidden; text-overflow:ellipsis; + white-space:nowrap; padding:1px 8px; + border:1px solid var(--grid); border-radius:6px; + font-size:11.5px; color:var(--ink-2); background:var(--page); + font-family:var(--mono); } + .skip .slug { font-style:italic; color:var(--muted); } + .skip .stat, .skip .chip { color:var(--muted); } + .skiperr { display:block; margin-top:8px; font-family:var(--mono); + font-size:13px; color:var(--muted); overflow-wrap:anywhere; } + .skip input { visibility:hidden; } + a { color:var(--s1); text-decoration:none; } + a:hover { text-decoration:underline; } + input[type=checkbox] { accent-color:var(--s1); } + .empty { max-width:70ch; color:var(--ink-2); } +""" + +_SEL_JS = """function compareSel() { + /* Selection is browser-side only: the first two checked `sel` checkboxes, + in DOM order, are A and B. Checking more than two uses the first two; + fewer than two does nothing. */ + var checked = document.querySelectorAll('input[name="sel"]:checked'); + if (checked.length < 2) return; + var a = checked[0].value; + var b = checked[1].value; + location = "/pair?a=" + a + "&b=" + b; +} +""" + + +def render_gallery(runs_dir: Path) -> str: + """The self-contained all-runs gallery: one row per reportable run + (headline numbers + a pick checkbox), one muted row per skipped run (the + reason in place of the numbers — a broken run stays visible), and a + browser-side compare button. Zero reportable runs: the page says + `betterbench run`.""" + reportable, skipped = list_runs(runs_dir) + note = "" + if not reportable: + note = ('

No reportable runs here — start one with ' + 'betterbench run.

') + rows = [] + for e in reportable: + try: + rows.append(_reportable_row(e)) + except Exception as exc: + rows.append(_degraded_row(e, exc)) + rows += [_skipped_row(e) for e in skipped] + block = "" + if rows: + block = ('\n
' + 'tick two runs, newest first = A
\n' + '
\n' + + "\n".join(" " + r for r in rows) + + '\n
') + return ( + '\n\n\n' + '\n' + '\n' + 'BetterBench — all runs\n\n\n\n
\n' + '
\n
BetterBench — runs
\n' + '

All runs

\n
\n' + + note + "\n" + block + '\n
\n' + '\n\n\n") + + +# --------------------------------------------------------------------------- # +# The pair page +# --------------------------------------------------------------------------- # +def _muted(text: str) -> str: + return f'

{_esc(text)}

' + + +def _delta(a, b) -> str: + """``b − a`` as ``{:+.1f}``, a dash when either side is unmeasured.""" + if a is None or b is None: + return "—" + return f"{b - a:+.1f}" + + +def _decode_series(res: dict, cat: str) -> list: + """A category's paired `decode_tps` series: the ok passes with a value.""" + recs = (res.get("single_stream") or {}).get(cat) or [] + return [r["decode_tps"] for r in recs + if r.get("ok") and r.get("decode_tps") is not None] + + +def _decode_block(a: dict, b: dict) -> str: + """Decode by category: a paired-t on `decode_tps`, pairing by + pass-index and truncating to the shorter series, at the default 95% + confidence — results do not record a confidence, so 95% is the + documented default. A category missing (or <2 paired values) on a side + renders ``not measured on `` in that side's cell, no stats; when + no category has pairs on both sides the block is only the muted note.""" + cats = sorted(set(a.get("single_stream") or {}) + | set(b.get("single_stream") or {})) + stats: dict[str, tuple] = {} + for cat in cats: + a_s = _decode_series(a, cat) + b_s = _decode_series(b, cat) + a_med = float(np.median(a_s)) if a_s else None + b_med = float(np.median(b_s)) if b_s else None + p = (paired_compare(a_s, b_s, cat) + if len(a_s) >= 2 and len(b_s) >= 2 else None) + stats[cat] = (a_s, b_s, a_med, b_med, p) + if all(s[4] is None for s in stats.values()): + return ('

Decode by category 95% CI

\n ' + + _muted("no decode category measured on both sides " + "(≥2 paired passes each) — nothing to compare")) + meds = [m for (_, _, a_m, b_m, _) in stats.values() + for m in (a_m, b_m) if m is not None] + top = max(meds) if meds else 1.0 + trs: list[str] = [] + for cat in cats: + a_s, b_s, a_med, b_med, p = stats[cat] + a_cell = _num(a_med) if a_med is not None else "not measured on A" + b_cell = _num(b_med) if b_med is not None else "not measured on B" + if p is None: + trs.append( + f" {_esc(cat)}" + f"{_esc(a_cell)}{_esc(b_cell)}" + f"———") + continue + wa = 100.0 * a_med / top + wb = 100.0 * b_med / top + verdict = "SIG" if p.significant else "noise" + trs.append( + f" {_esc(cat)}" + f"{_num(a_med)}{_num(b_med)}" + f"{p.pct_diff:+.2f}%" + f"[{p.ci_low_pct:+.1f}, {p.ci_high_pct:+.1f}]" + f"{verdict}" + f" " + f'
' + f'
' + f'
' + f"
") + return ('

Decode by category 95% CI

\n \n' + ' ' + '' + '\n' + ' \n' + "\n".join(trs) + + "\n \n
categorymed Amed BΔ (B−A)95% CIverdict
") + + +def _latency_side(res: dict) -> dict: + """This side's latency summary, read off the `single_rows` builders: + p50s as the median of the per-category p50s, p99s as the max of the + per-category p99s, 1%-lows as the min of the per-category 1%-lows. + Latencies are unpaired between runs (no interleaved `ab`), so each + metric is a per-side median — the block renders them side by side.""" + rows = single_rows(res) + + def collect(key: str) -> list: + return [r[key] for r in rows if r.get(key) is not None] + + def med(key: str): + v = collect(key) + return float(np.median(v)) if v else None + + def extreme(key: str, fn): + v = collect(key) + return fn(v) if v else None + + return { + "ttft_p50": med("ttft_p50"), + "ttft_p99": extreme("ttft_p99", max), + "update_p50": med("update_p50"), + "update_p99": extreme("update_p99", max), + "tok_per_update": med("tok_per_update"), + "itl_low1": extreme("itl_low1", min), + "itl_med": med("itl_med"), + "itl_high99": extreme("itl_high99", max), + } + + +def _latency_block(a: dict, b: dict) -> str: + """Latency rows: unpaired medians. The pass-level ITL/gap series do not + pair across runs without an interleaved `ab`, so medians only — the + footer says so and points at `betterbench ab` for CIs. Shape-dependent + columns: stream-update stats when both sides are batched, ITL tails + otherwise (a batched side naturally reports `None` for ITL).""" + sa = _latency_side(a) + sb = _latency_side(b) + batched = (report_is_batched(single_rows(a)) + and report_is_batched(single_rows(b))) + if batched: + shape = (("update p50 (ms)", "update_p50"), + ("update p99 (ms)", "update_p99"), + ("tok/update", "tok_per_update")) + else: + shape = (("itl 1% low (tok/s)", "itl_low1"), + ("itl median (tok/s)", "itl_med"), + ("itl 99% high (tok/s)", "itl_high99")) + metrics = (("ttft p50 (ms)", "ttft_p50"), + ("ttft p99 (ms)", "ttft_p99")) + shape + all_none = all(sa[k] is None and sb[k] is None + for _, k in metrics) + if all_none: + return ('

Latency (medians only)

\n ' + + _muted("latency not measured on either run")) + trs = [] + for label, key in metrics: + trs.append( + f" {_esc(label)}" + f"{_num(sa[key])}{_num(sb[key])}" + f"{_delta(sa[key], sb[key])}") + return ('

Latency (medians only)

\n \n' + ' ' + "\n" + ' \n' + "\n".join(trs) + + "\n \n
metricABΔ (B−A)
\n" + + _muted("latency rows are unpaired medians — pass-level " + "series don't pair across runs; for CIs on those, " + "run `betterbench ab`")) + + +def _safe_rows(builder, res: dict) -> list: + """One side's phase rows from a builder; an entry the builder can't + decode (an odd dict) degrades *that side* to no rows, so the block + falls back to its existing muted ``not measured on `` note + instead of raising the whole pair page.""" + try: + return builder(res) + except Exception: + return [] + + +def _block_fallback(title: str, note: str) -> str: + return (f'

{_esc(title)}

\n ' + _muted(note)) + + +def _guarded(title: str, note: str, make) -> str: + """One comparison block; any exception from the block degrades it to + its muted `` not measured on either run`` note rather than + raising the whole pair page.""" + try: + return make() + except Exception: + return _block_fallback(title, note) + + +def _phase_block(a: dict, b: dict, label: str, section: str, + key: str, cols: tuple) -> str: + """A prefill (per `target_depth`) / concurrency (per `level`) block: + the per-key medians side by side with the Δ, *restricted to the keys + present on both sides*; a key missing on a side renders + ``not measured on `` in that side's cells. Degraded to a muted + note when the side lacks the phase entirely (or its entries are + malformed: that side's rows resolve to none).""" + builder = prefill_rows if section == "prefill" else concurrency_rows + rows_a = [r for r in _safe_rows(builder, a) if r.get("skipped") is not True] + rows_b = [r for r in _safe_rows(builder, b) if r.get("skipped") is not True] + if not rows_a and not rows_b: + return (f'

{_esc(label)}

\n ' + + _muted(f"{label.lower()} not measured on either run")) + if not rows_a: + return (f'

{_esc(label)}

\n ' + + _muted(f"{label.lower()} not measured on A")) + if not rows_b: + return (f'

{_esc(label)}

\n ' + + _muted(f"{label.lower()} not measured on B")) + da = {r[key]: r for r in rows_a if r.get(key) is not None} + db = {r[key]: r for r in rows_b if r.get(key) is not None} + common = sorted(set(da) & set(db)) + head = [f"{'depth' if section == 'prefill' else 'level'}"] + for name, _ in cols: + head += [f"A {name}", f"B {name}"] + trs = [] + for k in common: + cells = [f"{_esc(k)}"] + for _name, fkey in cols: + va = da[k].get(fkey) + vb = db[k].get(fkey) + cells.append(f"{_num(va)}") + cells.append(f"{_num(vb)}") + cells.append(f"{_delta(va, vb)}") + trs.append(" " + "".join(cells) + "") + return (f'

{_esc(label)}

\n \n' + " " + "".join(head) + "\n" + " \n" + "\n".join(trs) + + "\n \n
") + + +def _combined_block(a: dict, b: dict) -> str: + """One row: A and B through `combined_score` with *each side's own + weights* (stated in the caption), Δ guarded for `a` being None/0. + Unified across the categories each side measured, on the side's own + `config.weights` — a missing side degrades to a muted note.""" + ca = combined_score(a) + cb = combined_score(b) + av = ca["decode"] if ca else None + bv = cb["decode"] if cb else None + if av is None and bv is None: + return ('

Combined decode

\n ' + + _muted("combined decode not measured on either run")) + a_cell = _num(av) if av is not None else "not measured on A" + b_cell = _num(bv) if bv is not None else "not measured on B" + if av in (None, 0) or bv is None: + d = "—" + else: + d = f"{100.0 * (bv - av) / av:+.2f}%" + return ('

Combined decode

\n \n' + ' ' + "\n" + ' \n' + f' ' + f"" + f"\n" + ' \n
weighted t/sABΔ (B−A)
combined{_esc(a_cell)}{_esc(b_cell)}{d}
\n' + + _muted("combined decode — weights are each run's own config")) + + +def compare_band(a: dict, b: dict) -> str: + """The pair page's comparison band: decode (paired-t, 95% CI, + pass-index pairing truncated to the shorter series), latency + (unpaired medians only), prefill / concurrency (medians per + depth/level, common keys), and combined decode (each side's own + weights). Each block degrades to a muted ``phase not measured on + `` note when that side lacks the phase — never a bare 0.""" + parts = [ + _guarded("Decode by category 95% CI", "decode not measured on either run", + lambda: _decode_block(a, b)), + _guarded("Latency (medians only)", + "latency not measured on either run", + lambda: _latency_block(a, b)), + _guarded("Prefill", "prefill not measured on either run", + lambda: _phase_block(a, b, "Prefill", "prefill", "target_depth", + (("prompt-tok med", "prompt_tokens_med"), + ("pp med", "pp_med")))), + _guarded("Concurrency", "concurrency not measured on either run", + lambda: _phase_block(a, b, "Concurrency", "concurrency", "level", + (("aggregate t/s", "aggregate_tps"), + ("decode med", "decode_med"), + ("ttft p50 (ms)", "ttft_p50")))), + _guarded("Combined decode", + "combined decode not measured on either run", + lambda: _combined_block(a, b)), + ] + return '
\n ' + "\n ".join(parts) \ + + "\n
" + + +_PAIR_CSS = """ + /* Baseline table rules (moved from the page CSS so the all-runs + gallery can drop its wide table and its nowrap columns without + changing the pair page's own tables). */ + table { border-collapse:collapse; width:100%; font-size:12.5px; + font-family:var(--mono); font-variant-numeric:tabular-nums; } + th, td { padding:7px 12px 7px 0; text-align:right; white-space:nowrap; } + th:first-child, td:first-child { text-align:left; } + thead th { color:var(--muted); font-weight:500; font-size:10.5px; + letter-spacing:.07em; text-transform:uppercase; + border-bottom:1px solid var(--grid); } + tbody tr + tr td { border-top:1px solid var(--grid); } + tbody td { color:var(--ink-2); } + td.slug { color:var(--ink); } + .cmt { display:flex; align-items:center; gap:8px; flex-wrap:wrap; + font-family:var(--mono); font-size:13px; } + .cmt select { font-family:var(--mono); font-size:13px; padding:6px 8px; + border:1px solid var(--grid); border-radius:7px; + background:var(--page); color:var(--ink); } + .cmt span { color:var(--muted); } + .cmt a { font-family:var(--mono); font-size:13px; } + .warn { border:1px solid var(--s2); border-radius:10px; + padding:10px 14px; font-family:var(--mono); font-size:12.5px; + color:var(--ink-2); } + .chips { max-width:none; } + .tiles-cmp { background:var(--surface); border:1px solid var(--grid); + border-radius:10px; padding:12px 16px 4px; } + .tiles-cmp h3.sect { font-family:var(--sans); font-size:14px; + margin:18px 0 6px; color:var(--ink); } + .tiles-cmp .muted { color:var(--muted); font-family:var(--mono); + font-size:12px; margin:4px 0 12px; } + .tiles-cmp tbody td { color:var(--ink-2); white-space:normal; } + .tiles-cmp td.cat { color:var(--ink); } + tr.brow td { border-top:0; padding:2px 12px 8px 0; } + .bars { display:flex; flex-direction:column; gap:3px; } + .bar { height:6px; background:var(--s1); border-radius:3px; + min-width:2px; } + .bar.bar2 { background:var(--s2); } + h2 { font-size:18px; margin:4px 0 6px; } + iframe.runpane { width:100%; border:1px solid var(--grid); + border-radius:10px; min-height:70vh; } +""" + +_PAIR_JS = """/* The in-situ A/B toolbar: changing either select navigates to + GET /pair?a=&b= via a plain relative-path + assignment — it works on any port; no URL construction needed. + Both selects carry every other reportable run's URL-encoded slug; + the swap is a plain anchor to the swapped pair, so it needs no JS. */ +var selA = document.getElementById('sel-a'); +var selB = document.getElementById('sel-b'); +function pairNav() { + /* Keep A first, B second, exactly as the page header reads them. */ + location = "/pair?a=" + selA.value + "&b=" + selB.value; +} +selA.addEventListener('change', pairNav); +selB.addEventListener('change', pairNav); +/* (handled by the plain `swap` anchor above: no JS state to sync) + A missing selection can't happen; both selects always default to A + and B respectively and are never cleared.) */ +""" + + +def _select_options(runs: list[RunEntry], exclude: str) -> str: + """The `') + return "\n ".join(opts) + + +def render_pair_page(runs_dir: Path, a_slug: str, b_slug: str) -> str: + """The self-contained pair page. Top to bottom: an A/B toolbar (two + `select`s — each listing every other reportable run — plus a swap + link; changing either navigates to `GET /pair?a=…&b=…`), the + always-on unpaired-in-time banner, the `mismatch_chips` row (hidden + when empty), `compare_band`, and the two full reports embedded as + `iframe`s pointing at `/run/` (the iframes sidestep every + id/CSS collision between two reports in one document and keep each + report exactly the standalone shape — the `/run/` page itself + is built by the serving layer's string injection, not here).""" + reportable, _skipped = list_runs(runs_dir) + by_slug = {e.slug: e for e in reportable} + missing = [s for s in (a_slug, b_slug) if s not in by_slug] + if missing: + raise ValueError( + f"run {missing[0]!r} has no parseable results.json") + ops_a = _select_options(reportable, exclude=b_slug) + ops_b = _select_options(reportable, exclude=a_slug) + qa = urllib.parse.quote(a_slug, safe="") + qb = urllib.parse.quote(b_slug, safe="") + try: + chips = mismatch_chips(by_slug[a_slug].results, + by_slug[b_slug].results) + except Exception: + # Best-effort: a non-dict `notes` (or other malformed side — + # unlike `run_manifest`, `mismatch_chips` has no isinstance + # tolerance) degrades the row to "no chips" rather than + # dropping the page; the common case (either side a dict) is + # unaffected — `mismatch_chips` is total for any dicts. + chips = [] + chips_html = "" + if chips: + inner = "".join(f'{_esc(c)}' + for c in chips) + chips_html = f"
{inner}
\n" + b_res = by_slug[b_slug].results + try: + band = compare_band(by_slug[a_slug].results, b_res) + except Exception: + band = _muted("the comparison band is unavailable for this pair — " + "entire block degraded") + return ( + '\n\n\n' + '\n' + '\n' + f"BetterBench — {_esc(a_slug)} vs {_esc(b_slug)}\n" + "\n\n" + "\n
\n" + "
\n" + "
BetterBench — compare
\n" + f"

{_esc(a_slug)} vs {_esc(b_slug)}

\n" + "
\n" + "
\n" + f" \n" + " vs\n" + f" \n" + f" swap ⇄\n" + "
\n" + "
cross-file compare is unpaired in time; " + "for a drift-cancelled verdict run `betterbench ab`. " + "Paired decode stats below use pass-index pairing " + "truncated to the shorter series.
\n" + + chips_html + + f" {band}\n" + f"

Run A — {a_slug}

\n" + f' \n" + f"

Run B — {b_slug}

\n" + f' \n" + "
\n" + "\n\n\n") diff --git a/betterbench/html_report.py b/betterbench/html_report.py index 2d6b6e6..e5f3458 100644 --- a/betterbench/html_report.py +++ b/betterbench/html_report.py @@ -243,7 +243,12 @@ def _tables(rows, conc, pre, env, batched=False, rrows=()) -> list | str: # --------------------------------------------------------------------------- # # Entry point # --------------------------------------------------------------------------- # -def render_html(results: dict) -> str: +def render_sections(results: dict) -> dict: + """Every section of the page, plus the chart data dict. + + `render_html` is exactly `render_document(render_sections(results))`; the + compare session composes the individual sections rather than the page. + """ cfg = results.get("config", {}) or {} env = results.get("env", {}) or {} rows = single_rows(results) @@ -338,17 +343,34 @@ def render_html(results: dict) -> str: "prefix cache. The band spans the 1% low to the 99% high.", note=note)) + return { + "title": _esc(f'BetterBench — {env.get("model", "run")}'), + "header": _header(results, cfg, env), + "tiles": _tiles(comb, conc, pre, cfg, batched), + "figures": "\n".join(figs), + "tables": _tables(rows, conc, pre, env, batched, rrows), + "footer": _footer(results, env, cfg), + "data": data, + } + + +def render_document(sections: dict) -> str: + """Fill the page shell's placeholders from a `render_sections` dict.""" page = _TEMPLATE - page = page.replace("__TITLE__", _esc(f'BetterBench — {env.get("model", "run")}')) - page = page.replace("__HEADER__", _header(results, cfg, env)) - page = page.replace("__TILES__", _tiles(comb, conc, pre, cfg, batched)) - page = page.replace("__FIGURES__", "\n".join(figs)) - page = page.replace("__TABLES__", _tables(rows, conc, pre, env, batched, rrows)) - page = page.replace("__FOOTER__", _footer(results, env, cfg)) - page = page.replace("__DATA__", json.dumps(data)) + page = page.replace("__TITLE__", sections["title"]) + page = page.replace("__HEADER__", sections["header"]) + page = page.replace("__TILES__", sections["tiles"]) + page = page.replace("__FIGURES__", sections["figures"]) + page = page.replace("__TABLES__", sections["tables"]) + page = page.replace("__FOOTER__", sections["footer"]) + page = page.replace("__DATA__", json.dumps(sections["data"])) return page +def render_html(results: dict) -> str: + return render_document(render_sections(results)) + + def _footer(results, env, cfg) -> str: weights = cfg.get("weights", {}) or {} w = ", ".join(f"{k} {v}" for k, v in weights.items()) @@ -377,16 +399,21 @@ def _footer(results, env, cfg) -> str: # --------------------------------------------------------------------------- # # The page shell: tokens, layout, and a small hand-rolled SVG chart core. -# Placeholders (__TITLE__ etc.) are filled by render_html. +# Placeholders (__TITLE__ etc.) are filled by render_document. # --------------------------------------------------------------------------- # -_TEMPLATE = r""" +# The +""" + +_TEMPLATE_MID = r"""
@@ -515,7 +544,9 @@ def _footer(results, env, cfg) -> str: __FOOTER__
+""" + +_TEMPLATE_TAIL = r""" """ + +_TEMPLATE = _PAGE_HEAD + PAGE_CSS + _TEMPLATE_MID + CHART_JS + _TEMPLATE_TAIL diff --git a/betterbench/session.py b/betterbench/session.py new file mode 100644 index 0000000..982a618 --- /dev/null +++ b/betterbench/session.py @@ -0,0 +1,249 @@ +"""The interactive compare session: a stdlib, GET-only, stateless HTTP +server over a runs directory. + +`make_server` binds `127.0.0.1` on port 0 (the kernel picks a free +port; the caller reads it back from `server.server_address[1]`), so +two sessions started at once can never collide on a fixed port. The +handler serves three routes and re-scans the runs directory on every +request — `list_runs` is called fresh each time, so a run that lands +while the session is open shows up on the next click; there is no +cache anywhere in this module. Slugs are only routed when they equal +a directory name in the current listing *and* survive the shape check +(no `/`, no `..` segment, no leading `.` after decoding), so a +percent-encoded traversal can never resolve to a real path. Unknown +paths, absent slugs and skipped runs each get a distinct plain-text +404. `start` is the top-level entry the CLI's no-args `compare` +branch calls. +""" +from __future__ import annotations + +import html +import json +import sys +import threading +import urllib.parse +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from .gallery import list_runs, render_gallery, render_pair_page +from .html_report import render_html + +_THE_404_PLAIN = "text/plain; charset=utf-8" +_WRAP_TARGET = '
\n' + + +def _shape_ok(slug: str) -> bool: + """The decoded-slug shape check: no `/`, no `..` segment, no + leading `.`. A failing slug can never be turned back into a path + under the runs dir — including a percent-encoded traversal that + only *decodes* into one.""" + if not slug or slug.startswith("."): + return False + # Note: slugs are *never* path-joined — the real security gate is + # the listed-directory-name match (a slug routes only if it equals + # a directory in `list_runs`), so don't trust this shape check + # alone; `".." not in slug.split("/")` is belt-and-braces (given + # the prior `/` check, the split would yield a single segment). + return "/" not in slug and ".." not in slug.split("/") + + +def _run_js(slug: str) -> str: + """The 15-line change-navigation script for the run page's + session bar — the pair page's toolbar (Task 3's `_PAIR_JS`) copied + for a single select and a fixed A, with no shared constant: + changing the `Compare…` select navigates to + `/pair?a=&b=`.""" + cur = json.dumps(urllib.parse.quote(str(slug), safe="")) + return ( + "/* The in-situ compare bar: changing the select navigates to\n" + " GET /pair?a=&b= via a plain relative-path\n" + " assignment — it works on any port; no URL construction needed.\n" + " The select carries every other reportable run's URL-encoded slug;\n" + " the swap is a plain anchor to the swapped pair, so it needs no JS. */\n" + "var selC = document.getElementById('sel-c');\n" + f"var curSlug = {cur};\n" + "function pairNav() {\n" + " /* Keep A first, B second, exactly as the page header reads them. */\n" + " location = \"/pair?a=\" + curSlug + \"&b=\" + selC.value;\n" + "}\n" + "if (selC) selC.addEventListener('change', pairNav);\n" + "/* (handled by the single select: A is always the current run.\n" + " A missing selection can't happen; the select always defaults to the\n" + " first other option and is never cleared.) */\n") + + +def _session_bar(slug: str, reportable: list) -> str: + """The bar injected above a single run's report header: a + `← All runs` anchor back at the gallery plus a `Compare…` + `select` whose options are every *other* reportable slug, + URL-encoded as the pair page encodes its selects' values.""" + opts = [] + for e in reportable: + if e.slug == slug: + continue + val = html.escape(urllib.parse.quote(str(e.slug), safe="")) + opts.append(f'') + return ('
← All runs' + 'Compare…' + '
') + + +class _Handler(BaseHTTPRequestHandler): + """One GET-only handler per session server. `runs_dir` is injected + by `make_server` (see there) into a per-server subclass, so this + class stays stateless; every request re-scans the directory — a + fresh `list_runs` each time, no cache. + + 404 bodies (plain text), in exactly these three forms: an unknown + path → `not found`; a valid-shape slug that isn't in the listing → + `not found — no such run: `; a listed-but-skipped slug + (corrupt or ab-only dir) → `not found — `. A + reportable slug whose results a builder can't render also degrades + to the `not found — ` form (render failure in + `_run_page`) rather than a connection reset. + """ + + def log_message(self, fmt, *args): + """No-op: suppresses the per-request access-log spam. To debug, + drop this override (or temporarily have it call + `super().log_message(fmt, *args)`) so the base class method + prints again.""" + pass + + def do_GET(self): + sp = urllib.parse.urlsplit(self.path) + path = urllib.parse.unquote(sp.path) + reportable, skipped = list_runs(self.runs_dir) + if path == "/": + self._send(200, render_gallery(self.runs_dir)) + return + if len(path) > len("/run/") and path.startswith("/run/"): + self._run_page(path[len("/run/"):], reportable, skipped) + return + if path == "/pair": + # `parse_qs` already unquotes each value exactly once — the + # wire carries the once-encoded slugs, so a second + # `urllib.parse.unquote` here would corrupt a name that + # contains a literal `%` (e.g. `a%2520b` → `a b`). + qs = {k: v[0] + for k, v in urllib.parse.parse_qs(sp.query).items() if v} + # `parse_qs` (with the `if v` filter) drops absent *and* + # empty values, so `None` here means the parameter is + # entirely missing — that's the plain unknown-shape 404 + # form, not a `no such run` one (no slugs to name): + if qs.get("a") is None or qs.get("b") is None: + self._send(404, "not found\n", _THE_404_PLAIN) + return + for slug in (qs.get("a"), qs.get("b")): + bad = self._slug_404(slug, reportable, skipped) + if bad: + self._send(404, bad, _THE_404_PLAIN) + return + self._send(200, render_pair_page(self.runs_dir, + qs.get("a"), qs.get("b"))) + return + self._send(404, "not found\n", _THE_404_PLAIN) + + def _run_page(self, slug: str, reportable: list, skipped: list): + bad = self._slug_404(slug, reportable, skipped) + if bad: + self._send(404, bad, _THE_404_PLAIN) + return + entry = next(e for e in reportable if e.slug == slug) + try: + page = render_html(entry.results) + except Exception as exc: + # A reportable run whose data a builder can't decode makes + # the gallery's degraded row — it is not renderable here; + # fall back to the plain 404 rather than reset the + # connection. + self._send(404, f"not found — {exc}\n", + _THE_404_PLAIN) + return + bar = _session_bar(slug, reportable) + if _WRAP_TARGET in page: + # Inject the session bar above the report header; when the + # target were missing (impossible with the current template, + # guarded anyway) the unmodified page is served — the + # already-rendered `page` is reused as-is, and the + # script-gating below works off that unchanged string. + page = page.replace(_WRAP_TARGET, + _WRAP_TARGET + bar + "\n", 1) + if "" in page: + page = page.replace("", + "\n", 1) + self._send(200, page) + + def _slug_404(self, slug, reportable: list, skipped: list): + """The 404 body for `slug`, or `None` when it is reportable.""" + if slug is not None and _shape_ok(slug) \ + and any(e.slug == slug for e in reportable): + return None + if slug is not None and slug in {e.slug for e in skipped}: + e = next(e for e in skipped if e.slug == slug) + return f"not found — {e.error}\n" + return f"not found — no such run: {slug}\n" + + def _send(self, status: int, body: str, + ctype: str = "text/html; charset=utf-8"): + data = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +def make_server(runs_dir: Path, open_browser: bool = False) -> ThreadingHTTPServer: + """Build a session server for `runs_dir`, always bound to + `127.0.0.1` on port 0 — the kernel allocates the free port and + the caller reads it back from `server_address[1]`, so two + sessions that start at once can never share a port. + + `runs_dir` is injected via a handler factory: `type` builds a + per-server subclass `type("H", (_Handler,), {"runs_dir": runs_dir})` + and the factory passes that class to the server, so the module + defines one stateless handler and each server carries its own + directory. The server is returned *already serving* — + `serve_forever` runs on a daemon thread — and is closed with + `shutdown()` then `server_close()`. When `open_browser` is true, + `webbrowser.open` is called on the gallery URL best-effort — any + exception is swallowed, the URL is printed by `start` either way. + """ + handler = type("H", (_Handler,), {"runs_dir": runs_dir}) + srv = ThreadingHTTPServer(("127.0.0.1", 0), handler) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + if open_browser: + try: + webbrowser.open(f"http://127.0.0.1:{srv.server_address[1]}/") + except Exception: + pass + return srv + + +def start(runs_dir: Path) -> None: + """The top-level entry the CLI's no-args `compare` calls. Exits + with a `no reportable runs` message when the dir is missing or + holds no reportable entries (a dir full of corrupt/ab-only entries + still *has* runs, so the message says *reportable*); otherwise + prints the run count, the directory and the URL with + `Ctrl-C to stop`, and waits until Ctrl-C (the serve loop is the + daemon thread `make_server` already started; on exit, `shutdown()` + stops the loop and then `server_close()` cleans up).""" + entries, _ = list_runs(runs_dir) + if not entries: + sys.exit(f"no reportable runs in {runs_dir} — run 'betterbench run' first") + srv = make_server(runs_dir, open_browser=True) + print(f"runs: {len(entries)}") + print(f"serving {runs_dir}") + print(f"open: http://127.0.0.1:{srv.server_address[1]}/ — Ctrl-C to stop") + try: + threading.Event().wait() + except KeyboardInterrupt: + pass + finally: + srv.shutdown() + srv.server_close() diff --git a/docs/METHODOLOGY.md b/docs/METHODOLOGY.md index 755b7a3..cb3a538 100644 --- a/docs/METHODOLOGY.md +++ b/docs/METHODOLOGY.md @@ -140,6 +140,46 @@ comparing in pairs: - Prefer **ITL median** as the lead signal (thousands of token samples ⇒ tight CI); per-run t/s needs ~100+ runs for a trustworthy p99. +## Cross-run compare +`betterbench compare A.json B.json` (a terminal table) and the no-argument +`betterbench compare` (a browser over every run in `$BETTERBENCH_HOME/runs/`) both compare +*saved* `results.json` files. State the main distinction up front: this is a **cross-run** +comparison. The two runs are not interleaved — no shared prompt +measured back-to-back under A and B — so **no common-mode drift cancels**. Everything the +pair page shows is best-effort attribution; the only path for a drift-cancelled verdict +remains a paired, interleaved `betterbench ab`, and the pair page's always-on banner says +exactly that. + +**The pairing rule (decode stats).** The decode numbers pair *by pass index*: both runs' +per-pass `decode_tps` series (ok passes only) are aligned positionally and truncated to +the shorter series, then passed through `paired_compare`'s default — a paired-t CI at 95%. +Pass index stands in for trial identity; it holds when both runs measured the same +categories on the same corpus at the same pass count, and degrades to "the first *n* +passes on each side" when they did not. That is exactly the ordering a corpus-version or +pass-count mismatch chip warns about. + +**What carries a CI, and what is medians-only — and why.** The *decode by category* rows +carry CIs (per the pairing rule above); the *combined decode* shows its Δ against each +side's own stated weights, with no CI. Everything else on the page — TTFT/ITL (or +stream-update) latency, prefill, and concurrency — is **medians-only**. The reason is not +laziness: a CI in BetterBench is an interval on a *per-trial difference*, and the +pass-level latency/gap series of two uninterleaved runs have **no shared trial identity** — +there is no per-trial difference to take a paired-t on, so an interval would be +manufactured significance. Medians display side by side with a Δ, flagged as "unpaired +medians" with a pointer to `betterbench ab`. (The terminal form shows only the +per-category decode table — medians, Δ%, the same 95% paired-t CI, and verdict — +with an "unpaired in time; prefer `betterbench ab`" footer.) + +**The mismatch chips.** The chips row (corpus version, sampling, host, GPU, and any differing +`--note` value) **flags, it does not block** — the page still renders, because the user +judges comparability with all the evidence on screen that the chips then narrate. A +corpus-version mismatch means the *prompts differed*: the combined decode Δ and the +category Δs are apples-to-oranges even when the medians look close, and the drift caveat +above applies on top. Same for a sampling mismatch (greedy vs temperature changes output +length, which moves aggregate t/s independent of the server) or a GPU mismatch. When the +chips row is empty, the page is a like-for-like comparison — but still cross-run, so the +banner stands. + ## Sample-size honesty A p1/p99 needs enough samples beyond the tail (`n · tail ≥ ~5`, i.e. 500 observations for a p99). BetterBench marks every percentile below that threshold with a `†` and records the diff --git a/tests/data/report_golden.html b/tests/data/report_golden.html new file mode 100644 index 0000000..582c4a9 --- /dev/null +++ b/tests/data/report_golden.html @@ -0,0 +1,488 @@ + + + + + +BetterBench — fixture-model + + + +
+
+
BetterBench 0.6.0 · 01 Jan 2025 · 00:00 · fixture-host
+

fixture-model

+
fixture-modelhttp://127.0.0.1:0/v1corpus v1.03 passes/catgreedycold prefix cache (nonce)NVIDIA Fixture Card8,192 tok contextkernel: fixture
+
+
+
+ Combined decode + 100.0 t/s + weighted across categories +
+
+ Combined update p99 + 20.0 ms + the stutter between stream updates +
+
+ Combined TTFT p50 + 50 ms + single-stream, batch = 1 +
+
+ Aggregate @ 8 concurrent + 733.9 t/s + 7/8 ok +
+
+ Prefill @ 16,384 tok + 109 t/s + median prompt processing +
+
+ +
+
+ Decode throughput by category + Median per-pass decode t/s at batch = 1, 1 passes per category. The dashed line is the weighted combined score. +
+ +
+

Hover a bar for its IQR and coefficient of variation — a high CV means the category's passes disagree, so read small differences there with care.

+
+
+
+ Stream-update gap by category + This server packs several tokens into one stream update, so there is no per-token latency to plot. These are the measured wall-clock gaps between updates: p50 is the typical rhythm, p99 the stutter. Lower is better. +
+
update p50update p99
+
+

Hover a bar for the tokens landing per update. p99 is an upper bound on any pause a reader feels; it is not comparable across servers that pack different numbers of tokens into an update.

+
+
+
+ Aggregate throughput under concurrency + Total tokens/sec across all in-flight requests as load rises — where this flattens is the throughput knee. +
+ +
+ +
+
+
+ Time-to-first-token under concurrency + Queueing shows up here first: p50 is the typical wait, p99 the tail. +
+
TTFT p50TTFT p99
+
+

With a modest request count per level, p99 rests on very few observations — treat a spike as a prompt to re-run, not a conclusion.

+
+
+
+ Prompt processing throughput by depth + Median prefill t/s (prompt tokens ÷ TTFT) at increasing input depth, cold prefix cache. The band spans the 1% low to the 99% high. +
+ +
+

Skipped depth 32,768 — deeper than the model's context window.

+
+
+ Full numbers +
+ + + + + + + +
Single-stream, batch = 1 · several tokens per stream update
categorypassesTTFT p50TTFT p99update p50 (ms)update p99 (ms)tok/updatedecode med±IQRCV
prose150.050.020.020.04.00100.00.00.0%
code150.050.020.020.01.00100.00.00.0%
+ + + + + + + + +
Concurrency sweep
levelok/reqaggregate t/sTTFT p50TTFT p99per-req decode med
15/5120.540.041.0120.5
44/4410.2120.0122.0102.5
87/8733.9310.0323.791.7
+ + + + + + + + + +
Prefill sweep · cold prefix cache
target depthprompt tok medTTFT p50PP 1% lowPP medianPP 99% high
1,0241,0229.2108.7111.2113.6
4,0964,08937.6107.7108.8109.9
16,38416,375150.6108.5108.8109.0
32,768skipped
+
+
+
Generated by BetterBench 0.6.0 from a corpus v1.0 run. Corpus hash abcdef0123. Combined-score weights — prose 0.5, code 0.5. Results are only comparable within a corpus version. Stopped at max_tokens: 2/2 runs (100%) — on a thinking model a truncated run measures the thinking phase, not a complete answer. 10 percentiles are marked † — they rest on fewer samples than n · tail ≥ 5 requires (a p99 needs 500 observations), so read them as roughly the worst observed rather than as percentiles. The full list is under sample_gate in the results JSON. See METHODOLOGY.md §sample-size.
+
+ + + diff --git a/tests/test_gallery.py b/tests/test_gallery.py new file mode 100644 index 0000000..306acd0 --- /dev/null +++ b/tests/test_gallery.py @@ -0,0 +1,211 @@ +"""Run scanning, run manifest, and the gallery page — pure functions over a runs dir. + +The headline numbers are recomputed through the same row builders the report +(`betterbench.report`) uses, so the gallery can't drift from it. +""" +from __future__ import annotations + +import json +from pathlib import Path + +from betterbench.gallery import (list_runs, render_gallery, render_pair_page, + run_manifest) + + +def _make_run(root: Path, slug: str, *, model, endpoint, greedy, corpus="1.0", + n_passes=4, with_concurrency=False, with_prefill=False, + broken_json=False, notes=None, max_model_len=None, + drop_conc_level=False): + """Write `root/slug/results.json` in a minimal schema-2 shape.""" + d = root / slug + d.mkdir(parents=True) + recs = [{"ok": True, "category": "prose", "ttft_ms": 48.0, + "decode_tps": 12.5, "update_gaps_ms": [20.0, 21.0], + "completion_tokens": 100, "n_chunks": 100, "chunking": "per_token", + "finish_reason": "stop"} for _ in range(n_passes)] + results = { + "schema": 2, "corpus_version": corpus, "betterbench_version": "0.9.9", + "env": {"timestamp": "2026-09-11T08:32:23+0200", "model": model, + "endpoint": endpoint, "host": "bench-host"}, + "config": {"greedy": greedy, "temperature": 0.7, + "runs_per_category": n_passes, + "weights": {"prose": 0.2, "code": 0.2, "math": 0.2, + "json": 0.2, "reasoning": 0.1, "chat": 0.1}}, + "single_stream": {"prose": [dict(r) for r in recs]}, + } + if with_concurrency: + results["concurrency"] = [ + {"level": 1, "ok": 2, "requests": 2, "aggregate_tps": 100.0, + "ttft_ms": [30.0, 31.0], "decode_tps": [50.0, 51.0]}, + {"level": 8, "ok": 8, "requests": 8, "aggregate_tps": 420.0, + "ttft_ms": [210.0, 224.0], "decode_tps": [50.0, 52.0]}, + ] + if drop_conc_level: + del results["concurrency"][0]["level"] + if with_prefill: + results["prefill"] = [ + {"target_depth": 200, "skipped": False, + "prompt_tokens": [150, 150], "ttft_ms": [100.0, 101.0], + "pp_tps": [1500.0, 1550.0]}, + ] + if notes is not None: + results["env"]["notes"] = notes + if max_model_len is not None: + results["env"]["max_model_len"] = max_model_len + (d / "results.json").write_text("{not json" if broken_json + else json.dumps(results)) + return d + + +def test_list_runs_orders_newest_first_and_flags_corrupt_json(tmp_path): + _make_run(tmp_path, "20260101T000000-old", model="m-old", + endpoint="http://e-o", greedy=True) + _make_run(tmp_path, "20260107T000000-mid", model="m-mid", + endpoint="http://e-m", greedy=True) + _make_run(tmp_path, "20260114T000000-new", model="m-new", + endpoint="http://e-n", greedy=False) + bad = tmp_path / "bad-run" + bad.mkdir() + (bad / "results.json").write_text("{not json") + reportable, skipped = list_runs(tmp_path) + assert len(reportable) == 3 + assert len(skipped) == 1 + assert skipped[0].slug == "bad-run" + assert skipped[0].results is None + try: + json.loads("{not json") + except json.JSONDecodeError as e: + assert str(e) in skipped[0].error + assert reportable[0].slug == "20260114T000000-new" # newest first + assert reportable[-1].slug == "20260101T000000-old" # oldest last + assert reportable[0].results is not None + assert reportable[0].error is None + + +def test_list_runs_ignores_non_directories_and_ab_only_dirs(tmp_path): + _make_run(tmp_path, "20260101T000000-valid", model="m", + endpoint="http://e", greedy=True) + (tmp_path / "stray.txt").write_text("not a run") + ab = tmp_path / "ab-only" + ab.mkdir() + (ab / "ab.json").write_text(json.dumps({"model": "m", "pairs": 0})) + reportable, skipped = list_runs(tmp_path) + assert [e.slug for e in reportable] == ["20260101T000000-valid"] + assert [e.slug for e in skipped] == ["ab-only"] + assert "no results.json" in skipped[0].error + # A missing runs dir is not an error: the caller decides what to do. + r, s = list_runs(tmp_path / "does-not-exist") + assert r == [] and s == [] + + +def test_render_gallery_lists_headline_numbers_and_links(tmp_path): + _make_run(tmp_path, "20260101T000000-old", model="model-alpha", + endpoint="http://a:1", greedy=True, with_concurrency=True) + _make_run(tmp_path, "20260114T000000-new", model="model-beta", + endpoint="http://b:1", greedy=False, with_prefill=True) + _make_run(tmp_path, "20260107T000000-bad", model="m-bad", + endpoint="http://x:1", greedy=True, broken_json=True) + html = render_gallery(tmp_path) + assert "20260101T000000-old" in html + assert "20260114T000000-new" in html + assert "model-alpha" in html and "model-beta" in html + assert "12.5" in html # uniform decode_tps -> combined 12.5 + assert "420.0" in html # aggregate at the top concurrency level + assert 'href="/run/20260101T000000-old"' in html + assert 'href="/run/20260114T000000-new"' in html + assert 'name="sel"' in html and 'value="20260114T000000-new"' in html + assert 'class="skip"' in html # the broken run is visible, not dropped + assert "20260107T000000-bad" in html + assert "Compare selected" in html + # Layout regressions: the wide 10-column table is gone, chips wrap + # (no horizontal scroll), and the pick checkbox is the first element + # of each run row — before the run link in document order. + assert " still reportable + assert {e.slug for e in reportable} == {"20260114T000000-good", + "20260107T000000-noenv"} + html = render_gallery(tmp_path) # the page must not die on this run + assert "" in html + assert "m-good" in html and "12.5" in html # the good run is intact + assert "20260107T000000-noenv" in html # odd run stays visible + + +def test_malformed_run_with_notes_as_list_keeps_gallery_alive(tmp_path): + _make_run(tmp_path, "20260114T000000-good", model="m-good", + endpoint="e", greedy=True) + _make_run(tmp_path, "20260107T000000-notes", + model="m-notes", endpoint="e", greedy=True, + notes=["hot", 7]) + html = render_gallery(tmp_path) + assert "m-good" in html and "m-notes" in html + assert "render error" not in html + + +def test_malformed_run_with_non_numeric_max_model_len_keeps_gallery_alive( + tmp_path): + _make_run(tmp_path, "20260114T000000-good", model="m-good", + endpoint="e", greedy=True) + _make_run(tmp_path, "20260107T000000-ctx", + model="m-ctx", endpoint="e", greedy=True, + max_model_len="128k") + html = render_gallery(tmp_path) + assert "m-good" in html and "m-ctx" in html + assert "tok context" not in html # the chip is omitted, not fabricated + assert "render error" not in html + + +def test_malformed_concurrency_entry_degrades_manifest_and_pair_page(tmp_path): + _make_run(tmp_path, "20260114T000000-a", model="ma", endpoint="ea", + greedy=True, with_concurrency=True, drop_conc_level=True) + _make_run(tmp_path, "20260107T000000-b", model="mb", endpoint="eb", + greedy=True, with_concurrency=True) + reportable, _ = list_runs(tmp_path) + a = next(e for e in reportable if e.slug == "20260114T000000-a") + m = run_manifest(a) # must not raise + assert m["aggregate_top_conc"] is None # number omitted, not fabricated + html = render_pair_page(tmp_path, "20260114T000000-a", + "20260107T000000-b") + assert "Concurrency" in html + assert "not measured on A" in html # degraded to the muted note + + +def test_non_object_results_json_is_a_muted_render_error_row(tmp_path): + _make_run(tmp_path, "20260114T000000-good", model="m-good", + endpoint="e", greedy=True) + odd = tmp_path / "20260107T000000-array" + odd.mkdir() + (odd / "results.json").write_text(json.dumps([1, 2, 3])) + reportable, _ = list_runs(tmp_path) + assert {e.slug for e in reportable} == {"20260114T000000-good", + "20260107T000000-array"} + html = render_gallery(tmp_path) + assert "m-good" in html # the good run is intact + assert 'class="skip"' in html # the odd run is a muted row + assert "render error" in html diff --git a/tests/test_pair_band.py b/tests/test_pair_band.py new file mode 100644 index 0000000..8298c82 --- /dev/null +++ b/tests/test_pair_band.py @@ -0,0 +1,166 @@ +"""The compare pair page: mismatch chips (two result dicts compared), the +comparison band (paired decode vs unpaired medians), the toolbar, and the +two embedded reports. + +Everything is pure over two results dicts and a runs dir; degradation +messages are asserted here (an absent phase renders a muted note, never a +bare number). +""" +from __future__ import annotations + +import json +import re + +from betterbench.gallery import compare_band, mismatch_chips, render_pair_page +from betterbench.metrics import paired_compare + +BANNER = ("cross-file compare is unpaired in time; for a drift-cancelled " + "verdict run `betterbench ab`. Paired decode stats below use " + "pass-index pairing truncated to the shorter series.") + + +def _make_run(root, slug, *, model, endpoint, greedy, temperature=0.7, + corpus="1.0", host="bench-host", notes=None, gpu=None, + categories=None, with_concurrency=False, with_prefill=False): + """Write `root/slug/results.json` in a minimal schema-2 shape and + return `(path, results dict)`. + + `categories` maps a name -> a list of decode_tps values, or a + `(value, n_passes)` uniform pair.""" + d = root / slug + d.mkdir(parents=True) + cats = categories or {"prose": [12.5] * 4} + single = {} + for cat, spec in cats.items(): + vals = ([float(spec[0])] * spec[1] if isinstance(spec, tuple) + else [float(v) for v in spec]) + recs = [{"ok": True, "category": cat, "ttft_ms": 48.0, + "decode_tps": t, "update_gaps_ms": [20.0, 21.0], + "completion_tokens": 100, "n_chunks": 100, + "chunking": "per_token", "finish_reason": "stop"} + for t in vals] + single[cat] = recs + results = { + "schema": 2, "corpus_version": corpus, "betterbench_version": "0.9.9", + "env": {"timestamp": "2026-09-11T08:32:23+0200", "model": model, + "endpoint": endpoint, "host": host, + "gpu": (gpu if gpu is not None + else {"nvidia_smi": "NVIDIA A100 80GB"}), + "notes": notes or {}}, + "config": {"greedy": greedy, "temperature": temperature, + "runs_per_category": 4, + "weights": {"prose": 0.2, "code": 0.2, "math": 0.2, + "json": 0.2, "reasoning": 0.1, "chat": 0.1}}, + "single_stream": single, + } + if with_concurrency: + results["concurrency"] = [ + {"level": 1, "ok": 2, "requests": 2, "aggregate_tps": 100.0, + "ttft_ms": [30.0, 31.0], "decode_tps": [50.0, 51.0]}, + {"level": 8, "ok": 8, "requests": 8, "aggregate_tps": 420.0, + "ttft_ms": [210.0, 224.0], "decode_tps": [50.0, 52.0]}, + ] + if with_prefill: + results["prefill"] = [ + {"target_depth": 200, "skipped": False, + "prompt_tokens": [150, 150], "ttft_ms": [100.0, 101.0], + "pp_tps": [1500.0, 1550.0]}, + ] + (d / "results.json").write_text(json.dumps(results)) + return d, results + + +def test_mismatch_chips_flags_corpus_sampling_host_notes(tmp_path): + _, a = _make_run(tmp_path, "r-a", model="m", endpoint="e", + greedy=True, corpus="1.0", host="node-a", + notes={"driver": "550.54", "rdma": "RoCEv2"}, + gpu={"nvidia_smi": ["driver header", "NVIDIA A100"]}) + _, b = _make_run(tmp_path, "r-b", model="m", endpoint="e", + greedy=False, corpus="1.1", host="node-b", + notes={"driver": "552.12"}, + gpu={"nvidia_smi": "NVIDIA H100"}) + chips = mismatch_chips(a, b) + assert "corpus v1.0 vs v1.1" in chips + assert "greedy vs temp 0.7" in chips + assert "node-a vs node-b" in chips + assert "NVIDIA A100 vs NVIDIA H100" in chips # GPU-label family + assert any(c.startswith("driver:") for c in chips) # a note value differs + assert any(c.startswith("rdma:") for c in chips) # a note on one side only + + +def test_mismatch_chips_empty_when_identical(tmp_path): + _, a = _make_run(tmp_path, "r-a", model="m", endpoint="e", greedy=True) + _, b = _make_run(tmp_path, "r-b", model="m", endpoint="e", greedy=True) + assert mismatch_chips(a, b) == [] + # total over any pair of result dicts: no keys, no crash + assert mismatch_chips({}, {}) == [] + assert mismatch_chips({"env": None}, {}) == [] + assert mismatch_chips({"config": None}, {}) == [] + + +def test_compare_band_decodes_paired_stats_and_missing_categories(tmp_path): + _, a = _make_run(tmp_path, "r-a", model="m", endpoint="e", greedy=True, + categories={"prose": (12.0, 5), "math": (20.0, 5)}) + _, b = _make_run(tmp_path, "r-b", model="m", endpoint="e", greedy=True, + categories={"prose": (13.0, 3), "code": (9.0, 2)}) + html = compare_band(a, b) + assert "not measured on B" in html # math missing from B + assert "not measured on A" in html # code missing from A + assert "+8.33" in html # prose: B beats A by 8.33% + # The verdict is taken from an in-process paired_compare (a zero- + # variance, non-zero difference is significant), so a future change + # to the metric code is tracked rather than the SIG/noise rendering + # asserted here being hardcoded. + p = paired_compare([12.0] * 5, [13.0] * 3, "prose") + assert ("SIG" if p.significant else "noise") in html + # the n<2 path short-circuits to "insufficient pairs", not sig + p1 = paired_compare([9.0], [9.0], "code") + assert p1.verdict == "insufficient pairs" and not p1.significant + + +def test_compare_band_no_shared_stats_shows_muted_note(tmp_path): + _, a = _make_run(tmp_path, "r-a", model="m", endpoint="e", greedy=True, + categories={"math": (20.0, 5)}) + _, b = _make_run(tmp_path, "r-b", model="m", endpoint="e", greedy=True, + categories={"code": (9.0, 2)}) + html = compare_band(a, b) + assert "no decode category measured on both sides" in html + # only the note, no rows: no per-side cells and no stats + assert "not measured on A" not in html + assert "not measured on B" not in html + assert "SIG" not in html + + +def test_render_pair_page_wires_toolbar_banner_chips_and_iframes(tmp_path): + _make_run(tmp_path, "20260114T000000-a", model="m", endpoint="e", + greedy=True, with_concurrency=True, with_prefill=True) + _make_run(tmp_path, "20260107T000000-b", model="m", endpoint="e", + greedy=True, corpus="1.1", # forced chip + with_concurrency=True, with_prefill=True) + _make_run(tmp_path, "20260101T000000-c&d", model="m", endpoint="e", + greedy=True) + html = render_pair_page(tmp_path, "20260114T000000-a", + "20260107T000000-b") + # the two embedded reports + assert 'src="/run/20260114T000000-a"' in html + assert 'src="/run/20260107T000000-b"' in html + assert 'title="Report A"' in html and 'title="Report B"' in html + # the banner sentence, verbatim + assert BANNER in html + # the forced mismatch chip + assert "corpus v1.0 vs v1.1" in html + # toolbar: each select lists every reportable run except itself + sel_a = re.search(r'', html, + re.S).group(1) + sel_b = re.search(r'', html, + re.S).group(1) + # each select excludes its own slug, includes the other and the third + assert "20260107T000000-b" not in sel_a + assert "20260114T000000-a" not in sel_b + assert "20260114T000000-a" in sel_a and "20260107T000000-b" in sel_b + assert "c%26d" in sel_a and "c%26d" in sel_b + # a slug with a `&` is URL-encoded (and html-escaped) in option values + assert 'value="20260101T000000-c%26d"' in sel_a + assert 'value="20260101T000000-c%26d"' in sel_b + # and the swap link carries the encoded slugs too + assert 'href="/pair?a=20260107T000000-b&b=20260114T000000-a"' in html diff --git a/tests/test_report_render.py b/tests/test_report_render.py index 5f650a0..8f580df 100644 --- a/tests/test_report_render.py +++ b/tests/test_report_render.py @@ -1,9 +1,15 @@ """Both table shapes render, and the batched one never shows a per-token number.""" from __future__ import annotations +import json +from pathlib import Path + from betterbench.html_report import render_html from betterbench.report import render_markdown +ROOT = Path(__file__).resolve().parents[1] +GOLDEN = ROOT / "tests" / "data" / "report_golden.html" + def _results(tok_per_update, n=200): """A synthetic single-stream result with a known tokens-per-update.""" @@ -84,3 +90,66 @@ def test_no_reasoning_evidence_means_no_extra_noise(): md = render_markdown(_thinking_results(known=0, unknown=6)) assert "Reasoning / answer split" not in md assert "Stopped at `max_tokens`: **6/6**" in md + + +def _wide_results(): + """Single-stream + concurrency + prefill in one fixture; drives every + section builder, both table shapes' inputs, and every chart.""" + a = _results(4.0) # batched + b = _results(1.0) # per-token + return { + "schema": 2, + "corpus_version": "1.0", + "env": { + "model": "fixture-model", "endpoint": "http://127.0.0.1:0/v1", + "host": "fixture-host", "timestamp": "2025-01-01T00:00:00", + "gpu": {"nvidia_smi": "NVIDIA Fixture Card"}, + "max_model_len": 8192, + "corpus_hash": "abcdef0123", + "notes": {"kernel": "fixture"}, + }, + "config": {"greedy": True, "runs_per_category": 3, "unique_nonce": True, + "weights": {"prose": 0.5, "code": 0.5}}, + "single_stream": { + "prose": a["single_stream"]["prose"], + "code": b["single_stream"]["prose"], + }, + "concurrency": [ + {"level": 1, "ok": 5, "requests": 5, "aggregate_tps": 120.5, + "ttft_ms": [40.0, 41.0, 39.0], "decode_tps": [120.5, 119.8, 121.2]}, + {"level": 4, "ok": 4, "requests": 4, "aggregate_tps": 410.25, + "ttft_ms": [120.0, 118.0, 122.0], "decode_tps": [102.5, 101.9, 103.1]}, + {"level": 8, "ok": 7, "requests": 8, "aggregate_tps": 733.9, + "ttft_ms": [310.0, 297.0, 324.0], "decode_tps": [91.7, 90.2, 93.5]}, + ], + "prefill": [ + {"target_depth": 1024, "prompt_tokens": [1023.0, 1021.0], + "ttft_ms": [9.0, 9.4], "pp_tps": [113.6, 108.7]}, + {"target_depth": 4096, "prompt_tokens": [4091.0, 4087.0], + "ttft_ms": [38.0, 37.2], "pp_tps": [107.7, 109.9]}, + {"target_depth": 16384, "prompt_tokens": [16379.0, 16371.0], + "ttft_ms": [151.0, 150.2], "pp_tps": [108.5, 109.0]}, + {"target_depth": 32768, "skipped": True}, + ], + } + + +def test_render_html_is_byte_identical_to_golden(): + assert render_html(_wide_results()) == GOLDEN.read_text() + + +def test_render_sections_and_render_document_compose_to_the_document(): + from betterbench.html_report import (render_document, render_sections) + fixture = _wide_results() + sections = render_sections(fixture) + assert set(sections) == {"title", "header", "tiles", "figures", + "tables", "footer", "data"} + document = render_document(sections) + assert document == render_html(fixture) + assert document == GOLDEN.read_text() + assert isinstance(sections["data"], dict) + golden = GOLDEN.read_text() + start = golden.index('"use strict";\nconst D = ') + end = golden.index(";\nconst NS", start) + snippet = golden[start + len('"use strict";\nconst D = '):end] + assert json.dumps(sections["data"]) == snippet diff --git a/tests/test_session_port.py b/tests/test_session_port.py new file mode 100644 index 0000000..6fb8cdf --- /dev/null +++ b/tests/test_session_port.py @@ -0,0 +1,337 @@ +"""The interactive compare session: a stdlib HTTP server bound to +127.0.0.1 on the kernel-assigned port 0, the three routes over a re-scanned +runs dir, and the `compare` no-args CLI branch that opens it. + +The `_make_run` fixture pattern is copied from `tests/test_gallery.py` (test +files in this repo don't import each other). +""" +from __future__ import annotations + +import http.client +import json +from pathlib import Path + +import numpy as np +import pytest + +from betterbench import session +from betterbench.cli import main +from betterbench.metrics import paired_compare +from betterbench.runs import betterbench_home + + +def _make_run(root: Path, slug: str, *, model, endpoint, greedy, + n_passes=4): + """Write `root/slug/results.json` in a minimal schema-2 shape and + return the results dict.""" + d = root / slug + d.mkdir(parents=True) + recs = [{"ok": True, "category": "prose", "ttft_ms": 48.0, + "decode_tps": 12.5, "update_gaps_ms": [20.0, 21.0], + "completion_tokens": 100, "n_chunks": 100, + "chunking": "per_token", "finish_reason": "stop"} + for _ in range(n_passes)] + results = { + "schema": 2, "corpus_version": "1.0", + "betterbench_version": "0.9.9", + "env": {"timestamp": "2026-09-11T08:32:23+0200", "model": model, + "endpoint": endpoint, "host": "bench-host"}, + "config": {"greedy": greedy, "temperature": 0.7, + "runs_per_category": n_passes, + "weights": {"prose": 0.2, "code": 0.2, "math": 0.2, + "json": 0.2, "reasoning": 0.1, "chat": 0.1}}, + "single_stream": {"prose": [dict(r) for r in recs]}, + } + (d / "results.json").write_text(json.dumps(results)) + return results + + +def _get(port: int, path: str) -> tuple[int, str]: + """One raw GET: `path` goes on the wire verbatim (so a percent- + encoding in it is never double-encoded by a client).""" + c = http.client.HTTPConnection("127.0.0.1", port) + c.request("GET", path) + r = c.getresponse() + body = r.read().decode("utf-8") + c.close() + return r.status, body + + +def test_zero_runs_exits_with_message(tmp_path): + with pytest.raises(SystemExit) as e: + session.start(tmp_path) + msg = str(e.value) + assert "no reportable runs" in msg + assert "betterbench run" in msg + + +def test_cli_one_or_three_args_errors(): + with pytest.raises(SystemExit) as e: + main(["compare", "a.json"]) + assert "expected 2 results files" in str(e.value) + with pytest.raises(SystemExit) as e: + main(["compare", "a", "b", "c"]) + assert "expected 2 results files" in str(e.value) + + +def test_routes_serve_gallery_run_and_pair(tmp_path): + _make_run(tmp_path, "20260101T000000-a", model="ma", + endpoint="http://a:1", greedy=True) + _make_run(tmp_path, "20260114T000000-b", model="mb", + endpoint="http://b:1", greedy=False) + srv = session.make_server(tmp_path) + port = srv.server_address[1] + try: + code, body = _get(port, "/") + assert code == 200 + assert "20260101T000000-a" in body + assert "20260114T000000-b" in body + + code, body = _get(port, "/run/20260101T000000-a") + assert code == 200 + assert body.startswith("") + assert "← All runs" in body # the session bar link + assert "Compare…" in body # the session bar select label + assert 'value="20260114T000000-b"' in body # the other run's option + assert '"use strict"' in body # the chart script survived + + code, body = _get(port, + "/pair?a=20260101T000000-a&b=20260114T000000-b") + assert code == 200 + assert '/run/20260101T000000-a' in body # iframe A + assert '/run/20260114T000000-b' in body # iframe B + assert "cross-file compare is unpaired in time" in body + finally: + srv.shutdown() + srv.server_close() + + +def test_unknown_slugs_are_404(tmp_path): + _make_run(tmp_path, "20260101T000000-a", model="ma", + endpoint="http://a:1", greedy=True) + srv = session.make_server(tmp_path) + port = srv.server_address[1] + try: + code, body = _get(port, "/pair?a=20260101T000000-a&b=nope") + assert code == 404 + assert "no such run: nope" in body + + code, body = _get(port, "/run/nope") + assert code == 404 + assert "no such run: nope" in body + + # Raw percent-encoded traversal: `%2e%2e` decodes to `..`, which + # fails the slug validation, so it can never resolve to a file. + code, _ = _get(port, "/%2e%2e/%2e%2e/etc/passwd") + assert code == 404 + + code, body = _get(port, "/nope") + assert code == 404 + assert body == "not found\n" + finally: + srv.shutdown() + srv.server_close() + + +def test_missing_pair_param_is_plain_404(tmp_path): + _make_run(tmp_path, "20260101T000000-a", model="ma", + endpoint="http://a:1", greedy=True) + srv = session.make_server(tmp_path) + port = srv.server_address[1] + try: + code, body = _get(port, "/pair") + assert code == 404 + assert body == "not found\n" + + code, body = _get(port, "/pair?a=20260101T000000-a") + assert code == 404 + assert body == "not found\n" + finally: + srv.shutdown() + srv.server_close() + + +def test_skipped_run_is_404_with_explanation(tmp_path): + ab = tmp_path / "ab-only-2026" + ab.mkdir() + (ab / "ab.json").write_text(json.dumps({"model": "m", "pairs": 0})) + bad = tmp_path / "corrupt-2026" + bad.mkdir() + (bad / "results.json").write_text("{not json") + srv = session.make_server(tmp_path) + port = srv.server_address[1] + try: + code, body = _get(port, "/run/ab-only-2026") + assert code == 404 + assert "no results.json" in body # the RunEntry.error + + code, body = _get(port, "/run/corrupt-2026") + assert code == 404 + try: + json.loads("{not json") + except json.JSONDecodeError as je: + assert str(je) in body # the entry's error, verbatim + finally: + srv.shutdown() + srv.server_close() + + +def test_unrenderable_run_is_plain_404_not_a_reset(tmp_path): + """A run whose `results.json` is a JSON **list** (parseable, + non-object): `list_runs` reports it (anything non-`None` is + reportable) but `render_html` can't render a list — + `render_sections` hits `'list' object has no attribute 'get'`. + `/run/` must answer a real plain-text 404 (the connected + client gets a status + body), not a connection reset; the gallery + serves the same run as a degraded row, and `/` is unaffected.""" + weird = tmp_path / "weird-shape-2026" + weird.mkdir() + (weird / "results.json").write_text( + json.dumps([{"schema": 2, "single_stream": {}}])) + srv = session.make_server(tmp_path) + port = srv.server_address[1] + try: + # An answered 404 with the pinned `not found — ` prefix; a + # connection reset would kill the raw client mid-response + # instead of yielding a status + body. + code, body = _get(port, "/run/weird-shape-2026") + assert code == 404 + assert body.startswith("not found — ") + # Other routes are unaffected. + code, _ = _get(port, "/") + assert code == 200 + finally: + srv.shutdown() + srv.server_close() + + +def test_percent_named_run_round_trips_all_routes(tmp_path): + """A run whose directory name contains a literal `%` round-trips + through every route under the encode-once/decode-once convention: + the links are `quote(slug, safe="")` on the wire and the handler + decodes exactly once, so the name matches the listing.""" + import urllib.parse + pct = "a%20b" # a dir literally named a-%-2-0-b + _make_run(tmp_path, pct, model="mp", endpoint="http://p:1", + greedy=True) + _make_run(tmp_path, "20260101T000000-a", model="ma", + endpoint="http://a:1", greedy=True) + enc = urllib.parse.quote(pct, safe="") # 'a%2520b' + srv = session.make_server(tmp_path) + port = srv.server_address[1] + try: + # (a) the gallery link is the once-encoded slug + code, body = _get(port, "/") + assert code == 200 + assert f'href="/run/{enc}"' in body + + # (b) the once-encoded path decodes to the literal name + code, body = _get(port, f"/run/{enc}") + assert code == 200 + assert "← All runs" in body + + # (c) the pair route (parse_qs decodes once, nothing more) + code, body = _get(port, + f"/pair?a={enc}&b=20260101T000000-a") + assert code == 200 + # (d) the pair's iframe src is the once-encoded slug too + assert f'src="/run/{enc}"' in body + finally: + srv.shutdown() + srv.server_close() + + +def test_next_run_is_visible_on_the_next_request(tmp_path): + _make_run(tmp_path, "20260101T000000-a", model="ma", + endpoint="http://a:1", greedy=True) + srv = session.make_server(tmp_path) + port = srv.server_address[1] + try: + _code, body = _get(port, "/") + assert "20260101T000000-a" in body + assert "20260115T000000-b" not in body + # A run lands on disk while the session is open: + _make_run(tmp_path, "20260115T000000-b", model="mb", + endpoint="http://b:1", greedy=False) + _code, body = _get(port, "/") # re-scan: no cache anywhere + assert "20260115T000000-b" in body + finally: + srv.shutdown() + srv.server_close() + + +def test_compare_two_arg_output_pinned(tmp_path, capsys): + """The two-arg `compare` terminal output, pinned byte for byte: an + argparse regression (e.g. `results` going back to positional + `a`/`b`) breaks this test, and any change to the table's shape does + too. The medians below are recomputed here from the same series the + files carry, exactly as `cmd_compare` computes them.""" + a = tmp_path / "a.json" + b = tmp_path / "b.json" + a_data = [12.0, 12.1, 11.9, 12.0] + b_data = [12.6, 12.7, 12.5, 12.6] + + def one(vals) -> dict: + recs = [{"ok": True, "category": "prose", "ttft_ms": 48.0, + "decode_tps": v, "update_gaps_ms": [20.0, 21.0], + "completion_tokens": 100, "n_chunks": 100, + "chunking": "per_token", "finish_reason": "stop"} + for v in vals] + return {"schema": 2, "single_stream": {"prose": recs}} + + a.write_text(json.dumps(one(a_data))) + b.write_text(json.dumps(one(b_data))) + + main(["compare", str(a), str(b)]) + out = capsys.readouterr().out + + pr = paired_compare(a_data, b_data, "prose", higher_is_better=True) + row = (f"| prose | {np.median(a_data):.1f} | {np.median(b_data):.1f} | " + f"{pr.pct_diff:+.2f}% | [{pr.ci_low_pct:+.1f}%,{pr.ci_high_pct:+.1f}%] | " + f"{'SIG' if pr.significant else 'noise'} |") + expected = ("# BetterBench compare (offline, per-category decode t/s)\n" + "\n" + "| category | A med | B med | Δ% | 95% CI | verdict |\n" + "|---|--:|--:|--:|---|---|\n" + + row + "\n" + "\n" + "*Cross-file compares are unpaired in time; prefer " + "`betterbench ab` for interleaved, drift-cancelled " + "comparisons.*\n") + assert out == expected + assert "# BetterBench compare (offline, per-category decode t/s)" in out + assert "| category | A med | B med | Δ% | 95% CI | verdict |" in out + assert f"| prose | {np.median(a_data):.1f}" in out + assert f"| {np.median(b_data):.1f}" in out + assert "Cross-file compares are unpaired in time" in out + + +def test_two_consecutive_sessions_get_different_ports(tmp_path): + _make_run(tmp_path, "20260101T000000-a", model="ma", + endpoint="http://a:1", greedy=True) + s1 = session.make_server(tmp_path) + try: + s2 = session.make_server(tmp_path) # both open concurrently + try: + assert s1.server_address[1] != s2.server_address[1] + finally: + s2.shutdown() + s2.server_close() + finally: + s1.shutdown() + s1.server_close() + + +def test_cli_no_args_routes_to_session(tmp_path, monkeypatch): + monkeypatch.setenv("BETTERBENCH_HOME", str(tmp_path)) + (tmp_path / "runs").mkdir() + _make_run(tmp_path / "runs", "20260101T000000-a", model="ma", + endpoint="http://a:1", greedy=True) + import betterbench.session as session_mod + calls: list = [] + monkeypatch.setattr(session_mod, "start", + lambda d: calls.append(d)) + main(["compare"]) + assert len(calls) == 1 + assert str(calls[0]).endswith("/runs") + assert str(betterbench_home()) == str(tmp_path)