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
package/src/router.ts
CHANGED
|
@@ -44,8 +44,20 @@ export interface RouterPolicy {
|
|
|
44
44
|
mode: AutoVerifyMode
|
|
45
45
|
minConfidence: number
|
|
46
46
|
maxCandidates: number
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Automatic route attempts (semantic classification, plan pre-review, team-task gate and
|
|
49
|
+
* compare/select/track) allowed within one task and one session.
|
|
50
|
+
*
|
|
51
|
+
* Routing and the final acceptance are metered by SEPARATE counters on purpose. They used
|
|
52
|
+
* to share one, which let routing spend the final gate's share: the gate is mandatory once
|
|
53
|
+
* armed (finalRequiredFromSeq), so a busy task could exhaust the shared counter on routes
|
|
54
|
+
* and then close the turn with the gate never having run.
|
|
55
|
+
*/
|
|
56
|
+
maxRoutePerTask: number
|
|
57
|
+
maxRoutePerSession: number
|
|
58
|
+
/** Attempts reserved for the final acceptance; routing can never spend these. */
|
|
59
|
+
maxFinalPerTask: number
|
|
60
|
+
maxFinalPerSession: number
|
|
49
61
|
maxModelCallsPerTask: number
|
|
50
62
|
maxModelCallsPerSession: number
|
|
51
63
|
maxInputChars: number
|
|
@@ -55,14 +67,22 @@ export interface RouterPolicy {
|
|
|
55
67
|
interface Reservation { id: string; phase: RoutePhase; fingerprint: string; taskStartSeq: number }
|
|
56
68
|
interface RouterState {
|
|
57
69
|
taskStartSeq: number
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
routeAttempts: number
|
|
71
|
+
finalAttempts: number
|
|
72
|
+
sessionRouteAttempts: number
|
|
73
|
+
sessionFinalAttempts: number
|
|
60
74
|
taskModelCalls: number
|
|
61
75
|
sessionModelCalls: number
|
|
62
76
|
completed: Set<string>
|
|
63
77
|
failed: Set<string>
|
|
64
78
|
inFlight?: Reservation
|
|
65
79
|
finalRequiredFromSeq?: number
|
|
80
|
+
/**
|
|
81
|
+
* Armed when a track route already cleared the completion threshold. The next stop
|
|
82
|
+
* boundary then skips automatic routing and runs the mandatory final gate instead of
|
|
83
|
+
* paying for another progress route whose steered text would be identical.
|
|
84
|
+
*/
|
|
85
|
+
finalPreferred: boolean
|
|
66
86
|
strictBlocked: boolean
|
|
67
87
|
}
|
|
68
88
|
|
|
@@ -163,11 +183,12 @@ export function buildEvidenceIndex(events: readonly SessionEvent[]): EvidenceInd
|
|
|
163
183
|
// Track the rolling view of team tasks across the session
|
|
164
184
|
const currentTeamTasks = new Map<string, TeamTaskItem>()
|
|
165
185
|
let narration: { seq: number; text: string } | undefined
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
186
|
+
// What each wrapper call (a `run_code` program) dispatched, keyed by the wrapper's
|
|
187
|
+
// callId: how many sub-calls it made and whether any of them produced evidence.
|
|
188
|
+
// Session V3 carries rootCallId on the dispatch event, which is the only reliable
|
|
189
|
+
// parent link: the wrapper's own result text is a concatenation of its sub-results,
|
|
190
|
+
// so the tool name of the wrapper says nothing about what it did.
|
|
191
|
+
const dispatchedWork = new Map<string, { count: number; evidence: boolean }>()
|
|
171
192
|
|
|
172
193
|
for (const rawEvent of relevant) {
|
|
173
194
|
const event = rawEvent as unknown as { type: string; seq: number; data: any }
|
|
@@ -192,9 +213,10 @@ export function buildEvidenceIndex(events: readonly SessionEvent[]): EvidenceInd
|
|
|
192
213
|
const data = event.data as { rootCallId?: string; subCallId?: string; name: string; isError?: boolean; content?: readonly ContentBlock[] }
|
|
193
214
|
const rootCallId = typeof data.rootCallId === 'string' && data.rootCallId ? data.rootCallId : undefined
|
|
194
215
|
if (rootCallId) {
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
216
|
+
const entry = dispatchedWork.get(rootCallId) ?? { count: 0, evidence: false }
|
|
217
|
+
entry.count += 1
|
|
218
|
+
if (isEvidenceOutput(data.name, Array.isArray(data.content) ? blockText(data.content) : '')) entry.evidence = true
|
|
219
|
+
dispatchedWork.set(rootCallId, entry)
|
|
198
220
|
}
|
|
199
221
|
const isOk = data.isError !== true && (!Array.isArray(data.content) || data.content.every(b => (b as { isError?: boolean }).isError !== true))
|
|
200
222
|
if (isOk) {
|
|
@@ -208,11 +230,12 @@ export function buildEvidenceIndex(events: readonly SessionEvent[]): EvidenceInd
|
|
|
208
230
|
for (const [callId, call] of calls) {
|
|
209
231
|
const result = results.get(callId)
|
|
210
232
|
if (!result) continue
|
|
211
|
-
// A wrapper
|
|
212
|
-
// (
|
|
213
|
-
// nested dispatches stay in the index under
|
|
214
|
-
// the same program is still available
|
|
215
|
-
|
|
233
|
+
// A wrapper whose every dispatch was bookkeeping/coordination carries only their
|
|
234
|
+
// payloads (the todo list, or a "started subagent" acknowledgement, echoed straight
|
|
235
|
+
// back), so it is coordination too. Its nested dispatches stay in the index under
|
|
236
|
+
// their own names, so real work done by the same program is still available.
|
|
237
|
+
const dispatched = dispatchedWork.get(callId)
|
|
238
|
+
if (dispatched !== undefined && dispatched.count > 0 && !dispatched.evidence) continue
|
|
216
239
|
paired.set(callId, { name: call.data.name, callSeq: call.seq, resultSeq: result.seq, text: blockText(result.data.message.content) })
|
|
217
240
|
}
|
|
218
241
|
return { problemSeq: taskStartSeq, calls: paired, todos, teamTasks, narration }
|
|
@@ -268,16 +291,188 @@ function canonicalTodoSnapshots(index: EvidenceIndex): Array<{ seq: number; todo
|
|
|
268
291
|
* A checkpoint already renders the todo/team snapshot these tools wrote, so their
|
|
269
292
|
* own result repeats it while displacing the real work output that came just before
|
|
270
293
|
* them.
|
|
294
|
+
*
|
|
295
|
+
* `present` belongs here for the same reason: declaring deliverables produces no
|
|
296
|
+
* independent output, and it is always the LAST call of a turn. A live session ran
|
|
297
|
+
* typecheck + the full suite, committed, then presented — and every stop boundary
|
|
298
|
+
* showed the judge "Latest observed tool output at routing time (run_code):
|
|
299
|
+
* presented: 8" instead of the verification run one call earlier. Following its own
|
|
300
|
+
* rule ("a state without real verification should not exceed K"), the judge capped
|
|
301
|
+
* the newest checkpoint at exactly K = 52.6% against a 0.8 threshold on four
|
|
302
|
+
* consecutive routes, so each one steered "continue the unfinished work" for work
|
|
303
|
+
* that was finished and verified. The `deliverables/presented` event still records
|
|
304
|
+
* what was presented; the tool result adds nothing the judge can grade.
|
|
271
305
|
*/
|
|
272
306
|
const BOOKKEEPING_TOOLS = new Set([
|
|
273
|
-
'todo_write', 'create_goal', 'get_goal', 'update_goal', 'interrupt_agent', 'list_agents', 'exit_plan_mode', 'skill',
|
|
307
|
+
'todo_write', 'create_goal', 'get_goal', 'update_goal', 'interrupt_agent', 'list_agents', 'exit_plan_mode', 'skill', 'present',
|
|
308
|
+
'job_list', 'job_kill', 'list_subagent_models', 'send_message',
|
|
274
309
|
])
|
|
275
310
|
|
|
276
|
-
/**
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
311
|
+
/**
|
|
312
|
+
* The plugin's own verdict tools.
|
|
313
|
+
*
|
|
314
|
+
* They must stay in the evidence index — {@link successfulExplicitKinds} reads it so an
|
|
315
|
+
* explicit route is not re-run automatically — but they are never rendered as observed
|
|
316
|
+
* work output: a judge grading progress from an earlier verdict would be grading itself,
|
|
317
|
+
* and a stale "passed" would grade as if the work behind it still stood.
|
|
318
|
+
*/
|
|
319
|
+
const VERIFIER_EVIDENCE_TOOLS = new Set(['verifier_compare', 'verifier_select', 'verifier_track', 'verifier_best_of_n', 'verifier_current_session'])
|
|
320
|
+
|
|
321
|
+
/** Tools that return either a child's report (evidence) or a bare start acknowledgement (not evidence). */
|
|
322
|
+
const SUBAGENT_TOOLS = new Set(['subagent', 'subagent_fork'])
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Start acknowledgement of a background child.
|
|
326
|
+
*
|
|
327
|
+
* `subagent`/`subagent_fork` cannot be excluded by name: a foreground call returns the
|
|
328
|
+
* child's report, a background one returns exactly `started subagent <childId>` (or
|
|
329
|
+
* `started background subagent job <id>`), which hides the output the child was asked to
|
|
330
|
+
* produce while looking like the newest "observed" result of the task.
|
|
331
|
+
*/
|
|
332
|
+
const CHILD_START_ACKNOWLEDGEMENT = /^started (?:background )?subagent\b/u
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Whether one settled tool result is evidence of work rather than coordination output.
|
|
336
|
+
*
|
|
337
|
+
* Single definition for every site that renders or offers evidence, so the checkpoint
|
|
338
|
+
* picker and the semantic router cannot disagree about what counts — the same
|
|
339
|
+
* disagreement produced the K-cap loop described on {@link BOOKKEEPING_TOOLS}.
|
|
340
|
+
* @param name - tool name that produced the result.
|
|
341
|
+
* @param text - rendered result text.
|
|
342
|
+
* @returns True when the result may be shown to a judge as observed output.
|
|
343
|
+
*/
|
|
344
|
+
function isEvidenceOutput(name: string, text: string): boolean {
|
|
345
|
+
if (BOOKKEEPING_TOOLS.has(name) || VERIFIER_EVIDENCE_TOOLS.has(name)) return false
|
|
346
|
+
const body = text.trim()
|
|
347
|
+
if (!body) return false
|
|
348
|
+
return !(SUBAGENT_TOOLS.has(name) && CHILD_START_ACKNOWLEDGEMENT.test(body))
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Signatures of an output that reports verification results. */
|
|
352
|
+
const VERIFICATION_SIGNATURES: readonly RegExp[] = [
|
|
353
|
+
/\bTest Files\s+\d+/u,
|
|
354
|
+
/\bTests?\s*:?\s*\d+\s+(?:passed|failed|skipped|todo)/iu,
|
|
355
|
+
/\bTest Suites?:\s*\d+/u,
|
|
356
|
+
/\b\d+\s+passed\b/u,
|
|
357
|
+
/\b(?:all\s+)?tests?\s+passed\b/iu,
|
|
358
|
+
/^\s*(?:ok|FAIL|PASS)\s+\S+/mu,
|
|
359
|
+
/\b(?:TEST|TYPECHECK|BUILD|LINT|CHECK|GATE)_EXIT\s*[:=]\s*0\b/u,
|
|
360
|
+
]
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Whether an output looks like a test/typecheck/build run reporting its result.
|
|
364
|
+
*
|
|
365
|
+
* Only decides whether one extra evidence block is worth rendering. A miss degrades to
|
|
366
|
+
* the single-output rendering the checkpoints always had, and a false positive shows the
|
|
367
|
+
* judge one more observed result — neither can invent evidence.
|
|
368
|
+
* @param text - rendered tool result.
|
|
369
|
+
* @returns True when the text carries a runner-shaped summary.
|
|
370
|
+
*/
|
|
371
|
+
function looksLikeVerificationRun(text: string): boolean {
|
|
372
|
+
return VERIFICATION_SIGNATURES.some(pattern => pattern.test(text))
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* How much settled tool work followed one call, as a short histogram.
|
|
377
|
+
*
|
|
378
|
+
* Lets the judge decide whether a verification run still covers the current state
|
|
379
|
+
* ("nothing but issue replies since" versus "12 writes since") instead of guessing.
|
|
380
|
+
* Counts come from the evidence index, so a wrapper and its dispatches are each counted
|
|
381
|
+
* as the results they produced.
|
|
382
|
+
* @param index - evidence index of the current task.
|
|
383
|
+
* @param call - the call to measure from.
|
|
384
|
+
* @param maxChars - hard cap for the summary.
|
|
385
|
+
* @returns The summary text, or '' when nothing followed.
|
|
386
|
+
*/
|
|
387
|
+
function trailingSummary(index: EvidenceIndex, call: EvidenceCall, maxChars: number): string {
|
|
388
|
+
const counts = new Map<string, number>()
|
|
389
|
+
let total = 0
|
|
390
|
+
for (const other of index.calls.values()) {
|
|
391
|
+
if (other.resultSeq <= call.resultSeq) continue
|
|
392
|
+
total += 1
|
|
393
|
+
counts.set(other.name, (counts.get(other.name) ?? 0) + 1)
|
|
394
|
+
}
|
|
395
|
+
if (total === 0) return ''
|
|
396
|
+
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
|
|
397
|
+
const summary = total + ' tool result(s) since, ' + ranked.slice(0, 4).map(([name, count]) => name + ' ×' + count).join(', ') + (ranked.length > 4 ? ', …' : '')
|
|
398
|
+
return summary.length <= maxChars ? summary : summary.slice(0, Math.max(1, maxChars - 1)) + '…'
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* One extra block for the newest verification run in the task.
|
|
403
|
+
*
|
|
404
|
+
* The judge's own rule caps a state whose evidence carries no verification (see
|
|
405
|
+
* {@link BOOKKEEPING_TOOLS}), but only ONE output fits per checkpoint — and the last
|
|
406
|
+
* call of a task is rarely the test run. A live session ran the full suite and then
|
|
407
|
+
* spent 88 tool calls closing issues and printing a status summary: the newest
|
|
408
|
+
* checkpoint showed only that summary, the judge answered exactly K = 52.6% against a
|
|
409
|
+
* 0.8 threshold, and the route steered "continue the unfinished work" for work that was
|
|
410
|
+
* finished and verified. Putting the newest verification run back in front of the judge
|
|
411
|
+
* restores evidence the session really produced; it does not loosen the threshold.
|
|
412
|
+
* @param index - evidence index of the current task.
|
|
413
|
+
* @param newest - the call already rendered as the newest output, if any.
|
|
414
|
+
* @param budget - maximum characters this block may occupy.
|
|
415
|
+
* @returns The labelled block and the run it came from, or an empty block.
|
|
416
|
+
*/
|
|
417
|
+
function verificationEvidence(index: EvidenceIndex, newest: EvidenceCall | undefined, budget: number): { text: string; call: EvidenceCall | undefined } {
|
|
418
|
+
if (budget < 128) return { text: '', call: undefined }
|
|
419
|
+
// A freshly observed run already sits in the newest block; showing an older one as
|
|
420
|
+
// well would only add noise (and could read as two independent verifications).
|
|
421
|
+
if (newest !== undefined && looksLikeVerificationRun(newest.text)) return { text: '', call: undefined }
|
|
422
|
+
let run: EvidenceCall | undefined
|
|
423
|
+
for (const pair of index.calls.values()) {
|
|
424
|
+
if (!isEvidenceOutput(pair.name, pair.text) || !looksLikeVerificationRun(pair.text)) continue
|
|
425
|
+
if (run === undefined || pair.resultSeq > run.resultSeq) run = pair
|
|
426
|
+
}
|
|
427
|
+
if (run === undefined) return { text: '', call: undefined }
|
|
428
|
+
const trailing = trailingSummary(index, run, 200)
|
|
429
|
+
const prefix = '\n\nLatest observed verification run (' + run.name + (trailing === '' ? '' : ' — ' + trailing) + '):\n'
|
|
430
|
+
if (prefix.length >= budget) return { text: '', call: undefined }
|
|
431
|
+
return { text: prefix + sanitizeVerifierText(run.text, budget - prefix.length), call: run }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Upper bound on the one-line-per-call digest attached to the newest checkpoint. */
|
|
435
|
+
const MAX_DIGEST_LINES = 8
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* One line per recent tool result, newest last.
|
|
439
|
+
*
|
|
440
|
+
* The detailed blocks above show the newest output and the newest verification run; a
|
|
441
|
+
* judge deciding whether that verification still covers the current state also needs to
|
|
442
|
+
* know what the calls in between actually did. The trailing histogram on the
|
|
443
|
+
* verification block names the tools, this names the work ("gh api … state=closed",
|
|
444
|
+
* "Updated todo list: 0 pending"), and it deliberately keeps coordination results — they
|
|
445
|
+
* are exactly what a tail of the task looks like.
|
|
446
|
+
*
|
|
447
|
+
* Rendered only for the checkpoint being judged, bounded by its own slice of the same
|
|
448
|
+
* per-item budget, and always keeping the newest lines when the budget is tight.
|
|
449
|
+
* @param index - evidence index of the current task.
|
|
450
|
+
* @param budget - maximum characters this block may occupy.
|
|
451
|
+
* @param shown - calls already rendered in full above, marked instead of dropped.
|
|
452
|
+
* @returns The labelled block, or '' when there is nothing recent to add.
|
|
453
|
+
*/
|
|
454
|
+
function recentEvidenceDigest(index: EvidenceIndex, budget: number, shown: readonly (EvidenceCall | undefined)[]): string {
|
|
455
|
+
if (budget < 128) return ''
|
|
456
|
+
// The plugin's own verdicts stay out even here: a one-line "winner A, score 1" in the
|
|
457
|
+
// judge's context is the self-grading this module refuses everywhere else.
|
|
458
|
+
const recent = [...index.calls.values()]
|
|
459
|
+
.filter(call => !VERIFIER_EVIDENCE_TOOLS.has(call.name))
|
|
460
|
+
.sort((a, b) => a.resultSeq - b.resultSeq)
|
|
461
|
+
.slice(-MAX_DIGEST_LINES)
|
|
462
|
+
if (recent.length === 0) return ''
|
|
463
|
+
const rendered = recent.map(call => {
|
|
464
|
+
const first = call.text.split('\n').map(line => line.trim()).find(line => line.length > 0) ?? ''
|
|
465
|
+
const mark = shown.includes(call) ? ' [shown above]' : ''
|
|
466
|
+
return ' [' + call.resultSeq + '] ' + (call.name + ': ' + first).slice(0, 110) + mark
|
|
467
|
+
})
|
|
468
|
+
const prefix = '\n\nRecent tool results (newest last):\n'
|
|
469
|
+
if (prefix.length >= budget) return ''
|
|
470
|
+
const room = budget - prefix.length
|
|
471
|
+
let kept = rendered
|
|
472
|
+
while (kept.length > 1 && kept.join('\n').length > room) kept = kept.slice(1)
|
|
473
|
+
const omitted = kept.length < rendered.length ? ' …older omitted\n' : ''
|
|
474
|
+
const body = omitted + kept.join('\n')
|
|
475
|
+
return prefix + (body.length > room ? body.slice(body.length - room) : body)
|
|
281
476
|
}
|
|
282
477
|
|
|
283
478
|
/**
|
|
@@ -292,29 +487,31 @@ function onlyBookkeepingDispatches(names: ReadonlySet<string> | undefined): bool
|
|
|
292
487
|
* snapshot they wrote, so their own result repeats it while displacing the real
|
|
293
488
|
* output produced just before them (a live session attached `create_goal`,
|
|
294
489
|
* `update_goal` and `interrupt_agent` output to checkpoints that had already run
|
|
295
|
-
* the task's test suite).
|
|
490
|
+
* the task's test suite). Coordination results are skipped by the same rule — see
|
|
491
|
+
* {@link isEvidenceOutput} — because they are also written after the work.
|
|
296
492
|
* @param index - evidence index of the current task.
|
|
297
493
|
* @param seq - checkpoint sequence number, or `Infinity` for the current state.
|
|
298
494
|
* @param budget - maximum characters the evidence may occupy.
|
|
299
495
|
* @param current - render the newest output in the task instead of the newest one before `seq`.
|
|
300
|
-
* @returns
|
|
496
|
+
* @returns The rendered block and the call it came from, or an empty block.
|
|
301
497
|
*/
|
|
302
|
-
function checkpointEvidence(index: EvidenceIndex, seq: number, budget: number, current = false): string {
|
|
303
|
-
|
|
498
|
+
function checkpointEvidence(index: EvidenceIndex, seq: number, budget: number, current = false): { text: string; call: EvidenceCall | undefined } {
|
|
499
|
+
const empty = { text: '', call: undefined }
|
|
500
|
+
if (budget < 64) return empty
|
|
304
501
|
let latest: EvidenceCall | undefined
|
|
305
502
|
for (const pair of index.calls.values()) {
|
|
306
|
-
if (
|
|
503
|
+
if (!isEvidenceOutput(pair.name, pair.text)) continue
|
|
307
504
|
if (pair.resultSeq <= seq && (latest === undefined || pair.resultSeq > latest.resultSeq)) latest = pair
|
|
308
505
|
}
|
|
309
|
-
if (latest === undefined) return
|
|
506
|
+
if (latest === undefined) return empty
|
|
310
507
|
// The prefix length depends on the tool name, so measure it instead of assuming a
|
|
311
508
|
// fixed overhead: with a long tool name the old "- 60" let the rendered step exceed
|
|
312
509
|
// maxItemChars, and boundDecision() then dropped the whole track decision silently.
|
|
313
510
|
const prefix = current
|
|
314
511
|
? '\n\nLatest observed tool output at routing time (' + latest.name + '):\n'
|
|
315
512
|
: '\n\nLatest observed tool output before this checkpoint (' + latest.name + '):\n'
|
|
316
|
-
if (prefix.length >= budget) return
|
|
317
|
-
return prefix + sanitizeVerifierText(latest.text, budget - prefix.length)
|
|
513
|
+
if (prefix.length >= budget) return empty
|
|
514
|
+
return { text: prefix + sanitizeVerifierText(latest.text, budget - prefix.length), call: latest }
|
|
318
515
|
}
|
|
319
516
|
|
|
320
517
|
/**
|
|
@@ -357,7 +554,11 @@ interface CheckpointSource { seq: number; label: string; body: string }
|
|
|
357
554
|
* the CURRENT state: its evidence is the newest observed output in the task rather
|
|
358
555
|
* than the newest one before the last todo snapshot (which is often several tool calls
|
|
359
556
|
* stale), and it carries the agent's latest prose as an explicitly labelled claim (see
|
|
360
|
-
* {@link currentNarration}), because prose deliverables never reach a tool.
|
|
557
|
+
* {@link currentNarration}), because prose deliverables never reach a tool. It gets one
|
|
558
|
+
* extra slots as well — the newest verification run in the task
|
|
559
|
+
* ({@link verificationEvidence}) and a one-line digest of the recent calls
|
|
560
|
+
* ({@link recentEvidenceDigest}) — because a single output slot cannot show both the
|
|
561
|
+
* tail of the task and the test run that tail is hiding.
|
|
361
562
|
* @param index - evidence index of the current task.
|
|
362
563
|
* @param sources - checkpoints in chronological order.
|
|
363
564
|
* @param maxItemChars - hard per-item cap enforced by boundDecision().
|
|
@@ -379,8 +580,20 @@ function renderCheckpointSteps(index: EvidenceIndex, sources: readonly Checkpoin
|
|
|
379
580
|
// never reaches a tool.
|
|
380
581
|
const isCurrent = position === kept.length - 1
|
|
381
582
|
const observed = checkpointEvidence(index, isCurrent ? Number.POSITIVE_INFINITY : source.seq, isCurrent ? Math.floor(evidenceBudget / 2) : evidenceBudget, isCurrent)
|
|
382
|
-
|
|
383
|
-
|
|
583
|
+
// Only the newest checkpoint can be short of verification: the older ones describe
|
|
584
|
+
// past states, and their single output is what was current for them then.
|
|
585
|
+
const verification = isCurrent
|
|
586
|
+
? verificationEvidence(index, observed.call, Math.floor(evidenceBudget / 2) - observed.text.length)
|
|
587
|
+
: { text: '', call: undefined }
|
|
588
|
+
// The digest comes out of the item cap rather than the evidence budget: it is a
|
|
589
|
+
// bounded summary, and the narration keeps the room the evidence budget reserved for
|
|
590
|
+
// it. MAX_DIGEST_LINES lines of ~110 characters never exceed a tenth of the item.
|
|
591
|
+
const digest = isCurrent
|
|
592
|
+
? recentEvidenceDigest(index, Math.min(1000, Math.floor(stepCap / 10)), [observed.call, verification.call])
|
|
593
|
+
: ''
|
|
594
|
+
const narration = isCurrent ? currentNarration(index, evidenceBudget - observed.text.length - verification.text.length) : ''
|
|
595
|
+
const used = observed.text.length + verification.text.length + digest.length + narration.length
|
|
596
|
+
return sanitizeVerifierText(note + source.label + source.body, Math.max(1, stepCap - used)) + observed.text + verification.text + digest + narration
|
|
384
597
|
})
|
|
385
598
|
return { steps, evidenceSeqs: kept.map(source => source.seq), omitted }
|
|
386
599
|
}
|
|
@@ -488,9 +701,10 @@ export function buildSemanticRoutePrompt(problem: string, events: readonly Sessi
|
|
|
488
701
|
let used = 0
|
|
489
702
|
let omitted = 0
|
|
490
703
|
for (const [callId, pair] of [...index.calls.entries()].reverse()) {
|
|
491
|
-
// Bookkeeping results are not alternatives to anything; offering
|
|
492
|
-
// classifier cite e.g. two goal/agent-control calls as competing
|
|
493
|
-
|
|
704
|
+
// Bookkeeping and coordination results are not alternatives to anything; offering
|
|
705
|
+
// them let the classifier cite e.g. two goal/agent-control calls as competing
|
|
706
|
+
// candidates, or a previous verdict as an artifact of work.
|
|
707
|
+
if (!isEvidenceOutput(pair.name, pair.text)) continue
|
|
494
708
|
const text = sanitizeVerifierText(pair.text, maxItemChars)
|
|
495
709
|
if (artifacts.length > 0 && used + text.length > maxInputChars) { omitted += 1; continue }
|
|
496
710
|
used += text.length
|
|
@@ -548,10 +762,10 @@ export function semanticDecision(output: SemanticRouteOutput, events: readonly S
|
|
|
548
762
|
const perItem = itemBudget(output.candidateCallIds.length, maxItemChars, maxInputChars)
|
|
549
763
|
const candidates = output.candidateCallIds.map((callId, i) => {
|
|
550
764
|
const pair = index.calls.get(callId)
|
|
551
|
-
//
|
|
765
|
+
// Non-evidence calls are not candidates (they were never offered to the classifier);
|
|
552
766
|
// a citation of one is an invalid reference, so the whole decision is rejected
|
|
553
767
|
// instead of comparing metadata as if it were alternative work.
|
|
554
|
-
if (!pair ||
|
|
768
|
+
if (!pair || !isEvidenceOutput(pair.name, pair.text)) return undefined
|
|
555
769
|
return { id: callId, groupId: 'semantic', label: pair.name + ' ' + (i + 1), content: sanitizeVerifierText(pair.text, perItem), callId, fromSeq: pair.callSeq, toSeq: pair.resultSeq }
|
|
556
770
|
}).filter((candidate): candidate is CandidateArtifact => candidate !== undefined)
|
|
557
771
|
if (candidates.length !== output.candidateCallIds.length) return undefined
|
|
@@ -581,13 +795,18 @@ export function semanticDecision(output: SemanticRouteOutput, events: readonly S
|
|
|
581
795
|
* cancels that.
|
|
582
796
|
*
|
|
583
797
|
* `select` does not need it: its ring is symmetric by construction and the pivot round
|
|
584
|
-
* is oriented per pair by the engine, so one round is already unbiased. `track`
|
|
585
|
-
*
|
|
798
|
+
* is oriented per pair by the engine, so one round is already unbiased. `track` has no
|
|
799
|
+
* slots at all, but it has its own repeat count: without token logprobs one call yields
|
|
800
|
+
* ONE sampled letter (5.3% of the scale per letter), so repeats are averaged to keep the
|
|
801
|
+
* progress curve from flipping bands on sampling noise — the same reason the upstream
|
|
802
|
+
* `n_evaluations` averages repeated verifications.
|
|
586
803
|
* @param decision - the routed decision about to run.
|
|
587
804
|
* @param configured - the configured auto-route repeat count.
|
|
805
|
+
* @param trackRepeats - repeat count for a `track` route; defaults to `configured`.
|
|
588
806
|
* @returns The repeat count to pass to the engine.
|
|
589
807
|
*/
|
|
590
|
-
export function routedRepeats(decision: RouteDecision, configured: number): number {
|
|
808
|
+
export function routedRepeats(decision: RouteDecision, configured: number, trackRepeats = configured): number {
|
|
809
|
+
if (decision.kind === 'track') return Math.max(1, trackRepeats)
|
|
591
810
|
if (decision.kind !== 'compare') return configured
|
|
592
811
|
return configured % 2 === 0 ? configured : configured + 1
|
|
593
812
|
}
|
|
@@ -621,8 +840,9 @@ export class AutoVerifierRouter {
|
|
|
621
840
|
const taskStartSeq = latestDirectUserSeq(sessionEvents(agent.session))
|
|
622
841
|
if (taskStartSeq === undefined) return undefined
|
|
623
842
|
const id = String(agent.id)
|
|
624
|
-
const state = this.states.get(id) ?? { taskStartSeq,
|
|
625
|
-
|
|
843
|
+
const state = this.states.get(id) ?? { taskStartSeq, routeAttempts: 0, finalAttempts: 0, sessionRouteAttempts: 0, sessionFinalAttempts: 0, taskModelCalls: 0, sessionModelCalls: 0, completed: new Set(), failed: new Set(), finalPreferred: false, strictBlocked: false }
|
|
844
|
+
// A new task resets its own attempt and model-call counters, but never the session counters.
|
|
845
|
+
if (state.taskStartSeq !== taskStartSeq) { state.taskStartSeq = taskStartSeq; state.routeAttempts = 0; state.finalAttempts = 0; state.taskModelCalls = 0; state.completed.clear(); state.failed.clear(); state.inFlight = undefined; state.finalRequiredFromSeq = undefined; state.finalPreferred = false; state.strictBlocked = false; this.exhaustedNotices.delete(id) }
|
|
626
846
|
this.states.set(id, state)
|
|
627
847
|
return state
|
|
628
848
|
}
|
|
@@ -630,9 +850,27 @@ export class AutoVerifierRouter {
|
|
|
630
850
|
reserve(agent: RoutedAgent, phase: RoutePhase, fingerprint: string, expectedCalls: number, policy: RouterPolicy): Reservation | undefined {
|
|
631
851
|
if (policy.mode === 'manual') return undefined
|
|
632
852
|
const state = this.state(agent)
|
|
633
|
-
if (!state || state.inFlight || state.completed.has(fingerprint)
|
|
853
|
+
if (!state || state.inFlight || state.completed.has(fingerprint)) return undefined
|
|
854
|
+
// Routing and the final gate draw on separate attempt counters, so no amount of
|
|
855
|
+
// routing can leave the mandatory acceptance without an attempt of its own.
|
|
856
|
+
const final = phase === 'final'
|
|
857
|
+
const attempts = final ? state.finalAttempts : state.routeAttempts
|
|
858
|
+
const sessionAttempts = final ? state.sessionFinalAttempts : state.sessionRouteAttempts
|
|
859
|
+
const maxTask = final ? policy.maxFinalPerTask : policy.maxRoutePerTask
|
|
860
|
+
const maxSession = final ? policy.maxFinalPerSession : policy.maxRoutePerSession
|
|
861
|
+
if (attempts >= maxTask || sessionAttempts >= maxSession || state.taskModelCalls + expectedCalls > policy.maxModelCallsPerTask || state.sessionModelCalls + expectedCalls > policy.maxModelCallsPerSession) return undefined
|
|
634
862
|
const reservation = { id: String(++this.serial), phase, fingerprint, taskStartSeq: state.taskStartSeq }
|
|
635
|
-
state.inFlight = reservation
|
|
863
|
+
state.inFlight = reservation
|
|
864
|
+
if (final) {
|
|
865
|
+
state.finalAttempts += 1; state.sessionFinalAttempts += 1
|
|
866
|
+
// The reservation honors the preference; a later failure must not keep routing
|
|
867
|
+
// disabled for the rest of the task.
|
|
868
|
+
state.finalPreferred = false
|
|
869
|
+
} else {
|
|
870
|
+
state.routeAttempts += 1; state.sessionRouteAttempts += 1
|
|
871
|
+
}
|
|
872
|
+
state.taskModelCalls += expectedCalls
|
|
873
|
+
state.sessionModelCalls += expectedCalls
|
|
636
874
|
return reservation
|
|
637
875
|
}
|
|
638
876
|
|
|
@@ -644,7 +882,7 @@ export class AutoVerifierRouter {
|
|
|
644
882
|
// a full session verification at the very next stop boundary, before anything was built
|
|
645
883
|
// (and, in strict mode, burn an attempt and set strictBlocked on that empty review).
|
|
646
884
|
if (reservation.phase !== 'semantic' && reservation.phase !== 'final' && reservation.phase !== 'plan_review') state.finalRequiredFromSeq = Math.max(state.finalRequiredFromSeq ?? 0, evidenceSeq ?? reservation.taskStartSeq)
|
|
647
|
-
if (reservation.phase === 'final') state.finalRequiredFromSeq = undefined
|
|
885
|
+
if (reservation.phase === 'final') { state.finalRequiredFromSeq = undefined; state.finalPreferred = false }
|
|
648
886
|
return true
|
|
649
887
|
}
|
|
650
888
|
|
|
@@ -652,6 +890,7 @@ export class AutoVerifierRouter {
|
|
|
652
890
|
const state = this.state(agent)
|
|
653
891
|
if (!state || state.inFlight?.id !== reservation.id) return
|
|
654
892
|
state.inFlight = undefined; state.failed.add(reservation.fingerprint); if (strict) state.strictBlocked = true
|
|
893
|
+
if (reservation.phase === 'final') state.finalPreferred = false
|
|
655
894
|
}
|
|
656
895
|
|
|
657
896
|
/**
|
|
@@ -679,7 +918,7 @@ export class AutoVerifierRouter {
|
|
|
679
918
|
budgetExhausted(agent: RoutedAgent, expectedCalls: number, policy: RouterPolicy): boolean {
|
|
680
919
|
const state = this.state(agent)
|
|
681
920
|
if (!state) return true
|
|
682
|
-
return state.
|
|
921
|
+
return state.routeAttempts >= policy.maxRoutePerTask || state.sessionRouteAttempts >= policy.maxRoutePerSession
|
|
683
922
|
|| state.taskModelCalls + expectedCalls > policy.maxModelCallsPerTask || state.sessionModelCalls + expectedCalls > policy.maxModelCallsPerSession
|
|
684
923
|
}
|
|
685
924
|
|
|
@@ -687,6 +926,17 @@ export class AutoVerifierRouter {
|
|
|
687
926
|
completedFingerprint(agent: RoutedAgent, fingerprint: string): boolean { return this.state(agent)?.completed.has(fingerprint) ?? false }
|
|
688
927
|
|
|
689
928
|
finalRequired(agent: RoutedAgent): number | undefined { return this.state(agent)?.finalRequiredFromSeq }
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Arm "run the final gate next": a track route already cleared the completion threshold,
|
|
932
|
+
* so the next stop boundary must not buy another route first.
|
|
933
|
+
* @param agent - Agent whose track route cleared the threshold.
|
|
934
|
+
*/
|
|
935
|
+
preferFinal(agent: RoutedAgent): void { const state = this.state(agent); if (state) state.finalPreferred = true }
|
|
936
|
+
|
|
937
|
+
/** Whether the next stop boundary must skip routing and run the final gate. */
|
|
938
|
+
finalPreferred(agent: RoutedAgent): boolean { return this.state(agent)?.finalPreferred ?? false }
|
|
939
|
+
|
|
690
940
|
strictBlocked(agent: RoutedAgent): boolean { return this.state(agent)?.strictBlocked ?? false }
|
|
691
941
|
release(agent: { id: unknown }): void { this.states.delete(String(agent.id)); this.exhaustedNotices.delete(String(agent.id)) }
|
|
692
942
|
}
|
package/src/statistics.test.ts
CHANGED
|
@@ -44,6 +44,12 @@ describe('StatisticsStore', () => {
|
|
|
44
44
|
expect(all.totals).toMatchObject({ invocations: 2, successes: 1, failures: 1, calls: 3, attempts: 4, retries: 1, tokens: 185, cacheHits: 1, cacheMisses: 2 })
|
|
45
45
|
expect(all.totals.successRate).toBe(0.5)
|
|
46
46
|
expect(all.totals.averageDurationMs).toBe(500)
|
|
47
|
+
// Provider prefix cache: 20 of 170 input tokens came from cache. DSH reports cached and
|
|
48
|
+
// uncached input as DISJOINT counts, so the denominator is their sum (that is also how
|
|
49
|
+
// tokens() totals them). This is a different quantity from the score-cache hit rate
|
|
50
|
+
// (1 of 3 lookups) and the two must never be conflated.
|
|
51
|
+
expect(all.totals.prefixCacheHitRate).toBeCloseTo(20 / 170, 10)
|
|
52
|
+
expect(all.totals.cacheHitRate).toBeCloseTo(1 / 3, 10)
|
|
47
53
|
expect(all.daily).toHaveLength(1)
|
|
48
54
|
expect(all.daily[0]).toMatchObject({ date: '2026-08-20', invocations: 2, calls: 3, tokens: 185 })
|
|
49
55
|
expect(all.tools.map(row => [row.toolName, row.invocations])).toEqual([['verifier_compare', 1], ['verifier_track', 1]])
|
|
@@ -55,6 +61,14 @@ describe('StatisticsStore', () => {
|
|
|
55
61
|
expect(JSON.parse(await readFile(file, 'utf8')).version).toBe(1)
|
|
56
62
|
})
|
|
57
63
|
|
|
64
|
+
it('reports a zero prefix cache rate for an empty range instead of NaN', async () => {
|
|
65
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-statistics-'))
|
|
66
|
+
const store = new StatisticsStore(join(root, 'statistics.json'), 100)
|
|
67
|
+
const empty = await store.overview({ fromMs: 0, toMs: 1 })
|
|
68
|
+
expect(empty.totals.prefixCacheHitRate).toBe(0)
|
|
69
|
+
expect(empty.totals.cacheHitRate).toBe(0)
|
|
70
|
+
})
|
|
71
|
+
|
|
58
72
|
it('serializes concurrent writes and keeps only the configured tail', async () => {
|
|
59
73
|
const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-statistics-'))
|
|
60
74
|
const store = new StatisticsStore(join(root, 'statistics.json'), 2)
|
|
@@ -196,6 +210,9 @@ describe('StatisticsStore', () => {
|
|
|
196
210
|
expect(summarizeVerdict('verifier_route_classify', { kind: 'none' }, 'semantic', thresholds)).toEqual({ phase: 'semantic', outcome: 'classified' })
|
|
197
211
|
// Select reports the ranked winner's score.
|
|
198
212
|
expect(summarizeVerdict('verifier_select', { index: 1, scores: [0.2, 0.9], ranking: [1, 0] }, 'select', thresholds)).toEqual({ phase: 'select', outcome: 'ranked', score: 0.9 })
|
|
213
|
+
// A candidate list whose entries were all byte-identical was never judged: say so instead
|
|
214
|
+
// of reporting index 0's 0.5 as if the tournament had ranked it.
|
|
215
|
+
expect(summarizeVerdict('verifier_select', { index: 0, scores: [0.5, 0.5], identical: true }, 'select', thresholds)).toEqual({ phase: 'select', outcome: 'identical-candidates' })
|
|
199
216
|
// Track reports the newest checkpoint (not the historical minimum) plus the curve.
|
|
200
217
|
expect(summarizeVerdict('verifier_track', { scores: [0, 0.10526315789473684, 0.7894736842105262] }, 'track', thresholds)).toEqual({
|
|
201
218
|
phase: 'track',
|
|
@@ -205,6 +222,26 @@ describe('StatisticsStore', () => {
|
|
|
205
222
|
threshold: 0.8,
|
|
206
223
|
})
|
|
207
224
|
expect(summarizeVerdict('verifier_track', { scores: [0.9] }, 'track', thresholds)).toEqual({ phase: 'track', outcome: 'passed', score: 0.9, threshold: 0.8 })
|
|
225
|
+
// Best-of-N is a GATE-shaped acceptance, not a select ranking: its scores array holds
|
|
226
|
+
// relative tournament shares and must never be read as the absolute score, while
|
|
227
|
+
// score/baselineScore/criteria come from the same empty-work comparison the gate runs.
|
|
228
|
+
expect(summarizeVerdict('verifier_best_of_n', {
|
|
229
|
+
scores: [0.9, 0.7, 0.4],
|
|
230
|
+
index: 0,
|
|
231
|
+
score: 0.66,
|
|
232
|
+
baselineScore: 0,
|
|
233
|
+
winner: 'A',
|
|
234
|
+
criteria: [{ id: 'specification', scoreA: 0.7 }, { id: 'output_match', scoreA: 0.62 }],
|
|
235
|
+
}, 'explicit', thresholds)).toEqual({
|
|
236
|
+
phase: 'explicit',
|
|
237
|
+
outcome: 'below-threshold',
|
|
238
|
+
score: 0.66,
|
|
239
|
+
baselineScore: 0,
|
|
240
|
+
criteria: [{ id: 'specification', score: 0.7 }, { id: 'output_match', score: 0.62 }],
|
|
241
|
+
winner: 'A',
|
|
242
|
+
threshold: 0.65,
|
|
243
|
+
})
|
|
244
|
+
expect(summarizeVerdict('verifier_best_of_n', { score: 0.9, baselineScore: 0, winner: 'A', criteria: [{ id: 'a', scoreA: 1 }] }, 'explicit', thresholds)).toMatchObject({ outcome: 'passed', score: 0.9, threshold: 0.65 })
|
|
208
245
|
})
|
|
209
246
|
|
|
210
247
|
it('reports the winning side of a comparison instead of the loser under a threshold', () => {
|
|
@@ -214,6 +251,8 @@ describe('StatisticsStore', () => {
|
|
|
214
251
|
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
252
|
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
253
|
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' })
|
|
254
|
+
// Byte-identical sides: distinguishable from a judged tie, because no call was made.
|
|
255
|
+
expect(summarizeVerdict('verifier_compare', { scoreA: 0.5, scoreB: 0.5, winner: 'tie', identical: true }, 'compare', thresholds)).toEqual({ phase: 'compare', outcome: 'identical', score: 0.5, scoreB: 0.5, winner: 'tie' })
|
|
217
256
|
// Session acceptance keeps A = the session and B = the empty-work baseline.
|
|
218
257
|
expect(summarizeVerdict('verifier_current_session', { score: 0.4, baselineScore: 0, winner: 'A' }, 'final', thresholds)).toEqual({
|
|
219
258
|
phase: 'final', outcome: 'below-threshold', score: 0.4, baselineScore: 0, winner: 'A', threshold: 0.65,
|
|
@@ -224,7 +263,10 @@ describe('StatisticsStore', () => {
|
|
|
224
263
|
// Losing to the baseline can never pass, however high the raw score is.
|
|
225
264
|
expect(summarizeVerdict('verifier_current_session', { score: 0.9, baselineScore: 0.95, winner: 'B' }, 'final', thresholds)).toMatchObject({ outcome: 'below-threshold' })
|
|
226
265
|
// One criterion below the threshold fails the record even though the mean passes.
|
|
227
|
-
|
|
266
|
+
// This case uses the EXACT shape `verifySession` returns (AcceptanceCriterion: {id, name,
|
|
267
|
+
// score}) — a previous version of this test fed {id, scoreA}, which is the compare shape, so
|
|
268
|
+
// it passed while every real session acceptance stored an empty criteria array.
|
|
269
|
+
expect(summarizeVerdict('verifier_current_session', { score: 0.6667, baselineScore: 0, winner: 'A', criteria: [{ id: 'specification', name: 'Specification Adherence', score: 1 }, { id: 'error_signals', name: 'Error Signal Detection', score: 0 }] }, 'final', thresholds)).toEqual({
|
|
228
270
|
phase: 'final',
|
|
229
271
|
outcome: 'below-threshold',
|
|
230
272
|
score: 0.6667,
|
|
@@ -233,6 +275,12 @@ describe('StatisticsStore', () => {
|
|
|
233
275
|
winner: 'A',
|
|
234
276
|
threshold: 0.65,
|
|
235
277
|
})
|
|
278
|
+
// The compare-shaped scoreA is still accepted, and a malformed entry is dropped rather than
|
|
279
|
+
// stored as NaN.
|
|
280
|
+
expect(summarizeVerdict('verifier_current_session', { score: 0.9, baselineScore: 0, winner: 'A', criteria: [{ id: 'legacy', scoreA: 0.4 }, { id: 'broken', score: 'high' }] }, 'final', thresholds)).toMatchObject({
|
|
281
|
+
outcome: 'below-threshold',
|
|
282
|
+
criteria: [{ id: 'legacy', score: 0.4 }],
|
|
283
|
+
})
|
|
236
284
|
})
|
|
237
285
|
|
|
238
286
|
it('keeps the track checkpoint progression and drops invalid entries', async () => {
|