webperf-snippets 1.8.0 → 1.10.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.10.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
77
77
  ```
78
78
 
79
79
  Single snippet:
@@ -136,6 +136,19 @@ Field data (CrUX p75, mobile)
136
136
 
137
137
  Get a key for the [Chrome UX Report API](https://developer.chrome.com/docs/crux/api) and pass it with `--crux-key` or the `CRUX_API_KEY` environment variable; prefer the variable, because a flag ends up in shell history and CI logs. The section appears in the human and markdown output and under `crux` in the `--json` output.
138
138
 
139
+ With a key, the field data also gives what CrUX knows of the LCP when it is an image: the p75 of its sub-parts, TTFB, load delay, load duration and render delay. When the measurement ran LCP-Subparts, with `--snippet LCP-Subparts` or the `core-web-vitals` workflow over 2.5 s, a table puts each one next to its lab value, with a note where one side is at least 1.5 times the other and 100 ms apart: a slower server or network for users, an image the lab finds sooner, slower connections, slower devices, or a lab slower than users. The field sub-parts describe only the loads whose LCP is an image, and the table says which share that is; when the LCP of the lab is not an image, only TTFB and render delay compare. Each p75 is taken on its own, so the sub-parts do not add up to the p75 of LCP. A line gives the navigation types of the users, by share, and points to Back-Forward-Cache when over 5 % of the visits come back without the back/forward cache. The JSON output carries them in `crux.subParts`, `crux.lcpImageShare`, `crux.roundTripTime` and `crux.navigationTypes`.
140
+
141
+ `--crux-history <weeks>` adds the trend of the field data over the last 1 to 40 weeks, from the [CrUX History API](https://developer.chrome.com/docs/crux/history-api), with the same key. Each week is the p75 of the 28 days before it, so the weeks overlap and the series is smooth. For LCP, CLS and INP the section gives the first and the last week, the change between them and its direction, `stable` within 5 %, and the weeks as a sparkline, where a gap is a week with too little data:
142
+
143
+ ```
144
+ Field data trend (CrUX p75, 40 weeks, mobile, origin-level)
145
+ LCP 2.88s → 3.10s ▲ +7.7% worsening ▃▃▄▃▃▃▄▆▇██▇▆▄▂▁▂▃▃▄▅▆▆▆▆▆▆
146
+ CLS 0.2600 → 0.2100 ▼ −19.2% improving ▅▆▆▇▇█▇▆▆▅▄▃▃▃▃▃▂▂▁▂▂▂▂▂▂▂
147
+ INP 253ms → 251ms ▼ −0.8% stable ▇▅▃▂▂▄▆██▇▅▃▂▃▄▆▅▃▂▁▂▃▃▄▅▆
148
+ ```
149
+
150
+ The JSON output carries it in `crux.history`, with the periods, the p75 of each week and the trend of each metric. Like the p75, it uses the page when it has data of its own and its origin otherwise, and says which in `scope`.
151
+
139
152
  - The population follows `--viewport`: `desktop` uses desktop data, `mobile` and `tablet` use phone data. The heading says which one.
140
153
  - When the page itself has no data, the CLI looks the origin up instead and labels the section `origin-level`, so it is not mistaken for page-level data. If the origin has none either, the output says `Field data: not available`.
141
154
  - A CrUX error (quota, network, no answer within 10 seconds) only skips the section. It never changes the exit code, and the key is never printed.
@@ -158,7 +171,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
158
171
  "timestamp": "2026-05-06T10:00:00.000Z",
159
172
  "navMs": 1240,
160
173
  "results": [],
161
- "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.8.0" }
174
+ "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.10.0" }
162
175
  }
163
176
  ```
164
177
 
@@ -190,7 +203,7 @@ One page load is one sample, and lab values vary from one load to the next. `--r
190
203
  npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
191
204
  ```
192
205
 
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`:
206
+ 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
207
 
195
208
  ```json
196
209
  "runs": {
@@ -199,12 +212,12 @@ The median run is the one with the median LCP. A workflow without LCP uses the f
199
212
  "medianRun": 3,
200
213
  "medianBy": "LCP",
201
214
  "summary": {
202
- "LCP": { "median": 2140, "min": 1980, "max": 2610, "values": [2610, 2010, 2140, 1980, 2290], "unit": "ms" }
215
+ "LCP": { "median": 2140, "min": 1980, "max": 2610, "values": [2610, 2010, 2140, 1980, 2290], "unit": "ms", "cv": 11.6, "agreement": "noisy" }
203
216
  }
204
217
  }
205
218
  ```
206
219
 
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`.
220
+ `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
221
 
209
222
  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
223
 
@@ -238,7 +251,7 @@ npx webperf-snippets --matrix matrix.json --budget-lcp 2500
238
251
 
239
252
  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
253
 
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.
254
+ 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
255
 
243
256
  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
257
 
@@ -257,6 +270,8 @@ npx webperf-snippets https://pr-123.example.com --runs 5 --baseline baseline.jso
257
270
 
258
271
  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
272
 
273
+ 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.
274
+
260
275
  | Metric | Share of the baseline | Minimum |
261
276
  | -------------- | --------------------- | ------- |
262
277
  | LCP, FCP, TTFB | 10% | 100 ms |
@@ -282,7 +297,7 @@ The report adds the comparison after the usual one, and `--markdown` a table for
282
297
  }
283
298
  ```
284
299
 
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.
300
+ 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
301
 
287
302
  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
303
 
@@ -325,6 +340,7 @@ Each budget compares the value measured, or the median with `--runs`, and fails
325
340
  | `--threshold <metric>=<v>` | The threshold of a metric for `--baseline`, such as `LCP=15%`, `LCP=200ms` or `CLS=0.1`. Repeat it for more metrics. |
326
341
  | `--fail-on-regression` | Exit `1` when a metric regressed or an error is new against `--baseline`. |
327
342
  | `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
343
+ | `--crux-history <weeks>` | Add the trend of the field data over the last 1 to 40 weeks. Needs a CrUX key. |
328
344
  | `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
329
345
  | `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
330
346
  | `--budget-lcp <ms>` | Exit `1` if LCP exceeds this value or was not measured. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webperf-snippets",
3
- "version": "1.8.0",
3
+ "version": "1.10.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
@@ -10,7 +10,7 @@ import { reportHuman, reportMatrixHuman } from "./reporters/human.js";
10
10
  import { reportJson } from "./reporters/json.js";
11
11
  import { reportMarkdown, reportMatrixMarkdown } from "./reporters/markdown.js";
12
12
  import { cliVersion, reportTo, validateReportUrl } from "./report-to.js";
13
- import { fetchCrux, formFactorFor } from "./crux.js";
13
+ import { fetchCrux, fetchCruxHistory, formFactorFor } from "./crux.js";
14
14
  import { MissingBrowserError } from "./browser.js";
15
15
  import { parseHeader } from "./headers.js";
16
16
  import { NETWORK_PRESETS, parseCpuFactor } from "./throttling.js";
@@ -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";
@@ -79,6 +80,7 @@ Options:
79
80
  LCP=200ms or CLS=0.1. Repeat it for more metrics
80
81
  --fail-on-regression Exit 1 when a metric regressed or an error is new against --baseline
81
82
  --crux-key <key> Add CrUX field data (p75) next to the measured values.
83
+ --crux-history <weeks> Add the trend of the field data over the last 1 to 40 weeks (needs a CrUX key).
82
84
  Also read from the CRUX_API_KEY environment variable
83
85
  --report-to <url> POST the results to this https URL after the run
84
86
  (without it the CLI makes no external calls)
@@ -174,6 +176,7 @@ async function main() {
174
176
  threshold: { type: "string", multiple: true },
175
177
  "fail-on-regression": { type: "boolean" },
176
178
  "crux-key": { type: "string" },
179
+ "crux-history": { type: "string" },
177
180
  "report-to": { type: "string" },
178
181
  "api-key": { type: "string" },
179
182
  verbose: { type: "boolean" },
@@ -252,6 +255,19 @@ async function main() {
252
255
  }
253
256
  if (medianBy != null && runCount == null && !matrixPath) fail("--median-by needs --runs");
254
257
 
258
+ // The field data: the p75 of the last 28 days with a key, and its trend week by week with --crux-history
259
+ const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
260
+ let cruxWeeks;
261
+ if (values["crux-history"] !== undefined) {
262
+ cruxWeeks = /^\d+$/.test(values["crux-history"]) ? Number(values["crux-history"]) : NaN;
263
+ if (!(cruxWeeks >= 1 && cruxWeeks <= 40)) fail(`--crux-history takes 1 to 40 weeks, not "${values["crux-history"]}"`);
264
+ if (!cruxKey) fail("--crux-history needs a CrUX key: --crux-key or CRUX_API_KEY");
265
+ }
266
+ const fieldDataOf = async (url, formFactor) => {
267
+ const crux = await fetchCrux({ url, apiKey: cruxKey, formFactor });
268
+ return cruxWeeks ? { ...crux, history: await fetchCruxHistory({ url, apiKey: cruxKey, formFactor, weeks: cruxWeeks }) } : crux;
269
+ };
270
+
255
271
  // The baseline is read and checked before the browser opens: a wrong file fails at once
256
272
  let baseline;
257
273
  let thresholds;
@@ -287,9 +303,11 @@ async function main() {
287
303
  const options = { waitMs, headless: !values.headed, interactScript, ...conditions };
288
304
  return items ? runSnippets({ ...options, items }) : runMeasurement({ ...options, workflow, rules: RULES });
289
305
  };
290
- // What the JSON records of how each page was measured, for a later run to compare with
306
+ // What the JSON records of how each page was measured, for a later run to compare with. The machine is
307
+ // measured once, before Chromium starts, so its processes do not slow the benchmark down.
308
+ const machine = machineOf();
291
309
  const conditionsFor = (viewport) =>
292
- measurementConditions({ viewport, waitMs, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
310
+ measurementConditions({ viewport, waitMs, machine, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
293
311
 
294
312
  if (matrixPath) {
295
313
  let matrix;
@@ -298,7 +316,7 @@ async function main() {
298
316
  } catch (err) {
299
317
  fail(err.message);
300
318
  }
301
- await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets });
319
+ await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets, cruxKey, fieldDataOf });
302
320
  return;
303
321
  }
304
322
 
@@ -313,9 +331,7 @@ async function main() {
313
331
  }
314
332
  payload.conditions = conditionsFor(viewportName);
315
333
 
316
- const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
317
- const crux = cruxKey ? await fetchCrux({ url, apiKey: cruxKey, formFactor: formFactorFor(viewportName) }) : undefined;
318
- if (crux) payload.crux = crux;
334
+ if (cruxKey) payload.crux = await fieldDataOf(url, formFactorFor(viewportName));
319
335
 
320
336
  if (baseline) {
321
337
  payload.baseline = compareRun(baseline.data, payload, { file: baseline.file, thresholds });
@@ -391,7 +407,7 @@ async function measureRuns(measure, count, medianBy, progress) {
391
407
 
392
408
  // Every combination of the matrix, in order, each one `matrix.runs` times. Each combination reports
393
409
  // its median run, and the exit code follows the same rules as one page, for every combination.
394
- async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets }) {
410
+ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets, cruxKey, fieldDataOf }) {
395
411
  for (const warning of matrix.warnings) process.stderr.write(`Warning: ${warning}\n`);
396
412
  const viewportOf = new Map(matrix.combinations.map((c) => [c.id, c.viewport]));
397
413
 
@@ -411,13 +427,12 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
411
427
  }
412
428
 
413
429
  // The field data of a URL depends on the form factor, not on the variant: one query for each pair
414
- const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
415
430
  if (cruxKey) {
416
431
  const queries = new Map();
417
432
  for (const c of combinations.filter((c) => !c.error)) {
418
433
  const formFactor = formFactorFor(viewportOf.get(c.id));
419
434
  const key = `${c.url} ${formFactor}`;
420
- if (!queries.has(key)) queries.set(key, fetchCrux({ url: c.url, apiKey: cruxKey, formFactor }));
435
+ if (!queries.has(key)) queries.set(key, fieldDataOf(c.url, formFactor));
421
436
  const crux = await queries.get(key);
422
437
  if (crux) c.crux = crux;
423
438
  }
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/crux.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { rate } from "./ratings.js";
2
2
 
3
3
  const DEFAULT_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord";
4
+ // The History API lives next to the daily one; an endpoint given for tests or a proxy points at both
5
+ const historyEndpointOf = (endpoint) => endpoint.replace(/records:queryRecord$/, "records:queryHistoryRecord");
4
6
  const CRUX_TIMEOUT_MS = 10000;
5
7
 
6
8
  const METRICS = [
@@ -27,6 +29,42 @@ function parseMetrics(record) {
27
29
  return metrics;
28
30
  }
29
31
 
32
+ // The sub-parts of the LCP as CrUX reports them, for the loads whose LCP is an image, by the names of
33
+ // the lab sub-parts of LCP-Subparts
34
+ const SUB_PARTS = [
35
+ { part: "ttfb", label: "TTFB", key: "largest_contentful_paint_image_time_to_first_byte" },
36
+ { part: "resourceLoadDelay", label: "Load delay", key: "largest_contentful_paint_image_resource_load_delay" },
37
+ { part: "resourceLoadTime", label: "Load duration", key: "largest_contentful_paint_image_resource_load_duration" },
38
+ { part: "elementRenderDelay", label: "Render delay", key: "largest_contentful_paint_image_element_render_delay" },
39
+ ];
40
+
41
+ const p75Of = (record, key) => {
42
+ const value = Number(record?.metrics?.[key]?.percentiles?.p75);
43
+ return Number.isFinite(value) ? value : undefined;
44
+ };
45
+
46
+ // What else the record of a page says besides LCP, CLS and INP: the sub-parts of an image LCP, the
47
+ // share of loads whose LCP is an image, the round trip time and the navigation types, by share
48
+ export function parseFieldExtras(record) {
49
+ const extras = {};
50
+ const subParts = {};
51
+ for (const { part, key } of SUB_PARTS) {
52
+ const value = p75Of(record, key);
53
+ if (value !== undefined) subParts[part] = value;
54
+ }
55
+ if (Object.keys(subParts).length) extras.subParts = subParts;
56
+ const imageShare = record?.metrics?.largest_contentful_paint_resource_type?.fractions?.image;
57
+ if (typeof imageShare === "number") extras.lcpImageShare = imageShare;
58
+ const rtt = p75Of(record, "round_trip_time");
59
+ if (rtt !== undefined) extras.roundTripTime = rtt;
60
+ const fractions = record?.metrics?.navigation_types?.fractions;
61
+ if (fractions) {
62
+ const sorted = Object.entries(fractions).filter(([, share]) => share > 0).sort((a, b) => b[1] - a[1]);
63
+ if (sorted.length) extras.navigationTypes = Object.fromEntries(sorted);
64
+ }
65
+ return extras;
66
+ }
67
+
30
68
  async function query({ endpoint, apiKey, body, timeoutMs }) {
31
69
  const res = await fetch(`${endpoint}?key=${encodeURIComponent(apiKey)}`, {
32
70
  method: "POST",
@@ -61,7 +99,78 @@ export async function fetchCrux({
61
99
  if (answer.status) return { unavailable: `the CrUX API answered ${answer.status}`, failed: true };
62
100
  const metrics = parseMetrics(answer.record);
63
101
  if (Object.keys(metrics).length === 0) return { unavailable: "no Core Web Vitals in the CrUX record" };
64
- return { scope, formFactor, metrics };
102
+ return { scope, formFactor, metrics, ...parseFieldExtras(answer.record) };
103
+ } catch (err) {
104
+ const reason = err.name === "TimeoutError" ? `no answer within ${timeoutMs}ms` : (err.cause?.code ?? "request failed");
105
+ return { unavailable: reason, failed: true };
106
+ }
107
+ }
108
+
109
+ // A trend compares the first and the last periods with data: each period already covers 28 days, so the
110
+ // series is smooth enough. Within 5 % it is stable; lower is better for every metric.
111
+ const STABLE_WITHIN = 5;
112
+
113
+ export function trendOf(p75s) {
114
+ const values = p75s.filter((v) => v !== null);
115
+ const first = values[0];
116
+ const last = values.at(-1);
117
+ if (values.length < 2) return { first, last, change: null, trend: null };
118
+ const change = first === 0 ? null : Math.round(((last - first) / first) * 1000) / 10;
119
+ const trend = change === null || Math.abs(change) <= STABLE_WITHIN ? "stable" : change < 0 ? "improving" : "worsening";
120
+ return { first, last, change, trend };
121
+ }
122
+
123
+ const BARS = "▁▂▃▄▅▆▇█";
124
+
125
+ // The series as bars between its lowest and highest values, a space for a period without data
126
+ export function sparkline(values) {
127
+ const known = values.filter((v) => v !== null);
128
+ const min = Math.min(...known);
129
+ const max = Math.max(...known);
130
+ return values.map((v) => (v === null ? " " : max === min ? BARS[3] : BARS[Math.round(((v - min) / (max - min)) * 7)])).join("");
131
+ }
132
+
133
+ const dateOf = ({ year, month, day }) => `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
134
+
135
+ // The record of the History API as periods, oldest first, and the p75s of each Core Web Vital
136
+ export function parseHistory(record) {
137
+ const periods = (record?.collectionPeriods ?? []).map((p) => ({ start: dateOf(p.firstDate), end: dateOf(p.lastDate) }));
138
+ const metrics = {};
139
+ for (const m of METRICS) {
140
+ const raw = record?.metrics?.[m.key]?.percentilesTimeseries?.p75s;
141
+ if (!raw) continue;
142
+ // CLS comes as strings ("0.21"), and a period with too little data as null
143
+ const p75s = raw.map((v) => (v === null || v === undefined || !Number.isFinite(Number(v)) ? null : Number(v)));
144
+ if (p75s.every((v) => v === null)) continue;
145
+ metrics[m.name] = { unit: m.unit, p75s, ...trendOf(p75s) };
146
+ }
147
+ return { periods, metrics };
148
+ }
149
+
150
+ // The p75 of LCP, CLS and INP week by week over the last `weeks` periods (1 to 40), from the History
151
+ // API: the URL, or its origin when the URL has too little traffic. Never throws, and like fetchCrux
152
+ // never puts the key in a message.
153
+ export async function fetchCruxHistory({
154
+ url,
155
+ apiKey,
156
+ weeks,
157
+ formFactor = "PHONE",
158
+ endpoint = process.env.WEBPERF_CRUX_ENDPOINT ?? DEFAULT_ENDPOINT,
159
+ timeoutMs = CRUX_TIMEOUT_MS,
160
+ }) {
161
+ const history = historyEndpointOf(endpoint);
162
+ try {
163
+ let scope = "url";
164
+ let answer = await query({ endpoint: history, apiKey, body: { url, formFactor, collectionPeriodCount: weeks }, timeoutMs });
165
+ if (answer.notFound) {
166
+ scope = "origin";
167
+ answer = await query({ endpoint: history, apiKey, body: { origin: new URL(url).origin, formFactor, collectionPeriodCount: weeks }, timeoutMs });
168
+ }
169
+ if (answer.notFound) return { unavailable: "not in the CrUX dataset" };
170
+ if (answer.status) return { unavailable: `the CrUX History API answered ${answer.status}`, failed: true };
171
+ const { periods, metrics } = parseHistory(answer.record);
172
+ if (Object.keys(metrics).length === 0) return { unavailable: "no Core Web Vitals in the CrUX history" };
173
+ return { scope, formFactor, weeks: periods.length, periods, metrics };
65
174
  } catch (err) {
66
175
  const reason = err.name === "TimeoutError" ? `no answer within ${timeoutMs}ms` : (err.cause?.code ?? "request failed");
67
176
  return { unavailable: reason, failed: true };
@@ -84,3 +193,79 @@ export function fieldDataView(crux, results) {
84
193
  });
85
194
  return { title, rows };
86
195
  }
196
+
197
+ // The trend of the field data, shared by both reporters: per metric the first and last p75 of the
198
+ // weeks, the change and its direction, and the series as a sparkline
199
+ export function historyView(history) {
200
+ const label = FORM_FACTOR_LABEL[history.formFactor] ?? history.formFactor.toLowerCase();
201
+ const title = `Field data trend (CrUX p75, ${history.weeks} weeks, ${label}${history.scope === "origin" ? ", origin-level" : ""})`;
202
+ const rows = METRICS.filter((m) => history.metrics[m.name]).map((m) => {
203
+ const { p75s, first, last, change, trend } = history.metrics[m.name];
204
+ const changeText = change === null ? "·" : `${change > 0 ? "▲ +" : change < 0 ? "▼ −" : "= "}${Math.abs(change)}%`;
205
+ return { metric: m.name, unit: m.unit, first, last, change: changeText, trend: trend ?? "·", sparkline: sparkline(p75s) };
206
+ });
207
+ return { title, rows };
208
+ }
209
+
210
+ const share = (fraction) => (fraction < 0.01 ? `${Math.round(fraction * 1000) / 10}%` : `${Math.round(fraction * 100)}%`);
211
+
212
+ // A sub-part counts as different when one side is at least 1.5 times the other and 100 ms apart: less
213
+ // than that is within the noise of the lab and of a percentile
214
+ const differs = (slow, fast) => slow >= fast * 1.5 && slow - fast >= 100;
215
+
216
+ const rttNote = (rtt) => (rtt === undefined ? "" : ` (RTT p75 ${rtt} ms)`);
217
+ const FIELD_SLOWER = {
218
+ ttfb: (rtt) => `real users wait longer for the server: network distance${rttNote(rtt)}, the CDN or cache misses the lab does not see`,
219
+ resourceLoadDelay: () => 'the lab finds the image sooner: preload it or give it fetchpriority="high", or users run more scripts before it',
220
+ resourceLoadTime: (rtt) => `slower connections in the field${rttNote(rtt)}: compress or resize the LCP image`,
221
+ elementRenderDelay: () => "slower devices or more main-thread work in the field",
222
+ };
223
+ const LAB_SLOWER = "the lab is slower than users: check the CPU and network throttling of the lab";
224
+
225
+ // The sub-parts of the field next to those of LCP-Subparts in the lab, when it ran, with a note where
226
+ // they differ, and what the field sub-parts describe. Null without field sub-parts.
227
+ export function subPartsView(extras, results) {
228
+ if (!extras.subParts) return null;
229
+ const labResult = results.find((r) => r.script === "LCP-Subparts" && r.status === "ok");
230
+ const labParts = labResult?.details?.subParts ?? {};
231
+ // The field sub-parts are those of image LCPs: a lab LCP without a resource, such as text, has no load
232
+ // delay or duration to set against them
233
+ const labImage = !labResult || Boolean(labResult.details?.url);
234
+ const rows = SUB_PARTS.filter(({ part }) => extras.subParts[part] !== undefined).map(({ part, label }) => {
235
+ const field = extras.subParts[part];
236
+ const comparable = labImage || part === "ttfb" || part === "elementRenderDelay";
237
+ const lab = comparable ? (labParts[part]?.value ?? null) : null;
238
+ let note = null;
239
+ if (lab !== null && differs(field, lab)) note = FIELD_SLOWER[part](extras.roundTripTime);
240
+ else if (lab !== null && differs(lab, field)) note = LAB_SLOWER;
241
+ return { part, label, field, lab, note };
242
+ });
243
+ const caveats = [
244
+ ...(extras.lcpImageShare !== undefined ? [`The field sub-parts describe the ${share(extras.lcpImageShare)} of loads whose LCP is an image.`] : []),
245
+ ...(labImage ? [] : ["The LCP of the lab is not an image, so it has no load delay or duration to compare."]),
246
+ "Each p75 is taken on its own, so the sub-parts do not add up to the p75 of LCP.",
247
+ ];
248
+ return { rows, caveats };
249
+ }
250
+
251
+ const NAVIGATION_LABELS = {
252
+ navigate: "navigate",
253
+ navigate_cache: "navigate from cache",
254
+ reload: "reload",
255
+ restore: "restore",
256
+ back_forward: "back/forward",
257
+ back_forward_cache: "back/forward cache",
258
+ prerender: "prerender",
259
+ };
260
+
261
+ // How users arrive at the page, by share, and a pointer to Back-Forward-Cache when over 5 % of the
262
+ // visits come back without the back/forward cache, which would have shown the page at once
263
+ export function navigationView(extras) {
264
+ if (!extras.navigationTypes) return null;
265
+ const parts = Object.entries(extras.navigationTypes).map(([type, fraction]) => `${NAVIGATION_LABELS[type] ?? type} ${share(fraction)}`);
266
+ const missed = extras.navigationTypes.back_forward ?? 0;
267
+ return {
268
+ line: `Navigations: ${parts.join(", ")}`,
269
+ hint: missed > 0.05 ? `${share(missed)} of the visits come back without the back/forward cache: run Back-Forward-Cache to see what blocks it.` : null,
270
+ };
271
+ }
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
+ }
package/src/mcp/server.js CHANGED
@@ -7,7 +7,7 @@ import { isTrackingSnippet } from "../tracking.js";
7
7
  import { runSnippets, runMeasurement, evaluateItems, collectTrackingData, VIEWPORT_PRESETS } from "../runner.js";
8
8
  import { WORKFLOWS } from "../workflows/index.js";
9
9
  import { RULES } from "../decision-tree.js";
10
- import { fetchCrux } from "../crux.js";
10
+ import { fetchCrux, fetchCruxHistory } from "../crux.js";
11
11
  import { runInteractions, isInputStep, settle } from "../interactions.js";
12
12
  import { createSessions, SessionError } from "./sessions.js";
13
13
  import { launchChromium, isMissingBrowser, MISSING_BROWSER } from "../browser.js";
@@ -295,10 +295,11 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
295
295
  {
296
296
  title: "Get CrUX field data",
297
297
  description:
298
- "Returns the p75 of LCP, CLS and INP that real Chrome users had on a URL over the last 28 days, from the Chrome UX Report, with their rating. Falls back to the origin when the URL has no data of its own. Compare it with the lab values of run_snippet: field data is what users get.",
298
+ "Returns the p75 of LCP, CLS and INP that real Chrome users had on a URL over the last 28 days, from the Chrome UX Report, with their rating. Falls back to the origin when the URL has no data of its own. Compare it with the lab values of run_snippet: field data is what users get. With weeks, it adds the trend: the p75 of each week, each the 28 days before it, with the change from the first week to the last.",
299
299
  inputSchema: z.object({
300
300
  url: HttpUrl,
301
301
  formFactor: z.enum(Object.keys(CRUX_FORM_FACTORS)).default("phone").describe("The devices the data describes"),
302
+ weeks: z.number().int().min(1).max(40).optional().describe("Add the trend of the last 1 to 40 weeks"),
302
303
  }),
303
304
  outputSchema: z.object({
304
305
  url: z.string(),
@@ -309,21 +310,38 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
309
310
  .optional()
310
311
  .describe("By metric name: LCP, CLS, INP"),
311
312
  unavailable: z.string().optional().describe("Why there is no field data, such as a page with too little traffic"),
313
+ subParts: z
314
+ .record(z.string(), z.number())
315
+ .optional()
316
+ .describe("The p75 of the sub-parts of an image LCP: ttfb, resourceLoadDelay, resourceLoadTime, elementRenderDelay"),
317
+ lcpImageShare: z.number().optional().describe("The share of loads whose LCP is an image, which the sub-parts describe"),
318
+ roundTripTime: z.number().optional().describe("The p75 of the round trip time of the users, in ms"),
319
+ navigationTypes: z.record(z.string(), z.number()).optional().describe("The share of each navigation type, such as navigate, reload or back_forward_cache"),
320
+ history: z
321
+ .looseObject({})
322
+ .meta({ additionalProperties: true })
323
+ .optional()
324
+ .describe("With weeks: scope, weeks, periods, and per metric its p75s, first, last, change in percent and trend"),
312
325
  }),
313
326
  annotations: { readOnlyHint: true, openWorldHint: true },
314
327
  },
315
- async ({ url, formFactor }) => {
328
+ async ({ url, formFactor, weeks }) => {
316
329
  const apiKey = env.CRUX_API_KEY;
317
330
  if (!apiKey) return toolError(CRUX_SETUP);
318
- const crux = await fetchCrux({
331
+ const options = {
319
332
  url,
320
333
  apiKey,
321
334
  formFactor: CRUX_FORM_FACTORS[formFactor],
322
335
  ...(env.WEBPERF_CRUX_ENDPOINT ? { endpoint: env.WEBPERF_CRUX_ENDPOINT } : {}),
323
- });
336
+ };
337
+ const crux = await fetchCrux(options);
324
338
  if (crux.failed) return toolError(`Could not read the CrUX data: ${crux.unavailable}.`);
325
- if (crux.unavailable) return toolResult({ url, formFactor, unavailable: crux.unavailable });
326
- return toolResult({ url, formFactor, scope: crux.scope, metrics: crux.metrics });
339
+ const history = weeks ? await fetchCruxHistory({ ...options, weeks }) : undefined;
340
+ // A history that failed says why in its own unavailable, next to the p75 that did come
341
+ const trend = history ? { history: history.failed ? { unavailable: history.unavailable } : history } : {};
342
+ if (crux.unavailable) return toolResult({ url, formFactor, unavailable: crux.unavailable, ...trend });
343
+ const { scope, metrics, subParts, lcpImageShare, roundTripTime, navigationTypes } = crux;
344
+ return toolResult({ url, formFactor, scope, metrics, subParts, lcpImageShare, roundTripTime, navigationTypes, ...trend });
327
345
  }
328
346
  );
329
347
 
@@ -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`}`;
@@ -1,9 +1,9 @@
1
1
  import { styleText } from "node:util";
2
2
  import { describeResult } from "../view.js";
3
3
  import { isSummaryResult, summaryNumbers } from "./summary-view.js";
4
- import { fieldDataView } from "../crux.js";
4
+ import { fieldDataView, historyView, subPartsView, navigationView } 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";
@@ -197,7 +197,8 @@ function renderResult(r, verbose) {
197
197
  }
198
198
 
199
199
  function renderFieldData(crux, results) {
200
- if (crux.unavailable) return [styleText("dim", `Field data: not available (${crux.unavailable})`)];
200
+ const trend = crux.history ? ["", ...renderFieldTrend(crux.history)] : [];
201
+ if (crux.unavailable) return [styleText("dim", `Field data: not available (${crux.unavailable})`), ...trend];
201
202
  const { title, rows } = fieldDataView(crux, results);
202
203
  const lines = [styleText("bold", title)];
203
204
  for (const row of rows) {
@@ -207,15 +208,50 @@ function renderFieldData(crux, results) {
207
208
  ` ${icon} ${pad(row.metric, 6)} ${pad(paint(row.rating, formatValue(row.p75, row.unit)), 10)} ${pad(styleText("dim", row.rating), 20)} ${styleText("dim", `(synthetic: ${synthetic})`)}`
208
209
  );
209
210
  }
210
- return lines;
211
+ return [...lines, ...renderFieldSubParts(crux, results), ...renderNavigations(crux), ...trend];
212
+ }
213
+
214
+ // "TTFB 1.53s lab 400ms real users wait longer for the server…"
215
+ function renderFieldSubParts(crux, results) {
216
+ const view = subPartsView(crux, results);
217
+ if (!view) return [];
218
+ return [
219
+ "",
220
+ styleText("bold", "LCP sub-parts (CrUX p75 against the lab)"),
221
+ ...view.rows.map(
222
+ (row) => ` ${pad(row.label, 14)}${pad(formatValue(row.field, "ms"), 10)}${pad(`lab ${row.lab === null ? "n/a" : formatValue(row.lab, "ms")}`, 12)}${row.note ?? ""}`.trimEnd()
223
+ ),
224
+ ...view.caveats.map((caveat) => styleText("dim", ` ${caveat}`)),
225
+ ];
226
+ }
227
+
228
+ function renderNavigations(crux) {
229
+ const view = navigationView(crux);
230
+ if (!view) return [];
231
+ return ["", styleText("dim", view.line), ...(view.hint ? [` ${view.hint}`] : [])];
232
+ }
233
+
234
+ // "LCP 2.88s → 3.10s ▲ +7.7% worsening ▁▅█"
235
+ function renderFieldTrend(history) {
236
+ if (history.unavailable) return [styleText("dim", `Field data trend: not available (${history.unavailable})`)];
237
+ const { title, rows } = historyView(history);
238
+ const style = { improving: "green", worsening: "red" };
239
+ return [
240
+ styleText("bold", title),
241
+ ...rows.map(
242
+ (row) =>
243
+ ` ${pad(row.metric, 6)}${pad(`${formatValue(row.first, row.unit)} → ${formatValue(row.last, row.unit)}`, 20)}${pad(row.change, 10)}${pad(style[row.trend] ? styleText(style[row.trend], row.trend) : row.trend, 12)}${row.sparkline}`
244
+ ),
245
+ ];
211
246
  }
212
247
 
213
- // The runs line, and the median, min and max of each metric across the runs
248
+ // The runs line, and the median, min and max of each metric across the runs, with how much they agree
214
249
  function renderRuns(runs) {
215
250
  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()}`));
251
+ for (const [metric, value] of Object.entries(runs.summary)) {
252
+ const { median, min, max, unit } = value;
253
+ const cell = (label, v) => pad(`${label} ${formatValue(v, unit)}`, 16);
254
+ lines.push(styleText("dim", ` ${pad(metric, 6)}${cell("median", median)}${cell("min", min)}${cell("max", max)}${describeAgreement(value)}`.trimEnd()));
219
255
  }
220
256
  return lines;
221
257
  }
@@ -274,7 +310,7 @@ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, th
274
310
  // One row per combination with the rated median of each metric, and below it the errors that decide
275
311
  // the exit code. With verbose, the report of the median run of each combination follows.
276
312
  export function reportMatrixHuman(report) {
277
- const { file, summary, hasVariants, metrics, rows, errors, comparison } = matrixView(report);
313
+ const { file, summary, hasVariants, metrics, rows, errors, comparison, legend } = matrixView(report);
278
314
  const columns = [["name", "Page"], ["device", "Device"], ...(hasVariants ? [["variant", "Variant"]] : [])];
279
315
  const widths = columns.map(([key, label]) => Math.max(...report.combinations.map((c) => c[key].length), label.length) + 3);
280
316
  const identity = (values) => values.map((value, i) => pad(value, widths[i])).join("");
@@ -292,11 +328,13 @@ export function reportMatrixHuman(report) {
292
328
  continue;
293
329
  }
294
330
  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);
331
+ const width = legend ? 18 : 14;
332
+ const value = cell ? pad(paint(cell.rating, `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}${cell.spread ? ` ${cell.spread}` : ""}`), width) : pad("·", width);
296
333
  return value + (changes ? pad(styleText(VERDICT_STYLE[changes[i].verdict], changes[i].text), 12) : "");
297
334
  });
298
335
  lines.push(row + drawn.join("").trimEnd());
299
336
  }
337
+ if (legend) lines.push(styleText("dim", ` ${legend}`));
300
338
 
301
339
  if (errors.length) {
302
340
  lines.push("");
@@ -1,8 +1,8 @@
1
- import { fieldDataView } from "../crux.js";
1
+ import { fieldDataView, historyView, subPartsView, navigationView } 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";
@@ -102,7 +102,8 @@ function renderSection(title, results) {
102
102
  }
103
103
 
104
104
  function renderFieldData(crux, results) {
105
- if (crux.unavailable) return [`> Field data: not available (${crux.unavailable})`];
105
+ const trend = crux.history ? ["", ...renderFieldTrend(crux.history)] : [];
106
+ if (crux.unavailable) return [`> Field data: not available (${crux.unavailable})`, ...trend];
106
107
  const { title, rows } = fieldDataView(crux, results);
107
108
  return [
108
109
  `### ${title}`,
@@ -112,14 +113,49 @@ function renderFieldData(crux, results) {
112
113
  const synthetic = row.synthetic == null ? "n/a" : formatValue(row.synthetic, row.unit);
113
114
  return `| ${row.metric} | ${formatValue(row.p75, row.unit)} | ${RATING_ICON[row.rating] ?? "·"} ${row.rating} | ${synthetic} |`;
114
115
  }),
116
+ ...renderFieldSubParts(crux, results),
117
+ ...renderNavigations(crux),
118
+ ...trend,
115
119
  ];
116
120
  }
117
121
 
118
- // The median, min and max of each metric across the runs
122
+ function renderFieldSubParts(crux, results) {
123
+ const view = subPartsView(crux, results);
124
+ if (!view) return [];
125
+ return [
126
+ "",
127
+ "### LCP sub-parts (CrUX p75 against the lab)",
128
+ "| Sub-part | Field p75 | Lab | Note |",
129
+ "|----------|-----------|-----|------|",
130
+ ...view.rows.map((row) => `| ${row.label} | ${formatValue(row.field, "ms")} | ${row.lab === null ? "n/a" : formatValue(row.lab, "ms")} | ${row.note ?? ""} |`),
131
+ "",
132
+ ...view.caveats.map((caveat) => `> ${caveat}`),
133
+ ];
134
+ }
135
+
136
+ function renderNavigations(crux) {
137
+ const view = navigationView(crux);
138
+ if (!view) return [];
139
+ return ["", `> ${view.line}`, ...(view.hint ? [">", `> ${view.hint}`] : [])];
140
+ }
141
+
142
+ function renderFieldTrend(history) {
143
+ if (history.unavailable) return [`> Field data trend: not available (${history.unavailable})`];
144
+ const { title, rows } = historyView(history);
145
+ return [
146
+ `### ${title}`,
147
+ "| Metric | First | Last | Change | Trend | Weeks |",
148
+ "|--------|-------|------|--------|-------|-------|",
149
+ ...rows.map((row) => `| ${row.metric} | ${formatValue(row.first, row.unit)} | ${formatValue(row.last, row.unit)} | ${row.change} | ${row.trend} | ${row.sparkline} |`),
150
+ ];
151
+ }
152
+
153
+ // The median, min and max of each metric across the runs, and how much they agree
119
154
  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)} |`);
155
+ const lines = ["| Metric | Median | Min | Max | Runs |", "|--------|--------|-----|-----|------|"];
156
+ for (const [metric, value] of Object.entries(runs.summary)) {
157
+ const { median, min, max, unit } = value;
158
+ lines.push(`| ${metric} | ${formatValue(median, unit)} | ${formatValue(min, unit)} | ${formatValue(max, unit)} | ${describeAgreement(value) || "·"} |`);
123
159
  }
124
160
  return lines;
125
161
  }
@@ -196,7 +232,7 @@ export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttli
196
232
  // report of the median run of each combination folded into a details block, to keep a pull request
197
233
  // comment readable.
198
234
  export function reportMatrixMarkdown(report) {
199
- const { file, summary, hasVariants, metrics, rows, errors, comparison } = matrixView(report);
235
+ const { file, summary, hasVariants, metrics, rows, errors, comparison, legend } = matrixView(report);
200
236
  // With --baseline, a change column follows each metric
201
237
  const metricHeadings = metrics.flatMap((m) => (comparison ? [m, "Δ"] : [m]));
202
238
  const headings = ["Page", "Device", ...(hasVariants ? ["Variant"] : []), ...metricHeadings];
@@ -208,12 +244,13 @@ export function reportMatrixMarkdown(report) {
208
244
  for (const { combination: c, cells, changes } of rows) {
209
245
  const drawn = cells
210
246
  ? cells.flatMap((cell, i) => {
211
- const value = cell ? `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}` : "·";
247
+ const value = cell ? `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}${cell.spread ? ` ${cell.spread}` : ""}` : "·";
212
248
  return changes ? [value, changes[i].text] : [value];
213
249
  })
214
250
  : ["❌ every run failed", ...metricHeadings.slice(1).map(() => "")];
215
251
  lines.push(row([c.name, c.device, ...(hasVariants ? [c.variant] : []), ...drawn]));
216
252
  }
253
+ if (legend) lines.push("", `> ${legend}`);
217
254
 
218
255
  if (errors.length) {
219
256
  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
+ }