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,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
|
+
)
|