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.
- benchkit/__init__.py +1 -0
- benchkit/_pi_proxy.py +219 -0
- benchkit/answer_key_guard.ts +49 -0
- benchkit/artifacts.py +87 -0
- benchkit/benchmarks/__init__.py +105 -0
- benchkit/benchmarks/aider_polyglot.py +405 -0
- benchkit/benchmarks/arc.py +52 -0
- benchkit/benchmarks/banking77.py +149 -0
- benchkit/benchmarks/base.py +19 -0
- benchkit/benchmarks/boolq.py +49 -0
- benchkit/benchmarks/chartqa.py +133 -0
- benchkit/benchmarks/code_verifier.py +77 -0
- benchkit/benchmarks/downloads.py +166 -0
- benchkit/benchmarks/evalplus.py +288 -0
- benchkit/benchmarks/git_surgery.py +485 -0
- benchkit/benchmarks/gpqa.py +56 -0
- benchkit/benchmarks/gsm8k.py +97 -0
- benchkit/benchmarks/hellaswag.py +49 -0
- benchkit/benchmarks/humaneval.py +103 -0
- benchkit/benchmarks/ifeval.py +56 -0
- benchkit/benchmarks/ifeval_instructions.py +477 -0
- benchkit/benchmarks/mbpp.py +60 -0
- benchkit/benchmarks/mc_arena.py +479 -0
- benchkit/benchmarks/mcq.py +94 -0
- benchkit/benchmarks/medfailbench.py +537 -0
- benchkit/benchmarks/mmlu.py +54 -0
- benchkit/benchmarks/mmlu_pro.py +62 -0
- benchkit/benchmarks/mmvp.py +115 -0
- benchkit/benchmarks/ocrbench.py +96 -0
- benchkit/benchmarks/openbookqa.py +52 -0
- benchkit/benchmarks/patcheval.py +929 -0
- benchkit/benchmarks/piqa.py +52 -0
- benchkit/benchmarks/ruler.py +172 -0
- benchkit/benchmarks/ruler_generator.py +535 -0
- benchkit/benchmarks/sanity.py +174 -0
- benchkit/benchmarks/treejs_arena.py +268 -0
- benchkit/benchmarks/truthfulqa.py +52 -0
- benchkit/benchmarks/utils.py +25 -0
- benchkit/benchmarks/vision.py +230 -0
- benchkit/benchmarks/vstar.py +108 -0
- benchkit/benchmarks/winogrande.py +52 -0
- benchkit/benchmarks/xstest.py +116 -0
- benchkit/browser.py +429 -0
- benchkit/catalog.py +121 -0
- benchkit/cli.py +1000 -0
- benchkit/client.py +1413 -0
- benchkit/controls.py +87 -0
- benchkit/datasets/MMLU_LICENSE.txt +21 -0
- benchkit/datasets/README.md +142 -0
- benchkit/datasets/arc_challenge.jsonl +1172 -0
- benchkit/datasets/banking77.jsonl +3080 -0
- benchkit/datasets/banking77_intents.json +79 -0
- benchkit/datasets/boolq.jsonl +3270 -0
- benchkit/datasets/code_verifier.jsonl +500 -0
- benchkit/datasets/gpqa_diamond.jsonl +198 -0
- benchkit/datasets/gsm8k.jsonl +1319 -0
- benchkit/datasets/hellaswag.jsonl +1000 -0
- benchkit/datasets/humaneval.jsonl +164 -0
- benchkit/datasets/ifeval.jsonl +541 -0
- benchkit/datasets/mbpp.jsonl +500 -0
- benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
- benchkit/datasets/medfailbench_hard30.jsonl +30 -0
- benchkit/datasets/medfailbench_hard30.md +13 -0
- benchkit/datasets/openbookqa.jsonl +500 -0
- benchkit/datasets/piqa.jsonl +1838 -0
- benchkit/datasets/sanity_code.jsonl +6 -0
- benchkit/datasets/truthfulqa.jsonl +817 -0
- benchkit/datasets/winogrande.jsonl +1267 -0
- benchkit/datasets/xstest.csv +451 -0
- benchkit/decision.py +194 -0
- benchkit/demo.py +451 -0
- benchkit/dockerfiles.py +247 -0
- benchkit/engine.py +1270 -0
- benchkit/evaluation.py +195 -0
- benchkit/events.py +141 -0
- benchkit/executor.py +96 -0
- benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
- benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
- benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
- benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
- benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
- benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
- benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
- benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
- benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
- benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
- benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
- benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
- benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
- benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
- benchkit/git_surgery/secret-in-history/README.md +27 -0
- benchkit/git_surgery/secret-in-history/setup.sh +107 -0
- benchkit/git_surgery/secret-in-history/verify.sh +106 -0
- benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
- benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
- benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
- benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
- benchkit/history.py +474 -0
- benchkit/jobs.py +332 -0
- benchkit/leakage.py +51 -0
- benchkit/looping.py +873 -0
- benchkit/mc_render.py +274 -0
- benchkit/mc_viewer/dist/atlas.png +0 -0
- benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
- benchkit/mc_viewer/dist/version.json +4 -0
- benchkit/mc_viewer/dist/viewer.html +23 -0
- benchkit/mc_viewer/dist/viewer.js +2 -0
- benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
- benchkit/mc_viewer/dist/worker.js +2 -0
- benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
- benchkit/metrics.py +100 -0
- benchkit/ollama.py +60 -0
- benchkit/perf.py +353 -0
- benchkit/perf_report.py +207 -0
- benchkit/perturbations.py +208 -0
- benchkit/pi_agent.py +476 -0
- benchkit/pi_package/package-lock.json +1827 -0
- benchkit/pi_package/package.json +8 -0
- benchkit/report.py +142 -0
- benchkit/report_markdown.py +645 -0
- benchkit/results.py +688 -0
- benchkit/retry.py +163 -0
- benchkit/runner.py +769 -0
- benchkit/sandbox.py +1167 -0
- benchkit/template_check.py +291 -0
- benchkit/templates/arena.html +504 -0
- benchkit/templates/history.html +58 -0
- benchkit/templates/perf.html +509 -0
- benchkit/templates/report.html +769 -0
- benchkit/tui/__init__.py +5 -0
- benchkit/tui/app.py +87 -0
- benchkit/tui/app.tcss +512 -0
- benchkit/tui/formatting.py +84 -0
- benchkit/tui/screens/__init__.py +1 -0
- benchkit/tui/screens/connect.py +191 -0
- benchkit/tui/screens/detail.py +288 -0
- benchkit/tui/screens/help.py +84 -0
- benchkit/tui/screens/modals.py +544 -0
- benchkit/tui/screens/results.py +361 -0
- benchkit/tui/screens/run.py +988 -0
- benchkit/tui/screens/setup.py +733 -0
- benchkit/tui/theme.py +135 -0
- benchkit/tui/widgets.py +120 -0
- benchkit_cli-0.1.0.dist-info/METADATA +294 -0
- benchkit_cli-0.1.0.dist-info/RECORD +149 -0
- benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
- benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
- benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|