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
benchkit/demo.py ADDED
@@ -0,0 +1,451 @@
1
+ """Offline stand-in inference client used by ``benchkit --demo``.
2
+
3
+ Demo mode exists so the interface can be explored (and reviewed) without an
4
+ inference server: it answers prompts locally with a per-model skill level, so
5
+ scores, speeds and pass/fail streaks look like a real run.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import importlib
12
+ import json
13
+ import random
14
+ import threading
15
+ import time
16
+ from collections.abc import Callable
17
+ from pathlib import Path
18
+
19
+ from benchkit.benchmarks.base import Task
20
+ from benchkit.client import GenerationUpdate
21
+ from benchkit.engine import benchmark, prompt_for, tasks_for
22
+ from benchkit.perturbations import perturb_task
23
+
24
+ LETTERS = "ABCDEFGHIJ"
25
+
26
+ DEMO_MODELS = [
27
+ {"name": "demo-nano:0.5b", "size": 0.4e9, "skill": 0.30, "speed": 240.0},
28
+ {"name": "demo-mini:3b", "size": 2.1e9, "skill": 0.55, "speed": 130.0},
29
+ {"name": "demo-pro:8b", "size": 5.2e9, "skill": 0.76, "speed": 68.0},
30
+ {"name": "demo-max:14b", "size": 9.4e9, "skill": 0.91, "speed": 34.0},
31
+ ]
32
+
33
+
34
+ def _dataset_path(key: str) -> Path | None:
35
+ """The bundled dataset file, if any; downloaded ones carry no solutions."""
36
+ module = importlib.import_module(type(benchmark(key)).__module__)
37
+ path = getattr(module, "DATASET", None)
38
+ return path if isinstance(path, Path) else None
39
+
40
+
41
+ def _canonical_solutions(key: str) -> dict[str, str]:
42
+ """Reference solutions from a dataset file, when it ships any."""
43
+ path = _dataset_path(key)
44
+ if path is None:
45
+ return {}
46
+
47
+ solutions: dict[str, str] = {}
48
+ try:
49
+ with open(path, encoding="utf-8") as handle:
50
+ for line in handle:
51
+ row = json.loads(line)
52
+ solution = row.get("canonical_solution") or row.get("code")
53
+ if solution:
54
+ solutions[row["task_id"]] = solution
55
+ # ValueError covers both JSONDecodeError and UnicodeDecodeError.
56
+ except (OSError, ValueError, KeyError):
57
+ return {}
58
+ return solutions
59
+
60
+
61
+ def _letters(task: Task) -> str:
62
+ """Option letters a multiple-choice task actually offers."""
63
+ choices = task.metadata.get("choices")
64
+ if isinstance(choices, (list, tuple)) and choices:
65
+ return LETTERS[: len(choices)]
66
+ return LETTERS[:4]
67
+
68
+
69
+ def _wrong_letter(answer: str, letters: str) -> str:
70
+ options = [letter for letter in letters if letter != answer]
71
+ return random.choice(options) if options else "A"
72
+
73
+
74
+ _FILLER_SENTENCES = (
75
+ "Here is a considered response to the request at hand.",
76
+ "The plan below keeps every stated requirement in view.",
77
+ "Each step is written plainly so nothing is left implicit.",
78
+ "A short pause for structure makes the result easier to follow.",
79
+ "The remaining detail fills out the picture without extra noise.",
80
+ "This closing thought ties the pieces back together.",
81
+ )
82
+
83
+
84
+ def _ifeval_body(sentences: int, words: int | None) -> str:
85
+ """Filler prose sized to the sentence and word budget of a task."""
86
+ text: list[str] = []
87
+ for index in range(max(sentences, 1)):
88
+ text.append(_FILLER_SENTENCES[index % len(_FILLER_SENTENCES)])
89
+
90
+ if words:
91
+ while len(" ".join(text).split()) < words:
92
+ text[-1] = text[-1].rstrip(".") + " and a little more detail follows."
93
+ return " ".join(text)
94
+
95
+
96
+ def _ifeval_answer(task: Task) -> str:
97
+ """Compose a response that satisfies the simpler IFEval constraints.
98
+
99
+ Demo mode never calls a real model, so the strongest demo answer is one
100
+ built directly from the instruction metadata. Constraints that need real
101
+ language understanding (response language, letter frequency) are skipped,
102
+ which keeps demo scores below a perfect run.
103
+ """
104
+ args = {
105
+ instruction: dict(kwargs or {})
106
+ for instruction, kwargs in zip(
107
+ task.metadata["instruction_id_list"],
108
+ task.metadata["kwargs"],
109
+ strict=False,
110
+ )
111
+ }
112
+
113
+ if "detectable_format:json_format" in args:
114
+ return '{"answer": "demo response", "complete": true}'
115
+ if "detectable_format:constrained_response" in args:
116
+ return "My answer is maybe."
117
+
118
+ sentence_spec = args.get("length_constraints:number_sentences")
119
+ sentences = 4
120
+ if sentence_spec:
121
+ target = int(sentence_spec["num_sentences"])
122
+ sentences = (
123
+ target if sentence_spec["relation"] == "at least" else max(target - 1, 1)
124
+ )
125
+
126
+ word_spec = args.get("length_constraints:number_words")
127
+ words = None
128
+ if word_spec and word_spec["relation"] == "at least":
129
+ words = int(word_spec["num_words"])
130
+ elif word_spec:
131
+ sentences = min(sentences, 2)
132
+
133
+ body = _ifeval_body(sentences, words)
134
+
135
+ keywords = args.get("keywords:existence", {}).get("keywords", [])
136
+ if keywords:
137
+ body += " Keywords in play: " + " ".join(keywords) + "."
138
+
139
+ frequency = args.get("keywords:frequency")
140
+ if frequency and frequency["relation"] == "at least":
141
+ repeats = [frequency["keyword"]] * int(frequency["frequency"])
142
+ body += " " + " ".join(repeats) + "."
143
+
144
+ placeholders = args.get("detectable_content:number_placeholders")
145
+ if placeholders:
146
+ count = int(placeholders["num_placeholders"])
147
+ body += " " + " ".join(f"[field {i + 1}]" for i in range(count))
148
+
149
+ highlights = args.get("detectable_format:number_highlighted_sections")
150
+ if highlights:
151
+ count = int(highlights["num_highlights"])
152
+ body += "\n" + "\n".join(f"*highlight {i + 1}*" for i in range(count))
153
+
154
+ bullets = args.get("detectable_format:number_bullet_lists")
155
+ if bullets:
156
+ count = int(bullets["num_bullets"])
157
+ body += "\n" + "\n".join(f"* point {i + 1}" for i in range(count))
158
+
159
+ sections = args.get("detectable_format:multiple_sections")
160
+ if sections:
161
+ spliter = sections["section_spliter"]
162
+ count = int(sections["num_sections"])
163
+ body = "\n".join(f"{spliter} {i + 1}\n{body}" for i in range(count))
164
+
165
+ paragraphs = args.get("length_constraints:number_paragraphs")
166
+ if paragraphs:
167
+ count = int(paragraphs["num_paragraphs"])
168
+ body = "\n***\n".join([body] * count)
169
+
170
+ if "detectable_format:title" in args:
171
+ body = f"<<Demo Response>>\n{body}"
172
+
173
+ if "combination:repeat_prompt" in args:
174
+ prompt = args["combination:repeat_prompt"].get("prompt_to_repeat", task.prompt)
175
+ body = f"{prompt}\n{body}"
176
+
177
+ if "combination:two_responses" in args:
178
+ body = f"{body}\n******\n{body}"
179
+
180
+ postscript = args.get("detectable_content:postscript")
181
+ if postscript:
182
+ body += f"\n\n{postscript['postscript_marker']} That is the whole answer."
183
+
184
+ end_phrase = args.get("startend:end_checker")
185
+ if end_phrase:
186
+ body += f" {end_phrase['end_phrase']}"
187
+
188
+ forbidden = args.get("keywords:forbidden_words", {}).get("forbidden_words", [])
189
+ for word in forbidden:
190
+ body = body.replace(word, "…").replace(word.capitalize(), "…")
191
+
192
+ if "punctuation:no_comma" in args:
193
+ body = body.replace(",", "")
194
+
195
+ if "change_case:english_capital" in args:
196
+ body = body.upper()
197
+ elif "change_case:english_lowercase" in args:
198
+ body = body.lower()
199
+
200
+ if "startend:quotation" in args:
201
+ body = f'"{body}"'
202
+
203
+ return body
204
+
205
+
206
+ def _good_answer(task: Task, solution: str | None) -> str:
207
+ metadata = task.metadata
208
+ solution = solution or metadata.get("canonical_solution")
209
+ if solution is not None:
210
+ return solution
211
+
212
+ if "instruction_id_list" in metadata:
213
+ return _ifeval_answer(task)
214
+
215
+ answers = metadata.get("answers")
216
+ if isinstance(answers, (list, tuple)):
217
+ return ", ".join(str(answer) for answer in answers)
218
+
219
+ answer = metadata.get("answer")
220
+ if isinstance(answer, bool):
221
+ return "Yes" if answer else "No"
222
+ if isinstance(answer, str) and answer.lower() in {"yes", "no"}:
223
+ return answer.capitalize()
224
+ if isinstance(answer, str) and answer.upper() in _letters(task):
225
+ return f"The answer is {answer.upper()}."
226
+ if answer is not None:
227
+ return f"Working through it step by step.\n\n#### {answer}"
228
+ return "A"
229
+
230
+
231
+ def _bad_answer(task: Task, has_solution: bool) -> str:
232
+ metadata = task.metadata
233
+ if has_solution or "test" in metadata:
234
+ return " raise NotImplementedError"
235
+
236
+ if "instruction_id_list" in metadata:
237
+ return "Sure, here is a quick answer that ignores the formatting rules."
238
+
239
+ if isinstance(metadata.get("answers"), (list, tuple)):
240
+ return "NOT_FOUND"
241
+
242
+ answer = metadata.get("answer")
243
+ if isinstance(answer, bool):
244
+ return "No" if answer else "Yes"
245
+ if isinstance(answer, str) and answer.lower() in {"yes", "no"}:
246
+ return "No" if answer.lower() == "yes" else "Yes"
247
+ if isinstance(answer, str) and answer.upper() in _letters(task):
248
+ return f"The answer is {_wrong_letter(answer.upper(), _letters(task))}."
249
+ if answer is not None:
250
+ return "Let me compute that.\n\n#### 42"
251
+ return "B"
252
+
253
+
254
+ class DemoClient:
255
+ """Mimics :class:`benchkit.client.InferenceClient` without a server."""
256
+
257
+ host = "demo://offline"
258
+ label = "Demo mode"
259
+ simulated_timing = True
260
+ provider = "demo"
261
+ timeout = 0.0
262
+
263
+ def __init__(self, speed: float = 1.0) -> None:
264
+ self.speed = max(speed, 0.01)
265
+ self._answers: dict[str, tuple[Task, str | None]] = {}
266
+ self._primed: set[tuple[str, str | None, int]] = set()
267
+
268
+ def prime(
269
+ self,
270
+ benchmark_keys: list[str],
271
+ perturbations: list[tuple[str, int]] | None = None,
272
+ ) -> None:
273
+ """Index prompts for the benchmarks about to run."""
274
+ specs: list[tuple[str | None, int]] = [(None, 42)]
275
+ specs.extend(perturbations or [])
276
+ for key in benchmark_keys:
277
+ bench = benchmark(key)
278
+ solutions = _canonical_solutions(key)
279
+ selected_tasks = tasks_for(key)
280
+ variants = getattr(bench, "variants", None)
281
+ select = getattr(bench, "tasks_for_variant", None)
282
+ if callable(variants) and callable(select):
283
+ selected_tasks = [
284
+ task
285
+ for variant in variants(self, DEMO_MODELS[0]["name"])
286
+ for task in select(selected_tasks, variant)
287
+ ]
288
+ for perturbation, seed in specs:
289
+ cache_key = (key, perturbation, seed)
290
+ if cache_key in self._primed:
291
+ continue
292
+ for task in selected_tasks:
293
+ case = perturb_task(key, task, perturbation, seed)
294
+ prompt = prompt_for(
295
+ bench,
296
+ case.prompt_task,
297
+ self,
298
+ DEMO_MODELS[0]["name"],
299
+ )
300
+ solution = solutions.get(task.id) or task.metadata.get(
301
+ "canonical_solution"
302
+ )
303
+ self._answers[prompt] = (case.evaluation_task, solution)
304
+ self._primed.add(cache_key)
305
+
306
+ def list_models(self) -> list[dict]:
307
+ return [
308
+ {
309
+ "name": model["name"],
310
+ "size": model["size"],
311
+ "status": "demo",
312
+ "context_length": self.context_length(model["name"]),
313
+ }
314
+ for model in DEMO_MODELS
315
+ ]
316
+
317
+ def max_parallel_requests(self, model: str) -> int:
318
+ """Keep the deterministic offline simulation single-threaded."""
319
+ return 1
320
+
321
+ def context_length(self, _model: str) -> int:
322
+ """Keep the offline demo representative without building huge prompts."""
323
+ return 8192
324
+
325
+ def unload_model(self, model: str) -> None:
326
+ time.sleep(0.05 / self.speed)
327
+
328
+ def force_unload_model(self, model: str) -> None:
329
+ self.unload_model(model)
330
+
331
+ def _profile(self, model: str) -> dict:
332
+ for entry in DEMO_MODELS:
333
+ if entry["name"] == model:
334
+ return entry
335
+ return DEMO_MODELS[-1]
336
+
337
+ def generate(
338
+ self,
339
+ model: str,
340
+ prompt: str,
341
+ on_progress: Callable[[GenerationUpdate], None] | None = None,
342
+ cancel_event: threading.Event | None = None,
343
+ images: list[str] | None = None,
344
+ ) -> dict:
345
+ profile = self._profile(model)
346
+ entry = self._answers.get(prompt)
347
+
348
+ # Deterministic per model+prompt so repeat runs stay comparable.
349
+ digest = hashlib.sha256(f"{model}{prompt}".encode()).hexdigest()
350
+ roll = int(digest[:8], 16) / 0xFFFFFFFF
351
+ random.seed(digest)
352
+
353
+ if entry is None:
354
+ response = "A"
355
+ else:
356
+ task, solution = entry
357
+ correct = roll < profile["skill"]
358
+ response = (
359
+ _good_answer(task, solution)
360
+ if correct
361
+ else _bad_answer(task, solution is not None)
362
+ )
363
+
364
+ healthy_thinking = (
365
+ "I will identify the requested output, check the relevant facts, "
366
+ "compare the available choices, and then return the concise final "
367
+ "answer. The evidence points consistently toward one option. "
368
+ )
369
+ looping = roll > profile["skill"] + 0.12
370
+ thinking = (
371
+ (
372
+ "I should reconsider this carefully because the previous path "
373
+ "may be wrong, so I will inspect the same evidence once more. "
374
+ )
375
+ * 14
376
+ if looping
377
+ else healthy_thinking
378
+ )
379
+
380
+ tokens = max(40 + int(roll * 260), len((thinking + response).split()))
381
+ tok_s = profile["speed"] * (0.85 + roll * 0.3)
382
+ elapsed = tokens / tok_s
383
+ emitted_thinking = ""
384
+
385
+ def cancelled_result() -> dict:
386
+ return {
387
+ "thinking": emitted_thinking,
388
+ "response": "",
389
+ "trace_status": "observed" if emitted_thinking else "unavailable",
390
+ "tok_s": 0.0,
391
+ "eval_count": 0,
392
+ "eval_duration_ns": 0,
393
+ "response_time_s": 0.0,
394
+ "done_reason": "cancelled",
395
+ "timed_out": False,
396
+ "cancelled": True,
397
+ }
398
+
399
+ if cancel_event is not None and cancel_event.is_set():
400
+ return cancelled_result()
401
+ if on_progress is not None:
402
+ chunks = [
403
+ thinking[index : index + 80] for index in range(0, len(thinking), 80)
404
+ ]
405
+ for index, chunk in enumerate(chunks, start=1):
406
+ time.sleep(min(0.012 / self.speed, 0.03))
407
+ if cancel_event is not None and cancel_event.is_set():
408
+ return cancelled_result()
409
+ emitted_thinking += chunk
410
+ on_progress(
411
+ GenerationUpdate(
412
+ thinking=chunk,
413
+ elapsed_s=elapsed * index / (len(chunks) + 1),
414
+ reasoning_channel_seen=True,
415
+ )
416
+ )
417
+ on_progress(
418
+ GenerationUpdate(
419
+ response=response,
420
+ elapsed_s=elapsed,
421
+ reasoning_channel_seen=True,
422
+ )
423
+ )
424
+ on_progress(
425
+ GenerationUpdate(
426
+ elapsed_s=elapsed,
427
+ reasoning_channel_seen=True,
428
+ done=True,
429
+ )
430
+ )
431
+ else:
432
+ time.sleep(min(elapsed / (40 * self.speed), 0.35))
433
+ if cancel_event is not None and cancel_event.is_set():
434
+ return cancelled_result()
435
+
436
+ return {
437
+ "thinking": thinking,
438
+ "response": response,
439
+ "trace_status": "observed",
440
+ "tok_s": tok_s,
441
+ "eval_count": tokens,
442
+ "eval_duration_ns": int(elapsed * 1e9),
443
+ "response_time_s": elapsed,
444
+ "done_reason": "stop",
445
+ "timed_out": False,
446
+ "cancelled": False,
447
+ }
448
+
449
+
450
+ def is_demo_client(client: object) -> bool:
451
+ return isinstance(client, DemoClient)
@@ -0,0 +1,247 @@
1
+ """What each sandbox image contains.
2
+
3
+ The Dockerfiles for the Pi harness (stock, Aider Polyglot toolchains, Git
4
+ Surgery, PatchEval task runtimes) and the mc-arena script runner. Nothing here
5
+ talks to Docker: :mod:`benchkit.sandbox` builds, runs and removes the images.
6
+ Every tool and base image is pinned, so a change here changes what a run
7
+ measures.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from dataclasses import dataclass
15
+
16
+ AIDER_POLYGLOT_COMMIT = "7e0611e77b54e2dea774cdc0aa00cf9f7ed6144f"
17
+ _UV_CACHE_DIR = "/root/.cache/uv"
18
+
19
+
20
+ _PI_INSTALL = """\
21
+ COPY pi-package/package.json pi-package/package-lock.json /opt/benchkit/pi/
22
+ RUN cd /opt/benchkit/pi \\
23
+ && npm ci --omit=dev \\
24
+ && ln -s /opt/benchkit/pi/node_modules/.bin/pi /usr/local/bin/pi
25
+ """
26
+
27
+ PI_DOCKERFILE = f"""\
28
+ FROM node:24-bookworm-slim
29
+
30
+ RUN apt-get update \\
31
+ && apt-get install -y --no-install-recommends bash ca-certificates git python3 ripgrep \\
32
+ && rm -rf /var/lib/apt/lists/*
33
+ {_PI_INSTALL}
34
+
35
+ COPY inference_proxy.py /opt/benchkit/inference_proxy.py
36
+ COPY answer_key_guard.ts /opt/benchkit/answer_key_guard.ts
37
+ RUN mkdir -p /workspace /home/node/.pi/agent \\
38
+ && chown -R node:node /workspace /home/node/.pi
39
+
40
+ USER node
41
+ WORKDIR /workspace
42
+ ENV PI_OFFLINE=1 PI_TELEMETRY=0
43
+ CMD ["sleep", "infinity"]
44
+ """
45
+
46
+ AIDER_PI_DOCKERFILE = f"""\
47
+ FROM node:24-bookworm
48
+
49
+ RUN apt-get update \\
50
+ && apt-get install -y --no-install-recommends \\
51
+ bash ca-certificates cmake curl g++ git libboost-date-time-dev \\
52
+ openjdk-17-jdk \\
53
+ python3 ripgrep unzip \\
54
+ && rm -rf /var/lib/apt/lists/*
55
+ {_PI_INSTALL}
56
+ ARG TARGETARCH
57
+ RUN curl -fsSL "https://go.dev/dl/go1.21.5.linux-${{TARGETARCH}}.tar.gz" \\
58
+ -o /tmp/go.tar.gz \\
59
+ && tar -C /usr/local -xzf /tmp/go.tar.gz \\
60
+ && rm /tmp/go.tar.gz
61
+ ENV PATH="/usr/local/go/bin:$PATH" RUSTUP_HOME="/opt/rustup" \\
62
+ CARGO_HOME="/opt/cargo-home"
63
+ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \\
64
+ | sh -s -- -y --no-modify-path
65
+ RUN mkdir -p /opt/javascript-deps \\
66
+ && cd /opt/javascript-deps \\
67
+ && npm init -y \\
68
+ && npm install jest@29.7.0 @babel/core@7.25.2 \\
69
+ @exercism/babel-preset-javascript@0.2.1 \\
70
+ @exercism/eslint-config-javascript@0.6.0 @types/jest@29.5.12 \\
71
+ @types/node@20.12.12 babel-jest@29.6.4 core-js@3.37.1 eslint@8.49.0
72
+ RUN curl -fsSL https://services.gradle.org/distributions/gradle-8.10.2-bin.zip \\
73
+ -o /tmp/gradle.zip \\
74
+ && unzip -q /tmp/gradle.zip -d /opt \\
75
+ && rm /tmp/gradle.zip
76
+ ENV PATH="/opt/gradle-8.10.2/bin:/opt/javascript-deps/node_modules/.bin:/opt/cargo-home/bin:$PATH" \\
77
+ NODE_PATH="/opt/javascript-deps/node_modules" \\
78
+ GOPATH="/opt/go" GRADLE_USER_HOME="/opt/gradle-cache"
79
+ RUN git clone https://github.com/Aider-AI/polyglot-benchmark.git \\
80
+ /opt/aider-polyglot \\
81
+ && git -C /opt/aider-polyglot checkout {AIDER_POLYGLOT_COMMIT} \\
82
+ && find /opt/aider-polyglot -name Cargo.toml -execdir cargo fetch \\; \\
83
+ && find /opt/aider-polyglot/rust/exercises/practice \\
84
+ -name Cargo-example.toml -exec sh -c '\
85
+ for manifest do \
86
+ cache_dir=$(mktemp -d); \
87
+ mkdir "$cache_dir/src"; \
88
+ cp "$manifest" "$cache_dir/Cargo.toml"; \
89
+ cp "$(dirname "$manifest")/example.rs" "$cache_dir/src/lib.rs"; \
90
+ cargo fetch --manifest-path "$cache_dir/Cargo.toml"; \
91
+ rm -rf "$cache_dir"; \
92
+ done' sh {{}} + \\
93
+ && find /opt/aider-polyglot/go/exercises/practice -name go.mod \\
94
+ -execdir go mod download \\; \\
95
+ && find /opt/aider-polyglot/java/exercises/practice -name build.gradle \\
96
+ -execdir gradle test --test-dry-run --no-daemon \\;
97
+ RUN find /opt/aider-polyglot -depth \\
98
+ \\( -name .meta -o -name .approaches \\
99
+ -o -iname '*example*' -o -iname '*reference*' -o -iname '*proof*' \\) \\
100
+ -exec rm -rf {{}} + \\
101
+ && rm -rf /opt/aider-polyglot/.git
102
+
103
+ COPY inference_proxy.py /opt/benchkit/inference_proxy.py
104
+ COPY answer_key_guard.ts /opt/benchkit/answer_key_guard.ts
105
+ RUN mkdir -p /workspace /home/node/.pi/agent /opt/go \\
106
+ && chown -R node:node /workspace /home/node/.pi /opt/cargo-home \\
107
+ /opt/rustup /opt/go /opt/gradle-cache \\
108
+ && chmod -R a+rX /opt/aider-polyglot
109
+
110
+ USER node
111
+ WORKDIR /workspace
112
+ ENV PI_OFFLINE=1 PI_TELEMETRY=0 CARGO_NET_OFFLINE=true
113
+ CMD ["sleep", "infinity"]
114
+ """
115
+
116
+ GIT_SURGERY_PI_DOCKERFILE = f"""\
117
+ FROM node:24-bookworm-slim
118
+
119
+ ARG GIT_DEBIAN_VERSION=1:2.39.5-0+deb12u3
120
+ RUN apt-get update \\
121
+ && apt-get install -y --no-install-recommends \\
122
+ bash ca-certificates git=${{GIT_DEBIAN_VERSION}} python3 ripgrep \\
123
+ && test "$(git --version)" = "git version 2.39.5" \\
124
+ && git config --system protocol.file.allow always \\
125
+ && rm -rf /var/lib/apt/lists/*
126
+ {_PI_INSTALL}
127
+
128
+ COPY inference_proxy.py /opt/benchkit/inference_proxy.py
129
+ COPY answer_key_guard.ts /opt/benchkit/answer_key_guard.ts
130
+ COPY git-surgery /opt/git-surgery
131
+ RUN chmod +x /opt/git-surgery/*/setup.sh /opt/git-surgery/*/verify.sh \\
132
+ && mkdir -p /workspace /home/node/.pi/agent \\
133
+ && chown -R node:node /workspace /home/node/.pi
134
+
135
+ USER node
136
+ WORKDIR /workspace
137
+ ENV PI_OFFLINE=1 PI_TELEMETRY=0
138
+ CMD ["sleep", "infinity"]
139
+ """
140
+
141
+
142
+ # mc-arena runs one untrusted, model-written Python script per task. The script
143
+ # only has to print JSON, so the image is a stock Python plus uv: the suite's
144
+ # answer contract is a PEP 723 script, and uv is what runs those.
145
+ MC_ARENA_UV_VERSION = "0.8.17"
146
+ MC_ARENA_DEFAULT_BASE = "python:3.12-slim"
147
+
148
+
149
+ def _mc_arena_base_image() -> str:
150
+ """The Python base image, overridable for hosts behind a registry mirror."""
151
+ return (
152
+ os.environ.get("BENCHKIT_MC_ARENA_BASE_IMAGE") or MC_ARENA_DEFAULT_BASE
153
+ ).strip()
154
+
155
+
156
+ def mc_arena_dockerfile() -> str:
157
+ return f"""\
158
+ FROM {_mc_arena_base_image()}
159
+
160
+ RUN pip install --no-cache-dir uv=={MC_ARENA_UV_VERSION} \\
161
+ && useradd --create-home --uid 1000 runner \\
162
+ && mkdir -p /work && chown runner:runner /work
163
+
164
+ USER runner
165
+ WORKDIR /work
166
+ ENV HOME=/home/runner UV_NO_PROGRESS=1 UV_OFFLINE=1
167
+ CMD ["sleep", "infinity"]
168
+ """
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class PatchEvalRuntimeRecipe:
173
+ """Trusted build instructions shipped with one validated PatchEval task."""
174
+
175
+ base_image: str
176
+ sync_command: tuple[str, ...]
177
+ bootstrap_command: tuple[str, ...] = ()
178
+ environment: tuple[str, ...] = ()
179
+ schema_version: int = 1
180
+
181
+
182
+ def _docker_run_cached(command: tuple[str, ...], uv_cache_id: str) -> str:
183
+ """Run a build command with the run's shared, private uv cache mounted."""
184
+ mount = f"--mount=type=cache,target={_UV_CACHE_DIR},id={uv_cache_id},sharing=locked"
185
+ return f"RUN {mount} {json.dumps(list(command), separators=(',', ':'))}\n"
186
+
187
+
188
+ def patcheval_dockerfile(recipe: PatchEvalRuntimeRecipe, uv_cache_id: str) -> str:
189
+ """Generate the three-stage task runtime.
190
+
191
+ The stages are split by what actually changes between tasks:
192
+
193
+ 1. ``benchkit-pi-assets`` depends only on the base image and the pinned Pi
194
+ assets, so the run's shared builder builds it once.
195
+ 2. ``benchkit-runtime`` adds the parent source and the frozen sync and
196
+ bootstrap commands, and reuses the run's uv cache.
197
+ 3. the final stage only adds task environment and the agent user.
198
+ """
199
+ bootstrap = (
200
+ _docker_run_cached(recipe.bootstrap_command, uv_cache_id)
201
+ if recipe.bootstrap_command
202
+ else ""
203
+ )
204
+ environment = "".join(
205
+ f"ENV {key}={json.dumps(value)}\n"
206
+ for key, value in (item.split("=", 1) for item in recipe.environment)
207
+ )
208
+ return f"""\
209
+ FROM node:24-bookworm-slim AS benchkit-node
210
+
211
+ FROM {recipe.base_image} AS benchkit-pi-assets
212
+
213
+ USER root
214
+ COPY --from=benchkit-node /usr/local/ /usr/local/
215
+ RUN apt-get update \\
216
+ && apt-get install -y --no-install-recommends \\
217
+ bash ca-certificates coreutils git passwd ripgrep tar \\
218
+ && rm -rf /var/lib/apt/lists/* \\
219
+ && (getent group node >/dev/null || groupadd --gid 1000 node) \\
220
+ && (id --user node >/dev/null 2>&1 || \\
221
+ useradd --uid 1000 --gid node --create-home --shell /bin/bash node)
222
+ {_PI_INSTALL}
223
+ COPY inference_proxy.py /opt/benchkit/inference_proxy.py
224
+ COPY answer_key_guard.ts /opt/benchkit/answer_key_guard.ts
225
+ RUN mkdir -p /workspace /home/node/.pi/agent \\
226
+ && chown -R node:node /workspace /home/node/.pi
227
+
228
+ FROM benchkit-pi-assets AS benchkit-runtime
229
+
230
+ ENV UV_PROJECT_ENVIRONMENT=/opt/venv UV_LINK_MODE=copy
231
+ WORKDIR /opt/project
232
+ COPY parent-source.tar /tmp/patcheval-parent.tar
233
+ RUN tar -xf /tmp/patcheval-parent.tar -C /opt/project \\
234
+ && find /opt/project -exec touch -t 198001010000 {{}} + \\
235
+ && rm /tmp/patcheval-parent.tar
236
+ {_docker_run_cached(recipe.sync_command, uv_cache_id)}{bootstrap}RUN rm -rf /opt/project \\
237
+ && test -d /opt/venv \\
238
+ && chmod -R a+rwX /opt/venv
239
+
240
+ FROM benchkit-runtime
241
+
242
+ {environment}USER node
243
+ WORKDIR /workspace
244
+ ENV PATH=/opt/venv/bin:$PATH HOME=/home/node USER=node LOGNAME=node \\
245
+ PI_OFFLINE=1 PI_TELEMETRY=0
246
+ CMD ["sleep", "infinity"]
247
+ """