jev-recipes 0.7.0 → 0.8.1
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 +5 -1
- package/dist/catalog/generated/details/callback-responsibility.json +45 -0
- package/dist/catalog/generated/details/contact-opt-out.json +45 -0
- package/dist/catalog/generated/details/followup-timing.json +45 -0
- package/dist/catalog/generated/details/game-action.json +8 -109
- package/dist/catalog/generated/loaders.js +3 -0
- package/dist/catalog/generated/metadata.js +78 -0
- package/dist/catalog/generated/names.d.ts +1 -1
- package/dist/catalog/generated/names.js +3 -0
- package/dist/catalog/schema.d.ts +3 -0
- package/dist/cli/evaluation.d.ts +1 -0
- package/dist/cli/evaluation.js +82 -0
- package/dist/cli/index.js +13 -0
- package/dist/cli/schema.d.ts +9 -0
- package/dist/evaluation/archive.d.ts +501 -0
- package/dist/evaluation/archive.js +276 -0
- package/dist/evaluation/dataset.d.ts +12 -0
- package/dist/evaluation/dataset.js +162 -0
- package/dist/evaluation/decisions.d.ts +10 -0
- package/dist/evaluation/decisions.js +87 -0
- package/dist/evaluation/engine.d.ts +50 -0
- package/dist/evaluation/engine.js +130 -0
- package/dist/evaluation/index.d.ts +5 -0
- package/dist/evaluation/index.js +3 -0
- package/dist/evaluation/report.d.ts +186 -0
- package/dist/evaluation/report.js +177 -0
- package/dist/evaluation/schema.d.ts +82 -0
- package/dist/evaluation/schema.js +50 -0
- package/dist/recipes/callback-responsibility/demo.json +25 -0
- package/dist/recipes/callback-responsibility/index.d.ts +5 -0
- package/dist/recipes/callback-responsibility/index.js +17 -0
- package/dist/recipes/callback-responsibility/schema.d.ts +42 -0
- package/dist/recipes/callback-responsibility/schema.js +20 -0
- package/dist/recipes/checkers-move/schema.d.ts +1 -1
- package/dist/recipes/choose-action/schema.d.ts +1 -1
- package/dist/recipes/contact-opt-out/demo.json +24 -0
- package/dist/recipes/contact-opt-out/index.d.ts +5 -0
- package/dist/recipes/contact-opt-out/index.js +17 -0
- package/dist/recipes/contact-opt-out/schema.d.ts +42 -0
- package/dist/recipes/contact-opt-out/schema.js +20 -0
- package/dist/recipes/correction-target/schema.d.ts +1 -1
- package/dist/recipes/exercise-select/schema.d.ts +1 -1
- package/dist/recipes/failure-kind/schema.d.ts +1 -1
- package/dist/recipes/field-select/schema.d.ts +1 -1
- package/dist/recipes/finger-actions/schema.d.ts +1 -1
- package/dist/recipes/followup-link/schema.d.ts +1 -1
- package/dist/recipes/followup-timing/demo.json +25 -0
- package/dist/recipes/followup-timing/index.d.ts +5 -0
- package/dist/recipes/followup-timing/index.js +18 -0
- package/dist/recipes/followup-timing/schema.d.ts +45 -0
- package/dist/recipes/followup-timing/schema.js +21 -0
- package/dist/recipes/game-action/schema.d.ts +6 -6
- package/dist/recipes/game-action/schema.js +11 -6
- package/dist/recipes/handoff/schema.d.ts +3 -3
- package/dist/recipes/incident-match/schema.d.ts +1 -1
- package/dist/recipes/model-route/schema.d.ts +1 -1
- package/dist/recipes/next-chord/schema.d.ts +1 -1
- package/dist/recipes/next-duration/schema.d.ts +1 -1
- package/dist/recipes/next-note/schema.d.ts +1 -1
- package/dist/recipes/objection-kind/schema.d.ts +3 -3
- package/dist/recipes/reference-resolve/schema.d.ts +1 -1
- package/dist/recipes/reply-template-match/schema.d.ts +1 -1
- package/dist/recipes/route/schema.d.ts +2 -2
- package/dist/recipes/route/schema.js +3 -3
- package/dist/recipes/route-many/schema.d.ts +3 -3
- package/dist/recipes/route-many/schema.js +3 -3
- package/dist/recipes/style-kind/schema.d.ts +3 -3
- package/dist/src/answers.d.ts +1 -1
- package/dist/src/answers.js +6 -2
- package/dist/src/data.d.ts +6 -0
- package/dist/src/data.js +41 -0
- package/dist/src/decisions.d.ts +1 -1
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +3 -0
- package/dist/src/schema.d.ts +4 -4
- package/dist/src/schema.js +4 -3
- package/package.json +23 -2
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { gunzipSync } from 'node:zlib';
|
|
6
|
+
import { datasetFingerprints, fingerprint, loadEvaluationRecipe, recipeFingerprint, validateCases, } from './dataset.js';
|
|
7
|
+
import { confidenceThresholds, evaluateCases } from './engine.js';
|
|
8
|
+
import { buildReport } from './report.js';
|
|
9
|
+
import { evaluationCaseSchema, evaluationOptionsSchema, evaluationPolicySchema, priceSchema, } from './schema.js';
|
|
10
|
+
const archiveSchema = z.object({
|
|
11
|
+
format: z.literal(1),
|
|
12
|
+
runId: z.string(),
|
|
13
|
+
createdAt: z.iso.datetime(),
|
|
14
|
+
mode: z.enum(['live', 'fixture', 'replay']),
|
|
15
|
+
packageVersion: z.string(),
|
|
16
|
+
recipe: z.string(),
|
|
17
|
+
recipeFingerprint: z.string(),
|
|
18
|
+
datasetFingerprint: z.string(),
|
|
19
|
+
inputFingerprint: z.string(),
|
|
20
|
+
answerKeyFingerprint: z.string(),
|
|
21
|
+
split: z.enum(['development', 'held-out']),
|
|
22
|
+
requestedModel: z.string(),
|
|
23
|
+
policy: evaluationPolicySchema,
|
|
24
|
+
price: priceSchema.optional(),
|
|
25
|
+
cases: z.array(evaluationCaseSchema).min(1),
|
|
26
|
+
rows: z.array(z
|
|
27
|
+
.object({
|
|
28
|
+
id: z.string(),
|
|
29
|
+
expected: z.record(z.string(), z.unknown()),
|
|
30
|
+
contested: z.boolean(),
|
|
31
|
+
adversarial: z.boolean(),
|
|
32
|
+
actual: z.record(z.string(), z.unknown()).optional(),
|
|
33
|
+
result: z.unknown().optional(),
|
|
34
|
+
error: z.string().optional(),
|
|
35
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
36
|
+
model: z.string().optional(),
|
|
37
|
+
correct: z.boolean(),
|
|
38
|
+
ready: z.boolean(),
|
|
39
|
+
durationMs: z.number().nonnegative(),
|
|
40
|
+
decisions: z.array(z.object({
|
|
41
|
+
minConfidence: z.number().min(0).max(1),
|
|
42
|
+
ready: z.boolean(),
|
|
43
|
+
correct: z.boolean(),
|
|
44
|
+
})),
|
|
45
|
+
exchanges: z.array(z.object({
|
|
46
|
+
request: z.record(z.string(), z.unknown()),
|
|
47
|
+
response: z.unknown().optional(),
|
|
48
|
+
error: z.string().optional(),
|
|
49
|
+
durationMs: z.number().nonnegative(),
|
|
50
|
+
})),
|
|
51
|
+
})
|
|
52
|
+
.passthrough()),
|
|
53
|
+
thresholds: z.array(z.number()),
|
|
54
|
+
sourceRun: z.string().optional(),
|
|
55
|
+
report: z.record(z.string(), z.unknown()),
|
|
56
|
+
});
|
|
57
|
+
export async function evaluate(name, values, options = {}) {
|
|
58
|
+
const settings = evaluationOptionsSchema.parse(options);
|
|
59
|
+
const recipe = await loadEvaluationRecipe(name);
|
|
60
|
+
const allCases = validateCases(recipe, values);
|
|
61
|
+
const cases = allCases.filter((entry) => entry.split === settings.split);
|
|
62
|
+
if (!cases.length)
|
|
63
|
+
throw new Error(`No ${settings.split} cases in the dataset.`);
|
|
64
|
+
if (cases.length > settings.maxCases)
|
|
65
|
+
throw new Error(`${cases.length} cases exceed maxCases=${settings.maxCases}. Set an explicit larger limit.`);
|
|
66
|
+
const sourceFingerprint = await recipeFingerprint(recipe.id);
|
|
67
|
+
if (settings.split === 'held-out') {
|
|
68
|
+
const chosen = settings.policy.selectedOn;
|
|
69
|
+
if (!chosen)
|
|
70
|
+
throw new Error('Held-out evaluation requires a policy frozen on a development run.');
|
|
71
|
+
if (chosen.recipeFingerprint !== sourceFingerprint ||
|
|
72
|
+
chosen.model !== (settings.model ?? 'provider-default'))
|
|
73
|
+
throw new Error('The frozen policy belongs to a different recipe version or requested model.');
|
|
74
|
+
}
|
|
75
|
+
const manifest = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
76
|
+
const metadata = {
|
|
77
|
+
format: 1,
|
|
78
|
+
runId: randomUUID(),
|
|
79
|
+
createdAt: new Date().toISOString(),
|
|
80
|
+
mode: options.mode ?? 'live',
|
|
81
|
+
packageVersion: manifest.version,
|
|
82
|
+
recipe: recipe.id,
|
|
83
|
+
recipeFingerprint: sourceFingerprint,
|
|
84
|
+
...datasetFingerprints(cases),
|
|
85
|
+
split: settings.split,
|
|
86
|
+
requestedModel: settings.model ?? 'provider-default',
|
|
87
|
+
policy: settings.policy,
|
|
88
|
+
...(settings.price ? { price: settings.price } : {}),
|
|
89
|
+
cases,
|
|
90
|
+
thresholds: settings.split === 'development' && Object.hasOwn(recipe.inputSchema.shape, 'minConfidence')
|
|
91
|
+
? confidenceThresholds
|
|
92
|
+
: [],
|
|
93
|
+
};
|
|
94
|
+
if (settings.policy.minConfidence !== undefined &&
|
|
95
|
+
!Object.hasOwn(recipe.inputSchema.shape, 'minConfidence'))
|
|
96
|
+
throw new Error('This recipe has no minConfidence policy.');
|
|
97
|
+
if (options.out)
|
|
98
|
+
await startArchive(options.out, metadata);
|
|
99
|
+
const rows = await evaluateCases(recipe, applyPolicy(cases, settings.policy), {
|
|
100
|
+
concurrency: settings.concurrency,
|
|
101
|
+
maxRequests: settings.maxRequests,
|
|
102
|
+
thresholds: metadata.thresholds,
|
|
103
|
+
...(options.client ? { client: options.client } : {}),
|
|
104
|
+
...(settings.model ? { model: settings.model } : {}),
|
|
105
|
+
...(options.out
|
|
106
|
+
? {
|
|
107
|
+
onCase: (row, index) => writeJson(join(options.out, 'cases', `${String(index).padStart(6, '0')}.json`), row),
|
|
108
|
+
}
|
|
109
|
+
: {}),
|
|
110
|
+
});
|
|
111
|
+
const run = {
|
|
112
|
+
...metadata,
|
|
113
|
+
rows,
|
|
114
|
+
report: buildReport(recipe.id, rows, metadata.thresholds, settings.price, reportEvidence(metadata)),
|
|
115
|
+
};
|
|
116
|
+
if (options.out)
|
|
117
|
+
await finishArchive(options.out, run);
|
|
118
|
+
return run;
|
|
119
|
+
}
|
|
120
|
+
export async function replay(run, policy = run.policy, out) {
|
|
121
|
+
const settings = evaluationPolicySchema.parse(policy);
|
|
122
|
+
const recipe = await loadEvaluationRecipe(run.recipe);
|
|
123
|
+
if ((await recipeFingerprint(run.recipe)) !== run.recipeFingerprint)
|
|
124
|
+
throw new Error('The installed recipe differs from this archive. Replay requires the recorded recipe version.');
|
|
125
|
+
if (run.split === 'held-out' && fingerprint(settings) !== fingerprint(run.policy))
|
|
126
|
+
throw new Error('Do not tune a policy on held-out data. Replay a development run to choose a policy.');
|
|
127
|
+
if (settings.minConfidence !== undefined &&
|
|
128
|
+
!Object.hasOwn(recipe.inputSchema.shape, 'minConfidence'))
|
|
129
|
+
throw new Error('This recipe has no minConfidence policy.');
|
|
130
|
+
const cases = validateCases(recipe, run.cases);
|
|
131
|
+
const metadata = {
|
|
132
|
+
...run,
|
|
133
|
+
runId: randomUUID(),
|
|
134
|
+
createdAt: new Date().toISOString(),
|
|
135
|
+
mode: 'replay',
|
|
136
|
+
sourceRun: run.runId,
|
|
137
|
+
policy: settings,
|
|
138
|
+
};
|
|
139
|
+
if (out)
|
|
140
|
+
await startArchive(out, { ...metadata, rows: undefined, report: undefined });
|
|
141
|
+
const rows = await evaluateCases(recipe, applyPolicy(cases, settings), {
|
|
142
|
+
recorded: run.rows,
|
|
143
|
+
thresholds: run.thresholds,
|
|
144
|
+
...(run.requestedModel === 'provider-default' ? {} : { model: run.requestedModel }),
|
|
145
|
+
});
|
|
146
|
+
const result = {
|
|
147
|
+
...metadata,
|
|
148
|
+
rows,
|
|
149
|
+
report: buildReport(run.recipe, rows, run.thresholds, run.price, reportEvidence(metadata)),
|
|
150
|
+
};
|
|
151
|
+
if (out)
|
|
152
|
+
await finishArchive(out, result);
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
export async function readRun(directory) {
|
|
156
|
+
const content = await readFile(join(directory, 'run.json'), 'utf8').catch(async (error) => {
|
|
157
|
+
if (error.code !== 'ENOENT')
|
|
158
|
+
throw error;
|
|
159
|
+
return gunzipSync(await readFile(join(directory, 'run.json.gz'))).toString('utf8');
|
|
160
|
+
});
|
|
161
|
+
const parsed = archiveSchema.parse(JSON.parse(content));
|
|
162
|
+
const run = parsed;
|
|
163
|
+
const hashes = datasetFingerprints(run.cases);
|
|
164
|
+
if (Object.entries(hashes).some(([key, hash]) => run[key] !== hash))
|
|
165
|
+
throw new Error('Archive dataset fingerprints do not match its cases.');
|
|
166
|
+
if (run.rows.length !== run.cases.length ||
|
|
167
|
+
new Set(run.rows.map((row) => row.id)).size !== run.cases.length ||
|
|
168
|
+
run.cases.some((entry) => !run.rows.some((row) => row.id === entry.id && fingerprint(row.expected) === fingerprint(entry.expected))))
|
|
169
|
+
throw new Error('Archive case records are incomplete.');
|
|
170
|
+
return {
|
|
171
|
+
...run,
|
|
172
|
+
report: buildReport(run.recipe, run.rows, run.thresholds, run.price, reportEvidence(run)),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
export function freezePolicy(run) {
|
|
176
|
+
if (run.split !== 'development')
|
|
177
|
+
throw new Error('Choose the policy on a development run.');
|
|
178
|
+
return {
|
|
179
|
+
...run.policy,
|
|
180
|
+
selectedOn: {
|
|
181
|
+
runId: run.runId,
|
|
182
|
+
datasetFingerprint: run.datasetFingerprint,
|
|
183
|
+
recipeFingerprint: run.recipeFingerprint,
|
|
184
|
+
model: run.requestedModel,
|
|
185
|
+
split: 'development',
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function reportEvidence(run) {
|
|
190
|
+
const provenance = new Map();
|
|
191
|
+
for (const entry of run.cases) {
|
|
192
|
+
const key = JSON.stringify(entry.provenance);
|
|
193
|
+
const counted = provenance.get(key) ?? { ...entry.provenance, cases: 0 };
|
|
194
|
+
counted.cases++;
|
|
195
|
+
provenance.set(key, counted);
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
runId: run.runId,
|
|
199
|
+
evaluatedAt: run.createdAt,
|
|
200
|
+
packageVersion: run.packageVersion,
|
|
201
|
+
recipeFingerprint: run.recipeFingerprint,
|
|
202
|
+
datasetFingerprint: run.datasetFingerprint,
|
|
203
|
+
split: run.split,
|
|
204
|
+
mode: run.mode,
|
|
205
|
+
policy: run.policy,
|
|
206
|
+
provenance: [...provenance.values()],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
export function compare(baseline, candidate) {
|
|
210
|
+
if (baseline.recipe !== candidate.recipe ||
|
|
211
|
+
baseline.inputFingerprint !== candidate.inputFingerprint ||
|
|
212
|
+
baseline.answerKeyFingerprint !== candidate.answerKeyFingerprint)
|
|
213
|
+
throw new Error('Comparison requires the same recipe, case IDs, inputs, split, and answer key.');
|
|
214
|
+
const delta = (first, second) => first === null || second === null ? null : Number((second - first).toFixed(6));
|
|
215
|
+
const describe = (run) => ({
|
|
216
|
+
runId: run.runId,
|
|
217
|
+
packageVersion: run.packageVersion,
|
|
218
|
+
recipeFingerprint: run.recipeFingerprint,
|
|
219
|
+
requestedModel: run.requestedModel,
|
|
220
|
+
models: run.report.models,
|
|
221
|
+
policy: run.policy,
|
|
222
|
+
mode: run.mode,
|
|
223
|
+
report: run.report,
|
|
224
|
+
});
|
|
225
|
+
return {
|
|
226
|
+
recipe: baseline.recipe,
|
|
227
|
+
cases: baseline.cases.length,
|
|
228
|
+
split: baseline.split,
|
|
229
|
+
baseline: describe(baseline),
|
|
230
|
+
candidate: describe(candidate),
|
|
231
|
+
delta: {
|
|
232
|
+
accuracy: delta(baseline.report.accuracy, candidate.report.accuracy),
|
|
233
|
+
readyAccuracy: delta(baseline.report.readyAccuracy, candidate.report.readyAccuracy),
|
|
234
|
+
review: candidate.report.review - baseline.report.review,
|
|
235
|
+
failed: candidate.report.failed - baseline.report.failed,
|
|
236
|
+
},
|
|
237
|
+
changed: candidate.rows.flatMap((row) => {
|
|
238
|
+
const before = baseline.rows.find((entry) => entry.id === row.id);
|
|
239
|
+
return before.correct !== row.correct ||
|
|
240
|
+
before.ready !== row.ready ||
|
|
241
|
+
before.error !== row.error
|
|
242
|
+
? [
|
|
243
|
+
{
|
|
244
|
+
id: row.id,
|
|
245
|
+
before: { correct: before.correct, ready: before.ready, error: before.error },
|
|
246
|
+
after: { correct: row.correct, ready: row.ready, error: row.error },
|
|
247
|
+
},
|
|
248
|
+
]
|
|
249
|
+
: [];
|
|
250
|
+
}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function applyPolicy(cases, policy) {
|
|
254
|
+
return cases.map((entry) => ({
|
|
255
|
+
...entry,
|
|
256
|
+
input: {
|
|
257
|
+
...entry.input,
|
|
258
|
+
...(policy.minConfidence === undefined ? {} : { minConfidence: policy.minConfidence }),
|
|
259
|
+
},
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
async function startArchive(directory, metadata) {
|
|
263
|
+
await mkdir(dirname(directory), { recursive: true });
|
|
264
|
+
await mkdir(directory, { mode: 0o700 });
|
|
265
|
+
await mkdir(join(directory, 'cases'), { mode: 0o700 });
|
|
266
|
+
await writeJson(join(directory, 'manifest.json'), { status: 'running', metadata });
|
|
267
|
+
}
|
|
268
|
+
async function finishArchive(directory, run) {
|
|
269
|
+
await writeJson(join(directory, 'run.json'), run);
|
|
270
|
+
await writeJson(join(directory, 'report.json'), run.report);
|
|
271
|
+
await writeJson(join(directory, 'policy.json'), run.split === 'development' ? freezePolicy(run) : run.policy);
|
|
272
|
+
await writeFile(join(directory, 'manifest.json'), JSON.stringify({ status: 'complete', runId: run.runId }, null, 2) + '\n', { mode: 0o600 });
|
|
273
|
+
}
|
|
274
|
+
async function writeJson(path, value) {
|
|
275
|
+
await writeFile(path, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
276
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EvaluationCase } from './schema.js';
|
|
2
|
+
import type { EvaluationRecipe } from './engine.js';
|
|
3
|
+
export declare function loadEvaluationRecipe(name: string): Promise<EvaluationRecipe>;
|
|
4
|
+
export declare function readEvaluationCases(path: string): Promise<unknown[]>;
|
|
5
|
+
export declare function validateCases(recipe: EvaluationRecipe, values: unknown[]): EvaluationCase[];
|
|
6
|
+
export declare function fingerprint(value: unknown): string;
|
|
7
|
+
export declare function datasetFingerprints(cases: EvaluationCase[]): {
|
|
8
|
+
datasetFingerprint: string;
|
|
9
|
+
inputFingerprint: string;
|
|
10
|
+
answerKeyFingerprint: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function recipeFingerprint(id: string): Promise<string>;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { describeRecipe } from '../catalog/index.js';
|
|
5
|
+
import { recipeNameSchema } from '../catalog/schema.js';
|
|
6
|
+
import { isRecord, comparableDecision } from './decisions.js';
|
|
7
|
+
import { evaluationCaseSchema } from './schema.js';
|
|
8
|
+
export async function loadEvaluationRecipe(name) {
|
|
9
|
+
const id = recipeNameSchema.parse(name);
|
|
10
|
+
const module = (await import(new URL(`../recipes/${id}/index.js`, import.meta.url).href));
|
|
11
|
+
const run = Object.values(module).find((value) => typeof value === 'function');
|
|
12
|
+
const inputSchema = Object.entries(module).find(([key]) => key.endsWith('InputSchema'))?.[1];
|
|
13
|
+
if (typeof run !== 'function' || !(inputSchema instanceof z.ZodObject))
|
|
14
|
+
throw new Error(`Cannot load recipe ${id}.`);
|
|
15
|
+
return { id, run: run, inputSchema };
|
|
16
|
+
}
|
|
17
|
+
export async function readEvaluationCases(path) {
|
|
18
|
+
const text = await readFile(path, 'utf8');
|
|
19
|
+
return text.split(/\r?\n/).flatMap((line, index) => {
|
|
20
|
+
if (!line.trim())
|
|
21
|
+
return [];
|
|
22
|
+
try {
|
|
23
|
+
return [JSON.parse(line)];
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error(`Invalid JSON on line ${index + 1} of ${path}.`);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export function validateCases(recipe, values) {
|
|
31
|
+
if (!values.length)
|
|
32
|
+
throw new Error('The case file is empty.');
|
|
33
|
+
const resultSchema = describeRecipe(recipeNameSchema.parse(recipe.id)).resultSchema;
|
|
34
|
+
const cases = values.map((value) => evaluationCaseSchema.parse(value));
|
|
35
|
+
const ids = new Set();
|
|
36
|
+
const familySplits = new Map();
|
|
37
|
+
for (const entry of cases) {
|
|
38
|
+
if (ids.has(entry.id))
|
|
39
|
+
throw new Error(`Duplicate case ID: ${entry.id}`);
|
|
40
|
+
ids.add(entry.id);
|
|
41
|
+
if (entry.family) {
|
|
42
|
+
if (familySplits.has(entry.family) && familySplits.get(entry.family) !== entry.split)
|
|
43
|
+
throw new Error(`Case family ${entry.family} crosses development and held-out splits.`);
|
|
44
|
+
familySplits.set(entry.family, entry.split);
|
|
45
|
+
}
|
|
46
|
+
recipe.inputSchema.parse(entry.input);
|
|
47
|
+
for (const [path, expected] of Object.entries(entry.expected)) {
|
|
48
|
+
if (path
|
|
49
|
+
.split('.')
|
|
50
|
+
.some((segment) => [
|
|
51
|
+
'status',
|
|
52
|
+
'confidence',
|
|
53
|
+
'probabilities',
|
|
54
|
+
'probability',
|
|
55
|
+
'model',
|
|
56
|
+
'usage',
|
|
57
|
+
'score',
|
|
58
|
+
].includes(segment)))
|
|
59
|
+
throw new Error(`${entry.id}: expected must name an ungated decision, not ${path}.`);
|
|
60
|
+
const field = schemaAtPath(resultSchema, path.split('.'));
|
|
61
|
+
if (!field)
|
|
62
|
+
throw new Error(`${entry.id}: unknown result field ${path}.`);
|
|
63
|
+
if (!acceptsValue(field, expected))
|
|
64
|
+
throw new Error(`${entry.id}: expected ${path} does not match the result schema.`);
|
|
65
|
+
}
|
|
66
|
+
if (isEmptyDecision(comparableDecision(entry.expected)))
|
|
67
|
+
throw new Error(`${entry.id}: expected must contain a decision to compare.`);
|
|
68
|
+
}
|
|
69
|
+
return cases;
|
|
70
|
+
}
|
|
71
|
+
function schemaAtPath(schema, segments) {
|
|
72
|
+
if (!segments.length)
|
|
73
|
+
return schema;
|
|
74
|
+
if (Array.isArray(schema.anyOf)) {
|
|
75
|
+
const candidates = schema.anyOf.flatMap((branch) => {
|
|
76
|
+
const field = isRecord(branch) ? schemaAtPath(branch, segments) : undefined;
|
|
77
|
+
return field ? [field] : [];
|
|
78
|
+
});
|
|
79
|
+
return candidates.length ? { anyOf: candidates } : undefined;
|
|
80
|
+
}
|
|
81
|
+
const [first, ...rest] = segments;
|
|
82
|
+
if (isRecord(schema.properties) && isRecord(schema.properties[first]))
|
|
83
|
+
return schemaAtPath(schema.properties[first], rest);
|
|
84
|
+
if (schema.type === 'array' && /^(0|[1-9]\d*)$/.test(first) && isRecord(schema.items))
|
|
85
|
+
return schemaAtPath(schema.items, rest);
|
|
86
|
+
if (isRecord(schema.additionalProperties))
|
|
87
|
+
return schemaAtPath(schema.additionalProperties, rest);
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
function acceptsValue(schema, value) {
|
|
91
|
+
if (Array.isArray(schema.anyOf))
|
|
92
|
+
return schema.anyOf.some((candidate) => isRecord(candidate) && acceptsValue(candidate, value));
|
|
93
|
+
if (Array.isArray(schema.enum) && !schema.enum.includes(value))
|
|
94
|
+
return false;
|
|
95
|
+
if (schema.const !== undefined && schema.const !== value)
|
|
96
|
+
return false;
|
|
97
|
+
if (Array.isArray(schema.type))
|
|
98
|
+
return schema.type.some((type) => acceptsValue({ ...schema, type }, value));
|
|
99
|
+
if (schema.type === 'null')
|
|
100
|
+
return value === null;
|
|
101
|
+
if (schema.type === 'array')
|
|
102
|
+
return (Array.isArray(value) &&
|
|
103
|
+
(!isRecord(schema.items) ||
|
|
104
|
+
value.every((item) => acceptsValue(schema.items, item))));
|
|
105
|
+
if (schema.type === 'object') {
|
|
106
|
+
if (!isRecord(value))
|
|
107
|
+
return false;
|
|
108
|
+
return Object.entries(value).every(([name, item]) => {
|
|
109
|
+
const field = schemaAtPath(schema, [name]);
|
|
110
|
+
return field ? acceptsValue(field, item) : schema.additionalProperties !== false;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (schema.type === 'integer')
|
|
114
|
+
return typeof value === 'number' && Number.isInteger(value);
|
|
115
|
+
return schema.type === undefined || typeof value === schema.type;
|
|
116
|
+
}
|
|
117
|
+
function isEmptyDecision(value) {
|
|
118
|
+
if (Array.isArray(value))
|
|
119
|
+
return value.length > 0 && value.every(isEmptyDecision);
|
|
120
|
+
if (isRecord(value))
|
|
121
|
+
return Object.values(value).every(isEmptyDecision);
|
|
122
|
+
return value === undefined;
|
|
123
|
+
}
|
|
124
|
+
export function fingerprint(value) {
|
|
125
|
+
return createHash('sha256')
|
|
126
|
+
.update(JSON.stringify(canonicalValue(value)))
|
|
127
|
+
.digest('hex');
|
|
128
|
+
}
|
|
129
|
+
function canonicalValue(value) {
|
|
130
|
+
if (Array.isArray(value))
|
|
131
|
+
return value.map(canonicalValue);
|
|
132
|
+
if (isRecord(value))
|
|
133
|
+
return Object.fromEntries(Object.keys(value)
|
|
134
|
+
.sort()
|
|
135
|
+
.map((key) => [key, canonicalValue(value[key])]));
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
export function datasetFingerprints(cases) {
|
|
139
|
+
const ordered = [...cases].sort((a, b) => a.id.localeCompare(b.id));
|
|
140
|
+
return {
|
|
141
|
+
datasetFingerprint: fingerprint(ordered),
|
|
142
|
+
inputFingerprint: fingerprint(ordered.map(({ id, input, split }) => ({ id, input, split }))),
|
|
143
|
+
answerKeyFingerprint: fingerprint(ordered.map(({ id, expected }) => ({ id, expected }))),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export async function recipeFingerprint(id) {
|
|
147
|
+
recipeNameSchema.parse(id);
|
|
148
|
+
const sources = new Map();
|
|
149
|
+
const root = new URL('../', import.meta.url);
|
|
150
|
+
async function visit(url) {
|
|
151
|
+
if (sources.has(url.href))
|
|
152
|
+
return;
|
|
153
|
+
const source = await readFile(url, 'utf8');
|
|
154
|
+
sources.set(url.href, source);
|
|
155
|
+
for (const match of source.matchAll(/(?:from\s*|import\s*)['"]([^'"]+)['"]/g)) {
|
|
156
|
+
if (match[1].startsWith('.'))
|
|
157
|
+
await visit(new URL(match[1], url));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
await visit(new URL(`recipes/${id}/index.js`, root));
|
|
161
|
+
return fingerprint(Object.fromEntries([...sources].map(([url, source]) => [url.slice(root.href.length), source])));
|
|
162
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function comparableDecision(value: unknown): unknown;
|
|
2
|
+
export declare function matchesExpected(actual: unknown, expected: unknown): boolean;
|
|
3
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
4
|
+
export declare function valueAtPath(record: unknown, path: string): unknown;
|
|
5
|
+
export declare function pickPaths(record: unknown, paths: string[]): Record<string, unknown>;
|
|
6
|
+
export declare function hasReviewAnywhere(value: unknown): boolean;
|
|
7
|
+
export declare function isReady(result: unknown): boolean;
|
|
8
|
+
export declare function caseConfidence(result: unknown, expectedPaths?: string[]): number | undefined;
|
|
9
|
+
export declare function mapWithConcurrencyLimit<T, R>(items: T[], limit: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
10
|
+
export declare function roundedTo(places: number, value: number): number;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
const metadataFields = new Set([
|
|
3
|
+
'model',
|
|
4
|
+
'usage',
|
|
5
|
+
'confidence',
|
|
6
|
+
'probability',
|
|
7
|
+
'probabilities',
|
|
8
|
+
'score',
|
|
9
|
+
'status',
|
|
10
|
+
]);
|
|
11
|
+
export function comparableDecision(value) {
|
|
12
|
+
if (Array.isArray(value))
|
|
13
|
+
return value.map(comparableDecision);
|
|
14
|
+
if (isRecord(value))
|
|
15
|
+
return Object.fromEntries(Object.entries(value)
|
|
16
|
+
.filter(([name, field]) => !metadataFields.has(name) && typeof field !== 'number')
|
|
17
|
+
.map(([name, field]) => [name, comparableDecision(field)]));
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
export function matchesExpected(actual, expected) {
|
|
21
|
+
return isDeepStrictEqual(comparableDecision(actual), comparableDecision(expected));
|
|
22
|
+
}
|
|
23
|
+
export function isRecord(value) {
|
|
24
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
export function valueAtPath(record, path) {
|
|
27
|
+
return path
|
|
28
|
+
.split('.')
|
|
29
|
+
.reduce((value, segment) => value !== null && typeof value === 'object' && Object.hasOwn(value, segment)
|
|
30
|
+
? value[segment]
|
|
31
|
+
: undefined, record);
|
|
32
|
+
}
|
|
33
|
+
export function pickPaths(record, paths) {
|
|
34
|
+
return Object.fromEntries(paths.map((path) => [path, valueAtPath(record, path)]));
|
|
35
|
+
}
|
|
36
|
+
export function hasReviewAnywhere(value) {
|
|
37
|
+
if (Array.isArray(value))
|
|
38
|
+
return value.some(hasReviewAnywhere);
|
|
39
|
+
if (isRecord(value))
|
|
40
|
+
return Object.entries(value).some(([name, field]) => name === 'status' ? field === 'review' : hasReviewAnywhere(field));
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
export function isReady(result) {
|
|
44
|
+
if (!isRecord(result))
|
|
45
|
+
return false;
|
|
46
|
+
if (result.status !== undefined)
|
|
47
|
+
return result.status === 'ready';
|
|
48
|
+
return !hasReviewAnywhere(result);
|
|
49
|
+
}
|
|
50
|
+
export function caseConfidence(result, expectedPaths = []) {
|
|
51
|
+
if (isRecord(result) && typeof result.confidence === 'number')
|
|
52
|
+
return result.confidence;
|
|
53
|
+
const scoped = expectedPaths.flatMap((path) => {
|
|
54
|
+
const segments = path.split('.');
|
|
55
|
+
for (let end = segments.length; end > 0; end--) {
|
|
56
|
+
const node = valueAtPath(result, segments.slice(0, end).join('.'));
|
|
57
|
+
if (isRecord(node) && typeof node.confidence === 'number')
|
|
58
|
+
return [node.confidence];
|
|
59
|
+
}
|
|
60
|
+
return [];
|
|
61
|
+
});
|
|
62
|
+
const values = scoped.length ? scoped : collectConfidences(result);
|
|
63
|
+
return values.length ? Math.min(...values) : undefined;
|
|
64
|
+
}
|
|
65
|
+
function collectConfidences(value) {
|
|
66
|
+
if (Array.isArray(value))
|
|
67
|
+
return value.flatMap(collectConfidences);
|
|
68
|
+
if (isRecord(value))
|
|
69
|
+
return Object.entries(value).flatMap(([name, field]) => name === 'confidence' && typeof field === 'number' ? [field] : collectConfidences(field));
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
export async function mapWithConcurrencyLimit(items, limit, worker) {
|
|
73
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 32)
|
|
74
|
+
throw new Error('Concurrency must be an integer from 1 to 32.');
|
|
75
|
+
const results = new Array(items.length);
|
|
76
|
+
let nextIndex = 0;
|
|
77
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
78
|
+
while (nextIndex < items.length) {
|
|
79
|
+
const index = nextIndex++;
|
|
80
|
+
results[index] = await worker(items[index], index);
|
|
81
|
+
}
|
|
82
|
+
}));
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
export function roundedTo(places, value) {
|
|
86
|
+
return Number(value.toFixed(places));
|
|
87
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { SystemOneRequest } from '@typesafe-ai/sdk';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
import type { RecipeOptions } from '../src/schema.js';
|
|
4
|
+
import type { EvaluationCase } from './schema.js';
|
|
5
|
+
export interface EvaluationRecipe {
|
|
6
|
+
id: string;
|
|
7
|
+
run: (input: unknown, options?: RecipeOptions) => Promise<unknown>;
|
|
8
|
+
inputSchema: z.ZodObject;
|
|
9
|
+
}
|
|
10
|
+
export interface Exchange {
|
|
11
|
+
request: SystemOneRequest;
|
|
12
|
+
response?: unknown;
|
|
13
|
+
error?: string;
|
|
14
|
+
durationMs: number;
|
|
15
|
+
}
|
|
16
|
+
export interface EvaluationRow {
|
|
17
|
+
id: string;
|
|
18
|
+
expected: Record<string, unknown>;
|
|
19
|
+
contested: boolean;
|
|
20
|
+
adversarial: boolean;
|
|
21
|
+
actual?: Record<string, unknown>;
|
|
22
|
+
result?: unknown;
|
|
23
|
+
error?: string;
|
|
24
|
+
confidence?: number | undefined;
|
|
25
|
+
model?: string | undefined;
|
|
26
|
+
correct: boolean;
|
|
27
|
+
ready: boolean;
|
|
28
|
+
durationMs: number;
|
|
29
|
+
exchanges: Exchange[];
|
|
30
|
+
decisions: {
|
|
31
|
+
minConfidence: number;
|
|
32
|
+
ready: boolean;
|
|
33
|
+
correct: boolean;
|
|
34
|
+
}[];
|
|
35
|
+
}
|
|
36
|
+
export interface EngineOptions extends RecipeOptions {
|
|
37
|
+
concurrency?: number;
|
|
38
|
+
maxRequests?: number;
|
|
39
|
+
thresholds?: number[];
|
|
40
|
+
recorded?: EvaluationRow[];
|
|
41
|
+
onCase?: (row: EvaluationRow, index: number) => Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
export declare const confidenceThresholds: number[];
|
|
44
|
+
export declare function evaluateCases(recipe: EvaluationRecipe, cases: EvaluationCase[], options?: EngineOptions): Promise<EvaluationRow[]>;
|
|
45
|
+
export declare function replayResponses(exchanges: Exchange[]): {
|
|
46
|
+
client: {
|
|
47
|
+
systemOne(request: SystemOneRequest): Promise<unknown>;
|
|
48
|
+
};
|
|
49
|
+
assertComplete(): void;
|
|
50
|
+
};
|