@crvouga/atlas 1.14.0 → 1.15.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 (35) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/dist/reports/src/evidence-detail.d.ts +19 -0
  3. package/dist/reports/src/evidence-detail.js +31 -4
  4. package/dist/reports/src/evidence-types.d.ts +2 -2
  5. package/dist/reports/src/layer-merge.js +4 -3
  6. package/dist/reports/src/layer-store.d.ts +1 -1
  7. package/dist/reports/src/layer-store.js +4 -2
  8. package/dist/reports/src/layer-types.d.ts +2 -0
  9. package/dist/reports/src/run-compare.d.ts +182 -0
  10. package/dist/reports/src/run-compare.js +329 -0
  11. package/dist/reports/src/test-catalog.d.ts +8 -0
  12. package/dist/reports/src/test-catalog.js +14 -1
  13. package/dist/schema/src/api.d.ts +4 -0
  14. package/dist/schema/src/api.js +7 -3
  15. package/docs/reference/reports.md +354 -3
  16. package/docs/ui.md +30 -1
  17. package/llms-full.txt +30 -1
  18. package/package.json +1 -1
  19. package/packages/schema/schemas/effective-delta.schema.json +2 -2
  20. package/packages/schema/schemas/effective-evidence.schema.json +2 -2
  21. package/packages/schema/schemas/evidence-page.schema.json +1 -1
  22. package/packages/schema/schemas/layer-rows.schema.json +1 -1
  23. package/packages/schema/schemas/report-run.schema.json +25 -3
  24. package/packages/schema/schemas/report-runs.schema.json +1 -1
  25. package/packages/schema/schemas/review-collection.schema.json +1 -1
  26. package/packages/schema/schemas/review-manifest.schema.json +1 -1
  27. package/packages/schema/schemas/test-catalog.schema.json +1 -1
  28. package/packages/schema/schemas/test-history.schema.json +2 -2
  29. package/packages/server/openapi.json +39 -17
  30. package/packages/visualizer/src/explorer/ComparePair.tsx +597 -0
  31. package/packages/visualizer/src/explorer/CompareView.tsx +340 -0
  32. package/packages/visualizer/src/explorer/Explorer.tsx +6 -2
  33. package/packages/visualizer/src/explorer/RunsView.tsx +10 -0
  34. package/packages/visualizer/src/explorer/playlist.tsx +1 -1
  35. package/packages/visualizer/src/explorer/search.ts +15 -3
package/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
- ## 1.14.0 (2026-10-29)
3
+ ## 1.15.0 (2026-10-29)
4
+
5
+ ### Features
6
+
7
+ - count a video whose bundle file was never stored as unavailable ([6e342e1](https://github.com/crvouga/atlas/commit/6e342e1c0994b78499e373496afa5035a387a43c))
8
+ - compare behavior promises, recordings and outcomes between two pinned runs ([9bc0056](https://github.com/crvouga/atlas/commit/9bc0056de84899801377c0cc1c3bb70c80e3820a))
9
+
10
+ ## 1.14.0 (2026-10-28)
4
11
 
5
12
  ### Features
6
13
 
@@ -46,11 +46,19 @@ export declare function summarizeEvidence(...holders: readonly EvidenceHolder[])
46
46
  * `effectiveScope`). Without `inherited` it is the same as `summarizeEvidence`.
47
47
  */
48
48
  export declare function summarizeEvidenceIn(inherited: EvidenceScope | undefined, ...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
49
+ /**
50
+ * Like {@link summarizeEvidenceIn}, knowing which bundle keys the layer names but does not hold (`unstored`, see
51
+ * `unstoredKeys`): a video whose only bytes are such a key is `available: false`, never a recording. Without
52
+ * `unstored` it is the same as `summarizeEvidenceIn`.
53
+ */
54
+ export declare function summarizeStoredEvidence(inherited: EvidenceScope | undefined, unstored: ReadonlySet<string> | undefined, ...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
49
55
  /**
50
56
  * Like {@link summarizeEvidence} for a layer and its parts: the same summary, but a video attached to the layer or a
51
57
  * part is owned by the `layer`, not by a check.
52
58
  */
53
59
  export declare function summarizeLayerEvidence(...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
60
+ /** Like {@link summarizeLayerEvidence}, knowing which bundle keys the layer names but does not hold (see {@link summarizeStoredEvidence}). */
61
+ export declare function summarizeStoredLayerEvidence(unstored: ReadonlySet<string> | undefined, ...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
54
62
  /**
55
63
  * Whether an attachment is a video, by its effective media type: the producer's `contentType`, else the one its file
56
64
  * name implies. A role never decides this on its own (`demo` on a PNG is not a video), except that a `video` role
@@ -113,3 +121,14 @@ export declare function partsEvidence(layer: {
113
121
  }): PartEvidence[];
114
122
  /** Every distinct bundle key (`bundleKey`) that the attachments of a holder, its steps and its attempts point to, so a layer's files can be copied. Attachments held by `uri` or inline `text` have none. */
115
123
  export declare function bundleKeysOf(holder: EvidenceHolder): string[];
124
+ /**
125
+ * The bundle keys that attachments of a layer, its parts and their checks name, but that the layer's `bundle` index does
126
+ * not hold: files the producer referenced and never stored. Empty for a layer whose every file is there. This reads only
127
+ * the index, never a blob, so it is cheap enough to ask of every layer as it is read.
128
+ */
129
+ export declare function unstoredKeys(layer: EvidenceHolder & {
130
+ readonly parts: readonly (EvidenceHolder & {
131
+ readonly checks: readonly EvidenceHolder[];
132
+ })[];
133
+ readonly bundle: Readonly<Record<string, unknown>>;
134
+ }): string[];
@@ -26,6 +26,14 @@ export function summarizeEvidence(...holders) {
26
26
  export function summarizeEvidenceIn(inherited, ...holders) {
27
27
  return summarize(holders, 'check', inherited);
28
28
  }
29
+ /**
30
+ * Like {@link summarizeEvidenceIn}, knowing which bundle keys the layer names but does not hold (`unstored`, see
31
+ * `unstoredKeys`): a video whose only bytes are such a key is `available: false`, never a recording. Without
32
+ * `unstored` it is the same as `summarizeEvidenceIn`.
33
+ */
34
+ export function summarizeStoredEvidence(inherited, unstored, ...holders) {
35
+ return summarize(holders, 'check', inherited, false, unstored);
36
+ }
29
37
  /**
30
38
  * Like {@link summarizeEvidence} for a layer and its parts: the same summary, but a video attached to the layer or a
31
39
  * part is owned by the `layer`, not by a check.
@@ -34,7 +42,11 @@ export function summarizeLayerEvidence(...holders) {
34
42
  // The first holder is the layer: its scope reaches the parts after it, which may declare their own.
35
43
  return summarize(holders, 'layer', undefined, true);
36
44
  }
37
- function summarize(holders, owner, inherited, firstIsParent = false) {
45
+ /** Like {@link summarizeLayerEvidence}, knowing which bundle keys the layer names but does not hold (see {@link summarizeStoredEvidence}). */
46
+ export function summarizeStoredLayerEvidence(unstored, ...holders) {
47
+ return summarize(holders, 'layer', undefined, true, unstored);
48
+ }
49
+ function summarize(holders, owner, inherited, firstIsParent = false, unstored) {
38
50
  if (!holders.some(holder => count(holder) > 0))
39
51
  return undefined;
40
52
  const roles = new Set();
@@ -50,7 +62,7 @@ function summarize(holders, owner, inherited, firstIsParent = false) {
50
62
  for (const tag of normalizeTags(item.tags))
51
63
  artifactTags.add(tag);
52
64
  roles.add(item.role ?? roleOfType(item.contentType ?? contentTypeOf(item.name)));
53
- const video = videoOf(item, by, where, effectiveScope(item.scope, ...scopes));
65
+ const video = videoOf(item, by, where, effectiveScope(item.scope, ...scopes), unstored);
54
66
  if (video)
55
67
  videos.push(video);
56
68
  }
@@ -98,10 +110,12 @@ export function isVideoAttachment(attachment) {
98
110
  export function attachmentIdentity(item) {
99
111
  return item.sha256 ? `sha256:${item.sha256}` : item.bundleKey ?? item.uri ?? (item.text === undefined ? undefined : `inline:${item.name}`);
100
112
  }
101
- function videoOf(item, owner, where, scope) {
113
+ function videoOf(item, owner, where, scope, unstored) {
102
114
  if (!isVideoAttachment(item))
103
115
  return undefined;
104
- const recording = attachmentIdentity(item);
116
+ // A bundle key the layer's bundle does not hold names bytes that are not there: only an address or inline text can still carry them, and the digest of lost bytes identifies nothing playable.
117
+ const lost = item.bundleKey !== undefined && unstored?.has(item.bundleKey) === true;
118
+ const recording = lost ? attachmentIdentity({ name: item.name, uri: item.uri, text: item.text }) : attachmentIdentity(item);
105
119
  const tags = normalizeTags(item.tags);
106
120
  return { name: item.name, owner, ...where, ...(tags.length ? { tags } : {}), ...(recording === undefined ? {} : { recording }), available: recording !== undefined, ...(scope ? { scope } : {}) };
107
121
  }
@@ -181,3 +195,16 @@ export function bundleKeysOf(holder) {
181
195
  }
182
196
  return [...keys];
183
197
  }
198
+ /**
199
+ * The bundle keys that attachments of a layer, its parts and their checks name, but that the layer's `bundle` index does
200
+ * not hold: files the producer referenced and never stored. Empty for a layer whose every file is there. This reads only
201
+ * the index, never a blob, so it is cheap enough to ask of every layer as it is read.
202
+ */
203
+ export function unstoredKeys(layer) {
204
+ const missing = new Set();
205
+ for (const holder of [layer, ...layer.parts, ...layer.parts.flatMap(part => part.checks)])
206
+ for (const key of bundleKeysOf(holder))
207
+ if (!Object.hasOwn(layer.bundle, key))
208
+ missing.add(key);
209
+ return [...missing];
210
+ }
@@ -56,7 +56,7 @@ export type VideoRef = {
56
56
  readonly tags?: readonly string[];
57
57
  /** What identifies the bytes (digest, bundle key or address); references with the same one are the same recording. Absent when `available` is false. */
58
58
  readonly recording?: string;
59
- /** False when the reference names no bytes at all (no bundle key, address or inline text), so it can never be played. */
59
+ /** False when the reference names no bytes at all (no bundle key, address or inline text), or a bundle key that the layer's own bundle does not hold, so it can never be played. */
60
60
  readonly available: boolean;
61
61
  /** What this recording shows, as declared for it: its own `scope`, else its attempt's, check's, part's or layer's. Absent when none declared one: its scope is unknown, not live, physical or audible. */
62
62
  readonly scope?: EvidenceScope;
@@ -67,7 +67,7 @@ export type VideoCounts = {
67
67
  readonly references: number;
68
68
  /** Distinct playable recordings: identical bytes referenced twice are one. */
69
69
  readonly recordings: number;
70
- /** References that name no bytes. A report that predates availability metadata has none of these and none of the others. */
70
+ /** References that name no bytes, or bytes the layer does not hold. A report that predates availability metadata has none of these and none of the others. */
71
71
  readonly unavailable: number;
72
72
  };
73
73
  /**
@@ -1,4 +1,4 @@
1
- import { summarizeEvidenceIn, summarizeLayerEvidence } from './evidence-detail.js';
1
+ import { summarizeStoredEvidence, summarizeStoredLayerEvidence } from './evidence-detail.js';
2
2
  import { effectiveScope } from './evidence-scope.js';
3
3
  import { layerPlace } from './layer-axes.js';
4
4
  import { UI_PLAN_KIND, uiStateOf } from './ui-state.js';
@@ -90,13 +90,14 @@ export function layerEvidence(summary, backend) {
90
90
  normalized.set(path, found = specPath(path));
91
91
  return found;
92
92
  };
93
+ const unstored = summary.unstored?.length ? new Set(summary.unstored) : undefined;
93
94
  const rows = [];
94
95
  const receipt = summary.recordings;
95
96
  const expectations = receipt ? new Map(receipt.items.map(item => [item.check, item])) : null;
96
97
  for (const part of summary.parts) {
97
98
  for (const check of part.checks) {
98
99
  const test = tests.get(check.id);
99
- const evidence = summarizeEvidenceIn(effectiveScope(part.scope, summary.scope), check);
100
+ const evidence = summarizeStoredEvidence(effectiveScope(part.scope, summary.scope), unstored, check);
100
101
  const item = expectations?.get(check.id);
101
102
  const state = uiStateOf(part.kind, check);
102
103
  rows.push({
@@ -117,7 +118,7 @@ export function layerEvidence(summary, backend) {
117
118
  }
118
119
  }
119
120
  const recording = summary.recording;
120
- const layerSummaryEvidence = summarizeLayerEvidence(summary, ...summary.parts);
121
+ const layerSummaryEvidence = summarizeStoredLayerEvidence(unstored, summary, ...summary.parts);
121
122
  const layer = {
122
123
  ...(summary.freshness ? { freshness: summary.freshness } : {}),
123
124
  ...(summary.run ? { run: summary.run } : {}),
@@ -48,5 +48,5 @@ export declare function publishLayerBundle(store: ObjectStore, summary: Omit<Lay
48
48
  export declare function readLayer(store: ObjectStore, id: string): Promise<ReportLayer | null>;
49
49
  /** Reads one captured file (a screenshot, a frame, a recorded report) of a layer, or `null` when the layer or the file is missing. Throws like `readLayer`. */
50
50
  export declare function readLayerFile(store: ObjectStore, id: string, file: string): Promise<BundleEntry | null>;
51
- /** Drops a layer's bundle index, leaving the summary composition reads. */
51
+ /** Drops a layer's bundle index, leaving the summary composition reads, plus the bundle keys its attachments name that the bundle does not hold (`unstored`), so a video that was never stored is not counted as a recording. */
52
52
  export declare function layerSummary(layer: ReportLayer): LayerSummary;
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { createBundleReader, writeBundle } from './bundle.js';
12
12
  import { readUtf8, writeUtf8 } from './object-io.js';
13
+ import { unstoredKeys } from './evidence-detail.js';
13
14
  import { LAYER_SCHEMA_VERSION, parseLayerDocument } from '../../schema/src/layer.js';
14
15
  /** The key prefix under which every layer's catalog entry is written. */
15
16
  export const LAYER_INDEX = 'layers/index/';
@@ -87,8 +88,9 @@ export async function readLayerFile(store, id, file) {
87
88
  const layer = await readLayer(store, id);
88
89
  return layer ? createBundleReader(store, layer.bundle)(file) : null;
89
90
  }
90
- /** Drops a layer's bundle index, leaving the summary composition reads. */
91
+ /** Drops a layer's bundle index, leaving the summary composition reads, plus the bundle keys its attachments name that the bundle does not hold (`unstored`), so a video that was never stored is not counted as a recording. */
91
92
  export function layerSummary(layer) {
92
93
  const { bundle: _, ...summary } = layer;
93
- return summary;
94
+ const unstored = unstoredKeys(layer);
95
+ return unstored.length ? { ...summary, unstored } : summary;
94
96
  }
@@ -52,6 +52,8 @@ export type LayerSummary = LayerExtras & {
52
52
  readonly sha: string;
53
53
  readonly source: string;
54
54
  } | null;
55
+ /** Bundle keys that attachments name but the layer's bundle does not hold, read from the layer's index when its summary was made (see `unstoredKeys`). Absent when there are none. In memory only: never part of the layer document. */
56
+ readonly unstored?: readonly string[];
55
57
  };
56
58
  /** A layer with the index that locates its captured files (screenshots, frames) in the store. */
57
59
  export type ReportLayer = LayerSummary & {
@@ -0,0 +1,182 @@
1
+ import { type LatencyChange, type LatencyEntry, type LatencySummary } from './latency.js';
2
+ import type { ReliabilityDocument, ReliabilityRate, ReliabilityScope } from './reliability.js';
3
+ import type { RunDetail, RunResult, RunSummary } from './test-catalog.js';
4
+ /** Where a pair of results (or one result with no partner) stands, in the order a review reads them. */
5
+ export declare const COMPARE_GROUPS: readonly ["regressed", "recovered", "still-failing", "not-comparable", "unknown", "removed", "added", "unchanged"];
6
+ /** One of {@link COMPARE_GROUPS}. */
7
+ export type CompareGroup = (typeof COMPARE_GROUPS)[number];
8
+ /** What each group is called and what it does and does not say. */
9
+ export declare const COMPARE_GROUP_TEXT: Readonly<Record<CompareGroup, {
10
+ readonly label: string;
11
+ readonly meaning: string;
12
+ }>>;
13
+ /**
14
+ * Whether the promise (what the behavior says should happen) is the same on both sides: `same` (same wording and the
15
+ * same recorded spec revision), `changed` (the wording differs), `revision-differs` (same wording, another spec-file
16
+ * revision), or `unknown` (a revision was not recorded on a side, so nothing can be said).
17
+ */
18
+ export type PromiseState = 'same' | 'changed' | 'revision-differs' | 'unknown';
19
+ /** What is known about whether the promise changed, and why. */
20
+ export type PromiseReading = {
21
+ readonly state: PromiseState;
22
+ readonly baselineTitle: string | null;
23
+ readonly candidateTitle: string | null;
24
+ readonly baselineRevision: string | null;
25
+ readonly candidateRevision: string | null;
26
+ /** In words, for a person. */
27
+ readonly reasons: readonly string[];
28
+ };
29
+ /** The condition a result was recorded under: with the check, what makes it its own result. */
30
+ export type Condition = {
31
+ readonly kind: string | null;
32
+ readonly producer: string;
33
+ readonly environment: string | null;
34
+ readonly parameters: Readonly<Record<string, string>>;
35
+ };
36
+ /** The condition of a result. A server that does not send `kind` leaves it null: unknown, equal only to another unknown. */
37
+ export declare function conditionOf(result: Pick<RunResult, 'kind' | 'producer' | 'environment' | 'parameters'>): Condition;
38
+ /** A stable text for a condition, equal exactly when two conditions are. */
39
+ export declare function conditionId(condition: Condition): string;
40
+ /** How two conditions differ, in words; empty when they are the same. */
41
+ export declare function conditionDifferences(baseline: Condition, candidate: Condition): string[];
42
+ /** One behavior's results on the two sides. */
43
+ export type ComparedResult = {
44
+ /** Identifies the pair within one comparison: the check, and the condition of each side. Stable for the same two runs. */
45
+ readonly id: string;
46
+ /** The check's identity (`TestSummary.key`). */
47
+ readonly key: string;
48
+ /** The check's title: the candidate's when it has one, else the baseline's. */
49
+ readonly title: string;
50
+ readonly specPath: string | null;
51
+ readonly baseline: RunResult | null;
52
+ readonly candidate: RunResult | null;
53
+ readonly group: CompareGroup;
54
+ /** One sentence saying what happened, and what is not known. */
55
+ readonly summary: string;
56
+ readonly promise: PromiseReading;
57
+ /** How the two conditions differ, in words, when both sides have a result and they were recorded under different conditions. */
58
+ readonly conditions: readonly string[];
59
+ /** What a reader must not assume about this pair, in words. */
60
+ readonly caveats: readonly string[];
61
+ };
62
+ /** The whole comparison of two pinned runs. */
63
+ export type RunComparison = {
64
+ readonly baseline: RunSummary;
65
+ readonly candidate: RunSummary;
66
+ /** Every pair and every unpaired result, in the order of {@link COMPARE_GROUPS}, then spec and title. */
67
+ readonly results: readonly ComparedResult[];
68
+ readonly counts: Readonly<Record<CompareGroup, number>>;
69
+ /** What a reader must not assume about the two runs as a whole: the same run on both sides, different branches, a candidate older than the baseline. */
70
+ readonly notes: readonly string[];
71
+ };
72
+ /** Short form of a spec fingerprint, to show. */
73
+ export declare function shortRevision(revision: string | null | undefined): string;
74
+ /** The wording a run recorded for a check: what that run's layer called it, else the check's newest title. */
75
+ export declare function recordedTitle(result: Pick<RunResult, 'title' | 'recordedTitle'>): string;
76
+ /**
77
+ * Pairs the results of two runs and reads what changed. Results are paired by check identity and then by condition (see
78
+ * the module comment); titles never pair two results. Each side's results are those the run recorded last for each
79
+ * check and condition (`RunDetail.results`).
80
+ *
81
+ * @param baseline - The run to compare against.
82
+ * @param candidate - The run under review. Both are used as given: a newer run never replaces either.
83
+ * @returns Every result of either run, grouped by what changed, with the reasons.
84
+ * @example
85
+ * ```ts
86
+ * const comparison = compareRuns(await runDetail('run:ci-100'), await runDetail('run:ci-101'));
87
+ * comparison.results.filter(item => item.group === 'regressed');
88
+ * ```
89
+ */
90
+ export declare function compareRuns(baseline: RunDetail, candidate: RunDetail): RunComparison;
91
+ /** What one product-latency metric shows on the two sides. */
92
+ export type MetricComparison = {
93
+ readonly state: 'compared';
94
+ readonly name: string;
95
+ readonly baseline: LatencySummary;
96
+ readonly candidate: LatencySummary;
97
+ readonly change: LatencyChange;
98
+ readonly reasons: readonly string[];
99
+ } | {
100
+ readonly state: 'incompatible';
101
+ readonly name: string;
102
+ readonly baseline: LatencySummary;
103
+ readonly candidate: LatencySummary;
104
+ readonly change: LatencyChange | null;
105
+ readonly reasons: readonly string[];
106
+ } | {
107
+ readonly state: 'baseline-only' | 'candidate-only';
108
+ readonly name: string;
109
+ readonly summary: LatencySummary;
110
+ readonly reasons: readonly string[];
111
+ };
112
+ /**
113
+ * Compares the product latency recorded in two sets of results of one check, one per side. A metric is compared only
114
+ * when it is the same measurement (name, unit, boundaries) and both sides were measured on the same devices, in the
115
+ * same environments, with the same instrumentation and other conditions; otherwise it is `incompatible` and no change
116
+ * is computed, with the reasons. A metric only one side recorded is named as such. A side without any latency is unknown,
117
+ * not fast: it contributes no metric. `conditionsDiffer` names differences of the results' own conditions, which make
118
+ * every metric incompatible.
119
+ */
120
+ export declare function compareProductLatency(baseline: readonly LatencyEntry[], candidate: readonly LatencyEntry[], conditionsDiffer?: readonly string[]): MetricComparison[];
121
+ /** How reliable a check was on each side, and whether the two figures can be set against each other. */
122
+ export type ReliabilityComparison = {
123
+ /** True only when both figures are established, were counted in the same scope and against the same recorded spec revision. */
124
+ readonly comparable: boolean;
125
+ readonly baseline: ReliabilityRate;
126
+ readonly candidate: ReliabilityRate;
127
+ /** Candidate minus baseline, in percentage points, only when comparable. */
128
+ readonly points?: number;
129
+ /** `higher` or `lower` only when the two intervals do not overlap; `indistinguishable` when they do. Absent when not comparable. */
130
+ readonly direction?: 'higher' | 'lower' | 'indistinguishable';
131
+ /** Why no change is stated, or what qualifies one. */
132
+ readonly reasons: readonly string[];
133
+ };
134
+ /**
135
+ * The scope that counts the independent executions of one result's check up to the end of its run: the same
136
+ * environment, producer and parameters, and the spec revision that result was recorded against (executions against
137
+ * another revision are other promises). A result that recorded no revision is counted across revisions, which
138
+ * {@link compareReliability} will not compare. Use it for each side, with that side's result and run.
139
+ */
140
+ export declare function reliabilityScopeAt(result: Pick<RunResult, 'environment' | 'producer' | 'parameters' | 'specRevision'>, run: Pick<RunSummary, 'endedAtMs'>): ReliabilityScope;
141
+ /**
142
+ * Sets the reliability of a check up to the baseline run against its reliability up to the candidate run. A change is
143
+ * stated only when both samples are established (at least `minimumExecutions` independent executions), both counted the
144
+ * same recorded spec revision (see {@link reliabilityScopeAt}) and the conditions match; whether the difference exceeds the uncertainty is
145
+ * said by `direction`. Otherwise each figure stands alone and the reasons say why.
146
+ */
147
+ export declare function compareReliability(baseline: ReliabilityDocument, candidate: ReliabilityDocument, conditionsDiffer?: readonly string[]): ReliabilityComparison;
148
+ /** The part of a recorded step this module reads. */
149
+ export type StepLike = {
150
+ readonly name: string;
151
+ readonly offsetMs?: number | undefined;
152
+ };
153
+ /** One step, found on one or both sides. */
154
+ export type AlignedStep = {
155
+ readonly name: string;
156
+ /** Milliseconds after the check began, on each side; absent when that side did not record it or its time. */
157
+ readonly baselineOffsetMs?: number;
158
+ readonly candidateOffsetMs?: number;
159
+ readonly onBaseline: boolean;
160
+ readonly onCandidate: boolean;
161
+ };
162
+ /** How the recorded steps of two results line up. */
163
+ export type StepAlignment = {
164
+ /** `aligned`: some step is on both sides with a time on both (a recording can be sought to it); `unaligned`: otherwise. */
165
+ readonly state: 'aligned' | 'unaligned';
166
+ readonly steps: readonly AlignedStep[];
167
+ /** The steps with a time on both sides: where both recordings can be sought together. */
168
+ readonly seekable: readonly (AlignedStep & {
169
+ readonly baselineOffsetMs: number;
170
+ readonly candidateOffsetMs: number;
171
+ })[];
172
+ /** Whether the two lists of step names differ (the steps themselves are part of the promise). */
173
+ readonly differs: boolean;
174
+ /** In words, why the recordings are or are not aligned. */
175
+ readonly explanation: string;
176
+ };
177
+ /**
178
+ * Lines up the recorded steps of two results by name (the n-th step of a name on one side with the n-th of that name on
179
+ * the other), keeping each side's own time origin: an offset is milliseconds after that side's check began. Nothing is
180
+ * aligned by position or by fuzzy wording. When nothing lines up, `explanation` says why.
181
+ */
182
+ export declare function alignSteps(baseline: readonly StepLike[], candidate: readonly StepLike[]): StepAlignment;