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/core.ts
CHANGED
|
@@ -54,8 +54,119 @@ export const DEFAULT_CRITERIA: Criterion[] = [
|
|
|
54
54
|
},
|
|
55
55
|
]
|
|
56
56
|
|
|
57
|
+
/** Task classes a rubric can be chosen for; `custom` lives at the config layer, not here. */
|
|
58
|
+
export const CRITERIA_PRESET_IDS = ['coding', 'debug', 'research', 'ops', 'writing'] as const
|
|
59
|
+
export type CriteriaPresetId = typeof CRITERIA_PRESET_IDS[number]
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Rubric per task class.
|
|
63
|
+
*
|
|
64
|
+
* Upstream ships one criteria file per benchmark (`criteria/swe_bench.md`, `terminal_bench.md`,
|
|
65
|
+
* `medagentbench.md`) and its TEMPLATE states the rule this table follows: 2-4 narrow criteria
|
|
66
|
+
* beat one broad one. Judging a research answer with "Output Match"/"Error Signal Detection"
|
|
67
|
+
* measures the wrong thing, and the automatic gate has no per-task override.
|
|
68
|
+
*
|
|
69
|
+
* `coding` is byte-identical to {@link DEFAULT_CRITERIA}: the default preset must not change
|
|
70
|
+
* any existing verdict, prompt or cache key.
|
|
71
|
+
*/
|
|
72
|
+
export const CRITERIA_PRESETS: Record<CriteriaPresetId, Criterion[]> = {
|
|
73
|
+
coding: DEFAULT_CRITERIA,
|
|
74
|
+
debug: [
|
|
75
|
+
{
|
|
76
|
+
id: 'reproduction',
|
|
77
|
+
name: 'Failure Reproduction',
|
|
78
|
+
description: 'Did the agent reproduce the reported failure BEFORE changing code? Look for a command or test whose observed output shows the failure happening. Penalize edits made without any reproduction, and treat "the user said it is broken" as no evidence.',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'root_cause',
|
|
82
|
+
name: 'Root Cause',
|
|
83
|
+
description: 'Compare the stated cause with the evidence: does the diagnosis point at code that the observed output actually implicates, rather than at the last error message? Penalize symptom patches and guesses presented as findings.',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'fix_verification',
|
|
87
|
+
name: 'Fix Verification',
|
|
88
|
+
description: 'Find the command that exercised the fix AFTER the last code change. Reward only observed output showing the previously failing case now passing with no new failures. Penalize "should be fixed" assertions and fixes verified before the final edit.',
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
research: [
|
|
92
|
+
{
|
|
93
|
+
id: 'question_addressed',
|
|
94
|
+
name: 'Question Addressed',
|
|
95
|
+
description: 'Does the answer address exactly what was asked, including every part of a multi-part question? Penalize thorough answers to a nearby but different question, and unanswered sub-questions.',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'source_grounding',
|
|
99
|
+
name: 'Source Grounding',
|
|
100
|
+
description: 'Is every material claim traceable to evidence the answer names (file, URL, command output)? Reward claims tied to a specific source; penalize confident claims with no traceable basis and citations that do not actually support the claim.',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'limits_stated',
|
|
104
|
+
name: 'Limits Stated',
|
|
105
|
+
description: 'Does the answer separate what was verified from what is inferred, and state assumptions, uncertainty and missing data? Penalize unqualified certainty that goes beyond the observed evidence.',
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
ops: [
|
|
109
|
+
{
|
|
110
|
+
id: 'change_specification',
|
|
111
|
+
name: 'Change Specification',
|
|
112
|
+
description: 'Compare the executed commands with the requested operation: target, environment, arguments and scope. Penalize actions against the wrong target, and side effects beyond the requested scope.',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'observed_result',
|
|
116
|
+
name: 'Observed Result',
|
|
117
|
+
description: 'Reward commands whose observed output shows the intended state (service up, file present, config applied). Penalize inferring success from an exit code without inspecting the resulting state.',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: 'reversibility',
|
|
121
|
+
name: 'Reversibility',
|
|
122
|
+
description: 'Does the work leave a way back: a backup, a recorded previous value, a dry run first, or a stated rollback path? Penalize irreversible changes made without one.',
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
writing: [
|
|
126
|
+
{
|
|
127
|
+
id: 'brief_adherence',
|
|
128
|
+
name: 'Brief Adherence',
|
|
129
|
+
description: 'Check the requested deliverable: format, length, audience and every explicit constraint. Penalize a well-written piece that answers a different brief.',
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'structure_clarity',
|
|
133
|
+
name: 'Structure And Clarity',
|
|
134
|
+
description: 'Judge whether the structure carries the argument: ordering, sections, and a point the reader can follow. Penalize padding, repetition and unsupported assertions used as filler.',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: 'factual_grounding',
|
|
138
|
+
name: 'Factual Grounding',
|
|
139
|
+
description: 'Are factual statements supported by the material the task supplied, or invented? Penalize fabricated specifics such as names, numbers, dates and quotes that do not appear in the evidence.',
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Derive a criterion id from free text: lowercase, alphanumerics and underscores, max 40 chars. */
|
|
145
|
+
export function slugCriterionId(text: string): string {
|
|
146
|
+
const slug = text.toLowerCase().replace(/[^a-z0-9]+/gu, '_').replace(/^_+|_+$/gu, '')
|
|
147
|
+
return slug.slice(0, 40).replace(/_+$/gu, '') || 'criterion'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Make an id unique against the ids already used, by appending _2, _3, ... */
|
|
151
|
+
export function dedupeCriterionId(id: string, seen: Set<string>): string {
|
|
152
|
+
let candidate = id
|
|
153
|
+
let suffix = 1
|
|
154
|
+
while (seen.has(candidate)) { suffix += 1; candidate = id + '_' + suffix }
|
|
155
|
+
seen.add(candidate)
|
|
156
|
+
return candidate
|
|
157
|
+
}
|
|
158
|
+
|
|
57
159
|
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
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The fixed baseline a session acceptance measures itself against.
|
|
163
|
+
*
|
|
164
|
+
* One definition, read by the automatic final gate and by `verifier_best_of_n`. The tool
|
|
165
|
+
* advertises an "absolute" score with the gate's own threshold, so if these two ever drifted
|
|
166
|
+
* apart the tool would be measuring against a different reference than the gate it predicts.
|
|
167
|
+
*/
|
|
168
|
+
export const EMPTY_WORK_BASELINE = '(No useful work or verification was performed.)'
|
|
169
|
+
|
|
59
170
|
/**
|
|
60
171
|
* Deterministic per-prompt delimiter token.
|
|
61
172
|
*
|
|
@@ -166,6 +277,23 @@ export function extractScore(completion: CompletionLogprobs, tag: string): numbe
|
|
|
166
277
|
return (letterValue(letter) - 1) / (GRANULARITY - 1)
|
|
167
278
|
}
|
|
168
279
|
|
|
280
|
+
/**
|
|
281
|
+
* One pairwise prompt focused on a single criterion.
|
|
282
|
+
*
|
|
283
|
+
* Everything that does not depend on the criterion (task, both trajectories, the rating
|
|
284
|
+
* scale) comes first and ONLY the criterion varies at the tail. That is not cosmetic:
|
|
285
|
+
* it maximizes the shared prompt prefix across the criteria of one comparison, so a
|
|
286
|
+
* prefix-caching backend serves the trace-heavy body from cache. Upstream documents the
|
|
287
|
+
* same constraint on its `build_prompt` ("Keep criterion-specific text strictly at the
|
|
288
|
+
* end when editing"), and `VerifierEngine.compare` warms the prefix with one job before
|
|
289
|
+
* fanning out the rest. Keep it that way.
|
|
290
|
+
* @param problem - task statement shown to the judge.
|
|
291
|
+
* @param traceA - candidate A's trajectory.
|
|
292
|
+
* @param traceB - candidate B's trajectory.
|
|
293
|
+
* @param criterion - the single criterion this call scores.
|
|
294
|
+
* @param groundTruthNote - note prepended to every judge prompt.
|
|
295
|
+
* @returns The rendered prompt.
|
|
296
|
+
*/
|
|
169
297
|
export function buildPairwisePrompt(problem: string, traceA: string, traceB: string, criterion: Criterion, groundTruthNote = DEFAULT_GROUND_TRUTH_NOTE): string {
|
|
170
298
|
const token = evidenceNonce(problem, traceA, traceB)
|
|
171
299
|
return [
|
|
@@ -193,13 +321,39 @@ export function buildProgressPrompt(problem: string, steps: readonly string[], c
|
|
|
193
321
|
'**Task instruction:**\n' + renderDelimitedBlock('TASK', token, problem.trim()),
|
|
194
322
|
'**Agent trajectory (' + steps.length + ' agent steps):**\n' + renderDelimitedBlock('AGENT_TRACE', token, trajectory),
|
|
195
323
|
"Score whether the agent's CURRENT state at each checkpoint would actually satisfy the task's hidden grader.",
|
|
196
|
-
'Use
|
|
197
|
-
|
|
324
|
+
'Use the 20-letter A-T scale: A = certainly NO (nothing useful done yet); B-G = leans NO (partial work, key pieces missing or broken); H-M = uncertain (a plausible solution is taking shape, but no convincing verification yet); N-S = leans YES (the right artifacts appear to be in place and partial verification has worked, with minor concerns); T = essentially certain YES (the relevant verification ran and its observed output literally matches what the task calls for, with no outstanding errors).',
|
|
325
|
+
"Calibration: effort, exploration, step count and confident narration are NOT progress — an agent that ran twenty commands without producing the right output deserves a score near A. Default to skepticism: a result with no real verification step should not exceed ~K, and even a verified-looking one should rarely exceed ~R unless the verification clearly matches the task's stated success criterion. Treat the agent's prose declarations (\"done\", \"all tests pass\") as ZERO evidence, and ground the score in the actions and the observed output you can see.",
|
|
326
|
+
'Successive checkpoints do not have to rise: a trajectory committed to a wrong approach should plateau, and a regression should score lower than the state it broke.',
|
|
198
327
|
'The checkpoints are:\n' + checkpoints.map((step, index) => ' Checkpoint ' + (index + 1) + ' = state right after Agent Step ' + step).join('\n'),
|
|
199
328
|
'Output EXACTLY these lines and nothing else:\n' + tags,
|
|
200
329
|
].join('\n\n')
|
|
201
330
|
}
|
|
202
331
|
|
|
332
|
+
/**
|
|
333
|
+
* One best-of-N drafting prompt.
|
|
334
|
+
*
|
|
335
|
+
* Deliberately NOT the judge contract: here the request IS the instruction to follow and the
|
|
336
|
+
* answer is free-form work product, so there is no data-only delimiter and no A–T verdict tag.
|
|
337
|
+
* The untrusted-content boundary is re-established downstream instead — every draft is embedded
|
|
338
|
+
* as delimited, nonce-terminated evidence when the judges score it.
|
|
339
|
+
*
|
|
340
|
+
* Only the final line varies between the N drafts, so the shared prompt prefix (which carries
|
|
341
|
+
* the whole request) is maximal. Same tail-only convention as `buildPairwisePrompt`'s
|
|
342
|
+
* criterion, and for the same prefix-caching reason.
|
|
343
|
+
* @param task - the request the drafts must answer.
|
|
344
|
+
* @param index - 0-based draft index.
|
|
345
|
+
* @param total - how many drafts are being generated.
|
|
346
|
+
* @returns The rendered prompt.
|
|
347
|
+
*/
|
|
348
|
+
export function buildGenerationPrompt(task: string, index: number, total: number): string {
|
|
349
|
+
return [
|
|
350
|
+
'You are one of ' + total + ' independent experts asked to answer the same request. Answer it yourself, completely and concretely, in the form the request calls for — code, patch, plan, or prose.',
|
|
351
|
+
'Do not ask clarifying questions, do not defer the work to a later step, and do not refer to the other attempts. Where the answer depends on a command, a test, or other evidence, state exactly what it is and what output would prove it; never claim a verification you cannot describe.',
|
|
352
|
+
'**Request:**\n' + task.trim(),
|
|
353
|
+
'Draft ' + (index + 1) + ' of ' + total + '.',
|
|
354
|
+
].join('\n\n')
|
|
355
|
+
}
|
|
356
|
+
|
|
203
357
|
/** Progress uses A=NO..T=YES, the reverse of pairwise success scoring. */
|
|
204
358
|
export function extractProgressScore(completion: CompletionLogprobs, tag: string): number {
|
|
205
359
|
return 1 - extractScore(completion, tag)
|
|
@@ -265,3 +419,64 @@ export function rankScores(wins: readonly number[], counts: readonly number[]):
|
|
|
265
419
|
return Array.from({ length: wins.length }, (_, index) => ({ index, score: (wins[index] ?? 0) / (counts[index] || 1) }))
|
|
266
420
|
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
267
421
|
}
|
|
422
|
+
|
|
423
|
+
/** Optional trailing `{#id}` anchor on a criterion heading, e.g. `### Root Cause {#cause}`. */
|
|
424
|
+
const CRITERION_ID_ANCHOR = /^(.*?)\s*\{#([A-Za-z0-9_-]+)\}\s*$/
|
|
425
|
+
const HTML_COMMENT = /<!--[\s\S]*?-->/gu
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Parse a criteria file (or an inline markdown string) into a ground-truth note and criteria.
|
|
429
|
+
*
|
|
430
|
+
* Upstream's criteria files are the reason this exists: a rubric you can edit, review and
|
|
431
|
+
* version beats a hard-coded list, and its TEMPLATE pins the layout. Format:
|
|
432
|
+
*
|
|
433
|
+
* # <title> (ignored)
|
|
434
|
+
* ## Ground Truth Note (optional)
|
|
435
|
+
* <one paragraph the verifier always sees>
|
|
436
|
+
* ## Criteria
|
|
437
|
+
* ### <Criterion Name> {#id} (the anchor is optional; the id is slugged from the name)
|
|
438
|
+
* <instruction for this criterion>
|
|
439
|
+
*
|
|
440
|
+
* HTML comments are stripped, so a file can carry author notes the judge never sees. The
|
|
441
|
+
* parser fails closed: no criteria, a heading without a body, or a blank instruction throws
|
|
442
|
+
* rather than silently scoring with fewer criteria than authored.
|
|
443
|
+
* @param text - criteria markdown.
|
|
444
|
+
* @returns The optional ground-truth note and the parsed criteria, in file order.
|
|
445
|
+
*/
|
|
446
|
+
export function parseCriteriaMarkdown(text: string): { groundTruthNote: string; criteria: Criterion[] } {
|
|
447
|
+
const lines = text.replace(HTML_COMMENT, '').split(/\r?\n/u)
|
|
448
|
+
const criteria: Criterion[] = []
|
|
449
|
+
const seen = new Set<string>()
|
|
450
|
+
let groundTruthNote = ''
|
|
451
|
+
let section: 'ground_truth' | 'criteria' | undefined
|
|
452
|
+
let current: { name: string; id: string } | undefined
|
|
453
|
+
let buffer: string[] = []
|
|
454
|
+
const flush = () => {
|
|
455
|
+
const body = buffer.join('\n').trim()
|
|
456
|
+
if (section === 'ground_truth') { if (!groundTruthNote) groundTruthNote = body }
|
|
457
|
+
else if (current !== undefined) { criteria.push({ id: current.id, name: current.name, description: body }); current = undefined }
|
|
458
|
+
buffer = []
|
|
459
|
+
}
|
|
460
|
+
for (const line of lines) {
|
|
461
|
+
if (line.startsWith('## ') && !line.startsWith('### ')) {
|
|
462
|
+
flush()
|
|
463
|
+
const heading = line.slice(3).trim().toLowerCase()
|
|
464
|
+
section = heading.includes('ground truth') ? 'ground_truth' : heading.includes('criteri') ? 'criteria' : undefined
|
|
465
|
+
} else if (line.startsWith('### ') && section === 'criteria') {
|
|
466
|
+
flush()
|
|
467
|
+
const heading = line.slice(4).trim()
|
|
468
|
+
const anchored = CRITERION_ID_ANCHOR.exec(heading)
|
|
469
|
+
const name = (anchored?.[1] ?? heading).trim()
|
|
470
|
+
current = { name, id: dedupeCriterionId(slugCriterionId(anchored?.[2] ?? name), seen) }
|
|
471
|
+
} else if (line.startsWith('# ')) {
|
|
472
|
+
continue
|
|
473
|
+
} else {
|
|
474
|
+
buffer.push(line)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
flush()
|
|
478
|
+
if (criteria.length === 0) throw new Error('llm-verifier: criteria file has no criteria — add a "## Criteria" section with one "### Criterion Name" heading per criterion')
|
|
479
|
+
const empty = criteria.filter(criterion => criterion.description.length === 0).map(criterion => criterion.id)
|
|
480
|
+
if (empty.length > 0) throw new Error('llm-verifier: criteria file has criteria with no instruction: ' + empty.join(', '))
|
|
481
|
+
return { groundTruthNote, criteria }
|
|
482
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { CRITERIA_PRESETS, DEFAULT_CRITERIA } from './core.ts'
|
|
3
|
+
import { CriteriaResolver } from './criteria.ts'
|
|
4
|
+
|
|
5
|
+
const FILE = ['# Rubric', '## Ground Truth Note', '', 'Trust raw output only.', '', '## Criteria', '', '### Alpha', '', 'Judge alpha carefully.', '', '### Beta {#b2}', '', 'Judge beta carefully.', ''].join('\n')
|
|
6
|
+
|
|
7
|
+
describe('CriteriaResolver', () => {
|
|
8
|
+
it('returns the bundled preset by reference, so switching presets changes the rubric', async () => {
|
|
9
|
+
const resolver = new CriteriaResolver()
|
|
10
|
+
const coding = await resolver.resolve('coding', undefined)
|
|
11
|
+
expect(coding.criteria).toBe(DEFAULT_CRITERIA)
|
|
12
|
+
expect(coding.source).toBe('coding')
|
|
13
|
+
const research = await resolver.resolve('research', undefined)
|
|
14
|
+
expect(research.criteria).toBe(CRITERIA_PRESETS.research)
|
|
15
|
+
expect(research.error).toBeUndefined()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('reads a custom markdown file and carries its ground-truth note', async () => {
|
|
19
|
+
const resolver = new CriteriaResolver(async () => FILE)
|
|
20
|
+
const resolved = await resolver.resolve('custom', 'rubric.md')
|
|
21
|
+
expect(resolved.source).toBe('custom')
|
|
22
|
+
expect(resolved.groundTruthNote).toBe('Trust raw output only.')
|
|
23
|
+
expect(resolved.criteria.map(criterion => criterion.id)).toEqual(['alpha', 'b2'])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('falls back to the coding rubric instead of failing the gate when the file is unusable', async () => {
|
|
27
|
+
const missing = new CriteriaResolver(async () => { throw new Error('ENOENT: no such file') })
|
|
28
|
+
const resolved = await missing.resolve('custom', 'nope.md')
|
|
29
|
+
expect(resolved.source).toBe('fallback')
|
|
30
|
+
expect(resolved.criteria).toBe(DEFAULT_CRITERIA)
|
|
31
|
+
expect(resolved.error).toMatch(/ENOENT/u)
|
|
32
|
+
expect(resolved.file).toBe('nope.md')
|
|
33
|
+
|
|
34
|
+
const unparsable = new CriteriaResolver(async () => '# no criteria in here')
|
|
35
|
+
const broken = await unparsable.resolve('custom', 'broken.md')
|
|
36
|
+
expect(broken.source).toBe('fallback')
|
|
37
|
+
expect(broken.error).toMatch(/no criteria/u)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('reports a missing file path rather than reading an empty one', async () => {
|
|
41
|
+
const resolver = new CriteriaResolver(async () => FILE)
|
|
42
|
+
const resolved = await resolver.resolve('custom', ' ')
|
|
43
|
+
expect(resolved.source).toBe('fallback')
|
|
44
|
+
expect(resolved.error).toMatch(/no criteria file configured/u)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('re-reads an edited file, and caches an unchanged one', async () => {
|
|
48
|
+
let text = FILE
|
|
49
|
+
let reads = 0
|
|
50
|
+
const resolver = new CriteriaResolver(async () => { reads += 1; return text })
|
|
51
|
+
const first = await resolver.resolve('custom', 'rubric.md')
|
|
52
|
+
expect(first.criteria.map(criterion => criterion.id)).toEqual(['alpha', 'b2'])
|
|
53
|
+
// Same content: the parse is reused, but the file is re-read so an edit is never missed.
|
|
54
|
+
await resolver.resolve('custom', 'rubric.md')
|
|
55
|
+
expect(reads).toBe(2)
|
|
56
|
+
text = FILE.replace('### Alpha', '### Gamma')
|
|
57
|
+
const third = await resolver.resolve('custom', 'rubric.md')
|
|
58
|
+
expect(third.criteria.map(criterion => criterion.id)).toEqual(['gamma', 'b2'])
|
|
59
|
+
})
|
|
60
|
+
})
|
package/src/criteria.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { CRITERIA_PRESETS, DEFAULT_CRITERIA, parseCriteriaMarkdown, type Criterion, type CriteriaPresetId } from './core.ts'
|
|
3
|
+
|
|
4
|
+
/** Everything the settings page can select: a bundled task-class preset, or a markdown file. */
|
|
5
|
+
export type CriteriaSelection = CriteriaPresetId | 'custom'
|
|
6
|
+
|
|
7
|
+
export interface ResolvedCriteria {
|
|
8
|
+
criteria: Criterion[]
|
|
9
|
+
/** Note prepended to every judge prompt, when the rubric carries one. */
|
|
10
|
+
groundTruthNote?: string
|
|
11
|
+
/** The preset id, `custom`, or `fallback` when a custom file could not be used. */
|
|
12
|
+
source: string
|
|
13
|
+
file?: string
|
|
14
|
+
/** Why the custom file was rejected; the `coding` rubric is used instead of failing the gate. */
|
|
15
|
+
error?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the configured rubric to the criteria the engine scores with.
|
|
20
|
+
*
|
|
21
|
+
* A custom markdown file is read on every resolve (a rubric file is a few kilobytes and it is
|
|
22
|
+
* resolved once per turn boundary), so editing it takes effect immediately. The parse is cached
|
|
23
|
+
* against the file text, so repeated resolves of an unchanged file cost one read.
|
|
24
|
+
*
|
|
25
|
+
* A broken custom file does NOT fail verification: a rubric typo must not disable the acceptance
|
|
26
|
+
* gate, so the coding preset is used and the reason travels in {@link ResolvedCriteria.error}
|
|
27
|
+
* for the settings page and the log. This mirrors the plugin's rule that scoring-channel
|
|
28
|
+
* capability problems degrade instead of aborting a verification.
|
|
29
|
+
*/
|
|
30
|
+
export class CriteriaResolver {
|
|
31
|
+
private cache: { key: string; value: ResolvedCriteria } | undefined
|
|
32
|
+
constructor(private readonly load: (path: string) => Promise<string> = path => readFile(path, 'utf8')) {}
|
|
33
|
+
|
|
34
|
+
async resolve(selection: CriteriaSelection, file: string | undefined): Promise<ResolvedCriteria> {
|
|
35
|
+
if (selection !== 'custom') {
|
|
36
|
+
const preset = CRITERIA_PRESETS[selection]
|
|
37
|
+
return { criteria: preset ?? DEFAULT_CRITERIA, source: preset ? selection : 'fallback', ...(preset ? {} : { error: 'unknown criteria preset: ' + String(selection) }) }
|
|
38
|
+
}
|
|
39
|
+
const path = (file ?? '').trim()
|
|
40
|
+
let text: string
|
|
41
|
+
try {
|
|
42
|
+
if (!path) throw new Error('no criteria file configured (set criteriaFile, or switch criteriaPreset away from custom)')
|
|
43
|
+
text = await this.load(path)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return this.remember('custom\u0000' + path + '\u0000!' + String(error), {
|
|
46
|
+
criteria: DEFAULT_CRITERIA,
|
|
47
|
+
source: 'fallback',
|
|
48
|
+
...(path ? { file: path } : {}),
|
|
49
|
+
error: error instanceof Error ? error.message : String(error),
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
const key = 'custom\u0000' + path + '\u0000' + text
|
|
53
|
+
if (this.cache?.key === key) return this.cache.value
|
|
54
|
+
try {
|
|
55
|
+
const parsed = parseCriteriaMarkdown(text)
|
|
56
|
+
return this.remember(key, {
|
|
57
|
+
criteria: parsed.criteria,
|
|
58
|
+
...(parsed.groundTruthNote ? { groundTruthNote: parsed.groundTruthNote } : {}),
|
|
59
|
+
source: 'custom',
|
|
60
|
+
file: path,
|
|
61
|
+
})
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return this.remember(key + '\u0000!' + String(error), {
|
|
64
|
+
criteria: DEFAULT_CRITERIA,
|
|
65
|
+
source: 'fallback',
|
|
66
|
+
file: path,
|
|
67
|
+
error: error instanceof Error ? error.message : String(error),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private remember(key: string, value: ResolvedCriteria): ResolvedCriteria {
|
|
73
|
+
this.cache = { key, value }
|
|
74
|
+
return value
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { DecisionStore, boundDecisionCalls, resolveDecisionsFile } from './decisions.ts'
|
|
6
|
+
|
|
7
|
+
const call = (overrides: Partial<{ label: string; channel: string; prompt: string; output: string; score?: number }> = {}) => ({
|
|
8
|
+
label: 'Specification Adherence repeat 1',
|
|
9
|
+
channel: 'explicit-tag',
|
|
10
|
+
prompt: 'score this',
|
|
11
|
+
output: '<score_A> K </score_A>',
|
|
12
|
+
...overrides,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('boundDecisionCalls', () => {
|
|
16
|
+
it('keeps a small capture intact', () => {
|
|
17
|
+
const bounded = boundDecisionCalls([call({ score: 0.5263 })])
|
|
18
|
+
expect(bounded).toHaveLength(1)
|
|
19
|
+
expect(bounded[0]!.score).toBe(0.5263)
|
|
20
|
+
expect(bounded[0]!.channel).toBe('explicit-tag')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('redacts and truncates every captured field', () => {
|
|
24
|
+
const bounded = boundDecisionCalls([call({ prompt: 'Authorization: Bearer abcdefghijklmnop ' + 'p'.repeat(20000), output: 'o'.repeat(9000) })])
|
|
25
|
+
expect(bounded[0]!.prompt).not.toContain('abcdefghijklmnop')
|
|
26
|
+
expect(bounded[0]!.prompt.length).toBeLessThanOrEqual(8000)
|
|
27
|
+
expect(bounded[0]!.output.length).toBeLessThanOrEqual(4000)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('bounds a record by call count and by the per-record character budget', () => {
|
|
31
|
+
// 40 calls are capped at MAX_CALLS = 32, and the equal per-call share keeps every one of
|
|
32
|
+
// them inside the character budget rather than dropping the tail.
|
|
33
|
+
const many = Array.from({ length: 40 }, (_, index) => call({ label: 'c' + index, prompt: 'p'.repeat(6000), output: 'o'.repeat(3000) }))
|
|
34
|
+
const bounded = boundDecisionCalls(many)
|
|
35
|
+
expect(bounded).toHaveLength(32)
|
|
36
|
+
const total = bounded.reduce((sum, value) => sum + value.prompt.length + value.output.length + value.label.length + value.channel.length, 0)
|
|
37
|
+
expect(total).toBeLessThanOrEqual(30000)
|
|
38
|
+
// The first call is always kept, so a snapshot never comes back empty.
|
|
39
|
+
expect(bounded[0]!.label).toBe('c0')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('keeps the drafts and the tournament of a 27-call best-of-N snapshot', () => {
|
|
43
|
+
// Regression: with MAX_CALLS = 12 and judge labels sorting BEFORE the draft labels, every
|
|
44
|
+
// draft was cut and the snapshot could not show what the judges had chosen between.
|
|
45
|
+
const calls = [
|
|
46
|
+
...Array.from({ length: 3 }, (_, index) => call({ label: 'draft ' + (index + 1), prompt: 'draft prompt ' + index, output: 'DRAFT-' + index + ' ' + 'd'.repeat(3000) })),
|
|
47
|
+
...Array.from({ length: 18 }, (_, index) => call({ label: 'criterion ' + (index % 3) + ' repeat ' + (index % 2 + 1) + ' #' + index, prompt: 'p'.repeat(6000), output: 'o'.repeat(3000) })),
|
|
48
|
+
...Array.from({ length: 6 }, (_, index) => call({ label: 'baseline ' + index, prompt: 'b'.repeat(6000), output: 'o'.repeat(3000) })),
|
|
49
|
+
]
|
|
50
|
+
const bounded = boundDecisionCalls(calls)
|
|
51
|
+
expect(bounded).toHaveLength(27)
|
|
52
|
+
for (const index of [0, 1, 2]) expect(bounded.some(value => value.label === 'draft ' + (index + 1))).toBe(true)
|
|
53
|
+
const total = bounded.reduce((sum, value) => sum + value.prompt.length + value.output.length + value.label.length + value.channel.length, 0)
|
|
54
|
+
expect(total).toBeLessThanOrEqual(30000)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('keeps every call of a six-call session acceptance instead of the first three', () => {
|
|
58
|
+
// Regression: a fixed 8000-char prompt window meant 30000 / 8000 drove the loop to break
|
|
59
|
+
// after three calls, and only the calls that finished first were stored — the acceptance
|
|
60
|
+
// path, the plugin's most expensive decision, lost half its criteria.
|
|
61
|
+
const acceptance = Array.from({ length: 6 }, (_, index) => call({
|
|
62
|
+
label: 'criterion ' + Math.floor(index / 2) + ' repeat ' + (index % 2 + 1),
|
|
63
|
+
prompt: 'HEAD\n' + 'p'.repeat(120000) + '\nTAIL',
|
|
64
|
+
output: '<score_A> K </score_A>\n' + 'o'.repeat(900),
|
|
65
|
+
}))
|
|
66
|
+
const bounded = boundDecisionCalls(acceptance)
|
|
67
|
+
expect(bounded).toHaveLength(6)
|
|
68
|
+
expect(new Set(bounded.map(value => value.label)).size).toBe(6)
|
|
69
|
+
// Both ends of the (much shorter) window still survive.
|
|
70
|
+
expect(bounded[0]!.prompt.startsWith('HEAD')).toBe(true)
|
|
71
|
+
expect(bounded[0]!.prompt.endsWith('TAIL')).toBe(true)
|
|
72
|
+
const total = bounded.reduce((sum, value) => sum + value.prompt.length + value.output.length + value.label.length + value.channel.length, 0)
|
|
73
|
+
expect(total).toBeLessThanOrEqual(30000)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('ignores a non-finite score instead of persisting NaN', () => {
|
|
77
|
+
const bounded = boundDecisionCalls([call({ score: Number.NaN })])
|
|
78
|
+
expect(bounded[0]!.score).toBeUndefined()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('boundCaptureText', () => {
|
|
83
|
+
it('keeps both ends of an over-long prompt instead of only the head', () => {
|
|
84
|
+
// A session-acceptance prompt runs past 100k characters: head-only truncation saved
|
|
85
|
+
// the instructions and dropped the trajectory tail, i.e. the part the judge graded.
|
|
86
|
+
const prompt = 'HEAD-MARKER\n' + 'x'.repeat(30000) + '\nTAIL-MARKER'
|
|
87
|
+
const kept = boundDecisionCalls([call({ prompt })])[0]!.prompt
|
|
88
|
+
expect(kept.length).toBeLessThanOrEqual(8000)
|
|
89
|
+
expect(kept.startsWith('HEAD-MARKER')).toBe(true)
|
|
90
|
+
expect(kept.endsWith('TAIL-MARKER')).toBe(true)
|
|
91
|
+
expect(kept).toContain('characters omitted')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('leaves a prompt that already fits untouched', () => {
|
|
95
|
+
const kept = boundDecisionCalls([call({ prompt: 'short prompt' })])[0]!.prompt
|
|
96
|
+
expect(kept).toBe('short prompt')
|
|
97
|
+
expect(kept).not.toContain('omitted')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('DecisionStore', () => {
|
|
102
|
+
it('stores a snapshot and finds it again from a fresh instance', async () => {
|
|
103
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-decisions-'))
|
|
104
|
+
const file = join(root, 'decisions.json')
|
|
105
|
+
const store = new DecisionStore(file)
|
|
106
|
+
const record = await store.record({ toolName: 'verifier_track', phase: 'track', startedAt: 1, provider: 'p', model: 'm', calls: [call()] })
|
|
107
|
+
expect(record?.calls).toHaveLength(1)
|
|
108
|
+
// Survives a restart: the second store loads the same file.
|
|
109
|
+
const reopened = new DecisionStore(file)
|
|
110
|
+
expect(await reopened.find(record!.id)).toMatchObject({ toolName: 'verifier_track', phase: 'track' })
|
|
111
|
+
const document = JSON.parse(await readFile(file, 'utf8'))
|
|
112
|
+
expect(document.version).toBe(1)
|
|
113
|
+
expect(document.records).toHaveLength(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('writes nothing when no call was captured', async () => {
|
|
117
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-decisions-'))
|
|
118
|
+
const file = join(root, 'decisions.json')
|
|
119
|
+
const store = new DecisionStore(file)
|
|
120
|
+
expect(await store.record({ toolName: 'verifier_track', phase: 'track', startedAt: 1, provider: 'p', model: 'm', calls: [] })).toBeUndefined()
|
|
121
|
+
await expect(readFile(file, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('keeps only the newest records and returns undefined for a pruned id', async () => {
|
|
125
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-decisions-'))
|
|
126
|
+
const file = join(root, 'decisions.json')
|
|
127
|
+
const store = new DecisionStore(file, 2)
|
|
128
|
+
const first = await store.record({ toolName: 'a', phase: 'explicit', startedAt: 1, provider: 'p', model: 'm', calls: [call()] })
|
|
129
|
+
const second = await store.record({ toolName: 'b', phase: 'explicit', startedAt: 2, provider: 'p', model: 'm', calls: [call()] })
|
|
130
|
+
const third = await store.record({ toolName: 'c', phase: 'explicit', startedAt: 3, provider: 'p', model: 'm', calls: [call()] })
|
|
131
|
+
expect(await store.find(first!.id)).toBeUndefined()
|
|
132
|
+
expect(await store.find(second!.id)).toMatchObject({ toolName: 'b' })
|
|
133
|
+
expect(await store.find(third!.id)).toMatchObject({ toolName: 'c' })
|
|
134
|
+
const document = JSON.parse(await readFile(file, 'utf8'))
|
|
135
|
+
expect(document.records.map((row: { toolName: string }) => row.toolName)).toEqual(['b', 'c'])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('skips a corrupt document instead of failing the invocation', async () => {
|
|
139
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-decisions-'))
|
|
140
|
+
const file = join(root, 'decisions.json')
|
|
141
|
+
await writeFile(file, JSON.stringify({ version: 1, records: [{ id: 5 }, { id: 'ok', toolName: 'a', startedAt: 1, calls: [] }] }), 'utf8')
|
|
142
|
+
const store = new DecisionStore(file)
|
|
143
|
+
await store.load()
|
|
144
|
+
expect(await store.find('ok')).toMatchObject({ toolName: 'a' })
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('resolves the snapshot file beside the statistics of the same topic', () => {
|
|
148
|
+
expect(resolveDecisionsFile(join('topic', 'verifier', 'scores-v1.json'))).toBe(join('topic', 'verifier', 'decisions-v1.json'))
|
|
149
|
+
})
|
|
150
|
+
})
|