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/jobs.py ADDED
@@ -0,0 +1,332 @@
1
+ """Job planning: what a run will do before anything is generated.
2
+
3
+ Slice parsing, the cached benchmark registry, ``JobSpec`` and the functions that
4
+ expand, route and count jobs, plus the per-task questions the engine asks before
5
+ it generates (the prompt, how many options a task offers, how many repairs it
6
+ gets). Nothing here talks to a model.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Collection, Sequence
12
+ from dataclasses import dataclass, replace
13
+ from typing import Literal
14
+
15
+ from benchkit import decision
16
+ from benchkit.benchmarks import REGISTRY
17
+ from benchkit.benchmarks.base import Task
18
+
19
+ MAX_REPAIR_ATTEMPTS = 10
20
+
21
+
22
+ class SliceError(ValueError):
23
+ """Raised when a task slice specification cannot be parsed."""
24
+
25
+
26
+ def parse_slice(spec: str | None, total: int) -> tuple[int, int]:
27
+ """Resolve a slice spec into ``(start, end)`` indices for a task list.
28
+
29
+ Accepted forms: ``"20"`` (first 20), ``"-20"`` (last 20) and ``"5-15"``
30
+ (range, 0-indexed, end exclusive). Blank or ``None`` means every task.
31
+ """
32
+ if spec is None:
33
+ return 0, total
34
+
35
+ text = spec.strip()
36
+ if not text:
37
+ return 0, total
38
+
39
+ try:
40
+ if text.startswith("-"):
41
+ count = int(text[1:])
42
+ if count <= 0:
43
+ raise ValueError(text)
44
+ return max(0, total - count), total
45
+
46
+ if "-" in text:
47
+ head, tail = text.split("-", 1)
48
+ start, end = int(head), int(tail)
49
+ if start < 0 or end <= start:
50
+ raise ValueError(text)
51
+ # `total <= 0` means the task count is not known yet, so only the
52
+ # syntax can be checked.
53
+ if 0 < total <= start:
54
+ raise ValueError(text)
55
+ return min(start, total), min(end, total)
56
+
57
+ count = int(text)
58
+ if count <= 0:
59
+ raise ValueError(text)
60
+ return 0, min(count, total)
61
+ except ValueError as exc:
62
+ raise SliceError(f"Invalid slice: {spec}") from exc
63
+
64
+
65
+ def slice_label(spec: str | None) -> str:
66
+ """Human readable description of a slice spec."""
67
+ if spec is None or not spec.strip():
68
+ return "all tasks"
69
+
70
+ text = spec.strip()
71
+ if text.startswith("-"):
72
+ return f"last {text[1:]}"
73
+ if "-" in text:
74
+ head, tail = text.split("-", 1)
75
+ return f"tasks {head}-{tail}"
76
+ return f"first {text}"
77
+
78
+
79
+ _BENCHMARKS: dict[str, object] = {}
80
+ _TASKS: dict[str, list[Task]] = {}
81
+
82
+
83
+ def benchmark(key: str):
84
+ """Return a cached benchmark instance for a registry key."""
85
+ if key not in _BENCHMARKS:
86
+ _BENCHMARKS[key] = REGISTRY[key]()
87
+ return _BENCHMARKS[key]
88
+
89
+
90
+ def tasks_for(key: str) -> list[Task]:
91
+ """Return the (cached) full task list for a registry key."""
92
+ if key not in _TASKS:
93
+ _TASKS[key] = benchmark(key).load_tasks()
94
+ return _TASKS[key]
95
+
96
+
97
+ def task_count(key: str) -> int:
98
+ declared = getattr(benchmark(key), "task_count", None)
99
+ if isinstance(declared, int):
100
+ return declared
101
+ return len(tasks_for(key))
102
+
103
+
104
+ def slice_task_count(key: str) -> int:
105
+ """Return the task count to which a user-provided slice is applied."""
106
+ per_variant = getattr(benchmark(key), "tasks_per_variant", None)
107
+ if isinstance(per_variant, int):
108
+ return per_variant
109
+ return task_count(key)
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class JobSpec:
114
+ """One model run against one benchmark, optionally sliced."""
115
+
116
+ model: str
117
+ benchmark: str
118
+ slice_spec: str | None = None
119
+ variant: str | None = None
120
+ perturbation: str | None = None
121
+ perturbation_seed: int = 42
122
+ harness: Literal["direct", "pi", "decision"] = "direct"
123
+ repair_attempts: int = 0
124
+
125
+ def __post_init__(self) -> None:
126
+ if not 0 <= self.repair_attempts <= MAX_REPAIR_ATTEMPTS:
127
+ raise ValueError(
128
+ f"repair_attempts must be between 0 and {MAX_REPAIR_ATTEMPTS}"
129
+ )
130
+
131
+ @property
132
+ def key(self) -> str:
133
+ return (
134
+ f"{self.model}|{self.benchmark}|{self.variant or ''}|"
135
+ f"{self.slice_spec or ''}|{self.perturbation or ''}|"
136
+ f"{self.perturbation_seed if self.perturbation else ''}|{self.harness}|"
137
+ f"{self.repair_attempts}"
138
+ )
139
+
140
+ @property
141
+ def benchmark_label(self) -> str:
142
+ parts = [self.benchmark]
143
+ if self.variant:
144
+ parts.append(self.variant)
145
+ if self.perturbation:
146
+ parts.append(self.perturbation)
147
+ return " · ".join(parts)
148
+
149
+ @property
150
+ def title(self) -> str:
151
+ return f"{self.benchmark_label} · {self.model} · {self.harness_label}"
152
+
153
+ @property
154
+ def harness_label(self) -> str:
155
+ label = {"pi": "Pi agent", "decision": "Decision"}.get(self.harness, "Direct")
156
+ return f"{label} + repair" if self.repair_attempts else label
157
+
158
+ def planned_total(self) -> int:
159
+ if self.variant is not None:
160
+ total = len(tasks_for_job(self))
161
+ else:
162
+ total = slice_task_count(self.benchmark)
163
+ try:
164
+ start, end = parse_slice(self.slice_spec, total)
165
+ except SliceError:
166
+ selected = total
167
+ else:
168
+ selected = max(0, end - start)
169
+ if self.variant is not None:
170
+ return selected
171
+ variants = getattr(benchmark(self.benchmark), "variant_count", 1)
172
+ return selected * variants if isinstance(variants, int) else selected
173
+
174
+
175
+ def job_matrix(
176
+ models: Sequence[str],
177
+ benchmarks: Sequence[tuple[str, str | None, str | None]],
178
+ harnesses: Sequence[str],
179
+ *,
180
+ repair_attempts: int = 0,
181
+ perturbation: str | None = None,
182
+ perturbation_seed: int = 42,
183
+ ) -> list[JobSpec]:
184
+ """Build every model x benchmark x harness job for a run.
185
+
186
+ ``benchmarks`` holds ``(key, slice_spec, variant)``. Jobs come out in run
187
+ order: one model at a time, and with a perturbation each perturbed job
188
+ right after its clean baseline. The headless runner and the TUI both build
189
+ their runs here.
190
+ """
191
+ jobs: list[JobSpec] = []
192
+ for model in models:
193
+ for key, slice_spec, variant in benchmarks:
194
+ for harness in harnesses:
195
+ jobs.append(
196
+ JobSpec(
197
+ model,
198
+ key,
199
+ slice_spec,
200
+ variant=variant,
201
+ harness=harness,
202
+ repair_attempts=repair_attempts,
203
+ )
204
+ )
205
+ if perturbation:
206
+ jobs.append(
207
+ JobSpec(
208
+ model,
209
+ key,
210
+ slice_spec,
211
+ variant=variant,
212
+ perturbation=perturbation,
213
+ perturbation_seed=perturbation_seed,
214
+ harness=harness,
215
+ repair_attempts=repair_attempts,
216
+ )
217
+ )
218
+ return jobs
219
+
220
+
221
+ def expand_jobs(jobs: list[JobSpec], client: object) -> list[JobSpec]:
222
+ """Expand benchmarks with independent score variants into concrete jobs."""
223
+ expanded: list[JobSpec] = []
224
+ for job in jobs:
225
+ if job.variant is not None:
226
+ expanded.append(job)
227
+ continue
228
+ bench = benchmark(job.benchmark)
229
+ variants = getattr(bench, "variants", None)
230
+ if not callable(variants):
231
+ expanded.append(job)
232
+ continue
233
+ for variant in variants(client, job.model):
234
+ expanded.append(
235
+ JobSpec(
236
+ model=job.model,
237
+ benchmark=job.benchmark,
238
+ slice_spec=job.slice_spec,
239
+ variant=str(variant),
240
+ perturbation=job.perturbation,
241
+ perturbation_seed=job.perturbation_seed,
242
+ harness=job.harness,
243
+ repair_attempts=job.repair_attempts,
244
+ )
245
+ )
246
+ return expanded
247
+
248
+
249
+ def route_decision_jobs(
250
+ jobs: list[JobSpec],
251
+ client: object,
252
+ forced: Collection[str] = (),
253
+ ) -> list[JobSpec]:
254
+ """Send native decision models through /v1/systemone.
255
+
256
+ A model is a decision model when the server says so, or when the user
257
+ names it in ``forced`` (``--decision-models``) because a proxy in between
258
+ hides it. Harness and repair settings are generation options, so a
259
+ decision model's jobs drop them (``--harness both`` collapses to one job).
260
+ A benchmark that cannot be asked as a typed question is a configuration
261
+ error, raised before anything runs.
262
+ """
263
+ detect = getattr(client, "is_decision_model", None)
264
+ if not callable(detect) and not forced:
265
+ return jobs
266
+
267
+ def is_decision(model: str) -> bool:
268
+ return model in forced or (callable(detect) and bool(detect(model)))
269
+
270
+ routed: dict[str, JobSpec] = {}
271
+ unsupported: dict[str, None] = {}
272
+ for job in jobs:
273
+ if is_decision(job.model):
274
+ if not decision.supports(job.benchmark):
275
+ unsupported[f"{job.model} × {job.benchmark}"] = None
276
+ continue
277
+ job = replace(job, harness="decision", repair_attempts=0)
278
+ routed.setdefault(job.key, job)
279
+ if unsupported:
280
+ raise ValueError(
281
+ "Decision models only answer multiple-choice and yes/no benchmarks ("
282
+ + ", ".join(decision.supported_benchmarks())
283
+ + "). Unsupported: "
284
+ + ", ".join(unsupported)
285
+ )
286
+ return list(routed.values())
287
+
288
+
289
+ def tasks_for_job(job: JobSpec) -> list[Task]:
290
+ """Return tasks belonging to one concrete job variant."""
291
+ bench = benchmark(job.benchmark)
292
+ tasks = tasks_for(job.benchmark)
293
+ select = getattr(bench, "tasks_for_variant", None)
294
+ if job.variant is not None and callable(select):
295
+ return list(select(tasks, job.variant))
296
+ return tasks
297
+
298
+
299
+ def prompt_for(bench: object, task: Task, client: object, model: str) -> str:
300
+ """Build a prompt, allowing synthetic suites to use the server tokenizer."""
301
+ build_for = getattr(bench, "build_prompt_for", None)
302
+ if callable(build_for):
303
+ return str(build_for(task, client, model))
304
+ return str(bench.build_prompt(task))
305
+
306
+
307
+ def option_count(bench: object, task: Task) -> int | None:
308
+ """Return how many answers a multiple-choice task offers, else None.
309
+
310
+ Tasks list their options in ``metadata["choices"]``; yes/no suites that
311
+ keep no list declare ``answer_options`` on the benchmark instead.
312
+ """
313
+ choices = task.metadata.get("choices")
314
+ if choices:
315
+ return len(choices)
316
+ declared = getattr(bench, "answer_options", None)
317
+ return declared if isinstance(declared, int) and declared > 0 else None
318
+
319
+
320
+ def repair_budget(bench: object, task: Task, requested: int) -> int:
321
+ """Repairs allowed for one task: none when it has at most two options.
322
+
323
+ Telling a model its pick of two was wrong hands it the answer, so a
324
+ repair there measures nothing.
325
+ """
326
+ count = option_count(bench, task)
327
+ return 0 if count is not None and count <= 2 else requested
328
+
329
+
330
+ def plan_total_tasks(jobs: list[JobSpec], client: object | None = None) -> int:
331
+ concrete = expand_jobs(jobs, client) if client is not None else jobs
332
+ return sum(job.planned_total() for job in concrete)
benchkit/leakage.py ADDED
@@ -0,0 +1,51 @@
1
+ """Shared deterministic guards against structurally leaked benchmark answers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from itertools import pairwise
7
+
8
+
9
+ def _common_prefix(values: list[str]) -> str:
10
+ """Longest shared leading substring, compared character by character.
11
+
12
+ Keys are not paths, so this is deliberately not ``os.path.commonpath``.
13
+ """
14
+ first, last = min(values), max(values)
15
+ for index, (a, b) in enumerate(zip(first, last, strict=False)):
16
+ if a != b:
17
+ return first[:index]
18
+ return first if len(first) <= len(last) else last
19
+
20
+
21
+ class PromptLeakageError(ValueError):
22
+ """Raised when task construction makes an answer structurally identifiable."""
23
+
24
+
25
+ def assert_candidate_parity(
26
+ *,
27
+ target_keys: list[str],
28
+ distractor_keys: list[str],
29
+ target_values: list[str],
30
+ distractor_values: list[str],
31
+ ) -> None:
32
+ """Reject simple key markers and value-distribution shortcuts."""
33
+ if target_keys and distractor_keys:
34
+ target_prefix = _common_prefix(target_keys) if len(target_keys) > 1 else ""
35
+ distractor_prefix = (
36
+ _common_prefix(distractor_keys) if len(distractor_keys) > 1 else ""
37
+ )
38
+ if len(target_prefix) >= 3 and target_prefix != distractor_prefix:
39
+ raise PromptLeakageError("target keys have a distinguishing prefix")
40
+ target_lengths = {len(key) for key in target_keys}
41
+ distractor_lengths = {len(key) for key in distractor_keys}
42
+ if target_lengths.isdisjoint(distractor_lengths):
43
+ raise PromptLeakageError("target keys have a distinguishing length")
44
+
45
+ numeric_distractors = [int(value) for value in distractor_values if value.isdigit()]
46
+ numeric_targets = [int(value) for value in target_values if value.isdigit()]
47
+ if len(numeric_distractors) >= 4 and numeric_targets:
48
+ steps = Counter(right - left for left, right in pairwise(numeric_distractors))
49
+ step, occurrences = steps.most_common(1)[0]
50
+ if step and occurrences == len(numeric_distractors) - 1:
51
+ raise PromptLeakageError("distractor values form a derivable sequence")