@wendellresearch-ai/benchwarmers 0.3.2 → 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.
- package/README.md +19 -4
- package/USAGE.md +8 -1
- package/VERSION +1 -1
- package/cli.py +4 -1
- package/metrics/aliases.py +24 -3
- package/metrics/endpoints.py +36 -4
- package/metrics/model_roles.py +3 -1
- package/metrics/route.py +3 -2
- package/metrics/wrong.py +3 -1
- package/package.json +1 -1
- package/scripts/check.py +2 -0
- package/scripts/onboard.py +12 -1
- package/scripts/test_no_owui.py +5 -2
- package/scripts/test_serve_edit.py +4 -4
- package/scripts/test_server_json.py +22 -2
- package/serve.py +286 -29
- package/suite/README.md +33 -0
- package/suite/pace.py +8 -4
- package/suite/run.py +144 -16
- package/suite/tasks/vision-bars/fixtures/bars.png +0 -0
- package/suite/tasks/vision-bars/task.json +40 -0
- package/suite/tasks/vision-invoice/fixtures/invoice.png +0 -0
- package/suite/tasks/vision-invoice/task.json +40 -0
- package/suite/tasks/vision-label/fixtures/label.png +0 -0
- package/suite/tasks/vision-label/task.json +52 -0
- package/suite/tasks/vision-receipt/fixtures/receipt.png +0 -0
- package/suite/tasks/vision-receipt/task.json +44 -0
- package/suite/tasks/vision-screen/fixtures/screen.png +0 -0
- package/suite/tasks/vision-screen/task.json +48 -0
- package/suite/tasks/vision-shapes/fixtures/shapes.png +0 -0
- package/suite/tasks/vision-shapes/task.json +40 -0
- package/suite/tasks/vision-table/fixtures/table.png +0 -0
- package/suite/tasks/vision-table/task.json +40 -0
- package/suite/test_vision_tasks.py +201 -0
- package/suite/vision_keys.py +74 -0
- package/suite/vision_sample.py +232 -0
- package/suite/vision_tasks.py +338 -0
- 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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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, "
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
2364
|
-
|
|
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
|
|
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() +
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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:
|