dsh-llm-verifier 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -9
- package/lib/auto-B5V49e-A.js +1517 -0
- package/lib/auto-B5V49e-A.js.map +1 -0
- package/lib/{caller-DJd_FlrQ.js → caller-DR_C7ueP.js} +154 -20
- package/lib/caller-DR_C7ueP.js.map +1 -0
- package/lib/caller.js +2 -2
- package/lib/client.js +804 -29
- package/lib/client.js.map +1 -1
- package/lib/core.js +226 -3
- package/lib/core.js.map +1 -1
- package/lib/index.js +1014 -1337
- package/lib/index.js.map +1 -1
- package/lib/replay.js +157 -0
- package/lib/replay.js.map +1 -0
- package/lib/types/auto.js +1 -1
- package/lib/types/auto.js.map +1 -1
- package/lib/types/caller.d.ts +65 -0
- package/lib/types/caller.d.ts.map +1 -1
- package/lib/types/caller.js +113 -5
- package/lib/types/caller.js.map +1 -1
- package/lib/types/client-i18n.d.ts +163 -6
- package/lib/types/client-i18n.d.ts.map +1 -1
- package/lib/types/client-i18n.js +137 -26
- package/lib/types/client-i18n.js.map +1 -1
- package/lib/types/client.d.ts +30 -0
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/client.js +94 -9
- package/lib/types/client.js.map +1 -1
- package/lib/types/config.d.ts +33 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +17 -2
- package/lib/types/config.js.map +1 -1
- package/lib/types/core.d.ts +84 -0
- package/lib/types/core.d.ts.map +1 -1
- package/lib/types/core.js +222 -2
- package/lib/types/core.js.map +1 -1
- package/lib/types/criteria.d.ts +33 -0
- package/lib/types/criteria.d.ts.map +1 -0
- package/lib/types/criteria.js +67 -0
- package/lib/types/criteria.js.map +1 -0
- package/lib/types/decisions.d.ts +84 -0
- package/lib/types/decisions.d.ts.map +1 -0
- package/lib/types/decisions.js +209 -0
- package/lib/types/decisions.js.map +1 -0
- package/lib/types/engine.d.ts +54 -1
- package/lib/types/engine.d.ts.map +1 -1
- package/lib/types/engine.js +175 -12
- package/lib/types/engine.js.map +1 -1
- package/lib/types/index.d.ts +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +392 -38
- package/lib/types/index.js.map +1 -1
- package/lib/types/replay.d.ts +88 -0
- package/lib/types/replay.d.ts.map +1 -0
- package/lib/types/replay.js +136 -0
- package/lib/types/replay.js.map +1 -0
- package/lib/types/router.d.ts +29 -5
- package/lib/types/router.d.ts.map +1 -1
- package/lib/types/router.js +283 -46
- package/lib/types/router.js.map +1 -1
- package/lib/types/statistics.d.ts +10 -1
- package/lib/types/statistics.d.ts.map +1 -1
- package/lib/types/statistics.js +35 -7
- package/lib/types/statistics.js.map +1 -1
- package/lib/types/top-logprobs.d.ts +36 -0
- package/lib/types/top-logprobs.d.ts.map +1 -1
- package/lib/types/top-logprobs.js +41 -14
- package/lib/types/top-logprobs.js.map +1 -1
- package/package.json +6 -1
- package/scripts/eval-replay.mjs +118 -0
- package/src/auto.ts +1 -1
- package/src/caller.test.ts +62 -1
- package/src/caller.ts +124 -4
- package/src/client-i18n.ts +137 -25
- package/src/client.test.ts +21 -0
- package/src/client.tsx +145 -7
- package/src/config.test.ts +32 -0
- package/src/config.ts +49 -2
- package/src/core.test.ts +95 -0
- package/src/core.ts +217 -2
- package/src/criteria.test.ts +60 -0
- package/src/criteria.ts +76 -0
- package/src/decisions.test.ts +150 -0
- package/src/decisions.ts +244 -0
- package/src/engine.test.ts +131 -0
- package/src/engine.ts +167 -10
- package/src/index.test.ts +278 -8
- package/src/index.ts +373 -37
- package/src/replay.test.ts +82 -0
- package/src/replay.ts +155 -0
- package/src/router.test.ts +240 -1
- package/src/router.ts +298 -48
- package/src/statistics.test.ts +49 -1
- package/src/statistics.ts +44 -7
- package/src/top-logprobs.test.ts +28 -0
- package/src/top-logprobs.ts +56 -8
- package/lib/caller-DJd_FlrQ.js.map +0 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { parseStatisticsRecords, replayDecisionScores, sweepThresholds } from './replay.ts'
|
|
3
|
+
|
|
4
|
+
const statistics = JSON.stringify({
|
|
5
|
+
version: 1,
|
|
6
|
+
records: [
|
|
7
|
+
{ toolName: 'verifier_current_session', startedAt: 1, success: true, verdict: { phase: 'final', score: 1, baselineScore: 0, winner: 'A', criteria: [{ id: 'a', score: 1 }, { id: 'b', score: 1 }], threshold: 0.65 } },
|
|
8
|
+
{ toolName: 'verifier_current_session', startedAt: 2, success: true, verdict: { phase: 'final', score: 0.6, baselineScore: 0, winner: 'A', criteria: [{ id: 'a', score: 1 }, { id: 'b', score: 0.2 }], threshold: 0.65 } },
|
|
9
|
+
{ toolName: 'verifier_current_session', startedAt: 3, success: false },
|
|
10
|
+
{ toolName: 'verifier_compare', startedAt: 4, success: true, verdict: { phase: 'compare', score: 0.9, scoreB: 0.1, winner: 'A' } },
|
|
11
|
+
],
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('parseStatisticsRecords', () => {
|
|
15
|
+
it('reads the verdict fields a sweep needs and survives a malformed document', () => {
|
|
16
|
+
const records = parseStatisticsRecords(statistics)
|
|
17
|
+
expect(records).toHaveLength(4)
|
|
18
|
+
expect(records[0]).toMatchObject({ toolName: 'verifier_current_session', score: 1, winner: 'A' })
|
|
19
|
+
expect(records[0]!.criteria).toEqual([{ id: 'a', score: 1 }, { id: 'b', score: 1 }])
|
|
20
|
+
// A failed invocation has no verdict at all: it must still parse, with no score.
|
|
21
|
+
expect(records[2]).toMatchObject({ success: false, criteria: [] })
|
|
22
|
+
expect(records[2]!.score).toBeUndefined()
|
|
23
|
+
expect(parseStatisticsRecords('not json')).toEqual([])
|
|
24
|
+
expect(parseStatisticsRecords('{"version":1}')).toEqual([])
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('sweepThresholds', () => {
|
|
29
|
+
it('reports what each threshold decides, including the per-criterion floor', () => {
|
|
30
|
+
const records = parseStatisticsRecords(statistics)
|
|
31
|
+
const rows = sweepThresholds(records, [0.5, 0.65, 0.9])
|
|
32
|
+
// Only the two session acceptances are gate decisions: compare uses another threshold.
|
|
33
|
+
expect(rows.every(row => row.total === 3)).toBe(true)
|
|
34
|
+
// At 0.5 the second record's mean (0.6) clears the bar, but its criterion b = 0.2 does not:
|
|
35
|
+
// that is exactly the failure the mean alone used to hide.
|
|
36
|
+
expect(rows.find(row => row.threshold === 0.5)).toMatchObject({ accepted: 1, rejectedVerdict: 0, rejectedMean: 0, rejectedCriterion: 1, unscored: 1 })
|
|
37
|
+
// At 0.65 the mean itself is short, so the floor never comes into play for that record.
|
|
38
|
+
expect(rows.find(row => row.threshold === 0.65)).toMatchObject({ accepted: 1, rejectedMean: 1, rejectedCriterion: 0, unscored: 1 })
|
|
39
|
+
expect(rows.find(row => row.threshold === 0.9)).toMatchObject({ accepted: 1, rejectedVerdict: 0, rejectedMean: 1, rejectedCriterion: 0, unscored: 1 })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('bounds the sweep at the extremes instead of producing NaN', () => {
|
|
43
|
+
const records = parseStatisticsRecords(statistics)
|
|
44
|
+
// A threshold of 0 disables the floor as well: every scored acceptance passes (the one
|
|
45
|
+
// unscored record is never an acceptance, at any threshold).
|
|
46
|
+
expect(sweepThresholds(records, [0])[0]).toMatchObject({ accepted: 2, rejectedCriterion: 0, unscored: 1 })
|
|
47
|
+
expect(sweepThresholds(records, [1])[0]).toMatchObject({ accepted: 1, rejectedMean: 1 })
|
|
48
|
+
expect(sweepThresholds([], [0.65])[0]).toMatchObject({ total: 0, accepted: 0 })
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('replayDecisionScores', () => {
|
|
53
|
+
it('re-parses a stored explicit-tag answer and flags drift and unparseable answers', () => {
|
|
54
|
+
const rows = replayDecisionScores([
|
|
55
|
+
// K is the 10th letter: (20 - 10) - 1 over 19 = 9/19.
|
|
56
|
+
{ label: 'clean', channel: 'explicit-tag', output: '<score_A> K </score_A>\n<score_B> T </score_B>', score: 9 / 19 },
|
|
57
|
+
{ label: 'changed', channel: 'explicit-tag', output: '<score_A> A </score_A>\n<score_B> T </score_B>', score: 0.5 },
|
|
58
|
+
// A tag that is present but carries no valid letter is a real parser failure.
|
|
59
|
+
{ label: 'broken', channel: 'explicit-tag', output: '<score_A> ZZ </score_A>', score: 0.5 },
|
|
60
|
+
{ label: 'distribution', channel: 'top-logprobs', output: '<score_A> A </score_A>', score: 0.83 },
|
|
61
|
+
])
|
|
62
|
+
expect(rows[0]).toMatchObject({ mode: 'match', reparsed: 9 / 19 })
|
|
63
|
+
expect(rows[1]).toMatchObject({ mode: 'drift', reparsed: 1 })
|
|
64
|
+
expect(rows[2]!.mode).toBe('unreadable')
|
|
65
|
+
// A top-logprobs score is an expectation over a distribution the snapshot does not keep.
|
|
66
|
+
expect(rows[3]).toMatchObject({ mode: 'drift', stored: 0.83 })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('replays a progress answer against the last checkpoint, inverted, and ignores non-scoring calls', () => {
|
|
70
|
+
const rows = replayDecisionScores([
|
|
71
|
+
// Track stores the LAST checkpoint and progresses A(=0) .. T(=1): <c2> T </c2> is 1.0.
|
|
72
|
+
{ label: 'progress repeat 1/3', channel: 'explicit-tag', output: '<c1> K </c1>\n<c2> T </c2>', score: 1 },
|
|
73
|
+
{ label: 'progress repeat 2/3', channel: 'explicit-tag', output: '<c1> K </c1>\n<c2> T </c2>', score: 0.5 },
|
|
74
|
+
// A route classification answers strict JSON — no score tag exists by design, and counting
|
|
75
|
+
// it as unreadable made every routing snapshot look like a regression.
|
|
76
|
+
{ label: 'route classify', channel: 'explicit-tag', output: '{"kind":"none","confidence":0.9}', score: undefined },
|
|
77
|
+
])
|
|
78
|
+
expect(rows[0]).toMatchObject({ mode: 'match', reparsed: 1 })
|
|
79
|
+
expect(rows[1]).toMatchObject({ mode: 'drift', reparsed: 1 })
|
|
80
|
+
expect(rows[2]!.mode).toBe('not-scored')
|
|
81
|
+
})
|
|
82
|
+
})
|
package/src/replay.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { extractScore } from './core.ts'
|
|
2
|
+
import { sessionAccepted } from './auto.ts'
|
|
3
|
+
|
|
4
|
+
/** One persisted invocation, reduced to the fields an acceptance decision depends on. */
|
|
5
|
+
export interface ReplayInvocation {
|
|
6
|
+
toolName: string
|
|
7
|
+
startedAt: number
|
|
8
|
+
success: boolean
|
|
9
|
+
score?: number
|
|
10
|
+
baselineScore?: number
|
|
11
|
+
winner?: 'A' | 'B' | 'tie'
|
|
12
|
+
criteria: Array<{ id: string; score: number }>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function numberAt(row: Record<string, unknown>, key: string): number | undefined {
|
|
16
|
+
const value = row[key]
|
|
17
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read one topic's statistics file into the records a threshold sweep can replay.
|
|
22
|
+
*
|
|
23
|
+
* Loose on purpose, exactly like the store's own loader: a record written by an older or newer
|
|
24
|
+
* plugin must not abort the replay. Records that carry no verdict (failed invocations) come back
|
|
25
|
+
* with no score, and the sweep reports them as unscored rather than as acceptances.
|
|
26
|
+
* @param text - raw `verifier/statistics-v1.json`.
|
|
27
|
+
* @returns The parsed invocations; an unreadable document yields an empty list.
|
|
28
|
+
*/
|
|
29
|
+
export function parseStatisticsRecords(text: string): ReplayInvocation[] {
|
|
30
|
+
let document: unknown
|
|
31
|
+
try { document = JSON.parse(text) } catch { return [] }
|
|
32
|
+
const records = typeof document === 'object' && document !== null ? (document as { records?: unknown }).records : undefined
|
|
33
|
+
if (!Array.isArray(records)) return []
|
|
34
|
+
const out: ReplayInvocation[] = []
|
|
35
|
+
for (const entry of records) {
|
|
36
|
+
if (typeof entry !== 'object' || entry === null) continue
|
|
37
|
+
const row = entry as Record<string, unknown>
|
|
38
|
+
const verdict = typeof row.verdict === 'object' && row.verdict !== null ? row.verdict as Record<string, unknown> : {}
|
|
39
|
+
const criteria: Array<{ id: string; score: number }> = []
|
|
40
|
+
for (const item of Array.isArray(verdict.criteria) ? verdict.criteria : []) {
|
|
41
|
+
if (typeof item !== 'object' || item === null) continue
|
|
42
|
+
const criterion = item as Record<string, unknown>
|
|
43
|
+
if (typeof criterion.id !== 'string') continue
|
|
44
|
+
const score = numberAt(criterion, 'score')
|
|
45
|
+
if (score !== undefined) criteria.push({ id: criterion.id, score })
|
|
46
|
+
}
|
|
47
|
+
const score = numberAt(verdict, 'score')
|
|
48
|
+
const baselineScore = numberAt(verdict, 'baselineScore')
|
|
49
|
+
const winner = verdict.winner === 'A' || verdict.winner === 'B' || verdict.winner === 'tie' ? verdict.winner : undefined
|
|
50
|
+
out.push({
|
|
51
|
+
toolName: typeof row.toolName === 'string' ? row.toolName : '',
|
|
52
|
+
startedAt: numberAt(row, 'startedAt') ?? 0,
|
|
53
|
+
success: row.success === true,
|
|
54
|
+
criteria,
|
|
55
|
+
...(score !== undefined ? { score } : {}),
|
|
56
|
+
...(baselineScore !== undefined ? { baselineScore } : {}),
|
|
57
|
+
...(winner !== undefined ? { winner } : {}),
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Why a recorded acceptance failed at one threshold, so a sweep shows WHAT the threshold decides. */
|
|
64
|
+
export interface ThresholdRow {
|
|
65
|
+
threshold: number
|
|
66
|
+
total: number
|
|
67
|
+
accepted: number
|
|
68
|
+
/** Losing to the empty-work baseline (or an unreadable verdict) can never pass. */
|
|
69
|
+
rejectedVerdict: number
|
|
70
|
+
/** The mean itself was below the threshold. */
|
|
71
|
+
rejectedMean: number
|
|
72
|
+
/** The mean passed but at least one criterion was below the threshold. */
|
|
73
|
+
rejectedCriterion: number
|
|
74
|
+
/** The record has no usable score (a failed invocation). */
|
|
75
|
+
unscored: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Re-score every recorded session acceptance at each threshold with the LIVE acceptance rule.
|
|
80
|
+
*
|
|
81
|
+
* This is the tuning tool the plan calls for: `autoVerifyThreshold` (and the per-criterion
|
|
82
|
+
* floor that {@link sessionAccepted} applies) is currently a hand-picked number with no evidence
|
|
83
|
+
* behind it. The stored verdict already carries the session score, the baseline score, the winner
|
|
84
|
+
* and the per-criterion scores — everything the gate looked at — so a sweep costs no model calls.
|
|
85
|
+
*
|
|
86
|
+
* Only `verifier_current_session` records are gate decisions; routed compare/select/track
|
|
87
|
+
* verdicts use different thresholds and are skipped.
|
|
88
|
+
* @param invocations - records from {@link parseStatisticsRecords}.
|
|
89
|
+
* @param thresholds - thresholds to test, typically 0.5..0.9.
|
|
90
|
+
* @returns One row per threshold, in the given order.
|
|
91
|
+
*/
|
|
92
|
+
export function sweepThresholds(invocations: readonly ReplayInvocation[], thresholds: readonly number[]): ThresholdRow[] {
|
|
93
|
+
const gateRecords = invocations.filter(record => record.toolName === 'verifier_current_session')
|
|
94
|
+
return thresholds.map(threshold => {
|
|
95
|
+
const row: ThresholdRow = { threshold, total: gateRecords.length, accepted: 0, rejectedVerdict: 0, rejectedMean: 0, rejectedCriterion: 0, unscored: 0 }
|
|
96
|
+
for (const record of gateRecords) {
|
|
97
|
+
if (record.score === undefined || record.winner === undefined) { row.unscored += 1; continue }
|
|
98
|
+
if (sessionAccepted({ score: record.score, winner: record.winner, criteria: record.criteria }, threshold)) { row.accepted += 1; continue }
|
|
99
|
+
if (record.winner !== 'A') row.rejectedVerdict += 1
|
|
100
|
+
else if (record.score < threshold) row.rejectedMean += 1
|
|
101
|
+
else row.rejectedCriterion += 1
|
|
102
|
+
}
|
|
103
|
+
return row
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One captured judge call re-parsed through the current scorer. */
|
|
108
|
+
export interface DecisionReplayRow {
|
|
109
|
+
label: string
|
|
110
|
+
channel: string
|
|
111
|
+
stored?: number
|
|
112
|
+
reparsed?: number
|
|
113
|
+
/**
|
|
114
|
+
* `not-scored` means the answer carries no score tag at all — a route classification, or any
|
|
115
|
+
* other non-scoring call. Counting those as failures made the report cry wolf on every
|
|
116
|
+
* `track` and `verifier_route_classify` snapshot.
|
|
117
|
+
*/
|
|
118
|
+
mode: 'match' | 'drift' | 'unreadable' | 'not-scored'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Re-parse captured judge answers with the current score parser.
|
|
123
|
+
*
|
|
124
|
+
* A snapshot stores the raw answer, so the parser can be replayed offline: a pairwise call must
|
|
125
|
+
* parse back to exactly the score it produced, and a drift means the parser (or the prompt format)
|
|
126
|
+
* changed under a stored answer. A top-logprobs call's stored score is an expectation over a token
|
|
127
|
+
* distribution the snapshot does not keep, so a text-channel re-parse is expected to differ and is
|
|
128
|
+
* reported as drift — read those rows as informational, not as regressions.
|
|
129
|
+
*
|
|
130
|
+
* The tag depends on the call: pairwise judging answers `<score_A>`, while progress judging answers
|
|
131
|
+
* `<c1>..<cN>` and the trace stores the LAST checkpoint, inverted (progress runs A = nothing done
|
|
132
|
+
* .. T = certainly done, the reverse of the pairwise scale). A call with neither tag is a route
|
|
133
|
+
* classification, i.e. not a score at all.
|
|
134
|
+
* @param calls - captured calls with their raw answers.
|
|
135
|
+
* @returns One row per call.
|
|
136
|
+
*/
|
|
137
|
+
export function replayDecisionScores(calls: ReadonlyArray<{ label: string; channel: string; output: string; score?: number }>): DecisionReplayRow[] {
|
|
138
|
+
return calls.map(call => {
|
|
139
|
+
const completion = { text: call.output, tokens: [], positions: [] }
|
|
140
|
+
const progressTags = [...call.output.matchAll(/<c(\d+)>/gu)].map(match => '<c' + match[1] + '>')
|
|
141
|
+
const tagged = call.output.includes('<score_A>') || progressTags.length > 0
|
|
142
|
+
let reparsed: number | undefined
|
|
143
|
+
if (tagged) {
|
|
144
|
+
try {
|
|
145
|
+
reparsed = call.output.includes('<score_A>')
|
|
146
|
+
? extractScore(completion, '<score_A>')
|
|
147
|
+
: 1 - extractScore(completion, progressTags[progressTags.length - 1]!)
|
|
148
|
+
} catch { reparsed = undefined }
|
|
149
|
+
}
|
|
150
|
+
const head = { label: call.label, channel: call.channel, ...(call.score !== undefined ? { stored: call.score } : {}) }
|
|
151
|
+
if (!tagged) return { ...head, mode: 'not-scored' as const }
|
|
152
|
+
if (reparsed === undefined) return { ...head, mode: 'unreadable' as const }
|
|
153
|
+
return { ...head, reparsed, mode: call.score !== undefined && Math.abs(call.score - reparsed) < 1e-9 ? 'match' as const : 'drift' as const }
|
|
154
|
+
})
|
|
155
|
+
}
|
package/src/router.test.ts
CHANGED
|
@@ -13,10 +13,18 @@ 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
|
+
/** The full-output evidence block of one rendered step, i.e. everything before its digest. */
|
|
17
|
+
function evidenceBlock(step: string): string {
|
|
18
|
+
const start = step.indexOf('Latest observed tool output')
|
|
19
|
+
if (start < 0) return ''
|
|
20
|
+
const rest = step.slice(start)
|
|
21
|
+
const end = rest.indexOf('\n\n')
|
|
22
|
+
return end < 0 ? rest : rest.slice(0, end)
|
|
23
|
+
}
|
|
16
24
|
function assistant(value: ReturnType<typeof session>, text: string, turn = 1, step = 1) {
|
|
17
25
|
value.append('assistant/message', { turn, step, message: createAssistantMessage({ content: [{ type: 'text', text }], source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) }, { surfaceOp: 'append' })
|
|
18
26
|
}
|
|
19
|
-
const policy: RouterPolicy = { mode: 'smart', minConfidence: .9, maxCandidates: 8,
|
|
27
|
+
const policy: RouterPolicy = { mode: 'smart', minConfidence: .9, maxCandidates: 8, maxRoutePerTask: 5, maxRoutePerSession: 20, maxFinalPerTask: 2, maxFinalPerSession: 8, maxModelCallsPerTask: 48, maxModelCallsPerSession: 160, maxInputChars: 60000, maxItemChars: 20000 }
|
|
20
28
|
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) })) })
|
|
21
29
|
|
|
22
30
|
describe('production structured routing', () => {
|
|
@@ -162,6 +170,193 @@ describe('production structured routing', () => {
|
|
|
162
170
|
expect(decision.steps[1]).not.toContain('counts:')
|
|
163
171
|
})
|
|
164
172
|
|
|
173
|
+
it('never attaches a deliverable presentation as checkpoint evidence', () => {
|
|
174
|
+
// Regression (live session, four routes in a row): `present` is the last call of a
|
|
175
|
+
// turn, so the newest "observed tool output" reaching the judge was the wrapper's
|
|
176
|
+
// `presented: 1` — the paths the agent had just declared — while the typecheck and
|
|
177
|
+
// test run sat one call earlier. The judge's own rule ("a state without real
|
|
178
|
+
// verification should not exceed K") then capped the newest checkpoint at exactly
|
|
179
|
+
// K = 52.6% against a 0.8 threshold, so every route steered "continue the
|
|
180
|
+
// unfinished work" for work that was finished and verified, until the per-task
|
|
181
|
+
// route budget ran out.
|
|
182
|
+
const value = session()
|
|
183
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
184
|
+
tool(value, 'pwsh', 'verify', 'TYPECHECK_EXIT=0\nTests 309 passed')
|
|
185
|
+
value.append('tool/call', { turn: 1, step: 1, callId: 'wrap' as never, name: 'run_code', arguments: '{}' })
|
|
186
|
+
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)
|
|
187
|
+
value.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'wrap' as never, content: [{ type: 'text', text: 'presented: 1' }], isError: false }) }, { surfaceOp: 'append' })
|
|
188
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
189
|
+
const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
190
|
+
expect(decision?.kind).toBe('track')
|
|
191
|
+
if (decision?.kind !== 'track') return
|
|
192
|
+
// The verification run is older than the presentation and must still win the
|
|
193
|
+
// evidence slot; the presentation survives only as one line of digest context.
|
|
194
|
+
const step = decision.steps[1]!
|
|
195
|
+
expect(evidenceBlock(step)).toContain('309 passed')
|
|
196
|
+
expect(evidenceBlock(step)).not.toContain('Presented C:')
|
|
197
|
+
expect(evidenceBlock(step)).not.toContain('presented: 1')
|
|
198
|
+
expect(step).toContain('Recent tool results')
|
|
199
|
+
expect(step).toContain('present: Presented C:')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('shows the newest verification run when the newest output is not one', () => {
|
|
203
|
+
// Regression (live session): the full suite passed, then 88 tool calls closed issues
|
|
204
|
+
// and printed a status summary. The newest checkpoint carried only that summary, so
|
|
205
|
+
// the judge applied its own "no real verification" ceiling — exactly K = 52.6%
|
|
206
|
+
// against a 0.8 threshold — and the route steered "continue" for finished work.
|
|
207
|
+
const value = session()
|
|
208
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
209
|
+
tool(value, 'pwsh', 'verify', 'Test Files 10 passed (10)\n Tests 86 passed (86)')
|
|
210
|
+
tool(value, 'pwsh', 'close', '1517 closed completed')
|
|
211
|
+
tool(value, 'pwsh', 'status', '--- ahead/behind origin/dev ---\n0\t0\nlib-artifact-check: OK')
|
|
212
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
213
|
+
const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
214
|
+
expect(decision?.kind).toBe('track')
|
|
215
|
+
if (decision?.kind !== 'track') return
|
|
216
|
+
const step = decision.steps[1]!
|
|
217
|
+
// The newest output stays the primary evidence...
|
|
218
|
+
expect(step).toContain('lib-artifact-check: OK')
|
|
219
|
+
// ...and the run it was hiding comes back, with how much happened after it.
|
|
220
|
+
expect(step).toContain('Latest observed verification run')
|
|
221
|
+
expect(step).toContain('86 passed')
|
|
222
|
+
expect(step).toContain('tool result(s) since')
|
|
223
|
+
// Historical checkpoints never grow this block: they describe past states.
|
|
224
|
+
expect(decision.steps[0]).not.toContain('Latest observed verification run')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('does not repeat a verification run that is already the newest output', () => {
|
|
228
|
+
const value = session()
|
|
229
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
230
|
+
tool(value, 'pwsh', 'verify', 'Test Files 1 passed (1)\n Tests 9 passed (9)')
|
|
231
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
232
|
+
const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
233
|
+
expect(decision?.kind).toBe('track')
|
|
234
|
+
if (decision?.kind !== 'track') return
|
|
235
|
+
expect(decision.steps[1]).toContain('Latest observed tool output at routing time')
|
|
236
|
+
expect(decision.steps[1]).not.toContain('Latest observed verification run')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('leaves the checkpoints alone when the task never verified anything', () => {
|
|
240
|
+
const value = session()
|
|
241
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
242
|
+
tool(value, 'pwsh', 'edit', 'The file has been updated successfully.')
|
|
243
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
244
|
+
const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
245
|
+
expect(decision?.kind).toBe('track')
|
|
246
|
+
if (decision?.kind !== 'track') return
|
|
247
|
+
expect(decision.steps[1]).toContain('The file has been updated successfully.')
|
|
248
|
+
expect(decision.steps[1]).not.toContain('Latest observed verification run')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('keeps the extra verification block inside the checkpoint caps', () => {
|
|
252
|
+
const value = session()
|
|
253
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
254
|
+
tool(value, 'pwsh', 'verify', 'Test Files 10 passed (10)\n' + 'T'.repeat(4000))
|
|
255
|
+
tool(value, 'pwsh', 'tail', 'Z'.repeat(4000))
|
|
256
|
+
assistant(value, 'Q'.repeat(4000))
|
|
257
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
258
|
+
for (const maxItemChars of [128, 200, 500, 2000, 20000]) {
|
|
259
|
+
const maxInputChars = Math.max(60000, maxItemChars * 2)
|
|
260
|
+
const decision = analyzeStructuredRoute(value.events, 8, maxItemChars, maxInputChars)
|
|
261
|
+
expect(decision, 'maxItemChars=' + maxItemChars).toMatchObject({ kind: 'track' })
|
|
262
|
+
if (decision?.kind !== 'track') continue
|
|
263
|
+
for (const step of decision.steps) expect(step.length, 'maxItemChars=' + maxItemChars).toBeLessThanOrEqual(maxItemChars)
|
|
264
|
+
expect(decision.steps.reduce((sum, step) => sum + step.length, 0), 'maxItemChars=' + maxItemChars).toBeLessThanOrEqual(maxInputChars)
|
|
265
|
+
expect(boundDecision(decision, { ...policy, maxItemChars, maxInputChars }), 'maxItemChars=' + maxItemChars).toBeDefined()
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('never attaches coordination output as checkpoint evidence', () => {
|
|
270
|
+
// The same rule as `present`, applied to the rest of the agent's own control
|
|
271
|
+
// surface: every one of these runs AFTER the work, so as the newest result it hides
|
|
272
|
+
// the verification run and the judge caps the checkpoint at K — the ceiling its own
|
|
273
|
+
// prompt sets for "no real verification" — against the 0.8 progress threshold.
|
|
274
|
+
const value = session()
|
|
275
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
276
|
+
tool(value, 'pwsh', 'verify', 'TYPECHECK_EXIT=0\nTests 309 passed')
|
|
277
|
+
tool(value, 'job_list', 'jobs', '(no background jobs)')
|
|
278
|
+
tool(value, 'job_kill', 'kill', 'requested cancellation of job job-1')
|
|
279
|
+
tool(value, 'send_message', 'send', '{"messageId":"m1"}')
|
|
280
|
+
tool(value, 'list_subagent_models', 'models', 'antigravity/gemini-3.8-flash — Gemini 3.8 Flash')
|
|
281
|
+
// A previous verdict is not observed work output either: grading progress from it
|
|
282
|
+
// would be the judge grading itself.
|
|
283
|
+
tool(value, 'verifier_current_session', 'verdict', '{"winner":"A","score":1,"baselineScore":0,"threshold":0.65}')
|
|
284
|
+
tool(value, 'subagent', 'child', 'started subagent 042f004d-fe19-4ccb-8b98-f08a00b32e87')
|
|
285
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
286
|
+
const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
287
|
+
expect(decision?.kind).toBe('track')
|
|
288
|
+
if (decision?.kind !== 'track') return
|
|
289
|
+
const step = decision.steps[1]!
|
|
290
|
+
expect(evidenceBlock(step)).toContain('309 passed')
|
|
291
|
+
for (const marker of ['(no background jobs)', 'requested cancellation', 'messageId', 'Gemini 3.8 Flash', '"baselineScore"', 'started subagent']) {
|
|
292
|
+
expect(evidenceBlock(step), marker).not.toContain(marker)
|
|
293
|
+
}
|
|
294
|
+
// The digest still shows them as one-line context — that tail is what the judge has
|
|
295
|
+
// to weigh — but a previous verdict never appears at all.
|
|
296
|
+
expect(step).toContain('Recent tool results')
|
|
297
|
+
expect(step).toContain('(no background jobs)')
|
|
298
|
+
expect(step).not.toContain('verifier_current_session')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('summarises the recent calls in one line each, newest last', () => {
|
|
302
|
+
// The verification block says HOW MUCH happened after the run; the digest says WHAT,
|
|
303
|
+
// so a judge can tell "only issue bookkeeping since" from "twelve writes since".
|
|
304
|
+
const value = session()
|
|
305
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
306
|
+
tool(value, 'pwsh', 'verify', 'Test Files 3 passed (3)\n Tests 12 passed (12)')
|
|
307
|
+
tool(value, 'pwsh', 'close', '1517 closed completed')
|
|
308
|
+
tool(value, 'todo_write', 'todos', 'Updated todo list: 0 pending, 0 in progress, 13 completed.')
|
|
309
|
+
tool(value, 'present', 'present', 'Presented C:\\repo\\src\\mapper.ts')
|
|
310
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
311
|
+
const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
312
|
+
expect(decision?.kind).toBe('track')
|
|
313
|
+
if (decision?.kind !== 'track') return
|
|
314
|
+
const step = decision.steps[1]!
|
|
315
|
+
const digest = step.slice(step.indexOf('Recent tool results'))
|
|
316
|
+
expect(digest).toContain('pwsh: 1517 closed completed')
|
|
317
|
+
expect(digest).toContain('todo_write: Updated todo list')
|
|
318
|
+
expect(digest).toContain('present: Presented C:')
|
|
319
|
+
// Newest last, and the two calls already rendered in full are marked, not repeated.
|
|
320
|
+
expect(digest.indexOf('1517 closed completed')).toBeLessThan(digest.indexOf('present: Presented C:'))
|
|
321
|
+
expect(digest).toContain('[shown above]')
|
|
322
|
+
// Historical checkpoints describe past states and never grow the digest.
|
|
323
|
+
expect(decision.steps[0]).not.toContain('Recent tool results')
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('keeps a foreground subagent report as checkpoint evidence', () => {
|
|
327
|
+
// Only the start acknowledgement is coordination. A foreground child returns the
|
|
328
|
+
// report it was asked for, which for delegated work is the deliverable itself.
|
|
329
|
+
const value = session()
|
|
330
|
+
value.append('todo/write', { todos: [{ content: 'Investigate', status: 'in_progress' }, { content: 'Report', status: 'pending' }] })
|
|
331
|
+
tool(value, 'subagent', 'child', 'Report: the mapper drops image parts when resolveRequestImages is undefined.')
|
|
332
|
+
value.append('todo/write', { todos: [{ content: 'Investigate', status: 'completed' }, { content: 'Report', status: 'completed' }] })
|
|
333
|
+
const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
334
|
+
expect(decision?.kind).toBe('track')
|
|
335
|
+
if (decision?.kind !== 'track') return
|
|
336
|
+
expect(decision.steps[1]).toContain('the mapper drops image parts')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('treats a wrapper that only started a background child as coordination', () => {
|
|
340
|
+
// In the PTC preset the picked result is the wrapper's own text, so the
|
|
341
|
+
// acknowledgement has to be recognised on the dispatch as well.
|
|
342
|
+
const value = session()
|
|
343
|
+
tool(value, 'pwsh', 'verify', 'all tests passed: 91 passed')
|
|
344
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
345
|
+
value.append('tool/call', { turn: 1, step: 1, callId: 'wrap' as never, name: 'run_code', arguments: '{}' })
|
|
346
|
+
value.append('tool/ptc-dispatch' as never, { rootCallId: 'wrap', subCallId: 'wrap:ptc:1', name: 'subagent', arguments: '{}', isError: false, content: [{ type: 'text', text: 'started subagent 042f004d' }] } as never)
|
|
347
|
+
value.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'wrap' as never, content: [{ type: 'text', text: 'started subagent 042f004d' }], isError: false }) }, { surfaceOp: 'append' })
|
|
348
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
349
|
+
const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
350
|
+
expect(decision?.kind).toBe('track')
|
|
351
|
+
if (decision?.kind !== 'track') return
|
|
352
|
+
const step = decision.steps[1]!
|
|
353
|
+
expect(evidenceBlock(step)).toContain('all tests passed')
|
|
354
|
+
expect(evidenceBlock(step)).not.toContain('started subagent')
|
|
355
|
+
// It is still visible as digest context: the point is not to hide the call, it is to
|
|
356
|
+
// stop an acknowledgement from occupying the evidence slot.
|
|
357
|
+
expect(step).toContain('started subagent')
|
|
358
|
+
})
|
|
359
|
+
|
|
165
360
|
it('keeps a wrapper that dispatched real work', () => {
|
|
166
361
|
const value = session()
|
|
167
362
|
value.append('tool/call', { turn: 1, step: 1, callId: 'wrap' as never, name: 'run_code', arguments: '{}' })
|
|
@@ -236,10 +431,18 @@ describe('semantic evidence references', () => {
|
|
|
236
431
|
tool(value, 'subagent', 'a', 'candidate A from a real subagent')
|
|
237
432
|
tool(value, 'create_goal', 'goal', '{"goal":{"id":"g1","phase":"active"}}')
|
|
238
433
|
tool(value, 'skill', 'skill', 'loaded review skill body')
|
|
434
|
+
tool(value, 'present', 'present', 'Presented C:\\repo\\src\\mapper.ts')
|
|
435
|
+
tool(value, 'job_list', 'jobs', '(no background jobs)')
|
|
436
|
+
tool(value, 'verifier_current_session', 'verdict', '{"winner":"A","score":1}')
|
|
437
|
+
tool(value, 'subagent', 'child-bg', 'started subagent 042f004d')
|
|
239
438
|
const prompt = buildSemanticRoutePrompt('pick the better one', value.events, 8, 20000, 60000)
|
|
240
439
|
expect(prompt).toContain('candidate A from a real subagent')
|
|
241
440
|
expect(prompt).not.toContain('"phase":"active"')
|
|
242
441
|
expect(prompt).not.toContain('loaded review skill body')
|
|
442
|
+
expect(prompt).not.toContain('Presented C:')
|
|
443
|
+
expect(prompt).not.toContain('(no background jobs)')
|
|
444
|
+
expect(prompt).not.toContain('"winner":"A"')
|
|
445
|
+
expect(prompt).not.toContain('started subagent')
|
|
243
446
|
})
|
|
244
447
|
|
|
245
448
|
it('rejects a semantic decision that cites bookkeeping evidence', () => {
|
|
@@ -396,6 +599,36 @@ describe('transactional router state', () => {
|
|
|
396
599
|
const final = router.reserve(agent, 'final', 'final', 4, policy)!
|
|
397
600
|
expect(router.commit(agent, final)).toBe(true); expect(router.finalRequired(agent)).toBeUndefined()
|
|
398
601
|
})
|
|
602
|
+
it('reserves final-verification budget independently of routing attempts', () => {
|
|
603
|
+
const value = session(); const agent = { id: value.id, session: value }; const router = new AutoVerifierRouter()
|
|
604
|
+
// Routing gets exactly its own cap: exhausting it must not refuse the armed final gate.
|
|
605
|
+
const budget = { ...policy, maxRoutePerTask: 1, maxFinalPerTask: 2 }
|
|
606
|
+
const first = router.reserve(agent, 'track', 'route-1', 1, budget)
|
|
607
|
+
expect(first).toBeDefined()
|
|
608
|
+
expect(router.commit(agent, first!, 7)).toBe(true)
|
|
609
|
+
expect(router.finalRequired(agent)).toBe(7)
|
|
610
|
+
expect(router.reserve(agent, 'track', 'route-2', 1, budget)).toBeUndefined()
|
|
611
|
+
expect(router.budgetExhausted(agent, 1, budget)).toBe(true)
|
|
612
|
+
const final = router.reserve(agent, 'final', 'final-1', 4, budget)
|
|
613
|
+
expect(final).toBeDefined()
|
|
614
|
+
expect(router.commit(agent, final!)).toBe(true)
|
|
615
|
+
expect(router.finalRequired(agent)).toBeUndefined()
|
|
616
|
+
})
|
|
617
|
+
it('prefers the final gate after a track route clears the completion threshold', () => {
|
|
618
|
+
const value = session(); const agent = { id: value.id, session: value }; const router = new AutoVerifierRouter()
|
|
619
|
+
expect(router.finalPreferred(agent)).toBe(false)
|
|
620
|
+
const track = router.reserve(agent, 'track', 'track-pass', 3, policy)!
|
|
621
|
+
expect(router.commit(agent, track, 9)).toBe(true)
|
|
622
|
+
router.preferFinal(agent)
|
|
623
|
+
expect(router.finalPreferred(agent)).toBe(true)
|
|
624
|
+
// Reserving the final attempt consumes the preference, and a failed acceptance must
|
|
625
|
+
// release routing again rather than disabling it for the rest of the task.
|
|
626
|
+
const final = router.reserve(agent, 'final', 'final', 4, policy)!
|
|
627
|
+
expect(router.finalPreferred(agent)).toBe(false)
|
|
628
|
+
router.fail(agent, final, false)
|
|
629
|
+
expect(router.finalPreferred(agent)).toBe(false)
|
|
630
|
+
expect(router.reserve(agent, 'track', 'route-after-failure', 1, policy)).toBeDefined()
|
|
631
|
+
})
|
|
399
632
|
it('does not arm final verification when a plan pre-review passes', () => {
|
|
400
633
|
const value = session(); const agent = { id: value.id, session: value }; const router = new AutoVerifierRouter()
|
|
401
634
|
const plan = router.reserve(agent, 'plan_review', 'plan', 1, policy)!
|
|
@@ -442,6 +675,12 @@ describe('transactional router state', () => {
|
|
|
442
675
|
expect(routedRepeats(select, 3)).toBe(3)
|
|
443
676
|
expect(routedRepeats(track, 1)).toBe(1)
|
|
444
677
|
expect(routedRepeats(track, 3)).toBe(3)
|
|
678
|
+
// track carries its own count: with the explicit-tag channel one call samples ONE
|
|
679
|
+
// letter (5.3% of the A-T scale), so repeats are averaged to keep sampling noise from
|
|
680
|
+
// flipping the progress curve between bands.
|
|
681
|
+
expect(routedRepeats(track, 1, 3)).toBe(3)
|
|
682
|
+
expect(routedRepeats(compare, 1, 3)).toBe(2)
|
|
683
|
+
expect(routedRepeats(select, 1, 3)).toBe(1)
|
|
445
684
|
})
|
|
446
685
|
|
|
447
686
|
it('estimates routed calls from the real tournament shape', () => {
|