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
@@ -4,7 +4,8 @@ import { join } from 'node:path'
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest'
5
5
  import type { VerifierClientConfig } from './caller.ts'
6
6
  import { ScoreCache, SingleFlight, type CachedPairScore } from './cache.ts'
7
- import { VerifierEngine } from './engine.ts'
7
+ import { VerifierEngine, orientRoundPairs } from './engine.ts'
8
+ import { pivotRoundPairs } from './core.ts'
8
9
  import { TopLogprobCapabilityCache } from './top-logprobs.ts'
9
10
 
10
11
  function chunks(text: string) { return [{ type: 'block-start', index: 0, blockType: 'text' }, { type: 'text-delta', index: 0, text }, { type: 'block-end', index: 0, block: { type: 'text', text } }, { type: 'usage', usage: { inputTokens: 7, cacheReadTokens: 3, outputTokens: 4, reasoningTokens: 2 } }, { type: 'finish', reason: { kind: 'stop' } }] as any[] }
@@ -72,6 +73,44 @@ describe('VerifierEngine tournament', () => {
72
73
  })
73
74
  })
74
75
 
76
+ describe('pivot round orientation', () => {
77
+ it('gives every ring leader both slots instead of always trajectory B', () => {
78
+ // Regression: the pivot round emitted [candidate, pivot] for every edge, so the ring
79
+ // leaders sat in trajectory B in ALL their extra matches — a judge that merely
80
+ // prefers slot A then averaged 0.36 for the pivots against 0.60 for everyone else
81
+ // and pushed the leaders to the bottom of the final ranking.
82
+ const key = ([a, b]: readonly [number, number]) => (a < b ? a + ',' + b : b + ',' + a)
83
+ for (const count of [4, 5, 6, 8]) {
84
+ const pivots = [0, 1]
85
+ const raw = pivotRoundPairs(count, pivots)
86
+ const oriented = orientRoundPairs(raw)
87
+ // Same unordered pairs — only the presentation order may change.
88
+ expect(oriented).toHaveLength(raw.length)
89
+ expect(oriented.map(key).sort()).toEqual(raw.map(key).sort())
90
+ expect(new Set(oriented.map(key)).size).toBe(oriented.length)
91
+ const slotA = new Map<number, number>()
92
+ const slotB = new Map<number, number>()
93
+ for (const [a, b] of oriented) {
94
+ slotA.set(a, (slotA.get(a) ?? 0) + 1)
95
+ slotB.set(b, (slotB.get(b) ?? 0) + 1)
96
+ }
97
+ for (const pivot of pivots) {
98
+ const a = slotA.get(pivot) ?? 0
99
+ const b = slotB.get(pivot) ?? 0
100
+ expect(a, 'count=' + count + ' pivot=' + pivot + ' must not always sit in slot B').toBeGreaterThan(0)
101
+ expect(Math.abs(a - b), 'count=' + count + ' pivot=' + pivot + ' slot balance').toBeLessThanOrEqual(1)
102
+ }
103
+ }
104
+ })
105
+
106
+ it('leaves a two-candidate select with exactly one match', () => {
107
+ // The orientation must not invent or duplicate matches.
108
+ const oriented = orientRoundPairs([[0, 1]])
109
+ expect(oriented).toHaveLength(1)
110
+ expect([oriented[0]![0], oriented[0]![1]].sort()).toEqual([0, 1])
111
+ })
112
+ })
113
+
75
114
  describe('VerifierEngine cache identity', () => {
76
115
  it('does not reuse an explicit-tag cache entry for top-logprobs scoring', async () => {
77
116
  const dir = mkdtempSync(join(tmpdir(), 'dsh-verifier-engine-'))
package/src/engine.ts CHANGED
@@ -60,6 +60,56 @@ function median(values: readonly number[]): number {
60
60
  function blankStats(): RunStats { return { ...emptyUsage(), cacheHits: 0, cacheMisses: 0, estimatedCostUsd: 0, topLogprobScores: 0, explicitTagScores: 0 } }
61
61
  function unorderedPair(a: number, b: number): string { return a < b ? a + ',' + b : b + ',' + a }
62
62
 
63
+ /**
64
+ * Deterministic A/B slot for one pivot-round pair, balanced by construction.
65
+ *
66
+ * The round used to emit `[candidate, pivot]` for every edge, so the ring leaders sat
67
+ * in trajectory B in all of their extra matches: with a judge that merely prefers slot A
68
+ * the pivots averaged 0.36 against 0.60 for everyone else and sank in the final ranking.
69
+ * Alternating on the sum of the two RANKS — not the raw indices: the pivot set is
70
+ * selected by score, so its indices can share a parity and a parity rule would then
71
+ * handicap half the field — gives every pivot and every candidate both slots within one
72
+ * edge of each other, without a second model call.
73
+ *
74
+ * Deliberately NOT in core.ts: `pivotRoundPairs` is compared against the upstream Python
75
+ * reference in `parity.test.ts`, so the orientation stays an orchestration-side decision.
76
+ * @param pair - one unordered pair from the pivot round.
77
+ * @param pivotRanks - pivot index → its rank among the pivots.
78
+ * @param nonPivotRanks - candidate index → its rank among the non-pivots.
79
+ * @returns The pair in the order it should be presented.
80
+ */
81
+ export function orientRoundPairs(pairs: readonly (readonly [number, number])[]): Array<[number, number]> {
82
+ const balance = new Map<number, number>()
83
+ return pairs.map(([a, b]) => {
84
+ const first = balance.get(a) ?? 0
85
+ const second = balance.get(b) ?? 0
86
+ // Slot A goes to whichever endpoint has held it less often; an equal count keeps the
87
+ // incoming order, so the result is deterministic for a given pair sequence.
88
+ const [x, y] = second < first ? [b, a] : [a, b]
89
+ balance.set(x, (balance.get(x) ?? 0) + 1)
90
+ balance.set(y, (balance.get(y) ?? 0) - 1)
91
+ return [x, y] as [number, number]
92
+ })
93
+ }
94
+
95
+ /**
96
+ * Arbitrary but fixed A/B slot for the single pair of a two-candidate select.
97
+ *
98
+ * There is only one match, so nothing can cancel a preference; the mix at least stops
99
+ * candidate 0 from always sitting in slot A. Real comparisons go through `compare`,
100
+ * where `routedRepeats` runs an even number of rounds instead.
101
+ * @param a - first candidate index of the pair.
102
+ * @param b - second candidate index of the pair.
103
+ * @returns The pair in the order it should be presented.
104
+ */
105
+ function orientPair(a: number, b: number): [number, number] {
106
+ const lo = Math.min(a, b)
107
+ const hi = Math.max(a, b)
108
+ let mixed = Math.imul(lo + 1, 0x9e3779b1) ^ Math.imul(hi + 1, 0x85ebca77)
109
+ mixed = Math.imul(mixed ^ (mixed >>> 15), 0x2545f491)
110
+ return ((mixed ^ (mixed >>> 13)) & 1) === 0 ? [a, b] : [b, a]
111
+ }
112
+
63
113
  function judgeLabel(client: VerifierClientConfig): string {
64
114
  return client.label?.trim() || client.provider + '/' + client.model
65
115
  }
@@ -422,10 +472,12 @@ export class VerifierEngine {
422
472
  return { index: 0, best: options.candidates[0]!, scores: [1], ranking: [0], pivots: [0], comparisons: 0, calls: 0, stats: blankStats(), judges }
423
473
  }
424
474
  if (options.candidates.length === 2) {
425
- // ringCycle(2) would judge the single unordered pair in both directions; play it once.
426
- const { rewards, judgeRewards, judgeOk, judgeErrors, judgeCalls, stats } = await this.scorePairs(options, [[0, 1]], signal)
475
+ // ringCycle(2) would judge the single unordered pair in both directions; play it
476
+ // once, in the orientation orientPair() picks so the slot is not always candidate 0.
477
+ const single = orientPair(0, 1)
478
+ const { rewards, judgeRewards, judgeOk, judgeErrors, judgeCalls, stats } = await this.scorePairs(options, [single], signal)
427
479
  const wins = [0, 0]; const counts = [0, 0]
428
- accumulatePairs([[0, 1]], rewards, wins, counts)
480
+ accumulatePairs([single], rewards, wins, counts)
429
481
  const ranked = rankScores(wins, counts); const index = ranked[0]!.index
430
482
  const judges: JudgeScore[] = this.clients.map((client, k) => {
431
483
  const isOk = judgeOk[k]!
@@ -464,7 +516,7 @@ export class VerifierEngine {
464
516
  // pivot→neighbour edges). Dropping those duplicates keeps each unordered pair to a
465
517
  // single match so wins/counts are not double-weighted and no pair is judged twice.
466
518
  const ringPairs = new Set(ring.map(pair => unorderedPair(pair[0], pair[1])))
467
- const rounds = pivotRoundPairs(options.candidates.length, pivots).filter(pair => !ringPairs.has(unorderedPair(pair[0], pair[1])))
519
+ const rounds = orientRoundPairs(pivotRoundPairs(options.candidates.length, pivots).filter(pair => !ringPairs.has(unorderedPair(pair[0], pair[1]))))
468
520
  const roundScores = await this.scorePairs(options, rounds, signal)
469
521
  const allRewards = new Map([...ringScores.rewards, ...roundScores.rewards])
470
522
  const wins = new Array<number>(options.candidates.length).fill(0)
package/src/index.ts CHANGED
@@ -12,12 +12,12 @@ import { ScoreCache, SingleFlight, resolveCacheFile, stableHash, type CachedPair
12
12
  import { VerifierEngine, normalizeCriteria, type JudgeScore, type RunStats } from './engine.ts'
13
13
  import { loadVerifierImages } from './images.ts'
14
14
  import { extractSession, sanitizeVerifierText, sessionEvents } from './session.ts'
15
- import { analyzeAutoTask, automaticFeedback, isSubagentSession } from './auto.ts'
16
- import { AutoVerifierRouter, analyzeStructuredRoute, boundDecision, buildSemanticRoutePrompt, estimateRoutedCalls, parseSemanticRoute, semanticDecision, semanticRouteHint, type RouteDecision, type RoutedVerifierKind } from './router.ts'
15
+ import { analyzeAutoTask, automaticFeedback, failedAcceptanceCriteria, isSubagentSession, sessionAccepted, type AcceptanceCriterion } from './auto.ts'
16
+ import { AutoVerifierRouter, analyzeStructuredRoute, boundDecision, buildSemanticRoutePrompt, estimateRoutedCalls, parseSemanticRoute, routedRepeats, semanticDecision, semanticRouteHint, type RouteDecision, type RoutedVerifierKind } from './router.ts'
17
17
  import { DEFAULT_CRITERIA } from './core.ts'
18
18
  import { buildPlanPreReviewPrompt, parseVerdictLetter, planFromArguments } from './plan-gate.ts'
19
19
  import { inspectTeamTasks, buildTeamTaskVerificationPrompt } from './team-gate.ts'
20
- import { StatisticsStore, emptyRunStats, errorDetails, mergeStatisticsOverviews, parseStatisticsQuery, resolveStatisticsFile, type StatisticsOverview, type VerifierToolName, type VerdictSummary } from './statistics.ts'
20
+ import { StatisticsStore, emptyRunStats, errorDetails, mergeStatisticsOverviews, parseStatisticsQuery, resolveStatisticsFile, summarizeVerdict, type StatisticsOverview, type VerifierToolName } from './statistics.ts'
21
21
  import { resolveTopicDataDir, type SessionArtifactLocator } from './topic-storage.ts'
22
22
 
23
23
  export const name = 'llm-verifier'
@@ -98,7 +98,7 @@ function rpcSuccess<T>(value: T) { return { ok: true as const, value } }
98
98
  function rpcFailure(message: string) { return { ok: false as const, error: { code: 'bad-request' as const, message, details: { issues: [] } } } }
99
99
 
100
100
  interface SessionVerificationOptions { fromSeq?: number; toSeq?: number; includeAssistantText?: boolean; redactPatterns?: readonly string[]; maxChars?: number; repeats?: number }
101
- interface SessionVerificationResult { sessionId: string; problem: string; score: number; baselineScore: number; winner: 'A' | 'B' | 'tie'; fromSeq: number; toSeq: number; omittedCharacters: number; calls: number; stats: RunStats; judges: JudgeScore[]; agreement: number }
101
+ interface SessionVerificationResult { sessionId: string; problem: string; score: number; baselineScore: number; winner: 'A' | 'B' | 'tie'; criteria: AcceptanceCriterion[]; fromSeq: number; toSeq: number; omittedCharacters: number; calls: number; stats: RunStats; judges: JudgeScore[]; agreement: number }
102
102
 
103
103
  export function apply(ctx: Context, config: Config = {}): void {
104
104
  const services = ctx as Context & { attachments: AttachmentStore; connection: HostConnectionHandle; sessionPersistence: SessionArtifactLocator & { list(signal?: AbortSignal): Promise<readonly unknown[]> } }
@@ -139,36 +139,12 @@ export function apply(ctx: Context, config: Config = {}): void {
139
139
  const images = (values: readonly string[] | undefined, signal: AbortSignal) => loadVerifierImages(values, signal)
140
140
  const route = (selected: { provider: string; model: string }) => ({ provider: selected.provider, model: selected.model })
141
141
  const requireEnabled = () => { if (!current().enabled) throw new Error('llm-verifier: verifier tools are disabled — enable them in Settings → LLM Verifier') }
142
- /**
143
- * Compact summary of what the judges decided, stored beside the call counters so
144
- * the dashboard can answer "why did this fail?" instead of only "how much did it cost".
145
- * @param toolName - the verifier tool that produced the value.
146
- * @param value - its rendered result.
147
- * @param phase - which stage produced it (explicit | compare | select | track | final | ...).
148
- * @returns A verdict summary; score fields are omitted when the tool has none.
149
- */
150
- const verdictFrom = (toolName: VerifierToolName, value: unknown, phase: string): VerdictSummary => {
142
+ const verdictFrom = (toolName: VerifierToolName, value: unknown, phase: string) => {
151
143
  const selected = current()
152
- const row = typeof value === 'object' && value !== null ? value as Record<string, unknown> : {}
153
- const numberAt = (key: string): number | undefined => (typeof row[key] === 'number' && Number.isFinite(row[key]) ? row[key] as number : undefined)
154
- const scores = Array.isArray(row.scores) ? row.scores.filter((entry): entry is number => typeof entry === 'number' && Number.isFinite(entry)) : []
155
- const winner = row.winner === 'A' || row.winner === 'B' || row.winner === 'tie' ? row.winner : undefined
156
- if (toolName === 'verifier_route_classify') return { phase, outcome: 'classified' }
157
- if (toolName === 'verifier_select') {
158
- const index = numberAt('index')
159
- const best = index === undefined ? undefined : scores[index]
160
- return { phase, outcome: 'ranked', ...(best !== undefined ? { score: best } : {}) }
161
- }
162
- if (toolName === 'verifier_track') {
163
- const worst = scores.length > 0 ? Math.min(...scores) : undefined
164
- const threshold = selected.autoTrackCompletionThreshold
165
- return { phase, outcome: worst !== undefined && worst >= threshold ? 'passed' : 'below-threshold', ...(worst !== undefined ? { score: worst } : {}), threshold }
166
- }
167
- const score = numberAt('score') ?? numberAt('scoreA')
168
- const baselineScore = numberAt('baselineScore')
169
- const threshold = selected.autoVerifyThreshold
170
- const outcome = winner === 'tie' ? 'tie' : winner === 'A' && score !== undefined && score >= threshold ? 'passed' : 'below-threshold'
171
- return { phase, outcome, ...(score !== undefined ? { score } : {}), ...(baselineScore !== undefined ? { baselineScore } : {}), ...(winner !== undefined ? { winner } : {}), threshold }
144
+ return summarizeVerdict(toolName, value, phase, {
145
+ autoVerifyThreshold: selected.autoVerifyThreshold,
146
+ autoTrackCompletionThreshold: selected.autoTrackCompletionThreshold,
147
+ })
172
148
  }
173
149
  const record = async <T>(toolName: VerifierToolName, agent: Agent, operation: () => Promise<{ result: T; selected: { provider: string; model: string } }>, phase = 'explicit'): Promise<T & { provider: string; model: string }> => {
174
150
  const startedAt = Date.now()
@@ -218,7 +194,7 @@ export function apply(ctx: Context, config: Config = {}): void {
218
194
  if (!extracted.problem.trim()) throw new Error('llm-verifier: no direct user task found in the selected session range — widen from_seq so the task statement is included')
219
195
  const { verifier, selected } = await engine(agent)
220
196
  const compared = await verifier.compare({ problem: extracted.problem, candidateA: extracted.trace, candidateB: '(No useful work or verification was performed.)', repeats: positive(options.repeats, 2, 'repeats'), images: extracted.images }, signal)
221
- const result: SessionVerificationResult = { sessionId: extracted.sessionId, problem: extracted.problem, score: compared.scoreA, baselineScore: compared.scoreB, winner: compared.winner, fromSeq: extracted.fromSeq, toSeq: extracted.toSeq, omittedCharacters: extracted.omittedCharacters, calls: compared.calls, stats: compared.stats, judges: compared.judges, agreement: compared.agreement }
197
+ const result: SessionVerificationResult = { sessionId: extracted.sessionId, problem: extracted.problem, score: compared.scoreA, baselineScore: compared.scoreB, winner: compared.winner, criteria: compared.criteria.map(row => ({ id: row.id, name: row.name, score: row.scoreA })), fromSeq: extracted.fromSeq, toSeq: extracted.toSeq, omittedCharacters: extracted.omittedCharacters, calls: compared.calls, stats: compared.stats, judges: compared.judges, agreement: compared.agreement }
222
198
  return { result, selected }
223
199
  }, phase)
224
200
  const compareCandidates = async (agent: Agent, problem: string, candidateA: string, candidateB: string, repeats: number, signal: AbortSignal, routedImages: readonly import('./caller.ts').VerifierImage[] = [], phase = 'explicit') => record('verifier_compare', agent, async () => {
@@ -393,8 +369,11 @@ export function apply(ctx: Context, config: Config = {}): void {
393
369
  if (!stillCurrent()) { autoRouter.fail(agent, taskReservation, false); return }
394
370
  const verdict = parseVerdictLetter(classified.text)
395
371
  if (verdict === undefined) {
396
- autoRouter.fail(agent, taskReservation, false)
397
- ctx.logger.warn('llm-verifier team task verification produced no verdict line; task ' + task.id + ' was not gated')
372
+ // Fail closed in strict mode like every other routed phase: an unparseable
373
+ // verdict must not silently leave the task ungated.
374
+ const strict = selected.autoVerifyMode === 'strict'
375
+ autoRouter.fail(agent, taskReservation, strict)
376
+ ctx.logger.warn('llm-verifier team task verification produced no verdict line; task ' + task.id + (strict ? ' stays blocked' : ' was not gated'))
398
377
  continue
399
378
  }
400
379
  if (verdict.score >= selected.autoVerifyThreshold) {
@@ -462,8 +441,10 @@ export function apply(ctx: Context, config: Config = {}): void {
462
441
 
463
442
  if (!stillCurrent()) return
464
443
  if (decision) {
465
- // Every judge scores every match, so the reservation has to cover the ensemble.
466
- const expectedCalls = estimateRoutedCalls(decision, selected.autoVerifyRepeats, DEFAULT_CRITERIA.length) * selected.judges.length
444
+ // Every judge scores every match, and compare/select round the repeat count up to an
445
+ // even number so the A/B slots get swapped, so the reservation has to cover both.
446
+ const repeats = routedRepeats(decision, selected.autoVerifyRepeats)
447
+ const expectedCalls = estimateRoutedCalls(decision, repeats, DEFAULT_CRITERIA.length) * selected.judges.length
467
448
  const reservation = autoRouter.reserve(agent, decision.kind, decision.fingerprint, expectedCalls, policy)
468
449
  if (reservation === undefined) {
469
450
  // A refused reservation used to drop the whole routed decision silently.
@@ -483,7 +464,7 @@ export function apply(ctx: Context, config: Config = {}): void {
483
464
  try {
484
465
  const extracted = await extractTask(agent, evidence.taskStartSeq, admittedLastSeq, selected.autoVerifyMaxChars, signal)
485
466
  if (decision.kind === 'compare') {
486
- const result = await compareCandidates(agent, extracted.problem, decision.candidates[0].content, decision.candidates[1].content, selected.autoVerifyRepeats, signal, extracted.images, 'compare')
467
+ const result = await compareCandidates(agent, extracted.problem, decision.candidates[0].content, decision.candidates[1].content, repeats, signal, extracted.images, 'compare')
487
468
  if (!stillCurrent()) { autoRouter.fail(agent, reservation, false); return }
488
469
  if (!autoRouter.commit(agent, reservation, admittedLastSeq)) return
489
470
  const winner = result.winner === 'A' ? decision.candidates[0].label : result.winner === 'B' ? decision.candidates[1].label : 'tie'
@@ -491,18 +472,23 @@ export function apply(ctx: Context, config: Config = {}): void {
491
472
  return
492
473
  }
493
474
  if (decision.kind === 'select') {
494
- const result = await selectCandidates(agent, extracted.problem, decision.candidates.map(candidate => candidate.content), selected.autoVerifyRepeats, signal, extracted.images, 'select')
475
+ const result = await selectCandidates(agent, extracted.problem, decision.candidates.map(candidate => candidate.content), repeats, signal, extracted.images, 'select')
495
476
  if (!stillCurrent()) { autoRouter.fail(agent, reservation, false); return }
496
477
  if (!autoRouter.commit(agent, reservation, admittedLastSeq)) return
497
478
  const ranking = result.ranking.map((index, rank) => (rank + 1) + '. ' + decision.candidates[index]!.label).join('\n')
498
479
  agent.steer(routeFeedback(decision, 'Ranking:\n' + ranking + '\nProceed with ' + decision.candidates[result.index]!.label + '.'))
499
480
  return
500
481
  }
501
- const result = await trackProgress(agent, extracted.problem, decision.steps, decision.checkpoints, selected.autoVerifyRepeats, signal, extracted.images, 'track')
482
+ const result = await trackProgress(agent, extracted.problem, decision.steps, decision.checkpoints, repeats, signal, extracted.images, 'track')
502
483
  if (!stillCurrent()) { autoRouter.fail(agent, reservation, false); return }
503
484
  if (!autoRouter.commit(agent, reservation, admittedLastSeq)) return
504
485
  const detail = result.scores.map((score, index) => 'Checkpoint step ' + decision.checkpoints[index] + ': ' + (score * 100).toFixed(1) + '%').join('\n')
505
- const continuation = result.scores.some(score => score < selected.autoTrackCompletionThreshold) ? '\nContinue the unfinished work.' : '\nPrepare final delivery evidence; final session verification is mandatory.'
486
+ // Judge the CURRENT state, not the whole history: the first checkpoint is the
487
+ // state right after the first todo snapshot, which is always "nothing done
488
+ // yet", so "any checkpoint below threshold" made the continue branch
489
+ // unconditional (5/5 live routes) and the threshold meaningless.
490
+ const latest = result.scores.length > 0 ? result.scores[result.scores.length - 1]! : 0
491
+ const continuation = latest < selected.autoTrackCompletionThreshold ? '\nContinue the unfinished work.' : '\nPrepare final delivery evidence; final session verification is mandatory.'
506
492
  agent.steer(routeFeedback(decision, detail + continuation))
507
493
  return
508
494
  } catch (error) {
@@ -542,11 +528,15 @@ export function apply(ctx: Context, config: Config = {}): void {
542
528
  try {
543
529
  const result = await verifySession(agent, { fromSeq: finalFromSeq, toSeq: admittedLastSeq, includeAssistantText: true, maxChars: selected.autoVerifyMaxChars, repeats: selected.autoVerifyFinalRepeats }, signal, 'final')
544
530
  if (!stillCurrent()) { autoRouter.fail(agent, finalReservation, false); return }
545
- const passed = result.winner === 'A' && result.score >= selected.autoVerifyThreshold
531
+ // The mean over criteria used to hide a single failed requirement, and the fixed
532
+ // empty-work baseline can never win on its own, so the gate is (a) the winner,
533
+ // (b) the mean score and (c) every criterion on its own.
534
+ const failed = failedAcceptanceCriteria(result.criteria, selected.autoVerifyThreshold)
535
+ const passed = sessionAccepted({ score: result.score, winner: result.winner, criteria: result.criteria }, selected.autoVerifyThreshold)
546
536
  if (passed) autoRouter.commit(agent, finalReservation)
547
537
  else {
548
538
  autoRouter.fail(agent, finalReservation, selected.autoVerifyMode === 'strict')
549
- agent.steer(createUserMessage({ content: [{ type: 'text', text: automaticFeedback(result.score, result.baselineScore, result.winner, selected.autoVerifyThreshold) }], source: { kind: 'plugin', plugin: 'dsh-llm-verifier' } }))
539
+ agent.steer(createUserMessage({ content: [{ type: 'text', text: automaticFeedback(result.score, result.baselineScore, result.winner, selected.autoVerifyThreshold, failed) }], source: { kind: 'plugin', plugin: 'dsh-llm-verifier' } }))
550
540
  }
551
541
  } catch (error) {
552
542
  autoRouter.fail(agent, finalReservation, selected.autoVerifyMode === 'strict')
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { Session } from '@deepseek-ai/dsh-session'
3
- import { createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
4
- import { analyzeStructuredRoute, AutoVerifierRouter, boundDecision, buildSemanticRoutePrompt, estimateRoutedCalls, latestDirectUserSeq, MAX_ROUTED_CHECKPOINTS, parseSemanticRoute, semanticDecision, semanticRouteHint, type RouterPolicy } from './router.ts'
3
+ import { createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
4
+ import { analyzeStructuredRoute, AutoVerifierRouter, boundDecision, buildSemanticRoutePrompt, estimateRoutedCalls, latestDirectUserSeq, MAX_ROUTED_CHECKPOINTS, parseSemanticRoute, routedRepeats, semanticDecision, semanticRouteHint, type RouterPolicy } from './router.ts'
5
5
  import { sanitizeVerifierText } from './session.ts'
6
6
 
7
7
  function session() {
@@ -13,6 +13,9 @@ function tool(value: ReturnType<typeof session>, name: string, id: string, text:
13
13
  value.append('tool/call', { turn, step, callId: id as never, name, arguments: '{}' })
14
14
  value.append('tool/result', { turn, step, message: createToolResultMessage({ callId: id as never, content: [{ type: 'text', text }], isError: false }) }, { surfaceOp: 'append' })
15
15
  }
16
+ function assistant(value: ReturnType<typeof session>, text: string, turn = 1, step = 1) {
17
+ value.append('assistant/message', { turn, step, message: createAssistantMessage({ content: [{ type: 'text', text }], source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) }, { surfaceOp: 'append' })
18
+ }
16
19
  const policy: RouterPolicy = { mode: 'smart', minConfidence: .9, maxCandidates: 8, maxPerTask: 5, maxPerSession: 20, maxModelCallsPerTask: 48, maxModelCallsPerSession: 160, maxInputChars: 60000, maxItemChars: 20000 }
17
20
  const envelope = (count: number) => JSON.stringify({ protocol: 'dsh-verifier-candidates', version: 1, groupId: 'auth', candidates: Array.from({ length: count }, (_, i) => ({ id: String(i + 1), label: 'C' + (i + 1), status: 'completed', content: 'candidate ' + (i + 1) })) })
18
21
 
@@ -45,6 +48,158 @@ describe('production structured routing', () => {
45
48
  expect(decision.steps.every(step => step.length <= 400)).toBe(true)
46
49
  }
47
50
  })
51
+ it('never attaches bookkeeping output as checkpoint evidence', () => {
52
+ // Regression: the newest successful result before a todo snapshot is usually the
53
+ // bookkeeping call that wrote it (create_goal/update_goal/todo_write/interrupt_agent),
54
+ // so every checkpoint showed the router its own metadata instead of the work that
55
+ // had just run the task's tests.
56
+ const value = session()
57
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
58
+ tool(value, 'pwsh', 'run', 'all tests passed: 91 passed')
59
+ tool(value, 'create_goal', 'goal', '{"goal":{"id":"g1","phase":"active"}}')
60
+ tool(value, 'interrupt_agent', 'stop', 'interrupt requested for agent abc')
61
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
62
+ const decision = analyzeStructuredRoute(value.events, 8, 400, 800)
63
+ expect(decision?.kind).toBe('track')
64
+ if (decision?.kind !== 'track') return
65
+ // The real run is older than both bookkeeping results, and must still win.
66
+ expect(decision.steps[1]).toContain('all tests passed')
67
+ expect(decision.steps[1]).not.toContain('"phase":"active"')
68
+ expect(decision.steps[1]).not.toContain('interrupt requested')
69
+ })
70
+
71
+ it('attaches the newest narration to the current checkpoint only', () => {
72
+ // A prose deliverable (review, analysis) never reaches a tool result, so every
73
+ // checkpoint scored "certainly NO" while the same session's final acceptance
74
+ // passed. The narration is labelled as a claim, and only the checkpoint the route
75
+ // actually judges carries it.
76
+ const value = session()
77
+ assistant(value, 'Early plan: read the parser first.')
78
+ value.append('todo/write', { todos: [{ content: 'Review', status: 'in_progress' }, { content: 'Report', status: 'pending' }] })
79
+ assistant(value, 'Deliverable: the review found two blocking issues.', 1, 2)
80
+ value.append('todo/write', { todos: [{ content: 'Review', status: 'completed' }, { content: 'Report', status: 'completed' }] })
81
+ const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
82
+ expect(decision?.kind).toBe('track')
83
+ if (decision?.kind !== 'track') return
84
+ expect(decision.steps[0]).not.toContain('Newest agent narration')
85
+ expect(decision.steps[1]).toContain('Newest agent narration')
86
+ expect(decision.steps[1]).toContain('two blocking issues')
87
+ expect(decision.steps[1]).not.toContain('Early plan')
88
+ })
89
+
90
+ it('keeps narration and evidence inside the checkpoint caps', () => {
91
+ const value = session()
92
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
93
+ tool(value, 'pwsh', 'run', 'Z'.repeat(4000))
94
+ assistant(value, 'Q'.repeat(4000))
95
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
96
+ for (const maxItemChars of [128, 200, 2000, 20000]) {
97
+ const maxInputChars = Math.max(60000, maxItemChars * 2)
98
+ const decision = analyzeStructuredRoute(value.events, 8, maxItemChars, maxInputChars)
99
+ expect(decision, 'maxItemChars=' + maxItemChars).toMatchObject({ kind: 'track' })
100
+ if (decision?.kind !== 'track') continue
101
+ for (const step of decision.steps) expect(step.length, 'maxItemChars=' + maxItemChars).toBeLessThanOrEqual(maxItemChars)
102
+ expect(decision.steps.reduce((sum, step) => sum + step.length, 0)).toBeLessThanOrEqual(maxInputChars)
103
+ // Regression guard for the caps that boundDecision() enforces on the whole route.
104
+ expect(boundDecision(decision, { ...policy, maxItemChars, maxInputChars }), 'maxItemChars=' + maxItemChars).toBeDefined()
105
+ }
106
+ })
107
+
108
+ it('re-routes the same todo snapshots once the rendered evidence changed', () => {
109
+ // Regression: the fingerprint covered only the todo snapshots, so a route whose
110
+ // prompt had changed (new evidence, new narration) was refused as "already routed".
111
+ const value = session()
112
+ tool(value, 'pwsh', 'run-a', 'first output')
113
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
114
+ tool(value, 'pwsh', 'run-b', 'second output')
115
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
116
+ const first = analyzeStructuredRoute(value.events, 8, 20000, 60000)
117
+ const again = analyzeStructuredRoute(value.events, 8, 20000, 60000)
118
+ expect(first?.kind).toBe('track')
119
+ // An unchanged log keeps its identity, so the budget guard still works.
120
+ expect(again?.fingerprint).toBe(first?.fingerprint)
121
+ assistant(value, 'Report: the deliverable is ready.', 2, 1)
122
+ const afterNarration = analyzeStructuredRoute(value.events, 8, 20000, 60000)
123
+ expect(afterNarration?.fingerprint).not.toBe(first?.fingerprint)
124
+ if (first?.kind !== 'track' || afterNarration?.kind !== 'track') return
125
+ expect(afterNarration.steps.at(-1)).toContain('Report: the deliverable is ready.')
126
+ })
127
+
128
+ it('renders the newest checkpoint as the state at routing time', () => {
129
+ // Regression: evidence was frozen at the last todo snapshot's seq, so work done
130
+ // after it — often the actual verification run — never reached the judge.
131
+ const value = session()
132
+ tool(value, 'pwsh', 'first', 'first run output')
133
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
134
+ tool(value, 'pwsh', 'second', 'verification run output')
135
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
136
+ tool(value, 'pwsh', 'third', 'output after the last snapshot')
137
+ const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
138
+ expect(decision?.kind).toBe('track')
139
+ if (decision?.kind !== 'track') return
140
+ // Historical checkpoints keep the output that was current for them...
141
+ expect(decision.steps[0]).toContain('first run output')
142
+ expect(decision.steps[0]).not.toContain('verification run output')
143
+ // ...while the newest one carries the newest output and says so.
144
+ expect(decision.steps[1]).toContain('at routing time')
145
+ expect(decision.steps[1]).toContain('output after the last snapshot')
146
+ })
147
+
148
+ it('treats a wrapper that only dispatched bookkeeping as bookkeeping', () => {
149
+ // Regression: a PTC program that only called todo_write produced a run_code result
150
+ // whose text was the todo list echoed back, and the wrapper's name hid what it did.
151
+ const value = session()
152
+ tool(value, 'pwsh', 'run', 'all tests passed: 91 passed')
153
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
154
+ value.append('tool/call', { turn: 1, step: 1, callId: 'wrap' as never, name: 'run_code', arguments: '{}' })
155
+ value.append('tool/ptc-dispatch' as never, { rootCallId: 'wrap', subCallId: 'wrap:ptc:1', name: 'todo_write', arguments: '{}', isError: false, content: [{ type: 'text', text: '{todos:[{content:"Implement",status:"completed"}]}' }] } as never)
156
+ value.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'wrap' as never, content: [{ type: 'text', text: '{ todos: [ { content: "Implement", status: "completed" } ], counts: {} }' }], isError: false }) }, { surfaceOp: 'append' })
157
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
158
+ const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
159
+ expect(decision?.kind).toBe('track')
160
+ if (decision?.kind !== 'track') return
161
+ expect(decision.steps[1]).toContain('all tests passed')
162
+ expect(decision.steps[1]).not.toContain('counts:')
163
+ })
164
+
165
+ it('never attaches a deliverable presentation as checkpoint evidence', () => {
166
+ // Regression (live session, four routes in a row): `present` is the last call of a
167
+ // turn, so the newest "observed tool output" reaching the judge was the wrapper's
168
+ // `presented: 1` — the paths the agent had just declared — while the typecheck and
169
+ // test run sat one call earlier. The judge's own rule ("a state without real
170
+ // verification should not exceed K") then capped the newest checkpoint at exactly
171
+ // K = 52.6% against a 0.8 threshold, so every route steered "continue the
172
+ // unfinished work" for work that was finished and verified, until the per-task
173
+ // route budget ran out.
174
+ const value = session()
175
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
176
+ tool(value, 'pwsh', 'verify', 'TYPECHECK_EXIT=0\nTests 309 passed')
177
+ value.append('tool/call', { turn: 1, step: 1, callId: 'wrap' as never, name: 'run_code', arguments: '{}' })
178
+ value.append('tool/ptc-dispatch' as never, { rootCallId: 'wrap', subCallId: 'wrap:ptc:1', name: 'present', arguments: '{}', isError: false, content: [{ type: 'text', text: 'Presented C:\\repo\\src\\mapper.ts' }] } as never)
179
+ value.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'wrap' as never, content: [{ type: 'text', text: 'presented: 1' }], isError: false }) }, { surfaceOp: 'append' })
180
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
181
+ const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
182
+ expect(decision?.kind).toBe('track')
183
+ if (decision?.kind !== 'track') return
184
+ // The verification run is older than the presentation, and must still win.
185
+ expect(decision.steps[1]).toContain('309 passed')
186
+ expect(decision.steps[1]).not.toContain('Presented C:')
187
+ expect(decision.steps[1]).not.toContain('presented: 1')
188
+ })
189
+
190
+ it('keeps a wrapper that dispatched real work', () => {
191
+ const value = session()
192
+ value.append('tool/call', { turn: 1, step: 1, callId: 'wrap' as never, name: 'run_code', arguments: '{}' })
193
+ value.append('tool/ptc-dispatch' as never, { rootCallId: 'wrap', subCallId: 'wrap:ptc:1', name: 'pwsh', arguments: '{}', isError: false, content: [{ type: 'text', text: 'tests passed' }] } as never)
194
+ value.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'wrap' as never, content: [{ type: 'text', text: 'tests passed in the wrapper output' }], isError: false }) }, { surfaceOp: 'append' })
195
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
196
+ value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
197
+ const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
198
+ expect(decision?.kind).toBe('track')
199
+ if (decision?.kind !== 'track') return
200
+ expect(decision.steps[1]).toContain('tests passed in the wrapper output')
201
+ })
202
+
48
203
  it('deduplicates identical todo snapshots', () => {
49
204
  const value = session()
50
205
  const todos = [{ content: 'Implement', status: 'in_progress' as const }, { content: 'Test', status: 'pending' as const }]
@@ -99,6 +254,32 @@ describe('semantic evidence references', () => {
99
254
  expect(semanticDecision(parsed, value.events)).toMatchObject({ kind: 'compare', source: 'semantic' })
100
255
  })
101
256
 
257
+ it('never offers bookkeeping calls as semantic candidates', () => {
258
+ // Regression: the artifact list carried every paired tool result, so the classifier
259
+ // could cite two goal/agent-control calls as "competing alternatives".
260
+ const value = session()
261
+ tool(value, 'subagent', 'a', 'candidate A from a real subagent')
262
+ tool(value, 'create_goal', 'goal', '{"goal":{"id":"g1","phase":"active"}}')
263
+ tool(value, 'skill', 'skill', 'loaded review skill body')
264
+ tool(value, 'present', 'present', 'Presented C:\\repo\\src\\mapper.ts')
265
+ const prompt = buildSemanticRoutePrompt('pick the better one', value.events, 8, 20000, 60000)
266
+ expect(prompt).toContain('candidate A from a real subagent')
267
+ expect(prompt).not.toContain('"phase":"active"')
268
+ expect(prompt).not.toContain('loaded review skill body')
269
+ expect(prompt).not.toContain('Presented C:')
270
+ })
271
+
272
+ it('rejects a semantic decision that cites bookkeeping evidence', () => {
273
+ const value = session()
274
+ tool(value, 'create_goal', 'goal-1', '{"goal":{"id":"g1"}}')
275
+ tool(value, 'create_goal', 'goal-2', '{"goal":{"id":"g2"}}')
276
+ const parsed = parseSemanticRoute(JSON.stringify({ kind: 'compare', confidence: .95, reason: 'alternatives', candidateCallIds: ['goal-1', 'goal-2'], checkpointSeqs: [] }))
277
+ expect(parsed?.kind).toBe('compare')
278
+ // Fail closed: the whole decision is dropped, and the caller records the reference
279
+ // as invalid instead of comparing metadata.
280
+ expect(semanticDecision(parsed!, value.events)).toBeUndefined()
281
+ })
282
+
102
283
  it('resolves candidates emitted via Session V3 tool/ptc-dispatch', () => {
103
284
  const value = session()
104
285
  value.append('tool/ptc-dispatch' as never, { subCallId: 'ptc-1', name: 'subagent', arguments: '{}', isError: false, content: [{ type: 'text', text: 'candidate A from ptc' }] } as never)
@@ -210,10 +391,28 @@ describe('semantic evidence references', () => {
210
391
  const value = session()
211
392
  value.append('team/task' as never, { task: { id: 'task-1', revision: 1, subject: 'Backend API', status: 'in_progress' } } as never)
212
393
  value.append('team/task' as never, { task: { id: 'task-1', revision: 2, subject: 'Backend API', status: 'completed' } } as never)
213
- expect(semanticRouteHint(value.events)).toBe(true)
394
+ // Snapshots are structured material, not a semantic hint: the structured pass claims
395
+ // them first, so asking the classifier about them would only ever pay for a route
396
+ // that could not have been produced anyway.
397
+ expect(semanticRouteHint(value.events)).toBe(false)
214
398
  const decision = analyzeStructuredRoute(value.events)
215
399
  expect(decision).toMatchObject({ kind: 'track', source: 'structured', reason: 'changed durable team tasks' })
216
400
  })
401
+
402
+ it('hints a classification only for material the structured pass never consumes', () => {
403
+ const subagent = session(); tool(subagent, 'subagent', 'a', 'candidate A')
404
+ expect(semanticRouteHint(subagent.events)).toBe(true)
405
+ const plan = session()
406
+ plan.append('tool/call', { turn: 1, step: 1, callId: 'plan' as never, name: 'exit_plan_mode', arguments: '{}' })
407
+ plan.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'plan' as never, content: [{ type: 'text', text: 'approved' }], isError: false }) }, { surfaceOp: 'append' })
408
+ expect(semanticRouteHint(plan.events)).toBe(true)
409
+ // Todo snapshots alone (all lists shorter than two items) are still structured-only.
410
+ const todos = session()
411
+ todos.append('todo/write', { todos: [{ content: 'Only step', status: 'in_progress' }] })
412
+ todos.append('todo/write', { todos: [{ content: 'Only step', status: 'completed' }] })
413
+ expect(analyzeStructuredRoute(todos.events)).toBeUndefined()
414
+ expect(semanticRouteHint(todos.events)).toBe(false)
415
+ })
217
416
  })
218
417
 
219
418
  describe('transactional router state', () => {
@@ -256,6 +455,22 @@ describe('transactional router state', () => {
256
455
  value.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'next task' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
257
456
  expect(router.claimExhaustedNotice(agent)).toBe(true)
258
457
  })
458
+ it('rounds compare repeats up to an even count and leaves the other routes alone', () => {
459
+ // compare judges ONE pair, so an odd count leaves its A/B preference uncorrected
460
+ // (the engine only swaps positions on odd repeat indices). select is already
461
+ // symmetric per pair and track has no slots, so both keep the configured count.
462
+ const compare = { kind: 'compare', candidates: [{}, {}] } as never
463
+ const select = { kind: 'select', candidates: [{}, {}, {}] } as never
464
+ const track = { kind: 'track', steps: [] } as never
465
+ expect(routedRepeats(compare, 1)).toBe(2)
466
+ expect(routedRepeats(compare, 2)).toBe(2)
467
+ expect(routedRepeats(compare, 3)).toBe(4)
468
+ expect(routedRepeats(select, 1)).toBe(1)
469
+ expect(routedRepeats(select, 3)).toBe(3)
470
+ expect(routedRepeats(track, 1)).toBe(1)
471
+ expect(routedRepeats(track, 3)).toBe(3)
472
+ })
473
+
259
474
  it('estimates routed calls from the real tournament shape', () => {
260
475
  const candidate = { id: 'c', groupId: 'g', label: 'c', content: 'x', callId: 'a', fromSeq: 1, toSeq: 2 }
261
476
  const base = { source: 'structured' as const, confidence: 1, reason: 'r', fingerprint: 'f' }