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/results.py ADDED
@@ -0,0 +1,688 @@
1
+ """Result shaping: from finished tasks to the rows reports serialize.
2
+
3
+ The per-task record, the running sums a job collects as its tasks finish, the
4
+ job result built from them, and the paired direct-versus-Pi annotation applied
5
+ once a run is over. Nothing here talks to a model, so it works on plain records.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from statistics import median
12
+ from typing import Literal
13
+
14
+ from benchkit import decision
15
+ from benchkit.benchmarks.base import Task
16
+ from benchkit.jobs import JobSpec, benchmark, option_count
17
+ from benchkit.metrics import throughput_metrics
18
+
19
+
20
+ @dataclass
21
+ class TaskRecord:
22
+ """Outcome of a single task."""
23
+
24
+ index: int
25
+ task_id: str
26
+ passed: bool
27
+ tok_s: float
28
+ response_time_s: float
29
+ prompt: str
30
+ response: str
31
+ outcome: Literal[
32
+ "pass",
33
+ "fail",
34
+ "loop_killed",
35
+ "timeout",
36
+ "length_exceeded",
37
+ "harness_error",
38
+ "contaminated",
39
+ ] = "fail"
40
+ perturbation: dict = field(default_factory=dict)
41
+ score: float = 0.0
42
+ error: str = ""
43
+ entry_point: str = ""
44
+ thinking: str = ""
45
+ output_tokens: int = 0
46
+ tokens_recovered: bool = False
47
+ done_reason: str = ""
48
+ timed_out: bool = False
49
+ loop_killed: bool = False
50
+ length_exceeded: bool = False
51
+ harness_error: bool = False
52
+ contaminated: bool = False
53
+ loop_kill_score: float = 0.0
54
+ loop_killed_at_s: float | None = None
55
+ trace_status: str = "unavailable"
56
+ thinking_time_s: float = 0.0
57
+ time_to_first_answer_s: float | None = None
58
+ loop_state: str = "unavailable"
59
+ loop_score: float = 0.0
60
+ loop_source: str = "none"
61
+ loop_detected_at_s: float | None = None
62
+ repeated_ngram_coverage: float = 0.0
63
+ max_window_similarity: float = 0.0
64
+ low_novelty_windows: int = 0
65
+ max_repeated_block: int = 0
66
+ loop_evidence: str = "none"
67
+ active_cycle: bool = False
68
+ recovered_cycle: bool = False
69
+ cycle_period_tokens: int = 0
70
+ cycle_repetitions: int = 0
71
+ repeated_suffix_tokens: int = 0
72
+ harness: str = "direct"
73
+ harness_version: str = ""
74
+ input_tokens: int = 0
75
+ model_turns: int = 1
76
+ tool_calls: int = 0
77
+ tool_trace: list[dict] = field(default_factory=list)
78
+ pi_scaffold: dict = field(default_factory=dict)
79
+ attempts: list[dict] = field(default_factory=list)
80
+ repair_attempts_used: int = 0
81
+ repair_feedback: list[str] = field(default_factory=list)
82
+ first_attempt_score: float = 0.0
83
+ repaired: bool = False
84
+ workspace: dict = field(default_factory=dict)
85
+ decision: dict = field(default_factory=dict)
86
+
87
+ @property
88
+ def label(self) -> str:
89
+ return (
90
+ f"{self.task_id} ({self.entry_point})" if self.entry_point else self.task_id
91
+ )
92
+
93
+
94
+ def _repair_score_curve(records: list[TaskRecord], repair_attempts: int) -> list[dict]:
95
+ """Return aggregate score and incremental gains after each repair round."""
96
+ if not records or repair_attempts <= 0:
97
+ return []
98
+
99
+ def score_at(record: TaskRecord, round_number: int) -> float:
100
+ if record.attempts:
101
+ attempt = record.attempts[min(round_number, len(record.attempts) - 1)]
102
+ return float(attempt.get("score", 0.0))
103
+ if round_number == 0:
104
+ return record.first_attempt_score * 100
105
+ return record.score * 100
106
+
107
+ curve: list[dict] = []
108
+ previous_score = 0.0
109
+ for round_number in range(repair_attempts + 1):
110
+ scores = [score_at(record, round_number) for record in records]
111
+ aggregate = sum(scores) / len(scores)
112
+ previous_scores = (
113
+ [score_at(record, round_number - 1) for record in records]
114
+ if round_number
115
+ else scores
116
+ )
117
+ curve.append(
118
+ {
119
+ "round": round_number,
120
+ "score": round(aggregate, 1),
121
+ "delta_pp": round(aggregate - previous_score, 1)
122
+ if round_number
123
+ else 0.0,
124
+ "cumulative_delta_pp": round(aggregate - curve[0]["score"], 1)
125
+ if curve
126
+ else 0.0,
127
+ "attempted": sum(
128
+ len(record.attempts) > round_number for record in records
129
+ )
130
+ if round_number
131
+ else len(records),
132
+ "new_passes": sum(
133
+ before < 100 <= after
134
+ for before, after in zip(previous_scores, scores, strict=True)
135
+ )
136
+ if round_number
137
+ else sum(score >= 100 for score in scores),
138
+ }
139
+ )
140
+ previous_score = aggregate
141
+ return curve
142
+
143
+
144
+ @dataclass
145
+ class TaskOutcome:
146
+ """One finalized task plus the raw metrics used by its job aggregate."""
147
+
148
+ record: TaskRecord
149
+ errors: int
150
+ eval_count: int
151
+ eval_duration_ns: int
152
+ response_time_s: float
153
+
154
+
155
+ @dataclass
156
+ class JobTally:
157
+ """Running sums for one job, folded in as its tasks finish."""
158
+
159
+ records_by_position: dict[int, TaskRecord] = field(default_factory=dict)
160
+ passed: int = 0
161
+ score_points: float = 0.0
162
+ scored_total: int = 0
163
+ errors: int = 0
164
+ total_tokens: int = 0
165
+ total_response_time: float = 0.0
166
+ throughput_tokens: int = 0
167
+ throughput_eval_ns: int = 0
168
+ throughput_response_time: float = 0.0
169
+ throughput_items: int = 0
170
+
171
+ def add(self, outcome: TaskOutcome) -> None:
172
+ record = outcome.record
173
+ self.records_by_position[record.index] = record
174
+ self.passed += int(record.passed)
175
+ self.score_points += record.score
176
+ self.scored_total += int(not record.harness_error and not record.contaminated)
177
+ self.errors += outcome.errors
178
+ self.total_tokens += outcome.eval_count
179
+ self.total_response_time += outcome.response_time_s
180
+ complete_terminal_metrics = not (
181
+ record.length_exceeded or record.timed_out
182
+ ) or (
183
+ outcome.eval_count > 0
184
+ and outcome.eval_duration_ns > 0
185
+ and outcome.response_time_s > 0
186
+ and not record.tokens_recovered
187
+ )
188
+ if (
189
+ not (record.loop_killed or record.harness_error)
190
+ and complete_terminal_metrics
191
+ ):
192
+ self.throughput_tokens += outcome.eval_count
193
+ self.throughput_eval_ns += outcome.eval_duration_ns
194
+ self.throughput_response_time += outcome.response_time_s
195
+ self.throughput_items += 1
196
+
197
+
198
+ def job_result(
199
+ job: JobSpec,
200
+ bench: object,
201
+ tasks: list[Task],
202
+ tally: JobTally,
203
+ *,
204
+ slice_spec: str | None,
205
+ concurrency: int,
206
+ wall_time_s: float,
207
+ loop_kill_enabled: bool,
208
+ loop_kill_percent: float,
209
+ loop_kill_seconds: float,
210
+ ) -> dict:
211
+ """Build the result row for a job that finished at least one task."""
212
+ records = [
213
+ tally.records_by_position[position]
214
+ for position in sorted(tally.records_by_position)
215
+ ]
216
+ total_time = round(wall_time_s, 1)
217
+ completed = len(records)
218
+ scored_records = [
219
+ record
220
+ for record in records
221
+ if not record.harness_error and not record.contaminated
222
+ ]
223
+ scored_total = len(scored_records)
224
+ throughput_coverage = (
225
+ tally.throughput_response_time / tally.total_response_time
226
+ if tally.total_response_time > 0
227
+ else tally.throughput_items / completed
228
+ if completed
229
+ else 0.0
230
+ )
231
+ throughput_wall_time = wall_time_s * throughput_coverage
232
+ throughput = throughput_metrics(
233
+ output_tokens=tally.throughput_tokens,
234
+ generation_time_s=tally.throughput_eval_ns / 1e9,
235
+ request_time_s=tally.throughput_response_time,
236
+ wall_time_s=throughput_wall_time,
237
+ )
238
+ tok_s_per_stream = round(throughput["tok_s_per_stream"], 1)
239
+ tok_s_aggregate = round(throughput["tok_s_aggregate"], 1)
240
+ concurrency_eff = round(
241
+ min(float(concurrency), throughput["concurrency_eff"]),
242
+ 2,
243
+ )
244
+ score = (
245
+ sum(record.score for record in scored_records) / scored_total * 100
246
+ if scored_total
247
+ else 0.0
248
+ )
249
+ loops = sum(record.loop_state == "looping" for record in records)
250
+ suspected = sum(
251
+ record.loop_state == "suspected" and not record.recovered_cycle
252
+ for record in records
253
+ )
254
+ recovered = sum(record.recovered_cycle for record in records)
255
+ timeouts = sum(record.timed_out for record in records)
256
+ loop_kills = sum(record.loop_killed for record in records)
257
+ length_exceeded = sum(record.length_exceeded for record in records)
258
+ harness_errors = sum(record.harness_error for record in records)
259
+ contaminated_records = [record for record in records if record.contaminated]
260
+ failures = sum(record.outcome == "fail" for record in records)
261
+ traced = sum(record.trace_status != "unavailable" for record in records)
262
+ latency_records = [
263
+ record
264
+ for record in records
265
+ if not (
266
+ record.loop_killed
267
+ or record.timed_out
268
+ or record.length_exceeded
269
+ or record.harness_error
270
+ )
271
+ and record.time_to_first_answer_s is not None
272
+ ]
273
+ thinking_times = [record.thinking_time_s for record in latency_records]
274
+ answer_times = [float(record.time_to_first_answer_s) for record in latency_records]
275
+ pi_scaffold = next(
276
+ (record.pi_scaffold for record in records if record.pi_scaffold), {}
277
+ )
278
+ pi_metadata = {f"pi_{key}": value for key, value in pi_scaffold.items()}
279
+ agentic = [
280
+ record.workspace.get("agentic_metrics", {})
281
+ for record in records
282
+ if isinstance(record.workspace.get("agentic_metrics"), dict)
283
+ ]
284
+ schema_valid = sum(int(item.get("tool_schema_valid_calls", 0)) for item in agentic)
285
+ schema_invalid = sum(
286
+ int(item.get("tool_schema_invalid_calls", 0)) for item in agentic
287
+ )
288
+ errored_calls = sum(int(item.get("errored_tool_calls", 0)) for item in agentic)
289
+ recoveries = sum(int(item.get("post_error_recoveries", 0)) for item in agentic)
290
+ redundant_calls = sum(int(item.get("redundant_tool_calls", 0)) for item in agentic)
291
+ destructive_actions = sum(
292
+ int(item.get("destructive_action_count", 0)) for item in agentic
293
+ )
294
+ observed_calls = schema_valid + schema_invalid
295
+ solved_records = [record for record in records if record.passed]
296
+ repair_score_curve = _repair_score_curve(scored_records, job.repair_attempts)
297
+ repair_round_columns = {
298
+ key: value
299
+ for point in repair_score_curve[1:]
300
+ for key, value in {
301
+ f"repair_round_{point['round']}_score": point["score"],
302
+ f"repair_round_{point['round']}_delta_pp": point["delta_pp"],
303
+ f"repair_round_{point['round']}_cumulative_delta_pp": point[
304
+ "cumulative_delta_pp"
305
+ ],
306
+ f"repair_round_{point['round']}_attempted": point["attempted"],
307
+ f"repair_round_{point['round']}_new_passes": point["new_passes"],
308
+ }.items()
309
+ }
310
+
311
+ result = {
312
+ "model": job.model,
313
+ "benchmark": bench.name,
314
+ "benchmark_label": job.benchmark_label,
315
+ "variant": job.variant,
316
+ "harness": job.harness,
317
+ "harness_label": job.harness_label,
318
+ "harness_version": (
319
+ next(
320
+ (
321
+ record.harness_version
322
+ for record in records
323
+ if record.harness_version
324
+ ),
325
+ "",
326
+ )
327
+ ),
328
+ "score": round(score, 1),
329
+ "passed": tally.passed,
330
+ "total": completed,
331
+ "scored_total": scored_total,
332
+ # Keep ``tok_s`` as a compatibility alias for consumers of older
333
+ # BenchKit JSON. New displays use the explicitly named metrics.
334
+ "tok_s": tok_s_per_stream,
335
+ "tok_s_per_stream": tok_s_per_stream,
336
+ "tok_s_aggregate": tok_s_aggregate,
337
+ "concurrency_eff": concurrency_eff,
338
+ "throughput_coverage": round(throughput_coverage * 100, 1),
339
+ "throughput_items": tally.throughput_items,
340
+ "throughput_wall_time": round(throughput_wall_time, 3),
341
+ "total_output_tokens": tally.total_tokens,
342
+ "throughput_output_tokens": tally.throughput_tokens,
343
+ "total_input_tokens": sum(record.input_tokens for record in records),
344
+ "model_turns": sum(record.model_turns for record in records),
345
+ "tool_calls": sum(record.tool_calls for record in records),
346
+ "tool_schema_valid_calls": schema_valid,
347
+ "tool_schema_invalid_calls": schema_invalid,
348
+ "tool_schema_validity_rate": round(schema_valid / observed_calls * 100, 1)
349
+ if observed_calls
350
+ else None,
351
+ "errored_tool_calls": errored_calls,
352
+ "post_error_recoveries": recoveries,
353
+ "post_error_recovery_rate": round(recoveries / errored_calls * 100, 1)
354
+ if errored_calls
355
+ else None,
356
+ "redundant_tool_calls": redundant_calls,
357
+ "redundant_action_rate": round(redundant_calls / observed_calls * 100, 1)
358
+ if observed_calls
359
+ else None,
360
+ "destructive_action_count": destructive_actions,
361
+ "destructive_action_rate": round(destructive_actions / observed_calls * 100, 1)
362
+ if observed_calls
363
+ else None,
364
+ "avg_turns_to_solve": round(
365
+ sum(record.model_turns for record in solved_records) / len(solved_records),
366
+ 1,
367
+ )
368
+ if solved_records
369
+ else None,
370
+ "avg_tokens_to_solve": round(
371
+ sum(record.input_tokens + record.output_tokens for record in solved_records)
372
+ / len(solved_records),
373
+ 1,
374
+ )
375
+ if solved_records
376
+ else None,
377
+ "first_attempt_score": round(
378
+ sum(record.first_attempt_score for record in scored_records)
379
+ / scored_total
380
+ * 100,
381
+ 1,
382
+ )
383
+ if scored_total
384
+ else 0.0,
385
+ "repair_delta_pp": round(
386
+ score
387
+ - sum(record.first_attempt_score for record in scored_records)
388
+ / scored_total
389
+ * 100,
390
+ 1,
391
+ )
392
+ if scored_total
393
+ else 0.0,
394
+ "repair_attempted": sum(record.repair_attempts_used > 0 for record in records),
395
+ "repair_turns": sum(record.repair_attempts_used for record in records),
396
+ "repair_successes": sum(record.repaired for record in records),
397
+ "repair_score_curve": repair_score_curve,
398
+ **repair_round_columns,
399
+ "sum_generation_time": round(tally.throughput_eval_ns / 1e9, 3),
400
+ "sum_request_time": round(tally.throughput_response_time, 3),
401
+ "avg_response_time": round(
402
+ tally.throughput_response_time / tally.throughput_items, 1
403
+ )
404
+ if tally.throughput_items
405
+ else 0.0,
406
+ "total_time": total_time,
407
+ "slice": slice_spec,
408
+ "concurrency": concurrency,
409
+ "errors": tally.errors,
410
+ "harness_errors": harness_errors,
411
+ "contaminated": len(contaminated_records),
412
+ "contamination_by_language": {
413
+ language: sum(
414
+ record.workspace.get("language") == language
415
+ for record in contaminated_records
416
+ )
417
+ for language in sorted(
418
+ {
419
+ str(record.workspace.get("language"))
420
+ for record in contaminated_records
421
+ if record.workspace.get("language")
422
+ }
423
+ )
424
+ },
425
+ "contaminated_tasks": [
426
+ {
427
+ "task_id": record.task_id,
428
+ "language": record.workspace.get("language", ""),
429
+ "guard_hits": record.workspace.get("answer_key_guard_hits", []),
430
+ }
431
+ for record in contaminated_records
432
+ ],
433
+ "length_exceeded": length_exceeded,
434
+ "failures": failures,
435
+ "timeouts": timeouts,
436
+ "loop_kills": loop_kills,
437
+ "loops": loops,
438
+ "suspected_loops": suspected,
439
+ "recovered_loops": recovered,
440
+ "loop_rate": round(loops / completed * 100, 1),
441
+ "trace_coverage": round(traced / completed * 100, 1),
442
+ "median_thinking_time": round(median(thinking_times), 1)
443
+ if thinking_times
444
+ else 0.0,
445
+ "median_time_to_answer": round(median(answer_times), 1)
446
+ if answer_times
447
+ else 0.0,
448
+ "loop_kill_enabled": loop_kill_enabled,
449
+ "loop_kill_percent": loop_kill_percent,
450
+ "loop_kill_seconds": loop_kill_seconds,
451
+ "tasks": [
452
+ {
453
+ "task_id": record.task_id,
454
+ "passed": record.passed,
455
+ "outcome": record.outcome,
456
+ "score": round(record.score * 100, 1),
457
+ "tok_s": record.tok_s,
458
+ "response_time_s": record.response_time_s,
459
+ "prompt": record.prompt,
460
+ "response": record.response,
461
+ "perturbation": record.perturbation or None,
462
+ "error": record.error,
463
+ "entry_point": record.entry_point,
464
+ "thinking": record.thinking,
465
+ "output_tokens": record.output_tokens,
466
+ "tokens_recovered": record.tokens_recovered,
467
+ "done_reason": record.done_reason,
468
+ "timed_out": record.timed_out,
469
+ "loop_killed": record.loop_killed,
470
+ "length_exceeded": record.length_exceeded,
471
+ "harness_error": record.harness_error,
472
+ "contaminated": record.contaminated,
473
+ "contamination_verdict": record.workspace.get(
474
+ "contamination_verdict", "CLEAR"
475
+ ),
476
+ "loop_kill_score": record.loop_kill_score,
477
+ "loop_killed_at_s": record.loop_killed_at_s,
478
+ "trace_status": record.trace_status,
479
+ "thinking_time_s": record.thinking_time_s,
480
+ "time_to_first_answer_s": record.time_to_first_answer_s,
481
+ "loop_state": record.loop_state,
482
+ "loop_score": record.loop_score,
483
+ "loop_source": record.loop_source,
484
+ "loop_detected_at_s": record.loop_detected_at_s,
485
+ "repeated_ngram_coverage": (record.repeated_ngram_coverage),
486
+ "max_window_similarity": record.max_window_similarity,
487
+ "low_novelty_windows": record.low_novelty_windows,
488
+ "max_repeated_block": record.max_repeated_block,
489
+ "loop_evidence": record.loop_evidence,
490
+ "active_cycle": record.active_cycle,
491
+ "recovered_cycle": record.recovered_cycle,
492
+ "cycle_period_tokens": record.cycle_period_tokens,
493
+ "cycle_repetitions": record.cycle_repetitions,
494
+ "repeated_suffix_tokens": record.repeated_suffix_tokens,
495
+ "harness": record.harness,
496
+ "harness_version": record.harness_version,
497
+ "input_tokens": record.input_tokens,
498
+ "model_turns": record.model_turns,
499
+ "tool_calls": record.tool_calls,
500
+ "tool_trace": record.tool_trace,
501
+ "attempts": record.attempts,
502
+ "repair_attempts_used": record.repair_attempts_used,
503
+ "repair_feedback": record.repair_feedback,
504
+ "first_attempt_score": round(record.first_attempt_score * 100, 1),
505
+ "repaired": record.repaired,
506
+ "workspace": record.workspace or None,
507
+ "agentic_metrics": record.workspace.get("agentic_metrics") or None,
508
+ "decision": record.decision or None,
509
+ }
510
+ for record in records
511
+ ],
512
+ **decision.summary([record.decision for record in scored_records], wall_time_s),
513
+ **pi_metadata,
514
+ **_result_metadata(job),
515
+ }
516
+ task_statistics = getattr(bench, "task_statistics", None)
517
+ if callable(task_statistics):
518
+ result["task_statistics"] = task_statistics(records)
519
+ # Multiple choice: a repair is a second guess at a small option set,
520
+ # so the headline is the first attempt and the repair gain sits beside
521
+ # it. Code and open-answer suites keep the repaired score.
522
+ if (
523
+ job.repair_attempts
524
+ and tasks
525
+ and all(option_count(bench, task) for task in tasks)
526
+ ):
527
+ result.update(
528
+ headline="first_attempt",
529
+ repaired_score=result["score"],
530
+ repaired_passed=result["passed"],
531
+ score=result["first_attempt_score"],
532
+ passed=sum(record.first_attempt_score >= 1.0 for record in scored_records),
533
+ )
534
+ summary_fields = getattr(bench, "summary_fields", None)
535
+ if callable(summary_fields):
536
+ result.update(summary_fields(scored_records))
537
+ return result
538
+
539
+
540
+ def _result_metadata(job: JobSpec) -> dict:
541
+ bench = benchmark(job.benchmark)
542
+ metadata = getattr(bench, "result_metadata", None)
543
+ value = metadata(job.variant) if callable(metadata) else None
544
+ result = dict(value) if isinstance(value, dict) else {}
545
+ result.update(
546
+ harness=job.harness,
547
+ harness_label=job.harness_label,
548
+ repair_attempts=job.repair_attempts,
549
+ )
550
+ if job.perturbation:
551
+ result.update(
552
+ perturbation=job.perturbation,
553
+ perturbation_seed=job.perturbation_seed,
554
+ include_in_overall=False,
555
+ )
556
+ return result
557
+
558
+
559
+ def empty_result(job: JobSpec) -> dict:
560
+ """Result shape for a job that finished without scoring a task."""
561
+ return {
562
+ "model": job.model,
563
+ "benchmark": job.benchmark,
564
+ "benchmark_label": job.benchmark_label,
565
+ "variant": job.variant,
566
+ "harness": job.harness,
567
+ "harness_label": job.harness_label,
568
+ "score": 0.0,
569
+ "passed": 0,
570
+ "total": 0,
571
+ "scored_total": 0,
572
+ "tok_s": 0.0,
573
+ "tok_s_per_stream": 0.0,
574
+ "tok_s_aggregate": 0.0,
575
+ "concurrency_eff": 0.0,
576
+ "throughput_coverage": 0.0,
577
+ "throughput_items": 0,
578
+ "throughput_wall_time": 0.0,
579
+ "total_output_tokens": 0,
580
+ "total_input_tokens": 0,
581
+ "model_turns": 0,
582
+ "tool_calls": 0,
583
+ "first_attempt_score": 0.0,
584
+ "repair_delta_pp": 0.0,
585
+ "repair_attempted": 0,
586
+ "repair_successes": 0,
587
+ "sum_generation_time": 0.0,
588
+ "sum_request_time": 0.0,
589
+ "avg_response_time": 0.0,
590
+ "total_time": 0.0,
591
+ "slice": job.slice_spec,
592
+ "concurrency": 1,
593
+ "errors": 0,
594
+ "harness_errors": 0,
595
+ "contaminated": 0,
596
+ "contamination_by_language": {},
597
+ "contaminated_tasks": [],
598
+ "length_exceeded": 0,
599
+ "timeouts": 0,
600
+ "loop_kills": 0,
601
+ "failures": 0,
602
+ "loops": 0,
603
+ "suspected_loops": 0,
604
+ "loop_rate": 0.0,
605
+ "trace_coverage": 0.0,
606
+ "median_thinking_time": 0.0,
607
+ "median_time_to_answer": 0.0,
608
+ "tasks": [],
609
+ **_result_metadata(job),
610
+ }
611
+
612
+
613
+ def _harness_pair_key(result: dict) -> tuple[object, ...]:
614
+ return (
615
+ result.get("model"),
616
+ result.get("benchmark"),
617
+ result.get("variant"),
618
+ result.get("slice"),
619
+ result.get("perturbation"),
620
+ result.get("perturbation_seed") if result.get("perturbation") else None,
621
+ result.get("repair_attempts", 0),
622
+ )
623
+
624
+
625
+ def annotate_harness_effect(results: list[dict]) -> None:
626
+ """Attach paired direct-versus-agent deltas to Pi result rows."""
627
+ direct = {
628
+ _harness_pair_key(result): result
629
+ for result in results
630
+ if result.get("harness", "direct") == "direct"
631
+ }
632
+ for result in results:
633
+ if result.get("harness") != "pi":
634
+ continue
635
+ baseline = direct.get(_harness_pair_key(result))
636
+ if baseline is None:
637
+ continue
638
+ baseline_tasks = {task["task_id"]: task for task in baseline.get("tasks", [])}
639
+ pairs = [
640
+ (baseline_tasks[task["task_id"]], task)
641
+ for task in result.get("tasks", [])
642
+ if task["task_id"] in baseline_tasks
643
+ and not task.get("harness_error")
644
+ and not baseline_tasks[task["task_id"]].get("harness_error")
645
+ ]
646
+ if not pairs:
647
+ continue
648
+ total = len(pairs)
649
+ direct_points = sum(float(before.get("score", 0)) for before, _ in pairs)
650
+ pi_points = sum(float(after.get("score", 0)) for _, after in pairs)
651
+ direct_first_points = sum(
652
+ float(before.get("first_attempt_score", before.get("score", 0)))
653
+ for before, _ in pairs
654
+ )
655
+ pi_first_points = sum(
656
+ float(after.get("first_attempt_score", after.get("score", 0)))
657
+ for _, after in pairs
658
+ )
659
+ gains = sum(
660
+ not bool(before.get("passed")) and bool(after.get("passed"))
661
+ for before, after in pairs
662
+ )
663
+ regressions = sum(
664
+ bool(before.get("passed")) and not bool(after.get("passed"))
665
+ for before, after in pairs
666
+ )
667
+ direct_loop_kills = sum(bool(before.get("loop_killed")) for before, _ in pairs)
668
+ harness_loop_kills = sum(bool(after.get("loop_killed")) for _, after in pairs)
669
+ result.update(
670
+ harness_paired_total=total,
671
+ direct_score=round(direct_points / total, 1),
672
+ harness_score=round(pi_points / total, 1),
673
+ harness_score_delta_pp=round((pi_points - direct_points) / total, 1),
674
+ direct_first_score=round(direct_first_points / total, 1),
675
+ harness_first_score=round(pi_first_points / total, 1),
676
+ harness_first_score_delta_pp=round(
677
+ (pi_first_points - direct_first_points) / total,
678
+ 1,
679
+ ),
680
+ harness_gains=gains,
681
+ harness_regressions=regressions,
682
+ direct_loop_kill_rate=round(direct_loop_kills / total * 100, 1),
683
+ harness_loop_kill_rate=round(harness_loop_kills / total * 100, 1),
684
+ loop_kill_delta_pp=round(
685
+ (harness_loop_kills - direct_loop_kills) / total * 100,
686
+ 1,
687
+ ),
688
+ )