benchkit-cli 0.1.0__py3-none-any.whl

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 (149) hide show
  1. benchkit/__init__.py +1 -0
  2. benchkit/_pi_proxy.py +219 -0
  3. benchkit/answer_key_guard.ts +49 -0
  4. benchkit/artifacts.py +87 -0
  5. benchkit/benchmarks/__init__.py +105 -0
  6. benchkit/benchmarks/aider_polyglot.py +405 -0
  7. benchkit/benchmarks/arc.py +52 -0
  8. benchkit/benchmarks/banking77.py +149 -0
  9. benchkit/benchmarks/base.py +19 -0
  10. benchkit/benchmarks/boolq.py +49 -0
  11. benchkit/benchmarks/chartqa.py +133 -0
  12. benchkit/benchmarks/code_verifier.py +77 -0
  13. benchkit/benchmarks/downloads.py +166 -0
  14. benchkit/benchmarks/evalplus.py +288 -0
  15. benchkit/benchmarks/git_surgery.py +485 -0
  16. benchkit/benchmarks/gpqa.py +56 -0
  17. benchkit/benchmarks/gsm8k.py +97 -0
  18. benchkit/benchmarks/hellaswag.py +49 -0
  19. benchkit/benchmarks/humaneval.py +103 -0
  20. benchkit/benchmarks/ifeval.py +56 -0
  21. benchkit/benchmarks/ifeval_instructions.py +477 -0
  22. benchkit/benchmarks/mbpp.py +60 -0
  23. benchkit/benchmarks/mc_arena.py +479 -0
  24. benchkit/benchmarks/mcq.py +94 -0
  25. benchkit/benchmarks/medfailbench.py +537 -0
  26. benchkit/benchmarks/mmlu.py +54 -0
  27. benchkit/benchmarks/mmlu_pro.py +62 -0
  28. benchkit/benchmarks/mmvp.py +115 -0
  29. benchkit/benchmarks/ocrbench.py +96 -0
  30. benchkit/benchmarks/openbookqa.py +52 -0
  31. benchkit/benchmarks/patcheval.py +929 -0
  32. benchkit/benchmarks/piqa.py +52 -0
  33. benchkit/benchmarks/ruler.py +172 -0
  34. benchkit/benchmarks/ruler_generator.py +535 -0
  35. benchkit/benchmarks/sanity.py +174 -0
  36. benchkit/benchmarks/treejs_arena.py +268 -0
  37. benchkit/benchmarks/truthfulqa.py +52 -0
  38. benchkit/benchmarks/utils.py +25 -0
  39. benchkit/benchmarks/vision.py +230 -0
  40. benchkit/benchmarks/vstar.py +108 -0
  41. benchkit/benchmarks/winogrande.py +52 -0
  42. benchkit/benchmarks/xstest.py +116 -0
  43. benchkit/browser.py +429 -0
  44. benchkit/catalog.py +121 -0
  45. benchkit/cli.py +1000 -0
  46. benchkit/client.py +1413 -0
  47. benchkit/controls.py +87 -0
  48. benchkit/datasets/MMLU_LICENSE.txt +21 -0
  49. benchkit/datasets/README.md +142 -0
  50. benchkit/datasets/arc_challenge.jsonl +1172 -0
  51. benchkit/datasets/banking77.jsonl +3080 -0
  52. benchkit/datasets/banking77_intents.json +79 -0
  53. benchkit/datasets/boolq.jsonl +3270 -0
  54. benchkit/datasets/code_verifier.jsonl +500 -0
  55. benchkit/datasets/gpqa_diamond.jsonl +198 -0
  56. benchkit/datasets/gsm8k.jsonl +1319 -0
  57. benchkit/datasets/hellaswag.jsonl +1000 -0
  58. benchkit/datasets/humaneval.jsonl +164 -0
  59. benchkit/datasets/ifeval.jsonl +541 -0
  60. benchkit/datasets/mbpp.jsonl +500 -0
  61. benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
  62. benchkit/datasets/medfailbench_hard30.jsonl +30 -0
  63. benchkit/datasets/medfailbench_hard30.md +13 -0
  64. benchkit/datasets/openbookqa.jsonl +500 -0
  65. benchkit/datasets/piqa.jsonl +1838 -0
  66. benchkit/datasets/sanity_code.jsonl +6 -0
  67. benchkit/datasets/truthfulqa.jsonl +817 -0
  68. benchkit/datasets/winogrande.jsonl +1267 -0
  69. benchkit/datasets/xstest.csv +451 -0
  70. benchkit/decision.py +194 -0
  71. benchkit/demo.py +451 -0
  72. benchkit/dockerfiles.py +247 -0
  73. benchkit/engine.py +1270 -0
  74. benchkit/evaluation.py +195 -0
  75. benchkit/events.py +141 -0
  76. benchkit/executor.py +96 -0
  77. benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
  78. benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
  79. benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
  80. benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
  81. benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
  82. benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
  83. benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
  84. benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
  85. benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
  86. benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
  87. benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
  88. benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
  89. benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
  90. benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
  91. benchkit/git_surgery/secret-in-history/README.md +27 -0
  92. benchkit/git_surgery/secret-in-history/setup.sh +107 -0
  93. benchkit/git_surgery/secret-in-history/verify.sh +106 -0
  94. benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
  95. benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
  96. benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
  97. benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
  98. benchkit/history.py +474 -0
  99. benchkit/jobs.py +332 -0
  100. benchkit/leakage.py +51 -0
  101. benchkit/looping.py +873 -0
  102. benchkit/mc_render.py +274 -0
  103. benchkit/mc_viewer/dist/atlas.png +0 -0
  104. benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
  105. benchkit/mc_viewer/dist/version.json +4 -0
  106. benchkit/mc_viewer/dist/viewer.html +23 -0
  107. benchkit/mc_viewer/dist/viewer.js +2 -0
  108. benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
  109. benchkit/mc_viewer/dist/worker.js +2 -0
  110. benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
  111. benchkit/metrics.py +100 -0
  112. benchkit/ollama.py +60 -0
  113. benchkit/perf.py +353 -0
  114. benchkit/perf_report.py +207 -0
  115. benchkit/perturbations.py +208 -0
  116. benchkit/pi_agent.py +476 -0
  117. benchkit/pi_package/package-lock.json +1827 -0
  118. benchkit/pi_package/package.json +8 -0
  119. benchkit/report.py +142 -0
  120. benchkit/report_markdown.py +645 -0
  121. benchkit/results.py +688 -0
  122. benchkit/retry.py +163 -0
  123. benchkit/runner.py +769 -0
  124. benchkit/sandbox.py +1167 -0
  125. benchkit/template_check.py +291 -0
  126. benchkit/templates/arena.html +504 -0
  127. benchkit/templates/history.html +58 -0
  128. benchkit/templates/perf.html +509 -0
  129. benchkit/templates/report.html +769 -0
  130. benchkit/tui/__init__.py +5 -0
  131. benchkit/tui/app.py +87 -0
  132. benchkit/tui/app.tcss +512 -0
  133. benchkit/tui/formatting.py +84 -0
  134. benchkit/tui/screens/__init__.py +1 -0
  135. benchkit/tui/screens/connect.py +191 -0
  136. benchkit/tui/screens/detail.py +288 -0
  137. benchkit/tui/screens/help.py +84 -0
  138. benchkit/tui/screens/modals.py +544 -0
  139. benchkit/tui/screens/results.py +361 -0
  140. benchkit/tui/screens/run.py +988 -0
  141. benchkit/tui/screens/setup.py +733 -0
  142. benchkit/tui/theme.py +135 -0
  143. benchkit/tui/widgets.py +120 -0
  144. benchkit_cli-0.1.0.dist-info/METADATA +294 -0
  145. benchkit_cli-0.1.0.dist-info/RECORD +149 -0
  146. benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
  147. benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
  148. benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  149. benchkit_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,479 @@
1
+ """Creative Minecraft arena: build prompts scored on the blocks, not the picture.
2
+
3
+ Like treejs-arena there is no ground truth here - a watchtower has no correct
4
+ answer. Unlike treejs-arena, "it rendered" is not the score: a build renders
5
+ whether it is a castle or three blocks of dirt, so small models would pass a
6
+ render check every time. What is scored instead is everything a block list can
7
+ be checked against on its own: whether the script ran, whether the block ids
8
+ exist, how much was actually built, whether it stayed in the volume, how varied
9
+ the palette is, and how much of it is floating in the air.
10
+
11
+ The model answers with one self-contained Python script. The script runs in a
12
+ network-less Docker container and prints the build as JSON; the block list it
13
+ prints is what gets measured, and then photographed from three fixed cameras
14
+ for human comparison.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import math
21
+ import re
22
+ from collections import Counter, deque
23
+ from functools import lru_cache
24
+ from importlib.resources import files
25
+
26
+ from benchkit.artifacts import task_dir
27
+ from benchkit.benchmarks.base import Task
28
+ from benchkit.benchmarks.utils import strip_think_tags
29
+ from benchkit.evaluation import EvaluationResult
30
+ from benchkit.mc_render import BUILD_SIZE, VIEWS, render_build, viewer_version
31
+ from benchkit.sandbox import SandboxError, run_python_script
32
+
33
+ # Frozen prompt set. Bump the version (and never edit a shipped prompt in
34
+ # place) so screenshots from different runs stay comparable.
35
+ PROMPT_SET_VERSION = "v1"
36
+
37
+ # The block table the renderer and the id check share, so a build cannot be
38
+ # marked valid with an id the renderer would then quietly drop.
39
+ DATASET = files("benchkit").joinpath("datasets/mc_blocks_1_20_1.jsonl")
40
+
41
+ SCRIPT_TIMEOUT_S = 60.0
42
+ MAX_ENTRIES = 200_000
43
+ MAX_FEEDBACK_CHARS = 4000
44
+
45
+ # The example matters more than it looks. Without an id spelled out in full,
46
+ # smaller models answer with "oak plank" or "Oak Planks" and every block is
47
+ # rejected for the wrong reason - bad formatting rather than a bad build.
48
+ PROMPT_PREFIX = f"""\
49
+ You are building a Minecraft structure. Answer with one self-contained Python \
50
+ script and nothing else.
51
+
52
+ The script must print, to stdout, a single JSON array of the blocks in the \
53
+ build:
54
+
55
+ [{{"x": 0, "y": 0, "z": 0, "block": "minecraft:oak_planks"}}, \
56
+ {{"x": 0, "y": 1, "z": 0, "block": "minecraft:cobblestone"}}]
57
+
58
+ Rules:
59
+ - Every block id is the full namespaced Minecraft id in lower case, exactly \
60
+ like "minecraft:oak_planks", "minecraft:stone_bricks" or "minecraft:glass". \
61
+ Ids that are not real Minecraft blocks are rejected.
62
+ - x, y and z are integers in the range 0 to {BUILD_SIZE - 1}. y is up, and \
63
+ y = 0 is the ground. Anything outside that {BUILD_SIZE}x{BUILD_SIZE}x\
64
+ {BUILD_SIZE} volume is rejected.
65
+ - Omit air; only list the blocks you place.
66
+ - The script begins with PEP 723 inline metadata and uses the standard \
67
+ library only. It runs with no network access.
68
+
69
+ Start the script with:
70
+
71
+ # /// script
72
+ # requires-python = ">=3.11"
73
+ # dependencies = []
74
+ # ///
75
+
76
+ Build this:
77
+ """
78
+
79
+ # fmt: off
80
+ PROMPTS: tuple[tuple[str, str, str], ...] = (
81
+ ("stone-pillar", "trivial", "A single stone brick pillar 8 blocks tall, standing on a 5x5 stone slab-free base of smooth stone."),
82
+ ("checkerboard-floor", "trivial", "A 12x12 checkerboard floor one block thick on the ground, alternating black concrete and white concrete."),
83
+ ("stone-watchtower", "medium", "A small square stone watchtower: cobblestone walls, a doorway on one side, a window on each of the other three sides, a wooden floor partway up, and a battlemented roof."),
84
+ ("arched-bridge", "medium", "A wooden footbridge with railings, arching over a river of water five blocks wide, with stone banks on both sides."),
85
+ ("windmill", "hard", "A windmill: a round stone tower with a wooden balcony near the top, a sloped roof, and four sails on a horizontal axle on one side."),
86
+ )
87
+ # fmt: on
88
+
89
+
90
+ @lru_cache(maxsize=1)
91
+ def block_table() -> tuple[frozenset[str], frozenset[str]]:
92
+ """Return ``(every block id, the air ids)`` for the pinned version."""
93
+ known: set[str] = set()
94
+ air: set[str] = set()
95
+ with DATASET.open(encoding="utf-8") as handle:
96
+ for line in handle:
97
+ line = line.strip()
98
+ if not line:
99
+ continue
100
+ row = json.loads(line)
101
+ known.add(row["block"])
102
+ if row.get("air"):
103
+ air.add(row["block"])
104
+ return frozenset(known), frozenset(air)
105
+
106
+
107
+ def extract_script(response: str) -> str:
108
+ """Pull the Python script out of a model response, fences and all."""
109
+ text = strip_think_tags(response).strip()
110
+ if "```" in text:
111
+ parts = text.split("```")
112
+ blocks = []
113
+ for index in range(1, len(parts), 2):
114
+ block = parts[index]
115
+ first, newline, rest = block.partition("\n")
116
+ label = first.strip().lower()
117
+ if newline and (
118
+ label in {"python", "py", "python3", ""} or " " not in label
119
+ ):
120
+ blocks.append(rest)
121
+ else:
122
+ blocks.append(block)
123
+ candidates = [block for block in blocks if block.strip()]
124
+ if candidates:
125
+ return max(candidates, key=len).strip()
126
+ # An unfenced answer is still a script if it looks like one.
127
+ if re.search(r"^\s*(import |from |print\(|# /// script)", text, re.MULTILINE):
128
+ return text
129
+ return ""
130
+
131
+
132
+ def parse_blocks(stdout: str) -> tuple[list, str]:
133
+ """Read the JSON array a build script printed, or say why it could not."""
134
+ text = stdout.strip()
135
+ if not text:
136
+ return [], "the script printed nothing to stdout"
137
+ try:
138
+ payload = json.loads(text)
139
+ except ValueError:
140
+ # Scripts often print a line of chatter before the JSON.
141
+ start = text.find("[")
142
+ end = text.rfind("]")
143
+ if start == -1 or end <= start:
144
+ return [], "stdout did not contain a JSON array"
145
+ try:
146
+ payload = json.loads(text[start : end + 1])
147
+ except ValueError as exc:
148
+ return [], f"stdout was not valid JSON ({exc})"
149
+ if not isinstance(payload, list):
150
+ return [], f"stdout was a JSON {type(payload).__name__}, not an array"
151
+ if len(payload) > MAX_ENTRIES:
152
+ return (
153
+ [],
154
+ f"the build listed {len(payload)} entries; the limit is {MAX_ENTRIES}",
155
+ )
156
+ return payload, ""
157
+
158
+
159
+ def _coordinate(value: object) -> int | None:
160
+ """Accept an integer coordinate, including ``4.0``; reject anything else."""
161
+ if isinstance(value, bool):
162
+ return None
163
+ if isinstance(value, int):
164
+ return value
165
+ if isinstance(value, float) and value.is_integer():
166
+ return int(value)
167
+ return None
168
+
169
+
170
+ def _normalize_id(value: object) -> str:
171
+ name = str(value).strip().lower()
172
+ return name if ":" in name else f"minecraft:{name}"
173
+
174
+
175
+ def _entropy(counts: Counter[str]) -> float:
176
+ """Shannon evenness of the palette: 0 for one block, 1 for a flat mix."""
177
+ total = sum(counts.values())
178
+ if total <= 0 or len(counts) <= 1:
179
+ return 0.0
180
+ bits = -sum((count / total) * math.log2(count / total) for count in counts.values())
181
+ return round(bits / math.log2(len(counts)), 4)
182
+
183
+
184
+ def _floating(positions: set[tuple[int, int, int]]) -> set[tuple[int, int, int]]:
185
+ """Blocks with no path of touching blocks down to the ground."""
186
+ if not positions:
187
+ return set()
188
+ grounded = {position for position in positions if position[1] == 0}
189
+ queue = deque(grounded)
190
+ while queue:
191
+ x, y, z = queue.popleft()
192
+ for dx, dy, dz in (
193
+ (1, 0, 0),
194
+ (-1, 0, 0),
195
+ (0, 1, 0),
196
+ (0, -1, 0),
197
+ (0, 0, 1),
198
+ (0, 0, -1),
199
+ ):
200
+ neighbour = (x + dx, y + dy, z + dz)
201
+ if neighbour in positions and neighbour not in grounded:
202
+ grounded.add(neighbour)
203
+ queue.append(neighbour)
204
+ return positions - grounded
205
+
206
+
207
+ def measure(payload: list) -> dict:
208
+ """Turn a printed block list into the metrics mc-arena actually scores."""
209
+ known, air = block_table()
210
+ placements: dict[tuple[int, int, int], str] = {}
211
+ hallucinated: Counter[str] = Counter()
212
+ malformed = 0
213
+ out_of_bounds = 0
214
+ air_entries = 0
215
+ duplicates = 0
216
+
217
+ for entry in payload:
218
+ if not isinstance(entry, dict):
219
+ malformed += 1
220
+ continue
221
+ x = _coordinate(entry.get("x"))
222
+ y = _coordinate(entry.get("y"))
223
+ z = _coordinate(entry.get("z"))
224
+ name = entry.get("block")
225
+ if x is None or y is None or z is None or not isinstance(name, str):
226
+ malformed += 1
227
+ continue
228
+ block = _normalize_id(name)
229
+ if block not in known:
230
+ hallucinated[block] += 1
231
+ continue
232
+ if not all(0 <= value < BUILD_SIZE for value in (x, y, z)):
233
+ out_of_bounds += 1
234
+ continue
235
+ if block in air:
236
+ air_entries += 1
237
+ continue
238
+ if (x, y, z) in placements:
239
+ duplicates += 1
240
+ placements[(x, y, z)] = block
241
+
242
+ palette = Counter(placements.values())
243
+ floating = _floating(set(placements))
244
+ total = len(placements)
245
+ extent = [0, 0, 0]
246
+ if placements:
247
+ for axis in range(3):
248
+ values = [position[axis] for position in placements]
249
+ extent[axis] = max(values) - min(values) + 1
250
+
251
+ return {
252
+ "entries": len(payload),
253
+ "entries_malformed": malformed,
254
+ "blocks_total": total,
255
+ "blocks_duplicate_positions": duplicates,
256
+ "blocks_out_of_bounds": out_of_bounds,
257
+ "blocks_air": air_entries,
258
+ "hallucinated_blocks": sum(hallucinated.values()),
259
+ "hallucinated_ids": sorted(hallucinated),
260
+ "palette_size": len(palette),
261
+ "palette_evenness": _entropy(palette),
262
+ "palette_dominant_share": (
263
+ round(max(palette.values()) / total, 4) if total else 0.0
264
+ ),
265
+ "palette_top": [
266
+ {"block": block, "count": count} for block, count in palette.most_common(6)
267
+ ],
268
+ "floating_blocks": len(floating),
269
+ "floating_fraction": round(len(floating) / total, 4) if total else 0.0,
270
+ "bounding_box": extent,
271
+ "volume_fill": round(total / BUILD_SIZE**3, 6),
272
+ "blocks": [
273
+ {"x": x, "y": y, "z": z, "block": block}
274
+ for (x, y, z), block in placements.items()
275
+ ],
276
+ }
277
+
278
+
279
+ def _clip(text: str) -> str:
280
+ if len(text) <= MAX_FEEDBACK_CHARS:
281
+ return text
282
+ return "…\n" + text[-MAX_FEEDBACK_CHARS:]
283
+
284
+
285
+ class MCArena:
286
+ """Frozen Minecraft build prompts, scored from the block list they print."""
287
+
288
+ name = "mc-arena"
289
+ task_count = len(PROMPTS)
290
+ prompt_set_version = PROMPT_SET_VERSION
291
+ # A build-validity rate is not a capability score, so it stays out of the
292
+ # headline average the way RULER and treejs-arena do.
293
+ include_in_overall = False
294
+ list_note = f"creative · build metrics · prompt set {PROMPT_SET_VERSION}"
295
+ evaluation_activity = "running the build script in Docker and rendering it"
296
+
297
+ def load_tasks(self) -> list[Task]:
298
+ return [
299
+ Task(
300
+ id=f"MCArena/{index}",
301
+ prompt=f"{PROMPT_PREFIX}\n{brief}",
302
+ metadata={
303
+ "slug": slug,
304
+ "difficulty": difficulty,
305
+ "scene": brief,
306
+ "prompt_set_version": PROMPT_SET_VERSION,
307
+ },
308
+ )
309
+ for index, (slug, difficulty, brief) in enumerate(PROMPTS)
310
+ ]
311
+
312
+ def build_prompt(self, task: Task) -> str:
313
+ return task.prompt
314
+
315
+ def result_metadata(self, variant: str | None = None) -> dict:
316
+ return {
317
+ "include_in_overall": self.include_in_overall,
318
+ "prompt_set_version": PROMPT_SET_VERSION,
319
+ "scoring": "build-metrics",
320
+ "minecraft_version": viewer_version().get("minecraft_version", ""),
321
+ }
322
+
323
+ def evaluate_with_feedback(self, task: Task, response: str) -> EvaluationResult:
324
+ slug = str(task.metadata.get("slug") or task.id)
325
+ base = {
326
+ "scoring": "build-metrics",
327
+ "prompt_set_version": PROMPT_SET_VERSION,
328
+ "prompt_slug": slug,
329
+ "difficulty": str(task.metadata.get("difficulty") or ""),
330
+ "scene": str(task.metadata.get("scene") or ""),
331
+ }
332
+ directory = task_dir(f"mc-{slug}")
333
+
334
+ script = extract_script(response)
335
+ if not script:
336
+ answer = directory / "response.txt"
337
+ answer.write_text(response, encoding="utf-8")
338
+ return EvaluationResult(
339
+ score=0.0,
340
+ feedback=(
341
+ "No Python script was found in the answer. Reply with the "
342
+ "complete script and nothing else."
343
+ ),
344
+ details={
345
+ **base,
346
+ "build_status": "no_script",
347
+ "response_text": str(answer),
348
+ },
349
+ )
350
+
351
+ source = directory / "build.py"
352
+ source.write_text(script, encoding="utf-8")
353
+ base["script_py"] = str(source)
354
+
355
+ try:
356
+ run = run_python_script(script, timeout_s=SCRIPT_TIMEOUT_S)
357
+ except SandboxError as exc:
358
+ # Docker itself is unavailable or broken: not the model's failure.
359
+ return EvaluationResult(
360
+ score=0.0,
361
+ error=f"mc-arena needs Docker to run generated scripts: {exc}",
362
+ details={**base, "build_status": "harness_error"},
363
+ )
364
+
365
+ base["script_exit_code"] = run.exit_code
366
+ if run.timed_out:
367
+ return EvaluationResult(
368
+ score=0.0,
369
+ feedback=(
370
+ f"The script was still running after {SCRIPT_TIMEOUT_S:g}s and "
371
+ "was killed. Generate the blocks directly instead of "
372
+ "searching or simulating, and reply with the complete "
373
+ "corrected script."
374
+ ),
375
+ details={**base, "build_status": "script_timeout"},
376
+ )
377
+ if not run.ok:
378
+ return EvaluationResult(
379
+ score=0.0,
380
+ feedback=(
381
+ "The script exited with an error. Its output was:\n\n"
382
+ f"{_clip(run.stderr)}\n\n"
383
+ "Fix the cause and reply with the complete corrected script."
384
+ ),
385
+ details={**base, "build_status": "script_error"},
386
+ )
387
+
388
+ if run.stdout_truncated:
389
+ # Only a prefix survived the sandbox's output cap, so the array is
390
+ # cut off somewhere - parsing it would score whatever happened to
391
+ # fit rather than what the script produced.
392
+ return EvaluationResult(
393
+ score=0.0,
394
+ feedback=(
395
+ "The script printed more than the output limit allows and "
396
+ "its output was cut off. Print the JSON array once, with no "
397
+ "other output, and reply with the complete corrected script."
398
+ ),
399
+ details={**base, "build_status": "output_limit"},
400
+ )
401
+
402
+ payload, problem = parse_blocks(run.stdout)
403
+ if problem:
404
+ return EvaluationResult(
405
+ score=0.0,
406
+ feedback=(
407
+ f"The script ran, but {problem}. It has to print one JSON "
408
+ 'array of {"x", "y", "z", "block"} objects and '
409
+ "nothing else. Reply with the complete corrected script."
410
+ ),
411
+ details={**base, "build_status": "bad_output"},
412
+ )
413
+
414
+ metrics = measure(payload)
415
+ blocks = metrics.pop("blocks")
416
+ details = {**base, **metrics, "build_status": "valid"}
417
+
418
+ listing = directory / "blocks.json"
419
+ listing.write_text(json.dumps(blocks), encoding="utf-8")
420
+ details["blocks_json"] = str(listing)
421
+
422
+ # The picture is never the verdict, so a machine that cannot render
423
+ # still scores the build. It just has nothing to show for it.
424
+ #
425
+ # Invalid builds are photographed too, before the check below rejects
426
+ # them: a build that put one block outside the volume is exactly the
427
+ # one worth looking at, and the gallery marks it INVALID either way.
428
+ render = render_build(blocks, directory)
429
+ details["render_status"] = "rendered" if render.rendered else "skipped"
430
+ if render.rendered:
431
+ details["screenshot"] = render.contact_sheet
432
+ for view in VIEWS:
433
+ if path := render.views.get(view):
434
+ details[f"screenshot_{view}"] = path
435
+ else:
436
+ details["skip_reason"] = render.error or "the build did not render"
437
+ if render.console_errors or render.page_errors:
438
+ details["render_diagnostics"] = render.diagnostics
439
+
440
+ if failure := self._invalid(metrics):
441
+ return EvaluationResult(
442
+ score=0.0,
443
+ feedback=(
444
+ f"The script ran, but {failure} Reply with the complete "
445
+ "corrected script."
446
+ ),
447
+ details={**details, "build_status": "invalid"},
448
+ )
449
+ return EvaluationResult(score=1.0, details=details)
450
+
451
+ def _invalid(self, metrics: dict) -> str:
452
+ """Say what makes a printed build unusable, or nothing if it is fine.
453
+
454
+ This is a validity check, not a taste check: a dull build passes. Only
455
+ things that make the block list not a build at all fail here, and the
456
+ quality metrics are carried on the task either way.
457
+ """
458
+ if metrics["hallucinated_blocks"]:
459
+ names = ", ".join(metrics["hallucinated_ids"][:8])
460
+ return (
461
+ f"{metrics['hallucinated_blocks']} block(s) used ids that do not "
462
+ f"exist in Minecraft ({names}). Use real namespaced ids."
463
+ )
464
+ if metrics["blocks_out_of_bounds"]:
465
+ return (
466
+ f"{metrics['blocks_out_of_bounds']} block(s) fell outside the "
467
+ f"{BUILD_SIZE}x{BUILD_SIZE}x{BUILD_SIZE} volume."
468
+ )
469
+ if metrics["entries_malformed"]:
470
+ return (
471
+ f"{metrics['entries_malformed']} entries were not "
472
+ '{"x", "y", "z", "block"} objects with integer coordinates.'
473
+ )
474
+ if not metrics["blocks_total"]:
475
+ return "the build was empty."
476
+ return ""
477
+
478
+ def evaluate(self, task: Task, response: str) -> bool:
479
+ return self.evaluate_with_feedback(task, response).passed
@@ -0,0 +1,94 @@
1
+ """Helpers for messy multiple-choice model outputs."""
2
+
3
+ import re
4
+
5
+ DEFAULT_LETTERS = "ABCD"
6
+
7
+ # Letters that are also ordinary English words. Once the option list runs past
8
+ # D a bare "I" or "A" in prose is far more likely to be a pronoun or an article
9
+ # than an answer, so they only count when punctuation marks them as an option
10
+ # or when they sit at one end of the response.
11
+ WORD_LETTERS = frozenset("AI")
12
+
13
+ # "the answer is (C)", "Answer: C", "final option - J".
14
+ _MARKED = re.compile(
15
+ r"(?:ANSWER|OPTION|CHOICE)S?"
16
+ r"(?:\s+(?:IS|ARE|WOULD\s+BE|SHOULD\s+BE|WOULD\s+HAVE\s+TO\s+BE))?"
17
+ r"\s*[:=\-–—]?\s*[(\[{*\"']*([A-Z])(?![A-Z0-9])"
18
+ )
19
+
20
+ # "(C)", "[C]" - punctuation that only ever wraps an option letter.
21
+ _BRACKETED = re.compile(r"[(\[{]\s*([A-Z])\s*[)\]}]")
22
+
23
+ # "C." / "C)" / "**C**" at the start of a line or after whitespace.
24
+ _LABELLED = re.compile(r"(?:^|[\s*\"'])([A-Z])\s*[).:,\-*]")
25
+
26
+
27
+ def _matches(pattern: re.Pattern[str], text: str, letters: str) -> list[str]:
28
+ return [match for match in pattern.findall(text) if match in letters]
29
+
30
+
31
+ def extract_choice(
32
+ response: str,
33
+ choices: list[str] | None = None,
34
+ letters: str = DEFAULT_LETTERS,
35
+ ) -> str | None:
36
+ """Extract an option letter from a free-form response.
37
+
38
+ `letters` is the option alphabet of the benchmark: A-D by default, A-J for
39
+ ten-option suites such as MMLU-Pro. Explicit markers ("the answer is (C)")
40
+ win over a letter that merely stands alone in the prose, and the tail of
41
+ the response is preferred over its opening, since models tend to reason
42
+ first and commit last.
43
+ """
44
+ text = " ".join(response.strip().split())
45
+ if not text:
46
+ return None
47
+
48
+ upper = text.upper()
49
+ letters = letters.upper()
50
+
51
+ for pattern in (_MARKED, _BRACKETED, _LABELLED):
52
+ found = _matches(pattern, upper, letters)
53
+ if found:
54
+ return found[-1]
55
+
56
+ ambiguous = WORD_LETTERS if len(letters) > len(DEFAULT_LETTERS) else frozenset()
57
+
58
+ def pick(indices: range) -> str | None:
59
+ for i in indices:
60
+ ch = upper[i]
61
+ if ch not in letters:
62
+ continue
63
+
64
+ prev = upper[i - 1] if i > 0 else " "
65
+ nxt = upper[i + 1] if i + 1 < len(upper) else " "
66
+ if prev.isalnum() or nxt.isalnum():
67
+ continue
68
+ # A bare "A"/"I" mid-sentence is prose, not an answer.
69
+ if ch in ambiguous and 0 < i < len(upper) - 1:
70
+ continue
71
+ return ch
72
+ return None
73
+
74
+ end_start = max(0, len(upper) - 48)
75
+ choice = pick(range(len(upper) - 1, end_start - 1, -1))
76
+ if choice:
77
+ return choice
78
+
79
+ start_end = min(len(upper), 48)
80
+ choice = pick(range(start_end))
81
+ if choice:
82
+ return choice
83
+
84
+ choice = pick(range(len(upper)))
85
+ if choice:
86
+ return choice
87
+
88
+ if choices:
89
+ lowered = text.lower()
90
+ for letter, choice_text in zip(letters, choices, strict=False):
91
+ if choice_text and choice_text.lower() in lowered:
92
+ return letter
93
+
94
+ return None