@wendellresearch-ai/benchwarmers 0.3.3 → 0.4.0

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.
Files changed (38) hide show
  1. package/README.md +16 -1
  2. package/USAGE.md +8 -1
  3. package/VERSION +1 -1
  4. package/cli.py +4 -1
  5. package/metrics/aliases.py +24 -3
  6. package/metrics/endpoints.py +36 -4
  7. package/metrics/model_roles.py +3 -1
  8. package/metrics/route.py +3 -2
  9. package/metrics/wrong.py +3 -1
  10. package/package.json +1 -1
  11. package/scripts/check.py +2 -0
  12. package/scripts/onboard.py +12 -1
  13. package/scripts/test_no_owui.py +5 -2
  14. package/scripts/test_serve_edit.py +4 -4
  15. package/scripts/test_server_json.py +22 -2
  16. package/serve.py +286 -29
  17. package/suite/README.md +33 -0
  18. package/suite/pace.py +8 -4
  19. package/suite/run.py +144 -16
  20. package/suite/tasks/vision-bars/fixtures/bars.png +0 -0
  21. package/suite/tasks/vision-bars/task.json +40 -0
  22. package/suite/tasks/vision-invoice/fixtures/invoice.png +0 -0
  23. package/suite/tasks/vision-invoice/task.json +40 -0
  24. package/suite/tasks/vision-label/fixtures/label.png +0 -0
  25. package/suite/tasks/vision-label/task.json +52 -0
  26. package/suite/tasks/vision-receipt/fixtures/receipt.png +0 -0
  27. package/suite/tasks/vision-receipt/task.json +44 -0
  28. package/suite/tasks/vision-screen/fixtures/screen.png +0 -0
  29. package/suite/tasks/vision-screen/task.json +48 -0
  30. package/suite/tasks/vision-shapes/fixtures/shapes.png +0 -0
  31. package/suite/tasks/vision-shapes/task.json +40 -0
  32. package/suite/tasks/vision-table/fixtures/table.png +0 -0
  33. package/suite/tasks/vision-table/task.json +40 -0
  34. package/suite/test_vision_tasks.py +201 -0
  35. package/suite/vision_keys.py +74 -0
  36. package/suite/vision_sample.py +232 -0
  37. package/suite/vision_tasks.py +338 -0
  38. package/ui/bench.js +87 -10
package/serve.py CHANGED
@@ -43,6 +43,7 @@ import re
43
43
  import subprocess
44
44
  import sys
45
45
  import threading
46
+ import time
46
47
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
47
48
 
48
49
  ROOT = pathlib.Path(__file__).resolve().parent
@@ -54,6 +55,7 @@ PORT = int(os.environ.get("BENCH_PORT") or 8122)
54
55
  # Once, at import: roster() used to insert two entries on every page render.
55
56
  for _d in ("metrics", "suite", "scripts"):
56
57
  sys.path.insert(0, str(ROOT / _d))
58
+ import aliases # noqa: E402
57
59
  from aliases import current as current_alias # noqa: E402
58
60
  import connect # noqa: E402
59
61
  import gateway # noqa: E402 -- its routes are answered on this port too (one port)
@@ -164,15 +166,25 @@ def suite_scores() -> dict:
164
166
  The notes are the record, so they are also the source here rather than a
165
167
  parallel database that could disagree with them. Later files win, which is
166
168
  what makes a re-run show up.
169
+
170
+ Not a vision or image model's (suite/run.images_only): the suite is text
171
+ and code, which it is never seated for, and the answers it gave before it
172
+ had that role stay in the notes, uncounted. Real work, perf and reviews
173
+ still count it. Nor the vision tasks (vision-*): the Vision panel reads
174
+ those (vision_rows).
167
175
  """
176
+ from run import images_only
168
177
  row = re.compile(r"^\|\s*([\w-]+)\s*\|\s*`([^`]+)`\s*\|\s*([^|]+?)\s*\|"
169
178
  r"\s*([^|]*?)\s*\|\s*([\d.]+)\s*\|")
170
179
  scores: dict[tuple[str, str], dict] = {}
180
+ skip = images_only()
171
181
  for path in sorted(DATA.glob("runs/suite-*.md")):
172
182
  for line in read(path).splitlines():
173
183
  if (m := row.match(line.strip())):
174
184
  task, model, found, invented, seconds = m.groups()
175
185
  model = current_alias(model)
186
+ if model in skip or task.startswith("vision-"):
187
+ continue
176
188
  scores[(task, model)] = {
177
189
  "found": found, "invented": invented.strip(" —-"),
178
190
  "seconds": float(seconds)}
@@ -444,6 +456,7 @@ def change_model(alias: str, new_alias: str | None, new_role: str | None,
444
456
  history = load_json(HISTORY_PATH)
445
457
  history[alias] = new_alias
446
458
  _write_atomic(HISTORY_PATH, _dump(history))
459
+ aliases.forget() # its one-second cache, so the next read has the rename
447
460
  changed.append("config/alias-history.json")
448
461
  # 3. configs keyed by alias
449
462
  roles = roles_config()
@@ -773,15 +786,28 @@ def restore_model(alias: str, who: str = "ui") -> dict:
773
786
  DISMISSED_PATH = DATA / "config" / "dismissed.json"
774
787
 
775
788
 
789
+ _modalities_index: tuple = (None, {})
790
+
791
+
776
792
  def _modelsdev_modalities() -> dict[str, dict]:
777
793
  """models.dev's cached modalities by comparable model name
778
- (sources.norm_model), read once per picker opening, not once per row."""
794
+ (sources.norm_model), not once per row, and built again only when the
795
+ daily fetch rewrites its copy (a second of every picker opening)."""
796
+ global _modalities_index
779
797
  import sources
798
+ try:
799
+ st = (sources.CACHE / "modelsdev.json").stat()
800
+ key = (st.st_mtime_ns, st.st_size)
801
+ except OSError:
802
+ key = None
803
+ if key and _modalities_index[0] == key:
804
+ return _modalities_index[1]
780
805
  out = {}
781
806
  for p in sources.cached("modelsdev").values():
782
807
  for k, m in ((p or {}).get("models") or {}).items():
783
808
  if (m.get("modalities") or {}).get("input"):
784
809
  out.setdefault(sources.norm_model(k), m["modalities"])
810
+ _modalities_index = (key, out)
785
811
  return out
786
812
 
787
813
 
@@ -797,7 +823,14 @@ def _modalities(entry: dict, served: str, index: dict) -> dict:
797
823
  return {"inputs": m.get("input") or [], "outputs": m.get("output") or []}
798
824
 
799
825
 
800
- def onboard_candidates() -> dict:
826
+ # What each endpoint answered, for LISTED_FOR seconds: name -> (when, its
827
+ # config, live, conns). Only the asking is kept; what is on the bench, retired
828
+ # or dismissed is taken off fresh on every opening.
829
+ LISTED_FOR = 300
830
+ _listed: dict[str, tuple] = {}
831
+
832
+
833
+ def onboard_candidates(fresh: bool = False) -> dict:
801
834
  """What every endpoint serves that the bench could onboard, for the picker.
802
835
 
803
836
  Served, minus registered, minus retired, minus dismissed (those come back
@@ -811,8 +844,13 @@ def onboard_candidates() -> dict:
811
844
  keys): provider-tags.json is written by that script on its schedule, so a
812
845
  model added to Open WebUI since was "unknown connection" until it ran.
813
846
  The file is the fallback when the hub's connections cannot be read.
847
+
848
+ An endpoint's answer is reused for LISTED_FOR seconds, unless `fresh` (the
849
+ picker's Refresh) or its entry in endpoints.json changed since; a failure
850
+ is never kept. Open WebUI alone can take ten seconds to list.
814
851
  """
815
- names = list(endpoints.load())
852
+ config = endpoints.load()
853
+ names = list(config)
816
854
  if not names:
817
855
  return {"ok": False, "errors": ["no endpoint yet: add one first (Endpoints, then Manage, then"
818
856
  " Add an endpoint), and what it serves is listed here"]}
@@ -827,10 +865,13 @@ def onboard_candidates() -> dict:
827
865
  from concurrent.futures import ThreadPoolExecutor
828
866
 
829
867
  def ask(name: str):
868
+ kept = _listed.get(name)
869
+ if not fresh and kept and kept[1] == config[name] and time.time() - kept[0] < LISTED_FOR:
870
+ return kept[2], kept[3], kept[0]
830
871
  try:
831
872
  live = endpoints.models(name)
832
873
  except Exception as exc: # noqa: BLE001
833
- return exc, []
874
+ return exc, [], 0
834
875
  conns = []
835
876
  if endpoints.hub(name) == "openwebui":
836
877
  import provider_tags
@@ -838,11 +879,14 @@ def onboard_candidates() -> dict:
838
879
  conns = provider_tags.connection_names(name)
839
880
  except Exception: # noqa: BLE001 -- the tags file answers instead
840
881
  pass
841
- return live, conns
882
+ _listed[name] = (time.time(), config[name], live, conns)
883
+ return live, conns, _listed[name][0]
842
884
  with ThreadPoolExecutor(max_workers=len(names)) as pool: # all at once, not in turn
843
885
  answers = dict(zip(names, pool.map(ask, names)))
886
+ # The oldest answer shown, so the picker can say how old its list is.
887
+ listed = min((at for _, _, at in answers.values() if at), default=time.time())
844
888
  mods = _modelsdev_modalities()
845
- for name, (live, conns) in answers.items():
889
+ for name, (live, conns, _) in answers.items():
846
890
  if isinstance(live, Exception):
847
891
  errors.append(f"cannot reach {name}: {live}")
848
892
  continue
@@ -869,7 +913,8 @@ def onboard_candidates() -> dict:
869
913
  (gone if key in dismissed else open_).append(row)
870
914
  if not open_ and not gone and errors:
871
915
  return {"ok": False, "errors": errors}
872
- return {"ok": True, "errors": errors, "candidates": open_, "dismissed": gone}
916
+ return {"ok": True, "errors": errors, "candidates": open_, "dismissed": gone,
917
+ "age": round(time.time() - listed)}
873
918
 
874
919
 
875
920
  def dismiss(served, undo: bool = False, who: str = "ui") -> dict:
@@ -1524,11 +1569,15 @@ def save_gateway_settings(max_calls, max_explorers, who: str = "ui") -> dict:
1524
1569
  # Quick: the probe only; the scheduled `suite --next` passes fill the suite in,
1525
1570
  # six tasks a run, so the same calls are spread out, not saved. Quick + ad
1526
1571
  # hoc: the probe only, and the bench never calls it on its own: the only one
1527
- # that saves calls, for a metered, capped or trial key.
1572
+ # that saves calls, for a metered, capped or trial key. Apart from the mode,
1573
+ # `vision` (the picker's Vision model box) gives it role vision as it is
1574
+ # registered: a seat only for requests that carry images, and its suite is the
1575
+ # vision tasks alone (suite/run.applies), now or through the scheduled passes.
1528
1576
  ONBOARD_MODES = {"full": [], "quick": ["--skip-suite"], "quick-adhoc": ["--skip-suite", "--ad-hoc"]}
1529
1577
 
1530
1578
 
1531
- def start_onboard(served: str, endpoint: str = "", who: str = "ui", mode: str = "full") -> dict:
1579
+ def start_onboard(served: str, endpoint: str = "", who: str = "ui", mode: str = "full",
1580
+ vision: bool = False) -> dict:
1532
1581
  """Kick off `scripts/onboard.py` for a served model, detached, logging to runs/.
1533
1582
 
1534
1583
  The script does its own checks in the right order (is it served, derive
@@ -1538,7 +1587,7 @@ def start_onboard(served: str, endpoint: str = "", who: str = "ui", mode: str =
1538
1587
  hands the rest to the script. It runs for minutes and spends calls, so it
1539
1588
  runs in the background and the page tails its log.
1540
1589
  """
1541
- return _spawn_onboard(served, endpoint, who, mode)[0]
1590
+ return _spawn_onboard(served, endpoint, who, mode, vision)[0]
1542
1591
 
1543
1592
 
1544
1593
  def _onboard_refusal(served: str, endpoint: str, mode: str = "full") -> list[str]:
@@ -1567,7 +1616,7 @@ def _onboard_refusal(served: str, endpoint: str, mode: str = "full") -> list[str
1567
1616
 
1568
1617
 
1569
1618
  def _spawn_onboard(served: str, endpoint: str = "", who: str = "ui",
1570
- mode: str = "full") -> tuple[dict, subprocess.Popen | None]:
1619
+ mode: str = "full", vision: bool = False) -> tuple[dict, subprocess.Popen | None]:
1571
1620
  """start_onboard's result, and the process (None when refused) for a caller that waits on it."""
1572
1621
  served = (served or "").strip()
1573
1622
  endpoint = (endpoint or "").strip() or endpoints.default()
@@ -1579,7 +1628,7 @@ def _spawn_onboard(served: str, endpoint: str = "", who: str = "ui",
1579
1628
  log = DATA / "runs" / f"onboard-{stamp}-{safe}.log"
1580
1629
  from run import bench_python
1581
1630
  cmd = [bench_python(), str(ROOT / "scripts" / "onboard.py"), served, "--endpoint", endpoint,
1582
- *ONBOARD_MODES[mode]]
1631
+ *ONBOARD_MODES[mode], *(["--vision"] if vision else [])]
1583
1632
  # The log handle goes straight to the child, unbuffered, so the page sees
1584
1633
  # each step as it prints. The old `cmd /c ... > log & echo` wrapper with
1585
1634
  # DETACHED_PROCESS sent the output to a console window instead, so every
@@ -1648,19 +1697,19 @@ def queue_onboard(items, who: str = "ui") -> dict:
1648
1697
  endpoint = str(it.get("endpoint") or "").strip() or endpoints.default()
1649
1698
  mode = str(it.get("mode") or "full")
1650
1699
  errors += _onboard_refusal(served, endpoint, mode)
1651
- picked.append((served, endpoint, mode))
1700
+ picked.append((served, endpoint, mode, it.get("vision") is True))
1652
1701
  with _OQ_LOCK:
1653
1702
  waiting = {(i["served"], i["endpoint"]) for i in _OQ["items"] if i["state"] in ("queued", "running")}
1654
- errors += [f"{s} from {e} is already in the queue" for s, e, _ in picked if (s, e) in waiting]
1655
- if len({(s, e) for s, e, _ in picked}) < len(picked):
1703
+ errors += [f"{s} from {e} is already in the queue" for s, e, *_ in picked if (s, e) in waiting]
1704
+ if len({(s, e) for s, e, *_ in picked}) < len(picked):
1656
1705
  errors.append("a model is picked twice")
1657
1706
  if errors:
1658
1707
  return {"ok": False, "errors": errors}
1659
1708
  if _OQ["worker"] is None:
1660
1709
  _OQ["items"] = []
1661
1710
  _OQ["stop"] = False # added after a Stop: these run
1662
- _OQ["items"] += [{"served": s, "endpoint": e, "mode": m, "state": "queued", "alias": None, "log": None,
1663
- "exit": None, "note": ""} for s, e, m in picked]
1711
+ _OQ["items"] += [{"served": s, "endpoint": e, "mode": m, "vision": v, "state": "queued", "alias": None,
1712
+ "log": None, "exit": None, "note": ""} for s, e, m, v in picked]
1664
1713
  if _OQ["worker"] is None:
1665
1714
  _OQ["worker"] = threading.Thread(target=_onboard_worker, args=(who,), daemon=True)
1666
1715
  _OQ["worker"].start()
@@ -1712,7 +1761,8 @@ def _onboard_worker(who: str) -> None:
1712
1761
  return
1713
1762
  nxt["state"] = "running"
1714
1763
  try:
1715
- res, proc = _spawn_onboard(nxt["served"], nxt["endpoint"], who, nxt.get("mode", "full"))
1764
+ res, proc = _spawn_onboard(nxt["served"], nxt["endpoint"], who, nxt.get("mode", "full"),
1765
+ nxt.get("vision", False))
1716
1766
  if proc is None: # refused now: the bench changed since it was queued
1717
1767
  state, note, code = "failed", "; ".join(res["errors"]), None
1718
1768
  else:
@@ -2174,13 +2224,34 @@ def _family(task: str) -> str:
2174
2224
  return "hand-built"
2175
2225
 
2176
2226
 
2227
+ _wrong: tuple = (0.0, {})
2228
+
2229
+
2230
+ def wrong_table() -> dict:
2231
+ """wrong.table(), shared by the three cards of one render: it scores every
2232
+ suite answer, and asking three times was a second of each reload."""
2233
+ global _wrong
2234
+ import wrong
2235
+ from run import images_only
2236
+ seen = _wrong
2237
+ if time.monotonic() - seen[0] > 5:
2238
+ table = wrong.table()
2239
+ # A vision or image model keeps its reviews (real work, judged) but not
2240
+ # its code-task answers: the suite is not what it is for (suite_scores).
2241
+ for alias in images_only() & set(table):
2242
+ r = table[alias]
2243
+ r.update(answers=0, wrong=0, by={k: [0, 0] for k in r["by"]}, hunt=dict.fromkeys(r["hunt"], 0))
2244
+ seen = _wrong = (time.monotonic(), table) # stamped once scored, not before
2245
+ return seen[1]
2246
+
2247
+
2177
2248
  def charts_data() -> dict:
2178
2249
  """Everything the Benchmarks charts draw, for bench.js. Registered models
2179
2250
  only: the tables keep retired ones behind "Show retired", a chart has no toggle."""
2180
2251
  import route
2181
2252
  import wrong
2182
2253
  live = on_bench()
2183
- recall, wr = route.suite_recall(), wrong.table()
2254
+ recall, wr = route.suite_recall(), wrong_table()
2184
2255
  overview = [{"model": m, "recall": recall[m][0] / recall[m][1], "wrong": wr[m]["wrong"] / wr[m]["answers"],
2185
2256
  "found": f"{recall[m][0]}/{recall[m][1]}", "wrongN": f"{wr[m]['wrong']}/{wr[m]['answers']}"}
2186
2257
  for m in sorted(live) if recall.get(m) and recall[m][1] and wr.get(m, {}).get("answers")]
@@ -2198,7 +2269,7 @@ def charts_data() -> dict:
2198
2269
  return {"overview": overview, "heat": heat, "families": ["hand-built", "claims", "seeded, function",
2199
2270
  "seeded, whole file", "real bugs"],
2200
2271
  "wrong": bars, "wrongTotals": totals, "kinds": list(wrong.KINDS), "hunt": hunt,
2201
- "work": work_rows(), **perf_charts()}
2272
+ "work": work_rows(), "vision": vision_rows(), **perf_charts()}
2202
2273
 
2203
2274
 
2204
2275
  def work_rows() -> list[dict]:
@@ -2215,6 +2286,184 @@ def work_rows() -> list[dict]:
2215
2286
  for m, s in sorted(summary().items()) if m in live and s["judged"]]
2216
2287
 
2217
2288
 
2289
+ VISION_STATES = ("right", "misread", "left out", "trap taken", "no answer")
2290
+ VISION_SEATS = ("checked, helped", "checked, did not help", "useful, no claims", "no use, no claims",
2291
+ "not judged", "no answer")
2292
+ _vision_work: tuple = (None, {})
2293
+
2294
+
2295
+ def vision_rows() -> dict:
2296
+ """The Vision panel's evidence, in two kinds kept apart.
2297
+
2298
+ TASKS: the vision tasks' saved answers (suite/tasks/vision-*), scored by
2299
+ the suite's own scorer and then field by field: right, misread (the line
2300
+ is there with another value), left out, trap taken (a value for something
2301
+ the picture does not show, or a switch that is off called on), no answer.
2302
+ The keys exist by construction (suite/vision_tasks.py), so this is the
2303
+ controlled comparison.
2304
+
2305
+ REAL WORK: every seat of the gateway's vision panels (runs/gateway/
2306
+ calls.jsonl, shape vision) and how it was judged (metrics/offloads.jsonl).
2307
+ A verdict that lists claims was checked claim by claim against the
2308
+ picture; one without claims says only useful or not, which for thicken's
2309
+ face corners means it agreed with another seat. Agreement is not a check
2310
+ (the bench's own rule: seats fail together), so the two are never added.
2311
+ """
2312
+ import run
2313
+ live = on_bench()
2314
+ heat, tasks = [], {}
2315
+ for path in sorted((DATA / "suite" / "tasks").glob("vision-*/task.json")):
2316
+ spec = json.loads(read(path))
2317
+ if spec.get("draft"):
2318
+ continue
2319
+ names = list(dict.fromkeys(c["label"].split(":")[0] for c in spec["must_find"]))
2320
+ traps = {c["label"].split(":")[0] for c in spec.get("must_not_claim", [])}
2321
+ tasks[spec["id"]] = {"title": spec["title"], "fields": names, "family": _vision_family(spec)}
2322
+ for answer in sorted((path.parent / "output").glob("*.md")):
2323
+ alias = current_alias(answer.stem)
2324
+ if alias not in live:
2325
+ continue
2326
+ text = read(answer)
2327
+ s = run.score(spec, text)
2328
+ missed = {lbl.split(":")[0] for lbl in s["missed"]}
2329
+ invented = {lbl.split(":")[0] for lbl in s["invented"]}
2330
+ for name in names:
2331
+ there = re.search(rf"(?m)^[^\w\n]*{re.escape(name).replace('_', '[ _]')}[^\w\n]*:", text.lower())
2332
+ state = ("no answer" if s["kind"] != "answered" else "trap taken" if name in invented
2333
+ else "right" if name not in missed else "misread" if there else "left out")
2334
+ heat.append({"model": alias, "task": spec["id"], "field": name, "key": f"{spec['id']}·{name}",
2335
+ "state": state, "trap": name in traps})
2336
+ models: dict[str, dict] = {}
2337
+ for h in heat:
2338
+ m = models.setdefault(h["model"], {"asked": set(), "answered": set(), "fields": 0, "right": 0,
2339
+ "traps": 0, "taken": 0})
2340
+ m["asked"].add(h["task"])
2341
+ if h["state"] != "no answer":
2342
+ m["answered"].add(h["task"])
2343
+ m["fields"] += 1
2344
+ m["right"] += h["state"] == "right"
2345
+ m["traps"] += h["trap"]
2346
+ m["taken"] += h["state"] == "trap taken"
2347
+ summary = {a: {"asked": len(m["asked"]), "answered": len(m["answered"]), "fields": m["fields"],
2348
+ "right": m["right"], "traps": m["traps"], "taken": m["taken"]} for a, m in models.items()}
2349
+ return {"tasks": tasks, "heat": heat, "summary": summary, "states": list(VISION_STATES),
2350
+ "seatKinds": list(VISION_SEATS), "work": _vision_work_rows(live), "held": run.vision_held()}
2351
+
2352
+
2353
+ VISION_FAMILIES = (("vision-cord-", "receipts"), ("vision-screenqa-", "screens"), ("vision-tablevqa-", "tables"),
2354
+ ("vision-pope-", "objects"))
2355
+
2356
+
2357
+ def _vision_family(spec: dict) -> str:
2358
+ """The grid's group: a public source (suite/vision_sample.py), the made
2359
+ pictures (suite/vision_tasks.py), or the install's own (written by hand)."""
2360
+ if spec.get("made_by", "").startswith("suite/vision_tasks.py"):
2361
+ return "made"
2362
+ return next((name for prefix, name in VISION_FAMILIES if spec["id"].startswith(prefix)), "own")
2363
+
2364
+
2365
+ def _vision_work_rows(live: set[str]) -> dict:
2366
+ """alias -> {seat kind: count, claims right, claims wrong, seconds}; read
2367
+ again only when the gateway's log or the verdicts change (5 MB a render)."""
2368
+ global _vision_work
2369
+ calls, verdicts = DATA / "runs" / "gateway" / "calls.jsonl", DATA / "metrics" / "offloads.jsonl"
2370
+ try:
2371
+ key = tuple((p.stat().st_mtime_ns, p.stat().st_size) for p in (calls, verdicts)) + (frozenset(live),)
2372
+ except OSError:
2373
+ return {}
2374
+ if _vision_work[0] == key:
2375
+ return _vision_work[1]
2376
+ from offload import entries
2377
+ judged = {e["call"]: e for e in entries() if e.get("call") and e.get("useful") is not None}
2378
+ out: dict[str, dict] = {}
2379
+ for raw in read(calls).splitlines():
2380
+ try:
2381
+ panel = json.loads(raw)
2382
+ except ValueError:
2383
+ continue
2384
+ if panel.get("shape") != "vision":
2385
+ continue
2386
+ for s in panel.get("seats", []):
2387
+ alias = current_alias(s.get("alias", ""))
2388
+ if alias not in live:
2389
+ continue
2390
+ r = out.setdefault(alias, {**dict.fromkeys(VISION_SEATS, 0), "real": 0, "false": 0, "seconds": []})
2391
+ v = judged.get(s.get("call"))
2392
+ if s.get("status") != "ok" or not (s.get("answer") or "").strip():
2393
+ kind = "no answer"
2394
+ elif v is None:
2395
+ kind = "not judged"
2396
+ elif v.get("real") or v.get("false"):
2397
+ kind = "checked, helped" if v["useful"] else "checked, did not help"
2398
+ r["real"] += len(v.get("real") or [])
2399
+ r["false"] += len(v.get("false") or [])
2400
+ else:
2401
+ kind = "useful, no claims" if v["useful"] else "no use, no claims"
2402
+ r[kind] += 1
2403
+ if kind != "no answer" and s.get("seconds"):
2404
+ r["seconds"].append(float(s["seconds"]))
2405
+ for r in out.values():
2406
+ secs = sorted(r.pop("seconds"))
2407
+ r["seconds"] = secs[len(secs) // 2] if secs else None
2408
+ _vision_work = (key, out)
2409
+ return out
2410
+
2411
+
2412
+ def vision_card() -> str:
2413
+ """Reading images: the vision tasks, then real work on images."""
2414
+ v = vision_rows()
2415
+ s, w = v["summary"], v["work"]
2416
+ body = ["<table><thead><tr>", th("Model"), th("Tasks answered", True), th("Fields read right", True),
2417
+ th("Traps taken", True), th("Image seats", True), th("Checked claims right", True),
2418
+ th("Checked claims wrong", True), th("Useful, no claims", True), th("Not judged", True),
2419
+ th("Median seconds", True), "</tr></thead><tbody>"]
2420
+
2421
+ def cell(n, d):
2422
+ return (f"<td class='num' data-sort='{n / d:.3f}'>{n}/{d}</td>" if d
2423
+ else "<td class='num muted' data-sort='-1'>—</td>")
2424
+ for m in sorted(set(s) | set(w)):
2425
+ t, r = s.get(m, {}), w.get(m, {})
2426
+ seats = sum(r.get(k, 0) for k in VISION_SEATS)
2427
+ claims = r.get("real", 0) + r.get("false", 0)
2428
+ body.append(f"{tr(m)}<td>{named(m)}</td>" + cell(t.get("answered", 0), t.get("asked", 0))
2429
+ + cell(t.get("right", 0), t.get("fields", 0)) + cell(t.get("taken", 0), t.get("traps", 0))
2430
+ + f"<td class='num'>{seats or '—'}</td>" + cell(r.get("real", 0), claims)
2431
+ + cell(r.get("false", 0), claims) + f"<td class='num'>{r.get('useful, no claims', 0) or '—'}</td>"
2432
+ + f"<td class='num'>{r.get('not judged', 0) or '—'}</td>"
2433
+ + (f"<td class='num'>{r['seconds']:.1f}</td>" if r.get("seconds") else "<td class='num muted'>—</td>")
2434
+ + "</tr>")
2435
+ body.append("</tbody></table>")
2436
+ key = """<dl>
2437
+ <dt>The vision tasks</dt><dd>Grouped by where the pictures come from: <b>made</b> with their answer
2438
+ key (<code>suite/vision_tasks.py</code>), your <b>own</b> photos keyed by hand, and items sampled
2439
+ from public datasets with people's answers (<code>suite/vision_sample.py</code>): photographed
2440
+ <b>receipts</b>, app <b>screens</b>, <b>tables</b>, and <b>objects</b> in photos. Each field is
2441
+ <b>right</b>, <b>misread</b> (another value), <b>left out</b>, or a <b>trap taken</b>: a value for
2442
+ something the picture does not show, where the right answer is <code>not shown</code> (a dot marks
2443
+ those fields). They run for every model that takes images; a vision model gets only these, never
2444
+ the code tasks.</dd>
2445
+ <dt>Real work on images</dt><dd>Every seat of a vision panel through the gateway. <b>Checked</b> means
2446
+ its verdict lists claims, each compared with the picture by hand: the claims right and wrong
2447
+ come from these alone. <b>Useful, no claims</b> is a verdict without them, which on thicken's
2448
+ photos means the seat agreed with another seat. Agreement is not a check, since seats fail
2449
+ together, so it is never counted as right.</dd>
2450
+ <dt>The number beside a name</dt><dd>How much there is: fields asked on the tasks, seats on real work.</dd>
2451
+ </dl>"""
2452
+ held = (f"<p class='hint'>The vision tasks wait until {esc(v['held'])}: no run picks them before then"
2453
+ " (<code>vision_from</code> in <code>config/suite.json</code>; remove it to start them now).</p>"
2454
+ if v["held"] else "")
2455
+ return ("<div class='panel'><h2>Vision</h2><p class='sub'>Reading images: fields read right on pictures with"
2456
+ " a known answer, then real work on images and how much of it was actually checked.</p>" + held +
2457
+ "<h3 class='chart-head'>On the vision tasks, field by field</h3><div class='chart' id='chart-vision-fields'></div>"
2458
+ "<h3 class='chart-head'>On real work with images, by how each seat was judged</h3>"
2459
+ "<div class='chart' id='chart-vision-work'></div>"
2460
+ "<details class='key'><summary>Table</summary><div class='toolbar'><input type='search'"
2461
+ " data-filter placeholder='Filter by model or connection' aria-label='Filter rows'>"
2462
+ "<label><input type='checkbox' data-retired> Show retired</label></div>"
2463
+ f"<div class='scroll'>{''.join(body)}</div></details>"
2464
+ f"<details class='key'><summary>How to read this</summary>{key}</details></div>")
2465
+
2466
+
2218
2467
  def perf_charts() -> dict:
2219
2468
  """Reliability over the last 14 days of probes, and speed on real tasks
2220
2469
  against how much a model finds, for the Performance panel of Benchmarks."""
@@ -2239,8 +2488,7 @@ def perf_charts() -> dict:
2239
2488
 
2240
2489
  def bughunt_card() -> str:
2241
2490
  """Seeded and real-bug tasks against their answer keys."""
2242
- import wrong
2243
- rows = {m: r["hunt"] for m, r in wrong.table().items() if m in current() and any(r["hunt"].values())}
2491
+ rows = {m: r["hunt"] for m, r in wrong_table().items() if m in current() and any(r["hunt"].values())}
2244
2492
  body = ["<table><thead><tr>", th("Model"), th("Found", True), th("Wrong line", True), th("Missed", True),
2245
2493
  th("Clean said clean", True), th("False alarm", True), "</tr></thead><tbody>"]
2246
2494
  for m in sorted(rows, key=lambda m: -rows[m]["found"]):
@@ -2360,8 +2608,9 @@ def external_card() -> str:
2360
2608
  def wrong_card() -> str:
2361
2609
  """metrics/wrong.py: how often each model asserts something false."""
2362
2610
  import wrong
2363
- rows = wrong.table()
2364
- live = current()
2611
+ from run import images_only
2612
+ rows = wrong_table()
2613
+ live, images = current(), images_only()
2365
2614
  body = ["<table><thead><tr>", th("Model"), th("Confidently wrong", True),
2366
2615
  *[th(k.capitalize(), True) for k in wrong.KINDS], th("Reviews", True), "</tr></thead><tbody>"]
2367
2616
 
@@ -2378,7 +2627,8 @@ def wrong_card() -> str:
2378
2627
  f" style='width:{max(4, round(rate * 100))}%'></span></span>"
2379
2628
  f"<span class='mono'>{r['wrong']}/{r['answers']}</span></span>")
2380
2629
  else:
2381
- rate, meter = -1, "<span class='muted'>no tasks yet</span>"
2630
+ rate, meter = -1, ("<span class='muted'>reads images: no code tasks</span>" if alias in images
2631
+ else "<span class='muted'>no tasks yet</span>")
2382
2632
  body.append(f"<tr><td>{named(alias)}</td><td data-sort='{rate:.3f}'>{meter}</td>"
2383
2633
  + "".join(cell(*r["by"][k]) for k in wrong.KINDS)
2384
2634
  + (f"<td class='num' data-sort='{r['review_false']}'>{r['review_false']} in {r['reviews']}</td>"
@@ -2404,7 +2654,8 @@ def wrong_card() -> str:
2404
2654
 
2405
2655
  def benchmarks_tab() -> str:
2406
2656
  data = json.dumps(charts_data(), ensure_ascii=False).replace("</", "<\\/")
2407
- return (overview_card() + bughunt_card() + work_card() + wrong_card() + suite_card() + perf_card() + external_card()
2657
+ return (overview_card() + bughunt_card() + work_card() + wrong_card() + suite_card() + vision_card()
2658
+ + perf_card() + external_card()
2408
2659
  + f"<script type='application/json' id='charts-data'>{data}</script>")
2409
2660
 
2410
2661
 
@@ -3620,6 +3871,9 @@ def editor() -> str:
3620
3871
  " with progress in the header. A model from somewhere else? Add its endpoint first.</p>"
3621
3872
  "<input type='search' id='o-find' placeholder='Search models or connections'"
3622
3873
  " aria-label='Search models'>"
3874
+ # How old the list is (serve.LISTED_FOR), and asking again now.
3875
+ "<p class='muted' id='o-age' hidden><span></span>"
3876
+ " <button class='linkish' type='button' id='o-refresh'>Refresh</button></p>"
3623
3877
  "<div class='picker' id='o-picker' role='group' aria-label='Served models'></div>"
3624
3878
  # serve.ONBOARD_MODES: how much each onboarding spends.
3625
3879
  "<label class='check'>Onboard as <select id='o-mode'>"
@@ -3627,6 +3881,8 @@ def editor() -> str:
3627
3881
  "<option value='quick'>Quick: probe now, the suite through the scheduled passes</option>"
3628
3882
  "<option value='quick-adhoc'>Quick + ad hoc: probe only; the bench never calls it on its own"
3629
3883
  " (metered, capped or trial keys)</option></select></label>"
3884
+ "<label class='check'><input type='checkbox' id='o-vision'> Vision model: a seat only for questions"
3885
+ " about images, and its suite is the vision tasks</label>"
3630
3886
  "</div>"
3631
3887
  "<div id='o-run' hidden><p class='served' id='o-result'></p><ol class='steps' id='o-steps'></ol>"
3632
3888
  "<details><summary class='muted'>Full log</summary><pre class='log' id='o-log'></pre></details></div>"
@@ -3761,8 +4017,8 @@ class Handler(gateway.Handler):
3761
4017
  log = parse_qs(urlparse(self.path).query).get("log", [""])[0]
3762
4018
  self._json(200, onboard_status(log))
3763
4019
  return
3764
- if self.path == "/api/candidates":
3765
- self._json(200, onboard_candidates())
4020
+ if self.path in ("/api/candidates", "/api/candidates?fresh=1"):
4021
+ self._json(200, onboard_candidates(fresh=self.path.endswith("fresh=1")))
3766
4022
  return
3767
4023
  if self.path == "/api/onboard-queue":
3768
4024
  self._json(200, onboard_queue())
@@ -3881,7 +4137,8 @@ class Handler(gateway.Handler):
3881
4137
  endpoint=str(p.get("endpoint") or "")),
3882
4138
  "/api/onboard": lambda p: start_onboard(str(p.get("served", "")),
3883
4139
  str(p.get("endpoint", "")),
3884
- mode=str(p.get("mode") or "full")),
4140
+ mode=str(p.get("mode") or "full"),
4141
+ vision=p.get("vision") is True),
3885
4142
  "/api/onboard-queue": lambda p: queue_onboard(p.get("items")),
3886
4143
  "/api/onboard-queue/stop": lambda p: stop_onboard_queue(),
3887
4144
  "/api/onboard-queue/clear": lambda p: clear_onboard_queue(),
package/suite/README.md CHANGED
@@ -107,6 +107,39 @@ The scorer ignores a `must_not_claim` phrase where it is negated in its own
107
107
  sentence ("there are no INSERT statements"), so ruling something out is never
108
108
  counted as claiming it.
109
109
 
110
+ ## Vision tasks
111
+
112
+ A fifth shape, added 2026-10-27: **reading images**. A task with `"shape":
113
+ "vision"` and `"images"` (files in its `fixtures/`) is sent with its pictures
114
+ as image parts, through the built-in client, to every model the catalog says
115
+ takes images; a vision model (role `vision`) gets only these, and a model that
116
+ cannot see never gets one (`run.applies`). The answer is `key: value` lines,
117
+ `not shown` for what the picture does not show, scored by
118
+ `suite/vision_keys.py`: a value for a field the picture cannot answer is an
119
+ invention, like a trap. They stay out of the routing recall and the code
120
+ reports; the Benchmarks page's Vision panel shows them field by field.
121
+
122
+ Three sources:
123
+
124
+ - **Made pictures** (`suite/vision_tasks.py`, needs Pillow to regenerate): a
125
+ receipt, a grading label, a bar chart, a table, a settings screen, shapes
126
+ and an invoice turned sideways, each key built from the data that drew it.
127
+ Shipped in `suite/tasks/vision-*` and copied into the data folder on the
128
+ next run (`run.seed_shipped`). Never seen in training; clean renders.
129
+ - **Public datasets** (`python suite/vision_sample.py --count 5`): photographed
130
+ receipts (CORD v2), app screens with an unanswerable question (RICO-ScreenQA),
131
+ tables (TableVQA-Bench) and absent-object questions (POPE), with the
132
+ datasets' human answers. Written into the data folder only, so each install
133
+ samples its own and nothing is redistributed.
134
+ - **Your own**: any task folder you write into the data folder's
135
+ `suite/tasks/vision-*` in the same form, from photos whose answers you have
136
+ checked. Real pictures of the real job, never in anyone's training data:
137
+ the strongest of the three.
138
+
139
+ To hold them back for a while (a hub being changed, say), put the day they may
140
+ start in the data folder's `config/suite.json`: `{"vision_from": "2026-10-30"}`.
141
+ Until then no run picks a vision task; `--task <id>` still runs one.
142
+
110
143
  ## Adding a task
111
144
 
112
145
  A task is one directory under `suite/tasks/` containing:
package/suite/pace.py CHANGED
@@ -72,10 +72,14 @@ def answered(task: str, model: str) -> bool:
72
72
 
73
73
 
74
74
  def queue(prefix: str, roster: list[str]) -> list[tuple[str, str]]:
75
- """(task, model) pairs without an answer yet, task by task across models."""
76
- tasks = sorted(p.parent.name for p in TASKS.glob(f"{prefix}*/task.json")
77
- if not json.loads(p.read_text(encoding="utf-8")).get("draft"))
78
- return [(t, m) for t in tasks for m in roster if not answered(t, m)]
75
+ """(task, model) pairs without an answer yet, task by task across models,
76
+ each only where the task is the model's (run.applies)."""
77
+ specs = [s for s in run.load_tasks(None) if s["id"].startswith(prefix)]
78
+ skip, seeing = run.images_only(), {}
79
+ if any(s.get("images") for s in specs):
80
+ seeing = {m: run.sees_images(m) for m in roster}
81
+ return [(s["id"], m) for s in specs for m in roster
82
+ if run.applies(s, m, skip, seeing) and not answered(s["id"], m)]
79
83
 
80
84
 
81
85
  def rate_limited(task: str, model: str) -> bool: