@crvouga/atlas 1.11.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 (70) hide show
  1. package/CHANGELOG.md +13 -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 +8 -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/rich-evidence.md +39 -0
  50. package/docs/server.md +6 -4
  51. package/docs/ui.md +22 -0
  52. package/llms-full.txt +279 -6
  53. package/llms.txt +2 -0
  54. package/package.json +7 -2
  55. package/packages/critics/README.md +18 -0
  56. package/packages/schema/schemas/critique.schema.json +254 -0
  57. package/packages/schema/schemas/server-config.schema.json +2 -1
  58. package/packages/schema/schemas/server-info.schema.json +1 -0
  59. package/packages/server/openapi.json +7 -6
  60. package/packages/visualizer/src/app/router.tsx +3 -0
  61. package/packages/visualizer/src/evidence/artifacts.tsx +88 -42
  62. package/packages/visualizer/src/evidence/behavior-panel.tsx +44 -25
  63. package/packages/visualizer/src/evidence/media-timing.test.ts +85 -0
  64. package/packages/visualizer/src/evidence/media-timing.ts +150 -0
  65. package/packages/visualizer/src/evidence/moments.tsx +457 -0
  66. package/packages/visualizer/src/evidence/outcome-summary.tsx +27 -0
  67. package/packages/visualizer/src/evidence/outcome.ts +7 -2
  68. package/packages/visualizer/src/explorer/TestDetail.tsx +2 -2
  69. package/packages/visualizer/src/explorer/playlist.tsx +89 -21
  70. package/packages/visualizer/src/explorer/search.ts +7 -1
@@ -0,0 +1,163 @@
1
+ /**
2
+ * @module Critiques as layers
3
+ *
4
+ * Turns a critic's judgements into the checks of a layer (`critiqueChecks`, `critiqueLayer`) and reads them back
5
+ * (`readCritique`, `critiquesOfLayer`). A critique is a check in a part of kind `critique` whose `status` says whether
6
+ * the critic ran; the opinion is in `extensions["atlas.critique"]`, and is repeated in standard evidence (a
7
+ * measurement for the score, an annotation for each finding) so a viewer that has never heard of critics still shows
8
+ * it. Critiques are anchored to the image hash they judged: when an entry's image changes, an older critique is still
9
+ * shown, as judging an earlier image, and the entry counts as not yet judged (`critiqueCurrency`).
10
+ *
11
+ * @see docs/critics.md
12
+ * @see docs/extending.md
13
+ */
14
+ import { CRITIC_PRODUCER_PREFIX, CRITIQUE_EXTENSION, CRITIQUE_PART_KIND, CRITIQUE_SCHEMA_VERSION, CritiqueSchema } from '../../schema/src/critique.js';
15
+ /** The prefix of the id of every critique check: `critique/<entry key>`. It keeps a critique from ever sharing a slot with a test. */
16
+ export const CRITIQUE_CHECK_PREFIX = 'critique/';
17
+ /** The check ids and labels one critique is published under. */
18
+ export function critiqueCheckId(entryKey) {
19
+ return `${CRITIQUE_CHECK_PREFIX}${entryKey}`;
20
+ }
21
+ /** `critic.<name>`: the producer of every layer of the critic's critiques. A critic-scoped token may publish only such layers. */
22
+ export function criticProducer(criticName) {
23
+ return `${CRITIC_PRODUCER_PREFIX}${criticName}`;
24
+ }
25
+ const LEVELS = { info: 'info', minor: 'warning', warning: 'warning', major: 'error', critical: 'error', error: 'error' };
26
+ function findingMessage(finding) {
27
+ const head = [finding.category, finding.rationale].filter(Boolean).join(': ');
28
+ const text = head || 'Finding';
29
+ return finding.suggestion ? `${text} (suggestion: ${finding.suggestion})` : text;
30
+ }
31
+ /**
32
+ * Builds the critique a critic produced for an entry. It fills in `schema`, `subject`, `critic`, `model` and
33
+ * `parameters` from the critic and the entry (so they cannot disagree with what was run) and keeps the verdict,
34
+ * findings and usage as given.
35
+ *
36
+ * @param critic the critic that ran.
37
+ * @param description the critic's `describe()` at the time of the run.
38
+ * @param entry the entry and the image hash that was judged.
39
+ * @param answer the critic's answer, or a `failure` instead of one.
40
+ */
41
+ export function buildCritique(critic, description, entry, answer) {
42
+ return {
43
+ schema: CRITIQUE_SCHEMA_VERSION,
44
+ subject: { entryKey: entry.key, imageSha256: entry.imageSha256, state: entry.state },
45
+ critic: { name: critic.name, version: critic.version, ...(critic.kind ? { kind: critic.kind } : {}) },
46
+ ...(description.model ? { model: { ...description.model } } : {}),
47
+ ...(description.parameters ? { parameters: { ...description.parameters } } : {}),
48
+ ...(answer.verdict ? { verdict: answer.verdict } : {}),
49
+ ...(answer.findings ? { findings: answer.findings } : {}),
50
+ ...(answer.failure ? { failure: answer.failure } : {}),
51
+ ...(answer.usage ? { usage: answer.usage } : {})
52
+ };
53
+ }
54
+ /**
55
+ * The checks of one layer for a set of outcomes, in the order given. A verdict is a `passed` check (the critic ran), a
56
+ * failure a `failed` one with its reason code in `detail`. The score is a measurement named `polish`; each finding is
57
+ * also an annotation of type `critique`. Nothing here decides a result: a low score is `passed`.
58
+ */
59
+ export function critiqueChecks(outcomes, options = {}) {
60
+ return outcomes.map(({ entry, critique }) => {
61
+ const verdict = critique.verdict;
62
+ const failure = critique.failure;
63
+ const score = verdict?.score;
64
+ return {
65
+ id: critiqueCheckId(entry.key),
66
+ title: `Critique of ${entry.title}`,
67
+ status: failure ? 'failed' : 'passed',
68
+ detail: failure ? `${failure.reason}${failure.message ? `: ${failure.message}` : ''}` : [verdict?.label, score === undefined ? undefined : `score ${score}`].filter(Boolean).join(', '),
69
+ measurements: score !== undefined && Number.isFinite(score) ? [{ name: 'polish', value: score, unit: 'score', ...(options.threshold === undefined ? {} : { limit: options.threshold }) }] : [],
70
+ annotations: (critique.findings ?? []).map(finding => ({
71
+ type: 'critique',
72
+ level: LEVELS[finding.severity ?? 'info'] ?? 'info',
73
+ message: findingMessage(finding),
74
+ ...(finding.id ? { extensions: { [CRITIQUE_EXTENSION]: { finding: finding.id } } } : {})
75
+ })),
76
+ labels: { critic: critique.critic.name, 'critic-version': critique.critic.version, ...(critique.model ? { model: critique.model.id } : {}) },
77
+ extensions: { [CRITIQUE_EXTENSION]: critique }
78
+ };
79
+ });
80
+ }
81
+ /**
82
+ * The input of `publishChecks` for one critic's outcomes: producer `critic.<name>`, part kind `critique`. The model and
83
+ * critic versions are labels and not part of the slot, so a newer version replaces an older one's critique of the same
84
+ * entry. Pass `parameters` to move something (such as a model version) into the slot instead, to compare side by side.
85
+ *
86
+ * @param args.critic the critic that ran.
87
+ * @param args.outcomes what it produced, successes and failures.
88
+ * @param args.recordedAtMs when, epoch milliseconds. Nothing here reads a clock, so the same input is the same layer.
89
+ */
90
+ export function critiqueLayer(args) {
91
+ return {
92
+ producer: criticProducer(args.critic.name),
93
+ kind: CRITIQUE_PART_KIND,
94
+ title: `Critiques by ${args.critic.name}`,
95
+ recordedAtMs: args.recordedAtMs,
96
+ sha: args.sha ?? null,
97
+ branch: args.branch ?? null,
98
+ runUrl: args.runUrl ?? null,
99
+ ...(args.environment === undefined ? {} : { environment: args.environment }),
100
+ parameters: { ...(args.parameters ?? {}) },
101
+ labels: { critic: args.critic.name, 'critic-version': args.critic.version },
102
+ checks: critiqueChecks(args.outcomes, args.threshold === undefined ? {} : { threshold: args.threshold })
103
+ };
104
+ }
105
+ /**
106
+ * Reads the critique of a check, or `null` when there is none that this reader can read: no `atlas.critique` extension,
107
+ * one that is malformed (a missing `subject`, a score that is not a number), or one of a version this release does not
108
+ * know. The check and its status are never affected: an unreadable opinion is unknown, not a pass and not a failure.
109
+ * Fields and values it does not know (a new category, severity or label) are kept.
110
+ */
111
+ export function readCritique(check) {
112
+ const raw = check.extensions?.[CRITIQUE_EXTENSION];
113
+ if (raw === undefined)
114
+ return null;
115
+ const parsed = CritiqueSchema.safeParse(raw);
116
+ if (!parsed.success || parsed.data.schema !== CRITIQUE_SCHEMA_VERSION)
117
+ return null;
118
+ return parsed.data;
119
+ }
120
+ /**
121
+ * Every readable critique in a layer's parts of kind `critique`. Checks whose critique cannot be read are left out, and
122
+ * so is any `atlas.critique` extension in a part of another kind: a test result is never mistaken for an opinion.
123
+ */
124
+ export function critiquesOfLayer(layer) {
125
+ const found = [];
126
+ for (const part of layer.parts) {
127
+ if (part.kind !== CRITIQUE_PART_KIND)
128
+ continue;
129
+ for (const check of part.checks) {
130
+ const critique = readCritique(check);
131
+ if (critique)
132
+ found.push({ layerId: layer.id, producer: layer.producer, recordedAtMs: part.recordedAtMs, checkId: check.id, status: check.status, critique });
133
+ }
134
+ }
135
+ return found;
136
+ }
137
+ /**
138
+ * Whether a critique judged the image an entry has now. `current`: yes. `earlier-image`: the entry's image has changed
139
+ * since, so the critique is shown as judging an earlier image and the entry counts as not yet judged by that critic.
140
+ */
141
+ export function critiqueCurrency(critique, currentImageSha256) {
142
+ return critique.subject.imageSha256 === currentImageSha256 ? 'current' : 'earlier-image';
143
+ }
144
+ const canonical = (value) => {
145
+ if (value === null || typeof value !== 'object')
146
+ return JSON.stringify(value) ?? 'null';
147
+ if (Array.isArray(value))
148
+ return `[${value.map(canonical).join(',')}]`;
149
+ const object = value;
150
+ return `{${Object.keys(object).sort().filter(key => object[key] !== undefined).map(key => `${JSON.stringify(key)}:${canonical(object[key])}`).join(',')}}`;
151
+ };
152
+ /**
153
+ * The identity of a critique for caching: the critic, its version, the model, the fixed parameters (the rubric hash
154
+ * among them) and the image hash. Two critiques with the same identity are the same judgement, and the second is not
155
+ * made. Key order in `parameters` does not matter.
156
+ */
157
+ export function critiqueIdentity(args) {
158
+ return canonical([args.critic.name, args.critic.version, args.model?.id ?? null, args.model?.provider ?? null, args.parameters ?? {}, args.imageSha256]);
159
+ }
160
+ /** The identity of an existing critique, as {@link critiqueIdentity} computes it for a run. */
161
+ export function identityOfCritique(critique) {
162
+ return critiqueIdentity({ critic: critique.critic, model: critique.model, parameters: critique.parameters, imageSha256: critique.subject.imageSha256 });
163
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @module Critics (`@crvouga/atlas/critics`)
3
+ *
4
+ * Automated reviewers of the UI catalog that mark which UIs need polish. A critic is a producer: it judges one captured
5
+ * UI at a time through one small contract (`defineCritic`), the runner (`runCritic`) owns concurrency, retries, rate
6
+ * limits, budgets and caching, and the opinions are stored as ordinary layers of parts of kind `critique` with the
7
+ * `atlas.critique` evidence (`critiqueLayer`, `readCritique`). A critique never changes whether a report passes: it is
8
+ * not a test result, is not counted, and only a project that opts in to `gate` mode can fail a command on a score, and
9
+ * only from calibrated, held-out evidence. Model-based, LLM-based and heuristic critics are the same on every
10
+ * path. Nothing here needs a model, a provider SDK or a network; critics bring their own.
11
+ *
12
+ * @see docs/critics.md
13
+ */
14
+ export * from './contract.js';
15
+ export * from './credentials.js';
16
+ export * from './critique.js';
17
+ export * from './judging.js';
18
+ export * from './publish.js';
19
+ export * from './runner.js';
20
+ export * from './scope.js';
21
+ export { CRITIC_PRODUCER_PREFIX, CRITIQUE_EXTENSION, CRITIQUE_PART_KIND, CRITIQUE_SCHEMA_VERSION, CritiqueSchema, type Critique, type CritiqueFinding } from '../../schema/src/critique.js';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @module Critics (`@crvouga/atlas/critics`)
3
+ *
4
+ * Automated reviewers of the UI catalog that mark which UIs need polish. A critic is a producer: it judges one captured
5
+ * UI at a time through one small contract (`defineCritic`), the runner (`runCritic`) owns concurrency, retries, rate
6
+ * limits, budgets and caching, and the opinions are stored as ordinary layers of parts of kind `critique` with the
7
+ * `atlas.critique` evidence (`critiqueLayer`, `readCritique`). A critique never changes whether a report passes: it is
8
+ * not a test result, is not counted, and only a project that opts in to `gate` mode can fail a command on a score, and
9
+ * only from calibrated, held-out evidence. Model-based, LLM-based and heuristic critics are the same on every
10
+ * path. Nothing here needs a model, a provider SDK or a network; critics bring their own.
11
+ *
12
+ * @see docs/critics.md
13
+ */
14
+ export * from './contract.js';
15
+ export * from './credentials.js';
16
+ export * from './critique.js';
17
+ export * from './judging.js';
18
+ export * from './publish.js';
19
+ export * from './runner.js';
20
+ export * from './scope.js';
21
+ export { CRITIC_PRODUCER_PREFIX, CRITIQUE_EXTENSION, CRITIQUE_PART_KIND, CRITIQUE_SCHEMA_VERSION, CritiqueSchema } from '../../schema/src/critique.js';
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @module State-aware judging, calibration and modes
3
+ *
4
+ * A raw score is not absolute. A loading overlay is not a full screen, so a score is read against the project's scores
5
+ * of the same state kind (`relativeVerdict`), and a threshold is measured per critic and per state kind against labelled
6
+ * examples (`calibrate`), recording sample sizes, precision and recall and whether the figures are in-sample or
7
+ * held-out. What happens when a score is low is the project's choice (`gateDecision`): `report` (shown, never fails
8
+ * anything), `warn` (surfaced on the command line) or `gate` (exit code 1 when a threshold is crossed, but only on a
9
+ * calibration with enough held-out evidence). A gate is computed here, by the reader, from critique data. It never
10
+ * changes a check's status, a slot or a winner.
11
+ *
12
+ * All scores are "higher is better". Everything here is pure.
13
+ *
14
+ * @see docs/critics.md#state-aware-judging-calibration-and-modes
15
+ */
16
+ import type { Critique } from '../../schema/src/critique.js';
17
+ /** The relative reading of a score, as stored in `verdict.relative`. */
18
+ export type Relative = NonNullable<NonNullable<Critique['verdict']>['relative']>;
19
+ /**
20
+ * Reads a score against the scores of the same state kind, and against the same entry's previous image.
21
+ *
22
+ * `percentile` is where the score falls among the population's scores of `stateKind` (ties count half), 0..100, higher
23
+ * is better; it is absent when no score of that state kind is known. Scores of other state kinds are ignored, so a
24
+ * loading overlay is compared with other loading overlays and never with full screens. `delta` is the score minus
25
+ * `previousScore` when there was one.
26
+ *
27
+ * @param args.score the entry's raw score.
28
+ * @param args.stateKind the kind of UI state of the entry.
29
+ * @param args.population every known score of the critic, with its state kind. May include the entry's own.
30
+ * @param args.previousScore the critic's score for the entry's previous image, when there was one.
31
+ */
32
+ export declare function relativeVerdict(args: {
33
+ readonly score: number;
34
+ readonly stateKind: string;
35
+ readonly population: readonly {
36
+ readonly stateKind: string;
37
+ readonly score: number;
38
+ }[];
39
+ readonly previousScore?: number;
40
+ }): Relative;
41
+ /** A labelled example for calibration: the critic's score for a UI, its state kind, and whether people judged it bad (needs polish). */
42
+ export type LabelledExample = {
43
+ readonly score: number;
44
+ readonly stateKind: string;
45
+ /** True when the UI needs polish: a positive for the critic to find. */
46
+ readonly bad: boolean;
47
+ /** True for an example the threshold was not chosen on. */
48
+ readonly heldOut?: boolean;
49
+ };
50
+ /** The calibration of one state kind. `threshold` flags a score below it; `null` when the examples cannot tell (one class missing). */
51
+ export type KindCalibration = {
52
+ readonly stateKind: string;
53
+ /** Scores below this are flagged as needing polish. `null`: none could be chosen. */
54
+ readonly threshold: number | null;
55
+ /** Examples of this state kind, all together. */
56
+ readonly samples: number;
57
+ /** How many of them are bad, and how many good. */
58
+ readonly positives: number;
59
+ readonly negatives: number;
60
+ /** Of what was flagged, the share that was bad; `null` when nothing was flagged. Measured on the examples named by `evaluatedOn`. */
61
+ readonly precision: number | null;
62
+ /** Of what was bad, the share that was flagged; `null` when nothing was bad. */
63
+ readonly recall: number | null;
64
+ /** True when the figures are measured on the examples the threshold was chosen on, which flatters them. */
65
+ readonly inSample: boolean;
66
+ /** Which examples the precision and recall are over: the `fit` examples (in-sample) or the `held-out` ones. */
67
+ readonly evaluatedOn: 'fit' | 'held-out';
68
+ };
69
+ /** The result of `calibrate`: per state kind, with the critic's provenance. */
70
+ export type Calibration = {
71
+ readonly schema: 1;
72
+ readonly critic: {
73
+ readonly name: string;
74
+ readonly version: string;
75
+ };
76
+ readonly kinds: readonly KindCalibration[];
77
+ };
78
+ /**
79
+ * Measures a critic against labelled examples, per state kind, and proposes a threshold for each.
80
+ *
81
+ * The threshold of a state kind is chosen on its examples that are not `heldOut` (best F1, then best precision, then
82
+ * the lower threshold), among the midpoints between neighbouring scores. If there are held-out examples of that kind,
83
+ * precision and recall are measured on them and `inSample` is false; otherwise they are measured on the same examples
84
+ * the threshold was chosen on and `inSample` is true, so nobody mistakes them for evidence. A state kind without both
85
+ * a good and a bad example gets `threshold: null`. State kinds come out sorted.
86
+ *
87
+ * @param critic the critic whose scores these are.
88
+ * @param examples labelled examples; scores must be finite.
89
+ */
90
+ export declare function calibrate(critic: {
91
+ readonly name: string;
92
+ readonly version: string;
93
+ }, examples: readonly LabelledExample[]): Calibration;
94
+ /** What happens when a score is below its threshold. `report` shows it and never fails anything. `warn` surfaces it on the command line. `gate` exits 1. */
95
+ export type GateMode = 'report' | 'warn' | 'gate';
96
+ /** The least evidence a calibration must have before `gate` is allowed. */
97
+ export type MinimumEvidence = {
98
+ /** Bad examples and good examples of the state kind, each. Default 10. */
99
+ readonly perClass: number;
100
+ /** Whether precision and recall must be measured on held-out examples. Default true. */
101
+ readonly heldOut: boolean;
102
+ };
103
+ /** The default minimum evidence for `gate`: 10 bad and 10 good examples per state kind, measured on held-out examples. */
104
+ export declare const DEFAULT_MINIMUM_EVIDENCE: MinimumEvidence;
105
+ /** A score below its state kind's threshold. */
106
+ export type BelowThreshold = {
107
+ readonly entryKey: string;
108
+ readonly stateKind: string;
109
+ readonly score: number;
110
+ readonly threshold: number;
111
+ };
112
+ /** The decision: the exit code and why. `refusal` is set when `gate` was asked for without enough evidence. */
113
+ export type GateDecision = {
114
+ readonly mode: GateMode;
115
+ /** 0 it passes (always, in `report` and `warn`), 1 a threshold was crossed in `gate`, 2 `gate` was refused for lack of evidence. */
116
+ readonly exitCode: 0 | 1 | 2;
117
+ /** Scores below their threshold, in the order given. */
118
+ readonly below: readonly BelowThreshold[];
119
+ /** Entries with no score to judge (the critic failed or gave none). Never counted as below, never as fine. */
120
+ readonly unjudged: readonly string[];
121
+ /** For `warn`: one line per score below its threshold. */
122
+ readonly warnings: readonly string[];
123
+ /** For a refused `gate`: what is missing, per state kind. */
124
+ readonly refusal: readonly string[];
125
+ };
126
+ /**
127
+ * Decides what a set of critic scores means for the exit code of a command.
128
+ *
129
+ * `report` and `warn` always give exit code 0 (`warn` also lists a warning for each score below its threshold).
130
+ * `gate` gives 1 when any score is below its state kind's threshold, but first requires, for every state kind present, a
131
+ * calibration of this critic and version with a threshold and at least `minimum.perClass` bad and good examples,
132
+ * measured on held-out examples when `minimum.heldOut`. Without that it refuses (exit 2) and says what is missing.
133
+ * An explicit `thresholds` entry replaces the calibrated threshold of its state kind but never waives the evidence.
134
+ *
135
+ * @param args.scores the critic's scores, one per entry; `score: null` for an entry it could not judge.
136
+ * @param args.calibration the critic's calibration, when there is one.
137
+ * @param args.thresholds thresholds by state kind that replace the calibrated ones.
138
+ */
139
+ export declare function gateDecision(args: {
140
+ readonly mode: GateMode;
141
+ readonly critic: {
142
+ readonly name: string;
143
+ readonly version: string;
144
+ };
145
+ readonly scores: readonly {
146
+ readonly entryKey: string;
147
+ readonly stateKind: string;
148
+ readonly score: number | null;
149
+ }[];
150
+ readonly calibration?: Calibration;
151
+ readonly thresholds?: Readonly<Record<string, number>>;
152
+ readonly minimum?: MinimumEvidence;
153
+ }): GateDecision;
@@ -0,0 +1,145 @@
1
+ const round = (value) => Math.round(value * 100) / 100;
2
+ /**
3
+ * Reads a score against the scores of the same state kind, and against the same entry's previous image.
4
+ *
5
+ * `percentile` is where the score falls among the population's scores of `stateKind` (ties count half), 0..100, higher
6
+ * is better; it is absent when no score of that state kind is known. Scores of other state kinds are ignored, so a
7
+ * loading overlay is compared with other loading overlays and never with full screens. `delta` is the score minus
8
+ * `previousScore` when there was one.
9
+ *
10
+ * @param args.score the entry's raw score.
11
+ * @param args.stateKind the kind of UI state of the entry.
12
+ * @param args.population every known score of the critic, with its state kind. May include the entry's own.
13
+ * @param args.previousScore the critic's score for the entry's previous image, when there was one.
14
+ */
15
+ export function relativeVerdict(args) {
16
+ const peers = args.population.filter(item => item.stateKind === args.stateKind && Number.isFinite(item.score));
17
+ const below = peers.filter(item => item.score < args.score).length;
18
+ const equal = peers.filter(item => item.score === args.score).length;
19
+ return {
20
+ stateKind: args.stateKind,
21
+ sampleSize: peers.length,
22
+ ...(peers.length ? { percentile: round(((below + equal / 2) / peers.length) * 100) } : {}),
23
+ ...(args.previousScore !== undefined && Number.isFinite(args.previousScore) ? { previousScore: args.previousScore, delta: round(args.score - args.previousScore) } : {})
24
+ };
25
+ }
26
+ function measure(examples, threshold) {
27
+ let tp = 0, fp = 0, fn = 0;
28
+ for (const example of examples) {
29
+ const flagged = example.score < threshold;
30
+ if (flagged && example.bad)
31
+ tp++;
32
+ else if (flagged)
33
+ fp++;
34
+ else if (example.bad)
35
+ fn++;
36
+ }
37
+ const precision = tp + fp ? tp / (tp + fp) : null;
38
+ const recall = tp + fn ? tp / (tp + fn) : null;
39
+ return { precision, recall, f1: precision && recall ? (2 * precision * recall) / (precision + recall) : 0 };
40
+ }
41
+ /**
42
+ * Measures a critic against labelled examples, per state kind, and proposes a threshold for each.
43
+ *
44
+ * The threshold of a state kind is chosen on its examples that are not `heldOut` (best F1, then best precision, then
45
+ * the lower threshold), among the midpoints between neighbouring scores. If there are held-out examples of that kind,
46
+ * precision and recall are measured on them and `inSample` is false; otherwise they are measured on the same examples
47
+ * the threshold was chosen on and `inSample` is true, so nobody mistakes them for evidence. A state kind without both
48
+ * a good and a bad example gets `threshold: null`. State kinds come out sorted.
49
+ *
50
+ * @param critic the critic whose scores these are.
51
+ * @param examples labelled examples; scores must be finite.
52
+ */
53
+ export function calibrate(critic, examples) {
54
+ const kinds = [...new Set(examples.map(example => example.stateKind))].sort();
55
+ return {
56
+ schema: 1,
57
+ critic: { name: critic.name, version: critic.version },
58
+ kinds: kinds.map(stateKind => {
59
+ const own = examples.filter(example => example.stateKind === stateKind && Number.isFinite(example.score));
60
+ const fit = own.filter(example => !example.heldOut);
61
+ const held = own.filter(example => example.heldOut);
62
+ const scores = [...new Set(fit.map(example => example.score))].sort((a, b) => a - b);
63
+ const usable = fit.some(example => example.bad) && fit.some(example => !example.bad);
64
+ let threshold = null;
65
+ if (usable) {
66
+ // Midpoints between neighbours, and one above the highest score (flag everything).
67
+ const candidates = [...scores.slice(1).map((score, index) => (score + scores[index]) / 2), scores[scores.length - 1] + 1];
68
+ let best = { f1: -1, precision: -1, threshold: 0 };
69
+ for (const candidate of candidates) {
70
+ const { f1, precision } = measure(fit, candidate);
71
+ if (f1 > best.f1 || (f1 === best.f1 && (precision ?? 0) > best.precision))
72
+ best = { f1, precision: precision ?? 0, threshold: candidate };
73
+ }
74
+ threshold = round(best.threshold);
75
+ }
76
+ const evaluated = held.length ? held : fit;
77
+ const figures = threshold === null ? { precision: null, recall: null } : measure(evaluated, threshold);
78
+ return {
79
+ stateKind, threshold,
80
+ samples: own.length,
81
+ positives: own.filter(example => example.bad).length,
82
+ negatives: own.filter(example => !example.bad).length,
83
+ precision: figures.precision === null ? null : round(figures.precision),
84
+ recall: figures.recall === null ? null : round(figures.recall),
85
+ inSample: held.length === 0,
86
+ evaluatedOn: held.length ? 'held-out' : 'fit'
87
+ };
88
+ })
89
+ };
90
+ }
91
+ /** The default minimum evidence for `gate`: 10 bad and 10 good examples per state kind, measured on held-out examples. */
92
+ export const DEFAULT_MINIMUM_EVIDENCE = { perClass: 10, heldOut: true };
93
+ /**
94
+ * Decides what a set of critic scores means for the exit code of a command.
95
+ *
96
+ * `report` and `warn` always give exit code 0 (`warn` also lists a warning for each score below its threshold).
97
+ * `gate` gives 1 when any score is below its state kind's threshold, but first requires, for every state kind present, a
98
+ * calibration of this critic and version with a threshold and at least `minimum.perClass` bad and good examples,
99
+ * measured on held-out examples when `minimum.heldOut`. Without that it refuses (exit 2) and says what is missing.
100
+ * An explicit `thresholds` entry replaces the calibrated threshold of its state kind but never waives the evidence.
101
+ *
102
+ * @param args.scores the critic's scores, one per entry; `score: null` for an entry it could not judge.
103
+ * @param args.calibration the critic's calibration, when there is one.
104
+ * @param args.thresholds thresholds by state kind that replace the calibrated ones.
105
+ */
106
+ export function gateDecision(args) {
107
+ const minimum = args.minimum ?? DEFAULT_MINIMUM_EVIDENCE;
108
+ const calibrated = args.calibration && args.calibration.critic.name === args.critic.name && args.calibration.critic.version === args.critic.version ? args.calibration : undefined;
109
+ const thresholdOf = (stateKind) => args.thresholds?.[stateKind] ?? calibrated?.kinds.find(kind => kind.stateKind === stateKind)?.threshold ?? null;
110
+ const unjudged = args.scores.filter(item => item.score === null).map(item => item.entryKey);
111
+ const below = [];
112
+ for (const item of args.scores) {
113
+ const threshold = thresholdOf(item.stateKind);
114
+ if (item.score !== null && threshold !== null && item.score < threshold)
115
+ below.push({ entryKey: item.entryKey, stateKind: item.stateKind, score: item.score, threshold });
116
+ }
117
+ const refusal = [];
118
+ if (args.mode === 'gate') {
119
+ if (args.calibration && !calibrated)
120
+ refusal.push(`The calibration is for ${args.calibration.critic.name}@${args.calibration.critic.version}, not ${args.critic.name}@${args.critic.version}: calibrate this version`);
121
+ for (const stateKind of [...new Set(args.scores.map(item => item.stateKind))].sort()) {
122
+ const kind = calibrated?.kinds.find(candidate => candidate.stateKind === stateKind);
123
+ if (!kind) {
124
+ if (!args.calibration || calibrated)
125
+ refusal.push(`No calibration for state kind "${stateKind}": run \`atlas critic calibrate ${args.critic.name}\` with labelled examples of it`);
126
+ continue;
127
+ }
128
+ if (kind.threshold === null)
129
+ refusal.push(`Calibration for "${stateKind}" has no threshold: it needs both good and bad examples`);
130
+ if (kind.positives < minimum.perClass || kind.negatives < minimum.perClass)
131
+ refusal.push(`Calibration for "${stateKind}" has ${kind.positives} bad and ${kind.negatives} good examples; gate needs at least ${minimum.perClass} of each`);
132
+ if (minimum.heldOut && kind.inSample)
133
+ refusal.push(`Calibration for "${stateKind}" is in-sample: gate needs precision and recall measured on held-out examples`);
134
+ }
135
+ }
136
+ const refused = refusal.length > 0;
137
+ return {
138
+ mode: args.mode,
139
+ exitCode: refused ? 2 : args.mode === 'gate' && below.length ? 1 : 0,
140
+ below: refused ? [] : below,
141
+ unjudged,
142
+ warnings: args.mode === 'warn' ? below.map(item => `${item.entryKey} (${item.stateKind}) scored ${item.score}, below ${item.threshold}`) : [],
143
+ refusal
144
+ };
145
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @module Publishing critiques
3
+ *
4
+ * `publishCritiques` publishes a critic's outcomes as one layer through any store, after checking that the layer is
5
+ * within what a critic-scoped credential may publish. The layer is content-addressed, so publishing the same outcomes
6
+ * again writes the same layer and changes nothing.
7
+ *
8
+ * @see docs/critics.md
9
+ */
10
+ import type { ReportLayer } from '../../reports/src/layer-types.js';
11
+ import type { ObjectStore } from '../../reports/src/object-store.js';
12
+ import { type PublishChecksOptions } from '../../reports/src/publish-checks.js';
13
+ import { critiqueLayer } from './critique.js';
14
+ /**
15
+ * Publishes the layer {@link critiqueLayer} builds for `args`. Returns the stored layer, or `null` when there is nothing
16
+ * to publish (no outcomes). Throws when the layer would be outside a critic's scope, which cannot happen for a layer
17
+ * built here and is checked so that a change cannot make it happen quietly.
18
+ *
19
+ * @param store where to publish.
20
+ * @param args see {@link critiqueLayer}.
21
+ * @param options attachment limits, as for `publishChecks`.
22
+ */
23
+ export declare function publishCritiques(store: ObjectStore, args: Parameters<typeof critiqueLayer>[0], options?: PublishChecksOptions): Promise<ReportLayer | null>;
@@ -0,0 +1,21 @@
1
+ import { publishChecks } from '../../reports/src/publish-checks.js';
2
+ import { critiqueLayer } from './critique.js';
3
+ import { criticScopeViolation } from './scope.js';
4
+ /**
5
+ * Publishes the layer {@link critiqueLayer} builds for `args`. Returns the stored layer, or `null` when there is nothing
6
+ * to publish (no outcomes). Throws when the layer would be outside a critic's scope, which cannot happen for a layer
7
+ * built here and is checked so that a change cannot make it happen quietly.
8
+ *
9
+ * @param store where to publish.
10
+ * @param args see {@link critiqueLayer}.
11
+ * @param options attachment limits, as for `publishChecks`.
12
+ */
13
+ export async function publishCritiques(store, args, options = {}) {
14
+ if (!args.outcomes.length)
15
+ return null;
16
+ const input = critiqueLayer(args);
17
+ const violation = criticScopeViolation({ producer: input.producer, parts: [{ kind: input.kind }] });
18
+ if (violation)
19
+ throw new Error(violation);
20
+ return publishChecks(store, input, options);
21
+ }
@@ -0,0 +1,104 @@
1
+ import type { Critique } from '../../schema/src/critique.js';
2
+ import { type Critic, type JudgeableEntry } from './contract.js';
3
+ import { type CritiqueOutcome } from './critique.js';
4
+ /** The most a run may spend before it stops cleanly. Any limit left out is unlimited. */
5
+ export type Budget = {
6
+ /** The most images to start judging in one run (retries of one image count once). */
7
+ readonly maxImages?: number;
8
+ /** The most tokens (input and output) to spend, summed from what critics report as `usage`. */
9
+ readonly maxTokens?: number;
10
+ /** The most money to spend, in US dollars, summed from what critics report as `usage.costUsd`. */
11
+ readonly maxCostUsd?: number;
12
+ };
13
+ /** What a run was told to do and how. Everything but the critic, the entries and `loadImage` has a default. */
14
+ export type RunOptions = {
15
+ /** The critic to run. */
16
+ readonly critic: Critic;
17
+ /** The entries to judge, each with the hash of its stored image. Judged in this order; a repeated key is judged once. */
18
+ readonly entries: Iterable<JudgeableEntry> | AsyncIterable<JudgeableEntry>;
19
+ /** Fetches an entry's image bytes. Called only for entries that will be judged. The runner checks the bytes against `imageSha256`. */
20
+ readonly loadImage: (entry: JudgeableEntry) => Promise<Uint8Array>;
21
+ /** Critiques that already exist (read with `critiquesOfLayer`). One with a verdict and the same identity as this run's makes the image skipped; a failed one does not. */
22
+ readonly existing?: readonly Critique[];
23
+ /** Judge again even when a current critique exists. */
24
+ readonly force?: boolean;
25
+ /** Plan only: count what would be judged and skipped and say where images would go. Nothing is loaded, sent or returned to publish. */
26
+ readonly dryRun?: boolean;
27
+ /** Most calls in flight at once. Default 4. */
28
+ readonly concurrency?: number;
29
+ /** Most attempts for one image when the provider is rate limited. Default 4. */
30
+ readonly maxAttempts?: number;
31
+ /** A call that takes longer than this is recorded as `timeout`, and the run goes on. Default 60 000. */
32
+ readonly timeoutMs?: number;
33
+ /** Backoff between attempts: `baseMs` doubles each attempt up to `maxMs`, with full jitter, and never less than `Retry-After`. Defaults 500 and 30 000. */
34
+ readonly backoff?: {
35
+ readonly baseMs?: number;
36
+ readonly maxMs?: number;
37
+ };
38
+ /** Stop cleanly when any limit is reached. */
39
+ readonly budget?: Budget;
40
+ /** Credentials this run holds. Every recorded message has them removed. */
41
+ readonly secrets?: readonly string[];
42
+ /** Cancels the run: in-flight calls are aborted, what finished is returned. */
43
+ readonly signal?: AbortSignal;
44
+ /** Waits. Default `setTimeout`. Tests pass a fake. */
45
+ readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
46
+ /** A number in [0, 1) for jitter. Default `Math.random`. */
47
+ readonly random?: () => number;
48
+ /** Called after each entry is finished or skipped, for progress. Receives no image bytes and no credentials. */
49
+ readonly onProgress?: (event: ProgressEvent) => void;
50
+ };
51
+ /** One step of progress. `waiting` counts entries not yet started. */
52
+ export type ProgressEvent = {
53
+ readonly entryKey: string;
54
+ readonly result: 'judged' | 'skipped' | 'failed';
55
+ readonly judged: number;
56
+ readonly skipped: number;
57
+ readonly failed: number;
58
+ readonly waiting: number;
59
+ };
60
+ /** Why a run ended before every entry was handled: it `budget`ed out or was `aborted`. */
61
+ export type StoppedBy = 'budget' | 'aborted';
62
+ /** What a run did. `outcomes` are what to publish (verdicts and failures); counts and `spent` are for the summary. */
63
+ export type RunResult = {
64
+ readonly critic: {
65
+ readonly name: string;
66
+ readonly version: string;
67
+ };
68
+ /** Where images are sent, as the critic described it. */
69
+ readonly sendsImagesTo: string | null;
70
+ /** The critiques to publish, in entry order: verdicts and failures. */
71
+ readonly outcomes: readonly CritiqueOutcome[];
72
+ /** Entries with a current critique that were not judged again. */
73
+ readonly skipped: number;
74
+ /** Entries judged with a verdict. */
75
+ readonly judged: number;
76
+ /** Entries recorded as failed. */
77
+ readonly failed: number;
78
+ /** Entries left unhandled because the run stopped. */
79
+ readonly waiting: number;
80
+ /** Set when the run stopped early, and why. */
81
+ readonly stoppedBy?: StoppedBy;
82
+ /** What the run spent, from the usage critics reported. */
83
+ readonly spent: {
84
+ readonly images: number;
85
+ readonly tokens: number;
86
+ readonly costUsd: number;
87
+ };
88
+ /** Whether this was a dry run: nothing was loaded, sent or to publish. */
89
+ readonly dryRun: boolean;
90
+ };
91
+ /**
92
+ * Runs a critic over entries. Entries with a current critique of the same identity are skipped without fetching their
93
+ * image; the rest are judged with bounded concurrency, a timeout and retries. The run never throws for a critic's
94
+ * failure: it records it. It throws only for a wrong call (a non-positive `concurrency`, `maxAttempts` or `timeoutMs`).
95
+ *
96
+ * A run stops cleanly when a budget is reached or the signal aborts: calls in flight finish (or are aborted), what was
97
+ * finished is returned in `outcomes` to publish, and `stoppedBy` says why. With concurrency above 1 a budget can be
98
+ * overshot by the calls already in flight.
99
+ *
100
+ * @example
101
+ * const result = await runCritic({ critic, entries, loadImage: entry => fetchImage(entry), existing, budget: { maxImages: 200 } });
102
+ * await publishChecks(store, critiqueLayer({ critic, outcomes: result.outcomes, recordedAtMs: Date.now() }));
103
+ */
104
+ export declare function runCritic(options: RunOptions): Promise<RunResult>;