@crvouga/atlas 1.21.0 → 1.22.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 (96) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/dist/core/src/cli.d.ts +2 -2
  3. package/dist/core/src/cli.js +4 -1
  4. package/dist/critics/src/catalog-client.d.ts +91 -0
  5. package/dist/critics/src/catalog-client.js +194 -0
  6. package/dist/critics/src/cli-calibrate.d.ts +4 -0
  7. package/dist/critics/src/cli-calibrate.js +211 -0
  8. package/dist/critics/src/cli-common.d.ts +108 -0
  9. package/dist/critics/src/cli-common.js +155 -0
  10. package/dist/critics/src/cli-review.d.ts +4 -0
  11. package/dist/critics/src/cli-review.js +174 -0
  12. package/dist/critics/src/cli-watch.d.ts +4 -0
  13. package/dist/critics/src/cli-watch.js +191 -0
  14. package/dist/critics/src/cli.d.ts +13 -0
  15. package/dist/critics/src/cli.js +490 -0
  16. package/dist/critics/src/index.d.ts +3 -0
  17. package/dist/critics/src/index.js +3 -0
  18. package/dist/critics/src/judging.d.ts +8 -0
  19. package/dist/critics/src/llm.d.ts +120 -0
  20. package/dist/critics/src/llm.js +266 -0
  21. package/dist/critics/src/marks.d.ts +74 -0
  22. package/dist/critics/src/marks.js +76 -0
  23. package/dist/critics/src/registry.d.ts +125 -0
  24. package/dist/critics/src/registry.js +203 -0
  25. package/dist/critics/src/spool.d.ts +32 -0
  26. package/dist/critics/src/spool.js +129 -0
  27. package/dist/critics/src/uiclip.d.ts +82 -0
  28. package/dist/critics/src/uiclip.js +167 -0
  29. package/dist/reports/src/critique-review.d.ts +111 -0
  30. package/dist/reports/src/critique-review.js +123 -0
  31. package/dist/reports/src/critique-row.d.ts +24 -0
  32. package/dist/reports/src/critique-row.js +39 -0
  33. package/dist/reports/src/evidence-types.d.ts +5 -0
  34. package/dist/reports/src/layer-merge.js +5 -0
  35. package/dist/reports/src/report-index.d.ts +6 -0
  36. package/dist/reports/src/report-index.js +12 -0
  37. package/dist/reports/src/ui-catalog-cli.d.ts +1 -1
  38. package/dist/reports/src/ui-catalog-cli.js +3 -3
  39. package/dist/reports/src/ui-catalog-entries.d.ts +147 -0
  40. package/dist/reports/src/ui-catalog-entries.js +190 -0
  41. package/dist/reports/src/ui-catalog-publish.js +2 -2
  42. package/dist/reports/src/ui-catalog.d.ts +2 -0
  43. package/dist/reports/src/ui-catalog.js +1 -1
  44. package/dist/reports/src/ui-state.d.ts +2 -0
  45. package/dist/reports/src/ui-state.js +2 -2
  46. package/dist/schema/src/api.d.ts +653 -1
  47. package/dist/schema/src/api.js +44 -0
  48. package/dist/schema/src/cli-results.d.ts +674 -1
  49. package/dist/schema/src/cli-results.js +177 -1
  50. package/dist/schema/src/critique.d.ts +31 -0
  51. package/dist/schema/src/critique.js +26 -0
  52. package/dist/schema/src/server-config.d.ts +3 -3
  53. package/dist/server/src/app.js +28 -1
  54. package/dist/server/src/openapi.js +19 -2
  55. package/docs/agents.md +11 -2
  56. package/docs/critics.md +273 -9
  57. package/docs/extending.md +1 -1
  58. package/docs/reference/cli.md +73 -3
  59. package/docs/reference/core.md +1 -1
  60. package/docs/reference/critics.md +1131 -29
  61. package/docs/reference/http-api.md +40 -0
  62. package/docs/reference/reports.md +463 -0
  63. package/docs/reference/schema.md +157 -1
  64. package/docs/report-contract.md +2 -1
  65. package/docs/server.md +2 -0
  66. package/docs/ui-catalog.md +4 -1
  67. package/docs/ui.md +63 -1
  68. package/llms-full.txt +469 -18
  69. package/llms.txt +1 -1
  70. package/package.json +9 -1
  71. package/packages/critics/README.md +1 -0
  72. package/packages/schema/schemas/critic-calibrate-result.schema.json +274 -0
  73. package/packages/schema/schemas/critic-doctor-result.schema.json +74 -0
  74. package/packages/schema/schemas/critic-list-result.schema.json +90 -0
  75. package/packages/schema/schemas/critic-review-result.schema.json +397 -0
  76. package/packages/schema/schemas/critic-run-result.schema.json +249 -0
  77. package/packages/schema/schemas/critic-setup-result.schema.json +34 -0
  78. package/packages/schema/schemas/critic-watch-result.schema.json +211 -0
  79. package/packages/schema/schemas/critique-mark.schema.json +81 -0
  80. package/packages/schema/schemas/effective-delta.schema.json +337 -0
  81. package/packages/schema/schemas/effective-evidence.schema.json +337 -0
  82. package/packages/schema/schemas/layer-rows.schema.json +337 -0
  83. package/packages/server/openapi.json +6755 -4738
  84. package/packages/visualizer/src/app/AppShell.tsx +3 -1
  85. package/packages/visualizer/src/app/router.tsx +20 -7
  86. package/packages/visualizer/src/components/Header.tsx +16 -4
  87. package/packages/visualizer/src/critiques/CritiquesPage.tsx +402 -0
  88. package/packages/visualizer/src/critiques/FindingOverlay.tsx +50 -0
  89. package/packages/visualizer/src/critiques/ranking.ts +54 -0
  90. package/packages/visualizer/src/critiques/search.ts +41 -0
  91. package/packages/visualizer/src/evidence/applicability-view.tsx +11 -7
  92. package/packages/visualizer/src/evidence/behaviors-page.tsx +22 -15
  93. package/packages/visualizer/src/explorer/Explorer.tsx +5 -0
  94. package/packages/visualizer/src/explorer/TestDetail.tsx +4 -1
  95. package/packages/visualizer/src/explorer/parts.tsx +19 -1
  96. package/packages/visualizer/src/explorer/playlist.tsx +11 -7
package/CHANGELOG.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
- ## 1.21.0 (2026-10-29)
3
+ ## 1.22.0 (2026-10-29)
4
+
5
+ ### Features
6
+
7
+ - watch, review and calibrate commands, finding overlays, agreement view and reviewer marks ([e871be2](https://github.com/crvouga/atlas/commit/e871be24e00b2faa97421c5e405e3790d60a10e9))
8
+ - connect Read, Watch and Inspect across behaviors, runs and recordings ([b986ee5](https://github.com/crvouga/atlas/commit/b986ee56413fda0451cd020d0c251fd83d63c92b))
9
+ - read the catalog, run critics from atlas critic, and list what needs polish ([91f1fb8](https://github.com/crvouga/atlas/commit/91f1fb8bca3e3f04c132e3114aa0fc3ef1b2f2db))
10
+
11
+ ## 1.21.0 (2026-10-28)
4
12
 
5
13
  ### Features
6
14
 
@@ -2,13 +2,13 @@
2
2
  * @module The `atlas` command
3
3
  *
4
4
  * Dispatches `atlas <command>`: the statechart commands (`lint`, `plan`, `run`, `merge`, `export`) and, loaded on demand so
5
- * they cost nothing otherwise, `serve`, `dev`, `init` and `report`. Statechart configuration is `atlas.config.ts` (see
5
+ * they cost nothing otherwise, `serve`, `dev`, `init`, `report` and `critic`. Statechart configuration is `atlas.config.ts` (see
6
6
  * `defineConfig`). A command that is wrong prints a message and exits 2; a plan, lint or run that finds problems exits 1.
7
7
  *
8
8
  * @see docs/statecharts.md
9
9
  * @see docs/reference/cli.md
10
10
  */
11
11
  /** The text `atlas --help` prints: every command, and the flags of the statechart commands. */
12
- export declare const USAGE = "atlas \u2014 executable statecharts\n\n atlas lint [--config atlas.config.ts | --specs <dir>] [--require-implementations]\n atlas plan [--config atlas.config.ts] [--state <name>]\n atlas run [--config atlas.config.ts] [--mode fast|showcase] [--workers n] [--state <name>]\n [--seed <name>]... [--paths id,id] [--shard i/n] [--rerun-failed | --reuse <run-dir>]\n [--no-retry] [--output <dir>]\n atlas merge [--config atlas.config.ts] [--output <dir>] <run-dir> <run-dir>...\n atlas export --specs <dir> --format scxml|mermaid|markdown|json [--out <file>]\n atlas server [--port 8080] one Atlas node: API, projects and client; needs ATLAS_S3_* (atlas serve --help)\n atlas client build [--no-data] [--out <dir>] build the project-agnostic Atlas client\n atlas dev [--port 5180] develop the Atlas server and client on one host\n atlas init [--server <url>] [--write] integrate this project: detect its suites, print the plan (atlas init --help)\n atlas report publish|stream|status|merge|doctor|policy \u2026 layered evidence for any kind of spec (atlas report --help)\n atlas ui-catalog plan|publish|status|diff|export \u2026 the UI catalog of captured states (atlas ui-catalog --help)\n\n --workers path attempts that run at once (default: one per CPU, capped by the driver)\n --seed only paths that start from this seed (\"start\" for paths setup starts); repeatable\n --shard 2/4 run the second quarter of the plan; merge the shards' runs afterwards\n --rerun-failed keep the newest run's passed paths and run only the rest\n";
12
+ export declare const USAGE = "atlas \u2014 executable statecharts\n\n atlas lint [--config atlas.config.ts | --specs <dir>] [--require-implementations]\n atlas plan [--config atlas.config.ts] [--state <name>]\n atlas run [--config atlas.config.ts] [--mode fast|showcase] [--workers n] [--state <name>]\n [--seed <name>]... [--paths id,id] [--shard i/n] [--rerun-failed | --reuse <run-dir>]\n [--no-retry] [--output <dir>]\n atlas merge [--config atlas.config.ts] [--output <dir>] <run-dir> <run-dir>...\n atlas export --specs <dir> --format scxml|mermaid|markdown|json [--out <file>]\n atlas server [--port 8080] one Atlas node: API, projects and client; needs ATLAS_S3_* (atlas serve --help)\n atlas client build [--no-data] [--out <dir>] build the project-agnostic Atlas client\n atlas dev [--port 5180] develop the Atlas server and client on one host\n atlas init [--server <url>] [--write] integrate this project: detect its suites, print the plan (atlas init --help)\n atlas report publish|stream|status|merge|doctor|policy \u2026 layered evidence for any kind of spec (atlas report --help)\n atlas critic list|setup|doctor|run \u2026 automated reviewers that mark which UIs need polish (atlas critic --help)\n atlas ui-catalog plan|publish|status|diff|export \u2026 the UI catalog of captured states (atlas ui-catalog --help)\n\n --workers path attempts that run at once (default: one per CPU, capped by the driver)\n --seed only paths that start from this seed (\"start\" for paths setup starts); repeatable\n --shard 2/4 run the second quarter of the plan; merge the shards' runs afterwards\n --rerun-failed keep the newest run's passed paths and run only the rest\n";
13
13
  /** Runs `atlas` with `argv` (default: the process arguments) and returns the exit code. Throws for a command that is wrong, which the `atlas` binary reports as a message and exit code 2. */
14
14
  export declare function main(argv?: string[]): Promise<number>;
@@ -2,7 +2,7 @@
2
2
  * @module The `atlas` command
3
3
  *
4
4
  * Dispatches `atlas <command>`: the statechart commands (`lint`, `plan`, `run`, `merge`, `export`) and, loaded on demand so
5
- * they cost nothing otherwise, `serve`, `dev`, `init` and `report`. Statechart configuration is `atlas.config.ts` (see
5
+ * they cost nothing otherwise, `serve`, `dev`, `init`, `report` and `critic`. Statechart configuration is `atlas.config.ts` (see
6
6
  * `defineConfig`). A command that is wrong prints a message and exits 2; a plan, lint or run that finds problems exits 1.
7
7
  *
8
8
  * @see docs/statecharts.md
@@ -33,6 +33,7 @@ export const USAGE = `atlas — executable statecharts
33
33
  atlas dev [--port 5180] develop the Atlas server and client on one host
34
34
  atlas init [--server <url>] [--write] integrate this project: detect its suites, print the plan (atlas init --help)
35
35
  atlas report publish|stream|status|merge|doctor|policy … layered evidence for any kind of spec (atlas report --help)
36
+ atlas critic list|setup|doctor|run … automated reviewers that mark which UIs need polish (atlas critic --help)
36
37
  atlas ui-catalog plan|publish|status|diff|export … the UI catalog of captured states (atlas ui-catalog --help)
37
38
 
38
39
  --workers path attempts that run at once (default: one per CPU, capped by the driver)
@@ -104,6 +105,8 @@ export async function main(argv = process.argv.slice(2)) {
104
105
  }
105
106
  if (command === 'init')
106
107
  return (await import('../../reports/src/init.js')).initMain(argv.slice(1), { out: text => process.stdout.write(text) });
108
+ if (command === 'critic')
109
+ return (await import('../../critics/src/cli.js')).runCriticCommand(argv.slice(1));
107
110
  if (command === 'report')
108
111
  return (await import('../../reports/src/report-cli.js')).runReportCommand(argv.slice(1));
109
112
  if (command === 'ui-catalog')
@@ -0,0 +1,91 @@
1
+ import { type CatalogEntriesQuery, type CatalogEntry } from '../../reports/src/ui-catalog-entries.js';
2
+ /** How to reach a server. `token` is a `critic`, `publish` or `admin` token for reading the catalog; any token that may read works. */
3
+ export type CatalogClientOptions = {
4
+ /** The Atlas server, such as `https://atlas.example.com`. */
5
+ readonly url: string;
6
+ /** Sent as `Authorization: Bearer`. Never put in the URL, never in an error. */
7
+ readonly token?: string;
8
+ /** A project of a shared node. Omit for the default project. */
9
+ readonly project?: string;
10
+ /** For tests and special runtimes. Default `fetch`. */
11
+ readonly fetch?: typeof fetch;
12
+ /** Retries after the first attempt for network errors, 5xx and 429. Default 3. */
13
+ readonly retries?: number;
14
+ /** Waits. Default `setTimeout`. */
15
+ readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
16
+ };
17
+ /** One page of the catalog as the server answered it. */
18
+ export type CatalogPage = {
19
+ /** The report version this was read at. */
20
+ readonly version: string;
21
+ readonly entries: readonly CatalogEntry[];
22
+ /** Entries that match, across all pages. */
23
+ readonly total: number;
24
+ /** Pass as `cursor` to read the next page; absent on the last. */
25
+ readonly nextCursor?: string;
26
+ /** Present when `since` was asked: whether the server remembered that version. When it did not, `entries` is the whole catalog. */
27
+ readonly since?: {
28
+ readonly requested: string;
29
+ readonly honored: boolean;
30
+ };
31
+ /** Captured cells that could not be offered for judging because no stored picture of a known hash backs them. */
32
+ readonly unjudgeable: number;
33
+ /** What the server could not read. */
34
+ readonly notices: readonly string[];
35
+ };
36
+ /** Everything a pass over the catalog saw, and the version to resume from. */
37
+ export type CatalogRead = {
38
+ /** The version of the first page. Resume with `since` this: it finds whatever changed while the rest was read. */
39
+ readonly version: string;
40
+ readonly entries: readonly CatalogEntry[];
41
+ readonly unjudgeable: number;
42
+ readonly notices: readonly string[];
43
+ /** False when a `since` was asked and the server did not remember it, so `entries` is the whole catalog. */
44
+ readonly sinceHonored: boolean;
45
+ };
46
+ /** Something new in the catalog, as {@link CatalogClient.follow} hands it over. */
47
+ export type CatalogBatch = {
48
+ /** The report version this batch brings the follower up to. */
49
+ readonly version: string;
50
+ /** Entries not seen before, or whose picture changed; none twice. */
51
+ readonly entries: readonly CatalogEntry[];
52
+ /** True for the first batch (the catch-up on connecting), false for what arrived afterwards. */
53
+ readonly initial: boolean;
54
+ };
55
+ /** An Atlas server refused or could not answer, with what to do about it. Its message never holds a token. */
56
+ export declare class CatalogClientError extends Error {
57
+ readonly status: number | null;
58
+ /** @param status the HTTP status, or null when the server could not be reached. */
59
+ constructor(message: string, status: number | null);
60
+ }
61
+ /** A connection to the catalog of one Atlas server. */
62
+ export type CatalogClient = {
63
+ /** The server address, with no secrets. */
64
+ readonly url: string;
65
+ /** One page of entries for a query (`cursor`, `limit`, `since`, `unjudgedBy`, filters). */
66
+ page(query?: CatalogEntriesQuery): Promise<CatalogPage>;
67
+ /** Every page of a query, in key order, each entry once. `query.cursor` is ignored. */
68
+ read(query?: Omit<CatalogEntriesQuery, 'cursor'>): Promise<CatalogRead>;
69
+ /** The picture of an entry: its stored bytes, checked against `entry.image.sha256`. Throws if they differ. */
70
+ image(entry: Pick<CatalogEntry, 'image'>): Promise<Uint8Array>;
71
+ /**
72
+ * Catches up with the catalog and then follows it: a batch on connecting (everything, or what changed since `since`), then
73
+ * a batch each time the server announces a change. A dropped connection is reconnected after a pause, and the next batch
74
+ * is a `since` read from the version held, so nothing is missed and nothing is handed over twice. Ends when `signal` aborts.
75
+ */
76
+ follow(options?: {
77
+ readonly since?: string;
78
+ readonly query?: Omit<CatalogEntriesQuery, 'cursor' | 'since'>;
79
+ readonly signal?: AbortSignal;
80
+ readonly reconnectMs?: number;
81
+ }): AsyncGenerator<CatalogBatch, void, void>;
82
+ };
83
+ /**
84
+ * A client for the catalog of an Atlas server. Nothing is read until a method is called.
85
+ *
86
+ * @example
87
+ * const catalog = createCatalogClient({ url: 'https://atlas.example.com', token: process.env.ATLAS_CRITIC_TOKEN });
88
+ * const { version, entries } = await catalog.read({ unjudgedBy: { name: 'uiclip', version: '1.0.0' } });
89
+ * for (const entry of entries) judge(entry, await catalog.image(entry));
90
+ */
91
+ export declare function createCatalogClient(options: CatalogClientOptions): CatalogClient;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * @module The catalog client
3
+ *
4
+ * How a critic process reads the UI catalog from an Atlas server: list entries a page at a time with a stable cursor
5
+ * (`page`, `entries`), read only what changed since a report version it holds (`since`), follow the server's event stream and
6
+ * catch up after every announcement or reconnection without missing an entry or handling one twice (`follow`), and fetch an
7
+ * entry's picture by the hash it was stored under, checking the bytes against it (`image`). It needs only `fetch`: no
8
+ * browser, no model, no SDK. The token goes in `Authorization: Bearer`, never in a URL, and is never put in an error.
9
+ *
10
+ * A missed event costs nothing: every wake-up is followed by a `since` read, and a version the server does not remember is
11
+ * answered with the whole catalog, which the client de-duplicates by entry key and picture hash.
12
+ *
13
+ * @see docs/critics.md#reading-the-catalog
14
+ */
15
+ import { CatalogEntriesSchema } from '../../schema/src/api.js';
16
+ import { catalogEntriesParams } from '../../reports/src/ui-catalog-entries.js';
17
+ import { sha256Hex } from '../../reports/src/sigv4.js';
18
+ /** An Atlas server refused or could not answer, with what to do about it. Its message never holds a token. */
19
+ export class CatalogClientError extends Error {
20
+ status;
21
+ /** @param status the HTTP status, or null when the server could not be reached. */
22
+ constructor(message, status) {
23
+ super(message);
24
+ this.status = status;
25
+ this.name = 'CatalogClientError';
26
+ }
27
+ }
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 encode = (path) => path.split('/').map(encodeURIComponent).join('/');
34
+ /**
35
+ * A client for the catalog of an Atlas server. Nothing is read until a method is called.
36
+ *
37
+ * @example
38
+ * const catalog = createCatalogClient({ url: 'https://atlas.example.com', token: process.env.ATLAS_CRITIC_TOKEN });
39
+ * const { version, entries } = await catalog.read({ unjudgedBy: { name: 'uiclip', version: '1.0.0' } });
40
+ * for (const entry of entries) judge(entry, await catalog.image(entry));
41
+ */
42
+ export function createCatalogClient(options) {
43
+ const base = options.url.replace(/\/+$/, '');
44
+ const api = options.project ? `${base}/api/projects/${encodeURIComponent(options.project)}` : `${base}/api`;
45
+ const doFetch = options.fetch ?? fetch;
46
+ const sleep = options.sleep ?? defaultSleep;
47
+ const retries = options.retries ?? 3;
48
+ const headers = options.token ? { authorization: `Bearer ${options.token}` } : {};
49
+ function explain(status, said) {
50
+ const tail = said ? `: ${said.replace(/[.\s]+$/, '')}` : '';
51
+ if (status === 401)
52
+ return new CatalogClientError(`${base} wants a token${tail}. Set ATLAS_CRITIC_TOKEN (or ATLAS_TOKEN): a critic, publish or admin token.`, status);
53
+ if (status === 403)
54
+ return new CatalogClientError(`${base} knows this token but it may not do that${tail}.`, status);
55
+ if (status === 404)
56
+ return new CatalogClientError(`${base} has no such thing${tail}. An older server may not have the catalog list: upgrade it.`, status);
57
+ return new CatalogClientError(`${base} answered ${status}${tail}`, status);
58
+ }
59
+ async function request(url, init = {}) {
60
+ for (let attempt = 0;; attempt++) {
61
+ let failure;
62
+ try {
63
+ const response = await doFetch(url, { headers: { ...headers, ...(init.accept ? { accept: init.accept } : {}) }, ...(init.signal ? { signal: init.signal } : {}) });
64
+ if (response.ok)
65
+ return response;
66
+ const said = await response.json().then((body) => (body && typeof body === 'object' && 'error' in body && typeof body.error === 'string' ? body.error : null), () => null);
67
+ failure = explain(response.status, said);
68
+ if (response.status < 500 && response.status !== 429)
69
+ throw failure;
70
+ }
71
+ catch (error) {
72
+ if (init.signal?.aborted)
73
+ throw error;
74
+ if (error instanceof CatalogClientError && error.status !== null && error.status < 500 && error.status !== 429)
75
+ throw error;
76
+ failure = error instanceof CatalogClientError ? error : new CatalogClientError(`cannot reach ${base} (${error instanceof Error ? error.message : String(error)})`, null);
77
+ }
78
+ if (attempt >= retries)
79
+ throw failure;
80
+ await sleep(Math.min(8_000, 100 * 2 ** attempt) * (0.5 + Math.random() / 2), init.signal);
81
+ }
82
+ }
83
+ async function page(query = {}) {
84
+ const search = catalogEntriesParams(query).toString();
85
+ const response = await request(`${api}/reports/ui-catalog/entries${search ? `?${search}` : ''}`, { accept: 'application/json' });
86
+ const parsed = CatalogEntriesSchema.safeParse(await response.json().catch(() => null));
87
+ // A document this reader cannot read is unknown, never an empty catalog.
88
+ if (!parsed.success)
89
+ throw new CatalogClientError(`${base} answered something that is not a catalog list. Upgrade @crvouga/atlas, or the server.`, response.status);
90
+ const document = parsed.data;
91
+ return {
92
+ version: document.version, entries: document.entries.map(entry => ({ ...entry, marks: entry.marks ?? [] })), total: document.total, unjudgeable: document.unjudgeable, notices: document.sources.notices,
93
+ ...(document.nextCursor ? { nextCursor: document.nextCursor } : {}),
94
+ ...(document.since ? { since: document.since } : {})
95
+ };
96
+ }
97
+ async function read(query = {}) {
98
+ const entries = [];
99
+ let version, unjudgeable = 0, notices = [], sinceHonored = true;
100
+ for (let cursor;;) {
101
+ const found = await page({ ...query, ...(cursor === undefined ? {} : { cursor }) });
102
+ if (version === undefined) {
103
+ version = found.version;
104
+ sinceHonored = found.since?.honored ?? true;
105
+ }
106
+ unjudgeable = found.unjudgeable;
107
+ notices = found.notices;
108
+ entries.push(...found.entries);
109
+ if (!found.nextCursor)
110
+ break;
111
+ cursor = found.nextCursor;
112
+ }
113
+ return { version: version, entries, unjudgeable, notices, sinceHonored };
114
+ }
115
+ async function image(entry) {
116
+ const response = await request(`${api}/reports${encode(entry.image.path)}`);
117
+ const bytes = new Uint8Array(await response.arrayBuffer());
118
+ if ((await sha256Hex(bytes)) !== entry.image.sha256)
119
+ throw new CatalogClientError(`The picture ${entry.image.path} is not the bytes the catalog hashed (${entry.image.sha256.slice(0, 12)}…); nothing was judged.`, response.status);
120
+ return bytes;
121
+ }
122
+ async function* follow(followOptions = {}) {
123
+ const signal = followOptions.signal;
124
+ let held = followOptions.since;
125
+ let initial = true;
126
+ // What was handed over: an entry is new when its key is unseen or its picture changed.
127
+ const seen = new Map();
128
+ async function* catchUp() {
129
+ const found = await read({ ...(followOptions.query ?? {}), ...(held === undefined ? {} : { since: held }) });
130
+ const fresh = found.entries.filter(entry => seen.get(entry.key) !== entry.image.sha256);
131
+ for (const entry of fresh)
132
+ seen.set(entry.key, entry.image.sha256);
133
+ held = found.version;
134
+ if (fresh.length || initial) {
135
+ const batch = { version: found.version, entries: fresh, initial };
136
+ initial = false;
137
+ yield batch;
138
+ }
139
+ }
140
+ while (!signal?.aborted) {
141
+ let response;
142
+ try {
143
+ response = await request(`${api}/events`, { accept: 'text/event-stream', ...(signal ? { signal } : {}) });
144
+ }
145
+ catch (error) {
146
+ if (signal?.aborted)
147
+ return;
148
+ // A refusal will not pass by waiting; the server being away will.
149
+ if (error instanceof CatalogClientError && error.status !== null && error.status !== 503)
150
+ throw error;
151
+ }
152
+ if (response?.body) {
153
+ // Whatever happened while disconnected is found by this read, not by the events that were missed.
154
+ try {
155
+ yield* catchUp();
156
+ const reader = response.body.getReader();
157
+ const decoder = new TextDecoder();
158
+ let buffered = '';
159
+ const onAbort = () => { reader.cancel().catch(() => { }); };
160
+ signal?.addEventListener('abort', onAbort, { once: true });
161
+ try {
162
+ for (;;) {
163
+ const chunk = await reader.read();
164
+ if (chunk.done)
165
+ break;
166
+ buffered += decoder.decode(chunk.value, { stream: true });
167
+ let end;
168
+ while ((end = buffered.indexOf('\n\n')) >= 0) {
169
+ const frame = buffered.slice(0, end);
170
+ buffered = buffered.slice(end + 2);
171
+ const name = /^event: (.+)$/m.exec(frame)?.[1];
172
+ if (name === 'report')
173
+ yield* catchUp();
174
+ }
175
+ }
176
+ }
177
+ finally {
178
+ signal?.removeEventListener('abort', onAbort);
179
+ }
180
+ }
181
+ catch (error) {
182
+ if (signal?.aborted)
183
+ return;
184
+ if (error instanceof CatalogClientError && error.status !== null && error.status !== 503)
185
+ throw error;
186
+ }
187
+ }
188
+ if (signal?.aborted)
189
+ return;
190
+ await sleep(followOptions.reconnectMs ?? 2_000, signal);
191
+ }
192
+ }
193
+ return { url: base, page, read, image, follow };
194
+ }
@@ -0,0 +1,4 @@
1
+ import { type CriticIo } from './cli-common.js';
2
+ import { type LoadedConfig } from './registry.js';
3
+ /** `atlas critic calibrate <name>`. */
4
+ export declare function calibrateCommand(argv: readonly string[], io: CriticIo, loaded: LoadedConfig): Promise<number>;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @module `atlas critic calibrate`
3
+ *
4
+ * Measures a critic against labelled examples, per state kind, and proposes a threshold for each: the library function
5
+ * `calibrate` behind a command. The examples are pictures in directories (`--good`, `--bad`, and `--heldout-good`, `--heldout-bad` for
6
+ * pictures the threshold must not be chosen on; a subdirectory's name is the state kind of the pictures in it, else `--state`, else
7
+ * `unknown`), and/or the whole-critique marks reviewers made (`--from-marks`: accepted means the UI needs polish, dismissed means it
8
+ * does not). The critic judges the pictures here, with the same consent, timeouts and budgets as `run`. Nothing is published: the
9
+ * calibration is a document (`--out`) that `atlas critic run --calibration` reads. Figures measured on the examples the threshold was
10
+ * chosen on are labelled in-sample; only held-out figures can open `--mode gate`.
11
+ *
12
+ * @see docs/critics.md#state-aware-judging-calibration-and-modes
13
+ */
14
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { markStanding } from '../../reports/src/critique-review.js';
17
+ import { sha256Hex } from '../../reports/src/sigv4.js';
18
+ import { createCatalogClient } from './catalog-client.js';
19
+ import { connection, defaultAsk, list, numberFlag, positionals, print, redactIn, value, wantsJson } from './cli-common.js';
20
+ import { calibrate, gateDecision } from './judging.js';
21
+ import { buildCritic } from './registry.js';
22
+ import { runCritic } from './runner.js';
23
+ const IMAGE = /\.(png|jpe?g|webp|gif)$/i;
24
+ const GROUPS = ['good', 'bad', 'heldout-good', 'heldout-bad'];
25
+ async function filesOf(directory) {
26
+ const found = [];
27
+ const walk = async (current) => {
28
+ for (const entry of (await readdir(current, { withFileTypes: true })).sort((a, b) => (a.name < b.name ? -1 : 1))) {
29
+ const full = path.join(current, entry.name);
30
+ if (entry.isDirectory())
31
+ await walk(full);
32
+ else if (IMAGE.test(entry.name))
33
+ found.push(full);
34
+ }
35
+ };
36
+ await walk(directory);
37
+ return found;
38
+ }
39
+ /** `atlas critic calibrate <name>`. */
40
+ export async function calibrateCommand(argv, io, loaded) {
41
+ const env = io.env ?? process.env;
42
+ const json = wantsJson(argv);
43
+ const interactive = !argv.includes('--non-interactive') && !json && (io.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY));
44
+ const ask = io.ask ?? defaultAsk;
45
+ const dryRun = argv.includes('--dry-run');
46
+ const cwd = io.cwd ?? process.cwd();
47
+ const [name] = positionals(argv);
48
+ if (!name)
49
+ throw new Error(`calibrate needs the name of a critic: ${Object.keys(loaded.config.critics).join(', ') || 'none are configured in atlas.critics.json'}`);
50
+ const dirs = Object.fromEntries(GROUPS.map(group => [group, list(argv, group)]));
51
+ const fromMarks = argv.includes('--from-marks');
52
+ const haveDirs = GROUPS.some(group => dirs[group].length > 0);
53
+ if (!haveDirs && !fromMarks)
54
+ throw new Error('calibrate needs labelled examples: --good <dir> and --bad <dir> (and --heldout-good / --heldout-bad for pictures the threshold must not be chosen on), or --from-marks to use the marks reviewers made on a server');
55
+ const defaultState = value(argv, 'state') ?? 'unknown';
56
+ const server = fromMarks ? await connection(argv, loaded, env) : null;
57
+ if (fromMarks && !server)
58
+ throw new Error('--from-marks needs --server <url> (or "server.url" in atlas.critics.json, or ATLAS_SERVER_URL)');
59
+ const sources = [];
60
+ for (const group of GROUPS)
61
+ for (const directory of dirs[group]) {
62
+ const root = path.resolve(cwd, directory);
63
+ let files;
64
+ try {
65
+ files = await filesOf(root);
66
+ }
67
+ catch {
68
+ throw new Error(`--${group} ${directory} cannot be read as a directory of pictures`);
69
+ }
70
+ if (!files.length)
71
+ throw new Error(`--${group} ${directory} holds no pictures (png, jpg, webp or gif)`);
72
+ for (const file of files) {
73
+ const relative = path.relative(root, file).split(path.sep);
74
+ sources.push({ key: `${group}/${path.basename(directory)}/${relative.join('/')}`, group, stateKind: relative.length > 1 ? relative[0] : defaultState, file });
75
+ }
76
+ }
77
+ const skipped = [];
78
+ const examples = [];
79
+ let version = value(argv, 'critic-version') ?? null;
80
+ let model;
81
+ let parameters;
82
+ let sendsImagesTo = null;
83
+ let fileExamples = 0;
84
+ if (haveDirs) {
85
+ const built = await buildCritic(loaded, name, io);
86
+ const secrets = built.secrets;
87
+ const description = built.critic.describe();
88
+ version = built.critic.version;
89
+ model = description.model ? { ...description.model } : undefined;
90
+ parameters = description.parameters ? { ...description.parameters } : undefined;
91
+ sendsImagesTo = description.sendsImagesTo;
92
+ if (!dryRun && sendsImagesTo !== null && !argv.includes('--yes')) {
93
+ if (!interactive)
94
+ throw new Error(`${name} sends images to ${sendsImagesTo}: pass --yes to allow it (nothing was sent)`);
95
+ const answer = await ask(`${name} will send ${sources.length} image${sources.length === 1 ? '' : 's'} to ${sendsImagesTo}. Continue? [y/N] `);
96
+ if (!/^y(es)?$/i.test(answer.trim()))
97
+ throw new Error(`Stopped: ${name} would send images to ${sendsImagesTo} and that was not confirmed (nothing was sent)`);
98
+ }
99
+ if (!dryRun)
100
+ await built.ready();
101
+ const bytes = new Map();
102
+ const entries = [];
103
+ for (const source of sources) {
104
+ const data = new Uint8Array(await readFile(source.file));
105
+ bytes.set(source.key, data);
106
+ entries.push({ key: source.key, title: path.basename(source.file), state: source.stateKind, imageSha256: await sha256Hex(data) });
107
+ }
108
+ const maxImages = numberFlag(argv, 'max-images', true), concurrency = numberFlag(argv, 'concurrency', true), timeoutMs = numberFlag(argv, 'timeout-ms', true);
109
+ const result = await runCritic({
110
+ critic: built.critic, entries, force: true, dryRun, loadImage: entry => Promise.resolve(bytes.get(entry.key)), secrets,
111
+ ...(maxImages ? { budget: { maxImages } } : {}), ...(concurrency ? { concurrency } : {}), ...(timeoutMs ? { timeoutMs } : {})
112
+ });
113
+ if (dryRun) {
114
+ const document = { ok: true, dryRun, critic: { name, version }, sendsImagesTo, examples: { files: sources.length, marks: 0, used: 0, skipped }, calibration: null, written: null, gate: null, warnings: [] };
115
+ if (json)
116
+ print(io, document);
117
+ else
118
+ io.out(`Would judge ${sources.length} ${sources.length === 1 ? 'picture' : 'pictures'} with ${name}@${version}. ${sendsImagesTo ? `Images would go to ${sendsImagesTo}` : 'Images stay on this machine'}. Nothing was sent or written.\n`);
119
+ return 0;
120
+ }
121
+ for (const outcome of result.outcomes) {
122
+ const source = sources.find(candidate => candidate.key === outcome.entry.key);
123
+ const score = outcome.critique.verdict?.score;
124
+ if (outcome.critique.failure || score === undefined) {
125
+ skipped.push({ source: source.key, reason: outcome.critique.failure ? `the critic could not judge it (${outcome.critique.failure.reason})` : 'the critic gave no score' });
126
+ continue;
127
+ }
128
+ examples.push({ score, stateKind: source.stateKind, bad: source.group.endsWith('bad'), ...(source.group.startsWith('heldout') ? { heldOut: true } : {}) });
129
+ fileExamples += 1;
130
+ }
131
+ for (const source of sources)
132
+ if (!result.outcomes.some(outcome => outcome.entry.key === source.key))
133
+ skipped.push({ source: source.key, reason: result.stoppedBy === 'budget' ? 'the budget was reached before it was judged' : 'it was not judged' });
134
+ }
135
+ // The marks: a whole-critique accept says the UI needs polish, a dismissal says it does not; findings are not examples.
136
+ let markExamples = 0;
137
+ if (fromMarks && server) {
138
+ const client = createCatalogClient({ url: server.url, ...(server.token ? { token: server.token } : {}), ...(server.project ? { project: server.project } : {}), ...(io.fetch ? { fetch: io.fetch } : {}), ...(io.sleep ? { sleep: io.sleep } : {}) });
139
+ const read = await client.read({});
140
+ for (const entry of read.entries) {
141
+ const found = entry.critiques.filter(item => item.currency === 'current' && item.critique.critic.name === name && !item.critique.failure && item.critique.verdict?.score !== undefined);
142
+ const standing = markStanding(entry.marks, { critic: name });
143
+ if (standing.state === 'unmarked' && !standing.earlier)
144
+ continue;
145
+ if (!found.length) {
146
+ skipped.push({ source: entry.key, reason: `${name} has no current score for it` });
147
+ continue;
148
+ }
149
+ if (version === null)
150
+ version = found[0].critique.critic.version;
151
+ const critique = found.find(item => item.critique.critic.version === version);
152
+ if (!critique) {
153
+ skipped.push({ source: entry.key, reason: `${name} judged it as version ${found[0].critique.critic.version}, not ${version}` });
154
+ continue;
155
+ }
156
+ if (standing.state === 'unmarked') {
157
+ skipped.push({ source: entry.key, reason: 'its marks were made about an earlier picture' });
158
+ continue;
159
+ }
160
+ if (standing.state === 'disputed') {
161
+ skipped.push({ source: entry.key, reason: 'reviewers disagree' });
162
+ continue;
163
+ }
164
+ examples.push({ score: critique.critique.verdict.score, stateKind: entry.state, bad: standing.state === 'accepted' });
165
+ markExamples += 1;
166
+ }
167
+ if (version === null)
168
+ version = read.entries.flatMap(entry => entry.critiques).find(item => item.critique.critic.name === name)?.critique.critic.version ?? null;
169
+ }
170
+ if (version === null)
171
+ throw new Error(`${name} has no critique on the server to calibrate (give --critic-version, or run it first)`);
172
+ const measured = calibrate({ name, version }, examples);
173
+ const calibration = {
174
+ ...measured,
175
+ critic: { ...measured.critic, ...(model ? { model } : {}), ...(parameters ? { parameters } : {}) },
176
+ createdAtMs: (io.clock ?? Date.now)()
177
+ };
178
+ const refusal = gateDecision({ mode: 'gate', critic: { name, version }, scores: calibration.kinds.map(kind => ({ entryKey: kind.stateKind, stateKind: kind.stateKind, score: null })), calibration }).refusal;
179
+ const warnings = [];
180
+ if (!examples.length)
181
+ warnings.push('No example had a score, so there is nothing to measure');
182
+ let written = null;
183
+ const out = value(argv, 'out');
184
+ if (out) {
185
+ const target = path.resolve(cwd, out);
186
+ await mkdir(path.dirname(target), { recursive: true });
187
+ await writeFile(target, `${JSON.stringify(calibration, null, 2)}\n`);
188
+ written = path.relative(cwd, target) || target;
189
+ }
190
+ const document = {
191
+ ok: true, dryRun, critic: { name, version }, sendsImagesTo,
192
+ examples: { files: fileExamples, marks: markExamples, used: examples.length, skipped },
193
+ calibration, written, gate: { eligible: refusal.length === 0 && examples.length > 0, reasons: refusal },
194
+ warnings: warnings.map(warning => redactIn(warning, []))
195
+ };
196
+ if (json) {
197
+ print(io, document);
198
+ return 0;
199
+ }
200
+ io.out(`Calibration of ${name}@${version}: ${examples.length} ${examples.length === 1 ? 'example' : 'examples'} (${fileExamples} from pictures, ${markExamples} from marks)${skipped.length ? `, ${skipped.length} left out` : ''}\n`);
201
+ for (const kind of calibration.kinds) {
202
+ io.out(` ${kind.stateKind.padEnd(12)} ${kind.samples} samples (${kind.positives} bad, ${kind.negatives} good) threshold ${kind.threshold ?? 'none'} precision ${kind.precision ?? 'n/a'} recall ${kind.recall ?? 'n/a'} ${kind.inSample ? 'IN-SAMPLE: measured on the examples the threshold was chosen on' : 'held-out'}\n`);
203
+ }
204
+ for (const item of skipped.slice(0, 10))
205
+ io.out(` left out ${item.source}: ${item.reason}\n`);
206
+ io.out(document.gate.eligible ? 'Gate mode would accept this calibration.\n' : `Gate mode would refuse this calibration:\n${refusal.map(line => ` - ${line}`).join('\n')}\n`);
207
+ io.out(written ? `Written to ${written}. Use it with \`atlas critic run --mode gate --calibration ${written}\`.\n` : 'Not written: pass --out <file> to keep it.\n');
208
+ for (const warning of document.warnings)
209
+ io.out(`! ${warning}\n`);
210
+ return 0;
211
+ }