dsh-llm-verifier 0.1.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/LICENSE +22 -0
- package/README.md +218 -0
- package/cordis.patch.yml +4 -0
- package/lib/caller-BgqCctCh.js +324 -0
- package/lib/caller-CGlgZ-Su.js +324 -0
- package/lib/caller.js +2 -0
- package/lib/client.js +385 -0
- package/lib/core.js +163 -0
- package/lib/index.js +1031 -0
- package/lib/types/cache.d.ts +27 -0
- package/lib/types/cache.js +82 -0
- package/lib/types/caller.d.ts +48 -0
- package/lib/types/caller.js +125 -0
- package/lib/types/client.d.ts +4 -0
- package/lib/types/client.js +66 -0
- package/lib/types/config.d.ts +35 -0
- package/lib/types/config.js +57 -0
- package/lib/types/core.d.ts +38 -0
- package/lib/types/core.js +177 -0
- package/lib/types/engine.d.ts +79 -0
- package/lib/types/engine.js +146 -0
- package/lib/types/images.d.ts +3 -0
- package/lib/types/images.js +45 -0
- package/lib/types/index.d.ts +11 -0
- package/lib/types/index.js +42 -0
- package/lib/types/session.d.ts +23 -0
- package/lib/types/session.js +67 -0
- package/lib/types/top-logprobs.d.ts +24 -0
- package/lib/types/top-logprobs.js +97 -0
- package/package.json +110 -0
- package/src/cache.ts +88 -0
- package/src/caller.test.ts +34 -0
- package/src/caller.ts +138 -0
- package/src/client.tsx +48 -0
- package/src/config.ts +84 -0
- package/src/core.test.ts +67 -0
- package/src/core.ts +204 -0
- package/src/engine.ts +109 -0
- package/src/images.ts +33 -0
- package/src/index.ts +48 -0
- package/src/parity.test.ts +71 -0
- package/src/session.test.ts +22 -0
- package/src/session.ts +75 -0
- package/src/top-logprobs.ts +97 -0
package/src/core.test.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
accumulatePairs,
|
|
4
|
+
bradleyTerry,
|
|
5
|
+
buildPairwisePrompt,
|
|
6
|
+
buildProgressPrompt,
|
|
7
|
+
extractProgressScore,
|
|
8
|
+
extractScore,
|
|
9
|
+
pivotRoundPairs,
|
|
10
|
+
rankScores,
|
|
11
|
+
ringCycle,
|
|
12
|
+
topPivots,
|
|
13
|
+
} from './core.ts'
|
|
14
|
+
|
|
15
|
+
function completion(text: string, tokens: string[] = [], positions: Array<Array<{ token: string; logprob: number }>> = []) {
|
|
16
|
+
return { text, tokens, positions }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('score extraction', () => {
|
|
20
|
+
it('parses literal final tags', () => {
|
|
21
|
+
expect(extractScore(completion('<score_A> A </score_A>'), '<score_A>')).toBe(1)
|
|
22
|
+
expect(extractScore(completion('<score_A> T </score_A>'), '<score_A>')).toBe(0)
|
|
23
|
+
})
|
|
24
|
+
it('uses the final literal tag', () => {
|
|
25
|
+
expect(extractScore(completion('<score_A> A </score_A> blah <score_A> T </score_A>'), '<score_A>')).toBe(0)
|
|
26
|
+
})
|
|
27
|
+
it('computes normalized top-logprob expectation', () => {
|
|
28
|
+
const value = extractScore(completion('', ['<score_A>'], [[], [{ token: 'A', logprob: Math.log(0.75) }, { token: 'T', logprob: Math.log(0.25) }]]), '<score_A>')
|
|
29
|
+
expect(value).toBeCloseTo(0.75)
|
|
30
|
+
})
|
|
31
|
+
it('reverses the progress scale', () => {
|
|
32
|
+
expect(extractProgressScore(completion('<c1> T </c1>'), '<c1>')).toBe(1)
|
|
33
|
+
expect(extractProgressScore(completion('<c1> A </c1>'), '<c1>')).toBe(0)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('prompts', () => {
|
|
38
|
+
it('puts criterion at the tail', () => {
|
|
39
|
+
const prompt = buildPairwisePrompt('task', 'a', 'b', { id: 'x', name: 'Criterion X', description: 'tail marker' })
|
|
40
|
+
expect(prompt.indexOf('**Trajectory B:**')).toBeLessThan(prompt.indexOf('tail marker'))
|
|
41
|
+
expect(prompt).toContain('<score_A> LETTER_A_TO_T </score_A>')
|
|
42
|
+
})
|
|
43
|
+
it('emits exact progress tags', () => {
|
|
44
|
+
expect(buildProgressPrompt('task', ['one', 'two'], [1, 2])).toContain('<c1>LETTER</c1>\n<c2>LETTER</c2>')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('pivot tournament', () => {
|
|
49
|
+
it('makes a Hamiltonian directed ring', () => {
|
|
50
|
+
const pairs = ringCycle(5, 7)
|
|
51
|
+
expect(pairs).toHaveLength(5)
|
|
52
|
+
expect(new Set(pairs.map(pair => pair[0])).size).toBe(5)
|
|
53
|
+
expect(new Set(pairs.map(pair => pair[1])).size).toBe(5)
|
|
54
|
+
})
|
|
55
|
+
it('generates linear pivot rounds', () => {
|
|
56
|
+
expect(pivotRoundPairs(5, [1, 3])).toEqual([[0, 1], [0, 3], [2, 1], [2, 3], [4, 1], [4, 3], [1, 3]])
|
|
57
|
+
})
|
|
58
|
+
it('ranks soft wins', () => {
|
|
59
|
+
expect(bradleyTerry(0.9, 0.1)).toBeGreaterThan(0.5)
|
|
60
|
+
const rewards = new Map<string, readonly [number, number]>([['0,1', [1, 0]]])
|
|
61
|
+
const wins = [0, 0]
|
|
62
|
+
const counts = [0, 0]
|
|
63
|
+
accumulatePairs([[0, 1]], rewards, wins, counts)
|
|
64
|
+
expect(topPivots(wins, counts, 1)).toEqual([0])
|
|
65
|
+
expect(rankScores(wins, counts)[0]?.index).toBe(0)
|
|
66
|
+
})
|
|
67
|
+
})
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/** Pure scoring and Probabilistic Pivot Tournament primitives. */
|
|
2
|
+
|
|
3
|
+
export interface Criterion {
|
|
4
|
+
id: string
|
|
5
|
+
name: string
|
|
6
|
+
description: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TokenAlternative {
|
|
10
|
+
token: string
|
|
11
|
+
logprob: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CompletionLogprobs {
|
|
15
|
+
text: string
|
|
16
|
+
tokens: string[]
|
|
17
|
+
positions: TokenAlternative[][]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CandidateScore {
|
|
21
|
+
index: number
|
|
22
|
+
score: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const GRANULARITY = 20
|
|
26
|
+
export const LETTERS = Array.from({ length: GRANULARITY }, (_, index) => String.fromCharCode(65 + index))
|
|
27
|
+
export const SCALE_DESCRIPTION = [
|
|
28
|
+
'Rate how likely the agent correctly solved the task on a 20-point scale using letters A through T:',
|
|
29
|
+
' A = clearly and completely succeeded with verified output (best)',
|
|
30
|
+
' B-D = succeeded with only minor issues',
|
|
31
|
+
' E-G = above average, mostly correct with some issues',
|
|
32
|
+
' H-J = uncertain, leans toward success',
|
|
33
|
+
' K-M = uncertain, leans toward failure',
|
|
34
|
+
' N-P = below average, significant issues remain',
|
|
35
|
+
' Q-S = failed with some partial progress',
|
|
36
|
+
' T = clearly and completely failed (worst)',
|
|
37
|
+
].join('\n')
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_CRITERIA: Criterion[] = [
|
|
40
|
+
{
|
|
41
|
+
id: 'specification',
|
|
42
|
+
name: 'Specification Adherence',
|
|
43
|
+
description: 'Re-read the task description and check exact requirements: file paths, output formats, naming, and explicit constraints. Penalize a solution that solves a similar but different problem.',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'output_match',
|
|
47
|
+
name: 'Output Match',
|
|
48
|
+
description: 'Find the final verification command and compare its actual stdout/stderr to the required output. Reward only evidence literally visible in observed output; do not trust narration.',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'error_signals',
|
|
52
|
+
name: 'Error Signal Detection',
|
|
53
|
+
description: 'Scan especially later steps for unresolved errors, tracebacks, non-zero exits, command-not-found, missing files, compilation failures, and test failures. Score only unresolved error evidence.',
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_GROUND_TRUTH_NOTE = "**IMPORTANT:** Focus on observed tool and terminal output as ground truth. Do NOT trust the agent's self-assessment or claims of success."
|
|
58
|
+
|
|
59
|
+
export function normalizeScoreLetter(token: string): string | undefined {
|
|
60
|
+
let value = token.trim()
|
|
61
|
+
if (value.startsWith('>')) value = value.slice(1).trim()
|
|
62
|
+
const match = /^([A-T])$/i.exec(value)
|
|
63
|
+
return match?.[1]?.toUpperCase()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function letterValue(letter: string): number {
|
|
67
|
+
return GRANULARITY - (letter.charCodeAt(0) - 65)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function findTagLogprobs(tokens: readonly string[], positions: readonly TokenAlternative[][], tag: string): TokenAlternative[] | undefined {
|
|
71
|
+
if (tokens.length === 0 || positions.length === 0) return undefined
|
|
72
|
+
for (const suffix of [tag, tag.slice(0, -1)]) {
|
|
73
|
+
let found: TokenAlternative[] | undefined
|
|
74
|
+
let text = ''
|
|
75
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
76
|
+
text += tokens[index]
|
|
77
|
+
if (text.trimEnd().endsWith(suffix) && index + 1 < positions.length) found = positions[index + 1]
|
|
78
|
+
}
|
|
79
|
+
if (found !== undefined) return found
|
|
80
|
+
}
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function extractScore(completion: CompletionLogprobs, tag: string): number {
|
|
85
|
+
const alternatives = findTagLogprobs(completion.tokens, completion.positions, tag)
|
|
86
|
+
const probabilities = new Map<number, number>()
|
|
87
|
+
for (const alternative of alternatives ?? []) {
|
|
88
|
+
const letter = normalizeScoreLetter(alternative.token)
|
|
89
|
+
if (letter === undefined || !Number.isFinite(alternative.logprob)) continue
|
|
90
|
+
const value = letterValue(letter)
|
|
91
|
+
probabilities.set(value, Math.max(probabilities.get(value) ?? 0, Math.exp(alternative.logprob)))
|
|
92
|
+
}
|
|
93
|
+
if (probabilities.size > 0) {
|
|
94
|
+
let probability = 0
|
|
95
|
+
let expectation = 0
|
|
96
|
+
for (const [value, p] of probabilities) {
|
|
97
|
+
probability += p
|
|
98
|
+
expectation += value * p
|
|
99
|
+
}
|
|
100
|
+
if (probability > 0) return (expectation / probability - 1) / (GRANULARITY - 1)
|
|
101
|
+
}
|
|
102
|
+
const name = tag.slice(1, -1)
|
|
103
|
+
const regex = new RegExp('<' + name + '>\\s*(.+?)\\s*</' + name + '>', 'gi')
|
|
104
|
+
let last: RegExpExecArray | null = null
|
|
105
|
+
for (let match = regex.exec(completion.text); match !== null; match = regex.exec(completion.text)) last = match
|
|
106
|
+
const letter = normalizeScoreLetter(last?.[1] ?? '')
|
|
107
|
+
return letter === undefined ? 0.5 : (letterValue(letter) - 1) / (GRANULARITY - 1)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildPairwisePrompt(problem: string, traceA: string, traceB: string, criterion: Criterion, groundTruthNote = DEFAULT_GROUND_TRUTH_NOTE): string {
|
|
111
|
+
return [
|
|
112
|
+
'You are an expert evaluator of AI coding agents. You will see a task description and two agent trajectories, then evaluate them on ONE specific criterion, stated at the end.',
|
|
113
|
+
groundTruthNote,
|
|
114
|
+
'**Task:**\n' + problem,
|
|
115
|
+
'**Trajectory A:**\n' + traceA,
|
|
116
|
+
'**Trajectory B:**\n' + traceB,
|
|
117
|
+
'**Rating Scale:**\n' + SCALE_DESCRIPTION,
|
|
118
|
+
'**Evaluation Guideline — ' + criterion.name + ':**\n' + criterion.description,
|
|
119
|
+
'Score each trajectory ONLY on this specific criterion ("' + criterion.name + '"). Ignore other aspects that are not relevant to it.',
|
|
120
|
+
'Reason it through first, then END your reply with exactly these two lines and nothing after them. Replace each placeholder with a single letter A-T, keeping the spaces around the letter exactly as shown:\n<score_A> LETTER_A_TO_T </score_A>\n<score_B> LETTER_A_TO_T </score_B>',
|
|
121
|
+
'Begin your analysis now.',
|
|
122
|
+
].join('\n\n')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function buildProgressPrompt(problem: string, steps: readonly string[], checkpoints: readonly number[]): string {
|
|
126
|
+
const trajectory = steps.map((step, index) => '=== Agent Step ' + (index + 1) + ' ===\n' + step.trim()).join('\n\n')
|
|
127
|
+
const tags = checkpoints.map((_, index) => '<c' + (index + 1) + '>LETTER</c' + (index + 1) + '>').join('\n')
|
|
128
|
+
return [
|
|
129
|
+
"You are a strict, skeptical evaluator of agent task attempts. Trust observed output — NOT the agent's narration.",
|
|
130
|
+
'**Task instruction:**\n' + problem.trim(),
|
|
131
|
+
'**Agent trajectory (' + steps.length + ' agent steps):**\n' + trajectory,
|
|
132
|
+
"Score whether the agent's CURRENT state at each checkpoint would actually satisfy the task's hidden grader.",
|
|
133
|
+
'Use A through T where A = certainly NO, H-M = uncertain, N-S = leans YES, and T = essentially certain YES with matching observed verification.',
|
|
134
|
+
'Effort and confident narration are not evidence. A state without real verification should not exceed K. Scores may decrease after regressions.',
|
|
135
|
+
'The checkpoints are:\n' + checkpoints.map((step, index) => ' Checkpoint ' + (index + 1) + ' = state right after Agent Step ' + step).join('\n'),
|
|
136
|
+
'Output EXACTLY these lines and nothing else:\n' + tags,
|
|
137
|
+
].join('\n\n')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Progress uses A=NO..T=YES, the reverse of pairwise success scoring. */
|
|
141
|
+
export function extractProgressScore(completion: CompletionLogprobs, tag: string): number {
|
|
142
|
+
return 1 - extractScore(completion, tag)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function bradleyTerry(rewardA: number, rewardB: number): number {
|
|
146
|
+
return 1 / (1 + Math.exp(-(rewardA - rewardB)))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function seededRandom(seed: number): () => number {
|
|
150
|
+
let state = seed >>> 0
|
|
151
|
+
return () => {
|
|
152
|
+
state = (state + 0x6D2B79F5) >>> 0
|
|
153
|
+
let value = state
|
|
154
|
+
value = Math.imul(value ^ value >>> 15, value | 1)
|
|
155
|
+
value ^= value + Math.imul(value ^ value >>> 7, value | 61)
|
|
156
|
+
return ((value ^ value >>> 14) >>> 0) / 4294967296
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function ringCycle(count: number, seed = 0): Array<[number, number]> {
|
|
161
|
+
if (count <= 1) return []
|
|
162
|
+
const permutation = Array.from({ length: count }, (_, index) => index)
|
|
163
|
+
const random = seededRandom(seed)
|
|
164
|
+
for (let index = count - 1; index > 0; index -= 1) {
|
|
165
|
+
const other = Math.floor(random() * (index + 1))
|
|
166
|
+
;[permutation[index], permutation[other]] = [permutation[other]!, permutation[index]!]
|
|
167
|
+
}
|
|
168
|
+
return permutation.map((candidate, index) => [candidate, permutation[(index + 1) % count]!] as [number, number])
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function pivotRoundPairs(count: number, pivots: readonly number[]): Array<[number, number]> {
|
|
172
|
+
const pivotSet = new Set(pivots)
|
|
173
|
+
const pairs: Array<[number, number]> = []
|
|
174
|
+
for (let candidate = 0; candidate < count; candidate += 1) {
|
|
175
|
+
if (!pivotSet.has(candidate)) for (const pivot of pivots) pairs.push([candidate, pivot])
|
|
176
|
+
}
|
|
177
|
+
const sorted = [...pivots].sort((a, b) => a - b)
|
|
178
|
+
for (let left = 0; left < sorted.length; left += 1) {
|
|
179
|
+
for (let right = left + 1; right < sorted.length; right += 1) pairs.push([sorted[left]!, sorted[right]!])
|
|
180
|
+
}
|
|
181
|
+
return pairs
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function accumulatePairs(pairs: readonly [number, number][], rewards: ReadonlyMap<string, readonly [number, number]>, wins: number[], counts: number[]): void {
|
|
185
|
+
for (const [a, b] of pairs) {
|
|
186
|
+
const reward = rewards.get(a + ',' + b) ?? [0.5, 0.5]
|
|
187
|
+
const probability = bradleyTerry(reward[0], reward[1])
|
|
188
|
+
wins[a] = (wins[a] ?? 0) + probability
|
|
189
|
+
counts[a] = (counts[a] ?? 0) + 1
|
|
190
|
+
wins[b] = (wins[b] ?? 0) + 1 - probability
|
|
191
|
+
counts[b] = (counts[b] ?? 0) + 1
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function topPivots(wins: readonly number[], counts: readonly number[], requested: number): number[] {
|
|
196
|
+
return Array.from({ length: wins.length }, (_, index) => index)
|
|
197
|
+
.sort((a, b) => ((wins[b] ?? 0) / (counts[b] || 1)) - ((wins[a] ?? 0) / (counts[a] || 1)) || a - b)
|
|
198
|
+
.slice(0, Math.min(requested, wins.length))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function rankScores(wins: readonly number[], counts: readonly number[]): CandidateScore[] {
|
|
202
|
+
return Array.from({ length: wins.length }, (_, index) => ({ index, score: (wins[index] ?? 0) / (counts[index] || 1) }))
|
|
203
|
+
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
204
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { addUsage, callVerifier, emptyUsage, type UsageStats, type VerifierClientConfig, type VerifierImage } from './caller.ts'
|
|
2
|
+
import { ScoreCache, stableHash } 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
|
+
|
|
18
|
+
export class VerifierEngine {
|
|
19
|
+
readonly client: VerifierClientConfig
|
|
20
|
+
readonly maxConcurrency: number
|
|
21
|
+
readonly cache: ScoreCache | undefined
|
|
22
|
+
readonly inputPrice: number
|
|
23
|
+
readonly outputPrice: number
|
|
24
|
+
|
|
25
|
+
constructor(client: VerifierClientConfig, maxConcurrency = 8, cache?: ScoreCache, prices: { input: number; output: number } = { input: 0, output: 0 }) {
|
|
26
|
+
this.client = client; this.maxConcurrency = maxConcurrency; this.cache = cache; this.inputPrice = prices.input; this.outputPrice = prices.output
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private finishStats(stats: RunStats): RunStats {
|
|
30
|
+
stats.estimatedCostUsd = ((stats.inputTokens + stats.cachedInputTokens) * this.inputPrice + stats.outputTokens * this.outputPrice) / 1_000_000
|
|
31
|
+
return stats
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
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 }> {
|
|
35
|
+
const ground = options.groundTruthNote ?? DEFAULT_GROUND_TRUTH_NOTE
|
|
36
|
+
const prompt = buildPairwisePrompt(options.problem, candidateA, candidateB, criterion, ground)
|
|
37
|
+
const imageKey = options.images?.map(image => stableHash([image.mediaType, Buffer.from(image.data).toString('base64')]))
|
|
38
|
+
const key = stableHash({ version: 2, scoringPolicy: 'auto-top-logprobs', provider: this.client.provider, model: this.client.model, effort: this.client.reasoningEffort, maxTokens: this.client.maxTokens, problem: options.problem, candidateA, candidateB, criterion, ground, repeat, imageKey })
|
|
39
|
+
const create = async () => {
|
|
40
|
+
const completion = await callVerifier(this.client, prompt, signal, options.images)
|
|
41
|
+
return { scoreA: extractScore(completion, '<score_A>'), scoreB: extractScore(completion, '<score_B>'), usage: completion.usage, scoringMode: completion.scoringMode, createdAt: Date.now() }
|
|
42
|
+
}
|
|
43
|
+
if (this.cache === undefined) { const value = await create(); return { scores: [value.scoreA, value.scoreB], usage: value.usage, scoringMode: value.scoringMode, hit: false } }
|
|
44
|
+
const cached = await this.cache.getOrCreate(key, create)
|
|
45
|
+
return { scores: [cached.value.scoreA, cached.value.scoreB], usage: cached.hit ? emptyUsage() : cached.value.usage, scoringMode: cached.value.scoringMode, hit: cached.hit }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async mapLimited<T, R>(items: readonly T[], worker: (item: T) => Promise<R>): Promise<R[]> {
|
|
49
|
+
const results = new Array<R>(items.length); let cursor = 0
|
|
50
|
+
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]!) } })
|
|
51
|
+
await Promise.all(runners); return results
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async compare(options: CompareOptions, signal?: AbortSignal): Promise<CompareResult> {
|
|
55
|
+
const criteria = options.criteria?.length ? options.criteria : DEFAULT_CRITERIA
|
|
56
|
+
const repeats = options.repeats ?? 2
|
|
57
|
+
const jobs = criteria.flatMap(criterion => Array.from({ length: repeats }, (_, repeat) => ({ criterion, repeat })))
|
|
58
|
+
// Prefix warm-up: one criterion/repeat runs first, then the shared-prefix fan-out.
|
|
59
|
+
const warm = jobs.slice(0, 1); const rest = jobs.slice(1)
|
|
60
|
+
const run = async (batch: typeof jobs) => this.mapLimited(batch, async ({ criterion, repeat }) => {
|
|
61
|
+
const swapped = repeat % 2 === 1
|
|
62
|
+
const result = await this.scoreOne(options, swapped ? options.candidateB : options.candidateA, swapped ? options.candidateA : options.candidateB, criterion, repeat, signal)
|
|
63
|
+
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 }
|
|
64
|
+
})
|
|
65
|
+
const values = [...await run(warm), ...await run(rest)]
|
|
66
|
+
const stats = blankStats()
|
|
67
|
+
for (const value of values) { addUsage(stats, value.usage); value.hit ? stats.cacheHits++ : stats.cacheMisses++; value.scoringMode === 'top-logprobs' ? stats.topLogprobScores++ : stats.explicitTagScores++ }
|
|
68
|
+
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)) } })
|
|
69
|
+
const scoreA = average(byCriterion.map(value => value.scoreA)); const scoreB = average(byCriterion.map(value => value.scoreB))
|
|
70
|
+
return { scoreA, scoreB, winner: Math.abs(scoreA - scoreB) < 1e-12 ? 'tie' : scoreA > scoreB ? 'A' : 'B', criteria: byCriterion, calls: stats.calls, stats: this.finishStats(stats) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async scorePairs(options: SelectOptions, pairs: readonly [number, number][], signal?: AbortSignal): Promise<{ rewards: Map<string, readonly [number, number]>; stats: RunStats }> {
|
|
74
|
+
const unique = [...new Map(pairs.map(pair => [pair[0] + ',' + pair[1], pair])).values()]
|
|
75
|
+
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) }))
|
|
76
|
+
const rewards = new Map<string, readonly [number, number]>(); const stats = blankStats()
|
|
77
|
+
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 }
|
|
78
|
+
return { rewards, stats: this.finishStats(stats) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
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 }> {
|
|
82
|
+
if (!steps.length || !checkpoints.length) throw new Error('llm-verifier: steps and checkpoints must not be empty')
|
|
83
|
+
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')
|
|
84
|
+
const prompt = buildProgressPrompt(problem, steps, checkpoints)
|
|
85
|
+
const completions = await this.mapLimited(Array.from({ length: repeats }, (_, index) => index), async () => callVerifier(this.client, prompt, signal, images))
|
|
86
|
+
const stats = blankStats(); for (const completion of completions) { addUsage(stats, completion.usage); completion.scoringMode === 'top-logprobs' ? stats.topLogprobScores++ : stats.explicitTagScores++ }
|
|
87
|
+
const runs = completions.map(completion => checkpoints.map((_, index) => extractProgressScore(completion, '<c' + (index + 1) + '>')))
|
|
88
|
+
return { scores: checkpoints.map((_, index) => average(runs.map(run => run[index]!))), perRepeat: runs, calls: stats.calls, stats: this.finishStats(stats) }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async select(options: SelectOptions, signal?: AbortSignal): Promise<SelectResult> {
|
|
92
|
+
if (!options.candidates.length) throw new Error('llm-verifier: candidates must not be empty')
|
|
93
|
+
if (options.candidates.length === 1) return { index: 0, best: options.candidates[0]!, scores: [1], ranking: [0], pivots: [0], comparisons: 0, calls: 0, stats: blankStats() }
|
|
94
|
+
const ring = ringCycle(options.candidates.length, options.seed ?? 0); const ringScores = await this.scorePairs(options, ring, signal)
|
|
95
|
+
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)
|
|
96
|
+
const pivots = topPivots(firstWins, firstCounts, options.pivots ?? 2); const rounds = pivotRoundPairs(options.candidates.length, pivots); const roundScores = await this.scorePairs(options, rounds, signal)
|
|
97
|
+
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)
|
|
98
|
+
accumulatePairs(ring, allRewards, wins, counts); accumulatePairs(rounds, allRewards, wins, counts); const ranked = rankScores(wins, counts); const index = ranked[0]!.index
|
|
99
|
+
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 }
|
|
100
|
+
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) }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function normalizeCriteria(input: unknown): Criterion[] {
|
|
105
|
+
if (input === undefined) return DEFAULT_CRITERIA
|
|
106
|
+
if (!Array.isArray(input) || !input.length) throw new Error('llm-verifier: criteria must be a non-empty array')
|
|
107
|
+
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) } })
|
|
108
|
+
}
|
|
109
|
+
export { DEFAULT_GROUND_TRUTH_NOTE }
|
package/src/images.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { VerifierImage } from './caller.ts'
|
|
2
|
+
|
|
3
|
+
const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
|
|
4
|
+
const MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
|
5
|
+
|
|
6
|
+
function parseDataUrl(value: string): VerifierImage | undefined {
|
|
7
|
+
const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=\s]+)$/i.exec(value)
|
|
8
|
+
if (!match) return undefined
|
|
9
|
+
const data = Buffer.from(match[2]!.replace(/\s/g, ''), 'base64')
|
|
10
|
+
if (data.byteLength > MAX_IMAGE_BYTES) throw new Error('llm-verifier: image exceeds 20 MiB')
|
|
11
|
+
return { mediaType: match[1]!.toLowerCase() as VerifierImage['mediaType'], data }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function loadVerifierImages(inputs: readonly string[] | undefined, signal?: AbortSignal): Promise<VerifierImage[]> {
|
|
15
|
+
const images: VerifierImage[] = []
|
|
16
|
+
for (const input of inputs ?? []) {
|
|
17
|
+
const data = parseDataUrl(input)
|
|
18
|
+
if (data !== undefined) { images.push(data); continue }
|
|
19
|
+
let url: URL
|
|
20
|
+
try { url = new URL(input) } catch { throw new Error('llm-verifier: images accept only HTTPS URLs or data:image/...;base64 URLs') }
|
|
21
|
+
if (url.protocol !== 'https:') throw new Error('llm-verifier: remote images must use HTTPS')
|
|
22
|
+
const response = await fetch(url, { redirect: 'error', signal })
|
|
23
|
+
if (!response.ok) throw new Error('llm-verifier: image fetch returned HTTP ' + response.status)
|
|
24
|
+
const type = (response.headers.get('content-type') ?? '').split(';')[0]!.toLowerCase()
|
|
25
|
+
if (!TYPES.has(type)) throw new Error('llm-verifier: unsupported image media type ' + type)
|
|
26
|
+
const declared = Number(response.headers.get('content-length') ?? 0)
|
|
27
|
+
if (declared > MAX_IMAGE_BYTES) throw new Error('llm-verifier: image exceeds 20 MiB')
|
|
28
|
+
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
29
|
+
if (bytes.byteLength > MAX_IMAGE_BYTES) throw new Error('llm-verifier: image exceeds 20 MiB')
|
|
30
|
+
images.push({ mediaType: type as VerifierImage['mediaType'], data: bytes })
|
|
31
|
+
}
|
|
32
|
+
return images
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
3
|
+
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
|
4
|
+
import { Config, installVerifierSettings, resolveConfig } from './config.ts'
|
|
5
|
+
import { RequestLimiter } from './caller.ts'
|
|
6
|
+
import { TopLogprobCapabilityCache } from './top-logprobs.ts'
|
|
7
|
+
import { ScoreCache, resolveCacheFile } from './cache.ts'
|
|
8
|
+
import { VerifierEngine, normalizeCriteria } from './engine.ts'
|
|
9
|
+
import { loadVerifierImages } from './images.ts'
|
|
10
|
+
import { extractSession } from './session.ts'
|
|
11
|
+
|
|
12
|
+
export const name = 'llm-verifier'
|
|
13
|
+
export const inject = ['tools', 'agents', 'attachments', 'llm']
|
|
14
|
+
export { Config }
|
|
15
|
+
export * from './core.ts'
|
|
16
|
+
export * from './engine.ts'
|
|
17
|
+
export * from './cache.ts'
|
|
18
|
+
export { callVerifier, RequestLimiter, type VerifierClientConfig, type VerifierImage, type UsageStats, type VerifierCompletion } from './caller.ts'
|
|
19
|
+
|
|
20
|
+
const criterionSchema = { type: 'object' as const, additionalProperties: false, properties: { id: { type: 'string' as const, required: true as const }, name: { type: 'string' as const, required: true as const }, description: { type: 'string' as const, required: true as const } } }
|
|
21
|
+
const statsSchema = { type: 'object' as const, additionalProperties: false, properties: { calls: { type: 'integer' as const, required: true as const }, attempts: { type: 'integer' as const, required: true as const }, retries: { type: 'integer' as const, required: true as const }, inputTokens: { type: 'integer' as const, required: true as const }, cachedInputTokens: { type: 'integer' as const, required: true as const }, outputTokens: { type: 'integer' as const, required: true as const }, reasoningTokens: { type: 'integer' as const, required: true as const }, cacheHits: { type: 'integer' as const, required: true as const }, cacheMisses: { type: 'integer' as const, required: true as const }, estimatedCostUsd: { type: 'number' as const, required: true as const }, topLogprobScores: { type: 'integer' as const, required: true as const }, explicitTagScores: { type: 'integer' as const, required: true as const } } }
|
|
22
|
+
const criterionResultSchema = { type: 'object' as const, additionalProperties: false, properties: { id: { type: 'string' as const, required: true as const }, name: { type: 'string' as const, required: true as const }, scoreA: { type: 'number' as const, required: true as const }, scoreB: { type: 'number' as const, required: true as const } } }
|
|
23
|
+
const commonParams = { criteria: { type: 'array' as const, items: criterionSchema }, repeats: { type: 'integer' as const }, images: { type: 'array' as const, items: { type: 'string' as const }, description: 'Optional HTTPS or data:image/...;base64 images. The selected DSH model must accept image input.' } }
|
|
24
|
+
function renderJson(value: unknown) { return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }] }
|
|
25
|
+
function positive(value: number | undefined, fallback: number, field: string): number { const result = value ?? fallback; if (!Number.isSafeInteger(result) || result <= 0) throw new Error('llm-verifier: ' + field + ' must be a positive integer'); return result }
|
|
26
|
+
|
|
27
|
+
export function apply(ctx: Context, config: Config = {}): void {
|
|
28
|
+
const entry = resolveConfig(config)
|
|
29
|
+
let limiter = new RequestLimiter(entry.maxConcurrency)
|
|
30
|
+
const current = installVerifierSettings(ctx, entry, () => { limiter = new RequestLimiter(current().maxConcurrency) })
|
|
31
|
+
const cache = new ScoreCache(resolveCacheFile(entry.cacheDir), entry.cacheMaxEntries)
|
|
32
|
+
const topLogprobCapabilities = new TopLogprobCapabilityCache()
|
|
33
|
+
const engine = async () => {
|
|
34
|
+
const selected = current()
|
|
35
|
+
await ctx.llm.resolveCallConfig({ provider: selected.provider, model: selected.model, ...(selected.reasoningEffort ? { reasoningEffort: selected.reasoningEffort as never } : {}), maxTokens: selected.maxTokens })
|
|
36
|
+
return { verifier: new VerifierEngine({ ...selected, ctx, llm: ctx.llm, attachments: ctx.attachments, topLogprobCapabilities, limiter }, selected.maxConcurrency, cache, { input: selected.estimatedInputUsdPerMillion, output: selected.estimatedOutputUsdPerMillion }), selected }
|
|
37
|
+
}
|
|
38
|
+
const images = (values: readonly string[] | undefined, signal: AbortSignal) => loadVerifierImages(values, signal)
|
|
39
|
+
const route = (selected: { provider: string; model: string }) => ({ provider: selected.provider, model: selected.model })
|
|
40
|
+
|
|
41
|
+
ctx.tools.register(defineTool({ name: 'verifier_compare', description: 'Use autonomously when exactly two substantive answers, patches, plans, or execution trajectories need an independent evidence-based comparison and the choice is consequential or uncertain. Do not use for trivial deterministic questions or when there is only one candidate. Uses the verifier model selected in DSH Settings, with top-logprob A–T expectations when supported and explicit-tag fallback otherwise.', parameters: { problem: { type: 'string', required: true }, candidate_a: { type: 'string', required: true }, candidate_b: { type: 'string', required: true }, ...commonParams }, output: { schema: { type: 'object', additionalProperties: false, properties: { scoreA: { type: 'number', required: true }, scoreB: { type: 'number', required: true }, winner: { type: 'string', enum: ['A', 'B', 'tie'], required: true }, criteria: { type: 'array', items: criterionResultSchema, required: true }, calls: { type: 'integer', required: true }, stats: { ...statsSchema, required: true }, provider: { type: 'string', required: true }, model: { type: 'string', required: true } } }, render: (_args, value) => renderJson(value) }, timeoutMs: entry.timeoutMs * 20, async execute(args, exec) { const { verifier, selected } = await engine(); const result = await verifier.compare({ problem: args.problem, candidateA: args.candidate_a, candidateB: args.candidate_b, criteria: normalizeCriteria(args.criteria), repeats: positive(args.repeats, 2, 'repeats'), images: await images(args.images, exec.signal) }, exec.signal); return { ...result, ...route(selected) } } }))
|
|
42
|
+
|
|
43
|
+
ctx.tools.register(defineTool({ name: 'verifier_select', description: 'Use autonomously when three or more substantive candidate answers, patches, plans, or trajectories must be ranked and an independent choice is valuable. Use verifier_compare for exactly two candidates; do not generate extra candidates merely to invoke this tool. Uses the configured DSH verifier model and the O(Nk) Probabilistic Pivot Tournament.', parameters: { problem: { type: 'string', required: true }, candidates: { type: 'array', items: { type: 'string' }, required: true }, ...commonParams, pivots: { type: 'integer' }, seed: { type: 'integer' } }, output: { schema: { type: 'object', additionalProperties: false, properties: { index: { type: 'integer', required: true }, best: { type: 'string', required: true }, scores: { type: 'array', items: { type: 'number' }, required: true }, ranking: { type: 'array', items: { type: 'integer' }, required: true }, pivots: { type: 'array', items: { type: 'integer' }, required: true }, comparisons: { type: 'integer', required: true }, calls: { type: 'integer', required: true }, stats: { ...statsSchema, required: true }, provider: { type: 'string', required: true }, model: { type: 'string', required: true } } }, render: (_args, value) => renderJson(value) }, timeoutMs: entry.timeoutMs * 100, async execute(args, exec) { const { verifier, selected } = await engine(); const result = await verifier.select({ problem: args.problem, candidates: args.candidates, criteria: normalizeCriteria(args.criteria), repeats: positive(args.repeats, 2, 'repeats'), pivots: positive(args.pivots, 2, 'pivots'), seed: args.seed ?? 0, images: await images(args.images, exec.signal) }, exec.signal); return { ...result, ...route(selected) } } }))
|
|
44
|
+
|
|
45
|
+
ctx.tools.register(defineTool({ name: 'verifier_track', description: 'Use autonomously for a genuinely multi-step task when progress at explicit checkpoints is uncertain or needs evidence-based measurement. Do not use for a single completed answer or invent checkpoints that were not supplied by the task history. Trusts observed output rather than narration.', parameters: { problem: { type: 'string', required: true }, steps: { type: 'array', items: { type: 'string' }, required: true }, checkpoints: { type: 'array', items: { type: 'integer' }, required: true }, repeats: commonParams.repeats, images: commonParams.images }, output: { schema: { type: 'object', additionalProperties: false, properties: { scores: { type: 'array', items: { type: 'number' }, required: true }, perRepeat: { type: 'array', items: { type: 'array', items: { type: 'number' } }, required: true }, calls: { type: 'integer', required: true }, stats: { ...statsSchema, required: true }, provider: { type: 'string', required: true }, model: { type: 'string', required: true } } }, render: (_args, value) => renderJson(value) }, timeoutMs: entry.timeoutMs * 20, async execute(args, exec) { const { verifier, selected } = await engine(); const result = await verifier.track(args.problem, args.steps, args.checkpoints, positive(args.repeats, 2, 'repeats'), exec.signal, await images(args.images, exec.signal)); return { ...result, ...route(selected) } } }))
|
|
46
|
+
|
|
47
|
+
ctx.tools.register(defineTool({ name: 'verifier_current_session', description: 'Use autonomously near the end of a non-trivial coding, debugging, migration, deployment, or operations task when independent completion verification would materially reduce risk and the session contains real tool evidence. Do not use for routine conversation, simple factual answers, or every turn. Extracts the current DSH session, applies secret redaction, bounds and truncation, then sends the evidence to the verifier model selected in Settings.', parameters: { from_seq: { type: 'integer' }, to_seq: { type: 'integer' }, include_assistant_text: { type: 'boolean' }, redact_patterns: { type: 'array', items: { type: 'string' } }, max_chars: { type: 'integer' }, repeats: { type: 'integer' } }, output: { schema: { type: 'object', additionalProperties: false, properties: { sessionId: { type: 'string', required: true }, problem: { type: 'string', required: true }, score: { type: 'number', required: true }, baselineScore: { type: 'number', required: true }, winner: { type: 'string', enum: ['A', 'B', 'tie'], required: true }, fromSeq: { type: 'integer', required: true }, toSeq: { type: 'integer', required: true }, omittedCharacters: { type: 'integer', required: true }, calls: { type: 'integer', required: true }, stats: { ...statsSchema, required: true }, provider: { type: 'string', required: true }, model: { type: 'string', required: true } } }, render: (_args, value) => renderJson(value) }, timeoutMs: entry.timeoutMs * 20, async execute(args, exec) { const agent = exec.agent ?? ctx.agents.currentInitiator(); if (agent === undefined) throw new Error('llm-verifier: verifier_current_session requires an agent-owned tool call'); const extracted = await extractSession(agent, async (ref: ImageAttachmentRef) => { const stored = await ctx.attachments.readImage(ref, exec.signal); return { data: stored.data, mediaType: stored.ref.mediaType } }, { fromSeq: args.from_seq, toSeq: args.to_seq, includeAssistantText: args.include_assistant_text, redactPatterns: args.redact_patterns, maxChars: args.max_chars }); const { verifier, selected } = await engine(); const result = await verifier.compare({ problem: extracted.problem, candidateA: extracted.trace, candidateB: '(No useful work or verification was performed.)', repeats: positive(args.repeats, 2, 'repeats'), images: extracted.images }, exec.signal); return { sessionId: extracted.sessionId, problem: extracted.problem, score: result.scoreA, baselineScore: result.scoreB, winner: result.winner, fromSeq: extracted.fromSeq, toSeq: extracted.toSeq, omittedCharacters: extracted.omittedCharacters, calls: result.calls, stats: result.stats, ...route(selected) } } }))
|
|
48
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import { ScoreCache } from './cache.ts'
|
|
7
|
+
import { extractProgressScore, extractScore, pivotRoundPairs } from './core.ts'
|
|
8
|
+
import { resolveConfig } from './config.ts'
|
|
9
|
+
|
|
10
|
+
const PYTHON_ROOT = join(import.meta.dirname, '..', '..', 'llm-as-a-verifier')
|
|
11
|
+
const hasPythonUpstream = existsSync(join(PYTHON_ROOT, 'llm_verifier'))
|
|
12
|
+
|
|
13
|
+
function python(payload: unknown): unknown {
|
|
14
|
+
const script = [
|
|
15
|
+
'import json,sys',
|
|
16
|
+
'sys.path.insert(0, sys.argv[1])',
|
|
17
|
+
'from llm_verifier.fine_grained_reward import extract_score',
|
|
18
|
+
'from llm_verifier.pivot_tournament import pivot_round_pairs',
|
|
19
|
+
'd=json.loads(sys.stdin.read())',
|
|
20
|
+
"result = extract_score(d['text'], d['tokens'], d['positions'], d['tag']) if d['kind']=='score' else pivot_round_pairs(d['n'], d['pivots'])",
|
|
21
|
+
'print(json.dumps(result))',
|
|
22
|
+
].join(';')
|
|
23
|
+
const source = payload as Record<string, unknown>
|
|
24
|
+
const compatible = source.kind === 'score' ? { ...source, positions: (source.positions as Array<Array<{ token: string; logprob: number }>>).map(position => position.map(item => [item.token, item.logprob])) } : source
|
|
25
|
+
const result = execFileSync('py', ['-3', '-c', script, PYTHON_ROOT], { input: JSON.stringify(compatible), encoding: 'utf8' })
|
|
26
|
+
return JSON.parse(result)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('Python parity fixtures', () => {
|
|
30
|
+
it.skipIf(!hasPythonUpstream)('matches literal and distribution score extraction', () => {
|
|
31
|
+
const fixtures = [
|
|
32
|
+
{ text: '<score_A> A </score_A>', tokens: [], positions: [], tag: '<score_A>' },
|
|
33
|
+
{ text: '<score_A> A </score_A> then <score_A> T </score_A>', tokens: [], positions: [], tag: '<score_A>' },
|
|
34
|
+
{ text: '', tokens: ['<score_A>'], positions: [[], [{ token: 'A', logprob: Math.log(0.7) }, { token: 'T', logprob: Math.log(0.3) }]], tag: '<score_A>' },
|
|
35
|
+
{ text: '', tokens: ['<score_A'], positions: [[], [{ token: '>B', logprob: Math.log(0.8) }, { token: '>S', logprob: Math.log(0.2) }]], tag: '<score_A>' },
|
|
36
|
+
]
|
|
37
|
+
for (const fixture of fixtures) {
|
|
38
|
+
const ts = extractScore(fixture, fixture.tag)
|
|
39
|
+
const py = python({ kind: 'score', ...fixture }) as number
|
|
40
|
+
expect(ts).toBeCloseTo(py, 12)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
it.skipIf(!hasPythonUpstream)('matches pivot pair generation', () => {
|
|
44
|
+
expect(pivotRoundPairs(7, [1, 4, 5])).toEqual(python({ kind: 'pivot', n: 7, pivots: [1, 4, 5] }))
|
|
45
|
+
})
|
|
46
|
+
it('matches reversed progress convention', () => {
|
|
47
|
+
expect(extractProgressScore({ text: '<c1> T </c1>', tokens: [], positions: [] }, '<c1>')).toBe(1)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('DSH model routing and cache', () => {
|
|
52
|
+
it('accepts any non-empty DSH provider and model route', () => {
|
|
53
|
+
expect(resolveConfig({ provider: 'openai', model: 'gpt-5' })).toMatchObject({ provider: 'openai', model: 'gpt-5' })
|
|
54
|
+
expect(resolveConfig({ provider: 'anthropic', model: 'claude-sonnet' })).toMatchObject({ provider: 'anthropic', model: 'claude-sonnet' })
|
|
55
|
+
expect(() => resolveConfig({ provider: '', model: 'gpt-5' })).toThrow(/provider must be non-empty/)
|
|
56
|
+
})
|
|
57
|
+
it('persists successful results and avoids duplicate creation', async () => {
|
|
58
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-verifier-'))
|
|
59
|
+
try {
|
|
60
|
+
const file = join(dir, 'scores.json')
|
|
61
|
+
const first = new ScoreCache(file, 100)
|
|
62
|
+
let creates = 0
|
|
63
|
+
const create = async () => { creates++; return { scoreA: 1, scoreB: 0, usage: { calls: 1, attempts: 1, retries: 0, inputTokens: 1, cachedInputTokens: 0, outputTokens: 1, reasoningTokens: 0 }, scoringMode: 'top-logprobs' as const, createdAt: Date.now() } }
|
|
64
|
+
expect((await first.getOrCreate('key', create)).hit).toBe(false)
|
|
65
|
+
expect((await first.getOrCreate('key', create)).hit).toBe(true)
|
|
66
|
+
const second = new ScoreCache(file, 100)
|
|
67
|
+
expect((await second.getOrCreate('key', create)).hit).toBe(true)
|
|
68
|
+
expect(creates).toBe(1)
|
|
69
|
+
} finally { rmSync(dir, { recursive: true, force: true }) }
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { Session } from '@deepseek-ai/dsh-session'
|
|
3
|
+
import { createUserMessage, createAssistantMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
|
4
|
+
import { extractSession } from './session.ts'
|
|
5
|
+
|
|
6
|
+
describe('current session extraction', () => {
|
|
7
|
+
it('keeps direct evidence, skips plugin instructions, and redacts secrets', async () => {
|
|
8
|
+
const session = Session.create('session-00000000-0000-4000-8000-000000000001' as never)
|
|
9
|
+
session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Fix task token=abc123' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
|
|
10
|
+
session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hidden plugin instruction' }], source: { kind: 'plugin', plugin: 'test' } }), { surfaceOp: 'append' })
|
|
11
|
+
session.append('assistant/message', { turn: 1, step: 1, message: createAssistantMessage({ content: [{ type: 'text', text: 'running checks' }], source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) }, { surfaceOp: 'append' })
|
|
12
|
+
session.append('tool/call', { turn: 1, step: 1, callId: 'call-1' as never, name: 'pwsh', arguments: '{"command":"test"}' })
|
|
13
|
+
session.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'call-1' as never, content: [{ type: 'text', text: 'exit 0 password=hunter2' }], isError: false }) }, { surfaceOp: 'append' })
|
|
14
|
+
const agent = { id: session.id, session } as never
|
|
15
|
+
const result = await extractSession(agent, async () => { throw new Error('no image expected') })
|
|
16
|
+
expect(result.problem).toContain('Fix task')
|
|
17
|
+
expect(result.problem).toContain('[REDACTED]')
|
|
18
|
+
expect(result.trace).not.toContain('hidden plugin instruction')
|
|
19
|
+
expect(result.trace).toContain('exit 0')
|
|
20
|
+
expect(result.trace).not.toContain('hunter2')
|
|
21
|
+
})
|
|
22
|
+
})
|