@crvouga/atlas 1.12.0 → 1.13.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 (57) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/dist/critics/src/contract.d.ts +113 -0
  3. package/dist/critics/src/contract.js +48 -0
  4. package/dist/critics/src/credentials.d.ts +72 -0
  5. package/dist/critics/src/credentials.js +136 -0
  6. package/dist/critics/src/critique.d.ts +128 -0
  7. package/dist/critics/src/critique.js +163 -0
  8. package/dist/critics/src/index.d.ts +21 -0
  9. package/dist/critics/src/index.js +21 -0
  10. package/dist/critics/src/judging.d.ts +153 -0
  11. package/dist/critics/src/judging.js +145 -0
  12. package/dist/critics/src/publish.d.ts +23 -0
  13. package/dist/critics/src/publish.js +21 -0
  14. package/dist/critics/src/runner.d.ts +104 -0
  15. package/dist/critics/src/runner.js +202 -0
  16. package/dist/critics/src/scope.d.ts +8 -0
  17. package/dist/critics/src/scope.js +26 -0
  18. package/dist/reports/src/compose.d.ts +2 -2
  19. package/dist/reports/src/compose.js +6 -5
  20. package/dist/reports/src/evidence-view.d.ts +2 -1
  21. package/dist/reports/src/evidence-view.js +5 -2
  22. package/dist/reports/src/layer-merge.d.ts +11 -3
  23. package/dist/reports/src/layer-merge.js +14 -3
  24. package/dist/reports/src/report-status.d.ts +1 -1
  25. package/dist/reports/src/report-status.js +6 -4
  26. package/dist/schema/src/api.d.ts +1 -0
  27. package/dist/schema/src/api.js +1 -1
  28. package/dist/schema/src/critique.d.ts +144 -0
  29. package/dist/schema/src/critique.js +96 -0
  30. package/dist/schema/src/index.d.ts +2 -1
  31. package/dist/schema/src/index.js +2 -1
  32. package/dist/schema/src/server-config.d.ts +7 -1
  33. package/dist/schema/src/server-config.js +6 -3
  34. package/dist/server/src/auth.d.ts +1 -1
  35. package/dist/server/src/auth.js +2 -2
  36. package/dist/server/src/config.js +2 -2
  37. package/dist/server/src/openapi.d.ts +3 -3
  38. package/dist/server/src/openapi.js +7 -4
  39. package/dist/server/src/store-api.js +39 -1
  40. package/docs/agents.md +1 -0
  41. package/docs/critics.md +190 -0
  42. package/docs/extending.md +1 -0
  43. package/docs/reference/critics.md +840 -0
  44. package/docs/reference/http-api.md +6 -2
  45. package/docs/reference/reports.md +20 -7
  46. package/docs/reference/schema.md +119 -2
  47. package/docs/reference/server.md +3 -3
  48. package/docs/report-contract.md +4 -0
  49. package/docs/server.md +6 -4
  50. package/llms-full.txt +211 -6
  51. package/llms.txt +2 -0
  52. package/package.json +7 -2
  53. package/packages/critics/README.md +18 -0
  54. package/packages/schema/schemas/critique.schema.json +254 -0
  55. package/packages/schema/schemas/server-config.schema.json +2 -1
  56. package/packages/schema/schemas/server-info.schema.json +1 -0
  57. package/packages/server/openapi.json +7 -6
@@ -0,0 +1,202 @@
1
+ /**
2
+ * @module The critic runner
3
+ *
4
+ * Runs one critic over entries of the UI catalog and returns the critiques to publish. The runner, not the critic, owns
5
+ * everything around the judgement: skipping what was already judged (same critic, version, model, parameters and image
6
+ * hash), fetching images only when needed, a bounded number of concurrent calls, a per-call timeout, retries with
7
+ * jittered backoff that honour a provider's `Retry-After`, budgets (images, tokens, money) that stop the run cleanly,
8
+ * and credential redaction of every message it records. Failures are data: a crash, a timeout, a rate limit that never
9
+ * cleared and an unparseable answer each become a critique with a `failure` and a reason code, which the next run
10
+ * retries, and never a score.
11
+ *
12
+ * Results are returned in the order of the entries, so the same inputs publish the same layer. Nothing here reads a
13
+ * clock, the environment or the network on its own: images, time, sleeping and randomness are passed in.
14
+ *
15
+ * @see docs/critics.md#running-critics
16
+ */
17
+ import { z } from 'zod/v4';
18
+ import { sha256Hex } from '../../reports/src/sigv4.js';
19
+ import { CriticError, InvalidOutputError, RateLimitedError } from './contract.js';
20
+ import { buildCritique, critiqueIdentity, identityOfCritique } from './critique.js';
21
+ import { createRedactor } from './credentials.js';
22
+ /** The shape a critic's answer must have. A score that is not a finite number, or a finding with a bad region, is `invalid-output`. */
23
+ const AnswerSchema = z.object({
24
+ verdict: z.looseObject({ score: z.number().finite().optional(), confidence: z.number().min(0).max(1).optional() }).optional(),
25
+ findings: z.array(z.looseObject({ confidence: z.number().min(0).max(1).optional() })).optional(),
26
+ usage: z.looseObject({ inputTokens: z.number().nonnegative().optional(), outputTokens: z.number().nonnegative().optional(), latencyMs: z.number().nonnegative().optional(), costUsd: z.number().nonnegative().optional() }).optional()
27
+ }).refine(answer => answer.verdict !== undefined || (answer.findings !== undefined && answer.findings.length > 0), 'the answer has neither a verdict nor findings');
28
+ const defaultSleep = (ms, signal) => new Promise(resolve => {
29
+ const timer = setTimeout(done, ms);
30
+ function done() { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); }
31
+ signal?.addEventListener('abort', done, { once: true });
32
+ });
33
+ const sha256Bytes = (bytes) => sha256Hex(bytes);
34
+ /** Races a critic's call against a timeout and the run's cancellation. Rejects with `CriticError('timeout')` on timeout. */
35
+ async function withTimeout(work, timeoutMs, outer) {
36
+ const controller = new AbortController();
37
+ const onOuter = () => controller.abort(outer?.reason);
38
+ if (outer?.aborted)
39
+ controller.abort(outer.reason);
40
+ else
41
+ outer?.addEventListener('abort', onOuter, { once: true });
42
+ let timer;
43
+ try {
44
+ return await Promise.race([
45
+ work(controller.signal),
46
+ new Promise((_, reject) => {
47
+ // Settle first: aborting may make the critic's own call reject at once, and the reason must be the timeout.
48
+ timer = setTimeout(() => { reject(new CriticError('timeout', `The critic did not answer within ${timeoutMs} ms`)); controller.abort(new Error('timeout')); }, timeoutMs);
49
+ })
50
+ ]);
51
+ }
52
+ finally {
53
+ if (timer !== undefined)
54
+ clearTimeout(timer);
55
+ outer?.removeEventListener('abort', onOuter);
56
+ }
57
+ }
58
+ /** Collects a sync or async iterable, keeping the first of each key. */
59
+ async function collect(entries) {
60
+ const seen = new Set();
61
+ const out = [];
62
+ for await (const entry of entries) {
63
+ if (seen.has(entry.key))
64
+ continue;
65
+ seen.add(entry.key);
66
+ out.push(entry);
67
+ }
68
+ return out;
69
+ }
70
+ /**
71
+ * Runs a critic over entries. Entries with a current critique of the same identity are skipped without fetching their
72
+ * image; the rest are judged with bounded concurrency, a timeout and retries. The run never throws for a critic's
73
+ * failure: it records it. It throws only for a wrong call (a non-positive `concurrency`, `maxAttempts` or `timeoutMs`).
74
+ *
75
+ * A run stops cleanly when a budget is reached or the signal aborts: calls in flight finish (or are aborted), what was
76
+ * finished is returned in `outcomes` to publish, and `stoppedBy` says why. With concurrency above 1 a budget can be
77
+ * overshot by the calls already in flight.
78
+ *
79
+ * @example
80
+ * const result = await runCritic({ critic, entries, loadImage: entry => fetchImage(entry), existing, budget: { maxImages: 200 } });
81
+ * await publishChecks(store, critiqueLayer({ critic, outcomes: result.outcomes, recordedAtMs: Date.now() }));
82
+ */
83
+ export async function runCritic(options) {
84
+ const concurrency = options.concurrency ?? 4;
85
+ const maxAttempts = options.maxAttempts ?? 4;
86
+ const timeoutMs = options.timeoutMs ?? 60_000;
87
+ for (const [name, value] of [['concurrency', concurrency], ['maxAttempts', maxAttempts], ['timeoutMs', timeoutMs]]) {
88
+ if (!Number.isFinite(value) || value < 1)
89
+ throw new Error(`Invalid ${name}: ${value}`);
90
+ }
91
+ const sleep = options.sleep ?? defaultSleep;
92
+ const random = options.random ?? Math.random;
93
+ const redact = createRedactor(options.secrets ?? []);
94
+ const { critic } = options;
95
+ const description = critic.describe();
96
+ const entries = await collect(options.entries);
97
+ const known = new Set();
98
+ if (!options.force)
99
+ for (const critique of options.existing ?? [])
100
+ if (critique.verdict !== undefined && !critique.failure)
101
+ known.add(identityOfCritique(critique));
102
+ const isKnown = (entry) => known.has(critiqueIdentity({ critic, model: description.model, parameters: description.parameters, imageSha256: entry.imageSha256 }));
103
+ const slots = entries.map(entry => (isKnown(entry) ? 'skipped' : undefined));
104
+ const identity = { name: critic.name, version: critic.version };
105
+ const toJudge = slots.filter(slot => slot === undefined).length;
106
+ if (options.dryRun) {
107
+ return { critic: identity, sendsImagesTo: description.sendsImagesTo, outcomes: [], skipped: entries.length - toJudge, judged: 0, failed: 0, waiting: toJudge, spent: { images: 0, tokens: 0, costUsd: 0 }, dryRun: true };
108
+ }
109
+ const budget = options.budget ?? {};
110
+ const spent = { images: 0, tokens: 0, costUsd: 0 };
111
+ let judged = 0, failed = 0, skipped = entries.length - toJudge, stoppedBy;
112
+ const exhausted = () => (budget.maxImages !== undefined && spent.images >= budget.maxImages) ||
113
+ (budget.maxTokens !== undefined && spent.tokens >= budget.maxTokens) ||
114
+ (budget.maxCostUsd !== undefined && spent.costUsd >= budget.maxCostUsd);
115
+ const progress = (entry, result) => options.onProgress?.({ entryKey: entry.key, result, judged, skipped, failed, waiting: slots.filter(slot => slot === undefined).length - inFlight });
116
+ let inFlight = 0;
117
+ const fail = (entry, reason, message) => ({
118
+ entry,
119
+ critique: buildCritique(critic, description, entry, { failure: { reason, message: redact(message).slice(0, 500) } })
120
+ });
121
+ /** Judges one entry; `null` when the run was cancelled meanwhile, because a cancelled call is not a failure of the critic. */
122
+ async function judge(entry) {
123
+ let image;
124
+ try {
125
+ image = await options.loadImage(entry);
126
+ }
127
+ catch (error) {
128
+ return fail(entry, 'image-unavailable', error instanceof Error ? error.message : String(error));
129
+ }
130
+ if ((await sha256Bytes(image)) !== entry.imageSha256)
131
+ return fail(entry, 'image-mismatch', 'The image bytes are not the ones the catalog hashed; nothing was judged');
132
+ for (let attempt = 1;; attempt++) {
133
+ if (options.signal?.aborted)
134
+ return null;
135
+ try {
136
+ const answer = await withTimeout(signal => critic.evaluate({ image, imageSha256: entry.imageSha256, entry: { key: entry.key, title: entry.title, state: entry.state, ...(entry.route === undefined ? {} : { route: entry.route }), ...(entry.profile === undefined ? {} : { profile: entry.profile }) }, signal }), timeoutMs, options.signal);
137
+ const checked = AnswerSchema.safeParse(answer);
138
+ if (!checked.success)
139
+ throw new InvalidOutputError(`The answer is not a critique: ${checked.error.issues[0]?.message ?? 'unknown'}`);
140
+ const usage = answer.usage;
141
+ spent.tokens += (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
142
+ spent.costUsd += usage?.costUsd ?? 0;
143
+ return { entry, critique: buildCritique(critic, description, entry, answer) };
144
+ }
145
+ catch (error) {
146
+ if (options.signal?.aborted)
147
+ return null;
148
+ if (error instanceof RateLimitedError && attempt < maxAttempts && !options.signal?.aborted) {
149
+ const base = options.backoff?.baseMs ?? 500, cap = options.backoff?.maxMs ?? 30_000;
150
+ const jittered = Math.floor(random() * Math.min(cap, base * 2 ** (attempt - 1)));
151
+ await sleep(Math.max(error.retryAfterMs ?? 0, jittered), options.signal);
152
+ continue;
153
+ }
154
+ if (error instanceof CriticError)
155
+ return fail(entry, error.reason, error.message);
156
+ return fail(entry, 'error', error instanceof Error ? error.message : String(error));
157
+ }
158
+ }
159
+ }
160
+ let next = 0;
161
+ async function worker() {
162
+ for (;;) {
163
+ if (options.signal?.aborted) {
164
+ stoppedBy ??= 'aborted';
165
+ return;
166
+ }
167
+ if (exhausted()) {
168
+ stoppedBy ??= 'budget';
169
+ return;
170
+ }
171
+ let index = next;
172
+ while (index < entries.length && slots[index] !== undefined)
173
+ index++;
174
+ if (index >= entries.length)
175
+ return;
176
+ next = index + 1;
177
+ const entry = entries[index];
178
+ inFlight += 1;
179
+ spent.images += 1;
180
+ const outcome = await judge(entry);
181
+ inFlight -= 1;
182
+ if (!outcome) {
183
+ stoppedBy ??= 'aborted';
184
+ return;
185
+ }
186
+ slots[index] = outcome;
187
+ if (outcome.critique.failure)
188
+ failed += 1;
189
+ else
190
+ judged += 1;
191
+ progress(entry, outcome.critique.failure ? 'failed' : 'judged');
192
+ }
193
+ }
194
+ for (let index = 0; index < entries.length; index++)
195
+ if (slots[index] === 'skipped')
196
+ progress(entries[index], 'skipped');
197
+ await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(toJudge, 1)) }, worker));
198
+ // A run that handled every entry did not stop early, whatever the budget now says.
199
+ const waiting = slots.filter(slot => slot === undefined).length;
200
+ const outcomes = slots.filter((slot) => slot !== undefined && slot !== 'skipped');
201
+ return { critic: identity, sendsImagesTo: description.sendsImagesTo, outcomes, skipped, judged, failed, waiting, ...(waiting > 0 && stoppedBy ? { stoppedBy } : {}), spent, dryRun: false };
202
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The rule a layer breaks for a critic-scoped credential, or `null` when it is within scope. The message says which rule
3
+ * and what was found, so the critic can fix it.
4
+ */
5
+ export declare function criticScopeViolation(layer: {
6
+ readonly producer?: unknown;
7
+ readonly parts?: unknown;
8
+ }): string | null;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @module The scope of a critic's credential
3
+ *
4
+ * A critic only needs to add opinions, so its credential may read and publish layers whose producer is `critic.<name>`
5
+ * and whose parts are all of kind `critique`, and nothing else. `criticScopeViolation` names the rule a layer breaks;
6
+ * the server applies it to every layer a critic-role token uploads, so a stolen critic token cannot forge test results.
7
+ *
8
+ * @see docs/critics.md#authentication-and-authorization
9
+ */
10
+ import { CRITIC_PRODUCER_PREFIX, CRITIQUE_PART_KIND } from '../../schema/src/critique.js';
11
+ /**
12
+ * The rule a layer breaks for a critic-scoped credential, or `null` when it is within scope. The message says which rule
13
+ * and what was found, so the critic can fix it.
14
+ */
15
+ export function criticScopeViolation(layer) {
16
+ if (typeof layer.producer !== 'string' || !layer.producer.startsWith(CRITIC_PRODUCER_PREFIX) || layer.producer.length === CRITIC_PRODUCER_PREFIX.length) {
17
+ return `A critic token may publish only layers whose producer is "${CRITIC_PRODUCER_PREFIX}<name>"; this layer's producer is ${JSON.stringify(layer.producer ?? null)}`;
18
+ }
19
+ const parts = Array.isArray(layer.parts) ? layer.parts : [];
20
+ if (!parts.length)
21
+ return `A critic token may publish only layers of parts of kind "${CRITIQUE_PART_KIND}"; this layer has no parts`;
22
+ const other = parts.find(part => part?.kind !== CRITIQUE_PART_KIND);
23
+ if (other)
24
+ return `A critic token may publish only parts of kind "${CRITIQUE_PART_KIND}"; this layer has a part of kind ${JSON.stringify(other?.kind ?? null)}`;
25
+ return null;
26
+ }
@@ -50,10 +50,10 @@ export type CombinedReport = {
50
50
  readonly summary: ReportSummary;
51
51
  };
52
52
  /**
53
- * Counts results by status. Diagnostic results (parts of kind `performance`, which only qualify a check) are left out
53
+ * Counts results by status. Opinion results (parts of kind `critique`) are never counted. Diagnostic results (parts of kind `performance`, which only qualify a check) are left out
54
54
  * whenever any other result is present; if only diagnostics exist, they are counted so the report is not empty.
55
55
  */
56
- export declare function countResults(results: readonly EvidenceResult[]): StatusCounts & {
56
+ export declare function countResults(all: readonly EvidenceResult[]): StatusCounts & {
57
57
  total: number;
58
58
  };
59
59
  /** Counts a combined report's winning results in total and per environment, and lists the specs that have no result at all. */
@@ -11,14 +11,15 @@
11
11
  */
12
12
  import { eachLimited } from './completion-order.js';
13
13
  import { catalogEntries, catalogLayers } from './layer-catalog.js';
14
- import { combineEvidence, distinctHeaders, emptyCounts, isDiagnosticKind, layerEvidence } from './layer-merge.js';
14
+ import { combineEvidence, distinctHeaders, emptyCounts, isDiagnosticKind, isOpinionKind, layerEvidence } from './layer-merge.js';
15
15
  import { RecordedSpecs } from './recorded-specs.js';
16
16
  const BACKEND_ID = /^[a-z0-9-]+$/;
17
17
  /**
18
- * Counts results by status. Diagnostic results (parts of kind `performance`, which only qualify a check) are left out
18
+ * Counts results by status. Opinion results (parts of kind `critique`) are never counted. Diagnostic results (parts of kind `performance`, which only qualify a check) are left out
19
19
  * whenever any other result is present; if only diagnostics exist, they are counted so the report is not empty.
20
20
  */
21
- export function countResults(results) {
21
+ export function countResults(all) {
22
+ const results = all.filter(result => !isOpinionKind(result.row.kind));
22
23
  const outcomes = results.some(result => !isDiagnosticKind(result.row.kind)) ? results.filter(result => !isDiagnosticKind(result.row.kind)) : results;
23
24
  const counts = emptyCounts();
24
25
  for (const result of outcomes)
@@ -27,7 +28,7 @@ export function countResults(results) {
27
28
  }
28
29
  /** Counts a combined report's winning results in total and per environment, and lists the specs that have no result at all. */
29
30
  export function summarize(combined) {
30
- const results = [...combined.specs.flatMap(entry => entry.results), ...combined.unassigned];
31
+ const results = [...combined.specs.flatMap(entry => entry.results), ...combined.unassigned].filter(result => !isOpinionKind(result.row.kind));
31
32
  const environments = new Map();
32
33
  for (const result of results) {
33
34
  const key = result.layer.environment ?? 'unspecified';
@@ -40,7 +41,7 @@ export function summarize(combined) {
40
41
  return {
41
42
  checks: countResults(results),
42
43
  byEnvironment: Object.fromEntries([...environments].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([key, value]) => [key, countResults(value)])),
43
- specsWithoutEvidence: combined.specs.filter(entry => entry.results.length === 0).map(entry => entry.spec.path)
44
+ specsWithoutEvidence: combined.specs.filter(entry => entry.results.every(result => isOpinionKind(result.row.kind))).map(entry => entry.spec.path)
44
45
  };
45
46
  }
46
47
  /**
@@ -32,7 +32,8 @@ export type CheckSlot = {
32
32
  /**
33
33
  * Groups the combined results (the winners of the slots) by check, in the order the checks were first seen.
34
34
  * A check's status is the worst of its non-diagnostic results; if it has only diagnostics (timings), those are its
35
- * outcomes. The title and id shown are the newest result's.
35
+ * outcomes. The title and id shown are the newest result's. Opinions (parts of kind `critique`, see `isOpinionKind`) are
36
+ * not checks and are left out.
36
37
  */
37
38
  export declare function checkSlots(results: readonly EvidenceResult[]): CheckSlot[];
38
39
  /** A spec's rolled-up status: the worst of its checks, or `none` when nothing was recorded for it. */
@@ -1,4 +1,4 @@
1
- import { emptyCounts, isDiagnosticKind, slotKey } from './layer-merge.js';
1
+ import { emptyCounts, isDiagnosticKind, isOpinionKind, slotKey } from './layer-merge.js';
2
2
  const WORST = { passed: 0, 'not-run': 1, running: 2, failed: 3 };
3
3
  function worst(statuses) {
4
4
  let status = 'passed';
@@ -13,11 +13,14 @@ function newestFirst(a, b) {
13
13
  /**
14
14
  * Groups the combined results (the winners of the slots) by check, in the order the checks were first seen.
15
15
  * A check's status is the worst of its non-diagnostic results; if it has only diagnostics (timings), those are its
16
- * outcomes. The title and id shown are the newest result's.
16
+ * outcomes. The title and id shown are the newest result's. Opinions (parts of kind `critique`, see `isOpinionKind`) are
17
+ * not checks and are left out.
17
18
  */
18
19
  export function checkSlots(results) {
19
20
  const groups = new Map();
20
21
  for (const result of results) {
22
+ if (isOpinionKind(result.row.kind))
23
+ continue;
21
24
  const group = groups.get(result.slot);
22
25
  if (group)
23
26
  group.push(result);
@@ -36,13 +36,21 @@ export declare function specPath(path: string): string;
36
36
  export declare function parameterKey(parameters: LayerParameters): string;
37
37
  /** Whether a part `kind` only qualifies an outcome (today only `performance`) and so is not counted as a check when anything else is recorded. */
38
38
  export declare function isDiagnosticKind(kind: string): boolean;
39
+ /**
40
+ * Whether a part `kind` records an opinion rather than an outcome (today only `critique`, an automated reviewer's
41
+ * judgement of a captured UI). An opinion is never counted as a check, never gates, and is not folded into a spec's
42
+ * rollup, even when it is the only thing recorded: a report of opinions alone has no test results. The check's `status`
43
+ * of such a part says whether the reviewer ran, not whether anything passed.
44
+ */
45
+ export declare function isOpinionKind(kind: string): boolean;
39
46
  /** A fresh status tally with every status at zero. */
40
47
  export declare function emptyCounts(): Record<EvidenceStatus, number>;
41
48
  /**
42
- * Counts rows by status, leaving out the diagnostic rows (see `isDiagnosticKind`) that only qualify an outcome.
43
- * When every row is diagnostic they are counted, so a layer of timings alone is not empty.
49
+ * Counts rows by status, leaving out the opinion rows (see `isOpinionKind`) always, and the diagnostic rows (see
50
+ * `isDiagnosticKind`) that only qualify an outcome. When every remaining row is diagnostic they are counted, so a
51
+ * layer of timings alone is not empty.
44
52
  */
45
- export declare function outcomeCounts(rows: readonly LayerRow[]): StatusCounts;
53
+ export declare function outcomeCounts(allRows: readonly LayerRow[]): StatusCounts;
46
54
  /**
47
55
  * Flattens a layer into what composition reads: a header (origin, normalised environment and parameters, counts,
48
56
  * evidence summary), one row per check of every part, and the spec fingerprints it recorded.
@@ -38,15 +38,26 @@ export function parameterKey(parameters) {
38
38
  export function isDiagnosticKind(kind) {
39
39
  return kind === 'performance';
40
40
  }
41
+ /**
42
+ * Whether a part `kind` records an opinion rather than an outcome (today only `critique`, an automated reviewer's
43
+ * judgement of a captured UI). An opinion is never counted as a check, never gates, and is not folded into a spec's
44
+ * rollup, even when it is the only thing recorded: a report of opinions alone has no test results. The check's `status`
45
+ * of such a part says whether the reviewer ran, not whether anything passed.
46
+ */
47
+ export function isOpinionKind(kind) {
48
+ return kind === 'critique';
49
+ }
41
50
  /** A fresh status tally with every status at zero. */
42
51
  export function emptyCounts() {
43
52
  return { passed: 0, failed: 0, running: 0, 'not-run': 0 };
44
53
  }
45
54
  /**
46
- * Counts rows by status, leaving out the diagnostic rows (see `isDiagnosticKind`) that only qualify an outcome.
47
- * When every row is diagnostic they are counted, so a layer of timings alone is not empty.
55
+ * Counts rows by status, leaving out the opinion rows (see `isOpinionKind`) always, and the diagnostic rows (see
56
+ * `isDiagnosticKind`) that only qualify an outcome. When every remaining row is diagnostic they are counted, so a
57
+ * layer of timings alone is not empty.
48
58
  */
49
- export function outcomeCounts(rows) {
59
+ export function outcomeCounts(allRows) {
60
+ const rows = allRows.filter(row => !isOpinionKind(row.kind));
50
61
  const outcomes = rows.some(row => !isDiagnosticKind(row.kind)) ? rows.filter(row => !isDiagnosticKind(row.kind)) : rows;
51
62
  const counts = emptyCounts();
52
63
  for (const row of outcomes)
@@ -25,7 +25,7 @@ export type StatusOptions = {
25
25
  * checks (or none completed), failed checks, running checks, specs without results (only with `requireAllSpecs`),
26
26
  * each required environment with no checks (the name is normalised, so `iOS` means `ios`), then every notice of the
27
27
  * report (an unreadable layer or backend). `failing` lists each failed or running winner, in a fixed order that does
28
- * not depend on read order; diagnostic `performance` checks are left out unless nothing else was recorded.
28
+ * not depend on read order; diagnostic `performance` checks are left out unless nothing else was recorded, and `critique` opinions are always left out.
29
29
  * `recordings` reports recording completeness of the same results apart from `ok`: an incomplete or unknown
30
30
  * completeness never changes the gate (use `atlas report publish --require-recordings` to fail on a missing video).
31
31
  *
@@ -11,7 +11,7 @@
11
11
  import { REPORT_STATUS_SCHEMA_VERSION } from '../../schema/src/layer.js';
12
12
  import { countResults } from './compose.js';
13
13
  import { normalizeEnvironment } from './layer-axes.js';
14
- import { isDiagnosticKind } from './layer-merge.js';
14
+ import { isDiagnosticKind, isOpinionKind } from './layer-merge.js';
15
15
  import { recordingCompleteness } from './recording-receipt.js';
16
16
  /** Total string order by code unit. */
17
17
  const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
@@ -23,7 +23,7 @@ const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
23
23
  * checks (or none completed), failed checks, running checks, specs without results (only with `requireAllSpecs`),
24
24
  * each required environment with no checks (the name is normalised, so `iOS` means `ios`), then every notice of the
25
25
  * report (an unreadable layer or backend). `failing` lists each failed or running winner, in a fixed order that does
26
- * not depend on read order; diagnostic `performance` checks are left out unless nothing else was recorded.
26
+ * not depend on read order; diagnostic `performance` checks are left out unless nothing else was recorded, and `critique` opinions are always left out.
27
27
  * `recordings` reports recording completeness of the same results apart from `ok`: an incomplete or unknown
28
28
  * completeness never changes the gate (use `atlas report publish --require-recordings` to fail on a missing video).
29
29
  *
@@ -34,7 +34,8 @@ const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
34
34
  * @see docs/report-contract.md#atlas-report-status
35
35
  */
36
36
  export function reportStatus(report, options = {}) {
37
- const results = [...report.combined.specs.flatMap(entry => entry.results), ...report.combined.unassigned];
37
+ // A critique is an opinion: a low score or a crashed critic never fails the gate (see `isOpinionKind`).
38
+ const results = [...report.combined.specs.flatMap(entry => entry.results), ...report.combined.unassigned].filter(result => !isOpinionKind(result.row.kind));
38
39
  const outcomes = results.some(result => !isDiagnosticKind(result.row.kind)) ? results.filter(result => !isDiagnosticKind(result.row.kind)) : results;
39
40
  const failing = outcomes
40
41
  .filter(result => result.row.status === 'failed' || result.row.status === 'running')
@@ -98,7 +99,8 @@ export function reportStatus(report, options = {}) {
98
99
  }
99
100
  /** `spec › check`, unless the check's id already starts with its spec. */
100
101
  const label = (item) => (item.spec && !item.check.startsWith(item.spec) ? `${item.spec} › ${item.check}` : item.check);
101
- function specOutcome(path, title, kind, results) {
102
+ function specOutcome(path, title, kind, all) {
103
+ const results = all.filter(result => !isOpinionKind(result.row.kind));
102
104
  const environments = new Map();
103
105
  for (const result of results) {
104
106
  const key = result.layer.environment ?? 'unspecified';
@@ -55,6 +55,7 @@ export declare const ServerInfoSchema: z.ZodObject<{
55
55
  version: z.ZodNullable<z.ZodString>;
56
56
  role: z.ZodEnum<{
57
57
  read: "read";
58
+ critic: "critic";
58
59
  publish: "publish";
59
60
  admin: "admin";
60
61
  }>;
@@ -44,7 +44,7 @@ export const ReadySchema = z.looseObject({
44
44
  export const ServerInfoSchema = z.looseObject({
45
45
  name: z.literal('atlas'),
46
46
  version: z.string().nullable().describe('The @crvouga/atlas version the server runs, or null when it does not know'),
47
- role: z.enum(['read', 'publish', 'admin']).describe('What the token of this request may do. A server with no tokens treats everyone as admin'),
47
+ role: z.enum(['read', 'critic', 'publish', 'admin']).describe('What the token of this request may do. A server with no tokens treats everyone as admin'),
48
48
  open: z.boolean().describe('True when the server has no tokens at all'),
49
49
  write: z.string().describe('The id of the backend uploads go to'),
50
50
  ready: z.boolean(),
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @module The `atlas.critique` evidence schema
3
+ *
4
+ * What an automated reviewer (a critic) says about one captured UI. It is not a new layer format: a critique is a
5
+ * check inside a part of kind `critique`, and the opinion rides in the check's `extensions["atlas.critique"]`, so by the
6
+ * rules of docs/extending.md it can never decide a result. The check's `status` says only whether the critic ran
7
+ * (`passed` a verdict was produced, `failed` the critic could not produce one, `running`, `not-run`).
8
+ *
9
+ * Everything except `schema`, `subject` and `critic` is optional, so a single score (a model-based critic) and rich
10
+ * findings (an LLM critic) are one shape. Every object is open, and `kind`, `category`, `severity`, `label` and `reason`
11
+ * are open strings: a value nobody has heard of is kept and shown generically, never rejected. A reader that cannot read
12
+ * the whole extension drops it and keeps the check and its status (`readCritique` in `@crvouga/atlas/critics`).
13
+ *
14
+ * @see docs/critics.md
15
+ * @see docs/extending.md
16
+ */
17
+ import { z } from 'zod/v4';
18
+ /** The key under which a critique is stored in a check's `extensions`. */
19
+ export declare const CRITIQUE_EXTENSION = "atlas.critique";
20
+ /** The version of the `atlas.critique` extension a reader of this release understands. A critique of another version is unknown, not wrong. */
21
+ export declare const CRITIQUE_SCHEMA_VERSION = 1;
22
+ /** The `kind` of the report part that holds critiques. Parts of this kind are opinions: never counted, never part of the gate. */
23
+ export declare const CRITIQUE_PART_KIND = "critique";
24
+ /** The prefix of the `producer` of a layer of critiques: `critic.<critic name>`. */
25
+ export declare const CRITIC_PRODUCER_PREFIX = "critic.";
26
+ /** The region of the image a finding is about, in the stated unit (`px` of the stored image, or `normalized` 0..1). */
27
+ export declare const CritiqueRegionSchema: z.ZodObject<{
28
+ unit: z.ZodString;
29
+ x: z.ZodNumber;
30
+ y: z.ZodNumber;
31
+ w: z.ZodNumber;
32
+ h: z.ZodNumber;
33
+ }, z.core.$loose>;
34
+ /** One thing a critic found: where, what kind, how bad, why, and what to do. Nothing is required. */
35
+ export declare const CritiqueFindingSchema: z.ZodObject<{
36
+ id: z.ZodOptional<z.ZodString>;
37
+ category: z.ZodOptional<z.ZodString>;
38
+ severity: z.ZodOptional<z.ZodString>;
39
+ confidence: z.ZodOptional<z.ZodNumber>;
40
+ region: z.ZodOptional<z.ZodObject<{
41
+ unit: z.ZodString;
42
+ x: z.ZodNumber;
43
+ y: z.ZodNumber;
44
+ w: z.ZodNumber;
45
+ h: z.ZodNumber;
46
+ }, z.core.$loose>>;
47
+ rationale: z.ZodOptional<z.ZodString>;
48
+ suggestion: z.ZodOptional<z.ZodString>;
49
+ }, z.core.$loose>;
50
+ /**
51
+ * How a score reads in context. A raw score is not absolute: a loading overlay is compared with other loading overlays,
52
+ * not with full screens, and a change against the same entry's previous image is more telling than the level.
53
+ */
54
+ export declare const CritiqueRelativeSchema: z.ZodObject<{
55
+ stateKind: z.ZodString;
56
+ percentile: z.ZodOptional<z.ZodNumber>;
57
+ sampleSize: z.ZodOptional<z.ZodNumber>;
58
+ previousScore: z.ZodOptional<z.ZodNumber>;
59
+ delta: z.ZodOptional<z.ZodNumber>;
60
+ }, z.core.$loose>;
61
+ /** The judgement: a score and how to read it, a label, a confidence. A critic that cannot score may give only a label. */
62
+ export declare const CritiqueVerdictSchema: z.ZodObject<{
63
+ score: z.ZodOptional<z.ZodNumber>;
64
+ scale: z.ZodOptional<z.ZodString>;
65
+ label: z.ZodOptional<z.ZodString>;
66
+ confidence: z.ZodOptional<z.ZodNumber>;
67
+ relative: z.ZodOptional<z.ZodObject<{
68
+ stateKind: z.ZodString;
69
+ percentile: z.ZodOptional<z.ZodNumber>;
70
+ sampleSize: z.ZodOptional<z.ZodNumber>;
71
+ previousScore: z.ZodOptional<z.ZodNumber>;
72
+ delta: z.ZodOptional<z.ZodNumber>;
73
+ }, z.core.$loose>>;
74
+ }, z.core.$loose>;
75
+ /** Why a critic produced no verdict: a reason code in an open vocabulary (`error`, `timeout`, `rate-limited`, `invalid-output`) and a message that holds no credential. */
76
+ export declare const CritiqueFailureSchema: z.ZodObject<{
77
+ reason: z.ZodString;
78
+ message: z.ZodOptional<z.ZodString>;
79
+ }, z.core.$loose>;
80
+ /**
81
+ * The `atlas.critique` extension, version 1: the image judged (`subject`), who judged (`critic`, `model`,
82
+ * `parameters`), the opinion (`verdict`, `findings`) or why there is none (`failure`), and what it cost (`usage`).
83
+ * The prompt is recorded only as a hash (`parameters.rubric`). Emitted as `critique.schema.json`.
84
+ */
85
+ export declare const CritiqueSchema: z.ZodObject<{
86
+ schema: z.ZodNumber;
87
+ subject: z.ZodObject<{
88
+ entryKey: z.ZodString;
89
+ imageSha256: z.ZodString;
90
+ state: z.ZodOptional<z.ZodString>;
91
+ }, z.core.$loose>;
92
+ critic: z.ZodObject<{
93
+ name: z.ZodString;
94
+ version: z.ZodString;
95
+ kind: z.ZodOptional<z.ZodString>;
96
+ }, z.core.$loose>;
97
+ model: z.ZodOptional<z.ZodObject<{
98
+ id: z.ZodString;
99
+ provider: z.ZodOptional<z.ZodString>;
100
+ }, z.core.$loose>>;
101
+ parameters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
102
+ verdict: z.ZodOptional<z.ZodObject<{
103
+ score: z.ZodOptional<z.ZodNumber>;
104
+ scale: z.ZodOptional<z.ZodString>;
105
+ label: z.ZodOptional<z.ZodString>;
106
+ confidence: z.ZodOptional<z.ZodNumber>;
107
+ relative: z.ZodOptional<z.ZodObject<{
108
+ stateKind: z.ZodString;
109
+ percentile: z.ZodOptional<z.ZodNumber>;
110
+ sampleSize: z.ZodOptional<z.ZodNumber>;
111
+ previousScore: z.ZodOptional<z.ZodNumber>;
112
+ delta: z.ZodOptional<z.ZodNumber>;
113
+ }, z.core.$loose>>;
114
+ }, z.core.$loose>>;
115
+ findings: z.ZodOptional<z.ZodArray<z.ZodObject<{
116
+ id: z.ZodOptional<z.ZodString>;
117
+ category: z.ZodOptional<z.ZodString>;
118
+ severity: z.ZodOptional<z.ZodString>;
119
+ confidence: z.ZodOptional<z.ZodNumber>;
120
+ region: z.ZodOptional<z.ZodObject<{
121
+ unit: z.ZodString;
122
+ x: z.ZodNumber;
123
+ y: z.ZodNumber;
124
+ w: z.ZodNumber;
125
+ h: z.ZodNumber;
126
+ }, z.core.$loose>>;
127
+ rationale: z.ZodOptional<z.ZodString>;
128
+ suggestion: z.ZodOptional<z.ZodString>;
129
+ }, z.core.$loose>>>;
130
+ failure: z.ZodOptional<z.ZodObject<{
131
+ reason: z.ZodString;
132
+ message: z.ZodOptional<z.ZodString>;
133
+ }, z.core.$loose>>;
134
+ usage: z.ZodOptional<z.ZodObject<{
135
+ inputTokens: z.ZodOptional<z.ZodNumber>;
136
+ outputTokens: z.ZodOptional<z.ZodNumber>;
137
+ latencyMs: z.ZodOptional<z.ZodNumber>;
138
+ costUsd: z.ZodOptional<z.ZodNumber>;
139
+ }, z.core.$loose>>;
140
+ }, z.core.$loose>;
141
+ /** A critique as stored in a check's `extensions["atlas.critique"]`. */
142
+ export type Critique = z.output<typeof CritiqueSchema>;
143
+ /** One finding of a critique. */
144
+ export type CritiqueFinding = z.output<typeof CritiqueFindingSchema>;