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
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 A through T where A = certainly NO, H-M = uncertain, N-S = leans YES, and T = essentially certain YES with matching observed verification.',
197
- 'Effort and confident narration are not evidence. A state without real verification should not exceed K. Scores may decrease after regressions.',
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
+ })
@@ -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
+ })