dsh-llm-verifier 0.2.1 → 0.3.1

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 (45) hide show
  1. package/README.md +15 -11
  2. package/lib/client.js +23 -3
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +389 -115
  5. package/lib/index.js.map +1 -1
  6. package/lib/types/auto.d.ts +32 -12
  7. package/lib/types/auto.d.ts.map +1 -1
  8. package/lib/types/auto.js +50 -38
  9. package/lib/types/auto.js.map +1 -1
  10. package/lib/types/client-i18n.d.ts +21 -1
  11. package/lib/types/client-i18n.d.ts.map +1 -1
  12. package/lib/types/client-i18n.js +22 -4
  13. package/lib/types/client-i18n.js.map +1 -1
  14. package/lib/types/client.d.ts.map +1 -1
  15. package/lib/types/client.js +1 -1
  16. package/lib/types/client.js.map +1 -1
  17. package/lib/types/engine.d.ts +19 -0
  18. package/lib/types/engine.d.ts.map +1 -1
  19. package/lib/types/engine.js +54 -4
  20. package/lib/types/engine.js.map +1 -1
  21. package/lib/types/index.d.ts.map +1 -1
  22. package/lib/types/index.js +32 -43
  23. package/lib/types/index.js.map +1 -1
  24. package/lib/types/router.d.ts +31 -0
  25. package/lib/types/router.d.ts.map +1 -1
  26. package/lib/types/router.js +192 -17
  27. package/lib/types/router.js.map +1 -1
  28. package/lib/types/statistics.d.ts +28 -0
  29. package/lib/types/statistics.d.ts.map +1 -1
  30. package/lib/types/statistics.js +101 -0
  31. package/lib/types/statistics.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/auto.test.ts +42 -23
  34. package/src/auto.ts +55 -46
  35. package/src/client-i18n.ts +32 -4
  36. package/src/client.test.ts +48 -3
  37. package/src/client.tsx +2 -0
  38. package/src/config.test.ts +6 -3
  39. package/src/engine.test.ts +40 -1
  40. package/src/engine.ts +56 -4
  41. package/src/index.ts +34 -44
  42. package/src/router.test.ts +218 -3
  43. package/src/router.ts +182 -14
  44. package/src/statistics.test.ts +116 -0
  45. package/src/statistics.ts +112 -0
package/src/router.ts CHANGED
@@ -98,6 +98,23 @@ function blockText(blocks: readonly ContentBlock[]): string {
98
98
  return parts.join('\n').trim()
99
99
  }
100
100
 
101
+ /**
102
+ * Text of one assistant turn, excluding reasoning blocks.
103
+ *
104
+ * Used only as the newest checkpoint's narration. Prose is not evidence, but for a
105
+ * deliverable that lives in prose (a review, an analysis) it is the only thing that
106
+ * describes the current state at all, so the judge receives it explicitly labelled
107
+ * as a claim instead of being shown nothing about the deliverable.
108
+ * @param blocks - content blocks of an `assistant/message` event.
109
+ * @returns The joined text, trimmed.
110
+ */
111
+ function narrativeText(blocks: readonly ContentBlock[]): string {
112
+ const parts: string[] = []
113
+ const visit = (items: readonly ContentBlock[]) => { for (const block of items) { if (block.type === 'text') parts.push(block.text); else if (block.type === 'tool-result') visit(block.content) } }
114
+ visit(blocks)
115
+ return parts.join('\n').trim()
116
+ }
117
+
101
118
  function successful(event: SessionEvent<'tool/result'>): boolean {
102
119
  return event.data.error === undefined && event.data.message.content.every(block => block.isError !== true)
103
120
  }
@@ -129,6 +146,8 @@ interface EvidenceIndex {
129
146
  calls: Map<string, EvidenceCall>
130
147
  todos: Map<number, TodoItem[]>
131
148
  teamTasks: Map<number, TeamTaskItem[]>
149
+ /** Newest assistant prose in the task; attached to the current checkpoint as a claim, never as evidence. */
150
+ narration?: { seq: number; text: string }
132
151
  }
133
152
 
134
153
  export function buildEvidenceIndex(events: readonly SessionEvent[]): EvidenceIndex | undefined {
@@ -143,6 +162,12 @@ export function buildEvidenceIndex(events: readonly SessionEvent[]): EvidenceInd
143
162
 
144
163
  // Track the rolling view of team tasks across the session
145
164
  const currentTeamTasks = new Map<string, TeamTaskItem>()
165
+ let narration: { seq: number; text: string } | undefined
166
+ // Tool names each wrapper call (a `run_code` program) dispatched, keyed by the
167
+ // wrapper's callId. Session V3 carries rootCallId on the dispatch event, which is the
168
+ // only reliable parent link: the wrapper's own result text is a concatenation of its
169
+ // sub-results, so the tool name of the wrapper says nothing about what it did.
170
+ const dispatchedTools = new Map<string, Set<string>>()
146
171
 
147
172
  for (const rawEvent of relevant) {
148
173
  const event = rawEvent as unknown as { type: string; seq: number; data: any }
@@ -156,8 +181,21 @@ export function buildEvidenceIndex(events: readonly SessionEvent[]): EvidenceInd
156
181
  teamTasks.set(event.seq, [...currentTeamTasks.values()])
157
182
  }
158
183
  }
184
+ else if (event.type === 'assistant/message') {
185
+ // Session V3 carries the blocks under message.content (same shape as tool/result);
186
+ // the bare content fallback keeps older/mock event streams working.
187
+ const blocks = event.data?.message?.content ?? event.data?.content
188
+ const text = narrativeText(Array.isArray(blocks) ? blocks : [])
189
+ if (text) narration = { seq: event.seq, text }
190
+ }
159
191
  else if (event.type === 'tool/ptc-dispatch' || event.type === 'tool/code-dispatch') {
160
- const data = event.data as { subCallId?: string; name: string; isError?: boolean; content?: readonly ContentBlock[] }
192
+ const data = event.data as { rootCallId?: string; subCallId?: string; name: string; isError?: boolean; content?: readonly ContentBlock[] }
193
+ const rootCallId = typeof data.rootCallId === 'string' && data.rootCallId ? data.rootCallId : undefined
194
+ if (rootCallId) {
195
+ const names = dispatchedTools.get(rootCallId) ?? new Set<string>()
196
+ names.add(data.name)
197
+ dispatchedTools.set(rootCallId, names)
198
+ }
161
199
  const isOk = data.isError !== true && (!Array.isArray(data.content) || data.content.every(b => (b as { isError?: boolean }).isError !== true))
162
200
  if (isOk) {
163
201
  const subCallId = String(data.subCallId ?? ('code:' + event.seq))
@@ -169,9 +207,15 @@ export function buildEvidenceIndex(events: readonly SessionEvent[]): EvidenceInd
169
207
 
170
208
  for (const [callId, call] of calls) {
171
209
  const result = results.get(callId)
172
- if (result) paired.set(callId, { name: call.data.name, callSeq: call.seq, resultSeq: result.seq, text: blockText(result.data.message.content) })
210
+ if (!result) continue
211
+ // A wrapper that dispatched nothing but bookkeeping carries only their payloads
212
+ // (typically the todo list echoed straight back), so it is bookkeeping too. Its
213
+ // nested dispatches stay in the index under their own names, so real work done by
214
+ // the same program is still available as evidence.
215
+ if (onlyBookkeepingDispatches(dispatchedTools.get(callId))) continue
216
+ paired.set(callId, { name: call.data.name, callSeq: call.seq, resultSeq: result.seq, text: blockText(result.data.message.content) })
173
217
  }
174
- return { problemSeq: taskStartSeq, calls: paired, todos, teamTasks }
218
+ return { problemSeq: taskStartSeq, calls: paired, todos, teamTasks, narration }
175
219
  }
176
220
 
177
221
  function parseTrustedWorkflow(value: unknown, callId: string, callSeq: number, resultSeq: number, maxCandidates: number, maxItemChars: number, maxInputChars: number): CandidateArtifact[] {
@@ -218,6 +262,35 @@ function canonicalTodoSnapshots(index: EvidenceIndex): Array<{ seq: number; todo
218
262
  return values
219
263
  }
220
264
 
265
+ /**
266
+ * Tools whose successful output only maintains the agent's own bookkeeping.
267
+ *
268
+ * A checkpoint already renders the todo/team snapshot these tools wrote, so their
269
+ * own result repeats it while displacing the real work output that came just before
270
+ * them.
271
+ *
272
+ * `present` belongs here for the same reason: declaring deliverables produces no
273
+ * independent output, and it is always the LAST call of a turn. A live session ran
274
+ * typecheck + the full suite, committed, then presented — and every stop boundary
275
+ * showed the judge "Latest observed tool output at routing time (run_code):
276
+ * presented: 8" instead of the verification run one call earlier. Following its own
277
+ * rule ("a state without real verification should not exceed K"), the judge capped
278
+ * the newest checkpoint at exactly K = 52.6% against a 0.8 threshold on four
279
+ * consecutive routes, so each one steered "continue the unfinished work" for work
280
+ * that was finished and verified. The `deliverables/presented` event still records
281
+ * what was presented; the tool result adds nothing the judge can grade.
282
+ */
283
+ const BOOKKEEPING_TOOLS = new Set([
284
+ 'todo_write', 'create_goal', 'get_goal', 'update_goal', 'interrupt_agent', 'list_agents', 'exit_plan_mode', 'skill', 'present',
285
+ ])
286
+
287
+ /** Whether a wrapper's dispatches were all bookkeeping; undefined/empty means it dispatched nothing. */
288
+ function onlyBookkeepingDispatches(names: ReadonlySet<string> | undefined): boolean {
289
+ if (names === undefined || names.size === 0) return false
290
+ for (const name of names) if (!BOOKKEEPING_TOOLS.has(name)) return false
291
+ return true
292
+ }
293
+
221
294
  /**
222
295
  * Observed tool evidence available at one checkpoint.
223
296
  *
@@ -225,22 +298,32 @@ function canonicalTodoSnapshots(index: EvidenceIndex): Array<{ seq: number; todo
225
298
  * threshold: the judge prompt explicitly refuses to credit a state that carries
226
299
  * no observed output. The most recent successful tool result at or before the
227
300
  * checkpoint is therefore attached as evidence.
301
+ *
302
+ * Bookkeeping tools are skipped: the checkpoint already renders the todo/team
303
+ * snapshot they wrote, so their own result repeats it while displacing the real
304
+ * output produced just before them (a live session attached `create_goal`,
305
+ * `update_goal` and `interrupt_agent` output to checkpoints that had already run
306
+ * the task's test suite).
228
307
  * @param index - evidence index of the current task.
229
- * @param seq - checkpoint sequence number.
308
+ * @param seq - checkpoint sequence number, or `Infinity` for the current state.
230
309
  * @param budget - maximum characters the evidence may occupy.
310
+ * @param current - render the newest output in the task instead of the newest one before `seq`.
231
311
  * @returns Evidence block, or '' when the task produced none yet.
232
312
  */
233
- function checkpointEvidence(index: EvidenceIndex, seq: number, budget: number): string {
313
+ function checkpointEvidence(index: EvidenceIndex, seq: number, budget: number, current = false): string {
234
314
  if (budget < 64) return ''
235
315
  let latest: EvidenceCall | undefined
236
316
  for (const pair of index.calls.values()) {
317
+ if (BOOKKEEPING_TOOLS.has(pair.name) || !pair.text.trim()) continue
237
318
  if (pair.resultSeq <= seq && (latest === undefined || pair.resultSeq > latest.resultSeq)) latest = pair
238
319
  }
239
320
  if (latest === undefined) return ''
240
321
  // The prefix length depends on the tool name, so measure it instead of assuming a
241
322
  // fixed overhead: with a long tool name the old "- 60" let the rendered step exceed
242
323
  // maxItemChars, and boundDecision() then dropped the whole track decision silently.
243
- const prefix = '\n\nLatest observed tool output before this checkpoint (' + latest.name + '):\n'
324
+ const prefix = current
325
+ ? '\n\nLatest observed tool output at routing time (' + latest.name + '):\n'
326
+ : '\n\nLatest observed tool output before this checkpoint (' + latest.name + '):\n'
244
327
  if (prefix.length >= budget) return ''
245
328
  return prefix + sanitizeVerifierText(latest.text, budget - prefix.length)
246
329
  }
@@ -280,6 +363,12 @@ interface CheckpointSource { seq: number; label: string; body: string }
280
363
  * The result fits both caps by construction: each step is at most min(maxItemChars,
281
364
  * maxInputChars / kept.length) characters, so the combined length can never exceed
282
365
  * maxInputChars and boundDecision() no longer drops the whole route.
366
+ *
367
+ * The newest checkpoint is also the state the route is judging, so it is rendered as
368
+ * the CURRENT state: its evidence is the newest observed output in the task rather
369
+ * than the newest one before the last todo snapshot (which is often several tool calls
370
+ * stale), and it carries the agent's latest prose as an explicitly labelled claim (see
371
+ * {@link currentNarration}), because prose deliverables never reach a tool.
283
372
  * @param index - evidence index of the current task.
284
373
  * @param sources - checkpoints in chronological order.
285
374
  * @param maxItemChars - hard per-item cap enforced by boundDecision().
@@ -294,12 +383,40 @@ function renderCheckpointSteps(index: EvidenceIndex, sources: readonly Checkpoin
294
383
  const evidenceBudget = Math.max(0, Math.min(Math.floor(maxInputChars / 2), Math.floor(maxItemChars / 2), Math.floor(stepCap / 2)))
295
384
  const steps = kept.map((source, position) => {
296
385
  const note = position === 0 && omitted > 0 ? 'Earlier ' + omitted + ' checkpoint(s) omitted; showing the ' + kept.length + ' most recent.\n' : ''
297
- const evidence = checkpointEvidence(index, source.seq, evidenceBudget)
298
- return sanitizeVerifierText(note + source.label + source.body, Math.max(1, stepCap - evidence.length)) + evidence
386
+ // Only the newest checkpoint describes the state the route is judging, so only it
387
+ // carries the agent's own latest narration and the newest observed output. Half of
388
+ // the evidence budget is held back for the narration: it is much longer than a tool
389
+ // result and used to be the deliverable for tasks (reviews, analyses) whose output
390
+ // never reaches a tool.
391
+ const isCurrent = position === kept.length - 1
392
+ const observed = checkpointEvidence(index, isCurrent ? Number.POSITIVE_INFINITY : source.seq, isCurrent ? Math.floor(evidenceBudget / 2) : evidenceBudget, isCurrent)
393
+ const narration = isCurrent ? currentNarration(index, evidenceBudget - observed.length) : ''
394
+ return sanitizeVerifierText(note + source.label + source.body, Math.max(1, stepCap - observed.length - narration.length)) + observed + narration
299
395
  })
300
396
  return { steps, evidenceSeqs: kept.map(source => source.seq), omitted }
301
397
  }
302
398
 
399
+ /**
400
+ * The agent's newest unverified prose, attached to the current checkpoint.
401
+ *
402
+ * Progress checkpoints are graded against "would this state satisfy the task", and
403
+ * for work whose deliverable is prose the todo snapshot plus one tool result say
404
+ * nothing about it: every checkpoint then scores "certainly NO" even though the
405
+ * deliverable exists (a live review task scored 0% on all four checkpoints while the
406
+ * final session acceptance of the same work passed). The block is labelled as a claim
407
+ * so the judge can weigh it without treating it as observed output.
408
+ * @param index - evidence index of the current task.
409
+ * @param budget - maximum characters the narration may occupy.
410
+ * @returns Narration block, or '' when the task produced no prose or has no budget.
411
+ */
412
+ function currentNarration(index: EvidenceIndex, budget: number): string {
413
+ const narration = index.narration
414
+ if (narration === undefined || budget < 64) return ''
415
+ const prefix = "\n\nNewest agent narration in this task (the agent's own claim — NOT observed evidence):\n"
416
+ if (prefix.length >= budget) return ''
417
+ return prefix + sanitizeVerifierText(narration.text, budget - prefix.length)
418
+ }
419
+
303
420
  function canonicalTeamTaskSnapshots(index: EvidenceIndex): Array<{ seq: number; tasks: TeamTaskItem[] }> {
304
421
  const values: Array<{ seq: number; tasks: TeamTaskItem[] }> = []
305
422
  let previous = ''
@@ -329,23 +446,46 @@ export function analyzeStructuredRoute(events: readonly SessionEvent[], maxCandi
329
446
  const snapshots = canonicalTodoSnapshots(index)
330
447
  if (snapshots.length >= 2 && snapshots.some(snapshot => snapshot.todos.length >= 2)) {
331
448
  const rendered = renderCheckpointSteps(index, snapshots.map(snapshot => ({ seq: snapshot.seq, label: 'Todo checkpoint seq ' + snapshot.seq + ':\n', body: snapshot.todos.map(todo => '- [' + todo.status + '] ' + todo.content).join('\n') })), maxItemChars, maxInputChars)
332
- return { kind: 'track', source: 'structured', confidence: 1, reason: 'changed durable todo snapshots', fingerprint: stableHash({ kind: 'track', snapshots }), steps: rendered.steps, checkpoints: rendered.steps.map((_, i) => i + 1), evidenceSeqs: rendered.evidenceSeqs }
449
+ // The fingerprint covers the RENDERED steps, not just the snapshots: the evidence
450
+ // attached to a checkpoint (and the newest narration) changes with the work that
451
+ // followed it, so a route whose prompt would differ must not be refused as
452
+ // "already routed". The semantic track route hashes its steps for the same reason.
453
+ return { kind: 'track', source: 'structured', confidence: 1, reason: 'changed durable todo snapshots', fingerprint: stableHash({ kind: 'track', snapshots, steps: rendered.steps }), steps: rendered.steps, checkpoints: rendered.steps.map((_, i) => i + 1), evidenceSeqs: rendered.evidenceSeqs }
333
454
  }
334
455
  const teamSnapshots = canonicalTeamTaskSnapshots(index)
335
456
  if (teamSnapshots.length >= 2) {
336
457
  const rendered = renderCheckpointSteps(index, teamSnapshots.map(snapshot => ({ seq: snapshot.seq, label: 'Team task checkpoint seq ' + snapshot.seq + ':\n', body: snapshot.tasks.map(task => '- [' + task.status + '] ' + task.subject + (task.description ? ' (' + task.description + ')' : '')).join('\n') })), maxItemChars, maxInputChars)
337
- return { kind: 'track', source: 'structured', confidence: 1, reason: 'changed durable team tasks', fingerprint: stableHash({ kind: 'track', teamSnapshots }), steps: rendered.steps, checkpoints: rendered.steps.map((_, i) => i + 1), evidenceSeqs: rendered.evidenceSeqs }
458
+ return { kind: 'track', source: 'structured', confidence: 1, reason: 'changed durable team tasks', fingerprint: stableHash({ kind: 'track', teamSnapshots, steps: rendered.steps }), steps: rendered.steps, checkpoints: rendered.steps.map((_, i) => i + 1), evidenceSeqs: rendered.evidenceSeqs }
338
459
  }
339
460
  }
340
461
  return undefined
341
462
  }
342
463
 
464
+ /**
465
+ * Artifacts that only the classifier can turn into candidates.
466
+ *
467
+ * Todo and team snapshots are deliberately absent. They are the structured track
468
+ * route's own input, and that route runs first — so listing them here could only make
469
+ * the hint true in shapes the structured pass already claimed (or in a task that
470
+ * already ran an explicit `verifier_track`, where re-classifying the same snapshots is
471
+ * not wanted). The one shape left out is a snapshot series whose lists are all shorter
472
+ * than two items, which is not worth a classification call.
473
+ */
474
+ const HINT_ARTIFACTS = new Set(['subagent', 'subagent_fork', 'workflow', 'exit_plan_mode'])
475
+
476
+ /**
477
+ * Whether a smart-mode stop boundary is worth a semantic classification call.
478
+ *
479
+ * The semantic phase only runs when the structured pass produced nothing, so this
480
+ * answers "is there material the structured pass never consumes?" — never "are there
481
+ * todo/team snapshots?", which the structured pass would have used already.
482
+ * @param events - Session event log.
483
+ * @returns True when a subagent/workflow/plan artifact exists.
484
+ */
343
485
  export function semanticRouteHint(events: readonly SessionEvent[]): boolean {
344
486
  const index = buildEvidenceIndex(events)
345
487
  if (!index) return false
346
- if ([...index.calls.values()].some(pair => pair.name === 'subagent' || pair.name === 'subagent_fork' || pair.name === 'workflow' || pair.name === 'exit_plan_mode')) return true
347
- if (canonicalTodoSnapshots(index).length >= 2) return true
348
- if (canonicalTeamTaskSnapshots(index).length >= 2) return true
488
+ for (const pair of index.calls.values()) if (HINT_ARTIFACTS.has(pair.name)) return true
349
489
  return false
350
490
  }
351
491
 
@@ -359,6 +499,9 @@ export function buildSemanticRoutePrompt(problem: string, events: readonly Sessi
359
499
  let used = 0
360
500
  let omitted = 0
361
501
  for (const [callId, pair] of [...index.calls.entries()].reverse()) {
502
+ // Bookkeeping results are not alternatives to anything; offering them let the
503
+ // classifier cite e.g. two goal/agent-control calls as competing candidates.
504
+ if (BOOKKEEPING_TOOLS.has(pair.name)) continue
362
505
  const text = sanitizeVerifierText(pair.text, maxItemChars)
363
506
  if (artifacts.length > 0 && used + text.length > maxInputChars) { omitted += 1; continue }
364
507
  used += text.length
@@ -416,7 +559,11 @@ export function semanticDecision(output: SemanticRouteOutput, events: readonly S
416
559
  const perItem = itemBudget(output.candidateCallIds.length, maxItemChars, maxInputChars)
417
560
  const candidates = output.candidateCallIds.map((callId, i) => {
418
561
  const pair = index.calls.get(callId)
419
- return pair ? { id: callId, groupId: 'semantic', label: pair.name + ' ' + (i + 1), content: sanitizeVerifierText(pair.text, perItem), callId, fromSeq: pair.callSeq, toSeq: pair.resultSeq } : undefined
562
+ // Bookkeeping calls are not candidates (they were never offered to the classifier);
563
+ // a citation of one is an invalid reference, so the whole decision is rejected
564
+ // instead of comparing metadata as if it were alternative work.
565
+ if (!pair || BOOKKEEPING_TOOLS.has(pair.name)) return undefined
566
+ return { id: callId, groupId: 'semantic', label: pair.name + ' ' + (i + 1), content: sanitizeVerifierText(pair.text, perItem), callId, fromSeq: pair.callSeq, toSeq: pair.resultSeq }
420
567
  }).filter((candidate): candidate is CandidateArtifact => candidate !== undefined)
421
568
  if (candidates.length !== output.candidateCallIds.length) return undefined
422
569
  const fingerprint = stableHash({ kind: output.kind, candidates })
@@ -435,6 +582,27 @@ export function semanticDecision(output: SemanticRouteOutput, events: readonly S
435
582
  * @param criteriaCount - number of criteria evaluated per comparison.
436
583
  * @returns The planned model-call count, never below 1.
437
584
  */
585
+ /**
586
+ * Scoring repeats a routed decision actually runs.
587
+ *
588
+ * `compare` judges ONE unordered pair, and `VerifierEngine.compare` only swaps the
589
+ * candidates on odd repeat indices — with the shipped default of a single round the
590
+ * first candidate therefore always sat in slot A, so the winner was partly decided by
591
+ * listing order. Rounding its count up to an even number averages a swapped round and
592
+ * cancels that.
593
+ *
594
+ * `select` does not need it: its ring is symmetric by construction and the pivot round
595
+ * is oriented per pair by the engine, so one round is already unbiased. `track` scores
596
+ * a single checkpoint list and has no slots at all.
597
+ * @param decision - the routed decision about to run.
598
+ * @param configured - the configured auto-route repeat count.
599
+ * @returns The repeat count to pass to the engine.
600
+ */
601
+ export function routedRepeats(decision: RouteDecision, configured: number): number {
602
+ if (decision.kind !== 'compare') return configured
603
+ return configured % 2 === 0 ? configured : configured + 1
604
+ }
605
+
438
606
  export function estimateRoutedCalls(decision: RouteDecision, repeats: number, criteriaCount: number): number {
439
607
  if (decision.kind === 'compare') return Math.max(1, criteriaCount * repeats)
440
608
  if (decision.kind === 'track') return Math.max(1, repeats)
@@ -7,6 +7,8 @@ import {
7
7
  emptyRunStats,
8
8
  mergeStatisticsOverviews,
9
9
  parseStatisticsQuery,
10
+ summarizeVerdict,
11
+ type VerdictThresholds,
10
12
  } from './statistics.ts'
11
13
 
12
14
  const readTracker = {
@@ -188,6 +190,120 @@ describe('StatisticsStore', () => {
188
190
  })
189
191
  })
190
192
 
193
+ it('summarizes every tool verdict from its own result shape', () => {
194
+ const thresholds: VerdictThresholds = { autoVerifyThreshold: 0.65, autoTrackCompletionThreshold: 0.8 }
195
+ // Route classification reports no score at all.
196
+ expect(summarizeVerdict('verifier_route_classify', { kind: 'none' }, 'semantic', thresholds)).toEqual({ phase: 'semantic', outcome: 'classified' })
197
+ // Select reports the ranked winner's score.
198
+ expect(summarizeVerdict('verifier_select', { index: 1, scores: [0.2, 0.9], ranking: [1, 0] }, 'select', thresholds)).toEqual({ phase: 'select', outcome: 'ranked', score: 0.9 })
199
+ // Track reports the newest checkpoint (not the historical minimum) plus the curve.
200
+ expect(summarizeVerdict('verifier_track', { scores: [0, 0.10526315789473684, 0.7894736842105262] }, 'track', thresholds)).toEqual({
201
+ phase: 'track',
202
+ outcome: 'below-threshold',
203
+ score: 0.7894736842105262,
204
+ scores: [0, 0.10526315789473684, 0.7894736842105262],
205
+ threshold: 0.8,
206
+ })
207
+ expect(summarizeVerdict('verifier_track', { scores: [0.9] }, 'track', thresholds)).toEqual({ phase: 'track', outcome: 'passed', score: 0.9, threshold: 0.8 })
208
+ })
209
+
210
+ it('reports the winning side of a comparison instead of the loser under a threshold', () => {
211
+ const thresholds: VerdictThresholds = { autoVerifyThreshold: 0.65, autoTrackCompletionThreshold: 0.8 }
212
+ // Regression: a comparison has no threshold, so "below-threshold" used to describe
213
+ // candidate A's (the loser's) score as a failure of the comparison itself.
214
+ expect(summarizeVerdict('verifier_compare', { scoreA: 0.2, scoreB: 0.9, winner: 'B' }, 'compare', thresholds)).toEqual({ phase: 'compare', outcome: 'compared', score: 0.9, scoreB: 0.9, winner: 'B' })
215
+ expect(summarizeVerdict('verifier_compare', { scoreA: 0.9, scoreB: 0.2, winner: 'A' }, 'compare', thresholds)).toEqual({ phase: 'compare', outcome: 'compared', score: 0.9, scoreB: 0.2, winner: 'A' })
216
+ expect(summarizeVerdict('verifier_compare', { scoreA: 0.5, scoreB: 0.5, winner: 'tie' }, 'compare', thresholds)).toEqual({ phase: 'compare', outcome: 'tie', score: 0.5, scoreB: 0.5, winner: 'tie' })
217
+ // Session acceptance keeps A = the session and B = the empty-work baseline.
218
+ expect(summarizeVerdict('verifier_current_session', { score: 0.4, baselineScore: 0, winner: 'A' }, 'final', thresholds)).toEqual({
219
+ phase: 'final', outcome: 'below-threshold', score: 0.4, baselineScore: 0, winner: 'A', threshold: 0.65,
220
+ })
221
+ expect(summarizeVerdict('verifier_current_session', { score: 0.7, baselineScore: 0, winner: 'A' }, 'final', thresholds)).toEqual({
222
+ phase: 'final', outcome: 'passed', score: 0.7, baselineScore: 0, winner: 'A', threshold: 0.65,
223
+ })
224
+ // Losing to the baseline can never pass, however high the raw score is.
225
+ expect(summarizeVerdict('verifier_current_session', { score: 0.9, baselineScore: 0.95, winner: 'B' }, 'final', thresholds)).toMatchObject({ outcome: 'below-threshold' })
226
+ // One criterion below the threshold fails the record even though the mean passes.
227
+ expect(summarizeVerdict('verifier_current_session', { score: 0.6667, baselineScore: 0, winner: 'A', criteria: [{ id: 'specification', scoreA: 1 }, { id: 'error_signals', scoreA: 0 }] }, 'final', thresholds)).toEqual({
228
+ phase: 'final',
229
+ outcome: 'below-threshold',
230
+ score: 0.6667,
231
+ baselineScore: 0,
232
+ criteria: [{ id: 'specification', score: 1 }, { id: 'error_signals', score: 0 }],
233
+ winner: 'A',
234
+ threshold: 0.65,
235
+ })
236
+ })
237
+
238
+ it('keeps the track checkpoint progression and drops invalid entries', async () => {
239
+ const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-statistics-'))
240
+ const file = join(root, 'statistics.json')
241
+ const store = new StatisticsStore(file)
242
+ const record = await store.record({
243
+ toolName: 'verifier_track',
244
+ startedAt: 100,
245
+ finishedAt: 200,
246
+ success: true,
247
+ provider: 'p',
248
+ model: 'm',
249
+ stats: stats(),
250
+ verdict: {
251
+ phase: 'track',
252
+ outcome: 'below-threshold',
253
+ // The newest checkpoint drives the outcome; the whole curve explains it.
254
+ score: 0.7894736842105262,
255
+ scores: [0, Number.NaN, Number.POSITIVE_INFINITY, 0.7894736842105262],
256
+ threshold: 0.8,
257
+ },
258
+ })
259
+ expect(record.verdict?.score).toBe(0.7894736842105262)
260
+ expect(record.verdict?.scores).toEqual([0, 0.7894736842105262])
261
+
262
+ const fresh = new StatisticsStore(file)
263
+ const result = await fresh.overview({ fromMs: 0, toMs: 1_000 })
264
+ expect(result.recent[0]?.verdict?.scores).toEqual([0, 0.7894736842105262])
265
+ })
266
+
267
+ it('caps a stored checkpoint progression at the boundary', async () => {
268
+ const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-statistics-'))
269
+ const store = new StatisticsStore(join(root, 'statistics.json'))
270
+ const scores = Array.from({ length: 70 }, (_, index) => index / 100)
271
+ const record = await store.record({
272
+ toolName: 'verifier_track', startedAt: 100, finishedAt: 200, success: true, provider: 'p', model: 'm', stats: stats(),
273
+ verdict: { phase: 'track', outcome: 'below-threshold', score: 0.69, scores, threshold: 0.8 },
274
+ })
275
+ expect(record.verdict?.scores).toHaveLength(64)
276
+ expect(record.verdict?.scores?.at(-1)).toBe(0.63)
277
+ })
278
+
279
+ it('stores the per-criterion breakdown and drops malformed entries', async () => {
280
+ const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-statistics-'))
281
+ const store = new StatisticsStore(join(root, 'statistics.json'))
282
+ const record = await store.record({
283
+ toolName: 'verifier_current_session', startedAt: 1, finishedAt: 2, success: true, provider: 'p', model: 'm', stats: stats(),
284
+ verdict: {
285
+ phase: 'final', outcome: 'below-threshold', score: 0.67, baselineScore: 0, winner: 'A', threshold: 0.65,
286
+ criteria: [{ id: 'specification', score: 1 }, { id: 42, score: 1 } as never, { id: 'error_signals', score: Number.NaN }, { id: 'output_match', score: 0 }],
287
+ },
288
+ })
289
+ expect(record.verdict?.criteria).toEqual([{ id: 'specification', score: 1 }, { id: 'output_match', score: 0 }])
290
+ })
291
+
292
+ it('stores scoreB for comparisons and drops a non-finite one', async () => {
293
+ const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-statistics-'))
294
+ const store = new StatisticsStore(join(root, 'statistics.json'))
295
+ const dropped = await store.record({
296
+ toolName: 'verifier_compare', startedAt: 1, finishedAt: 2, success: true, provider: 'p', model: 'm', stats: stats(),
297
+ verdict: { phase: 'compare', outcome: 'compared', score: 0.9, scoreB: Number.NaN, winner: 'A' },
298
+ })
299
+ expect(dropped.verdict?.scoreB).toBeUndefined()
300
+ const kept = await store.record({
301
+ toolName: 'verifier_compare', startedAt: 3, finishedAt: 4, success: true, provider: 'p', model: 'm', stats: stats(),
302
+ verdict: { phase: 'compare', outcome: 'compared', score: 0.9, scoreB: 0.15789473684210525, winner: 'A' },
303
+ })
304
+ expect(kept.verdict?.scoreB).toBe(0.15789473684210525)
305
+ })
306
+
191
307
  it('loads old records that have no verdict', async () => {
192
308
  const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-statistics-'))
193
309
  const file = join(root, 'statistics.json')
package/src/statistics.ts CHANGED
@@ -10,11 +10,111 @@ export interface VerdictSummary {
10
10
  phase?: string
11
11
  outcome?: string
12
12
  score?: number
13
+ /** Per-checkpoint progression of a `verifier_track` verdict, oldest first. Optional: old records do not carry it. */
14
+ scores?: number[]
15
+ /** Candidate B's score of a two-way comparison; `score` is the winning side. */
16
+ scoreB?: number
17
+ /** Per-criterion A-side scores of a session acceptance; the mean alone can hide a failed requirement. */
18
+ criteria?: Array<{ id: string; score: number }>
13
19
  baselineScore?: number
14
20
  winner?: 'A' | 'B' | 'tie'
15
21
  threshold?: number
16
22
  }
17
23
 
24
+ /** Upper bound on the stored checkpoint progression; one explicit call can legitimately carry 32 steps. */
25
+ const MAX_VERDICT_SCORES = 64
26
+
27
+ /** Upper bound on the stored per-criterion breakdown; the default rubric has three criteria. */
28
+ const MAX_VERDICT_CRITERIA = 16
29
+
30
+ /** Thresholds the verdict summary needs; plain values keep the mapping a pure function. */
31
+ export interface VerdictThresholds {
32
+ autoVerifyThreshold: number
33
+ autoTrackCompletionThreshold: number
34
+ }
35
+
36
+ /**
37
+ * Compact summary of what the judges decided, stored beside the call counters so
38
+ * the dashboard can answer "why did this fail?" instead of only "how much did it cost".
39
+ *
40
+ * Pure on purpose: this mapping used to live inside the plugin closure, where the two
41
+ * bugs it carried (a track verdict reported as the historical minimum, a comparison
42
+ * reported as the loser's score under a threshold it never used) could not be tested.
43
+ * @param toolName - the verifier tool that produced the value.
44
+ * @param value - its rendered result.
45
+ * @param phase - which stage produced it (explicit | compare | select | track | final | ...).
46
+ * @param thresholds - resolved acceptance thresholds.
47
+ * @returns A verdict summary; score fields are omitted when the tool has none.
48
+ */
49
+ export function summarizeVerdict(toolName: VerifierToolName, value: unknown, phase: string, thresholds: VerdictThresholds): VerdictSummary {
50
+ const row = typeof value === 'object' && value !== null ? value as Record<string, unknown> : {}
51
+ const numberAt = (key: string): number | undefined => (typeof row[key] === 'number' && Number.isFinite(row[key]) ? row[key] as number : undefined)
52
+ const scores = Array.isArray(row.scores) ? row.scores.filter((entry): entry is number => typeof entry === 'number' && Number.isFinite(entry)) : []
53
+ const winner = row.winner === 'A' || row.winner === 'B' || row.winner === 'tie' ? row.winner : undefined
54
+ if (toolName === 'verifier_route_classify') return { phase, outcome: 'classified' }
55
+ if (toolName === 'verifier_select') {
56
+ const index = numberAt('index')
57
+ const best = index === undefined ? undefined : scores[index]
58
+ return { phase, outcome: 'ranked', ...(best !== undefined ? { score: best } : {}) }
59
+ }
60
+ if (toolName === 'verifier_track') {
61
+ // The verdict reports the newest checkpoint (the one the continuation decision
62
+ // uses) plus the whole progression. Reporting only Math.min() made every routed
63
+ // track look like a 0% failure on the dashboard, because the first checkpoint of
64
+ // a task is always the untouched plan.
65
+ const latest = scores.length > 0 ? scores[scores.length - 1] : undefined
66
+ const threshold = thresholds.autoTrackCompletionThreshold
67
+ return {
68
+ phase,
69
+ outcome: latest !== undefined && latest >= threshold ? 'passed' : 'below-threshold',
70
+ ...(latest !== undefined ? { score: latest } : {}),
71
+ ...(scores.length > 1 ? { scores: [...scores] } : {}),
72
+ threshold,
73
+ }
74
+ }
75
+ if (toolName === 'verifier_compare') {
76
+ // A comparison has no threshold, so "below-threshold" used to describe the loser's
77
+ // score as a failure of the call itself. Report the winning side and keep both.
78
+ const scoreA = numberAt('score') ?? numberAt('scoreA')
79
+ const scoreB = numberAt('scoreB')
80
+ const score = winner === 'B' ? scoreB : scoreA
81
+ return {
82
+ phase,
83
+ outcome: winner === 'tie' ? 'tie' : 'compared',
84
+ ...(score !== undefined ? { score } : {}),
85
+ ...(scoreB !== undefined ? { scoreB } : {}),
86
+ ...(winner !== undefined ? { winner } : {}),
87
+ }
88
+ }
89
+ // Session acceptance: `score` is the session's own score and `baselineScore` the
90
+ // empty-work baseline it has to beat.
91
+ const score = numberAt('score') ?? numberAt('scoreA')
92
+ const baselineScore = numberAt('baselineScore')
93
+ const threshold = thresholds.autoVerifyThreshold
94
+ const criteria = Array.isArray(row.criteria)
95
+ ? (row.criteria as unknown[])
96
+ .map(entry => (typeof entry === 'object' && entry !== null ? entry as Record<string, unknown> : {}))
97
+ .filter(entry => typeof entry.id === 'string' && typeof entry.scoreA === 'number' && Number.isFinite(entry.scoreA))
98
+ .slice(0, MAX_VERDICT_CRITERIA)
99
+ .map(entry => ({ id: entry.id as string, score: entry.scoreA as number }))
100
+ : []
101
+ // A session does not pass on the mean alone: one criterion below the threshold fails
102
+ // the record too, which is exactly what the live gate now enforces.
103
+ const belowThreshold = criteria.filter(entry => !(entry.score >= threshold))
104
+ const outcome = winner === 'tie'
105
+ ? 'tie'
106
+ : winner === 'A' && score !== undefined && score >= threshold && belowThreshold.length === 0 ? 'passed' : 'below-threshold'
107
+ return {
108
+ phase,
109
+ outcome,
110
+ ...(score !== undefined ? { score } : {}),
111
+ ...(baselineScore !== undefined ? { baselineScore } : {}),
112
+ ...(criteria.length > 0 ? { criteria } : {}),
113
+ ...(winner !== undefined ? { winner } : {}),
114
+ threshold,
115
+ }
116
+ }
117
+
18
118
  export interface InvocationRecord {
19
119
  id: string
20
120
  toolName: VerifierToolName
@@ -132,6 +232,18 @@ function cleanVerdict(input: VerdictSummary | undefined): VerdictSummary | undef
132
232
  if (typeof input.phase === 'string') verdict.phase = input.phase
133
233
  if (typeof input.outcome === 'string') verdict.outcome = input.outcome
134
234
  if (typeof input.score === 'number' && Number.isFinite(input.score)) verdict.score = input.score
235
+ if (Array.isArray(input.scores)) {
236
+ const scores = input.scores.filter((entry): entry is number => typeof entry === 'number' && Number.isFinite(entry)).slice(0, MAX_VERDICT_SCORES)
237
+ if (scores.length > 0) verdict.scores = scores
238
+ }
239
+ if (typeof input.scoreB === 'number' && Number.isFinite(input.scoreB)) verdict.scoreB = input.scoreB
240
+ if (Array.isArray(input.criteria)) {
241
+ const criteria = input.criteria
242
+ .filter((entry): entry is { id: string; score: number } => typeof entry === 'object' && entry !== null && typeof (entry as { id?: unknown }).id === 'string' && typeof (entry as { score?: unknown }).score === 'number' && Number.isFinite((entry as { score: number }).score))
243
+ .slice(0, MAX_VERDICT_CRITERIA)
244
+ .map(entry => ({ id: entry.id, score: entry.score }))
245
+ if (criteria.length > 0) verdict.criteria = criteria
246
+ }
135
247
  if (typeof input.baselineScore === 'number' && Number.isFinite(input.baselineScore)) verdict.baselineScore = input.baselineScore
136
248
  if (input.winner === 'A' || input.winner === 'B' || input.winner === 'tie') verdict.winner = input.winner
137
249
  if (typeof input.threshold === 'number' && Number.isFinite(input.threshold)) verdict.threshold = input.threshold