@nifrajs/testing 3.1.0 → 3.2.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.
@@ -0,0 +1,660 @@
1
+ /**
2
+ * `@nifrajs/testing/agent-eval` - a deterministic, evidence-only eval harness for agent runs.
3
+ *
4
+ * A suite is a bounded set of cases, each scored by one or more typed rubrics. A rubric is a closed
5
+ * contract: an ordered outcome allow-list, a finite score range, and a set of named numeric metrics.
6
+ * Nothing else is admitted - a verdict carrying free-form evaluator prose, an out-of-range score, or
7
+ * an unknown outcome is rejected at parse time. There is no opaque blended "agent score": the report
8
+ * is the evidence contract (ids, codes, bounded numbers, digests) and nothing more.
9
+ *
10
+ * Determinism is structural: cases run in sorted id order and every digest is taken over a canonical,
11
+ * order-independent projection, so reordering declarations cannot change a digest. Baseline
12
+ * comparison classifies each (case, rubric) as equal / improved / tolerated / regressed / missing /
13
+ * incomparable against explicit tolerances, and every regression carries a stable, addressable id.
14
+ */
15
+
16
+ import { type FailureDirective, FailureInjectedError, runFailureScenario } from "./failure-lab.ts"
17
+ import { type FaultProfile, runFaultProfile } from "./fault-profile.ts"
18
+ import { type EffectLedger, type IdempotencyProof, proveIdempotency } from "./idempotency.ts"
19
+
20
+ const TOKEN = /^[a-z0-9][a-z0-9._:-]{0,127}$/
21
+ const MAX_CASES = 512
22
+ const MAX_RUBRICS = 32
23
+ const MAX_OUTCOMES = 32
24
+ const MAX_METRICS = 32
25
+
26
+ // ── Rubric contract ───────────────────────────────────────────────────────────────────────────
27
+
28
+ /** A closed rubric contract. Outcomes are ordered worst -> best; the index is the outcome rank. */
29
+ export interface RubricSpec {
30
+ readonly id: string
31
+ /** Ordered outcome codes, worst first. Rank = index; a change in rank is an improvement/regression. */
32
+ readonly outcomes: readonly string[]
33
+ /** Finite inclusive score range. */
34
+ readonly score: { readonly min: number; readonly max: number }
35
+ /** Whether a higher score is better. Default true. Governs improved/regressed classification. */
36
+ readonly higherIsBetter?: boolean
37
+ /** Allowed numeric metric names. A verdict metric outside this set is rejected. */
38
+ readonly metrics?: readonly string[]
39
+ }
40
+
41
+ /** One rubric's verdict on one case. Numbers and codes only; no free-form text field exists. */
42
+ export interface RubricVerdict {
43
+ readonly rubricId: string
44
+ readonly outcome: string
45
+ readonly score: number
46
+ readonly metrics?: Readonly<Record<string, number>>
47
+ }
48
+
49
+ // ── Case and suite ────────────────────────────────────────────────────────────────────────────
50
+
51
+ /** One eval case. `evaluate` is a deterministic producer of rubric verdicts - evidence, not a run. */
52
+ export interface AgentEvalCase {
53
+ readonly id: string
54
+ evaluate(): readonly RubricVerdict[] | PromiseLike<readonly RubricVerdict[]>
55
+ }
56
+
57
+ export interface AgentEvalSuiteSpec {
58
+ readonly id: string
59
+ readonly version?: number
60
+ readonly rubrics: readonly RubricSpec[]
61
+ readonly cases: readonly AgentEvalCase[]
62
+ }
63
+
64
+ export interface AgentEvalSuite {
65
+ readonly id: string
66
+ readonly version: number
67
+ readonly caseIds: readonly string[]
68
+ readonly rubricIds: readonly string[]
69
+ run(): Promise<AgentEvalReport>
70
+ }
71
+
72
+ // ── Report (the evidence contract) ────────────────────────────────────────────────────────────
73
+
74
+ /** The frozen shape of a rubric, embedded so a baseline comparison is self-contained. */
75
+ export interface RubricShape {
76
+ readonly rubricId: string
77
+ readonly outcomes: readonly string[]
78
+ readonly scoreMin: number
79
+ readonly scoreMax: number
80
+ readonly higherIsBetter: boolean
81
+ readonly digest: string
82
+ }
83
+
84
+ export interface CaseResult {
85
+ readonly caseId: string
86
+ readonly verdicts: readonly RubricVerdict[]
87
+ readonly digest: string
88
+ }
89
+
90
+ export interface AgentEvalReport {
91
+ readonly version: 1
92
+ readonly suiteId: string
93
+ readonly suiteVersion: number
94
+ readonly rubricShapes: readonly RubricShape[]
95
+ readonly cases: readonly CaseResult[]
96
+ readonly digest: string
97
+ }
98
+
99
+ // ── Baseline comparison ───────────────────────────────────────────────────────────────────────
100
+
101
+ export type ComparisonCode =
102
+ | "equal"
103
+ | "improved"
104
+ | "tolerated"
105
+ | "regressed"
106
+ | "missing"
107
+ | "incomparable"
108
+
109
+ /** Explicit tolerance for a score regression. `rubricId` omitted applies to every rubric. */
110
+ export interface ScoreTolerance {
111
+ readonly rubricId?: string
112
+ /** Absolute worsening tolerated. Default 0. */
113
+ readonly abs?: number
114
+ /** Relative worsening tolerated, as a fraction of the baseline magnitude. Default 0. */
115
+ readonly rel?: number
116
+ }
117
+
118
+ export interface BaselineOptions {
119
+ readonly tolerances?: readonly ScoreTolerance[]
120
+ /** Comparison codes that fail the assertion. Default `["regressed"]`. */
121
+ readonly failOn?: readonly ComparisonCode[]
122
+ }
123
+
124
+ export interface CaseComparison {
125
+ readonly caseId: string
126
+ readonly rubricId: string
127
+ readonly code: ComparisonCode
128
+ readonly regressionId: string
129
+ readonly baselineScore?: number
130
+ readonly currentScore?: number
131
+ }
132
+
133
+ export interface BaselineComparison {
134
+ readonly suiteId: string
135
+ readonly comparisons: readonly CaseComparison[]
136
+ readonly regressions: readonly string[]
137
+ readonly digest: string
138
+ }
139
+
140
+ /** Thrown by {@link assertAgentEvalBaseline} when a comparison contains a failing code. */
141
+ export class AgentEvalRegressionError extends Error {
142
+ constructor(readonly comparison: BaselineComparison) {
143
+ super(
144
+ `agent eval ${comparison.suiteId}: ${comparison.regressions.length} regression(s): ` +
145
+ comparison.regressions.join(", "),
146
+ )
147
+ this.name = "AgentEvalRegressionError"
148
+ }
149
+ }
150
+
151
+ // ── Composition and deterministic fault matrix ───────────────────────────────────────────────
152
+
153
+ /** Fault boundaries covered by the public deterministic matrix. */
154
+ export type AgentFailureMatrixKind =
155
+ | "model"
156
+ | "tool"
157
+ | "approval"
158
+ | "cancellation"
159
+ | "lease"
160
+ | "cursor"
161
+ | "registry"
162
+ | "deployment"
163
+
164
+ export interface AgentFailureMatrixCase {
165
+ readonly id: string
166
+ readonly kind: AgentFailureMatrixKind
167
+ readonly scheduleToken: string
168
+ readonly ok: boolean
169
+ readonly regressionId: string
170
+ }
171
+
172
+ export interface AgentFailureMatrixReport {
173
+ readonly version: 1
174
+ readonly seed: number
175
+ readonly cases: readonly AgentFailureMatrixCase[]
176
+ readonly digest: string
177
+ readonly ok: boolean
178
+ }
179
+
180
+ export class AgentFailureMatrixError extends Error {
181
+ constructor(readonly report: AgentFailureMatrixReport) {
182
+ super(
183
+ `agent failure matrix: ${report.cases
184
+ .filter((entry) => !entry.ok)
185
+ .map((entry) => entry.regressionId)
186
+ .join(", ")}`,
187
+ )
188
+ this.name = "AgentFailureMatrixError"
189
+ }
190
+ }
191
+
192
+ const MATRIX_CASES: readonly { readonly id: string; readonly kind: AgentFailureMatrixKind }[] = [
193
+ { id: "approval-denied", kind: "approval" },
194
+ { id: "cancel-race", kind: "cancellation" },
195
+ { id: "cursor-gap", kind: "cursor" },
196
+ { id: "deployment-loss", kind: "deployment" },
197
+ { id: "lease-expired", kind: "lease" },
198
+ { id: "model-reply", kind: "model" },
199
+ { id: "registry-drift", kind: "registry" },
200
+ { id: "tool-effect", kind: "tool" },
201
+ ]
202
+
203
+ function matrixSchedule(caseId: string): readonly FailureDirective[] {
204
+ return [{ point: `agent.${caseId}`, kind: "crash" }]
205
+ }
206
+
207
+ /**
208
+ * Run the same token-only crash fixture at each agent boundary. The implementation intentionally
209
+ * delegates scheduling and replay identity to the existing failure laboratory; this is a matrix
210
+ * consumer, not a second fault engine.
211
+ */
212
+ export async function runAgentFailureMatrix(
213
+ options: { readonly seed?: number } = {},
214
+ ): Promise<AgentFailureMatrixReport> {
215
+ const seed = Number.isFinite(options.seed) ? Math.trunc(options.seed as number) >>> 0 : 0x4e494652
216
+ const cases: AgentFailureMatrixCase[] = []
217
+ for (const entry of MATRIX_CASES) {
218
+ const caseId = entry.id
219
+ const report = await runFailureScenario(
220
+ {
221
+ name: `agent-${caseId}`,
222
+ execute(lab) {
223
+ lab.checkpoint(`agent.${caseId}`)
224
+ return true
225
+ },
226
+ verify: ({ error }) => error instanceof FailureInjectedError,
227
+ },
228
+ { seed, schedule: matrixSchedule(caseId) },
229
+ )
230
+ const scheduleToken = `${report.replay.seed}:${caseId}:1`
231
+ const regressionId = `${caseId}/${scheduleToken}`
232
+ cases.push({ id: caseId, kind: entry.kind, scheduleToken, ok: report.ok, regressionId })
233
+ }
234
+ const digest = await sha256(canonical({ version: 1, seed, cases }))
235
+ return Object.freeze({
236
+ version: 1,
237
+ seed,
238
+ cases: Object.freeze(cases),
239
+ digest,
240
+ ok: cases.every((entry) => entry.ok),
241
+ })
242
+ }
243
+
244
+ export function assertAgentFailureMatrix(report: AgentFailureMatrixReport): void {
245
+ if (!report.ok) throw new AgentFailureMatrixError(report)
246
+ }
247
+
248
+ export interface AgentEvalComponentEvidence {
249
+ readonly id: "trajectory" | "fault-profile" | "contract-lab" | "idempotency" | "certification"
250
+ readonly ok: boolean
251
+ readonly code?: string
252
+ readonly digest?: string
253
+ }
254
+
255
+ export interface AgentEvalCompositionOptions {
256
+ readonly suite: AgentEvalSuite
257
+ readonly trajectory?: () => Promise<{
258
+ readonly ok: boolean
259
+ readonly digest: string
260
+ readonly regressionIds?: readonly string[]
261
+ }>
262
+ readonly faultProfile?: { readonly profile: FaultProfile; readonly seed?: number }
263
+ readonly contractLab?: () => Promise<{
264
+ readonly ok: boolean
265
+ readonly digest?: string
266
+ readonly code?: string
267
+ }>
268
+ readonly idempotency?: {
269
+ readonly run: () => Promise<EffectLedger> | EffectLedger
270
+ readonly runs?: number
271
+ }
272
+ readonly certification?: () => Promise<{
273
+ readonly ok: boolean
274
+ readonly digest?: string
275
+ readonly code?: string
276
+ }>
277
+ }
278
+
279
+ export interface AgentEvalCompositionReport {
280
+ readonly version: 1
281
+ readonly suite: AgentEvalReport
282
+ readonly components: readonly AgentEvalComponentEvidence[]
283
+ readonly regressionIds: readonly string[]
284
+ readonly digest: string
285
+ readonly ok: boolean
286
+ }
287
+
288
+ /** Compose existing trajectory, fault, contract, idempotency, and certification owners. */
289
+ export async function runAgentEvalComposition(
290
+ options: AgentEvalCompositionOptions,
291
+ ): Promise<AgentEvalCompositionReport> {
292
+ const suite = await options.suite.run()
293
+ const components: AgentEvalComponentEvidence[] = []
294
+ const regressionIds: string[] = []
295
+ if (options.trajectory !== undefined) {
296
+ const result = await options.trajectory()
297
+ components.push({ id: "trajectory", ok: result.ok, digest: result.digest })
298
+ for (const id of result.regressionIds ?? []) regressionIds.push(id)
299
+ }
300
+ if (options.faultProfile !== undefined) {
301
+ const result = await runFaultProfile(
302
+ options.faultProfile.profile,
303
+ options.faultProfile.seed === undefined ? {} : { seed: options.faultProfile.seed },
304
+ )
305
+ components.push({ id: "fault-profile", ok: result.ok })
306
+ if (!result.ok) regressionIds.push(`fault-profile/${result.name}`)
307
+ }
308
+ if (options.contractLab !== undefined) {
309
+ const result = await options.contractLab()
310
+ components.push({
311
+ id: "contract-lab",
312
+ ok: result.ok,
313
+ ...(result.digest === undefined ? {} : { digest: result.digest }),
314
+ ...(result.code === undefined ? {} : { code: result.code }),
315
+ })
316
+ if (!result.ok) regressionIds.push(`contract-lab/${result.code ?? "failed"}`)
317
+ }
318
+ if (options.idempotency !== undefined) {
319
+ const result: IdempotencyProof = await proveIdempotency(options.idempotency)
320
+ components.push({ id: "idempotency", ok: result.ok })
321
+ if (!result.ok) regressionIds.push("idempotency/divergent")
322
+ }
323
+ if (options.certification !== undefined) {
324
+ const result = await options.certification()
325
+ components.push({
326
+ id: "certification",
327
+ ok: result.ok,
328
+ ...(result.digest === undefined ? {} : { digest: result.digest }),
329
+ ...(result.code === undefined ? {} : { code: result.code }),
330
+ })
331
+ if (!result.ok) regressionIds.push(`certification/${result.code ?? "failed"}`)
332
+ }
333
+ const ok = regressionIds.length === 0 && components.every((entry) => entry.ok)
334
+ const digest = await sha256(
335
+ canonical({ version: 1, suite: suite.digest, components, regressionIds }),
336
+ )
337
+ return Object.freeze({
338
+ version: 1,
339
+ suite,
340
+ components: Object.freeze(components),
341
+ regressionIds: Object.freeze(regressionIds.sort()),
342
+ digest,
343
+ ok,
344
+ })
345
+ }
346
+
347
+ export function assertAgentEval(report: AgentEvalCompositionReport): void {
348
+ if (!report.ok)
349
+ throw new AgentEvalRegressionError({
350
+ suiteId: report.suite.suiteId,
351
+ comparisons: [],
352
+ regressions: report.regressionIds,
353
+ digest: report.digest,
354
+ })
355
+ }
356
+
357
+ // ── Helpers ─────────────────────────────────────────────────────────────────────────────────
358
+
359
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
360
+ typeof value === "object" && value !== null && !Array.isArray(value)
361
+
362
+ function canonical(value: unknown): string {
363
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
364
+ if (isRecord(value))
365
+ return `{${Object.keys(value)
366
+ .sort()
367
+ .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
368
+ .join(",")}}`
369
+ return JSON.stringify(value)
370
+ }
371
+
372
+ async function sha256(value: string): Promise<string> {
373
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))
374
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")
375
+ }
376
+
377
+ function assertToken(value: unknown, what: string): asserts value is string {
378
+ if (typeof value !== "string" || !TOKEN.test(value))
379
+ throw new TypeError(`agent eval: ${what} must be a bounded token`)
380
+ }
381
+
382
+ function assertOnlyKeys(record: Record<string, unknown>, allowed: readonly string[]): void {
383
+ for (const key of Object.keys(record))
384
+ if (!allowed.includes(key))
385
+ throw new TypeError(`agent eval: verdict carries unexpected key '${key}'`)
386
+ }
387
+
388
+ function validateRubric(rubric: RubricSpec): void {
389
+ assertToken(rubric.id, "rubric id")
390
+ if (rubric.outcomes.length < 1 || rubric.outcomes.length > MAX_OUTCOMES)
391
+ throw new RangeError(`agent eval: rubric must declare 1-${MAX_OUTCOMES} outcomes`)
392
+ const seen = new Set<string>()
393
+ for (const outcome of rubric.outcomes) {
394
+ assertToken(outcome, "outcome code")
395
+ if (seen.has(outcome)) throw new TypeError(`agent eval: duplicate outcome '${outcome}'`)
396
+ seen.add(outcome)
397
+ }
398
+ if (!Number.isFinite(rubric.score.min) || !Number.isFinite(rubric.score.max))
399
+ throw new TypeError("agent eval: rubric score range must be finite")
400
+ if (rubric.score.min > rubric.score.max)
401
+ throw new RangeError("agent eval: rubric score min must not exceed max")
402
+ if (rubric.metrics !== undefined) {
403
+ if (rubric.metrics.length > MAX_METRICS)
404
+ throw new RangeError(`agent eval: rubric declares more than ${MAX_METRICS} metrics`)
405
+ const metricSeen = new Set<string>()
406
+ for (const metric of rubric.metrics) {
407
+ assertToken(metric, "metric name")
408
+ if (metricSeen.has(metric)) throw new TypeError(`agent eval: duplicate metric '${metric}'`)
409
+ metricSeen.add(metric)
410
+ }
411
+ }
412
+ }
413
+
414
+ /** Parse one verdict against its rubric. Fails closed on unknown outcome, bad range, or stray key. */
415
+ export function parseRubricVerdict(rubric: RubricSpec, value: unknown): RubricVerdict {
416
+ if (!isRecord(value)) throw new TypeError("agent eval: verdict must be an object")
417
+ assertOnlyKeys(value, ["rubricId", "outcome", "score", "metrics"])
418
+ if (value.rubricId !== rubric.id)
419
+ throw new TypeError(
420
+ `agent eval: verdict rubricId '${String(value.rubricId)}' != '${rubric.id}'`,
421
+ )
422
+ if (typeof value.outcome !== "string" || !rubric.outcomes.includes(value.outcome))
423
+ throw new TypeError(
424
+ `agent eval: unknown outcome '${String(value.outcome)}' for rubric ${rubric.id}`,
425
+ )
426
+ if (typeof value.score !== "number" || !Number.isFinite(value.score))
427
+ throw new TypeError(`agent eval: verdict score for ${rubric.id} must be a finite number`)
428
+ if (value.score < rubric.score.min || value.score > rubric.score.max)
429
+ throw new RangeError(
430
+ `agent eval: score ${value.score} out of [${rubric.score.min}, ${rubric.score.max}] for ${rubric.id}`,
431
+ )
432
+ let metrics: Record<string, number> | undefined
433
+ if (value.metrics !== undefined) {
434
+ if (!isRecord(value.metrics))
435
+ throw new TypeError("agent eval: verdict metrics must be an object")
436
+ const allowed = new Set(rubric.metrics ?? [])
437
+ const out: Record<string, number> = {}
438
+ for (const [key, metricValue] of Object.entries(value.metrics)) {
439
+ if (!allowed.has(key))
440
+ throw new TypeError(`agent eval: unknown metric '${key}' for ${rubric.id}`)
441
+ if (typeof metricValue !== "number" || !Number.isFinite(metricValue))
442
+ throw new TypeError(`agent eval: metric '${key}' must be a finite number`)
443
+ out[key] = metricValue
444
+ }
445
+ metrics = out
446
+ }
447
+ return Object.freeze({
448
+ rubricId: rubric.id,
449
+ outcome: value.outcome,
450
+ score: value.score,
451
+ ...(metrics !== undefined ? { metrics: Object.freeze(metrics) } : {}),
452
+ })
453
+ }
454
+
455
+ // ── Suite construction and execution ──────────────────────────────────────────────────────────
456
+
457
+ /** Declare a bounded, deterministic eval suite. Duplicate case or rubric ids fail closed. */
458
+ export function defineAgentEvalSuite(spec: AgentEvalSuiteSpec): AgentEvalSuite {
459
+ assertToken(spec.id, "suite id")
460
+ const version = spec.version ?? 1
461
+ if (!Number.isInteger(version) || version < 1)
462
+ throw new RangeError("agent eval: suite version must be a positive integer")
463
+ if (spec.rubrics.length < 1 || spec.rubrics.length > MAX_RUBRICS)
464
+ throw new RangeError(`agent eval: suite must declare 1-${MAX_RUBRICS} rubrics`)
465
+ const rubricById = new Map<string, RubricSpec>()
466
+ for (const rubric of spec.rubrics) {
467
+ validateRubric(rubric)
468
+ if (rubricById.has(rubric.id))
469
+ throw new TypeError(`agent eval: duplicate rubric id '${rubric.id}'`)
470
+ rubricById.set(rubric.id, rubric)
471
+ }
472
+ if (spec.cases.length < 1 || spec.cases.length > MAX_CASES)
473
+ throw new RangeError(`agent eval: suite must declare 1-${MAX_CASES} cases`)
474
+ const caseById = new Map<string, AgentEvalCase>()
475
+ for (const testCase of spec.cases) {
476
+ assertToken(testCase.id, "case id")
477
+ if (caseById.has(testCase.id))
478
+ throw new TypeError(`agent eval: duplicate case id '${testCase.id}'`)
479
+ caseById.set(testCase.id, testCase)
480
+ }
481
+
482
+ const caseIds = [...caseById.keys()].sort()
483
+ const rubricIds = [...rubricById.keys()].sort()
484
+
485
+ return Object.freeze({
486
+ id: spec.id,
487
+ version,
488
+ caseIds: Object.freeze(caseIds),
489
+ rubricIds: Object.freeze(rubricIds),
490
+ async run(): Promise<AgentEvalReport> {
491
+ const rubricShapes = await Promise.all(
492
+ rubricIds.map((id) => shapeOf(rubricById.get(id) as RubricSpec)),
493
+ )
494
+ const cases: CaseResult[] = []
495
+ for (const caseId of caseIds) {
496
+ const produced = await (caseById.get(caseId) as AgentEvalCase).evaluate()
497
+ const perRubric = new Map<string, RubricVerdict>()
498
+ for (const raw of produced) {
499
+ if (!isRecord(raw) || typeof raw.rubricId !== "string")
500
+ throw new TypeError(
501
+ `agent eval: case '${caseId}' produced a verdict without a rubricId`,
502
+ )
503
+ const rubric = rubricById.get(raw.rubricId)
504
+ if (rubric === undefined)
505
+ throw new TypeError(
506
+ `agent eval: case '${caseId}' scored unknown rubric '${raw.rubricId}'`,
507
+ )
508
+ if (perRubric.has(rubric.id))
509
+ throw new TypeError(`agent eval: case '${caseId}' scored rubric '${rubric.id}' twice`)
510
+ perRubric.set(rubric.id, parseRubricVerdict(rubric, raw))
511
+ }
512
+ const verdicts = [...perRubric.values()].sort((a, b) =>
513
+ a.rubricId.localeCompare(b.rubricId),
514
+ )
515
+ cases.push({
516
+ caseId,
517
+ verdicts: Object.freeze(verdicts),
518
+ digest: await sha256(canonical({ caseId, verdicts })),
519
+ })
520
+ }
521
+ const digest = await sha256(
522
+ canonical({
523
+ suiteId: spec.id,
524
+ suiteVersion: version,
525
+ rubricShapes,
526
+ cases: cases.map((entry) => ({ caseId: entry.caseId, digest: entry.digest })),
527
+ }),
528
+ )
529
+ return Object.freeze({
530
+ version: 1 as const,
531
+ suiteId: spec.id,
532
+ suiteVersion: version,
533
+ rubricShapes: Object.freeze(rubricShapes),
534
+ cases: Object.freeze(cases),
535
+ digest,
536
+ })
537
+ },
538
+ })
539
+ }
540
+
541
+ async function shapeOf(rubric: RubricSpec): Promise<RubricShape> {
542
+ const higherIsBetter = rubric.higherIsBetter ?? true
543
+ const projection = {
544
+ rubricId: rubric.id,
545
+ outcomes: rubric.outcomes,
546
+ scoreMin: rubric.score.min,
547
+ scoreMax: rubric.score.max,
548
+ higherIsBetter,
549
+ }
550
+ return Object.freeze({ ...projection, digest: await sha256(canonical(projection)) })
551
+ }
552
+
553
+ // ── Baseline comparison ───────────────────────────────────────────────────────────────────────
554
+
555
+ /** Compare a fresh report against a baseline report. Every (case, rubric) gets a stable id. */
556
+ export async function compareAgentEvalBaseline(
557
+ baseline: AgentEvalReport,
558
+ current: AgentEvalReport,
559
+ options: BaselineOptions = {},
560
+ ): Promise<BaselineComparison> {
561
+ const baseShapes = new Map(baseline.rubricShapes.map((shape) => [shape.rubricId, shape]))
562
+ const curShapes = new Map(current.rubricShapes.map((shape) => [shape.rubricId, shape]))
563
+ const baseVerdicts = indexVerdicts(baseline)
564
+ const curVerdicts = indexVerdicts(current)
565
+
566
+ const keys = new Set<string>([...baseVerdicts.keys(), ...curVerdicts.keys()])
567
+ const comparisons: CaseComparison[] = []
568
+ for (const key of [...keys].sort()) {
569
+ const [caseId, rubricId] = key.split("") as [string, string]
570
+ const regressionId = `${current.suiteId}/${caseId}/${rubricId}`
571
+ const base = baseVerdicts.get(key)
572
+ const cur = curVerdicts.get(key)
573
+ const code = classify(
574
+ base,
575
+ cur,
576
+ baseShapes.get(rubricId),
577
+ curShapes.get(rubricId),
578
+ rubricId,
579
+ options,
580
+ )
581
+ comparisons.push(
582
+ Object.freeze({
583
+ caseId,
584
+ rubricId,
585
+ code,
586
+ regressionId,
587
+ ...(base !== undefined ? { baselineScore: base.score } : {}),
588
+ ...(cur !== undefined ? { currentScore: cur.score } : {}),
589
+ }),
590
+ )
591
+ }
592
+ const failOn = new Set(options.failOn ?? ["regressed"])
593
+ const regressions = comparisons
594
+ .filter((entry) => failOn.has(entry.code))
595
+ .map((entry) => entry.regressionId)
596
+ const digest = await sha256(canonical({ suiteId: current.suiteId, comparisons }))
597
+ return Object.freeze({
598
+ suiteId: current.suiteId,
599
+ comparisons: Object.freeze(comparisons),
600
+ regressions: Object.freeze(regressions),
601
+ digest,
602
+ })
603
+ }
604
+
605
+ /** Assert no failing comparison. Throws {@link AgentEvalRegressionError} with the stable ids. */
606
+ export async function assertAgentEvalBaseline(
607
+ baseline: AgentEvalReport,
608
+ current: AgentEvalReport,
609
+ options: BaselineOptions = {},
610
+ ): Promise<BaselineComparison> {
611
+ const comparison = await compareAgentEvalBaseline(baseline, current, options)
612
+ if (comparison.regressions.length > 0) throw new AgentEvalRegressionError(comparison)
613
+ return comparison
614
+ }
615
+
616
+ function indexVerdicts(report: AgentEvalReport): Map<string, RubricVerdict> {
617
+ const map = new Map<string, RubricVerdict>()
618
+ for (const entry of report.cases)
619
+ for (const verdict of entry.verdicts) map.set(`${entry.caseId}${verdict.rubricId}`, verdict)
620
+ return map
621
+ }
622
+
623
+ function classify(
624
+ base: RubricVerdict | undefined,
625
+ cur: RubricVerdict | undefined,
626
+ baseShape: RubricShape | undefined,
627
+ curShape: RubricShape | undefined,
628
+ rubricId: string,
629
+ options: BaselineOptions,
630
+ ): ComparisonCode {
631
+ if (base === undefined || cur === undefined) return "missing"
632
+ if (baseShape === undefined || curShape === undefined || baseShape.digest !== curShape.digest)
633
+ return "incomparable"
634
+ const baseRank = curShape.outcomes.indexOf(base.outcome)
635
+ const curRank = curShape.outcomes.indexOf(cur.outcome)
636
+ if (baseRank !== curRank) return curRank > baseRank ? "improved" : "regressed"
637
+
638
+ // Same outcome: compare the score in the rubric's better-direction.
639
+ if (cur.score === base.score) return "equal"
640
+ const better = curShape.higherIsBetter ? cur.score > base.score : cur.score < base.score
641
+ if (better) return "improved"
642
+ const worseBy = Math.abs(cur.score - base.score)
643
+ const tolerance = resolveTolerance(rubricId, options)
644
+ const allowed = Math.max(tolerance.abs, tolerance.rel * Math.abs(base.score))
645
+ return worseBy <= allowed ? "tolerated" : "regressed"
646
+ }
647
+
648
+ function resolveTolerance(
649
+ rubricId: string,
650
+ options: BaselineOptions,
651
+ ): { readonly abs: number; readonly rel: number } {
652
+ let abs = 0
653
+ let rel = 0
654
+ for (const tolerance of options.tolerances ?? []) {
655
+ if (tolerance.rubricId !== undefined && tolerance.rubricId !== rubricId) continue
656
+ if (tolerance.abs !== undefined) abs = Math.max(abs, tolerance.abs)
657
+ if (tolerance.rel !== undefined) rel = Math.max(rel, tolerance.rel)
658
+ }
659
+ return { abs, rel }
660
+ }