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
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")
|