dsh-llm-verifier 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -9
- package/lib/auto-B5V49e-A.js +1517 -0
- package/lib/auto-B5V49e-A.js.map +1 -0
- package/lib/{caller-DJd_FlrQ.js → caller-DR_C7ueP.js} +154 -20
- package/lib/caller-DR_C7ueP.js.map +1 -0
- package/lib/caller.js +2 -2
- package/lib/client.js +804 -29
- package/lib/client.js.map +1 -1
- package/lib/core.js +226 -3
- package/lib/core.js.map +1 -1
- package/lib/index.js +1014 -1337
- package/lib/index.js.map +1 -1
- package/lib/replay.js +157 -0
- package/lib/replay.js.map +1 -0
- package/lib/types/auto.js +1 -1
- package/lib/types/auto.js.map +1 -1
- package/lib/types/caller.d.ts +65 -0
- package/lib/types/caller.d.ts.map +1 -1
- package/lib/types/caller.js +113 -5
- package/lib/types/caller.js.map +1 -1
- package/lib/types/client-i18n.d.ts +163 -6
- package/lib/types/client-i18n.d.ts.map +1 -1
- package/lib/types/client-i18n.js +137 -26
- package/lib/types/client-i18n.js.map +1 -1
- package/lib/types/client.d.ts +30 -0
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/client.js +94 -9
- package/lib/types/client.js.map +1 -1
- package/lib/types/config.d.ts +33 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +17 -2
- package/lib/types/config.js.map +1 -1
- package/lib/types/core.d.ts +84 -0
- package/lib/types/core.d.ts.map +1 -1
- package/lib/types/core.js +222 -2
- package/lib/types/core.js.map +1 -1
- package/lib/types/criteria.d.ts +33 -0
- package/lib/types/criteria.d.ts.map +1 -0
- package/lib/types/criteria.js +67 -0
- package/lib/types/criteria.js.map +1 -0
- package/lib/types/decisions.d.ts +84 -0
- package/lib/types/decisions.d.ts.map +1 -0
- package/lib/types/decisions.js +209 -0
- package/lib/types/decisions.js.map +1 -0
- package/lib/types/engine.d.ts +54 -1
- package/lib/types/engine.d.ts.map +1 -1
- package/lib/types/engine.js +175 -12
- package/lib/types/engine.js.map +1 -1
- package/lib/types/index.d.ts +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +392 -38
- package/lib/types/index.js.map +1 -1
- package/lib/types/replay.d.ts +88 -0
- package/lib/types/replay.d.ts.map +1 -0
- package/lib/types/replay.js +136 -0
- package/lib/types/replay.js.map +1 -0
- package/lib/types/router.d.ts +29 -5
- package/lib/types/router.d.ts.map +1 -1
- package/lib/types/router.js +283 -46
- package/lib/types/router.js.map +1 -1
- package/lib/types/statistics.d.ts +10 -1
- package/lib/types/statistics.d.ts.map +1 -1
- package/lib/types/statistics.js +35 -7
- package/lib/types/statistics.js.map +1 -1
- package/lib/types/top-logprobs.d.ts +36 -0
- package/lib/types/top-logprobs.d.ts.map +1 -1
- package/lib/types/top-logprobs.js +41 -14
- package/lib/types/top-logprobs.js.map +1 -1
- package/package.json +6 -1
- package/scripts/eval-replay.mjs +118 -0
- package/src/auto.ts +1 -1
- package/src/caller.test.ts +62 -1
- package/src/caller.ts +124 -4
- package/src/client-i18n.ts +137 -25
- package/src/client.test.ts +21 -0
- package/src/client.tsx +145 -7
- package/src/config.test.ts +32 -0
- package/src/config.ts +49 -2
- package/src/core.test.ts +95 -0
- package/src/core.ts +217 -2
- package/src/criteria.test.ts +60 -0
- package/src/criteria.ts +76 -0
- package/src/decisions.test.ts +150 -0
- package/src/decisions.ts +244 -0
- package/src/engine.test.ts +131 -0
- package/src/engine.ts +167 -10
- package/src/index.test.ts +278 -8
- package/src/index.ts +373 -37
- package/src/replay.test.ts +82 -0
- package/src/replay.ts +155 -0
- package/src/router.test.ts +240 -1
- package/src/router.ts +298 -48
- package/src/statistics.test.ts +49 -1
- package/src/statistics.ts +44 -7
- package/src/top-logprobs.test.ts +28 -0
- package/src/top-logprobs.ts +56 -8
- package/lib/caller-DJd_FlrQ.js.map +0 -1
package/src/index.test.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
5
|
import type { Context } from '@deepseek-ai/cordis'
|
|
3
6
|
import { apply } from './index.ts'
|
|
4
7
|
|
|
8
|
+
const tempDirs: string[] = []
|
|
9
|
+
function tempDir(): string { const dir = mkdtempSync(join(tmpdir(), 'dsh-verifier-assembly-')); tempDirs.push(dir); return dir }
|
|
10
|
+
afterEach(() => { for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) })
|
|
11
|
+
|
|
5
12
|
/**
|
|
6
13
|
* Assembly-level contract for the four registered tools.
|
|
7
14
|
*
|
|
@@ -9,7 +16,7 @@ import { apply } from './index.ts'
|
|
|
9
16
|
* reject a model-driven call BEFORE any model/topic work happens, both of which
|
|
10
17
|
* were previously uncovered (every other module has a sibling spec).
|
|
11
18
|
*/
|
|
12
|
-
function assemble(config: Record<string, unknown> = {}) {
|
|
19
|
+
function assemble(config: Record<string, unknown> = {}, options: { root?: string; initiator?: unknown; sessions?: unknown[]; stream?: (options: { messages: readonly unknown[] }) => AsyncIterable<unknown> } = {}) {
|
|
13
20
|
const tools = new Map<string, { output: { schema: { properties: Record<string, unknown> } }; execute: (args: unknown, exec: unknown) => Promise<unknown> }>()
|
|
14
21
|
const warnings: string[] = []
|
|
15
22
|
const rpc = new Map<string, (endpoint: string, payload: unknown) => unknown>()
|
|
@@ -18,9 +25,10 @@ function assemble(config: Record<string, unknown> = {}) {
|
|
|
18
25
|
get() { return undefined },
|
|
19
26
|
logger: { warn(value: unknown) { warnings.push(String(value)) }, info() {}, error() {}, debug() {} },
|
|
20
27
|
tools: { register(definition: never) { tools.set((definition as unknown as { name: string }).name, definition as never) } },
|
|
21
|
-
agents: { currentInitiator() { return
|
|
22
|
-
llm: { resolveCallConfig: async () => ({}) },
|
|
23
|
-
attachments: {},
|
|
28
|
+
agents: { currentInitiator() { return options.initiator } },
|
|
29
|
+
llm: { resolveCallConfig: async () => ({}), ...(options.stream === undefined ? {} : { stream: options.stream }) },
|
|
30
|
+
attachments: {},
|
|
31
|
+
sessionPersistence: { root: options.root ?? tempDir(), list: async () => options.sessions ?? [] },
|
|
24
32
|
// Older hosts answer statistics over the plugin RPC channel; capturing the handler
|
|
25
33
|
// lets the payload contract be tested without any host I/O.
|
|
26
34
|
connection: { rpc: { handle(channel: string, handler: (endpoint: string, payload: unknown) => unknown) { rpc.set(channel, handler) } } },
|
|
@@ -29,12 +37,15 @@ function assemble(config: Record<string, unknown> = {}) {
|
|
|
29
37
|
return { tools, warnings, rpc }
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
|
|
40
|
+
/** A session whose model is already logged: the normal case for best-of-N. */
|
|
41
|
+
const exec = { agent: { id: 'agent-1', session: { header: { id: 'session-1' }, requestHeader: () => ({ config: { provider: 'session-provider', model: 'session-model', reasoningEffort: 'high' } }) } }, signal: new AbortController().signal }
|
|
42
|
+
/** A session with no logged request header: the tool cannot know which model should draft. */
|
|
43
|
+
const bareExec = { agent: { id: 'agent-2', session: { header: { id: 'session-2' }, requestHeader: () => undefined } }, signal: new AbortController().signal }
|
|
33
44
|
|
|
34
45
|
describe('plugin assembly', () => {
|
|
35
46
|
it('registers exactly the four verifier tools with ensemble-aware output schemas', () => {
|
|
36
47
|
const { tools } = assemble()
|
|
37
|
-
expect([...tools.keys()].sort()).toEqual(['verifier_compare', 'verifier_current_session', 'verifier_select', 'verifier_track'])
|
|
48
|
+
expect([...tools.keys()].sort()).toEqual(['verifier_best_of_n', 'verifier_compare', 'verifier_current_session', 'verifier_select', 'verifier_track'])
|
|
38
49
|
for (const [name, definition] of tools) {
|
|
39
50
|
expect(definition.output.schema.properties.judges, name + ' must report the judge ensemble').toBeDefined()
|
|
40
51
|
}
|
|
@@ -66,9 +77,268 @@ describe('plugin assembly', () => {
|
|
|
66
77
|
expect(await handler!('other', {})).toMatchObject({ ok: false, error: { message: expect.stringMatching(/unknown llm-verifier endpoint/) } })
|
|
67
78
|
})
|
|
68
79
|
|
|
80
|
+
it('runs the judge probe from the global dashboard, attaching to the newest topic when there is no initiator', async () => {
|
|
81
|
+
// The dashboard is a global page: no current initiator, and possibly no session at all. The
|
|
82
|
+
// probe must explain what it needs instead of leaking a message about topic deletion.
|
|
83
|
+
const empty = assemble()
|
|
84
|
+
expect(await empty.rpc.get('/llm-verifier')!('probe', undefined)).toMatchObject({ ok: false, error: { message: expect.stringMatching(/needs one session/) } })
|
|
85
|
+
|
|
86
|
+
// With topics it runs for real against the NEWEST one. The stub has no llm.stream, so the
|
|
87
|
+
// single judge must come back as a REPORTED failure — not a crash, not a silent success.
|
|
88
|
+
const { rpc } = assemble({}, { sessions: [{ id: 'older', createdAt: 1 }, { id: 'newest', createdAt: 2 }] })
|
|
89
|
+
const outcome = await rpc.get('/llm-verifier')!('probe', undefined) as { ok: boolean; value: { judges: Array<Record<string, unknown>>; rubric: Record<string, unknown>; channelProbed?: boolean } }
|
|
90
|
+
expect(outcome.ok).toBe(true)
|
|
91
|
+
expect(outcome.value.channelProbed).toBe(true)
|
|
92
|
+
expect(outcome.value.rubric).toMatchObject({ source: 'coding', count: 3 })
|
|
93
|
+
expect(outcome.value.judges).toHaveLength(1)
|
|
94
|
+
expect(outcome.value.judges[0]).toMatchObject({ ok: false })
|
|
95
|
+
|
|
96
|
+
// A current initiator still wins over the fallback.
|
|
97
|
+
const withAgent = assemble({}, { sessions: [{ id: 'other', createdAt: 1 }], initiator: exec.agent })
|
|
98
|
+
expect((await withAgent.rpc.get('/llm-verifier')!('probe', undefined) as { ok: boolean }).ok).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('resolves the configured rubric through the probe, and degrades a broken custom file instead of failing', async () => {
|
|
102
|
+
const research = assemble({ criteriaPreset: 'research' }, { initiator: exec.agent })
|
|
103
|
+
const researched = await research.rpc.get('/llm-verifier')!('probe', undefined) as { value: { rubric: Record<string, unknown> } }
|
|
104
|
+
expect(researched.value.rubric).toMatchObject({ source: 'research', count: 3 })
|
|
105
|
+
|
|
106
|
+
// A custom file that does not exist must fall back to coding WITH the reason, not throw.
|
|
107
|
+
const missing = assemble({ criteriaPreset: 'custom', criteriaFile: join(tempDir(), 'nope.md') }, { initiator: exec.agent })
|
|
108
|
+
const degraded = await missing.rpc.get('/llm-verifier')!('probe', undefined) as { ok: boolean; value: { rubric: Record<string, unknown> } }
|
|
109
|
+
expect(degraded.ok).toBe(true)
|
|
110
|
+
expect(degraded.value.rubric).toMatchObject({ source: 'fallback', count: 3 })
|
|
111
|
+
expect(String(degraded.value.rubric.error)).toMatch(/ENOENT|no such file/u)
|
|
112
|
+
|
|
113
|
+
// A real file is parsed, and its note and criteria reach the probe. The stub judge then fails,
|
|
114
|
+
// but only AFTER the rubric was resolved — which is what this asserts.
|
|
115
|
+
const file = join(tempDir(), 'rubric.md')
|
|
116
|
+
writeFileSync(file, '# Rubric\n\n## Criteria\n\n### Only One\n\nScore the only criterion.\n')
|
|
117
|
+
const custom = assemble({ criteriaPreset: 'custom', criteriaFile: file }, { initiator: exec.agent })
|
|
118
|
+
const parsed = await custom.rpc.get('/llm-verifier')!('probe', undefined) as { value: { rubric: Record<string, unknown> } }
|
|
119
|
+
expect(parsed.value.rubric).toMatchObject({ source: 'custom', count: 1 })
|
|
120
|
+
})
|
|
121
|
+
|
|
69
122
|
it('survives an unavailable settings service and an unregistered connection', () => {
|
|
70
123
|
const { tools, warnings } = assemble()
|
|
71
|
-
expect(tools.size).toBe(
|
|
124
|
+
expect(tools.size).toBe(5)
|
|
72
125
|
expect(warnings).toEqual([])
|
|
73
126
|
})
|
|
74
127
|
})
|
|
128
|
+
|
|
129
|
+
/** Minimal text stream in the shape BlockAssembler consumes. */
|
|
130
|
+
function textStream(text: string, kind: 'stop' | 'max-tokens' = 'stop') {
|
|
131
|
+
return (async function* () {
|
|
132
|
+
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
133
|
+
yield { type: 'text-delta', index: 0, text }
|
|
134
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text } }
|
|
135
|
+
yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } }
|
|
136
|
+
yield { type: 'finish', reason: { kind } }
|
|
137
|
+
})()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The one draft the scripted judges reward, and the two they do not. */
|
|
141
|
+
const STRONG_DRAFT = 'The fix is in place. COMPLETE-AND-VERIFIED: ran the test suite, output 42 passed.'
|
|
142
|
+
const WEAK_DRAFT = 'I edited the file. It should work now.'
|
|
143
|
+
const BASELINE = '(No useful work or verification was performed.)'
|
|
144
|
+
|
|
145
|
+
/** Concatenated text of every text block in one request. */
|
|
146
|
+
function promptText(options: { messages: readonly unknown[] }): string {
|
|
147
|
+
return (options.messages as readonly { content?: readonly { type?: string; text?: string }[] }[])
|
|
148
|
+
.flatMap(message => message.content ?? [])
|
|
149
|
+
.filter(block => block.type === 'text' && typeof block.text === 'string')
|
|
150
|
+
.map(block => block.text as string)
|
|
151
|
+
.join('\n')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function section(prompt: string, tag: string): string {
|
|
155
|
+
return new RegExp('<<<' + tag + ':[^>]*>>>\\n([\\s\\S]*?)\\n<<<END_' + tag + ':').exec(prompt)?.[1] ?? ''
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A-T is success quality, so A is the winning letter and T is the empty-work baseline. */
|
|
159
|
+
function verdictLetter(block: string): string {
|
|
160
|
+
return block.includes('COMPLETE-AND-VERIFIED') ? 'A' : block.includes(BASELINE) ? 'T' : 'Q'
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Scripted model for a whole best-of-N invocation.
|
|
165
|
+
*
|
|
166
|
+
* Drafting calls are recognised by the drafting prompt's own tail (`Draft N of M.`) and are
|
|
167
|
+
* answered with one strong or weak draft; every other call is a judge, answered by scoring
|
|
168
|
+
* whichever side carries the verification marker.
|
|
169
|
+
* @param strongDraft - 1-based draft number that gets the marker; 0 to make every draft weak.
|
|
170
|
+
* @param failing - 1-based draft numbers whose generation throws.
|
|
171
|
+
* @param calls - receives one record per model call (route and sampling, in order).
|
|
172
|
+
* @param truncating - 1-based draft numbers whose FIRST attempt stops at the output ceiling.
|
|
173
|
+
*/
|
|
174
|
+
function scriptedStream(strongDraft: number, failing: readonly number[], calls: Array<Record<string, unknown>>, truncating: readonly number[] = []) {
|
|
175
|
+
return (options: { messages: readonly unknown[]; provider?: string; model?: string; temperature?: number; maxTokens?: number; reasoningEffort?: string }) => {
|
|
176
|
+
const prompt = promptText(options)
|
|
177
|
+
calls.push({ provider: options.provider, model: options.model, temperature: options.temperature, maxTokens: options.maxTokens, reasoningEffort: options.reasoningEffort })
|
|
178
|
+
const draft = /Draft (\d+) of \d+\./.exec(prompt)
|
|
179
|
+
if (draft !== null) {
|
|
180
|
+
const number = Number(draft[1])
|
|
181
|
+
if (failing.includes(number)) return (async function* () { throw new Error('draft ' + number + ' exploded') })()
|
|
182
|
+
// Distinct per draft number: byte-identical drafts would (correctly) be collapsed by the
|
|
183
|
+
// engine before the tournament, which changes the cost this suite measures.
|
|
184
|
+
return textStream((number === strongDraft ? STRONG_DRAFT : WEAK_DRAFT) + ' Draft ' + number + '.', truncating.includes(number) ? 'max-tokens' : 'stop')
|
|
185
|
+
}
|
|
186
|
+
return textStream('reasoning\n<score_A> ' + verdictLetter(section(prompt, 'TRAJECTORY_A')) + ' </score_A>\n<score_B> ' + verdictLetter(section(prompt, 'TRAJECTORY_B')) + ' </score_B>')
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const JUDGE = { provider: 'judge-provider', model: 'judge-model' }
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Recursive check that a rendered tool value satisfies its declared output schema.
|
|
194
|
+
*
|
|
195
|
+
* The host validates tool output against this schema, and a mismatch would only surface there —
|
|
196
|
+
* never in a unit test that just inspects fields it happens to know about. `required` sits on the
|
|
197
|
+
* property spec (the DSH shape), so an omitted required field and an undeclared extra field are
|
|
198
|
+
* both caught here.
|
|
199
|
+
*/
|
|
200
|
+
function assertMatchesSchema(value: unknown, schema: Record<string, any>, path: string): void {
|
|
201
|
+
if (schema.enum !== undefined) expect(schema.enum, path).toContain(value)
|
|
202
|
+
if (schema.type === 'object') {
|
|
203
|
+
const row = (value ?? {}) as Record<string, unknown>
|
|
204
|
+
const properties = (schema.properties ?? {}) as Record<string, Record<string, any>>
|
|
205
|
+
for (const [key, spec] of Object.entries(properties)) {
|
|
206
|
+
if (spec.required === true) expect(row[key], path + '.' + key + ' is required').toBeDefined()
|
|
207
|
+
if (row[key] !== undefined) assertMatchesSchema(row[key], spec, path + '.' + key)
|
|
208
|
+
}
|
|
209
|
+
if (schema.additionalProperties === false) expect(Object.keys(row).filter(key => !(key in properties)), path + ' has undeclared keys').toEqual([])
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
if (schema.type === 'array') {
|
|
213
|
+
expect(Array.isArray(value), path + ' is an array').toBe(true)
|
|
214
|
+
;(value as unknown[]).forEach((entry, index) => assertMatchesSchema(entry, schema.items as Record<string, any>, path + '[' + index + ']'))
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
if (schema.type === 'integer') expect(Number.isSafeInteger(value), path + ' is an integer').toBe(true)
|
|
218
|
+
else if (schema.type === 'number') expect(typeof value === 'number' && Number.isFinite(value), path + ' is a finite number').toBe(true)
|
|
219
|
+
else if (schema.type === 'string') expect(typeof value === 'string', path + ' is a string').toBe(true)
|
|
220
|
+
else if (schema.type === 'boolean') expect(typeof value === 'boolean', path + ' is a boolean').toBe(true)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
describe('verifier_best_of_n', () => {
|
|
224
|
+
it('registers a gate-shaped output schema', () => {
|
|
225
|
+
const definition = assemble().tools.get('verifier_best_of_n')!
|
|
226
|
+
const properties = definition.output.schema.properties
|
|
227
|
+
for (const key of [
|
|
228
|
+
'best', 'index', 'sources', 'scores', 'ranking', 'comparisons', 'pivots', 'generated', 'failed',
|
|
229
|
+
'failures', 'score', 'baselineScore', 'winner', 'criteria', 'threshold', 'passesThreshold',
|
|
230
|
+
'failedCriteria', 'calls', 'stats', 'generatorProvider', 'generatorModel', 'provider', 'model', 'judges',
|
|
231
|
+
]) expect(properties[key], key).toBeDefined()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('rejects an out-of-range draft count and a blank task before any model call', async () => {
|
|
235
|
+
const calls: Array<Record<string, unknown>> = []
|
|
236
|
+
const execute = assemble(JUDGE, { stream: scriptedStream(1, [], calls) }).tools.get('verifier_best_of_n')!.execute
|
|
237
|
+
await expect(execute({ task: 'do it', n: 5 }, exec)).rejects.toThrow(/n must be an integer between 2 and 4/u)
|
|
238
|
+
await expect(execute({ task: 'do it', n: 1 }, exec)).rejects.toThrow(/n must be an integer between 2 and 4/u)
|
|
239
|
+
await expect(execute({ task: ' ', n: 2 }, exec)).rejects.toThrow(/task\[0\] must be a non-empty string/u)
|
|
240
|
+
expect(calls).toHaveLength(0)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('drafts with the session model, ranks the drafts, and reports the gate-comparable score', async () => {
|
|
244
|
+
const calls: Array<Record<string, unknown>> = []
|
|
245
|
+
const result = await assemble(JUDGE, { stream: scriptedStream(2, [], calls) }).tools.get('verifier_best_of_n')!
|
|
246
|
+
.execute({ task: 'Fix the parser and prove it passes.' }, exec) as Record<string, any>
|
|
247
|
+
expect(result.generated).toBe(3)
|
|
248
|
+
expect(result.failed).toBe(0)
|
|
249
|
+
expect(result.failures).toEqual([])
|
|
250
|
+
expect(result.sources).toEqual([1, 2, 3])
|
|
251
|
+
// Draft 2 is the only one that shows verification, so it must win the tournament.
|
|
252
|
+
expect(result.index).toBe(1)
|
|
253
|
+
expect(result.best).toContain('COMPLETE-AND-VERIFIED')
|
|
254
|
+
expect(result.scores[1]).toBeGreaterThan(result.scores[0]!)
|
|
255
|
+
// The absolute score comes from the winner-vs-baseline comparison in the acceptance
|
|
256
|
+
// threshold's units, NOT from the relative tournament shares.
|
|
257
|
+
expect(result.score).toBe(1)
|
|
258
|
+
expect(result.baselineScore).toBe(0)
|
|
259
|
+
expect(result.winner).toBe('A')
|
|
260
|
+
expect(result.threshold).toBe(0.65)
|
|
261
|
+
expect(result.passesThreshold).toBe(true)
|
|
262
|
+
expect(result.failedCriteria).toEqual([])
|
|
263
|
+
expect(result.provider).toBe('judge-provider')
|
|
264
|
+
expect(result.model).toBe('judge-model')
|
|
265
|
+
expect(result.generatorProvider).toBe('session-provider')
|
|
266
|
+
expect(result.generatorModel).toBe('session-model')
|
|
267
|
+
// Generation used the session route with diversity sampling; judging used the judge route.
|
|
268
|
+
const drafted = calls.filter(call => call.model === 'session-model')
|
|
269
|
+
expect(drafted).toHaveLength(3)
|
|
270
|
+
for (const call of drafted) {
|
|
271
|
+
expect(call.provider).toBe('session-provider')
|
|
272
|
+
expect(call.temperature).toBe(1)
|
|
273
|
+
expect(call.maxTokens).toBe(16384)
|
|
274
|
+
expect(call.reasoningEffort).toBe('high')
|
|
275
|
+
}
|
|
276
|
+
expect(calls.filter(call => call.model === 'judge-model').length).toBeGreaterThan(3)
|
|
277
|
+
// The reported cost covers the drafts as well as every judge call.
|
|
278
|
+
expect(result.calls).toBe(calls.length)
|
|
279
|
+
// judges[].calls covers the WHOLE invocation: the tournament (18) plus the baseline (6).
|
|
280
|
+
expect(result.judges[0].calls).toBe(24)
|
|
281
|
+
expect(result.judges[0].calls + result.generated).toBe(result.calls)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('keeps a truncated draft, retries it once, and reports it instead of returning nothing', async () => {
|
|
285
|
+
// Regression from the first real end-to-end acceptance: every draft of a long task hit the
|
|
286
|
+
// output ceiling and the tool failed with "0 usable draft(s)" — the judge contract's
|
|
287
|
+
// max-tokens error had leaked into the generator.
|
|
288
|
+
const calls: Array<Record<string, unknown>> = []
|
|
289
|
+
const result = await assemble(JUDGE, { stream: scriptedStream(1, [], calls, [1]) }).tools.get('verifier_best_of_n')!
|
|
290
|
+
.execute({ task: 'do the thing' }, exec) as Record<string, any>
|
|
291
|
+
expect(result.generated).toBe(3)
|
|
292
|
+
expect(result.truncated).toEqual([1])
|
|
293
|
+
// One capped attempt per draft, no hidden second generation.
|
|
294
|
+
const ceilings = calls.filter(call => call.model === 'session-model').map(call => call.maxTokens)
|
|
295
|
+
expect(ceilings).toEqual([16384, 16384, 16384])
|
|
296
|
+
// A truncated draft is still judged on its text, so the strong one can still win.
|
|
297
|
+
expect(result.index).toBe(0)
|
|
298
|
+
expect(result.best).toContain('COMPLETE-AND-VERIFIED')
|
|
299
|
+
expect(result.passesThreshold).toBe(true)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('renders a result that satisfies the declared output schema', async () => {
|
|
303
|
+
const definition = assemble(JUDGE, { stream: scriptedStream(2, [], []) }).tools.get('verifier_best_of_n')!
|
|
304
|
+
const result = await definition.execute({ task: 'do the thing' }, exec)
|
|
305
|
+
assertMatchesSchema(result, definition.output.schema as Record<string, any>, 'best_of_n')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('fails closed when fewer than two drafts survive, naming every failure', async () => {
|
|
309
|
+
const execute = assemble(JUDGE, { stream: scriptedStream(1, [1, 3], []) }).tools.get('verifier_best_of_n')!.execute
|
|
310
|
+
await expect(execute({ task: 'do the thing' }, exec)).rejects.toThrow(/produced 1 usable draft\(s\) out of 3; at least 2 are required/u)
|
|
311
|
+
await expect(execute({ task: 'do the thing' }, exec)).rejects.toThrow(/draft 1: draft 1 exploded; draft 3: draft 3 exploded/u)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('reports a failing absolute score when every draft is weak', async () => {
|
|
315
|
+
const result = await assemble(JUDGE, { stream: scriptedStream(0, [], []) }).tools.get('verifier_best_of_n')!
|
|
316
|
+
.execute({ task: 'do the thing' }, exec) as Record<string, any>
|
|
317
|
+
expect(result.generated).toBe(3)
|
|
318
|
+
expect(result.winner).toBe('A')
|
|
319
|
+
expect(result.score).toBeLessThan(0.65)
|
|
320
|
+
expect(result.passesThreshold).toBe(false)
|
|
321
|
+
expect(result.failedCriteria).toHaveLength(3)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('stays inside the documented cost envelope', async () => {
|
|
325
|
+
// The README and the tool description publish these numbers, so they are locked here:
|
|
326
|
+
// 2 drafts + 6 tournament calls + 6 baseline calls; 3 + 18 + 6; n=4 adds 1..5 extra
|
|
327
|
+
// tournament pairs (36..54 judge calls) because the pivot round de-duplicates against the ring.
|
|
328
|
+
const measured: Record<number, { calls: number; comparisons: number }> = {}
|
|
329
|
+
for (const n of [2, 3, 4]) {
|
|
330
|
+
const result = await assemble(JUDGE, { stream: scriptedStream(2, [], []) }).tools.get('verifier_best_of_n')!
|
|
331
|
+
.execute({ task: 'do the thing', n }, exec) as Record<string, any>
|
|
332
|
+
measured[n] = { calls: result.calls, comparisons: result.comparisons }
|
|
333
|
+
}
|
|
334
|
+
expect(measured[2]).toEqual({ calls: 14, comparisons: 1 })
|
|
335
|
+
expect(measured[3]).toEqual({ calls: 27, comparisons: 3 })
|
|
336
|
+
expect(measured[4]!.calls).toBeGreaterThanOrEqual(40)
|
|
337
|
+
expect(measured[4]!.calls).toBeLessThanOrEqual(64)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('explains what to do when the session model is not logged yet', async () => {
|
|
341
|
+
const execute = assemble(JUDGE, { stream: scriptedStream(1, [], []) }).tools.get('verifier_best_of_n')!.execute
|
|
342
|
+
await expect(execute({ task: 'do the thing' }, bareExec)).rejects.toThrow(/no logged request header/u)
|
|
343
|
+
})
|
|
344
|
+
})
|