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/perf_report.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Write portable performance-profile reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _safe_json(data: object) -> str:
|
|
13
|
+
return (
|
|
14
|
+
json.dumps(data, ensure_ascii=False)
|
|
15
|
+
.replace("&", "\\u0026")
|
|
16
|
+
.replace("<", "\\u003c")
|
|
17
|
+
.replace(">", "\\u003e")
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_perf_html(profile: dict) -> str:
|
|
22
|
+
template = (
|
|
23
|
+
files("benchkit").joinpath("templates/perf.html").read_text(encoding="utf-8")
|
|
24
|
+
)
|
|
25
|
+
return template.replace("__BENCHKIT_PERF_DATA__", _safe_json(profile))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _number(case: dict, metric: str, field: str = "median") -> float:
|
|
29
|
+
return float((case.get(metric) or {}).get(field) or 0.0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _markdown(profile: dict) -> str:
|
|
33
|
+
environment = profile.get("environment") or {}
|
|
34
|
+
settings = profile.get("settings") or {}
|
|
35
|
+
note = environment.get("config_note") or environment.get("hardware") or ""
|
|
36
|
+
lines = [
|
|
37
|
+
"# BenchKit Performance Profile",
|
|
38
|
+
"",
|
|
39
|
+
f"**Model:** `{profile['model']}`",
|
|
40
|
+
f"**Provider:** {profile.get('provider') or 'unknown'}",
|
|
41
|
+
f"**Host:** `{profile.get('host') or ''}`",
|
|
42
|
+
f"**Generated:** {profile.get('generated_at') or ''}",
|
|
43
|
+
(
|
|
44
|
+
f"**Settings:** {settings.get('gen_tokens', 0)} output tokens · "
|
|
45
|
+
f"{settings.get('reps', 0)} repetitions · "
|
|
46
|
+
f"{settings.get('warmups', 0)} warmup(s)"
|
|
47
|
+
),
|
|
48
|
+
]
|
|
49
|
+
if note:
|
|
50
|
+
lines.append(f"**Configuration:** {note}")
|
|
51
|
+
startup = profile.get("startup") or {}
|
|
52
|
+
if startup.get("ttft_s") is not None:
|
|
53
|
+
lines.append(
|
|
54
|
+
f"**First request TTFT:** {startup['ttft_s']:.3f}s "
|
|
55
|
+
f"({startup.get('label', 'startup probe')})"
|
|
56
|
+
)
|
|
57
|
+
drift = profile.get("drift_check") or {}
|
|
58
|
+
if drift and not drift.get("error"):
|
|
59
|
+
warning = " ⚠ possible warmup/drift anomaly" if not drift["stable"] else ""
|
|
60
|
+
lines.append(
|
|
61
|
+
f"**Drift check:** {drift['depth_label']} TG "
|
|
62
|
+
f"{drift['initial_tg_tps']:.1f} → {drift['final_tg_tps']:.1f} tok/s "
|
|
63
|
+
f"({drift['change']:+.1%}){warning}"
|
|
64
|
+
)
|
|
65
|
+
sweep = profile.get("sweep_summary") or {}
|
|
66
|
+
lines.append(
|
|
67
|
+
f"**Sweep median:** "
|
|
68
|
+
f"TG {float(sweep.get('tg_tps_median') or 0):.1f} tok/s · "
|
|
69
|
+
f"PP {float(sweep.get('pp_tps_median') or 0):.1f} tok/s"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
lines.extend(
|
|
73
|
+
[
|
|
74
|
+
"",
|
|
75
|
+
"| Depth | Actual input | PP tok/s | TG tok/s | TTFT | Wall | Overhead | Output |",
|
|
76
|
+
"|------:|-------------:|---------:|---------:|-----:|-----:|---------:|-------:|",
|
|
77
|
+
]
|
|
78
|
+
)
|
|
79
|
+
for case in profile.get("cases", []):
|
|
80
|
+
actual = case.get("actual_input_tokens") or "—"
|
|
81
|
+
marker = "!" if not case.get("depth_within_tolerance", True) else ""
|
|
82
|
+
pp = f"{_number(case, 'pp_tps'):.1f}" if case.get("pp_reliable") else "n/a"
|
|
83
|
+
overhead = (
|
|
84
|
+
f"{_number(case, 'overhead_s'):.3f}s"
|
|
85
|
+
if case.get("overhead_available")
|
|
86
|
+
else "n/a"
|
|
87
|
+
)
|
|
88
|
+
lines.append(
|
|
89
|
+
f"| {case['depth_label']}{marker} | {actual} "
|
|
90
|
+
f"| {pp} "
|
|
91
|
+
f"| {_number(case, 'tg_tps'):.1f} "
|
|
92
|
+
f"| {_number(case, 'ttft_s'):.3f}s "
|
|
93
|
+
f"| {_number(case, 'wall_time_s'):.2f}s "
|
|
94
|
+
f"| {overhead} "
|
|
95
|
+
f"| {_number(case, 'output_tokens'):.0f} |"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
calibrated = any(
|
|
99
|
+
case.get("depth_method") == "calibrated" for case in profile.get("cases", [])
|
|
100
|
+
)
|
|
101
|
+
inaccurate = [
|
|
102
|
+
case["depth_label"]
|
|
103
|
+
for case in profile.get("cases", [])
|
|
104
|
+
if not case.get("depth_within_tolerance", True)
|
|
105
|
+
]
|
|
106
|
+
estimated = [
|
|
107
|
+
case["depth_label"]
|
|
108
|
+
for case in profile.get("cases", [])
|
|
109
|
+
if case.get("depth_method") == "estimated"
|
|
110
|
+
]
|
|
111
|
+
lines.extend(
|
|
112
|
+
[
|
|
113
|
+
"",
|
|
114
|
+
"> Values are medians. Min/max and every raw repetition are available "
|
|
115
|
+
"in `perf.json`.",
|
|
116
|
+
]
|
|
117
|
+
)
|
|
118
|
+
if calibrated:
|
|
119
|
+
lines.extend(
|
|
120
|
+
[
|
|
121
|
+
"",
|
|
122
|
+
"Depth method: calibrated. The server does not expose its "
|
|
123
|
+
"native tokenizer route, so BenchKit calibrated deterministic "
|
|
124
|
+
"text against the reported input-token count.",
|
|
125
|
+
]
|
|
126
|
+
)
|
|
127
|
+
if inaccurate:
|
|
128
|
+
lines.extend(
|
|
129
|
+
[
|
|
130
|
+
"",
|
|
131
|
+
f"⚠ Depth calibration remained outside ±1% for: "
|
|
132
|
+
f"{', '.join(inaccurate)}. Compare using Actual input.",
|
|
133
|
+
]
|
|
134
|
+
)
|
|
135
|
+
if estimated:
|
|
136
|
+
lines.extend(
|
|
137
|
+
[
|
|
138
|
+
"",
|
|
139
|
+
f"⚠ Token-depth calibration was unavailable for: "
|
|
140
|
+
f"{', '.join(estimated)}. Compare using Actual input.",
|
|
141
|
+
]
|
|
142
|
+
)
|
|
143
|
+
lines.extend(
|
|
144
|
+
[
|
|
145
|
+
"",
|
|
146
|
+
"PP is server-reported prompt processing throughput; TG is "
|
|
147
|
+
"server-reported token generation throughput. PP is shown as n/a "
|
|
148
|
+
"for tiny prompts where fixed overhead dominates the value.",
|
|
149
|
+
"",
|
|
150
|
+
"> Tip: Run the `ruler` benchmark to verify how much of the "
|
|
151
|
+
"configured context window is actually usable.",
|
|
152
|
+
"",
|
|
153
|
+
]
|
|
154
|
+
)
|
|
155
|
+
return "\n".join(lines)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def save_profile(profile: dict) -> Path:
|
|
159
|
+
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
160
|
+
output = Path("results") / timestamp
|
|
161
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
|
|
163
|
+
with open(output / "perf.json", "w", encoding="utf-8") as handle:
|
|
164
|
+
json.dump(profile, handle, indent=2, ensure_ascii=False)
|
|
165
|
+
|
|
166
|
+
rows = []
|
|
167
|
+
for case in profile.get("cases", []):
|
|
168
|
+
rows.append(
|
|
169
|
+
{
|
|
170
|
+
"model": profile["model"],
|
|
171
|
+
"depth": case["depth_label"],
|
|
172
|
+
"actual_input_tokens": case.get("actual_input_tokens", 0),
|
|
173
|
+
"depth_method": case.get("depth_method", ""),
|
|
174
|
+
"depth_deviation": case.get("depth_deviation", 0),
|
|
175
|
+
"depth_within_tolerance": case.get("depth_within_tolerance", False),
|
|
176
|
+
"pp_reliable": case.get("pp_reliable", False),
|
|
177
|
+
"overhead_available": case.get("overhead_available", False),
|
|
178
|
+
"pp_tps_median": _number(case, "pp_tps"),
|
|
179
|
+
"pp_tps_min": _number(case, "pp_tps", "min"),
|
|
180
|
+
"pp_tps_max": _number(case, "pp_tps", "max"),
|
|
181
|
+
"tg_tps_median": _number(case, "tg_tps"),
|
|
182
|
+
"tg_tps_min": _number(case, "tg_tps", "min"),
|
|
183
|
+
"tg_tps_max": _number(case, "tg_tps", "max"),
|
|
184
|
+
"ttft_s_median": _number(case, "ttft_s"),
|
|
185
|
+
"ttft_s_min": _number(case, "ttft_s", "min"),
|
|
186
|
+
"ttft_s_max": _number(case, "ttft_s", "max"),
|
|
187
|
+
"wall_time_s_median": _number(case, "wall_time_s"),
|
|
188
|
+
"overhead_s_median": _number(case, "overhead_s"),
|
|
189
|
+
"overhead_s_min": _number(case, "overhead_s", "min"),
|
|
190
|
+
"overhead_s_max": _number(case, "overhead_s", "max"),
|
|
191
|
+
"output_tokens_median": _number(case, "output_tokens"),
|
|
192
|
+
"successful_reps": case.get("successful_reps", 0),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
if rows:
|
|
196
|
+
with open(output / "perf.csv", "w", newline="", encoding="utf-8") as handle:
|
|
197
|
+
writer = csv.DictWriter(handle, fieldnames=rows[0].keys())
|
|
198
|
+
writer.writeheader()
|
|
199
|
+
writer.writerows(rows)
|
|
200
|
+
|
|
201
|
+
with open(output / "perf.md", "w", encoding="utf-8") as handle:
|
|
202
|
+
handle.write(_markdown(profile))
|
|
203
|
+
|
|
204
|
+
with open(output / "perf.html", "w", encoding="utf-8") as handle:
|
|
205
|
+
handle.write(render_perf_html(profile))
|
|
206
|
+
|
|
207
|
+
return output
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Deterministic, benchmark-aware input perturbations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import random
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
|
|
10
|
+
from benchkit.benchmarks.base import Task
|
|
11
|
+
|
|
12
|
+
CHOICE_ORDER = "choice-order"
|
|
13
|
+
PERTURBATIONS = (CHOICE_ORDER,)
|
|
14
|
+
|
|
15
|
+
# Every benchmark here stores visible options in ``metadata["choices"]`` and
|
|
16
|
+
# the correct option letter in ``metadata["answer"]``. TruthfulQA is the one
|
|
17
|
+
# exception to the all-choices-visible rule: its prompt intentionally shows at
|
|
18
|
+
# most A-D even though the source MC1 rows can carry more distractors.
|
|
19
|
+
CHOICE_ORDER_BENCHMARKS = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"arc",
|
|
22
|
+
"gpqa",
|
|
23
|
+
"hellaswag",
|
|
24
|
+
"mmlu",
|
|
25
|
+
"mmlu-pro",
|
|
26
|
+
"mmvp",
|
|
27
|
+
"openbookqa",
|
|
28
|
+
"piqa",
|
|
29
|
+
"truthfulqa",
|
|
30
|
+
"vstar",
|
|
31
|
+
"winogrande",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
VISIBLE_CHOICE_LIMITS = {"truthfulqa": 4}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class PerturbedCase:
|
|
39
|
+
"""Prompt and scoring tasks produced by one perturbation."""
|
|
40
|
+
|
|
41
|
+
prompt_task: Task
|
|
42
|
+
evaluation_task: Task
|
|
43
|
+
details: dict
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def perturbations_for(benchmark_key: str) -> tuple[str, ...]:
|
|
47
|
+
"""Return perturbations supported by one benchmark."""
|
|
48
|
+
if benchmark_key in CHOICE_ORDER_BENCHMARKS:
|
|
49
|
+
return (CHOICE_ORDER,)
|
|
50
|
+
return ()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def supports_perturbation(benchmark_key: str, name: str) -> bool:
|
|
54
|
+
"""Return whether a benchmark opts into a named perturbation."""
|
|
55
|
+
return name in perturbations_for(benchmark_key)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def unsupported_benchmarks(name: str, keys: Iterable[str]) -> list[str]:
|
|
59
|
+
"""Return the benchmarks in ``keys`` that cannot run a perturbation, once each."""
|
|
60
|
+
return list(
|
|
61
|
+
dict.fromkeys(key for key in keys if not supports_perturbation(key, name))
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _task_rng(benchmark_key: str, task_id: str, name: str, seed: int) -> random.Random:
|
|
66
|
+
material = f"{seed}\0{benchmark_key}\0{task_id}\0{name}".encode()
|
|
67
|
+
digest = hashlib.sha256(material).digest()
|
|
68
|
+
return random.Random(int.from_bytes(digest[:8], "big"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _choice_order(benchmark_key: str, task: Task, seed: int) -> PerturbedCase:
|
|
72
|
+
raw_choices = task.metadata.get("choices")
|
|
73
|
+
answer = str(task.metadata.get("answer", "")).upper()
|
|
74
|
+
if not isinstance(raw_choices, (list, tuple)):
|
|
75
|
+
raise ValueError(f"{benchmark_key}/{task.id} has no choice list")
|
|
76
|
+
|
|
77
|
+
choices = list(raw_choices)
|
|
78
|
+
visible_count = min(
|
|
79
|
+
len(choices),
|
|
80
|
+
VISIBLE_CHOICE_LIMITS.get(benchmark_key, len(choices)),
|
|
81
|
+
)
|
|
82
|
+
if visible_count < 2:
|
|
83
|
+
raise ValueError(f"{benchmark_key}/{task.id} needs at least two choices")
|
|
84
|
+
|
|
85
|
+
answer_index = ord(answer) - ord("A") if len(answer) == 1 else -1
|
|
86
|
+
if answer_index < 0 or answer_index >= visible_count:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"{benchmark_key}/{task.id} answer {answer!r} is not a visible choice"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
rng = _task_rng(benchmark_key, task.id, CHOICE_ORDER, seed)
|
|
92
|
+
order = list(range(visible_count))
|
|
93
|
+
rng.shuffle(order)
|
|
94
|
+
|
|
95
|
+
# A choice-order run should exercise answer-position sensitivity on every
|
|
96
|
+
# task. If the random permutation left the correct answer under its old
|
|
97
|
+
# letter, swap it with a deterministic different position.
|
|
98
|
+
new_answer_index = order.index(answer_index)
|
|
99
|
+
if new_answer_index == answer_index:
|
|
100
|
+
candidates = [index for index in range(visible_count) if index != answer_index]
|
|
101
|
+
swap_index = rng.choice(candidates)
|
|
102
|
+
order[answer_index], order[swap_index] = (
|
|
103
|
+
order[swap_index],
|
|
104
|
+
order[answer_index],
|
|
105
|
+
)
|
|
106
|
+
new_answer_index = order.index(answer_index)
|
|
107
|
+
|
|
108
|
+
visible_choices = [choices[index] for index in order]
|
|
109
|
+
perturbed_choices = visible_choices + choices[visible_count:]
|
|
110
|
+
perturbed_answer = chr(ord("A") + new_answer_index)
|
|
111
|
+
metadata = dict(task.metadata)
|
|
112
|
+
metadata.update(choices=perturbed_choices, answer=perturbed_answer)
|
|
113
|
+
perturbed_task = replace(task, metadata=metadata)
|
|
114
|
+
details = {
|
|
115
|
+
"name": CHOICE_ORDER,
|
|
116
|
+
"seed": seed,
|
|
117
|
+
# Original labels in their new A.. position. For example ["C", "A",
|
|
118
|
+
# "B"] means original C is now A, original A is now B, and so on.
|
|
119
|
+
"choice_order": [chr(ord("A") + index) for index in order],
|
|
120
|
+
"original_answer": answer,
|
|
121
|
+
"perturbed_answer": perturbed_answer,
|
|
122
|
+
}
|
|
123
|
+
return PerturbedCase(perturbed_task, perturbed_task, details)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def perturb_task(
|
|
127
|
+
benchmark_key: str,
|
|
128
|
+
task: Task,
|
|
129
|
+
name: str | None,
|
|
130
|
+
seed: int,
|
|
131
|
+
) -> PerturbedCase:
|
|
132
|
+
"""Copy and perturb a task without mutating the cached source task."""
|
|
133
|
+
if name is None:
|
|
134
|
+
return PerturbedCase(task, task, {})
|
|
135
|
+
if not supports_perturbation(benchmark_key, name):
|
|
136
|
+
raise ValueError(f"{benchmark_key} does not support perturbation {name!r}")
|
|
137
|
+
if name == CHOICE_ORDER:
|
|
138
|
+
return _choice_order(benchmark_key, task, seed)
|
|
139
|
+
raise ValueError(f"Unknown perturbation: {name}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _result_key(result: dict) -> tuple[object, ...]:
|
|
143
|
+
return (
|
|
144
|
+
result.get("model"),
|
|
145
|
+
result.get("benchmark"),
|
|
146
|
+
result.get("variant"),
|
|
147
|
+
result.get("slice"),
|
|
148
|
+
result.get("harness", "direct"),
|
|
149
|
+
result.get("repair_attempts", 0),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def annotate_robustness(results: list[dict]) -> None:
|
|
154
|
+
"""Attach paired clean-versus-perturbed metrics to completed results."""
|
|
155
|
+
baselines = {
|
|
156
|
+
_result_key(result): result
|
|
157
|
+
for result in results
|
|
158
|
+
if not result.get("perturbation")
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for result in results:
|
|
162
|
+
if not result.get("perturbation"):
|
|
163
|
+
continue
|
|
164
|
+
baseline = baselines.get(_result_key(result))
|
|
165
|
+
if baseline is None:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
baseline_tasks = {task["task_id"]: task for task in baseline.get("tasks", [])}
|
|
169
|
+
pairs = [
|
|
170
|
+
(baseline_tasks[task["task_id"]], task)
|
|
171
|
+
for task in result.get("tasks", [])
|
|
172
|
+
if task["task_id"] in baseline_tasks
|
|
173
|
+
]
|
|
174
|
+
if not pairs:
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
paired_total = len(pairs)
|
|
178
|
+
baseline_score = sum(float(clean.get("score", 0)) for clean, _ in pairs)
|
|
179
|
+
perturbed_score = sum(
|
|
180
|
+
float(perturbed.get("score", 0)) for _, perturbed in pairs
|
|
181
|
+
)
|
|
182
|
+
regressions = sum(
|
|
183
|
+
bool(clean.get("passed")) and not bool(perturbed.get("passed"))
|
|
184
|
+
for clean, perturbed in pairs
|
|
185
|
+
)
|
|
186
|
+
recoveries = sum(
|
|
187
|
+
not bool(clean.get("passed")) and bool(perturbed.get("passed"))
|
|
188
|
+
for clean, perturbed in pairs
|
|
189
|
+
)
|
|
190
|
+
comparison = {
|
|
191
|
+
"perturbation": result["perturbation"],
|
|
192
|
+
"perturbation_seed": result.get("perturbation_seed"),
|
|
193
|
+
"paired_total": paired_total,
|
|
194
|
+
"baseline_score": round(baseline_score / paired_total, 1),
|
|
195
|
+
"perturbed_score": round(perturbed_score / paired_total, 1),
|
|
196
|
+
"score_delta_pp": round(
|
|
197
|
+
(perturbed_score - baseline_score) / paired_total,
|
|
198
|
+
1,
|
|
199
|
+
),
|
|
200
|
+
"regressions": regressions,
|
|
201
|
+
"regression_rate": round(regressions / paired_total * 100, 1),
|
|
202
|
+
"recoveries": recoveries,
|
|
203
|
+
"recovery_rate": round(recoveries / paired_total * 100, 1),
|
|
204
|
+
}
|
|
205
|
+
result.update(comparison)
|
|
206
|
+
result["baseline_benchmark_label"] = baseline.get(
|
|
207
|
+
"benchmark_label", baseline.get("benchmark")
|
|
208
|
+
)
|