@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.
Files changed (38) hide show
  1. package/README.md +19 -4
  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,201 @@
1
+ """The vision tasks: each answer key takes a right answer whole and catches a
2
+ guess on its trap; a vision task goes only to a model that takes images, a
3
+ text task only to one that is not images only; the shipped tasks reach the
4
+ data folder without touching its saved answers.
5
+
6
+ python suite/test_vision_tasks.py
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import pathlib
13
+ import sys
14
+ import tempfile
15
+
16
+ HERE = pathlib.Path(__file__).resolve().parent
17
+ sys.path.insert(0, str(HERE))
18
+ import run # noqa: E402
19
+
20
+ RIGHT = {
21
+ "vision-receipt": "store: Harbor Lane Grocery\ndate: 2026-03-14\nitems: 4\npriciest: Cheddar 200g\n"
22
+ "total: $22.17\ntip: not shown",
23
+ "vision-label": "**grader:** Northline Grading\ngrade: MINT 9\nyear: 2019\nnumber: #148\nplayer: Maren Okafor\n"
24
+ "cert: 40731958\nsurface: 8.5\nautograph: no",
25
+ "vision-bars": "tallest: Billing\nshortest: Data\nweb: 35\nover_30: 2\ntarget: not shown",
26
+ "vision-table": "south_q3: 1,120\nwest_q1: 870\ncentral_q3: 1098\nq4_highest: East\nnortheast_q2: not shown",
27
+ "vision-screen": "toggles_on: Back up photos, Notify on conflicts\nconflict: Keep both\ndisabled: Sync now\n"
28
+ "error_code: E-213\naccount: not shown",
29
+ "vision-shapes": "- blue_circles: 6\n- red_squares: 3\n- green_triangles: 2\n- top_right: yellow star\n"
30
+ "- purple_shapes: 0",
31
+ "vision-invoice": "invoice: INV-2026-0417\ndue: 2026-05-02\namount_due: EUR 1,284.50\nbill_to: Kestrel Labs BV\n"
32
+ "po_number: not shown",
33
+ }
34
+ # Each one guesses the trap, and gets one field wrong besides.
35
+ GUESS = {
36
+ "vision-receipt": RIGHT["vision-receipt"].replace("tip: not shown", "tip: $3.00").replace("items: 4", "items: 5"),
37
+ "vision-label": RIGHT["vision-label"].replace("autograph: no", "autograph: yes").replace("40731958", "40781958"),
38
+ "vision-bars": RIGHT["vision-bars"].replace("target: not shown", "target: 40").replace("over_30: 2", "over_30: 3"),
39
+ "vision-table": RIGHT["vision-table"].replace("northeast_q2: not shown", "northeast_q2: 1,210")
40
+ .replace("central_q3: 1098", "central_q3: 1160"),
41
+ "vision-screen": RIGHT["vision-screen"].replace("account: not shown", "account: sam@example.com")
42
+ .replace("Notify on conflicts", "Notify on conflicts, Sync on mobile data"),
43
+ "vision-shapes": RIGHT["vision-shapes"].replace("purple_shapes: 0", "purple_shapes: 1")
44
+ .replace("blue_circles: 6", "blue_circles: 5"),
45
+ "vision-invoice": RIGHT["vision-invoice"].replace("po_number: not shown", "po_number: 7781")
46
+ .replace("due: 2026-05-02", "due: 2026-04-02"),
47
+ }
48
+
49
+
50
+ def main() -> None:
51
+ specs = {}
52
+ for path in sorted((HERE / "tasks").glob("vision-*/task.json")):
53
+ spec = json.loads(path.read_text(encoding="utf-8"))
54
+ spec["dir"] = path.parent
55
+ specs[spec["id"]] = spec
56
+ assert set(specs) == set(RIGHT), sorted(specs)
57
+ for tid, spec in specs.items():
58
+ assert spec["shape"] == "vision" and spec["images"], tid
59
+ assert all((spec["dir"] / "fixtures" / n).exists() for n in spec["images"]), tid
60
+ good = run.score(spec, RIGHT[tid])
61
+ assert good["kind"] == "answered" and good["found"] == good["of"] and not good["invented"], (tid, good)
62
+ bad = run.score(spec, GUESS[tid])
63
+ assert bad["found"] < bad["of"] and bad["invented"], (tid, bad)
64
+ assert run.image_urls(spec)[0].startswith("data:image/png;base64,"), tid
65
+
66
+ # Who each task is for: pictures to a model that takes them, text to one that is not images only.
67
+ text, pic = {"id": "t"}, {"id": "v", "images": ["x.png"]}
68
+ skip, seeing = {"eyes-only"}, {"eyes-only": True, "general-sees": True, "general-blind": False}
69
+ assert run.applies(text, "general-sees", skip, seeing) and run.applies(pic, "general-sees", skip, seeing)
70
+ assert run.applies(text, "general-blind", skip, seeing) and not run.applies(pic, "general-blind", skip, seeing)
71
+ assert not run.applies(text, "eyes-only", skip, seeing) and run.applies(pic, "eyes-only", skip, seeing)
72
+
73
+ # The shipped tasks reach the data folder; its saved answers stay.
74
+ kept = run.TASKS, run.SETTINGS
75
+ with tempfile.TemporaryDirectory() as tmp:
76
+ run.TASKS = pathlib.Path(tmp)
77
+ run.SETTINGS = pathlib.Path(tmp) / "suite.json"
78
+ try:
79
+ # A hold keeps them out of every run until its day; --task still runs one.
80
+ run.SETTINGS.write_text(json.dumps({"vision_from": "2999-01-01"}), encoding="utf-8")
81
+ assert run.vision_held() == "2999-01-01"
82
+ assert not [t for t in run.load_tasks(None) if t.get("images")]
83
+ assert run.load_tasks("vision-bars")[0]["images"]
84
+ run.SETTINGS.write_text(json.dumps({"vision_from": "2000-01-01"}), encoding="utf-8")
85
+ assert run.vision_held() == "" and len([t for t in run.load_tasks(None) if t.get("images")]) == len(RIGHT)
86
+ saved = run.TASKS / "vision-bars" / "output" / "m.md"
87
+ saved.parent.mkdir(parents=True)
88
+ saved.write_text("an answer", encoding="utf-8")
89
+ (run.TASKS / "vision-bars" / "task.json").write_text("{}", encoding="utf-8") # an old key
90
+ run.seed_shipped()
91
+ for tid in RIGHT:
92
+ assert (run.TASKS / tid / "task.json").read_bytes() == (HERE / "tasks" / tid / "task.json").read_bytes()
93
+ assert saved.read_text(encoding="utf-8") == "an answer"
94
+ finally:
95
+ run.TASKS, run.SETTINGS = kept
96
+ panel()
97
+ sample()
98
+ print("ok")
99
+
100
+
101
+ def sample() -> None:
102
+ """vision_sample.py builds each source's key from rows shaped as datasets-server
103
+ returns them (no network: rows and pictures stand in)."""
104
+ import vision_sample as vs
105
+ kept = vs.rows, vs.image
106
+ receipt = {"menu": [{"nm": "EGG TART", "cnt": "1", "price": "13,000"}, {"nm": "TEA", "cnt": "2", "price": "8,000"}],
107
+ "total": {"total_price": "21.000", "cashprice": "50.000", "changeprice": "29.000"}}
108
+ data = {
109
+ "naver-clova-ix/cord-v2": [{"ground_truth": json.dumps({"gt_parse": receipt})},
110
+ {"ground_truth": json.dumps({"gt_parse": {**receipt, "total": {
111
+ **receipt["total"], "creditcardprice": "21.000"}}})}], # paid by card: skipped
112
+ "bevaya/RICO-ScreenQA": [
113
+ {"screen_id": "7", "question": "What is the version?",
114
+ "ground_truth": [{"full_answer": "It is 1.69", "ui_elements": [{"text": "1.69"}]}]},
115
+ {"screen_id": "7", "question": "Is GPS on?", "ground_truth": [{"full_answer": "on", "ui_elements": [{"text": "ON"}]}]},
116
+ {"screen_id": "7", "question": "What is the lawn type?", "ground_truth": [{"full_answer": "<no answer>", "ui_elements": []}]}],
117
+ "lmms-lab-encoder/POPE": [{"image_source": "COCO_val2014_000000000042", "question": f"Is there a {t} in the image?",
118
+ "answer": a} for t, a in (("dog", "yes"), ("cat", "no"), ("ball", "yes"), ("car", "no"))],
119
+ }
120
+ vs.rows = lambda dataset, config, split, offset, length: data.get(dataset, [])
121
+ vs.image = lambda row, field="image": (b"\xff\xd8", ".jpg")
122
+ try:
123
+ (c, _, _), = list(vs.cord(0, 10))
124
+ (s, _, _), = list(vs.screenqa(0, 10))
125
+ (p, _, _), = list(vs.pope(0, 10))
126
+ finally:
127
+ vs.rows, vs.image = kept
128
+ for spec, right, wrong in (
129
+ (c, "total: Rp 21.000\ncash: 50,000\nchange: 29000\nitems: 2\ncard: not shown", "card: 4417"),
130
+ (s, "\n".join(f"q{n}: {a}" for n, a in enumerate(
131
+ ["not shown" if "lawn" in ln else "1.69" if "version" in ln else "on"
132
+ for ln in s["prompt"].splitlines() if ln.startswith("q")], 1)), None),
133
+ (p, "\n".join(f"q{n}: {'yes' if ('dog' in ln or 'ball' in ln) else 'no'}"
134
+ for n, ln in enumerate((ln for ln in p["prompt"].splitlines() if ln.startswith("q")), 1)), None)):
135
+ got = run.score(spec, right)
136
+ assert got["found"] == got["of"] and not got["invented"], (spec["id"], got, right)
137
+ assert run.score(c, "total: 21.000\ncard: 4417")["invented"], c
138
+ yes = "\n".join(f"q{n}: yes" for n in range(1, 5))
139
+ assert len(run.score(p, yes)["invented"]) == 2 # a yes to each absent thing
140
+
141
+
142
+ def panel() -> None:
143
+ """serve.vision_rows: the tasks field by field, and real work by how it was judged."""
144
+ sys.path.insert(0, str(HERE.parent))
145
+ import offload
146
+ import serve
147
+ kept = serve.DATA, serve.on_bench, offload.LOG, serve._vision_work
148
+ with tempfile.TemporaryDirectory() as tmp:
149
+ data = pathlib.Path(tmp)
150
+ for tid in ("vision-bars", "vision-shapes"):
151
+ out = data / "suite" / "tasks" / tid / "output"
152
+ out.mkdir(parents=True)
153
+ (out.parent / "task.json").write_bytes((HERE / "tasks" / tid / "task.json").read_bytes())
154
+ (out / "sharp.md").write_text(RIGHT[tid], encoding="utf-8")
155
+ (out / "guesser.md").write_text(GUESS[tid], encoding="utf-8")
156
+ (out / "gone.md").write_text(RIGHT[tid], encoding="utf-8") # not on the bench now
157
+ (data / "suite" / "tasks" / "vision-bars" / "output" / "down.md").write_text(
158
+ "Error: Error code: 503 - service unavailable", encoding="utf-8")
159
+ seats = [{"call": "p1.m1", "alias": "sharp", "status": "ok", "answer": "x", "seconds": 4},
160
+ {"call": "p1.m2", "alias": "guesser", "status": "ok", "answer": "x", "seconds": 9},
161
+ {"call": "p2.m1", "alias": "sharp", "status": "ok", "answer": "x", "seconds": 6},
162
+ {"call": "p2.m2", "alias": "guesser", "status": "error", "answer": "", "seconds": 1},
163
+ {"call": "p3.m1", "alias": "sharp", "status": "ok", "answer": "x", "seconds": 5}]
164
+ (data / "runs" / "gateway").mkdir(parents=True)
165
+ (data / "runs" / "gateway" / "calls.jsonl").write_text(
166
+ json.dumps({"shape": "vision", "seats": seats[:2]}) + "\n" + json.dumps({"shape": "vision", "seats": seats[2:4]})
167
+ + "\n" + json.dumps({"shape": "general", "seats": [dict(seats[0], call="p9.m1")]})
168
+ + "\n" + json.dumps({"shape": "vision", "seats": seats[4:]}) + "\n", encoding="utf-8")
169
+ (data / "metrics").mkdir()
170
+ log = data / "metrics" / "offloads.jsonl"
171
+ log.write_text("\n".join(json.dumps(e) for e in (
172
+ {"date": "2026-10-27", "model": "sharp", "task": "t", "call": "p1.m1", "useful": True,
173
+ "real": ["grade=9", "cert=1"], "false": []},
174
+ {"date": "2026-10-27", "model": "guesser", "task": "t", "call": "p1.m2", "useful": False,
175
+ "real": ["grade=9"], "false": ["cert=2"]},
176
+ {"date": "2026-10-27", "model": "sharp", "task": "t", "call": "p2.m1", "useful": True,
177
+ "note": "agreed with another seat"})) + "\n", encoding="utf-8")
178
+ serve.DATA, offload.LOG, serve._vision_work = data, log, (None, {})
179
+ serve.on_bench = lambda: {"sharp", "guesser", "down"}
180
+ try:
181
+ v = serve.vision_rows()
182
+ finally:
183
+ serve.DATA, serve.on_bench, offload.LOG, serve._vision_work = kept
184
+ s = v["summary"]
185
+ assert set(s) == {"sharp", "guesser", "down"}, s # "gone" is not on the bench
186
+ assert s["sharp"] == {"asked": 2, "answered": 2, "fields": 10, "right": 10, "traps": 2, "taken": 0}, s["sharp"]
187
+ assert s["guesser"]["right"] == 6 and s["guesser"]["taken"] == 2, s["guesser"]
188
+ assert s["down"] == {"asked": 1, "answered": 0, "fields": 0, "right": 0, "traps": 0, "taken": 0}, s["down"]
189
+ states = {(h["model"], h["task"], h["field"]): h["state"] for h in v["heat"]}
190
+ assert states[("guesser", "vision-bars", "over_30")] == "misread"
191
+ assert states[("guesser", "vision-bars", "target")] == "trap taken"
192
+ assert states[("down", "vision-bars", "web")] == "no answer"
193
+ w = v["work"]
194
+ assert w["sharp"]["checked, helped"] == 1 and w["sharp"]["useful, no claims"] == 1 and w["sharp"]["not judged"] == 1, w
195
+ assert (w["sharp"]["real"], w["sharp"]["false"], w["sharp"]["seconds"]) == (2, 0, 5.0), w["sharp"]
196
+ assert w["guesser"]["checked, did not help"] == 1 and w["guesser"]["no answer"] == 1, w["guesser"]
197
+ assert (w["guesser"]["real"], w["guesser"]["false"]) == (1, 1), w["guesser"]
198
+
199
+
200
+ if __name__ == "__main__":
201
+ main()
@@ -0,0 +1,74 @@
1
+ """How every vision task asks and is scored: answers as `key: value` lines,
2
+ a key per field, `not shown` for what the picture does not show.
3
+
4
+ Shared by suite/vision_tasks.py (the made pictures), suite/vision_sample.py
5
+ (items from public datasets) and any task written by hand, so one answer
6
+ style is scored one way. Standard library only.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ NOT_SHOWN = (r"(not shown|not visible|not present|not listed|not given|not stated|not on the|not in the"
14
+ r"|not provided|not included|none shown|n/?a\b|none\b|no such|does not show|doesn't show"
15
+ r"|cannot be (?:determined|answered|seen)|can't be (?:determined|answered|seen)|unanswerable)")
16
+ PROMPT = ("Read the attached image and answer in exactly these lines, one per line, nothing else:\n\n{lines}\n\n"
17
+ "If the image does not show something, write `not shown` for it. Do not guess.")
18
+
19
+
20
+ def key(name: str, value: str) -> str:
21
+ """A regex for the line `name: value`, markdown around the key allowed."""
22
+ k = re.escape(name).replace("_", "[ _]")
23
+ return rf"(?m)^[^\w\n]*{k}[^\w\n]*:[ \t*_`\"']*{value}"
24
+
25
+
26
+ def clause(name: str, value: str, label: str) -> dict:
27
+ return {"regex": key(name, value), "label": label}
28
+
29
+
30
+ def not_shown(name: str) -> tuple[dict, dict]:
31
+ """(must_find, must_not_claim) for a field the picture does not answer:
32
+ `not shown` is right, any other value an invention."""
33
+ return (clause(name, NOT_SHOWN, f"{name}: not shown"),
34
+ clause(name, rf"(?!{NOT_SHOWN}|unknown|unclear)\w", f"{name}: a value (the picture does not answer it)"))
35
+
36
+
37
+ def number(text: str) -> str:
38
+ """A regex for a printed amount however its thousands are separated:
39
+ "45,500" also matches 45.500, 45 500 and 45500; a currency word or sign
40
+ before it is allowed."""
41
+ digits = re.sub(r"[^\d.,]", "", text)
42
+ m = re.fullmatch(r"(.*?)[.,](\d{1,2})", digits) # one or two digits after the last mark: cents
43
+ whole, cents = (m.group(1), m.group(2)) if m else (digits, None)
44
+ groups = [g for g in re.split(r"[.,]", whole) if g]
45
+ body = r"[.,\s]?".join(groups)
46
+ if cents:
47
+ body += rf"[.,]{cents}"
48
+ return rf"(?:[a-z]{{1,3}}\.?\s*|[$€£¥]\s*)?{body}(?!\d)"
49
+
50
+
51
+ def phrase(text: str) -> str:
52
+ """A regex for a short answer anywhere on its line, spacing and case loose."""
53
+ words = [re.escape(w) for w in text.lower().split()]
54
+ return r".*" + r"\s+".join(words)
55
+
56
+
57
+ def _selftest() -> None:
58
+ n = number("45,500")
59
+ for ok in ("45,500", "45.500", "45 500", "45500", "rp 45.500", "Rp. 45,500"):
60
+ assert re.fullmatch(n, ok.lower()), (ok, n)
61
+ for bad in ("45,5000", "455,00"):
62
+ assert not re.fullmatch(n, bad), (bad, n)
63
+ assert re.fullmatch(number("3.14%").removesuffix("(?!\\d)"), "3.14"), number("3.14%")
64
+ assert re.fullmatch(number("$1,284.50"), "eur 1,284.50") and re.fullmatch(number("23.000"), "23000")
65
+ find, claim = not_shown("q3")
66
+ # Answers arrive lowercased (run.score).
67
+ assert re.search(find["regex"], "q3: not shown") and not re.search(claim["regex"], "q3: not shown")
68
+ assert re.search(claim["regex"], "q3: 12 questions") and re.search(find["regex"], "**q3**: cannot be determined")
69
+ assert re.search(key("q1", phrase("Unauthorized transactions")), "q1: from unauthorized transactions.")
70
+
71
+
72
+ if __name__ == "__main__":
73
+ _selftest()
74
+ print("ok")
@@ -0,0 +1,232 @@
1
+ """Vision tasks sampled from public datasets, each item's answer verified by people.
2
+
3
+ python suite/vision_sample.py --count 5 # five of each source
4
+ python suite/vision_sample.py --source cord --count 3 # one source
5
+ python suite/vision_sample.py --dry-run # what would be written
6
+
7
+ The made pictures (suite/vision_tasks.py) are clean renders: exact keys, never
8
+ seen in training, but no blur, glare or crumpled paper. These are the real
9
+ thing, read through Hugging Face's datasets-server like suite/rebench.py, no
10
+ `datasets` package:
11
+
12
+ cord naver-clova-ix/cord-v2 (CC BY 4.0): photographed receipts with
13
+ their totals, cash and change as annotated. Cash receipts only, so
14
+ a card number is a field the picture does not show.
15
+ screenqa bevaya/RICO-ScreenQA (CC BY 4.0): app screenshots, two questions
16
+ people answered from the screen and one they marked `<no answer>`.
17
+ tablevqa terryoo/TableVQA-Bench (CC BY 4.0): tables from financial reports
18
+ and Wikipedia as images, a short answer each.
19
+ pope lmms-lab-encoder/POPE (no licence on its card; COCO photos): "is
20
+ there a <thing> in the image?", two present and two absent, the
21
+ absent ones chosen to be likely (its adversarial split). A yes to
22
+ an absent thing is the invention.
23
+
24
+ Written to the data folder, never the repository: each install samples its
25
+ own, so nothing of theirs is redistributed. Written ready to run, not as
26
+ drafts: the answers are the datasets' own, checked by people. Look at a few
27
+ anyway (`--dry-run` prints each key); a source's quirks show in its first items.
28
+
29
+ POPE's photos are often in training sets (COCO is everywhere); the receipts,
30
+ screens and tables less so, and the made pictures and your own photos not
31
+ at all. The Vision panel shows each task, so a source that is too easy shows.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import hashlib
38
+ import json
39
+ import pathlib
40
+ import re
41
+ import sys
42
+ import urllib.parse
43
+ import urllib.request
44
+
45
+ HERE = pathlib.Path(__file__).resolve().parent
46
+ sys.path.insert(0, str(HERE))
47
+ from rebench import api # noqa: E402 -- datasets-server JSON, retrying its busy 500s
48
+ from vision_keys import NOT_SHOWN, PROMPT, clause, key, not_shown, number, phrase # noqa: E402
49
+
50
+ sys.path.insert(0, str(HERE.parent / "metrics"))
51
+ from paths import DATA # noqa: E402
52
+ TASKS = DATA / "suite" / "tasks"
53
+ PAGE = 100 # datasets-server's largest page
54
+
55
+
56
+ def rows(dataset: str, config: str, split: str, offset: int, length: int) -> list[dict]:
57
+ out = []
58
+ while len(out) < length:
59
+ page = api("rows", dataset=dataset, config=config, split=split, offset=offset + len(out),
60
+ length=min(PAGE, length - len(out)))
61
+ got = [r["row"] for r in page.get("rows", [])]
62
+ out += got
63
+ if len(got) < PAGE:
64
+ break
65
+ return out
66
+
67
+
68
+ def image(row: dict, field: str = "image") -> tuple[bytes, str]:
69
+ """The row's picture, fetched now: datasets-server's links expire."""
70
+ src = row[field]["src"]
71
+ req = urllib.request.Request(src, headers={"User-Agent": "benchwarmers"})
72
+ with urllib.request.urlopen(req, timeout=90) as r:
73
+ data = r.read()
74
+ ext = pathlib.PurePosixPath(urllib.parse.urlparse(src).path).suffix.lower() or ".jpg"
75
+ return data, ext
76
+
77
+
78
+ def alternatives(name: str, answers: list[str], label: str) -> dict:
79
+ """Any of several accepted answers, anywhere on the field's line."""
80
+ alts = "|".join(phrase(a)[2:] for a in answers)
81
+ return clause(name, rf".*(?:{alts})", label)
82
+
83
+
84
+ def spec(task_id: str, title: str, source: str, dataset: str, row_ref: str, licence: str,
85
+ lines: list[str], find: list[dict], not_claim: list[dict], why: str) -> dict:
86
+ return {"id": task_id, "title": title, "shape": "vision", "prompt": PROMPT.format(lines="\n".join(lines)),
87
+ "min_answer_chars": 8, "must_find": find, "must_not_claim": not_claim, "why": why,
88
+ "source": f"{dataset} {row_ref}", "source_url": f"https://huggingface.co/datasets/{dataset}",
89
+ "license": licence, "made_by": f"suite/vision_sample.py --source {source}"}
90
+
91
+
92
+ # ---- the sources: each yields (spec without images, picture bytes, extension) ----
93
+
94
+ def cord(offset: int, scan: int):
95
+ """Cash receipts: total, cash given, change, how many menu lines."""
96
+ ds = "naver-clova-ix/cord-v2"
97
+ for i, r in enumerate(rows(ds, "default", "test", offset, scan)):
98
+ gt = json.loads(r["ground_truth"]).get("gt_parse", {})
99
+ menu, total = gt.get("menu"), gt.get("total") or {}
100
+ menu = [menu] if isinstance(menu, dict) else menu or []
101
+ paid = {k: total.get(k) for k in ("total_price", "cashprice", "changeprice")}
102
+ if (not all(isinstance(v, str) and re.search(r"\d", v) for v in paid.values())
103
+ or any(k in total for k in ("creditcardprice", "emoneyprice"))
104
+ or not 1 <= len(menu) <= 8 or any("sub" in m for m in menu)):
105
+ continue
106
+ find = [clause("total", number(paid["total_price"]), f"total: {paid['total_price']}"),
107
+ clause("cash", number(paid["cashprice"]), f"cash: {paid['cashprice']}"),
108
+ clause("change", number(paid["changeprice"]), f"change: {paid['changeprice']}"),
109
+ clause("items", rf"{len(menu)}\b", f"items: {len(menu)}")]
110
+ trap_find, trap_claim = not_shown("card")
111
+ yield (spec(f"vision-cord-{offset + i}", "Read a photographed receipt", "cord", ds, f"test row {offset + i}",
112
+ "CC BY 4.0",
113
+ ["total: <the total to pay>", "cash: <cash given>", "change: <change given back>",
114
+ "items: <how many different items are listed>", "card: <the last four digits of the card used>"],
115
+ find + [trap_find], [trap_claim],
116
+ "A receipt as photographed, from CORD v2 (Park et al., 2019). Paid in cash, so no card is"
117
+ " shown. Items: " + "; ".join(f"{m.get('nm', '?')} x{m.get('cnt', '?')}" for m in menu)),
118
+ *image(r))
119
+
120
+
121
+ def screenqa(offset: int, scan: int):
122
+ """App screens: two questions answered from the screen, one it cannot answer."""
123
+ ds = "bevaya/RICO-ScreenQA"
124
+ screens: dict[str, list[dict]] = {}
125
+ for r in rows(ds, "default", "test", offset, scan):
126
+ screens.setdefault(r["screen_id"], []).append(r)
127
+ for sid, qs in screens.items():
128
+ def short(q):
129
+ return sorted({g["ui_elements"][0]["text"].strip() for g in q["ground_truth"] if g["ui_elements"]})
130
+ answered = [q for q in qs if 1 <= len(short(q)) <= 2 and all(0 < len(a) <= 40 for a in short(q))]
131
+ blank = [q for q in qs if all(g["full_answer"] == "<no answer>" for g in q["ground_truth"])]
132
+ if len(answered) < 2 or not blank:
133
+ continue
134
+ picked = answered[:2]
135
+ at = int(hashlib.sha1(sid.encode()).hexdigest(), 16) % 3 # where the unanswerable one sits
136
+ picked.insert(at, blank[0])
137
+ lines, find, claim = [], [], []
138
+ for n, q in enumerate(picked, 1):
139
+ name = f"q{n}"
140
+ lines.append(f"{name}: <answer to: {q['question']}>")
141
+ if q is blank[0]:
142
+ f, c = not_shown(name)
143
+ find.append(f)
144
+ claim.append(c)
145
+ else:
146
+ find.append(alternatives(name, short(q), f"{name}: {' or '.join(short(q))}"))
147
+ yield (spec(f"vision-screenqa-{sid}", "Answer questions about an app screen", "screenqa", ds,
148
+ f"screen {sid}", "CC BY 4.0", lines, find, claim,
149
+ "A RICO app screenshot with questions from ScreenQA (Hsiao et al., 2022); people marked one"
150
+ " as having no answer on this screen."),
151
+ *image(picked[0]))
152
+
153
+
154
+ def tablevqa(offset: int, scan: int):
155
+ """Tables as images: one question, one short answer."""
156
+ ds = "terryoo/TableVQA-Bench"
157
+ splits = ("fintabnetqa", "vwtq")
158
+ pages = [list(enumerate(rows(ds, "default", s, offset, scan // 2))) for s in splits]
159
+ # The two kinds in turn: a report's table, then a Wikipedia one.
160
+ for (i, r), split in ((item, s) for pair in zip(*pages) for item, s in zip(pair, splits)):
161
+ gt = (r.get("gt") or "").strip().rstrip(".")
162
+ if not gt or len(gt) > 24 or "," in gt and not re.fullmatch(r"[\d,.$%()-]+", gt):
163
+ continue
164
+ value = number(gt) if re.fullmatch(r"[\d,.$%()\s-]+", gt) and re.search(r"\d", gt) else phrase(gt)[2:]
165
+ yield (spec(f"vision-tablevqa-{split}-{offset + i}", "Read a table", "tablevqa", ds,
166
+ f"{split} row {offset + i}", "CC BY 4.0", [f"answer: <answer to: {r['question']}>"],
167
+ # Anywhere on the line, but whole: 3.14 is not the end of 13.14.
168
+ [clause("answer", rf".*(?<![\w.,]){value}", f"answer: {gt}")], [],
169
+ f"A table from {'a financial report (FinTabNet)' if split == 'fintabnetqa' else 'Wikipedia (WTQ)'},"
170
+ " via TableVQA-Bench (Kim et al., 2024)."),
171
+ *image(r))
172
+
173
+
174
+ def pope(offset: int, scan: int):
175
+ """Photos: is there a <thing>? Two present, two absent but likely."""
176
+ ds = "lmms-lab-encoder/POPE"
177
+ photos: dict[str, list[dict]] = {}
178
+ for r in rows(ds, "Full", "adversarial", offset, scan):
179
+ photos.setdefault(r["image_source"], []).append(r)
180
+ for src, qs in photos.items():
181
+ yes = [q for q in qs if q["answer"].strip().lower() == "yes"][:2]
182
+ no = [q for q in qs if q["answer"].strip().lower() == "no"][:2]
183
+ if len(yes) < 2 or len(no) < 2:
184
+ continue
185
+ order = [yes[0], no[0], no[1], yes[1]] if int(hashlib.sha1(src.encode()).hexdigest(), 16) % 2 else \
186
+ [no[0], yes[0], yes[1], no[1]]
187
+ lines, find, claim = [], [], []
188
+ for n, q in enumerate(order, 1):
189
+ name, thing = f"q{n}", re.sub(r"^is there an? |\s*in the image\?$", "", q["question"].lower())
190
+ lines.append(f"{name}: <yes or no: {q['question']}>")
191
+ find.append(clause(name, rf"{q['answer'].lower()}\b", f"{name}: {q['answer'].lower()} ({thing})"))
192
+ if q["answer"].lower() == "no":
193
+ claim.append(clause(name, r"yes\b", f"{name}: yes, a {thing} (there is none)"))
194
+ yield (spec(f"vision-pope-{src.rsplit('_', 1)[-1].lstrip('0')}", "Say which things a photo shows", "pope", ds,
195
+ src, "no licence on the dataset card; COCO photos", lines, find, claim,
196
+ f"COCO photo {src}, questions from POPE's adversarial split (Li et al., 2023): the absent"
197
+ " things are ones that often appear with what is there."),
198
+ *image(qs[0]))
199
+
200
+
201
+ SOURCES = {"cord": cord, "screenqa": screenqa, "tablevqa": tablevqa, "pope": pope}
202
+
203
+
204
+ def main() -> None:
205
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
206
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
207
+ ap.add_argument("--source", choices=[*SOURCES, "all"], default="all")
208
+ ap.add_argument("--count", type=int, default=5, help="tasks to write per source")
209
+ ap.add_argument("--offset", type=int, default=0, help="start this far into each source's rows")
210
+ ap.add_argument("--scan", type=int, default=300, help="rows to read per source, at most")
211
+ ap.add_argument("--dry-run", action="store_true", help="fetch and print each key, write nothing")
212
+ args = ap.parse_args()
213
+ for name in SOURCES if args.source == "all" else [args.source]:
214
+ made = 0
215
+ for s, picture, ext in SOURCES[name](args.offset, args.scan):
216
+ folder = TASKS / s["id"]
217
+ if folder.exists():
218
+ continue # sampled before: kept, answers and all
219
+ made += 1
220
+ print(f"{s['id']}: " + "; ".join(c["label"] for c in s["must_find"]))
221
+ if not args.dry_run:
222
+ (folder / "fixtures").mkdir(parents=True)
223
+ s["images"] = [f"{s['id'].removeprefix('vision-')}{ext}"]
224
+ (folder / "fixtures" / s["images"][0]).write_bytes(picture)
225
+ (folder / "task.json").write_text(json.dumps(s, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
226
+ if made >= args.count:
227
+ break
228
+ print(f"{name}: {made} {'would be ' if args.dry_run else ''}written")
229
+
230
+
231
+ if __name__ == "__main__":
232
+ main()