dsh-llm-verifier 0.1.13 → 0.1.16
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/LICENSE +22 -22
- package/README.md +328 -301
- package/cordis.patch.yml +4 -4
- package/lib/caller-BWwSVNZf.js.map +1 -0
- package/lib/client.js +176 -14
- package/lib/client.js.map +1 -0
- package/lib/core.js.map +1 -0
- package/lib/index.js +400 -51
- package/lib/index.js.map +1 -0
- package/lib/types/auto.d.ts.map +1 -0
- package/lib/types/auto.js +8 -2
- package/lib/types/auto.js.map +1 -0
- package/lib/types/cache.d.ts.map +1 -0
- package/lib/types/cache.js.map +1 -0
- package/lib/types/caller.d.ts.map +1 -0
- package/lib/types/caller.js.map +1 -0
- package/lib/types/client-i18n.d.ts +27 -0
- package/lib/types/client-i18n.d.ts.map +1 -0
- package/lib/types/client-i18n.js +18 -0
- package/lib/types/client-i18n.js.map +1 -0
- package/lib/types/client.d.ts +15 -2
- package/lib/types/client.d.ts.map +1 -0
- package/lib/types/client.js +137 -10
- package/lib/types/client.js.map +1 -0
- package/lib/types/config.d.ts +4 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +5 -3
- package/lib/types/config.js.map +1 -0
- package/lib/types/core.d.ts.map +1 -0
- package/lib/types/core.js.map +1 -0
- package/lib/types/engine.d.ts.map +1 -0
- package/lib/types/engine.js.map +1 -0
- package/lib/types/images.d.ts.map +1 -0
- package/lib/types/images.js.map +1 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +162 -9
- package/lib/types/index.js.map +1 -0
- package/lib/types/plan-gate.d.ts +27 -0
- package/lib/types/plan-gate.d.ts.map +1 -0
- package/lib/types/plan-gate.js +60 -0
- package/lib/types/plan-gate.js.map +1 -0
- package/lib/types/router.d.ts +28 -32
- package/lib/types/router.d.ts.map +1 -0
- package/lib/types/router.js +41 -9
- package/lib/types/router.js.map +1 -0
- package/lib/types/session.d.ts +11 -0
- package/lib/types/session.d.ts.map +1 -0
- package/lib/types/session.js +41 -11
- package/lib/types/session.js.map +1 -0
- package/lib/types/statistics.d.ts.map +1 -0
- package/lib/types/statistics.js.map +1 -0
- package/lib/types/team-gate.d.ts +20 -0
- package/lib/types/team-gate.d.ts.map +1 -0
- package/lib/types/team-gate.js +59 -0
- package/lib/types/team-gate.js.map +1 -0
- package/lib/types/top-logprobs.d.ts.map +1 -0
- package/lib/types/top-logprobs.js.map +1 -0
- package/lib/types/topic-storage.d.ts.map +1 -0
- package/lib/types/topic-storage.js.map +1 -0
- package/package.json +114 -112
- package/src/auto.test.ts +95 -86
- package/src/auto.ts +140 -133
- package/src/cache.ts +115 -115
- package/src/caller.test.ts +34 -34
- package/src/caller.ts +151 -151
- package/src/client-i18n.ts +18 -0
- package/src/client.tsx +383 -213
- package/src/config.ts +189 -183
- package/src/core.test.ts +71 -71
- package/src/core.ts +205 -205
- package/src/engine.test.ts +136 -136
- package/src/engine.ts +140 -140
- package/src/images.ts +33 -33
- package/src/index.ts +394 -247
- package/src/parity.test.ts +85 -85
- package/src/plan-gate.test.ts +56 -0
- package/src/plan-gate.ts +66 -0
- package/src/router.test.ts +107 -89
- package/src/router.ts +361 -309
- package/src/session.test.ts +71 -36
- package/src/session.ts +132 -103
- package/src/statistics.test.ts +80 -80
- package/src/statistics.ts +328 -328
- package/src/team-gate.test.ts +92 -0
- package/src/team-gate.ts +80 -0
- package/src/top-logprobs.test.ts +84 -84
- package/src/top-logprobs.ts +153 -153
- package/src/topic-storage.test.ts +31 -31
- package/src/topic-storage.ts +42 -42
package/src/engine.test.ts
CHANGED
|
@@ -1,136 +1,136 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
-
import { tmpdir } from 'node:os'
|
|
3
|
-
import { join } from 'node:path'
|
|
4
|
-
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
-
import type { VerifierClientConfig } from './caller.ts'
|
|
6
|
-
import { ScoreCache, SingleFlight, type CachedPairScore } from './cache.ts'
|
|
7
|
-
import { VerifierEngine } from './engine.ts'
|
|
8
|
-
import { TopLogprobCapabilityCache } from './top-logprobs.ts'
|
|
9
|
-
|
|
10
|
-
function chunks(text: string) { return [{ type: 'block-start', index: 0, blockType: 'text' }, { type: 'text-delta', index: 0, text }, { type: 'block-end', index: 0, block: { type: 'text', text } }, { type: 'usage', usage: { inputTokens: 7, cacheReadTokens: 3, outputTokens: 4, reasoningTokens: 2 } }, { type: 'finish', reason: { kind: 'stop' } }] as any[] }
|
|
11
|
-
async function* streamOf(items: any[]) { for (const item of items) yield item }
|
|
12
|
-
|
|
13
|
-
function clientConfig(overrides: Partial<VerifierClientConfig> = {}): VerifierClientConfig {
|
|
14
|
-
return { ctx: { get: () => undefined } as any, llm: { stream: async function* () { throw new Error('unexpected model call') } } as any, attachments: { saveImage: async () => ({}) } as any, topLogprobCapabilities: new TopLogprobCapabilityCache(), provider: 'openai', model: 'gpt-5', reasoningEffort: 'high', maxTokens: 100, timeoutMs: 1000, maxRetries: 0, retryBaseDelayMs: 1, ...overrides }
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Streams an explicit-tag verdict derived from which named candidate sits in trajectory A/B. */
|
|
18
|
-
function scriptedStream(judged: Array<string>, gate?: Array<() => void>): (options: any) => AsyncIterable<any[]> {
|
|
19
|
-
return function (options: any) {
|
|
20
|
-
const message = options.messages[0]
|
|
21
|
-
const prompt = typeof message.content === 'string' ? message.content : message.content.filter((block: any) => block.type === 'text').map((block: any) => block.text).join('')
|
|
22
|
-
const section = (marker: string) => { const at = prompt.indexOf(marker); if (at < 0) return ''; const end = prompt.indexOf('\n\n**', at); return prompt.slice(at + marker.length, end < 0 ? undefined : end) }
|
|
23
|
-
const traceA = section('**Trajectory A:**\n')
|
|
24
|
-
const traceB = section('**Trajectory B:**\n')
|
|
25
|
-
const letter = (trace: string) => trace.includes('STRONG') ? 'A' : 'T'
|
|
26
|
-
const criterion = /\*\*Evaluation Guideline — (.+?):\*\*/.exec(prompt)?.[1] ?? '?'
|
|
27
|
-
judged.push(criterion + '|' + [traceA.trim(), traceB.trim()].sort().join('|'))
|
|
28
|
-
const text = '<score_A> ' + letter(traceA) + ' </score_A>\n<score_B> ' + letter(traceB) + ' </score_B>'
|
|
29
|
-
if (gate === undefined) return streamOf(chunks(text))
|
|
30
|
-
return (async function* () { await new Promise<void>(resolve => { gate.push(resolve) }); yield* streamOf(chunks(text)) })()
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
afterEach(() => vi.unstubAllGlobals())
|
|
35
|
-
|
|
36
|
-
describe('VerifierEngine tournament', () => {
|
|
37
|
-
it('judges each unordered candidate pair at most once and ranks the strong candidate first', async () => {
|
|
38
|
-
const judged: Array<string> = []
|
|
39
|
-
const llm = { stream: scriptedStream(judged) } as any
|
|
40
|
-
const engine = new VerifierEngine(clientConfig({ llm }), 4)
|
|
41
|
-
const candidates = ['STRONG-0', 'WEAK-1', 'WEAK-2', 'WEAK-3']
|
|
42
|
-
const result = await engine.select({ problem: 'Pick the better implementation.', candidates, repeats: 1 })
|
|
43
|
-
// Ring edges incident to a pivot used to be re-judged (reversed) in the pivot round.
|
|
44
|
-
expect(new Set(judged).size).toBe(judged.length)
|
|
45
|
-
expect(result.comparisons).toBe(new Set(judged.map(entry => entry.split('|')[1] + '|' + entry.split('|')[2])).size)
|
|
46
|
-
expect(result.stats.calls).toBe(judged.length)
|
|
47
|
-
expect(result.ranking[0]).toBe(0)
|
|
48
|
-
expect(result.best).toBe('STRONG-0')
|
|
49
|
-
})
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
describe('VerifierEngine cache identity', () => {
|
|
53
|
-
it('does not reuse an explicit-tag cache entry for top-logprobs scoring', async () => {
|
|
54
|
-
const dir = mkdtempSync(join(tmpdir(), 'dsh-verifier-engine-'))
|
|
55
|
-
try {
|
|
56
|
-
const file = join(dir, 'scores.json')
|
|
57
|
-
const options = { problem: 'task', candidateA: 'AAA', candidateB: 'BBB', repeats: 1 }
|
|
58
|
-
const fallbackCaps = new TopLogprobCapabilityCache()
|
|
59
|
-
fallbackCaps.markUnsupported('openai', 'gpt-5')
|
|
60
|
-
const fallback = new VerifierEngine(clientConfig({ llm: { stream: scriptedStream([]) } as any, topLogprobCapabilities: fallbackCaps }), 4, new ScoreCache(file, 100))
|
|
61
|
-
const first = await fallback.compare(options)
|
|
62
|
-
expect(first.stats.explicitTagScores).toBe(3)
|
|
63
|
-
expect(first.stats.cacheMisses).toBe(3)
|
|
64
|
-
|
|
65
|
-
const alternative = (token: string, probability: number) => ({ token, logprob: Math.log(probability) })
|
|
66
|
-
const body = { choices: [{ message: { content: '<score_A> A </score_A> <score_B> T </score_B>' }, logprobs: { content: [{ token: '<score_A>', logprob: 0, top_logprobs: [] }, { token: 'A', logprob: -0.1, top_logprobs: [alternative('A', 0.7), alternative('T', 0.3)] }, { token: '<score_B>', logprob: 0, top_logprobs: [] }, { token: 'T', logprob: -0.1, top_logprobs: [alternative('T', 0.8), alternative('A', 0.2)] }] } }], usage: { prompt_tokens: 10, completion_tokens: 4 } }
|
|
67
|
-
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(body), { status: 200 })))
|
|
68
|
-
const context = { get(name: string) { if (name === 'settings') return { get: () => ({ providers: { openai: { api: 'openai-completions', baseURL: 'https://example.test/v1', apiKeyEnv: 'OPENAI_API_KEY' } } }) }; if (name === 'credentials') return { resolve: async () => ({ value: 'secret' }) }; return undefined } }
|
|
69
|
-
const live = new VerifierEngine(clientConfig({ ctx: context as any }), 4, new ScoreCache(file, 100))
|
|
70
|
-
const second = await live.compare(options)
|
|
71
|
-
expect(second.stats.topLogprobScores).toBe(3)
|
|
72
|
-
expect(second.stats.cacheHits).toBe(0)
|
|
73
|
-
expect(second.stats.cacheMisses).toBe(3)
|
|
74
|
-
} finally {
|
|
75
|
-
rmSync(dir, { recursive: true, force: true })
|
|
76
|
-
}
|
|
77
|
-
})
|
|
78
|
-
it('files a first-call explicit-tag downgrade under explicit-tag cache keys', async () => {
|
|
79
|
-
const dir = mkdtempSync(join(tmpdir(), 'dsh-verifier-engine-'))
|
|
80
|
-
try {
|
|
81
|
-
const file = join(dir, 'scores.json')
|
|
82
|
-
const options = { problem: 'task', candidateA: 'AAA', candidateB: 'BBB', repeats: 1 }
|
|
83
|
-
// No settings in ctx, so the first call predicts top-logprobs but discovers no
|
|
84
|
-
// route at runtime and downgrades; nothing was pre-marked unsupported.
|
|
85
|
-
const engine = new VerifierEngine(clientConfig({ llm: { stream: scriptedStream([]) } as any }), 4, new ScoreCache(file, 100))
|
|
86
|
-
const first = await engine.compare(options)
|
|
87
|
-
expect(first.stats.explicitTagScores).toBe(3)
|
|
88
|
-
expect(first.stats.cacheMisses).toBe(3)
|
|
89
|
-
// The downgraded entries must be found by the now-explicit-tag prediction.
|
|
90
|
-
const second = await engine.compare(options)
|
|
91
|
-
expect(second.stats.cacheHits).toBe(3)
|
|
92
|
-
expect(second.stats.calls).toBe(0)
|
|
93
|
-
expect(second.stats.explicitTagScores).toBe(3)
|
|
94
|
-
} finally {
|
|
95
|
-
rmSync(dir, { recursive: true, force: true })
|
|
96
|
-
}
|
|
97
|
-
})
|
|
98
|
-
it('plays a single match when select is called with two candidates', async () => {
|
|
99
|
-
const judged: Array<string> = []
|
|
100
|
-
const engine = new VerifierEngine(clientConfig({ llm: { stream: scriptedStream(judged) } as any }), 4)
|
|
101
|
-
const result = await engine.select({ problem: 'Pick the better implementation.', candidates: ['STRONG-0', 'WEAK-1'], repeats: 1 })
|
|
102
|
-
expect(new Set(judged).size).toBe(judged.length)
|
|
103
|
-
expect(result.comparisons).toBe(1)
|
|
104
|
-
expect(result.index).toBe(0)
|
|
105
|
-
expect(result.pivots).toEqual([])
|
|
106
|
-
})
|
|
107
|
-
it('merges concurrent identical requests across engine instances inside the first-call downgrade window', async () => {
|
|
108
|
-
const dir = mkdtempSync(join(tmpdir(), 'dsh-verifier-engine-'))
|
|
109
|
-
try {
|
|
110
|
-
const judged: Array<string> = []
|
|
111
|
-
const gate: Array<() => void> = []
|
|
112
|
-
const caps = new TopLogprobCapabilityCache()
|
|
113
|
-
// Separate engines, shared topic flight table + cache — the index.ts assembly shape.
|
|
114
|
-
const flights = new SingleFlight<{ value: CachedPairScore; hit: boolean }>()
|
|
115
|
-
const cache = new ScoreCache(join(dir, 'scores.json'), 100)
|
|
116
|
-
const makeEngine = () => new VerifierEngine(clientConfig({ llm: { stream: scriptedStream(judged, gate) } as any, topLogprobCapabilities: caps }), 4, cache, { input: 0, output: 0 }, flights)
|
|
117
|
-
const options = { problem: 'task', candidateA: 'AAA', candidateB: 'BBB', repeats: 1, criteria: [{ id: 'one', name: 'One', description: 'single criterion' }] }
|
|
118
|
-
// No settings in ctx, so t1 predicts top-logprobs but downgrades at runtime.
|
|
119
|
-
const first = makeEngine().compare(options)
|
|
120
|
-
caps.markUnsupported('openai', 'gpt-5')
|
|
121
|
-
const second = makeEngine().compare(options)
|
|
122
|
-
// Wait until the first engine's stream is actually parked on the gate (cache
|
|
123
|
-
// load does real fs I/O, so a single macrotask tick is not enough), then release.
|
|
124
|
-
for (let index = 0; index < 200 && gate.length === 0; index += 1) await new Promise<void>(resolve => setTimeout(resolve, 5))
|
|
125
|
-
while (gate.length > 0) gate.shift()!()
|
|
126
|
-
const [r1, r2] = await Promise.all([first, second])
|
|
127
|
-
// One criterion, one merged model call — without the shared flight the second engine would re-run it.
|
|
128
|
-
expect(judged).toHaveLength(1)
|
|
129
|
-
expect(r1.stats.cacheMisses).toBe(1)
|
|
130
|
-
expect(r2.stats.cacheHits).toBe(1)
|
|
131
|
-
expect(r2.stats.calls).toBe(0)
|
|
132
|
-
expect(r2.scoreA).toBe(r1.scoreA)
|
|
133
|
-
expect(r2.scoreB).toBe(r1.scoreB)
|
|
134
|
-
} finally { rmSync(dir, { recursive: true, force: true }) }
|
|
135
|
-
})
|
|
136
|
-
})
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import type { VerifierClientConfig } from './caller.ts'
|
|
6
|
+
import { ScoreCache, SingleFlight, type CachedPairScore } from './cache.ts'
|
|
7
|
+
import { VerifierEngine } from './engine.ts'
|
|
8
|
+
import { TopLogprobCapabilityCache } from './top-logprobs.ts'
|
|
9
|
+
|
|
10
|
+
function chunks(text: string) { return [{ type: 'block-start', index: 0, blockType: 'text' }, { type: 'text-delta', index: 0, text }, { type: 'block-end', index: 0, block: { type: 'text', text } }, { type: 'usage', usage: { inputTokens: 7, cacheReadTokens: 3, outputTokens: 4, reasoningTokens: 2 } }, { type: 'finish', reason: { kind: 'stop' } }] as any[] }
|
|
11
|
+
async function* streamOf(items: any[]) { for (const item of items) yield item }
|
|
12
|
+
|
|
13
|
+
function clientConfig(overrides: Partial<VerifierClientConfig> = {}): VerifierClientConfig {
|
|
14
|
+
return { ctx: { get: () => undefined } as any, llm: { stream: async function* () { throw new Error('unexpected model call') } } as any, attachments: { saveImage: async () => ({}) } as any, topLogprobCapabilities: new TopLogprobCapabilityCache(), provider: 'openai', model: 'gpt-5', reasoningEffort: 'high', maxTokens: 100, timeoutMs: 1000, maxRetries: 0, retryBaseDelayMs: 1, ...overrides }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Streams an explicit-tag verdict derived from which named candidate sits in trajectory A/B. */
|
|
18
|
+
function scriptedStream(judged: Array<string>, gate?: Array<() => void>): (options: any) => AsyncIterable<any[]> {
|
|
19
|
+
return function (options: any) {
|
|
20
|
+
const message = options.messages[0]
|
|
21
|
+
const prompt = typeof message.content === 'string' ? message.content : message.content.filter((block: any) => block.type === 'text').map((block: any) => block.text).join('')
|
|
22
|
+
const section = (marker: string) => { const at = prompt.indexOf(marker); if (at < 0) return ''; const end = prompt.indexOf('\n\n**', at); return prompt.slice(at + marker.length, end < 0 ? undefined : end) }
|
|
23
|
+
const traceA = section('**Trajectory A:**\n')
|
|
24
|
+
const traceB = section('**Trajectory B:**\n')
|
|
25
|
+
const letter = (trace: string) => trace.includes('STRONG') ? 'A' : 'T'
|
|
26
|
+
const criterion = /\*\*Evaluation Guideline — (.+?):\*\*/.exec(prompt)?.[1] ?? '?'
|
|
27
|
+
judged.push(criterion + '|' + [traceA.trim(), traceB.trim()].sort().join('|'))
|
|
28
|
+
const text = '<score_A> ' + letter(traceA) + ' </score_A>\n<score_B> ' + letter(traceB) + ' </score_B>'
|
|
29
|
+
if (gate === undefined) return streamOf(chunks(text))
|
|
30
|
+
return (async function* () { await new Promise<void>(resolve => { gate.push(resolve) }); yield* streamOf(chunks(text)) })()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
35
|
+
|
|
36
|
+
describe('VerifierEngine tournament', () => {
|
|
37
|
+
it('judges each unordered candidate pair at most once and ranks the strong candidate first', async () => {
|
|
38
|
+
const judged: Array<string> = []
|
|
39
|
+
const llm = { stream: scriptedStream(judged) } as any
|
|
40
|
+
const engine = new VerifierEngine(clientConfig({ llm }), 4)
|
|
41
|
+
const candidates = ['STRONG-0', 'WEAK-1', 'WEAK-2', 'WEAK-3']
|
|
42
|
+
const result = await engine.select({ problem: 'Pick the better implementation.', candidates, repeats: 1 })
|
|
43
|
+
// Ring edges incident to a pivot used to be re-judged (reversed) in the pivot round.
|
|
44
|
+
expect(new Set(judged).size).toBe(judged.length)
|
|
45
|
+
expect(result.comparisons).toBe(new Set(judged.map(entry => entry.split('|')[1] + '|' + entry.split('|')[2])).size)
|
|
46
|
+
expect(result.stats.calls).toBe(judged.length)
|
|
47
|
+
expect(result.ranking[0]).toBe(0)
|
|
48
|
+
expect(result.best).toBe('STRONG-0')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('VerifierEngine cache identity', () => {
|
|
53
|
+
it('does not reuse an explicit-tag cache entry for top-logprobs scoring', async () => {
|
|
54
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-verifier-engine-'))
|
|
55
|
+
try {
|
|
56
|
+
const file = join(dir, 'scores.json')
|
|
57
|
+
const options = { problem: 'task', candidateA: 'AAA', candidateB: 'BBB', repeats: 1 }
|
|
58
|
+
const fallbackCaps = new TopLogprobCapabilityCache()
|
|
59
|
+
fallbackCaps.markUnsupported('openai', 'gpt-5')
|
|
60
|
+
const fallback = new VerifierEngine(clientConfig({ llm: { stream: scriptedStream([]) } as any, topLogprobCapabilities: fallbackCaps }), 4, new ScoreCache(file, 100))
|
|
61
|
+
const first = await fallback.compare(options)
|
|
62
|
+
expect(first.stats.explicitTagScores).toBe(3)
|
|
63
|
+
expect(first.stats.cacheMisses).toBe(3)
|
|
64
|
+
|
|
65
|
+
const alternative = (token: string, probability: number) => ({ token, logprob: Math.log(probability) })
|
|
66
|
+
const body = { choices: [{ message: { content: '<score_A> A </score_A> <score_B> T </score_B>' }, logprobs: { content: [{ token: '<score_A>', logprob: 0, top_logprobs: [] }, { token: 'A', logprob: -0.1, top_logprobs: [alternative('A', 0.7), alternative('T', 0.3)] }, { token: '<score_B>', logprob: 0, top_logprobs: [] }, { token: 'T', logprob: -0.1, top_logprobs: [alternative('T', 0.8), alternative('A', 0.2)] }] } }], usage: { prompt_tokens: 10, completion_tokens: 4 } }
|
|
67
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(body), { status: 200 })))
|
|
68
|
+
const context = { get(name: string) { if (name === 'settings') return { get: () => ({ providers: { openai: { api: 'openai-completions', baseURL: 'https://example.test/v1', apiKeyEnv: 'OPENAI_API_KEY' } } }) }; if (name === 'credentials') return { resolve: async () => ({ value: 'secret' }) }; return undefined } }
|
|
69
|
+
const live = new VerifierEngine(clientConfig({ ctx: context as any }), 4, new ScoreCache(file, 100))
|
|
70
|
+
const second = await live.compare(options)
|
|
71
|
+
expect(second.stats.topLogprobScores).toBe(3)
|
|
72
|
+
expect(second.stats.cacheHits).toBe(0)
|
|
73
|
+
expect(second.stats.cacheMisses).toBe(3)
|
|
74
|
+
} finally {
|
|
75
|
+
rmSync(dir, { recursive: true, force: true })
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
it('files a first-call explicit-tag downgrade under explicit-tag cache keys', async () => {
|
|
79
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-verifier-engine-'))
|
|
80
|
+
try {
|
|
81
|
+
const file = join(dir, 'scores.json')
|
|
82
|
+
const options = { problem: 'task', candidateA: 'AAA', candidateB: 'BBB', repeats: 1 }
|
|
83
|
+
// No settings in ctx, so the first call predicts top-logprobs but discovers no
|
|
84
|
+
// route at runtime and downgrades; nothing was pre-marked unsupported.
|
|
85
|
+
const engine = new VerifierEngine(clientConfig({ llm: { stream: scriptedStream([]) } as any }), 4, new ScoreCache(file, 100))
|
|
86
|
+
const first = await engine.compare(options)
|
|
87
|
+
expect(first.stats.explicitTagScores).toBe(3)
|
|
88
|
+
expect(first.stats.cacheMisses).toBe(3)
|
|
89
|
+
// The downgraded entries must be found by the now-explicit-tag prediction.
|
|
90
|
+
const second = await engine.compare(options)
|
|
91
|
+
expect(second.stats.cacheHits).toBe(3)
|
|
92
|
+
expect(second.stats.calls).toBe(0)
|
|
93
|
+
expect(second.stats.explicitTagScores).toBe(3)
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(dir, { recursive: true, force: true })
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
it('plays a single match when select is called with two candidates', async () => {
|
|
99
|
+
const judged: Array<string> = []
|
|
100
|
+
const engine = new VerifierEngine(clientConfig({ llm: { stream: scriptedStream(judged) } as any }), 4)
|
|
101
|
+
const result = await engine.select({ problem: 'Pick the better implementation.', candidates: ['STRONG-0', 'WEAK-1'], repeats: 1 })
|
|
102
|
+
expect(new Set(judged).size).toBe(judged.length)
|
|
103
|
+
expect(result.comparisons).toBe(1)
|
|
104
|
+
expect(result.index).toBe(0)
|
|
105
|
+
expect(result.pivots).toEqual([])
|
|
106
|
+
})
|
|
107
|
+
it('merges concurrent identical requests across engine instances inside the first-call downgrade window', async () => {
|
|
108
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-verifier-engine-'))
|
|
109
|
+
try {
|
|
110
|
+
const judged: Array<string> = []
|
|
111
|
+
const gate: Array<() => void> = []
|
|
112
|
+
const caps = new TopLogprobCapabilityCache()
|
|
113
|
+
// Separate engines, shared topic flight table + cache — the index.ts assembly shape.
|
|
114
|
+
const flights = new SingleFlight<{ value: CachedPairScore; hit: boolean }>()
|
|
115
|
+
const cache = new ScoreCache(join(dir, 'scores.json'), 100)
|
|
116
|
+
const makeEngine = () => new VerifierEngine(clientConfig({ llm: { stream: scriptedStream(judged, gate) } as any, topLogprobCapabilities: caps }), 4, cache, { input: 0, output: 0 }, flights)
|
|
117
|
+
const options = { problem: 'task', candidateA: 'AAA', candidateB: 'BBB', repeats: 1, criteria: [{ id: 'one', name: 'One', description: 'single criterion' }] }
|
|
118
|
+
// No settings in ctx, so t1 predicts top-logprobs but downgrades at runtime.
|
|
119
|
+
const first = makeEngine().compare(options)
|
|
120
|
+
caps.markUnsupported('openai', 'gpt-5')
|
|
121
|
+
const second = makeEngine().compare(options)
|
|
122
|
+
// Wait until the first engine's stream is actually parked on the gate (cache
|
|
123
|
+
// load does real fs I/O, so a single macrotask tick is not enough), then release.
|
|
124
|
+
for (let index = 0; index < 200 && gate.length === 0; index += 1) await new Promise<void>(resolve => setTimeout(resolve, 5))
|
|
125
|
+
while (gate.length > 0) gate.shift()!()
|
|
126
|
+
const [r1, r2] = await Promise.all([first, second])
|
|
127
|
+
// One criterion, one merged model call — without the shared flight the second engine would re-run it.
|
|
128
|
+
expect(judged).toHaveLength(1)
|
|
129
|
+
expect(r1.stats.cacheMisses).toBe(1)
|
|
130
|
+
expect(r2.stats.cacheHits).toBe(1)
|
|
131
|
+
expect(r2.stats.calls).toBe(0)
|
|
132
|
+
expect(r2.scoreA).toBe(r1.scoreA)
|
|
133
|
+
expect(r2.scoreB).toBe(r1.scoreB)
|
|
134
|
+
} finally { rmSync(dir, { recursive: true, force: true }) }
|
|
135
|
+
})
|
|
136
|
+
})
|
package/src/engine.ts
CHANGED
|
@@ -1,140 +1,140 @@
|
|
|
1
|
-
import { addUsage, callVerifier, emptyUsage, predictScoringChannel, type ScoringMode, type UsageStats, type VerifierClientConfig, type VerifierImage } from './caller.ts'
|
|
2
|
-
import { ScoreCache, SingleFlight, stableHash, type CachedPairScore } from './cache.ts'
|
|
3
|
-
import {
|
|
4
|
-
DEFAULT_CRITERIA, DEFAULT_GROUND_TRUTH_NOTE, accumulatePairs, buildPairwisePrompt, buildProgressPrompt,
|
|
5
|
-
extractProgressScore, extractScore, pivotRoundPairs, rankScores, ringCycle, topPivots, type Criterion,
|
|
6
|
-
} from './core.ts'
|
|
7
|
-
|
|
8
|
-
export interface CompareOptions { problem: string; candidateA: string; candidateB: string; criteria?: readonly Criterion[]; groundTruthNote?: string; repeats?: number; images?: readonly VerifierImage[] }
|
|
9
|
-
export interface CriterionResult { id: string; name: string; scoreA: number; scoreB: number }
|
|
10
|
-
export interface RunStats extends UsageStats { cacheHits: number; cacheMisses: number; estimatedCostUsd: number; topLogprobScores: number; explicitTagScores: number }
|
|
11
|
-
export interface CompareResult { scoreA: number; scoreB: number; winner: 'A' | 'B' | 'tie'; criteria: CriterionResult[]; calls: number; stats: RunStats }
|
|
12
|
-
export interface SelectOptions { problem: string; candidates: readonly string[]; criteria?: readonly Criterion[]; groundTruthNote?: string; repeats?: number; pivots?: number; seed?: number; images?: readonly VerifierImage[] }
|
|
13
|
-
export interface SelectResult { index: number; best: string; scores: number[]; ranking: number[]; pivots: number[]; comparisons: number; calls: number; stats: RunStats }
|
|
14
|
-
|
|
15
|
-
function average(values: readonly number[]): number { return values.reduce((sum, value) => sum + value, 0) / (values.length || 1) }
|
|
16
|
-
function blankStats(): RunStats { return { ...emptyUsage(), cacheHits: 0, cacheMisses: 0, estimatedCostUsd: 0, topLogprobScores: 0, explicitTagScores: 0 } }
|
|
17
|
-
function unorderedPair(a: number, b: number): string { return a < b ? a + ',' + b : b + ',' + a }
|
|
18
|
-
|
|
19
|
-
export class VerifierEngine {
|
|
20
|
-
readonly client: VerifierClientConfig
|
|
21
|
-
readonly maxConcurrency: number
|
|
22
|
-
readonly cache: ScoreCache | undefined
|
|
23
|
-
readonly inputPrice: number
|
|
24
|
-
readonly outputPrice: number
|
|
25
|
-
private readonly flights: SingleFlight<{ value: CachedPairScore; hit: boolean }>
|
|
26
|
-
|
|
27
|
-
constructor(client: VerifierClientConfig, maxConcurrency = 8, cache?: ScoreCache, prices: { input: number; output: number } = { input: 0, output: 0 }, flights: SingleFlight<{ value: CachedPairScore; hit: boolean }> = new SingleFlight()) {
|
|
28
|
-
this.client = client; this.maxConcurrency = maxConcurrency; this.cache = cache; this.inputPrice = prices.input; this.outputPrice = prices.output; this.flights = flights
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
private finishStats(stats: RunStats): RunStats {
|
|
32
|
-
stats.estimatedCostUsd = ((stats.inputTokens + stats.cachedInputTokens) * this.inputPrice + stats.outputTokens * this.outputPrice) / 1_000_000
|
|
33
|
-
return stats
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
private async scoreOne(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 }> {
|
|
37
|
-
const ground = options.groundTruthNote ?? DEFAULT_GROUND_TRUTH_NOTE
|
|
38
|
-
const prompt = buildPairwisePrompt(options.problem, candidateA, candidateB, criterion, ground)
|
|
39
|
-
const imageKey = options.images?.map(image => stableHash([image.mediaType, Buffer.from(image.data).toString('base64')]))
|
|
40
|
-
// Cache identity pins the scoring channel, but the channel is only predictable
|
|
41
|
-
// before the call when the capability cache already knows the answer. Lookups use
|
|
42
|
-
// the predicted channel while entries are always stored under the ACTUAL
|
|
43
|
-
// completion mode, so a first-call downgrade lands under explicit-tag keys and is
|
|
44
|
-
// found by later explicit-tag calls instead of being misread as a top-logprobs
|
|
45
|
-
// expectation. The channel stays OUT of the in-flight dedup key below.
|
|
46
|
-
const identity = { version: 4, provider: this.client.provider, model: this.client.model, effort: this.client.reasoningEffort, maxTokens: this.client.maxTokens, repeat, promptHash: stableHash(prompt), imageKey }
|
|
47
|
-
const keyForMode = (scoringMode: ScoringMode) => stableHash({ ...identity, scoringMode })
|
|
48
|
-
const create = async () => {
|
|
49
|
-
const completion = await callVerifier(this.client, prompt, signal, options.images)
|
|
50
|
-
return { scoreA: extractScore(completion, '<score_A>'), scoreB: extractScore(completion, '<score_B>'), usage: completion.usage, scoringMode: completion.scoringMode, createdAt: Date.now() }
|
|
51
|
-
}
|
|
52
|
-
const cache = this.cache
|
|
53
|
-
if (cache === undefined) { const value = await create(); return { scores: [value.scoreA, value.scoreB], usage: value.usage, scoringMode: value.scoringMode, hit: false } }
|
|
54
|
-
// Registration happens in the synchronous segment before any await, so a request
|
|
55
|
-
// that arrives while the first one is still resolving its channel prediction
|
|
56
|
-
// still merges instead of duplicating the model call. The flight table is shared
|
|
57
|
-
// per topic, so concurrent tool calls through separate engine instances merge too.
|
|
58
|
-
const dedupeKey = stableHash(identity)
|
|
59
|
-
const outcome = await this.flights.run(dedupeKey, async () => cache.getOrCreate(keyForMode(await predictScoringChannel(this.client)), create, value => keyForMode(value.scoringMode)))
|
|
60
|
-
const landed = outcome.value
|
|
61
|
-
const reused = outcome.joined || landed.hit
|
|
62
|
-
return { scores: [landed.value.scoreA, landed.value.scoreB], usage: reused ? emptyUsage() : landed.value.usage, scoringMode: landed.value.scoringMode, hit: reused }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
private async mapLimited<T, R>(items: readonly T[], worker: (item: T) => Promise<R>): Promise<R[]> {
|
|
66
|
-
const results = new Array<R>(items.length); let cursor = 0
|
|
67
|
-
const runners = Array.from({ length: Math.min(this.maxConcurrency, items.length) }, async () => { while (cursor < items.length) { const index = cursor++; results[index] = await worker(items[index]!) } })
|
|
68
|
-
await Promise.all(runners); return results
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async compare(options: CompareOptions, signal?: AbortSignal): Promise<CompareResult> {
|
|
72
|
-
const criteria = options.criteria?.length ? options.criteria : DEFAULT_CRITERIA
|
|
73
|
-
const repeats = options.repeats ?? 2
|
|
74
|
-
const jobs = criteria.flatMap(criterion => Array.from({ length: repeats }, (_, repeat) => ({ criterion, repeat })))
|
|
75
|
-
// Prefix warm-up: one criterion/repeat runs first, then the shared-prefix fan-out.
|
|
76
|
-
const warm = jobs.slice(0, 1); const rest = jobs.slice(1)
|
|
77
|
-
const run = async (batch: typeof jobs) => this.mapLimited(batch, async ({ criterion, repeat }) => {
|
|
78
|
-
const swapped = repeat % 2 === 1
|
|
79
|
-
const result = await this.scoreOne(options, swapped ? options.candidateB : options.candidateA, swapped ? options.candidateA : options.candidateB, criterion, repeat, signal)
|
|
80
|
-
return { criterion, scoreA: swapped ? result.scores[1] : result.scores[0], scoreB: swapped ? result.scores[0] : result.scores[1], usage: result.usage, scoringMode: result.scoringMode, hit: result.hit }
|
|
81
|
-
})
|
|
82
|
-
const values = [...await run(warm), ...await run(rest)]
|
|
83
|
-
const stats = blankStats()
|
|
84
|
-
for (const value of values) { addUsage(stats, value.usage); value.hit ? stats.cacheHits++ : stats.cacheMisses++; value.scoringMode === 'top-logprobs' ? stats.topLogprobScores++ : stats.explicitTagScores++ }
|
|
85
|
-
const byCriterion = criteria.map(criterion => { const rows = values.filter(value => value.criterion.id === criterion.id); return { id: criterion.id, name: criterion.name, scoreA: average(rows.map(row => row.scoreA)), scoreB: average(rows.map(row => row.scoreB)) } })
|
|
86
|
-
const scoreA = average(byCriterion.map(value => value.scoreA)); const scoreB = average(byCriterion.map(value => value.scoreB))
|
|
87
|
-
return { scoreA, scoreB, winner: Math.abs(scoreA - scoreB) < 1e-12 ? 'tie' : scoreA > scoreB ? 'A' : 'B', criteria: byCriterion, calls: stats.calls, stats: this.finishStats(stats) }
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
private async scorePairs(options: SelectOptions, pairs: readonly [number, number][], signal?: AbortSignal): Promise<{ rewards: Map<string, readonly [number, number]>; stats: RunStats }> {
|
|
91
|
-
const unique = [...new Map(pairs.map(pair => [pair[0] + ',' + pair[1], pair])).values()]
|
|
92
|
-
const values = await this.mapLimited(unique, async ([a, b]) => ({ a, b, result: await this.compare({ problem: options.problem, candidateA: options.candidates[a]!, candidateB: options.candidates[b]!, criteria: options.criteria, groundTruthNote: options.groundTruthNote, repeats: options.repeats, images: options.images }, signal) }))
|
|
93
|
-
const rewards = new Map<string, readonly [number, number]>(); const stats = blankStats()
|
|
94
|
-
for (const value of values) { rewards.set(value.a + ',' + value.b, [value.result.scoreA, value.result.scoreB]); addUsage(stats, value.result.stats); stats.cacheHits += value.result.stats.cacheHits; stats.cacheMisses += value.result.stats.cacheMisses; stats.topLogprobScores += value.result.stats.topLogprobScores; stats.explicitTagScores += value.result.stats.explicitTagScores }
|
|
95
|
-
return { rewards, stats: this.finishStats(stats) }
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
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 }> {
|
|
99
|
-
if (!steps.length || !checkpoints.length) throw new Error('llm-verifier: steps and checkpoints must not be empty')
|
|
100
|
-
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')
|
|
101
|
-
const prompt = buildProgressPrompt(problem, steps, checkpoints)
|
|
102
|
-
const completions = await this.mapLimited(Array.from({ length: repeats }, (_, index) => index), async () => callVerifier(this.client, prompt, signal, images))
|
|
103
|
-
const stats = blankStats(); for (const completion of completions) { addUsage(stats, completion.usage); completion.scoringMode === 'top-logprobs' ? stats.topLogprobScores++ : stats.explicitTagScores++ }
|
|
104
|
-
const runs = completions.map(completion => checkpoints.map((_, index) => extractProgressScore(completion, '<c' + (index + 1) + '>')))
|
|
105
|
-
return { scores: checkpoints.map((_, index) => average(runs.map(run => run[index]!))), perRepeat: runs, calls: stats.calls, stats: this.finishStats(stats) }
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async select(options: SelectOptions, signal?: AbortSignal): Promise<SelectResult> {
|
|
109
|
-
if (!options.candidates.length) throw new Error('llm-verifier: candidates must not be empty')
|
|
110
|
-
if (options.candidates.length === 1) return { index: 0, best: options.candidates[0]!, scores: [1], ranking: [0], pivots: [0], comparisons: 0, calls: 0, stats: blankStats() }
|
|
111
|
-
if (options.candidates.length === 2) {
|
|
112
|
-
// ringCycle(2) would judge the single unordered pair in both directions; play it once.
|
|
113
|
-
const { rewards, stats } = await this.scorePairs(options, [[0, 1]], signal)
|
|
114
|
-
const wins = [0, 0]; const counts = [0, 0]
|
|
115
|
-
accumulatePairs([[0, 1]], rewards, wins, counts)
|
|
116
|
-
const ranked = rankScores(wins, counts); const index = ranked[0]!.index
|
|
117
|
-
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 }
|
|
118
|
-
}
|
|
119
|
-
const ring = ringCycle(options.candidates.length, options.seed ?? 0); const ringScores = await this.scorePairs(options, ring, signal)
|
|
120
|
-
const firstWins = new Array<number>(options.candidates.length).fill(0); const firstCounts = new Array<number>(options.candidates.length).fill(0); accumulatePairs(ring, ringScores.rewards, firstWins, firstCounts)
|
|
121
|
-
const pivots = topPivots(firstWins, firstCounts, options.pivots ?? 2)
|
|
122
|
-
// pivotRoundPairs regenerates every ring edge that touches a pivot (reversed for
|
|
123
|
-
// pivot→neighbour edges). Dropping those duplicates keeps each unordered pair to a
|
|
124
|
-
// single match so wins/counts are not double-weighted and no pair is judged twice.
|
|
125
|
-
const ringPairs = new Set(ring.map(pair => unorderedPair(pair[0], pair[1])))
|
|
126
|
-
const rounds = pivotRoundPairs(options.candidates.length, pivots).filter(pair => !ringPairs.has(unorderedPair(pair[0], pair[1])))
|
|
127
|
-
const roundScores = await this.scorePairs(options, rounds, signal)
|
|
128
|
-
const allRewards = new Map([...ringScores.rewards, ...roundScores.rewards]); const wins = new Array<number>(options.candidates.length).fill(0); const counts = new Array<number>(options.candidates.length).fill(0)
|
|
129
|
-
accumulatePairs(ring, allRewards, wins, counts); accumulatePairs(rounds, allRewards, wins, counts); const ranked = rankScores(wins, counts); const index = ranked[0]!.index
|
|
130
|
-
const stats = blankStats(); for (const source of [ringScores.stats, roundScores.stats]) { addUsage(stats, source); stats.cacheHits += source.cacheHits; stats.cacheMisses += source.cacheMisses; stats.topLogprobScores += source.topLogprobScores; stats.explicitTagScores += source.explicitTagScores }
|
|
131
|
-
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) }
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export function normalizeCriteria(input: unknown): Criterion[] {
|
|
136
|
-
if (input === undefined) return DEFAULT_CRITERIA
|
|
137
|
-
if (!Array.isArray(input) || !input.length) throw new Error('llm-verifier: criteria must be a non-empty array')
|
|
138
|
-
return input.map((value, index) => { if (typeof value !== 'object' || value === null) throw new Error('llm-verifier: criteria[' + index + '] must be an object'); const row = value as Record<string, unknown>; for (const key of ['id', 'name', 'description']) if (typeof row[key] !== 'string' || row[key].trim().length === 0) throw new Error('llm-verifier: criteria[' + index + '].' + key + ' must be non-empty'); return { id: String(row.id), name: String(row.name), description: String(row.description) } })
|
|
139
|
-
}
|
|
140
|
-
export { DEFAULT_GROUND_TRUTH_NOTE }
|
|
1
|
+
import { addUsage, callVerifier, emptyUsage, predictScoringChannel, type ScoringMode, type UsageStats, type VerifierClientConfig, type VerifierImage } from './caller.ts'
|
|
2
|
+
import { ScoreCache, SingleFlight, stableHash, type CachedPairScore } from './cache.ts'
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_CRITERIA, DEFAULT_GROUND_TRUTH_NOTE, accumulatePairs, buildPairwisePrompt, buildProgressPrompt,
|
|
5
|
+
extractProgressScore, extractScore, pivotRoundPairs, rankScores, ringCycle, topPivots, type Criterion,
|
|
6
|
+
} from './core.ts'
|
|
7
|
+
|
|
8
|
+
export interface CompareOptions { problem: string; candidateA: string; candidateB: string; criteria?: readonly Criterion[]; groundTruthNote?: string; repeats?: number; images?: readonly VerifierImage[] }
|
|
9
|
+
export interface CriterionResult { id: string; name: string; scoreA: number; scoreB: number }
|
|
10
|
+
export interface RunStats extends UsageStats { cacheHits: number; cacheMisses: number; estimatedCostUsd: number; topLogprobScores: number; explicitTagScores: number }
|
|
11
|
+
export interface CompareResult { scoreA: number; scoreB: number; winner: 'A' | 'B' | 'tie'; criteria: CriterionResult[]; calls: number; stats: RunStats }
|
|
12
|
+
export interface SelectOptions { problem: string; candidates: readonly string[]; criteria?: readonly Criterion[]; groundTruthNote?: string; repeats?: number; pivots?: number; seed?: number; images?: readonly VerifierImage[] }
|
|
13
|
+
export interface SelectResult { index: number; best: string; scores: number[]; ranking: number[]; pivots: number[]; comparisons: number; calls: number; stats: RunStats }
|
|
14
|
+
|
|
15
|
+
function average(values: readonly number[]): number { return values.reduce((sum, value) => sum + value, 0) / (values.length || 1) }
|
|
16
|
+
function blankStats(): RunStats { return { ...emptyUsage(), cacheHits: 0, cacheMisses: 0, estimatedCostUsd: 0, topLogprobScores: 0, explicitTagScores: 0 } }
|
|
17
|
+
function unorderedPair(a: number, b: number): string { return a < b ? a + ',' + b : b + ',' + a }
|
|
18
|
+
|
|
19
|
+
export class VerifierEngine {
|
|
20
|
+
readonly client: VerifierClientConfig
|
|
21
|
+
readonly maxConcurrency: number
|
|
22
|
+
readonly cache: ScoreCache | undefined
|
|
23
|
+
readonly inputPrice: number
|
|
24
|
+
readonly outputPrice: number
|
|
25
|
+
private readonly flights: SingleFlight<{ value: CachedPairScore; hit: boolean }>
|
|
26
|
+
|
|
27
|
+
constructor(client: VerifierClientConfig, maxConcurrency = 8, cache?: ScoreCache, prices: { input: number; output: number } = { input: 0, output: 0 }, flights: SingleFlight<{ value: CachedPairScore; hit: boolean }> = new SingleFlight()) {
|
|
28
|
+
this.client = client; this.maxConcurrency = maxConcurrency; this.cache = cache; this.inputPrice = prices.input; this.outputPrice = prices.output; this.flights = flights
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private finishStats(stats: RunStats): RunStats {
|
|
32
|
+
stats.estimatedCostUsd = ((stats.inputTokens + stats.cachedInputTokens) * this.inputPrice + stats.outputTokens * this.outputPrice) / 1_000_000
|
|
33
|
+
return stats
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private async scoreOne(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 }> {
|
|
37
|
+
const ground = options.groundTruthNote ?? DEFAULT_GROUND_TRUTH_NOTE
|
|
38
|
+
const prompt = buildPairwisePrompt(options.problem, candidateA, candidateB, criterion, ground)
|
|
39
|
+
const imageKey = options.images?.map(image => stableHash([image.mediaType, Buffer.from(image.data).toString('base64')]))
|
|
40
|
+
// Cache identity pins the scoring channel, but the channel is only predictable
|
|
41
|
+
// before the call when the capability cache already knows the answer. Lookups use
|
|
42
|
+
// the predicted channel while entries are always stored under the ACTUAL
|
|
43
|
+
// completion mode, so a first-call downgrade lands under explicit-tag keys and is
|
|
44
|
+
// found by later explicit-tag calls instead of being misread as a top-logprobs
|
|
45
|
+
// expectation. The channel stays OUT of the in-flight dedup key below.
|
|
46
|
+
const identity = { version: 4, provider: this.client.provider, model: this.client.model, effort: this.client.reasoningEffort, maxTokens: this.client.maxTokens, repeat, promptHash: stableHash(prompt), imageKey }
|
|
47
|
+
const keyForMode = (scoringMode: ScoringMode) => stableHash({ ...identity, scoringMode })
|
|
48
|
+
const create = async () => {
|
|
49
|
+
const completion = await callVerifier(this.client, prompt, signal, options.images)
|
|
50
|
+
return { scoreA: extractScore(completion, '<score_A>'), scoreB: extractScore(completion, '<score_B>'), usage: completion.usage, scoringMode: completion.scoringMode, createdAt: Date.now() }
|
|
51
|
+
}
|
|
52
|
+
const cache = this.cache
|
|
53
|
+
if (cache === undefined) { const value = await create(); return { scores: [value.scoreA, value.scoreB], usage: value.usage, scoringMode: value.scoringMode, hit: false } }
|
|
54
|
+
// Registration happens in the synchronous segment before any await, so a request
|
|
55
|
+
// that arrives while the first one is still resolving its channel prediction
|
|
56
|
+
// still merges instead of duplicating the model call. The flight table is shared
|
|
57
|
+
// per topic, so concurrent tool calls through separate engine instances merge too.
|
|
58
|
+
const dedupeKey = stableHash(identity)
|
|
59
|
+
const outcome = await this.flights.run(dedupeKey, async () => cache.getOrCreate(keyForMode(await predictScoringChannel(this.client)), create, value => keyForMode(value.scoringMode)))
|
|
60
|
+
const landed = outcome.value
|
|
61
|
+
const reused = outcome.joined || landed.hit
|
|
62
|
+
return { scores: [landed.value.scoreA, landed.value.scoreB], usage: reused ? emptyUsage() : landed.value.usage, scoringMode: landed.value.scoringMode, hit: reused }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async mapLimited<T, R>(items: readonly T[], worker: (item: T) => Promise<R>): Promise<R[]> {
|
|
66
|
+
const results = new Array<R>(items.length); let cursor = 0
|
|
67
|
+
const runners = Array.from({ length: Math.min(this.maxConcurrency, items.length) }, async () => { while (cursor < items.length) { const index = cursor++; results[index] = await worker(items[index]!) } })
|
|
68
|
+
await Promise.all(runners); return results
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async compare(options: CompareOptions, signal?: AbortSignal): Promise<CompareResult> {
|
|
72
|
+
const criteria = options.criteria?.length ? options.criteria : DEFAULT_CRITERIA
|
|
73
|
+
const repeats = options.repeats ?? 2
|
|
74
|
+
const jobs = criteria.flatMap(criterion => Array.from({ length: repeats }, (_, repeat) => ({ criterion, repeat })))
|
|
75
|
+
// Prefix warm-up: one criterion/repeat runs first, then the shared-prefix fan-out.
|
|
76
|
+
const warm = jobs.slice(0, 1); const rest = jobs.slice(1)
|
|
77
|
+
const run = async (batch: typeof jobs) => this.mapLimited(batch, async ({ criterion, repeat }) => {
|
|
78
|
+
const swapped = repeat % 2 === 1
|
|
79
|
+
const result = await this.scoreOne(options, swapped ? options.candidateB : options.candidateA, swapped ? options.candidateA : options.candidateB, criterion, repeat, signal)
|
|
80
|
+
return { criterion, scoreA: swapped ? result.scores[1] : result.scores[0], scoreB: swapped ? result.scores[0] : result.scores[1], usage: result.usage, scoringMode: result.scoringMode, hit: result.hit }
|
|
81
|
+
})
|
|
82
|
+
const values = [...await run(warm), ...await run(rest)]
|
|
83
|
+
const stats = blankStats()
|
|
84
|
+
for (const value of values) { addUsage(stats, value.usage); value.hit ? stats.cacheHits++ : stats.cacheMisses++; value.scoringMode === 'top-logprobs' ? stats.topLogprobScores++ : stats.explicitTagScores++ }
|
|
85
|
+
const byCriterion = criteria.map(criterion => { const rows = values.filter(value => value.criterion.id === criterion.id); return { id: criterion.id, name: criterion.name, scoreA: average(rows.map(row => row.scoreA)), scoreB: average(rows.map(row => row.scoreB)) } })
|
|
86
|
+
const scoreA = average(byCriterion.map(value => value.scoreA)); const scoreB = average(byCriterion.map(value => value.scoreB))
|
|
87
|
+
return { scoreA, scoreB, winner: Math.abs(scoreA - scoreB) < 1e-12 ? 'tie' : scoreA > scoreB ? 'A' : 'B', criteria: byCriterion, calls: stats.calls, stats: this.finishStats(stats) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async scorePairs(options: SelectOptions, pairs: readonly [number, number][], signal?: AbortSignal): Promise<{ rewards: Map<string, readonly [number, number]>; stats: RunStats }> {
|
|
91
|
+
const unique = [...new Map(pairs.map(pair => [pair[0] + ',' + pair[1], pair])).values()]
|
|
92
|
+
const values = await this.mapLimited(unique, async ([a, b]) => ({ a, b, result: await this.compare({ problem: options.problem, candidateA: options.candidates[a]!, candidateB: options.candidates[b]!, criteria: options.criteria, groundTruthNote: options.groundTruthNote, repeats: options.repeats, images: options.images }, signal) }))
|
|
93
|
+
const rewards = new Map<string, readonly [number, number]>(); const stats = blankStats()
|
|
94
|
+
for (const value of values) { rewards.set(value.a + ',' + value.b, [value.result.scoreA, value.result.scoreB]); addUsage(stats, value.result.stats); stats.cacheHits += value.result.stats.cacheHits; stats.cacheMisses += value.result.stats.cacheMisses; stats.topLogprobScores += value.result.stats.topLogprobScores; stats.explicitTagScores += value.result.stats.explicitTagScores }
|
|
95
|
+
return { rewards, stats: this.finishStats(stats) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
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 }> {
|
|
99
|
+
if (!steps.length || !checkpoints.length) throw new Error('llm-verifier: steps and checkpoints must not be empty')
|
|
100
|
+
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')
|
|
101
|
+
const prompt = buildProgressPrompt(problem, steps, checkpoints)
|
|
102
|
+
const completions = await this.mapLimited(Array.from({ length: repeats }, (_, index) => index), async () => callVerifier(this.client, prompt, signal, images))
|
|
103
|
+
const stats = blankStats(); for (const completion of completions) { addUsage(stats, completion.usage); completion.scoringMode === 'top-logprobs' ? stats.topLogprobScores++ : stats.explicitTagScores++ }
|
|
104
|
+
const runs = completions.map(completion => checkpoints.map((_, index) => extractProgressScore(completion, '<c' + (index + 1) + '>')))
|
|
105
|
+
return { scores: checkpoints.map((_, index) => average(runs.map(run => run[index]!))), perRepeat: runs, calls: stats.calls, stats: this.finishStats(stats) }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async select(options: SelectOptions, signal?: AbortSignal): Promise<SelectResult> {
|
|
109
|
+
if (!options.candidates.length) throw new Error('llm-verifier: candidates must not be empty')
|
|
110
|
+
if (options.candidates.length === 1) return { index: 0, best: options.candidates[0]!, scores: [1], ranking: [0], pivots: [0], comparisons: 0, calls: 0, stats: blankStats() }
|
|
111
|
+
if (options.candidates.length === 2) {
|
|
112
|
+
// ringCycle(2) would judge the single unordered pair in both directions; play it once.
|
|
113
|
+
const { rewards, stats } = await this.scorePairs(options, [[0, 1]], signal)
|
|
114
|
+
const wins = [0, 0]; const counts = [0, 0]
|
|
115
|
+
accumulatePairs([[0, 1]], rewards, wins, counts)
|
|
116
|
+
const ranked = rankScores(wins, counts); const index = ranked[0]!.index
|
|
117
|
+
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 }
|
|
118
|
+
}
|
|
119
|
+
const ring = ringCycle(options.candidates.length, options.seed ?? 0); const ringScores = await this.scorePairs(options, ring, signal)
|
|
120
|
+
const firstWins = new Array<number>(options.candidates.length).fill(0); const firstCounts = new Array<number>(options.candidates.length).fill(0); accumulatePairs(ring, ringScores.rewards, firstWins, firstCounts)
|
|
121
|
+
const pivots = topPivots(firstWins, firstCounts, options.pivots ?? 2)
|
|
122
|
+
// pivotRoundPairs regenerates every ring edge that touches a pivot (reversed for
|
|
123
|
+
// pivot→neighbour edges). Dropping those duplicates keeps each unordered pair to a
|
|
124
|
+
// single match so wins/counts are not double-weighted and no pair is judged twice.
|
|
125
|
+
const ringPairs = new Set(ring.map(pair => unorderedPair(pair[0], pair[1])))
|
|
126
|
+
const rounds = pivotRoundPairs(options.candidates.length, pivots).filter(pair => !ringPairs.has(unorderedPair(pair[0], pair[1])))
|
|
127
|
+
const roundScores = await this.scorePairs(options, rounds, signal)
|
|
128
|
+
const allRewards = new Map([...ringScores.rewards, ...roundScores.rewards]); const wins = new Array<number>(options.candidates.length).fill(0); const counts = new Array<number>(options.candidates.length).fill(0)
|
|
129
|
+
accumulatePairs(ring, allRewards, wins, counts); accumulatePairs(rounds, allRewards, wins, counts); const ranked = rankScores(wins, counts); const index = ranked[0]!.index
|
|
130
|
+
const stats = blankStats(); for (const source of [ringScores.stats, roundScores.stats]) { addUsage(stats, source); stats.cacheHits += source.cacheHits; stats.cacheMisses += source.cacheMisses; stats.topLogprobScores += source.topLogprobScores; stats.explicitTagScores += source.explicitTagScores }
|
|
131
|
+
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) }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function normalizeCriteria(input: unknown): Criterion[] {
|
|
136
|
+
if (input === undefined) return DEFAULT_CRITERIA
|
|
137
|
+
if (!Array.isArray(input) || !input.length) throw new Error('llm-verifier: criteria must be a non-empty array')
|
|
138
|
+
return input.map((value, index) => { if (typeof value !== 'object' || value === null) throw new Error('llm-verifier: criteria[' + index + '] must be an object'); const row = value as Record<string, unknown>; for (const key of ['id', 'name', 'description']) if (typeof row[key] !== 'string' || row[key].trim().length === 0) throw new Error('llm-verifier: criteria[' + index + '].' + key + ' must be non-empty'); return { id: String(row.id), name: String(row.name), description: String(row.description) } })
|
|
139
|
+
}
|
|
140
|
+
export { DEFAULT_GROUND_TRUTH_NOTE }
|