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/history.py ADDED
@@ -0,0 +1,474 @@
1
+ """Read historical BenchKit reports and serve the local history dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import mimetypes
8
+ import posixpath
9
+ import threading
10
+ import webbrowser
11
+ from collections.abc import Iterable
12
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
+ from importlib.resources import files
14
+ from pathlib import Path
15
+ from typing import Any
16
+ from urllib.parse import unquote, urlsplit
17
+
18
+ # Run directories are served under this prefix, one numbered slot per results
19
+ # root, so two roots holding the same timestamped run stay distinct.
20
+ ARTIFACT_PREFIX = "/files"
21
+
22
+ # Pages a run wrote itself, and that the dashboard links to directly.
23
+ RUN_PAGES = {"gallery": "arena.html", "report": "results.html"}
24
+
25
+ # The mc-arena viewer is served from the installed package rather than copied
26
+ # into every run directory: it is 3.5 MB of pinned bundle that never varies,
27
+ # and it only works over HTTP anyway (its mesher runs in a web worker, which
28
+ # browsers refuse to start from a file:// page).
29
+ VIEWER_PREFIX = "/viewer"
30
+
31
+
32
+ def _safe_json(data: object) -> str:
33
+ """Serialize data for an HTML script element without allowing tag breakout."""
34
+ return (
35
+ json.dumps(data, ensure_ascii=False)
36
+ .replace("&", "\\u0026")
37
+ .replace("<", "\\u003c")
38
+ .replace(">", "\\u003e")
39
+ )
40
+
41
+
42
+ def _number(value: object) -> float | int | None:
43
+ if value is None or isinstance(value, bool):
44
+ return None
45
+ try:
46
+ parsed = float(value)
47
+ except (TypeError, ValueError):
48
+ return None
49
+ return int(parsed) if parsed.is_integer() else parsed
50
+
51
+
52
+ def _schema_generation(result: dict[str, Any]) -> str:
53
+ if "trace_coverage" in result or "first_attempt_score" in result:
54
+ return "current"
55
+ if "harness" in result or "concurrency" in result:
56
+ return "enhanced"
57
+ return "legacy"
58
+
59
+
60
+ def _task_diagnostic(task: dict[str, Any]) -> dict[str, Any]:
61
+ return {
62
+ "task_id": str(task.get("task_id") or ""),
63
+ "score": _number(task.get("score")),
64
+ "passed": task.get("passed") if isinstance(task.get("passed"), bool) else None,
65
+ "outcome": str(task.get("outcome") or ""),
66
+ "loop_state": str(task.get("loop_state") or ""),
67
+ "loop_killed": task.get("loop_killed")
68
+ if isinstance(task.get("loop_killed"), bool)
69
+ else None,
70
+ "loop_score": _number(task.get("loop_score")),
71
+ "loop_source": str(task.get("loop_source") or ""),
72
+ "timed_out": task.get("timed_out")
73
+ if isinstance(task.get("timed_out"), bool)
74
+ else None,
75
+ "length_exceeded": task.get("length_exceeded")
76
+ if isinstance(task.get("length_exceeded"), bool)
77
+ else None,
78
+ "harness_error": task.get("harness_error")
79
+ if isinstance(task.get("harness_error"), bool)
80
+ else None,
81
+ "repaired": task.get("repaired")
82
+ if isinstance(task.get("repaired"), bool)
83
+ else None,
84
+ "repair_attempts_used": _number(task.get("repair_attempts_used")),
85
+ "tok_s": _number(task.get("tok_s")),
86
+ "response_time_s": _number(task.get("response_time_s")),
87
+ }
88
+
89
+
90
+ def _run_pages(directory: Path | None, base: str) -> dict[str, str]:
91
+ """URLs for the pages a run actually wrote, skipping the ones it did not."""
92
+ if directory is None or not base:
93
+ return {}
94
+ return {
95
+ name: f"{base}/{filename}"
96
+ for name, filename in RUN_PAGES.items()
97
+ if (directory / filename).is_file()
98
+ }
99
+
100
+
101
+ def _benchmark_row(
102
+ result: dict[str, Any],
103
+ *,
104
+ run_id: str,
105
+ sources: list[str],
106
+ digest: str,
107
+ index: int,
108
+ pages: dict[str, str] | None = None,
109
+ ) -> dict[str, Any] | None:
110
+ model = result.get("model")
111
+ benchmark = result.get("benchmark")
112
+ score = _number(result.get("score"))
113
+ if not model or not benchmark or score is None:
114
+ return None
115
+ tasks = result.get("tasks")
116
+ return {
117
+ "id": f"{digest[:12]}-{index}",
118
+ "run_id": run_id,
119
+ "sources": sources,
120
+ "pages": dict(pages or {}),
121
+ "model": str(model),
122
+ "benchmark": str(benchmark),
123
+ "benchmark_label": str(result.get("benchmark_label") or benchmark),
124
+ "score": score,
125
+ "passed": _number(result.get("passed")),
126
+ "scored_total": _number(result.get("scored_total") or result.get("total")),
127
+ "total": _number(result.get("total")),
128
+ "slice": result.get("slice"),
129
+ "variant": result.get("variant"),
130
+ "perturbation": result.get("perturbation"),
131
+ "harness": str(result.get("harness") or "legacy"),
132
+ "harness_label": str(result.get("harness_label") or "Legacy / direct"),
133
+ "harness_version": str(result.get("harness_version") or ""),
134
+ "schema_generation": _schema_generation(result),
135
+ "first_attempt_score": _number(result.get("first_attempt_score")),
136
+ "repair_attempted": _number(result.get("repair_attempted")),
137
+ "repair_successes": _number(result.get("repair_successes")),
138
+ "repair_delta_pp": _number(result.get("repair_delta_pp")),
139
+ "loops": _number(result.get("loops")),
140
+ "suspected_loops": _number(result.get("suspected_loops")),
141
+ "recovered_loops": _number(result.get("recovered_loops")),
142
+ "loop_kills": _number(result.get("loop_kills")),
143
+ "loop_rate": _number(result.get("loop_rate")),
144
+ "loop_kill_enabled": result.get("loop_kill_enabled")
145
+ if isinstance(result.get("loop_kill_enabled"), bool)
146
+ else None,
147
+ "errors": _number(result.get("errors")),
148
+ "timeouts": _number(result.get("timeouts")),
149
+ "length_exceeded": _number(result.get("length_exceeded")),
150
+ "harness_errors": _number(result.get("harness_errors")),
151
+ "tok_s": _number(result.get("tok_s")),
152
+ "tok_s_aggregate": _number(result.get("tok_s_aggregate")),
153
+ "tok_s_per_stream": _number(result.get("tok_s_per_stream")),
154
+ "avg_response_time": _number(result.get("avg_response_time")),
155
+ "total_time": _number(result.get("total_time")),
156
+ "concurrency": _number(result.get("concurrency")),
157
+ "trace_coverage": _number(result.get("trace_coverage")),
158
+ "task_diagnostics": [
159
+ _task_diagnostic(task) for task in tasks if isinstance(task, dict)
160
+ ]
161
+ if isinstance(tasks, list)
162
+ else [],
163
+ }
164
+
165
+
166
+ def _perf_rows(
167
+ profile: dict[str, Any], *, run_id: str, sources: list[str], digest: str
168
+ ) -> list[dict[str, Any]]:
169
+ model = profile.get("model") or (profile.get("settings") or {}).get("model")
170
+ cases = profile.get("cases")
171
+ if not model or not isinstance(cases, list):
172
+ return []
173
+ rows: list[dict[str, Any]] = []
174
+ for index, case in enumerate(cases):
175
+ if not isinstance(case, dict):
176
+ continue
177
+ rows.append(
178
+ {
179
+ "id": f"{digest[:12]}-{index}",
180
+ "run_id": run_id,
181
+ "sources": sources,
182
+ "model": str(model),
183
+ "version": str(profile.get("version") or ""),
184
+ "provider": str(profile.get("provider") or ""),
185
+ "host": str(profile.get("host") or ""),
186
+ "depth": _number(case.get("actual_input_tokens") or case.get("depth")),
187
+ "depth_label": str(case.get("depth_label") or case.get("depth") or ""),
188
+ "repetitions": _number(
189
+ case.get("successful_reps") or len(case.get("runs") or [])
190
+ ),
191
+ "pp_tps": _number((case.get("pp_tps") or {}).get("median")),
192
+ "tg_tps": _number((case.get("tg_tps") or {}).get("median")),
193
+ "ttft_s": _number((case.get("ttft_s") or {}).get("median")),
194
+ "wall_time_s": _number((case.get("wall_time_s") or {}).get("median")),
195
+ }
196
+ )
197
+ return rows
198
+
199
+
200
+ def build_archive(results_dirs: Iterable[str | Path]) -> dict[str, Any]:
201
+ """Read and normalize all supported reports from one or more directories."""
202
+ roots = [Path(value).expanduser().resolve() for value in results_dirs]
203
+ warnings: list[dict[str, str]] = []
204
+ discovered = 0
205
+ reports: dict[tuple[str, str], dict[str, Any]] = {}
206
+
207
+ for root_index, root in enumerate(roots):
208
+ if not root.is_dir():
209
+ warnings.append(
210
+ {
211
+ "kind": "missing_directory",
212
+ "path": str(root),
213
+ "message": "Results directory does not exist or is not a directory.",
214
+ }
215
+ )
216
+ continue
217
+ paths = sorted(root.glob("*/results.json")) + sorted(root.glob("*/perf.json"))
218
+ for report_path in paths:
219
+ discovered += 1
220
+ report_type = "benchmark" if report_path.name == "results.json" else "perf"
221
+ try:
222
+ raw = report_path.read_bytes()
223
+ payload = json.loads(raw)
224
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
225
+ warnings.append(
226
+ {
227
+ "kind": "invalid_report",
228
+ "path": str(report_path),
229
+ "message": str(exc),
230
+ }
231
+ )
232
+ continue
233
+ digest = hashlib.sha256(raw).hexdigest()
234
+ key = (report_type, digest)
235
+ location = str(report_path)
236
+ if key in reports:
237
+ reports[key]["sources"].append(location)
238
+ continue
239
+ reports[key] = {
240
+ "type": report_type,
241
+ "digest": digest,
242
+ "run_id": report_path.parent.name,
243
+ "sources": [location],
244
+ "payload": payload,
245
+ "directory": report_path.parent,
246
+ "base": f"{ARTIFACT_PREFIX}/{root_index}/{report_path.parent.name}",
247
+ }
248
+
249
+ benchmark_rows: list[dict[str, Any]] = []
250
+ performance_rows: list[dict[str, Any]] = []
251
+ for report in reports.values():
252
+ payload = report["payload"]
253
+ if report["type"] == "benchmark":
254
+ if not isinstance(payload, list):
255
+ warnings.append(
256
+ {
257
+ "kind": "unsupported_schema",
258
+ "path": report["sources"][0],
259
+ "message": "Expected a JSON array for results.json.",
260
+ }
261
+ )
262
+ continue
263
+ for index, result in enumerate(payload):
264
+ if not isinstance(result, dict):
265
+ continue
266
+ row = _benchmark_row(
267
+ result,
268
+ run_id=report["run_id"],
269
+ sources=report["sources"],
270
+ digest=report["digest"],
271
+ index=index,
272
+ pages=_run_pages(report["directory"], report["base"]),
273
+ )
274
+ if row is not None:
275
+ benchmark_rows.append(row)
276
+ elif isinstance(payload, dict):
277
+ performance_rows.extend(
278
+ _perf_rows(
279
+ payload,
280
+ run_id=report["run_id"],
281
+ sources=report["sources"],
282
+ digest=report["digest"],
283
+ )
284
+ )
285
+ else:
286
+ warnings.append(
287
+ {
288
+ "kind": "unsupported_schema",
289
+ "path": report["sources"][0],
290
+ "message": "Expected a JSON object for perf.json.",
291
+ }
292
+ )
293
+
294
+ benchmark_rows.sort(key=lambda row: (row["run_id"], row["id"]))
295
+ performance_rows.sort(key=lambda row: (row["run_id"], row["id"]))
296
+ return {
297
+ "roots": [str(root) for root in roots],
298
+ "counts": {
299
+ "discovered_reports": discovered,
300
+ "unique_reports": len(reports),
301
+ "benchmark_results": len(benchmark_rows),
302
+ "performance_points": len(performance_rows),
303
+ "models": len({row["model"] for row in benchmark_rows}),
304
+ },
305
+ "benchmark_rows": benchmark_rows,
306
+ "performance_rows": performance_rows,
307
+ "warnings": warnings,
308
+ }
309
+
310
+
311
+ def render_history_html(archive: dict[str, Any]) -> str:
312
+ """Render the packaged history dashboard with embedded archive data."""
313
+ template = (
314
+ files("benchkit").joinpath("templates/history.html").read_text(encoding="utf-8")
315
+ )
316
+ return template.replace("__BENCHKIT_HISTORY_DATA__", _safe_json(archive))
317
+
318
+
319
+ def resolve_artifact(roots: tuple[Path, ...], path: str) -> Path | None:
320
+ """Map an artifact URL onto a file inside one results root, or nothing.
321
+
322
+ Everything about this is deliberately narrow: the URL names a root by
323
+ index rather than by path, the result is resolved and then checked to be
324
+ inside that root, and only regular files are served. A request that walks
325
+ out of the root, names an unknown root, or lands on a directory gets
326
+ nothing back.
327
+ """
328
+ relative = unquote(urlsplit(path).path)
329
+ if not relative.startswith(f"{ARTIFACT_PREFIX}/"):
330
+ return None
331
+ parts = [part for part in relative[len(ARTIFACT_PREFIX) + 1 :].split("/") if part]
332
+ if len(parts) < 2 or not parts[0].isdigit():
333
+ return None
334
+ index = int(parts[0])
335
+ if index >= len(roots):
336
+ return None
337
+
338
+ root = roots[index].resolve()
339
+ try:
340
+ candidate = (root / posixpath.join(*parts[1:])).resolve()
341
+ except (OSError, ValueError):
342
+ return None
343
+ if candidate != root and root not in candidate.parents:
344
+ return None
345
+ return candidate if candidate.is_file() else None
346
+
347
+
348
+ def resolve_viewer_asset(path: str) -> Path | None:
349
+ """Map a viewer URL onto one file of the vendored prismarine-viewer build."""
350
+ relative = unquote(urlsplit(path).path)
351
+ if not relative.startswith(f"{VIEWER_PREFIX}/"):
352
+ return None
353
+ name = relative[len(VIEWER_PREFIX) + 1 :]
354
+ # One flat directory of known assets: no sub-paths, so nothing to escape.
355
+ # A backslash is a separator on Windows, where "C:\\..." would also throw
356
+ # the root away entirely, so reject it here and check containment anyway.
357
+ if not name or "/" in name or "\\" in name or name in {".", ".."}:
358
+ return None
359
+ root = Path(str(files("benchkit").joinpath("mc_viewer/dist"))).resolve()
360
+ try:
361
+ candidate = (root / name).resolve()
362
+ except (OSError, ValueError):
363
+ return None
364
+ if root not in candidate.parents:
365
+ return None
366
+ return candidate if candidate.is_file() else None
367
+
368
+
369
+ def _artifact_headers(relative_parts: tuple[str, ...]) -> list[tuple[str, str]]:
370
+ """Response headers for one served artifact.
371
+
372
+ Pages under ``pages/`` were written by the model being benchmarked, not by
373
+ BenchKit. Serving them from the dashboard's own origin would let them read
374
+ everything else this server exposes, so they are handed an opaque origin
375
+ with a sandbox policy: the scene still runs, it just cannot reach back.
376
+ """
377
+ # parts are (root index, run directory, ...path inside the run).
378
+ headers = [("X-Content-Type-Options", "nosniff")]
379
+ if len(relative_parts) > 2 and relative_parts[2] == "pages":
380
+ headers.append(("Content-Security-Policy", "sandbox allow-scripts"))
381
+ return headers
382
+
383
+
384
+ def create_history_server(
385
+ results_dirs: Iterable[str | Path], port: int = 0
386
+ ) -> ThreadingHTTPServer:
387
+ """Create a localhost-only server that rescans results on every page load."""
388
+ roots = tuple(Path(value).expanduser().resolve() for value in results_dirs)
389
+
390
+ class HistoryHandler(BaseHTTPRequestHandler):
391
+ def do_GET(self) -> None:
392
+ if urlsplit(self.path).path in {"/", "/index.html"}:
393
+ self._send(
394
+ render_history_html(build_archive(roots)).encode(),
395
+ "text/html; charset=utf-8",
396
+ )
397
+ return
398
+
399
+ asset = resolve_viewer_asset(self.path)
400
+ if asset is not None:
401
+ # The viewer gunzips blockStates itself, so the .gz goes out
402
+ # as opaque bytes: declaring Content-Encoding would have the
403
+ # browser unwrap it first, and the page would then try to
404
+ # decompress plain JSON.
405
+ kind, _ = mimetypes.guess_type(asset.name)
406
+ self._send(
407
+ asset.read_bytes(),
408
+ "application/octet-stream"
409
+ if asset.suffix == ".gz"
410
+ else kind or "application/octet-stream",
411
+ extra=[("X-Content-Type-Options", "nosniff")],
412
+ )
413
+ return
414
+
415
+ artifact = resolve_artifact(roots, self.path)
416
+ if artifact is None:
417
+ self.send_error(404)
418
+ return
419
+ try:
420
+ body = artifact.read_bytes()
421
+ except OSError:
422
+ self.send_error(404)
423
+ return
424
+ kind, _ = mimetypes.guess_type(artifact.name)
425
+ parts = tuple(
426
+ part
427
+ for part in unquote(urlsplit(self.path).path)[
428
+ len(ARTIFACT_PREFIX) + 1 :
429
+ ].split("/")
430
+ if part
431
+ )
432
+ self._send(
433
+ body,
434
+ kind or "application/octet-stream",
435
+ extra=_artifact_headers(parts),
436
+ )
437
+
438
+ def _send(
439
+ self,
440
+ body: bytes,
441
+ content_type: str,
442
+ extra: list[tuple[str, str]] | None = None,
443
+ ) -> None:
444
+ self.send_response(200)
445
+ self.send_header("Content-Type", content_type)
446
+ self.send_header("Content-Length", str(len(body)))
447
+ self.send_header("Cache-Control", "no-store")
448
+ for name, value in extra or []:
449
+ self.send_header(name, value)
450
+ self.end_headers()
451
+ self.wfile.write(body)
452
+
453
+ def log_message(self, format: str, *args: object) -> None:
454
+ return
455
+
456
+ return ThreadingHTTPServer(("127.0.0.1", port), HistoryHandler)
457
+
458
+
459
+ def serve_history(
460
+ results_dirs: Iterable[str | Path], *, port: int = 0, open_browser: bool = True
461
+ ) -> None:
462
+ """Serve the history dashboard until interrupted."""
463
+ server = create_history_server(results_dirs, port)
464
+ url = f"http://127.0.0.1:{server.server_port}/"
465
+ print(f"BenchKit history: {url}")
466
+ print("Press Ctrl-C to stop.")
467
+ if open_browser:
468
+ threading.Timer(0.1, webbrowser.open, args=(url,)).start()
469
+ try:
470
+ server.serve_forever()
471
+ except KeyboardInterrupt:
472
+ pass
473
+ finally:
474
+ server.server_close()