@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
@@ -0,0 +1,338 @@
1
+ """Make the vision tasks: pictures drawn here, each with its answer key.
2
+
3
+ python suite/vision_tasks.py # (re)writes suite/tasks/vision-*/
4
+
5
+ Needs Pillow, to draw (`pip install pillow`); nothing else does. The suite
6
+ reads the PNGs and task.json files this writes, which are committed, and
7
+ suite/run.py copies them into the data folder (run.seed_shipped).
8
+
9
+ THE KEY EXISTS BY CONSTRUCTION, as with suite/seed.py: every number and name
10
+ in a picture comes from the same data its task's answer key is built from, so
11
+ the two cannot disagree. The pictures are made, not photographed: nobody's
12
+ receipt, card or screen, so they ship with the public repository. What they
13
+ test is reading -- printed text, digits that must be exact, a chart against
14
+ its gridlines, a table cell by row and column, a screen's state, counting,
15
+ a page turned on its side -- the jobs vision seats are given.
16
+
17
+ ONE FIELD IN EACH ASKS FOR SOMETHING THE PICTURE DOES NOT SHOW. The prompt
18
+ says to write `not shown` then; a value is an invention, scored like a trap
19
+ in the text suite. Reading an image confidently wrong is the failure that
20
+ costs, the same as asserting a bug that is not there.
21
+
22
+ Answers are lines, `key: value`. Scoring is the suite's own (run.score):
23
+ regexes over the lowered answer, tolerant of markdown around the key.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import math
30
+ import pathlib
31
+
32
+ from PIL import Image, ImageDraw, ImageFont
33
+
34
+ from vision_keys import NOT_SHOWN, PROMPT, clause
35
+
36
+ HERE = pathlib.Path(__file__).resolve().parent
37
+ OUT = HERE / "tasks"
38
+
39
+
40
+ def font(size: int, kind: str = "sans"):
41
+ names = {"sans": ("arial.ttf", "DejaVuSans.ttf", "LiberationSans-Regular.ttf"),
42
+ "bold": ("arialbd.ttf", "DejaVuSans-Bold.ttf", "LiberationSans-Bold.ttf"),
43
+ "mono": ("consola.ttf", "DejaVuSansMono.ttf", "LiberationMono-Regular.ttf")}[kind]
44
+ for name in names:
45
+ try:
46
+ return ImageFont.truetype(name, size)
47
+ except OSError:
48
+ continue
49
+ return ImageFont.load_default(size)
50
+
51
+
52
+ def write(task_id: str, title: str, image: Image.Image, lines: list[str], find: list[dict],
53
+ not_claim: list[dict], why: str) -> None:
54
+ folder = OUT / task_id
55
+ (folder / "fixtures").mkdir(parents=True, exist_ok=True)
56
+ name = task_id.removeprefix("vision-") + ".png"
57
+ image.save(folder / "fixtures" / name, optimize=True)
58
+ spec = {"id": task_id, "title": title, "shape": "vision", "images": [name],
59
+ "prompt": PROMPT.format(lines="\n".join(lines)), "min_answer_chars": 20,
60
+ "must_find": find, "must_not_claim": not_claim, "why": why,
61
+ "made_by": "suite/vision_tasks.py"}
62
+ (folder / "task.json").write_text(json.dumps(spec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
63
+ print(f"wrote {folder.relative_to(HERE.parent)} ({len(find)} fields)")
64
+
65
+
66
+ # ---- a receipt: printed text, a date to rewrite, a sum to find ----------
67
+
68
+ def receipt() -> None:
69
+ items = [("Oat milk 1L", 3.49), ("Sourdough loaf", 5.25), ("Blueberries 250g", 4.99), ("Cheddar 200g", 6.80)]
70
+ subtotal = round(sum(p for _, p in items), 2)
71
+ tax = round(subtotal * 0.08, 2)
72
+ total = round(subtotal + tax, 2)
73
+ img = Image.new("RGB", (520, 760), "#f4f1ea")
74
+ d = ImageDraw.Draw(img)
75
+ mono, big = font(22, "mono"), font(30, "bold")
76
+ d.text((260, 50), "HARBOR LANE GROCERY", font=big, fill="#222", anchor="mm")
77
+ d.text((260, 92), "212 Quay Street", font=mono, fill="#333", anchor="mm")
78
+ d.text((260, 122), "Mar 14, 2026 17:42", font=mono, fill="#333", anchor="mm")
79
+ d.line((40, 160, 480, 160), fill="#888", width=2)
80
+ y = 190
81
+ for name, price in items:
82
+ d.text((50, y), name, font=mono, fill="#222")
83
+ d.text((470, y), f"{price:.2f}", font=mono, fill="#222", anchor="ra")
84
+ y += 44
85
+ d.line((40, y + 6, 480, y + 6), fill="#888", width=2)
86
+ y += 30
87
+ for label, value in (("SUBTOTAL", subtotal), ("TAX 8%", tax)):
88
+ d.text((50, y), label, font=mono, fill="#222")
89
+ d.text((470, y), f"{value:.2f}", font=mono, fill="#222", anchor="ra")
90
+ y += 40
91
+ d.text((50, y + 10), "TOTAL", font=big, fill="#111")
92
+ d.text((470, y + 10), f"{total:.2f}", font=big, fill="#111", anchor="ra")
93
+ d.text((260, y + 110), "PAID BY CARD", font=mono, fill="#333", anchor="mm")
94
+ d.text((260, y + 150), "THANK YOU", font=mono, fill="#333", anchor="mm")
95
+ priciest = max(items, key=lambda i: i[1])[0].split()[0].lower()
96
+ write("vision-receipt", "Read a receipt", img,
97
+ ["store: <name>", "date: <YYYY-MM-DD>", "items: <how many line items were bought>",
98
+ "priciest: <the most expensive item>", "total: <amount paid>", "tip: <tip amount>"],
99
+ [clause("store", r"harbor lane", "store: Harbor Lane Grocery"),
100
+ clause("date", r"2026-03-14\b", "date: 2026-03-14"),
101
+ clause("items", r"4\b", f"items: {len(items)}"),
102
+ clause("priciest", rf"{priciest}", "priciest: Cheddar 200g"),
103
+ clause("total", rf"\$?\s*{total:.2f}\b".replace(".", r"\."), f"total: {total:.2f}"),
104
+ clause("tip", NOT_SHOWN, "tip: not shown")],
105
+ [clause("tip", r"\$?\s*\d", "tip: a value (the receipt shows none)")],
106
+ "Printed text, a date written differently from how it is asked for, a count and a maximum.")
107
+
108
+
109
+ # ---- a grading label: digits that must be exact -------------------------
110
+
111
+ def label() -> None:
112
+ img = Image.new("RGB", (900, 300), "#dfe3e8")
113
+ d = ImageDraw.Draw(img)
114
+ d.rounded_rectangle((20, 20, 880, 280), radius=14, fill="#ffffff", outline="#1d3557", width=4)
115
+ d.rectangle((20, 20, 880, 70), fill="#1d3557")
116
+ d.text((450, 45), "NORTHLINE GRADING", font=font(28, "bold"), fill="#ffffff", anchor="mm")
117
+ sans, small = font(26, "bold"), font(20)
118
+ d.text((50, 95), "2019 HARBOR SERIES", font=sans, fill="#111")
119
+ d.text((50, 135), "#148 MAREN OKAFOR", font=sans, fill="#111")
120
+ d.text((50, 175), "GOLD REFRACTOR", font=sans, fill="#111")
121
+ d.text((50, 225), "CEN 9.5 COR 9 EDG 9.5 SUR 8.5", font=small, fill="#333")
122
+ d.text((760, 110), "MINT", font=font(30, "bold"), fill="#111", anchor="mm")
123
+ d.text((760, 175), "9", font=font(80, "bold"), fill="#b22222", anchor="mm")
124
+ d.text((620, 240), "CERT 40731958", font=font(22, "mono"), fill="#111", anchor="mm")
125
+ x = 520 # a barcode's look; it encodes nothing
126
+ for i, w in enumerate((3, 1, 2, 1, 1, 3, 2, 1, 3, 1, 2, 2, 1, 3, 1, 1, 2, 3, 1, 2)):
127
+ if i % 2 == 0:
128
+ d.rectangle((x, 90, x + w * 2, 150), fill="#111")
129
+ x += w * 2 + 2
130
+ write("vision-label", "Read a graded card's label", img,
131
+ ["grader: <company>", "grade: <the number>", "year: <year>", "number: <card number>",
132
+ "player: <name>", "cert: <certification number>", "surface: <surface subgrade>",
133
+ "autograph: <is the card signed: yes or no>"],
134
+ [clause("grader", r"northline", "grader: Northline"),
135
+ clause("grade", r"(mint\s*)?9\b(?!\.)", "grade: 9"),
136
+ clause("year", r"2019\b", "year: 2019"),
137
+ clause("number", r"#?\s*148\b", "number: 148"),
138
+ clause("player", r"maren okafor", "player: Maren Okafor"),
139
+ clause("cert", r"#?\s*40731958\b", "cert: 40731958"),
140
+ clause("surface", r"8\.5\b", "surface: 8.5"),
141
+ # A signed card's label says AUTO; this one does not, so "no" reads it right.
142
+ clause("autograph", r"(no\b|" + NOT_SHOWN[1:], "autograph: no, or not shown")],
143
+ [clause("autograph", r"(yes|signed|auto)", "autograph: yes (the label says nothing of one)")],
144
+ "A label read field by field; the cert number must be exact to the digit.")
145
+
146
+
147
+ # ---- a bar chart: values against gridlines ------------------------------
148
+
149
+ def bars() -> None:
150
+ teams = [("Billing", 45), ("Mobile", 25), ("Web", 35), ("Data", 15), ("Infra", 30)]
151
+ img = Image.new("RGB", (820, 560), "#ffffff")
152
+ d = ImageDraw.Draw(img)
153
+ d.text((410, 35), "Support tickets by team, Q2", font=font(26, "bold"), fill="#111", anchor="mm")
154
+ left, right, top, bottom, most = 90, 780, 80, 470, 50
155
+ for v in range(0, most + 1, 5):
156
+ y = bottom - (bottom - top) * v / most
157
+ d.line((left, y, right, y), fill="#d0d0d0" if v % 10 else "#a8a8a8", width=1)
158
+ if v % 10 == 0:
159
+ d.text((left - 12, y), str(v), font=font(18), fill="#333", anchor="rm")
160
+ d.line((left, top, left, bottom), fill="#333", width=2)
161
+ d.line((left, bottom, right, bottom), fill="#333", width=2)
162
+ slot = (right - left) / len(teams)
163
+ for i, (name, v) in enumerate(teams):
164
+ x0 = left + slot * i + slot * 0.2
165
+ x1 = left + slot * (i + 1) - slot * 0.2
166
+ d.rectangle((x0, bottom - (bottom - top) * v / most, x1, bottom - 1), fill="#3a6ea5")
167
+ d.text(((x0 + x1) / 2, bottom + 22), name, font=font(20), fill="#111", anchor="mm")
168
+ d.text((30, (top + bottom) / 2), "tickets", font=font(18), fill="#333", anchor="mm")
169
+ tall, short = max(teams, key=lambda t: t[1])[0], min(teams, key=lambda t: t[1])[0]
170
+ over = sum(v > 30 for _, v in teams)
171
+ write("vision-bars", "Read a bar chart", img,
172
+ ["tallest: <team>", "shortest: <team>", "web: <Web's value>",
173
+ "over_30: <how many teams are above 30, not counting 30 itself>", "target: <the target line's value>"],
174
+ [clause("tallest", tall.lower(), f"tallest: {tall}"),
175
+ clause("shortest", short.lower(), f"shortest: {short}"),
176
+ clause("web", r"35\b", "web: 35"),
177
+ clause("over_30", rf"{over}\b", f"over_30: {over}"),
178
+ clause("target", NOT_SHOWN, "target: not shown")],
179
+ [clause("target", r"\d", "target: a value (the chart has no target line)")],
180
+ "Values read against gridlines every 5, labelled every 10; one count asks for care at the boundary.")
181
+
182
+
183
+ # ---- a table: a cell by row and column ----------------------------------
184
+
185
+ def table() -> None:
186
+ cols = ["Q1", "Q2", "Q3", "Q4"]
187
+ rows = [("North", [1240, 1315, 1402, 1388]), ("South", [980, 1045, 1120, 1206]),
188
+ ("East", [1510, 1470, 1535, 1602]), ("West", [870, 905, 948, 1011]),
189
+ ("Central", [1105, 1160, 1098, 1175])]
190
+ x0, y0, cw, rh, first = 40, 70, 150, 54, 180
191
+ img = Image.new("RGB", (x0 * 2 + first + cw * len(cols), 420), "#ffffff")
192
+ d = ImageDraw.Draw(img)
193
+ d.text((img.width / 2, 32), "Units shipped by region", font=font(26, "bold"), fill="#111", anchor="mm")
194
+ sans, bold = font(22), font(22, "bold")
195
+ d.rectangle((x0, y0, x0 + first + cw * len(cols), y0 + rh), fill="#e8eef5")
196
+ d.text((x0 + 14, y0 + rh / 2), "Region", font=bold, fill="#111", anchor="lm")
197
+ for j, c in enumerate(cols):
198
+ d.text((x0 + first + cw * (j + 1) - 16, y0 + rh / 2), c, font=bold, fill="#111", anchor="rm")
199
+ for i, (name, vals) in enumerate(rows):
200
+ y = y0 + rh * (i + 1)
201
+ if i % 2:
202
+ d.rectangle((x0, y, x0 + first + cw * len(cols), y + rh), fill="#f6f6f6")
203
+ d.text((x0 + 14, y + rh / 2), name, font=sans, fill="#111", anchor="lm")
204
+ for j, v in enumerate(vals):
205
+ d.text((x0 + first + cw * (j + 1) - 16, y + rh / 2), f"{v:,}", font=sans, fill="#111", anchor="rm")
206
+ d.rectangle((x0, y0, x0 + first + cw * len(cols), y0 + rh * (len(rows) + 1)), outline="#999", width=2)
207
+ val = {name: dict(zip(cols, vals)) for name, vals in rows}
208
+ q4 = max(rows, key=lambda r: r[1][3])[0]
209
+
210
+ def num(n: int) -> str:
211
+ s = f"{n:,}"
212
+ return s.replace(",", ",?") + r"\b" if "," in s else s + r"\b"
213
+ write("vision-table", "Read a table", img,
214
+ ["south_q3: <number>", "west_q1: <number>", "central_q3: <number>",
215
+ "q4_highest: <the region with the most units in Q4>", "northeast_q2: <number>"],
216
+ [clause("south_q3", num(val["South"]["Q3"]), "south_q3: 1,120"),
217
+ clause("west_q1", num(val["West"]["Q1"]), "west_q1: 870"),
218
+ clause("central_q3", num(val["Central"]["Q3"]), "central_q3: 1,098"),
219
+ clause("q4_highest", q4.lower(), f"q4_highest: {q4}"),
220
+ clause("northeast_q2", NOT_SHOWN, "northeast_q2: not shown")],
221
+ [clause("northeast_q2", r"\d", "northeast_q2: a value (there is no Northeast row)")],
222
+ "Cells found by row and column; Central's Q3 dips, so a value from the wrong column shows.")
223
+
224
+
225
+ # ---- a settings screen: state, not just text -----------------------------
226
+
227
+ def screen() -> None:
228
+ img = Image.new("RGB", (760, 640), "#e9ecef")
229
+ d = ImageDraw.Draw(img)
230
+ d.rounded_rectangle((30, 30, 730, 610), radius=12, fill="#ffffff", outline="#c8ccd0", width=2)
231
+ d.text((60, 62), "Sync settings", font=font(28, "bold"), fill="#111")
232
+ d.rounded_rectangle((60, 110, 700, 160), radius=8, fill="#fdecea", outline="#e0a39d")
233
+ d.text((80, 135), "Last sync failed: storage quota exceeded (E-213)", font=font(20), fill="#8a1c12", anchor="lm")
234
+ toggles = [("Sync on mobile data", False), ("Back up photos", True),
235
+ ("Notify on conflicts", True), ("Pause when battery is low", False)]
236
+ y = 195
237
+ for name, on in toggles:
238
+ d.text((60, y + 15), name, font=font(22), fill="#111", anchor="lm")
239
+ d.rounded_rectangle((620, y, 690, y + 30), radius=15, fill="#2b7a3d" if on else "#c4c4c4")
240
+ cx = 675 if on else 635
241
+ d.ellipse((cx - 12, y + 3, cx + 12, y + 27), fill="#ffffff")
242
+ y += 52
243
+ d.text((60, y + 10), "When files conflict", font=font(22, "bold"), fill="#111")
244
+ options, chosen = ["Prefer the newer file", "Keep both", "Prefer this device"], "Keep both"
245
+ y += 50
246
+ for o in options:
247
+ d.ellipse((62, y, 84, y + 22), outline="#555", width=2)
248
+ if o == chosen:
249
+ d.ellipse((68, y + 6, 78, y + 16), fill="#1f5fbf")
250
+ d.text((96, y + 11), o, font=font(21), fill="#111", anchor="lm")
251
+ y += 36
252
+ d.rounded_rectangle((420, 548, 550, 590), radius=8, fill="#eeeeee", outline="#d5d5d5")
253
+ d.text((485, 569), "Sync now", font=font(20), fill="#a8a8a8", anchor="mm")
254
+ d.rounded_rectangle((570, 548, 700, 590), radius=8, fill="#1f5fbf")
255
+ d.text((635, 569), "Save", font=font(20, "bold"), fill="#ffffff", anchor="mm")
256
+ write("vision-screen", "Read a settings screen", img,
257
+ ["toggles_on: <every switch that is on>", "conflict: <the option chosen>",
258
+ "disabled: <the button that cannot be pressed>", "error_code: <code>", "account: <the signed-in email>"],
259
+ [clause("toggles_on", r".*back up photos", "toggles_on: Back up photos"),
260
+ clause("toggles_on", r".*notify on conflicts", "toggles_on: Notify on conflicts"),
261
+ clause("conflict", r"keep both", "conflict: Keep both"),
262
+ clause("disabled", r"sync now", "disabled: Sync now"),
263
+ clause("error_code", r"e-?213\b", "error_code: E-213"),
264
+ clause("account", NOT_SHOWN, "account: not shown")],
265
+ [clause("toggles_on", r".*(mobile data|battery)", "toggles_on: a switch that is off"),
266
+ clause("account", r"\S+@\S+", "account: an email (the screen shows none)")],
267
+ "A screen's state: which switches are on, which option is picked, which button is greyed out.")
268
+
269
+
270
+ # ---- shapes: counting and where things are -------------------------------
271
+
272
+ def shapes() -> None:
273
+ img = Image.new("RGB", (800, 600), "#ffffff")
274
+ d = ImageDraw.Draw(img)
275
+ blue = [(110, 120), (250, 90), (180, 300), (390, 210), (120, 480), (330, 420)]
276
+ red = [(520, 330), (640, 470), (260, 540)]
277
+ green = [(470, 120), (560, 520)]
278
+ for x, y in blue:
279
+ d.ellipse((x - 34, y - 34, x + 34, y + 34), fill="#2f6fd0")
280
+ for x, y in red:
281
+ d.rectangle((x - 32, y - 32, x + 32, y + 32), fill="#d33a2f")
282
+ for x, y in green:
283
+ d.polygon([(x, y - 38), (x - 38, y + 30), (x + 38, y + 30)], fill="#2e9e4f")
284
+ cx, cy, r = 715, 85, 50 # the star, in the top-right corner
285
+ d.polygon([(cx + (r if i % 2 == 0 else r * 0.45) * math.cos(math.radians(-90 + 36 * i)),
286
+ cy + (r if i % 2 == 0 else r * 0.45) * math.sin(math.radians(-90 + 36 * i))) for i in range(10)],
287
+ fill="#f2c200", outline="#b38f00")
288
+ write("vision-shapes", "Count shapes and say where one is", img,
289
+ ["blue_circles: <how many>", "red_squares: <how many>", "green_triangles: <how many>",
290
+ "top_right: <the shape in the top-right corner, with its colour>", "purple_shapes: <how many>"],
291
+ [clause("blue_circles", rf"{len(blue)}\b", f"blue_circles: {len(blue)}"),
292
+ clause("red_squares", rf"{len(red)}\b", f"red_squares: {len(red)}"),
293
+ clause("green_triangles", rf"{len(green)}\b", f"green_triangles: {len(green)}"),
294
+ clause("top_right", r".*star", "top_right: a yellow star"),
295
+ clause("purple_shapes", r"(0\b|none\b|zero\b|no\b|" + NOT_SHOWN[1:], "purple_shapes: 0")],
296
+ [clause("purple_shapes", r"[1-9]", "purple_shapes: a count above 0 (there are none)")],
297
+ "Counting to six without losing one, and a corner told apart from its neighbours.")
298
+
299
+
300
+ # ---- an invoice turned on its side ---------------------------------------
301
+
302
+ def invoice() -> None:
303
+ page = Image.new("RGB", (640, 820), "#ffffff")
304
+ d = ImageDraw.Draw(page)
305
+ d.text((40, 40), "INVOICE", font=font(40, "bold"), fill="#111")
306
+ d.text((40, 100), "INV-2026-0417", font=font(26, "mono"), fill="#111")
307
+ d.text((40, 160), "Issued: 2026-04-02", font=font(22), fill="#333")
308
+ d.text((40, 195), "Due: 2026-05-02", font=font(22), fill="#333")
309
+ d.text((40, 260), "Bill to", font=font(20, "bold"), fill="#555")
310
+ d.text((40, 290), "Kestrel Labs BV", font=font(24), fill="#111")
311
+ d.text((40, 322), "Havenstraat 18, Rotterdam", font=font(20), fill="#333")
312
+ lines = [("Model evaluation, March", 960.00), ("Hosting, March", 324.50)]
313
+ y = 400
314
+ d.line((40, y - 10, 600, y - 10), fill="#bbb", width=2)
315
+ for name, amount in lines:
316
+ d.text((40, y), name, font=font(22), fill="#111")
317
+ d.text((600, y), f"EUR {amount:,.2f}", font=font(22), fill="#111", anchor="ra")
318
+ y += 44
319
+ d.line((40, y + 4, 600, y + 4), fill="#bbb", width=2)
320
+ total = sum(a for _, a in lines)
321
+ d.text((40, y + 30), "Amount due", font=font(26, "bold"), fill="#111")
322
+ d.text((600, y + 30), f"EUR {total:,.2f}", font=font(26, "bold"), fill="#111", anchor="ra")
323
+ img = page.rotate(90, expand=True, fillcolor="#ffffff") # turned anticlockwise: reads bottom to top
324
+ write("vision-invoice", "Read an invoice turned on its side", img,
325
+ ["invoice: <invoice number>", "due: <YYYY-MM-DD>", "amount_due: <amount>", "bill_to: <company>",
326
+ "po_number: <purchase order number>"],
327
+ [clause("invoice", r"inv-2026-0417\b", "invoice: INV-2026-0417"),
328
+ clause("due", r"2026-05-02\b", "due: 2026-05-02"),
329
+ clause("amount_due", r"(eur|€)?\s*1,?284\.50\b", "amount_due: EUR 1,284.50"),
330
+ clause("bill_to", r"kestrel labs", "bill_to: Kestrel Labs BV"),
331
+ clause("po_number", NOT_SHOWN, "po_number: not shown")],
332
+ [clause("po_number", r"\d", "po_number: a value (the invoice has none)")],
333
+ "A page a scanner or a phone delivered sideways; two dates close together, only one asked for.")
334
+
335
+
336
+ if __name__ == "__main__":
337
+ for make in (receipt, label, bars, table, screen, shapes, invoice):
338
+ make()
package/ui/bench.js CHANGED
@@ -509,30 +509,42 @@
509
509
  o.picker.appendChild(d);
510
510
  }
511
511
  }
512
- function loadCandidates() {
513
- o.picker.textContent = ''; o.picker.appendChild(el('p', {class: 'empty'}, 'Asking the endpoints what they serve…'));
512
+ // The server keeps each endpoint's list a few minutes (serve.LISTED_FOR);
513
+ // Refresh asks them all again.
514
+ function showAge(s) {
515
+ var age = $('o-age');
516
+ age.hidden = s == null;
517
+ if (s == null) return;
518
+ age.firstChild.textContent = s < 60 ? 'Listed just now.' : 'Listed ' + Math.round(s / 60) + ' min ago.';
519
+ }
520
+ function loadCandidates(fresh) {
521
+ showAge(null);
522
+ o.picker.textContent = ''; o.picker.appendChild(el('p', {class: 'empty'}, 'Please wait. Gathering models from your endpoints…'));
514
523
  o.start.disabled = o.dismiss.disabled = true;
515
- o.find.readOnly = true; // typing before the list arrives is lost, or lands on a row
516
- fetch('/api/candidates').then(function (r) { return r.json(); }).then(function (res) {
517
- o.find.readOnly = false; o.find.focus();
524
+ // Typing before the list arrives is lost, or lands on a row; the mode waits with it.
525
+ o.find.readOnly = true; $('o-mode').disabled = $('o-vision').disabled = true;
526
+ function ready() { o.find.readOnly = false; $('o-mode').disabled = $('o-vision').disabled = false; }
527
+ fetch('/api/candidates' + (fresh ? '?fresh=1' : '')).then(function (r) { return r.json(); }).then(function (res) {
528
+ ready(); o.find.focus();
518
529
  if (!res.ok) { o.picker.textContent = ''; listErrors(o.errors, res.errors); return; }
519
530
  if (res.errors.length) listErrors(o.errors, res.errors); // one endpoint down; the rest listed
520
- data = res;
531
+ data = res; showAge(res.age);
521
532
  // Only what is still offered stays ticked.
522
533
  var offered = {};
523
534
  res.candidates.forEach(function (c) { offered[c.key] = 1; });
524
535
  Object.keys(chosen).forEach(function (k) { if (!offered[k]) delete chosen[k]; });
525
536
  renderPicker(); armStart();
526
- }).catch(function (err) { o.find.readOnly = false; o.errors.textContent = String(err); });
537
+ }).catch(function (err) { ready(); o.errors.textContent = String(err); });
527
538
  }
528
539
  $('onboard-open').addEventListener('click', function () {
529
540
  preStep = null;
530
541
  o.errors.textContent = ''; o.find.value = ''; o.choose.hidden = false; o.run.hidden = true;
531
- $('o-queue').hidden = true; chosen = {}; waitToken++;
542
+ $('o-queue').hidden = true; chosen = {}; waitToken++; $('o-vision').checked = false;
532
543
  o.start.hidden = o.dismiss.hidden = false; armStart(); o.title.textContent = 'Onboard a model';
533
544
  od.showModal(); o.find.focus(); loadCandidates();
534
545
  });
535
546
  o.find.addEventListener('input', renderPicker);
547
+ $('o-refresh').addEventListener('click', function () { o.errors.textContent = ''; loadCandidates(true); });
536
548
  o.close.addEventListener('click', function () { od.close(); });
537
549
  od.addEventListener('close', function () { if (timer) { clearInterval(timer); timer = null; } });
538
550
 
@@ -573,8 +585,8 @@
573
585
  // model at a time. The popup closes: the header's chip shows the batch, and
574
586
  // its Details the queue. Refused, it stays open with the reasons.
575
587
  o.start.addEventListener('click', function () {
576
- var mode = $('o-mode').value;
577
- var items = Object.keys(chosen).map(function (k) { return Object.assign({mode: mode}, chosen[k]); });
588
+ var mode = $('o-mode').value, vision = $('o-vision').checked;
589
+ var items = Object.keys(chosen).map(function (k) { return Object.assign({mode: mode, vision: vision}, chosen[k]); });
578
590
  if (!items.length) return;
579
591
  o.errors.textContent = ''; o.start.disabled = o.dismiss.disabled = true; o.start.textContent = 'Starting…';
580
592
  post('/api/onboard-queue', {items: items}).then(function (res) {
@@ -1737,6 +1749,71 @@
1737
1749
  });
1738
1750
  });
1739
1751
 
1752
+ // Vision tasks: one square per field and model, the task's name over its first field.
1753
+ put('chart-vision-fields', function (w) {
1754
+ var V = C.vision;
1755
+ if (!V.heat.length) return el('p', {class: 'hint'}, 'No vision task answers yet: the suite runs them for models'
1756
+ + ' that take images, at onboarding (Full) or through the scheduled passes.');
1757
+ // Grouped by where the pictures come from, the group's name over its first field.
1758
+ var order = ['made', 'own', 'receipts', 'screens', 'tables', 'objects'], keys = [], head = {};
1759
+ Object.keys(V.tasks).sort(function (a, b) {
1760
+ return order.indexOf(V.tasks[a].family) - order.indexOf(V.tasks[b].family) || (a < b ? -1 : 1);
1761
+ }).forEach(function (t) {
1762
+ var fam = V.tasks[t].family;
1763
+ V.tasks[t].fields.forEach(function (f) {
1764
+ if (!keys.some(function (k) { return head[k] === fam; })) head[t + '·' + f] = fam;
1765
+ keys.push(t + '·' + f);
1766
+ });
1767
+ });
1768
+ var S = V.summary;
1769
+ function share(m) { return S[m].fields ? S[m].right / S[m].fields : 0; }
1770
+ var models = Object.keys(S).sort(function (a, b) { return share(b) - share(a) || S[a].taken - S[b].taken; });
1771
+ var colour = {right: good, misread: signal, 'left out': line, 'trap taken': err, 'no answer': muted};
1772
+ function tip(d) { return d.model + '\n' + d.task.replace(/^vision-/, '') + ': ' + d.field + (d.trap ? ' (a trap)' : '') + '\n' + d.state; }
1773
+ return Plot.plot({
1774
+ // The group names tilted: a narrow group (a few tables) would run into its neighbour's.
1775
+ width: w, height: 22 * models.length + 140, style: style, marginLeft: 300, marginTop: 80, padding: 0.08,
1776
+ x: {domain: keys, axis: 'top', label: null, tickSize: 0, tickRotate: -60, textAnchor: 'start',
1777
+ tickFormat: function (k) { return head[k] || ''; }},
1778
+ y: {domain: models, label: null, tickSize: 0,
1779
+ tickFormat: function (m) { return m + ' (' + S[m].right + '/' + S[m].fields + ')'; }},
1780
+ color: {legend: true, domain: V.states, range: V.states.map(function (s) { return colour[s]; })},
1781
+ marks: [
1782
+ Plot.cell(V.heat, {x: 'key', y: 'model', fill: 'state', title: tip}),
1783
+ Plot.dot(V.heat.filter(function (d) { return d.trap; }), {x: 'key', y: 'model', r: 1.8, fill: ink, fillOpacity: 0.55}),
1784
+ Plot.ruleX(Object.keys(head), {x: function (k) { return k; }, stroke: ink, strokeOpacity: 0.25, dx: -2})
1785
+ ]
1786
+ });
1787
+ });
1788
+
1789
+ // Vision, real work: each model's seats on image panels by how they were judged.
1790
+ put('chart-vision-work', function (w) {
1791
+ var W = C.vision.work, kinds = C.vision.seatKinds, data = [], total = {};
1792
+ Object.keys(W).forEach(function (m) {
1793
+ 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]; });
1794
+ });
1795
+ if (!data.length) return el('p', {class: 'hint'}, 'No vision panels through the gateway yet.');
1796
+ // The models doing the most image work first: their bars are the evidence.
1797
+ var models = Object.keys(total).sort(function (a, b) { return total[b] - total[a] || (a < b ? -1 : 1); });
1798
+ var colour = {'checked, helped': good, 'checked, did not help': err, 'useful, no claims': sky,
1799
+ 'no use, no claims': warn, 'not judged': line, 'no answer': muted};
1800
+ // The claims checked by hand, beside the bar: the only right-or-wrong count real work has.
1801
+ var claims = models.filter(function (m) { return W[m].real + W[m].false; }).map(function (m) {
1802
+ return {model: m, text: W[m].real + ' of ' + (W[m].real + W[m].false) + ' claims right'};
1803
+ });
1804
+ return Plot.plot({
1805
+ width: w, height: rows(models.length), style: style, marginLeft: 300, marginRight: 150,
1806
+ x: {label: 'Share of its seats on image panels →', tickFormat: '.0%', grid: true, domain: [0, 1]},
1807
+ y: {domain: models, label: null, tickFormat: function (m) { return m + ' (' + total[m] + ')'; }},
1808
+ color: {legend: true, domain: kinds, range: kinds.map(function (k) { return colour[k]; })},
1809
+ marks: [
1810
+ Plot.barX(data, Plot.stackX({x: 'count', y: 'model', fill: 'kind', offset: 'normalize', order: kinds,
1811
+ title: function (d) { return d.model + ': ' + d.kind + ', ' + d.count + ' of ' + total[d.model] + ' seats'; }})),
1812
+ Plot.text(claims, {x: 1, y: 'model', text: 'text', dx: 8, textAnchor: 'start', fill: ink})
1813
+ ]
1814
+ });
1815
+ });
1816
+
1740
1817
  // Confidently wrong: stacked by kind, worst at the top.
1741
1818
  put('chart-wrong', function (w) {
1742
1819
  var T = C.wrongTotals;