@wendellresearch-ai/benchwarmers 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/serve.py CHANGED
@@ -39,6 +39,7 @@ import datetime
39
39
  import functools
40
40
  import html
41
41
  import json
42
+ import math
42
43
  import os
43
44
  import pathlib
44
45
  import re
@@ -2253,27 +2254,6 @@ def open_count(groups: dict) -> int:
2253
2254
  return sum(len(g["rows"]) + len(g["held"]) for g in groups.values())
2254
2255
 
2255
2256
 
2256
- def and_list(parts: list[str]) -> str:
2257
- return " and ".join(parts) if len(parts) < 3 else ", ".join(parts[:-1]) + " and " + parts[-1]
2258
-
2259
-
2260
- def actions_line(groups: dict) -> str:
2261
- """At a glance's one line about Action needed, when anything is open:
2262
- what is waiting and how soon, and the way to its page."""
2263
- rows = [r for g in groups.values() for r in g["rows"]]
2264
- held = sum(len(g["held"]) for g in groups.values())
2265
- if not rows and not held:
2266
- return ""
2267
- models = sum(1 for r in rows if r["alias"])
2268
- what = and_list([f"{n} {word}{'s' * (n != 1)}" for n, word in
2269
- ((models, "model"), (held, "connection"), (len(rows) - models, "notice")) if n])
2270
- soon = min(r["days"] for r in rows) if rows else 99
2271
- many = sum(r["days"] == soon for r in rows)
2272
- when = (f"; {many} retire{'s' * (many == 1)} in {soon} day{'s' * (soon != 1)}" if soon < 99 else "")
2273
- return (f"<p class='a-line'><strong>Action needed</strong> <span>{what}{when}.</span>"
2274
- " <button type='button' class='linkish' data-go='needs'>Review →</button></p>")
2275
-
2276
-
2277
2257
  def actions_page(groups: dict, auto: list) -> str:
2278
2258
  """Action needed, its own page since 2026-11-02: it sat above every page
2279
2259
  and grew to a screen and more of red. Grouped by connection, because
@@ -2372,167 +2352,6 @@ def price_of(alias: str, served: str, f: dict) -> str:
2372
2352
  return "paid" if sources.is_free(f.get("cost")) is False else "unknown"
2373
2353
 
2374
2354
 
2375
- def facet(name: str, label: str, values) -> str:
2376
- """A filter by one column's value, beside a panel's search box (bench.js
2377
- matches it against each row's data-<name>)."""
2378
- opts = "".join(f"<option value='{esc(v)}'>{esc(v)}</option>" for v in sorted(set(values) - {""}))
2379
- return (f"<label>{esc(label)} <select data-facet='{esc(name)}'><option value=''>any</option>"
2380
- f"{opts}</select></label>")
2381
-
2382
-
2383
- def facts_cells(alias: str, f: dict | None = None) -> str:
2384
- """Context window and capabilities, from metrics/catalog.py; a value you
2385
- entered is marked, and an unknown one says so rather than looking empty."""
2386
- f = f or catalog.info(alias)
2387
- ctx = f.get("context_window")
2388
- src = f["sources"].get("context_window", "")
2389
- ctx_cell = (f"<td class='num' data-sort='{ctx}' title='{esc(src)}'>"
2390
- f"{ctx // 1000:,}k{'*' if src == 'you' else ''}</td>" if ctx
2391
- else "<td class='num muted' data-sort='-1' title='unknown: fill it in the model popup'>?</td>")
2392
- caps = [CAP_LABELS[c] for c in catalog.CAPS if f.get(c)]
2393
- return ctx_cell + (f"<td class='caps'>{' '.join(chip(c, '') for c in caps)}</td>" if caps
2394
- else "<td class='muted'>—</td>")
2395
-
2396
-
2397
- def roster() -> str:
2398
- """Every model on one table: its state, what it has done, what it scored.
2399
-
2400
- Replaces a grid of offload cards that answered only "what was it used
2401
- for". Status first, because the first question on opening this page is
2402
- whether anything is broken; real work second; benchmarks after."""
2403
- try:
2404
- from offload import summary
2405
- work = summary()
2406
- except Exception: # noqa: BLE001
2407
- work = {}
2408
- try:
2409
- import auto_retire
2410
- rows, answers, _ = auto_retire.evidence()
2411
- except Exception: # noqa: BLE001
2412
- rows, answers = [], {}
2413
- last = dict(answers)
2414
- for r in rows:
2415
- if r["answered"]:
2416
- last[r["alias"]] = max(last.get(r["alias"], r["date"]), r["date"])
2417
- suite: dict[str, list[int]] = {}
2418
- for (_task, model), hit in suite_scores().items():
2419
- if (m := re.match(r"(\d+)/(\d+)", hit["found"])):
2420
- got = suite.setdefault(model, [0, 0, 0])
2421
- got[0] += int(m.group(1)); got[1] += int(m.group(2)); got[2] += 1
2422
- tiny = {r["model"]: float(r["p50_s"]) for r in perf_rows()
2423
- if r["prompt"] == "tiny" and int(r.get("answered") or 0) > 0}
2424
- status = model_status()
2425
- new = recently_onboarded()
2426
- stuck = onboard_issues(status)
2427
- directory = model_directory()
2428
- severity = {"err": 0, "warn": 1, "": 2, "ok": 3}
2429
-
2430
- up = uptime_rows()["models"]
2431
- body = ["<table><thead><tr>", th("Model"), th("Status"), th("Uptime 7d", True), th("Role"),
2432
- th("Context", True), th("Can"),
2433
- th("Offloads", True), th("Helped"), th("Suite", True),
2434
- th("Reply time", True), th("Last answer"), "</tr></thead><tbody>"]
2435
- # The model browser: each row says its connection, status, role and price
2436
- # for the toolbar's filters, and the filters offer what the rows hold.
2437
- offered: dict[str, list[str]] = {"connection": [], "status": [], "role": [], "price": []}
2438
-
2439
- def browse(**values) -> str:
2440
- for k, v in values.items():
2441
- offered[k].append(v)
2442
- return "".join(f" data-{k}='{esc(v)}'" for k, v in values.items())
2443
- for alias in sorted(status, key=lambda a: (-work.get(a, {}).get("offloads", 0), a)):
2444
- label, css, why = status[alias]
2445
- f = catalog.info(alias)
2446
- served = directory.get(alias, {}).get("served") or retired().get(alias, {}).get("served", "")
2447
- w = work.get(alias, {})
2448
- judged, useful = w.get("judged", 0), w.get("useful", 0)
2449
- if judged:
2450
- rate = useful / judged
2451
- helped = (f"<span class='helped'><span class='meter'><span"
2452
- f"{' class=low' if rate < .5 else ''} style='width:{max(4, round(rate * 100))}%'>"
2453
- f"</span></span><span class='mono'>{useful}/{judged}</span></span>")
2454
- else:
2455
- rate, helped = -1, "<span class='muted'>not judged</span>"
2456
- s = suite.get(alias)
2457
- suite_cell = (f"<td class='num' data-sort='{s[0] / s[1]:.3f}'"
2458
- f" title='{s[2]} task{'s' * (s[2] != 1)}'>{s[0]}/{s[1]}</td>"
2459
- if s and s[1] else "<td class='num muted' data-sort='-1'>—</td>")
2460
- p50 = tiny.get(alias)
2461
- seen = last.get(alias)
2462
- role = directory.get(alias, {}).get("role") or retired().get(alias, {}).get("role", "")
2463
- conn = catalog.connection_of(alias)
2464
- if conn == "?": # retired: no registration left to say, but its record does
2465
- conn = retired().get(alias, {}).get("tag", "").split("/")[0] or "unknown"
2466
- attrs = browse(connection=conn, status=re.sub(r" in \d+d$", "", label),
2467
- role=role, price=price_of(alias, served, f))
2468
- body.append(
2469
- f"{tr(alias, attrs)}<td>{named(alias)}</td>"
2470
- f"<td data-sort='{severity[css]}'>{chip(label, css, why)}"
2471
- + (" " + chip("new", "new", "onboarded this week") if alias in new else "")
2472
- + (" " + chip("onboard stopped", stuck[alias]["css"],
2473
- f"onboarding stopped at the {stuck[alias].get('step', 'probe')}:"
2474
- f" {stuck[alias]['label']}. Open the model for the error.")
2475
- if alias in stuck else "")
2476
- + "</td>" + uptime_cell(up.get(alias)) + f"<td>{esc(role)}</td>{facts_cells(alias, f)}"
2477
- f"<td class='num'>{w.get('offloads', 0)}</td>"
2478
- f"<td data-sort='{rate:.3f}'>{helped}</td>{suite_cell}"
2479
- + (f"<td class='num' data-sort='{p50}'>{p50:.1f}s</td>" if p50 is not None
2480
- else "<td class='num muted' data-sort='99999'>—</td>")
2481
- + (f"<td class='mono' data-sort='{seen}'>{seen}</td>" if seen
2482
- else "<td class='muted' data-sort='0'>never</td>")
2483
- + "</tr>")
2484
- # Decision models are not in the llm yaml, so no probe, suite or reply time
2485
- # applies; the role column says what they are and what they answer.
2486
- for alias, d in sorted(decide.load().items()):
2487
- w = work.get(alias, {})
2488
- body.append(
2489
- "<tr" + browse(connection=d.get("via", ""), status="no probe", role="decision",
2490
- price="free" if ":free" in d["served"] else "unknown") + ">"
2491
- f"<td>{named(alias)}</td>"
2492
- "<td data-sort='2'><span class='muted' title='decision models have no"
2493
- " availability probe yet'>—</span>"
2494
- + (" " + chip("new", "new", "onboarded this week") if d.get("onboarded", "") >=
2495
- (datetime.date.today() - datetime.timedelta(days=7)).isoformat() else "")
2496
- + "</td>" + uptime_cell(up.get(alias)) + f"<td>decision · {esc(', '.join(d['question_types']))}</td>"
2497
- "<td class='num muted' data-sort='-1'>—</td><td class='muted'>—</td>"
2498
- f"<td class='num'>{w.get('offloads', 0)}</td>"
2499
- "<td data-sort='-1'><span class='muted'>not judged</span></td>"
2500
- "<td class='num muted' data-sort='-1'>—</td><td class='num muted' data-sort='99999'>—</td>"
2501
- "<td class='muted' data-sort='0'>—</td></tr>")
2502
- body.append("</tbody></table>")
2503
- key = """<dl>
2504
- <dt>Status</dt><dd>From the latest probe. <em>healthy</em> answered;
2505
- <em>throttled</em> and <em>slow</em> are rate limits and timeouts, which
2506
- never retire a model inside 7 days; <em>retiring in Nd</em> is the
2507
- countdown before the bench retires it on its own. Hover a chip for the
2508
- provider's own words.</dd>
2509
- <dt>Uptime 7d</dt><dd>Of its calls in the last 7 days that say anything about the provider,
2510
- the share answered: busy, unreachable and timed out count against it; our own quota,
2511
- pacing and setup problems are left out. Benchmarks has it a day at a time.</dd>
2512
- <dt>Offloads, Helped</dt><dd>Real work handed over, and how much of what
2513
- was judged actually helped. Benchmarks never count here.</dd>
2514
- <dt>Suite</dt><dd>Verified findings located across the internal tasks:
2515
- the tier that decides routing.</dd>
2516
- <dt>Reply time</dt><dd>Median seconds to answer a one-word prompt: the
2517
- serving path's overhead, not the model's worth.</dd>
2518
- <dt>Last answer</dt><dd>The latest real answer from any source: probe,
2519
- suite or a useful offload. Seven days without one retires a model.</dd>
2520
- <dt>decision</dt><dd>A decision model: it answers typed questions (yes/no,
2521
- choice, score) with probabilities through a Decisions API, never chat.
2522
- The role column lists the question types it takes. No probe, suite or
2523
- reply time applies to it.</dd>
2524
- <dt>Filters</dt><dd>Connection is where a model's calls go (its Open WebUI
2525
- tag, or its own endpoint). Price: <em>free</em> for a <code>:free</code>
2526
- slug, a local model, a list price of 0 or a free-tier listing;
2527
- <em>paid</em> for a subscription or a list price; else unknown. Combine
2528
- them with the search box.</dd>
2529
- </dl>"""
2530
- facets = "".join(facet(k, k.title(), v) for k, v in offered.items())
2531
- return card("At a glance", "Every model on the bench: whether it works right "
2532
- "now, what it has been used for, and what it scored.",
2533
- "".join(body), key, facets)
2534
-
2535
-
2536
2355
  def on_bench() -> set[str]:
2537
2356
  """Aliases registered now: what the charts draw."""
2538
2357
  return set(registered().values())
@@ -2639,7 +2458,7 @@ def charts_data() -> dict:
2639
2458
  "rate": int(hit[1]) / max(1, int(hit[2])) if hit else None, "invented": bool(v["invented"])})
2640
2459
  bars = [{"model": m, "kind": k, "rate": w / r["answers"], "n": f"{w}/{n}"}
2641
2460
  for m, r in sorted(wr.items()) if m in live and r["answers"] for k, (w, n) in r["by"].items() if w]
2642
- totals = {m: {"rate": r["wrong"] / r["answers"], "answers": r["answers"]}
2461
+ totals = {m: {"rate": r["wrong"] / r["answers"], "answers": r["answers"], "wrong": r["wrong"]}
2643
2462
  for m, r in wr.items() if m in live and r["answers"]}
2644
2463
  hunt = [{"model": m, **r["hunt"]} for m, r in sorted(wr.items()) if m in live and any(r["hunt"].values())]
2645
2464
  return {"overview": overview, "heat": heat, "families": ["hand-built", "claims", "seeded, function",
@@ -2900,16 +2719,10 @@ def vision_card() -> str:
2900
2719
 
2901
2720
 
2902
2721
  def perf_charts() -> dict:
2903
- """Reliability over the last 14 days of probes, and speed on real tasks
2904
- against how much a model finds, for the Performance panel of Benchmarks."""
2722
+ """Speed on real tasks against how much a model finds, for the Performance
2723
+ panel of Benchmarks. (Whether probes answered is Uptime's.)"""
2905
2724
  import route
2906
2725
  live = on_bench()
2907
- since = (datetime.date.today() - datetime.timedelta(days=14)).isoformat()
2908
- outcomes: dict[tuple[str, str], int] = {}
2909
- for r in perf_rows():
2910
- if r["model"] in live and r.get("timestamp", "") >= since:
2911
- o = "answered" if int(r.get("answered") or 0) > 0 else (r.get("failure") or "error")
2912
- outcomes[(r["model"], o)] = outcomes.get((r["model"], o), 0) + 1
2913
2726
  seconds: dict[str, list[float]] = {}
2914
2727
  for (_task, m), v in suite_scores().items():
2915
2728
  if m in live and re.match(r"\d+/\d+", v["found"]):
@@ -2917,8 +2730,7 @@ def perf_charts() -> dict:
2917
2730
  recall = route.suite_recall()
2918
2731
  speed = [{"model": m, "seconds": sorted(s)[len(s) // 2], "recall": recall[m][0] / recall[m][1], "tasks": len(s)}
2919
2732
  for m, s in sorted(seconds.items()) if recall.get(m) and recall[m][1]]
2920
- return {"reliability": [{"model": m, "outcome": o, "count": n} for (m, o), n in sorted(outcomes.items())],
2921
- "speed": speed}
2733
+ return {"speed": speed}
2922
2734
 
2923
2735
 
2924
2736
  # ---- Trend: does a model score lower, or higher, than it used to? ----------
@@ -3384,15 +3196,6 @@ def _uptime_window(cells: list, days: int) -> dict:
3384
3196
  "median": seconds[len(seconds) // 2] if seconds else None}
3385
3197
 
3386
3198
 
3387
- def uptime_cell(cells: list | None, days: int = UPTIME_DAYS[0]) -> str:
3388
- """At a glance's Uptime column: answered of counted over the last `days`."""
3389
- t = _uptime_window(cells or [], days)
3390
- if t["pct"] is None:
3391
- return "<td class='num muted' data-sort='-1' title='no counted calls'>—</td>"
3392
- return (f"<td class='num{' err' if t['pct'] < .8 else ''}' data-sort='{t['pct']:.4f}'"
3393
- f" title='{t['up']} of {t['up'] + t['down']} answered'>{t['pct'] * 100:.1f}%</td>")
3394
-
3395
-
3396
3199
  def _why(kinds: dict, which) -> str:
3397
3200
  return ", ".join(f"{k} {n}" for k, n in sorted(kinds.items(), key=lambda kv: -kv[1]) if which(k))
3398
3201
 
@@ -3545,9 +3348,15 @@ DECIDE_KIND = {"errors": "choice of 6", "collection": "choice of the collections
3545
3348
  def decision_data() -> dict:
3546
3349
  """For bench.js: each model's calibration bins per set, [said, right, n]."""
3547
3350
  res = decide.results()
3548
- return {"sets": [[s, DECIDE_SETS[s]] for s in decide.SETS],
3351
+ models = sorted((res.get("models") or {}).items())
3352
+ return {"sets": [[s, DECIDE_SETS[s]] for s in decide.SETS], "low": decide.MIN_ITEMS,
3353
+ # Right of asked per model and set: the bars, as the tables count it.
3354
+ "scores": [{"model": a, "set": s, "correct": r.get("correct", 0), "asked": r["asked"],
3355
+ "answered": r.get("answered", 0), "brier": r.get("brier")}
3356
+ for a, by in models for s, r in by.items()
3357
+ if s in decide.SETS and isinstance(r, dict) and r.get("asked")],
3549
3358
  "points": [{"model": a, "set": s, "said": b["said"], "right": b["right"], "n": b["n"]}
3550
- for a, by in sorted((res.get("models") or {}).items()) for s, r in by.items()
3359
+ for a, by in models for s, r in by.items()
3551
3360
  if s in decide.SETS for b in r.get("calibration") or []]}
3552
3361
 
3553
3362
 
@@ -3614,8 +3423,7 @@ def decision_card() -> str:
3614
3423
  extra = (f"<h3 class='chart-head'>Who each use asks now</h3><ul class='links'>{''.join(uses)}</ul>"
3615
3424
  f"<p class='hint'>{'Measured up to ' + esc(when[:16].replace('T', ' ')) + '.' if when else 'Not measured yet.'}"
3616
3425
  " The scheduler's <code>decisions</code> job asks each model one question a set a day;"
3617
- " <code>python metrics/decide.py bench</code> runs a pass by hand.</p>"
3618
- "<h3 class='chart-head'>Calibration: said against right</h3>")
3426
+ " <code>python metrics/decide.py bench</code> runs a pass by hand.</p>")
3619
3427
  key = f"""<dl>
3620
3428
  <dt>The sets</dt><dd>Questions with known answers, built from the bench's own data, no hand labelling.
3621
3429
  <b>Error classes</b>: failed calls' error text (redacted: no keys, ids or emails) and the class the
@@ -3627,8 +3435,12 @@ def decision_card() -> str:
3627
3435
  picked, so always saying the commonest option scores low.</dd>
3628
3436
  <dt>Brier</dt><dd>How far its probabilities sat from the truth: 0 is perfect; for a choice it is
3629
3437
  summed over the options, so up to 2. Lower is better, and a sure wrong answer costs most.</dd>
3630
- <dt>Calibration</dt><dd>The chart: answers grouped by the probability the model gave its answer
3631
- (x), against how often those answers were right (y). On the diagonal, its 80% answers come true
3438
+ <dt>The bars</dt><dd>Right of asked for each model on each set, from 0%. A faint bar is a model
3439
+ under {low} items on that set: shown, not ranked yet. Hover a bar for its Brier.</dd>
3440
+ <dt>Calibration</dt><dd>The second chart, once a model answers at two or more levels of sureness
3441
+ on a set: answers grouped by the probability the model gave its answer
3442
+ (x), against how often those answers were right (y); both axes start just under the lowest
3443
+ answer, not at 0%. On the diagonal, its 80% answers come true
3632
3444
  80% of the time; under it, it is surer than it should be. A bigger dot is more answers. The gap is
3633
3445
  the average distance from the diagonal, weighted by answers.</dd>
3634
3446
  <dt>At {decide.CONFIDENT}</dt><dd>The gateway acts on a choice only when the model is this sure: how
@@ -3669,26 +3481,45 @@ def bughunt_card() -> str:
3669
3481
  grey said there was no bug.</dd>
3670
3482
  <dt>Left of the line</dt><dd>The same files untouched: red is a false alarm, claiming a
3671
3483
  bug in clean code. The longer the red, the less to trust its "there's a bug here".</dd>
3672
- <dt>Order</dt><dd>Found share minus false-alarm share, best at the top.</dd>
3484
+ <dt>Order</dt><dd>On what is proven, best at the top: the low end of the found share's likely
3485
+ range (95%) less the high end of the false alarms'. A few tasks give wide ranges, so they
3486
+ rank low; a model never shown clean code could be raising alarms on all of it.</dd>
3487
+ <dt>Faint bars</dt><dd>Under 10 tasks: shown, but little to go on.</dd>
3673
3488
  </dl>"""
3674
3489
  return chart_card("Bug hunt", "Every task with an answer key: bugs found against false alarms on the"
3675
3490
  " same files left clean. Right is good, left is bad.", "chart-hunt", "".join(body), key)
3676
3491
 
3677
3492
 
3493
+ def _wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
3494
+ """The 95% Wilson interval of k successes in n, as bench.js wilson() draws it."""
3495
+ if not n:
3496
+ return 0.0, 1.0
3497
+ p, z2 = k / n, z * z
3498
+ mid, half = p + z2 / (2 * n), z * math.sqrt(p * (1 - p) / n + z2 / (4 * n * n))
3499
+ return max(0.0, (mid - half) / (1 + z2 / n)), min(1.0, (mid + half) / (1 + z2 / n))
3500
+
3501
+
3678
3502
  def work_card() -> str:
3679
3503
  """How often real offloads helped."""
3680
3504
  rows = work_rows()
3681
3505
  body = ["<table><thead><tr>", th("Model"), th("Offloads", True), th("Judged", True), th("Helped", True),
3682
- th("Verified findings", True), th("False claims", True), "</tr></thead><tbody>"]
3683
- for r in sorted(rows, key=lambda r: -r["useful"] / r["judged"]):
3506
+ th("Likely between", True), th("Verified findings", True), th("False claims", True), "</tr></thead><tbody>"]
3507
+ for r in sorted(rows, key=lambda r: (-_wilson(r["useful"], r["judged"])[0], -r["judged"])):
3508
+ lo, hi = _wilson(r["useful"], r["judged"])
3684
3509
  body.append(f"<tr><td>{named(r['model'])}</td><td class='num'>{r['offloads']}</td><td class='num'>{r['judged']}</td>"
3685
3510
  f"<td class='num' data-sort='{r['useful'] / r['judged']:.3f}'>{r['useful']}/{r['judged']}</td>"
3511
+ f"<td class='num' data-sort='{lo:.3f}'>{lo * 100:.0f}–{hi * 100:.0f}%</td>"
3686
3512
  f"<td class='num'>{r['real']}</td><td class='num'>{r['false']}</td></tr>")
3687
3513
  body.append("</tbody></table>")
3688
3514
  key = """<dl>
3689
3515
  <dt>Helped</dt><dd>Of the offloads judged, the share recorded useful: real work (reviews,
3690
3516
  enumerations) handed to the model and checked afterwards. Benchmarks never count here.</dd>
3691
- <dt>The number beside a name</dt><dd>How many were judged. 3 of 3 is weaker evidence than 40 of 50.</dd>
3517
+ <dt>The dot and its line</dt><dd>The dot is the share that helped, bigger for more judged. The
3518
+ line is where the true share likely sits (a 95% Wilson interval): long for 3 of 3, short for
3519
+ 40 of 50. Green when even its low end is over half, red when even its high end is under,
3520
+ grey when there are too few to say.</dd>
3521
+ <dt>Order</dt><dd>By the low end of that line, so volume counts: 40 of 50 ranks above 3 of 3.</dd>
3522
+ <dt>The dashed line</dt><dd>Every model's judged offloads together, for comparison.</dd>
3692
3523
  <dt>Findings and false claims</dt><dd>Verified one by one in reviews, recorded since 2026-10-17.</dd>
3693
3524
  </dl>"""
3694
3525
  return chart_card("Real work", "When a model was given real work and the result was checked: how often"
@@ -3698,7 +3529,8 @@ def work_card() -> str:
3698
3529
  def overview_card() -> str:
3699
3530
  return (f"<div class='panel'>{SHARE}<h2>Who to trust with a review</h2><p class='sub'>Each dot is a model."
3700
3531
  " Right: finds more of the real bugs in our own code. Up: makes fewer things up (confidently"
3701
- " wrong on clean code, wrong lines, false claims, traps). Top right is where a reviewer should be.</p>"
3532
+ " wrong on clean code, wrong lines, false claims, traps). Top right is where a reviewer should be."
3533
+ " The models in or near the box, and the three that make most up, are named; hover for the rest.</p>"
3702
3534
  "<div class='chart' id='chart-overview'></div></div>")
3703
3535
 
3704
3536
 
@@ -3811,6 +3643,10 @@ def wrong_card() -> str:
3811
3643
  <dt>Reviews</dt><dd>False claims recorded in verdicts (<code>offload.py verdict|add
3812
3644
  --false "..."</code>), over judged offloads. Recorded in structured form only since
3813
3645
  2026-10-17, so older reviews show none.</dd>
3646
+ <dt>The thin line</dt><dd>Where the true share likely sits (95% range). Long for a few
3647
+ answers: 0 of 2 is not a clean record, and the line shows how bad it could be.</dd>
3648
+ <dt>Order</dt><dd>Worst proven at the top: by the low end of that line. Under 10 answers a
3649
+ model's bar is faint.</dd>
3814
3650
  </dl>"""
3815
3651
  return chart_card("Confidently wrong", "Who asserts things that are not true: false alarms on clean"
3816
3652
  " code, wrong lines, false claims confirmed, traps taken. Shorter is better.",
@@ -3874,17 +3710,14 @@ def perf_card() -> str:
3874
3710
  when it is slow enough to change how you work.</dd>
3875
3711
  </dl>"""
3876
3712
  key = key.replace("</dl>", """
3877
- <dt>Did it answer?</dt><dd>Each probe of the last 14 days, by what came back: an answer, a
3878
- rate limit (capacity), a timeout, or a failure the bench classifies (structural, paywalled,
3879
- entitlement, other).</dd>
3713
+ <dt>Did it answer?</dt><dd>Is on Uptime, by day, with our own quota and setup problems left out.</dd>
3880
3714
  <dt>Speed vs quality</dt><dd>Median seconds per answer on our own suite tasks (real prompts,
3881
- not the one-word probe) against the share of findings located. Top left is fast and good.</dd>
3715
+ not the one-word probe) against the share of findings located. Top left is fast and good:
3716
+ the green box is the faster half and the top quarter of findings.</dd>
3882
3717
  </dl>""")
3883
3718
  return chart_card("Performance", "What the serving path does — not what the models are worth. One"
3884
3719
  " reading is weather; the history is climate.", "chart-speed", body, key,
3885
- extra="<h3 class='chart-head'>Did it answer? Last 14 days of probes</h3>"
3886
- "<div class='chart' id='chart-reliability'></div>"
3887
- "<h3 class='chart-head'>Speed vs quality on our own tasks</h3>")
3720
+ extra="<h3 class='chart-head'>Speed vs quality on our own tasks</h3>")
3888
3721
 
3889
3722
 
3890
3723
  def favicon(theme: str) -> str:
@@ -3901,12 +3734,12 @@ def favicon(theme: str) -> str:
3901
3734
  return f"<link rel='icon' type='image/svg+xml' href='data:image/svg+xml,{quote(svg)}'>"
3902
3735
 
3903
3736
 
3904
- def render(theme: str | None = None, shown: str = "glance") -> str:
3737
+ def render(theme: str | None = None, shown: str = "suite") -> str:
3905
3738
  with locked.snapshot(): # each file read once a render (metrics/locked.py)
3906
3739
  return _page(theme, shown)
3907
3740
 
3908
3741
 
3909
- def _page(theme: str | None, shown: str = "glance") -> str:
3742
+ def _page(theme: str | None, shown: str = "suite") -> str:
3910
3743
  theme = theme if theme in themes() else saved_theme() # a ?theme preview, else the saved one
3911
3744
  return (
3912
3745
  f"<!doctype html><html lang='en' data-theme='{esc(theme)}'><meta charset='utf-8'>"
@@ -4300,13 +4133,21 @@ def collections_tab() -> str:
4300
4133
  " the paid ones. Click a suggestion or type your own: comma-separated lower-case words, up to 8.</p>"
4301
4134
  # Not data-filter: that binds to the whole panel. The same match (any
4302
4135
  # text, case-insensitive), on the member list only; the seats show who is ticked.
4303
- "<label class='field' for='c-member-filter'>Members</label>"
4136
+ "<div class='members-head'><label class='field' for='c-member-filter'>Members</label>"
4137
+ # Model details as they are now (a fact set in a model's popup, a refresh of the
4138
+ # catalog), the ticks and the search kept; also done when a model popup opened from here closes.
4139
+ "<button class='btn small' type='button' id='c-refresh' title='Fetch the models and their details again;"
4140
+ " what is ticked stays ticked'>Refresh model details</button>"
4141
+ "<span class='done' id='c-refresh-out' role='status'></span></div>"
4304
4142
  # The panel as it will sit, drawn as the Collections list draws it
4305
4143
  # (bench.js recount): a seat per ticked member, explorer seats dashed.
4306
4144
  "<div id='c-seats' class='seats' aria-live='polite'></div>"
4307
4145
  "<div class='member-find'><input type='search' id='c-member-filter' class='member-filter' spellcheck='false'"
4308
4146
  " autocomplete='off' placeholder='Filter by model, provider or served name' aria-controls='c-members'"
4309
- " aria-describedby='c-count'><select id='c-member-provider' aria-label='Provider' aria-controls='c-members'>"
4147
+ " aria-describedby='c-count'><select id='c-member-show' aria-label='Show' aria-controls='c-members'>"
4148
+ "<option value=''>Every model</option><option value='ticked'>Ticked</option>"
4149
+ "<option value='fits'>Fits the tags</option></select>"
4150
+ "<select id='c-member-provider' aria-label='Provider' aria-controls='c-members'>"
4310
4151
  "<option value=''>Every provider</option></select></div>"
4311
4152
  "<div id='c-members' class='members' role='group' aria-label='Members'></div>"
4312
4153
  "<p class='rules' id='c-count'></p>"
@@ -4374,9 +4215,10 @@ def collections_tab() -> str:
4374
4215
  # A grouped sidebar since 2026-10-19 (RELEASE-PLAN "The dashboard"): eight
4375
4216
  # pages were too many for a row of tabs. Collections holds the gateway's
4376
4217
  # requests under the collections they ran; Benchmarks holds Performance.
4377
- SECTIONS = (("Observe", (("glance", "At a glance", "roster"),
4218
+ # At a glance (every model's state and uptime) was retired 2026-11-10: Benchmarks,
4219
+ # Uptime and Models said it all again. Benchmarks is the home page.
4220
+ SECTIONS = (("Observe", (("suite", "Benchmarks", "benchmarks_tab"),
4378
4221
  ("needs", "Action needed", "actions_page"),
4379
- ("suite", "Benchmarks", "benchmarks_tab"),
4380
4222
  ("quotas", "Quota management", "quotas_page"),
4381
4223
  ("logs", "Logs", "logs_page"))),
4382
4224
  ("Gateway", (("endpoints", "Endpoints", "endpoints_page"),
@@ -4401,7 +4243,9 @@ def collections_and_gateway() -> str:
4401
4243
  # names an environment variable, or the app asks, or (an MCP header in Zed,
4402
4244
  # Cline or goose, which take no variable) KEY_HOLDER stays for the reader.
4403
4245
  # Only blocks marked `key` (shell lines, fields typed into an app) get the key
4404
- # made on the page, swapped in for KEY_HOLDER by bench.js.
4246
+ # made on the page, swapped in for KEY_HOLDER by bench.js. CLIENTS.md is written
4247
+ # from client_setups (scripts/clients_doc.py): after a change here, run it, as
4248
+ # check.py fails while the file differs.
4405
4249
  KEY_HOLDER = "<your gateway key>"
4406
4250
  KEY_ENV = "BENCHWARMERS_KEY" # the plugin's and the CLI's name for it too
4407
4251
  CLIENTS_READ = "2026-11-08"
@@ -4697,20 +4541,20 @@ def _snip_html(group: str, i: int, s: dict) -> str:
4697
4541
  def pane(key: str, actions: tuple | None = None) -> str:
4698
4542
  """One tab's page, as its section holds it. The page renders the tab shown
4699
4543
  (tabs); bench.js fetches the others from GET /api/pane when first opened."""
4700
- if key in ("needs", "glance"):
4544
+ if key == "needs":
4701
4545
  groups, auto = actions or open_actions()
4702
- return actions_page(groups, auto) if key == "needs" else actions_line(groups) + roster()
4546
+ return actions_page(groups, auto)
4703
4547
  return globals()[next(fn for k, _, fn in TABS if k == key)]()
4704
4548
 
4705
4549
 
4706
- def tabs(shown: str = "glance") -> str:
4707
- # Read once a render: Action needed's count in the sidebar, and its page or
4708
- # At a glance's line about it when one of those is shown. Only the tab
4709
- # shown is rendered (the bench_tab cookie, set by bench.js): rendering every
4710
- # one took seconds. The rest are empty until opened (data-lazy).
4550
+ def tabs(shown: str = "suite") -> str:
4551
+ # Read once a render: Action needed's count in the sidebar, and its page when
4552
+ # shown. Only the tab shown is rendered (the bench_tab cookie, set by bench.js):
4553
+ # rendering every one took seconds. The rest are empty until opened (data-lazy).
4554
+ # A tab that is gone (a cookie still saying glance) shows the home page.
4711
4555
  actions = open_actions()
4712
4556
  count = {"needs": open_count(actions[0])}
4713
- shown = shown if any(k == shown for k, _, _ in TABS) else "glance"
4557
+ shown = shown if any(k == shown for k, _, _ in TABS) else "suite"
4714
4558
 
4715
4559
  def button(key: str, label: str) -> str:
4716
4560
  n = count.get(key)
@@ -5331,6 +5175,28 @@ def endpoints_page() -> str:
5331
5175
  "<div class='done' id='l-done'></div></div>")
5332
5176
 
5333
5177
 
5178
+ def _reply_cell(by_prompt: dict) -> str:
5179
+ """Models' Reply: the daily probe's median seconds to a one-word answer (tiny),
5180
+ with the 18 KB prompt's (large) and tokens a second on hover. Was on Benchmarks'
5181
+ Performance, beside whether it answered, which is Uptime's now."""
5182
+ def p50(r):
5183
+ try:
5184
+ return float(r["p50_s"]) if r and int(r.get("answered") or 0) > 0 else None
5185
+ except (KeyError, TypeError, ValueError):
5186
+ return None
5187
+ tiny, large = p50(by_prompt.get("tiny")), p50(by_prompt.get("large"))
5188
+ if tiny is None and large is None:
5189
+ return "<td class='num muted' data-sort='999999'>—</td>"
5190
+ shown = tiny if tiny is not None else large
5191
+ why = "; ".join(b for b in (
5192
+ f"one-word prompt {tiny:.1f}s" if tiny is not None else "",
5193
+ f"18 KB prompt {large:.1f}s" if large is not None else "",
5194
+ f"{(by_prompt.get('large') or {}).get('tokens_per_s')} tok/s"
5195
+ if (by_prompt.get("large") or {}).get("tokens_per_s") else "") if b)
5196
+ return (f"<td class='num' data-sort='{shown:.2f}' title='median, the daily probe: {esc(why)}'>"
5197
+ f"{shown:.1f}s</td>")
5198
+
5199
+
5334
5200
  def models_page() -> str:
5335
5201
  """Every model on the bench by the connection it comes from."""
5336
5202
  cards = []
@@ -5344,6 +5210,10 @@ def models_page() -> str:
5344
5210
  # for what a collection may hold): they are on the bench like any other.
5345
5211
  by: dict[str, list[str]] = {}
5346
5212
  directory = model_directory()
5213
+ # The daily probe's latest reading per model and prompt (perf/history.csv), for Reply.
5214
+ speed: dict[str, dict] = {}
5215
+ for r in perf_rows():
5216
+ speed.setdefault(r["model"], {})[r.get("prompt", "")] = r
5347
5217
  for a in directory:
5348
5218
  by.setdefault(catalog.connection_of(a), []).append(a)
5349
5219
  for c in ({"name": n, "models": sorted(m)} for n, m in sorted(by.items())):
@@ -5356,7 +5226,7 @@ def models_page() -> str:
5356
5226
  kind = (" " + chip("decision", "", "a decision model: answers typed questions, not chat")
5357
5227
  if a in decisions else "")
5358
5228
  if directory[a].get("ad_hoc"):
5359
- kind += " " + chip("ad hoc", "", "ad hoc only: never chosen by the bench on its own, only when you"
5229
+ kind += " " + chip("ad hoc", "adhoc", "ad hoc only: never chosen by the bench on its own, only when you"
5360
5230
  " name it (a collection member, with, --model)")
5361
5231
  rows.append(f"<tr><td><a href='#' class='model' data-alias='{esc(a)}'>"
5362
5232
  f"<span class='alias'>{esc(a)}</span></a>{kind}</td>"
@@ -5364,19 +5234,27 @@ def models_page() -> str:
5364
5234
  f"<td class='num' data-sort='{f.get('context_window') or 0}'>{num(f.get('context_window'))}</td>"
5365
5235
  f"<td class='num' data-sort='{f.get('max_output') or 0}'>{num(f.get('max_output'))}</td>"
5366
5236
  f"<td>{esc(', '.join(f.get('input_modalities') or []) or '—')}</td>"
5237
+ + _reply_cell(speed.get(a, {}))
5367
5238
  + "".join(f"<td class='num'>{num(lim.get(k))}</td>" for k in catalog.LIMIT_KEYS) + "</tr>")
5368
5239
  # One column plan for every card, so the cards line up under each other
5369
5240
  # (each table sized its own columns, so they drifted from card to card).
5370
5241
  cols = "<colgroup><col class='c-model'><col class='c-via'><col class='c-n'><col class='c-n'>" \
5371
- "<col class='c-input'>" + "<col class='c-lim'>" * len(catalog.LIMIT_KEYS) + "</colgroup>"
5372
- cards.append(f"<div class='panel'><h3 class='chart-head'>{esc(c['name'])} · {len(c['models'])} models</h3>"
5242
+ "<col class='c-input'><col class='c-reply'>" + "<col class='c-lim'>" * len(catalog.LIMIT_KEYS) + "</colgroup>"
5243
+ # Each connection folds; the page's one box filters them all (bench.js applyFilter, data-conn).
5244
+ cards.append(f"<details class='panel model-card' open data-conn='{esc(c['name'])}'><summary>"
5245
+ f"<h3 class='chart-head'>{esc(c['name'])} · {len(c['models'])} models</h3></summary>"
5373
5246
  f"<table class='model-cards'>{cols}<thead><tr>" + th("Model") + th("Through")
5374
5247
  + th("Context", True) + th("Max output", True)
5375
- + th("Input") + "".join(th(k, True) for k in catalog.LIMIT_KEYS)
5376
- + f"</tr></thead><tbody>{''.join(rows)}</tbody></table></div>")
5248
+ + th("Input") + th("Reply", True) + "".join(th(k, True) for k in catalog.LIMIT_KEYS)
5249
+ + f"</tr></thead><tbody>{''.join(rows)}</tbody></table></details>")
5377
5250
  return ("<div class='panel'><h2>Models</h2><p class='sub'>Every model on the bench by the connection it"
5378
5251
  " comes from, with the endpoint it is reached through and what is known of its limits. A name"
5379
- " opens the model.</p></div>"
5252
+ " opens the model.</p><div class='toolbar'><input type='search' data-filter='page'"
5253
+ " placeholder='Find a model or connection' aria-label='Filter models'>"
5254
+ # One labelled toggle, as accordions do it (GOV.UK's "Show all sections"): Collapse all
5255
+ # while every table is open, else Expand all (bench.js foldButtons keeps it right).
5256
+ "<button class='fold-all' type='button' data-fold-all='close' aria-expanded='true'>"
5257
+ "<span class='fold-chev' aria-hidden='true'></span>Collapse all</button></div></div>"
5380
5258
  + "".join(cards))
5381
5259
 
5382
5260
 
@@ -5553,11 +5431,16 @@ def logs_page() -> str:
5553
5431
  for p in sorted((DATA / "runs").glob("onboard-*.log"), reverse=True)[:30])
5554
5432
 
5555
5433
  def section(title, body, n, open_=False):
5556
- return (f"<details class='panel'{' open' if open_ else ''}><summary><b>{esc(title)}</b>"
5557
- f" <span class='muted'>· {n}</span></summary>{body}</details>")
5434
+ # CSV: this log alone, its own columns, the filter applied (bench.js); for a spreadsheet.
5435
+ return (f"<details class='panel'{' open' if open_ else ''} data-log='{esc(title)}'><summary><b>{esc(title)}</b>"
5436
+ f" <span class='muted'>· {n}</span> <button class='btn small' type='button' data-csv"
5437
+ f" title='Save this log, as filtered, as a CSV file'>CSV</button></summary>{body}</details>")
5558
5438
  return (
5559
- "<div class='panel'><input data-filter='page' placeholder='Filter every log on this page'"
5560
- " aria-label='Filter logs'></div>"
5439
+ "<div class='panel'><div class='toolbar'><input data-filter='page' placeholder='Filter every log on this page'"
5440
+ " aria-label='Filter logs'>"
5441
+ # bench.js: what the page shows, filter applied, as one .txt to read offline.
5442
+ "<button class='btn small' type='button' id='logs-export' title='Save the logs on this page, as"
5443
+ " filtered, to a text file'>Export as text</button></div></div>"
5561
5444
  + section("Gateway calls", "<table><thead><tr>" + th("When") + th("Collection") + th("Shape")
5562
5445
  + th("Seats") + f"</tr></thead><tbody>{''.join(gw)}</tbody></table>", len(gw), True)
5563
5446
  + section("Changes", "<table><thead><tr>" + th("When") + th("By") + th("Model") + th("What")
@@ -5876,8 +5759,7 @@ class Handler(gateway.Handler):
5876
5759
  if self.path == "/api/needs": # after an action there: that page, not the whole one
5877
5760
  with locked.snapshot():
5878
5761
  groups, auto = open_actions()
5879
- self._json(200, {"html": actions_page(groups, auto), "line": actions_line(groups),
5880
- "count": open_count(groups)})
5762
+ self._json(200, {"html": actions_page(groups, auto), "count": open_count(groups)})
5881
5763
  return
5882
5764
  if path == "/api/pane": # a tab's page, when first opened (bench.js load)
5883
5765
  key = q.get("tab", [""])[0]
@@ -5996,7 +5878,7 @@ class Handler(gateway.Handler):
5996
5878
  theme = q.get("theme", [None])[0] # a preview; else the saved one
5997
5879
  shown = re.search(r"(?:^|;)\s*bench_tab=(\w+)", self.headers.get("Cookie") or "") # the tab last shown
5998
5880
  try:
5999
- page = render(theme, shown.group(1) if shown else "glance")
5881
+ page = render(theme, shown.group(1) if shown else "suite")
6000
5882
  except RuntimeError as exc: # endpoints.json unreadable: say which and why
6001
5883
  page = f"<!doctype html><meta charset='utf-8'><title>Benchwarmers</title><p>{esc(exc)}</p>"
6002
5884
  self._page(page) # gzipped beyond this machine (_length)