@crvouga/atlas 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/dist/reports/src/evidence-detail.js +6 -1
  3. package/dist/reports/src/evidence-types.d.ts +2 -0
  4. package/dist/reports/src/reliability.d.ts +181 -0
  5. package/dist/reports/src/reliability.js +294 -0
  6. package/dist/reports/src/report-index.js +1 -1
  7. package/dist/reports/src/test-catalog.d.ts +8 -2
  8. package/dist/reports/src/test-catalog.js +10 -5
  9. package/dist/schema/src/api.d.ts +226 -0
  10. package/dist/schema/src/api.js +51 -0
  11. package/dist/server/src/app.js +15 -0
  12. package/dist/server/src/llms.js +1 -0
  13. package/dist/server/src/openapi.js +25 -2
  14. package/docs/reference/http-api.md +48 -0
  15. package/docs/reference/reports.md +272 -1
  16. package/docs/reference/schema.md +11 -0
  17. package/docs/server.md +18 -0
  18. package/docs/ui.md +32 -1
  19. package/llms-full.txt +98 -1
  20. package/package.json +1 -1
  21. package/packages/schema/schemas/effective-delta.schema.json +26 -0
  22. package/packages/schema/schemas/effective-evidence.schema.json +26 -0
  23. package/packages/schema/schemas/evidence-page.schema.json +13 -0
  24. package/packages/schema/schemas/layer-rows.schema.json +13 -0
  25. package/packages/schema/schemas/test-catalog.schema.json +17 -0
  26. package/packages/schema/schemas/test-history.schema.json +34 -0
  27. package/packages/schema/schemas/test-reliability.schema.json +629 -0
  28. package/packages/server/openapi.json +1722 -568
  29. package/packages/visualizer/src/evidence/artifact-tags.tsx +1 -1
  30. package/packages/visualizer/src/explorer/ArtifactsView.tsx +11 -1
  31. package/packages/visualizer/src/explorer/Reliability.tsx +337 -0
  32. package/packages/visualizer/src/explorer/RunsView.tsx +11 -1
  33. package/packages/visualizer/src/explorer/TestDetail.tsx +3 -0
  34. package/packages/visualizer/src/explorer/catalog.tsx +1 -1
  35. package/packages/visualizer/src/explorer/playlist-entries.test.ts +51 -0
  36. package/packages/visualizer/src/explorer/playlist-entries.ts +134 -0
  37. package/packages/visualizer/src/explorer/playlist.tsx +441 -0
  38. package/packages/visualizer/src/explorer/search.ts +36 -0
package/CHANGELOG.md CHANGED
@@ -1,6 +1,18 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
- ## 1.8.0 (2026-10-29)
3
+ ## 1.10.0 (2026-10-29)
4
+
5
+ ### Features
6
+
7
+ - watch a filtered set of scenario recordings as a playlist ([00bac50](https://github.com/crvouga/atlas/commit/00bac503643601dfc54cb97ff1e16ce7ce74bd9f))
8
+
9
+ ## 1.9.0 (2026-10-28)
10
+
11
+ ### Features
12
+
13
+ - qualify reliability by independent executions, cohort, retries and time window ([cf5b6e6](https://github.com/crvouga/atlas/commit/cf5b6e6281895ad35be57dbd11d6dadc91b39986))
14
+
15
+ ## 1.8.0 (2026-10-28)
4
16
 
5
17
  ### Features
6
18
 
@@ -43,6 +43,7 @@ function summarize(holders, owner, inherited, firstIsParent = false) {
43
43
  const labels = {};
44
44
  const videos = [];
45
45
  let attachments = 0, steps = 0, annotations = 0, attempts = 0, links = 0;
46
+ const tried = [];
46
47
  const attachmentsOf = (list, by, scopes, where = {}) => {
47
48
  for (const item of list ?? []) {
48
49
  attachments += 1;
@@ -69,12 +70,16 @@ function summarize(holders, owner, inherited, firstIsParent = false) {
69
70
  steps += holder.steps?.length ?? 0;
70
71
  annotations += holder.annotations?.length ?? 0;
71
72
  attempts += holder.attempts?.length ?? 0;
73
+ for (const attempt of holder.attempts ?? [])
74
+ tried.push({ index: attempt.index, status: attempt.status });
72
75
  links += holder.links?.length ?? 0;
73
76
  for (const tag of holder.tags ?? [])
74
77
  tags.add(tag);
75
78
  Object.assign(labels, holder.labels);
76
79
  }
77
- return { attachments, steps, annotations, attempts, links, tags: [...tags], ...(artifactTags.size ? { artifactTags: [...artifactTags].sort() } : {}), labels, roles: [...roles].sort(), ...(videos.length ? { videos } : {}) };
80
+ // The outcome of each try in the order it was made, so a reader can tell a first-try pass from one that needed a retry.
81
+ const attemptStatuses = tried.sort((a, b) => a.index - b.index).map(attempt => attempt.status);
82
+ return { attachments, steps, annotations, attempts, ...(attemptStatuses.length ? { attemptStatuses } : {}), links, tags: [...tags], ...(artifactTags.size ? { artifactTags: [...artifactTags].sort() } : {}), labels, roles: [...roles].sort(), ...(videos.length ? { videos } : {}) };
78
83
  }
79
84
  /**
80
85
  * Whether an attachment is a video, by its effective media type: the producer's `contentType`, else the one its file
@@ -81,6 +81,8 @@ export type EvidenceSummary = {
81
81
  readonly annotations: number;
82
82
  /** How many tries were recorded when the check was retried. */
83
83
  readonly attempts: number;
84
+ /** The status of each recorded try, in the order they were made (its length is `attempts`). Absent when no try was recorded, and in reports written before this was recorded: a first try is then unknown, never assumed to have passed. */
85
+ readonly attemptStatuses?: readonly EvidenceStatus[];
84
86
  readonly links: number;
85
87
  readonly tags: readonly string[];
86
88
  /** The normalized tags of the attachments themselves (`#demo` is `demo`), sorted and distinct. Absent when no attachment is tagged. Unlike `tags` they are never inherited from a check or run. */
@@ -0,0 +1,181 @@
1
+ import { type TestResultEntry } from './test-catalog.js';
2
+ /** Version of the reliability document. */
3
+ export declare const RELIABILITY_VERSION = 1;
4
+ /** Completed executions below which a rate is shown but not treated as established. */
5
+ export declare const MIN_EXECUTIONS = 10;
6
+ /** What a reliability figure is about. Every field is optional; nothing asked is everything the history holds (of the current spec revision). */
7
+ export type ReliabilityScope = {
8
+ /** Environments or devices (`phone`, `desktop-web`). Executions in others never count towards these. */
9
+ readonly environment?: readonly string[];
10
+ readonly producer?: readonly string[];
11
+ /** `key=value` parameter choices (`device=small`). */
12
+ readonly parameter?: readonly string[];
13
+ readonly branch?: readonly string[];
14
+ /** Commits: a full id, or a prefix of at least seven characters. */
15
+ readonly sha?: readonly string[];
16
+ /** Only executions recorded against these spec fingerprints. Overrides `revision`. */
17
+ readonly specRevision?: readonly string[];
18
+ /** `current` (the default): only the newest spec revision recorded in the rest of the scope, the older ones shown as context. `any`: every revision, pooled. */
19
+ readonly revision?: 'current' | 'any';
20
+ /** Only executions at or after this time, epoch ms. */
21
+ readonly since?: number;
22
+ /** Only executions at or before this time, epoch ms. */
23
+ readonly until?: number;
24
+ };
25
+ /** Why an execution is not in the denominator. */
26
+ export type ReliabilityExclusion = 'not-run' | 'unfinished' | 'unknown-status' | 'historical-revision' | 'revision-not-recorded';
27
+ /** One independent execution: the last word of one run on one producer, environment, parameter set and kind. */
28
+ export type ReliabilityExecution = {
29
+ /** The key of the run it belongs to (see `runKey`). */
30
+ readonly run: string;
31
+ /** When its last word was recorded, epoch ms. */
32
+ readonly at: number;
33
+ /** Its last word. */
34
+ readonly status: string;
35
+ /** True when it is in the denominator. */
36
+ readonly counted: boolean;
37
+ /** Why it is not, when it is not. */
38
+ readonly excluded?: ReliabilityExclusion;
39
+ readonly environment: string | null;
40
+ readonly producer: string;
41
+ readonly kind: string;
42
+ readonly parameters: Readonly<Record<string, string>>;
43
+ readonly branch: string | null;
44
+ readonly sha: string | null;
45
+ readonly runUrl: string | null;
46
+ /** The layer holding its last word (open it with `/api/reports/evidence/detail/{backend}/{layer}?check=`). */
47
+ readonly layer: string;
48
+ readonly backend: string;
49
+ readonly durationMs: number | null;
50
+ readonly specRevision?: string;
51
+ /** How many raw snapshots (layers) recorded it. They are one execution, not that many trials. */
52
+ readonly snapshots: number;
53
+ /** The status of each recorded try, in order. Absent when no try was recorded. */
54
+ readonly attempts?: readonly string[];
55
+ /** The first try's status, when tries were recorded. */
56
+ readonly firstAttempt?: string;
57
+ /** True when a failed snapshot of this execution was followed by a later one: it was published again after a failure. */
58
+ readonly republished: boolean;
59
+ };
60
+ /** Counts and rates over a set of executions. */
61
+ export type ReliabilityRate = {
62
+ /** Independent executions that passed or failed. */
63
+ readonly executions: number;
64
+ readonly passed: number;
65
+ readonly failed: number;
66
+ /** passed / executions, or null with none. This is the eventual success rate. */
67
+ readonly rate: number | null;
68
+ };
69
+ /** A confidence interval and what it assumes. */
70
+ export type ReliabilityInterval = {
71
+ readonly method: 'wilson';
72
+ readonly level: number;
73
+ readonly lower: number;
74
+ readonly upper: number;
75
+ /** What must hold for the interval to mean anything. */
76
+ readonly assumptions: string;
77
+ };
78
+ /** A reason to doubt or qualify the figure. An open set: treat a code you do not know as informational. */
79
+ export type ReliabilityNote = {
80
+ readonly code: string;
81
+ /** `warning`: the figure should not be read as representative without this in mind. */
82
+ readonly severity: 'warning' | 'info';
83
+ readonly message: string;
84
+ };
85
+ /** One environment, producer and parameter set among the counted executions, with its own rate. */
86
+ export type ReliabilityCohort = ReliabilityRate & {
87
+ readonly environment: string | null;
88
+ readonly producer: string;
89
+ readonly parameters: Readonly<Record<string, string>>;
90
+ };
91
+ /** `GET /api/reports/tests/reliability`: how reliable a test is within a scope, and how that figure was counted. */
92
+ export type ReliabilityDocument = {
93
+ readonly schemaVersion: typeof RELIABILITY_VERSION;
94
+ readonly key: string;
95
+ readonly scope: ReliabilityScope;
96
+ /** Completed executions below which the sample is `insufficient`. */
97
+ readonly minimumExecutions: number;
98
+ /** Count only: `none` without an independent execution, `insufficient` below the minimum, `sufficient` otherwise. Says nothing about representativeness; read `notes`. */
99
+ readonly sample: 'none' | 'insufficient' | 'sufficient';
100
+ /** Eventual success over the counted executions. */
101
+ readonly eventual: ReliabilityRate;
102
+ readonly interval: ReliabilityInterval | null;
103
+ /** Executions whose first try is known (tries were recorded), and how many of those passed first time. */
104
+ readonly firstAttempt: ReliabilityRate;
105
+ /** Counted executions that recorded no tries: unknown first try, left out of `firstAttempt`. */
106
+ readonly unrecordedAttempts: number;
107
+ readonly attempts: {
108
+ /** Tries recorded across the counted executions. */
109
+ readonly total: number;
110
+ /** Counted executions that needed more than one try. */
111
+ readonly retried: number;
112
+ /** Counted executions that failed first and passed in the end. */
113
+ readonly recovered: number;
114
+ };
115
+ /** The revision `revision: 'current'` counted, or null when none is recorded. */
116
+ readonly currentRevision: string | null;
117
+ /** Earlier spec revisions, shown as context and counted nowhere above. */
118
+ readonly historical: {
119
+ readonly executions: number;
120
+ readonly passed: number;
121
+ readonly failed: number;
122
+ readonly revisions: readonly {
123
+ readonly specRevision: string;
124
+ readonly executions: number;
125
+ }[];
126
+ };
127
+ /** What was left out, by name. */
128
+ readonly excluded: Readonly<Record<ReliabilityExclusion, number>>;
129
+ /** Executions the scope did not select. */
130
+ readonly outsideScope: number;
131
+ /** Raw recorded snapshots behind the counted executions. */
132
+ readonly snapshots: number;
133
+ /** Counted executions per environment, producer and parameter set. */
134
+ readonly cohorts: readonly ReliabilityCohort[];
135
+ /** What the scope could choose among: the values the history holds, with their independent executions. */
136
+ readonly available: {
137
+ readonly environments: readonly {
138
+ readonly value: string;
139
+ readonly count: number;
140
+ }[];
141
+ readonly producers: readonly {
142
+ readonly value: string;
143
+ readonly count: number;
144
+ }[];
145
+ readonly branches: readonly {
146
+ readonly value: string;
147
+ readonly count: number;
148
+ }[];
149
+ readonly specRevisions: readonly {
150
+ readonly value: string;
151
+ readonly count: number;
152
+ }[];
153
+ };
154
+ readonly notes: readonly ReliabilityNote[];
155
+ /** Every independent execution the history holds for the scope's selection (counted or not), newest first: the denominator, open to inspection. */
156
+ readonly executions: readonly ReliabilityExecution[];
157
+ };
158
+ /**
159
+ * How reliable one test is inside a scope, from the recorded results of that test (`TestHistoryDocument.history`, any
160
+ * order). See the module comment for what is counted and what is left out.
161
+ *
162
+ * @param key The test's key, echoed in the document.
163
+ * @param history Every recorded result of the test the caller holds. When it holds as many as `HISTORY_PER_TEST` the
164
+ * document says the history is bounded.
165
+ * @param scope What to count; see {@link ReliabilityScope}.
166
+ * @example
167
+ * ```ts
168
+ * const document = reliability(test.key, history.history, { environment: ['phone'], since: Date.now() - 30 * 86_400_000 });
169
+ * if (document.sample !== 'sufficient') console.log(`only ${document.eventual.executions} executions`);
170
+ * ```
171
+ */
172
+ export declare function reliability(key: string, history: readonly TestResultEntry[], scope?: ReliabilityScope): ReliabilityDocument;
173
+ /** The scope as a short phrase, or `all recorded executions`. */
174
+ export declare function scopeText(scope: ReliabilityScope): string;
175
+ /** The reliability document as Markdown, for an agent or a terminal. */
176
+ export declare function reliabilityMarkdown(document: ReliabilityDocument): string;
177
+ /**
178
+ * The scope an address asks for: `environment`, `producer`, `parameter`, `branch`, `sha` and `specRevision` (comma-separated
179
+ * lists), `revision` (`any`, else current) and `since` and `until` (epoch milliseconds, or a date).
180
+ */
181
+ export declare function parseReliabilityScope(params: URLSearchParams): ReliabilityScope;
@@ -0,0 +1,294 @@
1
+ /**
2
+ * @module Reliability of a test
3
+ *
4
+ * How often a test passed, counted so the number can be trusted or doubted. A pass rate over raw history is easy to
5
+ * overinterpret: one execution is published as several snapshots, a retry is not a second trial, desktop and phone are
6
+ * different questions, and an earlier version of a behavior says nothing about the current one. This module answers
7
+ * from the recorded results of one test and a {@link ReliabilityScope}, and states what it counted:
8
+ *
9
+ * - **Independent executions.** The snapshots one run recorded for one producer, environment, parameter set and kind
10
+ * (running, finished, enriched) are one execution; its last word counts once, with the tries it recorded. Raw
11
+ * snapshots stay inspectable (`snapshots`) and are never weighted as new trials.
12
+ * - **First try and eventual.** Where tries were recorded, `firstAttempt` counts executions whose first try passed and
13
+ * `rate` counts those that passed in the end. An execution that recorded no tries is not assumed to have passed first
14
+ * time: it is left out of `firstAttempt` and counted in `unrecordedAttempts`.
15
+ * - **Scope.** Environment, producer, parameters, branch, commit, spec revision and a time window select the executions;
16
+ * the rest are shown as `outsideScope`, never mixed in. By default only the newest recorded revision of the spec
17
+ * counts, and earlier ones are shown as `historical` context.
18
+ * - **What is not a trial.** Only `passed` and `failed` executions are in the denominator. `not-run` and unfinished
19
+ * executions are excluded and counted by name; nothing missing becomes a pass.
20
+ * - **Limits, in words.** `notes` say when the sample is too small to establish anything, when environments, producers
21
+ * or branches are pooled, when failed cases were published again, and when history is bounded. A rate is never a
22
+ * production failure probability: the checks are a selection.
23
+ *
24
+ * It only reads results; it never changes a status, a slot or a winner.
25
+ *
26
+ * @see docs/server.md#reading-the-report-as-an-agent
27
+ */
28
+ import { parseTime, splitList } from './artifact-query.js';
29
+ import { HISTORY_PER_TEST } from './test-catalog.js';
30
+ /** Version of the reliability document. */
31
+ export const RELIABILITY_VERSION = 1;
32
+ /** Completed executions below which a rate is shown but not treated as established. */
33
+ export const MIN_EXECUTIONS = 10;
34
+ const COMPLETED = new Set(['passed', 'failed']);
35
+ const SEVERITY = { failed: 2, passed: 1 };
36
+ function cellKey(entry) {
37
+ return JSON.stringify([entry.run, entry.producer, entry.environment, entry.kind, Object.entries(entry.parameters).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))]);
38
+ }
39
+ /** The last word among a cell's snapshots: a completed one beats an unfinished one, then the newer, then the more severe, then the larger layer id. */
40
+ function lastWord(entries) {
41
+ let best = entries[0];
42
+ for (const entry of entries) {
43
+ const a = COMPLETED.has(entry.status), b = COMPLETED.has(best.status);
44
+ if (a !== b ? a : entry.at !== best.at ? entry.at > best.at : (SEVERITY[entry.status] ?? 0) !== (SEVERITY[best.status] ?? 0) ? (SEVERITY[entry.status] ?? 0) > (SEVERITY[best.status] ?? 0) : entry.layer > best.layer)
45
+ best = entry;
46
+ }
47
+ return best;
48
+ }
49
+ function matches(list, value) {
50
+ return !list?.length || (value !== null && list.includes(value));
51
+ }
52
+ function matchesSha(list, sha) {
53
+ if (!list?.length)
54
+ return true;
55
+ if (!sha)
56
+ return false;
57
+ const full = sha.toLowerCase();
58
+ return list.some(wanted => { const w = wanted.toLowerCase(); return full === w || (w.length >= 7 && full.startsWith(w)); });
59
+ }
60
+ function matchesParameters(list, parameters) {
61
+ return !list?.length || list.every(item => { const at = item.indexOf('='); return at < 0 ? item in parameters : parameters[item.slice(0, at)] === item.slice(at + 1); });
62
+ }
63
+ function rate(passed, failed) {
64
+ const executions = passed + failed;
65
+ return { executions, passed, failed, rate: executions ? passed / executions : null };
66
+ }
67
+ /** The Wilson score interval for `passed` of `n`, at 95%. It assumes independent executions under identical conditions. */
68
+ function wilson(passed, n) {
69
+ if (n === 0)
70
+ return null;
71
+ const z = 1.96, p = passed / n, z2 = z * z;
72
+ const centre = (p + z2 / (2 * n)) / (1 + z2 / n);
73
+ const half = (z * Math.sqrt((p * (1 - p)) / n + z2 / (4 * n * n))) / (1 + z2 / n);
74
+ return { method: 'wilson', level: 0.95, lower: Math.max(0, centre - half), upper: Math.min(1, centre + half), assumptions: 'Executions are independent and ran under the same conditions, and the checks are representative of what is being asked about. Pooled environments, shared causes of failure and selected reruns break these assumptions.' };
75
+ }
76
+ function tally(values) {
77
+ const counts = new Map();
78
+ for (const value of values)
79
+ if (value !== null)
80
+ counts.set(value, (counts.get(value) ?? 0) + 1);
81
+ return [...counts].map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count || (a.value < b.value ? -1 : 1));
82
+ }
83
+ /**
84
+ * How reliable one test is inside a scope, from the recorded results of that test (`TestHistoryDocument.history`, any
85
+ * order). See the module comment for what is counted and what is left out.
86
+ *
87
+ * @param key The test's key, echoed in the document.
88
+ * @param history Every recorded result of the test the caller holds. When it holds as many as `HISTORY_PER_TEST` the
89
+ * document says the history is bounded.
90
+ * @param scope What to count; see {@link ReliabilityScope}.
91
+ * @example
92
+ * ```ts
93
+ * const document = reliability(test.key, history.history, { environment: ['phone'], since: Date.now() - 30 * 86_400_000 });
94
+ * if (document.sample !== 'sufficient') console.log(`only ${document.eventual.executions} executions`);
95
+ * ```
96
+ */
97
+ export function reliability(key, history, scope = {}) {
98
+ // One execution per run, producer, environment, parameter set and kind, whatever number of snapshots recorded it.
99
+ const cells = new Map();
100
+ for (const entry of history) {
101
+ const id = cellKey(entry);
102
+ const cell = cells.get(id);
103
+ if (cell)
104
+ cell.entries.push(entry);
105
+ else
106
+ cells.set(id, { entries: [entry] });
107
+ }
108
+ const candidates = [...cells.values()].map(({ entries }) => {
109
+ const last = lastWord(entries);
110
+ const completedEarlier = entries.filter(entry => COMPLETED.has(entry.status) && entry !== last && entry.layer !== last.layer);
111
+ return { last, snapshots: new Set(entries.map(entry => entry.layer)).size, republished: COMPLETED.has(last.status) && completedEarlier.some(entry => entry.status === 'failed' && entry.at <= last.at) };
112
+ });
113
+ // Scope, apart from the window and the spec revision: which revision is current is a fact about this selection.
114
+ const inPlace = candidates.filter(({ last }) => matches(scope.environment, last.environment) && matches(scope.producer, last.producer) && matchesParameters(scope.parameter, last.parameters) && matches(scope.branch, last.branch) && matchesSha(scope.sha, last.sha));
115
+ let currentRevision = null;
116
+ let newest = -Infinity;
117
+ for (const { last } of inPlace)
118
+ if (last.specRevision && last.at > newest) {
119
+ newest = last.at;
120
+ currentRevision = last.specRevision;
121
+ }
122
+ const revisionsKnown = inPlace.some(({ last }) => last.specRevision);
123
+ const explicit = scope.specRevision?.length ? scope.specRevision : null;
124
+ const onlyCurrent = !explicit && (scope.revision ?? 'current') === 'current' && revisionsKnown;
125
+ const inWindow = (at) => (scope.since === undefined || at >= scope.since) && (scope.until === undefined || at <= scope.until);
126
+ const selected = inPlace.filter(({ last }) => inWindow(last.at));
127
+ const outsideScope = candidates.length - selected.length;
128
+ const excluded = { 'not-run': 0, unfinished: 0, 'unknown-status': 0, 'historical-revision': 0, 'revision-not-recorded': 0 };
129
+ const executions = [];
130
+ const historicalRevisions = new Map();
131
+ let historicalPassed = 0, historicalFailed = 0;
132
+ const counted = [];
133
+ for (const candidate of selected) {
134
+ const { last } = candidate;
135
+ let reason;
136
+ if (explicit ? !last.specRevision || !explicit.includes(last.specRevision) : onlyCurrent && last.specRevision !== currentRevision) {
137
+ reason = last.specRevision ? 'historical-revision' : 'revision-not-recorded';
138
+ }
139
+ else if (last.status === 'not-run')
140
+ reason = 'not-run';
141
+ else if (last.status === 'running')
142
+ reason = 'unfinished';
143
+ else if (!COMPLETED.has(last.status))
144
+ reason = 'unknown-status';
145
+ if (reason === 'historical-revision') {
146
+ historicalRevisions.set(last.specRevision, (historicalRevisions.get(last.specRevision) ?? 0) + 1);
147
+ if (last.status === 'passed')
148
+ historicalPassed += 1;
149
+ else if (last.status === 'failed')
150
+ historicalFailed += 1;
151
+ }
152
+ if (reason)
153
+ excluded[reason] += 1;
154
+ else
155
+ counted.push(candidate);
156
+ const attempts = last.evidence?.attemptStatuses;
157
+ executions.push({
158
+ run: last.run, at: last.at, status: last.status, counted: !reason, ...(reason ? { excluded: reason } : {}),
159
+ environment: last.environment, producer: last.producer, kind: last.kind, parameters: last.parameters, branch: last.branch, sha: last.sha, runUrl: last.runUrl,
160
+ layer: last.layer, backend: last.backend, durationMs: last.durationMs, ...(last.specRevision ? { specRevision: last.specRevision } : {}),
161
+ snapshots: candidate.snapshots, ...(attempts?.length ? { attempts, firstAttempt: attempts[0] } : {}), republished: candidate.republished,
162
+ });
163
+ }
164
+ executions.sort((a, b) => b.at - a.at || (a.layer < b.layer ? 1 : a.layer > b.layer ? -1 : 0));
165
+ const passed = counted.filter(({ last }) => last.status === 'passed').length;
166
+ const eventual = rate(passed, counted.length - passed);
167
+ let firstPassed = 0, firstFailed = 0, unrecorded = 0, totalAttempts = 0, retried = 0, recovered = 0;
168
+ for (const { last } of counted) {
169
+ const tries = last.evidence?.attemptStatuses;
170
+ if (!tries?.length) {
171
+ unrecorded += 1;
172
+ continue;
173
+ }
174
+ totalAttempts += tries.length;
175
+ if (tries.length > 1)
176
+ retried += 1;
177
+ if (tries[0] === 'passed')
178
+ firstPassed += 1;
179
+ else if (tries[0] === 'failed')
180
+ firstFailed += 1;
181
+ else
182
+ unrecorded += 1;
183
+ if (tries[0] === 'failed' && last.status === 'passed')
184
+ recovered += 1;
185
+ }
186
+ const cohortsMap = new Map();
187
+ for (const { last } of counted) {
188
+ const id = JSON.stringify([last.environment, last.producer, Object.entries(last.parameters).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))]);
189
+ const cohort = cohortsMap.get(id) ?? { environment: last.environment, producer: last.producer, parameters: last.parameters, passed: 0, failed: 0 };
190
+ if (last.status === 'passed')
191
+ cohort.passed += 1;
192
+ else
193
+ cohort.failed += 1;
194
+ cohortsMap.set(id, cohort);
195
+ }
196
+ const cohorts = [...cohortsMap.values()].map(({ passed: p, failed: f, ...rest }) => ({ ...rest, ...rate(p, f) })).sort((a, b) => b.executions - a.executions || (a.environment ?? '').localeCompare(b.environment ?? '') || a.producer.localeCompare(b.producer));
197
+ const bounded = history.length >= HISTORY_PER_TEST;
198
+ const oldest = history.reduce((at, entry) => Math.min(at, entry.at), Infinity);
199
+ const distinct = (list) => new Set(list.filter((value) => value !== null)).size;
200
+ const countedBranches = distinct(counted.map(({ last }) => last.branch));
201
+ const sample = eventual.executions === 0 ? 'none' : eventual.executions < MIN_EXECUTIONS ? 'insufficient' : 'sufficient';
202
+ const republished = counted.filter(candidate => candidate.republished).length;
203
+ const notes = [];
204
+ if (sample === 'none')
205
+ notes.push({ code: 'no-observations', severity: 'warning', message: 'No independent execution in this scope passed or failed, so there is no rate. Nothing is assumed to have passed.' });
206
+ else if (sample === 'insufficient')
207
+ notes.push({ code: 'insufficient-sample', severity: 'warning', message: `Only ${eventual.executions} independent ${eventual.executions === 1 ? 'execution' : 'executions'} in this scope (at least ${MIN_EXECUTIONS} are needed before a rate is read as established). ${eventual.failed === 0 ? 'All of them passed, which is not evidence that the next one will.' : 'The rate moves a great deal with each further execution.'}` });
208
+ if (!scope.environment?.length && distinct(counted.map(({ last }) => last.environment)) > 1)
209
+ notes.push({ code: 'pooled-environments', severity: 'warning', message: 'Executions from several environments are pooled. Choose an environment to ask about one; each has its own rate under cohorts.' });
210
+ if (!scope.producer?.length && distinct(counted.map(({ last }) => last.producer)) > 1)
211
+ notes.push({ code: 'pooled-producers', severity: 'warning', message: 'Executions from several producers are pooled. A change of producer can change how a check behaves without any change in the product.' });
212
+ if (!scope.branch?.length && countedBranches > 1)
213
+ notes.push({ code: 'pooled-branches', severity: 'warning', message: 'Executions from several branches are pooled, so they may not describe the same code.' });
214
+ if (!scope.parameter?.length && new Set(counted.map(({ last }) => JSON.stringify(Object.entries(last.parameters).sort()))).size > 1)
215
+ notes.push({ code: 'pooled-parameters', severity: 'info', message: 'Executions with different parameter choices are pooled; each has its own rate under cohorts.' });
216
+ if (republished)
217
+ notes.push({ code: 'selected-reruns', severity: 'warning', message: `${republished} counted ${republished === 1 ? 'execution was' : 'executions were'} published again after a failure, so a failed case may have been rerun until it passed. The rate is not representative of unselected runs.` });
218
+ if (bounded)
219
+ notes.push({ code: 'history-bounded', severity: 'warning', message: `Only the newest ${HISTORY_PER_TEST} recorded results of this test are kept, so earlier executions are missing and the figure is about recent history only.` });
220
+ if (bounded && scope.since !== undefined && oldest > scope.since)
221
+ notes.push({ code: 'window-incomplete', severity: 'warning', message: 'The time window begins before the oldest result that is kept, so executions inside it may be missing.' });
222
+ if (eventual.executions && unrecorded)
223
+ notes.push({ code: 'attempts-not-recorded', severity: 'info', message: `${unrecorded} counted ${unrecorded === 1 ? 'execution recorded' : 'executions recorded'} no tries, so whether they passed first time is unknown and they are left out of the first-try rate.` });
224
+ const left = Object.entries(excluded).filter(([, count]) => count > 0).map(([reason, count]) => `${count} ${reason.replace('-', ' ')}`);
225
+ if (left.length)
226
+ notes.push({ code: 'excluded', severity: 'info', message: `Left out of the denominator: ${left.join(', ')}. Only passed and failed executions are trials; a result that did not run is never counted as a pass.` });
227
+ if (explicit === null && scope.revision !== 'any' && !revisionsKnown && inPlace.length)
228
+ notes.push({ code: 'revision-not-recorded', severity: 'info', message: 'No result records which revision of the spec it ran against, so executions of different versions of the behavior cannot be told apart.' });
229
+ if (explicit === null && scope.revision === 'any' && new Set(inPlace.map(({ last }) => last.specRevision).filter(Boolean)).size > 1)
230
+ notes.push({ code: 'pooled-revisions', severity: 'warning', message: 'Executions against different revisions of the spec are pooled, though the behavior may have changed between them.' });
231
+ if (historicalRevisions.size)
232
+ notes.push({ code: 'historical-revisions', severity: 'info', message: `${[...historicalRevisions.values()].reduce((sum, count) => sum + count, 0)} executions against earlier spec revisions are shown as historical context and not counted.` });
233
+ return {
234
+ schemaVersion: RELIABILITY_VERSION, key, scope, minimumExecutions: MIN_EXECUTIONS, sample, eventual, interval: wilson(passed, eventual.executions),
235
+ firstAttempt: rate(firstPassed, firstFailed), unrecordedAttempts: unrecorded, attempts: { total: totalAttempts, retried, recovered },
236
+ currentRevision: onlyCurrent ? currentRevision : null,
237
+ historical: { executions: historicalPassed + historicalFailed, passed: historicalPassed, failed: historicalFailed, revisions: [...historicalRevisions].map(([specRevision, executions]) => ({ specRevision, executions })).sort((a, b) => b.executions - a.executions) },
238
+ excluded, outsideScope, snapshots: counted.reduce((sum, candidate) => sum + candidate.snapshots, 0), cohorts,
239
+ available: {
240
+ environments: tally(candidates.map(({ last }) => last.environment)), producers: tally(candidates.map(({ last }) => last.producer)),
241
+ branches: tally(candidates.map(({ last }) => last.branch)), specRevisions: tally(candidates.map(({ last }) => last.specRevision ?? null)),
242
+ },
243
+ notes, executions,
244
+ };
245
+ }
246
+ const percent = (value) => (value === null ? 'n/a' : `${Math.round(value * 1000) / 10}%`);
247
+ const cell = (text) => text.replaceAll('|', '\\|').replaceAll('\n', ' ');
248
+ /** The scope as a short phrase, or `all recorded executions`. */
249
+ export function scopeText(scope) {
250
+ const parts = [
251
+ scope.environment?.length ? `environment ${scope.environment.join(', ')}` : '', scope.producer?.length ? `producer ${scope.producer.join(', ')}` : '',
252
+ scope.parameter?.length ? scope.parameter.join(', ') : '', scope.branch?.length ? `branch ${scope.branch.join(', ')}` : '', scope.sha?.length ? `commit ${scope.sha.join(', ')}` : '',
253
+ scope.specRevision?.length ? `spec revision ${scope.specRevision.join(', ')}` : scope.revision === 'any' ? 'every spec revision' : '',
254
+ scope.since !== undefined ? `since ${new Date(scope.since).toISOString()}` : '', scope.until !== undefined ? `until ${new Date(scope.until).toISOString()}` : '',
255
+ ].filter(Boolean);
256
+ return parts.length ? parts.join('; ') : 'all recorded executions';
257
+ }
258
+ /** The reliability document as Markdown, for an agent or a terminal. */
259
+ export function reliabilityMarkdown(document) {
260
+ const { eventual, firstAttempt } = document;
261
+ const lines = [
262
+ `# Reliability: ${document.key}`, '',
263
+ `Scope: ${scopeText(document.scope)}${document.currentRevision ? ` (current spec revision \`${document.currentRevision}\`)` : ''}`, '',
264
+ `- Independent executions counted: ${eventual.executions} (${eventual.passed} passed, ${eventual.failed} failed) from ${document.snapshots} recorded snapshots`,
265
+ `- Sample: ${document.sample}${document.sample === 'sufficient' ? '' : ` (at least ${document.minimumExecutions} needed)`}`,
266
+ `- Eventual success: ${percent(eventual.rate)}${document.interval ? ` (95% Wilson interval ${percent(document.interval.lower)} to ${percent(document.interval.upper)})` : ''}`,
267
+ `- First-try success: ${percent(firstAttempt.rate)} of ${firstAttempt.executions} executions with recorded tries; ${document.unrecordedAttempts} recorded no tries`,
268
+ `- Tries recorded: ${document.attempts.total}; ${document.attempts.retried} executions retried, ${document.attempts.recovered} failed first and passed in the end`,
269
+ `- Outside the scope: ${document.outsideScope}; excluded: ${Object.entries(document.excluded).map(([reason, count]) => `${count} ${reason}`).join(', ')}`,
270
+ ];
271
+ if (document.historical.executions)
272
+ lines.push(`- Earlier spec revisions (context, not counted): ${document.historical.executions} executions, ${document.historical.passed} passed`);
273
+ if (document.notes.length)
274
+ lines.push('', '## Notes', '', ...document.notes.map(note => `- ${note.severity === 'warning' ? '**Warning**: ' : ''}${note.message}`));
275
+ if (document.cohorts.length > 1)
276
+ lines.push('', '## Cohorts', '', '| Environment | Producer | Parameters | Executions | Passed | Rate |', '| --- | --- | --- | --- | --- | --- |', ...document.cohorts.map(c => `| ${cell(c.environment ?? 'unspecified')} | ${cell(c.producer)} | ${cell(Object.entries(c.parameters).map(([k, v]) => `${k}=${v}`).join(', ') || '—')} | ${c.executions} | ${c.passed} | ${percent(c.rate)} |`));
277
+ if (document.executions.length)
278
+ lines.push('', '## Executions', '', '| Recorded | Run | Environment | Producer | Status | Tries | Counted |', '| --- | --- | --- | --- | --- | --- | --- |', ...document.executions.map(e => `| ${new Date(e.at).toISOString().replace('.000Z', 'Z')} | ${cell(e.run)} | ${cell(e.environment ?? 'unspecified')} | ${cell(e.producer)} | ${e.status} | ${e.attempts?.join(' → ') ?? 'not recorded'} | ${e.counted ? 'yes' : `no (${e.excluded})`} |`));
279
+ return `${lines.join('\n')}\n`;
280
+ }
281
+ /**
282
+ * The scope an address asks for: `environment`, `producer`, `parameter`, `branch`, `sha` and `specRevision` (comma-separated
283
+ * lists), `revision` (`any`, else current) and `since` and `until` (epoch milliseconds, or a date).
284
+ */
285
+ export function parseReliabilityScope(params) {
286
+ const list = (name) => params.getAll(name).flatMap(splitList);
287
+ const some = (name) => { const values = list(name); return values.length ? { [name]: values } : {}; };
288
+ const since = parseTime(params.get('since')), until = parseTime(params.get('until'));
289
+ return {
290
+ ...some('environment'), ...some('producer'), ...some('parameter'), ...some('branch'), ...some('sha'), ...some('specRevision'),
291
+ ...(params.get('revision') === 'any' ? { revision: 'any' } : {}),
292
+ ...(since === undefined ? {} : { since }), ...(until === undefined ? {} : { until }),
293
+ };
294
+ }
@@ -233,7 +233,7 @@ export class ReportIndex {
233
233
  if (!this.#include(evidence.layer))
234
234
  return;
235
235
  this.#added.push({ ...evidence.layer, backend });
236
- this.#history.add(evidence.layer, evidence.rows, specs, backend);
236
+ this.#history.add(evidence.layer, evidence.rows, specs, backend, evidence.fingerprints);
237
237
  const wins = new Set();
238
238
  const keyOf = layerResultKeys(evidence.layer);
239
239
  for (const row of evidence.rows) {
@@ -30,6 +30,8 @@ export type TestResultEntry = {
30
30
  readonly backend: string;
31
31
  /** Why it has its status: the first 500 characters of the check's detail. */
32
32
  readonly detail: string;
33
+ /** The fingerprint of its spec text the layer recorded, when it recorded one: results with different revisions were recorded against different promises. Absent means not recorded, never "the same". */
34
+ readonly specRevision?: string;
33
35
  /** What it carried beyond its outcome (attachments, steps, retries, tags, labels), when anything. */
34
36
  readonly evidence?: EvidenceSummary;
35
37
  /** 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). */
@@ -439,8 +441,12 @@ export declare class TestHistory {
439
441
  }[];
440
442
  /** How many layers were added. */
441
443
  get layers(): number;
442
- /** Takes in one layer: its header, its rows, the specs it recorded and the backend it was read from. A layer id already added is ignored. */
443
- add(layer: LayerHeader, rows: readonly LayerRow[], specs?: readonly SpecEntry[], backend?: string): void;
444
+ /**
445
+ * Takes in one layer: its header, its rows, the specs it recorded and the backend it was read from. A layer id already
446
+ * added is ignored. `fingerprints` (spec path to the fingerprint the layer recorded, as `LayerRows.fingerprints`) gives
447
+ * each result its `specRevision`; without it no result has one.
448
+ */
449
+ add(layer: LayerHeader, rows: readonly LayerRow[], specs?: readonly SpecEntry[], backend?: string, fingerprints?: Readonly<Record<string, string>>): void;
444
450
  /** The recorded results of one test (by `TestSummary.key`), newest first. Empty when none. */
445
451
  of(key: string): readonly TestResultEntry[];
446
452
  /** How many runs it knows of. */
@@ -56,8 +56,8 @@ function shortDetail(detail) {
56
56
  function completed(status) {
57
57
  return status === 'passed' || status === 'failed';
58
58
  }
59
- function entryOf(layer, row, backend) {
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) });
59
+ function entryOf(layer, row, backend, fingerprints = {}) {
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), f: row.specPath ? fingerprints[row.specPath] : undefined });
61
61
  }
62
62
  function originOf(layer, backend) {
63
63
  const parameters = Object.keys(layer.parameters).sort().map(key => `${key}=${layer.parameters[key]}`).join('\u0002');
@@ -72,6 +72,7 @@ function materialize(held) {
72
72
  return {
73
73
  at: o.at, status: held.s, durationMs: held.d, environment: o.environment, producer: o.producer, kind: held.k, parameters: o.parameters,
74
74
  branch: o.branch, sha: o.sha, runUrl: o.runUrl, run: o.run, layer: o.layer, backend: o.backend, detail: shortDetail(held.t),
75
+ ...(held.f ? { specRevision: held.f } : {}),
75
76
  ...(held.e ? { evidence: held.e } : {}),
76
77
  ...(held.m ? { latency: held.m } : {}),
77
78
  };
@@ -124,8 +125,12 @@ export class TestHistory {
124
125
  get layers() {
125
126
  return this.#seen.size;
126
127
  }
127
- /** Takes in one layer: its header, its rows, the specs it recorded and the backend it was read from. A layer id already added is ignored. */
128
- add(layer, rows, specs = [], backend = layer.backend) {
128
+ /**
129
+ * Takes in one layer: its header, its rows, the specs it recorded and the backend it was read from. A layer id already
130
+ * added is ignored. `fingerprints` (spec path to the fingerprint the layer recorded, as `LayerRows.fingerprints`) gives
131
+ * each result its `specRevision`; without it no result has one.
132
+ */
133
+ add(layer, rows, specs = [], backend = layer.backend, fingerprints = {}) {
129
134
  if (this.#seen.has(layer.id))
130
135
  return;
131
136
  this.#seen.add(layer.id);
@@ -182,7 +187,7 @@ export class TestHistory {
182
187
  ident.title = row.title;
183
188
  ident.titledAt = layer.recordedAtMs;
184
189
  }
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) });
190
+ this.#remember(slot, { o: origin, s: row.status, d: row.durationMs, k: row.kind, t: row.detail, e: row.evidence, m: productLatency(row.measurements), f: row.specPath ? fingerprints[row.specPath] : undefined });
186
191
  }
187
192
  }
188
193
  /** Sorted insert, newest first; past the bound, the oldest is dropped. */