@crvouga/atlas 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/dist/reports/src/explorer-types.d.ts +19 -0
  3. package/dist/reports/src/latency.d.ts +204 -0
  4. package/dist/reports/src/latency.js +219 -0
  5. package/dist/reports/src/reports-explorer-route.d.ts +19 -6
  6. package/dist/reports/src/reports-route.d.ts +19 -6
  7. package/dist/reports/src/test-catalog.d.ts +3 -1
  8. package/dist/reports/src/test-catalog.js +4 -2
  9. package/dist/schema/src/api.d.ts +151 -0
  10. package/dist/schema/src/api.js +4 -3
  11. package/dist/schema/src/layer.d.ts +90 -0
  12. package/dist/schema/src/layer.js +20 -1
  13. package/docs/extending.md +1 -1
  14. package/docs/reference/reports.md +347 -0
  15. package/docs/reference/schema.md +28 -0
  16. package/docs/rich-evidence.md +43 -0
  17. package/docs/ui.md +10 -1
  18. package/llms-full.txt +54 -2
  19. package/package.json +1 -1
  20. package/packages/schema/schemas/check-detail.schema.json +56 -1
  21. package/packages/schema/schemas/effective-delta.schema.json +60 -2
  22. package/packages/schema/schemas/effective-evidence.schema.json +60 -2
  23. package/packages/schema/schemas/layer-rows.schema.json +60 -2
  24. package/packages/schema/schemas/layer.schema.json +112 -2
  25. package/packages/schema/schemas/publish-checks.schema.json +56 -1
  26. package/packages/schema/schemas/test-catalog.schema.json +85 -0
  27. package/packages/schema/schemas/test-history.schema.json +170 -0
  28. package/packages/server/openapi.json +547 -8
  29. package/packages/visualizer/src/evidence/check-detail.tsx +9 -0
  30. package/packages/visualizer/src/explorer/ProductLatency.tsx +214 -0
  31. package/packages/visualizer/src/explorer/TestDetail.tsx +9 -2
package/CHANGELOG.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
- ## 1.7.0 (2026-10-29)
3
+ ## 1.8.0 (2026-10-29)
4
+
5
+ ### Features
6
+
7
+ - distinguish product latency from test duration and evaluate performance budgets ([7e06357](https://github.com/crvouga/atlas/commit/7e06357695d3c4c100fb7561cec8a89abbc6592b))
8
+
9
+ ## 1.7.0 (2026-10-28)
4
10
 
5
11
  ### Features
6
12
 
@@ -34,6 +34,25 @@ export type EvidenceMeasurement = {
34
34
  readonly unit: string;
35
35
  /** The allowance for `value`, in the same unit, when there is one. */
36
36
  readonly limit?: number;
37
+ /** What the number is, in an open vocabulary. `product-latency` is a user-visible response time between `from` and `to`; absent is just a number. */
38
+ readonly kind?: string;
39
+ /** The event the measured interval starts at. */
40
+ readonly from?: string;
41
+ /** The event the measured interval ends at. */
42
+ readonly to?: string;
43
+ /** The interval the name claims, when the measured one is only part of it. */
44
+ readonly intended?: {
45
+ readonly from?: string;
46
+ readonly to?: string;
47
+ };
48
+ /** How it was measured. */
49
+ readonly source?: string;
50
+ /** `observed` (default), `timeout`, `failed`; otherwise `value` is how long the observation waited, at least. */
51
+ readonly outcome?: string;
52
+ /** The version or method of the measurement. */
53
+ readonly instrumentation?: string;
54
+ /** What the observation is comparable with: `device`, `build`. */
55
+ readonly labels?: Readonly<Record<string, string>>;
37
56
  };
38
57
  /** What a check may carry beyond its outcome. All optional and open: see docs/extending.md. */
39
58
  export type EvidenceExtras = Partial<Pick<import('../../schema/src/layer.js').Check, 'tags' | 'labels' | 'links' | 'steps' | 'attachments' | 'annotations' | 'attempts' | 'extensions' | 'scope'>>;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @module Product latency
3
+ *
4
+ * Tells how quickly a product responded apart from how long a test took. A test's `durationMs` includes fixture setup,
5
+ * deliberate waits, demo pacing, retries and cleanup, so it says nothing about a user's wait. Product latency is only
6
+ * what a producer measured and named as such: a measurement of `kind` `product-latency` between two named events
7
+ * (`from`, `to`), optionally with a budget (`limit`), an outcome (`observed`, `timeout`, `failed`), the instrumentation,
8
+ * and labels (`device`, `build`) in its `labels`. Pure functions, safe in a browser.
9
+ *
10
+ * The rules, each of which keeps a number from looking healthier or more certain than the evidence is:
11
+ *
12
+ * - **Only named measurements are latency.** A check's duration, a video's length, a recorded step and a number with no
13
+ * `kind` are never product latency. No latency recorded is unknown, not fast.
14
+ * - **Boundaries are part of the claim.** Without both `from` and `to`, nothing is claimed from the numbers. When
15
+ * `intended` names a longer interval than the one measured (speech end to TTS dispatch, claimed as microphone to
16
+ * audible response) the measurement is partial and says so.
17
+ * - **Percentiles need samples.** p50 needs {@link MIN_P50_SAMPLES} observations and p95 {@link MIN_P95_SAMPLES}; below
18
+ * that they are withheld with the reason, never shown as if supported.
19
+ * - **A timeout or failed observation is a sample.** Its value is how long it waited, so a lower bound: it counts
20
+ * in the sample, in the percentiles and as a budget breach, and the summary says the figures are lower bounds.
21
+ * - **Comparisons are of the same metric.** Different boundaries are not compared; a change of instrumentation,
22
+ * environment, budget or other condition between the two sides is disclosed beside the change.
23
+ *
24
+ * It decides nothing: a latency, a budget breach and a comparison never change a status, a slot or a winner.
25
+ *
26
+ * @see docs/rich-evidence.md#product-latency
27
+ */
28
+ import type { EvidenceMeasurement } from './explorer-types.js';
29
+ /** The `kind` of a measurement that is a user-visible product latency. */
30
+ export declare const PRODUCT_LATENCY = "product-latency";
31
+ /** Observations needed before a p50 is shown. */
32
+ export declare const MIN_P50_SAMPLES = 5;
33
+ /** Observations needed before a p95 is shown. */
34
+ export declare const MIN_P95_SAMPLES = 20;
35
+ /** Whether a measurement is a product latency: only the producer's `kind` makes it one. A name, a unit or a value never does. */
36
+ export declare function isProductLatency(measurement: Pick<EvidenceMeasurement, 'kind'>): boolean;
37
+ /**
38
+ * The product-latency measurements of a check, or undefined when it has none (so a result with no latency carries
39
+ * nothing). Measurements that are not well formed (no finite value, no name) are left out.
40
+ */
41
+ export declare function productLatency(measurements: readonly EvidenceMeasurement[] | undefined): readonly EvidenceMeasurement[] | undefined;
42
+ /** How well the ends of a measurement are known: `declared` (both named), `partial` (it stops short of, or starts after, what it claims) or `undeclared`. */
43
+ export type BoundaryState = 'declared' | 'partial' | 'undeclared';
44
+ /** The ends of a measurement, in words, and whether they support a claim about the user's wait. */
45
+ export type Boundary = {
46
+ readonly state: BoundaryState;
47
+ readonly from?: string;
48
+ readonly to?: string;
49
+ /** The interval the measurement's name claims, when it is longer than what was measured. */
50
+ readonly intended?: string;
51
+ /** `submit pressed to confirmation visible`, `to confirmation visible (start not declared)`. */
52
+ readonly label: string;
53
+ };
54
+ /**
55
+ * What a measurement spans. `undeclared` when either end is missing: the numbers are shown and no product claim is made.
56
+ * `partial` when `intended` names a different end than the measured one: the interval is the measured one, never the intended.
57
+ */
58
+ export declare function boundaryOf(measurement: Pick<EvidenceMeasurement, 'from' | 'to' | 'intended'>): Boundary;
59
+ /** What happened to one observation: `observed`, or it `timeout`ed or `failed`, or the producer said something Atlas cannot read (`unclear`). */
60
+ export type ObservationOutcome = 'observed' | 'timeout' | 'failed' | 'unclear';
61
+ /** One recorded result, as much of it as this module reads. A `TestResultEntry` is one. */
62
+ export type LatencyEntry = {
63
+ /** When it was recorded, epoch milliseconds. */
64
+ readonly at: number;
65
+ readonly environment: string | null;
66
+ readonly parameters: Readonly<Record<string, string>>;
67
+ readonly sha: string | null;
68
+ readonly layer: string;
69
+ readonly run?: string;
70
+ readonly latency?: readonly EvidenceMeasurement[];
71
+ readonly evidence?: {
72
+ readonly labels: Readonly<Record<string, string>>;
73
+ };
74
+ };
75
+ /** One observation of a latency. Time units are converted to milliseconds. */
76
+ export type LatencyObservation = {
77
+ readonly name: string;
78
+ readonly unit: string;
79
+ readonly value: number;
80
+ readonly limit?: number;
81
+ readonly outcome: ObservationOutcome;
82
+ readonly boundary: Boundary;
83
+ readonly source?: string;
84
+ readonly instrumentation?: string;
85
+ readonly cohort: Readonly<Record<string, string>>;
86
+ readonly at: number;
87
+ readonly layer: string;
88
+ readonly environment: string | null;
89
+ readonly parameters: Readonly<Record<string, string>>;
90
+ readonly labels: Readonly<Record<string, string>>;
91
+ readonly sha: string | null;
92
+ };
93
+ /** The observations in recorded results, in the order given. Entries with no latency contribute none. */
94
+ export declare function latencyObservations(entries: readonly LatencyEntry[]): LatencyObservation[];
95
+ /** How observations are grouped: not at all, by `device`, by `build`, or by the UTC `window` (day) they were recorded in. */
96
+ export type LatencyGrouping = 'none' | 'device' | 'build' | 'window';
97
+ /** The groupings, in the order a control offers them. */
98
+ export declare const LATENCY_GROUPINGS: readonly LatencyGrouping[];
99
+ /**
100
+ * The group an observation falls in. `device` is the measurement's cohort, else the result's label, parameter or
101
+ * environment; `build` is the cohort or label, else the commit. One that names nothing is `unknown device` or
102
+ * `unknown build`: it is never merged into a named group.
103
+ */
104
+ export declare function groupOf(observation: LatencyObservation, by: LatencyGrouping): string;
105
+ /** A measured interval: the name, the unit and the ends. Observations of one metric are comparable; of two metrics they are not. */
106
+ export type LatencyMetric = {
107
+ /** Equal for observations of the same name, unit and boundaries. */
108
+ readonly key: string;
109
+ readonly name: string;
110
+ readonly unit: string;
111
+ readonly boundary: Boundary;
112
+ };
113
+ /** What the observations of one metric in one group show, and what they cannot. */
114
+ export type LatencySummary = {
115
+ readonly metric: LatencyMetric;
116
+ /** The group (a device, a build, a day, or all). */
117
+ readonly group: string;
118
+ /** Every observation, including those that timed out or failed. */
119
+ readonly samples: number;
120
+ /** Observations that completed. */
121
+ readonly observed: number;
122
+ readonly timeouts: number;
123
+ readonly failures: number;
124
+ /** Observations over their budget, plus every timeout or failure when a budget is set. */
125
+ readonly breaches: number;
126
+ /** The budget, from the newest observation that has one. */
127
+ readonly budget?: number;
128
+ /** The median, or undefined with the reason in `withheld`. */
129
+ readonly p50?: number;
130
+ readonly p95?: number;
131
+ /** Why a figure is not shown, in words. */
132
+ readonly withheld: readonly string[];
133
+ /** True when a timeout or failure's waiting time is in the figures: they are lower bounds. */
134
+ readonly lowerBound: boolean;
135
+ readonly max: number;
136
+ /** The newest observation's value. */
137
+ readonly last: number;
138
+ readonly lastAt: number;
139
+ /** Whether the numbers support a statement about the user's wait: false when the boundaries are not declared. */
140
+ readonly claim: boolean;
141
+ /** What the reader must not assume, in words: partial interval, missing boundaries, a single observation. */
142
+ readonly gaps: readonly string[];
143
+ readonly instrumentation: readonly string[];
144
+ readonly environments: readonly string[];
145
+ /** Other facts the observations differ by: cohort entries and parameters, as `key=value`. */
146
+ readonly conditions: readonly string[];
147
+ readonly budgets: readonly number[];
148
+ };
149
+ /** The metrics of a result set, each with its summary in every group, groups newest first. */
150
+ export type LatencyReview = {
151
+ readonly by: LatencyGrouping;
152
+ readonly metrics: readonly {
153
+ readonly metric: LatencyMetric;
154
+ readonly groups: readonly LatencySummary[];
155
+ }[];
156
+ /** Results that recorded no product latency. They are unknown, not fast. */
157
+ readonly unmeasured: number;
158
+ /** Results that did. */
159
+ readonly measured: number;
160
+ };
161
+ /**
162
+ * Reads recorded results (any order) into a review: for every metric, a summary in every group. A result with no
163
+ * product latency counts only in `unmeasured`. Metrics are listed by name; groups newest first.
164
+ *
165
+ * @param by How observations are grouped. Default `none`.
166
+ * @example
167
+ * ```ts
168
+ * const review = latencyReview(history.history, 'build');
169
+ * review.metrics[0]?.groups[0]?.p95; // undefined until 20 observations of that build
170
+ * ```
171
+ */
172
+ export declare function latencyReview(entries: readonly LatencyEntry[], by?: LatencyGrouping): LatencyReview;
173
+ /** A change of one figure from the baseline to the candidate. */
174
+ export type LatencyDelta = {
175
+ readonly baseline: number;
176
+ readonly candidate: number;
177
+ /** Candidate minus baseline, in the metric's unit. Positive is slower. */
178
+ readonly absolute: number;
179
+ /** The absolute change over the baseline, or null when the baseline is zero. */
180
+ readonly relative: number | null;
181
+ };
182
+ /** What changed between two summaries, or why that cannot be said. */
183
+ export type LatencyChange = {
184
+ /** Whether a change is stated at all. */
185
+ readonly comparable: boolean;
186
+ /** `p50` or `p95` when both sides support it; `last` compares the newest observation of each, which is not a percentile. */
187
+ readonly basis?: 'p50' | 'p95' | 'last';
188
+ readonly p50?: LatencyDelta;
189
+ readonly p95?: LatencyDelta;
190
+ readonly last?: LatencyDelta;
191
+ /** Why no change is stated. */
192
+ readonly reasons: readonly string[];
193
+ /** Differences in how the two sides were measured or run. A change should be read with them. */
194
+ readonly disclosures: readonly string[];
195
+ };
196
+ /**
197
+ * The change from a baseline to a candidate of the same metric. Different metrics (names, units or boundaries) are not
198
+ * compared and the reason is given. When both sides support p50 (and p95) the change is of those; otherwise only the
199
+ * newest observation of each is compared, labelled as such. Changes of instrumentation, environments, other
200
+ * conditions, budget and partial or lower-bound figures are listed in `disclosures`, never hidden.
201
+ */
202
+ export declare function compareLatency(baseline: LatencySummary, candidate: LatencySummary): LatencyChange;
203
+ /** `500 ms`, `1.2 s`: a latency in the largest unit that keeps it readable. Non-time units print as given. */
204
+ export declare function formatLatency(value: number | undefined, unit: string): string;
@@ -0,0 +1,219 @@
1
+ /** The `kind` of a measurement that is a user-visible product latency. */
2
+ export const PRODUCT_LATENCY = 'product-latency';
3
+ /** Observations needed before a p50 is shown. */
4
+ export const MIN_P50_SAMPLES = 5;
5
+ /** Observations needed before a p95 is shown. */
6
+ export const MIN_P95_SAMPLES = 20;
7
+ /** Whether a measurement is a product latency: only the producer's `kind` makes it one. A name, a unit or a value never does. */
8
+ export function isProductLatency(measurement) {
9
+ return measurement.kind === PRODUCT_LATENCY;
10
+ }
11
+ /**
12
+ * The product-latency measurements of a check, or undefined when it has none (so a result with no latency carries
13
+ * nothing). Measurements that are not well formed (no finite value, no name) are left out.
14
+ */
15
+ export function productLatency(measurements) {
16
+ if (!measurements?.length)
17
+ return undefined;
18
+ const found = measurements.filter(m => isProductLatency(m) && typeof m.name === 'string' && m.name !== '' && Number.isFinite(m.value));
19
+ return found.length ? found : undefined;
20
+ }
21
+ const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : undefined);
22
+ /**
23
+ * What a measurement spans. `undeclared` when either end is missing: the numbers are shown and no product claim is made.
24
+ * `partial` when `intended` names a different end than the measured one: the interval is the measured one, never the intended.
25
+ */
26
+ export function boundaryOf(measurement) {
27
+ const from = text(measurement.from), to = text(measurement.to);
28
+ const intendedFrom = text(measurement.intended?.from), intendedTo = text(measurement.intended?.to);
29
+ const base = { ...(from ? { from } : {}), ...(to ? { to } : {}) };
30
+ if (!from || !to) {
31
+ const label = from ? `from ${from} (end not declared)` : to ? `to ${to} (start not declared)` : 'boundaries not declared';
32
+ return { state: 'undeclared', ...base, label };
33
+ }
34
+ const shorter = (intendedFrom !== undefined && intendedFrom !== from) || (intendedTo !== undefined && intendedTo !== to);
35
+ const label = `${from} to ${to}`;
36
+ if (!shorter)
37
+ return { state: 'declared', ...base, label };
38
+ return { state: 'partial', ...base, intended: `${intendedFrom ?? from} to ${intendedTo ?? to}`, label };
39
+ }
40
+ function outcomeOf(value) {
41
+ if (value === undefined || value === 'observed')
42
+ return 'observed';
43
+ return value === 'timeout' || value === 'failed' ? value : 'unclear';
44
+ }
45
+ const TIME_UNITS = { ms: 1, millisecond: 1, milliseconds: 1, s: 1000, sec: 1000, second: 1000, seconds: 1000, us: 0.001, 'µs': 0.001 };
46
+ /** The observations in recorded results, in the order given. Entries with no latency contribute none. */
47
+ export function latencyObservations(entries) {
48
+ const out = [];
49
+ for (const entry of entries) {
50
+ for (const m of productLatency(entry.latency) ?? []) {
51
+ const factor = TIME_UNITS[m.unit.trim().toLowerCase()];
52
+ const unit = factor === undefined ? m.unit : 'ms';
53
+ const scale = (n) => (factor === undefined ? n : n * factor);
54
+ const cohort = {};
55
+ for (const [key, value] of Object.entries(m.labels ?? {}))
56
+ if (typeof value === 'string')
57
+ cohort[key] = value;
58
+ const source = text(m.source), instrumentation = text(m.instrumentation);
59
+ out.push({
60
+ name: m.name, unit, value: scale(m.value), ...(Number.isFinite(m.limit) ? { limit: scale(m.limit) } : {}),
61
+ outcome: outcomeOf(m.outcome), boundary: boundaryOf(m), ...(source ? { source } : {}), ...(instrumentation ? { instrumentation } : {}),
62
+ cohort, at: entry.at, layer: entry.layer, environment: entry.environment, parameters: entry.parameters, labels: entry.evidence?.labels ?? {}, sha: entry.sha
63
+ });
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+ /** The groupings, in the order a control offers them. */
69
+ export const LATENCY_GROUPINGS = ['none', 'device', 'build', 'window'];
70
+ /**
71
+ * The group an observation falls in. `device` is the measurement's cohort, else the result's label, parameter or
72
+ * environment; `build` is the cohort or label, else the commit. One that names nothing is `unknown device` or
73
+ * `unknown build`: it is never merged into a named group.
74
+ */
75
+ export function groupOf(observation, by) {
76
+ if (by === 'none')
77
+ return 'All observations';
78
+ if (by === 'window')
79
+ return new Date(observation.at).toISOString().slice(0, 10);
80
+ const { cohort, labels, parameters } = observation;
81
+ if (by === 'device')
82
+ return cohort.device ?? labels.device ?? parameters.device ?? observation.environment ?? 'unknown device';
83
+ return cohort.build ?? labels.build ?? (observation.sha ? observation.sha.slice(0, 10) : 'unknown build');
84
+ }
85
+ const metricOf = (o) => ({
86
+ key: [o.name, o.unit, o.boundary.from ?? '', o.boundary.to ?? ''].join('\u0001'), name: o.name, unit: o.unit, boundary: o.boundary
87
+ });
88
+ const sorted = (set) => [...set].sort();
89
+ /** Nearest-rank percentile of ascending `values`, as the test duration statistics compute it. */
90
+ function percentile(values, q) {
91
+ return values[Math.min(values.length - 1, Math.max(0, Math.ceil(q * values.length) - 1))];
92
+ }
93
+ function summarize(metric, group, observations) {
94
+ const newest = [...observations].sort((a, b) => b.at - a.at || (a.layer < b.layer ? 1 : -1));
95
+ const values = observations.map(o => o.value).sort((a, b) => a - b);
96
+ const observed = observations.filter(o => o.outcome === 'observed').length;
97
+ const timeouts = observations.filter(o => o.outcome === 'timeout').length;
98
+ const failures = observations.length - observed - timeouts;
99
+ const budgets = [...new Set(observations.flatMap(o => (o.limit === undefined ? [] : [o.limit])))].sort((a, b) => a - b);
100
+ const breaches = observations.filter(o => o.limit !== undefined && (o.outcome !== 'observed' || o.value > o.limit)).length;
101
+ const budget = newest.find(o => o.limit !== undefined)?.limit;
102
+ const withheld = [];
103
+ const gaps = [];
104
+ const claim = metric.boundary.state !== 'undeclared';
105
+ let p50, p95;
106
+ if (!claim) {
107
+ withheld.push(`Percentiles are withheld: ${metric.boundary.label}, so these numbers are not shown as how long a user waits.`);
108
+ gaps.push(`${metric.boundary.label[0].toUpperCase()}${metric.boundary.label.slice(1)}: the evidence does not say what was timed.`);
109
+ }
110
+ else {
111
+ if (values.length >= MIN_P50_SAMPLES)
112
+ p50 = percentile(values, 0.5);
113
+ else
114
+ withheld.push(`p50 needs ${MIN_P50_SAMPLES} observations; there ${values.length === 1 ? 'is 1' : `are ${values.length}`}.`);
115
+ if (values.length >= MIN_P95_SAMPLES)
116
+ p95 = percentile(values, 0.95);
117
+ else
118
+ withheld.push(`p95 needs ${MIN_P95_SAMPLES} observations; there ${values.length === 1 ? 'is 1' : `are ${values.length}`}.`);
119
+ }
120
+ if (metric.boundary.state === 'partial')
121
+ gaps.push(`Partial: measured ${metric.boundary.label}, not ${metric.boundary.intended}. It is not the full ${metric.boundary.intended} latency.`);
122
+ if (values.length === 1)
123
+ gaps.push('One observation: no spread, confidence or percentile can be drawn from it.');
124
+ if (timeouts + failures > 0)
125
+ gaps.push(`${timeouts + failures} of ${values.length} did not complete (${[timeouts ? `${timeouts} timed out` : '', failures ? `${failures} failed` : ''].filter(Boolean).join(', ')}); their value is how long they waited, so the figures are lower bounds.`);
126
+ if (budgets.length > 1)
127
+ gaps.push(`The budget changed across these observations (${budgets.join(', ')} ${metric.unit}).`);
128
+ const conditions = new Set();
129
+ for (const o of observations) {
130
+ for (const [key, value] of Object.entries(o.cohort))
131
+ if (key !== 'device' && key !== 'build')
132
+ conditions.add(`${key}=${value}`);
133
+ for (const [key, value] of Object.entries(o.parameters))
134
+ conditions.add(`${key}=${value}`);
135
+ }
136
+ return {
137
+ metric, group, samples: observations.length, observed, timeouts, failures, breaches, ...(budget === undefined ? {} : { budget }),
138
+ ...(p50 === undefined ? {} : { p50 }), ...(p95 === undefined ? {} : { p95 }), withheld, lowerBound: timeouts + failures > 0,
139
+ max: values.at(-1), last: newest[0].value, lastAt: newest[0].at, claim, gaps,
140
+ instrumentation: sorted(new Set(observations.map(o => o.instrumentation ?? 'not declared'))),
141
+ environments: sorted(new Set(observations.map(o => o.environment ?? 'not declared'))), conditions: sorted(conditions), budgets
142
+ };
143
+ }
144
+ /**
145
+ * Reads recorded results (any order) into a review: for every metric, a summary in every group. A result with no
146
+ * product latency counts only in `unmeasured`. Metrics are listed by name; groups newest first.
147
+ *
148
+ * @param by How observations are grouped. Default `none`.
149
+ * @example
150
+ * ```ts
151
+ * const review = latencyReview(history.history, 'build');
152
+ * review.metrics[0]?.groups[0]?.p95; // undefined until 20 observations of that build
153
+ * ```
154
+ */
155
+ export function latencyReview(entries, by = 'none') {
156
+ const cells = new Map();
157
+ for (const o of latencyObservations(entries)) {
158
+ const metric = metricOf(o);
159
+ const cell = cells.get(metric.key) ?? { metric, groups: new Map() };
160
+ cells.set(metric.key, cell);
161
+ const group = groupOf(o, by);
162
+ const list = cell.groups.get(group);
163
+ if (list)
164
+ list.push(o);
165
+ else
166
+ cell.groups.set(group, [o]);
167
+ }
168
+ const metrics = [...cells.values()].map(({ metric, groups }) => ({
169
+ metric, groups: [...groups].map(([group, list]) => summarize(metric, group, list)).sort((a, b) => b.lastAt - a.lastAt || (a.group < b.group ? -1 : 1))
170
+ })).sort((a, b) => (a.metric.name < b.metric.name ? -1 : a.metric.name > b.metric.name ? 1 : a.metric.key < b.metric.key ? -1 : 1));
171
+ const measured = entries.filter(e => productLatency(e.latency)).length;
172
+ return { by, metrics, unmeasured: entries.length - measured, measured };
173
+ }
174
+ const delta = (baseline, candidate) => ({ baseline, candidate, absolute: candidate - baseline, relative: baseline === 0 ? null : (candidate - baseline) / baseline });
175
+ const sameSet = (a, b) => a.length === b.length && a.every((value, i) => value === b[i]);
176
+ /**
177
+ * The change from a baseline to a candidate of the same metric. Different metrics (names, units or boundaries) are not
178
+ * compared and the reason is given. When both sides support p50 (and p95) the change is of those; otherwise only the
179
+ * newest observation of each is compared, labelled as such. Changes of instrumentation, environments, other
180
+ * conditions, budget and partial or lower-bound figures are listed in `disclosures`, never hidden.
181
+ */
182
+ export function compareLatency(baseline, candidate) {
183
+ const reasons = [];
184
+ const disclosures = [];
185
+ if (baseline.metric.key !== candidate.metric.key) {
186
+ reasons.push(baseline.metric.name === candidate.metric.name
187
+ ? `The measured interval differs (${baseline.metric.boundary.label} against ${candidate.metric.boundary.label}), so the two are not the same measurement.`
188
+ : 'These are different measurements.');
189
+ return { comparable: false, reasons, disclosures };
190
+ }
191
+ if (!baseline.claim)
192
+ return { comparable: false, reasons: ['The baseline does not say what it timed.'], disclosures };
193
+ if (!sameSet(baseline.instrumentation, candidate.instrumentation))
194
+ disclosures.push(`Instrumentation changed: ${baseline.instrumentation.join(', ')} became ${candidate.instrumentation.join(', ')}.`);
195
+ if (!sameSet(baseline.environments, candidate.environments))
196
+ disclosures.push(`Environments differ: ${baseline.environments.join(', ')} against ${candidate.environments.join(', ')}.`);
197
+ if (!sameSet(baseline.conditions, candidate.conditions))
198
+ disclosures.push(`Test conditions differ: ${baseline.conditions.join(', ') || 'none declared'} against ${candidate.conditions.join(', ') || 'none declared'}.`);
199
+ if (baseline.budget !== candidate.budget)
200
+ disclosures.push(`The budget differs: ${baseline.budget ?? 'none'} against ${candidate.budget ?? 'none'} ${candidate.metric.unit}.`);
201
+ if (baseline.lowerBound || candidate.lowerBound)
202
+ disclosures.push('Timeouts or failures are in these figures, so they are lower bounds.');
203
+ if (candidate.metric.boundary.state === 'partial')
204
+ disclosures.push(`Only part of the interval is measured (${candidate.metric.boundary.label}, not ${candidate.metric.boundary.intended}).`);
205
+ const p50 = baseline.p50 !== undefined && candidate.p50 !== undefined ? delta(baseline.p50, candidate.p50) : undefined;
206
+ const p95 = baseline.p95 !== undefined && candidate.p95 !== undefined ? delta(baseline.p95, candidate.p95) : undefined;
207
+ if (p50 || p95)
208
+ return { comparable: true, basis: p50 ? 'p50' : 'p95', ...(p50 ? { p50 } : {}), ...(p95 ? { p95 } : {}), reasons, disclosures };
209
+ disclosures.push(`Percentiles are not supported on both sides (${baseline.samples} against ${candidate.samples} observations), so only the newest observation of each is compared.`);
210
+ return { comparable: true, basis: 'last', last: delta(baseline.last, candidate.last), reasons, disclosures };
211
+ }
212
+ /** `500 ms`, `1.2 s`: a latency in the largest unit that keeps it readable. Non-time units print as given. */
213
+ export function formatLatency(value, unit) {
214
+ if (value === undefined || !Number.isFinite(value))
215
+ return 'withheld';
216
+ if (unit !== 'ms')
217
+ return `${Number(value.toFixed(2))} ${unit}`;
218
+ return Math.abs(value) >= 1000 ? `${Number((value / 1000).toFixed(2))} s` : `${Number(value.toFixed(1))} ms`;
219
+ }
@@ -434,6 +434,9 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
434
434
  [x: string]: import("hono/utils/types").JSONValue;
435
435
  } | undefined;
436
436
  }[] | undefined;
437
+ labels?: {
438
+ [x: string]: string;
439
+ } | undefined;
437
440
  extensions?: {
438
441
  [x: string]: import("hono/utils/types").JSONValue;
439
442
  } | undefined;
@@ -469,9 +472,6 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
469
472
  [x: string]: import("hono/utils/types").JSONValue;
470
473
  } | undefined;
471
474
  }[] | undefined;
472
- labels?: {
473
- [x: string]: string;
474
- } | undefined;
475
475
  readonly freshness?: "unknown" | "modified" | undefined;
476
476
  readonly parameters?: {
477
477
  readonly [x: string]: string;
@@ -850,6 +850,9 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
850
850
  [x: string]: import("hono/utils/types").JSONValue;
851
851
  } | undefined;
852
852
  }[] | undefined;
853
+ labels?: {
854
+ [x: string]: string;
855
+ } | undefined;
853
856
  extensions?: {
854
857
  [x: string]: import("hono/utils/types").JSONValue;
855
858
  } | undefined;
@@ -885,9 +888,6 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
885
888
  [x: string]: import("hono/utils/types").JSONValue;
886
889
  } | undefined;
887
890
  }[] | undefined;
888
- labels?: {
889
- [x: string]: string;
890
- } | undefined;
891
891
  readonly id: string;
892
892
  readonly title: string;
893
893
  readonly status: import('./explorer-types.js').EvidenceStatus;
@@ -898,6 +898,19 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
898
898
  readonly value: number;
899
899
  readonly unit: string;
900
900
  readonly limit?: number | undefined;
901
+ readonly kind?: string | undefined;
902
+ readonly from?: string | undefined;
903
+ readonly to?: string | undefined;
904
+ readonly intended?: {
905
+ readonly from?: string | undefined;
906
+ readonly to?: string | undefined;
907
+ } | undefined;
908
+ readonly source?: string | undefined;
909
+ readonly outcome?: string | undefined;
910
+ readonly instrumentation?: string | undefined;
911
+ readonly labels?: {
912
+ readonly [x: string]: string;
913
+ } | undefined;
901
914
  }[];
902
915
  readonly durationMs?: number | undefined;
903
916
  }[];
@@ -472,6 +472,9 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
472
472
  [x: string]: import("hono/utils/types").JSONValue;
473
473
  } | undefined;
474
474
  }[] | undefined;
475
+ labels?: {
476
+ [x: string]: string;
477
+ } | undefined;
475
478
  extensions?: {
476
479
  [x: string]: import("hono/utils/types").JSONValue;
477
480
  } | undefined;
@@ -507,9 +510,6 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
507
510
  [x: string]: import("hono/utils/types").JSONValue;
508
511
  } | undefined;
509
512
  }[] | undefined;
510
- labels?: {
511
- [x: string]: string;
512
- } | undefined;
513
513
  readonly freshness?: "unknown" | "modified" | undefined;
514
514
  readonly parameters?: {
515
515
  readonly [x: string]: string;
@@ -888,6 +888,9 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
888
888
  [x: string]: import("hono/utils/types").JSONValue;
889
889
  } | undefined;
890
890
  }[] | undefined;
891
+ labels?: {
892
+ [x: string]: string;
893
+ } | undefined;
891
894
  extensions?: {
892
895
  [x: string]: import("hono/utils/types").JSONValue;
893
896
  } | undefined;
@@ -923,9 +926,6 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
923
926
  [x: string]: import("hono/utils/types").JSONValue;
924
927
  } | undefined;
925
928
  }[] | undefined;
926
- labels?: {
927
- [x: string]: string;
928
- } | undefined;
929
929
  readonly id: string;
930
930
  readonly title: string;
931
931
  readonly status: import('./explorer-types.js').EvidenceStatus;
@@ -936,6 +936,19 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
936
936
  readonly value: number;
937
937
  readonly unit: string;
938
938
  readonly limit?: number | undefined;
939
+ readonly kind?: string | undefined;
940
+ readonly from?: string | undefined;
941
+ readonly to?: string | undefined;
942
+ readonly intended?: {
943
+ readonly from?: string | undefined;
944
+ readonly to?: string | undefined;
945
+ } | undefined;
946
+ readonly source?: string | undefined;
947
+ readonly outcome?: string | undefined;
948
+ readonly instrumentation?: string | undefined;
949
+ readonly labels?: {
950
+ readonly [x: string]: string;
951
+ } | undefined;
939
952
  }[];
940
953
  readonly durationMs?: number | undefined;
941
954
  }[];
@@ -1,5 +1,5 @@
1
1
  import { type ArtifactFilter, type FilterFields, type FilterName, type QueryFacets } from './artifact-query.js';
2
- import type { EvidenceStatus, SpecEntry } from './explorer-types.js';
2
+ import type { EvidenceMeasurement, EvidenceStatus, SpecEntry } from './explorer-types.js';
3
3
  import type { EvidenceSummary, LayerEvidence, LayerHeader, LayerRow, StatusCounts, VideoCounts, VideoRef } from './evidence-types.js';
4
4
  import { RecordedSpecs } from './recorded-specs.js';
5
5
  /** The version of the documents this module writes (`TestCatalog`, `TestHistoryDocument`, `RunList`, `RunDetail`). Only optional fields are added within it. */
@@ -32,6 +32,8 @@ export type TestResultEntry = {
32
32
  readonly detail: string;
33
33
  /** What it carried beyond its outcome (attachments, steps, retries, tags, labels), when anything. */
34
34
  readonly evidence?: EvidenceSummary;
35
+ /** Its product-latency measurements (`kind: product-latency`), when it recorded any. Separate from `durationMs`, which is how long the test took (setup, waits and pacing included). */
36
+ readonly latency?: readonly EvidenceMeasurement[];
35
37
  };
36
38
  /** The current result of a test in one producer, environment and parameter set: the report's winner for that slot. */
37
39
  export type TestOutcome = TestResultEntry;
@@ -25,6 +25,7 @@
25
25
  import { displayTag, normalizeTag, normalizeTags } from './artifact-tags.js';
26
26
  import { facetsOf, forRuns, labelList, matchesFilter, outcomeOfCounts, splitList, usedFilters } from './artifact-query.js';
27
27
  import { videoCounts } from './evidence-detail.js';
28
+ import { productLatency } from './latency.js';
28
29
  import { matchesScope, scopeTokens, UNKNOWN_SCOPE } from './evidence-scope.js';
29
30
  import { checkSlots } from './evidence-view.js';
30
31
  import { combineEvidence, emptyCounts, slotKey, specPath as normalSpecPath } from './layer-merge.js';
@@ -56,7 +57,7 @@ function completed(status) {
56
57
  return status === 'passed' || status === 'failed';
57
58
  }
58
59
  function entryOf(layer, row, backend) {
59
- return materialize({ o: originOf(layer, backend), s: row.status, d: row.durationMs, k: row.kind, t: row.detail, e: row.evidence });
60
+ return materialize({ o: originOf(layer, backend), s: row.status, d: row.durationMs, k: row.kind, t: row.detail, e: row.evidence, m: productLatency(row.measurements) });
60
61
  }
61
62
  function originOf(layer, backend) {
62
63
  const parameters = Object.keys(layer.parameters).sort().map(key => `${key}=${layer.parameters[key]}`).join('\u0002');
@@ -72,6 +73,7 @@ function materialize(held) {
72
73
  at: o.at, status: held.s, durationMs: held.d, environment: o.environment, producer: o.producer, kind: held.k, parameters: o.parameters,
73
74
  branch: o.branch, sha: o.sha, runUrl: o.runUrl, run: o.run, layer: o.layer, backend: o.backend, detail: shortDetail(held.t),
74
75
  ...(held.e ? { evidence: held.e } : {}),
76
+ ...(held.m ? { latency: held.m } : {}),
75
77
  };
76
78
  }
77
79
  /** Newest first; at the same time the more severe, then the larger layer id, so the order is total. */
@@ -180,7 +182,7 @@ export class TestHistory {
180
182
  ident.title = row.title;
181
183
  ident.titledAt = layer.recordedAtMs;
182
184
  }
183
- this.#remember(slot, { o: origin, s: row.status, d: row.durationMs, k: row.kind, t: row.detail, e: row.evidence });
185
+ this.#remember(slot, { o: origin, s: row.status, d: row.durationMs, k: row.kind, t: row.detail, e: row.evidence, m: productLatency(row.measurements) });
184
186
  }
185
187
  }
186
188
  /** Sorted insert, newest first; past the bound, the oldest is dropped. */