dsh-llm-verifier 0.3.0 → 0.4.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 -9
- package/lib/auto-B5V49e-A.js +1517 -0
- package/lib/auto-B5V49e-A.js.map +1 -0
- package/lib/{caller-DJd_FlrQ.js → caller-DR_C7ueP.js} +154 -20
- package/lib/caller-DR_C7ueP.js.map +1 -0
- package/lib/caller.js +2 -2
- package/lib/client.js +804 -29
- package/lib/client.js.map +1 -1
- package/lib/core.js +226 -3
- package/lib/core.js.map +1 -1
- package/lib/index.js +1014 -1337
- package/lib/index.js.map +1 -1
- package/lib/replay.js +157 -0
- package/lib/replay.js.map +1 -0
- package/lib/types/auto.js +1 -1
- package/lib/types/auto.js.map +1 -1
- package/lib/types/caller.d.ts +65 -0
- package/lib/types/caller.d.ts.map +1 -1
- package/lib/types/caller.js +113 -5
- package/lib/types/caller.js.map +1 -1
- package/lib/types/client-i18n.d.ts +163 -6
- package/lib/types/client-i18n.d.ts.map +1 -1
- package/lib/types/client-i18n.js +137 -26
- package/lib/types/client-i18n.js.map +1 -1
- package/lib/types/client.d.ts +30 -0
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/client.js +94 -9
- package/lib/types/client.js.map +1 -1
- package/lib/types/config.d.ts +33 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +17 -2
- package/lib/types/config.js.map +1 -1
- package/lib/types/core.d.ts +84 -0
- package/lib/types/core.d.ts.map +1 -1
- package/lib/types/core.js +222 -2
- package/lib/types/core.js.map +1 -1
- package/lib/types/criteria.d.ts +33 -0
- package/lib/types/criteria.d.ts.map +1 -0
- package/lib/types/criteria.js +67 -0
- package/lib/types/criteria.js.map +1 -0
- package/lib/types/decisions.d.ts +84 -0
- package/lib/types/decisions.d.ts.map +1 -0
- package/lib/types/decisions.js +209 -0
- package/lib/types/decisions.js.map +1 -0
- package/lib/types/engine.d.ts +54 -1
- package/lib/types/engine.d.ts.map +1 -1
- package/lib/types/engine.js +175 -12
- package/lib/types/engine.js.map +1 -1
- package/lib/types/index.d.ts +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +392 -38
- package/lib/types/index.js.map +1 -1
- package/lib/types/replay.d.ts +88 -0
- package/lib/types/replay.d.ts.map +1 -0
- package/lib/types/replay.js +136 -0
- package/lib/types/replay.js.map +1 -0
- package/lib/types/router.d.ts +29 -5
- package/lib/types/router.d.ts.map +1 -1
- package/lib/types/router.js +283 -46
- package/lib/types/router.js.map +1 -1
- package/lib/types/statistics.d.ts +10 -1
- package/lib/types/statistics.d.ts.map +1 -1
- package/lib/types/statistics.js +35 -7
- package/lib/types/statistics.js.map +1 -1
- package/lib/types/top-logprobs.d.ts +36 -0
- package/lib/types/top-logprobs.d.ts.map +1 -1
- package/lib/types/top-logprobs.js +41 -14
- package/lib/types/top-logprobs.js.map +1 -1
- package/package.json +6 -1
- package/scripts/eval-replay.mjs +118 -0
- package/src/auto.ts +1 -1
- package/src/caller.test.ts +62 -1
- package/src/caller.ts +124 -4
- package/src/client-i18n.ts +137 -25
- package/src/client.test.ts +21 -0
- package/src/client.tsx +145 -7
- package/src/config.test.ts +32 -0
- package/src/config.ts +49 -2
- package/src/core.test.ts +95 -0
- package/src/core.ts +217 -2
- package/src/criteria.test.ts +60 -0
- package/src/criteria.ts +76 -0
- package/src/decisions.test.ts +150 -0
- package/src/decisions.ts +244 -0
- package/src/engine.test.ts +131 -0
- package/src/engine.ts +167 -10
- package/src/index.test.ts +278 -8
- package/src/index.ts +373 -37
- package/src/replay.test.ts +82 -0
- package/src/replay.ts +155 -0
- package/src/router.test.ts +240 -1
- package/src/router.ts +298 -48
- package/src/statistics.test.ts +49 -1
- package/src/statistics.ts +44 -7
- package/src/top-logprobs.test.ts +28 -0
- package/src/top-logprobs.ts +56 -8
- package/lib/caller-DJd_FlrQ.js.map +0 -1
package/src/decisions.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { sanitizeVerifierText } from './session.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One model call captured inside an invocation.
|
|
8
|
+
*
|
|
9
|
+
* Everything the plugin decides is decided inside a prompt: "why did the progress
|
|
10
|
+
* judge answer K again" is a question about the exact text that was sent and the raw
|
|
11
|
+
* answer that came back. The statistics store answers "how much did it cost"; this one
|
|
12
|
+
* answers "why did it say that".
|
|
13
|
+
*/
|
|
14
|
+
export interface DecisionCall {
|
|
15
|
+
/** Which part of the invocation made the call (criterion, repeat, phase). */
|
|
16
|
+
label: string
|
|
17
|
+
/** Scoring channel that answered (top-logprobs | explicit-tag). */
|
|
18
|
+
channel: string
|
|
19
|
+
prompt: string
|
|
20
|
+
output: string
|
|
21
|
+
/** The score this call produced, when it produced one. */
|
|
22
|
+
score?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Sink the engine reports every REAL model call to (cache hits make no call). */
|
|
26
|
+
export type DecisionTrace = (call: DecisionCall) => void
|
|
27
|
+
|
|
28
|
+
export interface DecisionRecord {
|
|
29
|
+
id: string
|
|
30
|
+
toolName: string
|
|
31
|
+
phase: string
|
|
32
|
+
startedAt: number
|
|
33
|
+
provider: string
|
|
34
|
+
model: string
|
|
35
|
+
calls: DecisionCall[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface DecisionsDocument {
|
|
39
|
+
version: 1
|
|
40
|
+
records: DecisionRecord[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Upper bound on captured calls per invocation.
|
|
45
|
+
*
|
|
46
|
+
* A session acceptance makes six; an explicit best-of-N makes about 27 (N drafts, the
|
|
47
|
+
* tournament, and the winner-vs-baseline comparison). Keeping the old 12 would have dropped
|
|
48
|
+
* the drafts from the snapshot entirely — judge labels sort before `draft N` — so the one
|
|
49
|
+
* record that explains "which draft won and why" could not show the drafts at all. The
|
|
50
|
+
* per-record character budget is unchanged and shared equally, so the extra calls shrink each
|
|
51
|
+
* window instead of growing the file. 32 is the ceiling the equal-share floor allows:
|
|
52
|
+
* 32 x (512 prompt + 256 output) still fits in {@link MAX_RECORD_CHARS}.
|
|
53
|
+
*/
|
|
54
|
+
const MAX_CALLS = 32
|
|
55
|
+
const MAX_PROMPT_CHARS = 8000
|
|
56
|
+
const MAX_OUTPUT_CHARS = 4000
|
|
57
|
+
/** Upper bound on one record's captured text, so a wide select cannot fill the file. */
|
|
58
|
+
const MAX_RECORD_CHARS = 30000
|
|
59
|
+
/** Smallest window worth storing; at MAX_CALLS the equal share stays above this floor. */
|
|
60
|
+
const MIN_PROMPT_CHARS = 512
|
|
61
|
+
const MIN_OUTPUT_CHARS = 256
|
|
62
|
+
|
|
63
|
+
export function resolveDecisionsFile(cacheFile: string): string {
|
|
64
|
+
return join(dirname(cacheFile), 'decisions-v1.json')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Redact and bound captured calls.
|
|
69
|
+
*
|
|
70
|
+
* Runs the same sanitizer the prompts do, so a snapshot can never persist a secret the
|
|
71
|
+
* judge itself never saw, and drops calls past the per-record budget instead of writing
|
|
72
|
+
* an unbounded file.
|
|
73
|
+
* @param calls - captured calls, oldest first.
|
|
74
|
+
* @returns The bounded calls; empty when nothing was captured.
|
|
75
|
+
*/
|
|
76
|
+
export function boundCaptureText(text: string, maxChars: number): string {
|
|
77
|
+
if (!Number.isSafeInteger(maxChars) || maxChars < 1) throw new Error('llm-verifier: decision capture cap must be a positive integer')
|
|
78
|
+
const clean = sanitizeVerifierText(text, Math.max(1, text.length))
|
|
79
|
+
if (clean.length <= maxChars || maxChars < 256) return sanitizeVerifierText(text, maxChars)
|
|
80
|
+
const head = Math.floor(maxChars / 2)
|
|
81
|
+
const tail = maxChars - head
|
|
82
|
+
const omitted = Math.max(0, clean.length - head - tail)
|
|
83
|
+
const marker = '\n[… ' + omitted + ' characters omitted …]\n'
|
|
84
|
+
// The marker is charged to the tail's share, so the sum can never exceed the cap.
|
|
85
|
+
const keepTail = Math.max(0, tail - marker.length)
|
|
86
|
+
return clean.slice(0, head) + marker + (keepTail === 0 ? '' : clean.slice(-keepTail))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Redact and bound captured calls.
|
|
91
|
+
*
|
|
92
|
+
* Runs the same sanitizer the prompts do, so a snapshot can never persist a secret the
|
|
93
|
+
* judge itself never saw. The record is bounded twice: at {@link MAX_CALLS} calls, and at
|
|
94
|
+
* {@link MAX_RECORD_CHARS} characters shared EQUALLY between the calls it keeps — a six-call
|
|
95
|
+
* session acceptance must not shrink to its first three calls, and which three survived must not
|
|
96
|
+
* depend on completion order. Text uses {@link boundCaptureText}, which keeps both ends: a
|
|
97
|
+
* session-acceptance prompt runs past 100k characters, and head-only truncation kept the
|
|
98
|
+
* instructions while dropping the trajectory tail the judge actually graded.
|
|
99
|
+
* @param calls - captured calls; callers sort them by label so the bounded set is deterministic.
|
|
100
|
+
* @returns The bounded calls; empty when nothing was captured.
|
|
101
|
+
*/
|
|
102
|
+
export function boundDecisionCalls(calls: readonly DecisionCall[]): DecisionCall[] {
|
|
103
|
+
if (calls.length === 0) return []
|
|
104
|
+
// Over the count cap, keep an EVEN SPREAD (first and last included) rather than a prefix.
|
|
105
|
+
// A prefix is the wrong sample whenever the labels cluster: the engine labels its own calls
|
|
106
|
+
// in sorted order, so an oversized best-of-N (n=4 makes ~46 calls: the baseline, then the
|
|
107
|
+
// tournament, then `draft N` last) would lose every draft and keep only judge calls.
|
|
108
|
+
const selected = calls.length <= MAX_CALLS
|
|
109
|
+
? [...calls]
|
|
110
|
+
: Array.from({ length: MAX_CALLS }, (_, index) => calls[Math.round(index * (calls.length - 1) / (MAX_CALLS - 1))]!)
|
|
111
|
+
// Equal share per call, not first-come-first-served. A session acceptance makes six calls on
|
|
112
|
+
// ~8k-char prompts; a fixed 8k + 4k per call let only the FIRST THREE through, and which three
|
|
113
|
+
// survived depended on completion order, i.e. on the network — so the most expensive path in
|
|
114
|
+
// the plugin stored a different (and partial) set of criteria every time. Every call now keeps
|
|
115
|
+
// a smaller window: boundCaptureText keeps both ends, so the instruction head and the graded
|
|
116
|
+
// trajectory tail still survive.
|
|
117
|
+
const overhead = selected.reduce((sum, call) => sum + Math.min(call.label.length, 200) + Math.min(call.channel.length, 40), 0)
|
|
118
|
+
const perCall = Math.max(MIN_PROMPT_CHARS + MIN_OUTPUT_CHARS, Math.floor((MAX_RECORD_CHARS - overhead) / selected.length))
|
|
119
|
+
const bounded: DecisionCall[] = []
|
|
120
|
+
let used = 0
|
|
121
|
+
for (const call of selected) {
|
|
122
|
+
// The two windows must SUM to at most perCall, which the previous 80/20 split did not
|
|
123
|
+
// guarantee: once perCall fell below ~1000 (a wide invocation), the output floor pushed the
|
|
124
|
+
// pair over its share and the safety net below silently dropped the tail — the drafts.
|
|
125
|
+
const promptChars = Math.min(MAX_PROMPT_CHARS, Math.max(MIN_PROMPT_CHARS, Math.min(Math.floor(perCall * 0.8), perCall - MIN_OUTPUT_CHARS)))
|
|
126
|
+
const outputChars = Math.min(MAX_OUTPUT_CHARS, Math.max(MIN_OUTPUT_CHARS, perCall - promptChars))
|
|
127
|
+
const value: DecisionCall = {
|
|
128
|
+
label: call.label.slice(0, 200),
|
|
129
|
+
channel: call.channel.slice(0, 40),
|
|
130
|
+
prompt: boundCaptureText(call.prompt, promptChars),
|
|
131
|
+
output: boundCaptureText(call.output, outputChars),
|
|
132
|
+
...(typeof call.score === 'number' && Number.isFinite(call.score) ? { score: call.score } : {}),
|
|
133
|
+
}
|
|
134
|
+
const cost = value.label.length + value.channel.length + value.prompt.length + value.output.length
|
|
135
|
+
// Safety net only: the equal share already fits at MAX_CALLS, unless perCall is below the
|
|
136
|
+
// two floors (unreachable for realistic label lengths).
|
|
137
|
+
if (bounded.length > 0 && used + cost > MAX_RECORD_CHARS) break
|
|
138
|
+
bounded.push(value)
|
|
139
|
+
used += cost
|
|
140
|
+
}
|
|
141
|
+
return bounded
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface DecisionInput {
|
|
145
|
+
toolName: string
|
|
146
|
+
phase: string
|
|
147
|
+
startedAt: number
|
|
148
|
+
provider: string
|
|
149
|
+
model: string
|
|
150
|
+
calls: readonly DecisionCall[]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Per-topic ring buffer of decision snapshots.
|
|
155
|
+
*
|
|
156
|
+
* Mirrors {@link import('./statistics.ts').StatisticsStore}: one JSON file beside the
|
|
157
|
+
* statistics of the same topic, atomic replace on write, a fixed number of records, so
|
|
158
|
+
* the observable cost of "keep the evidence that explains a verdict" is bounded.
|
|
159
|
+
*/
|
|
160
|
+
export class DecisionStore {
|
|
161
|
+
private loaded = false
|
|
162
|
+
private hydrating: Promise<void> | undefined
|
|
163
|
+
private records: DecisionRecord[] = []
|
|
164
|
+
private writing: Promise<void> = Promise.resolve()
|
|
165
|
+
|
|
166
|
+
constructor(private readonly file: string, private readonly maxEntries = 40) {
|
|
167
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) throw new Error('llm-verifier: decision maxEntries must be a positive integer')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async record(input: DecisionInput): Promise<DecisionRecord | undefined> {
|
|
171
|
+
const calls = boundDecisionCalls(input.calls)
|
|
172
|
+
if (calls.length === 0) return undefined
|
|
173
|
+
const record: DecisionRecord = {
|
|
174
|
+
id: randomUUID(),
|
|
175
|
+
toolName: input.toolName,
|
|
176
|
+
phase: input.phase,
|
|
177
|
+
startedAt: input.startedAt,
|
|
178
|
+
provider: input.provider,
|
|
179
|
+
model: input.model,
|
|
180
|
+
calls,
|
|
181
|
+
}
|
|
182
|
+
const operation = async () => {
|
|
183
|
+
await this.load()
|
|
184
|
+
this.records.push(record)
|
|
185
|
+
if (this.records.length > this.maxEntries) this.records.splice(0, this.records.length - this.maxEntries)
|
|
186
|
+
await this.persist()
|
|
187
|
+
}
|
|
188
|
+
this.writing = this.writing.then(operation, operation)
|
|
189
|
+
await this.writing
|
|
190
|
+
return record
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One snapshot by invocation id, or undefined when it was pruned or never captured. */
|
|
194
|
+
async find(id: string): Promise<DecisionRecord | undefined> {
|
|
195
|
+
await this.writing.catch(() => {})
|
|
196
|
+
await this.load()
|
|
197
|
+
return this.records.find(record => record.id === id)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async load(): Promise<void> {
|
|
201
|
+
if (this.loaded) return
|
|
202
|
+
this.hydrating ??= (async () => {
|
|
203
|
+
try {
|
|
204
|
+
const document = JSON.parse(await readFile(this.file, 'utf8')) as Partial<DecisionsDocument>
|
|
205
|
+
if (document.version === 1 && Array.isArray(document.records)) {
|
|
206
|
+
this.records = document.records.filter(isDecisionRecord).slice(-this.maxEntries)
|
|
207
|
+
}
|
|
208
|
+
this.loaded = true
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
211
|
+
this.loaded = true
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
throw error
|
|
215
|
+
} finally {
|
|
216
|
+
this.hydrating = undefined
|
|
217
|
+
}
|
|
218
|
+
})()
|
|
219
|
+
await this.hydrating
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private async persist(): Promise<void> {
|
|
223
|
+
const snapshot: DecisionsDocument = { version: 1, records: this.records }
|
|
224
|
+
await mkdir(dirname(this.file), { recursive: true })
|
|
225
|
+
const temporary = this.file + '.tmp-' + process.pid + '-' + randomUUID()
|
|
226
|
+
await writeFile(temporary, JSON.stringify(snapshot), 'utf8')
|
|
227
|
+
try { await rename(temporary, this.file) } catch (error) { await unlink(temporary).catch(() => {}); throw error }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Loose validation, so a record written by a newer/older plugin still loads.
|
|
233
|
+
* @param value - parsed JSON entry.
|
|
234
|
+
* @returns True when the entry carries the fields the dashboard needs.
|
|
235
|
+
*/
|
|
236
|
+
function isDecisionRecord(value: unknown): value is DecisionRecord {
|
|
237
|
+
if (typeof value !== 'object' || value === null) return false
|
|
238
|
+
const row = value as Partial<DecisionRecord>
|
|
239
|
+
return typeof row.id === 'string'
|
|
240
|
+
&& typeof row.toolName === 'string'
|
|
241
|
+
&& typeof row.startedAt === 'number'
|
|
242
|
+
&& Array.isArray(row.calls)
|
|
243
|
+
&& row.calls.every(call => typeof call === 'object' && call !== null && typeof (call as DecisionCall).prompt === 'string' && typeof (call as DecisionCall).output === 'string')
|
|
244
|
+
}
|
package/src/engine.test.ts
CHANGED
|
@@ -166,6 +166,18 @@ describe('VerifierEngine cache identity', () => {
|
|
|
166
166
|
expect(result.index).toBe(0)
|
|
167
167
|
expect(result.pivots).toEqual([])
|
|
168
168
|
})
|
|
169
|
+
it('prefixes snapshot labels so two comparisons on the same criteria stay distinguishable', async () => {
|
|
170
|
+
const engine = new VerifierEngine(clientConfig({ llm: { stream: scriptedStream([]) } as any }), 4)
|
|
171
|
+
const labels: string[] = []
|
|
172
|
+
const trace = (call: { label: string }) => { labels.push(call.label) }
|
|
173
|
+
await engine.compare({ problem: 'task', candidateA: 'STRONG', candidateB: 'WEAK', repeats: 1, trace })
|
|
174
|
+
// Regression: without the prefix, a best-of-N baseline comparison was indistinguishable from
|
|
175
|
+
// the tournament it followed, and the snapshot read as if the judge had contradicted itself.
|
|
176
|
+
await engine.compare({ problem: 'task', candidateA: 'STRONG', candidateB: 'WEAK', repeats: 1, trace, traceLabelPrefix: 'baseline: ' })
|
|
177
|
+
expect(labels.filter(label => label.startsWith('baseline: '))).toHaveLength(3)
|
|
178
|
+
expect(labels.filter(label => !label.startsWith('baseline: '))).toHaveLength(3)
|
|
179
|
+
})
|
|
180
|
+
|
|
169
181
|
it('uses version 6 in scoreOne cache identity with temperature (regression FIX 1)', async () => {
|
|
170
182
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-verifier-engine-v6-'))
|
|
171
183
|
try {
|
|
@@ -599,3 +611,122 @@ describe('VerifierEngine N-judge ensemble', () => {
|
|
|
599
611
|
await expect(engine.track('Test problem', ['step 1'], [1], 1)).rejects.toThrow('track failed completely 1')
|
|
600
612
|
})
|
|
601
613
|
})
|
|
614
|
+
|
|
615
|
+
/** Records start/end of every model call, tagged with the A/B slot orientation of its prompt. */
|
|
616
|
+
function recordingStream(events: string[]): (options: any) => AsyncIterable<any[]> {
|
|
617
|
+
return function (options: any) {
|
|
618
|
+
const message = options.messages[0]
|
|
619
|
+
const prompt = typeof message.content === 'string' ? message.content : message.content.filter((block: any) => block.type === 'text').map((block: any) => block.text).join('')
|
|
620
|
+
const orientation = prompt.indexOf('ALPHA') < prompt.indexOf('BRAVO') ? 'AB' : 'BA'
|
|
621
|
+
return (async function* () {
|
|
622
|
+
events.push('start:' + orientation)
|
|
623
|
+
await new Promise(resolve => setTimeout(resolve, 1))
|
|
624
|
+
events.push('end:' + orientation)
|
|
625
|
+
yield* streamOf(chunks('<score_A> A </score_A>\n<score_B> T </score_B>'))
|
|
626
|
+
})()
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Drops any call that is not already covered by the score cache. */
|
|
631
|
+
function failIfCalled(): (options: any) => AsyncIterable<any[]> {
|
|
632
|
+
return function () {
|
|
633
|
+
throw new Error('unexpected model call')
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
describe('prefix warm-up', () => {
|
|
638
|
+
it('warms one call per distinct A/B slot orientation before fanning out, without extra calls', async () => {
|
|
639
|
+
const events: string[] = []
|
|
640
|
+
const engine = new VerifierEngine(clientConfig({ llm: { stream: recordingStream(events) } as any }), 8)
|
|
641
|
+
const result = await engine.compare({ problem: 'task', candidateA: 'ALPHA', candidateB: 'BRAVO', repeats: 2 })
|
|
642
|
+
|
|
643
|
+
// Three default criteria x two repeats = six calls. The warm-up only reorders them, and
|
|
644
|
+
// odd repeats swap the slots, so each of the two prompt prefixes needs its own warm call.
|
|
645
|
+
expect(result.stats.calls).toBe(6)
|
|
646
|
+
// Indices, not values: the two orientations repeat, so looking a start up by its text would
|
|
647
|
+
// find the earlier warm call instead of the later fan-out one.
|
|
648
|
+
const startIndices = events.map((entry, index) => (entry.startsWith('start:') ? index : -1)).filter(index => index >= 0)
|
|
649
|
+
expect(startIndices).toHaveLength(6)
|
|
650
|
+
expect(new Set(startIndices.slice(0, 2).map(index => events[index])).size).toBe(2)
|
|
651
|
+
// Nothing else starts until BOTH warm calls (one per orientation) have finished: with a
|
|
652
|
+
// single warm job the whole swapped-slot half of the fan-out was a cold cache miss.
|
|
653
|
+
expect(events.slice(0, startIndices[2]!).filter(entry => entry.startsWith('end:'))).toHaveLength(2)
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it('warms the identical track prompt once before the repeated calls', async () => {
|
|
657
|
+
const events: string[] = []
|
|
658
|
+
const stream = () => (async function* () {
|
|
659
|
+
events.push('start')
|
|
660
|
+
await new Promise(resolve => setTimeout(resolve, 1))
|
|
661
|
+
events.push('end')
|
|
662
|
+
yield* streamOf(chunks('<c1> T </c1>'))
|
|
663
|
+
})()
|
|
664
|
+
const engine = new VerifierEngine(clientConfig({ llm: { stream } as any }), 8)
|
|
665
|
+
const result = await engine.track('problem', ['step 1'], [1], 3)
|
|
666
|
+
|
|
667
|
+
// Every repeat sends the same prompt, so repeats 2 and 3 must not start before repeat 1
|
|
668
|
+
// has returned and populated the provider prefix cache.
|
|
669
|
+
expect(result.stats.calls).toBe(3)
|
|
670
|
+
const starts = events.map((entry, index) => (entry === 'start' ? index : -1)).filter(index => index >= 0)
|
|
671
|
+
expect(starts).toHaveLength(3)
|
|
672
|
+
expect(events.slice(0, starts[1]!).filter(entry => entry === 'end')).toHaveLength(1)
|
|
673
|
+
})
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
describe('byte-identical candidates', () => {
|
|
677
|
+
it('compares identical sides as an uninformative tie without calling the model', async () => {
|
|
678
|
+
const judged: string[] = []
|
|
679
|
+
const engine = new VerifierEngine(clientConfig({ llm: { stream: scriptedStream(judged) } as any }), 4)
|
|
680
|
+
const result = await engine.compare({ problem: 'task', candidateA: 'SAME', candidateB: 'SAME', repeats: 2 })
|
|
681
|
+
|
|
682
|
+
expect(judged).toHaveLength(0)
|
|
683
|
+
expect(result.calls).toBe(0)
|
|
684
|
+
expect(result.identical).toBe(true)
|
|
685
|
+
expect(result.winner).toBe('tie')
|
|
686
|
+
// 0.5 on both sides, not a confident 1.0: the acceptance gate must stay closed.
|
|
687
|
+
expect(result.scoreA).toBe(0.5)
|
|
688
|
+
expect(result.scoreB).toBe(0.5)
|
|
689
|
+
expect(result.criteria.map(row => row.scoreA)).toEqual([0.5, 0.5, 0.5])
|
|
690
|
+
expect(result.judges[0]).toMatchObject({ ok: true, calls: 0, winner: 'tie' })
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
it('ranks all-identical candidates without a single model call', async () => {
|
|
694
|
+
const judged: string[] = []
|
|
695
|
+
const engine = new VerifierEngine(clientConfig({ llm: { stream: scriptedStream(judged) } as any }), 4)
|
|
696
|
+
const result = await engine.select({ problem: 'task', candidates: ['SAME', 'SAME', 'SAME'], repeats: 1 })
|
|
697
|
+
|
|
698
|
+
expect(judged).toHaveLength(0)
|
|
699
|
+
expect(result.calls).toBe(0)
|
|
700
|
+
expect(result.comparisons).toBe(0)
|
|
701
|
+
expect(result.identical).toBe(true)
|
|
702
|
+
expect(result.best).toBe('SAME')
|
|
703
|
+
expect(result.scores).toEqual([0.5, 0.5, 0.5])
|
|
704
|
+
expect(result.ranking).toEqual([0, 1, 2])
|
|
705
|
+
expect(result.judges[0]).toMatchObject({ ok: true, calls: 0, scores: [0.5, 0.5, 0.5] })
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
it('judges duplicated candidates once and maps the verdict back onto every index', async () => {
|
|
709
|
+
const judged: string[] = []
|
|
710
|
+
const engine = new VerifierEngine(clientConfig({ llm: { stream: scriptedStream(judged) } as any }), 4)
|
|
711
|
+
const result = await engine.select({ problem: 'task', candidates: ['STRONG-0', 'WEAK-1', 'STRONG-0'], repeats: 1 })
|
|
712
|
+
|
|
713
|
+
// One compared pair (two distinct candidates) x three default criteria = three calls,
|
|
714
|
+
// where the duplicate would otherwise have doubled the tournament.
|
|
715
|
+
expect(result.comparisons).toBe(1)
|
|
716
|
+
expect(result.stats.calls).toBe(3)
|
|
717
|
+
expect(judged).toHaveLength(3)
|
|
718
|
+
expect(result.best).toBe('STRONG-0')
|
|
719
|
+
expect(result.index).toBe(0)
|
|
720
|
+
expect(result.ranking[0]).toBe(0)
|
|
721
|
+
expect(result.scores).toHaveLength(3)
|
|
722
|
+
// The duplicate inherits its representative's score, so no index is left undefined.
|
|
723
|
+
expect(result.scores[2]).toBe(result.scores[0])
|
|
724
|
+
expect(result.scores[0]).toBeGreaterThan(result.scores[1]!)
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
it('rejects a blank candidate instead of spending calls on it', async () => {
|
|
728
|
+
const engine = new VerifierEngine(clientConfig({ llm: { stream: failIfCalled() } as any }), 4)
|
|
729
|
+
await expect(engine.select({ problem: 'task', candidates: ['real work', ' '] })).rejects.toThrow(/blank/u)
|
|
730
|
+
await expect(engine.select({ problem: 'task', candidates: [] })).rejects.toThrow(/must not be empty/u)
|
|
731
|
+
})
|
|
732
|
+
})
|
package/src/engine.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { addUsage, callVerifier, emptyUsage, predictScoringChannel, type ScoringMode, type UsageStats, type VerifierClientConfig, type VerifierImage } from './caller.ts'
|
|
2
2
|
import { ScoreCache, SingleFlight, stableHash, type CachedPairScore } from './cache.ts'
|
|
3
|
+
import type { DecisionTrace } from './decisions.ts'
|
|
3
4
|
import {
|
|
4
5
|
DEFAULT_CRITERIA, DEFAULT_GROUND_TRUTH_NOTE, accumulatePairs, buildPairwisePrompt, buildProgressPrompt,
|
|
5
|
-
extractProgressScore, extractScore, pivotRoundPairs, rankScores, ringCycle,
|
|
6
|
+
dedupeCriterionId, extractProgressScore, extractScore, pivotRoundPairs, rankScores, ringCycle, slugCriterionId,
|
|
7
|
+
topPivots, type Criterion,
|
|
6
8
|
} from './core.ts'
|
|
7
9
|
|
|
8
|
-
export interface CompareOptions { problem: string; candidateA: string; candidateB: string; criteria?: readonly Criterion[]; groundTruthNote?: string; repeats?: number; images?: readonly VerifierImage[] }
|
|
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 }
|
|
9
11
|
export interface CriterionResult { id: string; name: string; scoreA: number; scoreB: number }
|
|
10
12
|
export interface RunStats extends UsageStats { cacheHits: number; cacheMisses: number; estimatedCostUsd: number; topLogprobScores: number; explicitTagScores: number }
|
|
11
13
|
|
|
@@ -32,9 +34,11 @@ export interface CompareResult {
|
|
|
32
34
|
stats: RunStats
|
|
33
35
|
judges: JudgeScore[]
|
|
34
36
|
agreement: number
|
|
37
|
+
/** Set when both sides were byte-identical: no model call was made and both sides score 0.5. */
|
|
38
|
+
identical?: true
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
export interface SelectOptions { problem: string; candidates: readonly string[]; criteria?: readonly Criterion[]; groundTruthNote?: string; repeats?: number; pivots?: number; seed?: number; images?: readonly VerifierImage[] }
|
|
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 }
|
|
38
42
|
|
|
39
43
|
export interface SelectResult {
|
|
40
44
|
index: number
|
|
@@ -46,6 +50,8 @@ export interface SelectResult {
|
|
|
46
50
|
calls: number
|
|
47
51
|
stats: RunStats
|
|
48
52
|
judges: JudgeScore[]
|
|
53
|
+
/** Set when every candidate was byte-identical: no pair was judged and every score is 0.5. */
|
|
54
|
+
identical?: true
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
function average(values: readonly number[]): number { return values.reduce((sum, value) => sum + value, 0) / (values.length || 1) }
|
|
@@ -58,6 +64,8 @@ function median(values: readonly number[]): number {
|
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
function blankStats(): RunStats { return { ...emptyUsage(), cacheHits: 0, cacheMisses: 0, estimatedCostUsd: 0, topLogprobScores: 0, explicitTagScores: 0 } }
|
|
67
|
+
/** Candidate indices best-first by score, ties broken by index. */
|
|
68
|
+
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) }
|
|
61
69
|
function unorderedPair(a: number, b: number): string { return a < b ? a + ',' + b : b + ',' + a }
|
|
62
70
|
|
|
63
71
|
/**
|
|
@@ -160,7 +168,12 @@ export class VerifierEngine {
|
|
|
160
168
|
const keyForMode = (scoringMode: ScoringMode) => stableHash({ ...identity, scoringMode })
|
|
161
169
|
const create = async () => {
|
|
162
170
|
const completion = await callVerifier(client, prompt, signal, options.images)
|
|
163
|
-
|
|
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() }
|
|
164
177
|
}
|
|
165
178
|
const cache = this.cache
|
|
166
179
|
if (cache === undefined) { const value = await create(); return { scores: [value.scoreA, value.scoreB], usage: value.usage, scoringMode: value.scoringMode, hit: false } }
|
|
@@ -181,12 +194,48 @@ export class VerifierEngine {
|
|
|
181
194
|
await Promise.all(runners); return results
|
|
182
195
|
}
|
|
183
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Verdict for a comparison whose two sides are byte-identical.
|
|
199
|
+
*
|
|
200
|
+
* Deliberately uninformative: no model call is made, both sides score 0.5 and the winner is
|
|
201
|
+
* a tie. Judging identical text would ask the model to break a tie it cannot break, and any
|
|
202
|
+
* confident score would let the acceptance gate pass a session indistinguishable from the
|
|
203
|
+
* empty-work baseline. Callers that need "these are the same" read {@link CompareResult.identical}.
|
|
204
|
+
* @param criteria - the criteria the comparison would have scored.
|
|
205
|
+
* @returns A tie carrying 0.5 per criterion, zero calls and one agreeing judge per client.
|
|
206
|
+
*/
|
|
207
|
+
private informationalTie(criteria: readonly Criterion[]): CompareResult {
|
|
208
|
+
const judges: JudgeScore[] = this.clients.map(client => ({ provider: client.provider, model: client.model, label: judgeLabel(client), ok: true, calls: 0, scoreA: 0.5, scoreB: 0.5, winner: 'tie' as const }))
|
|
209
|
+
return {
|
|
210
|
+
scoreA: 0.5,
|
|
211
|
+
scoreB: 0.5,
|
|
212
|
+
winner: 'tie',
|
|
213
|
+
criteria: criteria.map(criterion => ({ id: criterion.id, name: criterion.name, scoreA: 0.5, scoreB: 0.5 })),
|
|
214
|
+
calls: 0,
|
|
215
|
+
stats: this.finishStats(blankStats()),
|
|
216
|
+
judges,
|
|
217
|
+
agreement: 1,
|
|
218
|
+
identical: true,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
184
222
|
async compare(options: CompareOptions, signal?: AbortSignal): Promise<CompareResult> {
|
|
185
223
|
const criteria = options.criteria?.length ? options.criteria : DEFAULT_CRITERIA
|
|
224
|
+
if (options.candidateA === options.candidateB) return this.informationalTie(criteria)
|
|
186
225
|
const repeats = options.repeats ?? 2
|
|
187
226
|
const jobs = criteria.flatMap(criterion => Array.from({ length: repeats }, (_, repeat) => ({ criterion, repeat })))
|
|
188
|
-
// Prefix warm-up: one
|
|
189
|
-
|
|
227
|
+
// Prefix warm-up: one job per DISTINCT prompt prefix runs first, then the rest fan out.
|
|
228
|
+
// Only the A/B slot order changes the prompt prefix (the criterion sits at the tail, see
|
|
229
|
+
// core.buildPairwisePrompt) and odd repeats swap the slots, so each orientation needs its own
|
|
230
|
+
// warm call: warming a single job left every swapped-slot call cold, which is where half the
|
|
231
|
+
// input tokens of a session acceptance went. The warm jobs are calls that had to happen anyway.
|
|
232
|
+
const warm: typeof jobs = []; const rest: typeof jobs = []
|
|
233
|
+
const warmedOrientations = new Set<number>()
|
|
234
|
+
for (const job of jobs) {
|
|
235
|
+
const orientation = job.repeat % 2
|
|
236
|
+
if (warmedOrientations.has(orientation)) rest.push(job)
|
|
237
|
+
else { warmedOrientations.add(orientation); warm.push(job) }
|
|
238
|
+
}
|
|
190
239
|
const run = async (batch: typeof jobs) => this.mapLimited(batch, async ({ criterion, repeat }) => {
|
|
191
240
|
const swapped = repeat % 2 === 1
|
|
192
241
|
const candA = swapped ? options.candidateB : options.candidateA
|
|
@@ -338,6 +387,7 @@ export class VerifierEngine {
|
|
|
338
387
|
groundTruthNote: options.groundTruthNote,
|
|
339
388
|
repeats: options.repeats,
|
|
340
389
|
images: options.images,
|
|
390
|
+
trace: options.trace,
|
|
341
391
|
}, signal),
|
|
342
392
|
}))
|
|
343
393
|
const rewards = new Map<string, readonly [number, number]>()
|
|
@@ -372,7 +422,7 @@ export class VerifierEngine {
|
|
|
372
422
|
return { rewards, judgeRewards, judgeOk, judgeErrors, judgeCalls, stats: this.finishStats(stats) }
|
|
373
423
|
}
|
|
374
424
|
|
|
375
|
-
async track(problem: string, steps: readonly string[], checkpoints: readonly number[], repeats = 2, signal?: AbortSignal, images?: readonly VerifierImage[]): Promise<{ scores: number[]; perRepeat: number[][]; calls: number; stats: RunStats; judges: JudgeScore[] }> {
|
|
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[] }> {
|
|
376
426
|
if (!steps.length || !checkpoints.length) throw new Error('llm-verifier: steps and checkpoints must not be empty')
|
|
377
427
|
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')
|
|
378
428
|
const prompt = buildProgressPrompt(problem, steps, checkpoints)
|
|
@@ -383,12 +433,16 @@ export class VerifierEngine {
|
|
|
383
433
|
const judgePerRepeatScores: Array<number[][]> = Array.from({ length: this.clients.length }, () => [])
|
|
384
434
|
|
|
385
435
|
const repeatIndices = Array.from({ length: repeats }, (_, index) => index)
|
|
386
|
-
const
|
|
436
|
+
const runRepeat = async (repeatIndex: number) => {
|
|
387
437
|
const judgeResults = await Promise.all(
|
|
388
438
|
this.clients.map(async (client, k) => {
|
|
389
439
|
try {
|
|
390
440
|
const completion = await callVerifier(client, prompt, signal, images)
|
|
391
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] })
|
|
392
446
|
return {
|
|
393
447
|
k,
|
|
394
448
|
ok: true as const,
|
|
@@ -427,7 +481,13 @@ export class VerifierEngine {
|
|
|
427
481
|
}
|
|
428
482
|
|
|
429
483
|
return checkpoints.map((_, cIndex) => median(successful.map(r => r.scores[cIndex]!)))
|
|
430
|
-
}
|
|
484
|
+
}
|
|
485
|
+
// Every repeat sends the SAME prompt, so the first repeat warms the provider prefix cache for
|
|
486
|
+
// the others; fanning all repeats out at once left every call cold. The repeat count is
|
|
487
|
+
// unchanged — only the order, and therefore the cache hits, change.
|
|
488
|
+
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))
|
|
431
491
|
|
|
432
492
|
const scores = checkpoints.map((_, index) => average(runs.map(run => run[index]!)))
|
|
433
493
|
const judges: JudgeScore[] = this.clients.map((client, k) => {
|
|
@@ -457,8 +517,79 @@ export class VerifierEngine {
|
|
|
457
517
|
return { scores, perRepeat: runs, calls: stats.calls, stats: this.finishStats(stats), judges }
|
|
458
518
|
}
|
|
459
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Verdict for a candidate list whose entries are all byte-identical.
|
|
522
|
+
*
|
|
523
|
+
* Ranking identical text is a coin flip, so the result is deliberately uninformative
|
|
524
|
+
* (0.5 everywhere) rather than a confident 1.0. No model call is made. This is the
|
|
525
|
+
* cost-saving half of upstream's majority-vote shortcut without its semantics: we never
|
|
526
|
+
* declare an unjudged candidate the winner, we only decline to spend calls on a tie.
|
|
527
|
+
* @param candidates - the identical candidates (length >= 2).
|
|
528
|
+
* @returns A ranking with every score at 0.5 and zero calls.
|
|
529
|
+
*/
|
|
530
|
+
private identicalCandidates(candidates: readonly string[]): SelectResult {
|
|
531
|
+
const scores = candidates.map(() => 0.5)
|
|
532
|
+
const ranking = candidates.map((_, index) => index)
|
|
533
|
+
return {
|
|
534
|
+
index: 0,
|
|
535
|
+
best: candidates[0]!,
|
|
536
|
+
scores,
|
|
537
|
+
ranking,
|
|
538
|
+
pivots: [],
|
|
539
|
+
comparisons: 0,
|
|
540
|
+
calls: 0,
|
|
541
|
+
stats: blankStats(),
|
|
542
|
+
judges: this.clients.map(client => ({ provider: client.provider, model: client.model, label: judgeLabel(client), ok: true, calls: 0, scores: [...scores], ranking: [...ranking] })),
|
|
543
|
+
identical: true,
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Judge only the DISTINCT candidates, then expand the verdict back onto the caller's list.
|
|
549
|
+
*
|
|
550
|
+
* Duplicated candidates are the common case when an agent pastes several drafts of the same
|
|
551
|
+
* artifact: every pair that touches a duplicate is a comparison that cannot change the
|
|
552
|
+
* ranking but still costs model calls. The tournament runs on the distinct list and every
|
|
553
|
+
* duplicate inherits its representative's score, so no index, score or ranking entry shifts.
|
|
554
|
+
* @param options - the original select options, duplicates included.
|
|
555
|
+
* @param signal - caller's abort signal.
|
|
556
|
+
* @returns The tournament verdict mapped back onto the original candidate list.
|
|
557
|
+
*/
|
|
558
|
+
private async selectUnique(options: SelectOptions, signal?: AbortSignal): Promise<SelectResult> {
|
|
559
|
+
const unique: string[] = []
|
|
560
|
+
const representative: number[] = []
|
|
561
|
+
const seen = new Map<string, number>()
|
|
562
|
+
for (const candidate of options.candidates) {
|
|
563
|
+
let index = seen.get(candidate)
|
|
564
|
+
if (index === undefined) { index = unique.length; unique.push(candidate); seen.set(candidate, index) }
|
|
565
|
+
representative.push(index)
|
|
566
|
+
}
|
|
567
|
+
const result = await this.select({ ...options, candidates: unique }, signal)
|
|
568
|
+
const expand = (values: readonly number[]): number[] => representative.map(index => values[index] ?? 0)
|
|
569
|
+
const scores = expand(result.scores)
|
|
570
|
+
const judges = result.judges.map(judge => {
|
|
571
|
+
if (!judge.ok || judge.scores === undefined) return judge
|
|
572
|
+
const judgeScores = expand(judge.scores)
|
|
573
|
+
return { ...judge, scores: judgeScores, ...(judge.ranking === undefined ? {} : { ranking: rankByScore(judgeScores) }) }
|
|
574
|
+
})
|
|
575
|
+
return {
|
|
576
|
+
index: options.candidates.indexOf(result.best),
|
|
577
|
+
best: result.best,
|
|
578
|
+
scores,
|
|
579
|
+
ranking: rankByScore(scores),
|
|
580
|
+
pivots: result.pivots,
|
|
581
|
+
comparisons: result.comparisons,
|
|
582
|
+
calls: result.calls,
|
|
583
|
+
stats: result.stats,
|
|
584
|
+
judges,
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
460
588
|
async select(options: SelectOptions, signal?: AbortSignal): Promise<SelectResult> {
|
|
461
589
|
if (!options.candidates.length) throw new Error('llm-verifier: candidates must not be empty')
|
|
590
|
+
if (options.candidates.some(candidate => candidate.trim() === '')) throw new Error('llm-verifier: candidates must not contain blank entries')
|
|
591
|
+
if (options.candidates.length > 1 && options.candidates.every(candidate => candidate === options.candidates[0])) return this.identicalCandidates(options.candidates)
|
|
592
|
+
if (options.candidates.length > 1 && new Set(options.candidates).size !== options.candidates.length) return this.selectUnique(options, signal)
|
|
462
593
|
if (options.candidates.length === 1) {
|
|
463
594
|
const judges: JudgeScore[] = this.clients.map(client => ({
|
|
464
595
|
provider: client.provider,
|
|
@@ -569,9 +700,35 @@ export class VerifierEngine {
|
|
|
569
700
|
}
|
|
570
701
|
}
|
|
571
702
|
|
|
703
|
+
/**
|
|
704
|
+
* Normalize a caller-supplied `criteria` argument into the engine's canonical shape.
|
|
705
|
+
*
|
|
706
|
+
* Mirrors upstream's `normalize_criteria` so a caller does not have to fill in every field: a
|
|
707
|
+
* plain string is both the name and the instruction, and a missing `id` is slugged from the
|
|
708
|
+
* name (the score cache keys on the rendered prompt, so a slug is cosmetic). Ids are
|
|
709
|
+
* de-duplicated because `compare` groups per-criterion results by id — a collision would
|
|
710
|
+
* silently merge two criteria into one line of the verdict.
|
|
711
|
+
* @param input - undefined (the default rubric), or a non-empty array of strings/objects.
|
|
712
|
+
* @returns The criteria the engine will score with.
|
|
713
|
+
*/
|
|
572
714
|
export function normalizeCriteria(input: unknown): Criterion[] {
|
|
573
715
|
if (input === undefined) return DEFAULT_CRITERIA
|
|
574
716
|
if (!Array.isArray(input) || !input.length) throw new Error('llm-verifier: criteria must be a non-empty array')
|
|
575
|
-
|
|
717
|
+
const seen = new Set<string>()
|
|
718
|
+
return input.map((value, index) => {
|
|
719
|
+
if (typeof value === 'string') {
|
|
720
|
+
const text = value.trim()
|
|
721
|
+
if (!text) throw new Error('llm-verifier: criteria[' + index + '] must not be blank')
|
|
722
|
+
return { id: dedupeCriterionId(slugCriterionId(text), seen), name: text, description: text }
|
|
723
|
+
}
|
|
724
|
+
if (typeof value !== 'object' || value === null) throw new Error('llm-verifier: criteria[' + index + '] must be a string or an object')
|
|
725
|
+
const row = value as Record<string, unknown>
|
|
726
|
+
const field = (key: string) => (typeof row[key] === 'string' ? (row[key] as string).trim() : '')
|
|
727
|
+
const description = field('description')
|
|
728
|
+
if (!description) throw new Error('llm-verifier: criteria[' + index + '].description must be a non-empty string')
|
|
729
|
+
const name = field('name') || field('id') || slugCriterionId(description)
|
|
730
|
+
const id = field('id') || slugCriterionId(name)
|
|
731
|
+
return { id: dedupeCriterionId(id, seen), name, description }
|
|
732
|
+
})
|
|
576
733
|
}
|
|
577
734
|
export { DEFAULT_GROUND_TRUTH_NOTE }
|