@tuned-tensor/local 0.2.1 → 0.2.3

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