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/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()
|