@tuned-tensor/local 0.2.9 → 0.4.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 (89) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +110 -201
  3. package/dist/active-model.d.ts +16 -0
  4. package/dist/active-model.js +88 -0
  5. package/dist/active-model.js.map +1 -0
  6. package/dist/artifacts.d.ts +5 -4
  7. package/dist/artifacts.js +57 -33
  8. package/dist/artifacts.js.map +1 -1
  9. package/dist/compare.d.ts +1 -8
  10. package/dist/compare.js +1 -23
  11. package/dist/compare.js.map +1 -1
  12. package/dist/contracts.d.ts +234 -365
  13. package/dist/contracts.js +95 -224
  14. package/dist/contracts.js.map +1 -1
  15. package/dist/dataset.d.ts +11 -4
  16. package/dist/dataset.js +67 -125
  17. package/dist/dataset.js.map +1 -1
  18. package/dist/doctor.d.ts +2 -3
  19. package/dist/doctor.js +23 -161
  20. package/dist/doctor.js.map +1 -1
  21. package/dist/evaluation.d.ts +11 -71
  22. package/dist/evaluation.js +212 -572
  23. package/dist/evaluation.js.map +1 -1
  24. package/dist/general-regression.d.ts +10 -0
  25. package/dist/general-regression.js +16 -0
  26. package/dist/general-regression.js.map +1 -0
  27. package/dist/huggingface-cache.d.ts +8 -7
  28. package/dist/huggingface-cache.js +16 -8
  29. package/dist/huggingface-cache.js.map +1 -1
  30. package/dist/index.d.ts +2 -10
  31. package/dist/index.js +358 -627
  32. package/dist/index.js.map +1 -1
  33. package/dist/local-project.d.ts +1 -11
  34. package/dist/local-project.js +33 -61
  35. package/dist/local-project.js.map +1 -1
  36. package/dist/model-registry.d.ts +3 -16
  37. package/dist/model-registry.js +76 -292
  38. package/dist/model-registry.js.map +1 -1
  39. package/dist/model-server.d.ts +7 -3
  40. package/dist/model-server.js +34 -25
  41. package/dist/model-server.js.map +1 -1
  42. package/dist/orchestrator.d.ts +11 -25
  43. package/dist/orchestrator.js +393 -563
  44. package/dist/orchestrator.js.map +1 -1
  45. package/dist/prefetch.d.ts +1 -5
  46. package/dist/prefetch.js +14 -19
  47. package/dist/prefetch.js.map +1 -1
  48. package/dist/process-runner.d.ts +10 -29
  49. package/dist/process-runner.js +63 -169
  50. package/dist/process-runner.js.map +1 -1
  51. package/dist/process-training.d.ts +0 -1
  52. package/dist/process-training.js +10 -87
  53. package/dist/process-training.js.map +1 -1
  54. package/dist/store.d.ts +1 -3
  55. package/dist/store.js +92 -290
  56. package/dist/store.js.map +1 -1
  57. package/docs/architecture.md +87 -152
  58. package/docs/spark.md +66 -97
  59. package/examples/dry-runner.json +14 -0
  60. package/examples/general-regression.jsonl +4 -0
  61. package/examples/local-runner.json +13 -6
  62. package/examples/smoke-spec.json +41 -0
  63. package/package.json +6 -6
  64. package/training/local-runner/pyproject.toml +0 -5
  65. package/training/local-runner/src/evaluate.py +89 -234
  66. package/training/local-runner/src/model_contract.py +47 -0
  67. package/training/local-runner/src/prefetch.py +18 -4
  68. package/training/local-runner/src/serve.py +60 -117
  69. package/training/local-runner/src/sft_data.py +97 -0
  70. package/training/local-runner/src/train.py +146 -346
  71. package/training/local-runner/uv.lock +0 -1197
  72. package/dist/labeling-sanitize.d.ts +0 -31
  73. package/dist/labeling-sanitize.js +0 -158
  74. package/dist/labeling-sanitize.js.map +0 -1
  75. package/dist/labeling.d.ts +0 -155
  76. package/dist/labeling.js +0 -496
  77. package/dist/labeling.js.map +0 -1
  78. package/dist/openrouter.d.ts +0 -29
  79. package/dist/openrouter.js +0 -66
  80. package/dist/openrouter.js.map +0 -1
  81. package/dist/server.d.ts +0 -9
  82. package/dist/server.js +0 -211
  83. package/dist/server.js.map +0 -1
  84. package/docs/local-workflow-remediation-2026-07-13.md +0 -130
  85. package/docs/local-workflow-ux-review-2026-07-13.md +0 -458
  86. package/examples/dpo-preferences.jsonl +0 -2
  87. package/examples/dpo-run-request.json +0 -34
  88. package/examples/smoke-run-request.json +0 -34
  89. package/training/local-runner/src/train_dpo.py +0 -286
@@ -1,20 +1,21 @@
1
- import { copyFile, lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
1
+ import { lstat, mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
2
2
  import { createReadStream } from "node:fs";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { performance } from "node:perf_hooks";
5
5
  import { homedir } from "node:os";
6
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
+ import { basename, dirname, join, relative, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { defaultArtifactPrefix, fileUri, assertArtifactManifest, ARTIFACT_WORKFLOW_LOCK_FILE, claimRunArtifactDirectory, prepareRunDirectories, readJson, resolveRunArtifacts, writeArtifactManifest, writeFileAtomic, writeJsonAtomic, } from "./artifacts.js";
9
9
  import { evalReportSchema, fineTuneRunRequestSchema, localRunnerConfigSchema, runReportSchema, trainingReportSchema, } from "./contracts.js";
10
- import { buildSystemMessage, compileSpecToJsonl, examplesFromChatJsonl, examplesFromSpec, localAssetPathsFromChatJsonl, normalizeChatJsonlForRelocation, } from "./dataset.js";
11
- import { compareEvalReports, deriveSampleSeed, evaluateExamples, rescoreEvalReport, splitSpecExamples } from "./evaluation.js";
10
+ import { buildSystemMessage, compileSpecToJsonl, evaluationSuiteFromChatJsonl, examplesFromChatJsonl, examplesFromSpec, normalizeChatJsonlForRelocation, } from "./dataset.js";
11
+ import { INFERENCE_PROTOCOL_VERSION, compareEvalReports, deriveSampleSeed, evaluateExamples, splitSpecExamples, } from "./evaluation.js";
12
12
  import { launchProcessTraining } from "./process-training.js";
13
13
  import { assertUsableModelArtifact, localModelArtifactPath } from "./model-registry.js";
14
14
  import { ProcessCancelledError } from "./process-runner.js";
15
15
  import { createLocalStore } from "./store.js";
16
16
  import { withHuggingFaceCacheEnvironment } from "./huggingface-cache.js";
17
17
  import { verifyLocalBaseModel } from "./prefetch.js";
18
+ import { evaluateGeneralRegressionGate } from "./general-regression.js";
18
19
  export async function loadJsonFile(path) {
19
20
  return JSON.parse(await readFile(path, "utf8"));
20
21
  }
@@ -27,12 +28,9 @@ export async function loadLocalRunnerConfig(path) {
27
28
  const configPath = resolve(path);
28
29
  const base = dirname(configPath);
29
30
  const config = localRunnerConfigSchema.parse(await loadJsonFile(configPath));
30
- const configPathValue = (value, preserveBundled = false) => {
31
+ const configPathValue = (value) => {
31
32
  if (!value)
32
33
  return undefined;
33
- if (preserveBundled && (value === "training/local-runner" || value.startsWith("training/local-runner/"))) {
34
- return value;
35
- }
36
34
  if (value === "~")
37
35
  return homedir();
38
36
  if (value.startsWith("~/"))
@@ -43,24 +41,18 @@ export async function loadLocalRunnerConfig(path) {
43
41
  ...config,
44
42
  artifactRoot: configPathValue(config.artifactRoot),
45
43
  storeRoot: configPathValue(config.storeRoot),
46
- training: {
47
- ...config.training,
48
- cwd: configPathValue(config.training.cwd),
49
- project: configPathValue(config.training.project, true),
50
- script: configPathValue(config.training.script, true),
51
- },
52
44
  paths: {
53
45
  baseModel: configPathValue(config.paths.baseModel),
54
46
  modelCache: configPathValue(config.paths.modelCache),
55
47
  },
56
48
  evaluation: {
57
49
  ...config.evaluation,
58
- inference: {
59
- ...config.evaluation.inference,
60
- cwd: configPathValue(config.evaluation.inference.cwd),
61
- project: configPathValue(config.evaluation.inference.project, true),
62
- script: configPathValue(config.evaluation.inference.script, true),
63
- },
50
+ generalRegression: config.evaluation.generalRegression
51
+ ? {
52
+ ...config.evaluation.generalRegression,
53
+ dataset: configPathValue(config.evaluation.generalRegression.dataset),
54
+ }
55
+ : undefined,
64
56
  },
65
57
  };
66
58
  }
@@ -80,26 +72,149 @@ async function pathExists(path) {
80
72
  return false;
81
73
  }
82
74
  }
83
- function selectPrebuiltEvaluation(dataset) {
75
+ function selectPrebuiltEvaluationSplit(dataset) {
84
76
  if (!dataset)
85
77
  throw new Error("dataset_prebuilt is required");
86
- if (dataset.test)
87
- return { path: stripFileUri(dataset.test), split: "prebuilt_test" };
88
78
  if (dataset.validation)
89
- return { path: stripFileUri(dataset.validation), split: "prebuilt_validation" };
90
- return { path: stripFileUri(dataset.training), split: "prebuilt_training" };
79
+ return "prebuilt_validation";
80
+ if (dataset.test)
81
+ return "prebuilt_test";
82
+ throw new Error("dataset_prebuilt requires a distinct validation or test split");
91
83
  }
92
- function selectDpoEvaluation(request) {
93
- if (request.dataset_prebuilt?.test) {
94
- return { path: stripFileUri(request.dataset_prebuilt.test), split: "prebuilt_test" };
84
+ function assertMatchingDatasetSystems(jsonl, expectedSystem, split) {
85
+ for (const [index, line] of jsonl.split(/\r?\n/).entries()) {
86
+ if (!line.trim())
87
+ continue;
88
+ const row = JSON.parse(line);
89
+ const actualSystem = row.messages.find((message) => message.role === "system")?.content ?? "";
90
+ if (actualSystem !== expectedSystem) {
91
+ throw new Error(`Prebuilt ${split} row ${index + 1} system message must match the behavior spec system message.`);
92
+ }
93
+ }
94
+ }
95
+ function addDryRunPlaceholders(request, dryRun) {
96
+ if (!dryRun || !request || typeof request !== "object" || Array.isArray(request))
97
+ return request;
98
+ const candidate = request;
99
+ if (candidate.dataset_prebuilt)
100
+ return request;
101
+ const snapshot = candidate.spec_snapshot;
102
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot))
103
+ return request;
104
+ const examples = snapshot.examples;
105
+ if (!Array.isArray(examples) || examples.length > 0)
106
+ return request;
107
+ return {
108
+ ...candidate,
109
+ spec_snapshot: {
110
+ ...snapshot,
111
+ examples: [
112
+ { input: "Dry-run placeholder input A", output: "Dry-run placeholder output A" },
113
+ { input: "Dry-run placeholder input B", output: "Dry-run placeholder output B" },
114
+ ],
115
+ },
116
+ };
117
+ }
118
+ async function validateDatasetInputs(request, config) {
119
+ const inputIdentity = (value) => value.normalize("NFKC").trim().replace(/\s+/g, " ").toLocaleLowerCase("en");
120
+ const evalSampleSeed = config.evaluation.sampleSeed
121
+ ?? deriveSampleSeed(request.behavior_spec_id);
122
+ if (!request.dataset_prebuilt) {
123
+ const inlineExamples = examplesFromSpec(request.spec_snapshot);
124
+ const seenInputs = new Map();
125
+ for (const [index, example] of inlineExamples.entries()) {
126
+ const identity = inputIdentity(example.input);
127
+ const previous = seenInputs.get(identity);
128
+ if (previous !== undefined) {
129
+ throw new Error(`Inline examples ${previous + 1} and ${index + 1} have duplicate inputs; `
130
+ + "training and held-out evaluation require distinct prompts.");
131
+ }
132
+ seenInputs.set(identity, index);
133
+ }
134
+ if (!config.dryRun && inlineExamples.length < 2) {
135
+ throw new Error("A real local fine-tune requires at least 2 inline examples so training and evaluation use distinct data.");
136
+ }
137
+ const split = splitSpecExamples(inlineExamples, evalSampleSeed);
138
+ const hasHoldout = split.holdout.length > 0;
139
+ const trainingExamples = hasHoldout ? split.train : inlineExamples;
140
+ const evaluationExamples = hasHoldout ? split.holdout : inlineExamples;
141
+ return {
142
+ trainingJsonl: compileSpecToJsonl({ ...request.spec_snapshot, examples: trainingExamples }),
143
+ trainingExampleCount: trainingExamples.length,
144
+ evaluationExamples,
145
+ evalSplit: hasHoldout ? "spec_holdout" : "spec_examples",
146
+ };
95
147
  }
96
- if (request.dataset_prebuilt?.validation) {
97
- return { path: stripFileUri(request.dataset_prebuilt.validation), split: "prebuilt_validation" };
148
+ const dataset = request.dataset_prebuilt;
149
+ const splitEntries = [
150
+ ["training", dataset.training],
151
+ ["validation", dataset.validation],
152
+ ["test", dataset.test],
153
+ ];
154
+ const normalized = new Map();
155
+ const expectedSystem = buildSystemMessage(request.spec_snapshot);
156
+ for (const [name, value] of splitEntries) {
157
+ if (!value)
158
+ continue;
159
+ const path = stripFileUri(value);
160
+ const jsonl = await normalizeChatJsonlForRelocation(path);
161
+ assertMatchingDatasetSystems(jsonl, expectedSystem, name);
162
+ normalized.set(name, {
163
+ jsonl,
164
+ examples: await examplesFromChatJsonl(path),
165
+ });
98
166
  }
99
- return { split: "spec_examples" };
167
+ const evaluationSplit = selectPrebuiltEvaluationSplit(dataset);
168
+ const evaluationKey = evaluationSplit === "prebuilt_validation"
169
+ ? "validation"
170
+ : "test";
171
+ const training = normalized.get("training");
172
+ const evaluationData = normalized.get(evaluationKey);
173
+ if (!training || !evaluationData) {
174
+ throw new Error("Unable to load the configured prebuilt training and evaluation datasets.");
175
+ }
176
+ const trainingInputs = new Set(training.examples.map((example) => inputIdentity(example.input)));
177
+ for (const splitName of ["validation", "test"]) {
178
+ const split = normalized.get(splitName);
179
+ if (!split)
180
+ continue;
181
+ const splitInputs = new Set();
182
+ for (const example of split.examples) {
183
+ const identity = inputIdentity(example.input);
184
+ if (splitInputs.has(identity)) {
185
+ throw new Error(`Prebuilt ${splitName} data contains duplicate inputs; `
186
+ + "evaluation prompts must be unique so metrics and comparisons remain well-defined.");
187
+ }
188
+ splitInputs.add(identity);
189
+ }
190
+ const overlap = split.examples.filter((example) => trainingInputs.has(inputIdentity(example.input)));
191
+ if (overlap.length > 0) {
192
+ throw new Error(`Prebuilt training and ${splitName} data overlap on ${overlap.length} input(s); `
193
+ + "evaluation examples must be held out from training.");
194
+ }
195
+ }
196
+ return {
197
+ trainingJsonl: training.jsonl,
198
+ trainingExampleCount: training.examples.length,
199
+ evaluationExamples: evaluationData.examples,
200
+ evalSplit: evaluationSplit,
201
+ };
202
+ }
203
+ /**
204
+ * Parses the public contracts and fully validates every dataset before a run
205
+ * lock, store record, or artifact-directory claim is created.
206
+ */
207
+ export async function validateLocalFineTuneInput(input) {
208
+ const config = localRunnerConfigSchema.parse(input.config);
209
+ const request = fineTuneRunRequestSchema.parse(addDryRunPlaceholders(input.request, config.dryRun));
210
+ await validateDatasetInputs(request, config);
211
+ if (config.evaluation.generalRegression) {
212
+ await evaluationSuiteFromChatJsonl(config.evaluation.generalRegression.dataset, config.evaluation.generalRegression.systemPrompt);
213
+ }
214
+ return { request, config };
100
215
  }
101
216
  function artifactPrefix(request) {
102
- return request.artifacts?.prefix ?? defaultArtifactPrefix({
217
+ return defaultArtifactPrefix({
103
218
  userId: request.user_id,
104
219
  behaviorSpecId: request.behavior_spec_id,
105
220
  runId: request.run_id,
@@ -134,33 +249,6 @@ async function packageVersion() {
134
249
  return "unknown";
135
250
  }
136
251
  }
137
- async function effectiveConfigFingerprint(config) {
138
- const bundledProject = resolve(packageRoot, "training/local-runner");
139
- const projectRoots = [...new Set([
140
- bundledProject,
141
- config.training.project === "training/local-runner" ? bundledProject : config.training.project,
142
- config.evaluation.inference.project === "training/local-runner"
143
- ? bundledProject
144
- : config.evaluation.inference.project,
145
- ].filter((value) => Boolean(value)).map((value) => resolve(value)))];
146
- const projectFingerprints = {};
147
- for (const project of projectRoots) {
148
- projectFingerprints[project] = {
149
- pyproject: await hashFileIfPresent(resolve(project, "pyproject.toml")),
150
- uv_lock: await hashFileIfPresent(resolve(project, "uv.lock")),
151
- };
152
- }
153
- return hashJson({
154
- config,
155
- runtime: {
156
- tt_local_version: await packageVersion(),
157
- node_version: process.version,
158
- platform: process.platform,
159
- architecture: process.arch,
160
- project_fingerprints: projectFingerprints,
161
- },
162
- });
163
- }
164
252
  async function runtimeFingerprint() {
165
253
  const bundledProject = resolve(packageRoot, "training/local-runner");
166
254
  return hashJson({
@@ -179,14 +267,9 @@ function preparedSourceFingerprint(args) {
179
267
  preparation_config: args.preparationConfig,
180
268
  base_model_revision: args.baseModelRevision ?? null,
181
269
  base_model_fingerprint: args.baseModelFingerprint ?? null,
182
- parent_model_fingerprint: args.parentModelFingerprint ?? null,
183
270
  dataset_fingerprints: args.datasetFingerprints,
184
271
  });
185
272
  }
186
- async function countJsonlRows(path) {
187
- const text = await readFile(path, "utf8");
188
- return text.split(/\r?\n/).filter((line) => line.trim()).length;
189
- }
190
273
  async function hashFile(path) {
191
274
  const hash = createHash("sha256");
192
275
  for await (const chunk of createReadStream(path))
@@ -206,21 +289,6 @@ async function datasetFingerprints(request) {
206
289
  continue;
207
290
  const datasetPath = stripFileUri(value);
208
291
  fingerprints[key] = await hashFile(datasetPath);
209
- for (const assetPath of await localAssetPathsFromChatJsonl(datasetPath)) {
210
- fingerprints[`${key}_asset:${assetPath}`] = await hashFile(assetPath);
211
- }
212
- }
213
- for (const [exampleIndex, example] of request.spec_snapshot.examples.entries()) {
214
- for (const [assetIndex, asset] of (example.input_assets ?? []).entries()) {
215
- const value = asset.image ?? asset.data_uri ?? asset.uri ?? asset.path;
216
- if (!value
217
- || value.startsWith("data:")
218
- || (/^[a-z][a-z0-9+.-]*:/i.test(value) && !value.startsWith("file://"))) {
219
- continue;
220
- }
221
- const assetPath = stripFileUri(value);
222
- fingerprints[`spec_asset:${exampleIndex}:${assetIndex}:${assetPath}`] = await hashFile(assetPath);
223
- }
224
292
  }
225
293
  return fingerprints;
226
294
  }
@@ -246,44 +314,6 @@ async function resolveBaseModelRevision(request, config) {
246
314
  return undefined;
247
315
  }
248
316
  }
249
- async function fingerprintLocalArtifact(uri) {
250
- const root = localModelArtifactPath(uri);
251
- const metadata = await lstat(root);
252
- if (metadata.isSymbolicLink()) {
253
- throw new Error(`Parent model artifact must not be a symbolic link: ${root}`);
254
- }
255
- if (metadata.isFile()) {
256
- return hashJson({ kind: "file", size_bytes: metadata.size, sha256: await hashFile(root) });
257
- }
258
- if (!metadata.isDirectory())
259
- throw new Error(`Parent model artifact is not a file or directory: ${root}`);
260
- const files = [];
261
- const visit = async (directory) => {
262
- for (const entry of await readdir(directory, { withFileTypes: true })) {
263
- const child = resolve(directory, entry.name);
264
- if (entry.isSymbolicLink()) {
265
- throw new Error(`Parent model artifact must not contain symbolic links: ${child}`);
266
- }
267
- if (entry.isDirectory()) {
268
- await visit(child);
269
- continue;
270
- }
271
- const childMetadata = await stat(child);
272
- if (!childMetadata.isFile())
273
- continue;
274
- files.push({
275
- path: relative(root, child).split("\\").join("/"),
276
- size_bytes: childMetadata.size,
277
- sha256: await hashFile(child),
278
- });
279
- }
280
- };
281
- await visit(root);
282
- files.sort((left, right) => left.path.localeCompare(right.path));
283
- if (files.length === 0)
284
- throw new Error(`Parent model artifact directory is empty: ${root}`);
285
- return hashJson({ kind: "directory", files });
286
- }
287
317
  /** Hash a local base model while permitting Hugging Face snapshot file links. */
288
318
  export async function fingerprintLocalBaseModel(uri) {
289
319
  const root = localModelArtifactPath(uri);
@@ -331,8 +361,6 @@ function statusForProgressStage(stage) {
331
361
  return "training";
332
362
  if (stage === "preparing")
333
363
  return "preparing";
334
- if (stage === "scoring")
335
- return "scoring";
336
364
  if (stage === "reporting")
337
365
  return "reporting";
338
366
  return "training";
@@ -348,9 +376,6 @@ async function readStageMetadata(path) {
348
376
  return null;
349
377
  }
350
378
  }
351
- async function clearDependentStageArtifacts(artifacts) {
352
- await cleanupStageArtifacts(artifacts, "prepare");
353
- }
354
379
  async function removePrefixedArtifacts(path) {
355
380
  const directory = dirname(path);
356
381
  const prefix = `${basename(path)}.`;
@@ -366,6 +391,8 @@ async function cleanupStageArtifacts(artifacts, stage) {
366
391
  await Promise.all([
367
392
  removePrefixedArtifacts(artifacts.baselineEvalJson),
368
393
  removePrefixedArtifacts(artifacts.candidateEvalJson),
394
+ removePrefixedArtifacts(artifacts.generalBaselineEvalJson),
395
+ removePrefixedArtifacts(artifacts.generalCandidateEvalJson),
369
396
  rm(artifacts.trainingDir, { recursive: true, force: true }),
370
397
  removePrefixedArtifacts(artifacts.trainingReportJson),
371
398
  rm(resolve(artifacts.runDir, "model.tar.gz"), { force: true }),
@@ -377,6 +404,7 @@ async function cleanupStageArtifacts(artifacts, stage) {
377
404
  if (stage === "baseline") {
378
405
  await Promise.all([
379
406
  removePrefixedArtifacts(artifacts.baselineEvalJson),
407
+ removePrefixedArtifacts(artifacts.generalBaselineEvalJson),
380
408
  removeReport(),
381
409
  ]);
382
410
  return;
@@ -387,6 +415,7 @@ async function cleanupStageArtifacts(artifacts, stage) {
387
415
  removePrefixedArtifacts(artifacts.trainingReportJson),
388
416
  rm(resolve(artifacts.runDir, "model.tar.gz"), { force: true }),
389
417
  removePrefixedArtifacts(artifacts.candidateEvalJson),
418
+ removePrefixedArtifacts(artifacts.generalCandidateEvalJson),
390
419
  removeReport(),
391
420
  ]);
392
421
  await prepareRunDirectories(artifacts);
@@ -395,6 +424,7 @@ async function cleanupStageArtifacts(artifacts, stage) {
395
424
  if (stage === "candidate") {
396
425
  await Promise.all([
397
426
  removePrefixedArtifacts(artifacts.candidateEvalJson),
427
+ removePrefixedArtifacts(artifacts.generalCandidateEvalJson),
398
428
  removeReport(),
399
429
  ]);
400
430
  return;
@@ -489,54 +519,29 @@ async function acquireArtifactLock(artifacts) {
489
519
  function isDryTraining(training) {
490
520
  return training.metrics?.dry_run === true;
491
521
  }
492
- function stringMetadata(value, key) {
493
- const entry = value?.[key];
494
- return typeof entry === "string" && entry.length > 0 ? entry : undefined;
495
- }
496
522
  async function modelManifestContract(prepared, training) {
497
523
  if (isDryTraining(training) || !training.model_artifact_uri)
498
524
  return undefined;
499
- const metadata = training.artifact_metadata;
500
- const explicitContract = Boolean(metadata?.framework && metadata?.format);
501
- const inspection = await assertUsableModelArtifact(training.model_artifact_uri, {
502
- allowUnrecognizedPayload: explicitContract,
503
- });
525
+ const inspection = await assertUsableModelArtifact(training.model_artifact_uri);
504
526
  const adapterWeights = inspection.adapter_weight_file_count > 0
505
527
  && inspection.adapter_weight_bytes > 0;
506
- const fullModelWeights = inspection.full_model_weight_file_count > 0
507
- && inspection.full_model_weight_bytes > 0;
508
- if (!adapterWeights && !fullModelWeights && !explicitContract) {
509
- throw new Error(`Model artifact ${inspection.path} has no loadable adapter or Transformers full-model weights. `
510
- + "Custom trainers must set training.artifact.framework and training.artifact.format.");
511
- }
512
- if ((metadata?.framework === "transformers-peft" || metadata?.servable === true)
513
- && (!adapterWeights || !inspection.has_adapter_config)) {
514
- throw new Error(`Model artifact ${inspection.path} requires adapter_model.safetensors or adapter_model.bin and a non-empty `
515
- + "adapter_config.json for a PEFT/servable contract.");
528
+ if (!adapterWeights || !inspection.has_adapter_config) {
529
+ throw new Error(`Local training must produce a PEFT adapter with adapter_model.safetensors or adapter_model.bin and `
530
+ + `a non-empty adapter_config.json: ${inspection.path}`);
516
531
  }
517
- if (metadata?.framework === "transformers-full" && !fullModelWeights) {
518
- throw new Error(`Model artifact ${inspection.path} has no Transformers full-model weights.`);
519
- }
520
- const implicitPeftAdapter = adapterWeights && inspection.has_adapter_config;
521
- const format = metadata?.format
522
- ?? (inspection.kind === "file" && inspection.path.endsWith(".tar.gz")
523
- ? "tar.gz"
524
- : inspection.kind === "directory" ? "huggingface-directory" : "file");
532
+ const format = inspection.kind === "file" ? "tar.gz" : "huggingface-directory";
525
533
  return {
526
534
  artifact_kind: inspection.kind,
527
535
  format,
528
- framework: metadata?.framework ?? (implicitPeftAdapter ? "transformers-peft" : fullModelWeights ? "transformers-full" : "custom"),
536
+ framework: "transformers-peft",
529
537
  base_model: prepared.request.spec_snapshot.base_model,
530
- base_model_revision: stringMetadata(metadata, "base_model_revision")
531
- ?? stringMetadata(training.metrics, "base_model_revision")
532
- ?? prepared.metadata.base_model_revision
538
+ base_model_revision: prepared.metadata.base_model_revision
533
539
  ?? prepared.request.hyperparameters.base_model_revision,
534
540
  base_model_artifact_uri: training.base_model_artifact_uri,
535
541
  base_model_fingerprint: prepared.metadata.base_model_fingerprint ?? undefined,
536
- parent_model_artifact: prepared.request.hyperparameters.parent_model_artifact,
537
542
  artifact_uri: training.model_artifact_uri,
538
543
  artifact_root: inspection.path,
539
- servable: metadata?.servable ?? implicitPeftAdapter,
544
+ servable: true,
540
545
  };
541
546
  }
542
547
  async function refreshArtifactManifest(prepared) {
@@ -557,37 +562,19 @@ function stageOutputPath(prepared, stage) {
557
562
  function stageFingerprintPath(prepared, stage) {
558
563
  return `${stageOutputPath(prepared, stage)}.stage.json`;
559
564
  }
560
- function configuredPath(path, cwd) {
561
- if (path === "training/local-runner" || path.startsWith("training/local-runner/")) {
562
- return resolve(packageRoot, path);
563
- }
564
- if (path.startsWith("file://"))
565
- return stripFileUri(path);
566
- if (isAbsolute(path))
567
- return path;
568
- const fileLike = path.startsWith("./")
569
- || path.startsWith("../")
570
- || path.includes("/")
571
- || path.includes("\\")
572
- || /\.(?:py|js|mjs|cjs|ts|tsx|sh|bash|zsh|pl|rb)$/i.test(path);
573
- return fileLike ? resolve(cwd ?? process.cwd(), path) : null;
574
- }
575
- async function entrypointFileFingerprints(args) {
576
- const candidates = new Set();
577
- for (const value of args.values) {
578
- if (!value)
579
- continue;
580
- const path = configuredPath(value, args.cwd);
581
- if (path)
582
- candidates.add(path);
583
- }
584
- if (args.project) {
585
- const project = configuredPath(args.project, args.cwd) ?? resolve(args.cwd ?? process.cwd(), args.project);
586
- candidates.add(resolve(project, "pyproject.toml"));
587
- candidates.add(resolve(project, "uv.lock"));
588
- }
565
+ async function runnerFileFingerprints() {
566
+ const project = resolve(packageRoot, "training/local-runner");
567
+ const bundledSource = resolve(packageRoot, "training/local-runner/src");
568
+ const pythonSources = (await readdir(bundledSource, { withFileTypes: true }))
569
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".py"))
570
+ .map((entry) => resolve(bundledSource, entry.name));
571
+ const candidates = [...new Set([
572
+ ...pythonSources,
573
+ resolve(project, "pyproject.toml"),
574
+ resolve(project, "uv.lock"),
575
+ ])];
589
576
  const fingerprints = {};
590
- for (const path of [...candidates].sort())
577
+ for (const path of candidates.sort())
591
578
  fingerprints[path] = await hashFileIfPresent(path);
592
579
  return fingerprints;
593
580
  }
@@ -598,45 +585,26 @@ async function stageFingerprint(args) {
598
585
  dry_run: args.config.dryRun,
599
586
  };
600
587
  if (args.stage === "train") {
601
- const entrypointFiles = await entrypointFileFingerprints({
602
- values: [
603
- args.config.training.script
604
- ?? (args.prepared.request.training_method === "dpo"
605
- ? "training/local-runner/src/train_dpo.py"
606
- : "training/local-runner/src/train.py"),
607
- ...(args.config.training.command ?? []),
608
- ],
609
- cwd: args.config.training.cwd,
610
- project: args.config.training.project,
611
- });
588
+ const entrypointFiles = await runnerFileFingerprints();
612
589
  return hashJson({
613
590
  ...common,
614
- training: args.config.training,
615
591
  paths: args.config.paths,
616
592
  entrypoint_files: entrypointFiles,
617
593
  });
618
594
  }
619
- const selectedCommand = args.stage === "baseline"
620
- ? args.config.evaluation.baselineCommand
621
- : args.config.evaluation.candidateCommand;
622
- const entrypointFiles = await entrypointFileFingerprints({
623
- values: [
624
- args.config.evaluation.inference.script,
625
- ...(args.config.evaluation.inference.command ?? []),
626
- ...(selectedCommand ?? []),
627
- ],
628
- cwd: args.config.evaluation.inference.cwd,
629
- project: args.config.evaluation.inference.project,
630
- });
595
+ const entrypointFiles = await runnerFileFingerprints();
631
596
  const evaluation = {
632
- command: args.stage === "baseline"
633
- ? args.config.evaluation.baselineCommand
634
- : args.config.evaluation.candidateCommand,
597
+ inference_protocol_version: INFERENCE_PROTOCOL_VERSION,
635
598
  inference: args.config.evaluation.inference,
636
599
  scoring: args.config.evaluation.scoring,
637
600
  timeout_ms: args.config.evaluation.timeoutMs,
638
601
  baseline_cache: args.config.evaluation.baselineCache,
639
- llm: args.config.llm,
602
+ general_regression: args.prepared.generalRegression
603
+ ? {
604
+ dataset_sha256: args.prepared.generalRegression.datasetSha256,
605
+ system_prompt: args.prepared.generalRegression.system,
606
+ }
607
+ : null,
640
608
  model_cache: args.config.paths.modelCache,
641
609
  };
642
610
  return hashJson({
@@ -739,64 +707,27 @@ async function ensureRunRecord(args) {
739
707
  }
740
708
  async function computePreparedRun(args) {
741
709
  const { request, config, artifacts } = args;
742
- const evalSampleSeed = config.evaluation.sampleSeed ?? deriveSampleSeed(request.run_id);
743
- let examples = examplesFromSpec(request.spec_snapshot);
744
- let evalSplit = "spec_examples";
745
- let trainingExampleCount = examples.length;
746
- if (request.training_method === "dpo") {
747
- if (!request.dataset_prebuilt)
748
- throw new Error("DPO training requires dataset_prebuilt.");
749
- const trainingPath = stripFileUri(request.dataset_prebuilt.training);
750
- if (args.writeArtifacts)
751
- await copyFile(trainingPath, artifacts.trainingJsonl);
752
- trainingExampleCount = await countJsonlRows(trainingPath);
753
- const evaluation = selectDpoEvaluation(request);
754
- evalSplit = evaluation.split;
755
- examples = evaluation.path
756
- ? await examplesFromChatJsonl(evaluation.path)
757
- : examplesFromSpec(request.spec_snapshot);
758
- }
759
- else if (request.dataset_prebuilt) {
760
- const trainingPath = stripFileUri(request.dataset_prebuilt.training);
761
- const evaluation = selectPrebuiltEvaluation(request.dataset_prebuilt);
762
- evalSplit = evaluation.split;
763
- if (evalSplit === "prebuilt_training" && !config.dryRun && !config.evaluation.allowPrebuiltTrainingEval) {
764
- throw new Error("dataset_prebuilt has no test or validation split, so evaluation would run on the training data and "
765
- + "overstate improvement. Provide dataset_prebuilt.test or dataset_prebuilt.validation, or set "
766
- + "evaluation.allowPrebuiltTrainingEval=true to evaluate on the training split anyway.");
767
- }
768
- if (args.writeArtifacts) {
769
- const normalizedTraining = await normalizeChatJsonlForRelocation(trainingPath);
770
- await writeFile(artifacts.trainingJsonl, `${normalizedTraining}\n`, "utf8");
771
- }
772
- examples = await examplesFromChatJsonl(evaluation.path);
773
- trainingExampleCount = null;
774
- }
775
- else {
776
- const split = splitSpecExamples(request.spec_snapshot.examples, evalSampleSeed);
777
- let trainingExamples = request.spec_snapshot.examples;
778
- if (split.holdout.length > 0) {
779
- trainingExamples = split.train;
780
- examples = split.holdout;
781
- evalSplit = "spec_holdout";
782
- }
783
- trainingExampleCount = trainingExamples.length;
784
- if (args.writeArtifacts) {
785
- const jsonl = compileSpecToJsonl({ ...request.spec_snapshot, examples: trainingExamples });
786
- await writeFile(artifacts.trainingJsonl, `${jsonl}\n`, "utf8");
787
- }
710
+ const evalSampleSeed = config.evaluation.sampleSeed
711
+ ?? deriveSampleSeed(request.behavior_spec_id);
712
+ const dataset = await validateDatasetInputs(request, config);
713
+ const examples = dataset.evaluationExamples;
714
+ if (args.writeArtifacts) {
715
+ await writeFileAtomic(artifacts.trainingJsonl, `${dataset.trainingJsonl}\n`);
788
716
  }
789
717
  const system = buildSystemMessage(request.spec_snapshot);
790
718
  const baseModelForEvaluation = config.paths.baseModel ?? request.spec_snapshot.base_model;
791
- const parentModelArtifact = request.hyperparameters.parent_model_artifact;
792
- const parentModelFingerprint = parentModelArtifact
793
- ? await fingerprintLocalArtifact(parentModelArtifact)
794
- : undefined;
795
719
  const maxEvalExamples = config.evaluation.maxExamples ?? request.hyperparameters.max_eval_examples;
796
720
  const evalExamplesUsed = Math.min(maxEvalExamples ?? examples.length, examples.length);
797
721
  const fingerprints = await datasetFingerprints(request);
722
+ const generalRegressionConfig = config.evaluation.generalRegression;
723
+ const generalRegression = generalRegressionConfig
724
+ ? {
725
+ datasetPath: generalRegressionConfig.dataset,
726
+ datasetSha256: await hashFile(generalRegressionConfig.dataset),
727
+ ...await evaluationSuiteFromChatJsonl(generalRegressionConfig.dataset, generalRegressionConfig.systemPrompt),
728
+ }
729
+ : undefined;
798
730
  const requestFingerprint = hashJson(request);
799
- const effectiveConfigFingerprintValue = await effectiveConfigFingerprint(config);
800
731
  const runtimeFingerprintValue = await runtimeFingerprint();
801
732
  const baseModelRevision = await resolveBaseModelRevision(request, config);
802
733
  const baseModelFingerprint = config.paths.baseModel
@@ -806,9 +737,7 @@ async function computePreparedRun(args) {
806
737
  run_id: request.run_id,
807
738
  behavior_spec_id: request.behavior_spec_id,
808
739
  user_id: request.user_id,
809
- training_method: request.training_method,
810
740
  request_fingerprint: requestFingerprint,
811
- effective_config_fingerprint: effectiveConfigFingerprintValue,
812
741
  runtime_fingerprint: runtimeFingerprintValue,
813
742
  source_fingerprint: preparedSourceFingerprint({
814
743
  requestFingerprint,
@@ -818,19 +747,17 @@ async function computePreparedRun(args) {
818
747
  base_model_path: config.paths.baseModel,
819
748
  max_eval_examples: config.evaluation.maxExamples,
820
749
  eval_sample_seed: config.evaluation.sampleSeed,
821
- allow_prebuilt_training_eval: config.evaluation.allowPrebuiltTrainingEval,
822
750
  },
823
751
  baseModelRevision,
824
752
  baseModelFingerprint,
825
- parentModelFingerprint,
826
753
  datasetFingerprints: fingerprints,
827
754
  }),
828
- eval_split: evalSplit,
755
+ eval_split: dataset.evalSplit,
829
756
  eval_sample_seed: evalSampleSeed,
830
757
  eval_examples_total: examples.length,
831
758
  eval_examples_used: evalExamplesUsed,
832
759
  max_eval_examples: maxEvalExamples ?? null,
833
- training_example_count: trainingExampleCount,
760
+ training_example_count: dataset.trainingExampleCount,
834
761
  dataset_prebuilt: Boolean(request.dataset_prebuilt),
835
762
  dataset_format: request.dataset_prebuilt?.format ?? null,
836
763
  dataset_fingerprints: fingerprints,
@@ -838,8 +765,6 @@ async function computePreparedRun(args) {
838
765
  base_model_for_evaluation: baseModelForEvaluation,
839
766
  base_model_revision: baseModelRevision ?? null,
840
767
  base_model_fingerprint: baseModelFingerprint ?? null,
841
- parent_model_artifact: parentModelArtifact ?? null,
842
- parent_model_fingerprint: parentModelFingerprint ?? null,
843
768
  system_prompt_sha256: createHash("sha256").update(system).digest("hex"),
844
769
  prepared_at: new Date().toISOString(),
845
770
  };
@@ -853,8 +778,22 @@ async function computePreparedRun(args) {
853
778
  system,
854
779
  baseModelForEvaluation,
855
780
  maxEvalExamples,
781
+ generalRegression,
856
782
  };
857
783
  }
784
+ function generalRegressionEvaluationConfig(config) {
785
+ const { maxExamples: _maxExamples, sampleSeed: _sampleSeed, ...evaluation } = config.evaluation;
786
+ return { ...config, evaluation };
787
+ }
788
+ function generalRegressionRequiredPaths(prepared, kind) {
789
+ if (!prepared.generalRegression)
790
+ return [];
791
+ return [
792
+ kind === "baseline"
793
+ ? prepared.artifacts.generalBaselineEvalJson
794
+ : prepared.artifacts.generalCandidateEvalJson,
795
+ ];
796
+ }
858
797
  async function prepareStage(args) {
859
798
  const preparedExists = await pathExists(args.artifacts.stageMetadataJson)
860
799
  && await pathExists(args.artifacts.trainingJsonl);
@@ -865,7 +804,6 @@ async function prepareStage(args) {
865
804
  ? await readStageMetadata(args.artifacts.stageMetadataJson)
866
805
  : null;
867
806
  let canReuse = preparedExists
868
- && !args.force
869
807
  && existingMetadata?.source_fingerprint === prepared.metadata.source_fingerprint;
870
808
  if (canReuse) {
871
809
  canReuse = await verifyReusableArtifacts(args.artifacts, [
@@ -893,28 +831,18 @@ async function prepareStage(args) {
893
831
  });
894
832
  await throwIfCancelled(args.store, args.request);
895
833
  await args.store.invalidateRunOutputs(args.request.run_id, { report: true, model: true });
896
- await clearDependentStageArtifacts(args.artifacts);
834
+ await cleanupStageArtifacts(args.artifacts, "prepare");
897
835
  const refreshed = await computePreparedRun({ ...args, writeArtifacts: true });
898
836
  await refreshArtifactManifest(refreshed);
899
837
  return refreshed;
900
838
  }
901
- async function ensurePrepared(args) {
902
- return prepareStage({
903
- request: args.request,
904
- config: args.config,
905
- artifacts: args.artifacts,
906
- store: args.store,
907
- reporter: args.reporter,
908
- force: args.forcePrepare,
909
- });
910
- }
911
839
  async function runBaselineStage(args) {
912
- if (!args.force
913
- && await canReuseStageArtifact({
914
- stage: "baseline",
915
- prepared: args.prepared,
916
- config: args.config,
917
- })) {
840
+ if (await canReuseStageArtifact({
841
+ stage: "baseline",
842
+ prepared: args.prepared,
843
+ config: args.config,
844
+ additionalPaths: generalRegressionRequiredPaths(args.prepared, "baseline"),
845
+ })) {
918
846
  await throwIfCancelled(args.store, args.prepared.request);
919
847
  await updateRun({
920
848
  store: args.store,
@@ -943,19 +871,17 @@ async function runBaselineStage(args) {
943
871
  examples: args.prepared.examples.length,
944
872
  eval_examples_used: args.prepared.metadata.eval_examples_used,
945
873
  eval_split: args.prepared.metadata.eval_split,
946
- model_id: args.prepared.metadata.parent_model_artifact ?? args.prepared.baseModelForEvaluation,
947
- parent_model_artifact: args.prepared.metadata.parent_model_artifact,
874
+ model_id: args.prepared.baseModelForEvaluation,
948
875
  },
949
876
  });
950
877
  const report = await evaluateExamples({
951
878
  kind: "baseline",
952
- modelId: args.prepared.metadata.parent_model_artifact ?? args.prepared.baseModelForEvaluation,
879
+ modelId: args.prepared.baseModelForEvaluation,
953
880
  baseModelId: args.prepared.baseModelForEvaluation,
954
881
  baseModelRevision: args.config.paths.baseModel
955
882
  ? undefined
956
883
  : args.prepared.metadata.base_model_revision ?? undefined,
957
- sourceFingerprint: args.prepared.metadata.source_fingerprint,
958
- adapterPath: args.prepared.metadata.parent_model_artifact ?? undefined,
884
+ sourceFingerprint: args.prepared.metadata.base_model_fingerprint ?? undefined,
959
885
  examples: args.prepared.examples,
960
886
  system: args.prepared.system,
961
887
  config: args.config,
@@ -966,18 +892,47 @@ async function runBaselineStage(args) {
966
892
  sampleSeed: args.prepared.metadata.eval_sample_seed,
967
893
  shouldCancel: () => args.store.isCancellationRequested(args.prepared.request.run_id),
968
894
  });
895
+ if (args.prepared.generalRegression) {
896
+ await updateRun({
897
+ store: args.store,
898
+ reporter: args.reporter,
899
+ request: args.prepared.request,
900
+ status: "evaluating_baseline",
901
+ stage: "evaluating_baseline",
902
+ message: "Running baseline general regression evaluation.",
903
+ details: {
904
+ examples: args.prepared.generalRegression.examples.length,
905
+ dataset: args.prepared.generalRegression.datasetPath,
906
+ },
907
+ });
908
+ await evaluateExamples({
909
+ kind: "baseline",
910
+ modelId: args.prepared.baseModelForEvaluation,
911
+ baseModelId: args.prepared.baseModelForEvaluation,
912
+ baseModelRevision: args.config.paths.baseModel
913
+ ? undefined
914
+ : args.prepared.metadata.base_model_revision ?? undefined,
915
+ sourceFingerprint: args.prepared.metadata.base_model_fingerprint ?? undefined,
916
+ examples: args.prepared.generalRegression.examples,
917
+ system: args.prepared.generalRegression.system,
918
+ config: generalRegressionEvaluationConfig(args.config),
919
+ outputPath: args.prepared.artifacts.generalBaselineEvalJson,
920
+ reporter: args.runReporter,
921
+ evalSplit: "general_regression",
922
+ shouldCancel: () => args.store.isCancellationRequested(args.prepared.request.run_id),
923
+ });
924
+ }
969
925
  await throwIfCancelled(args.store, args.prepared.request);
970
926
  await writeStageFingerprint({ stage: "baseline", prepared: args.prepared, config: args.config });
971
927
  return report;
972
928
  }
973
929
  async function runTrainStage(args) {
974
- if (!args.force
975
- && await canReuseStageArtifact({
976
- stage: "train",
977
- prepared: args.prepared,
978
- config: args.config,
979
- verifyModel: true,
980
- })) {
930
+ if (await canReuseStageArtifact({
931
+ stage: "train",
932
+ prepared: args.prepared,
933
+ config: args.config,
934
+ verifyModel: true,
935
+ })) {
981
936
  await throwIfCancelled(args.store, args.prepared.request);
982
937
  await updateRun({
983
938
  store: args.store,
@@ -1011,7 +966,7 @@ async function runTrainStage(args) {
1011
966
  status: "training",
1012
967
  stage: "training",
1013
968
  message: args.config.dryRun ? "Recording dry-run training result." : "Launching local training process.",
1014
- details: { training_backend: args.config.training.backend, dry_run: args.config.dryRun },
969
+ details: { training_backend: "local-uv", dry_run: args.config.dryRun },
1015
970
  });
1016
971
  const training = await launchProcessTraining({
1017
972
  request: args.prepared.request,
@@ -1043,27 +998,9 @@ async function runTrainStage(args) {
1043
998
  }
1044
999
  return training;
1045
1000
  }
1046
- async function writeExternalTrainingReport(args) {
1047
- const training = trainingReportSchema.parse({
1048
- provider: args.config.training.backend === "command" ? "local-command" : "local-uv",
1049
- training_job_name: `external-${args.prepared.request.run_id}`,
1050
- model_artifact_uri: fileUri(localModelArtifactPath(args.modelArtifact)),
1051
- base_model_artifact_uri: args.config.paths.baseModel ? fileUri(args.config.paths.baseModel) : undefined,
1052
- parent_model_artifact_uri: args.prepared.metadata.parent_model_artifact ?? undefined,
1053
- artifact_metadata: {
1054
- ...(args.config.training.artifact ?? {}),
1055
- notes: args.config.training.artifact?.notes ?? "External model artifact supplied with --model-artifact.",
1056
- },
1057
- metrics: { external_model_artifact: true },
1058
- exit_code: null,
1059
- log_uri: fileUri(args.prepared.artifacts.trainingReportJson),
1060
- });
1061
- await writeJsonAtomic(args.prepared.artifacts.trainingReportJson, training);
1062
- return training;
1063
- }
1064
1001
  async function runCandidateStage(args) {
1065
1002
  let verifiedTraining;
1066
- if (!args.modelArtifact && await pathExists(args.prepared.artifacts.trainingReportJson)) {
1003
+ if (await pathExists(args.prepared.artifacts.trainingReportJson)) {
1067
1004
  const trainingCurrent = await canReuseStageArtifact({
1068
1005
  stage: "train",
1069
1006
  prepared: args.prepared,
@@ -1074,9 +1011,7 @@ async function runCandidateStage(args) {
1074
1011
  verifiedTraining = trainingReportSchema.parse(await readJson(args.prepared.artifacts.trainingReportJson));
1075
1012
  }
1076
1013
  }
1077
- if (!args.force
1078
- && !args.modelArtifact
1079
- && verifiedTraining
1014
+ if (verifiedTraining
1080
1015
  && await canReuseStageArtifact({
1081
1016
  stage: "candidate",
1082
1017
  prepared: args.prepared,
@@ -1085,6 +1020,7 @@ async function runCandidateStage(args) {
1085
1020
  additionalPaths: [
1086
1021
  args.prepared.artifacts.trainingReportJson,
1087
1022
  stageFingerprintPath(args.prepared, "train"),
1023
+ ...generalRegressionRequiredPaths(args.prepared, "candidate"),
1088
1024
  ],
1089
1025
  })) {
1090
1026
  await throwIfCancelled(args.store, args.prepared.request);
@@ -1110,33 +1046,16 @@ async function runCandidateStage(args) {
1110
1046
  await throwIfCancelled(args.store, args.prepared.request);
1111
1047
  return report;
1112
1048
  }
1113
- if (!args.modelArtifact && !verifiedTraining) {
1114
- throw new Error("candidate stage requires current verified training output. Run --stage train first.");
1049
+ if (!verifiedTraining) {
1050
+ throw new Error("Candidate evaluation requires current verified training output.");
1115
1051
  }
1116
1052
  await throwIfCancelled(args.store, args.prepared.request);
1117
- await args.store.invalidateRunOutputs(args.prepared.request.run_id, {
1118
- report: true,
1119
- model: Boolean(args.modelArtifact),
1120
- });
1053
+ await args.store.invalidateRunOutputs(args.prepared.request.run_id, { report: true });
1121
1054
  await cleanupStageArtifacts(args.prepared.artifacts, "candidate");
1122
- let training;
1123
- if (args.modelArtifact) {
1124
- training = await writeExternalTrainingReport({
1125
- prepared: args.prepared,
1126
- config: args.config,
1127
- modelArtifact: args.modelArtifact,
1128
- });
1129
- await writeStageFingerprint({ stage: "train", prepared: args.prepared, config: args.config });
1130
- }
1131
- else if (verifiedTraining) {
1132
- training = verifiedTraining;
1133
- }
1134
- else {
1135
- throw new Error("candidate stage requires training output or --model-artifact.");
1136
- }
1055
+ const training = verifiedTraining;
1137
1056
  const modelArtifact = training.model_artifact_uri;
1138
1057
  if (!modelArtifact)
1139
- throw new Error("candidate stage requires a model_artifact_uri in training-report.json or --model-artifact.");
1058
+ throw new Error("candidate stage requires a model_artifact_uri in training-report.json.");
1140
1059
  if (!isDryTraining(training)) {
1141
1060
  await modelManifestContract(args.prepared, training);
1142
1061
  await refreshArtifactManifest(args.prepared);
@@ -1174,6 +1093,36 @@ async function runCandidateStage(args) {
1174
1093
  sampleSeed: args.prepared.metadata.eval_sample_seed,
1175
1094
  shouldCancel: () => args.store.isCancellationRequested(args.prepared.request.run_id),
1176
1095
  });
1096
+ if (args.prepared.generalRegression) {
1097
+ await updateRun({
1098
+ store: args.store,
1099
+ reporter: args.reporter,
1100
+ request: args.prepared.request,
1101
+ status: "evaluating_candidate",
1102
+ stage: "evaluating_candidate",
1103
+ message: "Running candidate general regression evaluation.",
1104
+ details: {
1105
+ examples: args.prepared.generalRegression.examples.length,
1106
+ dataset: args.prepared.generalRegression.datasetPath,
1107
+ },
1108
+ });
1109
+ await evaluateExamples({
1110
+ kind: "candidate",
1111
+ modelId: modelArtifact,
1112
+ baseModelId: args.prepared.baseModelForEvaluation,
1113
+ baseModelRevision: args.config.paths.baseModel
1114
+ ? undefined
1115
+ : args.prepared.metadata.base_model_revision ?? undefined,
1116
+ adapterPath: modelArtifact,
1117
+ examples: args.prepared.generalRegression.examples,
1118
+ system: args.prepared.generalRegression.system,
1119
+ config: generalRegressionEvaluationConfig(args.config),
1120
+ outputPath: args.prepared.artifacts.generalCandidateEvalJson,
1121
+ reporter: args.runReporter,
1122
+ evalSplit: "general_regression",
1123
+ shouldCancel: () => args.store.isCancellationRequested(args.prepared.request.run_id),
1124
+ });
1125
+ }
1177
1126
  await throwIfCancelled(args.store, args.prepared.request);
1178
1127
  await writeStageFingerprint({
1179
1128
  stage: "candidate",
@@ -1183,82 +1132,20 @@ async function runCandidateStage(args) {
1183
1132
  });
1184
1133
  return report;
1185
1134
  }
1186
- async function runScoreStage(args) {
1187
- if (!await pathExists(args.prepared.artifacts.baselineEvalJson)) {
1188
- throw new Error("score stage requires baseline-eval.json. Run --stage baseline first.");
1189
- }
1190
- if (!await pathExists(args.prepared.artifacts.candidateEvalJson)) {
1191
- throw new Error("score stage requires candidate-eval.json. Run --stage candidate first.");
1192
- }
1193
- await verifyReusableArtifacts(args.prepared.artifacts, [
1194
- args.prepared.artifacts.baselineEvalJson,
1195
- stageFingerprintPath(args.prepared, "baseline"),
1196
- args.prepared.artifacts.candidateEvalJson,
1197
- stageFingerprintPath(args.prepared, "candidate"),
1198
- ]);
1199
- await throwIfCancelled(args.store, args.prepared.request);
1200
- await args.store.invalidateRunOutputs(args.prepared.request.run_id, { report: true });
1201
- await cleanupStageArtifacts(args.prepared.artifacts, "score");
1202
- await updateRun({
1203
- store: args.store,
1204
- reporter: args.reporter,
1205
- request: args.prepared.request,
1206
- status: "scoring",
1207
- stage: "scoring",
1208
- message: "Rescoring existing baseline and candidate outputs.",
1209
- details: { scoring_mode: args.config.evaluation.scoring.mode },
1210
- });
1211
- const rollbackPaths = [
1212
- args.prepared.artifacts.baselineEvalJson,
1213
- stageFingerprintPath(args.prepared, "baseline"),
1214
- args.prepared.artifacts.candidateEvalJson,
1215
- stageFingerprintPath(args.prepared, "candidate"),
1216
- ];
1217
- const originals = new Map();
1218
- for (const path of rollbackPaths)
1219
- originals.set(path, await readFile(path));
1220
- try {
1221
- const baseline = await rescoreEvalReport({
1222
- report: evalReportSchema.parse(await readJson(args.prepared.artifacts.baselineEvalJson)),
1223
- config: args.config,
1224
- outputPath: args.prepared.artifacts.baselineEvalJson,
1225
- system: args.prepared.system,
1226
- reporter: args.runReporter,
1227
- });
1228
- const candidate = await rescoreEvalReport({
1229
- report: evalReportSchema.parse(await readJson(args.prepared.artifacts.candidateEvalJson)),
1230
- config: args.config,
1231
- outputPath: args.prepared.artifacts.candidateEvalJson,
1232
- system: args.prepared.system,
1233
- reporter: args.runReporter,
1234
- });
1235
- await throwIfCancelled(args.store, args.prepared.request);
1236
- await writeStageFingerprint({ stage: "baseline", prepared: args.prepared, config: args.config });
1237
- const training = await pathExists(args.prepared.artifacts.trainingReportJson)
1238
- ? trainingReportSchema.parse(await readJson(args.prepared.artifacts.trainingReportJson))
1239
- : undefined;
1240
- await writeStageFingerprint({
1241
- stage: "candidate",
1242
- prepared: args.prepared,
1243
- config: args.config,
1244
- training,
1245
- });
1246
- return { baseline, candidate };
1247
- }
1248
- catch (error) {
1249
- await Promise.all([...originals].map(([path, contents]) => writeFileAtomic(path, contents)));
1250
- throw error;
1251
- }
1252
- }
1253
1135
  async function runReportStage(args) {
1254
1136
  if (!await pathExists(args.prepared.artifacts.baselineEvalJson)) {
1255
- throw new Error("report stage requires baseline-eval.json. Run --stage baseline first.");
1137
+ throw new Error("Run reporting requires baseline-eval.json.");
1256
1138
  }
1257
1139
  if (!await pathExists(args.prepared.artifacts.candidateEvalJson)) {
1258
- throw new Error("report stage requires candidate-eval.json. Run --stage candidate first.");
1140
+ throw new Error("Run reporting requires candidate-eval.json.");
1259
1141
  }
1260
1142
  if (!await pathExists(args.prepared.artifacts.trainingReportJson)) {
1261
- throw new Error("report stage requires training-report.json. Run --stage train first, or --stage candidate --model-artifact <path>.");
1143
+ throw new Error("Run reporting requires training-report.json.");
1144
+ }
1145
+ if (args.prepared.generalRegression
1146
+ && (!await pathExists(args.prepared.artifacts.generalBaselineEvalJson)
1147
+ || !await pathExists(args.prepared.artifacts.generalCandidateEvalJson))) {
1148
+ throw new Error("Run reporting requires both general regression evaluations.");
1262
1149
  }
1263
1150
  const currentTraining = await canReuseStageArtifact({
1264
1151
  stage: "train",
@@ -1270,12 +1157,18 @@ async function runReportStage(args) {
1270
1157
  stage: "baseline",
1271
1158
  prepared: args.prepared,
1272
1159
  config: args.config,
1160
+ additionalPaths: generalRegressionRequiredPaths(args.prepared, "baseline"),
1273
1161
  });
1274
1162
  const currentCandidate = await canReuseStageArtifact({
1275
1163
  stage: "candidate",
1276
1164
  prepared: args.prepared,
1277
1165
  config: args.config,
1278
1166
  verifyModel: true,
1167
+ additionalPaths: [
1168
+ args.prepared.artifacts.trainingReportJson,
1169
+ stageFingerprintPath(args.prepared, "train"),
1170
+ ...generalRegressionRequiredPaths(args.prepared, "candidate"),
1171
+ ],
1279
1172
  });
1280
1173
  if (!currentTraining || !currentBaseline || !currentCandidate) {
1281
1174
  throw new Error("report stage inputs are stale for the current request/config. Re-run baseline, train, and candidate as needed.");
@@ -1287,25 +1180,46 @@ async function runReportStage(args) {
1287
1180
  stageFingerprintPath(args.prepared, "candidate"),
1288
1181
  args.prepared.artifacts.trainingReportJson,
1289
1182
  stageFingerprintPath(args.prepared, "train"),
1183
+ ...generalRegressionRequiredPaths(args.prepared, "baseline"),
1184
+ ...generalRegressionRequiredPaths(args.prepared, "candidate"),
1290
1185
  ], { verifyModel: true });
1291
1186
  await throwIfCancelled(args.store, args.prepared.request);
1292
1187
  await args.store.invalidateRunOutputs(args.prepared.request.run_id, { report: true });
1293
1188
  await cleanupStageArtifacts(args.prepared.artifacts, "report");
1294
- if (args.emitReportingEvent) {
1295
- await updateRun({
1296
- store: args.store,
1297
- reporter: args.reporter,
1298
- request: args.prepared.request,
1299
- status: "reporting",
1300
- stage: "reporting",
1301
- message: "Writing run report.",
1302
- details: { report_path: args.prepared.artifacts.runReportJson },
1303
- });
1304
- }
1189
+ await updateRun({
1190
+ store: args.store,
1191
+ reporter: args.reporter,
1192
+ request: args.prepared.request,
1193
+ status: "reporting",
1194
+ stage: "reporting",
1195
+ message: "Writing run report.",
1196
+ details: { report_path: args.prepared.artifacts.runReportJson },
1197
+ });
1305
1198
  const baseline = evalReportSchema.parse(await readJson(args.prepared.artifacts.baselineEvalJson));
1306
1199
  const candidate = evalReportSchema.parse(await readJson(args.prepared.artifacts.candidateEvalJson));
1307
1200
  const training = trainingReportSchema.parse(await readJson(args.prepared.artifacts.trainingReportJson));
1308
1201
  const comparison = compareEvalReports(baseline, candidate);
1202
+ let generalRegression;
1203
+ if (args.prepared.generalRegression) {
1204
+ const generalBaseline = evalReportSchema.parse(await readJson(args.prepared.artifacts.generalBaselineEvalJson));
1205
+ const generalCandidate = evalReportSchema.parse(await readJson(args.prepared.artifacts.generalCandidateEvalJson));
1206
+ const generalComparison = compareEvalReports(generalBaseline, generalCandidate);
1207
+ const policy = args.config.evaluation.generalRegression;
1208
+ const gate = evaluateGeneralRegressionGate(generalComparison, policy);
1209
+ generalRegression = {
1210
+ dataset_uri: fileUri(args.prepared.generalRegression.datasetPath),
1211
+ dataset_sha256: args.prepared.generalRegression.datasetSha256,
1212
+ baseline: generalBaseline,
1213
+ candidate: generalCandidate,
1214
+ comparison: generalComparison,
1215
+ policy: {
1216
+ max_score_drop: policy.maxScoreDrop,
1217
+ max_pass_rate_drop: policy.maxPassRateDrop,
1218
+ },
1219
+ passed: gate.passed,
1220
+ failures: gate.failures,
1221
+ };
1222
+ }
1309
1223
  const completedAt = new Date().toISOString();
1310
1224
  const duration = elapsed(args.startedPerf);
1311
1225
  const report = runReportSchema.parse({
@@ -1319,18 +1233,23 @@ async function runReportStage(args) {
1319
1233
  baseline,
1320
1234
  candidate,
1321
1235
  comparison,
1236
+ general_regression: generalRegression,
1322
1237
  training,
1323
1238
  artifact_uris: {
1324
1239
  dataset: fileUri(args.prepared.artifacts.trainingJsonl),
1325
1240
  baseline_eval: fileUri(args.prepared.artifacts.baselineEvalJson),
1326
1241
  candidate_eval: fileUri(args.prepared.artifacts.candidateEvalJson),
1242
+ general_baseline_eval: generalRegression
1243
+ ? fileUri(args.prepared.artifacts.generalBaselineEvalJson)
1244
+ : undefined,
1245
+ general_candidate_eval: generalRegression
1246
+ ? fileUri(args.prepared.artifacts.generalCandidateEvalJson)
1247
+ : undefined,
1327
1248
  report: fileUri(args.prepared.artifacts.runReportJson),
1328
1249
  },
1329
1250
  run_metadata: {
1330
1251
  base_model: args.prepared.request.spec_snapshot.base_model,
1331
1252
  fine_tuned_model_id: training.model_artifact_uri ?? training.training_job_name,
1332
- parent_model_artifact: args.prepared.metadata.parent_model_artifact,
1333
- training_method: args.prepared.request.training_method,
1334
1253
  dataset_prebuilt: args.prepared.metadata.dataset_prebuilt,
1335
1254
  dataset_format: args.prepared.metadata.dataset_format,
1336
1255
  dataset_uri: fileUri(args.prepared.artifacts.trainingJsonl),
@@ -1362,15 +1281,20 @@ async function runReportStage(args) {
1362
1281
  report_path: args.prepared.artifacts.runReportJson,
1363
1282
  ...(!isDryTraining(training) ? { model_id: `local-${args.prepared.request.run_id}` } : {}),
1364
1283
  avg_score_delta: comparison.avg_score_delta,
1284
+ general_regression_passed: generalRegression?.passed,
1365
1285
  elapsed_seconds: duration.seconds,
1366
1286
  },
1367
1287
  });
1368
1288
  return report;
1369
1289
  }
1370
- export async function runLocalFineTuneStage(input) {
1290
+ export async function runLocalFineTune(input) {
1291
+ const validated = await validateLocalFineTuneInput({
1292
+ request: input.request,
1293
+ config: input.config,
1294
+ });
1295
+ input = { ...input, ...validated };
1371
1296
  const startedPerf = performance.now();
1372
1297
  const startedAt = new Date().toISOString();
1373
- const stage = input.stage ?? "all";
1374
1298
  const prefix = artifactPrefix(input.request);
1375
1299
  const artifacts = resolveRunArtifacts({ artifactRoot: input.config.artifactRoot, prefix });
1376
1300
  const store = createLocalStore(input.config.storeRoot);
@@ -1389,110 +1313,51 @@ export async function runLocalFineTuneStage(input) {
1389
1313
  let prepared;
1390
1314
  try {
1391
1315
  await throwIfCancelled(store, input.request);
1392
- prepared = await ensurePrepared({
1316
+ prepared = await prepareStage({
1393
1317
  request: input.request,
1394
1318
  config: input.config,
1395
1319
  artifacts,
1396
1320
  store,
1397
1321
  reporter: input.reporter,
1398
- forcePrepare: Boolean(input.force && (stage === "prepare" || stage === "all")),
1399
1322
  });
1400
1323
  await throwIfCancelled(store, input.request);
1401
- let report;
1402
- const completeRequestedStage = async (completedStage) => {
1403
- await throwIfCancelled(store, input.request);
1404
- const message = `${completedStage} stage completed.`;
1405
- const completedState = await store.updateRun({
1406
- runId: input.request.run_id,
1407
- status: "stage_completed",
1408
- stage: `${completedStage}_completed`,
1409
- message,
1410
- details: { requested_stage: completedStage },
1411
- });
1412
- if (completedState.status === "cancelled") {
1413
- throw new ProcessCancelledError(`Run ${input.request.run_id} was cancelled.`);
1414
- }
1415
- await input.reporter?.onEvent?.({
1416
- stage: `${completedStage}_completed`,
1417
- status: "completed",
1418
- message,
1419
- details: { requested_stage: completedStage },
1420
- });
1324
+ await runBaselineStage({
1325
+ prepared,
1326
+ config: input.config,
1327
+ store,
1328
+ reporter: input.reporter,
1329
+ runReporter,
1330
+ });
1331
+ await refreshArtifactManifest(prepared);
1332
+ await runTrainStage({
1333
+ prepared,
1334
+ config: input.config,
1335
+ store,
1336
+ reporter: input.reporter,
1337
+ runReporter,
1338
+ });
1339
+ await runCandidateStage({
1340
+ prepared,
1341
+ config: input.config,
1342
+ store,
1343
+ reporter: input.reporter,
1344
+ runReporter,
1345
+ });
1346
+ await refreshArtifactManifest(prepared);
1347
+ const report = await runReportStage({
1348
+ prepared,
1349
+ config: input.config,
1350
+ store,
1351
+ reporter: input.reporter,
1352
+ startedAt,
1353
+ startedPerf,
1354
+ });
1355
+ return {
1356
+ request: input.request,
1357
+ report,
1358
+ reportPath: artifacts.runReportJson,
1359
+ artifactDir: artifacts.runDir,
1421
1360
  };
1422
- if (stage === "prepare") {
1423
- await completeRequestedStage(stage);
1424
- return stageResult({ request: input.request, stage, artifacts });
1425
- }
1426
- if (stage === "baseline" || stage === "all") {
1427
- await runBaselineStage({
1428
- prepared,
1429
- config: input.config,
1430
- store,
1431
- reporter: input.reporter,
1432
- runReporter,
1433
- force: Boolean(input.force),
1434
- });
1435
- await refreshArtifactManifest(prepared);
1436
- if (stage === "baseline") {
1437
- await completeRequestedStage(stage);
1438
- return stageResult({ request: input.request, stage, artifacts });
1439
- }
1440
- }
1441
- if (stage === "train" || stage === "all") {
1442
- await runTrainStage({
1443
- prepared,
1444
- config: input.config,
1445
- store,
1446
- reporter: input.reporter,
1447
- runReporter,
1448
- force: Boolean(input.force),
1449
- });
1450
- if (stage === "train") {
1451
- await completeRequestedStage(stage);
1452
- return stageResult({ request: input.request, stage, artifacts });
1453
- }
1454
- }
1455
- if (stage === "candidate" || stage === "all") {
1456
- await runCandidateStage({
1457
- prepared,
1458
- config: input.config,
1459
- store,
1460
- reporter: input.reporter,
1461
- runReporter,
1462
- force: Boolean(input.force),
1463
- modelArtifact: input.modelArtifact,
1464
- });
1465
- await refreshArtifactManifest(prepared);
1466
- if (stage === "candidate") {
1467
- await completeRequestedStage(stage);
1468
- return stageResult({ request: input.request, stage, artifacts });
1469
- }
1470
- }
1471
- if (stage === "score") {
1472
- await runScoreStage({
1473
- prepared,
1474
- config: input.config,
1475
- store,
1476
- reporter: input.reporter,
1477
- runReporter,
1478
- });
1479
- await refreshArtifactManifest(prepared);
1480
- await completeRequestedStage(stage);
1481
- return stageResult({ request: input.request, stage, artifacts });
1482
- }
1483
- if (stage === "report" || stage === "all") {
1484
- report = await runReportStage({
1485
- prepared,
1486
- config: input.config,
1487
- store,
1488
- reporter: input.reporter,
1489
- startedAt,
1490
- startedPerf,
1491
- emitReportingEvent: stage === "report",
1492
- });
1493
- return stageResult({ request: input.request, stage, artifacts, report });
1494
- }
1495
- throw new Error(`Unknown run stage: ${stage}`);
1496
1361
  }
1497
1362
  catch (error) {
1498
1363
  const cancelled = error instanceof ProcessCancelledError
@@ -1524,39 +1389,4 @@ export async function runLocalFineTuneStage(input) {
1524
1389
  await releaseRunLock();
1525
1390
  }
1526
1391
  }
1527
- function stageResult(args) {
1528
- return {
1529
- request: args.request,
1530
- stage: args.stage,
1531
- report: args.report,
1532
- reportPath: args.report ? args.artifacts.runReportJson : undefined,
1533
- artifactDir: dirname(args.artifacts.runReportJson),
1534
- artifacts: {
1535
- training_jsonl: args.artifacts.trainingJsonl,
1536
- stage_metadata: args.artifacts.stageMetadataJson,
1537
- training_report: args.artifacts.trainingReportJson,
1538
- baseline_eval: args.artifacts.baselineEvalJson,
1539
- candidate_eval: args.artifacts.candidateEvalJson,
1540
- report: args.artifacts.runReportJson,
1541
- artifact_manifest: args.artifacts.artifactManifestJson,
1542
- },
1543
- };
1544
- }
1545
- export async function runLocalFineTune(input) {
1546
- const result = await runLocalFineTuneStage({
1547
- request: input.request,
1548
- config: input.config,
1549
- reporter: input.reporter,
1550
- stage: "all",
1551
- });
1552
- if (!result.report || !result.reportPath) {
1553
- throw new Error("Full local fine-tune run did not produce a report.");
1554
- }
1555
- return {
1556
- request: result.request,
1557
- report: result.report,
1558
- reportPath: result.reportPath,
1559
- artifactDir: result.artifactDir,
1560
- };
1561
- }
1562
1392
  //# sourceMappingURL=orchestrator.js.map