paseo-plugin-genui 0.0.0-stage → 0.1.0

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 (375) hide show
  1. package/LICENSE +202 -0
  2. package/OVERVIEW.md +21 -0
  3. package/README.md +77 -2
  4. package/assets/fonts/Excalifont-OFL.txt +109 -0
  5. package/assets/fonts/Excalifont-Regular-latin.woff2 +0 -0
  6. package/assets/icon.png +0 -0
  7. package/assets/icon.svg +1 -0
  8. package/assets/screenshot-chat.png +0 -0
  9. package/assets/screenshot-gallery.png +0 -0
  10. package/assets/screenshot-playground.png +0 -0
  11. package/client/components/content.tsx +468 -0
  12. package/client/components/data.tsx +967 -0
  13. package/client/components/dev.tsx +998 -0
  14. package/client/components/diagram.tsx +513 -0
  15. package/client/components/inputs.tsx +994 -0
  16. package/client/components/layout.tsx +239 -0
  17. package/client/components/media.tsx +428 -0
  18. package/client/components/project.tsx +641 -0
  19. package/client/components/widgets.tsx +836 -0
  20. package/client/gallery.tsx +449 -0
  21. package/client/live-ui.tsx +525 -0
  22. package/client/loaders.ts +342 -0
  23. package/client/markdown.tsx +172 -0
  24. package/client/node-renderer.tsx +149 -0
  25. package/client/render.tsx +186 -0
  26. package/client/runtime.ts +103 -0
  27. package/client/settings.tsx +57 -0
  28. package/client/web.ts +186 -0
  29. package/gallery/Acceptance-baseline.ui +1 -0
  30. package/gallery/Acceptance-checklist.ui +14 -0
  31. package/gallery/Acceptance-evidence.ui +9 -0
  32. package/gallery/Acceptance.ui +1 -0
  33. package/gallery/Badge.ui +3 -0
  34. package/gallery/Benchmark-context.ui +8 -0
  35. package/gallery/Benchmark-hyperfine.ui +1 -0
  36. package/gallery/Benchmark-runs-list.ui +9 -0
  37. package/gallery/Benchmark-runs.ui +1 -0
  38. package/gallery/Benchmark-vitest.ui +1 -0
  39. package/gallery/Benchmark.ui +9 -0
  40. package/gallery/Board-bb-tasks.ui +1 -0
  41. package/gallery/Board-gh-issues.ui +1 -0
  42. package/gallery/Board-gh-prs.ui +1 -0
  43. package/gallery/Board.ui +12 -0
  44. package/gallery/Button-destructive.ui +4 -0
  45. package/gallery/Button.ui +5 -0
  46. package/gallery/Calendar.ui +15 -0
  47. package/gallery/CalendarHeatmap-short.ui +11 -0
  48. package/gallery/CalendarHeatmap.ui +4 -0
  49. package/gallery/Callout.ui +4 -0
  50. package/gallery/Card.ui +4 -0
  51. package/gallery/Carousel.ui +8 -0
  52. package/gallery/Changes-paths.ui +1 -0
  53. package/gallery/Changes-uncommitted.ui +1 -0
  54. package/gallery/Changes.ui +1 -0
  55. package/gallery/Chart-area.ui +3 -0
  56. package/gallery/Chart-funnel.ui +3 -0
  57. package/gallery/Chart-heatmap.ui +11 -0
  58. package/gallery/Chart-pie.ui +3 -0
  59. package/gallery/Chart-radar.ui +3 -0
  60. package/gallery/Chart-scatter.ui +4 -0
  61. package/gallery/Chart-stacked.ui +5 -0
  62. package/gallery/Chart.ui +3 -0
  63. package/gallery/Checklist.ui +6 -0
  64. package/gallery/Choice.ui +8 -0
  65. package/gallery/Code.ui +3 -0
  66. package/gallery/Commits-limit.ui +1 -0
  67. package/gallery/Commits.ui +1 -0
  68. package/gallery/Compare-conditions.ui +4 -0
  69. package/gallery/Compare-three.ui +7 -0
  70. package/gallery/Compare.ui +4 -0
  71. package/gallery/Coverage-go.ui +1 -0
  72. package/gallery/Coverage-lcov.ui +1 -0
  73. package/gallery/Coverage-python.ui +1 -0
  74. package/gallery/Coverage.ui +1 -0
  75. package/gallery/DateInput.ui +5 -0
  76. package/gallery/Decision-percent.ui +10 -0
  77. package/gallery/Decision.ui +12 -0
  78. package/gallery/Dependencies-audit.ui +1 -0
  79. package/gallery/Dependencies-both.ui +1 -0
  80. package/gallery/Dependencies-list.ui +7 -0
  81. package/gallery/Dependencies-pip.ui +1 -0
  82. package/gallery/Dependencies-pnpm.ui +1 -0
  83. package/gallery/Dependencies.ui +1 -0
  84. package/gallery/Details.ui +4 -0
  85. package/gallery/Diagram-flow.ui +16 -0
  86. package/gallery/Diagram-sequence.ui +15 -0
  87. package/gallery/Diagram.ui +16 -0
  88. package/gallery/Diff.ui +3 -0
  89. package/gallery/Divider.ui +5 -0
  90. package/gallery/EditableTable.ui +9 -0
  91. package/gallery/Entity-list.ui +6 -0
  92. package/gallery/Entity.ui +8 -0
  93. package/gallery/Environments.ui +1 -0
  94. package/gallery/Estimate-points.ui +6 -0
  95. package/gallery/Estimate.ui +8 -0
  96. package/gallery/File.ui +4 -0
  97. package/gallery/Findings-biome.ui +1 -0
  98. package/gallery/Findings-eslint.ui +1 -0
  99. package/gallery/Findings-oxlint.ui +1 -0
  100. package/gallery/Findings-review.ui +1 -0
  101. package/gallery/Findings-ruff.ui +1 -0
  102. package/gallery/Findings-sarif.ui +1 -0
  103. package/gallery/Findings-ship.ui +11 -0
  104. package/gallery/Findings-tsc.ui +1 -0
  105. package/gallery/Findings.ui +7 -0
  106. package/gallery/Flashcards.ui +3 -0
  107. package/gallery/FollowUps.ui +4 -0
  108. package/gallery/Form.ui +8 -0
  109. package/gallery/Gallery-links.ui +8 -0
  110. package/gallery/Gallery-strip.ui +14 -0
  111. package/gallery/Gallery.ui +10 -0
  112. package/gallery/Gauge.ui +7 -0
  113. package/gallery/Grid.ui +8 -0
  114. package/gallery/Hotspots-photo.ui +8 -0
  115. package/gallery/Hotspots.ui +11 -0
  116. package/gallery/Icon.ui +5 -0
  117. package/gallery/Image.ui +4 -0
  118. package/gallery/ImageSearch-aspects.ui +6 -0
  119. package/gallery/ImageSearch.ui +4 -0
  120. package/gallery/Input.ui +5 -0
  121. package/gallery/Json-api.ui +1 -0
  122. package/gallery/Json.ui +8 -0
  123. package/gallery/KeyValue.ui +3 -0
  124. package/gallery/List-dots.ui +8 -0
  125. package/gallery/List.ui +8 -0
  126. package/gallery/ListEditor.ui +5 -0
  127. package/gallery/Logs-jsonl.ui +1 -0
  128. package/gallery/Logs-lines.ui +11 -0
  129. package/gallery/Logs-python.ui +1 -0
  130. package/gallery/Logs.ui +1 -0
  131. package/gallery/Map-city.ui +8 -0
  132. package/gallery/Map.ui +8 -0
  133. package/gallery/Metric.ui +3 -0
  134. package/gallery/OptionCards-multi.ui +10 -0
  135. package/gallery/OptionCards.ui +10 -0
  136. package/gallery/Pipeline-checks.ui +1 -0
  137. package/gallery/Pipeline-passed.ui +1 -0
  138. package/gallery/Pipeline-running.ui +1 -0
  139. package/gallery/Pipeline-stages.ui +13 -0
  140. package/gallery/Pipeline.ui +1 -0
  141. package/gallery/Plan.ui +9 -0
  142. package/gallery/Progress.ui +4 -0
  143. package/gallery/Provenance-deploy.ui +6 -0
  144. package/gallery/Provenance-no-receipt.ui +1 -0
  145. package/gallery/Provenance.ui +1 -0
  146. package/gallery/PullRequest.ui +1 -0
  147. package/gallery/Quiz.ui +6 -0
  148. package/gallery/Radio.ui +5 -0
  149. package/gallery/Rating.ui +5 -0
  150. package/gallery/Roadmap-sprint.ui +8 -0
  151. package/gallery/Roadmap-year.ui +9 -0
  152. package/gallery/Roadmap.ui +12 -0
  153. package/gallery/Row.ui +3 -0
  154. package/gallery/SegmentedBar-status.ui +8 -0
  155. package/gallery/SegmentedBar.ui +8 -0
  156. package/gallery/Select.ui +5 -0
  157. package/gallery/Slider.ui +7 -0
  158. package/gallery/Snippet.ui +3 -0
  159. package/gallery/Sources-chips.ui +4 -0
  160. package/gallery/Sources.ui +8 -0
  161. package/gallery/Sparkline.ui +6 -0
  162. package/gallery/Stack.ui +7 -0
  163. package/gallery/StackTrace-aggregate.ui +1 -0
  164. package/gallery/StackTrace-browser.ui +1 -0
  165. package/gallery/StackTrace-go.ui +1 -0
  166. package/gallery/StackTrace-java.ui +1 -0
  167. package/gallery/StackTrace-python.ui +1 -0
  168. package/gallery/StackTrace-rust.ui +1 -0
  169. package/gallery/StackTrace.ui +1 -0
  170. package/gallery/Status.ui +9 -0
  171. package/gallery/Stepper.ui +5 -0
  172. package/gallery/Steps.ui +8 -0
  173. package/gallery/Swatches-long-names.ui +4 -0
  174. package/gallery/Swatches-picker.ui +5 -0
  175. package/gallery/Swatches.ui +10 -0
  176. package/gallery/Table.ui +7 -0
  177. package/gallery/Tabs.ui +8 -0
  178. package/gallery/Tests-junit.ui +1 -0
  179. package/gallery/Tests-vitest-junit.ui +1 -0
  180. package/gallery/Tests-vitest.ui +1 -0
  181. package/gallery/Tests.ui +9 -0
  182. package/gallery/Text.ui +4 -0
  183. package/gallery/TextArea.ui +5 -0
  184. package/gallery/Threads-ids.ui +1 -0
  185. package/gallery/Threads.ui +1 -0
  186. package/gallery/Timeline.ui +8 -0
  187. package/gallery/Timer.ui +4 -0
  188. package/gallery/Toggle.ui +5 -0
  189. package/gallery/Transcript-text.ui +1 -0
  190. package/gallery/Transcript-untimed.ui +1 -0
  191. package/gallery/Transcript-whisper.ui +1 -0
  192. package/gallery/Transcript.ui +1 -0
  193. package/gallery/Tree.ui +3 -0
  194. package/gallery/Treemap-du.ui +1 -0
  195. package/gallery/Treemap-esbuild.ui +1 -0
  196. package/gallery/Treemap-tree.ui +7 -0
  197. package/gallery/Treemap.ui +18 -0
  198. package/gallery/Walkthrough.ui +9 -0
  199. package/gallery/Waterfall-har.ui +1 -0
  200. package/gallery/Waterfall-trace.ui +1 -0
  201. package/gallery/Waterfall.ui +11 -0
  202. package/gallery/data/acceptance/coil/viewport-lifecycle.json +25 -0
  203. package/gallery/data/acceptance/coil-gate.json +153 -0
  204. package/gallery/data/acceptance/orbit-baseline.json +163 -0
  205. package/gallery/data/acceptance/shots/gate-cell-opened.png +0 -0
  206. package/gallery/data/acceptance/shots/gate-light.png +0 -0
  207. package/gallery/data/acceptance/shots/gate-phone-dark.png +0 -0
  208. package/gallery/data/activity.json +1 -0
  209. package/gallery/data/bb-tasks-orb.json +420 -0
  210. package/gallery/data/biome.json +229 -0
  211. package/gallery/data/compare/status-after.png +0 -0
  212. package/gallery/data/compare/status-before.png +0 -0
  213. package/gallery/data/compare/status-dark-2x.png +0 -0
  214. package/gallery/data/compare/status-rerender.png +0 -0
  215. package/gallery/data/coverage-py.json +1 -0
  216. package/gallery/data/coverage-summary.json +1 -0
  217. package/gallery/data/coverage.out +23 -0
  218. package/gallery/data/deploys.json +1 -0
  219. package/gallery/data/device-runs.json +285 -0
  220. package/gallery/data/du-node-modules.txt +359 -0
  221. package/gallery/data/esbuild-meta.json +292 -0
  222. package/gallery/data/eslint.json +266 -0
  223. package/gallery/data/eslint.sarif +343 -0
  224. package/gallery/data/findings-review.json +76 -0
  225. package/gallery/data/gh-issues-nextjs.json +293 -0
  226. package/gallery/data/gh-pr-checks.json +272 -0
  227. package/gallery/data/gh-prs-nextjs.json +419 -0
  228. package/gallery/data/gh-run-failed.json +1 -0
  229. package/gallery/data/gh-run-passed.json +1 -0
  230. package/gallery/data/gh-run-running.json +1 -0
  231. package/gallery/data/github-release.json +57 -0
  232. package/gallery/data/hyperfine-startup.json +187 -0
  233. package/gallery/data/lcov.info +234 -0
  234. package/gallery/data/logs/media-sync.log +127 -0
  235. package/gallery/data/logs/thumbnailer.jsonl +17 -0
  236. package/gallery/data/logs/vitest-run.log +89 -0
  237. package/gallery/data/npm-audit.json +672 -0
  238. package/gallery/data/npm-outdated.json +179 -0
  239. package/gallery/data/oxlint.json +156 -0
  240. package/gallery/data/pip-outdated.json +68 -0
  241. package/gallery/data/pnpm-outdated.json +58 -0
  242. package/gallery/data/provenance/devicectl-install.json +35 -0
  243. package/gallery/data/provenance/install-timeout.log +4 -0
  244. package/gallery/data/provenance/iphone-install.json +72 -0
  245. package/gallery/data/provenance/iphone-no-receipt.json +57 -0
  246. package/gallery/data/provenance/live-home.jpg +0 -0
  247. package/gallery/data/provenance/live-probe.txt +10 -0
  248. package/gallery/data/provenance/xcodebuild-2811.log +19 -0
  249. package/gallery/data/provenance/xcodebuild-4712.log +19 -0
  250. package/gallery/data/pytest-junit.xml +64 -0
  251. package/gallery/data/python-org-trace.json +35 -0
  252. package/gallery/data/python-org.har +31 -0
  253. package/gallery/data/retry.ts +13 -0
  254. package/gallery/data/ruff.json +261 -0
  255. package/gallery/data/src/durations.py +63 -0
  256. package/gallery/data/src/json-logic.ts +156 -0
  257. package/gallery/data/src/mapMath.ts +135 -0
  258. package/gallery/data/src/semver/bump.go +73 -0
  259. package/gallery/data/traces/browser-error.json +5 -0
  260. package/gallery/data/traces/go/main.go +27 -0
  261. package/gallery/data/traces/go-panic.txt +16 -0
  262. package/gallery/data/traces/java-caused-by.txt +15 -0
  263. package/gallery/data/traces/node-aggregate.txt +30 -0
  264. package/gallery/data/traces/node-cause.txt +21 -0
  265. package/gallery/data/traces/py/deploy.json +5 -0
  266. package/gallery/data/traces/py/deploy.py +17 -0
  267. package/gallery/data/traces/py/settings.py +19 -0
  268. package/gallery/data/traces/python-chain.txt +30 -0
  269. package/gallery/data/traces/rust/main.rs +11 -0
  270. package/gallery/data/traces/rust-panic.txt +16 -0
  271. package/gallery/data/traces/shop/cart.mjs +19 -0
  272. package/gallery/data/traces/shop/pricing.mjs +12 -0
  273. package/gallery/data/traces/shop/status.mjs +9 -0
  274. package/gallery/data/traces/web/app.js +11 -0
  275. package/gallery/data/traces/web/index.html +1 -0
  276. package/gallery/data/traces/web/search.js +10 -0
  277. package/gallery/data/transcript/call-noisy.wav +0 -0
  278. package/gallery/data/transcript/call.json +362 -0
  279. package/gallery/data/transcript/call.wav +0 -0
  280. package/gallery/data/transcript/mlx-whisper-tiny.en-noisy.json +1 -0
  281. package/gallery/data/transcript/whisper-cpp-base.en-noisy.json +658 -0
  282. package/gallery/data/transcript/whisper-cpp-base.en.json +658 -0
  283. package/gallery/data/transcript/whisper-cpp-base.en.srt +16 -0
  284. package/gallery/data/transcript/whisper-cpp-tiny.en-noisy.json +619 -0
  285. package/gallery/data/tsc.txt +4 -0
  286. package/gallery/data/vitest-bench.json +119 -0
  287. package/gallery/data/vitest-junit.xml +133 -0
  288. package/gallery/data/vitest-report.json +612 -0
  289. package/gallery/images/bell.svg +10 -0
  290. package/gallery/images/bike.svg +47 -0
  291. package/gallery/images/footer-planet.webp +0 -0
  292. package/gallery/images/front-light.svg +13 -0
  293. package/gallery/images/golden-hour.webp +0 -0
  294. package/gallery/images/option-flowers.webp +0 -0
  295. package/gallery/images/option-hilltop.webp +0 -0
  296. package/gallery/images/option-meadow.webp +0 -0
  297. package/gallery/images/option-sky.webp +0 -0
  298. package/gallery/images/u-lock.svg +10 -0
  299. package/index.client.tsx +164 -0
  300. package/index.server.ts +216 -0
  301. package/package.json +50 -4
  302. package/paseo-plugin.json +14 -0
  303. package/server/data-source.ts +407 -0
  304. package/server/project.ts +981 -0
  305. package/server/web/address.ts +57 -0
  306. package/server/web/cache.ts +85 -0
  307. package/server/web/commons.ts +126 -0
  308. package/server/web/geocode.ts +40 -0
  309. package/server/web/image.ts +35 -0
  310. package/server/web/lookups.ts +248 -0
  311. package/server/web/net.ts +210 -0
  312. package/shared/core/acceptance.ts +744 -0
  313. package/shared/core/ast.ts +76 -0
  314. package/shared/core/benchmark.ts +743 -0
  315. package/shared/core/catalog.ts +733 -0
  316. package/shared/core/compare.ts +225 -0
  317. package/shared/core/coverage.ts +456 -0
  318. package/shared/core/data.ts +334 -0
  319. package/shared/core/decisions.ts +527 -0
  320. package/shared/core/dependencies.ts +604 -0
  321. package/shared/core/diagnostics.ts +24 -0
  322. package/shared/core/environments.ts +212 -0
  323. package/shared/core/evaluate.ts +737 -0
  324. package/shared/core/fields.ts +66 -0
  325. package/shared/core/findings.ts +1172 -0
  326. package/shared/core/form.ts +69 -0
  327. package/shared/core/index.ts +44 -0
  328. package/shared/core/junit.ts +252 -0
  329. package/shared/core/lexer.ts +275 -0
  330. package/shared/core/logs.ts +496 -0
  331. package/shared/core/markdown.ts +2374 -0
  332. package/shared/core/parser.ts +355 -0
  333. package/shared/core/perf-units.ts +228 -0
  334. package/shared/core/pipeline.ts +679 -0
  335. package/shared/core/project.ts +472 -0
  336. package/shared/core/provenance.ts +725 -0
  337. package/shared/core/stack-trace.ts +718 -0
  338. package/shared/core/streaming.ts +136 -0
  339. package/shared/core/test-results.ts +562 -0
  340. package/shared/core/thread-activity.ts +203 -0
  341. package/shared/core/tracking.ts +887 -0
  342. package/shared/core/transcript.ts +1364 -0
  343. package/shared/core/treemap.ts +446 -0
  344. package/shared/core/types.ts +70 -0
  345. package/shared/core/validate.ts +574 -0
  346. package/shared/core/waterfall.ts +536 -0
  347. package/shared/diagram/flowchart.ts +205 -0
  348. package/shared/diagram/layout.ts +440 -0
  349. package/shared/diagram/markdown.ts +135 -0
  350. package/shared/diagram/sequence.ts +122 -0
  351. package/shared/diagram/zoom.ts +114 -0
  352. package/shared/fallback.ts +120 -0
  353. package/shared/genui.test.ts +122 -0
  354. package/shared/instructions.ts +144 -0
  355. package/shared/logic/calendarMath.ts +130 -0
  356. package/shared/logic/chartMath.ts +289 -0
  357. package/shared/logic/choose-logic.ts +196 -0
  358. package/shared/logic/compare-logic.ts +270 -0
  359. package/shared/logic/content-model.ts +227 -0
  360. package/shared/logic/environments-logic.ts +53 -0
  361. package/shared/logic/explain-logic.ts +315 -0
  362. package/shared/logic/fieldLogic.ts +138 -0
  363. package/shared/logic/gallery-search.ts +71 -0
  364. package/shared/logic/icons.ts +322 -0
  365. package/shared/logic/json-logic.ts +153 -0
  366. package/shared/logic/mapMath.ts +133 -0
  367. package/shared/logic/project-logic.ts +39 -0
  368. package/shared/logic/references-model.ts +201 -0
  369. package/shared/logic/sketch.ts +165 -0
  370. package/shared/logic/supersession.ts +77 -0
  371. package/shared/logic/values.ts +133 -0
  372. package/shared/logic/widget-logic.ts +163 -0
  373. package/shared/rpc.ts +252 -0
  374. package/shared/segment.ts +81 -0
  375. package/shared/settings.ts +12 -0
@@ -0,0 +1,743 @@
1
+ // Benchmark results as Benchmark shows them: before/after rows with their
2
+ // change and a verdict that respects noise and the conditions each side was
3
+ // measured under, ranked runs (hyperfine --export-json, vitest bench
4
+ // --outputJson) with "1.84 ± 0.05 × faster than", or every run of an
5
+ // experiment with its metrics, status and conditions. Pure; shared by the
6
+ // renderer and the markdown fallback.
7
+ import { objectFields, unitInfo, type Fields } from "./perf-units";
8
+ import type { Value } from "./types";
9
+
10
+ export type Better = "lower" | "higher";
11
+ export type Verdict = "better" | "worse" | "same";
12
+
13
+ /** A change smaller than this (in percent) is noise whatever the spread. */
14
+ export const NOISE_PERCENT = 1;
15
+
16
+ export interface Comparison {
17
+ readonly name: string;
18
+ readonly before: number;
19
+ readonly after: number;
20
+ readonly unit: string | null;
21
+ readonly better: Better;
22
+ readonly delta: number;
23
+ /** The change in percent of `before`; null when `before` is 0. */
24
+ readonly percent: number | null;
25
+ /** The spread the change is judged against (a stddev), when the row has one. */
26
+ readonly noise: number | null;
27
+ readonly verdict: Verdict;
28
+ readonly note: string | null;
29
+ /** Each side's repeated values when the row gives a list (`before: [58.19, 58.04]`); its value is their mean. */
30
+ readonly beforeSamples: readonly number[];
31
+ readonly afterSamples: readonly number[];
32
+ /** The conditions each side was measured under; null when the row records none. */
33
+ readonly contextBefore: Context | null;
34
+ readonly contextAfter: Context | null;
35
+ /** How the two sides' conditions compare; null when the row records none. */
36
+ readonly comparability: Comparability | null;
37
+ /** False when a recorded condition differs: the change is then no result, and the row is not counted or judged. */
38
+ readonly comparable: boolean;
39
+ }
40
+
41
+ /** How a report's verdicts read: "faster/slower", "smaller/larger", or "better/worse". */
42
+ export interface VerdictWords {
43
+ readonly better: string;
44
+ readonly worse: string;
45
+ }
46
+
47
+ export interface CompareReport {
48
+ readonly kind: "compare";
49
+ readonly rows: readonly Comparison[];
50
+ /** Verdicts of the comparable rows. */
51
+ readonly counts: Readonly<Record<Verdict, number>>;
52
+ /** Rows whose two sides were measured under different conditions. */
53
+ readonly incomparable: number;
54
+ /** Conditions every row records on both sides with the same value, said once instead of per row. */
55
+ readonly shared: Context;
56
+ readonly words: VerdictWords;
57
+ /**
58
+ * The geometric mean of every comparable row's after/before, oriented so
59
+ * below 1 is better: 0.9 reads "10% faster overall". Null with fewer than
60
+ * two comparable rows, or when a value is not positive.
61
+ */
62
+ readonly geomean: number | null;
63
+ /** Items without a name or two numbers, left out. */
64
+ readonly ignored: number;
65
+ }
66
+
67
+ export interface Run {
68
+ readonly name: string;
69
+ readonly mean: number;
70
+ readonly stddev: number | null;
71
+ /**
72
+ * The uncertainty of the mean that the ratio's spread propagates: the
73
+ * stddev for hyperfine and hand-written runs (as hyperfine computes its
74
+ * "± "), the standard error of the mean for vitest, whose thousands of
75
+ * samples make the stddev say little about the mean.
76
+ */
77
+ readonly error: number | null;
78
+ /** The 99th percentile where the tool reports it (vitest): the plot's axis stops there, past one-off outliers. */
79
+ readonly p99: number | null;
80
+ readonly median: number | null;
81
+ readonly min: number;
82
+ readonly max: number;
83
+ /** Each run's time (hyperfine `times`, vitest `samples`), in the report's unit. */
84
+ readonly times: readonly number[];
85
+ /** mean ÷ the fastest mean (1 for the fastest). */
86
+ readonly ratio: number;
87
+ /** The spread of `ratio` propagated from both stddevs, as hyperfine computes it; null without stddevs. */
88
+ readonly ratioStddev: number | null;
89
+ /** Further facts the tool reported (user/system time, ops/s, ±rme, samples, memory). */
90
+ readonly facts: readonly { readonly label: string; readonly value: number; readonly unit: string | null }[];
91
+ /** A run that exited non-zero (hyperfine `exit_codes`), so its time measures a failure. */
92
+ readonly failed: boolean;
93
+ }
94
+
95
+ export interface RunGroup {
96
+ readonly name: string | null;
97
+ /** Where the group came from (a bench file), quiet beside its name. */
98
+ readonly source: string | null;
99
+ /** Fastest first. */
100
+ readonly runs: readonly Run[];
101
+ }
102
+
103
+ export interface RunsReport {
104
+ readonly kind: "runs";
105
+ readonly tool: "hyperfine" | "vitest" | "list";
106
+ /** The unit every time is in: "s" for hyperfine, "ms" for vitest, the given unit for a list. */
107
+ readonly unit: string;
108
+ readonly groups: readonly RunGroup[];
109
+ readonly ignored: number;
110
+ }
111
+
112
+ export type BenchmarkReport = CompareReport | RunsReport | AllRunsReport;
113
+ export type BenchmarkRead = { readonly ok: true; readonly report: BenchmarkReport } | { readonly ok: false; readonly error: string };
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Conditions
117
+
118
+ /** The conditions a value was measured under (device, build, thermal, display, cache, input…): key → value as written, in the order given. */
119
+ export type Context = Readonly<Record<string, string>>;
120
+
121
+ /** One condition on both sides of a comparison; null where that side does not record it. */
122
+ export interface ContextChange {
123
+ readonly key: string;
124
+ readonly before: string | null;
125
+ readonly after: string | null;
126
+ }
127
+
128
+ export interface Comparability {
129
+ /**
130
+ * matched: every condition recorded on both sides is the same. differs: one
131
+ * differs, so the change is not a like-for-like result. partial: nothing
132
+ * differs, but some conditions are recorded on one side only.
133
+ */
134
+ readonly state: "matched" | "differs" | "partial";
135
+ /** Conditions both sides record, with different values. */
136
+ readonly differing: readonly ContextChange[];
137
+ /** Conditions only one side records. */
138
+ readonly oneSided: readonly ContextChange[];
139
+ /** Conditions the row names as what it varies (`varies`): their change is the experiment, not a flaw. */
140
+ readonly varied: readonly ContextChange[];
141
+ /** Conditions both sides record with the same value. */
142
+ readonly same: Context;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // All runs
147
+
148
+ /** ok: the run measured; failed: it broke (crashed, errored); timeout: it ran out of time; other: a status the input names (skipped, thermal_refused…). */
149
+ export type RunStatus = "ok" | "failed" | "timeout" | "other";
150
+
151
+ export interface MetricColumn {
152
+ readonly key: string;
153
+ readonly label: string;
154
+ readonly unit: string | null;
155
+ readonly better: Better;
156
+ }
157
+
158
+ export interface RunRecord {
159
+ readonly name: string;
160
+ readonly status: RunStatus;
161
+ /** The status as written when it says more than the canonical word ("crashed", "thermal_refused"); null otherwise. */
162
+ readonly statusLabel: string | null;
163
+ /** Each metric's value by key; a metric the run did not measure is absent. Only an ok run's values are results. */
164
+ readonly values: Readonly<Record<string, number>>;
165
+ readonly context: Context;
166
+ readonly note: string | null;
167
+ }
168
+
169
+ export interface AllRunsReport {
170
+ readonly kind: "all-runs";
171
+ readonly metrics: readonly MetricColumn[];
172
+ /** In the order given, usually the order they ran. */
173
+ readonly runs: readonly RunRecord[];
174
+ readonly counts: Readonly<Record<RunStatus, number>>;
175
+ /** Conditions every run records with the same value. */
176
+ readonly shared: Context;
177
+ /** Conditions that differ between runs or that some runs leave out, in first-seen order: what the runs can be grouped by. */
178
+ readonly varying: readonly string[];
179
+ /** The grouping the input asks for (`groupBy`), when it names a varying condition. */
180
+ readonly groupBy: string | null;
181
+ /** Items that are not runs, left out. */
182
+ readonly ignored: number;
183
+ }
184
+
185
+ /** Runs sharing one value of the grouping condition, with the best value per metric among them. */
186
+ export interface Cohort {
187
+ /** The grouping condition; null when the runs are not grouped. */
188
+ readonly key: string | null;
189
+ /** Its value in these runs; null for runs that do not record it (or when not grouped). */
190
+ readonly value: string | null;
191
+ readonly runs: readonly RunRecord[];
192
+ /** The best value per metric key among the cohort's ok runs, where at least two measured it. */
193
+ readonly best: Readonly<Record<string, number>>;
194
+ /** Conditions that still differ inside the cohort: its best values compare unlike runs. */
195
+ readonly varying: readonly string[];
196
+ }
197
+
198
+ const USAGE = "Benchmark needs [{name, before, after}], hyperfine --export-json or vitest bench --outputJson output";
199
+
200
+ /** A finite number, or a string holding only one ("12.5"). */
201
+ function numeric(value: Value | undefined): number | null {
202
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
203
+ if (typeof value === "string" && /^\s*[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?\s*$/i.test(value)) return Number(value);
204
+ return null;
205
+ }
206
+
207
+ function label(value: Value | undefined): string {
208
+ return typeof value === "string" ? value.trim() : typeof value === "number" ? String(value) : "";
209
+ }
210
+
211
+ /** The words a verdict reads in, from what the rows measure. */
212
+ export function verdictWords(rows: readonly { readonly unit: string | null; readonly better: Better }[]): VerdictWords {
213
+ const kinds = new Set<string>(rows.map((row) => `${unitInfo(row.unit).kind}:${row.better}`));
214
+ if (kinds.size === 1) {
215
+ const only = [...kinds][0];
216
+ if (only === "time:lower" || only === "rate:higher") return { better: "faster", worse: "slower" };
217
+ if (only === "bytes:lower") return { better: "smaller", worse: "larger" };
218
+ }
219
+ return { better: "better", worse: "worse" };
220
+ }
221
+
222
+ /** better, worse, or same: within ±NOISE_PERCENT, or no bigger than the spread. */
223
+ export function verdictOf(before: number, after: number, better: Better, noise: number | null): Verdict {
224
+ const delta = after - before;
225
+ if (delta === 0) return "same";
226
+ if (before !== 0 && Math.abs(delta / before) * 100 < NOISE_PERCENT) return "same";
227
+ if (noise !== null && Math.abs(delta) <= noise) return "same";
228
+ return (delta < 0) === (better === "lower") ? "better" : "worse";
229
+ }
230
+
231
+ /** Conditions as written: scalar fields only (true/false read "yes"/"no"); null when there are none. */
232
+ export function readContext(value: Value | undefined): Context | null {
233
+ const fields = objectFields(value);
234
+ if (fields === null) return null;
235
+ const context: Record<string, string> = {};
236
+ for (const [key, item] of Object.entries(fields)) {
237
+ const text = typeof item === "boolean" ? (item ? "yes" : "no") : label(item);
238
+ if (key.trim() !== "" && text !== "") context[key.trim()] = text;
239
+ }
240
+ return Object.keys(context).length === 0 ? null : context;
241
+ }
242
+
243
+ /** Two conditions are the same when they read the same, ignoring case and spacing ("120 Hz" = "120 hz"). */
244
+ function sameCondition(a: string, b: string): boolean {
245
+ const normal = (text: string) => text.trim().toLowerCase().replace(/\s+/g, " ");
246
+ return normal(a) === normal(b);
247
+ }
248
+
249
+ /** Later contexts override earlier ones key by key; null when none records anything. */
250
+ function mergeContexts(...contexts: readonly (Context | null)[]): Context | null {
251
+ const present = contexts.filter((context): context is Context => context !== null);
252
+ return present.length === 0 ? null : Object.assign({}, ...present);
253
+ }
254
+
255
+ /** How the conditions of a before and an after compare; `varies` names the conditions the comparison changes on purpose. Null when neither side records any. */
256
+ export function compareContexts(before: Context | null, after: Context | null, varies: readonly string[] = []): Comparability | null {
257
+ if (before === null && after === null) return null;
258
+ const varied = new Set(varies.map((key) => key.trim().toLowerCase()));
259
+ const result = { differing: [] as ContextChange[], oneSided: [] as ContextChange[], varied: [] as ContextChange[], same: {} as Record<string, string> };
260
+ for (const key of new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])) {
261
+ const change: ContextChange = {
262
+ key,
263
+ before: before !== null && Object.hasOwn(before, key) ? before[key] : null,
264
+ after: after !== null && Object.hasOwn(after, key) ? after[key] : null,
265
+ };
266
+ if (change.before !== null && change.after !== null && sameCondition(change.before, change.after)) result.same[key] = change.before;
267
+ else if (varied.has(key.toLowerCase())) result.varied.push(change);
268
+ else if (change.before === null || change.after === null) result.oneSided.push(change);
269
+ else result.differing.push(change);
270
+ }
271
+ const state = result.differing.length > 0 ? "differs" : result.oneSided.length > 0 ? "partial" : "matched";
272
+ return { state, ...result };
273
+ }
274
+
275
+ /** The conditions every context records with the same value; nothing when one of them records none. */
276
+ export function sharedContext(contexts: readonly (Context | null)[]): Context {
277
+ const [first, ...rest] = contexts;
278
+ if (first === undefined || first === null || rest.some((context) => context === null)) return {};
279
+ const shared: Record<string, string> = {};
280
+ for (const [key, value] of Object.entries(first)) {
281
+ if (rest.every((context) => context !== null && Object.hasOwn(context, key) && sameCondition(context[key], value))) shared[key] = value;
282
+ }
283
+ return shared;
284
+ }
285
+
286
+ /** The sample standard deviation of two or more values; null for fewer. */
287
+ function sampleStddev(values: readonly number[]): number | null {
288
+ if (values.length < 2) return null;
289
+ const mean = values.reduce((sum, n) => sum + n, 0) / values.length;
290
+ return Math.sqrt(values.reduce((sum, n) => sum + (n - mean) ** 2, 0) / (values.length - 1));
291
+ }
292
+
293
+ interface Side {
294
+ readonly value: number;
295
+ readonly samples: readonly number[];
296
+ readonly spread: number | null;
297
+ readonly context: Context | null;
298
+ }
299
+
300
+ /** One side of a row: a number, a list of repeats (their mean), or `{value | mean, runs?, stddev?, context?}`. */
301
+ function readSide(value: Value | undefined): Side | null {
302
+ const single = numeric(value);
303
+ if (single !== null) return { value: single, samples: [], spread: null, context: null };
304
+ const fields = objectFields(value);
305
+ const repeats = fields === null ? value : (fields.runs ?? fields.times ?? fields.samples ?? fields.values);
306
+ const samples = Array.isArray(repeats) ? repeats.flatMap((item) => numeric(item) ?? []) : [];
307
+ const given = fields === null ? null : numeric(fields.value ?? fields.mean ?? fields.median);
308
+ if (given === null && samples.length === 0) return null;
309
+ return {
310
+ value: given ?? samples.reduce((sum, n) => sum + n, 0) / samples.length,
311
+ samples,
312
+ spread: (fields === null ? null : numeric(fields.stddev ?? fields.sd)) ?? sampleStddev(samples),
313
+ context: fields === null ? null : readContext(fields.context),
314
+ };
315
+ }
316
+
317
+ /** A name or a list of names. */
318
+ function labels(value: Value | undefined): string[] {
319
+ if (Array.isArray(value)) return value.map(label).filter((text) => text !== "");
320
+ const one = label(value);
321
+ return one === "" ? [] : one.split(/\s*,\s*/);
322
+ }
323
+
324
+ function readComparisons(items: readonly Value[], unit: string | null): CompareReport {
325
+ const rows: Comparison[] = [];
326
+ let ignored = 0;
327
+ for (const item of items) {
328
+ const fields = objectFields(item);
329
+ if (fields === null) {
330
+ ignored++;
331
+ continue;
332
+ }
333
+ const name = label(fields.name ?? fields.label ?? fields.title ?? fields.metric);
334
+ const beforeSide = readSide(fields.before ?? fields.baseline ?? fields.old);
335
+ const afterSide = readSide(fields.after ?? fields.current ?? fields.new);
336
+ if (name === "" || beforeSide === null || afterSide === null) {
337
+ ignored++;
338
+ continue;
339
+ }
340
+ const before = beforeSide.value;
341
+ const after = afterSide.value;
342
+ const better: Better = label(fields.better).toLowerCase() === "higher" ? "higher" : "lower";
343
+ const rowUnit = label(fields.unit) || unit;
344
+ const spreads = [fields.stddev, fields.sd, fields.noise, fields.beforeStddev, fields.afterStddev]
345
+ .map(numeric)
346
+ .concat(beforeSide.spread, afterSide.spread)
347
+ .filter((n): n is number => n !== null && n >= 0);
348
+ const noise = spreads.length === 0 ? null : Math.max(...spreads);
349
+ // A row's `context` holds for both sides; `contextBefore`/`contextAfter` and a side's own `context` override it.
350
+ const shared = readContext(fields.context ?? fields.conditions);
351
+ const contextBefore = mergeContexts(shared, readContext(fields.contextBefore ?? fields.beforeContext), beforeSide.context);
352
+ const contextAfter = mergeContexts(shared, readContext(fields.contextAfter ?? fields.afterContext), afterSide.context);
353
+ const comparability = compareContexts(contextBefore, contextAfter, labels(fields.varies ?? fields.vary));
354
+ rows.push({
355
+ name,
356
+ before,
357
+ after,
358
+ unit: rowUnit === "" ? null : rowUnit,
359
+ better,
360
+ delta: after - before,
361
+ percent: before === 0 ? null : ((after - before) / Math.abs(before)) * 100,
362
+ noise,
363
+ verdict: verdictOf(before, after, better, noise),
364
+ note: label(fields.note ?? fields.detail) || null,
365
+ beforeSamples: beforeSide.samples,
366
+ afterSamples: afterSide.samples,
367
+ contextBefore,
368
+ contextAfter,
369
+ comparability,
370
+ comparable: comparability?.state !== "differs",
371
+ });
372
+ }
373
+ const judged = rows.filter((row) => row.comparable);
374
+ const counts: Record<Verdict, number> = { better: 0, worse: 0, same: 0 };
375
+ for (const row of judged) counts[row.verdict]++;
376
+ let geomean: number | null = null;
377
+ if (judged.length > 1 && judged.every((row) => row.before > 0 && row.after > 0)) {
378
+ const logs = judged.map((row) => Math.log(row.better === "lower" ? row.after / row.before : row.before / row.after));
379
+ geomean = Math.exp(logs.reduce((sum, n) => sum + n, 0) / logs.length);
380
+ }
381
+ const anyContext = rows.some((row) => row.comparability !== null);
382
+ return {
383
+ kind: "compare",
384
+ rows,
385
+ counts,
386
+ incomparable: rows.length - judged.length,
387
+ shared: anyContext ? sharedContext(rows.flatMap((row) => [row.contextBefore, row.contextAfter])) : {},
388
+ words: verdictWords(rows),
389
+ geomean,
390
+ ignored,
391
+ };
392
+ }
393
+
394
+ /** Ranks runs by mean and computes each one's ratio to the fastest, with hyperfine's error propagation. */
395
+ export function rankRuns(runs: readonly Omit<Run, "ratio" | "ratioStddev">[]): Run[] {
396
+ const sorted = [...runs].sort((a, b) => a.mean - b.mean);
397
+ const fastest = sorted[0];
398
+ return sorted.map((run) => {
399
+ if (fastest === undefined || !(fastest.mean > 0)) return { ...run, ratio: 1, ratioStddev: null };
400
+ const ratio = run.mean / fastest.mean;
401
+ const ratioStddev =
402
+ run.error === null || fastest.error === null || !(run.mean > 0)
403
+ ? null
404
+ : ratio * Math.sqrt((run.error / run.mean) ** 2 + (fastest.error / fastest.mean) ** 2);
405
+ return { ...run, ratio, ratioStddev };
406
+ });
407
+ }
408
+
409
+ function numbers(value: Value | undefined): number[] {
410
+ return Array.isArray(value) ? value.flatMap((item) => (typeof item === "number" && Number.isFinite(item) ? [item] : [])) : [];
411
+ }
412
+
413
+ /** min and max from the fields, else from the runs, else the mean. */
414
+ function extent(fields: Fields, mean: number, times: readonly number[]): { min: number; max: number } {
415
+ const min = numeric(fields.min) ?? (times.length > 0 ? Math.min(...times) : mean);
416
+ const max = numeric(fields.max) ?? (times.length > 0 ? Math.max(...times) : mean);
417
+ return { min, max };
418
+ }
419
+
420
+ /** hyperfine --export-json: `{results: [{command, mean, stddev, median, user, system, min, max, times, exit_codes?, memory_usage_byte?}]}`, seconds. */
421
+ function readHyperfine(results: readonly Value[]): RunsReport {
422
+ const runs: Omit<Run, "ratio" | "ratioStddev">[] = [];
423
+ let ignored = 0;
424
+ for (const item of results) {
425
+ const fields = objectFields(item);
426
+ const mean = numeric(fields?.mean);
427
+ if (fields === null || mean === null) {
428
+ ignored++;
429
+ continue;
430
+ }
431
+ const times = numbers(fields.times);
432
+ const parameters = Object.entries(objectFields(fields.parameters) ?? {}).map(([key, value]) => `${key}=${label(value)}`);
433
+ const name = label(fields.command) || parameters.join(" ") || `Command ${runs.length + 1}`;
434
+ const facts: Run["facts"][number][] = [];
435
+ const user = numeric(fields.user);
436
+ const system = numeric(fields.system);
437
+ if (user !== null) facts.push({ label: "User", value: user, unit: "s" });
438
+ if (system !== null) facts.push({ label: "System", value: system, unit: "s" });
439
+ const memory = numbers(fields.memory_usage_byte);
440
+ if (memory.length > 0) facts.push({ label: "Peak memory", value: Math.max(...memory), unit: "B" });
441
+ facts.push({ label: "Runs", value: times.length > 0 ? times.length : 1, unit: null });
442
+ const exits = numbers(fields.exit_codes);
443
+ runs.push({
444
+ name,
445
+ mean,
446
+ stddev: numeric(fields.stddev),
447
+ error: numeric(fields.stddev),
448
+ p99: null,
449
+ median: numeric(fields.median),
450
+ ...extent(fields, mean, times),
451
+ times,
452
+ facts,
453
+ failed: exits.some((code) => code !== 0),
454
+ });
455
+ }
456
+ return { kind: "runs", tool: "hyperfine", unit: "s", groups: [{ name: null, source: null, runs: rankRuns(runs) }], ignored };
457
+ }
458
+
459
+ /** vitest bench --outputJson: `{files: [{filepath, groups: [{fullName, benchmarks: [{name, mean, sd, min, max, hz, rme, sampleCount, samples}]}]}]}`, milliseconds. */
460
+ function readVitest(files: readonly Value[]): RunsReport {
461
+ const groups: RunGroup[] = [];
462
+ let ignored = 0;
463
+ for (const file of files) {
464
+ const fileFields = objectFields(file);
465
+ if (fileFields === null || !Array.isArray(fileFields.groups)) continue;
466
+ for (const group of fileFields.groups) {
467
+ const groupFields = objectFields(group);
468
+ if (groupFields === null || !Array.isArray(groupFields.benchmarks)) continue;
469
+ const runs: Omit<Run, "ratio" | "ratioStddev">[] = [];
470
+ for (const bench of groupFields.benchmarks) {
471
+ const fields = objectFields(bench);
472
+ const mean = numeric(fields?.mean);
473
+ if (fields === null || mean === null) {
474
+ ignored++;
475
+ continue;
476
+ }
477
+ const times = numbers(fields.samples);
478
+ const facts: Run["facts"][number][] = [];
479
+ const hz = numeric(fields.hz);
480
+ const rme = numeric(fields.rme);
481
+ const samples = numeric(fields.sampleCount) ?? (times.length > 0 ? times.length : null);
482
+ const p99 = numeric(fields.p99);
483
+ if (hz !== null) facts.push({ label: "Throughput", value: hz, unit: "ops/s" });
484
+ if (rme !== null) facts.push({ label: "± rme", value: rme, unit: "%" });
485
+ if (p99 !== null) facts.push({ label: "p99", value: p99, unit: "ms" });
486
+ if (samples !== null) facts.push({ label: "Samples", value: samples, unit: null });
487
+ runs.push({
488
+ name: label(fields.name) || `Benchmark ${runs.length + 1}`,
489
+ mean,
490
+ stddev: numeric(fields.sd),
491
+ error: numeric(fields.sem),
492
+ p99,
493
+ median: numeric(fields.median),
494
+ ...extent(fields, mean, times),
495
+ times,
496
+ facts,
497
+ failed: false,
498
+ });
499
+ }
500
+ if (runs.length === 0) continue;
501
+ // "file.bench.ts > group > subgroup": the file goes quiet, the rest names the group.
502
+ const parts = label(groupFields.fullName).split(" > ");
503
+ const fileName = label(fileFields.filepath).split(/[/\\]/).pop() ?? "";
504
+ const source = parts.length > 1 ? parts[0] : fileName || null;
505
+ const name = parts.length > 1 ? parts.slice(1).join(" › ") : parts[0] || null;
506
+ groups.push({ name, source, runs: rankRuns(runs) });
507
+ }
508
+ }
509
+ return { kind: "runs", tool: "vitest", unit: "ms", groups, ignored };
510
+ }
511
+
512
+ /** A hand-written list of runs: `[{name, mean, stddev?, min?, max?, times?}]` in `unit` (default ms). */
513
+ function readRunList(items: readonly Value[], unit: string | null): RunsReport {
514
+ const runs: Omit<Run, "ratio" | "ratioStddev">[] = [];
515
+ let ignored = 0;
516
+ for (const item of items) {
517
+ const fields = objectFields(item);
518
+ const mean = numeric(fields?.mean);
519
+ if (fields === null || mean === null) {
520
+ ignored++;
521
+ continue;
522
+ }
523
+ const times = numbers(fields.times);
524
+ runs.push({
525
+ name: label(fields.name ?? fields.command) || `Run ${runs.length + 1}`,
526
+ mean,
527
+ stddev: numeric(fields.stddev ?? fields.sd),
528
+ error: numeric(fields.stddev ?? fields.sd),
529
+ p99: null,
530
+ median: numeric(fields.median),
531
+ ...extent(fields, mean, times),
532
+ times,
533
+ facts: times.length > 0 ? [{ label: "Runs", value: times.length, unit: null }] : [],
534
+ failed: false,
535
+ });
536
+ }
537
+ return { kind: "runs", tool: "list", unit: unit ?? "ms", groups: [{ name: null, source: null, runs: rankRuns(runs) }], ignored };
538
+ }
539
+
540
+ /** Status words and what they mean; anything else is kept as written ("thermal refused"). */
541
+ const RUN_STATUS: Record<string, RunStatus> = {
542
+ ok: "ok",
543
+ pass: "ok",
544
+ passed: "ok",
545
+ success: "ok",
546
+ succeeded: "ok",
547
+ done: "ok",
548
+ complete: "ok",
549
+ completed: "ok",
550
+ measured: "ok",
551
+ failed: "failed",
552
+ fail: "failed",
553
+ failure: "failed",
554
+ error: "failed",
555
+ errored: "failed",
556
+ crash: "failed",
557
+ crashed: "failed",
558
+ aborted: "failed",
559
+ killed: "failed",
560
+ timeout: "timeout",
561
+ "timed out": "timeout",
562
+ timedout: "timeout",
563
+ };
564
+
565
+ /** A metric key with its unit written in: "p99 interval (ms)", "p99_ms", "footprint_gb". */
566
+ const KEY_UNIT = /^(.+?)\s*\(([^)]+)\)$|^(.+?)[_\s-](ms|s|us|µs|ns|kb|mb|gb|mib|gib|pct)$/i;
567
+ /** Metrics where more is better unless the input says otherwise; everything else (times, sizes, intervals) is lower-is-better. */
568
+ const HIGHER_IS_BETTER = /fps|frame.?rate|throughput|ops|[rq]ps|hz|score|accuracy|hit|speed/i;
569
+
570
+ /** A metric's label, unit and direction: from its spec (`{label, unit, better}`), else from its key, else the component's unit. */
571
+ function metricColumn(key: string, spec: Fields | null, unit: string | null): MetricColumn {
572
+ const written = KEY_UNIT.exec(key);
573
+ const base = written === null ? key : (written[1] ?? written[3]);
574
+ const keyUnit = written === null ? (/fps/i.test(key) ? "fps" : null) : (written[2] ?? (written[4].toLowerCase() === "pct" ? "%" : written[4]));
575
+ const metricUnit = label(spec?.unit) || keyUnit || unit || null;
576
+ const direction = label(spec?.better).toLowerCase();
577
+ const better: Better =
578
+ direction === "higher" || direction === "lower" ? direction : HIGHER_IS_BETTER.test(base) || unitInfo(metricUnit).kind === "rate" ? "higher" : "lower";
579
+ const name = label(spec?.label ?? spec?.title) || (/^fps$/i.test(base) ? "FPS" : base.replace(/_/g, " "));
580
+ return { key, label: name, unit: metricUnit, better };
581
+ }
582
+
583
+ /** Conditions that differ between runs or that some runs leave out, in first-seen order. */
584
+ function varyingConditions(runs: readonly RunRecord[]): string[] {
585
+ const shared = runs.length === 0 ? {} : sharedContext(runs.map((run) => run.context));
586
+ return [...new Set(runs.flatMap((run) => Object.keys(run.context)))].filter((key) => !Object.hasOwn(shared, key));
587
+ }
588
+
589
+ /** Each metric's best value among the ok runs, where at least two measured it and they are not all equal. */
590
+ export function bestValues(runs: readonly RunRecord[], metrics: readonly MetricColumn[]): Record<string, number> {
591
+ const best: Record<string, number> = {};
592
+ for (const metric of metrics) {
593
+ const measured = runs.flatMap((run) => (run.status === "ok" && Object.hasOwn(run.values, metric.key) ? [run.values[metric.key]] : []));
594
+ if (measured.length < 2) continue;
595
+ const low = Math.min(...measured);
596
+ const high = Math.max(...measured);
597
+ if (low !== high) best[metric.key] = metric.better === "lower" ? low : high;
598
+ }
599
+ return best;
600
+ }
601
+
602
+ /**
603
+ * Every run of an experiment: `[{name | run, status?, metrics: {fps: 58, p99: 21.4} | {fps: {value, unit?, better?}}, context?, note?}]`,
604
+ * with `spec` (the object form's `metrics`) naming labels, units and directions: `[{key, label?, unit?, better?}]` or `{key: {label?, unit?, better?} | unit}`.
605
+ */
606
+ function readAllRuns(items: readonly Value[], spec: Value | undefined, groupBy: Value | undefined, unit: string | null): AllRunsReport {
607
+ const specs: Record<string, Fields> = {};
608
+ const order: string[] = [];
609
+ if (Array.isArray(spec)) {
610
+ for (const entry of spec) {
611
+ const fields = objectFields(entry);
612
+ const key = label(fields?.key ?? fields?.name ?? fields?.metric);
613
+ if (fields === null || key === "" || Object.hasOwn(specs, key)) continue;
614
+ specs[key] = fields;
615
+ order.push(key);
616
+ }
617
+ } else {
618
+ for (const [key, entry] of Object.entries(objectFields(spec) ?? {})) {
619
+ specs[key] = objectFields(entry) ?? { unit: label(entry) };
620
+ order.push(key);
621
+ }
622
+ }
623
+ const runs: RunRecord[] = [];
624
+ let ignored = 0;
625
+ for (const item of items) {
626
+ const fields = objectFields(item);
627
+ if (fields === null) {
628
+ ignored++;
629
+ continue;
630
+ }
631
+ const values: Record<string, number> = {};
632
+ for (const [key, raw] of Object.entries(objectFields(fields.metrics ?? fields.values) ?? {})) {
633
+ const detail = objectFields(raw);
634
+ const value = numeric(detail === null ? raw : detail.value);
635
+ if (!order.includes(key)) order.push(key);
636
+ // A metric's own {unit, better} stands in for a spec the input did not give.
637
+ if (detail !== null && !Object.hasOwn(specs, key)) specs[key] = detail;
638
+ if (value !== null) values[key] = value;
639
+ }
640
+ const written = label(fields.status ?? fields.outcome).toLowerCase().replace(/[_-]+/g, " ");
641
+ const status: RunStatus = written === "" ? "ok" : (RUN_STATUS[written] ?? "other");
642
+ runs.push({
643
+ name: label(fields.name ?? fields.run ?? fields.label ?? fields.title ?? fields.id) || `Run ${runs.length + 1}`,
644
+ status,
645
+ statusLabel: status === "ok" || written === "" || written === status || written === "fail" ? null : written,
646
+ values,
647
+ context: readContext(fields.context ?? fields.conditions) ?? {},
648
+ note: label(fields.note ?? fields.detail ?? fields.reason ?? fields.error) || null,
649
+ });
650
+ }
651
+ const counts: Record<RunStatus, number> = { ok: 0, failed: 0, timeout: 0, other: 0 };
652
+ for (const run of runs) counts[run.status]++;
653
+ const varying = varyingConditions(runs);
654
+ const wanted = label(groupBy).toLowerCase();
655
+ return {
656
+ kind: "all-runs",
657
+ metrics: order.map((key) => metricColumn(key, specs[key] ?? null, unit)),
658
+ runs,
659
+ counts,
660
+ shared: runs.length === 0 ? {} : sharedContext(runs.map((run) => run.context)),
661
+ varying,
662
+ groupBy: varying.find((key) => key.toLowerCase() === wanted) ?? null,
663
+ ignored,
664
+ };
665
+ }
666
+
667
+ /** The runs by their value of condition `key` (first-seen order, runs that do not record it last), or all together when `key` is null. */
668
+ export function groupRuns(report: AllRunsReport, key: string | null): Cohort[] {
669
+ const cohort = (value: string | null, runs: readonly RunRecord[]): Cohort => ({
670
+ key,
671
+ value,
672
+ runs,
673
+ best: bestValues(runs, report.metrics),
674
+ varying: varyingConditions(runs).filter((condition) => condition !== key),
675
+ });
676
+ if (key === null) return [cohort(null, report.runs)];
677
+ const groups: { value: string; runs: RunRecord[] }[] = [];
678
+ const unrecorded: RunRecord[] = [];
679
+ for (const run of report.runs) {
680
+ if (!Object.hasOwn(run.context, key)) {
681
+ unrecorded.push(run);
682
+ continue;
683
+ }
684
+ const value = run.context[key];
685
+ const group = groups.find((candidate) => sameCondition(candidate.value, value));
686
+ if (group === undefined) groups.push({ value, runs: [run] });
687
+ else group.runs.push(run);
688
+ }
689
+ return [...groups.map((group) => cohort(group.value, group.runs)), ...(unrecorded.length === 0 ? [] : [cohort(null, unrecorded)])];
690
+ }
691
+
692
+ /** Reads any input Benchmark takes; `unit` is the component's unit param. */
693
+ export function readBenchmark(value: Value | undefined, unit: string | null): BenchmarkRead {
694
+ if (value === undefined || value === null) return { ok: false, error: USAGE };
695
+ const fields = objectFields(value);
696
+ if (fields !== null) {
697
+ if (Array.isArray(fields.runs)) return { ok: true, report: readAllRuns(fields.runs, fields.metrics, fields.groupBy ?? fields.group, unit) };
698
+ if (Array.isArray(fields.results)) return { ok: true, report: readHyperfine(fields.results) };
699
+ if (Array.isArray(fields.files)) {
700
+ const report = readVitest(fields.files);
701
+ return report.groups.length === 0 && report.ignored === 0 ? { ok: false, error: "This vitest bench report has no benchmarks" } : { ok: true, report };
702
+ }
703
+ return { ok: false, error: USAGE };
704
+ }
705
+ if (!Array.isArray(value)) return { ok: false, error: USAGE };
706
+ if (value.some((item) => objectFields(objectFields(item)?.metrics) !== null)) return { ok: true, report: readAllRuns(value, undefined, undefined, unit) };
707
+ const runList = value.some((item) => {
708
+ const row = objectFields(item);
709
+ return row !== null && numeric(row.mean) !== null && row.before === undefined;
710
+ });
711
+ return { ok: true, report: runList ? readRunList(value, unit) : readComparisons(value, unit) };
712
+ }
713
+
714
+ /** "1.84 ± 0.05" for a ratio, or "1.84" without its spread. */
715
+ export function formatRatio(ratio: number, spread: number | null): string {
716
+ const digits = (n: number) => (n >= 100 ? n.toFixed(0) : n >= 10 ? n.toFixed(1) : n.toFixed(2));
717
+ return spread === null ? digits(ratio) : `${digits(ratio)} ± ${digits(spread)}`;
718
+ }
719
+
720
+ /** Conditions as one line: "thermal Nominal · display 120 Hz". */
721
+ export function formatConditions(context: Context): string {
722
+ return Object.entries(context)
723
+ .map(([key, value]) => `${key} ${value}`)
724
+ .join(" · ");
725
+ }
726
+
727
+ /** What a run's status says: "failed", "timed out", or the input's own word ("crashed", "thermal refused"). */
728
+ export function runStatusWord(run: RunRecord): string {
729
+ return run.statusLabel ?? (run.status === "timeout" ? "timed out" : run.status);
730
+ }
731
+
732
+ /** The runs that did not measure, counted per status word in first-seen order: [{word: "failed", count: 2}, {word: "thermal refused", count: 1}]. */
733
+ export function unmeasuredCounts(runs: readonly RunRecord[]): { readonly word: string; readonly status: RunStatus; readonly count: number }[] {
734
+ const counts: { word: string; status: RunStatus; count: number }[] = [];
735
+ for (const run of runs) {
736
+ if (run.status === "ok") continue;
737
+ const word = runStatusWord(run);
738
+ const entry = counts.find((candidate) => candidate.word === word);
739
+ if (entry === undefined) counts.push({ word, status: run.status, count: 1 });
740
+ else entry.count++;
741
+ }
742
+ return counts;
743
+ }