webperf-snippets 1.8.0 → 1.9.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.
package/README.md CHANGED
@@ -70,10 +70,10 @@ JSON output (for piping into `jq` or CI):
70
70
  npx webperf-snippets https://example.com --json
71
71
  ```
72
72
 
73
- The JSON output records how the page was measured in `conditions`, so a later run can tell whether it measured the same way: the viewport, the workflow (or the path of the snippet with `--snippet`), the wait and the version of the CLI.
73
+ The JSON output records how the page was measured in `conditions`, so a later run can tell whether it measured the same way: the viewport, the workflow (or the path of the snippet with `--snippet`), the wait, the version of the CLI and the machine: its CPU model, cores, architecture and memory, as the operating system reports them, and a benchmark index, a short CPU test in Node before Chromium starts, which only informs, since on the same machine it moves by up to 40 % with the load.
74
74
 
75
75
  ```json
76
- "conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.8.0" }
76
+ "conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.9.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
77
77
  ```
78
78
 
79
79
  Single snippet:
@@ -158,7 +158,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
158
158
  "timestamp": "2026-05-06T10:00:00.000Z",
159
159
  "navMs": 1240,
160
160
  "results": [],
161
- "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.8.0" }
161
+ "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.9.0" }
162
162
  }
163
163
  ```
164
164
 
@@ -190,7 +190,7 @@ One page load is one sample, and lab values vary from one load to the next. `--r
190
190
  npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
191
191
  ```
192
192
 
193
- The median run is the one with the median LCP. A workflow without LCP uses the first of FCP, TTFB and the navigation time that it measures, and `--median-by <metric>` picks another one (`LCP`, `FCP`, `TTFB`, `CLS`, `INP` or `navMs`). With an even count it takes the lower of the two middle runs, so the report is always a run that happened. The report adds the median, min and max of each metric across the runs, and the JSON output carries them in `runs`:
193
+ The median run is the one with the median LCP. A workflow without LCP uses the first of FCP, TTFB and the navigation time that it measures, and `--median-by <metric>` picks another one (`LCP`, `FCP`, `TTFB`, `CLS`, `INP` or `navMs`). With an even count it takes the lower of the two middle runs, so the report is always a run that happened. The report adds the median, min and max of each metric across the runs, and how much the runs agree, and the JSON output carries them in `runs`:
194
194
 
195
195
  ```json
196
196
  "runs": {
@@ -199,12 +199,12 @@ The median run is the one with the median LCP. A workflow without LCP uses the f
199
199
  "medianRun": 3,
200
200
  "medianBy": "LCP",
201
201
  "summary": {
202
- "LCP": { "median": 2140, "min": 1980, "max": 2610, "values": [2610, 2010, 2140, 1980, 2290], "unit": "ms" }
202
+ "LCP": { "median": 2140, "min": 1980, "max": 2610, "values": [2610, 2010, 2140, 1980, 2290], "unit": "ms", "cv": 11.6, "agreement": "noisy" }
203
203
  }
204
204
  }
205
205
  ```
206
206
 
207
- `medianRun` counts from 1, and `values` keeps the order of the runs. The budgets compare the median of each metric, not one run. A run that fails, such as a navigation that does not finish, counts in `failed` and is left out of the median; when every run fails, the command exits with code `1`. Each run is reported on stderr as it starts, so stdout keeps only the report. An invalid count or metric exits with code `2`.
207
+ `medianRun` counts from 1, and `values` keeps the order of the runs. `cv` is the coefficient of variation of the runs, their standard deviation over their mean in percent, and `agreement` its band: `consistent` up to 5 %, `variable` up to 10 % and `noisy` above, where one median says little and more runs help. Runs that vary by less than half of the minimum change of `--baseline` (50 ms for LCP, FCP and TTFB, 25 ms for INP, 0.025 for CLS) are `consistent` whatever their CV, since a local TTFB of 3 to 18 ms has a CV of 64 %. The budgets compare the median of each metric, not one run. A run that fails, such as a navigation that does not finish, counts in `failed` and is left out of the median; when every run fails, the command exits with code `1`. Each run is reported on stderr as it starts, so stdout keeps only the report. An invalid count or metric exits with code `2`.
208
208
 
209
209
  Measure several pages, on several devices and in several variants, in one command with `--matrix <file>`. The file lists them, and the CLI measures every URL on every device and in every variant, each combination `runs` times, one load after the other:
210
210
 
@@ -238,7 +238,7 @@ npx webperf-snippets --matrix matrix.json --budget-lcp 2500
238
238
 
239
239
  The options of the command line apply to every combination: `--workflow`, `--snippet`, `--wait`, `--interact-script`, `--header`, `--own-domains`, `--storage-state`, `--cpu`, `--network` and the budgets. The file replaces `cpu`, `network` and `storageState` where it sets them, and its `headers` and `ownDomains` add to the ones of the command line, the variant winning on the same header. A matrix file is meant to be versioned, so pass secrets, such as the token of an access proxy, with `--header` and an environment variable; a header of the file whose name looks like a secret raises a warning. A key the CLI does not know is an error, so a typo does not measure without its condition.
240
240
 
241
- The report is a table with one row per combination and the rated median of each metric, followed by the errors that decide the exit code; `--verbose` adds the report of the median run of each combination. `--markdown` gives the same table and folds the report of each combination into a `<details>` block, to keep a pull request comment readable. With `--json`, it is `{ "matrix", "runs", "combinations" }`, each combination being the report of its median run with its `id`, `name`, `device`, `variant` (when the file defines variants), `runs` and `conditions`, with the viewport of its device. The budgets compare the median of each combination. The command exits with code `1` when a combination exceeds a budget, has an error or fails every run, and with code `2` for an invalid file, before any page load, naming the place of the problem in the JSON. `--viewport` does not apply, since each device sets its own.
241
+ The report is a table with one row per combination and the rated median of each metric, a variable or noisy one followed by the coefficient of variation of its runs (`🟡 2.71s ±14%`), then the errors that decide the exit code; `--verbose` adds the report of the median run of each combination. `--markdown` gives the same table and folds the report of each combination into a `<details>` block, to keep a pull request comment readable. With `--json`, it is `{ "matrix", "runs", "combinations" }`, each combination being the report of its median run with its `id`, `name`, `device`, `variant` (when the file defines variants), `runs` and `conditions`, with the viewport of its device. The budgets compare the median of each combination. The command exits with code `1` when a combination exceeds a budget, has an error or fails every run, and with code `2` for an invalid file, before any page load, naming the place of the problem in the JSON. `--viewport` does not apply, since each device sets its own.
242
242
 
243
243
  With CrUX, the CLI asks once for each URL and form factor, so the variants of a device share their field data. With `--report-to`, it posts one report for each combination, the body of one page with `meta.device`, `meta.variant` and `meta.runs`; the headers of the command line and of the file are conditions of the load, and no report carries their values.
244
244
 
@@ -257,6 +257,8 @@ npx webperf-snippets https://pr-123.example.com --runs 5 --baseline baseline.jso
257
257
 
258
258
  LCP, FCP, TTFB, CLS and INP compare when both runs measured them, the median of each with `--runs`; a metric that only one side measured is `not measured`. A metric regresses when it gets worse by more than its threshold, the larger of a share of the baseline and a minimum, and, when the baseline has two runs or more, ends above the slowest of them: a median inside the range of the baseline is noise the baseline already had. It improves under the same rules the other way, and otherwise it is unchanged.
259
259
 
260
+ With five runs or more on each side, the range gives way to a test of the runs themselves: a change that beats its threshold also needs a one-sided Mann-Whitney U p-value under 0.05, the chance that runs of an unchanged page would differ this much. A median can move with two slow runs out of five; the test asks whether most runs moved. The JSON of the metric adds `stats`, with `p`, the `shift` (the Hodges-Lehmann estimate, the median of the differences between every run of one side and every run of the other) and its 95 % interval `ci`, and the reports add them to the verdict: `regressed (p = 0.028, +500ms, 95% CI +20ms to +600ms)`, or `unchanged (no evidence: p = 0.35)` for a change over the threshold that the runs do not back. Three runs a side can never reach p < 0.05, so under five the range decides.
261
+
260
262
  | Metric | Share of the baseline | Minimum |
261
263
  | -------------- | --------------------- | ------- |
262
264
  | LCP, FCP, TTFB | 10% | 100 ms |
@@ -282,7 +284,7 @@ The report adds the comparison after the usual one, and `--markdown` a table for
282
284
  }
283
285
  ```
284
286
 
285
- When both runs were measured differently, with another workflow or snippet, viewport, wait, throttling, number of runs or version of the CLI, or on another path or query, the CLI compares anyway and warns on stderr, in the reports and in `warnings`: whoever reads the pull request comment does not see the stderr of the CI. Another host does not warn, since it is the review app of a pull request against production. A baseline of one run warns that only the thresholds apply.
287
+ When both runs were measured differently, with another workflow or snippet, viewport, wait, throttling, number of runs or version of the CLI, on another CPU model or with other cores, or on another path or query, the CLI compares anyway and warns on stderr, in the reports and in `warnings`: whoever reads the pull request comment does not see the stderr of the CI. Another host does not warn, since it is the review app of a pull request against production. A baseline of one run warns that only the thresholds apply.
286
288
 
287
289
  The comparison only informs, and the exit code is the same as without it, unless `--fail-on-regression`, which exits with `1` when a metric regressed or an error is new, and names them on stderr. A baseline that does not exist or is not an output of the CLI, a baseline of a page for a matrix or of a matrix for a page, or a `--threshold` the CLI does not understand exits with code `2`, before the page loads.
288
290
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webperf-snippets",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "mcpName": "io.github.nucliweb/webperf-snippets",
5
5
  "description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
6
6
  "type": "module",
package/src/baseline.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { blockingErrors } from "./blocking.js";
3
3
  import { describeThrottling } from "./throttling.js";
4
+ import { mannWhitneyGreater, hodgesLehmann } from "./stats.js";
4
5
 
5
6
  // A measurement compared with an earlier one, its baseline: which metrics got worse or better beyond
6
7
  // the noise of the lab, which errors appeared or went away, and what differs in how both were
@@ -71,23 +72,40 @@ export function thresholdsWith(overrides) {
71
72
 
72
73
  const round = (n) => Math.round(n * 10000) / 10000;
73
74
 
74
- // The value of a metric in a page: the median of its runs, with their range when there are two or
75
- // more, or its one result, as runs.js picks it
75
+ // The value of a metric in a page: the median of its runs, with their range and their values when there
76
+ // are two or more, or its one result, as runs.js picks it
76
77
  function measured(payload, metric) {
77
78
  const summary = payload.runs?.summary?.[metric];
78
79
  if (summary) {
79
- const runs = summary.values.filter((value) => value !== null).length;
80
- return { value: summary.median, ...(runs >= 2 ? { range: { min: summary.min, max: summary.max } } : {}) };
80
+ const values = summary.values.filter((value) => value !== null);
81
+ return { value: summary.median, ...(values.length >= 2 ? { range: { min: summary.min, max: summary.max }, values } : {}) };
81
82
  }
82
83
  const result = payload.results?.find((r) => r.metric === metric && r.status === "ok" && typeof r.value === "number");
83
84
  return result ? { value: result.value } : null;
84
85
  }
85
86
 
86
- // A regression must beat the threshold of its metric and, when the baseline has several runs, the
87
- // worst of them: a median inside the range of the baseline is noise the baseline already had.
88
- function verdict(base, current, threshold) {
89
- if (current.value - base.value > threshold && (!base.range || current.value > base.range.max)) return "regressed";
90
- if (base.value - current.value > threshold && (!base.range || current.value < base.range.min)) return "improved";
87
+ // With five runs or more a side, Mann-Whitney U can tell a change from the noise of the runs; with
88
+ // fewer, its p-value cannot go under 0.05 (three a side give 0.05 at best), so the range decides.
89
+ export const RUNS_FOR_STATISTICS = 5;
90
+ const ALPHA = 0.05;
91
+
92
+ // The evidence of a change: the one-sided p-value in its direction, and the shift with its 95 % interval
93
+ function statsOf(base, current, delta) {
94
+ if ((base.values?.length ?? 0) < RUNS_FOR_STATISTICS || (current.values?.length ?? 0) < RUNS_FOR_STATISTICS) return undefined;
95
+ const { p, method } = delta < 0 ? mannWhitneyGreater(base.values, current.values) : mannWhitneyGreater(current.values, base.values);
96
+ const { shift, ci } = hodgesLehmann(base.values, current.values);
97
+ return { p: round(p), shift: round(shift), ci: ci.map(round), method };
98
+ }
99
+
100
+ // A regression must beat the threshold of its metric and, with five runs a side, be unlikely to come
101
+ // from the noise of the runs (p < 0.05). With fewer, when the baseline has several runs, it must beat
102
+ // the worst of them: a median inside the range of the baseline is noise the baseline already had.
103
+ function verdict(base, current, threshold, stats) {
104
+ const worse = current.value - base.value > threshold;
105
+ const better = base.value - current.value > threshold;
106
+ if (stats) return stats.p < ALPHA ? (worse ? "regressed" : better ? "improved" : "unchanged") : "unchanged";
107
+ if (worse && (!base.range || current.value > base.range.max)) return "regressed";
108
+ if (better && (!base.range || current.value < base.range.min)) return "improved";
91
109
  return "unchanged";
92
110
  }
93
111
 
@@ -104,13 +122,15 @@ function compareMetrics(base, current, thresholds) {
104
122
  const { relative = 0, absolute } = thresholds[metric];
105
123
  const threshold = round(Math.max(relative * b.value, absolute));
106
124
  const delta = c.value - b.value;
125
+ const stats = statsOf(b, c, delta);
107
126
  metrics[metric] = {
108
127
  baseline: b.value,
109
128
  current: c.value,
110
129
  delta: round(delta),
111
130
  deltaPercent: b.value === 0 ? null : Math.round((delta / b.value) * 1000) / 10,
112
131
  threshold,
113
- verdict: verdict(b, c, threshold),
132
+ verdict: verdict(b, c, threshold, stats),
133
+ ...(stats ? { stats } : {}),
114
134
  };
115
135
  }
116
136
  return metrics;
@@ -166,6 +186,14 @@ const pathOf = (url) => {
166
186
  }
167
187
  };
168
188
 
189
+ // Another CPU or other cores give other timings for the same page. A different benchmark index alone
190
+ // does not warn: on the same machine it moves by 40 % with its load.
191
+ function machineWarning(base, current) {
192
+ if (!base?.cpu || !current?.cpu) return null;
193
+ if (base.cpu === current.cpu && base.cores === current.cores) return null;
194
+ return `the baseline was measured on another machine (${base.cpu}, ${base.cores} cores) than this run (${current.cpu}, ${current.cores} cores); timings can differ for that alone`;
195
+ }
196
+
169
197
  // What differs in how both were measured. The comparison goes on: the warnings say how far to trust it.
170
198
  function warningsOf(base, current) {
171
199
  const warnings = [];
@@ -183,6 +211,8 @@ function warningsOf(base, current) {
183
211
  }
184
212
  if (runCount(base) < 2) warnings.push("the baseline has 1 run, so only the thresholds apply; measure both with --runs");
185
213
  else if (runCount(base) !== runCount(current)) warnings.push(`the baseline has ${runCount(base)} runs, and this run ${runCount(current)}`);
214
+ const machine = machineWarning(b?.machine, c?.machine);
215
+ if (machine) warnings.push(machine);
186
216
  if (b && c && b.cliVersion !== c.cliVersion) {
187
217
  warnings.push(`the baseline was measured with CLI ${b.cliVersion}, and this run with ${c.cliVersion}; the snippets may have changed`);
188
218
  }
package/src/bin.js CHANGED
@@ -20,6 +20,7 @@ import { loadMatrix } from "./matrix.js";
20
20
  import { blockingErrors } from "./blocking.js";
21
21
  import { MEASURED_BY, checkBudgets, parseBudgets, unmeasuredMetrics, violationsOf } from "./budgets.js";
22
22
  import { measurementConditions } from "./conditions.js";
23
+ import { machineOf } from "./machine.js";
23
24
  import { isHttpUrl } from "./url.js";
24
25
  import { loadBaseline, parseThreshold, thresholdsWith, compareRun, compareMatrix } from "./baseline.js";
25
26
  import { describeRegressions, describeMatrixRegressions, matrixComparisonView } from "./reporters/baseline-view.js";
@@ -287,9 +288,11 @@ async function main() {
287
288
  const options = { waitMs, headless: !values.headed, interactScript, ...conditions };
288
289
  return items ? runSnippets({ ...options, items }) : runMeasurement({ ...options, workflow, rules: RULES });
289
290
  };
290
- // What the JSON records of how each page was measured, for a later run to compare with
291
+ // What the JSON records of how each page was measured, for a later run to compare with. The machine is
292
+ // measured once, before Chromium starts, so its processes do not slow the benchmark down.
293
+ const machine = machineOf();
291
294
  const conditionsFor = (viewport) =>
292
- measurementConditions({ viewport, waitMs, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
295
+ measurementConditions({ viewport, waitMs, machine, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
293
296
 
294
297
  if (matrixPath) {
295
298
  let matrix;
package/src/conditions.js CHANGED
@@ -2,6 +2,6 @@ import { cliVersion } from "./report-to.js";
2
2
 
3
3
  // How a measurement was taken, kept in its JSON so that a later run compared with it can tell whether
4
4
  // both were measured the same way. A snippet is named by its path, so an alias and its path match.
5
- export function measurementConditions({ viewport, workflow, snippet, waitMs }) {
6
- return { viewport, ...(snippet ? { snippet } : { workflow }), waitMs, cliVersion };
5
+ export function measurementConditions({ viewport, workflow, snippet, waitMs, machine }) {
6
+ return { viewport, ...(snippet ? { snippet } : { workflow }), waitMs, cliVersion, ...(machine ? { machine } : {}) };
7
7
  }
package/src/machine.js ADDED
@@ -0,0 +1,42 @@
1
+ import { arch as osArch, cpus as osCpus, totalmem as osTotalmem } from "node:os";
2
+
3
+ // The machine a measurement ran on, kept in its conditions, so --baseline can say when two were measured
4
+ // on different machines: timings move with the CPU as much as with the page. The machine is told apart by
5
+ // what the operating system reports of it. The benchmark index only informs: on the same machine it
6
+ // moves by 40 % with its load and with the cores the system picks (performance or efficiency).
7
+
8
+ const WINDOW_MS = 40;
9
+ const WINDOWS = 5;
10
+
11
+ // A fixed piece of work: build a string and sort an array, as the benchmark index of Lighthouse builds
12
+ // strings in the page. Its result is returned so the engine cannot drop it.
13
+ function work() {
14
+ let s = "";
15
+ for (let i = 0; i < 2000; i++) s += String.fromCharCode(97 + (i % 26));
16
+ const numbers = Array.from({ length: 200 }, (_, i) => (i * 7919) % 211);
17
+ numbers.sort((a, b) => a - b);
18
+ return s.length + numbers[0];
19
+ }
20
+
21
+ // How many times the work fits in 40 ms, the best of five windows: the first one also warms up the
22
+ // engine, and the best one is the least touched by other work of the machine. In Node, so --cpu, which
23
+ // throttles the page, leaves it alone: it measures the machine. Alone, ten in a row vary by about 6 %.
24
+ export function benchmarkIndex() {
25
+ let best = 0;
26
+ let sink = 0;
27
+ for (let w = 0; w < WINDOWS; w++) {
28
+ const end = performance.now() + WINDOW_MS;
29
+ let count = 0;
30
+ while (performance.now() < end) {
31
+ sink += work();
32
+ count++;
33
+ }
34
+ best = Math.max(best, count);
35
+ }
36
+ return sink > 0 ? best : 0;
37
+ }
38
+
39
+ export function machineOf({ cpus = osCpus, arch = osArch(), totalmem = osTotalmem, benchmark = benchmarkIndex } = {}) {
40
+ const list = cpus();
41
+ return { cpu: list[0]?.model ?? "unknown", cores: list.length, arch, memoryGB: Math.round(totalmem() / 2 ** 30), benchmarkIndex: benchmark() };
42
+ }
@@ -15,9 +15,22 @@ function describeChange(metric, { delta, deltaPercent }) {
15
15
  return `${arrow(delta)} ${ms}${percent}`;
16
16
  }
17
17
 
18
+ // Three decimals where the verdict is decided, two above it: "0.028", "0.35"
19
+ const pValue = (p) => (p < 0.001 ? "< 0.001" : p < 0.05 ? p.toFixed(3) : p.toFixed(2));
20
+ const amountOf = (metric, n) => (metric === "CLS" ? signed(n, Math.abs(n).toFixed(4)) : signed(n, `${Math.abs(Math.round(n))}ms`));
21
+
22
+ // The evidence of a change measured with five runs a side: its p-value, its shift and the 95 % interval
23
+ // of the shift; for a change over the threshold that stayed unchanged, that the runs gave no evidence
24
+ function evidenceOf(metric, m) {
25
+ const { p, shift, ci } = m.stats;
26
+ if (m.verdict === "unchanged") return Math.abs(m.delta) > m.threshold ? `no evidence: p = ${pValue(p)}` : null;
27
+ return `p = ${pValue(p)}, ${amountOf(metric, shift)}, 95% CI ${amountOf(metric, ci[0])} to ${amountOf(metric, ci[1])}`;
28
+ }
29
+
18
30
  // An unchanged metric that moved stayed under its threshold, or beat it inside the range of the runs
19
31
  // of the baseline: noise the baseline already had
20
32
  function noteOf(metric, m) {
33
+ if (m.stats) return evidenceOf(metric, m) ?? (m.verdict === "unchanged" && m.delta !== 0 ? `under ${metric === "CLS" ? m.threshold : `${Math.round(m.threshold)}ms`}` : null);
21
34
  if (m.verdict !== "unchanged" || m.delta === 0) return null;
22
35
  if (Math.abs(m.delta) > m.threshold) return "inside the range of the baseline";
23
36
  return `under ${metric === "CLS" ? m.threshold : `${Math.round(m.threshold)}ms`}`;
@@ -3,7 +3,7 @@ import { describeResult } from "../view.js";
3
3
  import { isSummaryResult, summaryNumbers } from "./summary-view.js";
4
4
  import { fieldDataView } from "../crux.js";
5
5
  import { describeThrottling } from "../throttling.js";
6
- import { describeRuns } from "../runs.js";
6
+ import { describeRuns, describeAgreement } from "../runs.js";
7
7
  import { matrixView } from "./matrix-view.js";
8
8
  import { baselineView } from "./baseline-view.js";
9
9
  import { budgetRows, matrixBudgetFailures } from "./budgets-view.js";
@@ -210,12 +210,13 @@ function renderFieldData(crux, results) {
210
210
  return lines;
211
211
  }
212
212
 
213
- // The runs line, and the median, min and max of each metric across the runs
213
+ // The runs line, and the median, min and max of each metric across the runs, with how much they agree
214
214
  function renderRuns(runs) {
215
215
  const lines = [styleText("dim", describeRuns(runs))];
216
- for (const [metric, { median, min, max, unit }] of Object.entries(runs.summary)) {
217
- const cell = (label, value) => pad(`${label} ${formatValue(value, unit)}`, 16);
218
- lines.push(styleText("dim", ` ${pad(metric, 6)}${cell("median", median)}${cell("min", min)}${cell("max", max).trimEnd()}`));
216
+ for (const [metric, value] of Object.entries(runs.summary)) {
217
+ const { median, min, max, unit } = value;
218
+ const cell = (label, v) => pad(`${label} ${formatValue(v, unit)}`, 16);
219
+ lines.push(styleText("dim", ` ${pad(metric, 6)}${cell("median", median)}${cell("min", min)}${cell("max", max)}${describeAgreement(value)}`.trimEnd()));
219
220
  }
220
221
  return lines;
221
222
  }
@@ -274,7 +275,7 @@ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, th
274
275
  // One row per combination with the rated median of each metric, and below it the errors that decide
275
276
  // the exit code. With verbose, the report of the median run of each combination follows.
276
277
  export function reportMatrixHuman(report) {
277
- const { file, summary, hasVariants, metrics, rows, errors, comparison } = matrixView(report);
278
+ const { file, summary, hasVariants, metrics, rows, errors, comparison, legend } = matrixView(report);
278
279
  const columns = [["name", "Page"], ["device", "Device"], ...(hasVariants ? [["variant", "Variant"]] : [])];
279
280
  const widths = columns.map(([key, label]) => Math.max(...report.combinations.map((c) => c[key].length), label.length) + 3);
280
281
  const identity = (values) => values.map((value, i) => pad(value, widths[i])).join("");
@@ -292,11 +293,13 @@ export function reportMatrixHuman(report) {
292
293
  continue;
293
294
  }
294
295
  const drawn = cells.map((cell, i) => {
295
- const value = cell ? pad(paint(cell.rating, `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}`), 14) : pad("·", 14);
296
+ const width = legend ? 18 : 14;
297
+ const value = cell ? pad(paint(cell.rating, `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}${cell.spread ? ` ${cell.spread}` : ""}`), width) : pad("·", width);
296
298
  return value + (changes ? pad(styleText(VERDICT_STYLE[changes[i].verdict], changes[i].text), 12) : "");
297
299
  });
298
300
  lines.push(row + drawn.join("").trimEnd());
299
301
  }
302
+ if (legend) lines.push(styleText("dim", ` ${legend}`));
300
303
 
301
304
  if (errors.length) {
302
305
  lines.push("");
@@ -2,7 +2,7 @@ import { fieldDataView } from "../crux.js";
2
2
  import { describeResult } from "../view.js";
3
3
  import { isSummaryResult, summaryNumbers } from "./summary-view.js";
4
4
  import { describeThrottling } from "../throttling.js";
5
- import { describeRuns } from "../runs.js";
5
+ import { describeRuns, describeAgreement } from "../runs.js";
6
6
  import { matrixView } from "./matrix-view.js";
7
7
  import { baselineView } from "./baseline-view.js";
8
8
  import { budgetRows, matrixBudgetFailures } from "./budgets-view.js";
@@ -115,11 +115,12 @@ function renderFieldData(crux, results) {
115
115
  ];
116
116
  }
117
117
 
118
- // The median, min and max of each metric across the runs
118
+ // The median, min and max of each metric across the runs, and how much they agree
119
119
  function renderRuns(runs) {
120
- const lines = ["| Metric | Median | Min | Max |", "|--------|--------|-----|-----|"];
121
- for (const [metric, { median, min, max, unit }] of Object.entries(runs.summary)) {
122
- lines.push(`| ${metric} | ${formatValue(median, unit)} | ${formatValue(min, unit)} | ${formatValue(max, unit)} |`);
120
+ const lines = ["| Metric | Median | Min | Max | Runs |", "|--------|--------|-----|-----|------|"];
121
+ for (const [metric, value] of Object.entries(runs.summary)) {
122
+ const { median, min, max, unit } = value;
123
+ lines.push(`| ${metric} | ${formatValue(median, unit)} | ${formatValue(min, unit)} | ${formatValue(max, unit)} | ${describeAgreement(value) || "·"} |`);
123
124
  }
124
125
  return lines;
125
126
  }
@@ -196,7 +197,7 @@ export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttli
196
197
  // report of the median run of each combination folded into a details block, to keep a pull request
197
198
  // comment readable.
198
199
  export function reportMatrixMarkdown(report) {
199
- const { file, summary, hasVariants, metrics, rows, errors, comparison } = matrixView(report);
200
+ const { file, summary, hasVariants, metrics, rows, errors, comparison, legend } = matrixView(report);
200
201
  // With --baseline, a change column follows each metric
201
202
  const metricHeadings = metrics.flatMap((m) => (comparison ? [m, "Δ"] : [m]));
202
203
  const headings = ["Page", "Device", ...(hasVariants ? ["Variant"] : []), ...metricHeadings];
@@ -208,12 +209,13 @@ export function reportMatrixMarkdown(report) {
208
209
  for (const { combination: c, cells, changes } of rows) {
209
210
  const drawn = cells
210
211
  ? cells.flatMap((cell, i) => {
211
- const value = cell ? `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}` : "·";
212
+ const value = cell ? `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}${cell.spread ? ` ${cell.spread}` : ""}` : "·";
212
213
  return changes ? [value, changes[i].text] : [value];
213
214
  })
214
215
  : ["❌ every run failed", ...metricHeadings.slice(1).map(() => "")];
215
216
  lines.push(row([c.name, c.device, ...(hasVariants ? [c.variant] : []), ...drawn]));
216
217
  }
218
+ if (legend) lines.push("", `> ${legend}`);
217
219
 
218
220
  if (errors.length) {
219
221
  lines.push("", "### Errors", ...errors.map((e) => `- ${e}`));
@@ -6,6 +6,11 @@ import { changeCell, matrixComparisonView } from "./baseline-view.js";
6
6
  // The order of the metric columns of the matrix table
7
7
  const METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP"];
8
8
 
9
+ // A median whose runs are variable or noisy carries their coefficient of variation in its cell; the
10
+ // others carry nothing, so the table stays narrow and the marks stand out
11
+ const varies = (value) => value?.agreement === "variable" || value?.agreement === "noisy";
12
+ const LEGEND = "±: the coefficient of variation of the runs of a variable or noisy median";
13
+
9
14
  // What the human and markdown reports of a matrix show, so they only differ in how they draw it:
10
15
  // the title, the metric columns, one row per combination with the rated median of each metric, and
11
16
  // the errors that decide the exit code. With --baseline, each row also has the change of each metric
@@ -31,10 +36,13 @@ export function matrixView({ matrix, runs, combinations, baseline }) {
31
36
  ? null
32
37
  : metrics.map((metric) => {
33
38
  const value = c.runs.summary[metric];
34
- return value ? { median: value.median, unit: value.unit, rating: rate(metric, value.median) } : null;
39
+ return value
40
+ ? { median: value.median, unit: value.unit, rating: rate(metric, value.median), ...(varies(value) ? { spread: `±${Math.round(value.cv)}%` } : {}) }
41
+ : null;
35
42
  }),
36
43
  })),
37
44
  ...(baseline ? { comparison: matrixComparisonView(baseline, combinations) } : {}),
45
+ ...(measured.some((c) => metrics.some((metric) => varies(c.runs.summary[metric]))) ? { legend: LEGEND } : {}),
38
46
  errors: combinations.flatMap((c) =>
39
47
  c.error ? [`${c.id}: ${c.error}`] : blockingErrors(c.results).map((e) => `${c.id}: ${e.id}: ${e.message}`)
40
48
  ),
package/src/runs.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { DEFAULT_THRESHOLDS } from "./baseline.js";
2
+
1
3
  // Several runs of the same measurement, reduced to one: the median run, as WebPageTest reports it.
2
4
  // Every result of the report comes from that run, so they agree with each other (the sub-parts of
3
5
  // the LCP add up to its LCP), and a summary gives the median, min and max of each metric.
@@ -24,6 +26,24 @@ const measures = (runs, metric) => runs.some((run) => typeof valueOf(run, metric
24
26
  // the two middle ones, so the median is always a run that happened, never an average of two.
25
27
  const middle = (sorted) => sorted[Math.floor((sorted.length - 1) / 2)];
26
28
 
29
+ // How much the runs of a metric agree: their coefficient of variation, the sample standard deviation
30
+ // over the mean, in percent, and its band. Up to 5 % the runs agree; above 10 % one median says little.
31
+ // None for one run, or for runs whose mean is 0, such as a CLS of 0 in every run. Runs that vary by
32
+ // less than half of the change --baseline counts agree whatever their CV: a local TTFB of 3 to 18 ms
33
+ // has a CV of 64 % and a standard deviation of 6 ms.
34
+ function agreementOf(values, metric) {
35
+ if (values.length < 2) return {};
36
+ const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
37
+ if (mean === 0) return {};
38
+ const sd = Math.sqrt(values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (values.length - 1));
39
+ const cv = Math.round((sd / mean) * 1000) / 10;
40
+ if (sd < DEFAULT_THRESHOLDS[metric].absolute / 2) return { cv, agreement: "consistent" };
41
+ return { cv, agreement: cv <= 5 ? "consistent" : cv <= 10 ? "variable" : "noisy" };
42
+ }
43
+
44
+ // "noisy (CV 14.4%)", for the reports
45
+ export const describeAgreement = ({ cv, agreement }) => (cv === undefined ? "" : `${agreement} (CV ${cv}%)`);
46
+
27
47
  // "3 runs, median by LCP: run 2 (1 failed)", for the header of the reports
28
48
  export function describeRuns({ count, failed, medianRun, medianBy, medianByRequested }) {
29
49
  const by = medianByRequested ? `${medianBy} (no run measured ${medianByRequested})` : medianBy;
@@ -48,7 +68,7 @@ export function summarizeRuns(runs, { medianBy: requested } = {}) {
48
68
  const values = runs.map((run) => (run.error ? null : (valueOf(run, metric) ?? null)));
49
69
  const sorted = values.filter((value) => value !== null).sort((a, b) => a - b);
50
70
  const unit = completed.flatMap((run) => run.results).find((r) => r.metric === metric && r.unit)?.unit;
51
- summary[metric] = { median: middle(sorted), min: sorted[0], max: sorted[sorted.length - 1], values, unit };
71
+ summary[metric] = { median: middle(sorted), min: sorted[0], max: sorted[sorted.length - 1], values, unit, ...agreementOf(sorted, metric) };
52
72
  }
53
73
 
54
74
  return {
package/src/stats.js ADDED
@@ -0,0 +1,108 @@
1
+ // Statistics for comparing the runs of two measurements, without a browser: whether one sample tends to
2
+ // be greater than the other (Mann-Whitney U, one-sided) and by how much (the Hodges-Lehmann shift with its
3
+ // 95 % interval). Both work on ranks, so one slow run moves them less than it moves a mean.
4
+
5
+ const EXACT_UP_TO = 16;
6
+ const Z_975 = 1.959963984540054;
7
+
8
+ // The ranks of the pooled values, with the mean rank for ties, and the sizes of the groups of ties
9
+ function rank(values) {
10
+ const order = values.map((value, i) => ({ value, i })).sort((a, b) => a.value - b.value);
11
+ const ranks = new Array(values.length);
12
+ const ties = [];
13
+ for (let start = 0; start < order.length; ) {
14
+ let end = start;
15
+ while (end + 1 < order.length && order[end + 1].value === order[start].value) end++;
16
+ for (let k = start; k <= end; k++) ranks[order[k].i] = (start + end) / 2 + 1;
17
+ if (end > start) ties.push(end - start + 1);
18
+ start = end + 1;
19
+ }
20
+ return { ranks, ties };
21
+ }
22
+
23
+ // How many splits of m + n distinct values give each U, from U = 0 to m * n. A split with the largest
24
+ // value in the first sample has U of the split without it plus n; otherwise, U of the split without it.
25
+ function exactCounts(m, n) {
26
+ let prev = Array.from({ length: n + 1 }, () => [1]);
27
+ for (let i = 1; i <= m; i++) {
28
+ const row = [new Array(1).fill(1)];
29
+ for (let j = 1; j <= n; j++) {
30
+ const counts = new Array(i * j + 1).fill(0);
31
+ prev[j].forEach((c, u) => (counts[u + j] += c));
32
+ row[j - 1].forEach((c, u) => (counts[u] += c));
33
+ row.push(counts);
34
+ }
35
+ prev = row;
36
+ }
37
+ return prev[n];
38
+ }
39
+
40
+ // The complementary error function, with a fractional error under 1.2e-7 (Numerical Recipes, erfcc)
41
+ function erfc(x) {
42
+ const z = Math.abs(x);
43
+ const t = 1 / (1 + 0.5 * z);
44
+ const r =
45
+ t *
46
+ Math.exp(
47
+ -z * z -
48
+ 1.26551223 +
49
+ t * (1.00002368 + t * (0.37409196 + t * (0.09678418 + t * (-0.18628806 + t * (0.27886807 + t * (-1.13520398 + t * (1.48851587 + t * (-0.82215223 + t * 0.17087277))))))))
50
+ );
51
+ return x >= 0 ? r : 2 - r;
52
+ }
53
+
54
+ const upperTail = (z) => erfc(z / Math.SQRT2) / 2;
55
+
56
+ const isExact = (m, n, ties) => m <= EXACT_UP_TO && n <= EXACT_UP_TO && ties.length === 0;
57
+
58
+ // Whether `sample` tends to be greater than `reference`: U counts the pairs where it is (a tie counts
59
+ // half), and p is the probability of a U this large or larger if both came from the same distribution.
60
+ // For a regression of a timing, the sample is the current runs and the reference the baseline.
61
+ export function mannWhitneyGreater(sample, reference) {
62
+ const m = sample.length;
63
+ const n = reference.length;
64
+ const { ranks, ties } = rank([...sample, ...reference]);
65
+ const rankSum = ranks.slice(0, m).reduce((sum, r) => sum + r, 0);
66
+ const u = rankSum - (m * (m + 1)) / 2;
67
+
68
+ if (isExact(m, n, ties)) {
69
+ const counts = exactCounts(m, n);
70
+ const total = counts.reduce((sum, c) => sum + c, 0);
71
+ const above = counts.slice(Math.ceil(u)).reduce((sum, c) => sum + c, 0);
72
+ return { u, p: above / total, method: "exact" };
73
+ }
74
+
75
+ const N = m + n;
76
+ const tieTerm = ties.reduce((sum, t) => sum + t ** 3 - t, 0) / (N * (N - 1));
77
+ const sigma = Math.sqrt(((m * n) / 12) * (N + 1 - tieTerm));
78
+ const z = (u - (m * n) / 2 - 0.5) / sigma;
79
+ return { u, p: upperTail(z), method: "normal" };
80
+ }
81
+
82
+ const median = (sorted) => {
83
+ const mid = sorted.length / 2;
84
+ return sorted.length % 2 ? sorted[Math.floor(mid)] : (sorted[mid - 1] + sorted[mid]) / 2;
85
+ };
86
+
87
+ // By how much `sample` is greater than `reference`: the median of the differences of every pair, and the
88
+ // 95 % interval between two of those differences, chosen from the distribution of U. With samples too
89
+ // small for 95 %, such as three runs a side, the interval is every difference.
90
+ export function hodgesLehmann(reference, sample) {
91
+ const m = sample.length;
92
+ const n = reference.length;
93
+ const diffs = sample.flatMap((s) => reference.map((r) => s - r)).sort((a, b) => a - b);
94
+ const { ties } = rank([...sample, ...reference]);
95
+
96
+ let k;
97
+ if (isExact(m, n, ties)) {
98
+ // The largest k with P(U <= k - 1) at most 2.5 %
99
+ const counts = exactCounts(m, n);
100
+ const total = counts.reduce((sum, c) => sum + c, 0);
101
+ let below = 0;
102
+ k = 0;
103
+ while (k < counts.length && (below + counts[k]) / total <= 0.025) below += counts[k++];
104
+ } else {
105
+ k = Math.max(0, Math.floor((m * n) / 2 - Z_975 * Math.sqrt((m * n * (m + n + 1)) / 12)));
106
+ }
107
+ return { shift: median(diffs), ci: [diffs[k], diffs[m * n - 1 - k]] };
108
+ }