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,929 @@
1
+ """Hermetic bug-fix tasks scored by hidden tests outside the agent sandbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import shutil
11
+ import stat
12
+ import subprocess
13
+ import tarfile
14
+ import tempfile
15
+ import threading
16
+ import uuid
17
+ from concurrent.futures import ThreadPoolExecutor
18
+ from dataclasses import dataclass
19
+ from pathlib import Path, PurePosixPath
20
+
21
+ from benchkit.benchmarks import vision
22
+ from benchkit.benchmarks.base import Task
23
+ from benchkit.benchmarks.downloads import Dataset, Progress, cache_root
24
+ from benchkit.evaluation import EvaluationResult
25
+ from benchkit.sandbox import (
26
+ DockerTaskEnvironment,
27
+ LatestPiImage,
28
+ PatchEvalRuntimeRecipe,
29
+ SandboxError,
30
+ _run,
31
+ patcheval_pi_image,
32
+ resource_labels,
33
+ )
34
+
35
+ _SCHEMA_VERSION = 1
36
+ _TASKS_FILE = "tasks.jsonl"
37
+ _RELEASE_FILE = "dataset.json"
38
+ # The frozen release lives on Hugging Face. Only the release metadata and the
39
+ # files tasks.jsonl names are fetched (not the miner that built it), and every
40
+ # task pins its source archive and hidden tests by sha256, which load_tasks
41
+ # verifies, so a bad or moved upstream file fails loudly instead of scoring.
42
+ _HF_DATASET = "DogukanUrker/PatchEval"
43
+ _HF_REVISION = "main"
44
+ _RELEASE = "pilot-20"
45
+ _COMPLETE = ".complete"
46
+ _FETCH_LOCK = threading.Lock()
47
+ _GENERIC_REPAIR = (
48
+ "The issue is not fully resolved yet. Re-examine your changes and continue "
49
+ "working on the fix. Use the repository and any tests you can run to validate "
50
+ "your next attempt."
51
+ )
52
+ _MISSING_WORKSPACE = (
53
+ "The repository at the working directory was missing or empty when your "
54
+ "changes were collected. Restore the repository and continue working on the fix."
55
+ )
56
+ _UNUSABLE_WORKSPACE = (
57
+ "Your changes could not be collected from the repository. Keep the "
58
+ "repository a reasonable size, made of ordinary files, and continue working "
59
+ "on the fix."
60
+ )
61
+ _UNBUILDABLE_SUBMISSION = (
62
+ "With your changes applied the project no longer builds or installs. Make "
63
+ "sure it installs cleanly, then continue working on the fix."
64
+ )
65
+ # Docker reports trouble with the host or the daemon in these words. Only a
66
+ # failure that matches none of them may be blamed on the agent's submission.
67
+ _INFRASTRUCTURE_MARKERS = (
68
+ "no space left on device",
69
+ "cannot connect to the docker daemon",
70
+ "is the docker daemon running",
71
+ "error response from daemon",
72
+ "oci runtime",
73
+ "disk quota exceeded",
74
+ "too many open files",
75
+ "cannot allocate memory",
76
+ )
77
+ # docker exec exits 125-127 when the daemon or the runtime, not the command,
78
+ # failed, and the grader image is the task's, never the agent's.
79
+ _DOCKER_EXIT_CODES = frozenset({125, 126, 127})
80
+ _MAX_PATCH_BYTES = 5 * 1024 * 1024
81
+ _MAX_CANDIDATE_FILES = 50_000
82
+ _MAX_CANDIDATE_BYTES = 512 * 1024 * 1024
83
+ _BASE_IMAGE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/:+-]*")
84
+ _ENVIRONMENT_KEY_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
85
+ _PYTHON_TEST_GLOBS = (
86
+ "tests/**",
87
+ "test/**",
88
+ "**/tests/**",
89
+ "**/test/**",
90
+ "test_*.py",
91
+ "*_test.py",
92
+ "**/test_*.py",
93
+ "**/*_test.py",
94
+ "conftest.py",
95
+ "**/conftest.py",
96
+ )
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class PatchEvalSpec:
101
+ """One frozen PatchEval task and its host-only grading assets."""
102
+
103
+ id: str
104
+ repository: str
105
+ issue_title: str
106
+ issue_body: str
107
+ runtime_recipe: PatchEvalRuntimeRecipe
108
+ source_archive: Path
109
+ hidden_test_patch: Path
110
+ source_sha256: str
111
+ hidden_test_sha256: str
112
+ fail_to_pass_command: tuple[str, ...]
113
+ regression_command: tuple[str, ...]
114
+ setup_command: tuple[str, ...]
115
+ protected_globs: tuple[str, ...]
116
+ ignored_globs: tuple[str, ...]
117
+ timeout_s: int
118
+
119
+
120
+ class SubmissionRejected(Exception):
121
+ """The agent's workspace or patch, not the grader, made grading impossible.
122
+
123
+ It is scored as an ordinary failure, unlike the grader's own errors, which
124
+ are harness errors and leave the score's denominator.
125
+ """
126
+
127
+ def __init__(self, reason: str, feedback: str, detail: str = "") -> None:
128
+ super().__init__(reason)
129
+ self.reason = reason
130
+ self.feedback = feedback
131
+ self.detail = detail
132
+
133
+
134
+ def _is_infrastructure(text: str) -> bool:
135
+ lowered = text.lower()
136
+ return any(marker in lowered for marker in _INFRASTRUCTURE_MARKERS)
137
+
138
+
139
+ def _sha256(path: Path) -> str:
140
+ digest = hashlib.sha256()
141
+ with path.open("rb") as handle:
142
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
143
+ digest.update(chunk)
144
+ return digest.hexdigest()
145
+
146
+
147
+ def _safe_relative(root: Path, value: object, field: str) -> Path:
148
+ if not isinstance(value, str) or not value:
149
+ raise ValueError(f"{field} must be a non-empty relative path")
150
+ path = PurePosixPath(value)
151
+ if path.is_absolute() or ".." in path.parts:
152
+ raise ValueError(f"{field} must stay inside the dataset root")
153
+ resolved_root = root.resolve(strict=True)
154
+ candidate = root
155
+ for part in path.parts:
156
+ candidate = candidate / part
157
+ if candidate.is_symlink():
158
+ raise ValueError(f"{field} must not traverse symlinks")
159
+ try:
160
+ resolved = candidate.resolve(strict=True)
161
+ resolved.relative_to(resolved_root)
162
+ except (FileNotFoundError, RuntimeError, ValueError) as exc:
163
+ raise ValueError(f"{field} must stay inside the dataset root") from exc
164
+ return resolved
165
+
166
+
167
+ def _configured_root() -> Path | None:
168
+ """A local copy named by BENCHKIT_PATCHEVAL_DATASET, which wins."""
169
+ configured = os.environ.get("BENCHKIT_PATCHEVAL_DATASET")
170
+ return Path(configured).expanduser() if configured else None
171
+
172
+
173
+ def _cache_dir() -> Path:
174
+ return cache_root() / "patcheval" / _RELEASE
175
+
176
+
177
+ def _dataset_dir() -> Path:
178
+ return _configured_root() or _cache_dir()
179
+
180
+
181
+ def _dataset_installed() -> bool:
182
+ configured = _configured_root()
183
+ if configured is not None:
184
+ return (configured / _RELEASE_FILE).is_file()
185
+ return (_cache_dir() / _COMPLETE).is_file()
186
+
187
+
188
+ def _fetch_release(progress: Progress | None = None) -> Path:
189
+ """Return the dataset root, downloading the frozen release once."""
190
+ configured = _configured_root()
191
+ if configured is not None:
192
+ return configured
193
+ root = _cache_dir()
194
+ with _FETCH_LOCK:
195
+ if (root / _COMPLETE).is_file():
196
+ return root
197
+ root.mkdir(parents=True, exist_ok=True)
198
+ done = 0
199
+ with vision._http_client() as client:
200
+
201
+ def fetch(name: str) -> None:
202
+ nonlocal done
203
+ path = PurePosixPath(name)
204
+ if path.is_absolute() or ".." in path.parts or not path.parts:
205
+ raise RuntimeError(
206
+ f"PatchEval release names an unsafe path: {name}"
207
+ )
208
+ target = root.joinpath(*path.parts)
209
+ target.parent.mkdir(parents=True, exist_ok=True)
210
+ url = vision._url(_HF_DATASET, _HF_REVISION, name)
211
+ vision._download_file(client, url, target)
212
+ done += target.stat().st_size
213
+ if progress:
214
+ progress(done, None)
215
+
216
+ fetch(_RELEASE_FILE)
217
+ fetch(_TASKS_FILE)
218
+ for line in (root / _TASKS_FILE).read_text(encoding="utf-8").splitlines():
219
+ if not line.strip():
220
+ continue
221
+ record = json.loads(line)
222
+ for field in ("source_archive", "hidden_test_patch"):
223
+ if isinstance(record.get(field), str):
224
+ fetch(record[field])
225
+ # Written last, so an interrupted download is fetched again.
226
+ (root / _COMPLETE).write_text(_HF_REVISION + "\n", encoding="utf-8")
227
+ return root
228
+
229
+
230
+ def _strings(record: dict, field: str, *, required: bool = True) -> tuple[str, ...]:
231
+ value = record.get(field)
232
+ if value is None and not required:
233
+ return ()
234
+ if (
235
+ not isinstance(value, list)
236
+ or not value
237
+ or not all(isinstance(item, str) and item for item in value)
238
+ ):
239
+ raise ValueError(f"{field} must be a non-empty list of strings")
240
+ return tuple(value)
241
+
242
+
243
+ def _runtime_recipe(record: dict) -> PatchEvalRuntimeRecipe:
244
+ value = record.get("runtime_recipe")
245
+ if not isinstance(value, dict):
246
+ raise ValueError("runtime_recipe must be an object")
247
+ allowed = {
248
+ "schema_version",
249
+ "base_image",
250
+ "sync_command",
251
+ "bootstrap_command",
252
+ "environment",
253
+ }
254
+ unknown = set(value).difference(allowed)
255
+ if unknown:
256
+ raise ValueError(
257
+ "runtime_recipe contains unsupported fields: " + ", ".join(sorted(unknown))
258
+ )
259
+ if (
260
+ isinstance(value.get("schema_version"), bool)
261
+ or value.get("schema_version") != 1
262
+ ):
263
+ raise ValueError("runtime_recipe schema_version must be 1")
264
+ base_image = value.get("base_image")
265
+ if not isinstance(base_image, str) or not base_image:
266
+ raise ValueError("runtime_recipe base_image must be a versioned image tag")
267
+ if (
268
+ "@" in base_image
269
+ or any(character.isspace() for character in base_image)
270
+ or _BASE_IMAGE_RE.fullmatch(base_image) is None
271
+ or ":" not in base_image.rsplit("/", 1)[-1]
272
+ ):
273
+ raise ValueError("runtime_recipe base_image must be a versioned image tag")
274
+ tag = base_image.rsplit(":", 1)[-1]
275
+ if tag == "latest" or not any(character.isdigit() for character in tag):
276
+ raise ValueError("runtime_recipe base_image must be a versioned image tag")
277
+ try:
278
+ sync_command = _strings(value, "sync_command")
279
+ bootstrap_command = _strings(value, "bootstrap_command", required=False)
280
+ environment = _strings(value, "environment", required=False)
281
+ except ValueError as exc:
282
+ raise ValueError(f"runtime_recipe {exc}") from exc
283
+ keys: list[str] = []
284
+ for item in environment:
285
+ key, separator, _environment_value = item.partition("=")
286
+ if (
287
+ not separator
288
+ or _ENVIRONMENT_KEY_RE.fullmatch(key) is None
289
+ or "\n" in item
290
+ or "\r" in item
291
+ or "\0" in item
292
+ ):
293
+ raise ValueError("runtime_recipe environment entries must use KEY=value")
294
+ keys.append(key)
295
+ if len(keys) != len(set(keys)):
296
+ raise ValueError("runtime_recipe environment keys must be unique")
297
+ return PatchEvalRuntimeRecipe(
298
+ schema_version=1,
299
+ base_image=base_image,
300
+ sync_command=sync_command,
301
+ bootstrap_command=bootstrap_command,
302
+ environment=environment,
303
+ )
304
+
305
+
306
+ def _load_spec(root: Path, record: object) -> PatchEvalSpec:
307
+ if not isinstance(record, dict):
308
+ raise ValueError("each PatchEval task must be a JSON object")
309
+ if "runtime_image" in record:
310
+ raise ValueError("runtime_image is unsupported; use runtime_recipe")
311
+ required_strings = (
312
+ "id",
313
+ "repository",
314
+ "issue_title",
315
+ "issue_body",
316
+ "source_sha256",
317
+ "hidden_test_sha256",
318
+ )
319
+ for field in required_strings:
320
+ if not isinstance(record.get(field), str) or not record[field].strip():
321
+ raise ValueError(f"{field} must be a non-empty string")
322
+ if record.get("validated") is not True:
323
+ raise ValueError(f"PatchEval task {record['id']!r} is not miner-validated")
324
+
325
+ source = _safe_relative(root, record.get("source_archive"), "source_archive")
326
+ hidden = _safe_relative(root, record.get("hidden_test_patch"), "hidden_test_patch")
327
+ for path, field, expected in (
328
+ (source, "source_archive", record["source_sha256"]),
329
+ (hidden, "hidden_test_patch", record["hidden_test_sha256"]),
330
+ ):
331
+ if not path.is_file():
332
+ raise ValueError(f"{field} does not exist for task {record['id']!r}")
333
+ actual = _sha256(path)
334
+ if actual != expected:
335
+ raise ValueError(
336
+ f"{field} checksum mismatch for task {record['id']!r}: "
337
+ f"expected {expected}, got {actual}"
338
+ )
339
+ _validate_source_archive(source)
340
+
341
+ timeout_s = record.get("timeout_s", 300)
342
+ if isinstance(timeout_s, bool) or not isinstance(timeout_s, int):
343
+ raise ValueError("timeout_s must be an integer")
344
+ if not 10 <= timeout_s <= 1800:
345
+ raise ValueError("timeout_s must be between 10 and 1800 seconds")
346
+
347
+ return PatchEvalSpec(
348
+ id=record["id"],
349
+ repository=record["repository"],
350
+ issue_title=record["issue_title"].strip(),
351
+ issue_body=record["issue_body"].strip(),
352
+ runtime_recipe=_runtime_recipe(record),
353
+ source_archive=source,
354
+ hidden_test_patch=hidden,
355
+ source_sha256=record["source_sha256"],
356
+ hidden_test_sha256=record["hidden_test_sha256"],
357
+ fail_to_pass_command=_strings(record, "fail_to_pass_command"),
358
+ regression_command=_strings(record, "regression_command"),
359
+ setup_command=_strings(record, "setup_command", required=False),
360
+ protected_globs=tuple(
361
+ dict.fromkeys(_PYTHON_TEST_GLOBS + _strings(record, "protected_globs"))
362
+ ),
363
+ ignored_globs=_strings(record, "ignored_globs", required=False),
364
+ timeout_s=timeout_s,
365
+ )
366
+
367
+
368
+ def _source_archive_members(handle: tarfile.TarFile) -> list[tarfile.TarInfo]:
369
+ members = handle.getmembers()
370
+ for member in members:
371
+ path = PurePosixPath(member.name)
372
+ if path.is_absolute() or ".." in path.parts:
373
+ raise ValueError(f"unsafe path in source archive: {member.name!r}")
374
+ if member.issym() or member.islnk():
375
+ raise ValueError(f"source archives must dereference links: {member.name!r}")
376
+ if not member.isdir() and not member.isreg():
377
+ raise ValueError(f"unsupported archive entry: {member.name!r}")
378
+ return members
379
+
380
+
381
+ def _validate_source_archive(archive: Path) -> None:
382
+ with tarfile.open(archive, "r:*") as handle:
383
+ _source_archive_members(handle)
384
+
385
+
386
+ def _safe_extract(archive: Path, destination: Path) -> None:
387
+ """Extract a miner-produced source archive without following archive links."""
388
+ with tarfile.open(archive, "r:*") as handle:
389
+ members = _source_archive_members(handle)
390
+ handle.extractall(destination, members=members, filter="fully_trusted")
391
+
392
+
393
+ def _git(
394
+ worktree: Path, args: list[str], *, check: bool = True
395
+ ) -> subprocess.CompletedProcess[str]:
396
+ environment = os.environ.copy()
397
+ environment.update(
398
+ GIT_CONFIG_NOSYSTEM="1",
399
+ GIT_CONFIG_GLOBAL=os.devnull,
400
+ )
401
+ completed = subprocess.run(
402
+ ["git", "-C", str(worktree), *args],
403
+ text=True,
404
+ errors="replace",
405
+ capture_output=True,
406
+ env=environment,
407
+ timeout=60,
408
+ )
409
+ if check and completed.returncode:
410
+ detail = (completed.stderr or completed.stdout).strip()[-2000:]
411
+ raise RuntimeError(f"trusted patch extraction failed: {detail}")
412
+ return completed
413
+
414
+
415
+ def _remove_entry(path: Path) -> None:
416
+ if path.is_symlink() or path.is_file():
417
+ path.unlink()
418
+ elif path.exists():
419
+ shutil.rmtree(path)
420
+
421
+
422
+ def _copy_candidate(source: Path, destination: Path) -> None:
423
+ """Copy only ordinary files, directories, and symlinks from the sandbox."""
424
+ file_count = 0
425
+ byte_count = 0
426
+
427
+ def copy_entry(current: Path, target: Path) -> None:
428
+ nonlocal file_count, byte_count
429
+ if current.name == ".git":
430
+ return
431
+ info = current.lstat()
432
+ file_count += 1
433
+ if file_count > _MAX_CANDIDATE_FILES:
434
+ raise ValueError("candidate workspace contains too many files")
435
+ if stat.S_ISLNK(info.st_mode):
436
+ target.symlink_to(os.readlink(current))
437
+ elif stat.S_ISDIR(info.st_mode):
438
+ target.mkdir(mode=info.st_mode & 0o777, exist_ok=True)
439
+ for child in current.iterdir():
440
+ copy_entry(child, target / child.name)
441
+ elif stat.S_ISREG(info.st_mode):
442
+ byte_count += info.st_size
443
+ if byte_count > _MAX_CANDIDATE_BYTES:
444
+ raise ValueError("candidate workspace is too large")
445
+ shutil.copyfile(current, target, follow_symlinks=False)
446
+ target.chmod(info.st_mode & 0o777)
447
+ else:
448
+ raise ValueError(f"candidate contains unsupported file: {current.name!r}")
449
+
450
+ for child in source.iterdir():
451
+ copy_entry(child, destination / child.name)
452
+
453
+
454
+ def _matches(path: str, patterns: tuple[str, ...]) -> bool:
455
+ return any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns)
456
+
457
+
458
+ def _trusted_patch(
459
+ spec: PatchEvalSpec, environment: DockerTaskEnvironment
460
+ ) -> tuple[str, list[str], list[str]]:
461
+ """Diff a downloaded workspace against the host's pristine source archive."""
462
+ with tempfile.TemporaryDirectory(prefix="benchkit-patcheval-diff-") as directory:
463
+ root = Path(directory)
464
+ worktree = root / "trusted"
465
+ candidate = root / "candidate"
466
+ worktree.mkdir()
467
+ candidate.mkdir()
468
+ _safe_extract(spec.source_archive, worktree)
469
+ try:
470
+ environment.download(f"{environment.workdir}/.", candidate)
471
+ except SandboxError as exc:
472
+ # docker cp names a missing source path this way (older clients
473
+ # say "No such container:path"), often behind "Error response from
474
+ # daemon:". A missing container or a full disk reads differently
475
+ # and stays a grader fault.
476
+ message = str(exc).lower()
477
+ if (
478
+ "could not find the file" in message
479
+ or "no such container:path" in message
480
+ ) and "no space left on device" not in message:
481
+ raise SubmissionRejected(
482
+ "workspace missing at grading time", _MISSING_WORKSPACE, str(exc)
483
+ ) from exc
484
+ raise
485
+ if not any(candidate.iterdir()):
486
+ raise SubmissionRejected(
487
+ "workspace empty at grading time", _MISSING_WORKSPACE
488
+ )
489
+
490
+ _git(worktree, ["init", "-q"])
491
+ _git(worktree, ["add", "-A"])
492
+ _git(
493
+ worktree,
494
+ [
495
+ "-c",
496
+ "user.name=BenchKit",
497
+ "-c",
498
+ "user.email=benchkit@localhost",
499
+ "commit",
500
+ "-q",
501
+ "--no-gpg-sign",
502
+ "-m",
503
+ "pristine",
504
+ ],
505
+ )
506
+ for child in worktree.iterdir():
507
+ if child.name != ".git":
508
+ _remove_entry(child)
509
+ try:
510
+ _copy_candidate(candidate, worktree)
511
+ except ValueError as exc:
512
+ raise SubmissionRejected(str(exc), _UNUSABLE_WORKSPACE) from exc
513
+ _git(worktree, ["add", "--intent-to-add", "--", "."])
514
+ # -z keeps paths verbatim. Git otherwise quotes non-ASCII, quote,
515
+ # backslash, and control characters, and a quoted path matches neither
516
+ # its protected glob nor the pathspec of the second diff, so the
517
+ # agent's real edits are silently dropped from the submission.
518
+ changed = [
519
+ path
520
+ for path in _git(
521
+ worktree,
522
+ ["diff", "-z", "--name-only", "--no-renames", "HEAD", "--"],
523
+ ).stdout.split("\0")
524
+ if path
525
+ ]
526
+ excluded = [
527
+ path
528
+ for path in changed
529
+ if _matches(path, spec.protected_globs + spec.ignored_globs)
530
+ ]
531
+ included = [path for path in changed if path not in excluded]
532
+ if not included:
533
+ return "", changed, excluded
534
+ patch = _git(
535
+ worktree,
536
+ ["diff", "--binary", "--no-renames", "HEAD", "--", *included],
537
+ ).stdout
538
+ if len(patch.encode()) > _MAX_PATCH_BYTES:
539
+ raise SubmissionRejected(
540
+ "candidate patch exceeds the 5 MiB limit", _UNUSABLE_WORKSPACE
541
+ )
542
+ return patch, changed, excluded
543
+
544
+
545
+ def _docker_exec(
546
+ docker: str,
547
+ container: str,
548
+ command: list[str],
549
+ *,
550
+ workdir: str | None = None,
551
+ timeout: float = 60,
552
+ check: bool = True,
553
+ ) -> subprocess.CompletedProcess[str]:
554
+ return _run(
555
+ [
556
+ docker,
557
+ "exec",
558
+ *(["--workdir", workdir] if workdir else []),
559
+ container,
560
+ *command,
561
+ ],
562
+ timeout=timeout,
563
+ check=check,
564
+ )
565
+
566
+
567
+ def _submission_step(
568
+ docker: str,
569
+ container: str,
570
+ command: list[str],
571
+ *,
572
+ reason: str,
573
+ timeout: float = 60,
574
+ ) -> None:
575
+ """Run a step whose failure is the submission's unless Docker says otherwise."""
576
+ completed = _docker_exec(
577
+ docker,
578
+ container,
579
+ command,
580
+ workdir="/workspace/repo",
581
+ timeout=timeout,
582
+ check=False,
583
+ )
584
+ if not completed.returncode:
585
+ return
586
+ detail = (completed.stderr or completed.stdout or "").strip()[-4000:]
587
+ if completed.returncode in _DOCKER_EXIT_CODES or _is_infrastructure(detail):
588
+ raise SandboxError(f"Docker exec failed: {detail or 'unknown Docker error'}")
589
+ raise SubmissionRejected(reason, _UNBUILDABLE_SUBMISSION, detail)
590
+
591
+
592
+ def _grade_once(
593
+ spec: PatchEvalSpec,
594
+ image: LatestPiImage,
595
+ patch: str,
596
+ command: tuple[str, ...],
597
+ *,
598
+ hidden_tests: bool,
599
+ owner_id: str,
600
+ ) -> dict:
601
+ docker = image.docker
602
+ label = "f2p" if hidden_tests else "regression"
603
+ container = f"benchkit-patcheval-{label}-{uuid.uuid4().hex[:12]}"
604
+ labels = resource_labels(owner_id)
605
+ with tempfile.TemporaryDirectory(prefix="benchkit-patcheval-grade-") as directory:
606
+ patch_path = Path(directory) / "submission.patch"
607
+ patch_path.write_text(patch, encoding="utf-8")
608
+ try:
609
+ _run(
610
+ [
611
+ docker,
612
+ "run",
613
+ "--detach",
614
+ "--name",
615
+ container,
616
+ *labels,
617
+ "--network",
618
+ "none",
619
+ "--cap-drop",
620
+ "ALL",
621
+ "--security-opt",
622
+ "no-new-privileges",
623
+ "--pids-limit",
624
+ os.environ.get("BENCHKIT_SANDBOX_PIDS", "256"),
625
+ "--memory",
626
+ os.environ.get("BENCHKIT_SANDBOX_MEMORY", "2g"),
627
+ "--cpus",
628
+ os.environ.get("BENCHKIT_SANDBOX_CPUS", "2"),
629
+ image.image,
630
+ "sleep",
631
+ "infinity",
632
+ ],
633
+ timeout=60,
634
+ )
635
+ _run([docker, "cp", str(spec.source_archive), f"{container}:/tmp/source"])
636
+ _run([docker, "cp", str(patch_path), f"{container}:/tmp/submission.patch"])
637
+ if hidden_tests:
638
+ _run(
639
+ [
640
+ docker,
641
+ "cp",
642
+ str(spec.hidden_test_patch),
643
+ f"{container}:/tmp/hidden-tests.patch",
644
+ ]
645
+ )
646
+ _docker_exec(docker, container, ["mkdir", "-p", "/workspace/repo"])
647
+ _docker_exec(
648
+ docker,
649
+ container,
650
+ ["tar", "-xf", "/tmp/source", "-C", "/workspace/repo"],
651
+ )
652
+ _docker_exec(
653
+ docker,
654
+ container,
655
+ [
656
+ "find",
657
+ "/workspace/repo",
658
+ "-exec",
659
+ "touch",
660
+ "-t",
661
+ "198001010000",
662
+ "{}",
663
+ "+",
664
+ ],
665
+ )
666
+ if patch:
667
+ _submission_step(
668
+ docker,
669
+ container,
670
+ ["git", "apply", "--binary", "/tmp/submission.patch"],
671
+ reason="submitted patch does not apply to the pristine source",
672
+ )
673
+ if hidden_tests:
674
+ _docker_exec(
675
+ docker,
676
+ container,
677
+ ["git", "apply", "--binary", "/tmp/hidden-tests.patch"],
678
+ workdir="/workspace/repo",
679
+ )
680
+ if spec.setup_command:
681
+ # The miner validated this step on the pristine tree, so once the
682
+ # submission is applied a failure here is the submission's.
683
+ _submission_step(
684
+ docker,
685
+ container,
686
+ list(spec.setup_command),
687
+ reason="submitted tree fails to build or install",
688
+ timeout=spec.timeout_s + 30,
689
+ )
690
+ completed = _docker_exec(
691
+ docker,
692
+ container,
693
+ ["timeout", "--signal=KILL", f"{spec.timeout_s}s", *command],
694
+ workdir="/workspace/repo",
695
+ timeout=spec.timeout_s + 30,
696
+ check=False,
697
+ )
698
+ output = (completed.stdout + completed.stderr).strip()
699
+ return {
700
+ "exit_code": completed.returncode,
701
+ "output": output[-8000:],
702
+ "output_truncated": len(output) > 8000,
703
+ }
704
+ finally:
705
+ _run([docker, "rm", "--force", container], timeout=30, check=False)
706
+
707
+
708
+ class PatchEval:
709
+ """Fix real Python repository issues with hidden, deterministic grading."""
710
+
711
+ name = "patcheval"
712
+ task_count = 20
713
+ workspace_task = True
714
+ evaluation_activity = "running hidden PatchEval checks"
715
+ list_note = "pilot-20 · requires Pi and Docker"
716
+ data_source = Dataset(
717
+ summary="frozen pilot-20 release from Hugging Face",
718
+ size=None,
719
+ location=_dataset_dir,
720
+ is_installed=_dataset_installed,
721
+ install=_fetch_release,
722
+ )
723
+
724
+ def __init__(self, dataset_root: Path | None = None) -> None:
725
+ self.dataset_root = dataset_root
726
+ self.release = "unloaded"
727
+
728
+ def _root(self) -> Path:
729
+ return (self.dataset_root or _fetch_release()).resolve()
730
+
731
+ @staticmethod
732
+ def _spec(task: Task) -> PatchEvalSpec:
733
+ spec = task.metadata.get("spec")
734
+ if not isinstance(spec, PatchEvalSpec):
735
+ raise TypeError("PatchEval task metadata is missing its validated spec")
736
+ return spec
737
+
738
+ def load_tasks(self) -> list[Task]:
739
+ root = self._root()
740
+ release_path = root / _RELEASE_FILE
741
+ tasks_path = root / _TASKS_FILE
742
+ try:
743
+ release = json.loads(release_path.read_text(encoding="utf-8"))
744
+ except (OSError, json.JSONDecodeError) as exc:
745
+ raise RuntimeError(
746
+ f"could not read PatchEval dataset metadata: {exc}"
747
+ ) from exc
748
+ if (
749
+ not isinstance(release, dict)
750
+ or release.get("schema_version") != _SCHEMA_VERSION
751
+ ):
752
+ raise RuntimeError(
753
+ f"PatchEval dataset must use schema_version {_SCHEMA_VERSION}"
754
+ )
755
+ self.release = str(release.get("release") or "unknown")
756
+ try:
757
+ records = [
758
+ json.loads(line)
759
+ for line in tasks_path.read_text(encoding="utf-8").splitlines()
760
+ if line.strip()
761
+ ]
762
+ specs = [_load_spec(root, record) for record in records]
763
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
764
+ raise RuntimeError(f"invalid PatchEval dataset: {exc}") from exc
765
+ expected = release.get("task_count")
766
+ if expected != len(specs):
767
+ raise RuntimeError(
768
+ f"PatchEval release declares {expected} tasks but contains {len(specs)}"
769
+ )
770
+ ids = [spec.id for spec in specs]
771
+ if len(ids) != len(set(ids)):
772
+ raise RuntimeError("PatchEval task ids must be unique")
773
+ self.task_count = len(specs)
774
+ return [Task(spec.id, "", {"spec": spec}) for spec in specs]
775
+
776
+ def result_metadata(self, _variant: str | None) -> dict:
777
+ return {"dataset_release": self.release}
778
+
779
+ def build_prompt(self, task: Task) -> str:
780
+ spec = self._spec(task)
781
+ return (
782
+ "Fix the following issue in the current repository. Inspect the code, "
783
+ "make the necessary changes, and verify your solution.\n\n"
784
+ f"# {spec.issue_title}\n\n{spec.issue_body}"
785
+ )
786
+
787
+ def build_repair_prompt(self, _feedback: str, _attempt: int, _total: int) -> str:
788
+ return _GENERIC_REPAIR
789
+
790
+ def evaluate(self, _task: Task, _response: str) -> bool:
791
+ raise RuntimeError("PatchEval must be evaluated by its external grader")
792
+
793
+ def pi_image_for_task(self, task: Task) -> LatestPiImage:
794
+ spec = self._spec(task)
795
+ return patcheval_pi_image(
796
+ spec.source_archive,
797
+ spec.source_sha256,
798
+ spec.runtime_recipe,
799
+ )
800
+
801
+ def prepare_workspace(self, task: Task, environment: DockerTaskEnvironment) -> None:
802
+ spec = self._spec(task)
803
+ environment.workdir = "/workspace/repo"
804
+ environment.upload(spec.source_archive, "/tmp/patch-eval-source")
805
+ environment.exec(["mkdir", "-p", environment.workdir])
806
+ environment.exec(
807
+ ["tar", "-xf", "/tmp/patch-eval-source", "-C", environment.workdir]
808
+ )
809
+ environment.exec(
810
+ [
811
+ "find",
812
+ environment.workdir,
813
+ "-exec",
814
+ "touch",
815
+ "-t",
816
+ "198001010000",
817
+ "{}",
818
+ "+",
819
+ ]
820
+ )
821
+ environment.exec(["rm", "-rf", f"{environment.workdir}/.git"])
822
+ if spec.setup_command:
823
+ environment.exec(
824
+ list(spec.setup_command),
825
+ workdir=environment.workdir,
826
+ timeout=spec.timeout_s + 30,
827
+ )
828
+ environment.exec(["git", "init", "-q", environment.workdir])
829
+ environment.exec(
830
+ ["git", "-C", environment.workdir, "config", "user.name", "BenchKit"]
831
+ )
832
+ environment.exec(
833
+ [
834
+ "git",
835
+ "-C",
836
+ environment.workdir,
837
+ "config",
838
+ "user.email",
839
+ "benchkit@localhost",
840
+ ]
841
+ )
842
+ environment.exec(["git", "-C", environment.workdir, "add", "."])
843
+ environment.exec(
844
+ [
845
+ "git",
846
+ "-C",
847
+ environment.workdir,
848
+ "commit",
849
+ "-q",
850
+ "-m",
851
+ "starting state",
852
+ ]
853
+ )
854
+
855
+ def verify_workspace(
856
+ self,
857
+ task: Task,
858
+ environment: DockerTaskEnvironment,
859
+ _tool_trace: list[dict] | None = None,
860
+ ) -> EvaluationResult:
861
+ spec = self._spec(task)
862
+ try:
863
+ patch, changed, excluded = _trusted_patch(spec, environment)
864
+ # The two graders share nothing but the read-only task image and
865
+ # the submitted patch, so they run at the same time.
866
+ with ThreadPoolExecutor(max_workers=2) as pool:
867
+ f2p_grade = pool.submit(
868
+ _grade_once,
869
+ spec,
870
+ environment.image,
871
+ patch,
872
+ spec.fail_to_pass_command,
873
+ hidden_tests=True,
874
+ owner_id=environment.owner_id,
875
+ )
876
+ regression_grade = pool.submit(
877
+ _grade_once,
878
+ spec,
879
+ environment.image,
880
+ patch,
881
+ spec.regression_command,
882
+ hidden_tests=False,
883
+ owner_id=environment.owner_id,
884
+ )
885
+ # A grader fault on either side wins over a rejected
886
+ # submission, so both are collected before blaming the agent.
887
+ grades: list[dict] = []
888
+ rejected: SubmissionRejected | None = None
889
+ for future in (f2p_grade, regression_grade):
890
+ try:
891
+ grades.append(future.result())
892
+ except SubmissionRejected as exc:
893
+ rejected = rejected or exc
894
+ if rejected is not None:
895
+ raise rejected
896
+ f2p, regression = grades
897
+ except SubmissionRejected as exc:
898
+ return EvaluationResult(
899
+ 0.0,
900
+ feedback=exc.feedback,
901
+ details={
902
+ "repository": spec.repository,
903
+ "submission_rejected": exc.reason,
904
+ "submission_rejected_output": exc.detail[-8000:],
905
+ },
906
+ )
907
+ except (OSError, RuntimeError, ValueError, SandboxError) as exc:
908
+ return EvaluationResult(
909
+ 0.0,
910
+ error=f"PatchEval grader infrastructure failed: {type(exc).__name__}: {exc}",
911
+ )
912
+ passed = f2p["exit_code"] == 0 and regression["exit_code"] == 0
913
+ return EvaluationResult(
914
+ 1.0 if passed else 0.0,
915
+ feedback="" if passed else _GENERIC_REPAIR,
916
+ details={
917
+ "repository": spec.repository,
918
+ "f2p_exit_code": f2p["exit_code"],
919
+ "regression_exit_code": regression["exit_code"],
920
+ "f2p_output": f2p["output"],
921
+ "f2p_output_truncated": f2p["output_truncated"],
922
+ "regression_output": regression["output"],
923
+ "regression_output_truncated": regression["output_truncated"],
924
+ "changed_paths": changed,
925
+ "excluded_agent_paths": excluded,
926
+ "patch_sha256": hashlib.sha256(patch.encode()).hexdigest(),
927
+ "patch": patch,
928
+ },
929
+ )