dsh-llm-verifier 0.3.1 → 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 -1349
- 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 +271 -45
- 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 +217 -5
- package/src/router.ts +286 -47
- 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 }
|
|
@@ -282,13 +305,174 @@ function canonicalTodoSnapshots(index: EvidenceIndex): Array<{ seq: number; todo
|
|
|
282
305
|
*/
|
|
283
306
|
const BOOKKEEPING_TOOLS = new Set([
|
|
284
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',
|
|
285
309
|
])
|
|
286
310
|
|
|
287
|
-
/**
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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)
|
|
292
476
|
}
|
|
293
477
|
|
|
294
478
|
/**
|
|
@@ -303,29 +487,31 @@ function onlyBookkeepingDispatches(names: ReadonlySet<string> | undefined): bool
|
|
|
303
487
|
* snapshot they wrote, so their own result repeats it while displacing the real
|
|
304
488
|
* output produced just before them (a live session attached `create_goal`,
|
|
305
489
|
* `update_goal` and `interrupt_agent` output to checkpoints that had already run
|
|
306
|
-
* 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.
|
|
307
492
|
* @param index - evidence index of the current task.
|
|
308
493
|
* @param seq - checkpoint sequence number, or `Infinity` for the current state.
|
|
309
494
|
* @param budget - maximum characters the evidence may occupy.
|
|
310
495
|
* @param current - render the newest output in the task instead of the newest one before `seq`.
|
|
311
|
-
* @returns
|
|
496
|
+
* @returns The rendered block and the call it came from, or an empty block.
|
|
312
497
|
*/
|
|
313
|
-
function checkpointEvidence(index: EvidenceIndex, seq: number, budget: number, current = false): string {
|
|
314
|
-
|
|
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
|
|
315
501
|
let latest: EvidenceCall | undefined
|
|
316
502
|
for (const pair of index.calls.values()) {
|
|
317
|
-
if (
|
|
503
|
+
if (!isEvidenceOutput(pair.name, pair.text)) continue
|
|
318
504
|
if (pair.resultSeq <= seq && (latest === undefined || pair.resultSeq > latest.resultSeq)) latest = pair
|
|
319
505
|
}
|
|
320
|
-
if (latest === undefined) return
|
|
506
|
+
if (latest === undefined) return empty
|
|
321
507
|
// The prefix length depends on the tool name, so measure it instead of assuming a
|
|
322
508
|
// fixed overhead: with a long tool name the old "- 60" let the rendered step exceed
|
|
323
509
|
// maxItemChars, and boundDecision() then dropped the whole track decision silently.
|
|
324
510
|
const prefix = current
|
|
325
511
|
? '\n\nLatest observed tool output at routing time (' + latest.name + '):\n'
|
|
326
512
|
: '\n\nLatest observed tool output before this checkpoint (' + latest.name + '):\n'
|
|
327
|
-
if (prefix.length >= budget) return
|
|
328
|
-
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 }
|
|
329
515
|
}
|
|
330
516
|
|
|
331
517
|
/**
|
|
@@ -368,7 +554,11 @@ interface CheckpointSource { seq: number; label: string; body: string }
|
|
|
368
554
|
* the CURRENT state: its evidence is the newest observed output in the task rather
|
|
369
555
|
* than the newest one before the last todo snapshot (which is often several tool calls
|
|
370
556
|
* stale), and it carries the agent's latest prose as an explicitly labelled claim (see
|
|
371
|
-
* {@link currentNarration}), because prose deliverables never reach a tool.
|
|
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.
|
|
372
562
|
* @param index - evidence index of the current task.
|
|
373
563
|
* @param sources - checkpoints in chronological order.
|
|
374
564
|
* @param maxItemChars - hard per-item cap enforced by boundDecision().
|
|
@@ -390,8 +580,20 @@ function renderCheckpointSteps(index: EvidenceIndex, sources: readonly Checkpoin
|
|
|
390
580
|
// never reaches a tool.
|
|
391
581
|
const isCurrent = position === kept.length - 1
|
|
392
582
|
const observed = checkpointEvidence(index, isCurrent ? Number.POSITIVE_INFINITY : source.seq, isCurrent ? Math.floor(evidenceBudget / 2) : evidenceBudget, isCurrent)
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
395
597
|
})
|
|
396
598
|
return { steps, evidenceSeqs: kept.map(source => source.seq), omitted }
|
|
397
599
|
}
|
|
@@ -499,9 +701,10 @@ export function buildSemanticRoutePrompt(problem: string, events: readonly Sessi
|
|
|
499
701
|
let used = 0
|
|
500
702
|
let omitted = 0
|
|
501
703
|
for (const [callId, pair] of [...index.calls.entries()].reverse()) {
|
|
502
|
-
// Bookkeeping results are not alternatives to anything; offering
|
|
503
|
-
// classifier cite e.g. two goal/agent-control calls as competing
|
|
504
|
-
|
|
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
|
|
505
708
|
const text = sanitizeVerifierText(pair.text, maxItemChars)
|
|
506
709
|
if (artifacts.length > 0 && used + text.length > maxInputChars) { omitted += 1; continue }
|
|
507
710
|
used += text.length
|
|
@@ -559,10 +762,10 @@ export function semanticDecision(output: SemanticRouteOutput, events: readonly S
|
|
|
559
762
|
const perItem = itemBudget(output.candidateCallIds.length, maxItemChars, maxInputChars)
|
|
560
763
|
const candidates = output.candidateCallIds.map((callId, i) => {
|
|
561
764
|
const pair = index.calls.get(callId)
|
|
562
|
-
//
|
|
765
|
+
// Non-evidence calls are not candidates (they were never offered to the classifier);
|
|
563
766
|
// a citation of one is an invalid reference, so the whole decision is rejected
|
|
564
767
|
// instead of comparing metadata as if it were alternative work.
|
|
565
|
-
if (!pair ||
|
|
768
|
+
if (!pair || !isEvidenceOutput(pair.name, pair.text)) return undefined
|
|
566
769
|
return { id: callId, groupId: 'semantic', label: pair.name + ' ' + (i + 1), content: sanitizeVerifierText(pair.text, perItem), callId, fromSeq: pair.callSeq, toSeq: pair.resultSeq }
|
|
567
770
|
}).filter((candidate): candidate is CandidateArtifact => candidate !== undefined)
|
|
568
771
|
if (candidates.length !== output.candidateCallIds.length) return undefined
|
|
@@ -592,13 +795,18 @@ export function semanticDecision(output: SemanticRouteOutput, events: readonly S
|
|
|
592
795
|
* cancels that.
|
|
593
796
|
*
|
|
594
797
|
* `select` does not need it: its ring is symmetric by construction and the pivot round
|
|
595
|
-
* is oriented per pair by the engine, so one round is already unbiased. `track`
|
|
596
|
-
*
|
|
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.
|
|
597
803
|
* @param decision - the routed decision about to run.
|
|
598
804
|
* @param configured - the configured auto-route repeat count.
|
|
805
|
+
* @param trackRepeats - repeat count for a `track` route; defaults to `configured`.
|
|
599
806
|
* @returns The repeat count to pass to the engine.
|
|
600
807
|
*/
|
|
601
|
-
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)
|
|
602
810
|
if (decision.kind !== 'compare') return configured
|
|
603
811
|
return configured % 2 === 0 ? configured : configured + 1
|
|
604
812
|
}
|
|
@@ -632,8 +840,9 @@ export class AutoVerifierRouter {
|
|
|
632
840
|
const taskStartSeq = latestDirectUserSeq(sessionEvents(agent.session))
|
|
633
841
|
if (taskStartSeq === undefined) return undefined
|
|
634
842
|
const id = String(agent.id)
|
|
635
|
-
const state = this.states.get(id) ?? { taskStartSeq,
|
|
636
|
-
|
|
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) }
|
|
637
846
|
this.states.set(id, state)
|
|
638
847
|
return state
|
|
639
848
|
}
|
|
@@ -641,9 +850,27 @@ export class AutoVerifierRouter {
|
|
|
641
850
|
reserve(agent: RoutedAgent, phase: RoutePhase, fingerprint: string, expectedCalls: number, policy: RouterPolicy): Reservation | undefined {
|
|
642
851
|
if (policy.mode === 'manual') return undefined
|
|
643
852
|
const state = this.state(agent)
|
|
644
|
-
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
|
|
645
862
|
const reservation = { id: String(++this.serial), phase, fingerprint, taskStartSeq: state.taskStartSeq }
|
|
646
|
-
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
|
|
647
874
|
return reservation
|
|
648
875
|
}
|
|
649
876
|
|
|
@@ -655,7 +882,7 @@ export class AutoVerifierRouter {
|
|
|
655
882
|
// a full session verification at the very next stop boundary, before anything was built
|
|
656
883
|
// (and, in strict mode, burn an attempt and set strictBlocked on that empty review).
|
|
657
884
|
if (reservation.phase !== 'semantic' && reservation.phase !== 'final' && reservation.phase !== 'plan_review') state.finalRequiredFromSeq = Math.max(state.finalRequiredFromSeq ?? 0, evidenceSeq ?? reservation.taskStartSeq)
|
|
658
|
-
if (reservation.phase === 'final') state.finalRequiredFromSeq = undefined
|
|
885
|
+
if (reservation.phase === 'final') { state.finalRequiredFromSeq = undefined; state.finalPreferred = false }
|
|
659
886
|
return true
|
|
660
887
|
}
|
|
661
888
|
|
|
@@ -663,6 +890,7 @@ export class AutoVerifierRouter {
|
|
|
663
890
|
const state = this.state(agent)
|
|
664
891
|
if (!state || state.inFlight?.id !== reservation.id) return
|
|
665
892
|
state.inFlight = undefined; state.failed.add(reservation.fingerprint); if (strict) state.strictBlocked = true
|
|
893
|
+
if (reservation.phase === 'final') state.finalPreferred = false
|
|
666
894
|
}
|
|
667
895
|
|
|
668
896
|
/**
|
|
@@ -690,7 +918,7 @@ export class AutoVerifierRouter {
|
|
|
690
918
|
budgetExhausted(agent: RoutedAgent, expectedCalls: number, policy: RouterPolicy): boolean {
|
|
691
919
|
const state = this.state(agent)
|
|
692
920
|
if (!state) return true
|
|
693
|
-
return state.
|
|
921
|
+
return state.routeAttempts >= policy.maxRoutePerTask || state.sessionRouteAttempts >= policy.maxRoutePerSession
|
|
694
922
|
|| state.taskModelCalls + expectedCalls > policy.maxModelCallsPerTask || state.sessionModelCalls + expectedCalls > policy.maxModelCallsPerSession
|
|
695
923
|
}
|
|
696
924
|
|
|
@@ -698,6 +926,17 @@ export class AutoVerifierRouter {
|
|
|
698
926
|
completedFingerprint(agent: RoutedAgent, fingerprint: string): boolean { return this.state(agent)?.completed.has(fingerprint) ?? false }
|
|
699
927
|
|
|
700
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
|
+
|
|
701
940
|
strictBlocked(agent: RoutedAgent): boolean { return this.state(agent)?.strictBlocked ?? false }
|
|
702
941
|
release(agent: { id: unknown }): void { this.states.delete(String(agent.id)); this.exhaustedNotices.delete(String(agent.id)) }
|
|
703
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 () => {
|