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/client.tsx CHANGED
@@ -4,15 +4,16 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
4
4
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
5
5
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
6
6
  import { Button, IconDataOutline16, IconRefreshOutline16, Input } from '@deepseek-ai/dsh-client-ui-primitives'
7
- import { useEffect, useMemo, useRef, useState } from 'react'
7
+ import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
8
8
  import {
9
9
  zh, en, dictionaries, toolLabels, tFormat, useLanguage, detectLanguage,
10
10
  compact, money, duration, dateTime, type I18nDict,
11
11
  type VerdictSummary, resolveCacheDirOnSave, sameSettingValue, sectionForSave,
12
- WORST_CASE_ROUTE_CALLS_PER_JUDGE, WORST_CASE_FINAL_CALLS_PER_JUDGE, WORST_CASE_TASK_PER_JUDGE, WORST_CASE_SESSION_PER_JUDGE,
12
+ WORST_CASE_ROUTE_CALLS_PER_JUDGE, WORST_CASE_FINAL_CALLS_PER_JUDGE, WORST_CASE_TASK_PER_JUDGE, WORST_CASE_SESSION_PER_JUDGE, WORST_CASE_CRITERIA_PER_COMPARISON,
13
13
  computeJudgeCount, computeWorstCaseBudget, type BudgetWarningState,
14
14
  evaluateBudgetWarning, isVerdictFailed, formatPercentage, formatVerdictDetails,
15
15
  } from './client-i18n.ts'
16
+ import { CRITERIA_PRESETS, DEFAULT_GROUND_TRUTH_NOTE, buildPairwisePrompt } from './core.ts'
16
17
  import {
17
18
  type ExtraJudgeDraft,
18
19
  MAX_EXTRA_JUDGES,
@@ -32,14 +33,17 @@ export {
32
33
  }
33
34
 
34
35
  const NS = 'llm-verifier'
35
- export interface Values { enabled: boolean; autoVerifyMode: 'manual'|'smart'|'strict'; autoVerifyThreshold: number; autoVerifyRepeats: number; autoVerifyFinalRepeats: number; autoVerifyMinToolCalls: number; autoVerifyMaxChars: number; autoVerifyMaxPerTask: number; autoVerifyMaxPerSession: number; autoRouteSemantic: boolean; autoRouteMinConfidence: number; autoRouteMaxCandidates: number; autoRouteMaxPerTask: number; autoRouteMaxPerSession: number; autoTrackCompletionThreshold: number; autoRouteMaxItemChars: number; autoRouteMaxInputChars: number; autoMaxModelCallsPerTask: number; autoMaxModelCallsPerSession: number; autoVerifyTeamTasks: boolean; autoVerifyPlanMode: boolean; provider: string; model: string; reasoningEffort?: string; maxTokens: number; temperature: number; label?: string; maxConcurrency: number; maxRetries: number; retryBaseDelayMs: number; timeoutMs: number; cacheDir: string; cacheMaxEntries: number; estimatedInputUsdPerMillion: number; estimatedOutputUsdPerMillion: number; autoVerifySubagents: boolean; extraJudges: ExtraJudgeDraft[] }
36
+ interface DecisionCallView { label: string; channel: string; prompt: string; output: string; score?: number }
37
+ interface DecisionRecordView { id: string; toolName: string; phase: string; provider: string; model: string; startedAt: number; calls: DecisionCallView[] }
38
+
39
+ export interface Values { enabled: boolean; captureDecisions: boolean; autoVerifyMode: 'manual'|'smart'|'strict'; autoVerifyThreshold: number; autoVerifyRepeats: number; autoTrackRepeats: number; autoVerifyFinalRepeats: number; autoVerifyMinToolCalls: number; autoVerifyMaxChars: number; autoVerifyMaxPerTask: number; autoVerifyMaxPerSession: number; autoRouteSemantic: boolean; autoRouteMinConfidence: number; autoRouteMaxCandidates: number; autoRouteMaxPerTask: number; autoRouteMaxPerSession: number; autoTrackCompletionThreshold: number; autoRouteMaxItemChars: number; autoRouteMaxInputChars: number; autoMaxModelCallsPerTask: number; autoMaxModelCallsPerSession: number; autoVerifyTeamTasks: boolean; autoVerifyPlanMode: boolean; criteriaPreset: 'coding'|'debug'|'research'|'ops'|'writing'|'custom'; criteriaFile: string; provider: string; model: string; reasoningEffort?: string; maxTokens: number; temperature: number; label?: string; maxConcurrency: number; maxRetries: number; retryBaseDelayMs: number; timeoutMs: number; cacheDir: string; cacheMaxEntries: number; estimatedInputUsdPerMillion: number; estimatedOutputUsdPerMillion: number; autoVerifySubagents: boolean; extraJudges: ExtraJudgeDraft[] }
36
40
  export interface Loaded { groups: readonly ModelProviderGroup[]; settings: SettingsNamespaceView; writable: boolean; failures: string[] }
37
41
  export interface RunStats { calls: number; attempts: number; retries: number; inputTokens: number; cachedInputTokens: number; outputTokens: number; reasoningTokens: number; cacheHits: number; cacheMisses: number; estimatedCostUsd: number; topLogprobScores: number; explicitTagScores: number }
38
42
  export interface InvocationRecord { id: string; toolName: string; sessionId?: string; startedAt: number; finishedAt: number; durationMs: number; success: boolean; errorName?: string; errorMessage?: string; provider: string; model: string; stats: RunStats; verdict?: VerdictSummary }
39
43
  interface DailyStatistics { date: string; invocations: number; successes: number; failures: number; calls: number; tokens: number; estimatedCostUsd: number; byTool: Record<string, number> }
40
44
  interface ToolStatistics { toolName: string; invocations: number; successes: number; failures: number; successRate: number; averageDurationMs: number; calls: number; tokens: number; cacheHits: number; cacheMisses: number; estimatedCostUsd: number }
41
45
  interface ModelStatistics { provider: string; model: string; invocations: number; calls: number; tokens: number; estimatedCostUsd: number }
42
- interface Totals extends RunStats { invocations: number; successes: number; failures: number; successRate: number; averageDurationMs: number; tokens: number; cacheHitRate: number }
46
+ interface Totals extends RunStats { invocations: number; successes: number; failures: number; successRate: number; averageDurationMs: number; tokens: number; cacheHitRate: number; prefixCacheHitRate: number }
43
47
  interface StatisticsOverview { generatedAt: number; fromMs: number; toMs: number; sessionId?: string; totals: Totals; daily: DailyStatistics[]; tools: ToolStatistics[]; models: ModelStatistics[]; recent: InvocationRecord[] }
44
48
  interface VerifierRemote {
45
49
  session: {
@@ -67,6 +71,9 @@ interface VerifierRemote {
67
71
  }
68
72
  }
69
73
 
74
+ export interface JudgeProbeView { label: string; provider: string; model: string; ok: boolean; channel?: string; channelProbed?: boolean; scoreA?: number; scoreB?: number; latencyMs: number; calls?: number; inputTokens?: number; cachedInputTokens?: number; outputTokens?: number; error?: string }
75
+ export interface ProbeResultView { judges: JudgeProbeView[]; channelProbed?: boolean; rubric: { source: string; count: number; file?: string; error?: string } }
76
+
70
77
  interface VerifierSettingsProps { remote: VerifierRemote }
71
78
  interface StatisticsPageProps {
72
79
  sessionId?: string
@@ -87,10 +94,10 @@ const toggleStyle = (enabled: boolean): React.CSSProperties => ({ position: 'rel
87
94
  const toggleThumbStyle = (enabled: boolean): React.CSSProperties => ({ position: 'absolute', top: 3, left: enabled ? 21 : 3, width: 16, height: 16, borderRadius: '50%', background: 'var(--dsw-static-neutral-00, #fff)', boxShadow: '0 1px 3px rgba(0,0,0,.28)', transition: 'left .15s ease' })
88
95
  const dashboardCard: React.CSSProperties = { border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.13))', background: 'color-mix(in srgb, var(--dsw-alias-bg-module, #171925) 88%, transparent)', borderRadius: 16, boxShadow: '0 12px 36px rgba(0,0,0,.12)' }
89
96
  const muted: React.CSSProperties = { color: 'var(--dsw-text-secondary)', fontSize: 12 }
90
- const toolColors: Record<string, string> = { verifier_route_classify: '#d97706', verifier_compare: '#4f8cff', verifier_select: '#8b6df6', verifier_track: '#2fc5c9', verifier_current_session: '#f5a524' }
97
+ const toolColors: Record<string, string> = { verifier_route_classify: '#d97706', verifier_compare: '#4f8cff', verifier_select: '#8b6df6', verifier_track: '#2fc5c9', verifier_best_of_n: '#e2569b', verifier_current_session: '#f5a524' }
91
98
 
92
99
  function record(value: unknown): Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {} }
93
- function values(view: SettingsNamespaceView): Values { const v=record(view.value); const mode=v.autoVerifyMode==='manual'||v.autoVerifyMode==='strict'?v.autoVerifyMode:'smart'; return { enabled:v.enabled!==false,autoVerifyMode:mode,autoVerifyThreshold:Number(v.autoVerifyThreshold??.65),autoVerifyRepeats:Number(v.autoVerifyRepeats??1),autoVerifyFinalRepeats:Number(v.autoVerifyFinalRepeats??2),autoVerifyMinToolCalls:Number(v.autoVerifyMinToolCalls??3),autoVerifyMaxChars:Number(v.autoVerifyMaxChars??80000),autoVerifyMaxPerTask:Number(v.autoVerifyMaxPerTask??2),autoVerifyMaxPerSession:Number(v.autoVerifyMaxPerSession??8),autoRouteSemantic:v.autoRouteSemantic!==false,autoRouteMinConfidence:Number(v.autoRouteMinConfidence??.9),autoRouteMaxCandidates:Number(v.autoRouteMaxCandidates??8),autoRouteMaxPerTask:Number(v.autoRouteMaxPerTask??2),autoRouteMaxPerSession:Number(v.autoRouteMaxPerSession??8),autoTrackCompletionThreshold:Number(v.autoTrackCompletionThreshold??.8),autoRouteMaxItemChars:Number(v.autoRouteMaxItemChars??20000),autoRouteMaxInputChars:Number(v.autoRouteMaxInputChars??60000),autoMaxModelCallsPerTask:Number(v.autoMaxModelCallsPerTask??96),autoMaxModelCallsPerSession:Number(v.autoMaxModelCallsPerSession??240),autoVerifyTeamTasks:v.autoVerifyTeamTasks!==false,autoVerifyPlanMode:v.autoVerifyPlanMode!==false,provider:String(v.provider??''),model:String(v.model??''),...(typeof v.reasoningEffort==='string'?{reasoningEffort:v.reasoningEffort}:{}),maxTokens:Number(v.maxTokens??32768),temperature:Number(v.temperature??0.2),...(typeof v.label==='string'&&v.label.trim()?{label:v.label.trim()}:{}),maxConcurrency:Number(v.maxConcurrency??8),maxRetries:Number(v.maxRetries??3),retryBaseDelayMs:Number(v.retryBaseDelayMs??500),timeoutMs:Number(v.timeoutMs??300000),cacheDir:typeof v.cacheDir==='string'&&v.cacheDir.trim()?v.cacheDir.trim():'verifier',cacheMaxEntries:Number(v.cacheMaxEntries??10000),estimatedInputUsdPerMillion:Number(v.estimatedInputUsdPerMillion??0),estimatedOutputUsdPerMillion:Number(v.estimatedOutputUsdPerMillion??0),autoVerifySubagents:v.autoVerifySubagents===true,extraJudges:normalizeExtraJudges(v.extraJudges) } }
100
+ function values(view: SettingsNamespaceView): Values { const v=record(view.value); const mode=v.autoVerifyMode==='manual'||v.autoVerifyMode==='strict'?v.autoVerifyMode:'smart'; return { enabled:v.enabled!==false,captureDecisions:v.captureDecisions!==false,autoVerifyMode:mode,autoVerifyThreshold:Number(v.autoVerifyThreshold??.65),autoVerifyRepeats:Number(v.autoVerifyRepeats??1),autoTrackRepeats:Number(v.autoTrackRepeats??3),autoVerifyFinalRepeats:Number(v.autoVerifyFinalRepeats??2),autoVerifyMinToolCalls:Number(v.autoVerifyMinToolCalls??3),autoVerifyMaxChars:Number(v.autoVerifyMaxChars??80000),autoVerifyMaxPerTask:Number(v.autoVerifyMaxPerTask??2),autoVerifyMaxPerSession:Number(v.autoVerifyMaxPerSession??8),autoRouteSemantic:v.autoRouteSemantic!==false,autoRouteMinConfidence:Number(v.autoRouteMinConfidence??.9),autoRouteMaxCandidates:Number(v.autoRouteMaxCandidates??8),autoRouteMaxPerTask:Number(v.autoRouteMaxPerTask??2),autoRouteMaxPerSession:Number(v.autoRouteMaxPerSession??8),autoTrackCompletionThreshold:Number(v.autoTrackCompletionThreshold??.684),autoRouteMaxItemChars:Number(v.autoRouteMaxItemChars??20000),autoRouteMaxInputChars:Number(v.autoRouteMaxInputChars??60000),autoMaxModelCallsPerTask:Number(v.autoMaxModelCallsPerTask??96),autoMaxModelCallsPerSession:Number(v.autoMaxModelCallsPerSession??240),autoVerifyTeamTasks:v.autoVerifyTeamTasks!==false,autoVerifyPlanMode:v.autoVerifyPlanMode!==false,criteriaPreset:(v.criteriaPreset==='debug'||v.criteriaPreset==='research'||v.criteriaPreset==='ops'||v.criteriaPreset==='writing'||v.criteriaPreset==='custom')?v.criteriaPreset:'coding',criteriaFile:typeof v.criteriaFile==='string'?v.criteriaFile.trim():'',provider:String(v.provider??''),model:String(v.model??''),...(typeof v.reasoningEffort==='string'?{reasoningEffort:v.reasoningEffort}:{}),maxTokens:Number(v.maxTokens??32768),temperature:Number(v.temperature??0.2),...(typeof v.label==='string'&&v.label.trim()?{label:v.label.trim()}:{}),maxConcurrency:Number(v.maxConcurrency??8),maxRetries:Number(v.maxRetries??3),retryBaseDelayMs:Number(v.retryBaseDelayMs??500),timeoutMs:Number(v.timeoutMs??300000),cacheDir:typeof v.cacheDir==='string'&&v.cacheDir.trim()?v.cacheDir.trim():'verifier',cacheMaxEntries:Number(v.cacheMaxEntries??10000),estimatedInputUsdPerMillion:Number(v.estimatedInputUsdPerMillion??0),estimatedOutputUsdPerMillion:Number(v.estimatedOutputUsdPerMillion??0),autoVerifySubagents:v.autoVerifySubagents===true,extraJudges:normalizeExtraJudges(v.extraJudges) } }
94
101
  function message(error: unknown): string { return error instanceof Error ? error.message : String(error) }
95
102
  /** The endpoint answered but rejected the request: a transport fallback would only repeat it. */
96
103
  class EndpointError extends Error {}
@@ -165,13 +172,18 @@ export function VerifierSettings({ remote }: VerifierSettingsProps) {
165
172
  }
166
173
  const budgetWarning = useMemo(() => {
167
174
  if (!draft) return null
175
+ // The engine reserves budget from the REAL criteria count. A built-in preset's count is known
176
+ // here; a custom file's is not (it lives on the server), so the estimate falls back to the
177
+ // three-criteria default and the file field's help says as much.
178
+ const criteria = draft.criteriaPreset === 'custom' ? WORST_CASE_CRITERIA_PER_COMPARISON : CRITERIA_PRESETS[draft.criteriaPreset].length
168
179
  return evaluateBudgetWarning(
169
180
  draft.autoVerifyMode,
170
181
  draft.extraJudges.length,
171
182
  draft.autoMaxModelCallsPerTask,
172
183
  draft.autoMaxModelCallsPerSession,
184
+ criteria,
173
185
  )
174
- }, [draft?.autoVerifyMode, draft?.extraJudges.length, draft?.autoMaxModelCallsPerTask, draft?.autoMaxModelCallsPerSession])
186
+ }, [draft?.autoVerifyMode, draft?.criteriaPreset, draft?.extraJudges.length, draft?.autoMaxModelCallsPerTask, draft?.autoMaxModelCallsPerSession])
175
187
  // The saved section holds only real overrides: a draft field equal to the
176
188
  // composition base is dropped so a later plugin default still reaches this
177
189
  // install (see sectionForSave).
@@ -231,8 +243,12 @@ export function VerifierSettings({ remote }: VerifierSettingsProps) {
231
243
 
232
244
  <section style={group}><GroupTitle>{t['section.autoVerify']}</GroupTitle>
233
245
  <div style={row}><Label title={t['field.autoVerifyMode.title']} help={t['field.autoVerifyMode.help']}/><select style={selectStyle} disabled={busy} aria-label={t['field.autoVerifyMode.title']} value={draft.autoVerifyMode} onChange={e=>patch('autoVerifyMode',e.target.value as Values['autoVerifyMode'])}><option value="manual">{t['field.autoVerifyMode.manual']}</option><option value="smart">{t['field.autoVerifyMode.smart']}</option><option value="strict">{t['field.autoVerifyMode.strict']}</option></select></div>
246
+ <div style={row}><Label title={t['field.criteriaPreset.title']} help={t['field.criteriaPreset.help']}/><select style={selectStyle} disabled={busy} aria-label={t['field.criteriaPreset.title']} value={draft.criteriaPreset} onChange={e=>patch('criteriaPreset',e.target.value as Values['criteriaPreset'])}>{(['coding','debug','research','ops','writing','custom'] as const).map(id=><option key={id} value={id}>{t[('field.criteriaPreset.'+id) as keyof I18nDict] as string}</option>)}</select></div>
247
+ {draft.criteriaPreset==='custom'&&<div style={row}><Label title={t['field.criteriaFile.title']} help={t['field.criteriaFile.help']}/>{textField('criteriaFile','criteria/my-task.md')}</div>}
248
+ {draft.criteriaPreset!=='custom'&&(()=>{const preset=CRITERIA_PRESETS[draft.criteriaPreset];const first=preset[0];if(!first)return null;return <details style={{margin:'2px 0 0 4px',fontSize:12}}><summary style={{cursor:'pointer',color:'var(--dsw-text-secondary)'}}>{tFormat(t['field.criteriaPreset.previewSummary'],{count:preset.length})}</summary><pre style={{whiteSpace:'pre-wrap',wordBreak:'break-word',maxHeight:240,overflow:'auto',background:'var(--dsw-surface-sunken)',border:'1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12))',borderRadius:8,padding:10,marginTop:8}}>{buildPairwisePrompt(t['field.criteriaPreset.sampleTask'],t['field.criteriaPreset.sampleA'],t['field.criteriaPreset.sampleB'],first,DEFAULT_GROUND_TRUTH_NOTE)}</pre></details>})()}
234
249
  <div style={row}><Label title={t['field.autoRouteSemantic.title']} help={t['field.autoRouteSemantic.help']}/><button type="button" role="switch" aria-checked={draft.autoRouteSemantic} aria-label={t['field.autoRouteSemantic.title']} onClick={()=>patch('autoRouteSemantic',!draft.autoRouteSemantic)} style={toggleStyle(draft.autoRouteSemantic)}><span style={toggleThumbStyle(draft.autoRouteSemantic)}/></button></div>
235
250
  <div style={row}><Label title={t['field.autoVerifyTeamTasks.title']} help={t['field.autoVerifyTeamTasks.help']}/><button type="button" role="switch" aria-checked={draft.autoVerifyTeamTasks} aria-label={t['field.autoVerifyTeamTasks.title']} onClick={()=>patch('autoVerifyTeamTasks',!(draft.autoVerifyTeamTasks))} style={toggleStyle(draft.autoVerifyTeamTasks)}><span style={toggleThumbStyle(draft.autoVerifyTeamTasks)}/></button></div>
251
+ <div style={row}><Label title={t['field.captureDecisions.title']} help={t['field.captureDecisions.help']}/><button type="button" role="switch" aria-checked={draft.captureDecisions} aria-label={t['field.captureDecisions.title']} onClick={()=>patch('captureDecisions',!draft.captureDecisions)} style={toggleStyle(draft.captureDecisions)}><span style={toggleThumbStyle(draft.captureDecisions)}/></button></div>
236
252
  <div style={row}><Label title={t['field.autoVerifySubagents.title']} help={t['field.autoVerifySubagents.help']}/><button type="button" role="switch" aria-checked={draft.autoVerifySubagents} aria-label={t['field.autoVerifySubagents.title']} onClick={()=>patch('autoVerifySubagents',!draft.autoVerifySubagents)} style={toggleStyle(draft.autoVerifySubagents)}><span style={toggleThumbStyle(draft.autoVerifySubagents)}/></button></div>
237
253
  <div style={row}><Label title={t['field.autoVerifyPlanMode.title']} help={t['field.autoVerifyPlanMode.help']}/><button type="button" role="switch" aria-checked={draft.autoVerifyPlanMode} aria-label={t['field.autoVerifyPlanMode.title']} onClick={()=>patch('autoVerifyPlanMode',!(draft.autoVerifyPlanMode))} style={toggleStyle(draft.autoVerifyPlanMode)}><span style={toggleThumbStyle(draft.autoVerifyPlanMode)}/></button></div>
238
254
  <div style={row}><Label title={t['field.autoRouteMinConfidence.title']} help={t['field.autoRouteMinConfidence.help']}/>{numeric('autoRouteMinConfidence',0)}</div>
@@ -248,6 +264,7 @@ export function VerifierSettings({ remote }: VerifierSettingsProps) {
248
264
  {budgetWarning?.warnSession&&<p style={{margin:'-4px 0 10px',fontSize:12,lineHeight:'18px',color:'var(--dsw-alias-state-warn-label)'}}>{tFormat(t['field.autoMaxModelCallsPerSession.warnBudget'],{current:draft.autoMaxModelCallsPerSession,required:budgetWarning.worstCaseSession,judges:budgetWarning.judgeCount})}</p>}
249
265
  <div style={row}><Label title={t['field.autoVerifyThreshold.title']} help={t['field.autoVerifyThreshold.help']}/>{numeric('autoVerifyThreshold',0)}</div>
250
266
  <div style={row}><Label title={t['field.autoVerifyRepeats.title']} help={t['field.autoVerifyRepeats.help']}/>{numeric('autoVerifyRepeats',1)}</div>
267
+ <div style={row}><Label title={t['field.autoTrackRepeats.title']} help={t['field.autoTrackRepeats.help']}/>{numeric('autoTrackRepeats',1)}</div>
251
268
  <div style={row}><Label title={t['field.autoVerifyFinalRepeats.title']} help={t['field.autoVerifyFinalRepeats.help']}/>{numeric('autoVerifyFinalRepeats',1)}</div>
252
269
  <div style={row}><Label title={t['field.autoVerifyMinToolCalls.title']} help={t['field.autoVerifyMinToolCalls.help']}/>{numeric('autoVerifyMinToolCalls',1)}</div>
253
270
  <div style={row}><Label title={t['field.autoVerifyMaxChars.title']} help={t['field.autoVerifyMaxChars.help']}/>{numeric('autoVerifyMaxChars',1000)}</div>
@@ -363,6 +380,63 @@ export function StatisticsPage({ sessionId, rpc, isGlobal }: StatisticsPageProps
363
380
  const [loading, setLoading] = useState(true)
364
381
  const [error, setError] = useState<string | null>(null)
365
382
  const [refresh, setRefresh] = useState(0)
383
+ // Decision snapshots are fetched on demand: they carry whole prompts, and a dashboard
384
+ // that loaded all of them would ship megabytes of text for rows nobody opened.
385
+ const [snapshot, setSnapshot] = useState<{ id: string; record?: DecisionRecordView; error?: string } | null>(null)
386
+ const snapshotRequest = useRef(0)
387
+ const toggleSnapshot = async (id: string) => {
388
+ if (snapshot?.id === id) { setSnapshot(null); return }
389
+ const request = ++snapshotRequest.current
390
+ setSnapshot({ id })
391
+ const payload = { kind: 'decision', id }
392
+ try {
393
+ let record: DecisionRecordView | undefined
394
+ if (rpc && typeof rpc.call === 'function') {
395
+ try {
396
+ const result = await rpc.call('/api', 'llm-verifier/statistics', payload)
397
+ if (result && result.ok === true) record = (result.value as { decision?: DecisionRecordView }).decision
398
+ } catch (rpcError) { console.warn('[llm-verifier] decision rpc.call failed, trying fetch fallback:', rpcError) }
399
+ }
400
+ if (record === undefined) {
401
+ const response = await fetch('/api/llm-verifier/statistics', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
402
+ const body = await response.json().catch(() => undefined)
403
+ const value = body?.ok === true ? body.value : body?.result?.ok === true ? body.result.value : undefined
404
+ if (value === undefined || value === null) throw new EndpointError(body?.error?.message ?? body?.result?.error?.message ?? t['stats.requestFailed'])
405
+ record = (value as { decision?: DecisionRecordView }).decision
406
+ }
407
+ if (snapshotRequest.current === request) setSnapshot({ id, ...(record === undefined ? { error: t['recent.decisionMissing'] } : { record }) })
408
+ } catch (cause) {
409
+ if (snapshotRequest.current === request) setSnapshot({ id, error: message(cause) })
410
+ }
411
+ }
412
+ // Judge probe: one real, bounded call per configured judge. Not recorded in the statistics —
413
+ // it answers "which channel is this judge on, and is my rubric the one in effect", which the
414
+ // aggregate counters cannot.
415
+ const [probe, setProbe] = useState<{ busy: boolean; value?: ProbeResultView; error?: string } | null>(null)
416
+ const runProbe = async () => {
417
+ if (probe?.busy) return
418
+ setProbe({ busy: true })
419
+ const payload = { kind: 'probe' }
420
+ try {
421
+ let value: ProbeResultView | undefined
422
+ if (rpc && typeof rpc.call === 'function') {
423
+ try {
424
+ const result = await rpc.call('/api', 'llm-verifier/statistics', payload)
425
+ if (result && result.ok === true) value = result.value as ProbeResultView
426
+ } catch (rpcError) { console.warn('[llm-verifier] probe rpc.call failed, trying fetch fallback:', rpcError) }
427
+ }
428
+ if (value === undefined) {
429
+ const response = await fetch('/api/llm-verifier/statistics', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
430
+ const body = await response.json().catch(() => undefined)
431
+ const fallback = body?.ok === true ? body.value : body?.result?.ok === true ? body.result.value : undefined
432
+ if (fallback === undefined || fallback === null) throw new EndpointError(body?.error?.message ?? body?.result?.error?.message ?? t['stats.requestFailed'])
433
+ value = fallback as ProbeResultView
434
+ }
435
+ setProbe({ busy: false, value })
436
+ } catch (cause) {
437
+ setProbe({ busy: false, error: message(cause) })
438
+ }
439
+ }
366
440
  // Data belongs to the range/session that produced it: keeping the previous
367
441
  // range's numbers under an error banner reads as if they were current.
368
442
  const queryKey = days + '|' + sessionOnly + '|' + String(sessionId ?? '')
@@ -462,10 +536,32 @@ export function StatisticsPage({ sessionId, rpc, isGlobal }: StatisticsPageProps
462
536
  {[7, 30, 90].map(value => <button key={value} aria-pressed={days === value} onClick={() => setDays(value)} style={{ border: 0, borderRadius: 7, padding: '6px 10px', cursor: 'pointer', color: days === value ? '#fff' : 'var(--dsw-text-secondary)', background: days === value ? '#3f68d8' : 'transparent' }}>{tFormat(t['stats.daysUnit'], { days: value })}</button>)}
463
537
  </div>
464
538
  {Boolean(sessionId) && <button aria-pressed={sessionOnly} onClick={() => setSessionOnly(value => !value)} style={{ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.15))', borderRadius: 9, padding: '7px 11px', cursor: 'pointer', color: 'var(--dsw-text-primary)', background: sessionOnly ? 'rgba(79,140,255,.18)' : 'var(--dsw-surface-sunken)' }}>{sessionOnly ? t['stats.currentSession'] : t['stats.allSessions']}</button>}
539
+ <button type="button" disabled={probe?.busy === true} onClick={() => void runProbe()} style={{ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.15))', borderRadius: 9, padding: '7px 11px', cursor: 'pointer', color: 'var(--dsw-text-primary)', background: 'var(--dsw-surface-sunken)' }}>{probe?.busy === true ? t['probe.running'] : t['probe.button']}</button>
465
540
  <button title={t['stats.refresh']} onClick={() => setRefresh(value => value + 1)} style={{ display: 'grid', placeItems: 'center', width: 34, height: 34, borderRadius: 9, border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.15))', color: 'var(--dsw-text-primary)', background: 'var(--dsw-surface-sunken)', cursor: 'pointer' }}><IconRefreshOutline16 size={16} /></button>
466
541
  </div>
467
542
  </header>
468
543
  {error && <div style={{ ...dashboardCard, padding: 18, borderColor: 'var(--dsw-danger, #e85858)', color: 'var(--dsw-danger, #e85858)' }}>{error}<div style={{ ...muted, marginTop: 6 }}>{t['stats.hostRestartHint']}</div></div>}
544
+ {probe !== null && <section style={{ ...dashboardCard, padding: '16px 18px' }}>
545
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><strong>{t['probe.title']}</strong><span style={muted}>{t['probe.note']}</span></div>
546
+ {probe.error !== undefined && <div style={{ marginTop: 10, fontSize: 12, color: '#e76565' }}>{probe.error}</div>}
547
+ {probe.value !== undefined && <>
548
+ <div style={{ ...muted, marginTop: 8 }}>{tFormat(t['probe.rubric'], { source: probe.value.rubric.source, count: probe.value.rubric.count, file: probe.value.rubric.file ?? '' })}</div>
549
+ {probe.value.rubric.error !== undefined && <div style={{ marginTop: 6, fontSize: 12, color: '#e3bd63' }}>{tFormat(t['probe.rubricFallback'], { error: probe.value.rubric.error })}</div>}
550
+ <div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
551
+ {probe.value.judges.map(judge => <div key={judge.label + judge.model} style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', fontSize: 12, padding: '9px 11px', borderRadius: 9, background: 'var(--dsw-surface-sunken)' }}>
552
+ <span style={{ color: judge.ok ? '#77d49b' : '#e76565', fontWeight: 600 }}>{judge.ok ? 'OK' : t['probe.failed']}</span>
553
+ <strong>{judge.label}</strong>
554
+ <span style={muted}>{judge.provider}/{judge.model}</span>
555
+ {judge.ok ? <>
556
+ {judge.channel !== undefined && <span style={muted}>{tFormat(t['probe.channel'], { channel: judge.channel })}{judge.channelProbed === true ? ' · ' + t['probe.reprobed'] : ''}</span>}
557
+ {judge.scoreA !== undefined && judge.scoreB !== undefined && <span style={muted}>{tFormat(t['probe.scores'], { a: formatPercentage(judge.scoreA), b: formatPercentage(judge.scoreB) })}</span>}
558
+ <span style={muted}>{tFormat(t['probe.latency'], { ms: String(judge.latencyMs) })}</span>
559
+ {judge.calls !== undefined && <span style={muted}>{tFormat(t['probe.calls'], { calls: String(judge.calls) })}</span>}
560
+ </> : <span style={{ color: '#e76565' }}>{judge.error}</span>}
561
+ </div>)}
562
+ </div>
563
+ </>}
564
+ </section>}
469
565
  {loading && !data ? <div style={{ ...dashboardCard, padding: 32, textAlign: 'center', ...muted }}>{t['stats.loading']}</div> : <>
470
566
  <section style={{ ...dashboardCard, padding: '22px 24px', display: 'grid', gridTemplateColumns: 'minmax(220px,1.4fr) minmax(240px,1fr)', gap: 24, alignItems: 'center' }}>
471
567
  <div>
@@ -481,6 +577,7 @@ export function StatisticsPage({ sessionId, rpc, isGlobal }: StatisticsPageProps
481
577
  </section>
482
578
  <section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(180px,1fr))', gap: 12 }}>
483
579
  <Metric label={t['metric.cacheHitRate']} value={((totals?.cacheHitRate ?? 0) * 100).toFixed(1) + '%'} note={tFormat(t['metric.cacheHitNote'], { hits: compact(totals?.cacheHits ?? 0, lang), total: compact((totals?.cacheHits ?? 0) + (totals?.cacheMisses ?? 0), lang) })} accent="#b7dd64" />
580
+ <Metric label={t['metric.prefixCacheHitRate']} value={((totals?.prefixCacheHitRate ?? 0) * 100).toFixed(1) + '%'} note={tFormat(t['metric.prefixCacheHitNote'], { cached: compact(totals?.cachedInputTokens ?? 0, lang), input: compact((totals?.inputTokens ?? 0) + (totals?.cachedInputTokens ?? 0), lang) })} />
484
581
  <Metric label={t['metric.tokens']} value={compact(totals?.tokens ?? 0, lang)} note={tFormat(t['metric.tokensNote'], { input: compact((totals?.inputTokens ?? 0) + (totals?.cachedInputTokens ?? 0), lang), output: compact(totals?.outputTokens ?? 0, lang) })} />
485
582
  <Metric label={t['metric.avgModelCalls']} value={(totals?.invocations ?? 0) > 0 ? ((totals?.calls ?? 0) / (totals?.invocations ?? 1)).toFixed(1) : '0'} note={tFormat(t['metric.avgModelCallsNote'], { attempts: compact(totals?.attempts ?? 0, lang), retries: compact(totals?.retries ?? 0, lang) })} />
486
583
  <Metric label={t['metric.scoringMode']} value={compact(totals?.topLogprobScores ?? 0, lang)} note={tFormat(t['metric.scoringModeNote'], { explicit: compact(totals?.explicitTagScores ?? 0, lang) })} />
@@ -506,6 +603,47 @@ export function StatisticsPage({ sessionId, rpc, isGlobal }: StatisticsPageProps
506
603
  {verdictInfo.criteriaText && <span style={{ color: 'var(--dsw-text-secondary)' }}>{verdictInfo.criteriaText}</span>}
507
604
  {verdictInfo.winnerText && <span style={{ color: 'var(--dsw-text-secondary)' }}>{verdictInfo.winnerText}</span>}
508
605
  </div>}
606
+ <div style={{ margin: '6px 0 0 15px' }}>
607
+ <button type="button" onClick={() => void toggleSnapshot(item.id)} style={{ fontSize: 11, padding: '2px 7px', borderRadius: 5, cursor: 'pointer', color: 'var(--dsw-text-secondary)', background: 'var(--dsw-surface-sunken)', border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.14))' }}>{snapshot?.id === item.id ? t['recent.decisionHide'] : t['recent.decision']}</button>
608
+ </div>
609
+ <details style={{ margin: '6px 0 0 15px' }}>
610
+ <summary style={{ cursor: 'pointer', fontSize: 11, color: 'var(--dsw-text-secondary)' }}>{t['recent.details']}</summary>
611
+ <div style={{ marginTop: 8, border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.14))', borderRadius: 8, padding: '8px 10px', background: 'var(--dsw-surface-sunken)', fontSize: 11 }}>
612
+ {(item.verdict?.criteria?.length ?? 0) > 0 && <div style={{ display: 'grid', gridTemplateColumns: 'minmax(120px,1fr) auto auto', gap: '4px 14px', marginBottom: 8 }}>
613
+ <strong style={{ color: 'var(--dsw-text-secondary)' }}>{t['recent.detail.criterion']}</strong>
614
+ <strong style={{ color: 'var(--dsw-text-secondary)' }}>{t['recent.detail.score']}</strong>
615
+ <strong style={{ color: 'var(--dsw-text-secondary)' }}>{t['recent.detail.threshold']}</strong>
616
+ {item.verdict!.criteria!.map(criterion => {
617
+ const threshold = typeof item.verdict!.threshold === 'number' ? item.verdict!.threshold : undefined
618
+ const missed = threshold !== undefined && criterion.score < threshold
619
+ return <Fragment key={criterion.id}>
620
+ <span>{criterion.id}</span>
621
+ <span style={{ color: missed ? '#e76565' : '#77d49b' }}>{formatPercentage(criterion.score)}</span>
622
+ <span style={{ color: 'var(--dsw-text-secondary)' }}>{threshold === undefined ? '—' : formatPercentage(threshold)}</span>
623
+ </Fragment>
624
+ })}
625
+ </div>}
626
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, color: 'var(--dsw-text-secondary)' }}>
627
+ <span>{tFormat(t['recent.detail.calls'], { calls: compact(item.stats.calls, lang) })}</span>
628
+ <span>{tFormat(t['recent.detail.tokens'], { input: compact(item.stats.inputTokens, lang), cached: compact(item.stats.cachedInputTokens, lang), output: compact(item.stats.outputTokens, lang) })}</span>
629
+ <span>{tFormat(t['recent.detail.scoreCache'], { hits: compact(item.stats.cacheHits, lang), misses: compact(item.stats.cacheMisses, lang) })}</span>
630
+ <span>{tFormat(t['recent.detail.prefixCache'], { rate: ((item.stats.inputTokens + item.stats.cachedInputTokens) > 0 ? (100 * item.stats.cachedInputTokens / (item.stats.inputTokens + item.stats.cachedInputTokens)).toFixed(0) : '0') + '%' })}</span>
631
+ <span>{tFormat(t['recent.detail.cost'], { cost: money(item.stats.estimatedCostUsd) })}</span>
632
+ </div>
633
+ </div>
634
+ </details>
635
+ {snapshot?.id === item.id && <div style={{ margin: '8px 0 2px 15px', border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.14))', borderRadius: 8, padding: '8px 10px', background: 'var(--dsw-surface-sunken)' }}>
636
+ {snapshot.error !== undefined && <div style={{ fontSize: 11, color: '#e76565' }}>{snapshot.error}</div>}
637
+ {snapshot.record === undefined && snapshot.error === undefined && <div style={{ ...muted, fontSize: 11 }}>{t['recent.decisionLoading']}</div>}
638
+ {snapshot.record !== undefined && snapshot.record.calls.length === 0 && <div style={{ ...muted, fontSize: 11 }}>{t['recent.decisionEmpty']}</div>}
639
+ {(snapshot.record?.calls ?? []).map((call, index) => <div key={index} style={{ marginBottom: index === snapshot.record!.calls.length - 1 ? 0 : 10 }}>
640
+ <div style={{ fontSize: 11, color: 'var(--dsw-text-secondary)' }}>{call.label} · {call.channel}{call.score === undefined ? '' : ' · ' + formatPercentage(call.score)}</div>
641
+ <div style={{ fontSize: 11, marginTop: 4 }}>{t['recent.decisionPrompt']}</div>
642
+ <pre style={{ margin: 0, maxHeight: 180, overflow: 'auto', fontSize: 11, whiteSpace: 'pre-wrap', wordBreak: 'break-word', background: 'rgba(0,0,0,.2)', padding: '6px 8px', borderRadius: 6 }}>{call.prompt}</pre>
643
+ <div style={{ fontSize: 11, marginTop: 4 }}>{t['recent.decisionOutput']}</div>
644
+ <pre style={{ margin: 0, maxHeight: 140, overflow: 'auto', fontSize: 11, whiteSpace: 'pre-wrap', wordBreak: 'break-word', background: 'rgba(0,0,0,.2)', padding: '6px 8px', borderRadius: 6 }}>{call.output}</pre>
645
+ </div>)}
646
+ </div>}
509
647
  </div>
510
648
  <div style={{ textAlign: 'right' }}><div style={{ fontSize: 12 }}>{duration(item.durationMs)}</div><div style={{ ...muted, marginTop: 3 }}>{dateTime(item.startedAt, lang)}</div></div>
511
649
  </div>
@@ -311,6 +311,20 @@ describe('config - judges ensemble resolution', () => {
311
311
  ).toThrow('llm-verifier: extraJudges[0] must be an object')
312
312
  })
313
313
 
314
+ it('resolves the criteria preset and defaults to the historical coding rubric', () => {
315
+ const resolved = resolveConfig({})
316
+ // Default MUST stay 'coding': it is the only preset byte-identical to DEFAULT_CRITERIA, so an
317
+ // existing installation's verdicts and cache keys do not move.
318
+ expect(resolved.criteriaPreset).toBe('coding')
319
+ expect(resolved.criteriaFile).toBe('')
320
+ expect(resolveConfig({ criteriaPreset: 'research' }).criteriaPreset).toBe('research')
321
+ expect(resolveConfig({ criteriaPreset: 'custom', criteriaFile: ' rubric.md ' }).criteriaFile).toBe('rubric.md')
322
+ // A custom preset with no file is NOT a config error: the resolver degrades to coding and
323
+ // reports why, so a typo in a rubric path cannot disable the gate.
324
+ expect(resolveConfig({ criteriaPreset: 'custom' }).criteriaFile).toBe('')
325
+ expect(() => resolveConfig({ criteriaPreset: 'nonsense' as any })).toThrow('llm-verifier: criteriaPreset must be one of coding, debug, research, ops, writing, custom')
326
+ })
327
+
314
328
  it('regression: primary configuration still validates and behaves as before', () => {
315
329
  expect(() => resolveConfig({ provider: '' })).toThrow('llm-verifier: provider must be non-empty')
316
330
  expect(() => resolveConfig({ provider: ' ' })).toThrow('llm-verifier: provider must be non-empty')
@@ -377,6 +391,24 @@ describe('config - settings payload seam', () => {
377
391
  expect(resolved.judges.map(judge => judge.maxTokens)).toEqual([32768, 32768, 32768, 32768])
378
392
  })
379
393
 
394
+ it('carries the criteria preset across the settings schema, JSON boundary and resolveConfig', () => {
395
+ // The host validates the stored section with the schema and the RPC carries plain JSON, so the
396
+ // new keys must survive Config -> JSON -> Config -> resolveConfig, not just resolveConfig.
397
+ const payload = JSON.parse(JSON.stringify(Config({ provider: 'deepseek-official', model: 'deepseek-flash', criteriaPreset: 'ops', criteriaFile: ' criteria/ops.md ' })))
398
+ expect(payload.criteriaPreset).toBe('ops')
399
+ expect(payload.criteriaFile).toBe(' criteria/ops.md ')
400
+ const resolved = resolveConfig(payload)
401
+ expect(resolved.criteriaPreset).toBe('ops')
402
+ // The schema preserves what the user typed; resolveConfig is what trims.
403
+ expect(resolved.criteriaFile).toBe('criteria/ops.md')
404
+ // Every selectable value round-trips, and unset stays on the historical default.
405
+ for (const preset of ['coding', 'debug', 'research', 'ops', 'writing', 'custom'] as const) {
406
+ expect(resolveConfig(Config({ criteriaPreset: preset })).criteriaPreset).toBe(preset)
407
+ }
408
+ expect(Config({}).criteriaPreset).toBe('coding')
409
+ expect(resolveConfig({}).criteriaFile).toBe('')
410
+ })
411
+
380
412
  it('rejects the section the settings page blocks: a duplicate of the primary judge', () => {
381
413
  const drafts: ExtraJudgeDraft[] = [{ provider: 'deepseek-official', model: 'deepseek-flash' }]
382
414
  const payload = JSON.parse(JSON.stringify(serializeExtraJudges(drafts))) as NonNullable<Config['extraJudges']>
package/src/config.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
2
  import z from 'schemastery'
3
+ import { CRITERIA_PRESET_IDS, type CriteriaPresetId } from './core.ts'
4
+
5
+ /** A configured rubric: a bundled task-class preset, or a markdown file. */
6
+ export type CriteriaPresetSelection = CriteriaPresetId | 'custom'
3
7
 
4
8
  export const VERIFIER_SETTINGS_NAMESPACE = 'llm-verifier' as never
5
9
 
@@ -28,6 +32,16 @@ export interface Config {
28
32
  autoVerifyMode?: AutoVerifyMode
29
33
  autoVerifyThreshold?: number
30
34
  autoVerifyRepeats?: number
35
+ /**
36
+ * Scoring repeats for an automatic `track` route only.
37
+ *
38
+ * Progress scores on a model without token logprobs come from the explicit-tag
39
+ * channel, i.e. ONE sampled letter per call, and one letter is worth 5.3% of the
40
+ * A–T scale — adjacent bands are a couple of samples apart. Averaging repeats is
41
+ * what the upstream `n_evaluations` does for exactly this reason, and a track call
42
+ * is the cheapest kind (one prompt, no tournament), so it defaults to 3.
43
+ */
44
+ autoTrackRepeats?: number
31
45
  /**
32
46
  * Scoring repeats for the FINAL session acceptance only.
33
47
  *
@@ -50,6 +64,22 @@ export interface Config {
50
64
  autoRouteMaxInputChars?: number
51
65
  autoMaxModelCallsPerTask?: number
52
66
  autoMaxModelCallsPerSession?: number
67
+ /**
68
+ * Persist a bounded snapshot (prompt + raw answer) of every verifier model call.
69
+ *
70
+ * Answers "why did the judge say that?" without replaying a session from its event
71
+ * log; capped per call, per record and per invocation by `decisions.ts`.
72
+ */
73
+ captureDecisions?: boolean
74
+ /**
75
+ * Rubric the automatic gate (final acceptance, routed compare/select/track) scores with.
76
+ *
77
+ * Defaults to `coding` = the historical DEFAULT_CRITERIA, so an existing installation is
78
+ * unaffected. Judging a research or ops task with the coding rubric measures the wrong thing.
79
+ */
80
+ criteriaPreset?: CriteriaPresetSelection
81
+ /** Markdown rubric file, read when `criteriaPreset` is `custom`. See README for the format. */
82
+ criteriaFile?: string
53
83
  autoVerifyTeamTasks?: boolean
54
84
  autoVerifyPlanMode?: boolean
55
85
  autoVerifySubagents?: boolean
@@ -75,6 +105,7 @@ export interface ResolvedConfig {
75
105
  autoVerifyMode: AutoVerifyMode
76
106
  autoVerifyThreshold: number
77
107
  autoVerifyRepeats: number
108
+ autoTrackRepeats: number
78
109
  autoVerifyFinalRepeats: number
79
110
  autoVerifyMinToolCalls: number
80
111
  autoVerifyMaxChars: number
@@ -90,6 +121,9 @@ export interface ResolvedConfig {
90
121
  autoRouteMaxInputChars: number
91
122
  autoMaxModelCallsPerTask: number
92
123
  autoMaxModelCallsPerSession: number
124
+ captureDecisions: boolean
125
+ criteriaPreset: CriteriaPresetSelection
126
+ criteriaFile: string
93
127
  autoVerifyTeamTasks: boolean
94
128
  autoVerifyPlanMode: boolean
95
129
  autoVerifySubagents: boolean
@@ -122,6 +156,7 @@ export const Config: z<Config> = z.object({
122
156
  autoVerifyMode: z.union(['manual', 'smart', 'strict']).default('smart'),
123
157
  autoVerifyThreshold: z.number().min(0).max(1).default(0.65),
124
158
  autoVerifyRepeats: z.number().step(1).min(1).default(1),
159
+ autoTrackRepeats: z.number().step(1).min(1).default(3),
125
160
  autoVerifyFinalRepeats: z.number().step(1).min(1).default(2),
126
161
  autoVerifyMinToolCalls: z.number().step(1).min(1).default(3),
127
162
  autoVerifyMaxChars: z.number().step(1).min(1000).default(80000),
@@ -132,11 +167,14 @@ export const Config: z<Config> = z.object({
132
167
  autoRouteMaxCandidates: z.number().step(1).min(3).default(8),
133
168
  autoRouteMaxPerTask: z.number().step(1).min(1).default(2),
134
169
  autoRouteMaxPerSession: z.number().step(1).min(1).default(8),
135
- autoTrackCompletionThreshold: z.number().min(0).max(1).default(0.8),
170
+ autoTrackCompletionThreshold: z.number().min(0).max(1).default(0.684),
136
171
  autoRouteMaxItemChars: z.number().step(1).min(100).default(20000),
137
172
  autoRouteMaxInputChars: z.number().step(1).min(1000).default(60000),
138
173
  autoMaxModelCallsPerTask: z.number().step(1).min(1).default(96),
139
174
  autoMaxModelCallsPerSession: z.number().step(1).min(1).default(240),
175
+ captureDecisions: z.boolean().default(true),
176
+ criteriaPreset: z.union([...CRITERIA_PRESET_IDS, 'custom'] as const).default('coding'),
177
+ criteriaFile: z.string().default(''),
140
178
  autoVerifyTeamTasks: z.boolean().default(true),
141
179
  autoVerifyPlanMode: z.boolean().default(true),
142
180
  autoVerifySubagents: z.boolean().default(false),
@@ -201,6 +239,7 @@ export function resolveConfig(config: Config = {}): ResolvedConfig {
201
239
  if (!Number.isFinite(temperature) || temperature < 0 || temperature > 2) throw new Error('llm-verifier: temperature must be between 0 and 2')
202
240
  const values = {
203
241
  autoVerifyRepeats: config.autoVerifyRepeats ?? 1,
242
+ autoTrackRepeats: config.autoTrackRepeats ?? 3,
204
243
  autoVerifyFinalRepeats: config.autoVerifyFinalRepeats ?? 2,
205
244
  autoVerifyMinToolCalls: config.autoVerifyMinToolCalls ?? 3,
206
245
  autoVerifyMaxChars: config.autoVerifyMaxChars ?? 80000,
@@ -224,7 +263,7 @@ export function resolveConfig(config: Config = {}): ResolvedConfig {
224
263
  if (values.autoRouteMaxCandidates < 3 || values.autoRouteMaxCandidates > 16) throw new Error('llm-verifier: autoRouteMaxCandidates must be between 3 and 16')
225
264
  if (values.autoRouteMaxInputChars < values.autoRouteMaxItemChars * 2) throw new Error('llm-verifier: autoRouteMaxInputChars must fit at least two route items')
226
265
  const autoRouteMinConfidence = config.autoRouteMinConfidence ?? 0.9
227
- const autoTrackCompletionThreshold = config.autoTrackCompletionThreshold ?? 0.8
266
+ const autoTrackCompletionThreshold = config.autoTrackCompletionThreshold ?? 0.684
228
267
  if (![autoRouteMinConfidence, autoTrackCompletionThreshold].every(value => Number.isFinite(value) && value >= 0 && value <= 1)) throw new Error('llm-verifier: auto route thresholds must be between 0 and 1')
229
268
  const maxRetries = config.maxRetries ?? 3
230
269
  if (!Number.isSafeInteger(maxRetries) || maxRetries < 0) throw new Error('llm-verifier: maxRetries must be a non-negative safe integer')
@@ -235,6 +274,11 @@ export function resolveConfig(config: Config = {}): ResolvedConfig {
235
274
  const estimatedInputUsdPerMillion = config.estimatedInputUsdPerMillion ?? 0
236
275
  const estimatedOutputUsdPerMillion = config.estimatedOutputUsdPerMillion ?? 0
237
276
  if (![estimatedInputUsdPerMillion, estimatedOutputUsdPerMillion].every(value => Number.isFinite(value) && value >= 0)) throw new Error('llm-verifier: estimated token prices must be finite non-negative numbers')
277
+ const criteriaPreset = config.criteriaPreset ?? 'coding'
278
+ if (criteriaPreset !== 'custom' && !CRITERIA_PRESET_IDS.includes(criteriaPreset as CriteriaPresetId)) throw new Error('llm-verifier: criteriaPreset must be one of ' + [...CRITERIA_PRESET_IDS, 'custom'].join(', '))
279
+ // A half-configured custom rubric is NOT a config error: CriteriaResolver falls back to the
280
+ // coding preset and reports why, so a typo in a rubric path cannot disable the gate.
281
+ const criteriaFile = (config.criteriaFile ?? '').trim()
238
282
  const reasoningEffort = config.reasoningEffort?.trim()
239
283
 
240
284
  const extraJudges = config.extraJudges ?? []
@@ -306,6 +350,9 @@ export function resolveConfig(config: Config = {}): ResolvedConfig {
306
350
  autoRouteSemantic: config.autoRouteSemantic ?? true,
307
351
  autoRouteMinConfidence,
308
352
  autoTrackCompletionThreshold,
353
+ captureDecisions: config.captureDecisions ?? true,
354
+ criteriaPreset,
355
+ criteriaFile,
309
356
  autoVerifyTeamTasks: config.autoVerifyTeamTasks ?? true,
310
357
  autoVerifyPlanMode: config.autoVerifyPlanMode ?? true,
311
358
  autoVerifySubagents: config.autoVerifySubagents ?? false,
package/src/core.test.ts CHANGED
@@ -1,17 +1,22 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
+ CRITERIA_PRESETS,
3
4
  DEFAULT_CRITERIA,
5
+ EMPTY_WORK_BASELINE,
4
6
  accumulatePairs,
5
7
  bradleyTerry,
8
+ buildGenerationPrompt,
6
9
  buildPairwisePrompt,
7
10
  buildProgressPrompt,
8
11
  evidenceNonce,
9
12
  extractProgressScore,
10
13
  extractScore,
14
+ parseCriteriaMarkdown,
11
15
  pivotRoundPairs,
12
16
  rankScores,
13
17
  renderDelimitedBlock,
14
18
  ringCycle,
19
+ slugCriterionId,
15
20
  topPivots,
16
21
  } from './core.ts'
17
22
 
@@ -145,3 +150,93 @@ describe('pivot tournament', () => {
145
150
  expect(rankScores(wins, counts)[0]?.index).toBe(0)
146
151
  })
147
152
  })
153
+
154
+ describe('criteria presets', () => {
155
+ it('keeps the default preset byte-identical to the historical default rubric', () => {
156
+ // The default preset must not change a single verdict, prompt or cache key.
157
+ expect(CRITERIA_PRESETS.coding).toEqual(DEFAULT_CRITERIA)
158
+ expect(CRITERIA_PRESETS.coding).toBe(DEFAULT_CRITERIA)
159
+ })
160
+
161
+ it('gives every preset 2-4 narrow, uniquely identified criteria', () => {
162
+ for (const [name, criteria] of Object.entries(CRITERIA_PRESETS)) {
163
+ expect(criteria.length, name).toBeGreaterThanOrEqual(2)
164
+ expect(criteria.length, name).toBeLessThanOrEqual(4)
165
+ expect(new Set(criteria.map(criterion => criterion.id)).size, name).toBe(criteria.length)
166
+ for (const criterion of criteria) {
167
+ expect(criterion.id, name).toMatch(/^[a-z0-9_]+$/)
168
+ expect(criterion.name.trim().length, name).toBeGreaterThan(0)
169
+ expect(criterion.description.trim().length, name).toBeGreaterThan(40)
170
+ }
171
+ }
172
+ })
173
+ })
174
+
175
+ describe('criteria markdown', () => {
176
+ const file = [
177
+ '# Task — Verifier Criteria',
178
+ '<!-- an author note the judge never sees -->',
179
+ '',
180
+ '## Ground Truth Note',
181
+ '',
182
+ 'Do NOT trust the agent self-assessment.',
183
+ '',
184
+ '## Criteria',
185
+ '',
186
+ '### Final Answer Correctness',
187
+ '',
188
+ 'Check the answer against what the task asked for.',
189
+ '',
190
+ '### Empirical Verification {#verification}',
191
+ '',
192
+ 'Look at the commands the agent actually ran.',
193
+ '',
194
+ ].join('\n')
195
+
196
+ it('parses the note, the slugged ids and the pinned anchor', () => {
197
+ const parsed = parseCriteriaMarkdown(file)
198
+ expect(parsed.groundTruthNote).toBe('Do NOT trust the agent self-assessment.')
199
+ expect(parsed.criteria).toEqual([
200
+ { id: 'final_answer_correctness', name: 'Final Answer Correctness', description: 'Check the answer against what the task asked for.' },
201
+ { id: 'verification', name: 'Empirical Verification', description: 'Look at the commands the agent actually ran.' },
202
+ ])
203
+ })
204
+
205
+ it('slugs and de-duplicates colliding ids instead of merging two criteria', () => {
206
+ const parsed = parseCriteriaMarkdown('## Criteria\n\n### Same Name\n\nfirst body\n\n### Same Name\n\nsecond body\n')
207
+ expect(parsed.criteria.map(criterion => criterion.id)).toEqual(['same_name', 'same_name_2'])
208
+ expect(parsed.criteria).toHaveLength(2)
209
+ })
210
+
211
+ it('fails closed on a file with no criteria or with an empty instruction', () => {
212
+ expect(() => parseCriteriaMarkdown('# nothing here\n')).toThrow(/no criteria/u)
213
+ expect(() => parseCriteriaMarkdown('## Criteria\n\n### Empty\n\n### Other\n\nbody\n')).toThrow(/no instruction: empty/u)
214
+ })
215
+
216
+ it('derives ids with the same slug rule the parser uses', () => {
217
+ expect(slugCriterionId('Final Answer Correctness')).toBe('final_answer_correctness')
218
+ expect(slugCriterionId('***')).toBe('criterion')
219
+ expect(slugCriterionId('x'.repeat(80)).length).toBe(40)
220
+ })
221
+
222
+ it('renders a drafting prompt that cannot be mistaken for the judge contract', () => {
223
+ const prompt = buildGenerationPrompt('Fix the parser\nexactly.', 1, 3)
224
+ expect(prompt).toContain('Draft 2 of 3.')
225
+ expect(prompt).toContain('Fix the parser\nexactly.')
226
+ // Here the request IS the instruction to follow and the answer is free-form work product,
227
+ // so it is deliberately NOT wrapped in a data-only block nor given an A-T verdict contract.
228
+ expect(prompt).not.toContain('<<<TASK:')
229
+ expect(prompt).not.toContain('SECURITY')
230
+ expect(prompt).not.toContain('<score_A>')
231
+ // The only text that varies between the N drafts is the final line, so all of them share a
232
+ // maximal prompt prefix (the request itself) and a prefix cache can serve it.
233
+ const other = buildGenerationPrompt('Fix the parser\nexactly.', 2, 3)
234
+ let shared = 0
235
+ while (shared < prompt.length && prompt[shared] === other[shared]) shared += 1
236
+ expect(shared).toBeGreaterThan(prompt.length - 20)
237
+ })
238
+
239
+ it('keeps exactly one definition of the gate baseline', () => {
240
+ expect(EMPTY_WORK_BASELINE).toBe('(No useful work or verification was performed.)')
241
+ })
242
+ })