@sembl/testing 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sembl contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # @sembl/testing
2
+
3
+ Deterministic tests for code built on SEMBL, and a way to measure whether a
4
+ prompt or schema change helped.
5
+
6
+ ```sh
7
+ pnpm add -D @sembl/testing
8
+ ```
9
+
10
+ ## Record and replay
11
+
12
+ ```ts
13
+ import { replayOrRecord } from "@sembl/testing";
14
+ import { AnthropicProvider } from "@sembl/provider-anthropic";
15
+
16
+ const live = process.env.ANTHROPIC_API_KEY
17
+ ? new AnthropicProvider({ model: "claude-sonnet-5", apiKey: process.env.ANTHROPIC_API_KEY })
18
+ : undefined;
19
+
20
+ // Replays from ./recordings; records misses through `live` when it exists.
21
+ const provider = replayOrRecord("./recordings", live);
22
+ ```
23
+
24
+ `RecordingProvider(inner, dir)` writes every request/response pair to one
25
+ JSON file, named by schema id and a hash of what reached the model — the
26
+ system prompt, the user input and the JSON Schema. `ReplayProvider(dir)`
27
+ answers from those files and never touches the network; a request with no
28
+ recording throws `ReplayMissError`, which is what you want in CI: a miss
29
+ means a fixture or a description changed and nobody re-recorded.
30
+
31
+ Because the key covers everything the model sees, editing a field
32
+ description invalidates only the recordings it affects.
33
+
34
+ ## Eval harness
35
+
36
+ Nobody can tell whether a description change helped or hurt without
37
+ measuring. Fixtures are `{ input, expected }` pairs in a directory:
38
+
39
+ ```json
40
+ // evals/listing/sea-cabin.json
41
+ {
42
+ "input": { "file": "sea-cabin.html", "label": "Airbnb listing" },
43
+ "expected": { "name": "Sea Cabin", "sleeps": 6, "amenities": ["sauna", "hot tub"] }
44
+ }
45
+ ```
46
+
47
+ A file may hold one fixture or an array; an input may be a string, a
48
+ labelled source, a list of sources, or `{ "file": … }` to read a sibling
49
+ file. Then either call the harness:
50
+
51
+ ```ts
52
+ import { runEval, loadFixtures, formatReport } from "@sembl/testing";
53
+
54
+ const report = await runEval({
55
+ fixtures: loadFixtures("./evals/listing"),
56
+ schema: Listing,
57
+ provider,
58
+ mode: "partialCoerce",
59
+ prices: { inputPerMTok: 3, outputPerMTok: 15, cacheReadPerMTok: 0.3 },
60
+ });
61
+ console.log(formatReport(report));
62
+ ```
63
+
64
+ or run it from the CLI with a config module that exports the schema and the
65
+ provider:
66
+
67
+ ```js
68
+ // sembl.eval.mjs
69
+ export { Listing as schema } from "./dist/schemas.js";
70
+ export const provider = new AnthropicProvider({ model: "claude-sonnet-5", apiKey: process.env.ANTHROPIC_API_KEY });
71
+ export const prices = { inputPerMTok: 3, outputPerMTok: 15 };
72
+ export const coerceOptions = { onInvalidField: "clamp", maxInputChars: 40_000 };
73
+ ```
74
+
75
+ ```sh
76
+ sembl eval --config sembl.eval.mjs --fixtures ./evals/listing --replay ./evals/listing/recordings
77
+ ```
78
+
79
+ The report gives per-field precision and recall — a wrong value counts
80
+ against both, a missing one against recall, an unexpected one against
81
+ precision — plus token usage, cost when prices are given, and latency
82
+ percentiles. Every run is written to `<fixtures>/.sembl-eval/last-run.json`
83
+ (or `--out`) and the next run prints deltas against it, so a change shows up
84
+ as `R -20pt` on the field it broke. `--replay` records the first run and
85
+ replays it afterwards, which makes evals free and deterministic in CI;
86
+ `--min-recall` and `--min-precision` turn a regression into a failing exit
87
+ code. Arrays of primitives compare as sets, since the order amenities come
88
+ back in is not a fact about the listing.
package/dist/index.cjs ADDED
@@ -0,0 +1,521 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ RecordingProvider: () => RecordingProvider,
24
+ ReplayMissError: () => ReplayMissError,
25
+ ReplayProvider: () => ReplayProvider,
26
+ compareLeaves: () => compareLeaves,
27
+ diffReports: () => diffReports,
28
+ estimateCost: () => estimateCost,
29
+ fieldStats: () => fieldStats,
30
+ flattenLeaves: () => flattenLeaves,
31
+ formatReport: () => formatReport,
32
+ leavesEqual: () => leavesEqual,
33
+ loadFixtures: () => loadFixtures,
34
+ loadReport: () => loadReport,
35
+ recordingKey: () => recordingKey,
36
+ recordingPath: () => recordingPath,
37
+ replayOrRecord: () => replayOrRecord,
38
+ runEval: () => runEval,
39
+ saveReport: () => saveReport
40
+ });
41
+ module.exports = __toCommonJS(index_exports);
42
+
43
+ // src/replay.ts
44
+ var import_node_crypto = require("crypto");
45
+ var import_node_fs = require("fs");
46
+ var import_node_path = require("path");
47
+ function stable(value) {
48
+ return JSON.stringify(
49
+ value,
50
+ (_key, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : v
51
+ );
52
+ }
53
+ function recordingKey(request) {
54
+ const material = stable({
55
+ systemPrompt: request.systemPrompt,
56
+ userInput: request.userInput,
57
+ jsonSchema: request.jsonSchema
58
+ });
59
+ return (0, import_node_crypto.createHash)("sha256").update(material).digest("hex").slice(0, 24);
60
+ }
61
+ function slug(id) {
62
+ return id.replace(/[^A-Za-z0-9_-]+/g, "_").slice(0, 40) || "schema";
63
+ }
64
+ function recordingPath(dir, request) {
65
+ return (0, import_node_path.join)(dir, `${slug(request.schema.id)}.${recordingKey(request)}.json`);
66
+ }
67
+ var ReplayMissError = class extends Error {
68
+ constructor(key, dir, schemaId) {
69
+ super(
70
+ `No recording for a "${schemaId}" request (key ${key}) in ${dir}. Run once with a RecordingProvider, or pass a fallback provider to record misses.`
71
+ );
72
+ this.key = key;
73
+ this.dir = dir;
74
+ this.schemaId = schemaId;
75
+ this.name = "ReplayMissError";
76
+ }
77
+ };
78
+ var RecordingProvider = class {
79
+ constructor(inner, dir) {
80
+ this.inner = inner;
81
+ this.dir = dir;
82
+ (0, import_node_fs.mkdirSync)(dir, { recursive: true });
83
+ }
84
+ async complete(request) {
85
+ const response = await this.inner.complete(request);
86
+ const recording = {
87
+ key: recordingKey(request),
88
+ schemaId: request.schema.id,
89
+ request: {
90
+ systemPrompt: request.systemPrompt,
91
+ userInput: request.userInput,
92
+ jsonSchema: request.jsonSchema
93
+ },
94
+ response,
95
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
96
+ };
97
+ (0, import_node_fs.writeFileSync)(recordingPath(this.dir, request), JSON.stringify(recording, null, 2) + "\n");
98
+ return response;
99
+ }
100
+ };
101
+ var ReplayProvider = class {
102
+ constructor(dir, options = {}) {
103
+ this.dir = dir;
104
+ this.recorder = options.fallback ? new RecordingProvider(options.fallback, dir) : void 0;
105
+ }
106
+ recorder;
107
+ async complete(request) {
108
+ const path = recordingPath(this.dir, request);
109
+ if ((0, import_node_fs.existsSync)(path)) {
110
+ const recording = JSON.parse((0, import_node_fs.readFileSync)(path, "utf8"));
111
+ return recording.response;
112
+ }
113
+ if (this.recorder) {
114
+ return this.recorder.complete(request);
115
+ }
116
+ throw new ReplayMissError(recordingKey(request), this.dir, request.schema.id);
117
+ }
118
+ /** How many recordings the directory holds. */
119
+ size() {
120
+ if (!(0, import_node_fs.existsSync)(this.dir)) return 0;
121
+ return (0, import_node_fs.readdirSync)(this.dir).filter((f) => f.endsWith(".json")).length;
122
+ }
123
+ };
124
+ function replayOrRecord(dir, live) {
125
+ return new ReplayProvider(dir, live ? { fallback: live } : {});
126
+ }
127
+
128
+ // src/eval.ts
129
+ var import_node_async_hooks = require("async_hooks");
130
+ var import_node_fs2 = require("fs");
131
+ var import_node_path2 = require("path");
132
+ var import_core = require("@sembl/core");
133
+ function isFileInput(value) {
134
+ return typeof value === "object" && value !== null && typeof value.file === "string";
135
+ }
136
+ function resolveInput(input, baseDir) {
137
+ const one = (value) => {
138
+ if (typeof value === "string") return value;
139
+ if (isFileInput(value)) {
140
+ const text = (0, import_node_fs2.readFileSync)((0, import_node_path2.resolve)(baseDir, value.file), "utf8");
141
+ return value.label ? { label: value.label, text } : { text };
142
+ }
143
+ return value;
144
+ };
145
+ if (Array.isArray(input)) {
146
+ return input.map((v) => {
147
+ const r = one(v);
148
+ return typeof r === "string" ? { text: r } : r;
149
+ });
150
+ }
151
+ return one(input);
152
+ }
153
+ function loadFixtures(dir) {
154
+ const root = (0, import_node_path2.resolve)(dir);
155
+ if (!(0, import_node_fs2.existsSync)(root)) {
156
+ throw new Error(`Fixture directory not found: ${root}`);
157
+ }
158
+ const fixtures = [];
159
+ const files = (0, import_node_fs2.readdirSync)(root).filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort();
160
+ for (const file of files) {
161
+ const path = (0, import_node_path2.join)(root, file);
162
+ const parsed = JSON.parse((0, import_node_fs2.readFileSync)(path, "utf8"));
163
+ const list = Array.isArray(parsed) ? parsed : [parsed];
164
+ list.forEach((raw, index) => {
165
+ const fixture = raw;
166
+ if (!fixture || typeof fixture !== "object" || !("input" in fixture) || !("expected" in fixture)) {
167
+ throw new Error(`${path}${list.length > 1 ? `[${index}]` : ""}: a fixture needs "input" and "expected"`);
168
+ }
169
+ const stem = (0, import_node_path2.basename)(file, ".json");
170
+ fixtures.push({
171
+ name: fixture.name ?? (list.length > 1 ? `${stem}[${index}]` : stem),
172
+ input: resolveInput(fixture.input, (0, import_node_path2.dirname)(path)),
173
+ expected: fixture.expected
174
+ });
175
+ });
176
+ }
177
+ return fixtures;
178
+ }
179
+ function stable2(value) {
180
+ return JSON.stringify(
181
+ value,
182
+ (_k, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : v
183
+ );
184
+ }
185
+ function isPrimitive(value) {
186
+ return value === null || typeof value !== "object";
187
+ }
188
+ function flattenLeaves(value, prefix = "") {
189
+ const leaves = /* @__PURE__ */ new Map();
190
+ if (value === null || value === void 0) return leaves;
191
+ if (typeof value !== "object" || Array.isArray(value)) {
192
+ leaves.set(prefix, value);
193
+ return leaves;
194
+ }
195
+ for (const [key, child] of Object.entries(value)) {
196
+ const path = prefix ? `${prefix}.${key}` : key;
197
+ for (const [leafPath, leaf] of flattenLeaves(child, path)) {
198
+ leaves.set(leafPath, leaf);
199
+ }
200
+ }
201
+ return leaves;
202
+ }
203
+ function leavesEqual(a, b) {
204
+ if (Array.isArray(a) && Array.isArray(b)) {
205
+ if (a.length !== b.length) return false;
206
+ if (a.every(isPrimitive) && b.every(isPrimitive)) {
207
+ const sortedA = [...a].map(String).sort();
208
+ const sortedB = [...b].map(String).sort();
209
+ return sortedA.every((v, i) => v === sortedB[i]);
210
+ }
211
+ }
212
+ return stable2(a) === stable2(b);
213
+ }
214
+ function compareLeaves(expected, actual, provenance = {}) {
215
+ const want = flattenLeaves(expected);
216
+ const got = flattenLeaves(actual ?? {});
217
+ const paths = [.../* @__PURE__ */ new Set([...want.keys(), ...got.keys()])].sort();
218
+ return paths.map((path) => {
219
+ const top = path.split(".")[0];
220
+ const confidence = provenance[top]?.confidence;
221
+ const result = { path, outcome: "match" };
222
+ if (confidence) result.confidence = confidence;
223
+ if (want.has(path) && got.has(path)) {
224
+ result.expected = want.get(path);
225
+ result.actual = got.get(path);
226
+ result.outcome = leavesEqual(want.get(path), got.get(path)) ? "match" : "wrong";
227
+ } else if (want.has(path)) {
228
+ result.expected = want.get(path);
229
+ result.outcome = "missing";
230
+ } else {
231
+ result.actual = got.get(path);
232
+ result.outcome = "extra";
233
+ }
234
+ return result;
235
+ });
236
+ }
237
+ function ratio(num, den) {
238
+ return den === 0 ? null : num / den;
239
+ }
240
+ function fieldStats(items) {
241
+ const byPath = /* @__PURE__ */ new Map();
242
+ for (const item of items) {
243
+ for (const leaf of item.leaves) {
244
+ const stats = byPath.get(leaf.path) ?? { tp: 0, fp: 0, fn: 0 };
245
+ switch (leaf.outcome) {
246
+ case "match":
247
+ stats.tp += 1;
248
+ break;
249
+ case "wrong":
250
+ stats.fp += 1;
251
+ stats.fn += 1;
252
+ break;
253
+ case "missing":
254
+ stats.fn += 1;
255
+ break;
256
+ case "extra":
257
+ stats.fp += 1;
258
+ break;
259
+ }
260
+ byPath.set(leaf.path, stats);
261
+ }
262
+ }
263
+ return [...byPath.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([path, { tp, fp, fn }]) => ({
264
+ path,
265
+ tp,
266
+ fp,
267
+ fn,
268
+ precision: ratio(tp, tp + fp),
269
+ recall: ratio(tp, tp + fn)
270
+ }));
271
+ }
272
+ function percentile(sorted, p) {
273
+ if (sorted.length === 0) return 0;
274
+ const index = Math.min(sorted.length - 1, Math.ceil(p / 100 * sorted.length) - 1);
275
+ return sorted[Math.max(0, index)];
276
+ }
277
+ function emptyUsage() {
278
+ return { promptTokens: 0, completionTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
279
+ }
280
+ function addUsage(into, usage) {
281
+ if (!usage) return;
282
+ into.promptTokens += usage.promptTokens;
283
+ into.completionTokens += usage.completionTokens;
284
+ into.cacheReadTokens += usage.cacheReadTokens ?? 0;
285
+ into.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
286
+ }
287
+ function estimateCost(usage, prices) {
288
+ const per = (tokens, price) => tokens / 1e6 * price;
289
+ return per(usage.promptTokens, prices.inputPerMTok) + per(usage.completionTokens, prices.outputPerMTok) + per(usage.cacheReadTokens, prices.cacheReadPerMTok ?? prices.inputPerMTok) + per(usage.cacheWriteTokens, prices.cacheWritePerMTok ?? prices.inputPerMTok);
290
+ }
291
+ var context = new import_node_async_hooks.AsyncLocalStorage();
292
+ var MeteredProvider = class {
293
+ constructor(inner) {
294
+ this.inner = inner;
295
+ }
296
+ async complete(request) {
297
+ const response = await this.inner.complete(request);
298
+ const ctx = context.getStore();
299
+ if (ctx) {
300
+ ctx.calls += 1;
301
+ addUsage(ctx.usage, response.usage);
302
+ }
303
+ return response;
304
+ }
305
+ };
306
+ async function mapWithConcurrency(items, concurrency, fn) {
307
+ const results = new Array(items.length);
308
+ let next = 0;
309
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
310
+ while (next < items.length) {
311
+ const index = next++;
312
+ results[index] = await fn(items[index], index);
313
+ }
314
+ });
315
+ await Promise.all(workers);
316
+ return results;
317
+ }
318
+ async function runEval(options) {
319
+ const {
320
+ fixtures,
321
+ mode = "coerce",
322
+ provenance = false,
323
+ concurrency = 1,
324
+ prices,
325
+ provider,
326
+ ...coerceOptions
327
+ } = options;
328
+ const metered = new MeteredProvider(provider);
329
+ const run = mode === "coerce" ? provenance ? import_core.coerceWithProvenance : import_core.coerce : provenance ? import_core.partialCoerceWithProvenance : import_core.partialCoerce;
330
+ const items = await mapWithConcurrency(fixtures, concurrency, async (fixture, index) => {
331
+ const ctx = { usage: emptyUsage(), calls: 0 };
332
+ const name = fixture.name ?? `fixture ${index + 1}`;
333
+ const started = performance.now();
334
+ return context.run(ctx, async () => {
335
+ try {
336
+ const result = await run(fixture.input, {
337
+ ...coerceOptions,
338
+ provider: metered
339
+ });
340
+ const data = provenance ? result.data : result;
341
+ const prov = provenance ? result.provenance : {};
342
+ const issues = provenance ? result.issues : [];
343
+ const leaves = compareLeaves(fixture.expected, data, prov);
344
+ return {
345
+ name,
346
+ ok: true,
347
+ exact: leaves.every((l) => l.outcome === "match"),
348
+ leaves,
349
+ issues,
350
+ latencyMs: Math.round(performance.now() - started),
351
+ calls: ctx.calls,
352
+ usage: ctx.usage
353
+ };
354
+ } catch (error) {
355
+ return {
356
+ name,
357
+ ok: false,
358
+ error: error instanceof Error ? error.message : String(error),
359
+ exact: false,
360
+ leaves: compareLeaves(fixture.expected, void 0),
361
+ issues: [],
362
+ latencyMs: Math.round(performance.now() - started),
363
+ calls: ctx.calls,
364
+ usage: ctx.usage
365
+ };
366
+ }
367
+ });
368
+ });
369
+ const fields = fieldStats(items);
370
+ const tp = fields.reduce((s, f) => s + f.tp, 0);
371
+ const fp = fields.reduce((s, f) => s + f.fp, 0);
372
+ const fn = fields.reduce((s, f) => s + f.fn, 0);
373
+ const usage = emptyUsage();
374
+ for (const item of items) {
375
+ usage.promptTokens += item.usage.promptTokens;
376
+ usage.completionTokens += item.usage.completionTokens;
377
+ usage.cacheReadTokens += item.usage.cacheReadTokens;
378
+ usage.cacheWriteTokens += item.usage.cacheWriteTokens;
379
+ }
380
+ const latencies = items.map((i) => i.latencyMs).sort((a, b) => a - b);
381
+ return {
382
+ schemaId: options.schema.id,
383
+ mode,
384
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
385
+ items,
386
+ fields,
387
+ totals: {
388
+ fixtures: items.length,
389
+ ok: items.filter((i) => i.ok).length,
390
+ exact: items.filter((i) => i.exact).length,
391
+ precision: ratio(tp, tp + fp),
392
+ recall: ratio(tp, tp + fn),
393
+ usage,
394
+ calls: items.reduce((s, i) => s + i.calls, 0),
395
+ ...prices ? { cost: estimateCost(usage, prices) } : {},
396
+ latencyMs: {
397
+ p50: percentile(latencies, 50),
398
+ p95: percentile(latencies, 95),
399
+ max: latencies[latencies.length - 1] ?? 0,
400
+ total: latencies.reduce((s, l) => s + l, 0)
401
+ }
402
+ }
403
+ };
404
+ }
405
+ function saveReport(report, file) {
406
+ (0, import_node_fs2.mkdirSync)((0, import_node_path2.dirname)((0, import_node_path2.resolve)(file)), { recursive: true });
407
+ (0, import_node_fs2.writeFileSync)((0, import_node_path2.resolve)(file), JSON.stringify(report, null, 2) + "\n");
408
+ }
409
+ function loadReport(file) {
410
+ const path = (0, import_node_path2.resolve)(file);
411
+ if (!(0, import_node_fs2.existsSync)(path)) return void 0;
412
+ return JSON.parse((0, import_node_fs2.readFileSync)(path, "utf8"));
413
+ }
414
+ function delta(a, b) {
415
+ return a === null || a === void 0 || b === null || b === void 0 ? null : b - a;
416
+ }
417
+ function diffReports(previous, next) {
418
+ const prevFields = new Map(previous.fields.map((f) => [f.path, f]));
419
+ const fields = [];
420
+ for (const field of next.fields) {
421
+ const before = prevFields.get(field.path);
422
+ const precision = delta(before?.precision, field.precision);
423
+ const recall = delta(before?.recall, field.recall);
424
+ if (precision !== null && precision !== 0 || recall !== null && recall !== 0) {
425
+ fields.push({ path: field.path, precision, recall });
426
+ }
427
+ }
428
+ fields.sort((a, b) => Math.min(a.precision ?? 0, a.recall ?? 0) - Math.min(b.precision ?? 0, b.recall ?? 0));
429
+ return {
430
+ exact: next.totals.exact - previous.totals.exact,
431
+ ok: next.totals.ok - previous.totals.ok,
432
+ precision: delta(previous.totals.precision, next.totals.precision),
433
+ recall: delta(previous.totals.recall, next.totals.recall),
434
+ promptTokens: next.totals.usage.promptTokens - previous.totals.usage.promptTokens,
435
+ completionTokens: next.totals.usage.completionTokens - previous.totals.usage.completionTokens,
436
+ ...next.totals.cost !== void 0 && previous.totals.cost !== void 0 ? { cost: next.totals.cost - previous.totals.cost } : {},
437
+ p50Ms: next.totals.latencyMs.p50 - previous.totals.latencyMs.p50,
438
+ fields
439
+ };
440
+ }
441
+ function pct(value) {
442
+ return value === null ? " \u2013 " : `${(value * 100).toFixed(0).padStart(3)}%`;
443
+ }
444
+ function signed(value, digits = 0, suffix = "") {
445
+ if (value === null || value === 0) return "";
446
+ const text = digits > 0 ? value.toFixed(digits) : String(value);
447
+ return ` (${value > 0 ? "+" : ""}${text}${suffix})`;
448
+ }
449
+ function signedPct(value) {
450
+ return value === null || value === 0 ? "" : ` (${value > 0 ? "+" : ""}${(value * 100).toFixed(0)}pt)`;
451
+ }
452
+ function formatReport(report, diff) {
453
+ const t = report.totals;
454
+ const lines = [];
455
+ lines.push(
456
+ `Eval: ${report.schemaId} (${report.mode}) \u2014 ${t.fixtures} fixture(s), ${t.ok} ran${signed(diff?.ok ?? null)}, ${t.exact} exact${signed(diff?.exact ?? null)}`
457
+ );
458
+ lines.push("");
459
+ const width = Math.max(5, ...report.fields.map((f) => f.path.length));
460
+ const deltas = new Map((diff?.fields ?? []).map((f) => [f.path, f]));
461
+ lines.push(`${"Field".padEnd(width)} Prec Recall tp fp fn \u0394`);
462
+ for (const field of report.fields) {
463
+ const d = deltas.get(field.path);
464
+ const change = d ? [d.precision !== null && d.precision !== 0 ? `P${signedPct(d.precision).trim()}` : "", d.recall !== null && d.recall !== 0 ? `R${signedPct(d.recall).trim()}` : ""].filter(Boolean).join(" ") : "";
465
+ lines.push(
466
+ `${field.path.padEnd(width)} ${pct(field.precision)} ${pct(field.recall)} ${String(field.tp).padStart(2)} ${String(field.fp).padStart(2)} ${String(field.fn).padStart(2)} ${change}`
467
+ );
468
+ }
469
+ lines.push("");
470
+ lines.push(
471
+ `Overall: precision ${pct(t.precision).trim()}${signedPct(diff?.precision ?? null)}, recall ${pct(t.recall).trim()}${signedPct(diff?.recall ?? null)}`
472
+ );
473
+ const u = t.usage;
474
+ const cache = u.cacheReadTokens || u.cacheWriteTokens ? `, cache read ${u.cacheReadTokens.toLocaleString("en-US")} / write ${u.cacheWriteTokens.toLocaleString("en-US")}` : "";
475
+ lines.push(
476
+ `Tokens: ${u.promptTokens.toLocaleString("en-US")} prompt${signed(diff?.promptTokens ?? null)} / ${u.completionTokens.toLocaleString("en-US")} completion${signed(diff?.completionTokens ?? null)}${cache}; ${t.calls} call(s)` + (t.cost !== void 0 ? `; cost $${t.cost.toFixed(4)}${signed(diff?.cost ?? null, 4)}` : "")
477
+ );
478
+ lines.push(
479
+ `Latency: p50 ${t.latencyMs.p50}ms${signed(diff?.p50Ms ?? null, 0, "ms")}, p95 ${t.latencyMs.p95}ms, max ${t.latencyMs.max}ms`
480
+ );
481
+ const failed = report.items.filter((i) => !i.ok);
482
+ if (failed.length > 0) {
483
+ lines.push("");
484
+ lines.push("Failed:");
485
+ for (const item of failed) lines.push(` ${item.name}: ${item.error}`);
486
+ }
487
+ const imperfect = report.items.filter((i) => i.ok && !i.exact);
488
+ if (imperfect.length > 0) {
489
+ lines.push("");
490
+ lines.push("Mismatches:");
491
+ for (const item of imperfect) {
492
+ for (const leaf of item.leaves.filter((l) => l.outcome !== "match")) {
493
+ const detail = leaf.outcome === "wrong" ? `expected ${JSON.stringify(leaf.expected)}, got ${JSON.stringify(leaf.actual)}` : leaf.outcome === "missing" ? `expected ${JSON.stringify(leaf.expected)}, got nothing` : `unexpected ${JSON.stringify(leaf.actual)}`;
494
+ const conf = leaf.confidence ? ` [${leaf.confidence}]` : "";
495
+ lines.push(` ${item.name} \u203A ${leaf.path}: ${detail}${conf}`);
496
+ }
497
+ }
498
+ }
499
+ return lines.join("\n");
500
+ }
501
+ // Annotate the CommonJS export names for ESM import in node:
502
+ 0 && (module.exports = {
503
+ RecordingProvider,
504
+ ReplayMissError,
505
+ ReplayProvider,
506
+ compareLeaves,
507
+ diffReports,
508
+ estimateCost,
509
+ fieldStats,
510
+ flattenLeaves,
511
+ formatReport,
512
+ leavesEqual,
513
+ loadFixtures,
514
+ loadReport,
515
+ recordingKey,
516
+ recordingPath,
517
+ replayOrRecord,
518
+ runEval,
519
+ saveReport
520
+ });
521
+ //# sourceMappingURL=index.cjs.map