@wendellresearch-ai/benchwarmers 0.6.2 → 0.6.3

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.2
1
+ 0.6.3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wendellresearch-ai/benchwarmers",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
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",
@@ -68,13 +68,29 @@ def get(hub: str, path: str) -> dict:
68
68
  return json.load(response)
69
69
 
70
70
 
71
+ def _host(url: str) -> str:
72
+ return (url or "").split("//", 1)[-1].split("/", 1)[0].split(":", 1)[0].lower()
73
+
74
+
71
75
  def connection_names(hub: str) -> list[str]:
72
- """One short name per connection index. URLs only — never the keys."""
76
+ """One short name per connection index. URLs only — never the keys.
77
+
78
+ A provider reached both through the hub and on a direct endpoint of its
79
+ own (Cloudflare: chat through Open WebUI, decisions only direct) is ONE
80
+ connection: the hub's connection to that host takes the direct endpoint's
81
+ name, which is what its directly registered models are tagged (main). Any
82
+ provider, by host; other hubs (CLI Proxy API) are not direct endpoints."""
73
83
  urls = get(hub, "/openai/config").get("OPENAI_API_BASE_URLS", [])
84
+ direct: dict[str, str] = {}
85
+ for name, entry in endpoints.load().items():
86
+ if name != hub and not endpoints.hub(name):
87
+ direct.setdefault(_host(entry.get("base", "")), name)
74
88
  names = []
75
89
  for url in urls:
76
- host = url.split("//", 1)[-1].split("/", 1)[0].split(":", 1)[0]
77
- if host.replace(".", "").isdigit():
90
+ host = _host(url)
91
+ if host in direct and host not in (endpoints.connections().get("hosts") or {}):
92
+ names.append(direct[host])
93
+ elif host.replace(".", "").isdigit():
78
94
  # A BARE IP NAMES NOTHING, and guessing from it was worse than
79
95
  # useless: a proxy labelled `lan-proxy` is described by where it
80
96
  # sits rather than what it is, and Open WebUI does not expose
@@ -236,6 +236,14 @@ def owner(fake: str) -> None:
236
236
  assert tags["fast-1"] == "groq" and tags["llm7.codestral"] == "llm7-api" and tags["vendor/x:free"] == "openrouter", tags
237
237
  assert "never-read" not in json.dumps(tags)
238
238
  assert route.connection_of("fast-1") == "groq" and route.connection_of("x-free") == "openrouter"
239
+ # A provider also on a direct endpoint of its own is one connection: the hub's takes the endpoint's name.
240
+ real_load, both = endpoints.load, dict(endpoints.load())
241
+ both["groq-direct"] = {"base": "https://api.groq.com/openai/v1", "key": "groq-direct"}
242
+ endpoints.load = lambda *a, **k: both
243
+ try:
244
+ assert provider_tags.connection_names("openwebui") == ["groq-direct", "llm7-api"]
245
+ finally:
246
+ endpoints.load = real_load
239
247
  # A decision model onboarded from the direct endpoint: in no registration, untagged, its `via` is its connection.
240
248
  write("decision-models.json", {"dec-1": {"served": "vendor/decide:free", "endpoint": "openrouter", "via": "openrouter"},
241
249
  "dec-hub": {"served": "hub-decide", "endpoint": "openwebui", "via": "openwebui"}})
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
@@ -2639,7 +2640,7 @@ def charts_data() -> dict:
2639
2640
  "rate": int(hit[1]) / max(1, int(hit[2])) if hit else None, "invented": bool(v["invented"])})
2640
2641
  bars = [{"model": m, "kind": k, "rate": w / r["answers"], "n": f"{w}/{n}"}
2641
2642
  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"]}
2643
+ totals = {m: {"rate": r["wrong"] / r["answers"], "answers": r["answers"], "wrong": r["wrong"]}
2643
2644
  for m, r in wr.items() if m in live and r["answers"]}
2644
2645
  hunt = [{"model": m, **r["hunt"]} for m, r in sorted(wr.items()) if m in live and any(r["hunt"].values())]
2645
2646
  return {"overview": overview, "heat": heat, "families": ["hand-built", "claims", "seeded, function",
@@ -2900,16 +2901,10 @@ def vision_card() -> str:
2900
2901
 
2901
2902
 
2902
2903
  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."""
2904
+ """Speed on real tasks against how much a model finds, for the Performance
2905
+ panel of Benchmarks. (Whether probes answered is Uptime's.)"""
2905
2906
  import route
2906
2907
  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
2908
  seconds: dict[str, list[float]] = {}
2914
2909
  for (_task, m), v in suite_scores().items():
2915
2910
  if m in live and re.match(r"\d+/\d+", v["found"]):
@@ -2917,8 +2912,7 @@ def perf_charts() -> dict:
2917
2912
  recall = route.suite_recall()
2918
2913
  speed = [{"model": m, "seconds": sorted(s)[len(s) // 2], "recall": recall[m][0] / recall[m][1], "tasks": len(s)}
2919
2914
  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}
2915
+ return {"speed": speed}
2922
2916
 
2923
2917
 
2924
2918
  # ---- Trend: does a model score lower, or higher, than it used to? ----------
@@ -3545,9 +3539,15 @@ DECIDE_KIND = {"errors": "choice of 6", "collection": "choice of the collections
3545
3539
  def decision_data() -> dict:
3546
3540
  """For bench.js: each model's calibration bins per set, [said, right, n]."""
3547
3541
  res = decide.results()
3548
- return {"sets": [[s, DECIDE_SETS[s]] for s in decide.SETS],
3542
+ models = sorted((res.get("models") or {}).items())
3543
+ return {"sets": [[s, DECIDE_SETS[s]] for s in decide.SETS], "low": decide.MIN_ITEMS,
3544
+ # Right of asked per model and set: the bars, as the tables count it.
3545
+ "scores": [{"model": a, "set": s, "correct": r.get("correct", 0), "asked": r["asked"],
3546
+ "answered": r.get("answered", 0), "brier": r.get("brier")}
3547
+ for a, by in models for s, r in by.items()
3548
+ if s in decide.SETS and isinstance(r, dict) and r.get("asked")],
3549
3549
  "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()
3550
+ for a, by in models for s, r in by.items()
3551
3551
  if s in decide.SETS for b in r.get("calibration") or []]}
3552
3552
 
3553
3553
 
@@ -3614,8 +3614,7 @@ def decision_card() -> str:
3614
3614
  extra = (f"<h3 class='chart-head'>Who each use asks now</h3><ul class='links'>{''.join(uses)}</ul>"
3615
3615
  f"<p class='hint'>{'Measured up to ' + esc(when[:16].replace('T', ' ')) + '.' if when else 'Not measured yet.'}"
3616
3616
  " 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>")
3617
+ " <code>python metrics/decide.py bench</code> runs a pass by hand.</p>")
3619
3618
  key = f"""<dl>
3620
3619
  <dt>The sets</dt><dd>Questions with known answers, built from the bench's own data, no hand labelling.
3621
3620
  <b>Error classes</b>: failed calls' error text (redacted: no keys, ids or emails) and the class the
@@ -3627,8 +3626,12 @@ def decision_card() -> str:
3627
3626
  picked, so always saying the commonest option scores low.</dd>
3628
3627
  <dt>Brier</dt><dd>How far its probabilities sat from the truth: 0 is perfect; for a choice it is
3629
3628
  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
3629
+ <dt>The bars</dt><dd>Right of asked for each model on each set, from 0%. A faint bar is a model
3630
+ under {low} items on that set: shown, not ranked yet. Hover a bar for its Brier.</dd>
3631
+ <dt>Calibration</dt><dd>The second chart, once a model answers at two or more levels of sureness
3632
+ on a set: answers grouped by the probability the model gave its answer
3633
+ (x), against how often those answers were right (y); both axes start just under the lowest
3634
+ answer, not at 0%. On the diagonal, its 80% answers come true
3632
3635
  80% of the time; under it, it is surer than it should be. A bigger dot is more answers. The gap is
3633
3636
  the average distance from the diagonal, weighted by answers.</dd>
3634
3637
  <dt>At {decide.CONFIDENT}</dt><dd>The gateway acts on a choice only when the model is this sure: how
@@ -3669,26 +3672,45 @@ def bughunt_card() -> str:
3669
3672
  grey said there was no bug.</dd>
3670
3673
  <dt>Left of the line</dt><dd>The same files untouched: red is a false alarm, claiming a
3671
3674
  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>
3675
+ <dt>Order</dt><dd>On what is proven, best at the top: the low end of the found share's likely
3676
+ range (95%) less the high end of the false alarms'. A few tasks give wide ranges, so they
3677
+ rank low; a model never shown clean code could be raising alarms on all of it.</dd>
3678
+ <dt>Faint bars</dt><dd>Under 10 tasks: shown, but little to go on.</dd>
3673
3679
  </dl>"""
3674
3680
  return chart_card("Bug hunt", "Every task with an answer key: bugs found against false alarms on the"
3675
3681
  " same files left clean. Right is good, left is bad.", "chart-hunt", "".join(body), key)
3676
3682
 
3677
3683
 
3684
+ def _wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
3685
+ """The 95% Wilson interval of k successes in n, as bench.js wilson() draws it."""
3686
+ if not n:
3687
+ return 0.0, 1.0
3688
+ p, z2 = k / n, z * z
3689
+ mid, half = p + z2 / (2 * n), z * math.sqrt(p * (1 - p) / n + z2 / (4 * n * n))
3690
+ return max(0.0, (mid - half) / (1 + z2 / n)), min(1.0, (mid + half) / (1 + z2 / n))
3691
+
3692
+
3678
3693
  def work_card() -> str:
3679
3694
  """How often real offloads helped."""
3680
3695
  rows = work_rows()
3681
3696
  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"]):
3697
+ th("Likely between", True), th("Verified findings", True), th("False claims", True), "</tr></thead><tbody>"]
3698
+ for r in sorted(rows, key=lambda r: (-_wilson(r["useful"], r["judged"])[0], -r["judged"])):
3699
+ lo, hi = _wilson(r["useful"], r["judged"])
3684
3700
  body.append(f"<tr><td>{named(r['model'])}</td><td class='num'>{r['offloads']}</td><td class='num'>{r['judged']}</td>"
3685
3701
  f"<td class='num' data-sort='{r['useful'] / r['judged']:.3f}'>{r['useful']}/{r['judged']}</td>"
3702
+ f"<td class='num' data-sort='{lo:.3f}'>{lo * 100:.0f}–{hi * 100:.0f}%</td>"
3686
3703
  f"<td class='num'>{r['real']}</td><td class='num'>{r['false']}</td></tr>")
3687
3704
  body.append("</tbody></table>")
3688
3705
  key = """<dl>
3689
3706
  <dt>Helped</dt><dd>Of the offloads judged, the share recorded useful: real work (reviews,
3690
3707
  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>
3708
+ <dt>The dot and its line</dt><dd>The dot is the share that helped, bigger for more judged. The
3709
+ line is where the true share likely sits (a 95% Wilson interval): long for 3 of 3, short for
3710
+ 40 of 50. Green when even its low end is over half, red when even its high end is under,
3711
+ grey when there are too few to say.</dd>
3712
+ <dt>Order</dt><dd>By the low end of that line, so volume counts: 40 of 50 ranks above 3 of 3.</dd>
3713
+ <dt>The dashed line</dt><dd>Every model's judged offloads together, for comparison.</dd>
3692
3714
  <dt>Findings and false claims</dt><dd>Verified one by one in reviews, recorded since 2026-10-17.</dd>
3693
3715
  </dl>"""
3694
3716
  return chart_card("Real work", "When a model was given real work and the result was checked: how often"
@@ -3698,7 +3720,8 @@ def work_card() -> str:
3698
3720
  def overview_card() -> str:
3699
3721
  return (f"<div class='panel'>{SHARE}<h2>Who to trust with a review</h2><p class='sub'>Each dot is a model."
3700
3722
  " 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>"
3723
+ " wrong on clean code, wrong lines, false claims, traps). Top right is where a reviewer should be."
3724
+ " The models in or near the box, and the three that make most up, are named; hover for the rest.</p>"
3702
3725
  "<div class='chart' id='chart-overview'></div></div>")
3703
3726
 
3704
3727
 
@@ -3811,6 +3834,10 @@ def wrong_card() -> str:
3811
3834
  <dt>Reviews</dt><dd>False claims recorded in verdicts (<code>offload.py verdict|add
3812
3835
  --false "..."</code>), over judged offloads. Recorded in structured form only since
3813
3836
  2026-10-17, so older reviews show none.</dd>
3837
+ <dt>The thin line</dt><dd>Where the true share likely sits (95% range). Long for a few
3838
+ answers: 0 of 2 is not a clean record, and the line shows how bad it could be.</dd>
3839
+ <dt>Order</dt><dd>Worst proven at the top: by the low end of that line. Under 10 answers a
3840
+ model's bar is faint.</dd>
3814
3841
  </dl>"""
3815
3842
  return chart_card("Confidently wrong", "Who asserts things that are not true: false alarms on clean"
3816
3843
  " code, wrong lines, false claims confirmed, traps taken. Shorter is better.",
@@ -3874,17 +3901,14 @@ def perf_card() -> str:
3874
3901
  when it is slow enough to change how you work.</dd>
3875
3902
  </dl>"""
3876
3903
  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>
3904
+ <dt>Did it answer?</dt><dd>Is on Uptime, by day, with our own quota and setup problems left out.</dd>
3880
3905
  <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>
3906
+ not the one-word probe) against the share of findings located. Top left is fast and good:
3907
+ the green box is the faster half and the top quarter of findings.</dd>
3882
3908
  </dl>""")
3883
3909
  return chart_card("Performance", "What the serving path does — not what the models are worth. One"
3884
3910
  " 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>")
3911
+ extra="<h3 class='chart-head'>Speed vs quality on our own tasks</h3>")
3888
3912
 
3889
3913
 
3890
3914
  def favicon(theme: str) -> str:
@@ -4300,13 +4324,21 @@ def collections_tab() -> str:
4300
4324
  " the paid ones. Click a suggestion or type your own: comma-separated lower-case words, up to 8.</p>"
4301
4325
  # Not data-filter: that binds to the whole panel. The same match (any
4302
4326
  # text, case-insensitive), on the member list only; the seats show who is ticked.
4303
- "<label class='field' for='c-member-filter'>Members</label>"
4327
+ "<div class='members-head'><label class='field' for='c-member-filter'>Members</label>"
4328
+ # Model details as they are now (a fact set in a model's popup, a refresh of the
4329
+ # catalog), the ticks and the search kept; also done when a model popup opened from here closes.
4330
+ "<button class='btn small' type='button' id='c-refresh' title='Fetch the models and their details again;"
4331
+ " what is ticked stays ticked'>Refresh model details</button>"
4332
+ "<span class='done' id='c-refresh-out' role='status'></span></div>"
4304
4333
  # The panel as it will sit, drawn as the Collections list draws it
4305
4334
  # (bench.js recount): a seat per ticked member, explorer seats dashed.
4306
4335
  "<div id='c-seats' class='seats' aria-live='polite'></div>"
4307
4336
  "<div class='member-find'><input type='search' id='c-member-filter' class='member-filter' spellcheck='false'"
4308
4337
  " 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'>"
4338
+ " aria-describedby='c-count'><select id='c-member-show' aria-label='Show' aria-controls='c-members'>"
4339
+ "<option value=''>Every model</option><option value='ticked'>Ticked</option>"
4340
+ "<option value='fits'>Fits the tags</option></select>"
4341
+ "<select id='c-member-provider' aria-label='Provider' aria-controls='c-members'>"
4310
4342
  "<option value=''>Every provider</option></select></div>"
4311
4343
  "<div id='c-members' class='members' role='group' aria-label='Members'></div>"
4312
4344
  "<p class='rules' id='c-count'></p>"
@@ -5331,6 +5363,28 @@ def endpoints_page() -> str:
5331
5363
  "<div class='done' id='l-done'></div></div>")
5332
5364
 
5333
5365
 
5366
+ def _reply_cell(by_prompt: dict) -> str:
5367
+ """Models' Reply: the daily probe's median seconds to a one-word answer (tiny),
5368
+ with the 18 KB prompt's (large) and tokens a second on hover. Was on Benchmarks'
5369
+ Performance, beside whether it answered, which is Uptime's now."""
5370
+ def p50(r):
5371
+ try:
5372
+ return float(r["p50_s"]) if r and int(r.get("answered") or 0) > 0 else None
5373
+ except (KeyError, TypeError, ValueError):
5374
+ return None
5375
+ tiny, large = p50(by_prompt.get("tiny")), p50(by_prompt.get("large"))
5376
+ if tiny is None and large is None:
5377
+ return "<td class='num muted' data-sort='999999'>—</td>"
5378
+ shown = tiny if tiny is not None else large
5379
+ why = "; ".join(b for b in (
5380
+ f"one-word prompt {tiny:.1f}s" if tiny is not None else "",
5381
+ f"18 KB prompt {large:.1f}s" if large is not None else "",
5382
+ f"{(by_prompt.get('large') or {}).get('tokens_per_s')} tok/s"
5383
+ if (by_prompt.get("large") or {}).get("tokens_per_s") else "") if b)
5384
+ return (f"<td class='num' data-sort='{shown:.2f}' title='median, the daily probe: {esc(why)}'>"
5385
+ f"{shown:.1f}s</td>")
5386
+
5387
+
5334
5388
  def models_page() -> str:
5335
5389
  """Every model on the bench by the connection it comes from."""
5336
5390
  cards = []
@@ -5344,6 +5398,10 @@ def models_page() -> str:
5344
5398
  # for what a collection may hold): they are on the bench like any other.
5345
5399
  by: dict[str, list[str]] = {}
5346
5400
  directory = model_directory()
5401
+ # The daily probe's latest reading per model and prompt (perf/history.csv), for Reply.
5402
+ speed: dict[str, dict] = {}
5403
+ for r in perf_rows():
5404
+ speed.setdefault(r["model"], {})[r.get("prompt", "")] = r
5347
5405
  for a in directory:
5348
5406
  by.setdefault(catalog.connection_of(a), []).append(a)
5349
5407
  for c in ({"name": n, "models": sorted(m)} for n, m in sorted(by.items())):
@@ -5356,7 +5414,7 @@ def models_page() -> str:
5356
5414
  kind = (" " + chip("decision", "", "a decision model: answers typed questions, not chat")
5357
5415
  if a in decisions else "")
5358
5416
  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"
5417
+ kind += " " + chip("ad hoc", "adhoc", "ad hoc only: never chosen by the bench on its own, only when you"
5360
5418
  " name it (a collection member, with, --model)")
5361
5419
  rows.append(f"<tr><td><a href='#' class='model' data-alias='{esc(a)}'>"
5362
5420
  f"<span class='alias'>{esc(a)}</span></a>{kind}</td>"
@@ -5364,19 +5422,23 @@ def models_page() -> str:
5364
5422
  f"<td class='num' data-sort='{f.get('context_window') or 0}'>{num(f.get('context_window'))}</td>"
5365
5423
  f"<td class='num' data-sort='{f.get('max_output') or 0}'>{num(f.get('max_output'))}</td>"
5366
5424
  f"<td>{esc(', '.join(f.get('input_modalities') or []) or '—')}</td>"
5425
+ + _reply_cell(speed.get(a, {}))
5367
5426
  + "".join(f"<td class='num'>{num(lim.get(k))}</td>" for k in catalog.LIMIT_KEYS) + "</tr>")
5368
5427
  # One column plan for every card, so the cards line up under each other
5369
5428
  # (each table sized its own columns, so they drifted from card to card).
5370
5429
  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>"
5430
+ "<col class='c-input'><col class='c-reply'>" + "<col class='c-lim'>" * len(catalog.LIMIT_KEYS) + "</colgroup>"
5431
+ # Each connection folds; the page's one box filters them all (bench.js applyFilter, data-conn).
5432
+ cards.append(f"<details class='panel model-card' open data-conn='{esc(c['name'])}'><summary>"
5433
+ f"<h3 class='chart-head'>{esc(c['name'])} · {len(c['models'])} models</h3></summary>"
5373
5434
  f"<table class='model-cards'>{cols}<thead><tr>" + th("Model") + th("Through")
5374
5435
  + 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>")
5436
+ + th("Input") + th("Reply", True) + "".join(th(k, True) for k in catalog.LIMIT_KEYS)
5437
+ + f"</tr></thead><tbody>{''.join(rows)}</tbody></table></details>")
5377
5438
  return ("<div class='panel'><h2>Models</h2><p class='sub'>Every model on the bench by the connection it"
5378
5439
  " comes from, with the endpoint it is reached through and what is known of its limits. A name"
5379
- " opens the model.</p></div>"
5440
+ " opens the model.</p><div class='toolbar'><input type='search' data-filter='page'"
5441
+ " placeholder='Find a model or connection' aria-label='Filter models'></div></div>"
5380
5442
  + "".join(cards))
5381
5443
 
5382
5444
 
package/ui/bench.css CHANGED
@@ -293,10 +293,11 @@ td.num, th.num { text-align: right; }
293
293
  table.model-cards { table-layout: fixed; min-width: 52rem; }
294
294
  .panel:has(> table.model-cards) { overflow-x: auto; } /* scrolls on a narrow window instead of wrapping numbers */
295
295
  table.model-cards td.num { white-space: nowrap; }
296
- table.model-cards col.c-model { width: 24%; }
297
- table.model-cards col.c-via { width: 13%; }
296
+ table.model-cards col.c-model { width: 23%; }
297
+ table.model-cards col.c-via { width: 11%; }
298
298
  table.model-cards col.c-n { width: 10%; }
299
- table.model-cards col.c-input { width: 19%; }
299
+ table.model-cards col.c-input { width: 15%; }
300
+ table.model-cards col.c-reply { width: 7%; }
300
301
  table.model-cards col.c-lim { width: 6%; }
301
302
  table.model-cards td { overflow-wrap: anywhere; }
302
303
  tbody tr:hover > td { background: var(--step-1); }
@@ -319,6 +320,7 @@ a.model:hover .alias { box-shadow: inset 0 -.3em 0 var(--signal); }
319
320
  .chip.err { background: var(--err-ground); color: var(--err); }
320
321
  .chip.new { background: var(--sky); color: var(--ink-deep); }
321
322
  /* an endpoint a model is reached through: outlined, so it reads as a place, not a state */
323
+ .chip.adhoc { background: color-mix(in oklab, var(--sky) 55%, var(--paper-2)); color: var(--ink-deep); }
322
324
  .chip.via { background: transparent; border: 1px solid var(--line); color: var(--ink); font-family: var(--font-mono); }
323
325
  button.chip { font-family: inherit; border: 0; cursor: pointer; }
324
326
  button.chip:hover { outline: 1px solid currentColor; }
@@ -334,6 +336,8 @@ td.why { white-space: nowrap; } /* Uptime's down / left out,
334
336
  dialog { border: 1px solid var(--ink); border-radius: var(--radius); padding: 1.25rem 1.4rem;
335
337
  width: min(40rem, calc(100vw - 2rem)); max-height: calc(100vh - 2rem);
336
338
  background: var(--paper-2); color: var(--ink); box-shadow: 6px 6px 0 var(--ink); }
339
+ /* The collection popup holds the member table: wider, so names and chips sit on one line. */
340
+ dialog#c-form { width: min(54rem, calc(100vw - 2rem)); }
337
341
  dialog::backdrop { background: color-mix(in oklab, var(--ink-deep) 45%, transparent); }
338
342
  dialog h2 { margin: 0 0 .2rem; font-size: 1.375rem; font-weight: 700; }
339
343
  :is(dialog, .form-panel) .served { color: var(--muted); font-size: .9375rem; margin: 0 0 .9rem; overflow-wrap: anywhere; }
@@ -344,11 +348,24 @@ dialog h2 { margin: 0 0 .2rem; font-size: 1.375rem; font-weight: 700; }
344
348
  :is(dialog, .form-panel) .rules { font-size: .875rem; color: var(--muted); margin: .35rem 0 0; }
345
349
  :is(dialog, .form-panel) label.check { display: flex; align-items: center; gap: .5rem; margin-top: 1rem; cursor: pointer; }
346
350
  :is(dialog, .form-panel) fieldset { border: 0; padding: 0; margin: 1rem 0 0; }
347
- :is(dialog, .form-panel) .members { max-height: 18rem; overflow: auto; border-top: 1px solid var(--line); }
348
- /* One row per model: its name and what it holds, then a chip per provider it
349
- is on, each ticking that alias (bench.js fillForm). */
350
- :is(dialog, .form-panel) .members .m-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .2rem 1rem;
351
- align-items: center; padding: .4rem 0; border-bottom: 1px solid var(--line); }
351
+ :is(dialog, .form-panel) .members { max-height: min(30rem, 55vh); overflow: auto; border: 1px solid var(--line);
352
+ border-radius: var(--radius); }
353
+ /* A table to scan: the model (its name opens it), context, what it can do, then a
354
+ chip per provider it is on, each ticking that alias (bench.js drawMembers). */
355
+ :is(dialog, .form-panel) .members :is(.m-row, .m-head) { display: grid; gap: .2rem .8rem; align-items: center;
356
+ grid-template-columns: minmax(9rem, 1.4fr) 3.6rem minmax(0, 1fr) minmax(0, 1.3fr); padding: .4rem .6rem; }
357
+ :is(dialog, .form-panel) .members .m-head { position: sticky; top: 0; z-index: 1; background: var(--paper-2);
358
+ border-bottom: 1px solid var(--ink); font-size: .75rem; font-weight: 600; color: var(--muted); }
359
+ :is(dialog, .form-panel) .members .m-row { border-bottom: 1px solid var(--line); }
360
+ :is(dialog, .form-panel) .members .m-row:nth-child(odd) { background: color-mix(in oklab, var(--band) 45%, transparent); }
361
+ :is(dialog, .form-panel) .members .m-row:has(input:checked) { box-shadow: inset 3px 0 0 var(--bench-seat, var(--brand)); }
362
+ :is(dialog, .form-panel) .members .m-name a.model { color: var(--ink); text-decoration: none; font-size: .875rem; }
363
+ :is(dialog, .form-panel) .members .m-name a.model:hover { text-decoration: underline; }
364
+ :is(dialog, .form-panel) .members .m-ctx { font-family: var(--font-mono); font-size: .8125rem; color: var(--muted); text-align: right; }
365
+ :is(dialog, .form-panel) .members .m-can { display: flex; flex-wrap: wrap; gap: .2rem; }
366
+ :is(dialog, .form-panel) .members .m-can .chip { font-size: .6875rem; padding: 0 .4rem; }
367
+ #c-form .members-head { display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; }
368
+ #c-form .members-head .field { margin-right: auto; }
352
369
  :is(dialog, .form-panel) .members .m-name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 .5rem;
353
370
  font-size: .875rem; overflow-wrap: anywhere; }
354
371
  :is(dialog, .form-panel) .members .m-facts { color: var(--muted); font-size: .8125rem; }
@@ -380,6 +397,8 @@ dialog h2 { margin: 0 0 .2rem; font-size: 1.375rem; font-weight: 700; }
380
397
  :is(dialog, .form-panel) .profile .src { font-size: .75rem; color: var(--muted); }
381
398
  :is(dialog, .form-panel) .settings { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 1rem; }
382
399
  @media (max-width: 40rem) { :is(dialog, .form-panel) :is(.settings, .members .m-row) { grid-template-columns: minmax(0, 1fr); }
400
+ :is(dialog, .form-panel) .members .m-head { display: none; }
401
+ :is(dialog, .form-panel) .members .m-ctx { text-align: left; }
383
402
  :is(dialog, .form-panel) .members .m-routes { justify-content: flex-start; } }
384
403
  :is(dialog, .form-panel) fieldset label.check { margin-top: .4rem; flex-wrap: wrap; }
385
404
  :is(dialog, .form-panel) fieldset label.check .muted { font-size: .875rem; }
@@ -519,6 +538,9 @@ pre.log.answer { max-height: 40vh; }
519
538
  .chart { margin: .5rem 0 .75rem; overflow-x: auto; }
520
539
  .chart.muted { font-size: .875rem; }
521
540
  h3.chart-head { font-size: 1rem; margin: 1rem 0 .2rem; }
541
+ .chart-label { font-size: .875rem; font-weight: 600; margin: .9rem 0 .1rem; }
542
+ /* Models: each connection a fold, its heading the summary */
543
+ .model-card > summary h3.chart-head { display: inline; margin: 0; }
522
544
  /* the Collections tab: the dialog's form, kept to a readable width */
523
545
  .panel.form-panel > :not(#c-list, table) { max-width: 46rem; }
524
546
  /* a list of things you open (collections), with its New button above */
package/ui/bench.js CHANGED
@@ -253,13 +253,21 @@
253
253
  var box = scope.querySelector('input[data-filter]'), q = box ? box.value.trim().toLowerCase() : '';
254
254
  var facets = Array.prototype.filter.call(scope.querySelectorAll('select[data-facet]'), function (s) { return s.value; });
255
255
  scope.querySelectorAll('tbody tr, .log-line').forEach(function (r) {
256
- r.hidden = (q && r.textContent.toLowerCase().indexOf(q) === -1)
256
+ // A group's name (data-conn: Models' connection) counts as text of each of its rows.
257
+ var g = r.closest('[data-conn]'), text = r.textContent + (g ? ' ' + g.dataset.conn : '');
258
+ r.hidden = (q && text.toLowerCase().indexOf(q) === -1)
257
259
  || facets.some(function (s) { return r.dataset[s.dataset.facet] !== s.value; });
258
260
  });
259
261
  // A match inside a folded list (an endpoint's models) opens it.
260
262
  if (q) scope.querySelectorAll('details.models').forEach(function (d) {
261
263
  if (d.querySelector('tbody tr:not([hidden])')) d.open = true;
262
264
  });
265
+ // A group with no row left is hidden; one with a match opens.
266
+ scope.querySelectorAll('[data-conn]').forEach(function (g) {
267
+ var any = !!g.querySelector('tbody tr:not([hidden])');
268
+ g.hidden = !!q && !any;
269
+ if (q && any && g.tagName === 'DETAILS') g.open = true;
270
+ });
263
271
  }
264
272
  function scopeOf(c) {
265
273
  var box = c.closest('.toolbar') ? c.closest('.toolbar').querySelector('input[data-filter]') : c;
@@ -1880,7 +1888,7 @@
1880
1888
  pick: $('g-pick'), pickConf: $('g-pick-conf'),
1881
1889
  gErrors: $('g-errors'), gDone: $('g-done'), form: $('c-form'), status: $('c-status'),
1882
1890
  del: $('c-delete'), reply: $('c-reply'), merger: $('c-merger'), find: $('c-member-filter'),
1883
- seats: $('c-seats'), prov: $('c-member-provider'), adHoc: $('c-ad-hoc'),
1891
+ seats: $('c-seats'), prov: $('c-member-provider'), show: $('c-member-show'), adHoc: $('c-ad-hoc'),
1884
1892
  ownCalls: $('c-max-calls'), ownExp: $('c-max-explorers')};
1885
1893
  return loadCollections();
1886
1894
  };
@@ -1994,7 +2002,7 @@
1994
2002
  // the seats above the list always show what is picked, each with its ×, and
1995
2003
  // every count still includes it. A row goes when all its chips do.
1996
2004
  function filterMembers() {
1997
- var q = c.find.value.trim().toLowerCase(), prov = c.prov.value;
2005
+ var q = c.find.value.trim().toLowerCase(), prov = c.prov.value, show = c.show.value;
1998
2006
  c.members.querySelectorAll('.m-row').forEach(function (row) {
1999
2007
  var named = !q || row.dataset.name.toLowerCase().indexOf(q) !== -1, any = false;
2000
2008
  row.querySelectorAll('label.check').forEach(function (lab) {
@@ -2002,7 +2010,8 @@
2002
2010
  var text = (box.value + ' ' + (lab.dataset.served || '') + ' ' + (fact(box.value).connection || '') + ' ' +
2003
2011
  lab.textContent).toLowerCase();
2004
2012
  lab.hidden = !box.checked && !!lab.dataset.paid || !named && text.indexOf(q) === -1
2005
- || !!prov && (fact(box.value).provider || '') !== prov;
2013
+ || !!prov && (fact(box.value).provider || '') !== prov
2014
+ || show === 'ticked' && !box.checked || show === 'fits' && !!box.dataset.blocked;
2006
2015
  any = any || !lab.hidden;
2007
2016
  });
2008
2017
  row.hidden = !any;
@@ -2011,6 +2020,23 @@
2011
2020
  }
2012
2021
  on('input', 'c-member-filter', filterMembers);
2013
2022
  on('change', 'c-member-provider', filterMembers);
2023
+ on('change', 'c-member-show', filterMembers);
2024
+ // Refresh model details: the models and their facts fetched again, the list drawn
2025
+ // anew with the same ticks, search and choices. The model popup's directory too.
2026
+ function refreshMembers() {
2027
+ var chosen = picked(), out = $('c-refresh-out'), btn = $('c-refresh');
2028
+ btn.disabled = true; out.textContent = 'Refreshing…';
2029
+ return Promise.all([get('/api/collections'), directory()]).then(function (res) {
2030
+ C = res[0];
2031
+ drawMembers(chosen);
2032
+ applyTags();
2033
+ out.textContent = 'Up to date.';
2034
+ }).catch(function (err) { out.textContent = String(err); })
2035
+ .then(function () { btn.disabled = false; });
2036
+ }
2037
+ on('click', 'c-refresh', refreshMembers);
2038
+ // A model opened from the list may have been changed in its popup: what it now is, on close.
2039
+ md.addEventListener('close', function () { if (c.form && c.form.open) refreshMembers(); });
2014
2040
  // Escape in a box with text clears it; only an empty box lets Escape close the popup.
2015
2041
  on('keydown', 'c-member-filter', function (e) {
2016
2042
  if (e.key === 'Escape' && c.find.value) { e.preventDefault(); e.stopPropagation(); c.find.value = ''; filterMembers(); }
@@ -2059,13 +2085,37 @@
2059
2085
  c.ownExp.value = row && row.max_explorers !== undefined ? row.max_explorers : '';
2060
2086
  c.ownCalls.placeholder = C.settings.max_calls; c.ownExp.placeholder = C.settings.max_explorers;
2061
2087
  drawTagPicks();
2062
- c.members.textContent = ''; c.find.value = '';
2088
+ c.find.value = ''; c.show.value = ''; c.prov.value = '';
2089
+ $('c-refresh-out').textContent = '';
2090
+ drawMembers(row ? row.members : []);
2091
+ c.shape.textContent = '';
2092
+ c.shape.appendChild(el('option', {value: ''}, 'automatic'));
2093
+ C.shapes.forEach(function (s) {
2094
+ var o = el('option', {value: s}, s); if (row && row.shape === s) o.selected = true; c.shape.appendChild(o);
2095
+ });
2096
+ c.reply.value = row ? row.reply : 'all';
2097
+ c.merger.textContent = '';
2098
+ c.merger.appendChild(el('option', {value: ''}, 'automatic'));
2099
+ C.models.forEach(function (alias) {
2100
+ var o = el('option', {value: alias}, alias + (fact(alias).provider ? ' · ' + fact(alias).provider : ''));
2101
+ if (row && row.merger === alias) o.selected = true; c.merger.appendChild(o);
2102
+ });
2103
+ showMerger(); applyTags();
2104
+ }
2105
+ // The member list: a table to scan down, one row per model (named by its shortest
2106
+ // alias; the name opens its popup, as everywhere), its context and what it can do
2107
+ // in columns of their own, then a chip per provider it is on, each ticking that
2108
+ // alias. A chip whose window differs from the row's says its own. `chosen`: ticked.
2109
+ function drawMembers(chosen) {
2110
+ var keepProv = c.prov.value;
2111
+ c.members.textContent = '';
2063
2112
  c.prov.length = 1; // "Every provider", then each one a model is on
2064
2113
  Array.from(new Set(C.models.map(function (a) { return fact(a).provider; }).filter(Boolean))).sort()
2065
2114
  .forEach(function (p) { c.prov.appendChild(el('option', {value: p}, p)); });
2066
- // One row per model, named by its shortest alias, with what the first
2067
- // route says it holds and can do; each provider it is on is a chip that
2068
- // ticks that alias. A chip whose window differs from the row's says its own.
2115
+ c.prov.value = keepProv;
2116
+ var head = el('div', {class: 'm-head', 'aria-hidden': 'true'});
2117
+ ['Model', 'Context', 'Can', 'On, tick one'].forEach(function (h) { head.appendChild(el('span', null, h)); });
2118
+ c.members.appendChild(head);
2069
2119
  var groups = {};
2070
2120
  C.models.forEach(function (a) { var f = fact(a).family || a; (groups[f] = groups[f] || []).push(a); });
2071
2121
  Object.keys(groups).map(function (f) {
@@ -2073,18 +2123,22 @@
2073
2123
  return {family: f, name: routes[0], routes: routes};
2074
2124
  }).sort(function (x, y) { return x.name.toLowerCase() < y.name.toLowerCase() ? -1 : 1; }).forEach(function (g) {
2075
2125
  var first = fact(g.name), caps = first.caps || {};
2076
- var can = ['vision', 'tools'].filter(function (k) { return caps[k]; });
2077
2126
  var line = el('div', {class: 'm-row', role: 'group', 'aria-label': g.name, 'data-family': g.family, 'data-name': g.name});
2078
2127
  var name = el('div', {class: 'm-name'});
2079
- name.appendChild(el('span', {class: 'mono'}, g.name));
2080
- name.appendChild(el('span', {class: 'm-facts'}, [first.context ? tokens(first.context) : '', can.join(', '),
2081
- first.price === 'unknown' ? '' : first.price].filter(Boolean).join(' · ')));
2128
+ name.appendChild(el('a', {href: '#', class: 'model mono', 'data-alias': g.name, title: 'Open ' + g.name}, g.name));
2082
2129
  line.appendChild(name);
2130
+ line.appendChild(el('span', {class: 'm-ctx'}, first.context ? tokens(first.context) : '—'));
2131
+ var can = el('span', {class: 'm-can'});
2132
+ [['vision', 'vision'], ['tools', 'tools'], ['reasoning', 'reasons']].forEach(function (k) {
2133
+ if (caps[k[0]]) can.appendChild(el('span', {class: 'chip'}, k[1]));
2134
+ });
2135
+ if (first.price === 'free') can.appendChild(el('span', {class: 'chip ok'}, 'free'));
2136
+ line.appendChild(can);
2083
2137
  var chips = el('div', {class: 'm-routes'});
2084
2138
  g.routes.forEach(function (alias) {
2085
2139
  var f = fact(alias);
2086
2140
  var box = el('input', {type: 'checkbox', value: alias, 'aria-label': alias + ' on ' + (f.provider || 'its endpoint')});
2087
- box.checked = !!row && row.members.indexOf(alias) !== -1;
2141
+ box.checked = chosen.indexOf(alias) !== -1;
2088
2142
  box.addEventListener('change', applyTags); // ticked or not changes what a misfit shows as
2089
2143
  var lab = el('label', {class: 'check', 'data-served': (C.served || {})[alias] || ''});
2090
2144
  lab.appendChild(box);
@@ -2095,19 +2149,6 @@
2095
2149
  line.appendChild(chips);
2096
2150
  c.members.appendChild(line);
2097
2151
  });
2098
- c.shape.textContent = '';
2099
- c.shape.appendChild(el('option', {value: ''}, 'automatic'));
2100
- C.shapes.forEach(function (s) {
2101
- var o = el('option', {value: s}, s); if (row && row.shape === s) o.selected = true; c.shape.appendChild(o);
2102
- });
2103
- c.reply.value = row ? row.reply : 'all';
2104
- c.merger.textContent = '';
2105
- c.merger.appendChild(el('option', {value: ''}, 'automatic'));
2106
- C.models.forEach(function (alias) {
2107
- var o = el('option', {value: alias}, alias + (fact(alias).provider ? ' · ' + fact(alias).provider : ''));
2108
- if (row && row.merger === alias) o.selected = true; c.merger.appendChild(o);
2109
- });
2110
- showMerger(); applyTags();
2111
2152
  }
2112
2153
  // What a collection's members can all do, in the words the tag checks use.
2113
2154
  var CAN = {reasoning: 'reason'};
@@ -2660,7 +2701,8 @@
2660
2701
  try { box.appendChild(make(width())); } catch (e) { box.textContent = 'Chart failed: ' + e; }
2661
2702
  }
2662
2703
  // Pixel offsets that keep scatter labels apart: each label, in order, moves
2663
- // up or down until it clears the ones already placed.
2704
+ // up or down until it clears the ones already placed; null when no place is
2705
+ // free (that one is left to its hover, not drawn over another).
2664
2706
  function spread(points, xpx, ypx) {
2665
2707
  var boxes = [], tries = [-12, 14, -26, 28, -40, 42, -54, 56];
2666
2708
  return points.map(function (d) {
@@ -2672,7 +2714,7 @@
2672
2714
  return tries[k];
2673
2715
  }
2674
2716
  }
2675
- return tries[0];
2717
+ return null;
2676
2718
  });
2677
2719
  }
2678
2720
  function rows(n) { return 20 * n + 80; }
@@ -2682,6 +2724,16 @@
2682
2724
  var keep = s.length - over - 1;
2683
2725
  return over <= 0 ? s : s.slice(0, Math.ceil(keep / 2)) + '…' + s.slice(s.length - Math.floor(keep / 2));
2684
2726
  }
2727
+ function fit(s, max) { return middle(s, s.length - max); }
2728
+ var FEW = 10; // under this many tasks or answers, a row is drawn faint
2729
+ // The 95% Wilson interval of k successes in n: [low, high]. Honest at small n and at
2730
+ // 0 or n of n, where k/n ± 1.96·se says nothing (3 of 3 is not "100% ± 0").
2731
+ function wilson(k, n) {
2732
+ if (!n) return [0, 1];
2733
+ var z = 1.96, p = k / n, z2 = z * z, den = 1 + z2 / n;
2734
+ var mid = p + z2 / (2 * n), half = z * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n));
2735
+ return [Math.max(0, (mid - half) / den), Math.min(1, (mid + half) / den)];
2736
+ }
2685
2737
 
2686
2738
  // Trend: who moved, which way, by how much and how sure (serve.trend_data),
2687
2739
  // in the window the card's select says. A row per model with a task to
@@ -2728,7 +2780,9 @@
2728
2780
  var nameW = Math.min(260, Math.max(150, 7 * Math.max.apply(null, M.map(function (r) { return r.key.length; })) + 24));
2729
2781
  var evW = 320;
2730
2782
  w = $('chart-trend').clientWidth || w; // its own box: a phone's is narrower than width()'s 520
2731
- if (w < nameW + evW + 260) { evW = 0; nameW = Math.min(nameW, 130); }
2783
+ // Without the evidence column the names take what the bars do not need (260 px), not a
2784
+ // fixed 130 px: at the dashboard's width that cut "muse-glimmer-30b" to "muse-gl…mer-30b".
2785
+ if (w < nameW + evW + 260) { evW = 0; nameW = Math.min(nameW, Math.max(130, w - 280)); }
2732
2786
  var fits = Math.floor((nameW - 24) / 7), right = evW || 20, wide = w < nameW + right + 140;
2733
2787
  w = Math.max(w, nameW + right + 140);
2734
2788
  // Symmetric, to the biggest bar and room for its number; 20 pts at least.
@@ -2830,16 +2884,74 @@
2830
2884
  // the time; under it, it is surer than it should be. Panels stack on a phone.
2831
2885
  var DC = C.decide;
2832
2886
  if (DC) put('chart-decide', function (w) {
2833
- if (!DC.points.length) return el('p', {class: 'hint'}, 'No decision model has answered a benchmark question yet.');
2887
+ if (!(DC.scores || []).length) return el('p', {class: 'hint'}, 'No decision model has answered a benchmark question yet.');
2834
2888
  var names = {};
2835
2889
  DC.sets.forEach(function (s) { names[s[0]] = s[1]; });
2836
2890
  var panels = DC.sets.map(function (s) { return s[1]; });
2891
+ // One colour per model across both charts, by name, never by rank.
2892
+ var models = DC.scores.map(function (p) { return p.model; })
2893
+ .filter(function (m, i, all) { return all.indexOf(m) === i; }).sort();
2894
+ var colors = {domain: models, range: [sky, good, signal, warn, muted, ink, err]};
2895
+ var box = el('div');
2896
+ box.appendChild(el('h3', {class: 'chart-head'}, 'Right of asked, per set'));
2897
+ box.appendChild(decideBars(w, names, panels, models, colors));
2898
+ // Calibration only says something once a model's answers spread over two or more
2899
+ // levels of sureness; while each sits in one top bin it is one dot in a corner.
2900
+ var levels = DC.points.some(function (p, i, all) {
2901
+ return all.some(function (q) { return q !== p && q.model === p.model && q.set === p.set; });
2902
+ });
2903
+ if (levels) {
2904
+ box.appendChild(el('h3', {class: 'chart-head'}, 'Calibration: said against right'));
2905
+ box.appendChild(decideCalibration(w, names, panels, models, colors));
2906
+ } else {
2907
+ box.appendChild(el('p', {class: 'hint'}, 'Calibration shows once a model gives answers at two or more levels'
2908
+ + ' of sureness on a set; so far each answers at one, so the table\'s Brier and calibration gap say it.'));
2909
+ }
2910
+ return box;
2911
+ });
2912
+ // Right of asked as bars from 0: a small chart per set, a row per model asked on it,
2913
+ // best first. A model under the ranking floor (DC.low items) is drawn faint: shown,
2914
+ // not yet ranked.
2915
+ function decideBars(w, names, panels, models, colors) {
2916
+ var out = el('div');
2917
+ DC.sets.forEach(function (s) {
2918
+ var rows = DC.scores.filter(function (r) { return r.set === s[0]; }).map(function (r) {
2919
+ return Object.assign({share: r.correct / r.asked, ranked: r.asked >= DC.low}, r);
2920
+ }).sort(function (a, b) { return b.share - a.share || b.asked - a.asked || (a.model < b.model ? -1 : 1); });
2921
+ out.appendChild(el('p', {class: 'chart-label'}, s[1]));
2922
+ if (!rows.length) { out.appendChild(el('p', {class: 'hint'}, 'No model asked on this set yet.')); return; }
2923
+ out.appendChild(Plot.plot({
2924
+ width: w, height: rows.length * 26 + 40, style: style,
2925
+ marginLeft: w < 560 ? 120 : 160, marginRight: 50, marginTop: 4, marginBottom: 30,
2926
+ x: {domain: [0, 1], label: null, tickFormat: '.0%', ticks: 5, grid: true},
2927
+ y: {domain: rows.map(function (r) { return r.model; }), label: null},
2928
+ color: colors,
2929
+ marks: [
2930
+ Plot.barX(rows, {x: 'share', y: 'model', fill: 'model', insetTop: 4, insetBottom: 4, rx: 2,
2931
+ fillOpacity: function (r) { return r.ranked ? 1 : 0.4; },
2932
+ title: function (r) {
2933
+ return r.model + ' · ' + s[1] + '\n' + r.correct + ' right of ' + r.asked + ' asked ('
2934
+ + Math.round(r.share * 100) + '%)' + (r.brier != null ? '\nBrier ' + r.brier.toFixed(3) : '')
2935
+ + (r.ranked ? '' : '\nnot ranked yet: under ' + DC.low + ' items');
2936
+ }}),
2937
+ Plot.text(rows, {x: 'share', y: 'model', dx: 6, textAnchor: 'start', fill: ink,
2938
+ text: function (r) { return r.correct + '/' + r.asked; }}),
2939
+ Plot.ruleX([0], {stroke: ink})
2940
+ ]
2941
+ }));
2942
+ });
2943
+ return out;
2944
+ }
2945
+ function decideCalibration(w, names, panels, models, colors) {
2837
2946
  var pts = DC.points.map(function (p) { return Object.assign({panel: names[p.set]}, p); })
2838
2947
  .sort(function (a, b) { return a.said - b.said; });
2839
- var models = pts.map(function (p) { return p.model; })
2840
- .filter(function (m, i, all) { return all.indexOf(m) === i; }).sort();
2948
+ // Both axes start just under the lowest answer, not at 0%: a decision model is
2949
+ // rarely under 50% sure of its own answer, so a 0-100% frame left every dot in
2950
+ // one corner under a long empty diagonal. Same floor on both, so it stays 45°.
2951
+ var low = Math.min.apply(null, pts.map(function (p) { return Math.min(p.said, p.right); }));
2952
+ var lo = Math.max(0, Math.min(0.9, Math.floor((low - 0.05) * 20) / 20));
2841
2953
  var diag = [];
2842
- panels.forEach(function (s) { diag.push({panel: s, x: 0, y: 0}, {panel: s, x: 1, y: 1}); });
2954
+ panels.forEach(function (s) { diag.push({panel: s, x: lo, y: lo}, {panel: s, x: 1, y: 1}); });
2843
2955
  function tip(p) {
2844
2956
  return p.model + ' · ' + names[p.set] + '\n' + plural(p.n, 'answer') + ' it was about '
2845
2957
  + Math.round(p.said * 100) + '% sure of\n' + Math.round(p.right * 100) + '% of them right';
@@ -2850,10 +2962,11 @@
2850
2962
  return Plot.plot(Object.assign(opts, {
2851
2963
  width: narrow ? Math.min(w, 520) : w, height: narrow ? 3 * 230 + 70 : 320, style: style,
2852
2964
  marginLeft: 52, marginRight: narrow ? 90 : 20, marginBottom: 44, marginTop: 28,
2853
- x: {domain: [0, 1], label: 'Probability it gave its answer →', tickFormat: '.0%', ticks: 5, grid: true},
2854
- y: {domain: [0, 1], label: '↑ Share right', tickFormat: '.0%', ticks: 5, grid: true},
2965
+ inset: 14, // a dot at 100% clear of the frame
2966
+ x: {domain: [lo, 1], label: 'Probability it gave its answer →', tickFormat: '.0%', ticks: 5, grid: true},
2967
+ y: {domain: [lo, 1], label: '↑ Share right', tickFormat: '.0%', ticks: 5, grid: true},
2855
2968
  r: {range: [3, 13]},
2856
- color: {legend: true, domain: models, range: [sky, good, signal, warn, muted, ink, err]},
2969
+ color: Object.assign({legend: true}, colors),
2857
2970
  marks: [
2858
2971
  Plot.frame({stroke: line}),
2859
2972
  Plot.line(diag, Object.assign({x: 'x', y: 'y', z: 'panel', stroke: muted, strokeDasharray: '4 3'}, by)),
@@ -2862,17 +2975,24 @@
2862
2975
  title: tip}, by))
2863
2976
  ]
2864
2977
  }));
2865
- });
2978
+ }
2866
2979
  // Who to trust: finds real bugs (right) vs makes things up (up = less).
2867
2980
  put('chart-overview', function (w) {
2868
2981
  if (!C.overview.length) return el('p', {class: 'hint'}, 'No model has both suite scores and answers yet.');
2869
2982
  var worst = Math.max(0.1, Math.max.apply(null, C.overview.map(function (d) { return d.wrong; })));
2870
2983
  var x0 = Math.max(0, Math.floor((Math.min.apply(null, C.overview.map(function (d) { return d.recall; })) - 0.05) * 20) / 20);
2871
2984
  var pxW = w - 110, pxH = 370;
2872
- var pts = C.overview.slice().sort(function (a, b) { return a.wrong - b.wrong || b.recall - a.recall; });
2985
+ // Named: the models in or next to the zone (the ones to pick from), then the clearest
2986
+ // warnings (most made up). The rest are on hover: named all at once they ran together.
2987
+ var zoneX = x0 + (1 - x0) / 2, zoneY = worst / 4;
2988
+ var near = function (d) { return d.recall >= zoneX - (1 - x0) * 0.08 && d.wrong <= zoneY * 1.4; };
2989
+ var worstFew = C.overview.slice().sort(function (a, b) { return b.wrong - a.wrong; }).slice(0, 3);
2990
+ var pts = C.overview.filter(function (d) { return near(d) || worstFew.indexOf(d) !== -1; })
2991
+ .sort(function (a, b) { return a.wrong - b.wrong || b.recall - a.recall; });
2873
2992
  var offs = spread(pts, function (d) { return (d.recall - x0) / (1 - x0) * pxW; },
2874
2993
  function (d) { return d.wrong / worst * pxH; });
2875
- var labels = pts.map(function (d, i) { return {model: d.model, recall: d.recall, ty: d.wrong + offs[i] * worst / pxH}; });
2994
+ 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}; })
2995
+ .filter(Boolean);
2876
2996
  return Plot.plot({
2877
2997
  width: w, height: 440, style: style, grid: true, inset: 16, marginRight: 50, marginTop: 30,
2878
2998
  x: {label: 'Finds real bugs in our code →', domain: [x0, 1], tickFormat: '.0%'},
@@ -2900,71 +3020,72 @@
2900
3020
  tasks[h.model] = bugs + clean;
2901
3021
  var f = bugs ? h.found / bugs : 0, wl = bugs ? h['wrong line'] / bugs : 0, ms = bugs ? h.missed / bugs : 0;
2902
3022
  var fa = clean ? h['false alarm'] / clean : 0;
2903
- score[h.model] = f - fa;
3023
+ // Ranked on what is proven: the low end of the found share's 95% range less the HIGH
3024
+ // end of the false alarms' (never shown clean files: up to 100%), so a few tasks, or
3025
+ // none on clean code, cannot top the chart.
3026
+ score[h.model] = wilson(h.found, bugs)[0] - wilson(h['false alarm'], clean)[1];
2904
3027
  segs.push({model: h.model, part: 'false alarm', x1: -fa, x2: 0, n: h['false alarm'] + ' of ' + clean + ' clean files'});
2905
3028
  segs.push({model: h.model, part: 'found', x1: 0, x2: f, n: h.found + ' of ' + bugs + ' bugs'});
2906
3029
  segs.push({model: h.model, part: 'wrong line', x1: f, x2: f + wl, n: h['wrong line'] + ' of ' + bugs + ' bugs'});
2907
3030
  segs.push({model: h.model, part: 'missed', x1: f + wl, x2: f + wl + ms, n: h.missed + ' of ' + bugs + ' bugs'});
2908
3031
  });
2909
- var models = Object.keys(score).sort(function (a, b) { return score[b] - score[a]; });
3032
+ var models = Object.keys(score).sort(function (a, b) { return score[b] - score[a] || tasks[b] - tasks[a]; });
2910
3033
  return Plot.plot({
2911
- width: w, height: rows(models.length), style: style, marginLeft: 250,
3034
+ width: w, height: rows(models.length), style: style, marginLeft: w < 560 ? 150 : 250,
2912
3035
  x: {domain: [-1, 1], label: '← false alarms on clean files · bugs found →', grid: true,
2913
3036
  tickFormat: function (d) { return Math.abs(Math.round(d * 100)) + '%'; }},
2914
- y: {domain: models, label: null, tickFormat: function (m) { return m + ' (' + tasks[m] + ')'; }},
3037
+ y: {domain: models, label: null, tickFormat: function (m) { return fit(m, w < 560 ? 16 : 28) + ' (' + tasks[m] + ')'; }},
2915
3038
  color: {legend: true, domain: ['found', 'wrong line', 'missed', 'false alarm'], range: [good, signal, line, err]},
2916
3039
  marks: [
3040
+ // Under FEW tasks a model's bars are faint: shown, not much to go on.
2917
3041
  Plot.barX(segs.filter(function (s) { return s.x2 > s.x1; }), {x1: 'x1', x2: 'x2', y: 'model', fill: 'part',
2918
- title: function (d) { return d.model + ': ' + d.part + ', ' + d.n; }}),
3042
+ fillOpacity: function (d) { return tasks[d.model] < FEW ? 0.4 : 1; },
3043
+ title: function (d) { return d.model + ': ' + d.part + ', ' + d.n + (tasks[d.model] < FEW ? '\nfew tasks: little to go on' : ''); }}),
2919
3044
  Plot.ruleX([0], {stroke: ink, strokeWidth: 1.5})
2920
3045
  ]
2921
3046
  });
2922
3047
  });
2923
3048
 
2924
- // Real work: the share of judged offloads that helped, sample size beside the name.
3049
+ // Real work: how often each model helped, with how sure that is. The dot is the share
3050
+ // helped, its size how many were judged, the line the range the true share likely sits
3051
+ // in (95% Wilson interval: wide for 3 of 3, narrow for 40 of 50). Ranked by the low end
3052
+ // of that range, so volume counts: 3 of 3 no longer outranks 40 of 50. The dashed line
3053
+ // is every model's offloads pooled.
2925
3054
  put('chart-work', function (w) {
2926
- var R = C.work.slice().sort(function (a, b) { return b.useful / b.judged - a.useful / a.judged || b.judged - a.judged; });
2927
- if (!R.length) return el('p', {class: 'hint'}, 'No judged offloads yet.');
2928
- var by = {};
2929
- R.forEach(function (r) { by[r.model] = r; });
2930
- function share(r) { return r.useful / r.judged; }
3055
+ if (!C.work.length) return el('p', {class: 'hint'}, 'No judged offloads yet.');
3056
+ var R = C.work.map(function (r) {
3057
+ var b = wilson(r.useful, r.judged), p = r.useful / r.judged;
3058
+ var verdict = b[0] >= 0.5 ? 'helps' : b[1] < 0.5 ? 'does not help' : 'too few to say';
3059
+ return Object.assign({p: p, lo: b[0], hi: b[1], verdict: verdict}, r);
3060
+ }).sort(function (a, b) { return b.lo - a.lo || b.judged - a.judged; });
3061
+ var all = R.reduce(function (s, r) { return [s[0] + r.useful, s[1] + r.judged]; }, [0, 0]);
3062
+ var pooled = all[0] / all[1];
3063
+ var narrowW = w < 560, nameW = narrowW ? 130 : 210;
2931
3064
  return Plot.plot({
2932
- width: w, height: rows(R.length), style: style, marginLeft: 250, marginRight: 50,
2933
- x: {domain: [0, 1], label: 'Helped, share of judged offloads →', tickFormat: '.0%', grid: true},
3065
+ width: w, height: 24 * R.length + 90, style: style, marginLeft: nameW, marginRight: 64, marginTop: 34,
3066
+ x: {domain: [0, 1], label: 'Helped, share of judged offloads →', tickFormat: '.0%', ticks: 5, grid: true},
2934
3067
  y: {domain: R.map(function (r) { return r.model; }), label: null,
2935
- tickFormat: function (m) { return m + ' (' + by[m].judged + ')'; }},
3068
+ tickFormat: function (m) { return fit(m, narrowW ? 16 : 26); }},
3069
+ r: {domain: [0, Math.max.apply(null, R.map(function (r) { return r.judged; }))], range: [3, 11]},
3070
+ color: {domain: ['helps', 'too few to say', 'does not help'], range: [good, muted, err], legend: true},
2936
3071
  marks: [
2937
- Plot.barX(R, {x: share, y: 'model', fill: function (r) { return share(r) >= 0.5 ? good : err; },
2938
- title: function (r) { return r.model + ': helped ' + r.useful + ' of ' + r.judged + ' judged (' + r.offloads + ' offloads)'
2939
- + (r.real || r.false ? '\nverified findings ' + r.real + ', false claims ' + r.false : ''); }}),
2940
- Plot.text(R, {x: share, y: 'model', text: function (r) { return r.useful + '/' + r.judged; }, dx: 18, fill: ink}),
2941
- Plot.ruleX([0], {stroke: ink})
3072
+ Plot.ruleX([0.5], {stroke: line}),
3073
+ Plot.ruleX([pooled], {stroke: ink, strokeDasharray: '4 3', strokeOpacity: 0.6}),
3074
+ Plot.text([pooled], {x: function (d) { return d; }, frameAnchor: 'top', dy: -20, fill: muted, fontSize: 11,
3075
+ text: function () { return 'all models ' + Math.round(pooled * 100) + '%'; }}),
3076
+ Plot.ruleY(R, {y: 'model', x1: 'lo', x2: 'hi', stroke: 'verdict', strokeWidth: 2, strokeOpacity: 0.55}),
3077
+ Plot.dot(R, {x: 'p', y: 'model', r: 'judged', fill: 'verdict', stroke: halo, strokeWidth: 1.5,
3078
+ title: function (r) {
3079
+ return r.model + '\nhelped ' + r.useful + ' of ' + r.judged + ' judged (' + Math.round(r.p * 100) + '%; '
3080
+ + r.offloads + ' offloads)\nlikely between ' + Math.round(r.lo * 100) + '% and ' + Math.round(r.hi * 100) + '%'
3081
+ + (r.real || r.false ? '\nverified findings ' + r.real + ', false claims ' + r.false : '');
3082
+ }}),
3083
+ Plot.text(R, {x: 'hi', y: 'model', dx: 8, textAnchor: 'start', fill: ink, fontSize: 11,
3084
+ text: function (r) { return r.useful + '/' + r.judged; }})
2942
3085
  ]
2943
3086
  });
2944
3087
  });
2945
3088
 
2946
- // Did it answer: each probe of the last 14 days, by what came back.
2947
- put('chart-reliability', function (w) {
2948
- if (!C.reliability.length) return el('p', {class: 'hint'}, 'No probes in the last 14 days.');
2949
- var total = {}, ok = {};
2950
- C.reliability.forEach(function (d) {
2951
- total[d.model] = (total[d.model] || 0) + d.count;
2952
- if (d.outcome === 'answered') ok[d.model] = d.count;
2953
- });
2954
- var models = Object.keys(total).sort(function (a, b) { return (ok[b] || 0) / total[b] - (ok[a] || 0) / total[a]; });
2955
- var colour = {answered: good, capacity: signal, timeout: sky, structural: err, paywalled: warn,
2956
- entitlement: '#c0563f', other: muted, error: ink};
2957
- var order = Object.keys(colour).filter(function (o) { return C.reliability.some(function (d) { return d.outcome === o; }); });
2958
- return Plot.plot({
2959
- width: w, height: rows(models.length), style: style, marginLeft: 250,
2960
- x: {label: 'Share of probes →', tickFormat: '.0%', grid: true},
2961
- y: {domain: models, label: null},
2962
- color: {legend: true, domain: order, range: order.map(function (o) { return colour[o]; })},
2963
- marks: [Plot.barX(C.reliability, Plot.stackX({x: 'count', y: 'model', fill: 'outcome', offset: 'normalize', order: order,
2964
- title: function (d) { return d.model + ': ' + d.outcome + ' ' + d.count + ' of ' + total[d.model] + ' probes'; }}))]
2965
- });
2966
- });
2967
-
2968
3089
  // Speed vs quality: median seconds on our own tasks against findings located.
2969
3090
  put('chart-speed', function (w) {
2970
3091
  if (!C.speed.length) return el('p', {class: 'hint'}, 'No suite timings yet.');
@@ -2975,14 +3096,20 @@
2975
3096
  var pts = C.speed.slice().sort(function (a, b) { return b.recall - a.recall; });
2976
3097
  var offs = spread(pts, function (d) { return (Math.log(d.seconds) - Math.log(lo)) / (Math.log(hi) - Math.log(lo)) * pxW; },
2977
3098
  function (d) { return (1 - d.recall) / (1 - y0) * pxH; });
2978
- var labels = pts.map(function (d, i) { return {model: d.model, seconds: d.seconds, ty: d.recall - offs[i] * (1 - y0) / pxH}; });
3099
+ 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}; })
3100
+ .filter(Boolean);
2979
3101
  return Plot.plot({
2980
3102
  width: w, height: 420, style: style, grid: true, inset: 16, marginRight: 50, marginTop: 30,
2981
3103
  x: {type: 'log', domain: [lo, hi], label: 'Median seconds per answer on our tasks →'},
2982
3104
  y: {domain: [y0, 1], label: '↑ Findings located', tickFormat: '.0%'},
2983
3105
  marks: [
3106
+ // The zone to be in, as on Who to trust: the faster half (on the log scale) and the
3107
+ // top quarter of findings. Outlined, so it reads on a dark ground too.
3108
+ 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',
3109
+ fill: good, fillOpacity: 0.12, stroke: good, strokeOpacity: 0.5, strokeDasharray: '4 3'}),
2984
3110
  Plot.dot(C.speed, {x: 'seconds', y: 'recall', r: 6, fill: sky, stroke: ink, strokeWidth: 0.5}),
2985
- Plot.text(labels, {x: 'seconds', y: 'ty', text: 'model', fontSize: 11, fill: ink}),
3111
+ Plot.text(labels, {x: 'seconds', y: 'ty', text: 'model', fontSize: 11, fill: ink,
3112
+ stroke: halo, strokeWidth: 3.5, strokeOpacity: 0.9, paintOrder: 'stroke'}),
2986
3113
  Plot.tip(C.speed, Plot.pointer({x: 'seconds', y: 'recall',
2987
3114
  title: function (d) { return d.model + '\nmedian ' + d.seconds.toFixed(0) + 's over ' + d.tasks + ' tasks\nfinds ' + Math.round(d.recall * 100) + '%'; }}))
2988
3115
  ]
@@ -2997,25 +3124,41 @@
2997
3124
  return C.families.indexOf(fam[a]) - C.families.indexOf(fam[b]) || (a < b ? -1 : 1);
2998
3125
  });
2999
3126
  tasks.forEach(function (t) { if (!(fam[t] in first)) first[fam[t]] = t; });
3000
- var label = {};
3001
- Object.keys(first).forEach(function (f) { label[first[f]] = f; });
3127
+ // Each group's name over its first task, on two staggered rows: side by side they
3128
+ // ran into one another wherever a group is only a few tasks wide.
3129
+ var heads = C.families.filter(function (f) { return f in first; })
3130
+ .map(function (f, i) { return {task: first[f], name: f, row: i % 2}; });
3002
3131
  // By total found, so a model that played three tasks does not top one that played forty.
3003
- var sum = {};
3004
- C.heat.forEach(function (d) { sum[d.model] = (sum[d.model] || 0) + (d.rate || 0); });
3005
- var models = Object.keys(sum).sort(function (a, b) { return sum[b] - sum[a]; });
3132
+ // A model with no scored task (only grey) gets no row: it is named under the chart.
3133
+ var sum = {}, scored = {};
3134
+ C.heat.forEach(function (d) {
3135
+ sum[d.model] = (sum[d.model] || 0) + (d.rate || 0);
3136
+ if (d.rate != null) scored[d.model] = true;
3137
+ });
3138
+ var models = Object.keys(sum).filter(function (m) { return scored[m]; }).sort(function (a, b) { return sum[b] - sum[a]; });
3139
+ var idle = Object.keys(sum).filter(function (m) { return !scored[m]; }).sort();
3140
+ var shown = C.heat.filter(function (d) { return scored[d.model]; });
3141
+ var nameW = w < 560 ? 130 : 230;
3006
3142
  function tip(d) { return d.model + '\n' + d.task + ' (' + d.family + ')\n' + d.label + (d.invented ? '\ninvented something' : ''); }
3007
- return Plot.plot({
3008
- width: w, height: 22 * models.length + 70, style: style, marginLeft: 230, marginTop: 30, padding: 0.08,
3009
- x: {domain: tasks, axis: 'top', label: null, tickSize: 0, tickFormat: function (t) { return label[t] || ''; }},
3010
- y: {domain: models, label: null, tickSize: 0},
3143
+ var box = el('div');
3144
+ box.appendChild(Plot.plot({
3145
+ width: w, height: 22 * models.length + 84, style: style, marginLeft: nameW, marginTop: 44, padding: 0.08,
3146
+ x: {domain: tasks, axis: null},
3147
+ y: {domain: models, label: null, tickSize: 0, tickFormat: function (m) { return fit(m, w < 560 ? 16 : 30); }},
3011
3148
  color: {type: 'linear', domain: [0, 0.5, 1], range: [err, signal, good]},
3012
3149
  marks: [
3013
- Plot.cell(C.heat.filter(function (d) { return d.rate == null; }), {x: 'task', y: 'model', fill: line, title: tip}),
3014
- Plot.cell(C.heat.filter(function (d) { return d.rate != null; }), {x: 'task', y: 'model', fill: 'rate', title: tip}),
3015
- Plot.dot(C.heat.filter(function (d) { return d.invented; }), {x: 'task', y: 'model', r: 2.4, fill: ink}),
3016
- Plot.ruleX(Object.keys(label), {x: function (t) { return t; }, stroke: ink, strokeOpacity: 0.25, dx: -2})
3150
+ Plot.cell(shown.filter(function (d) { return d.rate == null; }), {x: 'task', y: 'model', fill: line, title: tip}),
3151
+ Plot.cell(shown.filter(function (d) { return d.rate != null; }), {x: 'task', y: 'model', fill: 'rate', title: tip}),
3152
+ Plot.dot(shown.filter(function (d) { return d.invented; }), {x: 'task', y: 'model', r: 2.4, fill: ink}),
3153
+ Plot.ruleX(heads, {x: 'task', stroke: ink, strokeOpacity: 0.25, dx: -2}),
3154
+ Plot.text(heads.filter(function (h) { return !h.row; }), {x: 'task', frameAnchor: 'top', dy: -30, dx: -2,
3155
+ text: 'name', textAnchor: 'start', fontSize: 11, fill: ink}),
3156
+ Plot.text(heads.filter(function (h) { return h.row; }), {x: 'task', frameAnchor: 'top', dy: -14, dx: -2,
3157
+ text: 'name', textAnchor: 'start', fontSize: 11, fill: ink})
3017
3158
  ]
3018
- });
3159
+ }));
3160
+ if (idle.length) box.appendChild(el('p', {class: 'hint'}, 'No scored task yet, so no row: ' + idle.join(', ') + '.'));
3161
+ return box;
3019
3162
  });
3020
3163
 
3021
3164
  // Vision tasks: one square per field and model, the task's name over its first field.
@@ -3062,39 +3205,66 @@
3062
3205
  kinds.forEach(function (k) { if (W[m][k]) data.push({model: m, kind: k, count: W[m][k]}); total[m] = (total[m] || 0) + W[m][k]; });
3063
3206
  });
3064
3207
  if (!data.length) return el('p', {class: 'hint'}, 'No vision panels through the gateway yet.');
3065
- // The models doing the most image work first: their bars are the evidence.
3066
- var models = Object.keys(total).sort(function (a, b) { return total[b] - total[a] || (a < b ? -1 : 1); });
3208
+ // The models doing the most image work first: their bars are the evidence. Under FEW
3209
+ // seats a row was mostly "no answer" and said little: those are listed under the chart.
3210
+ var all = Object.keys(total).sort(function (a, b) { return total[b] - total[a] || (a < b ? -1 : 1); });
3211
+ var models = all.filter(function (m) { return total[m] >= FEW; });
3212
+ var few = all.filter(function (m) { return total[m] < FEW; });
3213
+ if (!models.length) { models = all; few = []; }
3214
+ data = data.filter(function (d) { return models.indexOf(d.model) !== -1; });
3067
3215
  var colour = {'checked, helped': good, 'checked, did not help': err, 'useful, no claims': sky,
3068
3216
  'no use, no claims': warn, 'not judged': line, 'no answer': muted};
3069
3217
  // The claims checked by hand, beside the bar: the only right-or-wrong count real work has.
3070
3218
  var claims = models.filter(function (m) { return W[m].real + W[m].false; }).map(function (m) {
3071
3219
  return {model: m, text: W[m].real + ' of ' + (W[m].real + W[m].false) + ' claims right'};
3072
3220
  });
3073
- return Plot.plot({
3074
- width: w, height: rows(models.length), style: style, marginLeft: 300, marginRight: 150,
3221
+ var narrowV = w < 560, box = el('div');
3222
+ box.appendChild(Plot.plot({
3223
+ width: w, height: rows(models.length), style: style, marginLeft: narrowV ? 150 : 260, marginRight: narrowV ? 20 : 150,
3075
3224
  x: {label: 'Share of its seats on image panels →', tickFormat: '.0%', grid: true, domain: [0, 1]},
3076
- y: {domain: models, label: null, tickFormat: function (m) { return m + ' (' + total[m] + ')'; }},
3225
+ y: {domain: models, label: null, tickFormat: function (m) { return fit(m, narrowV ? 16 : 30) + ' (' + total[m] + ')'; }},
3077
3226
  color: {legend: true, domain: kinds, range: kinds.map(function (k) { return colour[k]; })},
3078
3227
  marks: [
3079
3228
  Plot.barX(data, Plot.stackX({x: 'count', y: 'model', fill: 'kind', offset: 'normalize', order: kinds,
3080
3229
  title: function (d) { return d.model + ': ' + d.kind + ', ' + d.count + ' of ' + total[d.model] + ' seats'; }})),
3081
- Plot.text(claims, {x: 1, y: 'model', text: 'text', dx: 8, textAnchor: 'start', fill: ink})
3230
+ Plot.text(narrowV ? [] : claims.filter(function (c) { return models.indexOf(c.model) !== -1; }),
3231
+ {x: 1, y: 'model', text: 'text', dx: 8, textAnchor: 'start', fill: ink})
3082
3232
  ]
3083
- });
3233
+ }));
3234
+ if (few.length) box.appendChild(el('p', {class: 'hint'}, 'Under ' + FEW + ' seats, so no bar: '
3235
+ + few.map(function (m) { return m + ' (' + total[m] + ')'; }).join(', ') + '. The table has them all.'));
3236
+ return box;
3084
3237
  });
3085
3238
 
3086
- // Confidently wrong: stacked by kind, worst at the top.
3239
+ // Confidently wrong: stacked by kind. A thin line runs over each bar for the range the
3240
+ // true share likely sits in (95% Wilson), so 0 of 2 shows how bad it could be, not a
3241
+ // clean record. Worst proven at the top: by the low end of that range. Few answers: faint.
3087
3242
  put('chart-wrong', function (w) {
3088
- var T = C.wrongTotals;
3089
- var models = Object.keys(T).sort(function (a, b) { return T[b].rate - T[a].rate || T[b].answers - T[a].answers; });
3243
+ var T = C.wrongTotals, R = {};
3244
+ Object.keys(T).forEach(function (m) {
3245
+ var b = wilson(T[m].wrong, T[m].answers);
3246
+ R[m] = {model: m, lo: b[0], hi: b[1], few: T[m].answers < FEW};
3247
+ });
3248
+ var models = Object.keys(T).sort(function (a, b) {
3249
+ return R[b].lo - R[a].lo || T[b].rate - T[a].rate || T[b].answers - T[a].answers;
3250
+ });
3251
+ var ranges = models.map(function (m) { return R[m]; });
3252
+ var top = Math.min(1, Math.max.apply(null, ranges.map(function (r) { return r.hi; })) + 0.02);
3090
3253
  return Plot.plot({
3091
- width: w, height: 20 * models.length + 70, style: style, marginLeft: 250,
3092
- x: {label: 'Confidently wrong, share of answers that could have been →', tickFormat: '.0%', grid: true},
3254
+ width: w, height: 20 * models.length + 70, style: style, marginLeft: w < 560 ? 150 : 250,
3255
+ x: {domain: [0, top], label: 'Confidently wrong, share of answers that could have been →', tickFormat: '.0%', grid: true},
3093
3256
  // The count beside each name: 1 of 2 is not the same evidence as 17 of 26.
3094
- y: {domain: models, label: null, tickFormat: function (m) { return m + ' (' + T[m].answers + ')'; }},
3257
+ y: {domain: models, label: null, tickFormat: function (m) { return fit(m, w < 560 ? 16 : 28) + ' (' + T[m].answers + ')'; }},
3095
3258
  color: {legend: true, domain: C.kinds, range: [err, signal, sky, muted]},
3096
3259
  marks: [
3097
- Plot.barX(C.wrong, {x: 'rate', y: 'model', fill: 'kind', title: function (d) { return d.model + ': ' + d.kind + ' ' + d.n; }}),
3260
+ Plot.barX(C.wrong, {x: 'rate', y: 'model', fill: 'kind', insetTop: 3, insetBottom: 3,
3261
+ fillOpacity: function (d) { return R[d.model].few ? 0.4 : 1; },
3262
+ title: function (d) { return d.model + ': ' + d.kind + ' ' + d.n; }}),
3263
+ Plot.ruleY(ranges, {y: 'model', x1: 'lo', x2: 'hi', stroke: ink, strokeOpacity: 0.45, strokeWidth: 1.5,
3264
+ title: function (r) {
3265
+ return r.model + ': ' + T[r.model].wrong + ' of ' + T[r.model].answers + ' confidently wrong\nlikely between '
3266
+ + Math.round(r.lo * 100) + '% and ' + Math.round(r.hi * 100) + '%' + (r.few ? '\nfew answers: little to go on' : '');
3267
+ }}),
3098
3268
  Plot.ruleX([0], {stroke: ink})
3099
3269
  ]
3100
3270
  });