@wendellresearch-ai/benchwarmers 0.3.3 → 0.5.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 (50) hide show
  1. package/GATEWAY.md +9 -5
  2. package/PRIVACY.md +9 -3
  3. package/README.md +31 -8
  4. package/USAGE.md +81 -15
  5. package/VERSION +1 -1
  6. package/cli.py +17 -5
  7. package/gateway.py +34 -2
  8. package/launcher.py +2 -1
  9. package/metrics/aliases.py +24 -3
  10. package/metrics/discover.py +68 -18
  11. package/metrics/endpoints.py +36 -4
  12. package/metrics/model_roles.py +3 -1
  13. package/metrics/pacing.py +23 -3
  14. package/metrics/quotaview.py +460 -0
  15. package/metrics/route.py +3 -2
  16. package/metrics/scheduler.py +2 -1
  17. package/metrics/sources.py +29 -6
  18. package/metrics/wrong.py +3 -1
  19. package/package.json +1 -1
  20. package/scripts/check.py +3 -0
  21. package/scripts/onboard.py +12 -1
  22. package/scripts/test_hubs.py +10 -5
  23. package/scripts/test_no_owui.py +14 -2
  24. package/scripts/test_quota.py +12 -2
  25. package/scripts/test_serve_edit.py +58 -5
  26. package/scripts/test_server_json.py +22 -2
  27. package/serve.py +570 -128
  28. package/suite/README.md +33 -0
  29. package/suite/pace.py +8 -4
  30. package/suite/run.py +144 -16
  31. package/suite/tasks/vision-bars/fixtures/bars.png +0 -0
  32. package/suite/tasks/vision-bars/task.json +40 -0
  33. package/suite/tasks/vision-invoice/fixtures/invoice.png +0 -0
  34. package/suite/tasks/vision-invoice/task.json +40 -0
  35. package/suite/tasks/vision-label/fixtures/label.png +0 -0
  36. package/suite/tasks/vision-label/task.json +52 -0
  37. package/suite/tasks/vision-receipt/fixtures/receipt.png +0 -0
  38. package/suite/tasks/vision-receipt/task.json +44 -0
  39. package/suite/tasks/vision-screen/fixtures/screen.png +0 -0
  40. package/suite/tasks/vision-screen/task.json +48 -0
  41. package/suite/tasks/vision-shapes/fixtures/shapes.png +0 -0
  42. package/suite/tasks/vision-shapes/task.json +40 -0
  43. package/suite/tasks/vision-table/fixtures/table.png +0 -0
  44. package/suite/tasks/vision-table/task.json +40 -0
  45. package/suite/test_vision_tasks.py +201 -0
  46. package/suite/vision_keys.py +74 -0
  47. package/suite/vision_sample.py +232 -0
  48. package/suite/vision_tasks.py +338 -0
  49. package/ui/bench.css +71 -6
  50. package/ui/bench.js +543 -49
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:
package/suite/run.py CHANGED
@@ -146,6 +146,50 @@ def available_models(cache: dict | None = None,
146
146
  return models
147
147
 
148
148
 
149
+ def images_only() -> set[str]:
150
+ """Aliases whose role is vision or image (model_roles). No text shape
151
+ would ever seat them, so the text and code tasks are not theirs: their
152
+ answers only spent calls and put a reader of images on the code charts.
153
+ Routing still sees them (available_models), where a vision model sits
154
+ for images."""
155
+ import model_roles
156
+ return {a for a, r in model_roles.load()["models"].items() if r in ("vision", "image")}
157
+
158
+
159
+ def sees_images(alias: str) -> bool:
160
+ """Whether the catalog says `alias` takes images, by the gateway's rule
161
+ (gateway.cannot_see): its input types, the Vision / OCR override in its
162
+ model popup, or the capability seen working. One the catalog knows
163
+ nothing about is not asked: a model that cannot see answers anyway,
164
+ describing a picture it never got, and that would score as reading badly."""
165
+ import catalog
166
+ import endpoints
167
+ if endpoints.entry_of(alias).get("api") == "responses": # the client sends it no images yet
168
+ return False
169
+ return bool(catalog.info(alias).get("vision"))
170
+
171
+
172
+ def applies(spec: dict, model: str, skip: set[str] | None = None, seeing: dict | None = None) -> bool:
173
+ """Whether `spec` is a task for `model`: a vision task (one with images)
174
+ for a model that takes them, any other for one that is not vision or
175
+ image only. So a vision model's suite is the vision tasks, a general one
176
+ that takes images gets both, and a text-only one never sees a picture.
177
+ `skip` (images_only()) and `seeing` (alias -> sees_images) save asking
178
+ again in a loop."""
179
+ if spec.get("images"):
180
+ if seeing is not None and model in seeing:
181
+ return seeing[model]
182
+ return sees_images(model)
183
+ return model not in (images_only() if skip is None else skip)
184
+
185
+
186
+ def tasks_for(model: str, tasks: list[dict]) -> list[dict]:
187
+ skip, seeing = images_only(), {}
188
+ if any(t.get("images") for t in tasks):
189
+ seeing[model] = sees_images(model)
190
+ return [t for t in tasks if applies(t, model, skip, seeing)]
191
+
192
+
149
193
  def _registered_names() -> dict[str, str]:
150
194
  """alias -> the model name the endpoint knows it by; {} with no yaml yet."""
151
195
  import endpoints
@@ -227,9 +271,9 @@ def next_free(tasks_dir: pathlib.Path) -> str | None:
227
271
  is picked up on the next run rather than after a full cycle.
228
272
  """
229
273
  best, best_age = None, None
230
- held = ad_hoc_models()
274
+ held, specs = ad_hoc_models(), load_tasks(None)
231
275
  for model in free_models():
232
- if model in held:
276
+ if model in held or not tasks_for(model, specs):
233
277
  continue
234
278
  outputs = list(tasks_dir.glob(f"*/output/{model}.md"))
235
279
  if not outputs:
@@ -267,17 +311,23 @@ def next_model(tasks_dir: pathlib.Path) -> str | None:
267
311
  A gap is a worse blind spot than a stale number: an old score is
268
312
  still evidence, a missing one is none.
269
313
  3. oldest, so the set refreshes on its own
314
+
315
+ Complete means every task that is the model's (tasks_for): a vision
316
+ model is complete on the vision tasks alone.
270
317
  """
271
- total = len([p for p in tasks_dir.iterdir() if p.is_dir()])
318
+ specs = load_tasks(None)
272
319
  free = set(free_models())
273
320
  best, best_key = None, None
274
321
  for model in available_models():
275
322
  if model in free:
276
323
  continue
277
- outputs = list(tasks_dir.glob(f"*/output/{model}.md"))
324
+ mine = tasks_for(model, specs)
325
+ if not mine:
326
+ continue
327
+ outputs = [p for p in (t["dir"] / "output" / f"{model}.md" for t in mine) if p.exists()]
278
328
  if not outputs:
279
329
  return model # never scored: go first
280
- key = (len(outputs) >= total, # False (incomplete) sorts first
330
+ key = (len(outputs) >= len(mine), # False (incomplete) sorts first
281
331
  max(o.stat().st_mtime for o in outputs))
282
332
  if best_key is None or key < best_key:
283
333
  best, best_key = model, key
@@ -314,7 +364,44 @@ def pick_tasks(tasks: list[dict], model: str, n: int = PER_RUN) -> list[dict]:
314
364
  return sorted(tasks, key=lambda s: answer(s).stat().st_mtime)[:n]
315
365
 
316
366
 
367
+ SHIPPED = HERE / "tasks"
368
+ SHIPPED_PREFIX = "vision-"
369
+
370
+
371
+ def seed_shipped() -> None:
372
+ """Copy the vision tasks this checkout ships (suite/tasks/vision-*: made
373
+ images with their answer keys, suite/vision_tasks.py) into the data
374
+ folder, and keep their prompt, key and images current there. The saved
375
+ answers (output/) are the install's and are never touched. Other tasks
376
+ are the install's own and are not shipped this way."""
377
+ if SHIPPED.resolve() == TASKS.resolve():
378
+ return
379
+ for task in SHIPPED.glob(f"{SHIPPED_PREFIX}*/task.json"):
380
+ for src in [task, *sorted((task.parent / "fixtures").glob("*"))]:
381
+ dst = TASKS / src.relative_to(SHIPPED)
382
+ if not dst.exists() or dst.read_bytes() != src.read_bytes():
383
+ dst.parent.mkdir(parents=True, exist_ok=True)
384
+ shutil.copyfile(src, dst)
385
+
386
+
387
+ SETTINGS = DATA / "config" / "suite.json"
388
+
389
+
390
+ def vision_held() -> str:
391
+ """The day the vision tasks start, while it is still ahead: `vision_from`
392
+ (YYYY-MM-DD) in config/suite.json, else "". Until then no run picks them
393
+ (scheduled, onboarding, pace); `--task` still runs one. A hold, set at a
394
+ release so the first vision runs wait for a quiet hub."""
395
+ try:
396
+ day = str(json.loads(SETTINGS.read_text(encoding="utf-8")).get("vision_from") or "")
397
+ except (OSError, ValueError, AttributeError):
398
+ return ""
399
+ return day if day > dt.date.today().isoformat() else ""
400
+
401
+
317
402
  def load_tasks(only: str | None) -> list[dict]:
403
+ seed_shipped()
404
+ held = "" if only else vision_held()
318
405
  tasks = []
319
406
  for path in sorted(TASKS.glob("*/task.json")):
320
407
  spec = json.loads(path.read_text(encoding="utf-8"))
@@ -325,6 +412,8 @@ def load_tasks(only: str | None) -> list[dict]:
325
412
  # A mined task is a draft until someone has read it (suite/mine.py).
326
413
  if spec.get("draft") and not only:
327
414
  continue
415
+ if held and spec.get("images"):
416
+ continue
328
417
  tasks.append(spec)
329
418
  if only and not tasks:
330
419
  sys.exit(f"no task called {only!r}")
@@ -348,6 +437,23 @@ def build_prompt(spec: dict) -> str:
348
437
  return "\n".join(parts)
349
438
 
350
439
 
440
+ MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
441
+
442
+
443
+ def image_urls(spec: dict) -> list[str]:
444
+ """A vision task's pictures (`images`, files in its fixtures/) as data:
445
+ URLs, the way the gateway passes a request's images on."""
446
+ import base64
447
+ urls = []
448
+ for name in spec.get("images", []):
449
+ path = spec["dir"] / "fixtures" / name
450
+ if not path.exists():
451
+ raise MissingAttachment(f"{spec['id']}: image not found: fixtures/{name}")
452
+ mime = MIME.get(path.suffix.lower(), "image/png")
453
+ urls.append(f"data:{mime};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}")
454
+ return urls
455
+
456
+
351
457
  # A model that was never allowed to answer has not answered badly, and a
352
458
  # service that fell over has not answered at all. Reporting either as 0/N is a
353
459
  # lie about the model — and reporting them as the SAME thing is a second lie,
@@ -810,12 +916,20 @@ def _child_env() -> dict:
810
916
 
811
917
 
812
918
  def ask_once(binary: str, model: str, prompt: str,
813
- timeout: int) -> tuple[str, float]:
919
+ timeout: int, images: list[str] | None = None) -> tuple[str, float]:
814
920
  # BENCH_CLIENT=builtin: the same call without the `llm` subprocess, giving
815
921
  # the same text for the same failure (metrics/client.py).
816
922
  if str(ROOT / "metrics") not in sys.path:
817
923
  sys.path.insert(0, str(ROOT / "metrics"))
818
924
  import client
925
+ if images:
926
+ # Pictures go as the API's own image parts, through the built-in
927
+ # client whichever is chosen: `llm -m` reads a prompt, not parts.
928
+ import formats
929
+ content = [formats.text_part(prompt), *(formats.image(u) for u in images)]
930
+ res = client.chat(model, "", timeout=timeout, messages=[{"role": "user", "content": content}],
931
+ purpose="vision task")
932
+ return client.output(res, timeout), res["seconds"]
819
933
  if client.backend(model) == "builtin":
820
934
  res = client.chat(model, prompt, timeout=timeout)
821
935
  return client.output(res, timeout), res["seconds"]
@@ -842,7 +956,7 @@ def ask_once(binary: str, model: str, prompt: str,
842
956
 
843
957
 
844
958
  def ask(binary: str, model: str, prompt: str, timeout: int,
845
- attempts: int = 3) -> tuple[str, float, int]:
959
+ attempts: int = 3, images: list[str] | None = None) -> tuple[str, float, int]:
846
960
  """Ask, retrying UPSTREAM FAILURES only.
847
961
 
848
962
  A rate limit or an overloaded service is a fact about the afternoon, not
@@ -853,7 +967,7 @@ def ask(binary: str, model: str, prompt: str, timeout: int,
853
967
  """
854
968
  total = 0.0
855
969
  for attempt in range(1, attempts + 1):
856
- body, seconds = ask_once(binary, model, prompt, timeout)
970
+ body, seconds = ask_once(binary, model, prompt, timeout, images)
857
971
  total += seconds
858
972
  kind = (outcome(body) or ("answered", ""))[0]
859
973
  if kind != "error" or attempt == attempts:
@@ -895,7 +1009,7 @@ def main() -> int:
895
1009
  if chosen is None:
896
1010
  sys.exit("no :free model registered")
897
1011
  args.models = [chosen]
898
- tasks = pick_tasks(tasks, chosen)
1012
+ tasks = pick_tasks(tasks_for(chosen, tasks), chosen)
899
1013
  print(f"--next-free chose {chosen}: {len(tasks)} tasks "
900
1014
  f"(one model per run; the free tier is 50 calls a day, shared)")
901
1015
 
@@ -904,10 +1018,10 @@ def main() -> int:
904
1018
  if chosen is None:
905
1019
  sys.exit("no paid model registered")
906
1020
  args.models = [chosen]
907
- total = len([p for p in TASKS.iterdir() if p.is_dir()])
908
- have = len(list(TASKS.glob(f"*/output/{chosen}.md")))
909
- tasks = pick_tasks(tasks, chosen)
910
- print(f"--next chose {chosen} ({have}/{total} tasks scored; "
1021
+ mine = tasks_for(chosen, tasks)
1022
+ have = sum((t["dir"] / "output" / f"{chosen}.md").exists() for t in mine)
1023
+ tasks = pick_tasks(mine, chosen)
1024
+ print(f"--next chose {chosen} ({have}/{len(mine)} tasks scored; "
911
1025
  f"{len(tasks)} this run)")
912
1026
 
913
1027
  models = args.models or [m for m in available_models()
@@ -919,10 +1033,20 @@ def main() -> int:
919
1033
  ready = []
920
1034
  for spec in tasks:
921
1035
  try:
922
- ready.append((spec, build_prompt(spec)))
1036
+ ready.append((spec, build_prompt(spec), image_urls(spec)))
923
1037
  except MissingAttachment as exc:
924
1038
  print(f"skipped {exc}")
925
- tasks, prompts = [s for s, _ in ready], {s["id"]: p for s, p in ready}
1039
+ tasks, prompts = [s for s, _, _ in ready], {s["id"]: p for s, p, _ in ready}
1040
+ pictures = {s["id"]: u for s, _, u in ready}
1041
+ # Each pair only where the task is the model's (applies): pictures to a
1042
+ # model that takes them, text and code to one that is not images only.
1043
+ skip = images_only()
1044
+ seeing = {m: sees_images(m) for m in models} if any(pictures.values()) else {}
1045
+ pairs = {(s["id"], m) for s in tasks for m in models if applies(s, m, skip, seeing)}
1046
+ left = len(tasks) * len(models) - len(pairs)
1047
+ if left:
1048
+ print(f"{left} task/model pair(s) left out: vision tasks go to models that take images,"
1049
+ " text and code tasks to models that are not vision or image only")
926
1050
 
927
1051
  if args.dry_run:
928
1052
  for spec in tasks:
@@ -939,16 +1063,20 @@ def main() -> int:
939
1063
 
940
1064
  for spec in tasks:
941
1065
  prompt = prompts[spec["id"]]
1066
+ if not any((spec["id"], m) in pairs for m in models):
1067
+ continue
942
1068
  print(f"\n## {spec['id']} — {spec['title']}")
943
1069
  print(f" {len(prompt):,} chars, {len(spec.get('must_find', []))} findings\n")
944
1070
  for model in models:
1071
+ if (spec["id"], model) not in pairs:
1072
+ continue
945
1073
  # N attempts at the SAME question. The spread is the point: a
946
1074
  # single sample is a measurement of one afternoon, and this suite
947
1075
  # has been reporting those as though they were properties.
948
1076
  takes = []
949
1077
  for _ in range(max(1, args.repeat)):
950
1078
  body, seconds, attempts = ask(binary, model, prompt,
951
- args.timeout)
1079
+ args.timeout, images=pictures[spec["id"]])
952
1080
  takes.append((score(spec, body), seconds, attempts, body))
953
1081
  if args.repeat > 1:
954
1082
  time.sleep(2)
@@ -0,0 +1,40 @@
1
+ {
2
+ "id": "vision-bars",
3
+ "title": "Read a bar chart",
4
+ "shape": "vision",
5
+ "images": [
6
+ "bars.png"
7
+ ],
8
+ "prompt": "Read the attached image and answer in exactly these lines, one per line, nothing else:\n\ntallest: <team>\nshortest: <team>\nweb: <Web's value>\nover_30: <how many teams are above 30, not counting 30 itself>\ntarget: <the target line's value>\n\nIf the image does not show something, write `not shown` for it. Do not guess.",
9
+ "min_answer_chars": 20,
10
+ "must_find": [
11
+ {
12
+ "regex": "(?m)^[^\\w\\n]*tallest[^\\w\\n]*:[ \\t*_`\\\"']*billing",
13
+ "label": "tallest: Billing"
14
+ },
15
+ {
16
+ "regex": "(?m)^[^\\w\\n]*shortest[^\\w\\n]*:[ \\t*_`\\\"']*data",
17
+ "label": "shortest: Data"
18
+ },
19
+ {
20
+ "regex": "(?m)^[^\\w\\n]*web[^\\w\\n]*:[ \\t*_`\\\"']*35\\b",
21
+ "label": "web: 35"
22
+ },
23
+ {
24
+ "regex": "(?m)^[^\\w\\n]*over[ _]30[^\\w\\n]*:[ \\t*_`\\\"']*2\\b",
25
+ "label": "over_30: 2"
26
+ },
27
+ {
28
+ "regex": "(?m)^[^\\w\\n]*target[^\\w\\n]*:[ \\t*_`\\\"']*(not shown|not visible|not present|not listed|not given|not stated|not on the|not in the|not provided|not included|none shown|n/?a\\b|none\\b|no such|does not show|doesn't show|cannot be (?:determined|answered|seen)|can't be (?:determined|answered|seen)|unanswerable)",
29
+ "label": "target: not shown"
30
+ }
31
+ ],
32
+ "must_not_claim": [
33
+ {
34
+ "regex": "(?m)^[^\\w\\n]*target[^\\w\\n]*:[ \\t*_`\\\"']*\\d",
35
+ "label": "target: a value (the chart has no target line)"
36
+ }
37
+ ],
38
+ "why": "Values read against gridlines every 5, labelled every 10; one count asks for care at the boundary.",
39
+ "made_by": "suite/vision_tasks.py"
40
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "id": "vision-invoice",
3
+ "title": "Read an invoice turned on its side",
4
+ "shape": "vision",
5
+ "images": [
6
+ "invoice.png"
7
+ ],
8
+ "prompt": "Read the attached image and answer in exactly these lines, one per line, nothing else:\n\ninvoice: <invoice number>\ndue: <YYYY-MM-DD>\namount_due: <amount>\nbill_to: <company>\npo_number: <purchase order number>\n\nIf the image does not show something, write `not shown` for it. Do not guess.",
9
+ "min_answer_chars": 20,
10
+ "must_find": [
11
+ {
12
+ "regex": "(?m)^[^\\w\\n]*invoice[^\\w\\n]*:[ \\t*_`\\\"']*inv-2026-0417\\b",
13
+ "label": "invoice: INV-2026-0417"
14
+ },
15
+ {
16
+ "regex": "(?m)^[^\\w\\n]*due[^\\w\\n]*:[ \\t*_`\\\"']*2026-05-02\\b",
17
+ "label": "due: 2026-05-02"
18
+ },
19
+ {
20
+ "regex": "(?m)^[^\\w\\n]*amount[ _]due[^\\w\\n]*:[ \\t*_`\\\"']*(eur|€)?\\s*1,?284\\.50\\b",
21
+ "label": "amount_due: EUR 1,284.50"
22
+ },
23
+ {
24
+ "regex": "(?m)^[^\\w\\n]*bill[ _]to[^\\w\\n]*:[ \\t*_`\\\"']*kestrel labs",
25
+ "label": "bill_to: Kestrel Labs BV"
26
+ },
27
+ {
28
+ "regex": "(?m)^[^\\w\\n]*po[ _]number[^\\w\\n]*:[ \\t*_`\\\"']*(not shown|not visible|not present|not listed|not given|not stated|not on the|not in the|not provided|not included|none shown|n/?a\\b|none\\b|no such|does not show|doesn't show|cannot be (?:determined|answered|seen)|can't be (?:determined|answered|seen)|unanswerable)",
29
+ "label": "po_number: not shown"
30
+ }
31
+ ],
32
+ "must_not_claim": [
33
+ {
34
+ "regex": "(?m)^[^\\w\\n]*po[ _]number[^\\w\\n]*:[ \\t*_`\\\"']*\\d",
35
+ "label": "po_number: a value (the invoice has none)"
36
+ }
37
+ ],
38
+ "why": "A page a scanner or a phone delivered sideways; two dates close together, only one asked for.",
39
+ "made_by": "suite/vision_tasks.py"
40
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "id": "vision-label",
3
+ "title": "Read a graded card's label",
4
+ "shape": "vision",
5
+ "images": [
6
+ "label.png"
7
+ ],
8
+ "prompt": "Read the attached image and answer in exactly these lines, one per line, nothing else:\n\ngrader: <company>\ngrade: <the number>\nyear: <year>\nnumber: <card number>\nplayer: <name>\ncert: <certification number>\nsurface: <surface subgrade>\nautograph: <is the card signed: yes or no>\n\nIf the image does not show something, write `not shown` for it. Do not guess.",
9
+ "min_answer_chars": 20,
10
+ "must_find": [
11
+ {
12
+ "regex": "(?m)^[^\\w\\n]*grader[^\\w\\n]*:[ \\t*_`\\\"']*northline",
13
+ "label": "grader: Northline"
14
+ },
15
+ {
16
+ "regex": "(?m)^[^\\w\\n]*grade[^\\w\\n]*:[ \\t*_`\\\"']*(mint\\s*)?9\\b(?!\\.)",
17
+ "label": "grade: 9"
18
+ },
19
+ {
20
+ "regex": "(?m)^[^\\w\\n]*year[^\\w\\n]*:[ \\t*_`\\\"']*2019\\b",
21
+ "label": "year: 2019"
22
+ },
23
+ {
24
+ "regex": "(?m)^[^\\w\\n]*number[^\\w\\n]*:[ \\t*_`\\\"']*#?\\s*148\\b",
25
+ "label": "number: 148"
26
+ },
27
+ {
28
+ "regex": "(?m)^[^\\w\\n]*player[^\\w\\n]*:[ \\t*_`\\\"']*maren okafor",
29
+ "label": "player: Maren Okafor"
30
+ },
31
+ {
32
+ "regex": "(?m)^[^\\w\\n]*cert[^\\w\\n]*:[ \\t*_`\\\"']*#?\\s*40731958\\b",
33
+ "label": "cert: 40731958"
34
+ },
35
+ {
36
+ "regex": "(?m)^[^\\w\\n]*surface[^\\w\\n]*:[ \\t*_`\\\"']*8\\.5\\b",
37
+ "label": "surface: 8.5"
38
+ },
39
+ {
40
+ "regex": "(?m)^[^\\w\\n]*autograph[^\\w\\n]*:[ \\t*_`\\\"']*(no\\b|not shown|not visible|not present|not listed|not given|not stated|not on the|not in the|not provided|not included|none shown|n/?a\\b|none\\b|no such|does not show|doesn't show|cannot be (?:determined|answered|seen)|can't be (?:determined|answered|seen)|unanswerable)",
41
+ "label": "autograph: no, or not shown"
42
+ }
43
+ ],
44
+ "must_not_claim": [
45
+ {
46
+ "regex": "(?m)^[^\\w\\n]*autograph[^\\w\\n]*:[ \\t*_`\\\"']*(yes|signed|auto)",
47
+ "label": "autograph: yes (the label says nothing of one)"
48
+ }
49
+ ],
50
+ "why": "A label read field by field; the cert number must be exact to the digit.",
51
+ "made_by": "suite/vision_tasks.py"
52
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "id": "vision-receipt",
3
+ "title": "Read a receipt",
4
+ "shape": "vision",
5
+ "images": [
6
+ "receipt.png"
7
+ ],
8
+ "prompt": "Read the attached image and answer in exactly these lines, one per line, nothing else:\n\nstore: <name>\ndate: <YYYY-MM-DD>\nitems: <how many line items were bought>\npriciest: <the most expensive item>\ntotal: <amount paid>\ntip: <tip amount>\n\nIf the image does not show something, write `not shown` for it. Do not guess.",
9
+ "min_answer_chars": 20,
10
+ "must_find": [
11
+ {
12
+ "regex": "(?m)^[^\\w\\n]*store[^\\w\\n]*:[ \\t*_`\\\"']*harbor lane",
13
+ "label": "store: Harbor Lane Grocery"
14
+ },
15
+ {
16
+ "regex": "(?m)^[^\\w\\n]*date[^\\w\\n]*:[ \\t*_`\\\"']*2026-03-14\\b",
17
+ "label": "date: 2026-03-14"
18
+ },
19
+ {
20
+ "regex": "(?m)^[^\\w\\n]*items[^\\w\\n]*:[ \\t*_`\\\"']*4\\b",
21
+ "label": "items: 4"
22
+ },
23
+ {
24
+ "regex": "(?m)^[^\\w\\n]*priciest[^\\w\\n]*:[ \\t*_`\\\"']*cheddar",
25
+ "label": "priciest: Cheddar 200g"
26
+ },
27
+ {
28
+ "regex": "(?m)^[^\\w\\n]*total[^\\w\\n]*:[ \\t*_`\\\"']*\\$?\\s*22\\.17\\b",
29
+ "label": "total: 22.17"
30
+ },
31
+ {
32
+ "regex": "(?m)^[^\\w\\n]*tip[^\\w\\n]*:[ \\t*_`\\\"']*(not shown|not visible|not present|not listed|not given|not stated|not on the|not in the|not provided|not included|none shown|n/?a\\b|none\\b|no such|does not show|doesn't show|cannot be (?:determined|answered|seen)|can't be (?:determined|answered|seen)|unanswerable)",
33
+ "label": "tip: not shown"
34
+ }
35
+ ],
36
+ "must_not_claim": [
37
+ {
38
+ "regex": "(?m)^[^\\w\\n]*tip[^\\w\\n]*:[ \\t*_`\\\"']*\\$?\\s*\\d",
39
+ "label": "tip: a value (the receipt shows none)"
40
+ }
41
+ ],
42
+ "why": "Printed text, a date written differently from how it is asked for, a count and a maximum.",
43
+ "made_by": "suite/vision_tasks.py"
44
+ }
@@ -0,0 +1,48 @@
1
+ {
2
+ "id": "vision-screen",
3
+ "title": "Read a settings screen",
4
+ "shape": "vision",
5
+ "images": [
6
+ "screen.png"
7
+ ],
8
+ "prompt": "Read the attached image and answer in exactly these lines, one per line, nothing else:\n\ntoggles_on: <every switch that is on>\nconflict: <the option chosen>\ndisabled: <the button that cannot be pressed>\nerror_code: <code>\naccount: <the signed-in email>\n\nIf the image does not show something, write `not shown` for it. Do not guess.",
9
+ "min_answer_chars": 20,
10
+ "must_find": [
11
+ {
12
+ "regex": "(?m)^[^\\w\\n]*toggles[ _]on[^\\w\\n]*:[ \\t*_`\\\"']*.*back up photos",
13
+ "label": "toggles_on: Back up photos"
14
+ },
15
+ {
16
+ "regex": "(?m)^[^\\w\\n]*toggles[ _]on[^\\w\\n]*:[ \\t*_`\\\"']*.*notify on conflicts",
17
+ "label": "toggles_on: Notify on conflicts"
18
+ },
19
+ {
20
+ "regex": "(?m)^[^\\w\\n]*conflict[^\\w\\n]*:[ \\t*_`\\\"']*keep both",
21
+ "label": "conflict: Keep both"
22
+ },
23
+ {
24
+ "regex": "(?m)^[^\\w\\n]*disabled[^\\w\\n]*:[ \\t*_`\\\"']*sync now",
25
+ "label": "disabled: Sync now"
26
+ },
27
+ {
28
+ "regex": "(?m)^[^\\w\\n]*error[ _]code[^\\w\\n]*:[ \\t*_`\\\"']*e-?213\\b",
29
+ "label": "error_code: E-213"
30
+ },
31
+ {
32
+ "regex": "(?m)^[^\\w\\n]*account[^\\w\\n]*:[ \\t*_`\\\"']*(not shown|not visible|not present|not listed|not given|not stated|not on the|not in the|not provided|not included|none shown|n/?a\\b|none\\b|no such|does not show|doesn't show|cannot be (?:determined|answered|seen)|can't be (?:determined|answered|seen)|unanswerable)",
33
+ "label": "account: not shown"
34
+ }
35
+ ],
36
+ "must_not_claim": [
37
+ {
38
+ "regex": "(?m)^[^\\w\\n]*toggles[ _]on[^\\w\\n]*:[ \\t*_`\\\"']*.*(mobile data|battery)",
39
+ "label": "toggles_on: a switch that is off"
40
+ },
41
+ {
42
+ "regex": "(?m)^[^\\w\\n]*account[^\\w\\n]*:[ \\t*_`\\\"']*\\S+@\\S+",
43
+ "label": "account: an email (the screen shows none)"
44
+ }
45
+ ],
46
+ "why": "A screen's state: which switches are on, which option is picked, which button is greyed out.",
47
+ "made_by": "suite/vision_tasks.py"
48
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "id": "vision-shapes",
3
+ "title": "Count shapes and say where one is",
4
+ "shape": "vision",
5
+ "images": [
6
+ "shapes.png"
7
+ ],
8
+ "prompt": "Read the attached image and answer in exactly these lines, one per line, nothing else:\n\nblue_circles: <how many>\nred_squares: <how many>\ngreen_triangles: <how many>\ntop_right: <the shape in the top-right corner, with its colour>\npurple_shapes: <how many>\n\nIf the image does not show something, write `not shown` for it. Do not guess.",
9
+ "min_answer_chars": 20,
10
+ "must_find": [
11
+ {
12
+ "regex": "(?m)^[^\\w\\n]*blue[ _]circles[^\\w\\n]*:[ \\t*_`\\\"']*6\\b",
13
+ "label": "blue_circles: 6"
14
+ },
15
+ {
16
+ "regex": "(?m)^[^\\w\\n]*red[ _]squares[^\\w\\n]*:[ \\t*_`\\\"']*3\\b",
17
+ "label": "red_squares: 3"
18
+ },
19
+ {
20
+ "regex": "(?m)^[^\\w\\n]*green[ _]triangles[^\\w\\n]*:[ \\t*_`\\\"']*2\\b",
21
+ "label": "green_triangles: 2"
22
+ },
23
+ {
24
+ "regex": "(?m)^[^\\w\\n]*top[ _]right[^\\w\\n]*:[ \\t*_`\\\"']*.*star",
25
+ "label": "top_right: a yellow star"
26
+ },
27
+ {
28
+ "regex": "(?m)^[^\\w\\n]*purple[ _]shapes[^\\w\\n]*:[ \\t*_`\\\"']*(0\\b|none\\b|zero\\b|no\\b|not shown|not visible|not present|not listed|not given|not stated|not on the|not in the|not provided|not included|none shown|n/?a\\b|none\\b|no such|does not show|doesn't show|cannot be (?:determined|answered|seen)|can't be (?:determined|answered|seen)|unanswerable)",
29
+ "label": "purple_shapes: 0"
30
+ }
31
+ ],
32
+ "must_not_claim": [
33
+ {
34
+ "regex": "(?m)^[^\\w\\n]*purple[ _]shapes[^\\w\\n]*:[ \\t*_`\\\"']*[1-9]",
35
+ "label": "purple_shapes: a count above 0 (there are none)"
36
+ }
37
+ ],
38
+ "why": "Counting to six without losing one, and a corner told apart from its neighbours.",
39
+ "made_by": "suite/vision_tasks.py"
40
+ }