@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 +21 -0
- package/README.md +88 -0
- package/dist/index.cjs +521 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +225 -0
- package/dist/index.d.ts +225 -0
- package/dist/index.js +483 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
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
|