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.
- package/README.md +133 -25
- package/lib/{auto-B5V49e-A.js → auto-DAMeKRkK.js} +775 -170
- package/lib/auto-DAMeKRkK.js.map +1 -0
- package/lib/{caller-DR_C7ueP.js → caller-BUK7OgUs.js} +168 -14
- package/lib/caller-BUK7OgUs.js.map +1 -0
- package/lib/caller.js +2 -2
- package/lib/client.js +1847 -846
- package/lib/client.js.map +1 -1
- package/lib/core.js +267 -11
- package/lib/core.js.map +1 -1
- package/lib/index.js +2985 -364
- package/lib/index.js.map +1 -1
- package/lib/replay.js +290 -3
- package/lib/replay.js.map +1 -1
- package/lib/types/auto.d.ts +69 -2
- package/lib/types/auto.d.ts.map +1 -1
- package/lib/types/auto.js +216 -23
- package/lib/types/auto.js.map +1 -1
- package/lib/types/cache.d.ts +9 -0
- package/lib/types/cache.d.ts.map +1 -1
- package/lib/types/cache.js +1 -1
- package/lib/types/cache.js.map +1 -1
- package/lib/types/caller.d.ts +23 -0
- package/lib/types/caller.d.ts.map +1 -1
- package/lib/types/caller.js +180 -14
- package/lib/types/caller.js.map +1 -1
- package/lib/types/client-fields.d.ts +174 -0
- package/lib/types/client-fields.d.ts.map +1 -0
- package/lib/types/client-fields.js +456 -0
- package/lib/types/client-fields.js.map +1 -0
- package/lib/types/client-i18n.d.ts +255 -1
- package/lib/types/client-i18n.d.ts.map +1 -1
- package/lib/types/client-i18n.js +180 -4
- package/lib/types/client-i18n.js.map +1 -1
- package/lib/types/client.d.ts +57 -44
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/client.js +286 -43
- package/lib/types/client.js.map +1 -1
- package/lib/types/config.d.ts +36 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +21 -0
- package/lib/types/config.js.map +1 -1
- package/lib/types/core.d.ts +155 -4
- package/lib/types/core.d.ts.map +1 -1
- package/lib/types/core.js +271 -10
- package/lib/types/core.js.map +1 -1
- package/lib/types/engine.d.ts +76 -2
- package/lib/types/engine.d.ts.map +1 -1
- package/lib/types/engine.js +364 -94
- package/lib/types/engine.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +906 -126
- package/lib/types/index.js.map +1 -1
- package/lib/types/process-activity.d.ts +109 -0
- package/lib/types/process-activity.d.ts.map +1 -0
- package/lib/types/process-activity.js +135 -0
- package/lib/types/process-activity.js.map +1 -0
- package/lib/types/process-selection.d.ts +572 -0
- package/lib/types/process-selection.d.ts.map +1 -0
- package/lib/types/process-selection.js +1183 -0
- package/lib/types/process-selection.js.map +1 -0
- package/lib/types/replay.d.ts +188 -0
- package/lib/types/replay.d.ts.map +1 -1
- package/lib/types/replay.js +231 -1
- package/lib/types/replay.js.map +1 -1
- package/lib/types/router.d.ts +321 -5
- package/lib/types/router.d.ts.map +1 -1
- package/lib/types/router.js +600 -100
- package/lib/types/router.js.map +1 -1
- package/lib/types/session.d.ts.map +1 -1
- package/lib/types/session.js +5 -1
- package/lib/types/session.js.map +1 -1
- package/lib/types/statistics.d.ts +92 -0
- package/lib/types/statistics.d.ts.map +1 -1
- package/lib/types/statistics.js +116 -2
- package/lib/types/statistics.js.map +1 -1
- package/package.json +2 -2
- package/scripts/eval-replay.mjs +55 -5
- package/scripts/sync-installed-profiles.mjs +113 -0
- package/src/auto.test.ts +195 -4
- package/src/auto.ts +249 -28
- package/src/cache.ts +10 -1
- package/src/caller.test.ts +202 -3
- package/src/caller.ts +195 -13
- package/src/client-fields.test.ts +309 -0
- package/src/client-fields.ts +619 -0
- package/src/client-i18n.ts +212 -3
- package/src/client.test.ts +45 -2
- package/src/client.tsx +470 -162
- package/src/config.test.ts +37 -0
- package/src/config.ts +57 -0
- package/src/core.test.ts +210 -0
- package/src/core.ts +327 -10
- package/src/engine.test.ts +322 -2
- package/src/engine.ts +426 -105
- package/src/index.test.ts +1210 -3
- package/src/index.ts +880 -110
- package/src/parity.test.ts +50 -1
- package/src/process-activity.test.ts +91 -0
- package/src/process-activity.ts +177 -0
- package/src/process-selection.test.ts +911 -0
- package/src/process-selection.ts +1429 -0
- package/src/replay.test.ts +168 -1
- package/src/replay.ts +333 -1
- package/src/router.test.ts +725 -1
- package/src/router.ts +772 -89
- package/src/session.test.ts +34 -0
- package/src/session.ts +5 -1
- package/src/statistics.test.ts +157 -0
- package/src/statistics.ts +178 -2
- package/lib/auto-B5V49e-A.js.map +0 -1
- 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,
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
stats
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|