@tuned-tensor/local 0.2.2 → 0.2.4

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.
@@ -1,10 +1,11 @@
1
- import { copyFile, readFile, writeFile } from "node:fs/promises";
1
+ import { copyFile, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
2
3
  import { performance } from "node:perf_hooks";
3
4
  import { dirname, resolve } from "node:path";
4
- import { defaultArtifactPrefix, fileUri, prepareRunDirectories, resolveRunArtifacts, writeJson, } from "./artifacts.js";
5
- import { fineTuneRunRequestSchema, localRunnerConfigSchema, runReportSchema, } from "./contracts.js";
5
+ import { defaultArtifactPrefix, fileUri, prepareRunDirectories, readJson, resolveRunArtifacts, writeJson, } from "./artifacts.js";
6
+ import { evalReportSchema, fineTuneRunRequestSchema, localRunnerConfigSchema, runReportSchema, trainingReportSchema, } from "./contracts.js";
6
7
  import { buildSystemMessage, compileSpecToJsonl, examplesFromChatJsonl, examplesFromSpec } from "./dataset.js";
7
- import { compareEvalReports, deriveSampleSeed, evaluateExamples, splitSpecExamples } from "./evaluation.js";
8
+ import { compareEvalReports, deriveSampleSeed, evaluateExamples, rescoreEvalReport, splitSpecExamples } from "./evaluation.js";
8
9
  import { launchProcessTraining } from "./process-training.js";
9
10
  import { createLocalStore } from "./store.js";
10
11
  export async function loadJsonFile(path) {
@@ -25,6 +26,15 @@ function elapsed(started) {
25
26
  function stripFileUri(path) {
26
27
  return path.replace(/^file:\/\//, "");
27
28
  }
29
+ async function pathExists(path) {
30
+ try {
31
+ await stat(path);
32
+ return true;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
28
38
  function selectPrebuiltEvaluation(dataset) {
29
39
  if (!dataset)
30
40
  throw new Error("dataset_prebuilt is required");
@@ -34,223 +44,613 @@ function selectPrebuiltEvaluation(dataset) {
34
44
  return { path: stripFileUri(dataset.validation), split: "prebuilt_validation" };
35
45
  return { path: stripFileUri(dataset.training), split: "prebuilt_training" };
36
46
  }
37
- export async function runLocalFineTune(input) {
38
- const startedPerf = performance.now();
39
- const startedAt = new Date().toISOString();
40
- const { request, config, reporter } = input;
41
- const prefix = request.artifacts?.prefix ?? defaultArtifactPrefix({
47
+ function selectDpoEvaluation(request) {
48
+ if (request.dataset_prebuilt?.test) {
49
+ return { path: stripFileUri(request.dataset_prebuilt.test), split: "prebuilt_test" };
50
+ }
51
+ if (request.dataset_prebuilt?.validation) {
52
+ return { path: stripFileUri(request.dataset_prebuilt.validation), split: "prebuilt_validation" };
53
+ }
54
+ return { split: "spec_examples" };
55
+ }
56
+ function artifactPrefix(request) {
57
+ return request.artifacts?.prefix ?? defaultArtifactPrefix({
42
58
  userId: request.user_id,
43
59
  behaviorSpecId: request.behavior_spec_id,
44
60
  runId: request.run_id,
45
61
  });
46
- const artifacts = resolveRunArtifacts({ artifactRoot: config.artifactRoot, prefix });
47
- const store = createLocalStore(config.storeRoot);
48
- await prepareRunDirectories(artifacts);
49
- await writeJson(resolve(artifacts.runDir, "request.json"), request);
50
- await store.startRun({ request, artifactDir: artifacts.runDir });
62
+ }
63
+ function hashJson(value) {
64
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
65
+ }
66
+ function preparedSourceFingerprint(args) {
67
+ return hashJson({
68
+ training_method: args.request.training_method,
69
+ spec_snapshot: args.request.spec_snapshot,
70
+ dataset_prebuilt: args.request.dataset_prebuilt ?? null,
71
+ dataset_fingerprints: args.datasetFingerprints,
72
+ base_model_for_evaluation: args.baseModelForEvaluation,
73
+ eval_sample_seed: args.evalSampleSeed,
74
+ max_eval_examples: args.maxEvalExamples ?? null,
75
+ });
76
+ }
77
+ async function countJsonlRows(path) {
78
+ const text = await readFile(path, "utf8");
79
+ return text.split(/\r?\n/).filter((line) => line.trim()).length;
80
+ }
81
+ async function hashFile(path) {
82
+ return createHash("sha256").update(await readFile(path)).digest("hex");
83
+ }
84
+ async function datasetFingerprints(request) {
85
+ const dataset = request.dataset_prebuilt;
86
+ if (!dataset)
87
+ return {};
88
+ const entries = [
89
+ ["training", dataset.training],
90
+ ["validation", dataset.validation],
91
+ ["test", dataset.test],
92
+ ];
93
+ const fingerprints = {};
94
+ for (const [key, value] of entries) {
95
+ if (value)
96
+ fingerprints[key] = await hashFile(stripFileUri(value));
97
+ }
98
+ return fingerprints;
99
+ }
100
+ function statusForProgressStage(stage) {
101
+ if (stage === "evaluating_baseline")
102
+ return "evaluating_baseline";
103
+ if (stage === "evaluating_candidate")
104
+ return "evaluating_candidate";
105
+ if (stage === "training")
106
+ return "training";
107
+ if (stage === "preparing")
108
+ return "preparing";
109
+ if (stage === "scoring")
110
+ return "scoring";
111
+ if (stage === "reporting")
112
+ return "reporting";
113
+ return "training";
114
+ }
115
+ async function readStageMetadata(path) {
51
116
  try {
52
- async function updateRun(args) {
53
- const state = await store.updateRun({ runId: request.run_id, ...args });
54
- await reporter?.onEvent?.({
55
- stage: args.stage,
56
- status: args.status,
57
- message: args.message,
58
- details: args.details,
117
+ const metadata = await readJson(path);
118
+ return typeof metadata.source_fingerprint === "string"
119
+ ? metadata
120
+ : null;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ async function clearDependentStageArtifacts(artifacts) {
127
+ await Promise.all([
128
+ rm(artifacts.baselineEvalJson, { force: true }),
129
+ rm(artifacts.candidateEvalJson, { force: true }),
130
+ rm(artifacts.trainingReportJson, { force: true }),
131
+ rm(artifacts.runReportJson, { force: true }),
132
+ ]);
133
+ }
134
+ function createStoreReporter(input) {
135
+ return {
136
+ verbose: input.reporter?.verbose,
137
+ async onEvent(event) {
138
+ await input.store.updateRun({
139
+ runId: input.request.run_id,
140
+ status: statusForProgressStage(event.stage),
141
+ stage: event.stage,
142
+ message: event.message,
143
+ details: event.details,
59
144
  });
60
- return state;
61
- }
62
- function statusForProgressStage(stage) {
63
- if (stage === "evaluating_baseline")
64
- return "evaluating_baseline";
65
- if (stage === "evaluating_candidate")
66
- return "evaluating_candidate";
67
- if (stage === "training")
68
- return "training";
69
- if (stage === "preparing")
70
- return "preparing";
71
- return "training";
72
- }
73
- const runReporter = {
74
- verbose: reporter?.verbose,
75
- async onEvent(event) {
76
- await store.updateRun({
77
- runId: request.run_id,
78
- status: statusForProgressStage(event.stage),
79
- stage: event.stage,
80
- message: event.message,
81
- details: event.details,
82
- });
83
- await reporter?.onEvent?.(event);
84
- },
85
- async onLog(log) {
86
- await reporter?.onLog?.(log);
87
- },
88
- };
89
- await reporter?.onEvent?.({
145
+ await input.reporter?.onEvent?.(event);
146
+ },
147
+ async onLog(log) {
148
+ await input.reporter?.onLog?.(log);
149
+ },
150
+ };
151
+ }
152
+ async function updateRun(input) {
153
+ const state = await input.store.updateRun({
154
+ runId: input.request.run_id,
155
+ status: input.status,
156
+ stage: input.stage,
157
+ message: input.message,
158
+ details: input.details,
159
+ });
160
+ await input.reporter?.onEvent?.({
161
+ stage: input.stage,
162
+ status: input.status,
163
+ message: input.message,
164
+ details: input.details,
165
+ });
166
+ return state;
167
+ }
168
+ async function ensureRunRecord(args) {
169
+ await prepareRunDirectories(args.artifacts);
170
+ await writeJson(resolve(args.artifacts.runDir, "request.json"), args.request);
171
+ try {
172
+ await args.store.getRun(args.request.run_id);
173
+ }
174
+ catch {
175
+ await args.store.startRun({ request: args.request, artifactDir: args.artifacts.runDir });
176
+ await args.reporter?.onEvent?.({
90
177
  stage: "queued",
91
178
  status: "queued",
92
179
  message: "Run queued.",
93
- details: { run_id: request.run_id, artifact_dir: artifacts.runDir },
94
- });
95
- await updateRun({
96
- status: "preparing",
97
- stage: "preparing",
98
- message: "Preparing local run artifacts.",
99
- details: { artifact_dir: artifacts.runDir },
180
+ details: { run_id: args.request.run_id, artifact_dir: args.artifacts.runDir },
100
181
  });
101
- // Deterministic per-run seed so the spec holdout split and truncated
102
- // sampling are reproducible and identical for baseline and candidate.
103
- const evalSampleSeed = config.evaluation.sampleSeed ?? deriveSampleSeed(request.run_id);
104
- let examples = examplesFromSpec(request.spec_snapshot);
105
- // With a single spec example there is nothing to hold out, so evaluation
106
- // runs on the training set; eval_split=spec_examples records that.
107
- let evalSplit = "spec_examples";
108
- let trainingExampleCount = examples.length;
109
- if (request.dataset_prebuilt) {
110
- const trainingPath = stripFileUri(request.dataset_prebuilt.training);
111
- const evaluation = selectPrebuiltEvaluation(request.dataset_prebuilt);
112
- evalSplit = evaluation.split;
113
- if (evalSplit === "prebuilt_training" && !config.dryRun && !config.evaluation.allowPrebuiltTrainingEval) {
114
- throw new Error("dataset_prebuilt has no test or validation split, so evaluation would run on the training data and "
115
- + "overstate improvement. Provide dataset_prebuilt.test or dataset_prebuilt.validation, or set "
116
- + "evaluation.allowPrebuiltTrainingEval=true to evaluate on the training split anyway.");
117
- }
182
+ }
183
+ }
184
+ async function computePreparedRun(args) {
185
+ const { request, config, artifacts } = args;
186
+ const evalSampleSeed = config.evaluation.sampleSeed ?? deriveSampleSeed(request.run_id);
187
+ let examples = examplesFromSpec(request.spec_snapshot);
188
+ let evalSplit = "spec_examples";
189
+ let trainingExampleCount = examples.length;
190
+ if (request.training_method === "dpo") {
191
+ if (!request.dataset_prebuilt)
192
+ throw new Error("DPO training requires dataset_prebuilt.");
193
+ const trainingPath = stripFileUri(request.dataset_prebuilt.training);
194
+ if (args.writeArtifacts)
118
195
  await copyFile(trainingPath, artifacts.trainingJsonl);
119
- examples = await examplesFromChatJsonl(evaluation.path);
120
- trainingExampleCount = null;
196
+ trainingExampleCount = await countJsonlRows(trainingPath);
197
+ const evaluation = selectDpoEvaluation(request);
198
+ evalSplit = evaluation.split;
199
+ examples = evaluation.path
200
+ ? await examplesFromChatJsonl(evaluation.path)
201
+ : examplesFromSpec(request.spec_snapshot);
202
+ }
203
+ else if (request.dataset_prebuilt) {
204
+ const trainingPath = stripFileUri(request.dataset_prebuilt.training);
205
+ const evaluation = selectPrebuiltEvaluation(request.dataset_prebuilt);
206
+ evalSplit = evaluation.split;
207
+ if (evalSplit === "prebuilt_training" && !config.dryRun && !config.evaluation.allowPrebuiltTrainingEval) {
208
+ throw new Error("dataset_prebuilt has no test or validation split, so evaluation would run on the training data and "
209
+ + "overstate improvement. Provide dataset_prebuilt.test or dataset_prebuilt.validation, or set "
210
+ + "evaluation.allowPrebuiltTrainingEval=true to evaluate on the training split anyway.");
121
211
  }
122
- else {
123
- // Split spec examples into train/holdout so evaluation does not score
124
- // the same examples the adapter was trained on.
125
- const split = splitSpecExamples(request.spec_snapshot.examples, evalSampleSeed);
126
- let trainingExamples = request.spec_snapshot.examples;
127
- if (split.holdout.length > 0) {
128
- trainingExamples = split.train;
129
- examples = split.holdout;
130
- evalSplit = "spec_holdout";
131
- }
132
- trainingExampleCount = trainingExamples.length;
212
+ if (args.writeArtifacts)
213
+ await copyFile(trainingPath, artifacts.trainingJsonl);
214
+ examples = await examplesFromChatJsonl(evaluation.path);
215
+ trainingExampleCount = null;
216
+ }
217
+ else {
218
+ const split = splitSpecExamples(request.spec_snapshot.examples, evalSampleSeed);
219
+ let trainingExamples = request.spec_snapshot.examples;
220
+ if (split.holdout.length > 0) {
221
+ trainingExamples = split.train;
222
+ examples = split.holdout;
223
+ evalSplit = "spec_holdout";
224
+ }
225
+ trainingExampleCount = trainingExamples.length;
226
+ if (args.writeArtifacts) {
133
227
  const jsonl = compileSpecToJsonl({ ...request.spec_snapshot, examples: trainingExamples });
134
228
  await writeFile(artifacts.trainingJsonl, `${jsonl}\n`, "utf8");
135
229
  }
136
- const system = buildSystemMessage(request.spec_snapshot);
137
- const baseModelForEvaluation = config.paths.baseModel ?? request.spec_snapshot.base_model;
138
- // Explicit config wins; the request hyperparameter fills in when unset.
139
- const maxEvalExamples = config.evaluation.maxExamples
140
- ?? request.hyperparameters.max_eval_examples;
230
+ }
231
+ const system = buildSystemMessage(request.spec_snapshot);
232
+ const baseModelForEvaluation = config.paths.baseModel ?? request.spec_snapshot.base_model;
233
+ const maxEvalExamples = config.evaluation.maxExamples ?? request.hyperparameters.max_eval_examples;
234
+ const evalExamplesUsed = Math.min(maxEvalExamples ?? examples.length, examples.length);
235
+ const fingerprints = await datasetFingerprints(request);
236
+ const metadata = {
237
+ run_id: request.run_id,
238
+ behavior_spec_id: request.behavior_spec_id,
239
+ user_id: request.user_id,
240
+ training_method: request.training_method,
241
+ source_fingerprint: preparedSourceFingerprint({
242
+ request,
243
+ baseModelForEvaluation,
244
+ evalSampleSeed,
245
+ maxEvalExamples,
246
+ datasetFingerprints: fingerprints,
247
+ }),
248
+ eval_split: evalSplit,
249
+ eval_sample_seed: evalSampleSeed,
250
+ eval_examples_total: examples.length,
251
+ eval_examples_used: evalExamplesUsed,
252
+ max_eval_examples: maxEvalExamples ?? null,
253
+ training_example_count: trainingExampleCount,
254
+ dataset_prebuilt: Boolean(request.dataset_prebuilt),
255
+ dataset_format: request.dataset_prebuilt?.format ?? null,
256
+ dataset_fingerprints: fingerprints,
257
+ dataset_uri: fileUri(artifacts.trainingJsonl),
258
+ base_model_for_evaluation: baseModelForEvaluation,
259
+ system_prompt_sha256: createHash("sha256").update(system).digest("hex"),
260
+ prepared_at: new Date().toISOString(),
261
+ };
262
+ if (args.writeArtifacts)
263
+ await writeJson(artifacts.stageMetadataJson, metadata);
264
+ return {
265
+ request,
266
+ artifacts,
267
+ metadata,
268
+ examples,
269
+ system,
270
+ baseModelForEvaluation,
271
+ maxEvalExamples,
272
+ };
273
+ }
274
+ async function prepareStage(args) {
275
+ const preparedExists = await pathExists(args.artifacts.stageMetadataJson)
276
+ && await pathExists(args.artifacts.trainingJsonl);
277
+ const prepared = await computePreparedRun({ ...args, writeArtifacts: false });
278
+ const existingMetadata = preparedExists
279
+ ? await readStageMetadata(args.artifacts.stageMetadataJson)
280
+ : null;
281
+ const canReuse = preparedExists
282
+ && !args.force
283
+ && existingMetadata?.source_fingerprint === prepared.metadata.source_fingerprint;
284
+ if (canReuse) {
285
+ await updateRun({
286
+ ...args,
287
+ status: "preparing",
288
+ stage: "preparing",
289
+ message: "Reusing prepared local run artifacts.",
290
+ details: { artifact_dir: args.artifacts.runDir },
291
+ });
292
+ return prepared;
293
+ }
294
+ await updateRun({
295
+ ...args,
296
+ status: "preparing",
297
+ stage: "preparing",
298
+ message: "Preparing local run artifacts.",
299
+ details: { artifact_dir: args.artifacts.runDir },
300
+ });
301
+ await clearDependentStageArtifacts(args.artifacts);
302
+ return computePreparedRun({ ...args, writeArtifacts: true });
303
+ }
304
+ async function ensurePrepared(args) {
305
+ return prepareStage({
306
+ request: args.request,
307
+ config: args.config,
308
+ artifacts: args.artifacts,
309
+ store: args.store,
310
+ reporter: args.reporter,
311
+ force: args.forcePrepare,
312
+ });
313
+ }
314
+ async function runBaselineStage(args) {
315
+ if (!args.force && await pathExists(args.prepared.artifacts.baselineEvalJson)) {
141
316
  await updateRun({
317
+ store: args.store,
318
+ reporter: args.reporter,
319
+ request: args.prepared.request,
142
320
  status: "evaluating_baseline",
143
321
  stage: "evaluating_baseline",
144
- message: "Running baseline evaluation.",
145
- details: {
146
- examples: examples.length,
147
- eval_examples_used: Math.min(maxEvalExamples ?? examples.length, examples.length),
148
- eval_split: evalSplit,
149
- model_id: baseModelForEvaluation,
150
- },
151
- });
152
- const baseline = await evaluateExamples({
153
- kind: "baseline",
154
- modelId: baseModelForEvaluation,
155
- baseModelId: baseModelForEvaluation,
156
- examples,
157
- system,
158
- config,
159
- outputPath: artifacts.baselineEvalJson,
160
- reporter: runReporter,
161
- maxExamples: maxEvalExamples,
162
- evalSplit,
163
- sampleSeed: evalSampleSeed,
322
+ message: "Reusing existing baseline evaluation.",
323
+ details: { path: args.prepared.artifacts.baselineEvalJson },
164
324
  });
325
+ return evalReportSchema.parse(await readJson(args.prepared.artifacts.baselineEvalJson));
326
+ }
327
+ await updateRun({
328
+ store: args.store,
329
+ reporter: args.reporter,
330
+ request: args.prepared.request,
331
+ status: "evaluating_baseline",
332
+ stage: "evaluating_baseline",
333
+ message: "Running baseline evaluation.",
334
+ details: {
335
+ examples: args.prepared.examples.length,
336
+ eval_examples_used: args.prepared.metadata.eval_examples_used,
337
+ eval_split: args.prepared.metadata.eval_split,
338
+ model_id: args.prepared.baseModelForEvaluation,
339
+ },
340
+ });
341
+ return evaluateExamples({
342
+ kind: "baseline",
343
+ modelId: args.prepared.baseModelForEvaluation,
344
+ baseModelId: args.prepared.baseModelForEvaluation,
345
+ examples: args.prepared.examples,
346
+ system: args.prepared.system,
347
+ config: args.config,
348
+ outputPath: args.prepared.artifacts.baselineEvalJson,
349
+ reporter: args.runReporter,
350
+ maxExamples: args.prepared.maxEvalExamples,
351
+ evalSplit: args.prepared.metadata.eval_split,
352
+ sampleSeed: args.prepared.metadata.eval_sample_seed,
353
+ });
354
+ }
355
+ async function runTrainStage(args) {
356
+ if (!args.force && await pathExists(args.prepared.artifacts.trainingReportJson)) {
165
357
  await updateRun({
358
+ store: args.store,
359
+ reporter: args.reporter,
360
+ request: args.prepared.request,
166
361
  status: "training",
167
362
  stage: "training",
168
- message: config.dryRun ? "Recording dry-run training result." : "Launching local training process.",
169
- details: { training_backend: config.training.backend, dry_run: config.dryRun },
363
+ message: "Reusing existing training result.",
364
+ details: { path: args.prepared.artifacts.trainingReportJson },
170
365
  });
171
- const training = await launchProcessTraining({ request, artifacts, config, reporter: runReporter });
366
+ return trainingReportSchema.parse(await readJson(args.prepared.artifacts.trainingReportJson));
367
+ }
368
+ await updateRun({
369
+ store: args.store,
370
+ reporter: args.reporter,
371
+ request: args.prepared.request,
372
+ status: "training",
373
+ stage: "training",
374
+ message: args.config.dryRun ? "Recording dry-run training result." : "Launching local training process.",
375
+ details: { training_backend: args.config.training.backend, dry_run: args.config.dryRun },
376
+ });
377
+ const training = await launchProcessTraining({
378
+ request: args.prepared.request,
379
+ artifacts: args.prepared.artifacts,
380
+ config: args.config,
381
+ reporter: args.runReporter,
382
+ });
383
+ await writeJson(args.prepared.artifacts.trainingReportJson, training);
384
+ return training;
385
+ }
386
+ async function writeExternalTrainingReport(args) {
387
+ const training = trainingReportSchema.parse({
388
+ provider: args.config.training.backend === "command" ? "local-command" : "local-uv",
389
+ training_job_name: `external-${args.prepared.request.run_id}`,
390
+ model_artifact_uri: args.modelArtifact,
391
+ base_model_artifact_uri: args.config.paths.baseModel ? fileUri(args.config.paths.baseModel) : undefined,
392
+ artifact_metadata: {
393
+ ...(args.config.training.artifact ?? {}),
394
+ notes: args.config.training.artifact?.notes ?? "External model artifact supplied with --model-artifact.",
395
+ },
396
+ metrics: { external_model_artifact: true },
397
+ exit_code: null,
398
+ log_uri: fileUri(args.prepared.artifacts.trainingReportJson),
399
+ });
400
+ await writeJson(args.prepared.artifacts.trainingReportJson, training);
401
+ return training;
402
+ }
403
+ async function runCandidateStage(args) {
404
+ if (!args.force && !args.modelArtifact && await pathExists(args.prepared.artifacts.candidateEvalJson)) {
172
405
  await updateRun({
406
+ store: args.store,
407
+ reporter: args.reporter,
408
+ request: args.prepared.request,
173
409
  status: "evaluating_candidate",
174
410
  stage: "evaluating_candidate",
175
- message: "Running candidate evaluation.",
176
- details: { model_artifact_uri: training.model_artifact_uri },
411
+ message: "Reusing existing candidate evaluation.",
412
+ details: { path: args.prepared.artifacts.candidateEvalJson },
177
413
  });
178
- const candidate = await evaluateExamples({
179
- kind: "candidate",
180
- modelId: training.model_artifact_uri ?? training.training_job_name,
181
- baseModelId: baseModelForEvaluation,
182
- adapterPath: training.model_artifact_uri,
183
- examples,
184
- system,
185
- config,
186
- outputPath: artifacts.candidateEvalJson,
187
- reporter: runReporter,
188
- maxExamples: maxEvalExamples,
189
- evalSplit,
190
- sampleSeed: evalSampleSeed,
414
+ return evalReportSchema.parse(await readJson(args.prepared.artifacts.candidateEvalJson));
415
+ }
416
+ let training;
417
+ if (args.modelArtifact) {
418
+ training = await writeExternalTrainingReport({
419
+ prepared: args.prepared,
420
+ config: args.config,
421
+ modelArtifact: args.modelArtifact,
191
422
  });
192
- const comparison = compareEvalReports(baseline, candidate);
193
- const completedAt = new Date().toISOString();
194
- const duration = elapsed(startedPerf);
195
- const report = runReportSchema.parse({
196
- run_id: request.run_id,
197
- behavior_spec_id: request.behavior_spec_id,
198
- user_id: request.user_id,
199
- run_number: request.run_number,
200
- base_model: request.spec_snapshot.base_model,
201
- fine_tuned_model_id: training.model_artifact_uri ?? training.training_job_name,
202
- status: "completed",
203
- baseline,
204
- candidate,
205
- comparison,
206
- training,
207
- artifact_uris: {
208
- dataset: fileUri(artifacts.trainingJsonl),
209
- baseline_eval: fileUri(artifacts.baselineEvalJson),
210
- candidate_eval: fileUri(artifacts.candidateEvalJson),
211
- report: fileUri(artifacts.runReportJson),
212
- },
213
- run_metadata: {
214
- base_model: request.spec_snapshot.base_model,
215
- fine_tuned_model_id: training.model_artifact_uri ?? training.training_job_name,
216
- dataset_prebuilt: Boolean(request.dataset_prebuilt),
217
- dataset_uri: fileUri(artifacts.trainingJsonl),
218
- spec_example_count: request.spec_snapshot.examples.length,
219
- training_example_count: trainingExampleCount,
220
- eval_examples_total: baseline.eval_examples_total,
221
- eval_examples_used: baseline.eval_examples_used,
222
- eval_split: baseline.eval_split,
223
- eval_sample_seed: baseline.eval_sample_seed ?? null,
224
- started_at: startedAt,
225
- completed_at: completedAt,
226
- elapsed_ms: duration.ms,
227
- elapsed_seconds: duration.seconds,
228
- },
229
- created_at: completedAt,
423
+ }
424
+ else if (await pathExists(args.prepared.artifacts.trainingReportJson)) {
425
+ training = trainingReportSchema.parse(await readJson(args.prepared.artifacts.trainingReportJson));
426
+ }
427
+ else {
428
+ throw new Error("candidate stage requires training output or --model-artifact.");
429
+ }
430
+ const modelArtifact = training.model_artifact_uri;
431
+ if (!modelArtifact)
432
+ throw new Error("candidate stage requires a model_artifact_uri in training-report.json or --model-artifact.");
433
+ await updateRun({
434
+ store: args.store,
435
+ reporter: args.reporter,
436
+ request: args.prepared.request,
437
+ status: "evaluating_candidate",
438
+ stage: "evaluating_candidate",
439
+ message: "Running candidate evaluation.",
440
+ details: { model_artifact_uri: modelArtifact },
441
+ });
442
+ return evaluateExamples({
443
+ kind: "candidate",
444
+ modelId: modelArtifact,
445
+ baseModelId: args.prepared.baseModelForEvaluation,
446
+ adapterPath: modelArtifact,
447
+ examples: args.prepared.examples,
448
+ system: args.prepared.system,
449
+ config: args.config,
450
+ outputPath: args.prepared.artifacts.candidateEvalJson,
451
+ reporter: args.runReporter,
452
+ maxExamples: args.prepared.maxEvalExamples,
453
+ evalSplit: args.prepared.metadata.eval_split,
454
+ sampleSeed: args.prepared.metadata.eval_sample_seed,
455
+ });
456
+ }
457
+ async function runScoreStage(args) {
458
+ if (!await pathExists(args.prepared.artifacts.baselineEvalJson)) {
459
+ throw new Error("score stage requires baseline-eval.json. Run --stage baseline first.");
460
+ }
461
+ if (!await pathExists(args.prepared.artifacts.candidateEvalJson)) {
462
+ throw new Error("score stage requires candidate-eval.json. Run --stage candidate first.");
463
+ }
464
+ await updateRun({
465
+ store: args.store,
466
+ reporter: args.reporter,
467
+ request: args.prepared.request,
468
+ status: "scoring",
469
+ stage: "scoring",
470
+ message: "Rescoring existing baseline and candidate outputs.",
471
+ details: { scoring_mode: args.config.evaluation.scoring.mode },
472
+ });
473
+ const baseline = await rescoreEvalReport({
474
+ report: evalReportSchema.parse(await readJson(args.prepared.artifacts.baselineEvalJson)),
475
+ config: args.config,
476
+ outputPath: args.prepared.artifacts.baselineEvalJson,
477
+ system: args.prepared.system,
478
+ reporter: args.runReporter,
479
+ });
480
+ const candidate = await rescoreEvalReport({
481
+ report: evalReportSchema.parse(await readJson(args.prepared.artifacts.candidateEvalJson)),
482
+ config: args.config,
483
+ outputPath: args.prepared.artifacts.candidateEvalJson,
484
+ system: args.prepared.system,
485
+ reporter: args.runReporter,
486
+ });
487
+ return { baseline, candidate };
488
+ }
489
+ async function runReportStage(args) {
490
+ if (!await pathExists(args.prepared.artifacts.baselineEvalJson)) {
491
+ throw new Error("report stage requires baseline-eval.json. Run --stage baseline first.");
492
+ }
493
+ if (!await pathExists(args.prepared.artifacts.candidateEvalJson)) {
494
+ throw new Error("report stage requires candidate-eval.json. Run --stage candidate first.");
495
+ }
496
+ if (!await pathExists(args.prepared.artifacts.trainingReportJson)) {
497
+ throw new Error("report stage requires training-report.json. Run --stage train first, or --stage candidate --model-artifact <path>.");
498
+ }
499
+ if (args.emitReportingEvent) {
500
+ await updateRun({
501
+ store: args.store,
502
+ reporter: args.reporter,
503
+ request: args.prepared.request,
504
+ status: "reporting",
505
+ stage: "reporting",
506
+ message: "Writing run report.",
507
+ details: { report_path: args.prepared.artifacts.runReportJson },
230
508
  });
231
- await writeJson(artifacts.runReportJson, report);
232
- await store.completeRun(report, artifacts.runDir, artifacts.runReportJson);
233
- await reporter?.onEvent?.({
234
- stage: "completed",
235
- status: "completed",
236
- message: "Run completed successfully.",
237
- details: {
238
- report_path: artifacts.runReportJson,
239
- model_id: `local-${request.run_id}`,
240
- avg_score_delta: comparison.avg_score_delta,
241
- elapsed_seconds: duration.seconds,
242
- },
509
+ }
510
+ const baseline = evalReportSchema.parse(await readJson(args.prepared.artifacts.baselineEvalJson));
511
+ const candidate = evalReportSchema.parse(await readJson(args.prepared.artifacts.candidateEvalJson));
512
+ const training = trainingReportSchema.parse(await readJson(args.prepared.artifacts.trainingReportJson));
513
+ const comparison = compareEvalReports(baseline, candidate);
514
+ const completedAt = new Date().toISOString();
515
+ const duration = elapsed(args.startedPerf);
516
+ const report = runReportSchema.parse({
517
+ run_id: args.prepared.request.run_id,
518
+ behavior_spec_id: args.prepared.request.behavior_spec_id,
519
+ user_id: args.prepared.request.user_id,
520
+ run_number: args.prepared.request.run_number,
521
+ base_model: args.prepared.request.spec_snapshot.base_model,
522
+ fine_tuned_model_id: training.model_artifact_uri ?? training.training_job_name,
523
+ status: "completed",
524
+ baseline,
525
+ candidate,
526
+ comparison,
527
+ training,
528
+ artifact_uris: {
529
+ dataset: fileUri(args.prepared.artifacts.trainingJsonl),
530
+ baseline_eval: fileUri(args.prepared.artifacts.baselineEvalJson),
531
+ candidate_eval: fileUri(args.prepared.artifacts.candidateEvalJson),
532
+ report: fileUri(args.prepared.artifacts.runReportJson),
533
+ },
534
+ run_metadata: {
535
+ base_model: args.prepared.request.spec_snapshot.base_model,
536
+ fine_tuned_model_id: training.model_artifact_uri ?? training.training_job_name,
537
+ training_method: args.prepared.request.training_method,
538
+ dataset_prebuilt: args.prepared.metadata.dataset_prebuilt,
539
+ dataset_format: args.prepared.metadata.dataset_format,
540
+ dataset_uri: fileUri(args.prepared.artifacts.trainingJsonl),
541
+ spec_example_count: args.prepared.request.spec_snapshot.examples.length,
542
+ training_example_count: args.prepared.metadata.training_example_count,
543
+ eval_examples_total: baseline.eval_examples_total,
544
+ eval_examples_used: baseline.eval_examples_used,
545
+ eval_split: baseline.eval_split,
546
+ eval_sample_seed: baseline.eval_sample_seed ?? null,
547
+ started_at: args.startedAt,
548
+ completed_at: completedAt,
549
+ elapsed_ms: duration.ms,
550
+ elapsed_seconds: duration.seconds,
551
+ },
552
+ created_at: completedAt,
553
+ });
554
+ await writeJson(args.prepared.artifacts.runReportJson, report);
555
+ await args.store.completeRun(report, args.prepared.artifacts.runDir, args.prepared.artifacts.runReportJson);
556
+ await args.reporter?.onEvent?.({
557
+ stage: "completed",
558
+ status: "completed",
559
+ message: "Run completed successfully.",
560
+ details: {
561
+ report_path: args.prepared.artifacts.runReportJson,
562
+ model_id: `local-${args.prepared.request.run_id}`,
563
+ avg_score_delta: comparison.avg_score_delta,
564
+ elapsed_seconds: duration.seconds,
565
+ },
566
+ });
567
+ return report;
568
+ }
569
+ export async function runLocalFineTuneStage(input) {
570
+ const startedPerf = performance.now();
571
+ const startedAt = new Date().toISOString();
572
+ const stage = input.stage ?? "all";
573
+ const prefix = artifactPrefix(input.request);
574
+ const artifacts = resolveRunArtifacts({ artifactRoot: input.config.artifactRoot, prefix });
575
+ const store = createLocalStore(input.config.storeRoot);
576
+ await ensureRunRecord({ request: input.request, artifacts, store, reporter: input.reporter });
577
+ const runReporter = createStoreReporter({ request: input.request, store, reporter: input.reporter });
578
+ try {
579
+ const prepared = await ensurePrepared({
580
+ request: input.request,
581
+ config: input.config,
582
+ artifacts,
583
+ store,
584
+ reporter: input.reporter,
585
+ forcePrepare: Boolean(input.force && (stage === "prepare" || stage === "all")),
243
586
  });
244
- return {
245
- request,
246
- report,
247
- reportPath: artifacts.runReportJson,
248
- artifactDir: dirname(artifacts.runReportJson),
249
- };
587
+ let report;
588
+ if (stage === "prepare") {
589
+ return stageResult({ request: input.request, stage, artifacts });
590
+ }
591
+ if (stage === "baseline" || stage === "all") {
592
+ await runBaselineStage({
593
+ prepared,
594
+ config: input.config,
595
+ store,
596
+ reporter: input.reporter,
597
+ runReporter,
598
+ force: Boolean(input.force),
599
+ });
600
+ if (stage === "baseline")
601
+ return stageResult({ request: input.request, stage, artifacts });
602
+ }
603
+ if (stage === "train" || stage === "all") {
604
+ await runTrainStage({
605
+ prepared,
606
+ config: input.config,
607
+ store,
608
+ reporter: input.reporter,
609
+ runReporter,
610
+ force: Boolean(input.force),
611
+ });
612
+ if (stage === "train")
613
+ return stageResult({ request: input.request, stage, artifacts });
614
+ }
615
+ if (stage === "candidate" || stage === "all") {
616
+ await runCandidateStage({
617
+ prepared,
618
+ config: input.config,
619
+ store,
620
+ reporter: input.reporter,
621
+ runReporter,
622
+ force: Boolean(input.force),
623
+ modelArtifact: input.modelArtifact,
624
+ });
625
+ if (stage === "candidate")
626
+ return stageResult({ request: input.request, stage, artifacts });
627
+ }
628
+ if (stage === "score") {
629
+ await runScoreStage({
630
+ prepared,
631
+ config: input.config,
632
+ store,
633
+ reporter: input.reporter,
634
+ runReporter,
635
+ });
636
+ return stageResult({ request: input.request, stage, artifacts });
637
+ }
638
+ if (stage === "report" || stage === "all") {
639
+ report = await runReportStage({
640
+ prepared,
641
+ store,
642
+ reporter: input.reporter,
643
+ startedAt,
644
+ startedPerf,
645
+ emitReportingEvent: stage === "report",
646
+ });
647
+ return stageResult({ request: input.request, stage, artifacts, report });
648
+ }
649
+ throw new Error(`Unknown run stage: ${stage}`);
250
650
  }
251
651
  catch (error) {
252
- await store.failRun(request.run_id, error instanceof Error ? error.message : String(error)).catch(() => undefined);
253
- await reporter?.onEvent?.({
652
+ await store.failRun(input.request.run_id, error instanceof Error ? error.message : String(error)).catch(() => undefined);
653
+ await input.reporter?.onEvent?.({
254
654
  stage: "failed",
255
655
  status: "failed",
256
656
  message: error instanceof Error ? error.message : String(error),
@@ -258,4 +658,38 @@ export async function runLocalFineTune(input) {
258
658
  throw error;
259
659
  }
260
660
  }
661
+ function stageResult(args) {
662
+ return {
663
+ request: args.request,
664
+ stage: args.stage,
665
+ report: args.report,
666
+ reportPath: args.report ? args.artifacts.runReportJson : undefined,
667
+ artifactDir: dirname(args.artifacts.runReportJson),
668
+ artifacts: {
669
+ training_jsonl: args.artifacts.trainingJsonl,
670
+ stage_metadata: args.artifacts.stageMetadataJson,
671
+ training_report: args.artifacts.trainingReportJson,
672
+ baseline_eval: args.artifacts.baselineEvalJson,
673
+ candidate_eval: args.artifacts.candidateEvalJson,
674
+ report: args.artifacts.runReportJson,
675
+ },
676
+ };
677
+ }
678
+ export async function runLocalFineTune(input) {
679
+ const result = await runLocalFineTuneStage({
680
+ request: input.request,
681
+ config: input.config,
682
+ reporter: input.reporter,
683
+ stage: "all",
684
+ });
685
+ if (!result.report || !result.reportPath) {
686
+ throw new Error("Full local fine-tune run did not produce a report.");
687
+ }
688
+ return {
689
+ request: result.request,
690
+ report: result.report,
691
+ reportPath: result.reportPath,
692
+ artifactDir: result.artifactDir,
693
+ };
694
+ }
261
695
  //# sourceMappingURL=orchestrator.js.map