dsh-llm-verifier 0.4.0 → 0.6.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 (112) hide show
  1. package/README.md +133 -25
  2. package/lib/{auto-B5V49e-A.js → auto-DAMeKRkK.js} +775 -170
  3. package/lib/auto-DAMeKRkK.js.map +1 -0
  4. package/lib/{caller-DR_C7ueP.js → caller-BUK7OgUs.js} +168 -14
  5. package/lib/caller-BUK7OgUs.js.map +1 -0
  6. package/lib/caller.js +2 -2
  7. package/lib/client.js +1847 -846
  8. package/lib/client.js.map +1 -1
  9. package/lib/core.js +267 -11
  10. package/lib/core.js.map +1 -1
  11. package/lib/index.js +2985 -364
  12. package/lib/index.js.map +1 -1
  13. package/lib/replay.js +290 -3
  14. package/lib/replay.js.map +1 -1
  15. package/lib/types/auto.d.ts +69 -2
  16. package/lib/types/auto.d.ts.map +1 -1
  17. package/lib/types/auto.js +216 -23
  18. package/lib/types/auto.js.map +1 -1
  19. package/lib/types/cache.d.ts +9 -0
  20. package/lib/types/cache.d.ts.map +1 -1
  21. package/lib/types/cache.js +1 -1
  22. package/lib/types/cache.js.map +1 -1
  23. package/lib/types/caller.d.ts +23 -0
  24. package/lib/types/caller.d.ts.map +1 -1
  25. package/lib/types/caller.js +180 -14
  26. package/lib/types/caller.js.map +1 -1
  27. package/lib/types/client-fields.d.ts +174 -0
  28. package/lib/types/client-fields.d.ts.map +1 -0
  29. package/lib/types/client-fields.js +456 -0
  30. package/lib/types/client-fields.js.map +1 -0
  31. package/lib/types/client-i18n.d.ts +255 -1
  32. package/lib/types/client-i18n.d.ts.map +1 -1
  33. package/lib/types/client-i18n.js +180 -4
  34. package/lib/types/client-i18n.js.map +1 -1
  35. package/lib/types/client.d.ts +57 -44
  36. package/lib/types/client.d.ts.map +1 -1
  37. package/lib/types/client.js +286 -43
  38. package/lib/types/client.js.map +1 -1
  39. package/lib/types/config.d.ts +36 -0
  40. package/lib/types/config.d.ts.map +1 -1
  41. package/lib/types/config.js +21 -0
  42. package/lib/types/config.js.map +1 -1
  43. package/lib/types/core.d.ts +155 -4
  44. package/lib/types/core.d.ts.map +1 -1
  45. package/lib/types/core.js +271 -10
  46. package/lib/types/core.js.map +1 -1
  47. package/lib/types/engine.d.ts +76 -2
  48. package/lib/types/engine.d.ts.map +1 -1
  49. package/lib/types/engine.js +364 -94
  50. package/lib/types/engine.js.map +1 -1
  51. package/lib/types/index.d.ts.map +1 -1
  52. package/lib/types/index.js +906 -126
  53. package/lib/types/index.js.map +1 -1
  54. package/lib/types/process-activity.d.ts +109 -0
  55. package/lib/types/process-activity.d.ts.map +1 -0
  56. package/lib/types/process-activity.js +135 -0
  57. package/lib/types/process-activity.js.map +1 -0
  58. package/lib/types/process-selection.d.ts +572 -0
  59. package/lib/types/process-selection.d.ts.map +1 -0
  60. package/lib/types/process-selection.js +1183 -0
  61. package/lib/types/process-selection.js.map +1 -0
  62. package/lib/types/replay.d.ts +188 -0
  63. package/lib/types/replay.d.ts.map +1 -1
  64. package/lib/types/replay.js +231 -1
  65. package/lib/types/replay.js.map +1 -1
  66. package/lib/types/router.d.ts +321 -5
  67. package/lib/types/router.d.ts.map +1 -1
  68. package/lib/types/router.js +600 -100
  69. package/lib/types/router.js.map +1 -1
  70. package/lib/types/session.d.ts.map +1 -1
  71. package/lib/types/session.js +5 -1
  72. package/lib/types/session.js.map +1 -1
  73. package/lib/types/statistics.d.ts +92 -0
  74. package/lib/types/statistics.d.ts.map +1 -1
  75. package/lib/types/statistics.js +116 -2
  76. package/lib/types/statistics.js.map +1 -1
  77. package/package.json +2 -2
  78. package/scripts/eval-replay.mjs +55 -5
  79. package/scripts/sync-installed-profiles.mjs +113 -0
  80. package/src/auto.test.ts +195 -4
  81. package/src/auto.ts +249 -28
  82. package/src/cache.ts +10 -1
  83. package/src/caller.test.ts +202 -3
  84. package/src/caller.ts +195 -13
  85. package/src/client-fields.test.ts +309 -0
  86. package/src/client-fields.ts +619 -0
  87. package/src/client-i18n.ts +212 -3
  88. package/src/client.test.ts +45 -2
  89. package/src/client.tsx +470 -162
  90. package/src/config.test.ts +37 -0
  91. package/src/config.ts +57 -0
  92. package/src/core.test.ts +210 -0
  93. package/src/core.ts +327 -10
  94. package/src/engine.test.ts +322 -2
  95. package/src/engine.ts +426 -105
  96. package/src/index.test.ts +1210 -3
  97. package/src/index.ts +880 -110
  98. package/src/parity.test.ts +50 -1
  99. package/src/process-activity.test.ts +91 -0
  100. package/src/process-activity.ts +177 -0
  101. package/src/process-selection.test.ts +911 -0
  102. package/src/process-selection.ts +1429 -0
  103. package/src/replay.test.ts +168 -1
  104. package/src/replay.ts +333 -1
  105. package/src/router.test.ts +725 -1
  106. package/src/router.ts +772 -89
  107. package/src/session.test.ts +34 -0
  108. package/src/session.ts +5 -1
  109. package/src/statistics.test.ts +157 -0
  110. package/src/statistics.ts +178 -2
  111. package/lib/auto-B5V49e-A.js.map +0 -1
  112. package/lib/caller-DR_C7ueP.js.map +0 -1
package/src/engine.ts CHANGED
@@ -1,15 +1,77 @@
1
- import { addUsage, callVerifier, emptyUsage, predictScoringChannel, type ScoringMode, type UsageStats, type VerifierClientConfig, type VerifierImage } from './caller.ts'
1
+ import { addUsage, attachUsage, callVerifier, emptyUsage, partialUsage, predictScoringChannel, requestAttempts, type ScoringMode, type UsageStats, type VerifierClientConfig, type VerifierImage } from './caller.ts'
2
2
  import { ScoreCache, SingleFlight, stableHash, type CachedPairScore } from './cache.ts'
3
3
  import type { DecisionTrace } from './decisions.ts'
4
4
  import {
5
- DEFAULT_CRITERIA, DEFAULT_GROUND_TRUTH_NOTE, accumulatePairs, buildPairwisePrompt, buildProgressPrompt,
6
- dedupeCriterionId, extractProgressScore, extractScore, pivotRoundPairs, rankScores, ringCycle, slugCriterionId,
7
- topPivots, type Criterion,
5
+ DEFAULT_CRITERIA, DEFAULT_GROUND_TRUTH_NOTE, PROPOSAL_CRITERIA, accumulatePairs, buildPairwisePrompt, buildProgressPrompt,
6
+ dedupeCriterionId, diagnosticKey, extractProgressScore, extractScore, parseDiagnostics, pivotRoundPairs, rankScores,
7
+ ringCycle, slugCriterionId, swapDiagnosticEvidence, topPivots, type Criterion, type Diagnostic, type ReviewStage,
8
8
  } from './core.ts'
9
9
 
10
- export interface CompareOptions { problem: string; candidateA: string; candidateB: string; criteria?: readonly Criterion[]; groundTruthNote?: string; repeats?: number; images?: readonly VerifierImage[]; trace?: DecisionTrace; /** Prefix for this comparison's decision-snapshot labels; one invocation that judges twice on the same criteria needs them distinguishable. */ traceLabelPrefix?: string }
10
+ /** Evidence tokens one pairwise prompt shows the judge, exactly as the prompt lists them. */
11
+ const PAIRWISE_EVIDENCE = ['TASK', 'A', 'B'] as const
12
+
13
+ /**
14
+ * Cap on the findings ONE invocation reports, after de-duplication across criteria, repeats and judges.
15
+ *
16
+ * The per-call cap is {@link MAX_DIAGNOSTICS}; the default final acceptance is 3 criteria x 2 repeats,
17
+ * so the aggregate is deliberately a small multiple: the feedback budget (4000 characters) is the real
18
+ * limit, and a list that covers every criterion is not a location, it is a second rubric.
19
+ */
20
+ const MAX_AGGREGATED_DIAGNOSTICS = 6
21
+
22
+ /** Append findings in order, de-duplicated, never exceeding the aggregate cap. */
23
+ function mergeDiagnostics(target: Diagnostic[], seen: Set<string>, incoming: readonly Diagnostic[] | undefined): void {
24
+ for (const diagnostic of incoming ?? []) {
25
+ if (target.length >= MAX_AGGREGATED_DIAGNOSTICS) return
26
+ const key = diagnosticKey(diagnostic)
27
+ if (seen.has(key)) continue
28
+ seen.add(key)
29
+ target.push(diagnostic)
30
+ }
31
+ }
32
+
33
+ export interface CompareOptions {
34
+ problem: string
35
+ candidateA: string
36
+ candidateB: string
37
+ criteria?: readonly Criterion[]
38
+ groundTruthNote?: string
39
+ repeats?: number
40
+ images?: readonly VerifierImage[]
41
+ trace?: DecisionTrace
42
+ /** Prefix for this comparison's decision-snapshot labels; one invocation that judges twice on the same criteria needs them distinguishable. */
43
+ traceLabelPrefix?: string
44
+ /**
45
+ * Which review stage both sides belong to.
46
+ *
47
+ * `proposal` switches the default rubric to {@link PROPOSAL_CRITERIA} and the prompt to the
48
+ * unexecuted-draft framing; omitted (or `artifact`) keeps the historical semantics, so every
49
+ * existing caller, cache key and verdict is unchanged.
50
+ */
51
+ reviewStage?: ReviewStage
52
+ /** Task domain for the prompt's role sentence; normally the criteria preset id. */
53
+ domain?: string
54
+ /**
55
+ * Optional reference context rendered as a separate data-only block between the task and the
56
+ * two candidates (P06's bounded process evidence pack). Omitted renders nothing.
57
+ */
58
+ context?: string
59
+ }
11
60
  export interface CriterionResult { id: string; name: string; scoreA: number; scoreB: number }
12
- export interface RunStats extends UsageStats { cacheHits: number; cacheMisses: number; estimatedCostUsd: number; topLogprobScores: number; explicitTagScores: number }
61
+ export interface RunStats extends UsageStats {
62
+ cacheHits: number
63
+ cacheMisses: number
64
+ estimatedCostUsd: number
65
+ topLogprobScores: number
66
+ explicitTagScores: number
67
+ /**
68
+ * At least one request failed before returning usage. The tokens it may have spent are
69
+ * UNKNOWN, not zero: a failed call must not make the invocation look free.
70
+ */
71
+ usageIncomplete?: boolean
72
+ /** Calls that were attempted on the direct logprob transport and downgraded to explicit tags. */
73
+ channelFallbacks?: number
74
+ }
13
75
 
14
76
  export interface JudgeScore {
15
77
  provider: string
@@ -36,9 +98,33 @@ export interface CompareResult {
36
98
  agreement: number
37
99
  /** Set when both sides were byte-identical: no model call was made and both sides score 0.5. */
38
100
  identical?: true
101
+ /**
102
+ * Verified findings the judge located, de-duplicated and capped.
103
+ *
104
+ * Empty is the normal case and means "nothing locatable was reported" — never "nothing is wrong".
105
+ * A score served from the cache carries none: replaying a finding from an older run would present a
106
+ * past observation as current evidence.
107
+ */
108
+ diagnostics: Diagnostic[]
39
109
  }
40
110
 
41
- export interface SelectOptions { problem: string; candidates: readonly string[]; criteria?: readonly Criterion[]; groundTruthNote?: string; repeats?: number; pivots?: number; seed?: number; images?: readonly VerifierImage[]; trace?: DecisionTrace }
111
+ export interface SelectOptions {
112
+ problem: string
113
+ candidates: readonly string[]
114
+ criteria?: readonly Criterion[]
115
+ groundTruthNote?: string
116
+ repeats?: number
117
+ pivots?: number
118
+ seed?: number
119
+ images?: readonly VerifierImage[]
120
+ trace?: DecisionTrace
121
+ /** Review stage applied to every pair of the tournament; see {@link CompareOptions.reviewStage}. */
122
+ reviewStage?: ReviewStage
123
+ /** Task domain for the prompt's role sentence; normally the criteria preset id. */
124
+ domain?: string
125
+ /** Reference context shown to every pair; see {@link CompareOptions.context}. */
126
+ context?: string
127
+ }
42
128
 
43
129
  export interface SelectResult {
44
130
  index: number
@@ -52,6 +138,8 @@ export interface SelectResult {
52
138
  judges: JudgeScore[]
53
139
  /** Set when every candidate was byte-identical: no pair was judged and every score is 0.5. */
54
140
  identical?: true
141
+ /** Findings the judges located, aggregated across every judged pair (see {@link CompareResult.diagnostics}). */
142
+ diagnostics: Diagnostic[]
55
143
  }
56
144
 
57
145
  function average(values: readonly number[]): number { return values.reduce((sum, value) => sum + value, 0) / (values.length || 1) }
@@ -64,8 +152,105 @@ function median(values: readonly number[]): number {
64
152
  }
65
153
 
66
154
  function blankStats(): RunStats { return { ...emptyUsage(), cacheHits: 0, cacheMisses: 0, estimatedCostUsd: 0, topLogprobScores: 0, explicitTagScores: 0 } }
155
+
156
+ /**
157
+ * Where an invocation records the usage it accumulated before it finally failed.
158
+ *
159
+ * The engine aggregates per-judge usage while jobs are still running, so a later job that
160
+ * throws used to discard everything the earlier jobs had already spent: a run with two
161
+ * successful calls then one failure was persisted as one attempt and zero tokens. Attaching
162
+ * the live stats object to the error keeps every known request and token.
163
+ */
164
+ /**
165
+ * Usage accumulated before an invocation failed; undefined when the error carries none.
166
+ *
167
+ * The carrier may be a bare UsageStats (a caller-level response) or a full RunStats (an engine
168
+ * accumulator). It is normalized here so no caller can merge a partial shape and turn the
169
+ * RunStats-only counters into NaN (which the host serializes as null and rejects).
170
+ */
171
+ export function partialStats(error: unknown): RunStats | undefined {
172
+ const value = partialUsage<Partial<RunStats> & UsageStats>(error)
173
+ if (value === undefined) return undefined
174
+ return {
175
+ ...blankStats(),
176
+ ...value,
177
+ cacheHits: value.cacheHits ?? 0,
178
+ cacheMisses: value.cacheMisses ?? 0,
179
+ estimatedCostUsd: value.estimatedCostUsd ?? 0,
180
+ topLogprobScores: value.topLogprobScores ?? 0,
181
+ explicitTagScores: value.explicitTagScores ?? 0,
182
+ }
183
+ }
184
+
185
+ /** A failed judge result knows its usage through one of two carriers; fold whichever it has. */
186
+ function accountFailure(stats: RunStats, error: unknown): void {
187
+ const partial = partialStats(error)
188
+ if (partial !== undefined) {
189
+ mergeRunStats(stats, partial)
190
+ return
191
+ }
192
+ // The request never returned usage. The attempt count is the one known fact.
193
+ const attempts = requestAttempts(error)
194
+ stats.attempts += attempts
195
+ stats.retries += Math.max(0, attempts - 1)
196
+ }
197
+
198
+ /**
199
+ * Fold one nested run's counters into an accumulator (usage, cache, channel, incompleteness).
200
+ *
201
+ * Shared with index.ts so a multi-phase tool (best-of-N) accumulates generation, tournament and
202
+ * baseline usage the same way. Never folds a value into itself, and zero-fills the RunStats-only
203
+ * counters so a partial source cannot poison the totals with NaN.
204
+ */
205
+ export function mergeRunStats(target: RunStats, source: (UsageStats & Partial<RunStats>) | undefined): void {
206
+ if (source === undefined || source === target) return
207
+ addUsage(target, source)
208
+ target.cacheHits += source.cacheHits ?? 0
209
+ target.cacheMisses += source.cacheMisses ?? 0
210
+ target.topLogprobScores += source.topLogprobScores ?? 0
211
+ target.explicitTagScores += source.explicitTagScores ?? 0
212
+ if (source.usageIncomplete) target.usageIncomplete = true
213
+ if (source.channelFallbacks) target.channelFallbacks = (target.channelFallbacks ?? 0) + source.channelFallbacks
214
+ }
215
+
216
+ /** Keep a billed-but-unusable response's usage on its error. */
217
+ function attachBilled(error: unknown, usage: UsageStats): void {
218
+ const billed = blankStats()
219
+ addUsage(billed, usage)
220
+ if (usage.usageIncomplete) billed.usageIncomplete = true
221
+ attachUsage(error, billed)
222
+ }
67
223
  /** Candidate indices best-first by score, ties broken by index. */
68
224
  function rankByScore(scores: readonly number[]): number[] { return Array.from({ length: scores.length }, (_, index) => index).sort((a, b) => (scores[b] ?? 0) - (scores[a] ?? 0) || a - b) }
225
+
226
+ /**
227
+ * Rewrite one pair's `A`/`B` finding into the tournament's candidate identity.
228
+ *
229
+ * The label is 1-based on purpose: the automatic selection feedback locates candidates as `[N]`,
230
+ * and the tool result is read by the same agent that reads that feedback.
231
+ */
232
+ function locatePairDiagnostic(diagnostic: Diagnostic, a: number, b: number): Diagnostic {
233
+ if (diagnostic.evidence === 'A') return { ...diagnostic, evidence: 'candidate ' + (a + 1) }
234
+ if (diagnostic.evidence === 'B') return { ...diagnostic, evidence: 'candidate ' + (b + 1) }
235
+ return diagnostic
236
+ }
237
+
238
+ /**
239
+ * Renumber one deduplicated selection's finding onto the caller's original candidate list.
240
+ *
241
+ * {@link locatePairDiagnostic} numbers the DISTINCT candidates the tournament judged; the caller
242
+ * passed a list that may repeat entries, so `candidate 2` of the compressed list can be
243
+ * `candidate 3` of the caller's. Anything that is not a candidate reference is left alone.
244
+ * @param diagnostic - one aggregated finding.
245
+ * @param originals - distinct index -> the caller's first index of that candidate.
246
+ * @returns The finding, renumbered when it names a candidate.
247
+ */
248
+ function remapCandidateDiagnostic(diagnostic: Diagnostic, originals: readonly number[]): Diagnostic {
249
+ const match = /^candidate (\d+)$/u.exec(diagnostic.evidence)
250
+ if (match === null) return diagnostic
251
+ const original = originals[Number(match[1]) - 1]
252
+ return original === undefined ? diagnostic : { ...diagnostic, evidence: 'candidate ' + (original + 1) }
253
+ }
69
254
  function unorderedPair(a: number, b: number): string { return a < b ? a + ',' + b : b + ',' + a }
70
255
 
71
256
  /**
@@ -154,9 +339,26 @@ export class VerifierEngine {
154
339
  return stats
155
340
  }
156
341
 
157
- private async scoreOne(client: VerifierClientConfig, options: CompareOptions, candidateA: string, candidateB: string, criterion: Criterion, repeat: number, signal?: AbortSignal): Promise<{ scores: readonly [number, number]; usage: UsageStats; scoringMode: 'top-logprobs' | 'explicit-tag'; hit: boolean }> {
342
+ /**
343
+ * Prompt framing for one comparison.
344
+ *
345
+ * Rebuilt from the stage/domain the caller declared. Both fields are part of the RENDERED
346
+ * prompt, so the score cache keys on them automatically; the cache's `version` does not need
347
+ * to move when only this framing changes.
348
+ * @param options - the comparison's options.
349
+ * @returns Stage and domain to forward to {@link buildPairwisePrompt}.
350
+ */
351
+ private framing(options: CompareOptions): { stage?: ReviewStage; domain?: string; context?: string } {
352
+ return {
353
+ ...(options.reviewStage === undefined ? {} : { stage: options.reviewStage }),
354
+ ...(options.domain === undefined ? {} : { domain: options.domain }),
355
+ ...(options.context === undefined ? {} : { context: options.context }),
356
+ }
357
+ }
358
+
359
+ private async scoreOne(client: VerifierClientConfig, options: CompareOptions, candidateA: string, candidateB: string, criterion: Criterion, repeat: number, signal?: AbortSignal): Promise<{ scores: readonly [number, number]; usage: UsageStats; scoringMode: 'top-logprobs' | 'explicit-tag'; hit: boolean; channelFallback: boolean; diagnostics: Diagnostic[] }> {
158
360
  const ground = options.groundTruthNote ?? DEFAULT_GROUND_TRUTH_NOTE
159
- const prompt = buildPairwisePrompt(options.problem, candidateA, candidateB, criterion, ground)
361
+ const prompt = buildPairwisePrompt(options.problem, candidateA, candidateB, criterion, ground, this.framing(options))
160
362
  const imageKey = options.images?.map(image => stableHash([image.mediaType, Buffer.from(image.data).toString('base64')]))
161
363
  // Cache identity pins the scoring channel, but the channel is only predictable
162
364
  // before the call when the capability cache already knows the answer. Lookups use
@@ -166,17 +368,30 @@ export class VerifierEngine {
166
368
  // expectation. The channel stays OUT of the in-flight dedup key below.
167
369
  const identity = { version: 6, provider: client.provider, model: client.model, effort: client.reasoningEffort, maxTokens: client.maxTokens, temperature: client.temperature, repeat, promptHash: stableHash(prompt), imageKey }
168
370
  const keyForMode = (scoringMode: ScoringMode) => stableHash({ ...identity, scoringMode })
371
+ // Captured out of band: a fallback describes the REQUEST, not the score, so it must not
372
+ // enter the cached value (a cache hit later must not claim a downgrade that never happened).
373
+ let fellBack = false
169
374
  const create = async () => {
170
375
  const completion = await callVerifier(client, prompt, signal, options.images)
171
- const scoreA = extractScore(completion, '<score_A>')
172
- const scoreB = extractScore(completion, '<score_B>')
173
- // Traced here, not in scoreOne(): a cache hit or a merged in-flight call makes no
174
- // model call, and a snapshot that showed one anyway would be a fabrication.
175
- options.trace?.({ label: (options.traceLabelPrefix ?? '') + criterion.name + ' repeat ' + (repeat + 1), channel: completion.scoringMode, prompt, output: completion.text, score: scoreA })
176
- return { scoreA, scoreB, usage: completion.usage, scoringMode: completion.scoringMode, createdAt: Date.now() }
376
+ try {
377
+ const scoreA = extractScore(completion, '<score_A>')
378
+ const scoreB = extractScore(completion, '<score_B>')
379
+ fellBack = completion.channelFallback === true
380
+ // Traced here, not in scoreOne(): a cache hit or a merged in-flight call makes no
381
+ // model call, and a snapshot that showed one anyway would be a fabrication.
382
+ options.trace?.({ label: (options.traceLabelPrefix ?? '') + criterion.name + ' repeat ' + (repeat + 1), channel: completion.scoringMode, prompt, output: completion.text, score: scoreA })
383
+ // Parsed against exactly what this prompt offered; anything unverifiable is dropped.
384
+ const diagnostics = parseDiagnostics(completion.text, { criteria: [criterion.name, criterion.id], evidence: PAIRWISE_EVIDENCE })
385
+ return { scoreA, scoreB, usage: completion.usage, scoringMode: completion.scoringMode, diagnostics, createdAt: Date.now() }
386
+ } catch (error) {
387
+ // The response came back and was billed, but carried no usable score. Keep its usage
388
+ // on the error so the failed row reports known requests and tokens, not zero.
389
+ attachBilled(error, completion.usage)
390
+ throw error
391
+ }
177
392
  }
178
393
  const cache = this.cache
179
- if (cache === undefined) { const value = await create(); return { scores: [value.scoreA, value.scoreB], usage: value.usage, scoringMode: value.scoringMode, hit: false } }
394
+ if (cache === undefined) { const value = await create(); return { scores: [value.scoreA, value.scoreB], usage: value.usage, scoringMode: value.scoringMode, hit: false, channelFallback: fellBack, diagnostics: value.diagnostics } }
180
395
  // Registration happens in the synchronous segment before any await, so a request
181
396
  // that arrives while the first one is still resolving its channel prediction
182
397
  // still merges instead of duplicating the model call. The flight table is shared
@@ -185,13 +400,37 @@ export class VerifierEngine {
185
400
  const outcome = await this.flights.run(dedupeKey, async () => cache.getOrCreate(keyForMode(await predictScoringChannel(client)), create, value => keyForMode(value.scoringMode)))
186
401
  const landed = outcome.value
187
402
  const reused = outcome.joined || landed.hit
188
- return { scores: [landed.value.scoreA, landed.value.scoreB], usage: reused ? emptyUsage() : landed.value.usage, scoringMode: landed.value.scoringMode, hit: reused }
403
+ // Findings ride along with the cache entry: the key is the rendered prompt, so they describe the
404
+ // very evidence that produced this score. An entry written before P04 simply has none.
405
+ return { scores: [landed.value.scoreA, landed.value.scoreB], usage: reused ? emptyUsage() : landed.value.usage, scoringMode: landed.value.scoringMode, hit: reused, channelFallback: reused ? false : fellBack, diagnostics: landed.value.diagnostics ?? [] }
189
406
  }
190
407
 
191
408
  private async mapLimited<T, R>(items: readonly T[], worker: (item: T) => Promise<R>): Promise<R[]> {
192
409
  const results = new Array<R>(items.length); let cursor = 0
193
- const runners = Array.from({ length: Math.min(this.maxConcurrency, items.length) }, async () => { while (cursor < items.length) { const index = cursor++; results[index] = await worker(items[index]!) } })
194
- await Promise.all(runners); return results
410
+ let failed = false
411
+ let failure: unknown
412
+ const runners = Array.from({ length: Math.min(this.maxConcurrency, items.length) }, async () => {
413
+ while (!failed && cursor < items.length) {
414
+ const index = cursor++
415
+ try { results[index] = await worker(items[index]!) }
416
+ catch (error) {
417
+ // Stop buying NEW work, but let every in-flight call settle first: it was already
418
+ // paid for, and its usage is merged into the shared accumulator the error carries.
419
+ // Rejecting immediately (the old Promise.all) discarded those calls' usage.
420
+ if (!failed) { failed = true; failure = error }
421
+ }
422
+ }
423
+ })
424
+ await Promise.all(runners)
425
+ if (failed) {
426
+ const partial = partialStats(failure)
427
+ // All workers have settled, so the accumulator is final. partialStats returns a normalized
428
+ // COPY, so pricing it in place would be discarded: price it and write it back onto the
429
+ // error the failure row actually reads.
430
+ if (partial !== undefined) attachUsage(failure, this.finishStats(partial))
431
+ throw failure
432
+ }
433
+ return results
195
434
  }
196
435
 
197
436
  /**
@@ -216,11 +455,16 @@ export class VerifierEngine {
216
455
  judges,
217
456
  agreement: 1,
218
457
  identical: true,
458
+ // No judge ran, so nothing was located: an informational tie has nothing to report.
459
+ diagnostics: [],
219
460
  }
220
461
  }
221
462
 
222
463
  async compare(options: CompareOptions, signal?: AbortSignal): Promise<CompareResult> {
223
- const criteria = options.criteria?.length ? options.criteria : DEFAULT_CRITERIA
464
+ // An unexecuted proposal has no observed output to match, so scoring it with the artifact
465
+ // rubric ("Output Match") fails it by construction. The stage therefore selects the default
466
+ // rubric; an explicit `criteria` always wins and is used verbatim.
467
+ const criteria = options.criteria?.length ? options.criteria : options.reviewStage === 'proposal' ? PROPOSAL_CRITERIA : DEFAULT_CRITERIA
224
468
  if (options.candidateA === options.candidateB) return this.informationalTie(criteria)
225
469
  const repeats = options.repeats ?? 2
226
470
  const jobs = criteria.flatMap(criterion => Array.from({ length: repeats }, (_, repeat) => ({ criterion, repeat })))
@@ -236,6 +480,18 @@ export class VerifierEngine {
236
480
  if (warmedOrientations.has(orientation)) rest.push(job)
237
481
  else { warmedOrientations.add(orientation); warm.push(job) }
238
482
  }
483
+ // Shared with the workers so a job that fails still reports what the earlier jobs spent:
484
+ // aggregating only after every job resolved discarded all of it when one threw. A run with
485
+ // two successful judge calls and one failure used to persist one attempt and zero tokens.
486
+ const stats = blankStats()
487
+ const judgeCalls = new Array<number>(this.clients.length).fill(0)
488
+ const judgeOk = new Array<boolean>(this.clients.length).fill(true)
489
+ const judgeErrors = new Array<string | undefined>(this.clients.length).fill(undefined)
490
+ const judgeJobScores: Array<Array<{ criterionId: string; scoreA: number; scoreB: number }>> = Array.from(
491
+ { length: this.clients.length },
492
+ () => []
493
+ )
494
+
239
495
  const run = async (batch: typeof jobs) => this.mapLimited(batch, async ({ criterion, repeat }) => {
240
496
  const swapped = repeat % 2 === 1
241
497
  const candA = swapped ? options.candidateB : options.candidateA
@@ -252,6 +508,10 @@ export class VerifierEngine {
252
508
  usage: res.usage,
253
509
  scoringMode: res.scoringMode,
254
510
  hit: res.hit,
511
+ channelFallback: res.channelFallback,
512
+ // This round rendered the swapped slots; the scores are mapped back above, so the
513
+ // findings must be too, or a defect in the caller's A is reported against B.
514
+ diagnostics: swapped ? res.diagnostics.map(swapDiagnosticEvidence) : res.diagnostics,
255
515
  }
256
516
  } catch (error) {
257
517
  return {
@@ -262,44 +522,47 @@ export class VerifierEngine {
262
522
  }
263
523
  })
264
524
  )
265
- const successful = judgeResults.filter((r): r is Extract<typeof r, { ok: true }> => r.ok)
266
- if (successful.length === 0) {
267
- const firstFail = judgeResults.find(r => !r.ok)! as Extract<typeof judgeResults[number], { ok: false }>
268
- throw firstFail.error
269
- }
270
- const leafScoreA = median(successful.map(r => r.scoreA))
271
- const leafScoreB = median(successful.map(r => r.scoreB))
272
- return { criterion, repeat, leafScoreA, leafScoreB, judgeResults }
273
- })
274
-
275
- const jobOutcomes = [...await run(warm), ...await run(rest)]
276
- const stats = blankStats()
277
- const judgeCalls = new Array<number>(this.clients.length).fill(0)
278
- const judgeOk = new Array<boolean>(this.clients.length).fill(true)
279
- const judgeErrors = new Array<string | undefined>(this.clients.length).fill(undefined)
280
- const judgeJobScores: Array<Array<{ criterionId: string; scoreA: number; scoreB: number }>> = Array.from(
281
- { length: this.clients.length },
282
- () => []
283
- )
284
-
285
- for (const outcome of jobOutcomes) {
286
- for (const r of outcome.judgeResults) {
525
+ for (const r of judgeResults) {
287
526
  if (r.ok) {
288
527
  judgeCalls[r.k] += r.usage.calls
289
- judgeJobScores[r.k]!.push({ criterionId: outcome.criterion.id, scoreA: r.scoreA, scoreB: r.scoreB })
528
+ judgeJobScores[r.k]!.push({ criterionId: criterion.id, scoreA: r.scoreA, scoreB: r.scoreB })
290
529
  addUsage(stats, r.usage)
530
+ if (r.usage.usageIncomplete) stats.usageIncomplete = true
291
531
  if (r.hit) stats.cacheHits++
292
532
  else stats.cacheMisses++
293
533
  if (r.scoringMode === 'top-logprobs') stats.topLogprobScores++
294
534
  else stats.explicitTagScores++
535
+ if (r.channelFallback) stats.channelFallbacks = (stats.channelFallbacks ?? 0) + 1
295
536
  } else {
296
537
  judgeOk[r.k] = false
538
+ // The failed request really happened; fold in whatever accounting it carries (billed
539
+ // but unusable usage, or just its attempt count) and mark the row incomplete rather
540
+ // than reporting a confident zero-cost call.
541
+ accountFailure(stats, r.error)
542
+ stats.usageIncomplete = true
297
543
  if (judgeErrors[r.k] === undefined) {
298
544
  judgeErrors[r.k] = r.error instanceof Error ? r.error.message : String(r.error)
299
545
  }
300
546
  }
301
547
  }
302
- }
548
+ const successful = judgeResults.filter((r): r is Extract<typeof r, { ok: true }> => r.ok)
549
+ if (successful.length === 0) {
550
+ const firstFail = judgeResults.find(r => !r.ok)! as Extract<typeof judgeResults[number], { ok: false }>
551
+ // Carry the usage the earlier jobs already spent on this error.
552
+ attachUsage(firstFail.error, stats)
553
+ throw firstFail.error
554
+ }
555
+ const leafScoreA = median(successful.map(r => r.scoreA))
556
+ const leafScoreB = median(successful.map(r => r.scoreB))
557
+ return { criterion, repeat, leafScoreA, leafScoreB, judgeResults, diagnostics: successful.flatMap(r => r.diagnostics) }
558
+ })
559
+
560
+ const jobOutcomes = [...await run(warm), ...await run(rest)]
561
+ // Findings are located information, not a score: they are aggregated across criteria, repeats and
562
+ // judges but de-duplicated and capped, and they never enter the score arithmetic.
563
+ const diagnostics: Diagnostic[] = []
564
+ const diagnosticSeen = new Set<string>()
565
+ for (const row of jobOutcomes) mergeDiagnostics(diagnostics, diagnosticSeen, row.diagnostics)
303
566
 
304
567
  const byCriterion = criteria.map(criterion => {
305
568
  const rows = jobOutcomes.filter(row => row.criterion.id === criterion.id)
@@ -364,6 +627,7 @@ export class VerifierEngine {
364
627
  stats: this.finishStats(stats),
365
628
  judges,
366
629
  agreement,
630
+ diagnostics,
367
631
  }
368
632
  }
369
633
 
@@ -374,40 +638,29 @@ export class VerifierEngine {
374
638
  judgeErrors: Array<string | undefined>
375
639
  judgeCalls: number[]
376
640
  stats: RunStats
641
+ diagnostics: Diagnostic[]
377
642
  }> {
378
643
  const unique = [...new Map(pairs.map(pair => [pair[0] + ',' + pair[1], pair])).values()]
379
- const values = await this.mapLimited(unique, async ([a, b]) => ({
380
- a,
381
- b,
382
- result: await this.compare({
383
- problem: options.problem,
384
- candidateA: options.candidates[a]!,
385
- candidateB: options.candidates[b]!,
386
- criteria: options.criteria,
387
- groundTruthNote: options.groundTruthNote,
388
- repeats: options.repeats,
389
- images: options.images,
390
- trace: options.trace,
391
- }, signal),
392
- }))
644
+ // Aggregated inside each worker so a pair that fails still reports what the other pairs
645
+ // already spent; the outer loop used to run only after every pair had resolved.
393
646
  const rewards = new Map<string, readonly [number, number]>()
394
647
  const judgeRewards: Array<Map<string, readonly [number, number]>> = Array.from({ length: this.clients.length }, () => new Map())
395
648
  const judgeOk = new Array<boolean>(this.clients.length).fill(true)
396
649
  const judgeErrors = new Array<string | undefined>(this.clients.length).fill(undefined)
397
650
  const judgeCalls = new Array<number>(this.clients.length).fill(0)
398
651
  const stats = blankStats()
399
-
400
- for (const value of values) {
401
- const pairKey = value.a + ',' + value.b
402
- rewards.set(pairKey, [value.result.scoreA, value.result.scoreB])
403
- addUsage(stats, value.result.stats)
404
- stats.cacheHits += value.result.stats.cacheHits
405
- stats.cacheMisses += value.result.stats.cacheMisses
406
- stats.topLogprobScores += value.result.stats.topLogprobScores
407
- stats.explicitTagScores += value.result.stats.explicitTagScores
408
-
652
+ const diagnostics: Diagnostic[] = []
653
+ const diagnosticSeen = new Set<string>()
654
+ const recordPair = (a: number, b: number, result: CompareResult): void => {
655
+ const pairKey = a + ',' + b
656
+ rewards.set(pairKey, [result.scoreA, result.scoreB])
657
+ mergeRunStats(stats, result.stats)
658
+ // A pair's findings reference ITS two slots. A tournament ranks N candidates, so the slots
659
+ // are rewritten into the original candidate identity before they are aggregated: reporting
660
+ // "evidence B" for a pair the caller never saw as A/B points the agent at the wrong object.
661
+ mergeDiagnostics(diagnostics, diagnosticSeen, result.diagnostics.map(diagnostic => locatePairDiagnostic(diagnostic, a, b)))
409
662
  for (let k = 0; k < this.clients.length; k++) {
410
- const js = value.result.judges[k]!
663
+ const js = result.judges[k]!
411
664
  judgeCalls[k] += js.calls
412
665
  if (js.ok && js.scoreA !== undefined && js.scoreB !== undefined) {
413
666
  judgeRewards[k]!.set(pairKey, [js.scoreA, js.scoreB])
@@ -419,10 +672,34 @@ export class VerifierEngine {
419
672
  }
420
673
  }
421
674
  }
422
- return { rewards, judgeRewards, judgeOk, judgeErrors, judgeCalls, stats: this.finishStats(stats) }
675
+ await this.mapLimited(unique, async ([a, b]) => {
676
+ try {
677
+ const result = await this.compare({
678
+ problem: options.problem,
679
+ candidateA: options.candidates[a]!,
680
+ candidateB: options.candidates[b]!,
681
+ criteria: options.criteria,
682
+ groundTruthNote: options.groundTruthNote,
683
+ repeats: options.repeats,
684
+ images: options.images,
685
+ trace: options.trace,
686
+ ...(options.reviewStage === undefined ? {} : { reviewStage: options.reviewStage }),
687
+ ...(options.domain === undefined ? {} : { domain: options.domain }),
688
+ ...(options.context === undefined ? {} : { context: options.context }),
689
+ }, signal)
690
+ recordPair(a, b, result)
691
+ } catch (error) {
692
+ // Merge this pair's own partial usage into the shared accumulator, then hand the union
693
+ // to the caller's failure row.
694
+ mergeRunStats(stats, partialStats(error))
695
+ attachUsage(error, stats)
696
+ throw error
697
+ }
698
+ })
699
+ return { rewards, judgeRewards, judgeOk, judgeErrors, judgeCalls, stats: this.finishStats(stats), diagnostics }
423
700
  }
424
701
 
425
- async track(problem: string, steps: readonly string[], checkpoints: readonly number[], repeats = 2, signal?: AbortSignal, images?: readonly VerifierImage[], trace?: DecisionTrace): Promise<{ scores: number[]; perRepeat: number[][]; calls: number; stats: RunStats; judges: JudgeScore[] }> {
702
+ async track(problem: string, steps: readonly string[], checkpoints: readonly number[], repeats = 2, signal?: AbortSignal, images?: readonly VerifierImage[], trace?: DecisionTrace): Promise<{ scores: number[]; perRepeat: number[][]; calls: number; stats: RunStats; judges: JudgeScore[]; diagnostics: Diagnostic[] }> {
426
703
  if (!steps.length || !checkpoints.length) throw new Error('llm-verifier: steps and checkpoints must not be empty')
427
704
  for (const checkpoint of checkpoints) if (!Number.isSafeInteger(checkpoint) || checkpoint < 1 || checkpoint > steps.length) throw new Error('llm-verifier: each checkpoint must be an integer between 1 and steps.length')
428
705
  const prompt = buildProgressPrompt(problem, steps, checkpoints)
@@ -438,16 +715,27 @@ export class VerifierEngine {
438
715
  this.clients.map(async (client, k) => {
439
716
  try {
440
717
  const completion = await callVerifier(client, prompt, signal, images)
441
- const scores = checkpoints.map((_, index) => extractProgressScore(completion, '<c' + (index + 1) + '>'))
442
- // One snapshot per repeat for the primary judge only: extra judges would
443
- // multiply the record past its budget, and the primary is what the verdict
444
- // reports.
445
- if (k === 0) trace?.({ label: 'progress repeat ' + (repeatIndex + 1) + '/' + repeats + (this.clients.length > 1 ? ' judge 1/' + this.clients.length : ''), channel: completion.scoringMode, prompt, output: completion.text, score: scores[scores.length - 1] })
446
- return {
447
- k,
448
- ok: true as const,
449
- completion,
450
- scores,
718
+ try {
719
+ const scores = checkpoints.map((_, index) => extractProgressScore(completion, '<c' + (index + 1) + '>'))
720
+ // One snapshot per repeat for the primary judge only: extra judges would
721
+ // multiply the record past its budget, and the primary is what the verdict
722
+ // reports.
723
+ if (k === 0) trace?.({ label: 'progress repeat ' + (repeatIndex + 1) + '/' + repeats + (this.clients.length > 1 ? ' judge 1/' + this.clients.length : ''), channel: completion.scoringMode, prompt, output: completion.text, score: scores[scores.length - 1] })
724
+ // The prompt offered the checkpoint labels c1..cN and the task block as citable
725
+ // evidence; anything else the judge cites is dropped by the parser.
726
+ const labels = checkpoints.map((_, index) => 'c' + (index + 1))
727
+ const diagnostics = parseDiagnostics(completion.text, { checkpoints: labels, evidence: ['TASK', ...labels] })
728
+ return {
729
+ k,
730
+ ok: true as const,
731
+ completion,
732
+ scores,
733
+ diagnostics,
734
+ }
735
+ } catch (error) {
736
+ // The response came back and was billed; a failed progress parse must keep its usage.
737
+ attachBilled(error, completion.usage)
738
+ throw error
451
739
  }
452
740
  } catch (error) {
453
741
  return {
@@ -459,35 +747,53 @@ export class VerifierEngine {
459
747
  })
460
748
  )
461
749
 
462
- const successful = judgeResults.filter((r): r is Extract<typeof r, { ok: true }> => r.ok)
463
- if (successful.length === 0) {
464
- const firstFail = judgeResults.find(r => !r.ok)! as Extract<typeof judgeResults[number], { ok: false }>
465
- throw firstFail.error
466
- }
467
-
750
+ // Aggregate BEFORE the all-failed check so an earlier repeat's usage survives a later
751
+ // repeat throwing; the error then carries it to the failure row.
468
752
  for (const r of judgeResults) {
469
753
  if (r.ok) {
470
754
  judgeCalls[r.k] += r.completion.usage.calls
471
755
  judgePerRepeatScores[r.k]!.push(r.scores)
472
756
  addUsage(stats, r.completion.usage)
757
+ if (r.completion.usage.usageIncomplete) stats.usageIncomplete = true
473
758
  if (r.completion.scoringMode === 'top-logprobs') stats.topLogprobScores++
474
759
  else stats.explicitTagScores++
760
+ if (r.completion.channelFallback) stats.channelFallbacks = (stats.channelFallbacks ?? 0) + 1
475
761
  } else {
476
762
  judgeOk[r.k] = false
763
+ accountFailure(stats, r.error)
764
+ stats.usageIncomplete = true
477
765
  if (judgeErrors[r.k] === undefined) {
478
766
  judgeErrors[r.k] = r.error instanceof Error ? r.error.message : String(r.error)
479
767
  }
480
768
  }
481
769
  }
482
770
 
483
- return checkpoints.map((_, cIndex) => median(successful.map(r => r.scores[cIndex]!)))
771
+ const successful = judgeResults.filter((r): r is Extract<typeof r, { ok: true }> => r.ok)
772
+ if (successful.length === 0) {
773
+ const firstFail = judgeResults.find(r => !r.ok)! as Extract<typeof judgeResults[number], { ok: false }>
774
+ attachUsage(firstFail.error, stats)
775
+ throw firstFail.error
776
+ }
777
+
778
+ const diagnostics: Diagnostic[] = []
779
+ const diagnosticSeen = new Set<string>()
780
+ for (const row of successful) mergeDiagnostics(diagnostics, diagnosticSeen, row.diagnostics)
781
+ return { scores: checkpoints.map((_, cIndex) => median(successful.map(r => r.scores[cIndex]!))), diagnostics }
484
782
  }
485
783
  // Every repeat sends the SAME prompt, so the first repeat warms the provider prefix cache for
486
784
  // the others; fanning all repeats out at once left every call cold. The repeat count is
487
785
  // unchanged — only the order, and therefore the cache hits, change.
488
786
  const runs: number[][] = []
489
- if (repeatIndices.length > 0) runs.push(...await this.mapLimited(repeatIndices.slice(0, 1), runRepeat))
490
- runs.push(...await this.mapLimited(repeatIndices.slice(1), runRepeat))
787
+ const diagnostics: Diagnostic[] = []
788
+ const diagnosticSeen = new Set<string>()
789
+ const collect = (outcomes: Array<{ scores: number[]; diagnostics: Diagnostic[] }>): void => {
790
+ for (const outcome of outcomes) {
791
+ runs.push(outcome.scores)
792
+ mergeDiagnostics(diagnostics, diagnosticSeen, outcome.diagnostics)
793
+ }
794
+ }
795
+ if (repeatIndices.length > 0) collect(await this.mapLimited(repeatIndices.slice(0, 1), runRepeat))
796
+ collect(await this.mapLimited(repeatIndices.slice(1), runRepeat))
491
797
 
492
798
  const scores = checkpoints.map((_, index) => average(runs.map(run => run[index]!)))
493
799
  const judges: JudgeScore[] = this.clients.map((client, k) => {
@@ -514,7 +820,7 @@ export class VerifierEngine {
514
820
  }
515
821
  })
516
822
 
517
- return { scores, perRepeat: runs, calls: stats.calls, stats: this.finishStats(stats), judges }
823
+ return { scores, perRepeat: runs, calls: stats.calls, stats: this.finishStats(stats), judges, diagnostics }
518
824
  }
519
825
 
520
826
  /**
@@ -541,6 +847,7 @@ export class VerifierEngine {
541
847
  stats: blankStats(),
542
848
  judges: this.clients.map(client => ({ provider: client.provider, model: client.model, label: judgeLabel(client), ok: true, calls: 0, scores: [...scores], ranking: [...ranking] })),
543
849
  identical: true,
850
+ diagnostics: [],
544
851
  }
545
852
  }
546
853
 
@@ -557,11 +864,13 @@ export class VerifierEngine {
557
864
  */
558
865
  private async selectUnique(options: SelectOptions, signal?: AbortSignal): Promise<SelectResult> {
559
866
  const unique: string[] = []
867
+ /** unique index -> the caller's FIRST index of that candidate (the inverse of `representative`). */
868
+ const originals: number[] = []
560
869
  const representative: number[] = []
561
870
  const seen = new Map<string, number>()
562
871
  for (const candidate of options.candidates) {
563
872
  let index = seen.get(candidate)
564
- if (index === undefined) { index = unique.length; unique.push(candidate); seen.set(candidate, index) }
873
+ if (index === undefined) { index = unique.length; unique.push(candidate); seen.set(candidate, index); originals.push(representative.length) }
565
874
  representative.push(index)
566
875
  }
567
876
  const result = await this.select({ ...options, candidates: unique }, signal)
@@ -572,6 +881,12 @@ export class VerifierEngine {
572
881
  const judgeScores = expand(judge.scores)
573
882
  return { ...judge, scores: judgeScores, ...(judge.ranking === undefined ? {} : { ranking: rankByScore(judgeScores) }) }
574
883
  })
884
+ // The tournament numbered the DISTINCT candidates. The caller never saw that list, so a finding
885
+ // has to be renumbered onto the original indices exactly like the scores and rankings above —
886
+ // otherwise "candidate 2" of a deduplicated list points at a different entry of the caller's.
887
+ const diagnostics: Diagnostic[] = []
888
+ const diagnosticSeen = new Set<string>()
889
+ for (const diagnostic of result.diagnostics) mergeDiagnostics(diagnostics, diagnosticSeen, [remapCandidateDiagnostic(diagnostic, originals)])
575
890
  return {
576
891
  index: options.candidates.indexOf(result.best),
577
892
  best: result.best,
@@ -582,6 +897,7 @@ export class VerifierEngine {
582
897
  calls: result.calls,
583
898
  stats: result.stats,
584
899
  judges,
900
+ diagnostics,
585
901
  }
586
902
  }
587
903
 
@@ -600,13 +916,13 @@ export class VerifierEngine {
600
916
  scores: [1],
601
917
  ranking: [0],
602
918
  }))
603
- return { index: 0, best: options.candidates[0]!, scores: [1], ranking: [0], pivots: [0], comparisons: 0, calls: 0, stats: blankStats(), judges }
919
+ return { index: 0, best: options.candidates[0]!, scores: [1], ranking: [0], pivots: [0], comparisons: 0, calls: 0, stats: blankStats(), judges, diagnostics: [] }
604
920
  }
605
921
  if (options.candidates.length === 2) {
606
922
  // ringCycle(2) would judge the single unordered pair in both directions; play it
607
923
  // once, in the orientation orientPair() picks so the slot is not always candidate 0.
608
924
  const single = orientPair(0, 1)
609
- const { rewards, judgeRewards, judgeOk, judgeErrors, judgeCalls, stats } = await this.scorePairs(options, [single], signal)
925
+ const { rewards, judgeRewards, judgeOk, judgeErrors, judgeCalls, stats, diagnostics } = await this.scorePairs(options, [single], signal)
610
926
  const wins = [0, 0]; const counts = [0, 0]
611
927
  accumulatePairs([single], rewards, wins, counts)
612
928
  const ranked = rankScores(wins, counts); const index = ranked[0]!.index
@@ -635,7 +951,7 @@ export class VerifierEngine {
635
951
  ranking: jRanked.map(v => v.index),
636
952
  }
637
953
  })
638
- return { index, best: options.candidates[index]!, scores: wins.map((value, candidate) => value / (counts[candidate] || 1)), ranking: ranked.map(value => value.index), pivots: [], comparisons: 1, calls: stats.calls, stats, judges }
954
+ return { index, best: options.candidates[index]!, scores: wins.map((value, candidate) => value / (counts[candidate] || 1)), ranking: ranked.map(value => value.index), pivots: [], comparisons: 1, calls: stats.calls, stats, judges, diagnostics }
639
955
  }
640
956
  const ring = ringCycle(options.candidates.length, options.seed ?? 0)
641
957
  const ringScores = await this.scorePairs(options, ring, signal)
@@ -648,7 +964,16 @@ export class VerifierEngine {
648
964
  // single match so wins/counts are not double-weighted and no pair is judged twice.
649
965
  const ringPairs = new Set(ring.map(pair => unorderedPair(pair[0], pair[1])))
650
966
  const rounds = orientRoundPairs(pivotRoundPairs(options.candidates.length, pivots).filter(pair => !ringPairs.has(unorderedPair(pair[0], pair[1]))))
651
- const roundScores = await this.scorePairs(options, rounds, signal)
967
+ // The ring phase is already paid for. Accumulate it BEFORE the pivot phase so a pivot
968
+ // failure keeps the successful pairs' usage on the thrown error instead of losing it.
969
+ const stats = blankStats()
970
+ mergeRunStats(stats, ringScores.stats)
971
+ const roundScores = await this.scorePairs(options, rounds, signal).catch((error: unknown) => {
972
+ mergeRunStats(stats, partialStats(error))
973
+ attachUsage(error, this.finishStats(stats))
974
+ throw error
975
+ })
976
+ mergeRunStats(stats, roundScores.stats)
652
977
  const allRewards = new Map([...ringScores.rewards, ...roundScores.rewards])
653
978
  const wins = new Array<number>(options.candidates.length).fill(0)
654
979
  const counts = new Array<number>(options.candidates.length).fill(0)
@@ -656,14 +981,6 @@ export class VerifierEngine {
656
981
  accumulatePairs(rounds, allRewards, wins, counts)
657
982
  const ranked = rankScores(wins, counts)
658
983
  const index = ranked[0]!.index
659
- const stats = blankStats()
660
- for (const source of [ringScores.stats, roundScores.stats]) {
661
- addUsage(stats, source)
662
- stats.cacheHits += source.cacheHits
663
- stats.cacheMisses += source.cacheMisses
664
- stats.topLogprobScores += source.topLogprobScores
665
- stats.explicitTagScores += source.explicitTagScores
666
- }
667
984
 
668
985
  const judges: JudgeScore[] = this.clients.map((client, k) => {
669
986
  const isOk = ringScores.judgeOk[k]! && roundScores.judgeOk[k]!
@@ -696,7 +1013,11 @@ export class VerifierEngine {
696
1013
  }
697
1014
  })
698
1015
 
699
- return { index, best: options.candidates[index]!, scores: Array.from({ length: options.candidates.length }, (_, candidate) => wins[candidate]! / (counts[candidate] || 1)), ranking: ranked.map(value => value.index), pivots, comparisons: ring.length + rounds.length, calls: stats.calls, stats: this.finishStats(stats), judges }
1016
+ const diagnostics: Diagnostic[] = []
1017
+ const diagnosticSeen = new Set<string>()
1018
+ mergeDiagnostics(diagnostics, diagnosticSeen, ringScores.diagnostics)
1019
+ mergeDiagnostics(diagnostics, diagnosticSeen, roundScores.diagnostics)
1020
+ return { index, best: options.candidates[index]!, scores: Array.from({ length: options.candidates.length }, (_, candidate) => wins[candidate]! / (counts[candidate] || 1)), ranking: ranked.map(value => value.index), pivots, comparisons: ring.length + rounds.length, calls: stats.calls, stats: this.finishStats(stats), judges, diagnostics }
700
1021
  }
701
1022
  }
702
1023