@crvouga/atlas 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +7 -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 +10 -0
  19. package/llms-full.txt +76 -0
  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/explorer/Reliability.tsx +337 -0
  30. package/packages/visualizer/src/explorer/TestDetail.tsx +3 -0
  31. package/packages/visualizer/src/explorer/catalog.tsx +1 -1
  32. package/packages/visualizer/src/explorer/search.ts +36 -0
@@ -0,0 +1,337 @@
1
+ /**
2
+ * @module Reliability of a test
3
+ *
4
+ * The open test's reliability, qualified: how many independent executions it rests on (not snapshots, not tries), for
5
+ * which environment, branch, spec revision and time window, how often it passed first time and in the end, and why the
6
+ * figure may not be what it looks like (a small sample, pooled environments, failed cases published again, bounded
7
+ * history). The scope is in the address and the executions behind the figure (its denominator) open in place, so a link
8
+ * shows the same figure and the same executions. It is worked out from the history the page already holds
9
+ * (`reliability` in the reports library), so it needs no further request.
10
+ */
11
+ import { Accordion, Alert, Anchor, Badge, Box, Button, Checkbox, Code, CopyButton, Flex, List, MultiSelect, SimpleGrid, Stack, Table, Text, TextInput, Title, VisuallyHidden } from '@mantine/core';
12
+ import { IconLink } from '@tabler/icons-react';
13
+ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
14
+ import { reliability, type ReliabilityExecution } from '@crvouga/atlas/reports/reliability';
15
+ import type { TestResultEntry } from '@crvouga/atlas/reports/test-catalog';
16
+ import { Link } from '@tanstack/react-router';
17
+ import { useMemo } from 'react';
18
+ import { exactTime, plural, shortSha, STATUS_WORD } from '../evidence/format';
19
+ import { StatusMark } from '../evidence/marks';
20
+ import { joined, reliabilityScopeOf, values } from './search';
21
+ import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
22
+
23
+ /** A recorded status as a mark and a word; one this viewer does not know is shown by its own name and as unknown, never as a pass. */
24
+ const known = (status: string) => (Object.hasOwn(STATUS_WORD, status) && status !== 'none' ? (status as keyof typeof STATUS_WORD) : 'none');
25
+ const word = (status: string) => (known(status) === 'none' ? status : STATUS_WORD[known(status)]);
26
+ const percent = (rate: number | null) => (rate === null ? 'no rate' : `${Math.round(rate * 1000) / 10}%`);
27
+ const outOf = (passed: number, executions: number) => `${passed} of ${plural(executions, 'execution', 'executions')} passed`;
28
+ const REASONS: Record<string, string> = {
29
+ 'not-run': 'Did not run',
30
+ unfinished: 'Not finished',
31
+ 'unknown-status': 'Status not understood',
32
+ 'historical-revision': 'Earlier spec revision',
33
+ 'revision-not-recorded': 'Spec revision not recorded'
34
+ };
35
+
36
+ function Choice({ label, param, options, chosen }: { label: string; param: 'relEnv' | 'relProducer' | 'relBranch' | 'relParam'; options: readonly { value: string; label: string }[]; chosen: string[] }) {
37
+ const go = useExplorerNavigate();
38
+ if (options.length < 2 && !chosen.length) return null;
39
+ return (
40
+ <MultiSelect
41
+ label={label}
42
+ data={options as { value: string; label: string }[]}
43
+ value={chosen}
44
+ searchable
45
+ clearable
46
+ placeholder={chosen.length ? undefined : `Any ${label.toLowerCase()}`}
47
+ nothingFoundMessage="Nothing found"
48
+ onChange={(next) => go({ [param]: joined(next) } as never, { replace: true })}
49
+ />
50
+ );
51
+ }
52
+
53
+ function Denominator({ executions, layer }: { executions: readonly ReliabilityExecution[]; layer: string | undefined }) {
54
+ return (
55
+ <Table.ScrollContainer minWidth={760}>
56
+ <Table>
57
+ <Table.Caption>Independent executions of this test in the scope, newest first. Snapshots of one execution are one row.</Table.Caption>
58
+ <Table.Thead>
59
+ <Table.Tr>
60
+ <Table.Th scope="col">Recorded</Table.Th>
61
+ <Table.Th scope="col">Environment</Table.Th>
62
+ <Table.Th scope="col">Status</Table.Th>
63
+ <Table.Th scope="col">Tries</Table.Th>
64
+ <Table.Th scope="col">Snapshots</Table.Th>
65
+ <Table.Th scope="col">Branch</Table.Th>
66
+ <Table.Th scope="col">Commit</Table.Th>
67
+ <Table.Th scope="col">Counted</Table.Th>
68
+ <Table.Th scope="col">
69
+ <VisuallyHidden>Evidence</VisuallyHidden>
70
+ </Table.Th>
71
+ </Table.Tr>
72
+ </Table.Thead>
73
+ <Table.Tbody>
74
+ {executions.map((execution) => (
75
+ <Table.Tr key={`${execution.layer}|${execution.kind}|${execution.producer}|${execution.environment}|${JSON.stringify(execution.parameters)}`} data-status={execution.status} data-counted={execution.counted}>
76
+ <Table.Td title={exactTime(execution.at)}>{exactTime(execution.at)}</Table.Td>
77
+ <Table.Td>
78
+ {environmentLabel(execution.environment)}
79
+ {Object.entries(execution.parameters).map(([name, value]) => (
80
+ <Text key={name} span inherit c="dimmed">
81
+ {' '}
82
+ {name}: {value}
83
+ </Text>
84
+ ))}
85
+ </Table.Td>
86
+ <Table.Td>
87
+ <Flex component="span" display="inline-flex" gap="xs" align="center" wrap="wrap">
88
+ <StatusMark status={known(execution.status)} size={12} />
89
+ {word(execution.status)}
90
+ </Flex>
91
+ </Table.Td>
92
+ <Table.Td>{execution.attempts ? execution.attempts.map(word).join(' → ') : 'Not recorded'}</Table.Td>
93
+ <Table.Td>
94
+ {execution.snapshots}
95
+ {execution.republished && (
96
+ <Text span inherit c="dimmed">
97
+ {' '}
98
+ (published again after a failure)
99
+ </Text>
100
+ )}
101
+ </Table.Td>
102
+ <Table.Td c="dimmed">{execution.branch ?? '—'}</Table.Td>
103
+ <Table.Td>
104
+ <Code>{shortSha(execution.sha) ?? '—'}</Code>
105
+ </Table.Td>
106
+ <Table.Td>{execution.counted ? 'Yes' : `No: ${REASONS[execution.excluded ?? ''] ?? execution.excluded}`}</Table.Td>
107
+ <Table.Td>
108
+ <Anchor
109
+ aria-label={`Open the ${word(execution.status)} result recorded ${exactTime(execution.at)} in ${environmentLabel(execution.environment)}`}
110
+ aria-current={execution.layer === layer ? 'true' : undefined}
111
+ renderRoot={(props) => <Link {...props} to="/" search={patched({ layer: execution.layer })} resetScroll={false} activeOptions={{ exact: true }} />}
112
+ >
113
+ Open this result
114
+ </Anchor>
115
+ </Table.Td>
116
+ </Table.Tr>
117
+ ))}
118
+ </Table.Tbody>
119
+ </Table>
120
+ </Table.ScrollContainer>
121
+ );
122
+ }
123
+
124
+ /**
125
+ * The reliability section of an open test: scope choices, the qualified figures, why they may mislead, and the
126
+ * executions they count. `entries` is every recorded result of the test the page holds, newest first; while it is
127
+ * `undefined` the history is still being read.
128
+ */
129
+ export function Reliability({ testKey, entries }: { testKey: string; entries: readonly TestResultEntry[] | undefined }) {
130
+ const search = useExplorerSearch();
131
+ const go = useExplorerNavigate();
132
+ const scope = useMemo(() => reliabilityScopeOf(search), [search]);
133
+ const document = useMemo(() => (entries ? reliability(testKey, entries, scope) : null), [testKey, entries, scope]);
134
+ const parameters = useMemo(() => {
135
+ const counts = new Map<string, number>();
136
+ for (const entry of entries ?? []) for (const [name, value] of Object.entries(entry.parameters)) counts.set(`${name}=${value}`, (counts.get(`${name}=${value}`) ?? 0) + 1);
137
+ return [...counts].map(([value, count]) => ({ value, label: `${value} (${count})` }));
138
+ }, [entries]);
139
+ if (!document)
140
+ return (
141
+ <Box aria-label="Reliability" component="section" mb="lg">
142
+ <Title order={3} mb="sm" c="dimmed">
143
+ Reliability
144
+ </Title>
145
+ <Text role="status" component="div" c="dimmed">
146
+ Reading the history…
147
+ </Text>
148
+ </Box>
149
+ );
150
+ const { eventual, firstAttempt } = document;
151
+ const label = (list: readonly { value: string; count: number }[], format: (value: string) => string = (value) => value) => list.map(({ value, count }) => ({ value, label: `${format(value)} (${count})` }));
152
+ const warnings = document.notes.filter((note) => note.severity === 'warning');
153
+ const infos = document.notes.filter((note) => note.severity === 'info');
154
+ const scoped = Object.keys(scope).length > 0;
155
+ return (
156
+ <Box aria-label="Reliability" component="section" mb="lg">
157
+ <Title order={3} mb="sm" c="dimmed">
158
+ Reliability
159
+ </Title>
160
+ <Text component="div" c="dimmed" mb="sm">
161
+ Counted over independent executions: the snapshots one run recorded for one environment are one execution, and a retry is a try of it, not another
162
+ execution. This describes the checks Atlas holds, not how often the product fails in production.
163
+ </Text>
164
+ <Stack gap="sm" mb="md">
165
+ <SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
166
+ <Choice label="Environment" param="relEnv" options={label(document.available.environments, environmentLabel)} chosen={values(search.relEnv)} />
167
+ <Choice label="Producer" param="relProducer" options={label(document.available.producers)} chosen={values(search.relProducer)} />
168
+ <Choice label="Branch" param="relBranch" options={label(document.available.branches)} chosen={values(search.relBranch)} />
169
+ <Choice label="Parameters" param="relParam" options={parameters} chosen={values(search.relParam)} />
170
+ <TextInput type="date" label="Executions from" value={search.relSince ?? ''} onChange={(event) => go({ relSince: event.target.value || undefined }, { replace: true })} />
171
+ <TextInput type="date" label="Executions until" value={search.relUntil ?? ''} onChange={(event) => go({ relUntil: event.target.value || undefined }, { replace: true })} />
172
+ </SimpleGrid>
173
+ {document.available.specRevisions.length > 0 && (
174
+ <Checkbox
175
+ label="Include earlier revisions of the spec"
176
+ description={document.currentRevision ? 'Off: only executions of the newest recorded spec revision count; earlier ones are shown as context.' : 'On: every revision is pooled, though the behavior may have changed between them.'}
177
+ checked={search.relRevision === 'any'}
178
+ onChange={(event) => go({ relRevision: event.currentTarget.checked ? 'any' : undefined }, { replace: true })}
179
+ />
180
+ )}
181
+ </Stack>
182
+
183
+ <Flex gap="xs" align="center" wrap="wrap" mb="sm" role="status" aria-label="Reliability sample">
184
+ <Badge color={document.sample === 'sufficient' ? 'gray' : 'orange'} variant="light">
185
+ {document.sample === 'none' ? 'No independent executions' : document.sample === 'insufficient' ? 'Insufficient evidence' : 'Sample large enough to read as a rate'}
186
+ </Badge>
187
+ <Text span inherit fw={600}>
188
+ {plural(eventual.executions, 'independent execution', 'independent executions')}
189
+ </Text>
190
+ <Text span inherit c="dimmed">
191
+ {scoped ? 'in this scope' : 'in all recorded history'}
192
+ {document.sample === 'insufficient' ? `; at least ${document.minimumExecutions} are needed before a rate means much` : ''}
193
+ </Text>
194
+ </Flex>
195
+
196
+ <SimpleGrid component="dl" cols={{ base: 1, sm: 2, md: 3 }}>
197
+ <Stack gap="sm">
198
+ <Box component="dt">Eventual success</Box>
199
+ <Box component="dd" m={0}>
200
+ {eventual.executions ? `${outOf(eventual.passed, eventual.executions)} (${percent(eventual.rate)})` : 'No rate: nothing completed in this scope'}
201
+ </Box>
202
+ </Stack>
203
+ <Stack gap="sm">
204
+ <Box component="dt">First-try success</Box>
205
+ <Box component="dd" m={0}>
206
+ {firstAttempt.executions ? `${outOf(firstAttempt.passed, firstAttempt.executions)} first time (${percent(firstAttempt.rate)})` : 'Unknown: no tries recorded'}
207
+ {document.unrecordedAttempts > 0 && firstAttempt.executions > 0 && (
208
+ <Text span inherit c="dimmed">
209
+ {' '}
210
+ · {document.unrecordedAttempts} recorded no tries
211
+ </Text>
212
+ )}
213
+ </Box>
214
+ </Stack>
215
+ <Stack gap="sm">
216
+ <Box component="dt">Tries</Box>
217
+ <Box component="dd" m={0}>
218
+ {document.attempts.total
219
+ ? `${document.attempts.total} recorded; ${plural(document.attempts.retried, 'execution', 'executions')} retried, ${document.attempts.recovered} failed first and passed after`
220
+ : 'None recorded'}
221
+ </Box>
222
+ </Stack>
223
+ <Stack gap="sm">
224
+ <Box component="dt">Interval (95%)</Box>
225
+ <Box component="dd" m={0} title={document.interval?.assumptions}>
226
+ {document.interval ? `${percent(document.interval.lower)} to ${percent(document.interval.upper)} (Wilson)` : '—'}
227
+ </Box>
228
+ </Stack>
229
+ <Stack gap="sm">
230
+ <Box component="dt">Recorded snapshots</Box>
231
+ <Box component="dd" m={0}>
232
+ {document.snapshots} behind the counted {plural(eventual.executions, 'execution', 'executions')}
233
+ </Box>
234
+ </Stack>
235
+ <Stack gap="sm">
236
+ <Box component="dt">Not counted</Box>
237
+ <Box component="dd" m={0}>
238
+ {document.outsideScope} outside the scope
239
+ {Object.entries(document.excluded)
240
+ .filter(([, count]) => count > 0)
241
+ .map(([reason, count]) => `, ${count} ${(REASONS[reason] ?? reason).toLowerCase()}`)
242
+ .join('')}
243
+ </Box>
244
+ </Stack>
245
+ </SimpleGrid>
246
+
247
+ {warnings.length > 0 && (
248
+ <Alert color="orange" title="Read this figure with care" mt="sm">
249
+ <List>
250
+ {warnings.map((note) => (
251
+ <List.Item key={note.code}>{note.message}</List.Item>
252
+ ))}
253
+ </List>
254
+ </Alert>
255
+ )}
256
+ {infos.length > 0 && (
257
+ <List mt="sm" c="dimmed">
258
+ {infos.map((note) => (
259
+ <List.Item key={note.code}>{note.message}</List.Item>
260
+ ))}
261
+ </List>
262
+ )}
263
+ {document.interval && (
264
+ <Text component="div" c="dimmed" mt="sm">
265
+ Interval assumes: {document.interval.assumptions}
266
+ </Text>
267
+ )}
268
+ {document.historical.executions > 0 && (
269
+ <Text component="div" c="dimmed" mt="sm">
270
+ Earlier spec revisions, shown as context and not counted: {outOf(document.historical.passed, document.historical.executions)}.
271
+ </Text>
272
+ )}
273
+
274
+ {document.cohorts.length > 1 && (
275
+ <Table.ScrollContainer minWidth={500} mt="sm">
276
+ <Table>
277
+ <Table.Caption>Each environment, producer and parameter set on its own</Table.Caption>
278
+ <Table.Thead>
279
+ <Table.Tr>
280
+ <Table.Th scope="col">Environment</Table.Th>
281
+ <Table.Th scope="col">Producer</Table.Th>
282
+ <Table.Th scope="col">Executions</Table.Th>
283
+ <Table.Th scope="col">Passed</Table.Th>
284
+ </Table.Tr>
285
+ </Table.Thead>
286
+ <Table.Tbody>
287
+ {document.cohorts.map((cohort) => (
288
+ <Table.Tr key={JSON.stringify([cohort.environment, cohort.producer, cohort.parameters])}>
289
+ <Table.Td>
290
+ {environmentLabel(cohort.environment)}
291
+ {Object.entries(cohort.parameters).map(([name, value]) => (
292
+ <Text key={name} span inherit c="dimmed">
293
+ {' '}
294
+ {name}: {value}
295
+ </Text>
296
+ ))}
297
+ </Table.Td>
298
+ <Table.Td>{cohort.producer}</Table.Td>
299
+ <Table.Td>{cohort.executions}</Table.Td>
300
+ <Table.Td>
301
+ {cohort.passed} ({percent(cohort.rate)})
302
+ </Table.Td>
303
+ </Table.Tr>
304
+ ))}
305
+ </Table.Tbody>
306
+ </Table>
307
+ </Table.ScrollContainer>
308
+ )}
309
+
310
+ <Accordion variant="separated" mt="sm" value={search.relOpen ? 'denominator' : null} onChange={(value) => go({ relOpen: value ? true : undefined }, { replace: true })}>
311
+ <Accordion.Item value="denominator">
312
+ <Accordion.Control>
313
+ Executions behind this figure: {document.executions.filter((execution) => execution.counted).length} counted
314
+ {document.executions.some((execution) => !execution.counted) ? `, ${document.executions.filter((execution) => !execution.counted).length} not counted` : ''}
315
+ </Accordion.Control>
316
+ <Accordion.Panel>
317
+ <Stack gap="sm">
318
+ <Flex gap="xs" align="center" wrap="wrap">
319
+ <CopyButton value={typeof window === 'undefined' ? '' : window.location.href}>
320
+ {({ copied, copy }) => (
321
+ <Button type="button" variant="default" onClick={copy} leftSection={<IconLink size={16} aria-hidden="true" />}>
322
+ {copied ? 'Link copied' : 'Copy link to this scope'}
323
+ </Button>
324
+ )}
325
+ </CopyButton>
326
+ <Text span inherit c="dimmed">
327
+ The link opens this scope and these executions.
328
+ </Text>
329
+ </Flex>
330
+ {document.executions.length > 0 ? <Denominator executions={document.executions} layer={search.layer} /> : <Text c="dimmed">No execution of this test matches the scope.</Text>}
331
+ </Stack>
332
+ </Accordion.Panel>
333
+ </Accordion.Item>
334
+ </Accordion>
335
+ </Box>
336
+ );
337
+ }
@@ -21,6 +21,7 @@ import { outcomeHeadline, selectedResult } from '../evidence/outcome';
21
21
  import { useTestHistory } from './catalog';
22
22
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
23
23
  import { Badges, runName } from './parts';
24
+ import { Reliability } from './Reliability';
24
25
  import { ProductLatency } from './ProductLatency';
25
26
  import { usePinnedResult, type PinnedResult } from './pinned';
26
27
  const RecordingPage = lazy(() => import('../evidence/recording-page').then((module) => ({ default: module.RecordingPage })));
@@ -405,6 +406,8 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
405
406
  {entries.length > 1 && <Trend entries={entries} />}
406
407
  </Box>
407
408
 
409
+ {!history.isError && <Reliability testKey={test.key} entries={history.data === null ? [] : history.data?.history} />}
410
+
408
411
  <ProductLatency entries={entries} />
409
412
 
410
413
  {(test.tags.length > 0 || Object.keys(test.labels).length > 0) && (
@@ -180,7 +180,7 @@ function usePageHistory(layers: readonly LayerEvidence[] | null): PageHistory |
180
180
  const key = `${entry.layer.backend}|${entry.layer.id}`;
181
181
  if (current.rows.has(key)) continue;
182
182
  current.rows.set(key, entry.rows);
183
- current.history.add(entry.layer, entry.rows, [], entry.layer.backend);
183
+ current.history.add(entry.layer, entry.rows, [], entry.layer.backend, entry.fingerprints);
184
184
  }
185
185
  return { history: current.history, stamp: `page:${current.gen}:${current.history.rev}` };
186
186
  }, [layers]);
@@ -10,6 +10,7 @@
10
10
  * `type`, `label`, `branch`, `outcome`, `since` and `until`, so a link to one tab's filters opens the same query on another.
11
11
  */
12
12
  import { joinList, splitList, type ArtifactFilter } from '@crvouga/atlas/reports/artifact-query';
13
+ import type { ReliabilityScope } from '@crvouga/atlas/reports/reliability';
13
14
  import { TEST_SORTS, type TestQuery } from '@crvouga/atlas/reports/test-catalog';
14
15
  import { z } from 'zod/v4';
15
16
 
@@ -71,6 +72,18 @@ export const ExplorerSearchFields = {
71
72
  source: text,
72
73
  /** With `openRun` and `layer`: the one file of that layer (an `artifactRef`: `id:…`, `key:…` or `name:…`) the run's Artifacts select. */
73
74
  artifact: text,
75
+ /** The scope of the open test's reliability figure: environments, producers, branches, `key=value` parameters. */
76
+ relEnv: text,
77
+ relProducer: text,
78
+ relBranch: text,
79
+ relParam: text,
80
+ /** The reliability window, as `since` and `until` (`YYYY-MM-DD`, local time). */
81
+ relSince: text,
82
+ relUntil: text,
83
+ /** `any`: count every spec revision. Otherwise only the current one is counted. */
84
+ relRevision: z.enum(['any']).optional().catch(undefined),
85
+ /** The reliability's independent executions (its denominator) are open. */
86
+ relOpen: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
74
87
  /** Only the runs, and the results of an opened run, that have a playable video recording. */
75
88
  video: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined)
76
89
  };
@@ -102,6 +115,14 @@ export type ExplorerSearch = {
102
115
  readonly layer?: string;
103
116
  readonly source?: string;
104
117
  readonly artifact?: string;
118
+ readonly relEnv?: string;
119
+ readonly relProducer?: string;
120
+ readonly relBranch?: string;
121
+ readonly relParam?: string;
122
+ readonly relSince?: string;
123
+ readonly relUntil?: string;
124
+ readonly relRevision?: 'any';
125
+ readonly relOpen?: true;
105
126
  readonly video?: true;
106
127
  };
107
128
 
@@ -153,6 +174,21 @@ export function narrowed(search: ExplorerSearch): boolean {
153
174
  return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun || search.video);
154
175
  }
155
176
 
177
+ /** The reliability scope an address asks for, in the shape `reliability` reads: days are the viewer's local days. */
178
+ export function reliabilityScopeOf(search: ExplorerSearch): ReliabilityScope {
179
+ const list = (value: string | undefined) => (values(value).length ? values(value) : undefined);
180
+ const parts = {
181
+ environment: list(search.relEnv),
182
+ producer: list(search.relProducer),
183
+ branch: list(search.relBranch),
184
+ parameter: list(search.relParam),
185
+ ...(search.relRevision ? { revision: search.relRevision } : {}),
186
+ since: dayMs(search.relSince, false),
187
+ until: dayMs(search.relUntil, true)
188
+ };
189
+ return Object.fromEntries(Object.entries(parts).filter(([, value]) => value !== undefined)) as ReliabilityScope;
190
+ }
191
+
156
192
  /** The filters of the Runs and Artifacts tabs, in the address. */
157
193
  export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'domain', 'spec', 'since', 'until', 'video'] as const;
158
194