@ultimat3/ai 1.0.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.
package/src/errors.ts ADDED
@@ -0,0 +1,422 @@
1
+ // The X_* codes owned by @ultimat3/ai. Budget and threshold failures name the exact knob to
2
+ // change, because the caller is often a CI job with no human attached.
3
+
4
+ import { registerErrorCodes, UltimateError } from '@ultimat3/core';
5
+
6
+ export const AI_ERROR_CODES = [
7
+ 'X_AI_PROVIDER_UNAVAILABLE',
8
+ 'X_AI_KEY_MISSING',
9
+ 'X_AI_REQUEST_INVALID',
10
+ 'X_AI_BUDGET_EXCEEDED',
11
+ 'X_AI_GATEWAY_MISSING',
12
+ 'X_AI_PROMPT_VERSION',
13
+ 'X_LLM_OUTPUT_INVALID',
14
+ 'X_LLM_REFUSED',
15
+ 'X_LLM_TRUNCATED',
16
+ 'X_EVAL_THRESHOLD',
17
+ 'X_EVAL_BASELINE_MISSING',
18
+ 'X_EVAL_BASELINE_INVALID',
19
+ 'X_EVAL_MISSING',
20
+ 'X_EVAL_RECORDING',
21
+ 'X_VECTOR_DIM_MISMATCH',
22
+ 'X_VECTOR_SCOPE_WIDENED',
23
+ 'X_AI_EMBEDDER_INVALID',
24
+ ] as const;
25
+
26
+ export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
27
+
28
+ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
29
+ X_AI_PROVIDER_UNAVAILABLE: 'the model provider is unreachable',
30
+ X_AI_KEY_MISSING: 'the provider API key is not set',
31
+ X_AI_REQUEST_INVALID: 'the provider would reject this request',
32
+ X_AI_BUDGET_EXCEEDED: 'a model call would exceed its budget',
33
+ X_AI_GATEWAY_MISSING: 'an llm() action ran with no gateway installed',
34
+ X_AI_PROMPT_VERSION: 'prompt version or slots are wrong',
35
+ X_LLM_OUTPUT_INVALID: 'structured output failed its schema on the answer and the repair turn',
36
+ X_LLM_REFUSED: 'the model declined the request',
37
+ X_LLM_TRUNCATED: 'the answer hit its maxTokens ceiling before it was complete',
38
+ X_EVAL_THRESHOLD: 'an eval scored below its tolerance',
39
+ X_EVAL_BASELINE_MISSING: 'an eval has no recorded baseline to gate against',
40
+ X_EVAL_BASELINE_INVALID: 'a recorded baseline cannot be read',
41
+ X_EVAL_MISSING: 'a prompt has no eval',
42
+ X_EVAL_RECORDING: 'the gate ran with baseline recording switched on',
43
+ X_VECTOR_DIM_MISMATCH: 'embedding dimensions differ from the store',
44
+ X_VECTOR_SCOPE_WIDENED: 'a derived vector scope tried to leave its tenant',
45
+ X_AI_EMBEDDER_INVALID: 'an Embedder returned fewer vectors than texts it was given',
46
+ };
47
+
48
+ // Titles must be registered for `format()` to render the contract's first line. Unconditional and
49
+ // in one call: every code above is owned here, so a second package claiming one is a real conflict
50
+ // that has to surface as X_ERROR_CODE_DUPLICATE rather than resolve to whoever imported first.
51
+ // `X_FORBIDDEN` (policy's) and `X_CURRENCY_MISMATCH` (money's) are thrown here but never titled.
52
+ registerErrorCodes(
53
+ Object.fromEntries(Object.entries(AI_ERROR_TITLES).map(([code, title]) => [code, { title }])),
54
+ );
55
+
56
+ const docsFor = (code: AiErrorCode): string => `https://ultimate.dev/errors/${code}`;
57
+
58
+ /** Every configured provider refused or errored. Carries what each one said. */
59
+ export class AiProviderUnavailableError extends UltimateError {
60
+ constructor(input: { model: string; attempts: readonly string[] }) {
61
+ super({
62
+ code: 'X_AI_PROVIDER_UNAVAILABLE',
63
+ cause: `no provider could serve model "${input.model}" (${input.attempts.join(' | ')})`,
64
+ fix: 'check ai.providers in app.config.ts and the provider API key env var',
65
+ docs: docsFor('X_AI_PROVIDER_UNAVAILABLE'),
66
+ });
67
+ }
68
+ }
69
+
70
+ /**
71
+ * A request would exceed its token budget. Thrown BEFORE the call, so nothing is spent and
72
+ * nothing is silently truncated — a truncated prompt produces a confidently wrong answer,
73
+ * which is worse than a refusal.
74
+ */
75
+ export class AiBudgetExceededError extends UltimateError {
76
+ constructor(input: {
77
+ scope: string;
78
+ requested: number;
79
+ remaining: number;
80
+ limit: number;
81
+ /** What the numbers count. Money scopes pass minor units so nothing reads as a float. */
82
+ unit?: string;
83
+ }) {
84
+ super({
85
+ code: 'X_AI_BUDGET_EXCEEDED',
86
+ cause:
87
+ `request needs ${input.requested} ${input.unit ?? 'tokens'} but scope ` +
88
+ `"${input.scope}" has ${input.remaining} of ${input.limit} left`,
89
+ fix: `raise ai.budget for "${input.scope}" in app.config.ts, or shorten the prompt`,
90
+ docs: docsFor('X_AI_BUDGET_EXCEEDED'),
91
+ });
92
+ }
93
+ }
94
+
95
+ /**
96
+ * An `llm()` action ran with no gateway installed. Ambient rather than injected because a
97
+ * declaration is authored at module scope, long before a provider exists — so the miss is a
98
+ * boot-order fault, and naming the boot call is the whole fix.
99
+ */
100
+ export class AiGatewayMissingError extends UltimateError {
101
+ constructor(input: { prompt: string }) {
102
+ super({
103
+ code: 'X_AI_GATEWAY_MISSING',
104
+ cause: `an llm action on prompt "${input.prompt}" ran before any gateway was configured`,
105
+ fix: 'configureAi({ gateway: createGateway({ providers: [new AnthropicProvider()] }) }) at boot',
106
+ docs: docsFor('X_AI_GATEWAY_MISSING'),
107
+ });
108
+ }
109
+ }
110
+
111
+ /**
112
+ * The model's answer failed the action's `output` schema on the first turn AND on the repair
113
+ * turn that followed. Two failures is a disagreement between the prompt and the schema, not a
114
+ * bad roll — a third attempt only spends money, so this throws instead of looping.
115
+ */
116
+ export class LlmOutputInvalidError extends UltimateError {
117
+ constructor(input: { prompt: string; attempts: number; issues: string }) {
118
+ super({
119
+ code: 'X_LLM_OUTPUT_INVALID',
120
+ cause:
121
+ `prompt "${input.prompt}" returned output failing its schema on all ` +
122
+ `${input.attempts} attempts: ${input.issues}`,
123
+ fix: 'describe the output shape in the prompt template and bump its version, or widen `output` in the llm() declaration',
124
+ docs: docsFor('X_LLM_OUTPUT_INVALID'),
125
+ });
126
+ }
127
+ }
128
+
129
+ /**
130
+ * The provider's safety classifiers declined the request. A refusal is a 200 with no answer in
131
+ * it, so it has to become an error HERE or it becomes an empty string somewhere downstream that
132
+ * reads exactly like a model with nothing to say. Distinct from `X_LLM_OUTPUT_INVALID` because
133
+ * the fix is different: nothing about the schema is wrong, and a repair turn buys a second
134
+ * refusal at full price.
135
+ */
136
+ export class LlmRefusedError extends UltimateError {
137
+ constructor(input: {
138
+ prompt: string;
139
+ model: string;
140
+ /** A blessed model that is NOT the one that refused — the fix has to be pasteable. */
141
+ alternative: string;
142
+ category: string | undefined;
143
+ explanation: string | undefined;
144
+ }) {
145
+ super({
146
+ code: 'X_LLM_REFUSED',
147
+ cause:
148
+ `model "${input.model}" declined prompt "${input.prompt}"` +
149
+ `${input.category === undefined ? '' : ` (${input.category})`}` +
150
+ `${input.explanation === undefined ? '' : `: ${input.explanation}`}`,
151
+ fix: `set model: '${input.alternative}' on the llm() declaration, or edit the template in definePrompt('${input.prompt}') and bump its version`,
152
+ docs: docsFor('X_LLM_REFUSED'),
153
+ meta: { model: input.model, category: input.category },
154
+ });
155
+ }
156
+ }
157
+
158
+ /**
159
+ * The answer was cut off at the enforced ceiling and what arrived does not satisfy the schema.
160
+ * Thrown instead of the repair turn on purpose: the ceiling does not move between attempts, so
161
+ * a second answer truncates at exactly the same place and only spends money.
162
+ */
163
+ export class LlmTruncatedError extends UltimateError {
164
+ constructor(input: { prompt: string; maxTokens: number }) {
165
+ super({
166
+ code: 'X_LLM_TRUNCATED',
167
+ cause: `prompt "${input.prompt}" was cut off at its ${input.maxTokens}-token ceiling`,
168
+ fix: `set maxTokens: ${input.maxTokens * 2} on the llm() declaration, or drop fields from its output schema`,
169
+ docs: docsFor('X_LLM_TRUNCATED'),
170
+ });
171
+ }
172
+ }
173
+
174
+ /** A prompt was requested at a version whose content hash does not match the registry. */
175
+ export class AiPromptVersionError extends UltimateError {
176
+ constructor(input: { id: string; requested: string; available: readonly string[] }) {
177
+ super({
178
+ code: 'X_AI_PROMPT_VERSION',
179
+ cause: `prompt "${input.id}" has no version "${input.requested}" (have: ${
180
+ input.available.length > 0 ? input.available.join(', ') : 'none'
181
+ })`,
182
+ fix: 'bump the version in definePrompt after editing the template, then x manifest',
183
+ docs: docsFor('X_AI_PROMPT_VERSION'),
184
+ });
185
+ }
186
+ }
187
+
188
+ /**
189
+ * A prompt was rendered with variables its template does not accept. Shares
190
+ * `X_AI_PROMPT_VERSION` because it is the same class of fault: the prompt artifact and the
191
+ * call site disagree about the prompt's contract.
192
+ */
193
+ export class AiPromptRenderError extends UltimateError {
194
+ constructor(input: { ref: string; missing: readonly string[] }) {
195
+ super({
196
+ code: 'X_AI_PROMPT_VERSION',
197
+ cause: `prompt "${input.ref}" was rendered without: ${input.missing.join(', ')}`,
198
+ fix: 'pass every {{variable}} the template declares, or remove it from the template',
199
+ docs: docsFor('X_AI_PROMPT_VERSION'),
200
+ });
201
+ }
202
+ }
203
+
204
+ /**
205
+ * An eval scored further below its recorded baseline than its tolerance allows. The gate is the
206
+ * DROP, not an absolute number — a model that got marginally worse everywhere is not the same
207
+ * event as a prompt edit that broke one case, and only the second one is anybody's fault.
208
+ *
209
+ * This is a test failure, not a warning.
210
+ */
211
+ export class EvalThresholdError extends UltimateError {
212
+ constructor(input: {
213
+ eval: string;
214
+ score: number;
215
+ baseline: number;
216
+ tolerance: number;
217
+ promptVersion: string;
218
+ regressed: readonly string[];
219
+ }) {
220
+ super({
221
+ code: 'X_EVAL_THRESHOLD',
222
+ cause:
223
+ `eval "${input.eval}" scored ${input.score.toFixed(3)} against a recorded baseline of ` +
224
+ `${input.baseline.toFixed(3)} (tolerance ${input.tolerance.toFixed(3)}) on prompt ` +
225
+ `version ${input.promptVersion}; regressed: ${input.regressed.join(', ')}`,
226
+ fix: `x test ${input.eval} to see per-case scores, then fix the prompt — or ULTIMATE_EVAL_RECORD=1 x test eval to accept the new numbers as a reviewed diff`,
227
+ docs: docsFor('X_EVAL_THRESHOLD'),
228
+ });
229
+ }
230
+ }
231
+
232
+ /**
233
+ * An eval declared a baseline that has never been recorded. Not a pass: an eval with nothing to
234
+ * compare against gates on nothing, and a step that cannot fail is a step that is not running.
235
+ */
236
+ export class EvalBaselineMissingError extends UltimateError {
237
+ constructor(input: { eval: string; path: string; reason: string; fix?: string }) {
238
+ super({
239
+ code: 'X_EVAL_BASELINE_MISSING',
240
+ cause: `eval "${input.eval}" gates against ${input.path}, which ${input.reason}`,
241
+ fix: input.fix ?? `ULTIMATE_EVAL_RECORD=1 x test eval, then commit ${input.path}`,
242
+ docs: docsFor('X_EVAL_BASELINE_MISSING'),
243
+ });
244
+ }
245
+ }
246
+
247
+ /** A recorded baseline that cannot be read. Never treated as absent — that would erase a gate. */
248
+ export class EvalBaselineInvalidError extends UltimateError {
249
+ constructor(input: { path: string; problem: string }) {
250
+ super({
251
+ code: 'X_EVAL_BASELINE_INVALID',
252
+ cause: `the recorded baseline ${input.path} ${input.problem}`,
253
+ fix: `ULTIMATE_EVAL_RECORD=1 x test eval to re-record ${input.path}`,
254
+ docs: docsFor('X_EVAL_BASELINE_INVALID'),
255
+ });
256
+ }
257
+ }
258
+
259
+ /**
260
+ * A registered prompt that no eval names. An unevaluated prompt is untested code that costs
261
+ * money and answers users, so the gate fails on it exactly like an untyped module.
262
+ */
263
+ export class EvalMissingError extends UltimateError {
264
+ constructor(input: { prompt: string; id: string }) {
265
+ super({
266
+ code: 'X_EVAL_MISSING',
267
+ cause: `prompt "${input.prompt}" has no eval`,
268
+ fix: `defineEval({ name: '${input.id}', prompt, cases, scorers, tolerance, baseline }) beside the prompt, then ULTIMATE_EVAL_RECORD=1 x test eval`,
269
+ docs: docsFor('X_EVAL_MISSING'),
270
+ });
271
+ }
272
+ }
273
+
274
+ /**
275
+ * The gate ran with baseline recording switched on. Recording makes every eval write the numbers
276
+ * it just measured and pass, so a `x verify` that inherited the flag reports green over scores
277
+ * nothing compared — and rewrites the committed baselines on its way through, which is the half
278
+ * a red step alone would not undo. Recording is a deliberate, reviewable diff, never a gate run.
279
+ */
280
+ export class EvalRecordingError extends UltimateError {
281
+ constructor(input: { env: string }) {
282
+ super({
283
+ code: 'X_EVAL_RECORDING',
284
+ cause: `${input.env} is set, so every eval would re-record its baseline instead of gating on it`,
285
+ fix: `env -u ${input.env} x verify`,
286
+ docs: docsFor('X_EVAL_RECORDING'),
287
+ });
288
+ }
289
+ }
290
+
291
+ /** A vector's length does not match the store's declared dimension. */
292
+ export class VectorDimMismatchError extends UltimateError {
293
+ constructor(input: { store: string; expected: number; received: number }) {
294
+ super({
295
+ code: 'X_VECTOR_DIM_MISMATCH',
296
+ cause: `store "${input.store}" expects ${input.expected} dimensions, got ${input.received}`,
297
+ fix: 'use the same embedder that created the store, or x ai reindex to rebuild it',
298
+ docs: docsFor('X_VECTOR_DIM_MISMATCH'),
299
+ });
300
+ }
301
+ }
302
+
303
+ /**
304
+ * A derived vector scope tried to leave the tenant it was bound to. Scopes only ever tighten,
305
+ * so this is always the same mistake: a request handler re-scoping the store it was handed
306
+ * instead of deriving from the unscoped one. Widening silently would be a cross-tenant read.
307
+ */
308
+ export class VectorScopeWidenedError extends UltimateError {
309
+ constructor(input: { store: string; held: string; requested: string }) {
310
+ super({
311
+ code: 'X_VECTOR_SCOPE_WIDENED',
312
+ cause:
313
+ `store "${input.store}" is bound to tenant "${input.held}" and cannot be re-scoped ` +
314
+ `to "${input.requested}"`,
315
+ fix: `derive from the unscoped store instead: vectorStore.scoped({ tenant: '${input.requested}' })`,
316
+ docs: docsFor('X_VECTOR_SCOPE_WIDENED'),
317
+ });
318
+ }
319
+ }
320
+
321
+ /**
322
+ * An embedder returned vectors of a length other than the one it declares. Separate from the
323
+ * store's mismatch because the fix is different: here the DECLARATION is wrong, and every
324
+ * vector written before this call is the wrong width in whatever store accepted them.
325
+ */
326
+ export class EmbedderDimMismatchError extends UltimateError {
327
+ constructor(input: { embedder: string; expected: number; received: number }) {
328
+ super({
329
+ code: 'X_VECTOR_DIM_MISMATCH',
330
+ cause:
331
+ `embedder "${input.embedder}" is declared with ${input.expected} dimensions but the ` +
332
+ `provider returned ${input.received}`,
333
+ fix: `set dimension: ${input.received} on the embedder, then x ai reindex to rebuild the store`,
334
+ docs: docsFor('X_VECTOR_DIM_MISMATCH'),
335
+ });
336
+ }
337
+ }
338
+
339
+ /**
340
+ * `embedOne` asked an `Embedder` for one vector and got none back — a batch-size invariant the
341
+ * embedder itself broke, not a caller mistake. Distinct from `X_VECTOR_DIM_MISMATCH`: this fires
342
+ * before there is a vector at all, so there is nothing yet to measure the width of.
343
+ */
344
+ export class AiEmbedderInvalidError extends UltimateError {
345
+ constructor(input: { embedder: string }) {
346
+ super({
347
+ code: 'X_AI_EMBEDDER_INVALID',
348
+ cause: `embedder "${input.embedder}" returned no vector for a batch of one text`,
349
+ // The `${…}` the fix used to carry is unreadable to the `errors` gate, which blanks every
350
+ // interpolation — so the literal half alone has to name the call. Which embedder broke the
351
+ // invariant is a fact of the failure, and the cause and `meta` are where facts live.
352
+ fix: 'return one vector per input text from embed(), in the order the texts arrived',
353
+ docs: docsFor('X_AI_EMBEDDER_INVALID'),
354
+ meta: { embedder: input.embedder },
355
+ });
356
+ }
357
+ }
358
+
359
+ /** No credential at call time. Named env var, because that is the whole fix. */
360
+ export class AiKeyMissingError extends UltimateError {
361
+ constructor(input: { provider: string; envVar: string }) {
362
+ super({
363
+ code: 'X_AI_KEY_MISSING',
364
+ cause: `provider "${input.provider}" has no API key: ${input.envVar} is unset and none was passed to its constructor`,
365
+ fix: `export ${input.envVar}=<key>, or pass { apiKey } when constructing the provider`,
366
+ docs: docsFor('X_AI_KEY_MISSING'),
367
+ meta: { provider: input.provider, envVar: input.envVar },
368
+ });
369
+ }
370
+ }
371
+
372
+ /**
373
+ * A request the provider would answer with a 400, refused locally instead. Local because a
374
+ * round trip to learn a rule the framework already knows is a round trip that costs latency
375
+ * and teaches nothing — and the provider's own message names the field, not the fix.
376
+ */
377
+ export class AiRequestInvalidError extends UltimateError {
378
+ constructor(input: { detail: string; fix: string }) {
379
+ super({
380
+ code: 'X_AI_REQUEST_INVALID',
381
+ cause: input.detail,
382
+ fix: input.fix,
383
+ docs: docsFor('X_AI_REQUEST_INVALID'),
384
+ });
385
+ }
386
+ }
387
+
388
+ /**
389
+ * The provider answered with a non-2xx, an in-band error event, or a body nothing can be read
390
+ * out of. Carries `status` as a real field so the gateway's retry policy can read it: a 429 or
391
+ * a 503 is momentary, a 400 is the same rejection forever and retrying only burns the budget.
392
+ */
393
+ export class AiTransportError extends UltimateError {
394
+ readonly status: number | undefined;
395
+
396
+ constructor(input: { provider: string; status?: number | undefined; detail: string }) {
397
+ super({
398
+ code: 'X_AI_PROVIDER_UNAVAILABLE',
399
+ cause: `provider "${input.provider}" ${
400
+ input.status === undefined ? 'failed' : `returned ${input.status}`
401
+ }: ${input.detail}`,
402
+ fix: fixForStatus(input.status),
403
+ docs: docsFor('X_AI_PROVIDER_UNAVAILABLE'),
404
+ meta: { provider: input.provider, status: input.status },
405
+ });
406
+ this.status = input.status;
407
+ }
408
+ }
409
+
410
+ function fixForStatus(status: number | undefined): string {
411
+ if (status === 401 || status === 403) {
412
+ return 'export ANTHROPIC_API_KEY=<key> with a key that is active for this model';
413
+ }
414
+ if (status === 429) {
415
+ return 'lower concurrency or raise the provider rate limit; the gateway already backs off';
416
+ }
417
+ if (status !== undefined && status >= 400 && status < 500) {
418
+ return 'fix the request named in cause — shorten the prompt, or correct the tool schema';
419
+ }
420
+ if (status !== undefined) return 'retry; if it persists check the provider status page';
421
+ return 'check egress from this process (x doctor --json), then the provider status page';
422
+ }
@@ -0,0 +1,140 @@
1
+ // The recorded scores an eval gates against.
2
+ //
3
+ // The gate is the DELTA from the last accepted run, never an absolute number: models drift,
4
+ // prompts should not. An absolute floor fails every eval at once the day a provider ships a
5
+ // slightly different model, which trains everyone to lower thresholds until they mean nothing.
6
+ //
7
+ // A baseline is a committed file, so accepting a new number is a reviewable diff — and it
8
+ // carries the prompt ref and hash that produced it, because a score with no prompt attached is
9
+ // not a measurement.
10
+
11
+ import { isAbsolute } from 'node:path';
12
+ import { EvalBaselineInvalidError, EvalBaselineMissingError } from './errors';
13
+
14
+ export interface EvalBaseline {
15
+ readonly eval: string;
16
+ /** `id@version` of the prompt that produced these numbers — usually the previous version. */
17
+ readonly prompt: string;
18
+ readonly promptHash: string;
19
+ readonly score: number;
20
+ /** Per-case scores, so the gate can name the case a prompt edit broke. */
21
+ readonly cases: Readonly<Record<string, number>>;
22
+ }
23
+
24
+ /** One score that fell further than the eval's declared tolerance allows. */
25
+ export interface Regression {
26
+ /** A case name, or `overall` for the run's mean. */
27
+ readonly case: string;
28
+ readonly baseline: number;
29
+ readonly score: number;
30
+ }
31
+
32
+ /** The run-level entry, named so a failure reads the same whether one case or all of them fell. */
33
+ export const OVERALL = 'overall';
34
+
35
+ /** Set to re-record every baseline instead of gating on it: `ULTIMATE_EVAL_RECORD=1 x test eval`. */
36
+ export const RECORD_ENV = 'ULTIMATE_EVAL_RECORD';
37
+
38
+ export const recordingBaselines = (): boolean => (Bun.env[RECORD_ENV] ?? '') !== '';
39
+
40
+ /**
41
+ * Where the baseline lives. Declarations write `import.meta.resolve('./x.baseline.json')`, which
42
+ * is stable in a checkout and in a container; a cwd-relative path would resolve to a different
43
+ * file depending on where the suite was started, which is how a gate silently stops gating.
44
+ */
45
+ export function baselinePath(spec: string, evalName: string): string {
46
+ if (spec.startsWith('file://')) return Bun.fileURLToPath(spec);
47
+ if (isAbsolute(spec)) return spec;
48
+ throw new EvalBaselineMissingError({
49
+ eval: evalName,
50
+ path: spec,
51
+ reason: 'is neither an absolute path nor a file:// URL',
52
+ fix: `baseline: import.meta.resolve('${spec}') in defineEval({ name: '${evalName}' })`,
53
+ });
54
+ }
55
+
56
+ /** The recorded scores, or `undefined` when this eval has never been recorded. */
57
+ export async function readBaseline(path: string): Promise<EvalBaseline | undefined> {
58
+ const file = Bun.file(path);
59
+ if (!(await file.exists())) return undefined;
60
+ const parsed: unknown = await file.json().catch(() => undefined);
61
+ return parseBaseline(parsed, path);
62
+ }
63
+
64
+ /** Fixed key order and a trailing newline: a re-record must diff as scores, not as formatting. */
65
+ export async function writeBaseline(path: string, baseline: EvalBaseline): Promise<void> {
66
+ const ordered = {
67
+ eval: baseline.eval,
68
+ prompt: baseline.prompt,
69
+ promptHash: baseline.promptHash,
70
+ score: round(baseline.score),
71
+ cases: Object.fromEntries(
72
+ Object.keys(baseline.cases)
73
+ .sort()
74
+ .map((name) => [name, round(baseline.cases[name] ?? 0)]),
75
+ ),
76
+ };
77
+ await Bun.write(path, `${JSON.stringify(ordered, null, 2)}\n`);
78
+ }
79
+
80
+ /**
81
+ * Every score that dropped further than `tolerance`. The run mean AND each case, because a mean
82
+ * that holds while one case collapses is the regression an eval exists to catch.
83
+ *
84
+ * A case the baseline does not know is not compared — a new case has nothing to regress from,
85
+ * and the run mean already covers whatever it scores.
86
+ */
87
+ export function regressionsAgainst(input: {
88
+ readonly baseline: EvalBaseline;
89
+ readonly score: number;
90
+ readonly cases: Readonly<Record<string, number>>;
91
+ readonly tolerance: number;
92
+ }): readonly Regression[] {
93
+ const found: Regression[] = [];
94
+ const fell = (was: number, now: number): boolean => now < was - input.tolerance;
95
+ if (fell(input.baseline.score, input.score)) {
96
+ found.push({ case: OVERALL, baseline: input.baseline.score, score: input.score });
97
+ }
98
+ for (const [name, was] of Object.entries(input.baseline.cases)) {
99
+ const now = input.cases[name];
100
+ if (now !== undefined && fell(was, now)) found.push({ case: name, baseline: was, score: now });
101
+ }
102
+ return found;
103
+ }
104
+
105
+ /** `case 0.40 ← 0.95` — the two numbers a reader needs, in the order they happened. */
106
+ export const describeRegression = (regression: Regression): string =>
107
+ `${regression.case} ${regression.score.toFixed(2)} ← ${regression.baseline.toFixed(2)}`;
108
+
109
+ /** Three decimals: enough to see a real drift, few enough that noise is not a diff. */
110
+ const round = (value: number): number => Math.round(value * 1000) / 1000;
111
+
112
+ function parseBaseline(value: unknown, path: string): EvalBaseline {
113
+ const invalid = (problem: string): never => {
114
+ throw new EvalBaselineInvalidError({ path, problem });
115
+ };
116
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
117
+ return invalid('is not a JSON object');
118
+ }
119
+ const record = value as Record<string, unknown>;
120
+ const text = (key: string): string =>
121
+ typeof record[key] === 'string' ? (record[key] as string) : invalid(`has no string "${key}"`);
122
+ const score = record['score'];
123
+ if (typeof score !== 'number') invalid('has no number "score"');
124
+ const cases = record['cases'];
125
+ if (typeof cases !== 'object' || cases === null || Array.isArray(cases)) {
126
+ invalid('has no object "cases"');
127
+ }
128
+ const scores: Record<string, number> = {};
129
+ for (const [name, entry] of Object.entries(cases as Record<string, unknown>)) {
130
+ if (typeof entry !== 'number') invalid(`has a non-numeric score for case "${name}"`);
131
+ scores[name] = entry as number;
132
+ }
133
+ return {
134
+ eval: text('eval'),
135
+ prompt: text('prompt'),
136
+ promptHash: text('promptHash'),
137
+ score: score as number,
138
+ cases: scores,
139
+ };
140
+ }