@crvouga/atlas 1.20.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 (114) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/dist/core/src/cli.d.ts +2 -2
  3. package/dist/core/src/cli.js +7 -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/compose.d.ts +23 -1
  30. package/dist/reports/src/compose.js +29 -19
  31. package/dist/reports/src/critique-review.d.ts +111 -0
  32. package/dist/reports/src/critique-review.js +123 -0
  33. package/dist/reports/src/critique-row.d.ts +24 -0
  34. package/dist/reports/src/critique-row.js +39 -0
  35. package/dist/reports/src/evidence-types.d.ts +5 -0
  36. package/dist/reports/src/layer-merge.js +5 -0
  37. package/dist/reports/src/report-cli.d.ts +13 -0
  38. package/dist/reports/src/report-cli.js +6 -4
  39. package/dist/reports/src/report-index.d.ts +6 -0
  40. package/dist/reports/src/report-index.js +12 -0
  41. package/dist/reports/src/ui-catalog-cli.d.ts +10 -0
  42. package/dist/reports/src/ui-catalog-cli.js +391 -0
  43. package/dist/reports/src/ui-catalog-diff.d.ts +104 -0
  44. package/dist/reports/src/ui-catalog-diff.js +130 -0
  45. package/dist/reports/src/ui-catalog-entries.d.ts +147 -0
  46. package/dist/reports/src/ui-catalog-entries.js +190 -0
  47. package/dist/reports/src/ui-catalog-export.d.ts +82 -0
  48. package/dist/reports/src/ui-catalog-export.js +221 -0
  49. package/dist/reports/src/ui-catalog-publish.js +2 -2
  50. package/dist/reports/src/ui-catalog.d.ts +2 -0
  51. package/dist/reports/src/ui-catalog.js +1 -1
  52. package/dist/reports/src/ui-state.d.ts +2 -0
  53. package/dist/reports/src/ui-state.js +2 -2
  54. package/dist/schema/src/api.d.ts +653 -1
  55. package/dist/schema/src/api.js +44 -0
  56. package/dist/schema/src/cli-results.d.ts +991 -1
  57. package/dist/schema/src/cli-results.js +265 -1
  58. package/dist/schema/src/critique.d.ts +31 -0
  59. package/dist/schema/src/critique.js +26 -0
  60. package/dist/schema/src/server-config.d.ts +3 -3
  61. package/dist/server/src/app.js +28 -1
  62. package/dist/server/src/openapi.js +19 -2
  63. package/docs/agents.md +16 -2
  64. package/docs/critics.md +273 -9
  65. package/docs/extending.md +1 -1
  66. package/docs/reference/cli.md +126 -1
  67. package/docs/reference/core.md +1 -1
  68. package/docs/reference/critics.md +1131 -29
  69. package/docs/reference/http-api.md +40 -0
  70. package/docs/reference/reports.md +855 -0
  71. package/docs/reference/schema.md +197 -1
  72. package/docs/report-contract.md +2 -1
  73. package/docs/server.md +2 -0
  74. package/docs/ui-catalog.md +110 -13
  75. package/docs/ui.md +69 -2
  76. package/llms-full.txt +636 -26
  77. package/llms.txt +1 -1
  78. package/package.json +9 -1
  79. package/packages/critics/README.md +1 -0
  80. package/packages/schema/schemas/critic-calibrate-result.schema.json +274 -0
  81. package/packages/schema/schemas/critic-doctor-result.schema.json +74 -0
  82. package/packages/schema/schemas/critic-list-result.schema.json +90 -0
  83. package/packages/schema/schemas/critic-review-result.schema.json +397 -0
  84. package/packages/schema/schemas/critic-run-result.schema.json +249 -0
  85. package/packages/schema/schemas/critic-setup-result.schema.json +34 -0
  86. package/packages/schema/schemas/critic-watch-result.schema.json +211 -0
  87. package/packages/schema/schemas/critique-mark.schema.json +81 -0
  88. package/packages/schema/schemas/effective-delta.schema.json +337 -0
  89. package/packages/schema/schemas/effective-evidence.schema.json +337 -0
  90. package/packages/schema/schemas/layer-rows.schema.json +337 -0
  91. package/packages/schema/schemas/ui-catalog-diff-result.schema.json +201 -0
  92. package/packages/schema/schemas/ui-catalog-export-result.schema.json +83 -0
  93. package/packages/schema/schemas/ui-catalog-publish-result.schema.json +116 -0
  94. package/packages/schema/schemas/ui-catalog-status.schema.json +197 -0
  95. package/packages/server/openapi.json +6755 -4738
  96. package/packages/visualizer/src/app/AppShell.tsx +3 -1
  97. package/packages/visualizer/src/app/router.tsx +20 -7
  98. package/packages/visualizer/src/catalog/Picture.tsx +32 -0
  99. package/packages/visualizer/src/catalog/UiCatalogCompare.tsx +289 -0
  100. package/packages/visualizer/src/catalog/UiCatalogPage.tsx +18 -28
  101. package/packages/visualizer/src/catalog/history.ts +61 -0
  102. package/packages/visualizer/src/catalog/search.ts +14 -3
  103. package/packages/visualizer/src/components/Header.tsx +16 -4
  104. package/packages/visualizer/src/critiques/CritiquesPage.tsx +402 -0
  105. package/packages/visualizer/src/critiques/FindingOverlay.tsx +50 -0
  106. package/packages/visualizer/src/critiques/ranking.ts +54 -0
  107. package/packages/visualizer/src/critiques/search.ts +41 -0
  108. package/packages/visualizer/src/evidence/applicability-view.tsx +11 -7
  109. package/packages/visualizer/src/evidence/behaviors-page.tsx +22 -15
  110. package/packages/visualizer/src/evidence/evidence-context.tsx +2 -1
  111. package/packages/visualizer/src/explorer/Explorer.tsx +5 -0
  112. package/packages/visualizer/src/explorer/TestDetail.tsx +4 -1
  113. package/packages/visualizer/src/explorer/parts.tsx +19 -1
  114. package/packages/visualizer/src/explorer/playlist.tsx +11 -7
@@ -0,0 +1,125 @@
1
+ import { z } from 'zod/v4';
2
+ import { type Critic } from './contract.js';
3
+ /** The configuration of one critic. `type` chooses what it is. */
4
+ export declare const CriticConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
5
+ type: z.ZodLiteral<"uiclip">;
6
+ checkpoint: z.ZodObject<{
7
+ path: z.ZodString;
8
+ sha256: z.ZodString;
9
+ url: z.ZodOptional<z.ZodString>;
10
+ }, z.core.$strip>;
11
+ }, z.core.$loose>, z.ZodObject<{
12
+ type: z.ZodLiteral<"llm">;
13
+ provider: z.ZodDefault<z.ZodEnum<{
14
+ "openai-compatible": "openai-compatible";
15
+ anthropic: "anthropic";
16
+ gemini: "gemini";
17
+ }>>;
18
+ baseUrl: z.ZodOptional<z.ZodString>;
19
+ apiKey: z.ZodOptional<z.ZodString>;
20
+ model: z.ZodString;
21
+ rubric: z.ZodOptional<z.ZodString>;
22
+ rubricFile: z.ZodOptional<z.ZodString>;
23
+ temperature: z.ZodOptional<z.ZodNumber>;
24
+ maxTokens: z.ZodOptional<z.ZodNumber>;
25
+ version: z.ZodOptional<z.ZodString>;
26
+ }, z.core.$loose>, z.ZodObject<{
27
+ type: z.ZodLiteral<"module">;
28
+ module: z.ZodString;
29
+ export: z.ZodOptional<z.ZodString>;
30
+ }, z.core.$loose>], "type">;
31
+ /** The configuration file: which server, and the critics by name. */
32
+ export declare const CriticsConfigSchema: z.ZodObject<{
33
+ server: z.ZodOptional<z.ZodObject<{
34
+ url: z.ZodOptional<z.ZodString>;
35
+ project: z.ZodOptional<z.ZodString>;
36
+ token: z.ZodOptional<z.ZodString>;
37
+ }, z.core.$loose>>;
38
+ spool: z.ZodOptional<z.ZodString>;
39
+ critics: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodDiscriminatedUnion<[z.ZodObject<{
40
+ type: z.ZodLiteral<"uiclip">;
41
+ checkpoint: z.ZodObject<{
42
+ path: z.ZodString;
43
+ sha256: z.ZodString;
44
+ url: z.ZodOptional<z.ZodString>;
45
+ }, z.core.$strip>;
46
+ }, z.core.$loose>, z.ZodObject<{
47
+ type: z.ZodLiteral<"llm">;
48
+ provider: z.ZodDefault<z.ZodEnum<{
49
+ "openai-compatible": "openai-compatible";
50
+ anthropic: "anthropic";
51
+ gemini: "gemini";
52
+ }>>;
53
+ baseUrl: z.ZodOptional<z.ZodString>;
54
+ apiKey: z.ZodOptional<z.ZodString>;
55
+ model: z.ZodString;
56
+ rubric: z.ZodOptional<z.ZodString>;
57
+ rubricFile: z.ZodOptional<z.ZodString>;
58
+ temperature: z.ZodOptional<z.ZodNumber>;
59
+ maxTokens: z.ZodOptional<z.ZodNumber>;
60
+ version: z.ZodOptional<z.ZodString>;
61
+ }, z.core.$loose>, z.ZodObject<{
62
+ type: z.ZodLiteral<"module">;
63
+ module: z.ZodString;
64
+ export: z.ZodOptional<z.ZodString>;
65
+ }, z.core.$loose>], "type">>>;
66
+ }, z.core.$loose>;
67
+ /** The configuration file as read. */
68
+ export type CriticsConfig = z.output<typeof CriticsConfigSchema>;
69
+ /** The configuration of one critic. */
70
+ export type CriticConfig = z.output<typeof CriticConfigSchema>;
71
+ /** A configuration and where it came from; `file` is null when there is none, and every critic is then absent. */
72
+ export type LoadedConfig = {
73
+ readonly config: CriticsConfig;
74
+ readonly file: string | null;
75
+ readonly directory: string;
76
+ };
77
+ /** The name of the configuration file a project commits. */
78
+ export declare const CONFIG_FILE = "atlas.critics.json";
79
+ /**
80
+ * Reads the critics configuration. With no `file`, `atlas.critics.json` in `cwd`, which may be absent (no critics are then
81
+ * configured). A file that was named and is missing, is not JSON, contains a literal credential, or has the wrong shape is an error that
82
+ * says where and never repeats a credential.
83
+ */
84
+ export declare function loadCriticsConfig(cwd: string, file?: string): Promise<LoadedConfig>;
85
+ /** What a critic's `inspect` found about one aspect: fine, a warning, or a problem with how to fix it. */
86
+ export type Inspection = {
87
+ /** For example `model-file`, `credential`, `provider`, `module`. */
88
+ readonly aspect: string;
89
+ readonly status: 'ok' | 'warning' | 'problem';
90
+ readonly detail: string;
91
+ readonly fix?: string;
92
+ };
93
+ /** What `setup` did for a critic. */
94
+ export type SetupReport = {
95
+ readonly ok: true;
96
+ readonly details: readonly string[];
97
+ };
98
+ /** A critic built from configuration, with what a command needs around it. */
99
+ export type BuiltCritic = {
100
+ readonly name: string;
101
+ readonly type: CriticConfig['type'];
102
+ readonly critic: Critic;
103
+ /** The credentials this critic holds, for redaction. */
104
+ readonly secrets: readonly string[];
105
+ /** Finds what is wrong before a run, one finding per aspect. Never calls the model. */
106
+ inspect(): Promise<Inspection[]>;
107
+ /** Provisions what the critic needs (a verified checkpoint) or checks its credentials. */
108
+ setup(): Promise<SetupReport>;
109
+ /** Opens what a run needs (a model session) so a problem is found before any image is judged. */
110
+ ready(): Promise<void>;
111
+ };
112
+ /** What building a critic may use besides the file system: the environment, `fetch` and module loading. Tests pass fakes. */
113
+ export type RegistryIo = {
114
+ readonly env?: Readonly<Record<string, string | undefined>>;
115
+ readonly fetch?: typeof fetch;
116
+ readonly importModule?: (name: string) => Promise<unknown>;
117
+ };
118
+ /**
119
+ * Builds the critic `name` of a configuration. Throws, naming the problem, for a name that is not configured, a credential
120
+ * reference that cannot be resolved, or a module that cannot be loaded.
121
+ *
122
+ * @param loaded the configuration and its directory.
123
+ * @param name the critic's name in the configuration.
124
+ */
125
+ export declare function buildCritic(loaded: LoadedConfig, name: string, io?: RegistryIo): Promise<BuiltCritic>;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * @module Configured critics
3
+ *
4
+ * Reads the critics a project configured (`atlas.critics.json`) and builds them: the built-in UIClip critic, the LLM
5
+ * critic over a provider, or a critic of the project's own (a module whose default export is a `defineCritic` result). The
6
+ * configuration names a credential by reference (`env:NAME`, `file:PATH`) and never contains one, so it can be committed;
7
+ * a configuration with a literal credential is refused before anything is built. Credentials are resolved when a critic is
8
+ * built for a run and never stored.
9
+ *
10
+ * Every built critic can say what is wrong with it (`inspect`) so `atlas critic doctor` reports each problem on its own
11
+ * with how to fix it, and how to provision itself (`setup`).
12
+ *
13
+ * @see docs/critics.md#configuring-critics
14
+ */
15
+ import { readFile } from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import { pathToFileURL } from 'node:url';
18
+ import { z } from 'zod/v4';
19
+ import { defineCritic } from './contract.js';
20
+ import { assertNoLiteralCredentials, createRedactor, resolveCredential } from './credentials.js';
21
+ import { ANTHROPIC_API_VERSION, anthropicProvider, createLlmCritic, geminiProvider, openAiCompatibleProvider } from './llm.js';
22
+ import { createUiclipCritic, MissingDependencyError, setupUiclip, verifyCheckpoint } from './uiclip.js';
23
+ const CheckpointConfig = z.object({
24
+ path: z.string().min(1).describe('Where the checkpoint file lives, relative to the configuration file'),
25
+ sha256: z.string().regex(/^[0-9a-f]{64}$/, 'the SHA-256 the checkpoint must have: 64 lowercase hex digits').describe('The pinned SHA-256 of the checkpoint'),
26
+ url: z.string().url().optional().describe('Where `atlas critic setup` may download it from (https). Inference never downloads')
27
+ });
28
+ /** The configuration of one critic. `type` chooses what it is. */
29
+ export const CriticConfigSchema = z.discriminatedUnion('type', [
30
+ z.looseObject({ type: z.literal('uiclip'), checkpoint: CheckpointConfig }),
31
+ z.looseObject({
32
+ type: z.literal('llm'),
33
+ provider: z.enum(['openai-compatible', 'anthropic', 'gemini']).default('openai-compatible').describe('`openai-compatible`: any server that speaks chat completions (needs baseUrl). `anthropic`: the Messages API. `gemini`: the Gemini API. All over plain fetch, with no SDK'),
34
+ baseUrl: z.string().url().optional().describe('For example https://llm.example.com/v1; required for `openai-compatible`. `anthropic` and `gemini` default to their own hosts. Images are sent to this host'),
35
+ apiKey: z.string().optional().describe('A reference to the key: `env:NAME` or `file:PATH`. Never the key. Required for `anthropic` and `gemini`'),
36
+ model: z.string().min(1),
37
+ rubric: z.string().optional().describe('What to look for, as text; recorded as a hash'),
38
+ rubricFile: z.string().optional().describe('Or a file holding the rubric, relative to the configuration file'),
39
+ temperature: z.number().min(0).max(2).optional(),
40
+ maxTokens: z.number().int().positive().optional(),
41
+ version: z.string().min(1).optional().describe('Change it when the rubric or prompt changes meaning, so critiques are made again')
42
+ }),
43
+ z.looseObject({ type: z.literal('module'), module: z.string().min(1).describe('A TypeScript or JavaScript file, relative to the configuration file, whose default export is a critic (`defineCritic`)'), export: z.string().optional().describe('The export to use instead of the default') })
44
+ ]);
45
+ /** The configuration file: which server, and the critics by name. */
46
+ export const CriticsConfigSchema = z.looseObject({
47
+ server: z.looseObject({
48
+ url: z.string().optional().describe('The Atlas server'),
49
+ project: z.string().optional(),
50
+ token: z.string().optional().describe('A reference to the token: `env:NAME` or `file:PATH`. Never the token')
51
+ }).optional(),
52
+ spool: z.string().optional().describe('Where critiques that could not be sent are kept, relative to the configuration file. Default .atlas/critic-spool'),
53
+ critics: z.record(z.string().regex(/^[a-z0-9][a-z0-9.-]*$/, 'a critic name is lowercase letters, digits, "-" and "."'), CriticConfigSchema).default({})
54
+ });
55
+ /** The name of the configuration file a project commits. */
56
+ export const CONFIG_FILE = 'atlas.critics.json';
57
+ /**
58
+ * Reads the critics configuration. With no `file`, `atlas.critics.json` in `cwd`, which may be absent (no critics are then
59
+ * configured). A file that was named and is missing, is not JSON, contains a literal credential, or has the wrong shape is an error that
60
+ * says where and never repeats a credential.
61
+ */
62
+ export async function loadCriticsConfig(cwd, file) {
63
+ const target = path.resolve(cwd, file ?? CONFIG_FILE);
64
+ let text;
65
+ try {
66
+ text = await readFile(target, 'utf8');
67
+ }
68
+ catch {
69
+ if (file)
70
+ throw new Error(`The critics configuration ${file} cannot be read`);
71
+ return { config: CriticsConfigSchema.parse({}), file: null, directory: cwd };
72
+ }
73
+ let raw;
74
+ try {
75
+ raw = JSON.parse(text);
76
+ }
77
+ catch {
78
+ throw new Error(`${path.relative(cwd, target) || target} is not JSON`);
79
+ }
80
+ assertNoLiteralCredentials(raw);
81
+ const parsed = CriticsConfigSchema.safeParse(raw);
82
+ if (!parsed.success)
83
+ throw new Error(`${path.relative(cwd, target) || target} is not a critics configuration: ${parsed.error.issues.slice(0, 3).map(issue => `${issue.path.join('.') || '(root)'}: ${issue.message}`).join('; ')}`);
84
+ return { config: parsed.data, file: target, directory: path.dirname(target) };
85
+ }
86
+ /**
87
+ * Builds the critic `name` of a configuration. Throws, naming the problem, for a name that is not configured, a credential
88
+ * reference that cannot be resolved, or a module that cannot be loaded.
89
+ *
90
+ * @param loaded the configuration and its directory.
91
+ * @param name the critic's name in the configuration.
92
+ */
93
+ export async function buildCritic(loaded, name, io = {}) {
94
+ const entry = loaded.config.critics[name];
95
+ if (!entry)
96
+ throw new Error(`No critic named "${name}" is configured${loaded.file ? ` in ${path.basename(loaded.file)}` : ` (there is no ${CONFIG_FILE})`}. Configured: ${Object.keys(loaded.config.critics).join(', ') || 'none'}`);
97
+ const resolve = (relative) => path.resolve(loaded.directory, relative);
98
+ const env = io.env ?? process.env;
99
+ if (entry.type === 'uiclip') {
100
+ const checkpoint = { path: resolve(entry.checkpoint.path), sha256: entry.checkpoint.sha256, ...(entry.checkpoint.url ? { url: entry.checkpoint.url } : {}) };
101
+ const critic = createUiclipCritic({ checkpoint, name, ...(io.importModule ? { modules: { importModule: io.importModule } } : {}) });
102
+ const load = io.importModule ?? ((module) => import(module));
103
+ return {
104
+ name, type: 'uiclip', critic, secrets: [],
105
+ async inspect() {
106
+ const found = [];
107
+ try {
108
+ await verifyCheckpoint(checkpoint);
109
+ found.push({ aspect: 'model-file', status: 'ok', detail: `The checkpoint ${entry.checkpoint.path} matches its pinned checksum` });
110
+ }
111
+ catch (error) {
112
+ found.push({ aspect: 'model-file', status: 'problem', detail: error instanceof Error ? error.message : String(error), fix: `Run \`atlas critic setup ${name}\`, or put the checkpoint at ${entry.checkpoint.path}` });
113
+ }
114
+ for (const dependency of ['onnxruntime-node', 'sharp']) {
115
+ try {
116
+ await load(dependency);
117
+ found.push({ aspect: dependency, status: 'ok', detail: `${dependency} is installed` });
118
+ }
119
+ catch {
120
+ found.push({ aspect: dependency, status: 'problem', detail: new MissingDependencyError(dependency, 'The UIClip critic').message, fix: `bun add ${dependency}` });
121
+ }
122
+ }
123
+ return found;
124
+ },
125
+ async setup() {
126
+ const result = await setupUiclip(checkpoint, io.fetch ? { fetch: io.fetch } : {});
127
+ return { ok: true, details: [`${result.downloaded ? 'Downloaded' : 'Found'} the checkpoint at ${entry.checkpoint.path}`, `Verified its SHA-256 ${result.sha256}`, 'Inference never downloads anything'] };
128
+ },
129
+ ready: () => critic.ready()
130
+ };
131
+ }
132
+ if (entry.type === 'llm') {
133
+ const key = entry.apiKey ? await resolveCredential(entry.apiKey, { env }) : undefined;
134
+ const secrets = key ? [key] : [];
135
+ const redact = createRedactor(secrets);
136
+ const rubric = entry.rubricFile ? await readFile(resolve(entry.rubricFile), 'utf8').catch(() => { throw new Error(`The rubric file ${entry.rubricFile} cannot be read`); }) : entry.rubric;
137
+ const transport = io.fetch ? { fetch: io.fetch } : {};
138
+ let provider;
139
+ if (entry.provider === 'anthropic' || entry.provider === 'gemini') {
140
+ if (!key)
141
+ throw new Error(`The critic "${name}" uses the ${entry.provider} provider, which needs an apiKey: set it to a reference such as env:SOME_API_KEY`);
142
+ provider = (entry.provider === 'anthropic' ? anthropicProvider : geminiProvider)({ apiKey: key, ...(entry.baseUrl ? { baseUrl: entry.baseUrl } : {}), ...transport });
143
+ }
144
+ else {
145
+ if (!entry.baseUrl)
146
+ throw new Error(`The critic "${name}" uses the openai-compatible provider, which needs a baseUrl such as https://llm.example.com/v1`);
147
+ provider = openAiCompatibleProvider({ baseUrl: entry.baseUrl, ...(key ? { apiKey: key } : {}), ...transport });
148
+ }
149
+ const critic = createLlmCritic({ provider, model: entry.model, name, ...(rubric ? { rubric } : {}), ...(entry.temperature === undefined ? {} : { temperature: entry.temperature }), ...(entry.maxTokens === undefined ? {} : { maxTokens: entry.maxTokens }), ...(entry.version ? { version: entry.version } : {}) });
150
+ const doFetch = io.fetch ?? fetch;
151
+ return {
152
+ name, type: 'llm', critic, secrets,
153
+ async inspect() {
154
+ const found = [];
155
+ found.push(entry.apiKey ? { aspect: 'credential', status: 'ok', detail: `${entry.apiKey} is set` } : { aspect: 'credential', status: 'warning', detail: 'No apiKey is configured: only a server that needs none will answer', fix: 'Set apiKey to a reference such as env:SOME_API_KEY' });
156
+ // A cheap authenticated listing per provider: it checks the host answers and the credential is accepted, and never sends an image.
157
+ const host = `https://${provider.sendsImagesTo}`;
158
+ const probe = entry.provider === 'anthropic' ? { url: `${entry.baseUrl?.replace(/\/+$/, '') ?? host}/v1/models`, headers: { 'x-api-key': key, 'anthropic-version': ANTHROPIC_API_VERSION } }
159
+ : entry.provider === 'gemini' ? { url: `${entry.baseUrl?.replace(/\/+$/, '') ?? host}/v1beta/models`, headers: { 'x-goog-api-key': key } }
160
+ : { url: `${entry.baseUrl.replace(/\/+$/, '')}/models`, headers: key ? { authorization: `Bearer ${key}` } : {} };
161
+ try {
162
+ const response = await doFetch(probe.url, { headers: probe.headers, signal: AbortSignal.timeout(5_000) });
163
+ await response.body?.cancel();
164
+ if (response.status === 401 || response.status === 403)
165
+ found.push({ aspect: 'provider', status: 'problem', detail: `${provider.sendsImagesTo} rejected the credential (${response.status})`, fix: `Check the key that ${entry.apiKey ?? 'apiKey'} names` });
166
+ else if (response.status >= 500)
167
+ found.push({ aspect: 'provider', status: 'problem', detail: `${provider.sendsImagesTo} answered ${response.status}`, fix: 'Try again later, or check the provider status' });
168
+ else
169
+ found.push({ aspect: 'provider', status: 'ok', detail: `${provider.sendsImagesTo} is reachable` });
170
+ }
171
+ catch (error) {
172
+ found.push({ aspect: 'provider', status: 'problem', detail: redact(`${provider.sendsImagesTo} is not reachable (${error instanceof Error ? error.name : 'error'})`), fix: `Check ${entry.baseUrl ? `baseUrl (${entry.baseUrl})` : 'the network'}` });
173
+ }
174
+ return found;
175
+ },
176
+ async setup() {
177
+ return { ok: true, details: [entry.apiKey ? `The credential ${entry.apiKey} is set` : 'No credential is configured', `Images would be sent to ${provider.sendsImagesTo}`] };
178
+ },
179
+ async ready() { }
180
+ };
181
+ }
182
+ const file = resolve(entry.module);
183
+ const load = io.importModule ? () => io.importModule(pathToFileURL(file).href) : () => import(pathToFileURL(file).href);
184
+ let module;
185
+ try {
186
+ module = await load();
187
+ }
188
+ catch (error) {
189
+ throw new Error(`The module ${entry.module} of critic "${name}" cannot be loaded: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`);
190
+ }
191
+ const exported = module[entry.export ?? 'default'];
192
+ if (!exported || typeof exported.evaluate !== 'function' || typeof exported.describe !== 'function')
193
+ throw new Error(`The module ${entry.module} has no critic as its ${entry.export ?? 'default'} export: use defineCritic`);
194
+ if (exported.name !== name)
195
+ throw new Error(`The module ${entry.module} exports a critic named "${exported.name}" but it is configured as "${name}": the names must agree, because the name is the producer of its layers`);
196
+ const critic = defineCritic(exported);
197
+ return {
198
+ name, type: 'module', critic, secrets: [],
199
+ async inspect() { return [{ aspect: 'module', status: 'ok', detail: `${entry.module} exports the critic "${critic.name}"` }]; },
200
+ async setup() { await critic.setup?.(); return { ok: true, details: [critic.setup ? `Ran the setup of ${critic.name}` : `${critic.name} needs no setup`] }; },
201
+ async ready() { }
202
+ };
203
+ }
@@ -0,0 +1,32 @@
1
+ import type { ObjectStore } from '../../reports/src/object-store.js';
2
+ /** A store that spools what it cannot send. */
3
+ export type SpoolingStore = ObjectStore & {
4
+ /** How many objects this store has written to the spool since it was made. */
5
+ readonly spooled: () => number;
6
+ };
7
+ /** How many objects wait in a spool directory. */
8
+ export declare function pendingInSpool(dir: string): Promise<number>;
9
+ /**
10
+ * Wraps `inner` so that a `put` that cannot reach the server is written to `dir` instead of throwing. After the first such
11
+ * failure the rest of the run goes straight to the spool (it does not wait through every retry again), and `head` answers
12
+ * "not there". Reads are not spooled. A refusal from the server still throws.
13
+ *
14
+ * @param inner the store a run publishes to.
15
+ * @param dir the spool directory; created when first needed.
16
+ */
17
+ export declare function spoolingStore(inner: ObjectStore, dir: string): SpoolingStore;
18
+ /** What {@link flushSpool} did. */
19
+ export type SpoolFlush = {
20
+ /** Objects sent and removed from the spool. */
21
+ readonly sent: number;
22
+ /** Objects still waiting (the server is still away, or refused one). */
23
+ readonly remaining: number;
24
+ /** Why sending stopped, with no credential in it; absent when everything was sent. */
25
+ readonly error?: string;
26
+ };
27
+ /**
28
+ * Sends what an earlier run could not, in the order it was written, and removes each object once the server has it. Stops at the
29
+ * first failure and leaves the rest, so the order a layer needs (its files, then the layer, then its catalog entry) is kept. A
30
+ * spool that is empty or missing is not an error.
31
+ */
32
+ export declare function flushSpool(inner: ObjectStore, dir: string): Promise<SpoolFlush>;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @module Spooling unsent critiques
3
+ *
4
+ * A critic that has finished judging must not lose its work because the server went away. `spoolingStore` wraps the store
5
+ * a run publishes to: when the server cannot be reached (a network error, a 5xx, a rate limit that did not clear) the
6
+ * objects of the layer are written to a local directory instead of failing, and the next invocation sends them with
7
+ * `flushSpool`, byte for byte what was written, in the order they were written. A layer is content-addressed and the store
8
+ * protocol is idempotent, so sending one that did arrive is harmless and nothing is duplicated. A refusal (401, 403, 409,
9
+ * 413, 422) is not spooled: it will not pass by waiting, and the caller must hear it.
10
+ *
11
+ * What is spooled is only what would have been published (a layer, its files and its catalog entry): never a credential.
12
+ *
13
+ * @see docs/critics.md#when-the-server-goes-away
14
+ */
15
+ import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import { AtlasHttpError } from '../../reports/src/http-store.js';
18
+ import { sha256Hex } from '../../reports/src/sigv4.js';
19
+ /** Whether an error says the server is away or overloaded, as opposed to saying no. */
20
+ function isAbsence(error) {
21
+ return error instanceof AtlasHttpError ? error.retryable || error.status === null : true;
22
+ }
23
+ async function entries(dir) {
24
+ try {
25
+ return (await readdir(dir)).filter(name => name.endsWith('.json')).sort();
26
+ }
27
+ catch {
28
+ return [];
29
+ }
30
+ }
31
+ /** How many objects wait in a spool directory. */
32
+ export async function pendingInSpool(dir) {
33
+ return (await entries(dir)).length;
34
+ }
35
+ /**
36
+ * Wraps `inner` so that a `put` that cannot reach the server is written to `dir` instead of throwing. After the first such
37
+ * failure the rest of the run goes straight to the spool (it does not wait through every retry again), and `head` answers
38
+ * "not there". Reads are not spooled. A refusal from the server still throws.
39
+ *
40
+ * @param inner the store a run publishes to.
41
+ * @param dir the spool directory; created when first needed.
42
+ */
43
+ export function spoolingStore(inner, dir) {
44
+ let away = false;
45
+ let count = 0;
46
+ let sequence;
47
+ const next = async () => {
48
+ sequence ??= entries(dir).then(names => (names.length ? Number.parseInt(names[names.length - 1].split('-')[0], 10) + 1 : 1));
49
+ const value = await sequence;
50
+ sequence = Promise.resolve(value + 1);
51
+ return value;
52
+ };
53
+ async function spool(key, bytes, contentType) {
54
+ await mkdir(dir, { recursive: true, mode: 0o700 });
55
+ const id = (await sha256Hex(key)).slice(0, 16);
56
+ // The same object spooled twice is one file: nothing is duplicated.
57
+ if ((await entries(dir)).some(name => name.endsWith(`-${id}.json`)))
58
+ return;
59
+ const name = `${String(await next()).padStart(8, '0')}-${id}.json`;
60
+ const body = { key, contentType, base64: Buffer.from(bytes).toString('base64') };
61
+ await writeFile(path.join(dir, `${name}.tmp`), JSON.stringify(body), { mode: 0o600 });
62
+ await rename(path.join(dir, `${name}.tmp`), path.join(dir, name));
63
+ count += 1;
64
+ }
65
+ const store = {
66
+ spooled: () => count,
67
+ get: key => inner.get(key),
68
+ async put(key, bytes, contentType) {
69
+ if (!away) {
70
+ try {
71
+ await inner.put(key, bytes, contentType);
72
+ return;
73
+ }
74
+ catch (error) {
75
+ if (!isAbsence(error))
76
+ throw error;
77
+ away = true;
78
+ }
79
+ }
80
+ await spool(key, bytes, contentType);
81
+ },
82
+ async head(key) {
83
+ if (away)
84
+ return false;
85
+ try {
86
+ return await inner.head(key);
87
+ }
88
+ catch (error) {
89
+ if (!isAbsence(error))
90
+ throw error;
91
+ away = true;
92
+ return false;
93
+ }
94
+ },
95
+ delete: key => inner.delete(key),
96
+ deleteMany: keys => inner.deleteMany(keys),
97
+ getUri: key => inner.getUri(key),
98
+ listKeys: prefix => inner.listKeys(prefix),
99
+ withPrefix: prefix => inner.withPrefix(prefix)
100
+ };
101
+ return store;
102
+ }
103
+ /**
104
+ * Sends what an earlier run could not, in the order it was written, and removes each object once the server has it. Stops at the
105
+ * first failure and leaves the rest, so the order a layer needs (its files, then the layer, then its catalog entry) is kept. A
106
+ * spool that is empty or missing is not an error.
107
+ */
108
+ export async function flushSpool(inner, dir) {
109
+ const names = await entries(dir);
110
+ let sent = 0;
111
+ for (const name of names) {
112
+ let item;
113
+ try {
114
+ item = JSON.parse(await readFile(path.join(dir, name), 'utf8'));
115
+ }
116
+ catch {
117
+ return { sent, remaining: names.length - sent, error: `The spooled file ${name} cannot be read; it was left in place` };
118
+ }
119
+ try {
120
+ await inner.put(item.key, Uint8Array.from(Buffer.from(item.base64, 'base64')), item.contentType);
121
+ }
122
+ catch (error) {
123
+ return { sent, remaining: names.length - sent, error: error instanceof Error ? error.message : String(error) };
124
+ }
125
+ await rm(path.join(dir, name), { force: true });
126
+ sent += 1;
127
+ }
128
+ return { sent, remaining: 0 };
129
+ }
@@ -0,0 +1,82 @@
1
+ import { type Critic } from './contract.js';
2
+ /** The version of this adapter's own logic, recorded in every critique. The checkpoint is part of the identity too (through `model.id`). */
3
+ export declare const UICLIP_VERSION = "1.0.0";
4
+ /** A pinned checkpoint: where it lives on disk, the SHA-256 it must have, and, optionally, where `setupUiclip` may fetch it from. */
5
+ export type UiclipCheckpoint = {
6
+ /** The file on disk. */
7
+ readonly path: string;
8
+ /** Lowercase hex SHA-256 the file must have. Required: an unpinned checkpoint is refused. */
9
+ readonly sha256: string;
10
+ /** Where `setupUiclip` may download it from when the file is missing. Never used by inference. */
11
+ readonly url?: string;
12
+ };
13
+ /** Something that scores one image: the model behind the critic. */
14
+ export type UiclipRuntime = {
15
+ /** The score for the image bytes; higher is better. Must be finite and deterministic for the same bytes. */
16
+ score(image: Uint8Array, signal: AbortSignal): Promise<number>;
17
+ /** Frees the session. */
18
+ close?(): Promise<void>;
19
+ };
20
+ /** Thrown when an optional dependency a runtime needs is not installed. The message names it and says how to install it. */
21
+ export declare class MissingDependencyError extends Error {
22
+ readonly dependency: string;
23
+ /** @param dependency the package that is missing. */
24
+ constructor(dependency: string, purpose: string);
25
+ }
26
+ /**
27
+ * Checks a file against a pinned SHA-256. Returns the hash it has. Throws when the file is missing or its hash differs, naming
28
+ * both hashes, so a corrupted or swapped model is an error and never silently used.
29
+ */
30
+ export declare function verifyCheckpoint(checkpoint: Pick<UiclipCheckpoint, 'path' | 'sha256'>): Promise<string>;
31
+ /** What {@link setupUiclip} did. */
32
+ export type UiclipSetup = {
33
+ /** The file that was verified. */
34
+ readonly path: string;
35
+ /** Its SHA-256, equal to the pin. */
36
+ readonly sha256: string;
37
+ /** True when it was fetched now; false when it was already there. */
38
+ readonly downloaded: boolean;
39
+ };
40
+ /**
41
+ * Provisions the checkpoint: uses the file when it is there, else downloads it from `checkpoint.url` (to a temporary file, hashing as
42
+ * it arrives), and in both cases verifies the pinned SHA-256. A download whose hash differs is deleted and is an error; an
43
+ * existing file whose hash differs is an error and is left alone. This is the only place anything is downloaded.
44
+ *
45
+ * @param checkpoint the pinned checkpoint.
46
+ * @param options.fetch for tests; default `fetch`.
47
+ * @param options.signal cancels the download.
48
+ */
49
+ export declare function setupUiclip(checkpoint: UiclipCheckpoint, options?: {
50
+ readonly fetch?: typeof fetch;
51
+ readonly signal?: AbortSignal;
52
+ }): Promise<UiclipSetup>;
53
+ /** The modules a runtime imports, so a test can replace them. */
54
+ export type UiclipModules = {
55
+ readonly importModule?: (name: string) => Promise<unknown>;
56
+ };
57
+ /**
58
+ * Opens an ONNX Runtime session on a verified checkpoint and scores images with it: the image is resized to 224 by 224 and
59
+ * normalised as CLIP does, and the first output's first value is the score. The checkpoint's hash is verified first and a
60
+ * mismatch is an error. Needs the optional `onnxruntime-node` and `sharp`; the error names the missing one. Nothing is
61
+ * downloaded. The graph and score head are those of the checkpoint you pinned; this has not been validated against any particular
62
+ * checkpoint, so measure it on your own examples (`calibrate`) before reading its scores.
63
+ */
64
+ export declare function loadOnnxRuntime(checkpoint: Pick<UiclipCheckpoint, 'path' | 'sha256'>, modules?: UiclipModules): Promise<UiclipRuntime>;
65
+ /**
66
+ * The UIClip critic: judges each image with a runtime and reports the score and nothing else (a score is a valid critique; it
67
+ * has no findings). Deterministic for a deterministic runtime. Its identity includes the checkpoint hash, so a different
68
+ * checkpoint is judged again. The runtime is created once, on the first image.
69
+ *
70
+ * @param options.checkpoint the pinned checkpoint. Its hash is the model id.
71
+ * @param options.runtime a runtime, or a function that makes one. Default: {@link loadOnnxRuntime} on the checkpoint.
72
+ * @example
73
+ * const critic = createUiclipCritic({ checkpoint: { path: '.atlas/uiclip.onnx', sha256 }, runtime: { score: async () => 0.5 } });
74
+ */
75
+ export declare function createUiclipCritic(options: {
76
+ readonly checkpoint: Pick<UiclipCheckpoint, 'path' | 'sha256'>;
77
+ readonly runtime?: UiclipRuntime | (() => Promise<UiclipRuntime>);
78
+ readonly name?: string;
79
+ readonly modules?: UiclipModules;
80
+ }): Critic & {
81
+ ready(): Promise<void>;
82
+ };