@mui/internal-benchmark 0.0.3-canary.16 → 0.0.3-canary.17

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.
package/Metric.d.mts CHANGED
@@ -1,5 +1,24 @@
1
+ import type { RunnerTestCase } from 'vitest';
1
2
  import type { MetricConfig, MetricKind } from "./types.mjs";
2
3
  import "./taskMetaAugmentation.mjs";
4
+ /** One alarmed scalar (sub-)series the adaptive stopping rule tracks, named for reporting. */
5
+ export interface AdaptiveMetricSamples {
6
+ /** Metric name, with a `#sub-series` suffix for named sub-series. */
7
+ name: string;
8
+ samples: number[];
9
+ }
10
+ /**
11
+ * Snapshot of the raw samples collected so far for a test's alarmed scalar metrics — one entry per
12
+ * (metric, sub-series), each named. The adaptive stopping rule reads this so measurement keeps
13
+ * sampling while any metric that will be significance-tested downstream is still imprecise, not just
14
+ * the render duration, and so a non-convergence warning can name the specific metric that stayed
15
+ * noisy.
16
+ *
17
+ * Discrete metrics (compared as exact counts) and informational scalar metrics (shown but never
18
+ * flagged) are excluded: their precision never changes a verdict, and gating on a noisy one would
19
+ * needlessly block convergence.
20
+ */
21
+ export declare function collectAdaptiveMetricSamples(test: RunnerTestCase): AdaptiveMetricSamples[];
3
22
  export interface MetricRecordOptions {
4
23
  /** Sub-series label. Recorded under `${name}#${id}` in the report; omit for the base series. */
5
24
  id?: string;
package/Metric.mjs CHANGED
@@ -12,10 +12,9 @@ function flush(test, accumulator) {
12
12
  for (const [name, entry] of accumulator) {
13
13
  const series = {};
14
14
  for (const [seriesId, samples] of entry.series) {
15
- series[seriesId] = {
16
- ...aggregateSamples(samples),
17
- count: samples.length
18
- };
15
+ // `aggregateSamples` reports the effective (post-outlier-removal) count as `count`, which is
16
+ // the `n` behind mean/stdDev — exactly what a downstream Welch's t-test needs.
17
+ series[seriesId] = aggregateSamples(samples);
19
18
  }
20
19
  store[name] = {
21
20
  kind: entry.kind,
@@ -25,6 +24,39 @@ function flush(test, accumulator) {
25
24
  }
26
25
  test.meta.benchmarkMetrics = store;
27
26
  }
27
+
28
+ /** One alarmed scalar (sub-)series the adaptive stopping rule tracks, named for reporting. */
29
+
30
+ /**
31
+ * Snapshot of the raw samples collected so far for a test's alarmed scalar metrics — one entry per
32
+ * (metric, sub-series), each named. The adaptive stopping rule reads this so measurement keeps
33
+ * sampling while any metric that will be significance-tested downstream is still imprecise, not just
34
+ * the render duration, and so a non-convergence warning can name the specific metric that stayed
35
+ * noisy.
36
+ *
37
+ * Discrete metrics (compared as exact counts) and informational scalar metrics (shown but never
38
+ * flagged) are excluded: their precision never changes a verdict, and gating on a noisy one would
39
+ * needlessly block convergence.
40
+ */
41
+ export function collectAdaptiveMetricSamples(test) {
42
+ const accumulator = accumulators.get(test);
43
+ if (!accumulator) {
44
+ return [];
45
+ }
46
+ const sampleSets = [];
47
+ for (const [metricName, entry] of accumulator) {
48
+ if (entry.kind !== 'scalar' || entry.config.alarm === undefined) {
49
+ continue;
50
+ }
51
+ for (const [seriesId, samples] of entry.series) {
52
+ sampleSets.push({
53
+ name: seriesId === '' ? metricName : `${metricName}#${seriesId}`,
54
+ samples
55
+ });
56
+ }
57
+ }
58
+ return sampleSets;
59
+ }
28
60
  /**
29
61
  * Base class for custom benchmark metrics. Use `ScalarMetric` or `DiscreteMetric`.
30
62
  *
package/ciReport.d.mts CHANGED
@@ -10,15 +10,19 @@ declare const renderStatsSchema: z.ZodObject<{
10
10
  actualDuration: z.ZodNumber;
11
11
  stdDev: z.ZodNumber;
12
12
  outliers: z.ZodNumber;
13
+ count: z.ZodOptional<z.ZodNumber>;
13
14
  }, z.core.$strip>;
14
15
  declare const metricStatsSchema: z.ZodObject<{
15
16
  mean: z.ZodNumber;
16
17
  stdDev: z.ZodNumber;
17
18
  outliers: z.ZodNumber;
19
+ count: z.ZodOptional<z.ZodNumber>;
18
20
  }, z.core.$strip>;
19
21
  declare const benchmarkReportEntrySchema: z.ZodObject<{
20
22
  iterations: z.ZodNumber;
21
23
  totalDuration: z.ZodNumber;
24
+ totalStdDev: z.ZodOptional<z.ZodNumber>;
25
+ totalCount: z.ZodOptional<z.ZodNumber>;
22
26
  renders: z.ZodArray<z.ZodObject<{
23
27
  id: z.ZodString;
24
28
  phase: z.ZodEnum<{
@@ -30,16 +34,20 @@ declare const benchmarkReportEntrySchema: z.ZodObject<{
30
34
  actualDuration: z.ZodNumber;
31
35
  stdDev: z.ZodNumber;
32
36
  outliers: z.ZodNumber;
37
+ count: z.ZodOptional<z.ZodNumber>;
33
38
  }, z.core.$strip>>;
34
39
  metrics: z.ZodRecord<z.ZodString, z.ZodObject<{
35
40
  mean: z.ZodNumber;
36
41
  stdDev: z.ZodNumber;
37
42
  outliers: z.ZodNumber;
43
+ count: z.ZodOptional<z.ZodNumber>;
38
44
  }, z.core.$strip>>;
39
45
  }, z.core.$strip>;
40
46
  declare const benchmarkReportSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
41
47
  iterations: z.ZodNumber;
42
48
  totalDuration: z.ZodNumber;
49
+ totalStdDev: z.ZodOptional<z.ZodNumber>;
50
+ totalCount: z.ZodOptional<z.ZodNumber>;
43
51
  renders: z.ZodArray<z.ZodObject<{
44
52
  id: z.ZodString;
45
53
  phase: z.ZodEnum<{
@@ -51,11 +59,13 @@ declare const benchmarkReportSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
51
59
  actualDuration: z.ZodNumber;
52
60
  stdDev: z.ZodNumber;
53
61
  outliers: z.ZodNumber;
62
+ count: z.ZodOptional<z.ZodNumber>;
54
63
  }, z.core.$strip>>;
55
64
  metrics: z.ZodRecord<z.ZodString, z.ZodObject<{
56
65
  mean: z.ZodNumber;
57
66
  stdDev: z.ZodNumber;
58
67
  outliers: z.ZodNumber;
68
+ count: z.ZodOptional<z.ZodNumber>;
59
69
  }, z.core.$strip>>;
60
70
  }, z.core.$strip>>;
61
71
  declare const metricDefinitionSchema: z.ZodObject<{
package/ciReport.mjs CHANGED
@@ -31,16 +31,26 @@ const renderStatsSchema = z.object({
31
31
  startTime: z.number(),
32
32
  actualDuration: z.number(),
33
33
  stdDev: z.number(),
34
- outliers: z.number()
34
+ outliers: z.number(),
35
+ // Effective sample count behind mean/stdDev, used as `n` for the comparison's Welch's t-test.
36
+ // Optional so baselines uploaded before it was added still parse.
37
+ count: z.number().optional()
35
38
  });
36
39
  const metricStatsSchema = z.object({
37
40
  mean: z.number(),
38
41
  stdDev: z.number(),
39
- outliers: z.number()
42
+ outliers: z.number(),
43
+ // See `renderStatsSchema.count`. Optional for backward compatibility with older uploads.
44
+ count: z.number().optional()
40
45
  });
41
46
  const benchmarkReportEntrySchema = z.object({
42
47
  iterations: z.number(),
43
48
  totalDuration: z.number(),
49
+ // Spread and effective sample count of the per-iteration total duration, used as the stdDev/`n`
50
+ // for the comparison's Welch test on total duration. Optional for backward compatibility with
51
+ // uploads made before they were added.
52
+ totalStdDev: z.number().optional(),
53
+ totalCount: z.number().optional(),
44
54
  renders: z.array(renderStatsSchema),
45
55
  metrics: z.record(z.string(), metricStatsSchema)
46
56
  });
package/format.d.mts CHANGED
@@ -4,9 +4,15 @@ export declare const green: (s: string) => string;
4
4
  export declare const yellow: (s: string) => string;
5
5
  export declare const cyan: (s: string) => string;
6
6
  export declare function fileUrl(filePath: string): string;
7
- interface Column {
7
+ export interface Column {
8
8
  header: string;
9
- width: number;
9
+ /** Width the column keeps when every cell is narrower. It grows to fit a wider cell. */
10
+ minWidth: number;
11
+ /**
12
+ * Width the column may not exceed; wider cells are truncated with an ellipsis. Omit to let the
13
+ * column grow freely, which is what a value the caller can't pre-measure (a metric formatted with
14
+ * an arbitrary unit) needs — truncating those would destroy the number the table exists to show.
15
+ */
16
+ maxWidth?: number;
10
17
  }
11
- export declare function printTable(columns: Column[], rows: string[][], footer?: string, title?: string): void;
12
- export {};
18
+ export declare function printTable(columns: Column[], rows: string[][], footer?: string, title?: string): void;
package/format.mjs CHANGED
@@ -8,19 +8,72 @@ export const cyan = s => styleText('cyan', s);
8
8
  export function fileUrl(filePath) {
9
9
  return pathToFileURL(filePath).href;
10
10
  }
11
- function truncate(str, maxLength) {
12
- if (str.length <= maxLength) {
11
+
12
+ /** Matches an ANSI colour code. Captured, so `split` keeps the codes alongside the text. */
13
+ // eslint-disable-next-line no-control-regex
14
+ const ANSI_PATTERN = /(\x1b\[[0-9;]*m)/g;
15
+ function stripAnsi(str) {
16
+ return str.replace(ANSI_PATTERN, '');
17
+ }
18
+ /** Printable width, i.e. ignoring the zero-width ANSI escape codes that colour a string. */
19
+ function visibleWidth(str) {
20
+ return stripAnsi(str).length;
21
+ }
22
+
23
+ /**
24
+ * Truncates to a visible width, marking the cut with an ellipsis. Escape codes are carried over
25
+ * rather than counted, so a styled string keeps the closing code that would otherwise be dropped
26
+ * along with the text it terminated (leaving the rest of the terminal painted).
27
+ */
28
+ function truncate(str, maxWidth) {
29
+ if (visibleWidth(str) <= maxWidth) {
13
30
  return str;
14
31
  }
15
- return `${str.slice(0, maxLength - 1)}…`;
32
+ let result = '';
33
+ let remaining = maxWidth - 1;
34
+ for (const segment of str.split(ANSI_PATTERN)) {
35
+ if (segment.startsWith('\x1b')) {
36
+ result += segment;
37
+ } else if (remaining > 0) {
38
+ result += segment.slice(0, remaining);
39
+ remaining -= Math.min(segment.length, remaining);
40
+ }
41
+ }
42
+ return `${result}…`;
43
+ }
44
+
45
+ /** Right-aligns to a visible width, so colour codes in the cell don't eat into the padding. */
46
+ function padCell(cell, width) {
47
+ return ' '.repeat(Math.max(0, width - visibleWidth(cell))) + cell;
48
+ }
49
+
50
+ /** Fits a string to an exact visible width, truncating or padding as needed. */
51
+ function fitCell(cell, width) {
52
+ return padCell(truncate(cell, width), width);
16
53
  }
17
54
  export function printTable(columns, rows, footer, title) {
18
- const colWidths = columns.map(col => col.width);
19
- const totalInner = colWidths.reduce((sum, w) => sum + w + 2, 0) + colWidths.length - 1;
55
+ // Cells are sized off content, because a caller can't know how wide a value will render (a metric
56
+ // carrying a unit — `0.456 ms±0.057 ms` is far wider than a bare `0.46±0.06`). Padding a cell
57
+ // that already overflows its column is a no-op, which used to let one long value push that row's
58
+ // dividers out of line with every other row.
59
+ const colWidths = columns.map((col, index) => {
60
+ const content = Math.max(col.minWidth, visibleWidth(col.header), ...rows.map(row => visibleWidth(row[index] ?? '')));
61
+ return col.maxWidth === undefined ? content : Math.min(content, col.maxWidth);
62
+ });
63
+ const innerWidth = () => colWidths.reduce((sum, w) => sum + w + 2, 0) + colWidths.length - 1;
64
+ // The footer spans the whole table, so it too can be the widest thing in it. It reports counts
65
+ // that are worth nothing abbreviated, so the table stretches to fit it — the slack goes on the
66
+ // last column, keeping the columns left of it where the reader expects. A title, by contrast, is
67
+ // a name: arbitrarily long, and legible truncated, so it fits itself to the table below.
68
+ const footerWidth = footer ? visibleWidth(footer) + 2 : 0;
69
+ if (colWidths.length > 0 && footerWidth > innerWidth()) {
70
+ colWidths[colWidths.length - 1] += footerWidth - innerWidth();
71
+ }
72
+ const totalInner = innerWidth();
20
73
  if (title) {
21
74
  const titleTop = dim(`┌${'─'.repeat(totalInner)}┐`);
22
75
  const titleContent = ` ${truncate(title, totalInner - 2)}`;
23
- const titlePadding = totalInner - titleContent.length;
76
+ const titlePadding = totalInner - visibleWidth(titleContent);
24
77
  const titleLine = dim('│') + titleContent + ' '.repeat(Math.max(0, titlePadding)) + dim('│');
25
78
  const titleSep = dim(`├${colWidths.map(w => '─'.repeat(w + 2)).join('┬')}┤`);
26
79
 
@@ -36,7 +89,7 @@ export function printTable(columns, rows, footer, title) {
36
89
  console.log(topBorder);
37
90
  }
38
91
  const headerSep = dim(`├${colWidths.map(w => '─'.repeat(w + 2)).join('┼')}┤`);
39
- const headerCells = columns.map(col => ` ${col.header.padStart(col.width)} `);
92
+ const headerCells = columns.map((col, index) => ` ${fitCell(col.header, colWidths[index])} `);
40
93
  const headerLine = dim('│') + headerCells.join(dim('│')) + dim('│');
41
94
 
42
95
  // eslint-disable-next-line no-console
@@ -44,14 +97,15 @@ export function printTable(columns, rows, footer, title) {
44
97
  // eslint-disable-next-line no-console
45
98
  console.log(headerSep);
46
99
  for (const row of rows) {
47
- const cells = row.map((cell, i) => ` ${cell.padStart(colWidths[i])} `);
100
+ // Driven by the columns, not the row, so a short row still emits every cell and divider.
101
+ const cells = colWidths.map((width, index) => ` ${fitCell(row[index] ?? '', width)} `);
48
102
  // eslint-disable-next-line no-console
49
103
  console.log(dim('│') + cells.join(dim('│')) + dim('│'));
50
104
  }
51
105
  if (footer) {
52
106
  const footerSep = dim(`├${colWidths.map(w => '─'.repeat(w + 2)).join('┴')}┤`);
53
107
  const footerContent = ` ${footer}`;
54
- const padding = totalInner - stripAnsi(footerContent).length;
108
+ const padding = totalInner - visibleWidth(footerContent);
55
109
  const footerLine = dim('│') + footerContent + ' '.repeat(Math.max(0, padding)) + dim('│');
56
110
  const bottomBorder = dim(`└${'─'.repeat(totalInner)}┘`);
57
111
 
@@ -66,10 +120,4 @@ export function printTable(columns, rows, footer, title) {
66
120
  // eslint-disable-next-line no-console
67
121
  console.log(bottomBorder);
68
122
  }
69
- }
70
-
71
- // Strip ANSI escape codes to measure visible string length
72
- function stripAnsi(str) {
73
- // eslint-disable-next-line no-control-regex
74
- return str.replace(/\x1b\[[0-9;]*m/g, '');
75
123
  }
package/index.d.mts CHANGED
@@ -14,8 +14,26 @@ declare global {
14
14
  }
15
15
  }
16
16
  interface BenchmarkOptions {
17
+ /**
18
+ * Fixed number of measured iterations. When set, disables adaptive sampling (equivalent to
19
+ * `minRuns === maxRuns === runs`). Prefer leaving this unset and letting the harness sample
20
+ * adaptively; use it only to pin a benchmark to an exact iteration count.
21
+ */
17
22
  runs?: number;
23
+ /** Warmup iterations run before measurement begins (not recorded). Defaults to `5`. */
18
24
  warmupRuns?: number;
25
+ /**
26
+ * Adaptive sampling: the harness keeps measuring until the mean render duration is estimated to
27
+ * within `targetRme`, then stops. `minRuns` is the floor before the stopping rule can trigger,
28
+ * `maxRuns` the ceiling. Ignored when `runs` is set. Defaults: `minRuns` 10, `maxRuns` 50.
29
+ */
30
+ minRuns?: number;
31
+ maxRuns?: number;
32
+ /**
33
+ * Target relative margin of error (half-width of the 95% confidence interval of the mean, as a
34
+ * fraction of the mean) at which adaptive sampling stops. Defaults to `0.02` (2%).
35
+ */
36
+ targetRme?: number;
19
37
  afterEach?: () => Promise<void> | void;
20
38
  /**
21
39
  * Start each iteration with React render/paint recording paused. The interaction callback then
package/index.mjs CHANGED
@@ -12,6 +12,8 @@ import * as ReactDOMClient from 'react-dom/client'; // aliased to react-dom/prof
12
12
  import * as ReactDOM from 'react-dom';
13
13
  import { ElementTiming } from "./ElementTiming.mjs";
14
14
  import { ScalarMetric } from "./ScalarMetric.mjs";
15
+ import { describeUnconvergedSignals, relativeMarginOfError } from "./stats.mjs";
16
+ import { collectAdaptiveMetricSamples } from "./Metric.mjs";
15
17
  import { metricsGate } from "./metricsGate.mjs";
16
18
  import { createReactRecordingControls } from "./reactRecording.mjs";
17
19
  import { runProfileSession } from "./profileSession.mjs";
@@ -199,10 +201,30 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
199
201
  it(name, async ({
200
202
  task
201
203
  }) => {
202
- const runs = options?.runs ?? 20;
203
- const warmupRuns = options?.warmupRuns ?? 10;
204
- const totalRuns = warmupRuns + runs;
204
+ const warmupRuns = options?.warmupRuns ?? 5;
205
+ // A fixed `runs` pins both bounds; otherwise sample adaptively between min and max.
206
+ const isAdaptive = options?.runs === undefined;
207
+ const minRuns = options?.runs ?? options?.minRuns ?? 10;
208
+ const maxRuns = options?.runs ?? options?.maxRuns ?? 50;
209
+ const targetRme = options?.targetRme ?? 0.02;
210
+
211
+ // Fail fast on misconfigured sampling bounds rather than silently running a surprising loop.
212
+ if (minRuns < 1 || maxRuns < minRuns || targetRme <= 0) {
213
+ throw new Error(`Invalid benchmark sampling options for "${name}": require 1 <= minRuns (${minRuns}) <= ` + `maxRuns (${maxRuns}) and targetRme (${targetRme}) > 0.`);
214
+ }
215
+
216
+ // Upper bound on the loop; the adaptive stopping rule usually breaks out earlier.
217
+ const totalRuns = warmupRuns + maxRuns;
205
218
  const iterations = [];
219
+ // Per measured iteration: total render duration, the primary signal the stopping rule converges
220
+ // on (alongside each alarmed scalar metric).
221
+ const iterationDurations = [];
222
+ // Relative margin of error of the render duration at the last stopping check — reused for the
223
+ // non-convergence warning instead of recomputing the same trim/variance pass.
224
+ let durationRme = Infinity;
225
+ // Per-metric relative margin of error from the last stopping check (only populated once render
226
+ // duration has converged), named so the non-convergence warning can point at the noisy metric.
227
+ let metricRmes = [];
206
228
 
207
229
  // Paint timings are recorded as one harness-owned `bench:paint` metric: the default sentinel
208
230
  // is the base series (`bench:paint`) and named `elementtiming` markers are sub-series
@@ -297,11 +319,57 @@ export function benchmark(name, renderFn, interactionOrOptions, maybeOptions) {
297
319
  iterations.push({
298
320
  renders: captures
299
321
  });
322
+ // Total render duration of this iteration — the adaptive stopping rule's convergence signal.
323
+ iterationDurations.push(captures.reduce((sum, capture) => sum + capture.actualDuration, 0));
300
324
  }
301
325
  if (options?.afterEach) {
302
326
  // eslint-disable-next-line no-await-in-loop
303
327
  await options.afterEach();
304
328
  }
329
+
330
+ // Adaptive stopping: once past the floor, stop as soon as every measured signal — the mean
331
+ // render duration and each alarmed scalar metric — is estimated tightly enough. Custom metrics
332
+ // can only *delay* the stop (the `minRuns` floor and duration target still apply), so a noisy
333
+ // metric keeps sampling rather than being left underpowered for the dashboard's significance
334
+ // test. `maxRuns` caps the work for benchmarks too noisy to reach `targetRme`.
335
+ if (!isWarmup && iterationDurations.length >= minRuns) {
336
+ durationRme = relativeMarginOfError(iterationDurations);
337
+ // Duration is the primary (and usually last-to-tighten) signal, so check it first: the
338
+ // per-metric margin-of-error pass only runs once duration itself has converged. A
339
+ // zero-centered metric (mean <= 0) yields a margin of error of 0 — relative precision is
340
+ // undefined for it — so it never blocks; such a metric can't fire a relative alarm anyway.
341
+ if (durationRme <= targetRme) {
342
+ metricRmes = collectAdaptiveMetricSamples(task).map(({
343
+ name: metricName,
344
+ samples
345
+ }) => ({
346
+ name: metricName,
347
+ rme: relativeMarginOfError(samples)
348
+ }));
349
+ if (metricRmes.every(({
350
+ rme
351
+ }) => rme <= targetRme)) {
352
+ break;
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ // Warn only when adaptive sampling exhausted `maxRuns` without every signal reaching `targetRme`,
359
+ // naming each signal that stayed noisy so the author knows what to fix — the common (converged)
360
+ // case stays quiet so large suites don't flood CI logs. Skipped in fixed mode (`runs` set), where
361
+ // there is no convergence target to miss. Reuses the margins of error already computed above.
362
+ if (isAdaptive && iterationDurations.length >= maxRuns) {
363
+ const unconverged = describeUnconvergedSignals([{
364
+ label: 'duration',
365
+ rme: durationRme
366
+ }, ...metricRmes.map(metric => ({
367
+ label: `metric '${metric.name}'`,
368
+ rme: metric.rme
369
+ }))], targetRme);
370
+ if (unconverged.length > 0) {
371
+ console.warn(`Benchmark "${name}" reached maxRuns (${maxRuns} runs) without converging to the ` + `${(targetRme * 100).toFixed(2)}% target: ${unconverged.join(', ')}. Results may be ` + `noisier than intended — consider raising maxRuns or reducing variance.`);
372
+ }
305
373
  }
306
374
  task.meta.benchmarkIterations = iterations;
307
375
  task.meta.benchmarkName = name;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-benchmark",
3
- "version": "0.0.3-canary.16",
3
+ "version": "0.0.3-canary.17",
4
4
  "author": "MUI Team",
5
5
  "description": "Benchmark utilities for MUI projects. Internal package.",
6
6
  "repository": {
@@ -68,5 +68,5 @@
68
68
  }
69
69
  }
70
70
  },
71
- "gitSha": "c11419b9b49a34230751911dc3bbd5fc2764b05b"
71
+ "gitSha": "69ae6a8458bc68be5bef922f72637180b2b160e5"
72
72
  }
package/reporter.mjs CHANGED
@@ -7,9 +7,6 @@ import { uploadCiReport } from "./upload.mjs";
7
7
  import { syncPrComment } from "./syncPrComment.mjs";
8
8
  // Import for TaskMeta augmentation side effect
9
9
  import "./taskMetaAugmentation.mjs";
10
- function getEventKey(event) {
11
- return `${event.id}:${event.phase}`;
12
- }
13
10
 
14
11
  /** Order-insensitive deep equality, treating a missing key and an `undefined` value as equal. */
15
12
  function deepEqual(first, second) {
@@ -56,21 +53,24 @@ function generateReportFromIterations(iterations) {
56
53
  const renderStats = [];
57
54
  for (let index = 0; index < expectedLength; index += 1) {
58
55
  const durations = iterations.map(iteration => iteration.renders[index].actualDuration);
56
+
57
+ // The per-render coefficient of variation is surfaced as the "Var%" column of the results
58
+ // table (informational). It no longer warrants a warning: the Welch comparison already accounts
59
+ // for a render's spread (a noisy render yields a high p-value rather than a false flag), and
60
+ // adaptive sampling drives the *mean's* precision to `targetRme`, warning separately when it
61
+ // can't converge. High spread alone no longer implies an unreliable result.
59
62
  const {
60
63
  mean: iqrMean,
61
64
  stdDev: iqrStdDev,
62
- outliers
65
+ outliers,
66
+ count
63
67
  } = aggregateSamples(durations);
64
- const coefficientOfVariation = iqrMean > 0 ? iqrStdDev / iqrMean : 0;
65
- if (iqrMean > 1 && coefficientOfVariation > 0.1) {
66
- const event = firstIteration.renders[index];
67
- console.warn(`High coefficient of variation (${(coefficientOfVariation * 100).toFixed(1)}%) for render #${index} event "${getEventKey(event)}". ` + `Mean: ${iqrMean.toFixed(2)}ms, StdDev: ${iqrStdDev.toFixed(2)}ms. Results may be unreliable.`);
68
- }
69
68
  renderStats.push({
70
69
  event: firstIteration.renders[index],
71
70
  iqrMean,
72
71
  iqrStdDev,
73
- outliers
72
+ outliers,
73
+ count
74
74
  });
75
75
  }
76
76
 
@@ -84,13 +84,13 @@ function generateReportFromIterations(iterations) {
84
84
  meanGaps.push(calculateMean(gaps));
85
85
  }
86
86
  const renders = [];
87
- let totalDuration = 0;
88
87
  for (let index = 0; index < expectedLength; index += 1) {
89
88
  const {
90
89
  event,
91
90
  iqrMean,
92
91
  iqrStdDev,
93
- outliers
92
+ outliers,
93
+ count
94
94
  } = renderStats[index];
95
95
  const startTime = index === 0 ? 0 : renders[index - 1].startTime + renders[index - 1].actualDuration + meanGaps[index];
96
96
  renders.push({
@@ -99,16 +99,27 @@ function generateReportFromIterations(iterations) {
99
99
  startTime,
100
100
  actualDuration: iqrMean,
101
101
  stdDev: iqrStdDev,
102
- outliers
102
+ outliers,
103
+ count
103
104
  });
104
- totalDuration += iqrMean;
105
105
  }
106
106
 
107
+ // Aggregate the *actual* per-iteration total duration (sum of that iteration's render durations).
108
+ // `totalDuration`, `totalStdDev`, and `totalCount` are all taken from this one distribution so the
109
+ // comparison's Welch test gets a mutually consistent (mean, stdDev, n) triple — and the spread
110
+ // reflects real cross-render correlation, which a sum of per-render variances cannot. (Because
111
+ // outliers are filtered here on the totals, not per render, `totalDuration` may differ marginally
112
+ // from summing the per-render means shown in the render table.)
113
+ const perIterationTotals = iterations.map(iteration => iteration.renders.reduce((sum, render) => sum + render.actualDuration, 0));
114
+ const totalStats = aggregateSamples(perIterationTotals);
115
+
107
116
  // Custom + paint metrics are merged separately from `task.meta.benchmarkMetrics`.
108
117
  const metrics = {};
109
118
  return {
110
119
  iterations: iterationCount,
111
- totalDuration,
120
+ totalDuration: totalStats.mean,
121
+ totalStdDev: totalStats.stdDev,
122
+ totalCount: totalStats.count,
112
123
  renders,
113
124
  metrics
114
125
  };
@@ -116,8 +127,9 @@ function generateReportFromIterations(iterations) {
116
127
  const LABEL_WIDTH = 28;
117
128
  const STAT_WIDTH = 16;
118
129
  const CV_WIDTH = 8;
130
+ const OUT_WIDTH = 4;
119
131
  function colorCV(cv) {
120
- const str = `${cv.toFixed(1)}%`.padStart(CV_WIDTH);
132
+ const str = `${cv.toFixed(1)}%`;
121
133
  if (cv > 10) {
122
134
  return red(str);
123
135
  }
@@ -126,6 +138,33 @@ function colorCV(cv) {
126
138
  }
127
139
  return dim(str);
128
140
  }
141
+
142
+ /**
143
+ * Columns shared by the duration and metric tables: a label pinned to a fixed width, then stats
144
+ * free to grow (a metric can be formatted with any unit, so its width isn't knowable here).
145
+ */
146
+ function statColumns(labelHeader, statHeader) {
147
+ return [{
148
+ header: labelHeader,
149
+ minWidth: LABEL_WIDTH,
150
+ maxWidth: LABEL_WIDTH
151
+ }, {
152
+ header: statHeader,
153
+ minWidth: STAT_WIDTH
154
+ }, {
155
+ header: 'Var%',
156
+ minWidth: CV_WIDTH
157
+ }, {
158
+ header: 'Out',
159
+ minWidth: OUT_WIDTH
160
+ }];
161
+ }
162
+
163
+ /** A `label | mean±σ | variation | outliers` row, as used by both tables. */
164
+ function statRow(label, iqrStr, mean, stdDev, outliers) {
165
+ const cv = mean > 0 ? stdDev / mean * 100 : 0;
166
+ return [label, cyan(iqrStr), colorCV(cv), outliers > 0 ? yellow(String(outliers)) : dim('0')];
167
+ }
129
168
  function printDurationMatrix(name, report, footer) {
130
169
  if (report.renders.length === 0) {
131
170
  return;
@@ -133,24 +172,10 @@ function printDurationMatrix(name, report, footer) {
133
172
  const rows = [];
134
173
  for (let r = 0; r < report.renders.length; r += 1) {
135
174
  const render = report.renders[r];
136
- const label = `#${r} ${render.id}:${render.phase}`;
137
175
  const iqrStr = `${render.actualDuration.toFixed(2)}±${render.stdDev.toFixed(2)}`;
138
- const cv = render.actualDuration > 0 ? render.stdDev / render.actualDuration * 100 : 0;
139
- rows.push([label.slice(0, LABEL_WIDTH).padStart(LABEL_WIDTH), cyan(iqrStr.padStart(STAT_WIDTH)), colorCV(cv), render.outliers > 0 ? yellow(String(render.outliers).padStart(4)) : dim('0'.padStart(4))]);
176
+ rows.push(statRow(`#${r} ${render.id}:${render.phase}`, iqrStr, render.actualDuration, render.stdDev, render.outliers));
140
177
  }
141
- printTable([{
142
- header: 'Render',
143
- width: LABEL_WIDTH
144
- }, {
145
- header: 'Mean±σ (ms)',
146
- width: STAT_WIDTH
147
- }, {
148
- header: 'Var%',
149
- width: CV_WIDTH
150
- }, {
151
- header: 'Out',
152
- width: 4
153
- }], rows, footer, name);
178
+ printTable(statColumns('Render', 'Mean±σ (ms)'), rows, footer, name);
154
179
  }
155
180
 
156
181
  /** Strips a `#sub-series` suffix to recover the metric name used to look up its definition. */
@@ -178,9 +203,13 @@ function mergeCustomMetrics(report, customMetrics, definitions) {
178
203
  report.metrics[key] = {
179
204
  mean: stats.mean,
180
205
  stdDev: stats.stdDev,
181
- outliers: stats.outliers
206
+ outliers: stats.outliers,
207
+ count: stats.count
182
208
  };
183
- maxCount = Math.max(maxCount, stats.count);
209
+ // `stats.count` is the post-outlier-removal count; add back the dropped outliers to recover
210
+ // the raw number of recorded samples, which is what a metric-only benchmark reports as its
211
+ // iteration count below.
212
+ maxCount = Math.max(maxCount, stats.count + stats.outliers);
184
213
  }
185
214
  const definition = {
186
215
  kind: metric.kind,
@@ -208,23 +237,10 @@ function printMetricsTable(name, metrics, iterationCount, definitions) {
208
237
  const rows = entries.map(([metricName, stats]) => {
209
238
  const definition = definitions[baseMetricName(metricName)];
210
239
  const iqrStr = `${formatMetricValue(stats.mean, definition)}±${formatMetricValue(stats.stdDev, definition)}`;
211
- const cv = stats.mean > 0 ? stats.stdDev / stats.mean * 100 : 0;
212
240
  const label = definition?.alarm ? `${metricName} ⚠` : metricName;
213
- return [label.slice(0, LABEL_WIDTH).padStart(LABEL_WIDTH), cyan(iqrStr.padStart(STAT_WIDTH)), colorCV(cv), stats.outliers > 0 ? yellow(String(stats.outliers).padStart(4)) : dim('0'.padStart(4))];
241
+ return statRow(label, iqrStr, stats.mean, stats.stdDev, stats.outliers);
214
242
  });
215
- printTable([{
216
- header: 'Metric',
217
- width: LABEL_WIDTH
218
- }, {
219
- header: 'Mean±σ',
220
- width: STAT_WIDTH
221
- }, {
222
- header: 'Var%',
223
- width: CV_WIDTH
224
- }, {
225
- header: 'Out',
226
- width: 4
227
- }], rows, dim(`${iterationCount} iterations`), `${name} — Metrics`);
243
+ printTable(statColumns('Metric', 'Mean±σ'), rows, dim(`${iterationCount} iterations`), `${name} — Metrics`);
228
244
  }
229
245
  async function loadBaselineReport(baselinePath) {
230
246
  const raw = await fs.readFile(baselinePath, 'utf8');
package/stats.d.mts CHANGED
@@ -9,12 +9,54 @@ export declare function quantile(sorted: number[], q: number): number;
9
9
  */
10
10
  export declare function isOutlier(value: number, q1: number, q3: number): boolean;
11
11
  /**
12
- * Aggregates a series of samples into a mean, standard deviation, and outlier count using
13
- * IQR-based outlier removal. Falls back to the raw values when filtering would remove
14
- * everything. This is the shared aggregation core for custom metrics.
12
+ * Drops IQR outliers (values outside the 1.5×IQR fences), returning the surviving samples. Falls
13
+ * back to the original values when filtering would remove everything. Shared by every consumer that
14
+ * wants to reason about typical performance rather than measurement artifacts (GC pauses, scheduling
15
+ * hiccups), so they all trim identically.
16
+ */
17
+ export declare function removeOutliers(values: number[]): number[];
18
+ /**
19
+ * Aggregates a series of samples into a mean, standard deviation, outlier count, and effective
20
+ * sample count using IQR-based outlier removal. This is the shared aggregation core for custom
21
+ * metrics.
22
+ *
23
+ * `count` is the number of samples that actually back `mean`/`stdDev` (post-outlier-removal), so a
24
+ * downstream Welch's t-test can use it directly as the `n` behind those stats.
15
25
  */
16
26
  export declare function aggregateSamples(values: number[]): {
17
27
  mean: number;
18
28
  stdDev: number;
19
29
  outliers: number;
20
- };
30
+ count: number;
31
+ };
32
+ /**
33
+ * Relative margin of error of the mean — half the 95% confidence interval width divided by the
34
+ * mean, using the normal approximation (`z = 1.96`). This is the adaptive-sampling stopping signal:
35
+ * measurement continues until this drops to a target. A precise t-quantile is unnecessary just to
36
+ * decide when enough samples have been collected.
37
+ *
38
+ * IQR outliers are removed first, so the margin of error is measured on the same trimmed
39
+ * distribution the reported stats and the Welch comparison use. Without this, a benchmark that is
40
+ * stable apart from recurring GC/scheduling spikes would keep a high raw margin of error and sample
41
+ * all the way to its maximum (and warn that it "did not converge") even though its typical estimate
42
+ * settled long ago.
43
+ *
44
+ * Returns `Infinity` below two (trimmed) samples (spread can't be estimated) and `0` when the mean
45
+ * is non-positive (e.g. a metric-only benchmark with no render duration to converge on), so such
46
+ * benchmarks stop at their minimum run count rather than sampling to the maximum.
47
+ */
48
+ export declare function relativeMarginOfError(samples: number[]): number;
49
+ /** A named adaptive-sampling signal (render duration or a metric) and its achieved margin of error. */
50
+ export interface SignalConvergence {
51
+ /** Human label, e.g. `duration` or `metric 'input-latency'`. */
52
+ label: string;
53
+ /** Relative margin of error achieved; `Infinity` when there were too few samples to estimate it. */
54
+ rme: number;
55
+ }
56
+ /**
57
+ * Describes the signals that failed to reach `targetRme`, each as a short phrase naming the signal
58
+ * and the margin of error it settled at (or "too few samples" when it couldn't be estimated).
59
+ * Returns an empty array when every signal converged. Pure so the non-convergence warning can be
60
+ * unit-tested apart from the measured benchmark loop.
61
+ */
62
+ export declare function describeUnconvergedSignals(signals: SignalConvergence[], targetRme: number): string[];
package/stats.mjs CHANGED
@@ -27,21 +27,89 @@ export function isOutlier(value, q1, q3) {
27
27
  }
28
28
 
29
29
  /**
30
- * Aggregates a series of samples into a mean, standard deviation, and outlier count using
31
- * IQR-based outlier removal. Falls back to the raw values when filtering would remove
32
- * everything. This is the shared aggregation core for custom metrics.
30
+ * Drops IQR outliers (values outside the 1.5×IQR fences), returning the surviving samples. Falls
31
+ * back to the original values when filtering would remove everything. Shared by every consumer that
32
+ * wants to reason about typical performance rather than measurement artifacts (GC pauses, scheduling
33
+ * hiccups), so they all trim identically.
33
34
  */
34
- export function aggregateSamples(values) {
35
+ export function removeOutliers(values) {
35
36
  const sorted = values.toSorted((first, second) => first - second);
36
37
  const q1 = quantile(sorted, 0.25);
37
38
  const q3 = quantile(sorted, 0.75);
38
39
  const filtered = values.filter(value => !isOutlier(value, q1, q3));
39
- const used = filtered.length > 0 ? filtered : values;
40
+ return filtered.length > 0 ? filtered : values;
41
+ }
42
+
43
+ /**
44
+ * Aggregates a series of samples into a mean, standard deviation, outlier count, and effective
45
+ * sample count using IQR-based outlier removal. This is the shared aggregation core for custom
46
+ * metrics.
47
+ *
48
+ * `count` is the number of samples that actually back `mean`/`stdDev` (post-outlier-removal), so a
49
+ * downstream Welch's t-test can use it directly as the `n` behind those stats.
50
+ */
51
+ export function aggregateSamples(values) {
52
+ const used = removeOutliers(values);
40
53
  const mean = calculateMean(used);
41
54
  const stdDev = calculateStdDev(used, mean);
42
55
  return {
43
56
  mean,
44
57
  stdDev,
45
- outliers: values.length - used.length
58
+ outliers: values.length - used.length,
59
+ count: used.length
46
60
  };
61
+ }
62
+
63
+ /**
64
+ * Relative margin of error of the mean — half the 95% confidence interval width divided by the
65
+ * mean, using the normal approximation (`z = 1.96`). This is the adaptive-sampling stopping signal:
66
+ * measurement continues until this drops to a target. A precise t-quantile is unnecessary just to
67
+ * decide when enough samples have been collected.
68
+ *
69
+ * IQR outliers are removed first, so the margin of error is measured on the same trimmed
70
+ * distribution the reported stats and the Welch comparison use. Without this, a benchmark that is
71
+ * stable apart from recurring GC/scheduling spikes would keep a high raw margin of error and sample
72
+ * all the way to its maximum (and warn that it "did not converge") even though its typical estimate
73
+ * settled long ago.
74
+ *
75
+ * Returns `Infinity` below two (trimmed) samples (spread can't be estimated) and `0` when the mean
76
+ * is non-positive (e.g. a metric-only benchmark with no render duration to converge on), so such
77
+ * benchmarks stop at their minimum run count rather than sampling to the maximum.
78
+ */
79
+ export function relativeMarginOfError(samples) {
80
+ const used = removeOutliers(samples);
81
+ const n = used.length;
82
+ if (n < 2) {
83
+ return Infinity;
84
+ }
85
+ const mean = calculateMean(used);
86
+ if (mean <= 0) {
87
+ return 0;
88
+ }
89
+ // Bessel-corrected (sample) variance: the samples estimate the spread of the population.
90
+ const variance = used.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (n - 1);
91
+ const standardError = Math.sqrt(variance / n);
92
+ return 1.96 * standardError / mean;
93
+ }
94
+
95
+ /** A named adaptive-sampling signal (render duration or a metric) and its achieved margin of error. */
96
+
97
+ /**
98
+ * Describes the signals that failed to reach `targetRme`, each as a short phrase naming the signal
99
+ * and the margin of error it settled at (or "too few samples" when it couldn't be estimated).
100
+ * Returns an empty array when every signal converged. Pure so the non-convergence warning can be
101
+ * unit-tested apart from the measured benchmark loop.
102
+ */
103
+ export function describeUnconvergedSignals(signals, targetRme) {
104
+ const unconverged = [];
105
+ for (const {
106
+ label,
107
+ rme
108
+ } of signals) {
109
+ if (rme > targetRme) {
110
+ const achieved = Number.isFinite(rme) ? `RME ${(rme * 100).toFixed(2)}%` : 'too few samples';
111
+ unconverged.push(`${label} (${achieved})`);
112
+ }
113
+ }
114
+ return unconverged;
47
115
  }
package/types.d.mts CHANGED
@@ -83,7 +83,10 @@ export interface MetricSampleStats {
83
83
  mean: number;
84
84
  stdDev: number;
85
85
  outliers: number;
86
- /** Number of recorded samples (used to derive iteration counts; stripped from the report). */
86
+ /**
87
+ * Effective sample count behind `mean`/`stdDev` (post-outlier-removal). Serialized into the
88
+ * report as the `n` a Welch's t-test uses to compare this series against a baseline.
89
+ */
87
90
  count: number;
88
91
  }
89
92
  /** A custom metric's aggregated data attached to `task.meta`, keyed by metric name. */