@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +84 -204
  3. package/dist/artifacts.d.ts +3 -4
  4. package/dist/artifacts.js +55 -33
  5. package/dist/artifacts.js.map +1 -1
  6. package/dist/compare.d.ts +1 -8
  7. package/dist/compare.js +1 -23
  8. package/dist/compare.js.map +1 -1
  9. package/dist/contracts.d.ts +59 -366
  10. package/dist/contracts.js +73 -224
  11. package/dist/contracts.js.map +1 -1
  12. package/dist/dataset.d.ts +2 -4
  13. package/dist/dataset.js +31 -125
  14. package/dist/dataset.js.map +1 -1
  15. package/dist/doctor.d.ts +2 -3
  16. package/dist/doctor.js +23 -161
  17. package/dist/doctor.js.map +1 -1
  18. package/dist/evaluation.d.ts +11 -71
  19. package/dist/evaluation.js +212 -572
  20. package/dist/evaluation.js.map +1 -1
  21. package/dist/huggingface-cache.d.ts +8 -7
  22. package/dist/huggingface-cache.js +16 -8
  23. package/dist/huggingface-cache.js.map +1 -1
  24. package/dist/index.d.ts +0 -10
  25. package/dist/index.js +205 -628
  26. package/dist/index.js.map +1 -1
  27. package/dist/local-project.d.ts +1 -11
  28. package/dist/local-project.js +33 -61
  29. package/dist/local-project.js.map +1 -1
  30. package/dist/model-registry.d.ts +3 -16
  31. package/dist/model-registry.js +76 -292
  32. package/dist/model-registry.js.map +1 -1
  33. package/dist/model-server.d.ts +1 -2
  34. package/dist/model-server.js +9 -18
  35. package/dist/model-server.js.map +1 -1
  36. package/dist/orchestrator.d.ts +11 -25
  37. package/dist/orchestrator.js +246 -566
  38. package/dist/orchestrator.js.map +1 -1
  39. package/dist/prefetch.d.ts +1 -5
  40. package/dist/prefetch.js +14 -19
  41. package/dist/prefetch.js.map +1 -1
  42. package/dist/process-runner.d.ts +10 -29
  43. package/dist/process-runner.js +63 -169
  44. package/dist/process-runner.js.map +1 -1
  45. package/dist/process-training.d.ts +0 -1
  46. package/dist/process-training.js +10 -87
  47. package/dist/process-training.js.map +1 -1
  48. package/dist/store.d.ts +1 -3
  49. package/dist/store.js +92 -290
  50. package/dist/store.js.map +1 -1
  51. package/docs/architecture.md +87 -152
  52. package/docs/spark.md +66 -97
  53. package/examples/dry-runner.json +14 -0
  54. package/examples/local-runner.json +8 -6
  55. package/examples/smoke-spec.json +41 -0
  56. package/package.json +6 -6
  57. package/training/local-runner/pyproject.toml +0 -5
  58. package/training/local-runner/src/evaluate.py +89 -234
  59. package/training/local-runner/src/model_contract.py +47 -0
  60. package/training/local-runner/src/prefetch.py +18 -4
  61. package/training/local-runner/src/serve.py +54 -115
  62. package/training/local-runner/src/sft_data.py +97 -0
  63. package/training/local-runner/src/train.py +146 -346
  64. package/training/local-runner/uv.lock +0 -1197
  65. package/dist/labeling-sanitize.d.ts +0 -31
  66. package/dist/labeling-sanitize.js +0 -158
  67. package/dist/labeling-sanitize.js.map +0 -1
  68. package/dist/labeling.d.ts +0 -155
  69. package/dist/labeling.js +0 -496
  70. package/dist/labeling.js.map +0 -1
  71. package/dist/openrouter.d.ts +0 -29
  72. package/dist/openrouter.js +0 -66
  73. package/dist/openrouter.js.map +0 -1
  74. package/dist/server.d.ts +0 -9
  75. package/dist/server.js +0 -211
  76. package/dist/server.js.map +0 -1
  77. package/docs/local-workflow-remediation-2026-07-13.md +0 -130
  78. package/docs/local-workflow-ux-review-2026-07-13.md +0 -458
  79. package/examples/dpo-preferences.jsonl +0 -2
  80. package/examples/dpo-run-request.json +0 -34
  81. package/examples/smoke-run-request.json +0 -34
  82. package/training/local-runner/src/train_dpo.py +0 -286
@@ -1,26 +1,18 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
1
  import { createHash } from "node:crypto";
3
- import { dirname, isAbsolute, join, resolve } from "node:path";
4
- import { performance } from "node:perf_hooks";
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 { writeJson, fileUri } from "./artifacts.js";
7
- import { resolveTrainingModel } from "./model-registry.js";
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, withHuggingFaceCacheEnvironment } from "./huggingface-cache.js";
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 scoreActual(expected, actual) {
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 (2 * precision * recall) / (precision + recall);
39
+ return 2 * precision * recall / (precision + recall);
47
40
  }
48
- /** Derives a deterministic 32-bit seed from an arbitrary string (FNV-1a). */
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 t = Math.imul(state ^ (state >>> 15), 1 | state);
62
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
63
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
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.slice(0, count).sort((left, right) => left - right).map((index) => examples[index]);
68
+ return indices
69
+ .slice(0, count)
70
+ .sort((left, right) => left - right)
71
+ .map((index) => examples[index]);
82
72
  }
83
73
  /**
84
- * Deterministically splits spec examples into a training split and an eval
85
- * holdout so that default spec runs do not evaluate on their own training
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 holdoutIndices = new Set(indices.slice(0, holdoutCount));
87
+ const heldOut = new Set(indices.slice(0, holdoutCount));
102
88
  return {
103
- train: examples.filter((_, index) => !holdoutIndices.has(index)),
104
- holdout: examples.filter((_, index) => holdoutIndices.has(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 candidate.
110
+ // Try the next representation.
125
111
  }
126
112
  }
127
113
  return null;
128
114
  }
129
- function normalizeJsonValue(value) {
115
+ function canonicalJson(value) {
130
116
  if (Array.isArray(value))
131
- return value.map((item) => normalizeJsonValue(item));
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, normalizeJsonValue(item)]));
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 exactScore = scoreActual(expected, actual);
129
+ const score = exactScore(expected, actual);
147
130
  return {
148
- score: exactScore,
149
- passed: exactScore === 1,
150
- reasoning: "Expected output is not a JSON object; fell back to normalized exact match.",
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
- // A configured field that the expected output does not define cannot be
169
- // verified, so it must never count as correct.
170
- if (!Object.prototype.hasOwnProperty.call(expectedJson, field)) {
171
- missingExpectedFields.push(field);
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: (actualJson
187
- ? `JSON field score: ${correct}/${fields.length} configured fields matched.`
188
- : "Actual output is not a JSON object.") + missingNote,
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 scored = scores.filter((score) => score.fields.includes(field));
202
- const correct = scored.filter((score) => score.fieldResults[field]).length;
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: scored.length,
206
- accuracy: scored.length > 0 ? correct / scored.length : 0,
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
- async function runInferenceCommand(args) {
229
- return runJsonStdInCommand({
230
- command: args.command,
231
- payload: {
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
- return buildEntrypointCommand(config.evaluation.inference, {
247
- defaultScript: "training/local-runner/src/evaluate.py",
248
- extraArgs: ["--input", inputPath, "--output", outputPath],
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 runBatchInference(args) {
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: modelLoader,
263
+ model_loader: "causal_lm",
271
264
  adapter_path: fileUriToPath(args.adapterPath),
272
265
  system: args.system,
273
- examples: args.examples,
274
- model_cache: args.config.paths.modelCache ? resolve(args.config.paths.modelCache) : undefined,
275
- trust_remote_code: args.config.evaluation.inference.trustRemoteCode,
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
- const entrypoint = buildBatchInferenceCommand(args.config, inputPath, outputPath);
285
- const command = entrypoint.displayCommand;
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} ${label} inference.`,
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
- cwd: args.config.evaluation.inference.cwd,
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: `${label} inference timed out after ${args.config.evaluation.timeoutMs}ms`,
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(`${label} inference exited ${result.exitCode}: ${result.stderr.slice(0, 1000)}`);
306
+ throw new Error(`Transformers inference exited ${result.exitCode}: ${result.stderr.slice(0, 1000)}`);
326
307
  }
327
- await args.reporter?.onEvent?.({
328
- stage: `evaluating_${args.kind}`,
329
- status: "running",
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
- if (!process.env[config.llm.apiKeyEnv]?.trim()) {
401
- return {
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 { available: true };
315
+ return parseBatchInferenceResult(output, args.examples.map((_, index) => String(index)));
407
316
  }
408
317
  /**
409
- * Fail before model inference when judge scoring cannot run. Exact-match
410
- * fallback is allowed only when the user opted into it explicitly in config.
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, scoringMode = config.evaluation.scoring.mode) {
413
- if (scoringMode !== "llm_judge")
414
- return { available: false };
415
- const availability = evaluationJudgeAvailability(config);
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
- const judgeAvailability = evaluationJudgeAvailability(args.config);
458
- const payload = {
459
- v: 3,
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.map((example) => ({
466
- input: example.input,
467
- output: example.output,
468
- assets: example.input_assets ?? null,
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
- mode: args.config.evaluation.scoring.mode,
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 clearlyLocalReference(value) {
484
- return value.startsWith("file://")
485
- || isAbsolute(value)
486
- || value.startsWith("./")
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 === null) {
531
- try {
532
- const raw = await readFile(new URL("../package.json", import.meta.url), "utf8");
533
- cachedPackageVersion = String(JSON.parse(raw).version ?? "unknown");
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
- if (inferenceProvider === "batch_command" && !args.config.evaluation.inference.command) {
555
- throw new Error("evaluation.inference.provider=batch_command requires evaluation.inference.command");
369
+ catch {
370
+ cachedPackageVersion = "unknown";
556
371
  }
557
- const configuredScoringMode = args.config.evaluation.scoring.mode;
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 scoreGeneratedEvalResults(args) {
564
- const results = [];
565
- const jsonFieldScores = [];
566
- let judgeModelId = null;
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 (jsonFieldScore)
619
- jsonFieldScores.push(jsonFieldScore);
620
- const score = judged?.score ?? jsonFieldScore?.score ?? exactScore;
621
- const scoredBy = judged
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: judged?.passed ?? jsonFieldScore?.passed ?? score === 1,
387
+ passed: json?.passed ?? score === 1,
633
388
  score,
634
- reasoning: judged?.reasoning ?? jsonFieldScore?.reasoning ?? (args.inferenceProvider === "none"
635
- ? "No inference command configured; recorded an empty local response."
636
- : args.scoringMode === "llm_judge"
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: scoredBy,
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
- const report = {
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: avgScore,
683
- pass_rate: passRate,
684
- exact_match_rate: exactMatchRate,
685
- avg_token_f1: avgTokenF1,
686
- avg_latency_ms: avgLatency,
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: scoringMethod,
693
- judge_model_id: judgeModelId,
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.scoringMode,
696
- json_field_metrics: args.scoringMode === "json_fields"
697
- ? aggregateJsonFieldMetrics(jsonFieldScores, total)
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
- // Explicit config takes precedence over the per-run request hyperparameter
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
- // When truncating, take a deterministic seeded sample (not a prefix) so a
713
- // sorted or grouped eval file does not bias the evaluated subset. Baseline
714
- // and candidate runs receive the same seed and therefore identical examples.
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 { command, inferenceProvider, scoringMode } = resolveEvaluationRuntime({
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
- && inferenceProvider === "transformers"
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: selected,
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
- await writeJson(args.outputPath, report);
764
- await args.reporter?.onEvent?.({
765
- stage: `evaluating_${args.kind}`,
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 inferred = inferenceProvider === "transformers" || inferenceProvider === "batch_command"
774
- ? await runBatchInference({
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: selected,
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
- const report = await scoreGeneratedEvalResults({
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
- scoringMode,
820
- inferenceProvider,
821
- system: args.system,
515
+ inferenceProvider: inference ? "transformers" : "none",
822
516
  evalSplit: args.evalSplit,
823
- evalSampleSeed: truncated ? sampleSeed : null,
824
- generationConfig,
825
- logUri: inferenceProvider === "transformers" || inferenceProvider === "batch_command"
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
- // Do not cache reports with fallback-scored examples: a transient judge
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 newResult = candidate.results[index];
885
- const newScore = newResult?.score ?? 0;
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
  }