@wendellresearch-ai/benchwarmers 0.6.6 → 0.6.7

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/VERSION CHANGED
@@ -1 +1 @@
1
- 0.6.6
1
+ 0.6.7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wendellresearch-ai/benchwarmers",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "description": "Self-hosted panel gateway for LLMs: ask a collection of models at once, keep every answer side by side, rotate explorer models in, and let your verdicts pick the next panel.",
5
5
  "mcpName": "io.github.WendellResearch-AI/benchwarmers",
6
6
  "license": "MIT",
package/serve.py CHANGED
@@ -2370,10 +2370,11 @@ SHARE = ("<details class='share'><summary>Share</summary><div class='share-menu'
2370
2370
  + "</span></div></details>")
2371
2371
 
2372
2372
 
2373
- def chart_card(title: str, sub: str, chart_id: str, table: str, key: str, extra: str = "",
2373
+ def chart_card(title: str, sub: str, chart_id: str, table: str, key: str = "", extra: str = "",
2374
2374
  retired: bool = True, after: str = "") -> str:
2375
2375
  """A Benchmarks panel: the chart first (drawn by bench.js with Observable
2376
- Plot), the table folded under it, the key under that. `extra` goes above
2376
+ Plot), the table folded under it, then a key if one is given (the reports
2377
+ have none: the name, the chart and one line say it). `extra` goes above
2377
2378
  the chart, `after` right under it."""
2378
2379
  return (f"<div class='panel'>{SHARE}<h2>{title}</h2><p class='sub'>{sub}</p>{extra}"
2379
2380
  f"<div class='chart' id='{chart_id}'></div>{after}"
@@ -2381,7 +2382,7 @@ def chart_card(title: str, sub: str, chart_id: str, table: str, key: str, extra:
2381
2382
  " data-filter placeholder='Filter by model or connection' aria-label='Filter rows'>"
2382
2383
  + ("<label><input type='checkbox' data-retired> Show retired</label>" if retired else "") + "</div>"
2383
2384
  f"<div class='scroll'>{table}</div></details>"
2384
- f"<details class='key'><summary>How to read this</summary>{key}</details></div>")
2385
+ + (f"<details class='key'><summary>How to read this</summary>{key}</details>" if key else "") + "</div>")
2385
2386
 
2386
2387
 
2387
2388
  def _family(task: str) -> str:
@@ -2441,16 +2442,22 @@ def wrong_table() -> dict:
2441
2442
  return seen[1]
2442
2443
 
2443
2444
 
2445
+ def overview_rows() -> list[dict]:
2446
+ """Who to trust, a row per registered model with both suite scores and answers:
2447
+ the share of real findings located, and the share of answers confidently wrong."""
2448
+ import route
2449
+ recall, wr = route.suite_recall(), wrong_table()
2450
+ return [{"model": m, "recall": recall[m][0] / recall[m][1], "wrong": wr[m]["wrong"] / wr[m]["answers"],
2451
+ "found": f"{recall[m][0]}/{recall[m][1]}", "wrongN": f"{wr[m]['wrong']}/{wr[m]['answers']}"}
2452
+ for m in sorted(on_bench()) if recall.get(m) and recall[m][1] and wr.get(m, {}).get("answers")]
2453
+
2454
+
2444
2455
  def charts_data() -> dict:
2445
2456
  """Everything the Benchmarks charts draw, for bench.js. Registered models
2446
2457
  only: the tables keep retired ones behind "Show retired", a chart has no toggle."""
2447
- import route
2448
2458
  import wrong
2449
2459
  live = on_bench()
2450
- recall, wr = route.suite_recall(), wrong_table()
2451
- overview = [{"model": m, "recall": recall[m][0] / recall[m][1], "wrong": wr[m]["wrong"] / wr[m]["answers"],
2452
- "found": f"{recall[m][0]}/{recall[m][1]}", "wrongN": f"{wr[m]['wrong']}/{wr[m]['answers']}"}
2453
- for m in sorted(live) if recall.get(m) and recall[m][1] and wr.get(m, {}).get("answers")]
2460
+ wr = wrong_table()
2454
2461
  heat = []
2455
2462
  for (task, m), v in sorted(suite_scores().items()):
2456
2463
  if m in live:
@@ -2462,7 +2469,7 @@ def charts_data() -> dict:
2462
2469
  totals = {m: {"rate": r["wrong"] / r["answers"], "answers": r["answers"], "wrong": r["wrong"]}
2463
2470
  for m, r in wr.items() if m in live and r["answers"]}
2464
2471
  hunt = [{"model": m, **r["hunt"]} for m, r in sorted(wr.items()) if m in live and any(r["hunt"].values())]
2465
- return {"overview": overview, "heat": heat, "families": ["hand-built", "claims", "seeded, function",
2472
+ return {"overview": overview_rows(), "heat": heat, "families": ["hand-built", "claims", "seeded, function",
2466
2473
  "seeded, whole file", "real bugs"],
2467
2474
  "wrong": bars, "wrongTotals": totals, "kinds": list(wrong.KINDS), "hunt": hunt,
2468
2475
  "work": work_rows(), "vision": vision_rows(), "uptime": uptime_data(), "trend": trend_data(),
@@ -2688,42 +2695,18 @@ def vision_card() -> str:
2688
2695
  + (f"<td class='num'>{r['seconds']:.1f}</td>" if r.get("seconds") else "<td class='num muted'>—</td>")
2689
2696
  + "</tr>")
2690
2697
  body.append("</tbody></table>")
2691
- key = """<dl>
2692
- <dt>The vision tasks</dt><dd>Grouped by where the pictures come from: <b>made</b> with their answer
2693
- key (<code>suite/vision_tasks.py</code>), your <b>own</b> photos keyed by hand, and items sampled
2694
- from public datasets with people's answers (<code>suite/vision_sample.py</code>): photographed
2695
- <b>receipts</b>, app <b>screens</b>, <b>tables</b>, and <b>objects</b> in photos. Each field is
2696
- <b>right</b>, <b>misread</b> (another value), <b>left out</b>, or a <b>trap taken</b>: a value for
2697
- something the picture does not show, where the right answer is <code>not shown</code> (a dot marks
2698
- those fields). They run for every model that takes images; a vision model gets only these, never
2699
- the code tasks.</dd>
2700
- <dt>Real work on images</dt><dd>Every seat of a vision panel through the gateway. <b>Checked</b> means
2701
- its verdict lists claims, each compared with the picture by hand: the claims right and wrong
2702
- come from these alone. <b>Useful, no claims</b> is a verdict without them, which on thicken's
2703
- photos means the seat agreed with another seat. Agreement is not a check, since seats fail
2704
- together, so it is never counted as right.</dd>
2705
- <dt>The real-work chart</dt><dd>Read as Real work is. The dot is the share of its judged seats your
2706
- verdict called useful (checked and helped, or useful with no claims), bigger for more judged; the
2707
- line is where that share likely sits (95%), and the order is by its low end, so a lot of evidence
2708
- outranks a little. Green when even the low end is over half, red when even the high end is under,
2709
- grey when there are too few to say. Beside it: useful of judged, how often it answered at all, and
2710
- the claims checked by hand, the only count of right and wrong. Seats not judged or not answered
2711
- are in those words and the table, not the dot.</dd>
2712
- <dt>The number beside a name</dt><dd>How much there is: fields asked on the tasks.</dd>
2713
- </dl>"""
2714
2698
  held = (f"<p class='hint'>The vision tasks wait until {esc(v['held'])}: no run picks them before then"
2715
2699
  " (<code>vision_from</code> in <code>config/suite.json</code>; remove it to start them now).</p>"
2716
2700
  if v["held"] else "")
2717
- return (f"<div class='panel'>{SHARE}<h2>Vision</h2><p class='sub'>Reading images: fields read right on pictures with"
2718
- " a known answer, then real work on images and how much of it was actually checked.</p>" + held +
2701
+ return (f"<div class='panel'>{SHARE}<h2>Vision</h2><p class='sub'>Reading images: fields read right on pictures"
2702
+ " with a known answer, then real work on images.</p>" + held +
2719
2703
  "<h3 class='chart-head'>On the vision tasks, field by field</h3><div class='chart' id='chart-vision-fields'></div>"
2720
2704
  "<h3 class='chart-head'>On real work with images: judged useful, and how sure that is</h3>"
2721
2705
  "<div class='chart' id='chart-vision-work'></div>"
2722
2706
  "<details class='key'><summary>Table</summary><div class='toolbar'><input type='search'"
2723
2707
  " data-filter placeholder='Filter by model or connection' aria-label='Filter rows'>"
2724
2708
  "<label><input type='checkbox' data-retired> Show retired</label></div>"
2725
- f"<div class='scroll'>{''.join(body)}</div></details>"
2726
- f"<details class='key'><summary>How to read this</summary>{key}</details></div>")
2709
+ f"<div class='scroll'>{''.join(body)}</div></details></div>")
2727
2710
 
2728
2711
 
2729
2712
  def perf_charts() -> dict:
@@ -2892,35 +2875,8 @@ def trend_card() -> str:
2892
2875
  pick = ("<div class='toolbar'><label>Window <select data-trend-days>"
2893
2876
  + "".join(f"<option value='{w}'>last {w} days</option>" for w in TREND_DAYS)
2894
2877
  + f"</select></label></div>{legend}")
2895
- key = f"""<dl>
2896
- <dt>A bar</dt><dd>How far a model's scores in the window sit from its own scores before it, on the tasks
2897
- scored in both: each task as a share of its findings, so a 1-finding claim and an 11-finding file count
2898
- the same, then averaged over those tasks. Left is lower, right is higher; the shaded band is 10 points
2899
- either side. Hover for each task's move.</dd>
2900
- <dt>The groups</dt><dd><b>Lower</b> (or <b>higher</b>) when, on average, the scores moved 10 points or
2901
- more, on at least two tasks and twice as many as moved the other way, in at least two separate runs.
2902
- <b>Steady</b> otherwise: a steady bar past the band moved on average, but not on enough tasks or runs.
2903
- <b>Too little to say</b>, with no bar, when fewer than three tasks can be compared, or fewer than two
2904
- runs in the window or before it. One bad run, or one task, is never a trend, and nor is one run to
2905
- compare against. Biggest move first in each group.</dd>
2906
- <dt>Beside the bar</dt><dd>The evidence: the tasks compared and how many went down or up, then the runs
2907
- in the window against the runs before it. A model with no task scored both in the window and before it
2908
- is named under the chart.</dd>
2909
- <dt>The window</dt><dd>{first} days by default: a paid model is scored about once a week and a free one
2910
- about every 15 days, six tasks a run, so 7 days holds one run at most and could only say what one
2911
- afternoon did. {first} days holds 2 to 4 runs. Longer windows hold more runs but leave less before them
2912
- to compare with (the scores start {esc(t['first'] or 'with the first run')}).</dd>
2913
- <dt>Runs</dt><dd>Days a model was scored (a task asked twice in a day counts once, as the mean of its
2914
- scores). In the window, how many sat below or above its earlier scores.</dd>
2915
- <dt>What is counted</dt><dd>Scores as the run notes recorded them (<code>runs/suite-*.md</code>), under
2916
- each model's current name; timeouts, errors and refusals are not scores and are left out (Uptime counts
2917
- them). A task whose number of findings changed starts again. The answers behind the scores are kept
2918
- from 2026-11-08 in <code>runs/answers/&lt;task&gt;/&lt;model&gt;/</code>; older ones were replaced by
2919
- the next run and are gone.</dd>
2920
- </dl>"""
2921
- return chart_card("Trend", "Who scores lower, or higher, than it used to? Each model against its own earlier"
2922
- " scores on the same tasks; flagged only when the move holds over several tasks and runs.",
2923
- "chart-trend", "".join(window(w) for w in TREND_DAYS), key, extra=pick)
2878
+ return chart_card("Trend", "Who scores lower, or higher, than it used to on the same tasks.",
2879
+ "chart-trend", "".join(window(w) for w in TREND_DAYS), extra=pick)
2924
2880
 
2925
2881
 
2926
2882
  # ---- Uptime: was the provider there when the bench called it? --------------
@@ -3311,31 +3267,8 @@ def uptime_card() -> str:
3311
3267
  + "".join(f"<option value='{w}'>last {w}</option>" for w in UPTIME_DAYS)
3312
3268
  + "</select></label><label>By <select data-uptime-by><option value='providers'>provider</option>"
3313
3269
  f"<option value='models'>model</option></select></label></div>{legend}")
3314
- key = """<dl>
3315
- <dt>Down</dt><dd>The provider was busy or overloaded (a 5xx, high demand, a shared pool's
3316
- 429), unreachable (a broken stream, no reply), timed out (the gateway's 420 s included), or
3317
- failed in a way no rule recognises yet, listed under the chart so a rule can be added. A
3318
- shape a decision model was asked about counts as it said, marked "decided by" with the model
3319
- and how sure it was (at 0.6 or more; below that it stays unrecognised).</dd>
3320
- <dt>Left out</dt><dd>Neither up nor down, since it says nothing about the provider: our own
3321
- quota or allowance used up (a day, a month, credits), our own per-minute or concurrency
3322
- limit, a call the bench's pacing never sent, our client failing, our LAN hub refusing the
3323
- connection, and setup problems (sign-in, no such model, no access, our own request).</dd>
3324
- <dt>Up</dt><dd>It answered. An empty reply still counts as an answer.</dd>
3325
- <dt>A cell</dt><dd>One day: answered of the calls counted that day. Hollow when nothing was
3326
- counted that day; hover for the day, the count and the main reasons.</dd>
3327
- <dt>The % and (n)</dt><dd>Answered of counted over the days shown, and how many were counted:
3328
- weighted by calls, so a provider's row is every call to any of its models.</dd>
3329
- <dt>What is counted</dt><dd>Only calls the bench makes anyway: the daily probe, every gateway
3330
- seat, the suite's own calls once the client's ledger keeps their error text
3331
- (2026-11-08 on), and every decision model call (<code>metrics/decisions.jsonl</code>), on the
3332
- connection behind its served name. Each is classified from its error text when this is drawn,
3333
- so a better rule changes the past too. It is shown only: nothing is routed or retired by it.</dd>
3334
- </dl>"""
3335
- return chart_card("Uptime", "Whether the provider was there when the bench called it: the daily probe"
3336
- " and every real call, one cell a day. Busy, unreachable and timed out count as down;"
3337
- " our own quota, our own pacing and setup problems are left out.",
3338
- "chart-uptime-models", "".join(window(w) for w in UPTIME_DAYS), key,
3270
+ return chart_card("Uptime", "Whether the provider was there when the bench called it, one cell a day.",
3271
+ "chart-uptime-models", "".join(window(w) for w in UPTIME_DAYS),
3339
3272
  extra=pick + "<div class='chart' id='chart-uptime-providers'></div>",
3340
3273
  after="".join(odd(w) for w in UPTIME_DAYS))
3341
3274
 
@@ -3432,42 +3365,8 @@ def decision_card() -> str:
3432
3365
  f"<p class='hint'>{'Measured up to ' + esc(when[:16].replace('T', ' ')) + '.' if when else 'Not measured yet.'}"
3433
3366
  " The scheduler's <code>decisions</code> job asks each model one question a set a day;"
3434
3367
  " <code>python metrics/decide.py bench</code> runs a pass by hand.</p>")
3435
- key = f"""<dl>
3436
- <dt>The sets</dt><dd>Questions with known answers, built from the bench's own data, no hand labelling.
3437
- <b>Error classes</b>: failed calls' error text (redacted: no keys, ids or emails) and the class the
3438
- uptime rules give it. <b>Collections</b>: gateway requests and the collection the caller named (not
3439
- ones a decision model chose). <b>Useful answers</b>: seat answers you judged, with their request,
3440
- a yes/no question.</dd>
3441
- <dt>Right of asked</dt><dd>Each item's latest answer; a failed call counts as asked and not right.
3442
- <b>Accuracy</b> is of the answers given; F1 is the mean over the options of how well each was
3443
- picked, so always saying the commonest option scores low.</dd>
3444
- <dt>Brier</dt><dd>How far its probabilities sat from the truth: 0 is perfect; for a choice it is
3445
- summed over the options, so up to 2. Lower is better, and a sure wrong answer costs most.</dd>
3446
- <dt>The bars</dt><dd>Right of asked for each model on each set, from 0%. A faint bar is a model
3447
- under {low} items on that set: shown, not ranked yet. Hover a bar for its Brier.</dd>
3448
- <dt>Calibration</dt><dd>The second chart, once a model answers at two or more levels of sureness
3449
- on a set: answers grouped by the probability the model gave its answer
3450
- (x), against how often those answers were right (y); both axes start just under the lowest
3451
- answer, not at 0%. On the diagonal, its 80% answers come true
3452
- 80% of the time; under it, it is surer than it should be. A bigger dot is more answers. The gap is
3453
- the average distance from the diagonal, weighted by answers.</dd>
3454
- <dt>At {decide.CONFIDENT}</dt><dd>The gateway acts on a choice only when the model is this sure: how
3455
- many answers were that sure, and how many of those were right.</dd>
3456
- <dt>Rank</dt><dd>Among models with {low} or more items asked on the set, by right answers
3457
- smoothed for sample size ((right + 1) / (asked + 2)). Below {low} a model is shown but not ranked,
3458
- and a use keeps its default (the first onboarded model that takes the question).</dd>
3459
- <dt>Who each use asks</dt><dd>The shape of a request and a new model's role are ranked on the
3460
- collection set (one described option of several, for a text), the notice check on useful answers
3461
- (the only yes/no set). A use asked per request never goes to a <code>:free</code> model while
3462
- another can answer: their calls come out of OpenRouter's free daily budget, which the suite and
3463
- the probe share.</dd>
3464
- <dt>Failures</dt><dd>Calls that came back with no answer, by the uptime classes (setup: no such
3465
- model, or paywalled). The pass stops a model at its first failure.</dd>
3466
- </dl>"""
3467
- return chart_card("Decision models", "The models that answer typed questions (a choice, a yes or no) with"
3468
- " a probability: how often they are right on the bench's own labelled questions, how far"
3469
- " to trust their probabilities, and which one each use asks.",
3470
- "chart-decide", "".join(tables), key, extra=extra, retired=False)
3368
+ return chart_card("Decision", "How often the models that answer typed questions (a choice, a yes or no)"
3369
+ " are right, and how sure.", "chart-decide", "".join(tables), extra=extra, retired=False)
3471
3370
 
3472
3371
 
3473
3372
  def bughunt_card() -> str:
@@ -3483,19 +3382,8 @@ def bughunt_card() -> str:
3483
3382
  + "".join(f"<td class='num' data-sort='{h[k]}'>{h[k]}/{clean}</td>" for k in ("clean", "false alarm"))
3484
3383
  + "</tr>")
3485
3384
  body.append("</tbody></table>")
3486
- key = """<dl>
3487
- <dt>Right of the line</dt><dd>Bug tasks (a real file with one change seeded, or a public
3488
- file before a real fix): green found the changed line, yellow named another line,
3489
- grey said there was no bug.</dd>
3490
- <dt>Left of the line</dt><dd>The same files untouched: red is a false alarm, claiming a
3491
- bug in clean code. The longer the red, the less to trust its "there's a bug here".</dd>
3492
- <dt>Order</dt><dd>On what is proven, best at the top: the low end of the found share's likely
3493
- range (95%) less the high end of the false alarms'. A few tasks give wide ranges, so they
3494
- rank low; a model never shown clean code could be raising alarms on all of it.</dd>
3495
- <dt>Faint bars</dt><dd>Under 10 tasks: shown, but little to go on.</dd>
3496
- </dl>"""
3497
- return chart_card("Bug hunt", "Every task with an answer key: bugs found against false alarms on the"
3498
- " same files left clean. Right is good, left is bad.", "chart-hunt", "".join(body), key)
3385
+ return chart_card("Bug hunt", "Bugs found against false alarms on the same files left clean.",
3386
+ "chart-hunt", "".join(body))
3499
3387
 
3500
3388
 
3501
3389
  def _wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
@@ -3519,27 +3407,21 @@ def work_card() -> str:
3519
3407
  f"<td class='num' data-sort='{lo:.3f}'>{lo * 100:.0f}–{hi * 100:.0f}%</td>"
3520
3408
  f"<td class='num'>{r['real']}</td><td class='num'>{r['false']}</td></tr>")
3521
3409
  body.append("</tbody></table>")
3522
- key = """<dl>
3523
- <dt>Helped</dt><dd>Of the offloads judged, the share recorded useful: real work (reviews,
3524
- enumerations) handed to the model and checked afterwards. Benchmarks never count here.</dd>
3525
- <dt>The dot and its line</dt><dd>The dot is the share that helped, bigger for more judged. The
3526
- line is where the true share likely sits (a 95% Wilson interval): long for 3 of 3, short for
3527
- 40 of 50. Green when even its low end is over half, red when even its high end is under,
3528
- grey when there are too few to say.</dd>
3529
- <dt>Order</dt><dd>By the low end of that line, so volume counts: 40 of 50 ranks above 3 of 3.</dd>
3530
- <dt>The dashed line</dt><dd>Every model's judged offloads together, for comparison.</dd>
3531
- <dt>Findings and false claims</dt><dd>Verified one by one in reviews, recorded since 2026-10-17.</dd>
3532
- </dl>"""
3533
- return chart_card("Real work", "When a model was given real work and the result was checked: how often"
3534
- " it helped. The most direct measure of trust the bench has.", "chart-work", "".join(body), key)
3410
+ return chart_card("Real work", "How often a model helped when given real work that was checked afterwards.",
3411
+ "chart-work", "".join(body))
3535
3412
 
3536
3413
 
3537
3414
  def overview_card() -> str:
3538
- return (f"<div class='panel'>{SHARE}<h2>Who to trust with a review</h2><p class='sub'>Each dot is a model."
3539
- " Right: finds more of the real bugs in our own code. Up: makes fewer things up (confidently"
3540
- " wrong on clean code, wrong lines, false claims, traps). Top right is where a reviewer should be."
3541
- " The models in or near the box, and the three that make most up, are named; hover for the rest.</p>"
3542
- "<div class='chart' id='chart-overview'></div></div>")
3415
+ rows = sorted(overview_rows(), key=lambda d: (d["wrong"], -d["recall"]))
3416
+ body = ["<table><thead><tr>", th("Model"), th("Finds real bugs", True), th("Makes things up", True),
3417
+ "</tr></thead><tbody>"]
3418
+ body += [f"<tr><td>{named(d['model'])}</td>"
3419
+ f"<td class='num' data-sort='{d['recall']:.3f}'>{d['found']} ({d['recall'] * 100:.0f}%)</td>"
3420
+ f"<td class='num' data-sort='{d['wrong']:.3f}'>{d['wrongN']} ({d['wrong'] * 100:.0f}%)</td></tr>"
3421
+ for d in rows]
3422
+ body.append("</tbody></table>")
3423
+ return chart_card("Who to trust with a review", "Top right is the reviewer to pick: finds the real bugs"
3424
+ " and makes little up.", "chart-overview", "".join(body), retired=False)
3543
3425
 
3544
3426
 
3545
3427
  def suite_card() -> str:
@@ -3575,22 +3457,8 @@ def suite_card() -> str:
3575
3457
  + "".join(cells) + flag + "</tr>")
3576
3458
  body.append("</tbody></table>")
3577
3459
  body = "".join(body)
3578
- key = """<dl>
3579
- <dt>n / N</dt><dd>Verified findings the model located, out of the total
3580
- for that task. The answers were established from source before any
3581
- model saw them.</dd>
3582
- <dt>Invented</dt><dd><strong>The column that matters.</strong> Something
3583
- asserted that is not true of the code. A confident wrong answer costs
3584
- more than a silent miss.</dd>
3585
- <dt>timeout / refused / error</dt><dd>Not a score. The model did not
3586
- answer — a filter, an overloaded service and a slow model are three
3587
- different facts and are never merged.</dd>
3588
- <dt>(tag)</dt><dd>The connection it arrives through: its Open WebUI tag, or its endpoint. Models
3589
- sharing one share its rate limit.</dd>
3590
- </dl>"""
3591
- return chart_card("Our code", "Every task on our own code, one square each: green found everything,"
3592
- " red found nothing, grey did not answer; a dot means it invented something. Hover"
3593
- " for the task and score.", "chart-code", body, key)
3460
+ return chart_card("Our code", "Every task on our own code, one square per task and model: green found"
3461
+ " everything, red nothing.", "chart-code", body)
3594
3462
 
3595
3463
 
3596
3464
  EXTERNAL = (("LiveBench", "https://livebench.ai/", "contamination-limited questions, refreshed monthly"),
@@ -3605,8 +3473,8 @@ def external_card() -> str:
3605
3473
  """Links, not scores: the External tab showed FuncBench numbers nobody
3606
3474
  refreshed and was dropped (2026-10-16). Public leaderboards are read there."""
3607
3475
  rows = "".join(f"<li>{ext_link(u, n)} <span class='muted'>— {esc(why)}</span></li>" for n, u, why in EXTERNAL)
3608
- return ("<div class='panel'><h2>External</h2><p class='sub'>Public leaderboards, for comparison."
3609
- " The bench does not copy their numbers; its own tests above are what routing uses.</p>"
3476
+ return ("<div class='panel'><h2>External</h2><p class='sub'>Public leaderboards, for comparison;"
3477
+ " routing uses the bench's own tests.</p>"
3610
3478
  f"<ul class='links'>{rows}</ul></div>")
3611
3479
 
3612
3480
 
@@ -3639,26 +3507,8 @@ def wrong_card() -> str:
3639
3507
  + (f"<td class='num' data-sort='{r['review_false']}'>{r['review_false']} in {r['reviews']}</td>"
3640
3508
  if r["reviews"] else "<td class='num muted' data-sort='-1'>—</td>") + "</tr>")
3641
3509
  body.append("</tbody></table>")
3642
- key = """<dl>
3643
- <dt>Confidently wrong</dt><dd>Answers that asserted something false, of the answers
3644
- that could have. Being wrong is why the bench asks several models; asserting a bug
3645
- that is not there is what sends someone to fix working code. Lower is better.</dd>
3646
- <dt>Control</dt><dd>A seeded or real-bug control (the clean file) answered FAULT.</dd>
3647
- <dt>Wrong line</dt><dd>A seeded or real-bug task answered FAULT at a line the change did
3648
- not touch. Saying NO FAULT on a bug is a miss and is not counted.</dd>
3649
- <dt>False claim</dt><dd>A claim task whose claim is false, answered VERDICT: TRUE.</dd>
3650
- <dt>Trap</dt><dd>A hand-built task's planted falsehood, asserted.</dd>
3651
- <dt>Reviews</dt><dd>False claims recorded in verdicts (<code>offload.py verdict|add
3652
- --false "..."</code>), over judged offloads. Recorded in structured form only since
3653
- 2026-10-17, so older reviews show none.</dd>
3654
- <dt>The thin line</dt><dd>Where the true share likely sits (95% range). Long for a few
3655
- answers: 0 of 2 is not a clean record, and the line shows how bad it could be.</dd>
3656
- <dt>Order</dt><dd>Worst proven at the top: by the low end of that line. Under 10 answers a
3657
- model's bar is faint.</dd>
3658
- </dl>"""
3659
- return chart_card("Confidently wrong", "Who asserts things that are not true: false alarms on clean"
3660
- " code, wrong lines, false claims confirmed, traps taken. Shorter is better.",
3661
- "chart-wrong", "".join(body), key)
3510
+ return chart_card("Confidently wrong", "Who asserts things that are not true; shorter is better.",
3511
+ "chart-wrong", "".join(body))
3662
3512
 
3663
3513
 
3664
3514
  def benchmarks_tab() -> str:
@@ -3670,7 +3520,7 @@ def benchmarks_tab() -> str:
3670
3520
  ("trend", "Trend", trend_card), ("performance", "Performance", perf_card),
3671
3521
  ("work", "Real work", work_card), ("hunt", "Bug hunt", bughunt_card),
3672
3522
  ("wrong", "Confidently wrong", wrong_card), ("code", "Our code", suite_card),
3673
- ("vision", "Vision", vision_card), ("decisions", "Decision models", decision_card),
3523
+ ("vision", "Vision", vision_card), ("decisions", "Decision", decision_card),
3674
3524
  ("external", "External", external_card))
3675
3525
  links = "".join(f"<a href='#report-{key}'>{esc(label)}</a>" for key, label, _ in reports)
3676
3526
  cards = "".join(card().replace("<div class='panel'", f"<div class='panel report' id='report-{key}'", 1)
@@ -3708,34 +3558,8 @@ def perf_card() -> str:
3708
3558
  f"title='p50 {p50:.1f}s'><span style='width:100%'></span></span></td></tr>")
3709
3559
  body.append("</tbody></table>")
3710
3560
  body = "".join(body)
3711
- key = """<dl>
3712
- <dt>The bars</dt><dd><strong>p50 latency, relative to the slowest row in
3713
- this table.</strong> A full-width bar is the slowest here, not a score
3714
- out of anything.</dd>
3715
- <dt>p50 / p95</dt><dd>Median and 95th-percentile seconds to a complete
3716
- answer. Medians, not means — one timeout makes a mean fiction. p95 is
3717
- where rate limiting shows itself.</dd>
3718
- <dt>tiny / large</dt><dd>A one-word prompt measures overhead and
3719
- queueing; an 18 KB prompt measures throughput. They answer different
3720
- questions.</dd>
3721
- <dt>ch/s</dt><dd>Characters per second, from the large prompt only. A
3722
- proxy for tokens/s, affected by how verbose a model is.</dd>
3723
- <dt>tok/s</dt><dd>Output tokens per second from the provider's own count, first token to
3724
- last, large prompt only. Only where the built-in client made the call and the provider
3725
- reported usage; blank otherwise, never estimated.</dd>
3726
- <dt>Outcomes</dt><dd>answered / refused / error / timeout.</dd>
3727
- <dt>Weight it LEAST</dt><dd>Seconds are a wash. Speed only vetoes a model
3728
- when it is slow enough to change how you work.</dd>
3729
- </dl>"""
3730
- key = key.replace("</dl>", """
3731
- <dt>Did it answer?</dt><dd>Is on Uptime, by day, with our own quota and setup problems left out.</dd>
3732
- <dt>Speed vs quality</dt><dd>Median seconds per answer on our own suite tasks (real prompts,
3733
- not the one-word probe) against the share of findings located. Top left is fast and good:
3734
- the green box is the faster half and the top quarter of findings.</dd>
3735
- </dl>""")
3736
- return chart_card("Performance", "What the serving path does — not what the models are worth. One"
3737
- " reading is weather; the history is climate.", "chart-speed", body, key,
3738
- extra="<h3 class='chart-head'>Speed vs quality on our own tasks</h3>")
3561
+ return chart_card("Performance", "How fast each model answers our own tasks, against how much it finds.",
3562
+ "chart-speed", body)
3739
3563
 
3740
3564
 
3741
3565
  def favicon(theme: str) -> str:
@@ -4017,6 +3841,8 @@ def discover_card() -> str:
4017
3841
  which asks /api/discover-model for the rest: its routes are where Add is."""
4018
3842
  all_rows = _discover_rows()
4019
3843
  rows = [r for r in all_rows if r["free"] or r["reach"] in ("connected", "on bench")]
3844
+ # New models first, the newest at the top; the rest keep discover.py's order (a stable sort).
3845
+ rows.sort(key=lambda r: r["first_seen"] if r["new"] else "", reverse=True)
4020
3846
  hidden = len(all_rows) - len(rows)
4021
3847
  reach_css = {"on bench": "ok", "connected": "ok", "easy to add": "warn", "other": ""}
4022
3848
  body = ["<table><thead><tr>", th("Model"), th("Rank", True), th("Provider"), th("Reach"), th("Price"),
@@ -4039,8 +3865,9 @@ def discover_card() -> str:
4039
3865
  add = ("<button class='btn small' type='button'>Add…</button>"
4040
3866
  if any(not x["on_bench_as"] for x in r["routes"]) else "")
4041
3867
  body.append(
4042
- f"<tr class='pick-row{'' if r['free'] else ' paid'}' data-provider='{esc(r['provider'])}'"
4043
- f" data-model='{esc(r['model'])}'><td>"
3868
+ f"<tr class='pick-row' data-provider='{esc(r['provider'])}' data-model='{esc(r['model'])}'"
3869
+ f" data-reach='{r['reach']}' data-price='{'free' if r['free'] else 'paid'}'"
3870
+ f" data-new='{'1' if r['new'] else ''}' data-caps='{' '.join(caps)}'><td>"
4044
3871
  f"<a href='#' class='dmodel'><span class='alias'>{esc(r['name'])}</span></a>"
4045
3872
  + (" " + chip("new", "new", f"first seen {r['first_seen']}") if r["new"] else "")
4046
3873
  + f"<span class='conn'>{esc(r['model'])}"
@@ -4063,9 +3890,23 @@ def discover_card() -> str:
4063
3890
  import discover
4064
3891
  import sources
4065
3892
  ages = [a for a in map(sources.age_s, [*sources.FEEDS, discover.LISTS]) if a is not None]
4066
- toolbar = ("<div class='toolbar'><input type='search' data-filter placeholder='Filter by model,"
4067
- " provider or reach' aria-label='Filter rows'>"
4068
- "<label><input type='checkbox' data-paid> Show paid and unpriced (connected providers)</label>"
3893
+ # The rail: a search box and a checkbox per value of each facet, matched against the
3894
+ # rows' data-* by bench.js (dApply), which also fills in the counts and pages the list.
3895
+ def group(title: str, facet: str, values) -> str:
3896
+ return (f"<fieldset><legend>{esc(title)}</legend>" + "".join(
3897
+ f"<label><input type='checkbox' data-df='{facet}' value='{esc(v)}'> {esc(label)}"
3898
+ "<span class='n'></span></label>" for v, label in values) + "</fieldset>")
3899
+ provs = {r["provider"]: r["provider_name"] for r in rows}
3900
+ rail = ("<aside class='facets' id='d-facets' aria-label='Filter models'>"
3901
+ "<input type='search' id='d-q' placeholder='Search models' aria-label='Search models'>"
3902
+ + group("Show", "new", [("1", "New")])
3903
+ + group("Price", "price", [("free", "Free"), ("paid", "Paid and unpriced")])
3904
+ + group("Reach", "reach", [(v, v.capitalize()) for v in reach_css])
3905
+ + group("Can", "caps", [(v, v.capitalize()) for v in ("tools", "vision", "reasons")])
3906
+ + group(f"Provider · {len(provs)}", "provider",
3907
+ sorted(provs.items(), key=lambda kv: kv[1].lower()))
3908
+ + "</aside>")
3909
+ toolbar = ("<div class='toolbar'>"
4069
3910
  "<button class='btn small' type='button' id='d-refresh' title='Fetch FreeLLMAPI, models.dev and"
4070
3911
  " every Open WebUI connection&#39;s own model list now, instead of waiting for the daily fetch'>"
4071
3912
  "Refresh lists</button><span class='muted' id='d-refreshed'>"
@@ -4084,7 +3925,7 @@ def discover_card() -> str:
4084
3925
  <dt>Price</dt><dd>Free if FreeLLMAPI lists it (a catalog of free tiers) or models.dev
4085
3926
  prices it at 0: models.dev shows the PAID price even where a free tier exists. Otherwise
4086
3927
  input / output per million tokens. <em>Unknown</em> where only the provider's own list has
4087
- the model and it says nothing of price; those are behind <em>Show paid and unpriced</em>.</dd>
3928
+ the model and it says nothing of price; those are under <em>Paid and unpriced</em>.</dd>
4088
3929
  <dt>Limits</dt><dd>The free tier's, from FreeLLMAPI: requests and tokens per minute (rpm,
4089
3930
  tpm) and per day (rpd, tpd), and roughly how many tokens a month that allows.</dd>
4090
3931
  <dt>New</dt><dd>First seen in either list in the last 7 days.</dd>
@@ -4111,7 +3952,9 @@ def discover_card() -> str:
4111
3952
  f" models.dev and your providers' own lists, deduped; {hidden:,} paid models on providers you have no route"
4112
3953
  f" to are left out. Click a model for everything known about it and the routes it can be"
4113
3954
  f" added by.</p>{toolbar}"
3955
+ f"<div class='discover'>{rail}<div><p class='hint d-count' id='d-count' aria-live='polite'></p>"
4114
3956
  f"<div class='scroll'>{''.join(body)}</div>"
3957
+ "<button class='btn small' type='button' id='d-more' hidden>Show 100 more</button></div></div>"
4115
3958
  f"<details class='key'><summary>How to read this</summary>{key}</details></div>")
4116
3959
 
4117
3960
 
@@ -4219,7 +4062,7 @@ def collections_tab() -> str:
4219
4062
  " names none</label>"
4220
4063
  "<div class='settings'><div><label class='field' for='g-pick-conf'>Only when the model is at least"
4221
4064
  "</label><input id='g-pick-conf' type='number' min='0.05' max='1' step='0.05'></div></div>"
4222
- "<p class='rules'>A decision model (the one Benchmarks › Decision models says the collection pick"
4065
+ "<p class='rules'>A decision model (the one Benchmarks › Decision says the collection pick"
4223
4066
  " asks) chooses from the collections' descriptions and tags, and the reply says which and why"
4224
4067
  " (<code>bench.picked</code>, <code>X-Bench-Picked</code>). Less sure than this, or switched off,"
4225
4068
  " the request is refused with each collection's description and tags, so the caller can choose."
package/ui/bench.css CHANGED
@@ -302,7 +302,24 @@ tbody tr:hover > td { background: var(--step-1); }
302
302
  tr.retired > td { color: var(--muted); }
303
303
  tr.retired .alias { text-decoration: line-through; }
304
304
  body:not(.show-retired) tr.retired { display: none; }
305
- body:not(.show-paid) tr.paid { display: none; }
305
+ /* Discover: the facet rail beside the list. It stays in view and scrolls by itself. */
306
+ .discover { display: grid; grid-template-columns: 13.5rem minmax(0, 1fr); gap: 1.5rem; align-items: start; }
307
+ .facets { position: sticky; top: 1rem; max-height: calc(100vh - 2rem); overflow-y: auto; font-size: .875rem;
308
+ padding-right: .5rem; }
309
+ .facets input[type=search] { font: inherit; width: 100%; padding: .4rem .6rem; border: 1px solid var(--line);
310
+ border-radius: var(--radius); background: var(--paper); color: var(--ink); }
311
+ .facets fieldset { border: 0; padding: 0; margin: .9rem 0 0; min-width: 0; }
312
+ .facets legend { padding: 0; margin-bottom: .2rem; font-size: .8125rem; font-weight: 700; color: var(--muted); }
313
+ .facets label { display: flex; align-items: center; gap: .4rem; padding: .12rem 0; cursor: pointer; }
314
+ .facets label .n { margin-left: auto; padding-left: .5rem; color: var(--muted); font-variant-numeric: tabular-nums; }
315
+ .facets label.none { color: var(--muted); }
316
+ .discover td:nth-child(n+2) { white-space: nowrap; } /* one line a model: the table scrolls sideways instead */
317
+ .d-count { margin: 0 0 .5rem; }
318
+ #d-more { margin-top: .9rem; }
319
+ @media (max-width: 900px) {
320
+ .discover { grid-template-columns: minmax(0, 1fr); }
321
+ .facets { position: static; max-height: 45vh; }
322
+ }
306
323
  .muted { color: var(--muted); }
307
324
  .ok { color: var(--ok); } .warn { color: var(--warn); } .err { color: var(--err); }
308
325
 
package/ui/bench.js CHANGED
@@ -123,7 +123,7 @@
123
123
  var onShow = {}; // a page's loader, run each time it is shown
124
124
  var onLoad = {}; // a page's set-up, run each time its HTML arrives
125
125
  var shown = null, fetching = {}, ready = {}, marks = 0, last = {}, edited = {};
126
- var KEPT = 'input[data-filter], select[data-facet], input[data-retired], input[data-paid], [data-kept]'; // controls a swap keeps (or may reset)
126
+ var KEPT = 'input[data-filter], select[data-facet], input[data-retired], input[data-df], #d-q, [data-kept]'; // controls a swap keeps (or may reset)
127
127
  function show(id) {
128
128
  shown = id;
129
129
  tabs.forEach(function (b) { b.setAttribute('aria-selected', String(b.dataset.tab === id)); });
@@ -204,7 +204,7 @@
204
204
  function headKey(tr) { return tr.textContent; }
205
205
  function setUp(id) {
206
206
  delete edited['tab-' + id];
207
- applyRetired(); applyPaid();
207
+ applyRetired();
208
208
  return (ready[id] = Promise.resolve(onLoad[id] && onLoad[id]()));
209
209
  }
210
210
  tabs.forEach(function (b) { b.addEventListener('click', function () { show(b.dataset.tab); }); });
@@ -527,17 +527,67 @@
527
527
  }, failed);
528
528
  });
529
529
 
530
- // ---- Discover: paid models hidden unless asked for -------------------
531
- var showPaid = store('bench.paid') === '1';
532
- function applyPaid() {
533
- document.body.classList.toggle('show-paid', showPaid);
534
- document.querySelectorAll('input[data-paid]').forEach(function (c) { c.checked = showPaid; });
530
+ // ---- Discover: the facet rail -----------------------------------------
531
+ // A row shows when it matches the search box and every facet with a box ticked: any of
532
+ // the ticked values (for Can: all of them, since a model has several). Each box's count
533
+ // is what ticking it would leave, given the other facets. Free only until asked otherwise.
534
+ var D_FACETS = ['new', 'price', 'reach', 'caps', 'provider'], D_PAGE = 100;
535
+ var dPick = {price: ['free']}, dQ = '', dLimit = D_PAGE;
536
+ try { dPick = JSON.parse(store('bench.discover')) || dPick; } catch (e) { /* not ours: the default */ }
537
+ // The list is paged in CSS, so the first rows shown stay the first after a sort by a column.
538
+ var dPageCss = document.head.appendChild(el('style'));
539
+ function dHas(r, k, v) { return k === 'caps' ? r.dataset.caps.split(' ').indexOf(v) !== -1 : r.dataset[k] === v; }
540
+ function dApply() {
541
+ var rail = $('d-facets');
542
+ if (!rail) return;
543
+ var picked = D_FACETS.filter(function (k) { return (dPick[k] || []).length; });
544
+ var q = dQ.trim().toLowerCase(), counts = {}, hits = 0;
545
+ $('tab-discover').querySelectorAll('tr.pick-row').forEach(function (r) {
546
+ var fails = picked.filter(function (k) {
547
+ return !dPick[k][k === 'caps' ? 'every' : 'some'](function (v) { return dHas(r, k, v); });
548
+ });
549
+ var text = !q || r.textContent.toLowerCase().indexOf(q) !== -1;
550
+ r.hidden = !text || fails.length > 0;
551
+ if (!r.hidden) hits++;
552
+ if (!text || fails.length > 1) return;
553
+ D_FACETS.forEach(function (k) {
554
+ // Failing only this facet, another of its boxes would show the row (not Can: it wants all).
555
+ if (fails.length && (fails[0] !== k || k === 'caps')) return;
556
+ (k === 'caps' ? r.dataset.caps.split(' ') : [r.dataset[k]]).forEach(function (v) {
557
+ counts[k + '|' + v] = (counts[k + '|' + v] || 0) + 1;
558
+ });
559
+ });
560
+ });
561
+ rail.querySelectorAll('input[data-df]').forEach(function (c) {
562
+ var n = counts[c.dataset.df + '|' + c.value] || 0;
563
+ c.parentNode.querySelector('.n').textContent = n.toLocaleString();
564
+ c.parentNode.classList.toggle('none', !n && !c.checked);
565
+ });
566
+ $('d-count').textContent = hits.toLocaleString() + (hits === 1 ? ' model' : ' models')
567
+ + (hits > dLimit ? ', the first ' + dLimit.toLocaleString() + ' shown' : '');
568
+ $('d-more').hidden = hits <= dLimit;
569
+ dPageCss.textContent = '#tab-discover tr.pick-row:nth-child(n+' + (dLimit + 1) + ' of :not([hidden])) { display: none; }';
535
570
  }
571
+ onLoad.discover = function () {
572
+ $('d-q').value = dQ;
573
+ $('d-facets').querySelectorAll('input[data-df]').forEach(function (c) {
574
+ c.checked = (dPick[c.dataset.df] || []).indexOf(c.value) !== -1;
575
+ });
576
+ dApply();
577
+ };
536
578
  document.addEventListener('change', function (ev) {
537
- var c = ev.target;
538
- if (c.matches('input[data-paid]')) { showPaid = c.checked; store('bench.paid', showPaid ? '1' : '0'); applyPaid(); }
579
+ if (!ev.target.matches('input[data-df]')) return;
580
+ dPick = {};
581
+ $('d-facets').querySelectorAll('input[data-df]:checked').forEach(function (c) {
582
+ (dPick[c.dataset.df] = dPick[c.dataset.df] || []).push(c.value);
583
+ });
584
+ store('bench.discover', JSON.stringify(dPick));
585
+ dLimit = D_PAGE; dApply();
586
+ });
587
+ document.addEventListener('input', function (ev) {
588
+ if (ev.target.id === 'd-q') { dQ = ev.target.value; dLimit = D_PAGE; dApply(); }
539
589
  });
540
- applyPaid();
590
+ on('click', 'd-more', function () { dLimit += D_PAGE; dApply(); });
541
591
 
542
592
  // ---- live: the page follows the data ---------------------------------
543
593
  // The data's stamp (serve.data_stamp) is asked every 10 s while the page is
@@ -3091,12 +3141,13 @@
3091
3141
  // Who to trust: finds real bugs (right) vs makes things up (up = less).
3092
3142
  put('chart-overview', function (w) {
3093
3143
  if (!C.overview.length) return el('p', {class: 'hint'}, 'No model has both suite scores and answers yet.');
3094
- var worst = Math.max(0.1, Math.max.apply(null, C.overview.map(function (d) { return d.wrong; })));
3144
+ var worst = Math.max(0.15, Math.max.apply(null, C.overview.map(function (d) { return d.wrong; })));
3095
3145
  var x0 = Math.max(0, Math.floor((Math.min.apply(null, C.overview.map(function (d) { return d.recall; })) - 0.05) * 20) / 20);
3096
3146
  var pxW = w - 110, pxH = 370;
3097
3147
  // Named: the models in or next to the zone (the ones to pick from), then the clearest
3098
3148
  // warnings (most made up). The rest are on hover: named all at once they ran together.
3099
- var zoneX = x0 + (1 - x0) / 2, zoneY = worst / 4;
3149
+ // The zone: finds 90% or more of the real bugs, makes up 15% or less.
3150
+ var zoneX = Math.max(x0, 0.9), zoneY = 0.15;
3100
3151
  var near = function (d) { return d.recall >= zoneX - (1 - x0) * 0.08 && d.wrong <= zoneY * 1.4; };
3101
3152
  var worstFew = C.overview.slice().sort(function (a, b) { return b.wrong - a.wrong; }).slice(0, 3);
3102
3153
  var pts = C.overview.filter(function (d) { return near(d) || worstFew.indexOf(d) !== -1; })
@@ -3106,12 +3157,13 @@
3106
3157
  var labels = pts.map(function (d, i) { return offs[i] == null ? null : {model: d.model, recall: d.recall, ty: d.wrong + offs[i] * worst / pxH}; })
3107
3158
  .filter(Boolean);
3108
3159
  return Plot.plot({
3109
- width: w, height: 440, style: style, grid: true, inset: 16, marginRight: 50, marginTop: 30,
3160
+ // marginBottom: room for the axis label under the tick labels, which it ran into.
3161
+ width: w, height: 456, style: style, grid: true, inset: 16, marginRight: 50, marginTop: 30, marginBottom: 46,
3110
3162
  x: {label: 'Finds real bugs in our code →', domain: [x0, 1], tickFormat: '.0%'},
3111
3163
  y: {label: '↑ Makes fewer things up', domain: [0, worst], reverse: true, tickFormat: '.0%'},
3112
3164
  marks: [
3113
3165
  // The zone a reviewer should be in: outlined, so it reads on a dark ground too.
3114
- Plot.rect([{x1: x0 + (1 - x0) / 2, x2: 1, y1: 0, y2: worst / 4}], {x1: 'x1', x2: 'x2', y1: 'y1', y2: 'y2',
3166
+ Plot.rect([{x1: zoneX, x2: 1, y1: 0, y2: zoneY}], {x1: 'x1', x2: 'x2', y1: 'y1', y2: 'y2',
3115
3167
  fill: good, fillOpacity: 0.12, stroke: good, strokeOpacity: 0.5, strokeDasharray: '4 3'}),
3116
3168
  Plot.dot(C.overview, {x: 'recall', y: 'wrong', r: 6, fill: good, stroke: ink, strokeWidth: 0.5}),
3117
3169
  // A halo in the panel's colour keeps each name legible over dots, grid lines and the zone.
@@ -3205,19 +3257,22 @@
3205
3257
  var lo = Math.max(1, Math.min.apply(null, s) * 0.8), hi = Math.max.apply(null, s) * 1.25;
3206
3258
  var y0 = Math.max(0, Math.floor((Math.min.apply(null, C.speed.map(function (d) { return d.recall; })) - 0.05) * 20) / 20);
3207
3259
  var pxW = w - 110, pxH = 340;
3208
- var pts = C.speed.slice().sort(function (a, b) { return b.recall - a.recall; });
3260
+ // The zone: 90% or more of the findings, in 40 seconds or less. Named: the models in
3261
+ // or next to it, as on Who to trust; the rest are on hover.
3262
+ var zoneS = Math.min(hi, Math.max(lo, 40)), zoneR = Math.max(y0, 0.9);
3263
+ var pts = C.speed.filter(function (d) { return d.seconds <= zoneS * 1.25 && d.recall >= zoneR - (1 - y0) * 0.08; })
3264
+ .sort(function (a, b) { return b.recall - a.recall; });
3209
3265
  var offs = spread(pts, function (d) { return (Math.log(d.seconds) - Math.log(lo)) / (Math.log(hi) - Math.log(lo)) * pxW; },
3210
3266
  function (d) { return (1 - d.recall) / (1 - y0) * pxH; });
3211
3267
  var labels = pts.map(function (d, i) { return offs[i] == null ? null : {model: d.model, seconds: d.seconds, ty: d.recall - offs[i] * (1 - y0) / pxH}; })
3212
3268
  .filter(Boolean);
3213
3269
  return Plot.plot({
3214
- width: w, height: 420, style: style, grid: true, inset: 16, marginRight: 50, marginTop: 30,
3270
+ width: w, height: 436, style: style, grid: true, inset: 16, marginRight: 50, marginTop: 30, marginBottom: 46,
3215
3271
  x: {type: 'log', domain: [lo, hi], label: 'Median seconds per answer on our tasks →'},
3216
3272
  y: {domain: [y0, 1], label: '↑ Findings located', tickFormat: '.0%'},
3217
3273
  marks: [
3218
- // The zone to be in, as on Who to trust: the faster half (on the log scale) and the
3219
- // top quarter of findings. Outlined, so it reads on a dark ground too.
3220
- Plot.rect([{x1: lo, x2: Math.sqrt(lo * hi), y1: y0 + (1 - y0) * 3 / 4, y2: 1}], {x1: 'x1', x2: 'x2', y1: 'y1', y2: 'y2',
3274
+ // Outlined, so it reads on a dark ground too.
3275
+ Plot.rect([{x1: lo, x2: zoneS, y1: zoneR, y2: 1}], {x1: 'x1', x2: 'x2', y1: 'y1', y2: 'y2',
3221
3276
  fill: good, fillOpacity: 0.12, stroke: good, strokeOpacity: 0.5, strokeDasharray: '4 3'}),
3222
3277
  Plot.dot(C.speed, {x: 'seconds', y: 'recall', r: 6, fill: sky, stroke: ink, strokeWidth: 0.5}),
3223
3278
  Plot.text(labels, {x: 'seconds', y: 'ty', text: 'model', fontSize: 11, fill: ink,