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/browser.py ADDED
@@ -0,0 +1,429 @@
1
+ """Headless Chromium rendering for untrusted, model-generated HTML pages.
2
+
3
+ The renderer is deliberately hostile to the page it loads: every request is
4
+ denied unless its host is on a small module-CDN allowlist, downloads and
5
+ service workers are off, and every navigation, settle window and screenshot
6
+ runs under an explicit deadline. A page that hangs, crashes or wanders off the
7
+ allowlist fails the render instead of stalling the run.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import tempfile
14
+ import threading
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from urllib.parse import urlsplit
18
+
19
+ DEFAULT_VIEWPORT = (1280, 800)
20
+ DEFAULT_SETTLE_S = 4.0
21
+ DEFAULT_TIMEOUT_S = 30.0
22
+ THUMBNAIL_QUALITY = 70
23
+
24
+ # Chromium error codes that mean the request never reached a server: no DNS,
25
+ # no route, no proxy, no TLS. Anything else - an abort, a 404, a page-level
26
+ # fetch the model got wrong - says nothing about the machine.
27
+ CONNECTION_ERRORS = (
28
+ "ERR_NAME_NOT_RESOLVED",
29
+ "ERR_NAME_RESOLUTION_FAILED",
30
+ "ERR_INTERNET_DISCONNECTED",
31
+ "ERR_NETWORK_CHANGED",
32
+ "ERR_CONNECTION_",
33
+ "ERR_ADDRESS_UNREACHABLE",
34
+ "ERR_PROXY_CONNECTION_FAILED",
35
+ "ERR_TUNNEL_CONNECTION_FAILED",
36
+ "ERR_TIMED_OUT",
37
+ "ERR_SSL_",
38
+ "ERR_CERT_",
39
+ )
40
+
41
+ # Hosts that ship the ES module builds a self-contained three.js page can
42
+ # realistically import. Everything else - telemetry, analytics, arbitrary
43
+ # fetches from generated code - is aborted before it leaves the browser.
44
+ DEFAULT_ALLOWED_HOSTS = (
45
+ "cdn.jsdelivr.net",
46
+ "cdnjs.cloudflare.com",
47
+ "esm.sh",
48
+ "threejs.org",
49
+ "unpkg.com",
50
+ )
51
+
52
+ # Recorded before any page script runs, so a page cannot hide a missing canvas
53
+ # or a swallowed rejection by monkey-patching the same APIs afterwards.
54
+ _INSTRUMENTATION = """
55
+ (() => {
56
+ const state = { frames: 0, contexts: [], rejections: [] };
57
+ Object.defineProperty(window, "__benchkit", { value: state });
58
+ const raf = window.requestAnimationFrame.bind(window);
59
+ window.requestAnimationFrame = callback => raf(time => {
60
+ state.frames += 1;
61
+ return callback(time);
62
+ });
63
+ const getContext = HTMLCanvasElement.prototype.getContext;
64
+ HTMLCanvasElement.prototype.getContext = function (type, ...rest) {
65
+ const context = getContext.call(this, type, ...rest);
66
+ if (context) state.contexts.push(String(type));
67
+ return context;
68
+ };
69
+ window.addEventListener("unhandledrejection", event => {
70
+ state.rejections.push(String((event.reason && event.reason.stack) || event.reason));
71
+ });
72
+ })();
73
+ """
74
+
75
+ _PAGE_STATE = """
76
+ () => {
77
+ const state = window.__benchkit || { frames: 0, contexts: [], rejections: [] };
78
+ const canvases = [...document.querySelectorAll("canvas")].map(canvas => ({
79
+ width: canvas.width,
80
+ height: canvas.height,
81
+ client_width: canvas.clientWidth,
82
+ client_height: canvas.clientHeight,
83
+ }));
84
+ return {
85
+ frames: state.frames,
86
+ contexts: [...new Set(state.contexts)],
87
+ rejections: state.rejections,
88
+ canvases,
89
+ };
90
+ }
91
+ """
92
+
93
+
94
+ def _env_float(name: str, default: float) -> float:
95
+ try:
96
+ return float(os.environ.get(name, str(default)))
97
+ except ValueError:
98
+ return default
99
+
100
+
101
+ def _env_bool(name: str, default: bool) -> bool:
102
+ raw = os.environ.get(name)
103
+ if raw is None:
104
+ return default
105
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
106
+
107
+
108
+ def allowed_hosts() -> tuple[str, ...]:
109
+ """Hosts the rendered page may reach, or none in strict offline mode."""
110
+ if _env_bool("BENCHKIT_RENDER_OFFLINE", False):
111
+ return ()
112
+ raw = os.environ.get("BENCHKIT_RENDER_ALLOWED_HOSTS")
113
+ if raw is None:
114
+ return DEFAULT_ALLOWED_HOSTS
115
+ return tuple(host.strip().lower() for host in raw.split(",") if host.strip())
116
+
117
+
118
+ def _host_allowed(url: str, hosts: tuple[str, ...]) -> bool:
119
+ host = (urlsplit(url).hostname or "").lower()
120
+ if not host:
121
+ return False
122
+ return any(host == allowed or host.endswith(f".{allowed}") for allowed in hosts)
123
+
124
+
125
+ def chromium_executable() -> str | None:
126
+ """An existing Chromium build to use instead of Playwright's own download."""
127
+ return os.environ.get("BENCHKIT_RENDER_CHROMIUM") or None
128
+
129
+
130
+ @dataclass
131
+ class RenderResult:
132
+ """Everything one page load told us about the generated file."""
133
+
134
+ rendered: bool = False
135
+ console_errors: list[str] = field(default_factory=list)
136
+ page_errors: list[str] = field(default_factory=list)
137
+ blocked_requests: list[str] = field(default_factory=list)
138
+ failed_requests: list[str] = field(default_factory=list)
139
+ # Requests BenchKit allowed that never reached a server at all.
140
+ unreachable: list[str] = field(default_factory=list)
141
+ canvases: list[dict] = field(default_factory=list)
142
+ contexts: list[str] = field(default_factory=list)
143
+ frames: int = 0
144
+ screenshot: str = ""
145
+ thumbnail: str = ""
146
+ # Set when BenchKit itself could not render (no Playwright, no browser).
147
+ # Callers report this as a harness error rather than a failed page.
148
+ error: str = ""
149
+
150
+ @property
151
+ def diagnostics(self) -> str:
152
+ """Console and network output, in the order a developer would read it."""
153
+ lines: list[str] = []
154
+ for message in self.page_errors:
155
+ lines.append(f"[uncaught] {message}")
156
+ for message in self.console_errors:
157
+ lines.append(f"[console] {message}")
158
+ for url in self.blocked_requests:
159
+ lines.append(f"[blocked] {url}")
160
+ for url in self.failed_requests:
161
+ lines.append(f"[network] {url}")
162
+ if not self.canvases:
163
+ lines.append("[page] no <canvas> element was present after the wait")
164
+ elif not any(
165
+ canvas.get("width") and canvas.get("height") for canvas in self.canvases
166
+ ):
167
+ lines.append("[page] the <canvas> element has zero drawing-buffer size")
168
+ if self.canvases and not self.contexts:
169
+ lines.append("[page] no rendering context was acquired on any canvas")
170
+ return "\n".join(lines)
171
+
172
+
173
+ def launch_args() -> list[str]:
174
+ args = [
175
+ # SwiftShader keeps WebGL working on headless machines without a GPU.
176
+ "--use-gl=angle",
177
+ "--use-angle=swiftshader",
178
+ "--enable-unsafe-swiftshader",
179
+ "--disable-dev-shm-usage",
180
+ "--mute-audio",
181
+ ]
182
+ if _env_bool("BENCHKIT_RENDER_NO_SANDBOX", False):
183
+ args.append("--no-sandbox")
184
+ return args
185
+
186
+
187
+ def _render(
188
+ page_path: Path,
189
+ screenshot: Path,
190
+ thumbnail: Path,
191
+ viewport: tuple[int, int],
192
+ settle_s: float,
193
+ timeout_s: float,
194
+ result: RenderResult,
195
+ ) -> None:
196
+ """Load one file:// page and fill ``result`` in place."""
197
+ try:
198
+ from playwright.sync_api import Error as PlaywrightError
199
+ from playwright.sync_api import sync_playwright
200
+ except ImportError:
201
+ result.error = (
202
+ "playwright is not installed; install the browser extra "
203
+ "(uv sync --extra browser) and run 'playwright install chromium'"
204
+ )
205
+ return
206
+
207
+ hosts = allowed_hosts()
208
+ page_url = page_path.resolve().as_uri()
209
+ timeout_ms = timeout_s * 1000
210
+
211
+ def guard(route, request) -> None:
212
+ """Deny anything the page asks for beyond itself and the allowlist."""
213
+ url = request.url
214
+ if url == page_url or url.startswith(("data:", "blob:", "about:")):
215
+ route.continue_()
216
+ return
217
+ if _host_allowed(url, hosts):
218
+ route.continue_()
219
+ return
220
+ result.blocked_requests.append(url)
221
+ route.abort()
222
+
223
+ executable = chromium_executable()
224
+ try:
225
+ with sync_playwright() as playwright:
226
+ browser = playwright.chromium.launch(
227
+ args=launch_args(),
228
+ executable_path=executable,
229
+ )
230
+ try:
231
+ context = browser.new_context(
232
+ viewport={"width": viewport[0], "height": viewport[1]},
233
+ accept_downloads=False,
234
+ service_workers="block",
235
+ )
236
+ context.set_default_timeout(timeout_ms)
237
+ context.add_init_script(_INSTRUMENTATION)
238
+ context.route("**/*", guard)
239
+ page = context.new_page()
240
+ page.on(
241
+ "console",
242
+ lambda message: (
243
+ result.console_errors.append(message.text)
244
+ if message.type == "error"
245
+ else None
246
+ ),
247
+ )
248
+ page.on(
249
+ "pageerror", lambda error: result.page_errors.append(str(error))
250
+ )
251
+
252
+ def on_failed(request) -> None:
253
+ if request.url in result.blocked_requests:
254
+ return
255
+ reason = request.failure or ""
256
+ result.failed_requests.append(
257
+ f"{request.url} ({reason})" if reason else request.url
258
+ )
259
+ if any(code in reason for code in CONNECTION_ERRORS):
260
+ result.unreachable.append(request.url)
261
+
262
+ page.on("requestfailed", on_failed)
263
+ page.goto(page_url, wait_until="load", timeout=timeout_ms)
264
+ page.wait_for_timeout(settle_s * 1000)
265
+ state = page.evaluate(_PAGE_STATE)
266
+ result.frames = int(state.get("frames") or 0)
267
+ result.contexts = [str(item) for item in state.get("contexts") or []]
268
+ result.canvases = list(state.get("canvases") or [])
269
+ result.page_errors.extend(
270
+ str(item) for item in state.get("rejections") or []
271
+ )
272
+ page.screenshot(path=str(screenshot), type="png", timeout=timeout_ms)
273
+ thumbnail.write_bytes(
274
+ page.screenshot(
275
+ type="jpeg",
276
+ quality=THUMBNAIL_QUALITY,
277
+ timeout=timeout_ms,
278
+ )
279
+ )
280
+ result.screenshot = str(screenshot)
281
+ result.thumbnail = str(thumbnail)
282
+ finally:
283
+ browser.close()
284
+ except PlaywrightError as exc:
285
+ message = str(exc).strip().splitlines()[0] if str(exc).strip() else repr(exc)
286
+ if "executable doesn't exist" in message.lower():
287
+ result.error = (
288
+ "chromium is not installed for playwright (run "
289
+ f"'playwright install chromium', or set BENCHKIT_RENDER_CHROMIUM "
290
+ f"to an existing build): {message}"
291
+ )
292
+ elif screenshot.exists():
293
+ # The page loaded and was captured; the failure is the page's.
294
+ result.page_errors.append(message)
295
+ else:
296
+ result.page_errors.append(f"page load failed: {message}")
297
+ except Exception as exc: # pragma: no cover - defensive
298
+ result.error = f"{type(exc).__name__}: {exc}"
299
+
300
+
301
+ def render_page(
302
+ page_path: Path,
303
+ screenshot: Path,
304
+ thumbnail: Path,
305
+ *,
306
+ viewport: tuple[int, int] = DEFAULT_VIEWPORT,
307
+ settle_s: float | None = None,
308
+ timeout_s: float | None = None,
309
+ ) -> RenderResult:
310
+ """Render one HTML file and report whether the page came up clean.
311
+
312
+ The page is judged rendered when it raised no uncaught exception, logged no
313
+ console error, reached no blocked host, and ended with a sized canvas that
314
+ actually acquired a drawing context.
315
+ """
316
+ settle_s = DEFAULT_SETTLE_S if settle_s is None else settle_s
317
+ timeout_s = DEFAULT_TIMEOUT_S if timeout_s is None else timeout_s
318
+ result = RenderResult()
319
+
320
+ # Playwright's sync API refuses to run inside a thread with a live asyncio
321
+ # loop, which is exactly where the TUI evaluates tasks, so every render
322
+ # gets a clean thread of its own.
323
+ worker = threading.Thread(
324
+ target=_render,
325
+ args=(
326
+ page_path,
327
+ screenshot,
328
+ thumbnail,
329
+ viewport,
330
+ settle_s,
331
+ timeout_s,
332
+ result,
333
+ ),
334
+ name="benchkit-render",
335
+ daemon=True,
336
+ )
337
+ worker.start()
338
+ # Navigation, settle and two screenshots each carry their own deadline;
339
+ # this is the backstop for a browser that stops answering entirely.
340
+ worker.join(timeout=(timeout_s * 3) + settle_s + 30.0)
341
+ if worker.is_alive():
342
+ result.error = (
343
+ f"headless render did not finish within its {timeout_s:g}s budget"
344
+ )
345
+ return result
346
+
347
+ # The verdict is what the page achieved: no uncaught exception, and a sized
348
+ # canvas that acquired a drawing context. Console output and blocked hosts
349
+ # are reported and fed back for repair, but a scene that draws is a scene
350
+ # that rendered - a stray console.error or an analytics ping it never
351
+ # needed does not undo that.
352
+ result.rendered = bool(
353
+ not result.error
354
+ and not result.page_errors
355
+ and result.contexts
356
+ and any(
357
+ int(canvas.get("width") or 0) > 0 and int(canvas.get("height") or 0) > 0
358
+ for canvas in result.canvases
359
+ )
360
+ )
361
+ return result
362
+
363
+
364
+ # A scene that needs nothing but a working headless WebGL stack. When this
365
+ # cannot render, no model output can either, and the fault is the machine's.
366
+ PROBE_PAGE = """<!doctype html><html><head><style>
367
+ html,body{margin:0;height:100%;overflow:hidden}canvas{display:block}
368
+ </style></head><body><canvas id="probe"></canvas><script>
369
+ const canvas = document.getElementById("probe");
370
+ canvas.width = window.innerWidth;
371
+ canvas.height = window.innerHeight;
372
+ const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
373
+ if (!gl) throw new Error("no WebGL context: this browser has no working GPU or software rasterizer");
374
+ gl.clearColor(0.1, 0.5, 0.4, 1);
375
+ gl.clear(gl.COLOR_BUFFER_BIT);
376
+ requestAnimationFrame(() => {});
377
+ </script></body></html>
378
+ """
379
+
380
+ _PROBE_LOCK = threading.Lock()
381
+ _PROBE: tuple[bool, str] | None = None
382
+
383
+
384
+ def probe_environment(force: bool = False) -> tuple[bool, str]:
385
+ """Check once whether this machine can render WebGL at all.
386
+
387
+ Returns ``(ok, detail)``. The result is cached for the process: it answers
388
+ a question about the machine, not about any one page.
389
+ """
390
+ global _PROBE
391
+ with _PROBE_LOCK:
392
+ if _PROBE is not None and not force:
393
+ return _PROBE
394
+
395
+ with tempfile.TemporaryDirectory(prefix="benchkit-render-probe-") as directory:
396
+ root = Path(directory)
397
+ page = root / "probe.html"
398
+ page.write_text(PROBE_PAGE, encoding="utf-8")
399
+ outcome = render_page(
400
+ page,
401
+ root / "probe.png",
402
+ root / "probe.jpg",
403
+ settle_s=0.5,
404
+ timeout_s=render_timeout_s(),
405
+ )
406
+
407
+ if outcome.error:
408
+ answer = (False, outcome.error)
409
+ elif outcome.rendered:
410
+ answer = (
411
+ True,
412
+ f"headless WebGL is available ({', '.join(outcome.contexts)})",
413
+ )
414
+ else:
415
+ answer = (
416
+ False,
417
+ "headless Chromium could not render a plain WebGL canvas: "
418
+ + (outcome.diagnostics.replace("\n", " · ") or "no diagnostics"),
419
+ )
420
+ _PROBE = answer
421
+ return answer
422
+
423
+
424
+ def render_settle_s() -> float:
425
+ return _env_float("BENCHKIT_RENDER_SETTLE", DEFAULT_SETTLE_S)
426
+
427
+
428
+ def render_timeout_s() -> float:
429
+ return _env_float("BENCHKIT_RENDER_TIMEOUT", DEFAULT_TIMEOUT_S)
benchkit/catalog.py ADDED
@@ -0,0 +1,121 @@
1
+ """Which benchmark datasets are installed, and installing the missing ones.
2
+
3
+ Shared by ``benchkit datasets`` and the TUI's datasets dialog. A benchmark
4
+ whose class has no ``data_source`` ships its data in the wheel and is always
5
+ installed. Nothing here loads tasks, so listing never triggers a download.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ from collections.abc import Iterable
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Literal
15
+
16
+ from benchkit.benchmarks import REGISTRY
17
+ from benchkit.benchmarks.downloads import Dataset, Progress, fmt_bytes
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class DatasetStatus:
22
+ benchmark: str
23
+ kind: Literal["bundled", "download", "manual"]
24
+ installed: bool
25
+ summary: str
26
+ size: int | None
27
+ location: str
28
+ hint: str = ""
29
+ approximate: bool = False
30
+
31
+ @property
32
+ def installable(self) -> bool:
33
+ return self.kind == "download" and not self.installed
34
+
35
+
36
+ def source(key: str) -> Dataset | None:
37
+ return getattr(REGISTRY[key], "data_source", None)
38
+
39
+
40
+ def status(key: str) -> DatasetStatus:
41
+ data = source(key)
42
+ if data is None:
43
+ return DatasetStatus(key, "bundled", True, "bundled with BenchKit", None, "")
44
+ try:
45
+ installed = bool(data.is_installed())
46
+ except Exception:
47
+ installed = False
48
+ return DatasetStatus(
49
+ benchmark=key,
50
+ kind="download" if data.install is not None else "manual",
51
+ installed=installed,
52
+ summary=data.summary,
53
+ size=data.size,
54
+ location=str(data.location()),
55
+ hint=data.hint,
56
+ approximate=data.approximate,
57
+ )
58
+
59
+
60
+ def size_label(entry: DatasetStatus) -> str:
61
+ """What the data costs on disk, or what installing it will download.
62
+
63
+ Installed data is measured, so the figure is right whatever upstream
64
+ shipped. Missing data shows its declared download size with a ``↓``, or
65
+ ``?`` when the suite declares none. Measuring walks the cache, so this is
66
+ kept out of ``status`` and only runs for listings.
67
+ """
68
+ if entry.kind == "bundled":
69
+ path = _bundled_file(entry.benchmark)
70
+ return fmt_bytes(path.stat().st_size) if path else "in code"
71
+ if entry.installed:
72
+ return fmt_bytes(_disk_usage(Path(entry.location)))
73
+ if entry.kind == "manual":
74
+ return ""
75
+ if entry.size is None:
76
+ return "?"
77
+ return f"↓ {'~' if entry.approximate else ''}{fmt_bytes(entry.size)}"
78
+
79
+
80
+ def _bundled_file(key: str) -> Path | None:
81
+ module = importlib.import_module(REGISTRY[key].__module__)
82
+ dataset = getattr(module, "DATASET", None)
83
+ if dataset is None or isinstance(dataset, str):
84
+ return None
85
+ path = Path(str(dataset))
86
+ return path if path.is_file() else None
87
+
88
+
89
+ def _disk_usage(path: Path) -> int:
90
+ if path.is_file():
91
+ return path.stat().st_size
92
+ return sum(item.stat().st_size for item in path.rglob("*") if item.is_file())
93
+
94
+
95
+ def statuses(keys: Iterable[str] | None = None) -> list[DatasetStatus]:
96
+ return [status(key) for key in (REGISTRY if keys is None else keys)]
97
+
98
+
99
+ def missing(keys: Iterable[str] | None = None) -> list[str]:
100
+ """Benchmarks whose data is absent but can be fetched automatically.
101
+
102
+ Suites sharing one source (ruler and ruler-full) are listed once.
103
+ """
104
+ seen: set[int] = set()
105
+ keys_out = []
106
+ for entry in statuses(keys):
107
+ identity = id(source(entry.benchmark))
108
+ if entry.installable and identity not in seen:
109
+ seen.add(identity)
110
+ keys_out.append(entry.benchmark)
111
+ return keys_out
112
+
113
+
114
+ def install(key: str, progress: Progress | None = None) -> None:
115
+ """Fetch one benchmark's data. Raises with an actionable message."""
116
+ data = source(key)
117
+ if data is None:
118
+ return
119
+ if data.install is None:
120
+ raise RuntimeError(f"{key} cannot be installed automatically: {data.hint}")
121
+ data.install(progress)