benchforge 0.1.11 → 0.2.4

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 (253) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +99 -294
  3. package/bin/benchforge +1 -2
  4. package/dist/AnalyzeArchive-8NCJhmhS.mjs +145 -0
  5. package/dist/AnalyzeArchive-8NCJhmhS.mjs.map +1 -0
  6. package/dist/BenchMatrix-BZVrBB_h.mjs +1050 -0
  7. package/dist/BenchMatrix-BZVrBB_h.mjs.map +1 -0
  8. package/dist/{BenchRunner-BzyUfiyB.d.mts → BenchRunner-DglX1NOn.d.mts} +119 -66
  9. package/dist/CoverageSampler-D5T9DRqe.mjs +27 -0
  10. package/dist/CoverageSampler-D5T9DRqe.mjs.map +1 -0
  11. package/dist/Formatters-BWj3d4sv.mjs +95 -0
  12. package/dist/Formatters-BWj3d4sv.mjs.map +1 -0
  13. package/dist/{HeapSampler-B8dtKHn1.mjs → HeapSampler-Dq-hpXem.mjs} +4 -4
  14. package/dist/HeapSampler-Dq-hpXem.mjs.map +1 -0
  15. package/dist/RunBenchCLI-C17DrJz8.mjs +3075 -0
  16. package/dist/RunBenchCLI-C17DrJz8.mjs.map +1 -0
  17. package/dist/StatisticalUtils-BD92crgM.mjs +255 -0
  18. package/dist/StatisticalUtils-BD92crgM.mjs.map +1 -0
  19. package/dist/TimeSampler-Ds8n7l2B.mjs +29 -0
  20. package/dist/TimeSampler-Ds8n7l2B.mjs.map +1 -0
  21. package/dist/ViewerServer-BJhdnxlN.mjs +639 -0
  22. package/dist/ViewerServer-BJhdnxlN.mjs.map +1 -0
  23. package/dist/ViewerServer-CuMNdNBz.mjs +2 -0
  24. package/dist/bin/benchforge.mjs +4 -5
  25. package/dist/bin/benchforge.mjs.map +1 -1
  26. package/dist/index.d.mts +711 -558
  27. package/dist/index.mjs +98 -3
  28. package/dist/index.mjs.map +1 -0
  29. package/dist/runners/WorkerScript.d.mts +12 -4
  30. package/dist/runners/WorkerScript.mjs +77 -105
  31. package/dist/runners/WorkerScript.mjs.map +1 -1
  32. package/dist/viewer/assets/CIPlot-BkOvMoMa.js +1 -0
  33. package/dist/viewer/assets/HistogramKde-CmSyUFY0.js +1 -0
  34. package/dist/viewer/assets/LegendUtils-BJpbn_jr.js +55 -0
  35. package/dist/viewer/assets/SampleTimeSeries-C4VBhXr3.js +1 -0
  36. package/dist/viewer/assets/index-Br9bp_cX.js +153 -0
  37. package/dist/viewer/assets/index-NzXXe_CC.css +1 -0
  38. package/dist/viewer/index.html +19 -0
  39. package/dist/viewer/speedscope/LICENSE +21 -0
  40. package/dist/viewer/speedscope/SourceCodePro-Regular.ttf-ILST5JV6.woff2 +0 -0
  41. package/dist/viewer/speedscope/favicon-16x16-V2DMIAZS.js +2 -0
  42. package/dist/viewer/speedscope/favicon-16x16-V2DMIAZS.js.map +7 -0
  43. package/dist/viewer/speedscope/favicon-16x16-VSI62OPJ.png +0 -0
  44. package/dist/viewer/speedscope/favicon-32x32-3EB2YCUY.png +0 -0
  45. package/dist/viewer/speedscope/favicon-32x32-THY3JDJL.js +2 -0
  46. package/dist/viewer/speedscope/favicon-32x32-THY3JDJL.js.map +7 -0
  47. package/dist/viewer/speedscope/favicon-FOKUP5Y5.ico +0 -0
  48. package/dist/viewer/speedscope/favicon-M34RF7BI.js +2 -0
  49. package/dist/viewer/speedscope/favicon-M34RF7BI.js.map +7 -0
  50. package/dist/viewer/speedscope/file-format-schema.json +274 -0
  51. package/dist/viewer/speedscope/index.html +19 -0
  52. package/dist/viewer/speedscope/jfrview_bg-BLJXNNQB.wasm +0 -0
  53. package/dist/viewer/speedscope/perf-vertx-stacks-01-collapsed-all-ZNUIGAJL.txt +199 -0
  54. package/dist/viewer/speedscope/release.txt +3 -0
  55. package/dist/viewer/speedscope/source-code-pro.LICENSE.md +93 -0
  56. package/dist/viewer/speedscope/speedscope-GHPHNKXC.css +2 -0
  57. package/dist/viewer/speedscope/speedscope-GHPHNKXC.css.map +7 -0
  58. package/dist/viewer/speedscope/speedscope-QZFMJ7VP.js +212 -0
  59. package/dist/viewer/speedscope/speedscope-QZFMJ7VP.js.map +7 -0
  60. package/package.json +52 -27
  61. package/src/bin/benchforge.ts +2 -2
  62. package/src/cli/AnalyzeArchive.ts +232 -0
  63. package/src/cli/BrowserBench.ts +322 -0
  64. package/src/cli/CliArgs.ts +164 -51
  65. package/src/cli/CliExport.ts +179 -0
  66. package/src/cli/CliOptions.ts +147 -0
  67. package/src/cli/CliReport.ts +197 -0
  68. package/src/cli/FilterBenchmarks.ts +18 -30
  69. package/src/cli/RunBenchCLI.ts +132 -866
  70. package/src/cli/SuiteRunner.ts +160 -0
  71. package/src/cli/ViewerServer.ts +282 -0
  72. package/src/export/AllocExport.ts +121 -0
  73. package/src/export/ArchiveExport.ts +146 -0
  74. package/src/export/ArchiveFormat.ts +50 -0
  75. package/src/export/CoverageExport.ts +148 -0
  76. package/src/export/EditorUri.ts +10 -0
  77. package/src/export/PerfettoExport.ts +64 -99
  78. package/src/export/SpeedscopeTypes.ts +98 -0
  79. package/src/export/TimeExport.ts +115 -0
  80. package/src/index.ts +86 -67
  81. package/src/matrix/BenchMatrix.ts +230 -0
  82. package/src/matrix/CaseLoader.ts +8 -6
  83. package/src/matrix/MatrixDirRunner.ts +153 -0
  84. package/src/matrix/MatrixFilter.ts +49 -47
  85. package/src/matrix/MatrixInlineRunner.ts +50 -0
  86. package/src/matrix/MatrixReport.ts +90 -250
  87. package/src/matrix/VariantLoader.ts +5 -5
  88. package/src/profiling/browser/BenchLoop.ts +51 -0
  89. package/src/profiling/browser/BrowserCDP.ts +133 -0
  90. package/src/profiling/browser/BrowserGcStats.ts +33 -0
  91. package/src/profiling/browser/BrowserProfiler.ts +160 -0
  92. package/src/profiling/browser/CdpClient.ts +82 -0
  93. package/src/profiling/browser/CdpPage.ts +138 -0
  94. package/src/profiling/browser/ChromeLauncher.ts +158 -0
  95. package/src/profiling/browser/ChromeTraceEvent.ts +28 -0
  96. package/src/profiling/browser/PageLoadMode.ts +61 -0
  97. package/src/profiling/node/CoverageSampler.ts +27 -0
  98. package/src/profiling/node/CoverageTypes.ts +23 -0
  99. package/src/profiling/node/HeapSampleReport.ts +261 -0
  100. package/src/{heap-sample → profiling/node}/HeapSampler.ts +1 -2
  101. package/src/{heap-sample → profiling/node}/ResolvedProfile.ts +18 -9
  102. package/src/profiling/node/TimeSampler.ts +57 -0
  103. package/src/report/BenchmarkReport.ts +146 -0
  104. package/src/report/Colors.ts +9 -0
  105. package/src/report/Formatters.ts +110 -0
  106. package/src/report/GcSections.ts +151 -0
  107. package/src/{GitUtils.ts → report/GitUtils.ts} +18 -19
  108. package/src/report/HtmlReport.ts +223 -0
  109. package/src/report/ParseStats.ts +73 -0
  110. package/src/report/StandardSections.ts +147 -0
  111. package/src/report/ViewerSections.ts +286 -0
  112. package/src/report/text/TableReport.ts +253 -0
  113. package/src/report/text/TextReport.ts +123 -0
  114. package/src/runners/AdaptiveWrapper.ts +116 -236
  115. package/src/runners/BenchRunner.ts +20 -15
  116. package/src/{Benchmark.ts → runners/BenchmarkSpec.ts} +5 -6
  117. package/src/runners/CreateRunner.ts +5 -7
  118. package/src/runners/GcStats.ts +47 -50
  119. package/src/{MeasuredResults.ts → runners/MeasuredResults.ts} +43 -37
  120. package/src/runners/MergeBatches.ts +123 -0
  121. package/src/{NodeGC.ts → runners/NodeGC.ts} +2 -3
  122. package/src/runners/RunnerOrchestrator.ts +127 -243
  123. package/src/runners/RunnerUtils.ts +75 -1
  124. package/src/runners/SampleStats.ts +100 -0
  125. package/src/runners/TimingRunner.ts +244 -0
  126. package/src/runners/TimingUtils.ts +3 -2
  127. package/src/runners/WorkerScript.ts +135 -151
  128. package/src/stats/BootstrapDifference.ts +282 -0
  129. package/src/{PermutationTest.ts → stats/PermutationTest.ts} +8 -17
  130. package/src/stats/StatisticalUtils.ts +445 -0
  131. package/src/{tests → test}/AdaptiveConvergence.test.ts +10 -10
  132. package/src/test/AdaptiveRunner.test.ts +39 -41
  133. package/src/{tests → test}/AdaptiveSampling.test.ts +9 -9
  134. package/src/test/AdaptiveStatistics.integration.ts +2 -2
  135. package/src/{tests → test}/BenchMatrix.test.ts +19 -16
  136. package/src/test/BenchmarkReport.test.ts +63 -13
  137. package/src/test/BrowserBench.e2e.test.ts +186 -17
  138. package/src/test/BrowserBench.test.ts +10 -5
  139. package/src/test/BuildTimeSection.test.ts +130 -0
  140. package/src/test/CapSamples.test.ts +82 -0
  141. package/src/test/CoverageExport.test.ts +115 -0
  142. package/src/test/CoverageSampler.test.ts +33 -0
  143. package/src/test/HeapAttribution.test.ts +14 -14
  144. package/src/{tests → test}/MatrixFilter.test.ts +1 -1
  145. package/src/{tests → test}/MatrixReport.test.ts +1 -1
  146. package/src/test/PermutationTest.test.ts +1 -1
  147. package/src/{tests → test}/RealDataValidation.test.ts +6 -6
  148. package/src/test/RunBenchCLI.test.ts +39 -38
  149. package/src/test/RunnerOrchestrator.test.ts +12 -12
  150. package/src/test/StatisticalUtils.test.ts +48 -12
  151. package/src/{table-util/test → test}/TableReport.test.ts +2 -2
  152. package/src/test/TestUtils.ts +12 -7
  153. package/src/test/TimeExport.test.ts +139 -0
  154. package/src/test/TimeSampler.test.ts +37 -0
  155. package/src/test/ViewerLive.e2e.test.ts +159 -0
  156. package/src/test/ViewerStatic.static.e2e.test.ts +137 -0
  157. package/src/{tests → test}/fixtures/baseline/impl.ts +1 -1
  158. package/src/{tests → test}/fixtures/bevy30-samples.ts +3 -1
  159. package/src/test/fixtures/cases/asyncCases.ts +9 -0
  160. package/src/{tests → test}/fixtures/cases/cases.ts +5 -2
  161. package/src/test/fixtures/cases/variants/product.ts +2 -0
  162. package/src/test/fixtures/cases/variants/sum.ts +2 -0
  163. package/src/test/fixtures/discover/fast.ts +1 -0
  164. package/src/{tests → test}/fixtures/discover/slow.ts +1 -1
  165. package/src/test/fixtures/invalid/bad.ts +1 -0
  166. package/src/test/fixtures/loader/fast.ts +1 -0
  167. package/src/{tests → test}/fixtures/loader/slow.ts +1 -1
  168. package/src/test/fixtures/loader/stateful.ts +2 -0
  169. package/src/test/fixtures/stateful/stateful.ts +2 -0
  170. package/src/test/fixtures/variants/extra.ts +1 -0
  171. package/src/test/fixtures/variants/impl.ts +1 -0
  172. package/src/test/fixtures/worker/fast.ts +1 -0
  173. package/src/{tests → test}/fixtures/worker/slow.ts +1 -1
  174. package/src/viewer/DateFormat.ts +30 -0
  175. package/src/viewer/Helpers.ts +23 -0
  176. package/src/viewer/LineData.ts +120 -0
  177. package/src/viewer/Providers.ts +191 -0
  178. package/src/viewer/ReportData.ts +123 -0
  179. package/src/viewer/State.ts +49 -0
  180. package/src/viewer/Theme.ts +15 -0
  181. package/src/viewer/components/App.tsx +73 -0
  182. package/src/viewer/components/DropZone.tsx +71 -0
  183. package/src/viewer/components/LazyPlot.ts +33 -0
  184. package/src/viewer/components/SamplesPanel.tsx +214 -0
  185. package/src/viewer/components/Shell.tsx +26 -0
  186. package/src/viewer/components/SourcePanel.tsx +216 -0
  187. package/src/viewer/components/SummaryPanel.tsx +332 -0
  188. package/src/viewer/components/TabBar.tsx +131 -0
  189. package/src/viewer/components/TabContent.tsx +46 -0
  190. package/src/viewer/components/ThemeToggle.tsx +50 -0
  191. package/src/viewer/index.html +20 -0
  192. package/src/viewer/main.tsx +4 -0
  193. package/src/viewer/plots/CIPlot.ts +313 -0
  194. package/src/{html/browser → viewer/plots}/HistogramKde.ts +33 -38
  195. package/src/viewer/plots/LegendUtils.ts +134 -0
  196. package/src/viewer/plots/PlotTypes.ts +85 -0
  197. package/src/viewer/plots/RenderPlots.ts +230 -0
  198. package/src/viewer/plots/SampleTimeSeries.ts +306 -0
  199. package/src/viewer/plots/SvgHelpers.ts +136 -0
  200. package/src/viewer/plots/TimeSeriesMarks.ts +319 -0
  201. package/src/viewer/report.css +427 -0
  202. package/src/viewer/shell.css +357 -0
  203. package/src/viewer/tsconfig.json +11 -0
  204. package/dist/BrowserHeapSampler-B6asLKWQ.mjs +0 -202
  205. package/dist/BrowserHeapSampler-B6asLKWQ.mjs.map +0 -1
  206. package/dist/GcStats-wX7Xyblu.mjs +0 -77
  207. package/dist/GcStats-wX7Xyblu.mjs.map +0 -1
  208. package/dist/HeapSampler-B8dtKHn1.mjs.map +0 -1
  209. package/dist/TimingUtils-DwOwkc8G.mjs +0 -597
  210. package/dist/TimingUtils-DwOwkc8G.mjs.map +0 -1
  211. package/dist/browser/index.js +0 -914
  212. package/dist/src-B-DDaCa9.mjs +0 -3108
  213. package/dist/src-B-DDaCa9.mjs.map +0 -1
  214. package/src/BenchMatrix.ts +0 -380
  215. package/src/BenchmarkReport.ts +0 -161
  216. package/src/HtmlDataPrep.ts +0 -148
  217. package/src/StandardSections.ts +0 -261
  218. package/src/StatisticalUtils.ts +0 -175
  219. package/src/TypeUtil.ts +0 -8
  220. package/src/browser/BrowserGcStats.ts +0 -44
  221. package/src/browser/BrowserHeapSampler.ts +0 -271
  222. package/src/export/JsonExport.ts +0 -103
  223. package/src/export/JsonFormat.ts +0 -91
  224. package/src/export/SpeedscopeExport.ts +0 -202
  225. package/src/heap-sample/HeapSampleReport.ts +0 -269
  226. package/src/html/HtmlReport.ts +0 -131
  227. package/src/html/HtmlTemplate.ts +0 -284
  228. package/src/html/Types.ts +0 -88
  229. package/src/html/browser/CIPlot.ts +0 -287
  230. package/src/html/browser/LegendUtils.ts +0 -163
  231. package/src/html/browser/RenderPlots.ts +0 -263
  232. package/src/html/browser/SampleTimeSeries.ts +0 -389
  233. package/src/html/browser/Types.ts +0 -96
  234. package/src/html/browser/index.ts +0 -1
  235. package/src/html/index.ts +0 -17
  236. package/src/runners/BasicRunner.ts +0 -364
  237. package/src/table-util/ConvergenceFormatters.ts +0 -19
  238. package/src/table-util/Formatters.ts +0 -157
  239. package/src/table-util/README.md +0 -70
  240. package/src/table-util/TableReport.ts +0 -293
  241. package/src/tests/fixtures/cases/asyncCases.ts +0 -7
  242. package/src/tests/fixtures/cases/variants/product.ts +0 -2
  243. package/src/tests/fixtures/cases/variants/sum.ts +0 -2
  244. package/src/tests/fixtures/discover/fast.ts +0 -1
  245. package/src/tests/fixtures/invalid/bad.ts +0 -1
  246. package/src/tests/fixtures/loader/fast.ts +0 -1
  247. package/src/tests/fixtures/loader/stateful.ts +0 -2
  248. package/src/tests/fixtures/stateful/stateful.ts +0 -2
  249. package/src/tests/fixtures/variants/extra.ts +0 -1
  250. package/src/tests/fixtures/variants/impl.ts +0 -1
  251. package/src/tests/fixtures/worker/fast.ts +0 -1
  252. /package/src/{table-util/test → test}/TableValueExtractor.test.ts +0 -0
  253. /package/src/{table-util/test → test}/TableValueExtractor.ts +0 -0
@@ -0,0 +1,46 @@
1
+ import type { ViewerConfig } from "../Providers.ts";
2
+ import { activeTabId, provider, sourceTabs } from "../State.ts";
3
+ import { SamplesPanel } from "./SamplesPanel.tsx";
4
+ import { SourcePanel } from "./SourcePanel.tsx";
5
+ import { SummaryPanel } from "./SummaryPanel.tsx";
6
+
7
+ /** Renders all tab panels, showing only the active one via CSS class toggling. */
8
+ export function TabContent() {
9
+ const dataProvider = provider.value!;
10
+ const { config } = dataProvider;
11
+ const tabId = activeTabId.value;
12
+ const panelClass = (id: string) => `report-panel${tabId === id ? " active" : ""}`;
13
+
14
+ return (
15
+ <div class="tab-content">
16
+ <div id="summary-panel" class={panelClass("summary")}>
17
+ {config.hasReport && <SummaryPanel />}
18
+ </div>
19
+ <div id="samples-panel" class={panelClass("samples")}>
20
+ <SamplesPanel />
21
+ </div>
22
+ <iframe
23
+ id="speedscope-iframe"
24
+ src={iframeSrc(dataProvider.profileUrl("alloc"), config)}
25
+ style={{ display: tabId === "flamechart" ? "block" : "none" }}
26
+ />
27
+ <iframe
28
+ id="time-speedscope-iframe"
29
+ src={iframeSrc(dataProvider.profileUrl("time"), config)}
30
+ style={{ display: tabId === "time-flamechart" ? "block" : "none" }}
31
+ />
32
+ {sourceTabs.value.map(st => (
33
+ <SourcePanel key={st.id} tab={st} />
34
+ ))}
35
+ </div>
36
+ );
37
+ }
38
+
39
+ /** Build a Speedscope iframe hash-URL with optional editor URI. */
40
+ function iframeSrc(url: string | null, config: ViewerConfig): string {
41
+ if (!url) return "";
42
+ const parts = ["profileURL=" + encodeURIComponent(url)];
43
+ if (config.editorUri)
44
+ parts.push("editorUri=" + encodeURIComponent(config.editorUri));
45
+ return "speedscope/#" + parts.join("&");
46
+ }
@@ -0,0 +1,50 @@
1
+ import { themePreference } from "../State.ts";
2
+ import { setTheme } from "../Theme.ts";
3
+
4
+ /** Light/dark mode buttons. Clicking the active mode reverts to system default. */
5
+ export function ThemeToggle(): preact.JSX.Element {
6
+ const pref = themePreference.value;
7
+
8
+ return (
9
+ <div class="theme-toggle">
10
+ <button
11
+ class={`theme-btn${pref === "light" ? " active" : ""}`}
12
+ title="Light mode"
13
+ onClick={() => setTheme(pref === "light" ? "system" : "light")}
14
+ >
15
+ <Sun />
16
+ </button>
17
+ <button
18
+ class={`theme-btn${pref === "dark" ? " active" : ""}`}
19
+ title="Dark mode"
20
+ onClick={() => setTheme(pref === "dark" ? "system" : "dark")}
21
+ >
22
+ <Moon />
23
+ </button>
24
+ </div>
25
+ );
26
+ }
27
+
28
+ function Sun() {
29
+ return (
30
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
31
+ <circle cx="12" cy="12" r="5" />
32
+ <line x1="12" y1="1" x2="12" y2="3" />
33
+ <line x1="12" y1="21" x2="12" y2="23" />
34
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
35
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
36
+ <line x1="1" y1="12" x2="3" y2="12" />
37
+ <line x1="21" y1="12" x2="23" y2="12" />
38
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
39
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
40
+ </svg>
41
+ );
42
+ }
43
+
44
+ function Moon() {
45
+ return (
46
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
47
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
48
+ </svg>
49
+ );
50
+ }
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Benchforge Viewer</title>
7
+ <script>
8
+ var m = document.cookie.match(/(?:^|; )theme=(light|dark)/);
9
+ if (m) document.documentElement.dataset.theme = m[1];
10
+ </script>
11
+ <link rel="stylesheet" href="./shell.css">
12
+ <link rel="stylesheet" href="./report.css">
13
+ </head>
14
+
15
+ <body>
16
+ <div id="app"></div>
17
+
18
+ <script type="module" src="./main.tsx"></script>
19
+ </body>
20
+ </html>
@@ -0,0 +1,4 @@
1
+ import { render } from "preact";
2
+ import { App } from "./components/App.tsx";
3
+
4
+ render(<App />, document.getElementById("app")!);
@@ -0,0 +1,313 @@
1
+ import type {
2
+ CILevel,
3
+ DifferenceCI,
4
+ HistogramBin,
5
+ } from "../../stats/StatisticalUtils.ts";
6
+ import { formatPct } from "./PlotTypes.ts";
7
+ import {
8
+ createSvg,
9
+ ensureHatchPattern,
10
+ ensureSketchFilter,
11
+ line,
12
+ path,
13
+ rect,
14
+ text,
15
+ } from "./SvgHelpers.ts";
16
+
17
+ export interface DistributionPlotOptions {
18
+ width?: number;
19
+ height?: number;
20
+ title?: string;
21
+ smooth?: boolean;
22
+ direction?: "faster" | "slower" | "uncertain" | "equivalent";
23
+ /** Pre-formatted CI bound labels (overrides default formatPct) */
24
+ ciLabels?: [string, string];
25
+ /** Include zero in x scale (default true, set false for absolute-value plots) */
26
+ includeZero?: boolean;
27
+ /** Centered label above chart (e.g., the formatted point estimate) */
28
+ pointLabel?: string;
29
+ /** Equivalence margin in percent (draws shaded band at +/- margin) */
30
+ equivMargin?: number;
31
+ /** Block-level or sample-level CI */
32
+ ciLevel?: CILevel;
33
+ /** false ==> dashed border (insufficient batches for reliable CI) */
34
+ ciReliable?: boolean;
35
+ }
36
+
37
+ type Scales = { x: (v: number) => number; y: (v: number) => number };
38
+ type Layout = {
39
+ width: number;
40
+ height: number;
41
+ margin: { top: number; right: number; bottom: number; left: number };
42
+ plot: { w: number; h: number };
43
+ };
44
+ const defaultMargin = { top: 22, right: 12, bottom: 22, left: 12 };
45
+
46
+ const defaultOpts = {
47
+ width: 260,
48
+ height: 85,
49
+ title: "Δ%",
50
+ smooth: true,
51
+ direction: "uncertain" as const,
52
+ includeZero: true,
53
+ };
54
+
55
+ const colors = {
56
+ faster: { fill: "#bbf7d0", stroke: "#22c55e" },
57
+ slower: { fill: "#fee2e2", stroke: "#ef4444" },
58
+ uncertain: { fill: "#dbeafe", stroke: "#3b82f6" },
59
+ equivalent: { fill: "#dcfce7", stroke: "#86efac" },
60
+ };
61
+
62
+ /** Create a small distribution plot showing histogram with CI shading */
63
+ export function createDistributionPlot(
64
+ histogram: HistogramBin[],
65
+ ci: [number, number],
66
+ pointEstimate: number,
67
+ options: DistributionPlotOptions = {},
68
+ ): SVGSVGElement {
69
+ const opts = { ...defaultOpts, ...options };
70
+ const layout = buildLayout(opts.width, opts.height, !!opts.pointLabel);
71
+ const svg = createSvg(layout.width, layout.height);
72
+ if (!histogram?.length) return svg;
73
+
74
+ const { fill, stroke } = colors[opts.direction];
75
+ const { includeZero, equivMargin } = opts;
76
+ const scales = buildScales(
77
+ histogram,
78
+ ci,
79
+ layout,
80
+ includeZero,
81
+ equivMargin,
82
+ pointEstimate,
83
+ );
84
+ const { margin, plot } = layout;
85
+ const ptX = scales.x(pointEstimate);
86
+
87
+ drawTitles(svg, opts, margin, ptX);
88
+
89
+ if (equivMargin && includeZero)
90
+ drawMarginZone(svg, equivMargin, scales, layout);
91
+
92
+ const ciX = scales.x(ci[0]);
93
+ const ciRect = rect(ciX, margin.top, scales.x(ci[1]) - ciX, plot.h, { fill });
94
+ const strength = includeZero ? "ci-region-strong" : "ci-region";
95
+ ciRect.classList.add(strength, `ci-${opts.direction}`);
96
+ if (opts.ciReliable === false) {
97
+ ciRect.classList.add("ci-unreliable");
98
+ ciRect.setAttribute("filter", `url(#${ensureSketchFilter(svg)})`);
99
+ }
100
+ svg.appendChild(ciRect);
101
+
102
+ if (opts.smooth) drawSmoothedDist(svg, histogram, scales, stroke);
103
+ else drawHistogramBars(svg, histogram, scales, layout, stroke);
104
+
105
+ drawReferenceLine(svg, scales, layout, includeZero);
106
+ svg.appendChild(
107
+ line(ptX, margin.top, ptX, margin.top + plot.h, {
108
+ stroke,
109
+ strokeWidth: "2",
110
+ }),
111
+ );
112
+
113
+ drawCILabels(svg, ci, scales, layout, opts);
114
+ return svg;
115
+ }
116
+
117
+ /** Convenience wrapper for DifferenceCI data */
118
+ export function createCIPlot(
119
+ ci: DifferenceCI,
120
+ options: Partial<DistributionPlotOptions> = {},
121
+ ): SVGSVGElement {
122
+ if (!ci.histogram) return createSvg(0, 0);
123
+ return createDistributionPlot(ci.histogram, ci.ci, ci.percent, {
124
+ title: ci.label,
125
+ direction: ci.direction,
126
+ ciLevel: ci.ciLevel,
127
+ ciReliable: ci.ciReliable,
128
+ ...options,
129
+ });
130
+ }
131
+
132
+ /** Use minimal margins when the chart is too small for standard spacing. */
133
+ function buildLayout(
134
+ width: number,
135
+ height: number,
136
+ hasPointLabel?: boolean,
137
+ ): Layout {
138
+ const compact = height < defaultMargin.top + defaultMargin.bottom + 10;
139
+ const margin = compact
140
+ ? { top: 4, right: 6, bottom: 4, left: 6 }
141
+ : { ...defaultMargin, top: hasPointLabel ? 30 : defaultMargin.top };
142
+ const plot = {
143
+ w: width - margin.left - margin.right,
144
+ h: height - margin.top - margin.bottom,
145
+ };
146
+ return { width, height, margin, plot };
147
+ }
148
+
149
+ function buildScales(
150
+ histogram: HistogramBin[],
151
+ ci: [number, number],
152
+ layout: Layout,
153
+ includeZero: boolean,
154
+ equivMargin?: number,
155
+ pointEstimate?: number,
156
+ ): Scales {
157
+ const { margin, plot } = layout;
158
+ const xs = histogram.map(b => b.x);
159
+ const extra = includeZero ? [0] : [];
160
+ const marginBounds = equivMargin ? [-equivMargin, equivMargin] : [];
161
+ const ptBounds = pointEstimate != null ? [pointEstimate] : [];
162
+ const xMin = Math.min(...xs, ci[0], ...extra, ...marginBounds, ...ptBounds);
163
+ const xMax = Math.max(...xs, ci[1], ...extra, ...marginBounds, ...ptBounds);
164
+ const yMax = Math.max(...histogram.map(b => b.count));
165
+ const xRange = xMax - xMin || 1;
166
+ return {
167
+ x: (v: number) => margin.left + ((v - xMin) / xRange) * plot.w,
168
+ y: (v: number) => margin.top + plot.h - (v / yMax) * plot.h,
169
+ };
170
+ }
171
+
172
+ function drawTitles(
173
+ svg: SVGSVGElement,
174
+ opts: DistributionPlotOptions,
175
+ margin: Layout["margin"],
176
+ pointX: number,
177
+ ): void {
178
+ if (opts.title)
179
+ svg.appendChild(
180
+ text(margin.left, 14, opts.title, "start", "13", "currentColor", "600"),
181
+ );
182
+ if (opts.pointLabel) {
183
+ const el = text(
184
+ pointX,
185
+ margin.top - 6,
186
+ opts.pointLabel,
187
+ "middle",
188
+ "15",
189
+ "currentColor",
190
+ "700",
191
+ );
192
+ svg.appendChild(el);
193
+ }
194
+ }
195
+
196
+ /** Draw equivalence margin zone: hatched band centered vertically */
197
+ function drawMarginZone(
198
+ svg: SVGSVGElement,
199
+ equivMargin: number,
200
+ scales: Scales,
201
+ layout: Layout,
202
+ ): void {
203
+ const { margin, plot } = layout;
204
+ const x1 = scales.x(-equivMargin);
205
+ const x2 = scales.x(equivMargin);
206
+ const fill = `url(#${ensureHatchPattern(svg)})`;
207
+ const bandH = plot.h / 3;
208
+ const bandY = margin.top + (plot.h - bandH) / 2;
209
+ const zone = rect(x1, bandY, x2 - x1, bandH, { fill, strokeWidth: "1.5" });
210
+ zone.classList.add("margin-zone");
211
+ svg.appendChild(zone);
212
+ }
213
+
214
+ /** Draw a filled area + stroke path using gaussian-smoothed histogram data */
215
+ function drawSmoothedDist(
216
+ svg: SVGSVGElement,
217
+ histogram: HistogramBin[],
218
+ scales: Scales,
219
+ stroke: string,
220
+ ): void {
221
+ const sorted = [...histogram].sort((a, b) => a.x - b.x);
222
+ const smoothed = gaussianSmooth(sorted, 2);
223
+ const pts = smoothed.map(b => `${scales.x(b.x)},${scales.y(b.count)}`);
224
+ const base = scales.y(0);
225
+ const startX = scales.x(smoothed[0].x);
226
+ const endX = scales.x(smoothed.at(-1)!.x);
227
+ const fillD = `M${startX},${base}L${pts.join("L")}L${endX},${base}Z`;
228
+ const fillPath = path(fillD, { fill: stroke });
229
+ fillPath.classList.add("dist-fill");
230
+ svg.appendChild(fillPath);
231
+ const strokePath = path(`M${pts.join("L")}`, {
232
+ stroke,
233
+ fill: "none",
234
+ strokeWidth: "1.5",
235
+ });
236
+ strokePath.classList.add("dist-stroke");
237
+ svg.appendChild(strokePath);
238
+ }
239
+
240
+ function drawHistogramBars(
241
+ svg: SVGSVGElement,
242
+ histogram: HistogramBin[],
243
+ scales: Scales,
244
+ layout: Layout,
245
+ stroke: string,
246
+ ): void {
247
+ const sorted = [...histogram].sort((a, b) => a.x - b.x);
248
+ const binW = sorted.length > 1 ? sorted[1].x - sorted[0].x : 1;
249
+ const xRange = scales.x(sorted.at(-1)!.x) - scales.x(sorted[0].x) + binW;
250
+ const barW = (binW / xRange) * layout.plot.w * 0.9;
251
+ const base = scales.y(0);
252
+ const attrs = { fill: stroke, opacity: "0.6" };
253
+ for (const bin of sorted) {
254
+ const top = scales.y(bin.count);
255
+ svg.appendChild(
256
+ rect(scales.x(bin.x) - barW / 2, top, barW, base - top, attrs),
257
+ );
258
+ }
259
+ }
260
+
261
+ /** Draw zero reference line extending past plot area (comparison CIs only) */
262
+ function drawReferenceLine(
263
+ svg: SVGSVGElement,
264
+ scales: Scales,
265
+ layout: Layout,
266
+ includeZero: boolean,
267
+ ): void {
268
+ const { margin, plot } = layout;
269
+ const zeroX = scales.x(0);
270
+ const inBounds = zeroX >= margin.left && zeroX <= layout.width - margin.right;
271
+ if (!includeZero || !inBounds) return;
272
+
273
+ svg.appendChild(
274
+ line(zeroX, margin.top - 4, zeroX, margin.top + plot.h + 4, {
275
+ stroke: "#000",
276
+ strokeWidth: "1",
277
+ }),
278
+ );
279
+ }
280
+
281
+ function drawCILabels(
282
+ svg: SVGSVGElement,
283
+ ci: [number, number],
284
+ scales: Scales,
285
+ layout: Layout,
286
+ opts: DistributionPlotOptions & { includeZero: boolean },
287
+ ): void {
288
+ if (layout.margin.bottom < 15) return;
289
+ const labelY = layout.height - 4;
290
+ const loLabel = opts.ciLabels?.[0] ?? formatPct(ci[0], 0);
291
+ const hiLabel = opts.ciLabels?.[1] ?? formatPct(ci[1], 0);
292
+ const loX = scales.x(ci[0]);
293
+ const hiX = scales.x(ci[1]);
294
+ const minGap = Math.max(loLabel.length, hiLabel.length) * 6;
295
+ if (!opts.includeZero || hiX - loX >= minGap) {
296
+ svg.appendChild(text(loX, labelY, loLabel, "middle", "11"));
297
+ svg.appendChild(text(hiX, labelY, hiLabel, "middle", "11"));
298
+ }
299
+ }
300
+
301
+ /** Apply gaussian kernel smoothing to histogram bins */
302
+ function gaussianSmooth(bins: HistogramBin[], sigma: number): HistogramBin[] {
303
+ return bins.map((bin, i) => {
304
+ let sum = 0;
305
+ let wt = 0;
306
+ for (let j = 0; j < bins.length; j++) {
307
+ const w = Math.exp(-((i - j) ** 2) / (2 * sigma ** 2));
308
+ sum += bins[j].count * w;
309
+ wt += w;
310
+ }
311
+ return { x: bin.x, count: sum / wt };
312
+ });
313
+ }
@@ -1,34 +1,40 @@
1
1
  import * as Plot from "@observablehq/plot";
2
2
  import * as d3 from "d3";
3
3
  import { buildLegend, type LegendItem } from "./LegendUtils.ts";
4
- import type { Sample } from "./Types.ts";
4
+ import { getTimeUnit, plotLayout, type Sample } from "./PlotTypes.ts";
5
+
6
+ interface Bar {
7
+ benchmark: string;
8
+ count: number;
9
+ x1: number;
10
+ x2: number;
11
+ }
5
12
 
6
13
  /** Create histogram + KDE plot for sample distribution */
7
14
  export function createHistogramKde(
8
15
  allSamples: Sample[],
9
16
  benchmarkNames: string[],
10
17
  ): SVGSVGElement | HTMLElement {
18
+ const values = allSamples.map(d => d.value);
19
+ const { unitSuffix, convertValue, formatValue } = getTimeUnit(values);
20
+ const converted = allSamples.map(d => ({
21
+ ...d,
22
+ value: convertValue(d.value),
23
+ }));
11
24
  const { barData, binMin, binMax, yMax } = buildBarData(
12
- allSamples,
25
+ converted,
13
26
  benchmarkNames,
14
27
  );
15
28
  const { colorMap, legendItems } = buildColorData(benchmarkNames);
16
- const xMax = binMax + (binMax - binMin) * 0.45; // extend for legend
17
29
 
18
30
  return Plot.plot({
19
- marginTop: 24,
20
- marginLeft: 70,
21
- marginRight: 10,
22
- marginBottom: 60,
23
- width: 550,
24
- height: 300,
25
- style: { fontSize: "14px" },
31
+ ...plotLayout,
26
32
  x: {
27
- label: "Time (ms)",
33
+ label: `Time (${unitSuffix})`,
28
34
  labelAnchor: "center",
29
- domain: [binMin, xMax],
35
+ domain: [binMin, binMax],
30
36
  labelOffset: 45,
31
- tickFormat: (d: number) => d.toFixed(1),
37
+ tickFormat: formatValue,
32
38
  ticks: 5,
33
39
  },
34
40
  y: {
@@ -43,45 +49,34 @@ export function createHistogramKde(
43
49
  x1: "x1",
44
50
  x2: "x2",
45
51
  y: "count",
46
- fill: (d: (typeof barData)[0]) => colorMap.get(d.benchmark),
52
+ fill: (d: Bar) => colorMap.get(d.benchmark),
47
53
  fillOpacity: 0.6,
48
- tip: true,
49
- title: (d: (typeof barData)[0]) => `${d.benchmark}: ${d.count}`,
50
54
  }),
51
55
  Plot.ruleY([0]),
52
- ...buildLegend({ xMin: binMin, xMax, yMax }, legendItems),
56
+ ...buildLegend({ xMin: binMin, xMax: binMax, yMax }, legendItems),
53
57
  ],
54
58
  });
55
59
  }
56
60
 
57
61
  /** Bin samples into grouped histogram bars for each benchmark */
58
62
  function buildBarData(allSamples: Sample[], benchmarkNames: string[]) {
59
- const values = allSamples.map(d => d.value);
60
- const sorted = values.sort((a, b) => a - b);
61
- const binMin = d3.quantile(sorted, 0.01)!;
62
- const binMax = d3.quantile(sorted, 0.99)!;
63
+ const sortedValues = allSamples.map(d => d.value).sort((a, b) => a - b);
64
+ const binMin = d3.quantile(sortedValues, 0.01)!;
65
+ const binMax = d3.quantile(sortedValues, 0.99)!;
63
66
  const binCount = 25;
64
67
  const step = (binMax - binMin) / binCount;
65
68
  const thresholds = d3.range(1, binCount).map(i => binMin + i * step);
66
- const plotWidth = 550;
67
-
68
69
  const bins = d3
69
70
  .bin<Sample, number>()
70
71
  .domain([binMin, binMax])
71
72
  .thresholds(thresholds)
72
73
  .value(d => d.value)(allSamples);
73
74
 
74
- const barData: {
75
- benchmark: string;
76
- count: number;
77
- x1: number;
78
- x2: number;
79
- }[] = [];
80
75
  const n = benchmarkNames.length;
81
- const unitsPerPx = (binMax - binMin) / plotWidth;
76
+ const unitsPerPx = (binMax - binMin) / plotLayout.width;
82
77
  const groupGapPx = 8;
83
78
 
84
- for (const bin of bins) {
79
+ const barData: Bar[] = bins.flatMap(bin => {
85
80
  const counts = new Map<string, number>();
86
81
  for (const d of bin)
87
82
  counts.set(d.benchmark, (counts.get(d.benchmark) || 0) + 1);
@@ -91,12 +86,12 @@ function buildBarData(allSamples: Sample[], benchmarkNames: string[]) {
91
86
  const start = bin.x0! + groupGap / 2;
92
87
  const w = (full - groupGap) / n;
93
88
 
94
- benchmarkNames.forEach((benchmark, i) => {
89
+ return benchmarkNames.map((benchmark, i) => {
95
90
  const x1 = start + i * w;
96
91
  const x2 = start + (i + 1) * w;
97
- barData.push({ benchmark, count: counts.get(benchmark) || 0, x1, x2 });
92
+ return { benchmark, count: counts.get(benchmark) || 0, x1, x2 };
98
93
  });
99
- }
94
+ });
100
95
 
101
96
  const maxCount = d3.max(barData, d => d.count)! || 1;
102
97
  const yMax = maxCount * 1.15;
@@ -104,13 +99,13 @@ function buildBarData(allSamples: Sample[], benchmarkNames: string[]) {
104
99
  return { barData, binMin, binMax, yMax };
105
100
  }
106
101
 
102
+ /** Map benchmark names to colors and legend items using Observable 10 palette */
107
103
  function buildColorData(benchmarkNames: string[]) {
108
104
  const scheme = (d3 as any).schemeObservable10;
109
- const colorMap = new Map(
110
- benchmarkNames.map((name, i) => [name, scheme[i % 10]]),
111
- );
105
+ const color = (i: number) => scheme[i % 10];
106
+ const colorMap = new Map(benchmarkNames.map((name, i) => [name, color(i)]));
112
107
  const legendItems: LegendItem[] = benchmarkNames.map((name, i) => ({
113
- color: scheme[i % 10],
108
+ color: color(i),
114
109
  label: name,
115
110
  style: "vertical-bar",
116
111
  }));