@tuned-tensor/local 0.2.8 → 0.3.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/CHANGELOG.md +67 -0
- package/README.md +84 -204
- package/dist/artifacts.d.ts +3 -4
- package/dist/artifacts.js +55 -33
- package/dist/artifacts.js.map +1 -1
- package/dist/compare.d.ts +1 -8
- package/dist/compare.js +1 -23
- package/dist/compare.js.map +1 -1
- package/dist/contracts.d.ts +59 -366
- package/dist/contracts.js +73 -224
- package/dist/contracts.js.map +1 -1
- package/dist/dataset.d.ts +2 -4
- package/dist/dataset.js +31 -125
- package/dist/dataset.js.map +1 -1
- package/dist/doctor.d.ts +2 -3
- package/dist/doctor.js +23 -161
- package/dist/doctor.js.map +1 -1
- package/dist/evaluation.d.ts +11 -71
- package/dist/evaluation.js +212 -572
- package/dist/evaluation.js.map +1 -1
- package/dist/huggingface-cache.d.ts +8 -7
- package/dist/huggingface-cache.js +16 -8
- package/dist/huggingface-cache.js.map +1 -1
- package/dist/index.d.ts +0 -10
- package/dist/index.js +205 -628
- package/dist/index.js.map +1 -1
- package/dist/local-project.d.ts +1 -11
- package/dist/local-project.js +33 -61
- package/dist/local-project.js.map +1 -1
- package/dist/model-registry.d.ts +3 -16
- package/dist/model-registry.js +76 -292
- package/dist/model-registry.js.map +1 -1
- package/dist/model-server.d.ts +1 -2
- package/dist/model-server.js +9 -18
- package/dist/model-server.js.map +1 -1
- package/dist/orchestrator.d.ts +11 -25
- package/dist/orchestrator.js +246 -566
- package/dist/orchestrator.js.map +1 -1
- package/dist/prefetch.d.ts +1 -5
- package/dist/prefetch.js +14 -19
- package/dist/prefetch.js.map +1 -1
- package/dist/process-runner.d.ts +10 -29
- package/dist/process-runner.js +63 -169
- package/dist/process-runner.js.map +1 -1
- package/dist/process-training.d.ts +0 -1
- package/dist/process-training.js +10 -87
- package/dist/process-training.js.map +1 -1
- package/dist/store.d.ts +1 -3
- package/dist/store.js +92 -290
- package/dist/store.js.map +1 -1
- package/docs/architecture.md +87 -152
- package/docs/spark.md +66 -97
- package/examples/dry-runner.json +14 -0
- package/examples/local-runner.json +8 -6
- package/examples/smoke-spec.json +41 -0
- package/package.json +6 -6
- package/training/local-runner/pyproject.toml +0 -5
- package/training/local-runner/src/evaluate.py +89 -234
- package/training/local-runner/src/model_contract.py +47 -0
- package/training/local-runner/src/prefetch.py +18 -4
- package/training/local-runner/src/serve.py +54 -115
- package/training/local-runner/src/sft_data.py +97 -0
- package/training/local-runner/src/train.py +146 -346
- package/training/local-runner/uv.lock +0 -1197
- package/dist/labeling-sanitize.d.ts +0 -31
- package/dist/labeling-sanitize.js +0 -158
- package/dist/labeling-sanitize.js.map +0 -1
- package/dist/labeling.d.ts +0 -155
- package/dist/labeling.js +0 -496
- package/dist/labeling.js.map +0 -1
- package/dist/openrouter.d.ts +0 -29
- package/dist/openrouter.js +0 -66
- package/dist/openrouter.js.map +0 -1
- package/dist/server.d.ts +0 -9
- package/dist/server.js +0 -211
- package/dist/server.js.map +0 -1
- package/docs/local-workflow-remediation-2026-07-13.md +0 -130
- package/docs/local-workflow-ux-review-2026-07-13.md +0 -458
- package/examples/dpo-preferences.jsonl +0 -2
- package/examples/dpo-run-request.json +0 -34
- package/examples/smoke-run-request.json +0 -34
- package/training/local-runner/src/train_dpo.py +0 -286
package/dist/evaluation.js
CHANGED
|
@@ -1,26 +1,18 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
1
|
import { createHash } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
4
|
import { evalReportSchema, } from "./contracts.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { openRouterChat } from "./openrouter.js";
|
|
9
|
-
import { buildEntrypointCommand, runJsonStdInCommand, runLoggedProcess } from "./process-runner.js";
|
|
5
|
+
import { fileUri, writeJson } from "./artifacts.js";
|
|
6
|
+
import { buildBundledPythonCommand, runLoggedProcess, withBundledPythonEnvironment, } from "./process-runner.js";
|
|
10
7
|
import { defaultLocalHome } from "./store.js";
|
|
11
|
-
import { minimalMachineLearningEnvironment,
|
|
8
|
+
import { minimalMachineLearningEnvironment, withOfflineHuggingFaceCacheEnvironment, } from "./huggingface-cache.js";
|
|
12
9
|
function normalize(value) {
|
|
13
10
|
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
|
14
11
|
}
|
|
15
|
-
function
|
|
12
|
+
function exactScore(expected, actual) {
|
|
16
13
|
return normalize(expected) === normalize(actual) ? 1 : 0;
|
|
17
14
|
}
|
|
18
|
-
/**
|
|
19
|
-
* Token-overlap F1 between expected and actual output (bag-of-words, case and
|
|
20
|
-
* whitespace insensitive). This is a cheap deterministic reference-similarity
|
|
21
|
-
* signal for free-text tasks where exact match is always 0 and an LLM judge
|
|
22
|
-
* can be noisy; both scoring paths keep working unchanged alongside it.
|
|
23
|
-
*/
|
|
15
|
+
/** Cheap deterministic free-text similarity reported beside exact match. */
|
|
24
16
|
export function tokenF1(expected, actual) {
|
|
25
17
|
const tokenize = (value) => normalize(value).match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
26
18
|
const expectedTokens = tokenize(expected);
|
|
@@ -29,8 +21,9 @@ export function tokenF1(expected, actual) {
|
|
|
29
21
|
return expectedTokens.length === actualTokens.length ? 1 : 0;
|
|
30
22
|
}
|
|
31
23
|
const counts = new Map();
|
|
32
|
-
for (const token of expectedTokens)
|
|
24
|
+
for (const token of expectedTokens) {
|
|
33
25
|
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
26
|
+
}
|
|
34
27
|
let overlap = 0;
|
|
35
28
|
for (const token of actualTokens) {
|
|
36
29
|
const remaining = counts.get(token) ?? 0;
|
|
@@ -43,9 +36,9 @@ export function tokenF1(expected, actual) {
|
|
|
43
36
|
return 0;
|
|
44
37
|
const precision = overlap / actualTokens.length;
|
|
45
38
|
const recall = overlap / expectedTokens.length;
|
|
46
|
-
return
|
|
39
|
+
return 2 * precision * recall / (precision + recall);
|
|
47
40
|
}
|
|
48
|
-
/**
|
|
41
|
+
/** Deterministic 32-bit FNV-1a seed. */
|
|
49
42
|
export function deriveSampleSeed(value) {
|
|
50
43
|
let hash = 0x811c9dc5;
|
|
51
44
|
for (let index = 0; index < value.length; index += 1) {
|
|
@@ -58,17 +51,11 @@ function mulberry32(seed) {
|
|
|
58
51
|
let state = seed >>> 0;
|
|
59
52
|
return () => {
|
|
60
53
|
state = (state + 0x6d2b79f5) | 0;
|
|
61
|
-
let
|
|
62
|
-
|
|
63
|
-
return ((
|
|
54
|
+
let value = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
55
|
+
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
|
|
56
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
|
64
57
|
};
|
|
65
58
|
}
|
|
66
|
-
/**
|
|
67
|
-
* Selects `count` examples with a seeded shuffle so that truncated evaluation
|
|
68
|
-
* is a deterministic random sample instead of a dataset-order prefix. The
|
|
69
|
-
* selected examples keep their original relative order, so baseline and
|
|
70
|
-
* candidate evaluations that use the same seed score identical examples.
|
|
71
|
-
*/
|
|
72
59
|
export function sampleExamples(examples, count, seed) {
|
|
73
60
|
if (count >= examples.length)
|
|
74
61
|
return examples;
|
|
@@ -78,15 +65,14 @@ export function sampleExamples(examples, count, seed) {
|
|
|
78
65
|
const swap = Math.floor(random() * (index + 1));
|
|
79
66
|
[indices[index], indices[swap]] = [indices[swap], indices[index]];
|
|
80
67
|
}
|
|
81
|
-
return indices
|
|
68
|
+
return indices
|
|
69
|
+
.slice(0, count)
|
|
70
|
+
.sort((left, right) => left - right)
|
|
71
|
+
.map((index) => examples[index]);
|
|
82
72
|
}
|
|
83
73
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* data. The holdout is a seeded random sample of about `holdoutRatio` of the
|
|
87
|
-
* examples, with at least 1 holdout and at least 1 training example. Both
|
|
88
|
-
* splits preserve the original example order. With fewer than 2 examples the
|
|
89
|
-
* holdout is empty and callers should fall back to training-set evaluation.
|
|
74
|
+
* Inline specs get a deterministic holdout. A real run with fewer than two
|
|
75
|
+
* examples is rejected by the shared validator before this function is used.
|
|
90
76
|
*/
|
|
91
77
|
export function splitSpecExamples(examples, seed, holdoutRatio = 0.2) {
|
|
92
78
|
if (examples.length < 2)
|
|
@@ -98,10 +84,10 @@ export function splitSpecExamples(examples, seed, holdoutRatio = 0.2) {
|
|
|
98
84
|
const swap = Math.floor(random() * (index + 1));
|
|
99
85
|
[indices[index], indices[swap]] = [indices[swap], indices[index]];
|
|
100
86
|
}
|
|
101
|
-
const
|
|
87
|
+
const heldOut = new Set(indices.slice(0, holdoutCount));
|
|
102
88
|
return {
|
|
103
|
-
train: examples.filter((_, index) => !
|
|
104
|
-
holdout: examples.filter((_, index) =>
|
|
89
|
+
train: examples.filter((_, index) => !heldOut.has(index)),
|
|
90
|
+
holdout: examples.filter((_, index) => heldOut.has(index)),
|
|
105
91
|
};
|
|
106
92
|
}
|
|
107
93
|
function extractJsonObject(value) {
|
|
@@ -121,33 +107,30 @@ function extractJsonObject(value) {
|
|
|
121
107
|
}
|
|
122
108
|
}
|
|
123
109
|
catch {
|
|
124
|
-
// Try the next
|
|
110
|
+
// Try the next representation.
|
|
125
111
|
}
|
|
126
112
|
}
|
|
127
113
|
return null;
|
|
128
114
|
}
|
|
129
|
-
function
|
|
115
|
+
function canonicalJson(value) {
|
|
130
116
|
if (Array.isArray(value))
|
|
131
|
-
return value.map(
|
|
117
|
+
return value.map(canonicalJson);
|
|
132
118
|
if (value && typeof value === "object") {
|
|
133
119
|
return Object.fromEntries(Object.entries(value)
|
|
134
120
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
135
|
-
.map(([key, item]) => [key,
|
|
121
|
+
.map(([key, item]) => [key, canonicalJson(item)]));
|
|
136
122
|
}
|
|
137
123
|
return value;
|
|
138
124
|
}
|
|
139
|
-
function jsonValuesEqual(left, right) {
|
|
140
|
-
return JSON.stringify(normalizeJsonValue(left)) === JSON.stringify(normalizeJsonValue(right));
|
|
141
|
-
}
|
|
142
125
|
function scoreJsonFields(expected, actual, configuredFields) {
|
|
143
126
|
const expectedJson = extractJsonObject(expected);
|
|
144
127
|
const actualJson = extractJsonObject(actual);
|
|
145
128
|
if (!expectedJson) {
|
|
146
|
-
const
|
|
129
|
+
const score = exactScore(expected, actual);
|
|
147
130
|
return {
|
|
148
|
-
score
|
|
149
|
-
passed:
|
|
150
|
-
reasoning: "Expected output is not
|
|
131
|
+
score,
|
|
132
|
+
passed: score === 1,
|
|
133
|
+
reasoning: "Expected output is not JSON; used normalized exact match.",
|
|
151
134
|
actualJsonValid: Boolean(actualJson),
|
|
152
135
|
schemaMatch: false,
|
|
153
136
|
fields: [],
|
|
@@ -163,29 +146,20 @@ function scoreJsonFields(expected, actual, configuredFields) {
|
|
|
163
146
|
&& expectedKeys.length === actualKeys.length
|
|
164
147
|
&& expectedKeys.every((key, index) => key === actualKeys[index]);
|
|
165
148
|
const fieldResults = {};
|
|
166
|
-
const missingExpectedFields = [];
|
|
167
149
|
for (const field of fields) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
fieldResults[field] = false;
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
fieldResults[field] = actualJson ? jsonValuesEqual(expectedJson[field], actualJson[field]) : false;
|
|
150
|
+
fieldResults[field] = Object.prototype.hasOwnProperty.call(expectedJson, field)
|
|
151
|
+
&& Boolean(actualJson)
|
|
152
|
+
&& JSON.stringify(canonicalJson(expectedJson[field]))
|
|
153
|
+
=== JSON.stringify(canonicalJson(actualJson?.[field]));
|
|
176
154
|
}
|
|
177
155
|
const correct = Object.values(fieldResults).filter(Boolean).length;
|
|
178
156
|
const score = fields.length > 0 ? correct / fields.length : 0;
|
|
179
|
-
const passed = fields.length > 0 && correct === fields.length;
|
|
180
|
-
const missingNote = missingExpectedFields.length > 0
|
|
181
|
-
? ` Configured fields missing from expected output scored as incorrect: ${missingExpectedFields.join(", ")}.`
|
|
182
|
-
: "";
|
|
183
157
|
return {
|
|
184
158
|
score,
|
|
185
|
-
passed,
|
|
186
|
-
reasoning:
|
|
187
|
-
? `JSON field score: ${correct}/${fields.length}
|
|
188
|
-
: "Actual output is not a JSON object."
|
|
159
|
+
passed: fields.length > 0 && correct === fields.length,
|
|
160
|
+
reasoning: actualJson
|
|
161
|
+
? `JSON field score: ${correct}/${fields.length} fields matched.`
|
|
162
|
+
: "Actual output is not a JSON object.",
|
|
189
163
|
actualJsonValid: Boolean(actualJson),
|
|
190
164
|
schemaMatch,
|
|
191
165
|
fields,
|
|
@@ -198,12 +172,12 @@ function aggregateJsonFieldMetrics(scores, total) {
|
|
|
198
172
|
const fields = [...new Set(scores.flatMap((score) => score.fields))].sort();
|
|
199
173
|
const field_accuracy = {};
|
|
200
174
|
for (const field of fields) {
|
|
201
|
-
const
|
|
202
|
-
const correct =
|
|
175
|
+
const applicable = scores.filter((score) => score.fields.includes(field));
|
|
176
|
+
const correct = applicable.filter((score) => score.fieldResults[field]).length;
|
|
203
177
|
field_accuracy[field] = {
|
|
204
178
|
correct,
|
|
205
|
-
total:
|
|
206
|
-
accuracy:
|
|
179
|
+
total: applicable.length,
|
|
180
|
+
accuracy: applicable.length > 0 ? correct / applicable.length : 0,
|
|
207
181
|
};
|
|
208
182
|
}
|
|
209
183
|
const validJsonCount = scores.filter((score) => score.actualJsonValid).length;
|
|
@@ -225,293 +199,152 @@ function fileUriToPath(value) {
|
|
|
225
199
|
return undefined;
|
|
226
200
|
return value.startsWith("file://") ? value.slice("file://".length) : value;
|
|
227
201
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
system: args.system,
|
|
233
|
-
prompt: args.prompt,
|
|
234
|
-
expected: args.expected,
|
|
235
|
-
},
|
|
236
|
-
timeoutMs: args.timeoutMs,
|
|
237
|
-
timeoutMessage: `Inference command timed out after ${args.timeoutMs}ms`,
|
|
238
|
-
errorPrefix: "Inference command",
|
|
239
|
-
shouldCancel: args.shouldCancel,
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
function buildBatchInferenceCommand(config, inputPath, outputPath) {
|
|
243
|
-
if (config.evaluation.inference.provider === "batch_command") {
|
|
244
|
-
return buildEntrypointCommand({ ...config.evaluation.inference, backend: "command" }, { extraArgs: ["--input", inputPath, "--output", outputPath] });
|
|
202
|
+
export const INFERENCE_PROTOCOL_VERSION = 2;
|
|
203
|
+
function parseBatchInferenceResult(value, expectedIds) {
|
|
204
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
205
|
+
throw new Error("Transformers inference output must be a JSON object");
|
|
245
206
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
207
|
+
const output = value;
|
|
208
|
+
if (!Array.isArray(output.results) || output.results.length !== expectedIds.length) {
|
|
209
|
+
throw new Error(`Transformers inference returned ${Array.isArray(output.results) ? output.results.length : 0} `
|
|
210
|
+
+ `predictions; expected ${expectedIds.length}`);
|
|
211
|
+
}
|
|
212
|
+
const expected = new Set(expectedIds);
|
|
213
|
+
const byId = new Map();
|
|
214
|
+
for (const [index, entry] of output.results.entries()) {
|
|
215
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
216
|
+
throw new Error(`Transformers prediction ${index} must be an object`);
|
|
217
|
+
}
|
|
218
|
+
const prediction = entry;
|
|
219
|
+
if (typeof prediction.id !== "string" || !expected.has(prediction.id)) {
|
|
220
|
+
throw new Error(`Transformers prediction ${index} has an unknown or missing id`);
|
|
221
|
+
}
|
|
222
|
+
if (byId.has(prediction.id)) {
|
|
223
|
+
throw new Error(`Transformers inference returned duplicate id ${prediction.id}`);
|
|
224
|
+
}
|
|
225
|
+
if (typeof prediction.actual !== "string") {
|
|
226
|
+
throw new Error(`Transformers prediction ${index} must include string actual`);
|
|
227
|
+
}
|
|
228
|
+
if (typeof prediction.latency_ms !== "number"
|
|
229
|
+
|| !Number.isInteger(prediction.latency_ms)
|
|
230
|
+
|| prediction.latency_ms < 0) {
|
|
231
|
+
throw new Error(`Transformers prediction ${index} must include non-negative integer latency_ms`);
|
|
232
|
+
}
|
|
233
|
+
byId.set(prediction.id, {
|
|
234
|
+
id: prediction.id,
|
|
235
|
+
actual: prediction.actual,
|
|
236
|
+
latency_ms: prediction.latency_ms,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const optionalString = (key) => typeof output[key] === "string" ? output[key] : undefined;
|
|
240
|
+
return {
|
|
241
|
+
...(optionalString("model_id") ? { model_id: optionalString("model_id") } : {}),
|
|
242
|
+
...(optionalString("base_model") ? { base_model: optionalString("base_model") } : {}),
|
|
243
|
+
...(optionalString("adapter_path") ? { adapter_path: optionalString("adapter_path") } : {}),
|
|
244
|
+
...(output.generation_config
|
|
245
|
+
&& typeof output.generation_config === "object"
|
|
246
|
+
&& !Array.isArray(output.generation_config)
|
|
247
|
+
? { generation_config: output.generation_config }
|
|
248
|
+
: {}),
|
|
249
|
+
results: expectedIds.map((id) => byId.get(id)),
|
|
250
|
+
};
|
|
250
251
|
}
|
|
251
|
-
async function
|
|
252
|
-
const provider = args.config.evaluation.inference.provider === "batch_command" ? "batch_command" : "transformers";
|
|
253
|
-
const label = provider === "batch_command" ? "batch command" : "Transformers";
|
|
252
|
+
async function runTransformersInference(args) {
|
|
254
253
|
const inputPath = `${args.outputPath}.inference-input.json`;
|
|
255
254
|
const outputPath = `${args.outputPath}.inference-output.json`;
|
|
256
255
|
const logPath = `${args.outputPath}.inference.log`;
|
|
257
256
|
await mkdir(dirname(inputPath), { recursive: true });
|
|
258
|
-
let modelLoader;
|
|
259
|
-
try {
|
|
260
|
-
modelLoader = resolveTrainingModel(args.baseModelId).loader;
|
|
261
|
-
}
|
|
262
|
-
catch {
|
|
263
|
-
modelLoader = undefined;
|
|
264
|
-
}
|
|
265
257
|
await writeFile(inputPath, `${JSON.stringify({
|
|
258
|
+
protocol_version: INFERENCE_PROTOCOL_VERSION,
|
|
266
259
|
kind: args.kind,
|
|
267
260
|
model_id: args.modelId,
|
|
268
261
|
base_model: args.baseModelId,
|
|
269
262
|
base_model_revision: args.baseModelRevision,
|
|
270
|
-
model_loader:
|
|
263
|
+
model_loader: "causal_lm",
|
|
271
264
|
adapter_path: fileUriToPath(args.adapterPath),
|
|
272
265
|
system: args.system,
|
|
273
|
-
examples: args.examples,
|
|
274
|
-
|
|
275
|
-
|
|
266
|
+
examples: args.examples.map((example, index) => ({
|
|
267
|
+
id: String(index),
|
|
268
|
+
input: example.input,
|
|
269
|
+
})),
|
|
270
|
+
model_cache: args.config.paths.modelCache
|
|
271
|
+
? resolve(args.config.paths.modelCache)
|
|
272
|
+
: undefined,
|
|
273
|
+
trust_remote_code: false,
|
|
276
274
|
device: args.config.evaluation.inference.device,
|
|
277
|
-
chat_template_kwargs: args.config.evaluation.inference.chatTemplateKwargs,
|
|
278
275
|
generation: {
|
|
279
276
|
max_new_tokens: args.config.evaluation.inference.maxNewTokens,
|
|
280
277
|
temperature: args.config.evaluation.inference.temperature,
|
|
281
278
|
top_p: args.config.evaluation.inference.topP,
|
|
282
279
|
},
|
|
283
280
|
}, null, 2)}\n`, "utf8");
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
const inheritedEnv = provider === "transformers"
|
|
287
|
-
? minimalMachineLearningEnvironment(process.env, {
|
|
288
|
-
includeHfToken: (() => {
|
|
289
|
-
try {
|
|
290
|
-
return resolveTrainingModel(args.baseModelId).requiresHfToken;
|
|
291
|
-
}
|
|
292
|
-
catch {
|
|
293
|
-
return false;
|
|
294
|
-
}
|
|
295
|
-
})(),
|
|
296
|
-
})
|
|
297
|
-
: process.env;
|
|
281
|
+
await rm(outputPath, { force: true });
|
|
282
|
+
const entrypoint = buildBundledPythonCommand("evaluate.py", ["--input", inputPath, "--output", outputPath]);
|
|
298
283
|
await args.reporter?.onEvent?.({
|
|
299
284
|
stage: `evaluating_${args.kind}`,
|
|
300
285
|
status: "running",
|
|
301
|
-
message: `Starting ${args.kind}
|
|
286
|
+
message: `Starting ${args.kind} Transformers inference.`,
|
|
302
287
|
details: {
|
|
303
288
|
model_id: args.modelId,
|
|
304
289
|
examples: args.examples.length,
|
|
305
|
-
command,
|
|
290
|
+
command: entrypoint.displayCommand,
|
|
306
291
|
log_path: logPath,
|
|
307
292
|
},
|
|
308
293
|
});
|
|
309
294
|
const result = await runLoggedProcess({
|
|
310
295
|
command: entrypoint.command,
|
|
311
296
|
commandArgs: entrypoint.commandArgs,
|
|
312
|
-
|
|
313
|
-
env: withHuggingFaceCacheEnvironment({
|
|
314
|
-
...inheritedEnv,
|
|
315
|
-
...args.config.evaluation.inference.env,
|
|
316
|
-
}, args.config.paths.modelCache),
|
|
297
|
+
env: withBundledPythonEnvironment(withOfflineHuggingFaceCacheEnvironment(minimalMachineLearningEnvironment(process.env), args.config.paths.modelCache)),
|
|
317
298
|
logPath,
|
|
318
299
|
timeoutMs: args.config.evaluation.timeoutMs,
|
|
319
|
-
timeoutMessage:
|
|
300
|
+
timeoutMessage: `Transformers inference timed out after ${args.config.evaluation.timeoutMs}ms`,
|
|
320
301
|
reporter: args.reporter,
|
|
321
302
|
stage: `evaluating_${args.kind}`,
|
|
322
303
|
shouldCancel: args.shouldCancel,
|
|
323
304
|
});
|
|
324
305
|
if (result.exitCode !== 0) {
|
|
325
|
-
throw new Error(
|
|
306
|
+
throw new Error(`Transformers inference exited ${result.exitCode}: ${result.stderr.slice(0, 1000)}`);
|
|
326
307
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
message: `Finished ${args.kind} ${label} inference.`,
|
|
331
|
-
details: { output_path: outputPath, log_path: logPath },
|
|
332
|
-
});
|
|
333
|
-
return {
|
|
334
|
-
...JSON.parse(await readFile(outputPath, "utf8")),
|
|
335
|
-
provider,
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
/**
|
|
339
|
-
* Builds the judge messages. The spec's compiled system message (system
|
|
340
|
-
* prompt, guidelines, and constraints) is forwarded as task_instructions so
|
|
341
|
-
* the judge scores conformance to the task, not just similarity to the
|
|
342
|
-
* reference. Without it, a judge treats `expected` as a fact checklist and
|
|
343
|
-
* systematically penalizes outputs trained toward a different style (for
|
|
344
|
-
* example concise summaries) even when they follow the spec.
|
|
345
|
-
*/
|
|
346
|
-
export function buildJudgeMessages(args) {
|
|
347
|
-
return [
|
|
348
|
-
{
|
|
349
|
-
role: "system",
|
|
350
|
-
content: "You are a strict evaluator. Score how well the actual output fulfills the task instructions "
|
|
351
|
-
+ "for the given prompt. The expected output is a reference answer showing the desired style, length, "
|
|
352
|
-
+ "and content; treat it as one correct answer, not an exhaustive checklist. Penalize factual errors, "
|
|
353
|
-
+ "contradictions of the prompt, and violations of the task instructions. Do not penalize an output "
|
|
354
|
-
+ "merely for omitting secondary reference details when the task instructions call for that brevity. "
|
|
355
|
-
+ "Return JSON only with keys score (0 to 1), passed (boolean), and reasoning (string).",
|
|
356
|
-
},
|
|
357
|
-
{
|
|
358
|
-
role: "user",
|
|
359
|
-
content: JSON.stringify({
|
|
360
|
-
...(args.taskInstructions ? { task_instructions: args.taskInstructions } : {}),
|
|
361
|
-
prompt: args.prompt,
|
|
362
|
-
expected: args.expected,
|
|
363
|
-
actual: args.actual,
|
|
364
|
-
}),
|
|
365
|
-
},
|
|
366
|
-
];
|
|
367
|
-
}
|
|
368
|
-
async function judgeWithOpenRouter(args) {
|
|
369
|
-
if (!args.config.llm) {
|
|
370
|
-
throw new Error("evaluation.scoring.mode=llm_judge requires llm OpenRouter config");
|
|
371
|
-
}
|
|
372
|
-
const result = await openRouterChat(buildJudgeMessages(args), {
|
|
373
|
-
model: args.config.llm.model,
|
|
374
|
-
apiKeyEnv: args.config.llm.apiKeyEnv,
|
|
375
|
-
appName: args.config.llm.appName,
|
|
376
|
-
siteUrl: args.config.llm.siteUrl,
|
|
377
|
-
timeoutMs: args.config.evaluation.timeoutMs,
|
|
378
|
-
});
|
|
379
|
-
const parsed = extractJsonObject(result.content);
|
|
380
|
-
if (!parsed) {
|
|
381
|
-
throw new Error(`OpenRouter judge returned malformed JSON: ${result.content.slice(0, 200)}`);
|
|
382
|
-
}
|
|
383
|
-
const score = typeof parsed.score === "number"
|
|
384
|
-
? Math.max(0, Math.min(1, parsed.score))
|
|
385
|
-
: 0;
|
|
386
|
-
return {
|
|
387
|
-
score,
|
|
388
|
-
passed: typeof parsed.passed === "boolean" ? parsed.passed : score === 1,
|
|
389
|
-
reasoning: typeof parsed.reasoning === "string" ? parsed.reasoning : "OpenRouter judge returned no reasoning.",
|
|
390
|
-
model: result.model ?? args.config.llm.model,
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
export function evaluationJudgeAvailability(config) {
|
|
394
|
-
if (!config.llm) {
|
|
395
|
-
return {
|
|
396
|
-
available: false,
|
|
397
|
-
reason: "the llm OpenRouter configuration is missing",
|
|
398
|
-
};
|
|
308
|
+
let output;
|
|
309
|
+
try {
|
|
310
|
+
output = JSON.parse(await readFile(outputPath, "utf8"));
|
|
399
311
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
available: false,
|
|
403
|
-
reason: `${config.llm.apiKeyEnv} is not set`,
|
|
404
|
-
};
|
|
312
|
+
catch (error) {
|
|
313
|
+
throw new Error(`Transformers inference did not write valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
405
314
|
}
|
|
406
|
-
return
|
|
315
|
+
return parseBatchInferenceResult(output, args.examples.map((_, index) => String(index)));
|
|
407
316
|
}
|
|
408
317
|
/**
|
|
409
|
-
*
|
|
410
|
-
*
|
|
318
|
+
* Deterministic local scoring needs no network credentials. This function is
|
|
319
|
+
* kept as the shared preflight hook used by validate, doctor, and run.
|
|
411
320
|
*/
|
|
412
|
-
export function assertEvaluationScoringReady(config
|
|
413
|
-
if (
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if (!availability.available && config.evaluation.scoring.fallback !== "exact_match") {
|
|
417
|
-
throw new Error(`evaluation.scoring.mode=llm_judge cannot start because ${availability.reason}. `
|
|
418
|
-
+ "Configure llm and its API key, or explicitly set "
|
|
419
|
-
+ "evaluation.scoring.fallback=exact_match.");
|
|
321
|
+
export function assertEvaluationScoringReady(config) {
|
|
322
|
+
if (config.evaluation.scoring.mode === "json_fields"
|
|
323
|
+
&& config.evaluation.scoring.fields?.some((field) => !field.trim())) {
|
|
324
|
+
throw new Error("evaluation.scoring.fields must contain non-empty field names");
|
|
420
325
|
}
|
|
421
|
-
return availability;
|
|
422
326
|
}
|
|
423
|
-
const FACTUAL_REASONING = /incorrect|error|misstate|mis-state|contradict|wrong|inaccurate|not supported|misattribut|invent|fabricat|halluc/i;
|
|
424
|
-
const OMISSION_REASONING = /omit|missing|leaves out|leave out|lacks|does not (?:mention|include|cover)|fails to (?:mention|include|cover)/i;
|
|
425
|
-
const STYLE_REASONING = /verbose|too long|too short|format|style|tone|first person|markdown|preamble/i;
|
|
426
|
-
/**
|
|
427
|
-
* Coarse category for a judge reasoning string so comparison reports can
|
|
428
|
-
* answer "what kind of worse?" without re-reading every example. Factual
|
|
429
|
-
* problems dominate omissions, which dominate style notes; fallback marks
|
|
430
|
-
* examples that were never judge-scored (their score is not comparable to
|
|
431
|
-
* judged ones).
|
|
432
|
-
*/
|
|
433
|
-
export function classifyJudgeReasoning(reasoning, scoredBy) {
|
|
434
|
-
if (scoredBy === "exact_match_fallback")
|
|
435
|
-
return "fallback";
|
|
436
|
-
if (!reasoning)
|
|
437
|
-
return "other";
|
|
438
|
-
if (reasoning.startsWith("LLM judge failed"))
|
|
439
|
-
return "fallback";
|
|
440
|
-
if (FACTUAL_REASONING.test(reasoning))
|
|
441
|
-
return "factual";
|
|
442
|
-
if (OMISSION_REASONING.test(reasoning))
|
|
443
|
-
return "omission";
|
|
444
|
-
if (STYLE_REASONING.test(reasoning))
|
|
445
|
-
return "style";
|
|
446
|
-
return "other";
|
|
447
|
-
}
|
|
448
|
-
/**
|
|
449
|
-
* Cache key for a baseline evaluation. Baseline outputs are deterministic for
|
|
450
|
-
* a given model, example set, and generation settings, and judge scores only
|
|
451
|
-
* depend on those plus the scoring configuration, so re-running a spec with
|
|
452
|
-
* an unchanged baseline can reuse the previous report instead of paying for
|
|
453
|
-
* inference and judge calls again. The package version participates so rubric
|
|
454
|
-
* or evaluator changes invalidate old entries.
|
|
455
|
-
*/
|
|
456
327
|
export function baselineCacheKey(args) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
328
|
+
return createHash("sha256").update(JSON.stringify({
|
|
329
|
+
v: 6,
|
|
330
|
+
inference_protocol_version: INFERENCE_PROTOCOL_VERSION,
|
|
460
331
|
package_version: args.packageVersion,
|
|
461
332
|
model_id: args.modelId,
|
|
462
333
|
base_model_revision: args.baseModelRevision ?? null,
|
|
463
334
|
source_fingerprint: args.sourceFingerprint ?? null,
|
|
464
335
|
system: args.system,
|
|
465
|
-
examples: args.examples
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
})),
|
|
470
|
-
// The full inference config participates: a different evaluator script,
|
|
471
|
-
// project, or generation setting must produce a different cache entry.
|
|
336
|
+
examples: args.examples,
|
|
337
|
+
eval_examples_total: args.evalExamplesTotal,
|
|
338
|
+
eval_split: args.evalSplit ?? null,
|
|
339
|
+
eval_sample_seed: args.evalSampleSeed ?? null,
|
|
472
340
|
inference: args.config.evaluation.inference,
|
|
473
|
-
scoring:
|
|
474
|
-
|
|
475
|
-
fallback: args.config.evaluation.scoring.fallback,
|
|
476
|
-
fields: args.config.evaluation.scoring.fields ?? null,
|
|
477
|
-
judge: args.config.llm ?? null,
|
|
478
|
-
judge_available: judgeAvailability.available,
|
|
479
|
-
},
|
|
480
|
-
};
|
|
481
|
-
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
341
|
+
scoring: args.config.evaluation.scoring,
|
|
342
|
+
})).digest("hex");
|
|
482
343
|
}
|
|
483
|
-
function
|
|
484
|
-
return
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|| value.startsWith("../")
|
|
488
|
-
|| value === "~"
|
|
489
|
-
|| value.startsWith("~/");
|
|
490
|
-
}
|
|
491
|
-
/**
|
|
492
|
-
* A cache hit is safe only when every mutable input has a content identity.
|
|
493
|
-
* Remote model branches and remote image URLs may change while retaining the
|
|
494
|
-
* same string, so those evaluations deliberately bypass the shared cache.
|
|
495
|
-
*/
|
|
496
|
-
function baselineInputsAreCacheStable(args) {
|
|
497
|
-
const baseModel = args.baseModelId ?? args.modelId;
|
|
498
|
-
const localBase = Boolean(args.config.paths.baseModel) || clearlyLocalReference(baseModel);
|
|
499
|
-
if (localBase ? !args.sourceFingerprint : !args.baseModelRevision)
|
|
500
|
-
return false;
|
|
501
|
-
if (args.adapterPath && !args.sourceFingerprint)
|
|
502
|
-
return false;
|
|
503
|
-
for (const example of args.examples) {
|
|
504
|
-
for (const asset of example.input_assets ?? []) {
|
|
505
|
-
const reference = asset.image ?? asset.data_uri ?? asset.uri ?? asset.path;
|
|
506
|
-
if (!reference || reference.startsWith("data:"))
|
|
507
|
-
continue;
|
|
508
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(reference) && !reference.startsWith("file://"))
|
|
509
|
-
return false;
|
|
510
|
-
if (!args.sourceFingerprint)
|
|
511
|
-
return false;
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
return true;
|
|
344
|
+
function baselineInputsAreStable(args) {
|
|
345
|
+
return args.config.paths.baseModel
|
|
346
|
+
? Boolean(args.sourceFingerprint)
|
|
347
|
+
: Boolean(args.baseModelRevision);
|
|
515
348
|
}
|
|
516
349
|
function baselineCachePath(config, key) {
|
|
517
350
|
const root = config.storeRoot ? resolve(config.storeRoot) : defaultLocalHome();
|
|
@@ -527,150 +360,48 @@ async function readBaselineCache(path) {
|
|
|
527
360
|
}
|
|
528
361
|
let cachedPackageVersion = null;
|
|
529
362
|
async function packageVersion() {
|
|
530
|
-
if (cachedPackageVersion
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
catch {
|
|
536
|
-
cachedPackageVersion = "unknown";
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
return cachedPackageVersion;
|
|
540
|
-
}
|
|
541
|
-
function resolveEvaluationRuntime(args) {
|
|
542
|
-
const command = args.kind === "baseline"
|
|
543
|
-
? args.config.evaluation.baselineCommand
|
|
544
|
-
: args.config.evaluation.candidateCommand;
|
|
545
|
-
const provider = args.config.evaluation.inference.provider;
|
|
546
|
-
const inferenceProvider = args.config.dryRun
|
|
547
|
-
? "none"
|
|
548
|
-
: provider === "command"
|
|
549
|
-
? "command"
|
|
550
|
-
: provider;
|
|
551
|
-
if (inferenceProvider === "command" && !command) {
|
|
552
|
-
throw new Error(`evaluation.inference.provider=command requires evaluation.${args.kind}Command`);
|
|
363
|
+
if (cachedPackageVersion !== null)
|
|
364
|
+
return cachedPackageVersion;
|
|
365
|
+
try {
|
|
366
|
+
const raw = await readFile(new URL("../package.json", import.meta.url), "utf8");
|
|
367
|
+
cachedPackageVersion = String(JSON.parse(raw).version ?? "unknown");
|
|
553
368
|
}
|
|
554
|
-
|
|
555
|
-
|
|
369
|
+
catch {
|
|
370
|
+
cachedPackageVersion = "unknown";
|
|
556
371
|
}
|
|
557
|
-
|
|
558
|
-
const scoringMode = (args.config.dryRun || inferenceProvider === "none") && configuredScoringMode === "llm_judge"
|
|
559
|
-
? "exact_match"
|
|
560
|
-
: configuredScoringMode;
|
|
561
|
-
return { command, inferenceProvider, scoringMode };
|
|
372
|
+
return cachedPackageVersion;
|
|
562
373
|
}
|
|
563
|
-
async function
|
|
564
|
-
const
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
const judgeAvailability = assertEvaluationScoringReady(args.config, args.scoringMode);
|
|
568
|
-
const shouldJudge = args.scoringMode === "llm_judge" && judgeAvailability.available;
|
|
569
|
-
for (const [index, generated] of args.generated.entries()) {
|
|
570
|
-
const exactScore = scoreActual(generated.expected, generated.actual);
|
|
571
|
-
if (shouldJudge && index === 0) {
|
|
572
|
-
await args.reporter?.onEvent?.({
|
|
573
|
-
stage: `evaluating_${args.kind}`,
|
|
574
|
-
status: "running",
|
|
575
|
-
message: `Scoring ${args.kind} outputs with OpenRouter judge.`,
|
|
576
|
-
details: { model: args.config.llm?.model, examples: args.generated.length },
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
let judged = null;
|
|
580
|
-
let judgeFellBack = false;
|
|
581
|
-
if (args.scoringMode === "llm_judge" && !shouldJudge) {
|
|
582
|
-
judgeFellBack = true;
|
|
583
|
-
judged = {
|
|
584
|
-
score: exactScore,
|
|
585
|
-
passed: exactScore === 1,
|
|
586
|
-
reasoning: `LLM judge unavailable (${judgeAvailability.reason}); scored by normalized exact match.`,
|
|
587
|
-
model: null,
|
|
588
|
-
};
|
|
589
|
-
}
|
|
590
|
-
else if (shouldJudge) {
|
|
591
|
-
try {
|
|
592
|
-
judged = await judgeWithOpenRouter({
|
|
593
|
-
prompt: generated.prompt,
|
|
594
|
-
expected: generated.expected,
|
|
595
|
-
actual: generated.actual,
|
|
596
|
-
taskInstructions: args.system?.trim() || undefined,
|
|
597
|
-
config: args.config,
|
|
598
|
-
});
|
|
599
|
-
}
|
|
600
|
-
catch (error) {
|
|
601
|
-
if (args.config.evaluation.scoring.fallback === "fail")
|
|
602
|
-
throw error;
|
|
603
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
604
|
-
judgeFellBack = true;
|
|
605
|
-
judged = {
|
|
606
|
-
score: exactScore,
|
|
607
|
-
passed: exactScore === 1,
|
|
608
|
-
reasoning: `LLM judge failed (${message.slice(0, 300)}); scored by normalized exact match.`,
|
|
609
|
-
model: null,
|
|
610
|
-
};
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
if (judged?.model)
|
|
614
|
-
judgeModelId = judged.model;
|
|
615
|
-
const jsonFieldScore = args.scoringMode === "json_fields"
|
|
374
|
+
async function scoreGenerated(args) {
|
|
375
|
+
const jsonScores = [];
|
|
376
|
+
const results = args.generated.map((generated) => {
|
|
377
|
+
const json = args.config.evaluation.scoring.mode === "json_fields"
|
|
616
378
|
? scoreJsonFields(generated.expected, generated.actual, args.config.evaluation.scoring.fields)
|
|
617
379
|
: null;
|
|
618
|
-
if (
|
|
619
|
-
|
|
620
|
-
const score =
|
|
621
|
-
|
|
622
|
-
? (judgeFellBack ? "exact_match_fallback" : "llm_judge")
|
|
623
|
-
: jsonFieldScore
|
|
624
|
-
? "json_fields"
|
|
625
|
-
: args.inferenceProvider === "none"
|
|
626
|
-
? "heuristic"
|
|
627
|
-
: "exact_match";
|
|
628
|
-
results.push({
|
|
380
|
+
if (json)
|
|
381
|
+
jsonScores.push(json);
|
|
382
|
+
const score = json?.score ?? exactScore(generated.expected, generated.actual);
|
|
383
|
+
return {
|
|
629
384
|
prompt: generated.prompt,
|
|
630
385
|
expected: generated.expected,
|
|
631
386
|
actual: generated.actual,
|
|
632
|
-
passed:
|
|
387
|
+
passed: json?.passed ?? score === 1,
|
|
633
388
|
score,
|
|
634
|
-
reasoning:
|
|
635
|
-
? "
|
|
636
|
-
:
|
|
637
|
-
? "OpenRouter judge unavailable; fell back to normalized exact match."
|
|
638
|
-
: "Scored by normalized exact match."),
|
|
389
|
+
reasoning: json?.reasoning ?? (args.inferenceProvider === "none"
|
|
390
|
+
? "Dry run: model inference was not executed."
|
|
391
|
+
: "Scored by normalized exact match."),
|
|
639
392
|
latency_ms: generated.latency_ms,
|
|
640
|
-
scored_by:
|
|
641
|
-
|
|
642
|
-
}
|
|
643
|
-
const total = results.length;
|
|
644
|
-
const avgScore = total > 0
|
|
645
|
-
? results.reduce((sum, result) => sum + result.score, 0) / total
|
|
646
|
-
: 0;
|
|
647
|
-
const passRate = total > 0
|
|
648
|
-
? results.filter((result) => result.passed).length / total
|
|
649
|
-
: 0;
|
|
650
|
-
const exactMatchRate = total > 0
|
|
651
|
-
? results.filter((result) => scoreActual(result.expected, result.actual) === 1).length / total
|
|
652
|
-
: 0;
|
|
653
|
-
const avgTokenF1 = total > 0
|
|
654
|
-
? results.reduce((sum, result) => sum + tokenF1(result.expected, result.actual), 0) / total
|
|
655
|
-
: 0;
|
|
656
|
-
const judgeScored = results.filter((result) => result.scored_by === "llm_judge");
|
|
657
|
-
const fallbackScoredCount = results.filter((result) => result.scored_by === "exact_match_fallback").length;
|
|
658
|
-
const judgeOnlyAvgScore = judgeScored.length > 0
|
|
659
|
-
? judgeScored.reduce((sum, result) => sum + result.score, 0) / judgeScored.length
|
|
660
|
-
: null;
|
|
661
|
-
const avgLatency = total > 0
|
|
662
|
-
? Math.round(results.reduce((sum, result) => sum + result.latency_ms, 0) / total)
|
|
663
|
-
: 0;
|
|
664
|
-
const scoringMethod = judgeModelId
|
|
665
|
-
? "llm_judge"
|
|
666
|
-
: args.scoringMode === "json_fields"
|
|
667
|
-
? "json_fields"
|
|
668
|
-
: args.inferenceProvider === "command"
|
|
669
|
-
? "command"
|
|
393
|
+
scored_by: json
|
|
394
|
+
? "json_fields"
|
|
670
395
|
: args.inferenceProvider === "none"
|
|
671
396
|
? "heuristic"
|
|
672
|
-
: "exact_match"
|
|
673
|
-
|
|
397
|
+
: "exact_match",
|
|
398
|
+
};
|
|
399
|
+
});
|
|
400
|
+
const total = results.length;
|
|
401
|
+
const mean = (values) => values.length > 0
|
|
402
|
+
? values.reduce((sum, value) => sum + value, 0) / values.length
|
|
403
|
+
: 0;
|
|
404
|
+
const report = evalReportSchema.parse({
|
|
674
405
|
kind: args.kind,
|
|
675
406
|
model_id: args.modelId,
|
|
676
407
|
total,
|
|
@@ -679,64 +410,46 @@ async function scoreGeneratedEvalResults(args) {
|
|
|
679
410
|
eval_truncated: args.evalExamplesTotal > total,
|
|
680
411
|
eval_split: args.evalSplit,
|
|
681
412
|
eval_sample_seed: args.evalSampleSeed ?? null,
|
|
682
|
-
avg_score:
|
|
683
|
-
pass_rate:
|
|
684
|
-
exact_match_rate:
|
|
685
|
-
avg_token_f1:
|
|
686
|
-
avg_latency_ms:
|
|
687
|
-
judge_scored_count: judgeScored.length,
|
|
688
|
-
fallback_scored_count: fallbackScoredCount,
|
|
689
|
-
judge_only_avg_score: judgeOnlyAvgScore,
|
|
413
|
+
avg_score: mean(results.map((result) => result.score)),
|
|
414
|
+
pass_rate: mean(results.map((result) => result.passed ? 1 : 0)),
|
|
415
|
+
exact_match_rate: mean(results.map((result) => exactScore(result.expected, result.actual))),
|
|
416
|
+
avg_token_f1: mean(results.map((result) => tokenF1(result.expected, result.actual))),
|
|
417
|
+
avg_latency_ms: Math.round(mean(results.map((result) => result.latency_ms))),
|
|
690
418
|
results,
|
|
691
419
|
artifact_uri: fileUri(args.outputPath),
|
|
692
|
-
scoring_method:
|
|
693
|
-
|
|
420
|
+
scoring_method: args.config.evaluation.scoring.mode === "json_fields"
|
|
421
|
+
? "json_fields"
|
|
422
|
+
: args.inferenceProvider === "none"
|
|
423
|
+
? "heuristic"
|
|
424
|
+
: "exact_match",
|
|
694
425
|
inference_provider: args.inferenceProvider,
|
|
695
|
-
scoring_mode: args.
|
|
696
|
-
json_field_metrics: args.
|
|
697
|
-
? aggregateJsonFieldMetrics(
|
|
426
|
+
scoring_mode: args.config.evaluation.scoring.mode,
|
|
427
|
+
json_field_metrics: args.config.evaluation.scoring.mode === "json_fields"
|
|
428
|
+
? aggregateJsonFieldMetrics(jsonScores, total)
|
|
698
429
|
: undefined,
|
|
699
430
|
generation_config: args.generationConfig,
|
|
700
431
|
log_uri: args.logUri,
|
|
701
|
-
};
|
|
432
|
+
});
|
|
702
433
|
await writeJson(args.outputPath, report);
|
|
703
434
|
return report;
|
|
704
435
|
}
|
|
705
436
|
export async function evaluateExamples(args) {
|
|
706
|
-
|
|
707
|
-
// (passed by the orchestrator via args.maxExamples).
|
|
437
|
+
assertEvaluationScoringReady(args.config);
|
|
708
438
|
const maxExamples = args.config.evaluation.maxExamples
|
|
709
439
|
?? args.maxExamples
|
|
710
440
|
?? args.examples.length;
|
|
711
441
|
const truncated = args.examples.length > maxExamples;
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
const sampleSeed = args.config.evaluation.sampleSeed ?? args.sampleSeed ?? 0;
|
|
716
|
-
const selected = truncated
|
|
717
|
-
? sampleExamples(args.examples, maxExamples, sampleSeed)
|
|
442
|
+
const seed = args.config.evaluation.sampleSeed ?? args.sampleSeed ?? 0;
|
|
443
|
+
const examples = truncated
|
|
444
|
+
? sampleExamples(args.examples, maxExamples, seed)
|
|
718
445
|
: args.examples;
|
|
719
|
-
const
|
|
720
|
-
kind: args.kind,
|
|
721
|
-
config: args.config,
|
|
722
|
-
});
|
|
723
|
-
const judgeAvailability = assertEvaluationScoringReady(args.config, scoringMode);
|
|
724
|
-
let generationConfig;
|
|
725
|
-
// Baseline outputs and scores are fully determined by the cache key inputs,
|
|
726
|
-
// so an unchanged baseline can reuse the previous report instead of paying
|
|
727
|
-
// for inference and judge calls on every run of the same spec.
|
|
446
|
+
const evalSampleSeed = truncated || args.evalSplit === "spec_holdout" ? seed : null;
|
|
728
447
|
const cacheEligible = args.kind === "baseline"
|
|
729
448
|
&& args.config.evaluation.baselineCache
|
|
730
449
|
&& !args.config.dryRun
|
|
731
|
-
&&
|
|
732
|
-
&& (scoringMode !== "llm_judge" || judgeAvailability.available)
|
|
733
|
-
&& baselineInputsAreCacheStable({
|
|
734
|
-
modelId: args.modelId,
|
|
735
|
-
baseModelId: args.baseModelId,
|
|
450
|
+
&& baselineInputsAreStable({
|
|
736
451
|
baseModelRevision: args.baseModelRevision,
|
|
737
|
-
adapterPath: args.adapterPath,
|
|
738
452
|
sourceFingerprint: args.sourceFingerprint,
|
|
739
|
-
examples: selected,
|
|
740
453
|
config: args.config,
|
|
741
454
|
});
|
|
742
455
|
const cacheKey = cacheEligible
|
|
@@ -745,7 +458,10 @@ export async function evaluateExamples(args) {
|
|
|
745
458
|
baseModelRevision: args.baseModelRevision,
|
|
746
459
|
sourceFingerprint: args.sourceFingerprint,
|
|
747
460
|
system: args.system,
|
|
748
|
-
examples
|
|
461
|
+
examples,
|
|
462
|
+
evalExamplesTotal: args.examples.length,
|
|
463
|
+
evalSplit: args.evalSplit,
|
|
464
|
+
evalSampleSeed,
|
|
749
465
|
config: args.config,
|
|
750
466
|
packageVersion: await packageVersion(),
|
|
751
467
|
})
|
|
@@ -753,164 +469,88 @@ export async function evaluateExamples(args) {
|
|
|
753
469
|
if (cacheKey) {
|
|
754
470
|
const cached = await readBaselineCache(baselineCachePath(args.config, cacheKey));
|
|
755
471
|
if (cached) {
|
|
756
|
-
const report = {
|
|
472
|
+
const report = evalReportSchema.parse({
|
|
757
473
|
...cached,
|
|
758
474
|
cached: true,
|
|
759
475
|
cache_key: cacheKey,
|
|
760
476
|
artifact_uri: fileUri(args.outputPath),
|
|
761
477
|
eval_split: args.evalSplit ?? cached.eval_split,
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
status: "running",
|
|
767
|
-
message: "Reusing cached baseline evaluation (identical model, examples, and scoring).",
|
|
768
|
-
details: { cache_key: cacheKey, examples: report.total },
|
|
478
|
+
eval_examples_total: args.examples.length,
|
|
479
|
+
eval_examples_used: examples.length,
|
|
480
|
+
eval_truncated: truncated,
|
|
481
|
+
eval_sample_seed: evalSampleSeed,
|
|
769
482
|
});
|
|
483
|
+
await writeJson(args.outputPath, report);
|
|
770
484
|
return report;
|
|
771
485
|
}
|
|
772
486
|
}
|
|
773
|
-
const
|
|
774
|
-
?
|
|
487
|
+
const inference = args.config.dryRun
|
|
488
|
+
? null
|
|
489
|
+
: await runTransformersInference({
|
|
775
490
|
kind: args.kind,
|
|
776
491
|
modelId: args.modelId,
|
|
777
492
|
baseModelId: args.baseModelId ?? args.modelId,
|
|
778
493
|
baseModelRevision: args.baseModelRevision,
|
|
779
494
|
adapterPath: args.adapterPath,
|
|
780
|
-
examples
|
|
495
|
+
examples,
|
|
781
496
|
system: args.system,
|
|
782
497
|
config: args.config,
|
|
783
498
|
outputPath: args.outputPath,
|
|
784
499
|
reporter: args.reporter,
|
|
785
500
|
shouldCancel: args.shouldCancel,
|
|
786
|
-
})
|
|
787
|
-
: null;
|
|
788
|
-
generationConfig = inferred?.generation_config;
|
|
789
|
-
const generated = [];
|
|
790
|
-
for (const [index, example] of selected.entries()) {
|
|
791
|
-
const started = performance.now();
|
|
792
|
-
const commandActual = inferenceProvider === "command" && command
|
|
793
|
-
? await runInferenceCommand({
|
|
794
|
-
command,
|
|
795
|
-
prompt: example.input,
|
|
796
|
-
system: args.system,
|
|
797
|
-
expected: example.output,
|
|
798
|
-
timeoutMs: args.config.evaluation.timeoutMs,
|
|
799
|
-
shouldCancel: args.shouldCancel,
|
|
800
|
-
})
|
|
801
|
-
: undefined;
|
|
802
|
-
const inferredResult = inferred?.results[index];
|
|
803
|
-
const actual = commandActual ?? inferredResult?.actual ?? "";
|
|
804
|
-
const latencyMs = inferredResult?.latency_ms ?? Math.max(0, Math.round(performance.now() - started));
|
|
805
|
-
generated.push({
|
|
806
|
-
prompt: example.input,
|
|
807
|
-
expected: example.output,
|
|
808
|
-
actual,
|
|
809
|
-
latency_ms: latencyMs,
|
|
810
501
|
});
|
|
811
|
-
|
|
812
|
-
|
|
502
|
+
const generated = examples.map((example, index) => ({
|
|
503
|
+
prompt: example.input,
|
|
504
|
+
expected: example.output,
|
|
505
|
+
actual: inference?.results[index]?.actual ?? "",
|
|
506
|
+
latency_ms: inference?.results[index]?.latency_ms ?? 0,
|
|
507
|
+
}));
|
|
508
|
+
const report = await scoreGenerated({
|
|
813
509
|
kind: args.kind,
|
|
814
510
|
modelId: args.modelId,
|
|
815
511
|
generated,
|
|
816
512
|
evalExamplesTotal: args.examples.length,
|
|
817
513
|
config: args.config,
|
|
818
514
|
outputPath: args.outputPath,
|
|
819
|
-
|
|
820
|
-
inferenceProvider,
|
|
821
|
-
system: args.system,
|
|
515
|
+
inferenceProvider: inference ? "transformers" : "none",
|
|
822
516
|
evalSplit: args.evalSplit,
|
|
823
|
-
evalSampleSeed
|
|
824
|
-
generationConfig,
|
|
825
|
-
logUri:
|
|
826
|
-
? fileUri(`${args.outputPath}.inference.log`)
|
|
827
|
-
: undefined,
|
|
828
|
-
reporter: args.reporter,
|
|
517
|
+
evalSampleSeed,
|
|
518
|
+
generationConfig: inference?.generation_config,
|
|
519
|
+
logUri: inference ? fileUri(`${args.outputPath}.inference.log`) : undefined,
|
|
829
520
|
});
|
|
830
521
|
if (cacheKey) {
|
|
831
|
-
|
|
832
|
-
// failure would otherwise be replayed into every future run.
|
|
833
|
-
if ((report.fallback_scored_count ?? 0) === 0
|
|
834
|
-
&& (scoringMode !== "llm_judge" || report.judge_scored_count === report.total)) {
|
|
835
|
-
await writeJson(baselineCachePath(args.config, cacheKey), { ...report, cache_key: cacheKey });
|
|
836
|
-
}
|
|
522
|
+
await writeJson(baselineCachePath(args.config, cacheKey), { ...report, cache_key: cacheKey });
|
|
837
523
|
}
|
|
838
524
|
return report;
|
|
839
525
|
}
|
|
840
|
-
export async function rescoreEvalReport(args) {
|
|
841
|
-
const configuredScoringMode = args.config.evaluation.scoring.mode;
|
|
842
|
-
const scoringMode = args.config.dryRun && configuredScoringMode === "llm_judge"
|
|
843
|
-
? "exact_match"
|
|
844
|
-
: configuredScoringMode;
|
|
845
|
-
const inferenceProvider = args.report.inference_provider ?? "none";
|
|
846
|
-
return scoreGeneratedEvalResults({
|
|
847
|
-
kind: args.report.kind,
|
|
848
|
-
modelId: args.report.model_id,
|
|
849
|
-
generated: args.report.results.map((result) => ({
|
|
850
|
-
prompt: result.prompt,
|
|
851
|
-
expected: result.expected,
|
|
852
|
-
actual: result.actual,
|
|
853
|
-
latency_ms: result.latency_ms,
|
|
854
|
-
})),
|
|
855
|
-
evalExamplesTotal: args.report.eval_examples_total,
|
|
856
|
-
config: args.config,
|
|
857
|
-
outputPath: args.outputPath,
|
|
858
|
-
scoringMode,
|
|
859
|
-
inferenceProvider,
|
|
860
|
-
system: args.system,
|
|
861
|
-
evalSplit: args.report.eval_split,
|
|
862
|
-
evalSampleSeed: args.report.eval_sample_seed ?? null,
|
|
863
|
-
generationConfig: args.report.generation_config,
|
|
864
|
-
logUri: args.report.log_uri,
|
|
865
|
-
reporter: args.reporter,
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
526
|
export function compareEvalReports(baseline, candidate) {
|
|
869
527
|
let regressions = 0;
|
|
870
528
|
let improvements = 0;
|
|
871
529
|
const regressedExamples = [];
|
|
872
|
-
// All categories are always present: zod v4 enum-keyed records are
|
|
873
|
-
// exhaustive, so a partial taxonomy fails runReportSchema validation.
|
|
874
|
-
const taxonomy = {
|
|
875
|
-
factual: 0,
|
|
876
|
-
omission: 0,
|
|
877
|
-
style: 0,
|
|
878
|
-
fallback: 0,
|
|
879
|
-
other: 0,
|
|
880
|
-
};
|
|
881
530
|
const count = Math.min(baseline.results.length, candidate.results.length);
|
|
882
531
|
for (let index = 0; index < count; index += 1) {
|
|
883
532
|
const oldScore = baseline.results[index]?.score ?? 0;
|
|
884
|
-
const
|
|
885
|
-
const newScore =
|
|
533
|
+
const result = candidate.results[index];
|
|
534
|
+
const newScore = result?.score ?? 0;
|
|
886
535
|
if (newScore < oldScore) {
|
|
887
536
|
regressions += 1;
|
|
888
|
-
const category = classifyJudgeReasoning(newResult?.reasoning ?? null, newResult?.scored_by);
|
|
889
|
-
taxonomy[category] = (taxonomy[category] ?? 0) + 1;
|
|
890
537
|
regressedExamples.push({
|
|
891
538
|
prompt: baseline.results[index]?.prompt ?? "",
|
|
892
539
|
old_score: oldScore,
|
|
893
540
|
new_score: newScore,
|
|
894
|
-
category,
|
|
895
541
|
});
|
|
896
542
|
}
|
|
897
543
|
else if (newScore > oldScore) {
|
|
898
544
|
improvements += 1;
|
|
899
545
|
}
|
|
900
546
|
}
|
|
901
|
-
const judgeOnlyDelta = typeof baseline.judge_only_avg_score === "number"
|
|
902
|
-
&& typeof candidate.judge_only_avg_score === "number"
|
|
903
|
-
? candidate.judge_only_avg_score - baseline.judge_only_avg_score
|
|
904
|
-
: null;
|
|
905
547
|
return {
|
|
906
548
|
avg_score_delta: candidate.avg_score - baseline.avg_score,
|
|
907
549
|
pass_rate_delta: candidate.pass_rate - baseline.pass_rate,
|
|
908
550
|
exact_match_rate_delta: candidate.exact_match_rate - baseline.exact_match_rate,
|
|
909
551
|
token_f1_delta: (candidate.avg_token_f1 ?? 0) - (baseline.avg_token_f1 ?? 0),
|
|
910
|
-
judge_only_avg_score_delta: judgeOnlyDelta,
|
|
911
552
|
regressions,
|
|
912
553
|
improvements,
|
|
913
|
-
regression_taxonomy: regressions > 0 ? taxonomy : undefined,
|
|
914
554
|
regressed_examples: regressedExamples,
|
|
915
555
|
};
|
|
916
556
|
}
|