@deepthonk/core 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runner.js CHANGED
@@ -2,19 +2,19 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { z } from "zod";
5
- import { fitBradleyTerry } from "./bradleyTerry.js";
5
+ import { canonicalizeComparisons, fitBradleyTerry } from "./bradleyTerry.js";
6
6
  import { BudgetTracker } from "./budgetTracker.js";
7
+ import { readValidatedPhaseReceiptUsage, runCheckpointedPhase, traceSchemaVersion, validatePhaseCommit, validatePhaseCommitIfPresent, writePhaseCommit } from "./checkpointStore.js";
7
8
  import { planBudget, validateProfile } from "./budget.js";
8
9
  import { BudgetExceededError, CancelledError, ConfigError, DeepThonkError } from "./errors.js";
9
10
  import { runArtifactFiles } from "./artifacts.js";
10
11
  import { parseJsonObject } from "./json.js";
11
12
  import { claimRunLockOwnership, emptyUsage, releaseRunLock, verifyRunLockClaim } from "./lifecycle.js";
12
13
  import { makeKRegularPairs } from "./pairScheduler.js";
13
- import { runLimitedPhase } from "./phaseRunner.js";
14
14
  import { comparePrompt, finalizePrompt, generatePrompt, mutatePrompt } from "./prompts.js";
15
15
  import { createRng } from "./rng.js";
16
- import { assertNoPruneInProgress, assertNoLegacyRedactedBudget, buildPopulationMap, buildResumePlan, groupComparisons, persistPrunedTrace, pruneTraceToPlan, readResumeTrace, reconstructScores, replayBudgetUsage, resolveResumeRunId, resumeConfigError, toResumePlanStatus } from "./resume.js";
17
- import { runConfigSchema, builtInProfiles, phaseCompletedEventSchema } from "./schemas.js";
16
+ import { assertNoPruneInProgress, assertNoLegacyRedactedBudget, applyUsageRecords, buildPopulationMap, buildResumePlan, groupComparisons, persistPrunedTrace, pruneTraceToPlan, readResumeTrace, reconstructScores, replayBudgetUsage, resolveResumeRunId, resumeConfigError, toResumePlanStatus, validateUsageRecords, validatedReceiptUsageIds } from "./resume.js";
17
+ import { runConfigSchema, resolveRunConfigDefaults, builtInProfiles, phaseCompletedEventSchema } from "./schemas.js";
18
18
  import { aggregateCritiques } from "./critique.js";
19
19
  import { TraceStore } from "./traceStore.js";
20
20
  const compareOutputSchema = z.object({
@@ -27,7 +27,7 @@ const compareOutputSchema = z.object({
27
27
  selection_reason: z.string().default("")
28
28
  });
29
29
  export async function runDeepThonk(configInput, driver, control = {}, resumeState) {
30
- const config = runConfigSchema.parse(configInput);
30
+ const config = resolveRunConfigDefaults(runConfigSchema.parse(configInput));
31
31
  validateProfile(config.profile);
32
32
  enforceBudget(config);
33
33
  const runId = resumeState?.runId ??
@@ -81,7 +81,7 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
81
81
  });
82
82
  };
83
83
  const assertNotCancelled = async (phase) => {
84
- if (await control.shouldCancel?.()) {
84
+ if (control.signal?.aborted || (await control.shouldCancel?.())) {
85
85
  throw new CancelledError(`Run cancelled before ${phase}.`, {
86
86
  code: "run.cancelled",
87
87
  retryable: false,
@@ -134,9 +134,13 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
134
134
  }
135
135
  else {
136
136
  await assertNotCancelled("initial population");
137
- population = await generateInitialPopulation(config, driver, trace, rng, runId, tracker, guards);
137
+ population = await generateInitialPopulation(config, driver, trace, rng, runId, tracker, guards, control.signal);
138
138
  await trace.writePopulation(0, population);
139
139
  await assertBudget("initial population");
140
+ await writePhaseCommit(config.runDir, "initial_generation", {
141
+ population,
142
+ usage: await phaseUsageRows(trace, "initial_generation")
143
+ });
140
144
  await markPhaseCompleted(trace, "initial_generation");
141
145
  await writeStatus("running", "population_completed", { generation: 0 });
142
146
  }
@@ -166,11 +170,17 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
166
170
  pairs,
167
171
  population,
168
172
  tracker,
169
- guards
173
+ guards,
174
+ signal: control.signal
170
175
  });
171
176
  scores = fitBradleyTerry(population, comparisons, config.profile.lambda, gen);
172
177
  await trace.writeScores(gen, scores);
173
178
  await assertBudget(`generation ${gen} comparisons`);
179
+ await writePhaseCommit(config.runDir, `generation_judging:${gen}`, {
180
+ comparisons,
181
+ scores,
182
+ usage: await phaseUsageRows(trace, `generation_judging:${gen}`)
183
+ });
174
184
  await markPhaseCompleted(trace, "generation_judging", gen);
175
185
  }
176
186
  const ranked = rankPopulation(population, scores);
@@ -178,7 +188,9 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
178
188
  const discarded = bottomQuartile(ranked);
179
189
  const survivors = ranked.filter((candidate) => !discarded.has(candidate.id));
180
190
  const mutationParents = survivors.slice(0, config.profile.n - elites.length);
181
- const critiquesByCandidate = aggregateCritiques(population, comparisons);
191
+ const critiquesByCandidate = aggregateCritiques(population, comparisons, {
192
+ aggregateChars: config.critiqueLimits.aggregateChars
193
+ });
182
194
  await assertNotCancelled(`generation ${gen} mutation`);
183
195
  await writeStatus("running", "generation_mutation", { generation: gen });
184
196
  await traceDiscardedCandidates(trace, ranked, new Set([...elites, ...mutationParents].map((candidate) => candidate.id)), discarded, gen);
@@ -190,7 +202,8 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
190
202
  survivors: mutationParents,
191
203
  critiquesByCandidate,
192
204
  tracker,
193
- guards
205
+ guards,
206
+ signal: control.signal
194
207
  });
195
208
  const eliteCopies = copyElites(elites, gen);
196
209
  for (const elite of eliteCopies) {
@@ -200,6 +213,10 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
200
213
  population = keepPopulationSize([...eliteCopies, ...mutants], config.profile.n);
201
214
  await trace.writePopulation(gen, population);
202
215
  await assertBudget(`generation ${gen} mutation`);
216
+ await writePhaseCommit(config.runDir, `generation_mutation:${gen}`, {
217
+ population,
218
+ usage: await phaseUsageRows(trace, `generation_mutation:${gen}`)
219
+ });
203
220
  await markPhaseCompleted(trace, "generation_mutation", gen);
204
221
  await writeStatus("running", "generation_completed", { generation: gen });
205
222
  }
@@ -212,8 +229,12 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
212
229
  else {
213
230
  await assertNotCancelled("final ranking");
214
231
  await writeStatus("running", "final_ranking", { generation: "final" });
215
- const finalRng = phaseRng(config.seed, "final_judging");
216
- const finalPairs = makeKRegularPairs(population.map((candidate) => candidate.id), config.profile.m, finalRng);
232
+ const finalRng = config.rank
233
+ ? createRng(config.rank.seed ?? hashSeed(`${config.seed}:final_judging:`))
234
+ : phaseRng(config.seed, "final_judging");
235
+ const finalPairs = config.rank
236
+ ? configuredFinalPairs(population.map((candidate) => candidate.id), config.rank, finalRng)
237
+ : makeKRegularPairs(population.map((candidate) => candidate.id), config.profile.m, finalRng);
217
238
  finalComparisons = await judgePairs({
218
239
  config,
219
240
  driver,
@@ -224,11 +245,17 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
224
245
  pairs: finalPairs,
225
246
  population,
226
247
  tracker,
227
- guards
248
+ guards,
249
+ signal: control.signal
228
250
  });
229
251
  finalScores = fitBradleyTerry(population, finalComparisons, config.profile.lambda, "final");
230
252
  await trace.writeScores("final", finalScores);
231
253
  await assertBudget("final ranking");
254
+ await writePhaseCommit(config.runDir, "final_judging", {
255
+ comparisons: finalComparisons,
256
+ scores: finalScores,
257
+ usage: await phaseUsageRows(trace, "final_judging")
258
+ });
232
259
  await markPhaseCompleted(trace, "final_judging");
233
260
  }
234
261
  const winner = rankPopulation(population, finalScores)[0];
@@ -236,7 +263,7 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
236
263
  throw new ConfigError("Run produced no winner.");
237
264
  await assertNotCancelled("finalization");
238
265
  await writeStatus("running", "finalizing", { generation: "final" });
239
- const finalAnswer = await maybeFinalize(winner, config, driver, trace, tracker, guards);
266
+ const finalAnswer = await maybeFinalize(winner, config, driver, trace, tracker, guards, control.signal);
240
267
  await assertBudget("finalization");
241
268
  const completedAt = new Date().toISOString();
242
269
  const summary = {
@@ -255,6 +282,12 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
255
282
  completed_at: completedAt
256
283
  };
257
284
  await trace.writeSummary(summary, finalAnswer, winner.content);
285
+ await writePhaseCommit(config.runDir, "finalizing", {
286
+ summary,
287
+ finalAnswer,
288
+ winner: winner.content,
289
+ usage: await phaseUsageRows(trace, "finalizing")
290
+ });
258
291
  await markPhaseCompleted(trace, "finalizing");
259
292
  await trace.event({ type: "run.completed", winner_id: winner.id, completed_at: completedAt });
260
293
  if (resumeState)
@@ -278,9 +311,19 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
278
311
  throw error;
279
312
  }
280
313
  finally {
281
- if (ownsLock) {
314
+ let closeError;
315
+ try {
316
+ await trace.close();
317
+ }
318
+ catch (error) {
319
+ if (caughtError !== undefined)
320
+ process.stderr.write(`deepthonk: failed to close trace handles: ${error.message}\n`);
321
+ else
322
+ closeError = error;
323
+ }
324
+ if (ownsLock || verifiedPreclaim) {
282
325
  try {
283
- await releaseRunLock(config.runDir, ownedLockClaimId);
326
+ await releaseRunLock(config.runDir, ownsLock ? ownedLockClaimId : control.lockClaim?.claimId);
284
327
  }
285
328
  catch (error) {
286
329
  if (caughtError !== undefined) {
@@ -291,13 +334,11 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
291
334
  }
292
335
  }
293
336
  }
337
+ if (closeError !== undefined)
338
+ throw closeError;
294
339
  }
295
340
  }
296
341
  export async function resumeDeepThonk(runDir, driver, options = {}) {
297
- const existingSummary = await readOptionalJson(runDir, runArtifactFiles.summary);
298
- if (existingSummary) {
299
- resumeConfigError("Run already has summary.json; nothing to resume.", "resume.already_complete", "Use deepthonk inspect/result to read the completed run.");
300
- }
301
342
  const rawConfig = await readRequiredConfig(runDir);
302
343
  assertNoLegacyRedactedBudget(rawConfig);
303
344
  const currentVersion = await currentPackageVersion();
@@ -306,7 +347,7 @@ export async function resumeDeepThonk(runDir, driver, options = {}) {
306
347
  }
307
348
  assertStoredOutputConfigComplete(rawConfig);
308
349
  const parsedConfig = runConfigSchema.parse(rawConfig);
309
- const config = { ...parsedConfig, runDir };
350
+ const config = resolveRunConfigDefaults({ ...parsedConfig, runDir });
310
351
  validateProfile(config.profile);
311
352
  enforceBudget(config);
312
353
  assertResumeProviderMatches(rawConfig, config, driver, options);
@@ -321,6 +362,7 @@ export async function resumeDeepThonk(runDir, driver, options = {}) {
321
362
  });
322
363
  }
323
364
  try {
365
+ const existingSummary = await readOptionalFinalSummary(runDir, rawConfig.traceSchemaVersion === traceSchemaVersion);
324
366
  const status = await readOptionalJson(runDir, runArtifactFiles.status);
325
367
  if ((status?.state === "running" || status?.state === "pending") && isLiveWorker(status.worker_pid)) {
326
368
  resumeConfigError(`Run is still in flight at phase ${status.phase}.`, "resume.in_flight", "Wait for the worker to finish, or cancel/stop it before resuming.");
@@ -328,15 +370,39 @@ export async function resumeDeepThonk(runDir, driver, options = {}) {
328
370
  const trace = await readResumeTrace(runDir);
329
371
  const runId = resolveResumeRunId(config, status, trace.events);
330
372
  const plan = buildResumePlan(config, trace.events);
331
- if (plan.nextPhase.phase === "summary") {
332
- resumeConfigError("Trace says finalizing completed, but summary.json is missing.", "resume.inconsistent_trace", "Inspect the run directory and restore summary.json, or start a fresh run.");
373
+ if (existingSummary && rawConfig.traceSchemaVersion !== traceSchemaVersion) {
374
+ resumeConfigError("Run already has summary.json; nothing to resume.", "resume.already_complete", "Use deepthonk inspect/result to read the completed run.");
375
+ }
376
+ if (existingSummary &&
377
+ rawConfig.traceSchemaVersion === traceSchemaVersion &&
378
+ plan.nextPhase.phase !== "finalizing" &&
379
+ plan.nextPhase.phase !== "summary") {
380
+ resumeConfigError(`summary.json exists before the finalizing phase boundary (${plan.nextPhase.phase}).`, "resume.trace_v2_integrity", "Restore the matching finalization artifacts or start a fresh run.");
333
381
  }
334
382
  const planStatus = toResumePlanStatus(runDir, runId, plan);
335
- const pruned = pruneTraceToPlan(trace, plan);
383
+ let receiptUsageIds;
384
+ if (rawConfig.traceSchemaVersion === traceSchemaVersion) {
385
+ const receiptUsage = await readValidatedPhaseReceiptUsage(runDir, phaseCursorKey(plan.nextPhase));
386
+ receiptUsageIds = validatedReceiptUsageIds(receiptUsage);
387
+ }
388
+ const pruned = pruneTraceToPlan(trace, plan, { dropUsageIds: receiptUsageIds });
336
389
  assertResumeRunIdsAgree(runId, config, status, pruned);
337
390
  const populationByGeneration = buildPopulationMap(config, pruned.populations, pruned.candidates, plan);
338
391
  const comparisonsByGeneration = groupComparisons(pruned.comparisons);
339
392
  const scoresByGeneration = reconstructScores(config, populationByGeneration, comparisonsByGeneration, pruned.scores, plan, runId);
393
+ validateUsageRecords(pruned.usage);
394
+ if (rawConfig.traceSchemaVersion === traceSchemaVersion) {
395
+ await validateTraceV2CompletedPhases(runDir, config, plan.completed, populationByGeneration, comparisonsByGeneration, scoresByGeneration, pruned.usage);
396
+ if (plan.nextPhase.phase === "finalizing" && !plan.completed.has("finalizing")) {
397
+ await validatePhaseCommitIfPresent(runDir, "finalizing", await finalizingArtifacts(runDir, trace.usage));
398
+ }
399
+ }
400
+ if (plan.nextPhase.phase === "summary") {
401
+ if (existingSummary) {
402
+ resumeConfigError("Run already has a validated summary.json and finalizing commit; nothing to resume.", "resume.already_complete", "Use deepthonk inspect/result to read the completed run.");
403
+ }
404
+ resumeConfigError("Trace says finalizing completed, but summary.json is missing or invalid.", rawConfig.traceSchemaVersion === traceSchemaVersion ? "resume.trace_v2_integrity" : "resume.inconsistent_trace", "Inspect the run directory and restore the matching finalization artifacts, or start a fresh run.");
405
+ }
340
406
  if (options.dryRun)
341
407
  return planStatus;
342
408
  const tracker = replayBudgetUsage(config, pruned.usage);
@@ -418,7 +484,7 @@ function resumeComparisons(resumeState, generation) {
418
484
  fix: "Inspect the run directory for missing comparison trace rows."
419
485
  });
420
486
  }
421
- return comparisons;
487
+ return canonicalizeComparisons(comparisons);
422
488
  }
423
489
  function resumeScores(resumeState, generation, population, comparisons, lambda) {
424
490
  return resumeState?.scoresByGeneration.get(generation) ?? fitBradleyTerry(population, comparisons, lambda, generation);
@@ -439,6 +505,13 @@ function resumePhaseKey(phase, generation) {
439
505
  return `${phase}:${generation}`;
440
506
  return phase;
441
507
  }
508
+ function phaseCursorKey(cursor) {
509
+ if ((cursor.phase === "generation_judging" || cursor.phase === "generation_mutation") &&
510
+ typeof cursor.generation === "number") {
511
+ return `${cursor.phase}:${cursor.generation}`;
512
+ }
513
+ return cursor.phase;
514
+ }
442
515
  function providerLabel(driver) {
443
516
  const value = driver;
444
517
  if (typeof value.provider === "string")
@@ -670,6 +743,20 @@ async function readRequiredConfig(runDir) {
670
743
  }
671
744
  return config;
672
745
  }
746
+ async function readOptionalFinalSummary(runDir, traceV2) {
747
+ try {
748
+ return await readOptionalJson(runDir, runArtifactFiles.summary);
749
+ }
750
+ catch (error) {
751
+ if (!traceV2)
752
+ throw error;
753
+ throw new ConfigError(`Could not parse summary.json: ${error.message}`, {
754
+ code: "resume.trace_v2_integrity",
755
+ retryable: false,
756
+ fix: "Restore summary.json from the same completed run, or resume from the pre-finalization artifacts."
757
+ });
758
+ }
759
+ }
673
760
  async function readOptionalJson(runDir, fileName) {
674
761
  try {
675
762
  return JSON.parse(await readFile(join(runDir, fileName), "utf8"));
@@ -680,6 +767,16 @@ async function readOptionalJson(runDir, fileName) {
680
767
  throw error;
681
768
  }
682
769
  }
770
+ async function readOptionalText(runDir, fileName) {
771
+ try {
772
+ return await readFile(join(runDir, fileName), "utf8");
773
+ }
774
+ catch (error) {
775
+ if (error.code === "ENOENT")
776
+ return undefined;
777
+ throw error;
778
+ }
779
+ }
683
780
  function isLiveWorker(pid) {
684
781
  if (!pid)
685
782
  return true;
@@ -713,10 +810,81 @@ export function copyElites(elites, generation) {
713
810
  status: "elite",
714
811
  metadata: {
715
812
  ...candidate.metadata,
716
- createdAt: new Date().toISOString()
813
+ createdAt: candidate.metadata.createdAt
717
814
  }
718
815
  }));
719
816
  }
817
+ async function validateTraceV2CompletedPhases(runDir, config, completed, populations, comparisons, scores, usage) {
818
+ if (completed.has("initial_generation")) {
819
+ await validatePhaseCommit(runDir, "initial_generation", {
820
+ population: populations.get(0),
821
+ usage: usage.filter((record) => usagePhaseKey(record.phase) === "initial_generation")
822
+ });
823
+ }
824
+ for (let generation = 1; generation <= config.profile.t; generation += 1) {
825
+ if (completed.has(`generation_judging:${generation}`)) {
826
+ const phaseKey = `generation_judging:${generation}`;
827
+ await validatePhaseCommit(runDir, phaseKey, {
828
+ comparisons: comparisons.get(generation),
829
+ scores: scores.get(generation),
830
+ usage: usage.filter((record) => usagePhaseKey(record.phase) === phaseKey)
831
+ });
832
+ }
833
+ if (completed.has(`generation_mutation:${generation}`)) {
834
+ const phaseKey = `generation_mutation:${generation}`;
835
+ await validatePhaseCommit(runDir, phaseKey, {
836
+ population: populations.get(generation),
837
+ usage: usage.filter((record) => usagePhaseKey(record.phase) === phaseKey)
838
+ });
839
+ }
840
+ }
841
+ if (completed.has("final_judging")) {
842
+ await validatePhaseCommit(runDir, "final_judging", {
843
+ comparisons: comparisons.get("final"),
844
+ scores: scores.get("final"),
845
+ usage: usage.filter((record) => usagePhaseKey(record.phase) === "final_judging")
846
+ });
847
+ }
848
+ if (completed.has("finalizing")) {
849
+ await validatePhaseCommit(runDir, "finalizing", await finalizingArtifacts(runDir, usage));
850
+ }
851
+ }
852
+ async function finalizingArtifacts(runDir, usage) {
853
+ try {
854
+ return {
855
+ summary: await readOptionalJson(runDir, runArtifactFiles.summary),
856
+ finalAnswer: await readOptionalText(runDir, runArtifactFiles.final),
857
+ winner: await readOptionalText(runDir, runArtifactFiles.winner),
858
+ usage: usage.filter((record) => usagePhaseKey(record.phase) === "finalizing")
859
+ };
860
+ }
861
+ catch (error) {
862
+ throw new ConfigError(`Could not read finalization artifacts: ${error.message}`, {
863
+ code: "resume.trace_v2_integrity",
864
+ retryable: false,
865
+ fix: "Restore summary.json, final.txt, and winner.txt from the same completed run."
866
+ });
867
+ }
868
+ }
869
+ async function phaseUsageRows(trace, phaseKey) {
870
+ const usage = await trace.readJsonl(runArtifactFiles.usage).catch(() => []);
871
+ return usage.filter((record) => usagePhaseKey(record.phase) === phaseKey);
872
+ }
873
+ function usagePhaseKey(phase) {
874
+ if (phase === "gen0_initial")
875
+ return "initial_generation";
876
+ if (phase === "final_judge")
877
+ return "final_judging";
878
+ if (phase === "finalize")
879
+ return "finalizing";
880
+ const judge = /^gen(\d+)_judge$/.exec(phase);
881
+ if (judge)
882
+ return `generation_judging:${judge[1]}`;
883
+ const mutation = /^gen(\d+)_mutate$/.exec(phase);
884
+ if (mutation)
885
+ return `generation_mutation:${mutation[1]}`;
886
+ return undefined;
887
+ }
720
888
  export function keepPopulationSize(population, n) {
721
889
  return population.slice(0, n);
722
890
  }
@@ -737,60 +905,92 @@ async function traceDiscardedCandidates(trace, ranked, carriedIds, bottomQuartil
737
905
  await trace.event({ type: "candidate.discarded", candidate_id: candidate.id, generation, reason: discardReason });
738
906
  }
739
907
  }
740
- async function generateInitialPopulation(config, driver, trace, rng, runId, tracker, guards) {
908
+ async function generateInitialPopulation(config, driver, trace, rng, runId, tracker, guards, signal) {
741
909
  const jobs = Array.from({ length: config.profile.n }, (_, index) => ({
742
910
  index,
743
911
  id: `g0_c${index}_${rng.id("c")}`,
744
912
  prompt: generatePrompt(config.task, config.rubric, config.promptStyle, config.promptOverrides?.generate)
745
913
  }));
746
- const candidates = await runLimitedPhase(jobs.map((job) => async () => {
747
- const reservation = await guards.beforeCall("initial population call");
748
- let result;
749
- try {
750
- result = await driver.generate({
751
- task: config.task,
752
- rubric: config.rubric,
753
- model: config.generatorModel,
754
- temperature: config.profile.sampleTemperature,
755
- candidateIndex: job.index,
756
- prompt: job.prompt
757
- });
758
- }
759
- catch (error) {
760
- await recordFailedInvocation({
761
- tracker,
762
- reservation,
763
- trace,
764
- phase: "gen0_initial",
765
- role: "generator",
766
- provider: config.provider,
767
- model: config.generatorModel,
768
- error
769
- });
770
- throw error;
914
+ const promptRef = config.output.includePrompts && jobs[0] ? await trace.writePrompt(jobs[0].prompt) : undefined;
915
+ const candidates = await runCheckpointedPhase({
916
+ runDir: config.runDir,
917
+ phaseKey: "initial_generation",
918
+ jobs: jobs.map((job) => {
919
+ const receiptUsage = [];
920
+ return {
921
+ id: job.id,
922
+ input: {
923
+ task: config.task,
924
+ rubric: config.rubric,
925
+ model: config.generatorModel,
926
+ temperature: config.profile.sampleTemperature,
927
+ candidateIndex: job.index,
928
+ prompt: job.prompt,
929
+ maxOutputTokens: config.modelOutputTokens?.generation ?? 4_096
930
+ },
931
+ receiptUsage: () => receiptUsage,
932
+ run: async (phaseSignal) => {
933
+ const reservation = await guards.beforeCall("initial population call");
934
+ let result;
935
+ try {
936
+ result = await driver.generate({
937
+ task: config.task,
938
+ rubric: config.rubric,
939
+ model: config.generatorModel,
940
+ temperature: config.profile.sampleTemperature,
941
+ candidateIndex: job.index,
942
+ prompt: job.prompt,
943
+ signal: phaseSignal,
944
+ maxOutputTokens: config.modelOutputTokens?.generation ?? 4_096
945
+ });
946
+ }
947
+ catch (error) {
948
+ await recordFailedInvocation({
949
+ tracker,
950
+ reservation,
951
+ trace,
952
+ phase: "gen0_initial",
953
+ role: "generator",
954
+ provider: config.provider,
955
+ model: config.generatorModel,
956
+ error
957
+ });
958
+ throw error;
959
+ }
960
+ const delta = tracker.record(result, { provider: config.provider, model: config.generatorModel }, reservation);
961
+ const usageRecord = buildUsageRecord({
962
+ phase: "gen0_initial",
963
+ role: "generator",
964
+ result,
965
+ delta,
966
+ provider: result.provider ?? config.provider,
967
+ model: result.model ?? config.generatorModel
968
+ });
969
+ receiptUsage.push(usageRecord);
970
+ await trace.writeUsage(usageRecord);
971
+ await guards.afterCall("initial population call");
972
+ const candidate = {
973
+ id: job.id,
974
+ generation: 0,
975
+ kind: "initial",
976
+ content: result.text,
977
+ status: "generated",
978
+ metadata: resultMetadata(result, config, promptRef)
979
+ };
980
+ return candidate;
981
+ }
982
+ };
983
+ }),
984
+ concurrency: config.concurrency.generate,
985
+ signal,
986
+ validateOutput: isCandidateCheckpoint,
987
+ onResult: async (candidate, context) => {
988
+ if (context.reused)
989
+ await restoreCheckpointUsage(context.receiptUsage, tracker, trace);
990
+ await trace.writeCandidate(candidate);
991
+ await trace.event({ type: "candidate.generated", run_id: runId, candidate_id: candidate.id });
771
992
  }
772
- const delta = tracker.record(result, { provider: config.provider, model: config.generatorModel }, reservation);
773
- await trace.writeUsage(buildUsageRecord({
774
- phase: "gen0_initial",
775
- role: "generator",
776
- result,
777
- delta,
778
- provider: result.provider ?? config.provider,
779
- model: result.model ?? config.generatorModel
780
- }));
781
- await guards.afterCall("initial population call");
782
- const candidate = {
783
- id: job.id,
784
- generation: 0,
785
- kind: "initial",
786
- content: result.text,
787
- status: "generated",
788
- metadata: resultMetadata(result, config, job.prompt)
789
- };
790
- await trace.writeCandidate(candidate);
791
- await trace.event({ type: "candidate.generated", run_id: runId, candidate_id: candidate.id });
792
- return candidate;
793
- }), config.concurrency.generate);
993
+ });
794
994
  return candidates;
795
995
  }
796
996
  async function judgePairs(args) {
@@ -810,223 +1010,340 @@ async function judgePairs(args) {
810
1010
  presentedA,
811
1011
  presentedB,
812
1012
  id: `${args.generation}_cmp_${pairIndex}_${args.rng.id("cmp")}`,
813
- prompt: comparePrompt(args.config.task, presentedA, presentedB, args.config.rubric, args.config.promptStyle, args.config.promptOverrides?.compare)
1013
+ prompt: comparePrompt(args.config.task, presentedA, presentedB, args.config.rubric, args.config.promptStyle, args.config.promptOverrides?.compare),
1014
+ promptRef: undefined
814
1015
  };
815
1016
  });
816
- const comparisons = await runLimitedPhase(jobs.map((job) => async () => {
817
- let parsed;
818
- let rawOutput;
819
- let invalidJson = false;
820
- let resultModel;
821
- let resultProvider;
822
- let resultRetryCount = 0;
823
- let jsonParseFailures = 0;
824
- let inputTokens = 0;
825
- let inputCacheHitTokens = 0;
826
- let inputCacheMissTokens = 0;
827
- let outputTokens = 0;
828
- let totalTokens = 0;
829
- for (let attempt = 0; attempt <= args.config.retry.invalidJsonRetries; attempt += 1) {
830
- const reservation = await args.guards.beforeCall(`${args.generation} comparison call`);
831
- let result;
832
- try {
833
- result = await args.driver.compare({
1017
+ const phaseKey = args.generation === "final" ? "final_judging" : `generation_judging:${args.generation}`;
1018
+ const comparisons = await runCheckpointedPhase({
1019
+ runDir: args.config.runDir,
1020
+ phaseKey,
1021
+ jobs: jobs.map((job) => {
1022
+ const receiptUsage = [];
1023
+ return {
1024
+ id: job.id,
1025
+ input: {
834
1026
  task: args.config.task,
835
1027
  rubric: args.config.rubric,
836
1028
  model: args.config.judgeModel,
837
1029
  temperature: args.config.profile.judgeTemperature,
838
1030
  candidateA: job.presentedA,
839
1031
  candidateB: job.presentedB,
840
- prompt: job.prompt
841
- });
842
- }
843
- catch (error) {
844
- await recordFailedInvocation({
845
- tracker: args.tracker,
846
- reservation,
847
- trace: args.trace,
848
- phase: args.generation === "final" ? "final_judge" : `gen${args.generation}_judge`,
849
- role: "judge",
850
- provider: args.config.provider,
851
- model: args.config.judgeModel,
852
- error
853
- });
854
- throw error;
855
- }
856
- const delta = args.tracker.record(result, { provider: args.config.provider, model: args.config.judgeModel }, reservation);
857
- await args.trace.writeUsage(buildUsageRecord({
858
- phase: args.generation === "final" ? "final_judge" : `gen${args.generation}_judge`,
859
- role: "judge",
860
- result,
861
- delta,
862
- provider: result.provider ?? args.config.provider,
863
- model: result.model ?? args.config.judgeModel
864
- }));
865
- await args.guards.afterCall(`${args.generation} comparison call`);
866
- inputTokens += result.usage?.inputTokens ?? 0;
867
- inputCacheHitTokens += result.usage?.inputCacheHitTokens ?? 0;
868
- inputCacheMissTokens += result.usage?.inputCacheMissTokens ?? 0;
869
- outputTokens += result.usage?.outputTokens ?? 0;
870
- totalTokens += result.usage?.totalTokens ?? (result.usage?.inputTokens ?? 0) + (result.usage?.outputTokens ?? 0);
871
- rawOutput = args.config.output.includeRawModelOutputs ? result.raw ?? result.text : undefined;
872
- resultModel = result.model;
873
- resultProvider = result.provider;
874
- resultRetryCount += result.retryCount ?? 0;
875
- try {
876
- parsed = compareOutputSchema.parse(parseJsonObject(result.text));
877
- invalidJson = false;
878
- break;
879
- }
880
- catch {
881
- invalidJson = true;
882
- jsonParseFailures += 1;
883
- }
884
- }
885
- if (invalidJson || !parsed) {
886
- throw new ConfigError(`Judge produced ${jsonParseFailures} consecutive invalid-JSON responses (${args.config.retry.invalidJsonRetries + 1} attempts) for comparison ${job.id}. Refusing to synthesize a tie and pollute the ranking.`, {
887
- code: "judge.persistent_invalid_json",
888
- retryable: false,
889
- fix: "The judge model is producing unparseable output. Inspect the raw response (set output.includeRawModelOutputs: true), switch judge models, or raise retry.invalidJsonRetries if the failures are transient."
1032
+ prompt: job.prompt,
1033
+ invalidJsonRetries: args.config.retry.invalidJsonRetries,
1034
+ maxOutputTokens: args.config.modelOutputTokens?.judge ?? 1_024
1035
+ },
1036
+ receiptUsage: () => receiptUsage,
1037
+ run: async (phaseSignal) => {
1038
+ if (args.config.output.includePrompts && !job.promptRef)
1039
+ job.promptRef = await args.trace.writePrompt(job.prompt);
1040
+ let parsed;
1041
+ let rawOutput;
1042
+ let invalidJson = false;
1043
+ let resultModel;
1044
+ let resultProvider;
1045
+ let resultRetryCount = 0;
1046
+ let jsonParseFailures = 0;
1047
+ let inputTokens = 0;
1048
+ let inputCacheHitTokens = 0;
1049
+ let inputCacheMissTokens = 0;
1050
+ let outputTokens = 0;
1051
+ let totalTokens = 0;
1052
+ for (let attempt = 0; attempt <= args.config.retry.invalidJsonRetries; attempt += 1) {
1053
+ const reservation = await args.guards.beforeCall(`${args.generation} comparison call`);
1054
+ let result;
1055
+ try {
1056
+ result = await args.driver.compare({
1057
+ task: args.config.task,
1058
+ rubric: args.config.rubric,
1059
+ model: args.config.judgeModel,
1060
+ temperature: args.config.profile.judgeTemperature,
1061
+ candidateA: job.presentedA,
1062
+ candidateB: job.presentedB,
1063
+ prompt: job.prompt,
1064
+ signal: phaseSignal,
1065
+ maxOutputTokens: args.config.modelOutputTokens?.judge ?? 1_024
1066
+ });
1067
+ }
1068
+ catch (error) {
1069
+ await recordFailedInvocation({
1070
+ tracker: args.tracker,
1071
+ reservation,
1072
+ trace: args.trace,
1073
+ phase: args.generation === "final" ? "final_judge" : `gen${args.generation}_judge`,
1074
+ role: "judge",
1075
+ provider: args.config.provider,
1076
+ model: args.config.judgeModel,
1077
+ error
1078
+ });
1079
+ throw error;
1080
+ }
1081
+ const delta = args.tracker.record(result, { provider: args.config.provider, model: args.config.judgeModel }, reservation);
1082
+ const usageRecord = buildUsageRecord({
1083
+ phase: args.generation === "final" ? "final_judge" : `gen${args.generation}_judge`,
1084
+ role: "judge",
1085
+ result,
1086
+ delta,
1087
+ provider: result.provider ?? args.config.provider,
1088
+ model: result.model ?? args.config.judgeModel
1089
+ });
1090
+ receiptUsage.push(usageRecord);
1091
+ await args.trace.writeUsage(usageRecord);
1092
+ await args.guards.afterCall(`${args.generation} comparison call`);
1093
+ inputTokens += result.usage?.inputTokens ?? 0;
1094
+ inputCacheHitTokens += result.usage?.inputCacheHitTokens ?? 0;
1095
+ inputCacheMissTokens += result.usage?.inputCacheMissTokens ?? 0;
1096
+ outputTokens += result.usage?.outputTokens ?? 0;
1097
+ totalTokens += result.usage?.totalTokens ?? (result.usage?.inputTokens ?? 0) + (result.usage?.outputTokens ?? 0);
1098
+ rawOutput = args.config.output.includeRawModelOutputs ? result.raw ?? result.text : undefined;
1099
+ resultModel = result.model;
1100
+ resultProvider = result.provider;
1101
+ resultRetryCount += result.retryCount ?? 0;
1102
+ try {
1103
+ parsed = compareOutputSchema.parse(parseJsonObject(result.text));
1104
+ invalidJson = false;
1105
+ break;
1106
+ }
1107
+ catch {
1108
+ invalidJson = true;
1109
+ jsonParseFailures += 1;
1110
+ }
1111
+ }
1112
+ if (invalidJson || !parsed) {
1113
+ throw new ConfigError(`Judge produced ${jsonParseFailures} consecutive invalid-JSON responses (${args.config.retry.invalidJsonRetries + 1} attempts) for comparison ${job.id}. Refusing to synthesize a tie and pollute the ranking.`, {
1114
+ code: "judge.persistent_invalid_json",
1115
+ retryable: false,
1116
+ fix: "The judge model is producing unparseable output. Inspect the raw response (set output.includeRawModelOutputs: true), switch judge models, or raise retry.invalidJsonRetries if the failures are transient."
1117
+ });
1118
+ }
1119
+ const comparison = {
1120
+ id: job.id,
1121
+ runId: args.runId,
1122
+ generation: args.generation,
1123
+ candidateAId: job.presentedA.id,
1124
+ candidateBId: job.presentedB.id,
1125
+ presentedAOriginalId: job.presentedA.id,
1126
+ presentedBOriginalId: job.presentedB.id,
1127
+ winner: parsed.winner,
1128
+ confidence: parsed.confidence,
1129
+ critiqueForA: parsed.feedback_a ?? parsed.critique_for_A ?? "",
1130
+ critiqueForB: parsed.feedback_b ?? parsed.critique_for_B ?? "",
1131
+ selectionReason: parsed.selection_reason ?? "",
1132
+ rawOutput,
1133
+ model: resultModel,
1134
+ provider: resultProvider,
1135
+ metadata: compactMetadata({
1136
+ invalid_json_tie: invalidJson ? true : undefined,
1137
+ json_parse_failures: jsonParseFailures || undefined,
1138
+ model_call_count: jsonParseFailures + (parsed ? 1 : 0),
1139
+ input_tokens: inputTokens || undefined,
1140
+ input_cache_hit_tokens: inputCacheHitTokens || undefined,
1141
+ input_cache_miss_tokens: inputCacheMissTokens || undefined,
1142
+ output_tokens: outputTokens || undefined,
1143
+ total_tokens: totalTokens || undefined,
1144
+ scheduled_candidate_a_id: job.originalA.id,
1145
+ scheduled_candidate_b_id: job.originalB.id,
1146
+ provider_retry_count: resultRetryCount || undefined,
1147
+ promptRef: job.promptRef
1148
+ })
1149
+ };
1150
+ return comparison;
1151
+ }
1152
+ };
1153
+ }),
1154
+ concurrency: args.config.concurrency.judge,
1155
+ signal: args.signal,
1156
+ validateOutput: isComparisonCheckpoint,
1157
+ onResult: async (comparison, context) => {
1158
+ if (context.reused)
1159
+ await restoreCheckpointUsage(context.receiptUsage, args.tracker, args.trace);
1160
+ await args.trace.writeComparison(comparison);
1161
+ await args.trace.event({
1162
+ type: "comparison.completed",
1163
+ run_id: args.runId,
1164
+ comparison_id: comparison.id,
1165
+ generation: args.generation
890
1166
  });
891
1167
  }
892
- const comparison = {
893
- id: job.id,
894
- runId: args.runId,
895
- generation: args.generation,
896
- candidateAId: job.presentedA.id,
897
- candidateBId: job.presentedB.id,
898
- presentedAOriginalId: job.presentedA.id,
899
- presentedBOriginalId: job.presentedB.id,
900
- winner: parsed.winner,
901
- confidence: parsed.confidence,
902
- critiqueForA: parsed.feedback_a ?? parsed.critique_for_A ?? "",
903
- critiqueForB: parsed.feedback_b ?? parsed.critique_for_B ?? "",
904
- selectionReason: parsed.selection_reason ?? "",
905
- rawOutput,
906
- model: resultModel,
907
- provider: resultProvider,
908
- metadata: compactMetadata({
909
- invalid_json_tie: invalidJson ? true : undefined,
910
- json_parse_failures: jsonParseFailures || undefined,
911
- model_call_count: jsonParseFailures + (parsed ? 1 : 0),
912
- input_tokens: inputTokens || undefined,
913
- input_cache_hit_tokens: inputCacheHitTokens || undefined,
914
- input_cache_miss_tokens: inputCacheMissTokens || undefined,
915
- output_tokens: outputTokens || undefined,
916
- total_tokens: totalTokens || undefined,
917
- scheduled_candidate_a_id: job.originalA.id,
918
- scheduled_candidate_b_id: job.originalB.id,
919
- provider_retry_count: resultRetryCount || undefined,
920
- prompt: args.config.output.includePrompts ? job.prompt : undefined
921
- })
922
- };
923
- await args.trace.writeComparison(comparison);
924
- await args.trace.event({
925
- type: "comparison.completed",
926
- run_id: args.runId,
927
- comparison_id: comparison.id,
928
- generation: args.generation
929
- });
930
- return comparison;
931
- }), args.config.concurrency.judge);
1168
+ });
932
1169
  return comparisons;
933
1170
  }
934
1171
  async function mutateSurvivors(args) {
935
- const mutants = await runLimitedPhase(args.survivors.map((candidate, index) => async () => {
936
- const critique = args.critiquesByCandidate.get(candidate.id) ?? "";
937
- const prompt = mutatePrompt(args.config.task, candidate, critique, args.config.rubric, args.config.promptStyle, args.config.promptOverrides?.mutate);
938
- const reservation = await args.guards.beforeCall(`generation ${args.generation} mutation call`);
939
- let result;
940
- try {
941
- result = await args.driver.mutate({
942
- task: args.config.task,
943
- rubric: args.config.rubric,
944
- model: args.config.mutatorModel,
945
- temperature: args.config.profile.mutateTemperature,
946
- candidate,
947
- critique,
948
- prompt
949
- });
950
- }
951
- catch (error) {
952
- await recordFailedInvocation({
953
- tracker: args.tracker,
954
- reservation,
955
- trace: args.trace,
956
- phase: `gen${args.generation}_mutate`,
957
- role: "mutator",
958
- provider: args.config.provider,
959
- model: args.config.mutatorModel,
960
- error
961
- });
962
- throw error;
1172
+ const mutants = await runCheckpointedPhase({
1173
+ runDir: args.config.runDir,
1174
+ phaseKey: `generation_mutation:${args.generation}`,
1175
+ jobs: args.survivors.map((candidate, index) => {
1176
+ const critique = args.critiquesByCandidate.get(candidate.id) ?? "";
1177
+ const prompt = mutatePrompt(args.config.task, candidate, critique, args.config.rubric, args.config.promptStyle, args.config.promptOverrides?.mutate);
1178
+ const receiptUsage = [];
1179
+ let promptRef;
1180
+ return {
1181
+ id: `g${args.generation}_m${index}_${candidate.id}`,
1182
+ input: {
1183
+ task: args.config.task,
1184
+ rubric: args.config.rubric,
1185
+ model: args.config.mutatorModel,
1186
+ temperature: args.config.profile.mutateTemperature,
1187
+ candidate,
1188
+ critique,
1189
+ prompt,
1190
+ maxOutputTokens: args.config.modelOutputTokens?.mutation ?? 4_096
1191
+ },
1192
+ receiptUsage: () => receiptUsage,
1193
+ run: async (phaseSignal) => {
1194
+ if (args.config.output.includePrompts && !promptRef)
1195
+ promptRef = await args.trace.writePrompt(prompt);
1196
+ const reservation = await args.guards.beforeCall(`generation ${args.generation} mutation call`);
1197
+ let result;
1198
+ try {
1199
+ result = await args.driver.mutate({
1200
+ task: args.config.task,
1201
+ rubric: args.config.rubric,
1202
+ model: args.config.mutatorModel,
1203
+ temperature: args.config.profile.mutateTemperature,
1204
+ candidate,
1205
+ critique,
1206
+ prompt,
1207
+ signal: phaseSignal,
1208
+ maxOutputTokens: args.config.modelOutputTokens?.mutation ?? 4_096
1209
+ });
1210
+ }
1211
+ catch (error) {
1212
+ await recordFailedInvocation({
1213
+ tracker: args.tracker,
1214
+ reservation,
1215
+ trace: args.trace,
1216
+ phase: `gen${args.generation}_mutate`,
1217
+ role: "mutator",
1218
+ provider: args.config.provider,
1219
+ model: args.config.mutatorModel,
1220
+ error
1221
+ });
1222
+ throw error;
1223
+ }
1224
+ const delta = args.tracker.record(result, { provider: args.config.provider, model: args.config.mutatorModel }, reservation);
1225
+ const usageRecord = buildUsageRecord({
1226
+ phase: `gen${args.generation}_mutate`,
1227
+ role: "mutator",
1228
+ result,
1229
+ delta,
1230
+ provider: result.provider ?? args.config.provider,
1231
+ model: result.model ?? args.config.mutatorModel
1232
+ });
1233
+ receiptUsage.push(usageRecord);
1234
+ await args.trace.writeUsage(usageRecord);
1235
+ await args.guards.afterCall(`generation ${args.generation} mutation call`);
1236
+ const mutant = {
1237
+ id: `g${args.generation}_m${index}_${candidate.id}`,
1238
+ generation: args.generation,
1239
+ parentId: candidate.id,
1240
+ kind: "mutation",
1241
+ content: result.text,
1242
+ status: "mutated",
1243
+ metadata: resultMetadata(result, args.config, promptRef)
1244
+ };
1245
+ return mutant;
1246
+ }
1247
+ };
1248
+ }),
1249
+ concurrency: args.config.concurrency.mutate,
1250
+ signal: args.signal,
1251
+ validateOutput: isCandidateCheckpoint,
1252
+ onResult: async (mutant, context) => {
1253
+ if (context.reused)
1254
+ await restoreCheckpointUsage(context.receiptUsage, args.tracker, args.trace);
1255
+ await args.trace.writeCandidate(mutant);
1256
+ await args.trace.event({ type: "candidate.mutated", candidate_id: mutant.id, parent_id: mutant.parentId });
963
1257
  }
964
- const delta = args.tracker.record(result, { provider: args.config.provider, model: args.config.mutatorModel }, reservation);
965
- await args.trace.writeUsage(buildUsageRecord({
966
- phase: `gen${args.generation}_mutate`,
967
- role: "mutator",
968
- result,
969
- delta,
970
- provider: result.provider ?? args.config.provider,
971
- model: result.model ?? args.config.mutatorModel
972
- }));
973
- await args.guards.afterCall(`generation ${args.generation} mutation call`);
974
- const mutant = {
975
- id: `g${args.generation}_m${index}_${candidate.id}`,
976
- generation: args.generation,
977
- parentId: candidate.id,
978
- kind: "mutation",
979
- content: result.text,
980
- status: "mutated",
981
- metadata: resultMetadata(result, args.config, prompt)
982
- };
983
- await args.trace.writeCandidate(mutant);
984
- await args.trace.event({ type: "candidate.mutated", candidate_id: mutant.id, parent_id: mutant.parentId });
985
- return mutant;
986
- }), args.config.concurrency.mutate);
1258
+ });
987
1259
  return mutants;
988
1260
  }
989
- async function maybeFinalize(winner, config, driver, trace, tracker, guards) {
990
- if (!config.finalizerModel || !driver.finalize)
991
- return winner.content;
992
- const reservation = await guards.beforeCall("finalization call");
993
- let result;
994
- try {
995
- result = await driver.finalize({
996
- task: config.task,
997
- rubric: config.rubric,
998
- model: config.finalizerModel,
999
- candidate: winner,
1000
- prompt: finalizePrompt(config.task, winner, config.rubric, config.promptStyle, config.promptOverrides?.finalize)
1001
- });
1002
- }
1003
- catch (error) {
1004
- await recordFailedInvocation({
1005
- tracker,
1006
- reservation,
1007
- trace,
1008
- phase: "finalize",
1009
- role: "finalizer",
1010
- provider: config.provider,
1011
- model: config.finalizerModel,
1012
- error
1013
- });
1014
- throw error;
1015
- }
1016
- const delta = tracker.record(result, { provider: config.provider, model: config.finalizerModel }, reservation);
1017
- await trace.writeUsage(buildUsageRecord({
1018
- phase: "finalize",
1019
- role: "finalizer",
1020
- result,
1021
- delta,
1022
- provider: result.provider ?? config.provider,
1023
- model: result.model ?? config.finalizerModel
1024
- }));
1025
- await guards.afterCall("finalization call");
1026
- await trace.event({ type: "finalized", winner_id: winner.id, model: result.model, provider: result.provider });
1027
- return result.text;
1261
+ async function maybeFinalize(winner, config, driver, trace, tracker, guards, signal) {
1262
+ const enabled = Boolean(config.finalizerModel && driver.finalize);
1263
+ const prompt = finalizePrompt(config.task, winner, config.rubric, config.promptStyle, config.promptOverrides?.finalize);
1264
+ const promptRef = enabled && config.output.includePrompts ? await trace.writePrompt(prompt) : undefined;
1265
+ let finalModel;
1266
+ let finalProvider;
1267
+ const receiptUsage = [];
1268
+ const [finalized] = await runCheckpointedPhase({
1269
+ runDir: config.runDir,
1270
+ phaseKey: "finalizing",
1271
+ jobs: [
1272
+ {
1273
+ id: `finalizer:${winner.id}`,
1274
+ input: {
1275
+ enabled,
1276
+ task: config.task,
1277
+ rubric: config.rubric,
1278
+ model: config.finalizerModel,
1279
+ candidate: winner,
1280
+ prompt,
1281
+ maxOutputTokens: config.modelOutputTokens?.finalizer ?? 4_096
1282
+ },
1283
+ receiptUsage: () => receiptUsage,
1284
+ run: async (phaseSignal) => {
1285
+ if (!enabled || !config.finalizerModel || !driver.finalize)
1286
+ return { answer: winner.content, promptRef: undefined };
1287
+ const reservation = await guards.beforeCall("finalization call");
1288
+ let result;
1289
+ try {
1290
+ result = await driver.finalize({
1291
+ task: config.task,
1292
+ rubric: config.rubric,
1293
+ model: config.finalizerModel,
1294
+ candidate: winner,
1295
+ prompt,
1296
+ signal: phaseSignal,
1297
+ maxOutputTokens: config.modelOutputTokens?.finalizer ?? 4_096
1298
+ });
1299
+ }
1300
+ catch (error) {
1301
+ await recordFailedInvocation({
1302
+ tracker,
1303
+ reservation,
1304
+ trace,
1305
+ phase: "finalize",
1306
+ role: "finalizer",
1307
+ provider: config.provider,
1308
+ model: config.finalizerModel,
1309
+ error
1310
+ });
1311
+ throw error;
1312
+ }
1313
+ const delta = tracker.record(result, { provider: config.provider, model: config.finalizerModel }, reservation);
1314
+ const usageRecord = buildUsageRecord({
1315
+ phase: "finalize",
1316
+ role: "finalizer",
1317
+ result,
1318
+ delta,
1319
+ provider: result.provider ?? config.provider,
1320
+ model: result.model ?? config.finalizerModel
1321
+ });
1322
+ receiptUsage.push(usageRecord);
1323
+ await trace.writeUsage(usageRecord);
1324
+ await guards.afterCall("finalization call");
1325
+ finalModel = result.model;
1326
+ finalProvider = result.provider;
1327
+ return { answer: result.text, promptRef };
1328
+ }
1329
+ }
1330
+ ],
1331
+ concurrency: 1,
1332
+ signal,
1333
+ validateOutput: (output) => Boolean(output &&
1334
+ typeof output === "object" &&
1335
+ typeof output.answer === "string"),
1336
+ onResult: async (_output, context) => {
1337
+ if (context.reused)
1338
+ await restoreCheckpointUsage(context.receiptUsage, tracker, trace);
1339
+ if (enabled) {
1340
+ await trace.event({ type: "finalized", winner_id: winner.id, model: finalModel, provider: finalProvider, prompt_ref: promptRef });
1341
+ }
1342
+ }
1343
+ });
1344
+ return finalized.answer;
1028
1345
  }
1029
- function resultMetadata(result, config, prompt) {
1346
+ function resultMetadata(result, config, promptRef) {
1030
1347
  return compactMetadata({
1031
1348
  model: result.model,
1032
1349
  provider: result.provider,
@@ -1038,13 +1355,65 @@ function resultMetadata(result, config, prompt) {
1038
1355
  latencyMs: result.latencyMs,
1039
1356
  retryCount: result.retryCount,
1040
1357
  rawOutput: config.output.includeRawModelOutputs ? result.raw : undefined,
1041
- prompt: config.output.includePrompts ? prompt : undefined,
1358
+ promptRef: config.output.includePrompts ? promptRef : undefined,
1042
1359
  createdAt: new Date().toISOString()
1043
1360
  });
1044
1361
  }
1045
1362
  function compactMetadata(metadata) {
1046
1363
  return Object.fromEntries(Object.entries(metadata).filter(([, value]) => value !== undefined));
1047
1364
  }
1365
+ const restoredUsageByTracker = new WeakMap();
1366
+ async function restoreCheckpointUsage(usage, tracker, trace) {
1367
+ if (usage === undefined)
1368
+ return;
1369
+ if (!Array.isArray(usage)) {
1370
+ throw new ConfigError("Trace-v2 checkpoint usage must be an array of UsageRecord rows.", {
1371
+ code: "resume.usage_invalid",
1372
+ retryable: false
1373
+ });
1374
+ }
1375
+ const ids = validatedReceiptUsageIds(usage);
1376
+ const restored = restoredUsageByTracker.get(tracker) ?? new Set();
1377
+ for (const id of ids) {
1378
+ if (restored.has(id)) {
1379
+ throw new ConfigError(`Trace-v2 usage ${id} was restored more than once.`, {
1380
+ code: "resume.usage_invalid",
1381
+ retryable: false
1382
+ });
1383
+ }
1384
+ restored.add(id);
1385
+ }
1386
+ restoredUsageByTracker.set(tracker, restored);
1387
+ const records = usage;
1388
+ applyUsageRecords(tracker, records);
1389
+ for (const record of records)
1390
+ await trace.writeUsage(record);
1391
+ }
1392
+ function isCandidateCheckpoint(output) {
1393
+ if (!output || typeof output !== "object" || Array.isArray(output))
1394
+ return false;
1395
+ const candidate = output;
1396
+ return (typeof candidate.id === "string" &&
1397
+ candidate.id.length > 0 &&
1398
+ Number.isInteger(candidate.generation) &&
1399
+ typeof candidate.content === "string" &&
1400
+ typeof candidate.kind === "string" &&
1401
+ Boolean(candidate.metadata && typeof candidate.metadata === "object" && typeof candidate.metadata.createdAt === "string"));
1402
+ }
1403
+ function isComparisonCheckpoint(output) {
1404
+ if (!output || typeof output !== "object" || Array.isArray(output))
1405
+ return false;
1406
+ const comparison = output;
1407
+ return (typeof comparison.id === "string" &&
1408
+ typeof comparison.runId === "string" &&
1409
+ (typeof comparison.generation === "number" || comparison.generation === "final") &&
1410
+ typeof comparison.candidateAId === "string" &&
1411
+ typeof comparison.candidateBId === "string" &&
1412
+ (comparison.winner === "A" || comparison.winner === "B" || comparison.winner === "tie") &&
1413
+ typeof comparison.critiqueForA === "string" &&
1414
+ typeof comparison.critiqueForB === "string" &&
1415
+ typeof comparison.selectionReason === "string");
1416
+ }
1048
1417
  function summaryProfile(profile) {
1049
1418
  return {
1050
1419
  n: profile.n,
@@ -1085,6 +1454,7 @@ function summaryModels(config) {
1085
1454
  function buildUsageRecord(args) {
1086
1455
  return {
1087
1456
  schema_version: 1,
1457
+ usage_id: randomUUID(),
1088
1458
  ts: new Date().toISOString(),
1089
1459
  phase: args.phase,
1090
1460
  role: args.role,
@@ -1122,7 +1492,8 @@ function enforceBudget(config) {
1122
1492
  invalidJsonRetries: config.retry.invalidJsonRetries,
1123
1493
  includeFinalizer: Boolean(config.finalizerModel)
1124
1494
  });
1125
- const plannedCalls = plan.calls + plan.finalizer_calls;
1495
+ const explicitFinalCalls = config.rank ? configuredFinalPairCount(config.profile.n, config.rank) : plan.final_judge_calls;
1496
+ const plannedCalls = plan.calls - plan.final_judge_calls + explicitFinalCalls + plan.finalizer_calls;
1126
1497
  if (config.budget?.maxCalls !== undefined && plannedCalls > config.budget.maxCalls) {
1127
1498
  throw new BudgetExceededError(`Planned call count ${plannedCalls} exceeds maxCalls ${config.budget.maxCalls}.`, {
1128
1499
  code: "budget.planned_calls_exceeded",
@@ -1130,6 +1501,64 @@ function enforceBudget(config) {
1130
1501
  });
1131
1502
  }
1132
1503
  }
1504
+ function configuredFinalPairs(candidateIds, rank, rng) {
1505
+ if (rank.mode === "k-regular") {
1506
+ if (rank.k === undefined) {
1507
+ throw new ConfigError("rank.mode k-regular requires rank.k.", { code: "rank.k_required", retryable: false });
1508
+ }
1509
+ const pairs = makeKRegularPairs(candidateIds, rank.k, rng);
1510
+ assertRankCallLimit(pairs.length, rank.maxCalls);
1511
+ return pairs;
1512
+ }
1513
+ const pairs = [];
1514
+ for (let left = 0; left < candidateIds.length; left += 1) {
1515
+ for (let right = left + 1; right < candidateIds.length; right += 1) {
1516
+ pairs.push({ a: candidateIds[left], b: candidateIds[right] });
1517
+ }
1518
+ }
1519
+ if (pairs.length > 100 && (rank.maxCalls === undefined || rank.maxCalls < pairs.length)) {
1520
+ throw new ConfigError(`All-pairs final ranking requires explicit rank.maxCalls >= ${pairs.length}.`, {
1521
+ code: "rank.max_calls_required",
1522
+ retryable: false,
1523
+ fix: "Raise rank.maxCalls or select k-regular mode."
1524
+ });
1525
+ }
1526
+ assertRankCallLimit(pairs.length, rank.maxCalls);
1527
+ return rng.shuffle(pairs);
1528
+ }
1529
+ function configuredFinalPairCount(n, rank) {
1530
+ if (rank.mode === "all-pairs") {
1531
+ const calls = (n * (n - 1)) / 2;
1532
+ if (calls > 100 && (rank.maxCalls === undefined || rank.maxCalls < calls)) {
1533
+ throw new ConfigError(`All-pairs final ranking requires explicit rank.maxCalls >= ${calls}.`, {
1534
+ code: "rank.max_calls_required",
1535
+ retryable: false
1536
+ });
1537
+ }
1538
+ assertRankCallLimit(calls, rank.maxCalls);
1539
+ return calls;
1540
+ }
1541
+ if (rank.k === undefined) {
1542
+ throw new ConfigError("rank.mode k-regular requires rank.k.", { code: "rank.k_required", retryable: false });
1543
+ }
1544
+ if (rank.k >= n || (n * rank.k) % 2 !== 0) {
1545
+ throw new ConfigError(`Invalid k-regular final ranking for n=${n}, k=${rank.k}.`, {
1546
+ code: "rank.k_invalid",
1547
+ retryable: false
1548
+ });
1549
+ }
1550
+ const calls = (n * rank.k) / 2;
1551
+ assertRankCallLimit(calls, rank.maxCalls);
1552
+ return calls;
1553
+ }
1554
+ function assertRankCallLimit(calls, maxCalls) {
1555
+ if (maxCalls !== undefined && calls > maxCalls) {
1556
+ throw new ConfigError(`Final ranking requires ${calls} calls, exceeding rank.maxCalls ${maxCalls}.`, {
1557
+ code: "rank.max_calls_exceeded",
1558
+ retryable: false
1559
+ });
1560
+ }
1561
+ }
1133
1562
  function cloneUsage(usage) {
1134
1563
  return {
1135
1564
  ...emptyUsage(),