@nifrajs/testing 2.14.1 → 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.
- package/README.md +8 -0
- package/dist/agent-certification.d.ts +41 -0
- package/dist/agent-certification.d.ts.map +1 -0
- package/dist/agent-certification.js +242 -0
- package/dist/agent-certification.js.map +1 -0
- package/dist/agent-eval.d.ts +192 -0
- package/dist/agent-eval.d.ts.map +1 -0
- package/dist/agent-eval.js +410 -0
- package/dist/agent-eval.js.map +1 -0
- package/dist/contract-lab.d.ts +43 -0
- package/dist/contract-lab.d.ts.map +1 -0
- package/dist/contract-lab.js +139 -0
- package/dist/contract-lab.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +11 -6
- package/src/agent-certification.ts +296 -0
- package/src/agent-eval.ts +660 -0
- package/src/contract-lab.ts +196 -0
- package/src/index.ts +52 -0
|
@@ -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
|
+
}
|