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