@deepthonk/core 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +26 -11
  2. package/dist/artifacts.js +178 -11
  3. package/dist/artifacts.js.map +1 -1
  4. package/dist/bradleyTerry.d.ts +1 -0
  5. package/dist/bradleyTerry.js +27 -2
  6. package/dist/bradleyTerry.js.map +1 -1
  7. package/dist/budget.d.ts +7 -1
  8. package/dist/budget.js +6 -1
  9. package/dist/budget.js.map +1 -1
  10. package/dist/budgetTracker.d.ts +6 -1
  11. package/dist/budgetTracker.js +32 -1
  12. package/dist/budgetTracker.js.map +1 -1
  13. package/dist/checkpointStore.d.ts +56 -0
  14. package/dist/checkpointStore.js +351 -0
  15. package/dist/checkpointStore.js.map +1 -0
  16. package/dist/critique.d.ts +3 -1
  17. package/dist/critique.js +24 -9
  18. package/dist/critique.js.map +1 -1
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/lifecycle.d.ts +33 -1
  23. package/dist/lifecycle.js +207 -6
  24. package/dist/lifecycle.js.map +1 -1
  25. package/dist/phaseRunner.d.ts +6 -1
  26. package/dist/phaseRunner.js +53 -19
  27. package/dist/phaseRunner.js.map +1 -1
  28. package/dist/resume.d.ts +10 -3
  29. package/dist/resume.js +357 -39
  30. package/dist/resume.js.map +1 -1
  31. package/dist/runner.d.ts +3 -1
  32. package/dist/runner.js +894 -246
  33. package/dist/runner.js.map +1 -1
  34. package/dist/schemas.d.ts +397 -47
  35. package/dist/schemas.js +67 -5
  36. package/dist/schemas.js.map +1 -1
  37. package/dist/services.d.ts +9 -0
  38. package/dist/services.js +115 -12
  39. package/dist/services.js.map +1 -1
  40. package/dist/traceStore.d.ts +31 -2
  41. package/dist/traceStore.js +182 -21
  42. package/dist/traceStore.js.map +1 -1
  43. package/package.json +1 -1
package/dist/runner.js CHANGED
@@ -1,19 +1,20 @@
1
+ import { createHash, randomUUID } from "node:crypto";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import { z } from "zod";
4
- import { fitBradleyTerry } from "./bradleyTerry.js";
5
+ import { canonicalizeComparisons, fitBradleyTerry } from "./bradleyTerry.js";
5
6
  import { BudgetTracker } from "./budgetTracker.js";
7
+ import { readValidatedPhaseReceiptUsage, runCheckpointedPhase, traceSchemaVersion, validatePhaseCommit, validatePhaseCommitIfPresent, writePhaseCommit } from "./checkpointStore.js";
6
8
  import { planBudget, validateProfile } from "./budget.js";
7
9
  import { BudgetExceededError, CancelledError, ConfigError, DeepThonkError } from "./errors.js";
8
10
  import { runArtifactFiles } from "./artifacts.js";
9
11
  import { parseJsonObject } from "./json.js";
10
- import { claimRunLock, emptyUsage, releaseRunLock } from "./lifecycle.js";
12
+ import { claimRunLockOwnership, emptyUsage, releaseRunLock, verifyRunLockClaim } from "./lifecycle.js";
11
13
  import { makeKRegularPairs } from "./pairScheduler.js";
12
- import { runLimitedPhase } from "./phaseRunner.js";
13
14
  import { comparePrompt, finalizePrompt, generatePrompt, mutatePrompt } from "./prompts.js";
14
15
  import { createRng } from "./rng.js";
15
- import { assertNoPruneInProgress, buildPopulationMap, buildResumePlan, groupComparisons, persistPrunedTrace, pruneTraceToPlan, readResumeTrace, reconstructScores, replayBudgetUsage, resolveResumeRunId, resumeConfigError, toResumePlanStatus } from "./resume.js";
16
- 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";
17
18
  import { aggregateCritiques } from "./critique.js";
18
19
  import { TraceStore } from "./traceStore.js";
19
20
  const compareOutputSchema = z.object({
@@ -26,25 +27,42 @@ const compareOutputSchema = z.object({
26
27
  selection_reason: z.string().default("")
27
28
  });
28
29
  export async function runDeepThonk(configInput, driver, control = {}, resumeState) {
29
- const config = runConfigSchema.parse(configInput);
30
+ const config = resolveRunConfigDefaults(runConfigSchema.parse(configInput));
30
31
  validateProfile(config.profile);
31
32
  enforceBudget(config);
32
- const runId = resumeState?.runId ?? `run_${new Date().toISOString().replace(/[:.]/g, "-")}_${Math.abs(config.seed)}`;
33
+ const runId = resumeState?.runId ??
34
+ config.runId ??
35
+ `run_${new Date().toISOString().replace(/[:.]/g, "-")}_${randomUUID()}`;
33
36
  const trace = new TraceStore(config.runDir);
34
37
  const rng = createRng(config.seed);
35
38
  const tracker = resumeState?.tracker ?? new BudgetTracker(config);
36
39
  const startedAt = resumeState?.startedAt ?? new Date().toISOString();
37
40
  let ownsLock = false;
38
- if (!resumeState && !control.lockClaimed) {
39
- const claimed = await claimRunLock(config.runDir, control.jobId ?? runId);
40
- if (!claimed) {
41
- throw new ConfigError(`Run directory is already claimed: ${config.runDir}`, {
42
- code: "run.directory_locked",
43
- retryable: false,
44
- fix: "Wait for the active run to finish, or choose a fresh run directory."
45
- });
41
+ let ownedLockClaimId;
42
+ let verifiedPreclaim = false;
43
+ if (!resumeState) {
44
+ if (control.lockClaim) {
45
+ verifiedPreclaim = await verifyRunLockClaim(config.runDir, control.lockClaim, control.jobId);
46
+ if (!verifiedPreclaim) {
47
+ throw new ConfigError("The supplied run lock claim does not own the current run.lock.", {
48
+ code: "run.lock_not_owned",
49
+ retryable: false,
50
+ fix: "Claim the run directory with claimRunLockOwnership and pass that exact claim to the worker."
51
+ });
52
+ }
53
+ }
54
+ else {
55
+ const claimed = await claimRunLockOwnership(config.runDir, control.jobId ?? runId);
56
+ if (!claimed) {
57
+ throw new ConfigError(`Run directory is already claimed: ${config.runDir}`, {
58
+ code: "run.directory_locked",
59
+ retryable: false,
60
+ fix: "Wait for the active run to finish, or choose a fresh run directory."
61
+ });
62
+ }
63
+ ownsLock = true;
64
+ ownedLockClaimId = claimed.claimId;
46
65
  }
47
- ownsLock = true;
48
66
  }
49
67
  let stopped = false;
50
68
  let caughtError;
@@ -63,7 +81,7 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
63
81
  });
64
82
  };
65
83
  const assertNotCancelled = async (phase) => {
66
- if (await control.shouldCancel?.()) {
84
+ if (control.signal?.aborted || (await control.shouldCancel?.())) {
67
85
  throw new CancelledError(`Run cancelled before ${phase}.`, {
68
86
  code: "run.cancelled",
69
87
  retryable: false,
@@ -89,6 +107,7 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
89
107
  throw new BudgetExceededError(`Run already stopped before ${phase}.`, { code: "budget.stopped" });
90
108
  await assertNotCancelled(phase);
91
109
  await assertBudget(phase);
110
+ return tracker.reserveCall(phase);
92
111
  },
93
112
  async afterCall(phase) {
94
113
  await assertNotCancelled(phase);
@@ -97,7 +116,7 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
97
116
  };
98
117
  try {
99
118
  if (!resumeState) {
100
- await trace.init({ ...config, version: await currentPackageVersion() }, runId);
119
+ await trace.init({ ...config, version: await currentPackageVersion() }, runId, { allowOwnedLock: ownsLock || verifiedPreclaim, pendingJobId: verifiedPreclaim ? control.jobId : undefined });
101
120
  }
102
121
  else {
103
122
  await trace.event({
@@ -115,9 +134,13 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
115
134
  }
116
135
  else {
117
136
  await assertNotCancelled("initial population");
118
- population = await generateInitialPopulation(config, driver, trace, rng, runId, tracker, guards);
137
+ population = await generateInitialPopulation(config, driver, trace, rng, runId, tracker, guards, control.signal);
119
138
  await trace.writePopulation(0, population);
120
139
  await assertBudget("initial population");
140
+ await writePhaseCommit(config.runDir, "initial_generation", {
141
+ population,
142
+ usage: await phaseUsageRows(trace, "initial_generation")
143
+ });
121
144
  await markPhaseCompleted(trace, "initial_generation");
122
145
  await writeStatus("running", "population_completed", { generation: 0 });
123
146
  }
@@ -147,11 +170,17 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
147
170
  pairs,
148
171
  population,
149
172
  tracker,
150
- guards
173
+ guards,
174
+ signal: control.signal
151
175
  });
152
176
  scores = fitBradleyTerry(population, comparisons, config.profile.lambda, gen);
153
177
  await trace.writeScores(gen, scores);
154
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
+ });
155
184
  await markPhaseCompleted(trace, "generation_judging", gen);
156
185
  }
157
186
  const ranked = rankPopulation(population, scores);
@@ -159,7 +188,9 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
159
188
  const discarded = bottomQuartile(ranked);
160
189
  const survivors = ranked.filter((candidate) => !discarded.has(candidate.id));
161
190
  const mutationParents = survivors.slice(0, config.profile.n - elites.length);
162
- const critiquesByCandidate = aggregateCritiques(population, comparisons);
191
+ const critiquesByCandidate = aggregateCritiques(population, comparisons, {
192
+ aggregateChars: config.critiqueLimits.aggregateChars
193
+ });
163
194
  await assertNotCancelled(`generation ${gen} mutation`);
164
195
  await writeStatus("running", "generation_mutation", { generation: gen });
165
196
  await traceDiscardedCandidates(trace, ranked, new Set([...elites, ...mutationParents].map((candidate) => candidate.id)), discarded, gen);
@@ -171,7 +202,8 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
171
202
  survivors: mutationParents,
172
203
  critiquesByCandidate,
173
204
  tracker,
174
- guards
205
+ guards,
206
+ signal: control.signal
175
207
  });
176
208
  const eliteCopies = copyElites(elites, gen);
177
209
  for (const elite of eliteCopies) {
@@ -181,6 +213,10 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
181
213
  population = keepPopulationSize([...eliteCopies, ...mutants], config.profile.n);
182
214
  await trace.writePopulation(gen, population);
183
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
+ });
184
220
  await markPhaseCompleted(trace, "generation_mutation", gen);
185
221
  await writeStatus("running", "generation_completed", { generation: gen });
186
222
  }
@@ -193,8 +229,12 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
193
229
  else {
194
230
  await assertNotCancelled("final ranking");
195
231
  await writeStatus("running", "final_ranking", { generation: "final" });
196
- const finalRng = phaseRng(config.seed, "final_judging");
197
- 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);
198
238
  finalComparisons = await judgePairs({
199
239
  config,
200
240
  driver,
@@ -205,11 +245,17 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
205
245
  pairs: finalPairs,
206
246
  population,
207
247
  tracker,
208
- guards
248
+ guards,
249
+ signal: control.signal
209
250
  });
210
251
  finalScores = fitBradleyTerry(population, finalComparisons, config.profile.lambda, "final");
211
252
  await trace.writeScores("final", finalScores);
212
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
+ });
213
259
  await markPhaseCompleted(trace, "final_judging");
214
260
  }
215
261
  const winner = rankPopulation(population, finalScores)[0];
@@ -217,7 +263,7 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
217
263
  throw new ConfigError("Run produced no winner.");
218
264
  await assertNotCancelled("finalization");
219
265
  await writeStatus("running", "finalizing", { generation: "final" });
220
- const finalAnswer = await maybeFinalize(winner, config, driver, trace, tracker, guards);
266
+ const finalAnswer = await maybeFinalize(winner, config, driver, trace, tracker, guards, control.signal);
221
267
  await assertBudget("finalization");
222
268
  const completedAt = new Date().toISOString();
223
269
  const summary = {
@@ -236,6 +282,12 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
236
282
  completed_at: completedAt
237
283
  };
238
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
+ });
239
291
  await markPhaseCompleted(trace, "finalizing");
240
292
  await trace.event({ type: "run.completed", winner_id: winner.id, completed_at: completedAt });
241
293
  if (resumeState)
@@ -259,9 +311,19 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
259
311
  throw error;
260
312
  }
261
313
  finally {
262
- 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) {
263
325
  try {
264
- await releaseRunLock(config.runDir);
326
+ await releaseRunLock(config.runDir, ownsLock ? ownedLockClaimId : control.lockClaim?.claimId);
265
327
  }
266
328
  catch (error) {
267
329
  if (caughtError !== undefined) {
@@ -272,27 +334,26 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
272
334
  }
273
335
  }
274
336
  }
337
+ if (closeError !== undefined)
338
+ throw closeError;
275
339
  }
276
340
  }
277
341
  export async function resumeDeepThonk(runDir, driver, options = {}) {
278
- const existingSummary = await readOptionalJson(runDir, runArtifactFiles.summary);
279
- if (existingSummary) {
280
- resumeConfigError("Run already has summary.json; nothing to resume.", "resume.already_complete", "Use deepthonk inspect/result to read the completed run.");
281
- }
282
342
  const rawConfig = await readRequiredConfig(runDir);
343
+ assertNoLegacyRedactedBudget(rawConfig);
283
344
  const currentVersion = await currentPackageVersion();
284
345
  if (!sameMajorMinor(typeof rawConfig.version === "string" ? rawConfig.version : undefined, currentVersion)) {
285
346
  resumeConfigError(`Cannot resume run from version ${String(rawConfig.version ?? "missing")}; current package version is ${currentVersion}. Resume requires matching major.minor.`, "resume.version_mismatch", "Start a fresh run with the current DeepThonk version, or resume with a package version whose major.minor matches the trace.");
286
347
  }
287
348
  assertStoredOutputConfigComplete(rawConfig);
288
349
  const parsedConfig = runConfigSchema.parse(rawConfig);
289
- const config = { ...parsedConfig, runDir };
350
+ const config = resolveRunConfigDefaults({ ...parsedConfig, runDir });
290
351
  validateProfile(config.profile);
291
352
  enforceBudget(config);
292
353
  assertResumeProviderMatches(rawConfig, config, driver, options);
293
354
  assertNoPruneInProgress(runDir);
294
355
  const lockRunId = resolveResumeRunId(config, undefined, []);
295
- const claimed = await claimRunLock(runDir, lockRunId);
356
+ const claimed = await claimRunLockOwnership(runDir, lockRunId);
296
357
  if (!claimed) {
297
358
  throw new ConfigError(`Run directory is already claimed: ${runDir}`, {
298
359
  code: "run.directory_locked",
@@ -301,6 +362,7 @@ export async function resumeDeepThonk(runDir, driver, options = {}) {
301
362
  });
302
363
  }
303
364
  try {
365
+ const existingSummary = await readOptionalFinalSummary(runDir, rawConfig.traceSchemaVersion === traceSchemaVersion);
304
366
  const status = await readOptionalJson(runDir, runArtifactFiles.status);
305
367
  if ((status?.state === "running" || status?.state === "pending") && isLiveWorker(status.worker_pid)) {
306
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.");
@@ -308,16 +370,41 @@ export async function resumeDeepThonk(runDir, driver, options = {}) {
308
370
  const trace = await readResumeTrace(runDir);
309
371
  const runId = resolveResumeRunId(config, status, trace.events);
310
372
  const plan = buildResumePlan(config, trace.events);
311
- if (plan.nextPhase.phase === "summary") {
312
- 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.");
313
381
  }
314
382
  const planStatus = toResumePlanStatus(runDir, runId, plan);
315
- if (options.dryRun)
316
- return planStatus;
317
- 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 });
389
+ assertResumeRunIdsAgree(runId, config, status, pruned);
318
390
  const populationByGeneration = buildPopulationMap(config, pruned.populations, pruned.candidates, plan);
319
391
  const comparisonsByGeneration = groupComparisons(pruned.comparisons);
320
- const scoresByGeneration = reconstructScores(config, populationByGeneration, comparisonsByGeneration, pruned.scores, plan);
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
+ }
406
+ if (options.dryRun)
407
+ return planStatus;
321
408
  const tracker = replayBudgetUsage(config, pruned.usage);
322
409
  const startedAt = status?.started_at ?? new Date().toISOString();
323
410
  await new TraceStore(runDir).writeStatus({
@@ -343,7 +430,26 @@ export async function resumeDeepThonk(runDir, driver, options = {}) {
343
430
  });
344
431
  }
345
432
  finally {
346
- await releaseRunLock(runDir);
433
+ await releaseRunLock(runDir, claimed?.claimId);
434
+ }
435
+ }
436
+ function assertResumeRunIdsAgree(resolvedRunId, config, status, trace) {
437
+ const observed = new Set();
438
+ if (config.runId)
439
+ observed.add(config.runId);
440
+ if (status?.run_id)
441
+ observed.add(status.run_id);
442
+ for (const event of trace.events) {
443
+ if (typeof event.run_id === "string")
444
+ observed.add(event.run_id);
445
+ }
446
+ for (const comparison of trace.comparisons) {
447
+ if (comparison.runId)
448
+ observed.add(comparison.runId);
449
+ }
450
+ observed.add(resolvedRunId);
451
+ if (observed.size > 1) {
452
+ resumeConfigError(`Resume artifacts disagree on run ID: ${[...observed].sort().join(", ")}.`, "resume.run_id_mismatch", "Restore artifacts from one run; do not combine trace files from different run directories.");
347
453
  }
348
454
  }
349
455
  async function markPhaseCompleted(trace, phase, generation) {
@@ -378,7 +484,7 @@ function resumeComparisons(resumeState, generation) {
378
484
  fix: "Inspect the run directory for missing comparison trace rows."
379
485
  });
380
486
  }
381
- return comparisons;
487
+ return canonicalizeComparisons(comparisons);
382
488
  }
383
489
  function resumeScores(resumeState, generation, population, comparisons, lambda) {
384
490
  return resumeState?.scoresByGeneration.get(generation) ?? fitBradleyTerry(population, comparisons, lambda, generation);
@@ -399,6 +505,13 @@ function resumePhaseKey(phase, generation) {
399
505
  return `${phase}:${generation}`;
400
506
  return phase;
401
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
+ }
402
515
  function providerLabel(driver) {
403
516
  const value = driver;
404
517
  if (typeof value.provider === "string")
@@ -425,10 +538,19 @@ function assertStoredOutputConfigComplete(rawConfig) {
425
538
  }
426
539
  }
427
540
  function assertResumeProviderMatches(rawConfig, config, driver, options) {
541
+ assertProviderReplayInternalConsistency(config);
428
542
  const runtimeProvider = options.provider ?? providerLabel(driver) ?? config.provider;
429
543
  if (runtimeProvider !== config.provider) {
430
544
  providerMismatch(`Cannot resume provider ${config.provider} with runtime provider ${runtimeProvider}.`);
431
545
  }
546
+ const expectedFingerprint = config.providerReplay?.routeFingerprint;
547
+ if (expectedFingerprint !== undefined && providerRouteFingerprint(driver) !== expectedFingerprint) {
548
+ providerMismatch("Cannot resume because the provider route fingerprint changed.");
549
+ }
550
+ if (config.providerReplay?.baseUrl !== undefined &&
551
+ normalizeBaseUrl(providerBaseUrl(driver)) !== normalizeBaseUrl(config.providerReplay.baseUrl)) {
552
+ providerMismatch(`Cannot resume base URL ${config.providerReplay.baseUrl} with runtime base URL ${providerBaseUrl(driver) ?? "missing"}.`);
553
+ }
432
554
  const expectedRoutes = expectedProviderRoutes(rawConfig, config);
433
555
  const actualRoutes = runtimeProviderRoutes(driver);
434
556
  for (const role of providerRoles) {
@@ -450,8 +572,92 @@ function assertResumeProviderMatches(rawConfig, config, driver, options) {
450
572
  }
451
573
  }
452
574
  }
575
+ function assertProviderReplayInternalConsistency(config) {
576
+ const replay = config.providerReplay;
577
+ if (!replay)
578
+ return;
579
+ const expectedModels = {
580
+ generator: config.generatorModel,
581
+ mutator: config.mutatorModel,
582
+ judge: config.judgeModel,
583
+ finalizer: config.finalizerModel
584
+ };
585
+ if (replay.provider !== config.provider ||
586
+ replay.models.generator !== expectedModels.generator ||
587
+ replay.models.mutator !== expectedModels.mutator ||
588
+ replay.models.judge !== expectedModels.judge ||
589
+ replay.models.finalizer !== expectedModels.finalizer) {
590
+ resumeConfigError("Stored run provider/model fields disagree with providerReplay.", "resume.provider_replay_mismatch", "Restore config.json from the original run; do not edit provider or model fields independently.");
591
+ }
592
+ if (replay.routeFingerprint !== undefined && replay.routeFingerprint !== coreProviderReplayFingerprint(replay)) {
593
+ resumeConfigError("Stored providerReplay fingerprint does not match its route fields.", "resume.provider_replay_mismatch", "Restore the original providerReplay block instead of editing route fields.");
594
+ }
595
+ }
596
+ function coreProviderReplayFingerprint(replay) {
597
+ const roles = Object.fromEntries(providerRoles.flatMap((role) => {
598
+ const route = replay.roleProviders?.[role];
599
+ return route
600
+ ? [[role, routeFingerprintValue(route.provider, route.baseUrl, route.apiKeyEnv, route.model, route.supportsJsonMode)]]
601
+ : [];
602
+ }));
603
+ const value = {
604
+ base: routeFingerprintValue(replay.provider, replay.baseUrl, replay.apiKeyEnv, replay.models, replay.supportsJsonMode),
605
+ roles,
606
+ samplingPreferences: replay.samplingPreferences ?? null
607
+ };
608
+ return `sha256:${createHash("sha256").update(stableJson(value)).digest("hex")}`;
609
+ }
610
+ function routeFingerprintValue(provider, baseUrl, apiKeyEnv, model, supportsJsonMode) {
611
+ return Object.fromEntries(Object.entries({
612
+ provider,
613
+ baseUrl: normalizeFingerprintUrl(baseUrl),
614
+ apiKeyEnv,
615
+ model,
616
+ supportsJsonMode
617
+ }).filter(([, value]) => value !== undefined));
618
+ }
619
+ function normalizeFingerprintUrl(baseUrl) {
620
+ if (!baseUrl)
621
+ return undefined;
622
+ try {
623
+ const parsed = new URL(baseUrl);
624
+ parsed.hash = "";
625
+ parsed.hostname = parsed.hostname.toLowerCase();
626
+ parsed.pathname = parsed.pathname.replace(/\/+$/, "");
627
+ return parsed.toString().replace(/\/$/, "");
628
+ }
629
+ catch {
630
+ return baseUrl.replace(/\/+$/, "");
631
+ }
632
+ }
633
+ function stableJson(value) {
634
+ if (Array.isArray(value))
635
+ return `[${value.map(stableJson).join(",")}]`;
636
+ if (value && typeof value === "object") {
637
+ const entries = Object.entries(value)
638
+ .sort(([left], [right]) => left.localeCompare(right))
639
+ .map(([key, inner]) => `${JSON.stringify(key)}:${stableJson(inner)}`);
640
+ return `{${entries.join(",")}}`;
641
+ }
642
+ return JSON.stringify(value) ?? "null";
643
+ }
453
644
  const providerRoles = ["generator", "mutator", "judge", "finalizer"];
454
645
  function expectedProviderRoutes(rawConfig, config) {
646
+ const replayRoutes = config.providerReplay?.roleProviders;
647
+ if (replayRoutes) {
648
+ const routes = {};
649
+ for (const role of providerRoles) {
650
+ const route = replayRoutes[role];
651
+ if (!route)
652
+ continue;
653
+ routes[role] = {
654
+ provider: route.provider,
655
+ baseUrl: route.baseUrl,
656
+ model: route.model
657
+ };
658
+ }
659
+ return routes;
660
+ }
455
661
  const providers = rawConfig.providers;
456
662
  if (!isRecord(providers))
457
663
  return {};
@@ -486,6 +692,16 @@ function runtimeProviderRoutes(driver) {
486
692
  }
487
693
  return routes;
488
694
  }
695
+ function providerRouteFingerprint(driver) {
696
+ const value = driver;
697
+ if (typeof value.routeFingerprint === "string")
698
+ return value.routeFingerprint;
699
+ if (typeof value.config?.routeFingerprint === "string")
700
+ return value.config.routeFingerprint;
701
+ if (value.baseDriver)
702
+ return providerRouteFingerprint(value.baseDriver);
703
+ return undefined;
704
+ }
489
705
  function providerBaseUrl(driver) {
490
706
  const value = driver;
491
707
  if (typeof value.baseUrl === "string")
@@ -527,6 +743,20 @@ async function readRequiredConfig(runDir) {
527
743
  }
528
744
  return config;
529
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
+ }
530
760
  async function readOptionalJson(runDir, fileName) {
531
761
  try {
532
762
  return JSON.parse(await readFile(join(runDir, fileName), "utf8"));
@@ -537,6 +767,16 @@ async function readOptionalJson(runDir, fileName) {
537
767
  throw error;
538
768
  }
539
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
+ }
540
780
  function isLiveWorker(pid) {
541
781
  if (!pid)
542
782
  return true;
@@ -544,8 +784,8 @@ function isLiveWorker(pid) {
544
784
  process.kill(pid, 0);
545
785
  return true;
546
786
  }
547
- catch {
548
- return false;
787
+ catch (error) {
788
+ return error.code !== "ESRCH";
549
789
  }
550
790
  }
551
791
  export function rankPopulation(population, scores) {
@@ -570,10 +810,81 @@ export function copyElites(elites, generation) {
570
810
  status: "elite",
571
811
  metadata: {
572
812
  ...candidate.metadata,
573
- createdAt: new Date().toISOString()
813
+ createdAt: candidate.metadata.createdAt
574
814
  }
575
815
  }));
576
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
+ }
577
888
  export function keepPopulationSize(population, n) {
578
889
  return population.slice(0, n);
579
890
  }
@@ -594,44 +905,92 @@ async function traceDiscardedCandidates(trace, ranked, carriedIds, bottomQuartil
594
905
  await trace.event({ type: "candidate.discarded", candidate_id: candidate.id, generation, reason: discardReason });
595
906
  }
596
907
  }
597
- async function generateInitialPopulation(config, driver, trace, rng, runId, tracker, guards) {
908
+ async function generateInitialPopulation(config, driver, trace, rng, runId, tracker, guards, signal) {
598
909
  const jobs = Array.from({ length: config.profile.n }, (_, index) => ({
599
910
  index,
600
911
  id: `g0_c${index}_${rng.id("c")}`,
601
912
  prompt: generatePrompt(config.task, config.rubric, config.promptStyle, config.promptOverrides?.generate)
602
913
  }));
603
- const candidates = await runLimitedPhase(jobs.map((job) => async () => {
604
- await guards.beforeCall("initial population call");
605
- const result = await driver.generate({
606
- task: config.task,
607
- rubric: config.rubric,
608
- model: config.generatorModel,
609
- temperature: config.profile.sampleTemperature,
610
- candidateIndex: job.index,
611
- prompt: job.prompt
612
- });
613
- const delta = tracker.record(result, { provider: config.provider, model: config.generatorModel });
614
- await trace.writeUsage(buildUsageRecord({
615
- phase: "gen0_initial",
616
- role: "generator",
617
- result,
618
- delta,
619
- provider: result.provider ?? config.provider,
620
- model: result.model ?? config.generatorModel
621
- }));
622
- await guards.afterCall("initial population call");
623
- const candidate = {
624
- id: job.id,
625
- generation: 0,
626
- kind: "initial",
627
- content: result.text,
628
- status: "generated",
629
- metadata: resultMetadata(result, config, job.prompt)
630
- };
631
- await trace.writeCandidate(candidate);
632
- await trace.event({ type: "candidate.generated", run_id: runId, candidate_id: candidate.id });
633
- return candidate;
634
- }), config.concurrency.generate);
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 });
992
+ }
993
+ });
635
994
  return candidates;
636
995
  }
637
996
  async function judgePairs(args) {
@@ -651,175 +1010,340 @@ async function judgePairs(args) {
651
1010
  presentedA,
652
1011
  presentedB,
653
1012
  id: `${args.generation}_cmp_${pairIndex}_${args.rng.id("cmp")}`,
654
- 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
655
1015
  };
656
1016
  });
657
- const comparisons = await runLimitedPhase(jobs.map((job) => async () => {
658
- let parsed;
659
- let rawOutput;
660
- let invalidJson = false;
661
- let resultModel;
662
- let resultProvider;
663
- let resultRetryCount = 0;
664
- let jsonParseFailures = 0;
665
- let inputTokens = 0;
666
- let inputCacheHitTokens = 0;
667
- let inputCacheMissTokens = 0;
668
- let outputTokens = 0;
669
- let totalTokens = 0;
670
- for (let attempt = 0; attempt <= args.config.retry.invalidJsonRetries; attempt += 1) {
671
- await args.guards.beforeCall(`${args.generation} comparison call`);
672
- const result = await args.driver.compare({
673
- task: args.config.task,
674
- rubric: args.config.rubric,
675
- model: args.config.judgeModel,
676
- temperature: args.config.profile.judgeTemperature,
677
- candidateA: job.presentedA,
678
- candidateB: job.presentedB,
679
- prompt: job.prompt
680
- });
681
- const delta = args.tracker.record(result, { provider: args.config.provider, model: args.config.judgeModel });
682
- await args.trace.writeUsage(buildUsageRecord({
683
- phase: args.generation === "final" ? "final_judge" : `gen${args.generation}_judge`,
684
- role: "judge",
685
- result,
686
- delta,
687
- provider: result.provider ?? args.config.provider,
688
- model: result.model ?? args.config.judgeModel
689
- }));
690
- await args.guards.afterCall(`${args.generation} comparison call`);
691
- inputTokens += result.usage?.inputTokens ?? 0;
692
- inputCacheHitTokens += result.usage?.inputCacheHitTokens ?? 0;
693
- inputCacheMissTokens += result.usage?.inputCacheMissTokens ?? 0;
694
- outputTokens += result.usage?.outputTokens ?? 0;
695
- totalTokens += result.usage?.totalTokens ?? (result.usage?.inputTokens ?? 0) + (result.usage?.outputTokens ?? 0);
696
- rawOutput = args.config.output.includeRawModelOutputs ? result.raw ?? result.text : undefined;
697
- resultModel = result.model;
698
- resultProvider = result.provider;
699
- resultRetryCount += result.retryCount ?? 0;
700
- try {
701
- parsed = compareOutputSchema.parse(parseJsonObject(result.text));
702
- invalidJson = false;
703
- break;
704
- }
705
- catch {
706
- invalidJson = true;
707
- jsonParseFailures += 1;
708
- }
709
- }
710
- if (invalidJson || !parsed) {
711
- 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.`, {
712
- code: "judge.persistent_invalid_json",
713
- retryable: false,
714
- 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."
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: {
1026
+ task: args.config.task,
1027
+ rubric: args.config.rubric,
1028
+ model: args.config.judgeModel,
1029
+ temperature: args.config.profile.judgeTemperature,
1030
+ candidateA: job.presentedA,
1031
+ candidateB: job.presentedB,
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
715
1166
  });
716
1167
  }
717
- const comparison = {
718
- id: job.id,
719
- runId: args.runId,
720
- generation: args.generation,
721
- candidateAId: job.presentedA.id,
722
- candidateBId: job.presentedB.id,
723
- presentedAOriginalId: job.presentedA.id,
724
- presentedBOriginalId: job.presentedB.id,
725
- winner: parsed?.winner ?? "tie",
726
- confidence: parsed?.confidence,
727
- critiqueForA: parsed?.feedback_a ?? parsed?.critique_for_A ?? "Invalid comparison JSON; recorded as tie.",
728
- critiqueForB: parsed?.feedback_b ?? parsed?.critique_for_B ?? "Invalid comparison JSON; recorded as tie.",
729
- selectionReason: parsed?.selection_reason ?? "invalid_json_tie",
730
- rawOutput,
731
- model: resultModel,
732
- provider: resultProvider,
733
- metadata: compactMetadata({
734
- invalid_json_tie: invalidJson ? true : undefined,
735
- json_parse_failures: jsonParseFailures || undefined,
736
- model_call_count: jsonParseFailures + (parsed ? 1 : 0),
737
- input_tokens: inputTokens || undefined,
738
- input_cache_hit_tokens: inputCacheHitTokens || undefined,
739
- input_cache_miss_tokens: inputCacheMissTokens || undefined,
740
- output_tokens: outputTokens || undefined,
741
- total_tokens: totalTokens || undefined,
742
- scheduled_candidate_a_id: job.originalA.id,
743
- scheduled_candidate_b_id: job.originalB.id,
744
- provider_retry_count: resultRetryCount || undefined,
745
- prompt: args.config.output.includePrompts ? job.prompt : undefined
746
- })
747
- };
748
- await args.trace.writeComparison(comparison);
749
- await args.trace.event({
750
- type: "comparison.completed",
751
- run_id: args.runId,
752
- comparison_id: comparison.id,
753
- generation: args.generation
754
- });
755
- return comparison;
756
- }), args.config.concurrency.judge);
1168
+ });
757
1169
  return comparisons;
758
1170
  }
759
1171
  async function mutateSurvivors(args) {
760
- const mutants = await runLimitedPhase(args.survivors.map((candidate, index) => async () => {
761
- const critique = args.critiquesByCandidate.get(candidate.id) ?? "";
762
- const prompt = mutatePrompt(args.config.task, candidate, critique, args.config.rubric, args.config.promptStyle, args.config.promptOverrides?.mutate);
763
- await args.guards.beforeCall(`generation ${args.generation} mutation call`);
764
- const result = await args.driver.mutate({
765
- task: args.config.task,
766
- rubric: args.config.rubric,
767
- model: args.config.mutatorModel,
768
- temperature: args.config.profile.mutateTemperature,
769
- candidate,
770
- critique,
771
- prompt
772
- });
773
- const delta = args.tracker.record(result, { provider: args.config.provider, model: args.config.mutatorModel });
774
- await args.trace.writeUsage(buildUsageRecord({
775
- phase: `gen${args.generation}_mutate`,
776
- role: "mutator",
777
- result,
778
- delta,
779
- provider: result.provider ?? args.config.provider,
780
- model: result.model ?? args.config.mutatorModel
781
- }));
782
- await args.guards.afterCall(`generation ${args.generation} mutation call`);
783
- const mutant = {
784
- id: `g${args.generation}_m${index}_${candidate.id}`,
785
- generation: args.generation,
786
- parentId: candidate.id,
787
- kind: "mutation",
788
- content: result.text,
789
- status: "mutated",
790
- metadata: resultMetadata(result, args.config, prompt)
791
- };
792
- await args.trace.writeCandidate(mutant);
793
- await args.trace.event({ type: "candidate.mutated", candidate_id: mutant.id, parent_id: mutant.parentId });
794
- return mutant;
795
- }), args.config.concurrency.mutate);
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 });
1257
+ }
1258
+ });
796
1259
  return mutants;
797
1260
  }
798
- async function maybeFinalize(winner, config, driver, trace, tracker, guards) {
799
- if (!config.finalizerModel || !driver.finalize)
800
- return winner.content;
801
- await guards.beforeCall("finalization call");
802
- const result = await driver.finalize({
803
- task: config.task,
804
- rubric: config.rubric,
805
- model: config.finalizerModel,
806
- candidate: winner,
807
- prompt: finalizePrompt(config.task, winner, config.rubric, config.promptStyle, config.promptOverrides?.finalize)
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
+ }
808
1343
  });
809
- const delta = tracker.record(result, { provider: config.provider, model: config.finalizerModel });
810
- await trace.writeUsage(buildUsageRecord({
811
- phase: "finalize",
812
- role: "finalizer",
813
- result,
814
- delta,
815
- provider: result.provider ?? config.provider,
816
- model: result.model ?? config.finalizerModel
817
- }));
818
- await guards.afterCall("finalization call");
819
- await trace.event({ type: "finalized", winner_id: winner.id, model: result.model, provider: result.provider });
820
- return result.text;
1344
+ return finalized.answer;
821
1345
  }
822
- function resultMetadata(result, config, prompt) {
1346
+ function resultMetadata(result, config, promptRef) {
823
1347
  return compactMetadata({
824
1348
  model: result.model,
825
1349
  provider: result.provider,
@@ -831,13 +1355,65 @@ function resultMetadata(result, config, prompt) {
831
1355
  latencyMs: result.latencyMs,
832
1356
  retryCount: result.retryCount,
833
1357
  rawOutput: config.output.includeRawModelOutputs ? result.raw : undefined,
834
- prompt: config.output.includePrompts ? prompt : undefined,
1358
+ promptRef: config.output.includePrompts ? promptRef : undefined,
835
1359
  createdAt: new Date().toISOString()
836
1360
  });
837
1361
  }
838
1362
  function compactMetadata(metadata) {
839
1363
  return Object.fromEntries(Object.entries(metadata).filter(([, value]) => value !== undefined));
840
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
+ }
841
1417
  function summaryProfile(profile) {
842
1418
  return {
843
1419
  n: profile.n,
@@ -878,6 +1454,7 @@ function summaryModels(config) {
878
1454
  function buildUsageRecord(args) {
879
1455
  return {
880
1456
  schema_version: 1,
1457
+ usage_id: randomUUID(),
881
1458
  ts: new Date().toISOString(),
882
1459
  phase: args.phase,
883
1460
  role: args.role,
@@ -892,18 +1469,31 @@ function buildUsageRecord(args) {
892
1469
  output_usd: args.delta.outputUsd,
893
1470
  total_usd: args.delta.usd,
894
1471
  latency_ms: args.result.latencyMs,
895
- retry_count: args.result.retryCount
1472
+ retry_count: args.result.retryCount,
1473
+ outcome: args.outcome,
1474
+ error_code: args.errorCode
896
1475
  };
897
1476
  }
1477
+ async function recordFailedInvocation(args) {
1478
+ const delta = args.tracker.failCall(args.reservation);
1479
+ await bestEffortTraceWrite("failed call usage", () => args.trace.writeUsage(buildUsageRecord({
1480
+ phase: args.phase,
1481
+ role: args.role,
1482
+ result: { text: "" },
1483
+ delta,
1484
+ provider: args.provider,
1485
+ model: args.model,
1486
+ outcome: "failed",
1487
+ errorCode: args.error instanceof DeepThonkError ? args.error.code : args.error instanceof Error ? args.error.name : "unknown_error"
1488
+ })));
1489
+ }
898
1490
  function enforceBudget(config) {
899
- const plan = planBudget(config.profile);
900
- // Each comparison call may fire up to invalidJsonRetries extra requests before the loop gives up.
901
- // Include that worst-case headroom so a user who sets --max-calls = plan.calls isn't tripped by
902
- // a single retry mid-run.
903
- const judgeCalls = plan.per_generation_judge_calls * config.profile.t + plan.final_judge_calls;
904
- const retryHeadroom = judgeCalls * config.retry.invalidJsonRetries;
905
- const finalizerCalls = config.finalizerModel ? 1 : 0;
906
- const plannedCalls = plan.calls + finalizerCalls + retryHeadroom;
1491
+ const plan = planBudget(config.profile, {
1492
+ invalidJsonRetries: config.retry.invalidJsonRetries,
1493
+ includeFinalizer: Boolean(config.finalizerModel)
1494
+ });
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;
907
1497
  if (config.budget?.maxCalls !== undefined && plannedCalls > config.budget.maxCalls) {
908
1498
  throw new BudgetExceededError(`Planned call count ${plannedCalls} exceeds maxCalls ${config.budget.maxCalls}.`, {
909
1499
  code: "budget.planned_calls_exceeded",
@@ -911,6 +1501,64 @@ function enforceBudget(config) {
911
1501
  });
912
1502
  }
913
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
+ }
914
1562
  function cloneUsage(usage) {
915
1563
  return {
916
1564
  ...emptyUsage(),