dsh-llm-verifier 0.2.0 → 0.3.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.
Files changed (46) hide show
  1. package/README.md +15 -11
  2. package/lib/client.js +137 -13
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +377 -115
  5. package/lib/index.js.map +1 -1
  6. package/lib/types/auto.d.ts +32 -12
  7. package/lib/types/auto.d.ts.map +1 -1
  8. package/lib/types/auto.js +50 -38
  9. package/lib/types/auto.js.map +1 -1
  10. package/lib/types/client-i18n.d.ts +50 -1
  11. package/lib/types/client-i18n.d.ts.map +1 -1
  12. package/lib/types/client-i18n.js +83 -4
  13. package/lib/types/client-i18n.js.map +1 -1
  14. package/lib/types/client.d.ts +2 -2
  15. package/lib/types/client.d.ts.map +1 -1
  16. package/lib/types/client.js +81 -32
  17. package/lib/types/client.js.map +1 -1
  18. package/lib/types/engine.d.ts +19 -0
  19. package/lib/types/engine.d.ts.map +1 -1
  20. package/lib/types/engine.js +54 -4
  21. package/lib/types/engine.js.map +1 -1
  22. package/lib/types/index.d.ts.map +1 -1
  23. package/lib/types/index.js +32 -43
  24. package/lib/types/index.js.map +1 -1
  25. package/lib/types/router.d.ts +31 -0
  26. package/lib/types/router.d.ts.map +1 -1
  27. package/lib/types/router.js +181 -17
  28. package/lib/types/router.js.map +1 -1
  29. package/lib/types/statistics.d.ts +28 -0
  30. package/lib/types/statistics.d.ts.map +1 -1
  31. package/lib/types/statistics.js +101 -0
  32. package/lib/types/statistics.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/auto.test.ts +42 -23
  35. package/src/auto.ts +55 -46
  36. package/src/client-i18n.ts +93 -4
  37. package/src/client.test.ts +134 -3
  38. package/src/client.tsx +61 -4
  39. package/src/config.test.ts +6 -3
  40. package/src/engine.test.ts +40 -1
  41. package/src/engine.ts +56 -4
  42. package/src/index.ts +34 -44
  43. package/src/router.test.ts +191 -3
  44. package/src/router.ts +171 -14
  45. package/src/statistics.test.ts +116 -0
  46. package/src/statistics.ts +112 -0
@@ -33,7 +33,7 @@ export const zh = {
33
33
  'field.autoRouteMaxPerSession.title': '每会话最多路由',
34
34
  'field.autoRouteMaxPerSession.help': '同一会话中自动路由的尝试上限;与「每会话最多验收」共享计数器,实际可用次数同样是两者之和。',
35
35
  'field.autoTrackCompletionThreshold.title': '进度完成阈值',
36
- 'field.autoTrackCompletionThreshold.help': 'track 任一检查点低于该值时 steering 要求 Agent 继续执行;范围 0–1。',
36
+ 'field.autoTrackCompletionThreshold.help': 'track 最新检查点低于该值时 steering 要求 Agent 继续执行;更早的检查点只作展示,范围 0–1。',
37
37
  'field.autoRouteMaxItemChars.title': '单项证据字符上限',
38
38
  'field.autoRouteMaxItemChars.help': '每个候选或检查点在脱敏后允许发送给裁判的最大字符数。',
39
39
  'field.autoRouteMaxInputChars.title': '路由证据总字符上限',
@@ -116,6 +116,7 @@ export const zh = {
116
116
  'settings.catalogFailures': '部分模型目录读取失败',
117
117
  'settings.saved': '设置已保存。',
118
118
  'settings.reload': '重新载入',
119
+ 'settings.unsaved': '当前页面有未保存的修改:点击「保存」后才会写入设置;「重新载入」会丢弃这些修改。',
119
120
  'settings.save': '保存设置',
120
121
  'settings.saving': '保存中…',
121
122
 
@@ -145,6 +146,7 @@ export const zh = {
145
146
  'stats.outcome.passed': '通过',
146
147
  'stats.outcome.below-threshold': '未达标',
147
148
  'stats.outcome.tie': '平局',
149
+ 'stats.outcome.compared': '已比较',
148
150
  'stats.outcome.error': '异常',
149
151
  'stats.outcome.dropped-over-budget': '超预算丢弃',
150
152
  'stats.outcome.invalid-references': '引用无效',
@@ -152,6 +154,8 @@ export const zh = {
152
154
  'stats.verdict.winner': '胜方 {winner}',
153
155
  'stats.verdict.scoreThreshold': '得分 {score}(阈值 {threshold})',
154
156
  'stats.verdict.scoreOnly': '得分 {score}',
157
+ 'stats.verdict.checkpoints': '检查点 {scores}',
158
+ 'stats.verdict.criteria': '标准 {criteria}',
155
159
 
156
160
  // Metrics
157
161
  'metric.cacheHitRate': '缓存命中率',
@@ -243,7 +247,7 @@ export const en: I18nDict = {
243
247
  'field.autoRouteMaxPerSession.title': 'Max Routes per Session',
244
248
  'field.autoRouteMaxPerSession.help': 'Session attempt cap for automatic routing; shares its counter with "Max Verifications per Session" (effective limit is the sum of both).',
245
249
  'field.autoTrackCompletionThreshold.title': 'Progress Completion Threshold',
246
- 'field.autoTrackCompletionThreshold.help': 'When any track checkpoint falls below this score, steering instructs the Agent to continue; range 0–1.',
250
+ 'field.autoTrackCompletionThreshold.help': 'When the newest track checkpoint falls below this score, steering instructs the Agent to continue; earlier checkpoints are shown only; range 0–1.',
247
251
  'field.autoRouteMaxItemChars.title': 'Max Chars per Route Item',
248
252
  'field.autoRouteMaxItemChars.help': 'Maximum sanitized character count allowed per candidate or checkpoint when sent to the judge.',
249
253
  'field.autoRouteMaxInputChars.title': 'Total Route Input Chars Cap',
@@ -326,6 +330,7 @@ export const en: I18nDict = {
326
330
  'settings.catalogFailures': 'Some model catalogs failed to load',
327
331
  'settings.saved': 'Settings saved.',
328
332
  'settings.reload': 'Reload',
333
+ 'settings.unsaved': 'This page has unsaved changes: click Save to write them; Reload discards them.',
329
334
  'settings.save': 'Save Settings',
330
335
  'settings.saving': 'Saving…',
331
336
 
@@ -355,6 +360,7 @@ export const en: I18nDict = {
355
360
  'stats.outcome.passed': 'Passed',
356
361
  'stats.outcome.below-threshold': 'Below Threshold',
357
362
  'stats.outcome.tie': 'Tie',
363
+ 'stats.outcome.compared': 'Compared',
358
364
  'stats.outcome.error': 'Error',
359
365
  'stats.outcome.dropped-over-budget': 'Dropped (over budget)',
360
366
  'stats.outcome.invalid-references': 'Invalid references',
@@ -362,6 +368,8 @@ export const en: I18nDict = {
362
368
  'stats.verdict.winner': 'Winner {winner}',
363
369
  'stats.verdict.scoreThreshold': 'Score {score} (threshold {threshold})',
364
370
  'stats.verdict.scoreOnly': 'Score {score}',
371
+ 'stats.verdict.checkpoints': 'Checkpoints {scores}',
372
+ 'stats.verdict.criteria': 'Criteria {criteria}',
365
373
 
366
374
  // Metrics
367
375
  'metric.cacheHitRate': 'Cache Hit Rate',
@@ -491,6 +499,12 @@ export interface VerdictSummary {
491
499
  phase?: string
492
500
  outcome?: string
493
501
  score?: number
502
+ /** Per-checkpoint progression of a `verifier_track` verdict, oldest first; absent on older records. */
503
+ scores?: number[]
504
+ /** Candidate B's score of a two-way comparison; `score` is the winning side. */
505
+ scoreB?: number
506
+ /** Per-criterion A-side scores of a session acceptance; the mean alone can hide a failed requirement. */
507
+ criteria?: Array<{ id: string; score: number }>
494
508
  baselineScore?: number
495
509
  winner?: 'A' | 'B' | 'tie'
496
510
  threshold?: number
@@ -505,7 +519,66 @@ export function resolveCacheDirOnSave(
505
519
  return undefined
506
520
  }
507
521
 
508
- /** An eight-candidate select: ring + pivot rounds (18 pairs) x three criteria, one repeat. */
522
+ /**
523
+ * Deep equality for JSON-shaped settings values, insensitive to object key
524
+ * order and treating a missing key as `undefined`. The settings page compares a
525
+ * draft against a resolved view that was built from a different object shape,
526
+ * so identity comparison is never enough.
527
+ */
528
+ export function sameSettingValue(left: unknown, right: unknown): boolean {
529
+ if (left === right) return true
530
+ if (Array.isArray(left) && Array.isArray(right)) {
531
+ if (left.length !== right.length) return false
532
+ for (let index = 0; index < left.length; index += 1) {
533
+ if (!sameSettingValue(left[index], right[index])) return false
534
+ }
535
+ return true
536
+ }
537
+ if (Array.isArray(left) || Array.isArray(right)) return false
538
+ if (typeof left !== 'object' || typeof right !== 'object' || left === null || right === null) return false
539
+ const a = left as Record<string, unknown>
540
+ const b = right as Record<string, unknown>
541
+ for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
542
+ if (!sameSettingValue(a[key], b[key])) return false
543
+ }
544
+ return true
545
+ }
546
+
547
+ /**
548
+ * Build the user layer for one settings save.
549
+ *
550
+ * Keys this client does not own survive untouched and every draft field is
551
+ * written, but a field that only repeats the base composition is *dropped*
552
+ * rather than pinned. Pinning is what makes a shipped default change
553
+ * unreachable: the budget fields once defaulted to 48/160, and a section that
554
+ * stored those numbers kept overriding 64/240 and then 96/240 forever. `base`
555
+ * is the descriptor's composition base (the resolved plugin config), so the
556
+ * stored section keeps exactly what the user really overrode.
557
+ *
558
+ * An explicit `undefined` draft value means "clear this override": it deletes
559
+ * the stored key instead of pinning a valueless one, which is how the form
560
+ * removes a judge label, a reasoning effort or a cache directory.
561
+ *
562
+ * A host that reports no `base` cannot be pruned against and keeps the old
563
+ * write-everything behavior.
564
+ */
565
+ export function sectionForSave(
566
+ user: Record<string, unknown>,
567
+ draft: Record<string, unknown>,
568
+ base: Record<string, unknown> | undefined,
569
+ ): Record<string, unknown> {
570
+ const section: Record<string, unknown> = { ...user }
571
+ for (const [key, value] of Object.entries(draft)) {
572
+ if (value === undefined || (base !== undefined && sameSettingValue(value, base[key]))) {
573
+ delete section[key]
574
+ continue
575
+ }
576
+ section[key] = value
577
+ }
578
+ return section
579
+ }
580
+
581
+ /** An eight-candidate select: ring + pivot rounds (18 pairs) x three criteria, one round (the per-pair orientation removes the slot bias). */
509
582
  export const WORST_CASE_ROUTE_CALLS_PER_JUDGE = 54
510
583
  /** Final acceptance: three criteria x the default two repeats (one per A/B position). */
511
584
  export const WORST_CASE_FINAL_CALLS_PER_JUDGE = 6
@@ -568,6 +641,8 @@ export function formatVerdictDetails(
568
641
  outcomeText?: string
569
642
  phaseText?: string
570
643
  scoreText?: string
644
+ checkpointsText?: string
645
+ criteriaText?: string
571
646
  winnerText?: string
572
647
  isFailed: boolean
573
648
  } {
@@ -589,12 +664,26 @@ export function formatVerdictDetails(
589
664
  }
590
665
  }
591
666
 
667
+ // One track verdict carries a whole progression; showing only the newest score
668
+ // hides why the route decided to continue.
669
+ let checkpointsText: string | undefined
670
+ if (Array.isArray(verdict.scores) && verdict.scores.length > 1) {
671
+ checkpointsText = tFormat(t['stats.verdict.checkpoints'], { scores: verdict.scores.map(formatPercentage).join(' → ') })
672
+ }
673
+
674
+ // The acceptance mean can hide a requirement that failed on its own, so the breakdown
675
+ // is what tells the reader which one is holding the task back.
676
+ let criteriaText: string | undefined
677
+ if (Array.isArray(verdict.criteria) && verdict.criteria.length > 0) {
678
+ criteriaText = tFormat(t['stats.verdict.criteria'], { criteria: verdict.criteria.map(criterion => criterion.id + ' ' + formatPercentage(criterion.score)).join(' · ') })
679
+ }
680
+
592
681
  let winnerText: string | undefined
593
682
  if (verdict.winner) {
594
683
  const winnerValue = verdict.winner === 'tie' ? t['stats.winner.tie'] : verdict.winner
595
684
  winnerText = tFormat(t['stats.verdict.winner'], { winner: winnerValue })
596
685
  }
597
686
 
598
- return { outcomeText, phaseText, scoreText, winnerText, isFailed }
687
+ return { outcomeText, phaseText, scoreText, checkpointsText, criteriaText, winnerText, isFailed }
599
688
  }
600
689
 
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
2
2
  import { zh, en, toolLabels, tFormat, detectLanguage, compact, dateTime } from './client-i18n.ts'
3
3
  import {
4
4
  resolveCacheDirOnSave,
5
+ sameSettingValue,
6
+ sectionForSave,
5
7
  WORST_CASE_ROUTE_CALLS_PER_JUDGE,
6
8
  WORST_CASE_FINAL_CALLS_PER_JUDGE,
7
9
  WORST_CASE_TASK_PER_JUDGE,
@@ -365,9 +367,94 @@ describe('resolveCacheDirOnSave rule', () => {
365
367
  })
366
368
  })
367
369
 
370
+ describe('sameSettingValue', () => {
371
+ it('treats a missing key and an explicit undefined as the same value', () => {
372
+ expect(sameSettingValue({ reasoningEffort: undefined }, {})).toBe(true)
373
+ expect(sameSettingValue({}, { reasoningEffort: undefined })).toBe(true)
374
+ })
375
+
376
+ it('ignores object key order but not array order', () => {
377
+ expect(sameSettingValue({ a: 1, b: { c: 2, d: 3 } }, { b: { d: 3, c: 2 }, a: 1 })).toBe(true)
378
+ expect(sameSettingValue([1, 2], [1, 2])).toBe(true)
379
+ expect(sameSettingValue([1, 2], [2, 1])).toBe(false)
380
+ expect(sameSettingValue([1, 2], [1, 2, 3])).toBe(false)
381
+ })
382
+
383
+ it('never equates an absent value with null, 0 or false', () => {
384
+ expect(sameSettingValue(null, undefined)).toBe(false)
385
+ expect(sameSettingValue(0, undefined)).toBe(false)
386
+ expect(sameSettingValue(false, undefined)).toBe(false)
387
+ expect(sameSettingValue(0, false)).toBe(false)
388
+ expect(sameSettingValue('0', 0)).toBe(false)
389
+ })
390
+
391
+ it('compares nested judge drafts', () => {
392
+ const judge = { provider: 'deepseek-official', model: 'deepseek-flash', maxTokens: 32768 }
393
+ expect(sameSettingValue([judge], [{ maxTokens: 32768, model: 'deepseek-flash', provider: 'deepseek-official' }])).toBe(true)
394
+ expect(sameSettingValue([judge], [{ ...judge, maxTokens: 4096 }])).toBe(false)
395
+ })
396
+ })
397
+
398
+ describe('settings save layer', () => {
399
+ const BASE = {
400
+ autoMaxModelCallsPerTask: 96,
401
+ autoMaxModelCallsPerSession: 240,
402
+ enabled: true,
403
+ autoVerifyMaxPerSession: 8,
404
+ extraJudges: [] as unknown[],
405
+ }
406
+
407
+ it('drops every field that only repeats the base composition', () => {
408
+ expect(sectionForSave({}, { enabled: true, autoMaxModelCallsPerTask: 96, extraJudges: [] }, BASE)).toEqual({})
409
+ })
410
+
411
+ it('keeps a field whose value differs from the base', () => {
412
+ expect(sectionForSave({}, { enabled: true, autoMaxModelCallsPerTask: 54 }, BASE))
413
+ .toEqual({ autoMaxModelCallsPerTask: 54 })
414
+ })
415
+
416
+ it('prunes a stored copy of a value the base already supplies', () => {
417
+ // The shape this plugin used to write: every field pinned, including the
418
+ // budget values that later moved 48 -> 64 -> 96 in the plugin defaults.
419
+ const stored = { enabled: true, autoVerifyMaxPerSession: 8, autoMaxModelCallsPerTask: 54 }
420
+ const draft = { enabled: true, autoVerifyMaxPerSession: 8, autoMaxModelCallsPerTask: 54 }
421
+ expect(sectionForSave(stored, draft, BASE)).toEqual({ autoMaxModelCallsPerTask: 54 })
422
+ })
423
+
424
+ it('keeps user-layer keys this client does not own', () => {
425
+ expect(sectionForSave({ futureKey: 'keep-me' }, { enabled: true }, BASE)).toEqual({ futureKey: 'keep-me' })
426
+ })
427
+
428
+ it('writes every draft field when the host reports no base', () => {
429
+ expect(sectionForSave({}, { enabled: true, autoMaxModelCallsPerTask: 96 }, undefined))
430
+ .toEqual({ enabled: true, autoMaxModelCallsPerTask: 96 })
431
+ })
432
+
433
+ it('never stores an undefined override but keeps a key the base omits', () => {
434
+ expect(sectionForSave({}, { reasoningEffort: undefined, autoVerifyThreshold: 0.65 }, BASE))
435
+ .toEqual({ autoVerifyThreshold: 0.65 })
436
+ })
437
+
438
+ it('clears a stored override the draft no longer carries', () => {
439
+ const stored = { label: 'old judge', cacheDir: 'legacy-cache', reasoningEffort: 'high' }
440
+ expect(sectionForSave(stored, { label: undefined, cacheDir: undefined, reasoningEffort: undefined }, BASE))
441
+ .toEqual({})
442
+ })
443
+
444
+ it('clears a stored override even when the host reports no base', () => {
445
+ expect(sectionForSave({ label: 'old judge' }, { label: undefined }, undefined)).toEqual({})
446
+ })
447
+
448
+ it('keeps an extra judge whose base counterpart differs', () => {
449
+ const judge = { provider: 'antigravity', model: 'gemini-3.8-flash' }
450
+ expect(sectionForSave({}, { extraJudges: [judge] }, BASE)).toEqual({ extraJudges: [judge] })
451
+ })
452
+ })
453
+
368
454
  describe('budget worst case and sanity warnings', () => {
369
455
  it('defines correct constants for worst case calls per judge', () => {
370
- // Routing (54) plus the final acceptance (3 criteria x 2 repeats) per judge.
456
+ // Routing (18 pairs x 3 criteria, one round per pair) plus the final acceptance
457
+ // (3 criteria x 2 repeats) per judge.
371
458
  expect(WORST_CASE_ROUTE_CALLS_PER_JUDGE).toBe(54)
372
459
  expect(WORST_CASE_FINAL_CALLS_PER_JUDGE).toBe(6)
373
460
  expect(WORST_CASE_TASK_PER_JUDGE).toBe(60)
@@ -394,14 +481,14 @@ describe('budget worst case and sanity warnings', () => {
394
481
  })
395
482
 
396
483
  it('returns null when budgets meet or exceed worst case requirements', () => {
397
- // 1 judge (0 extra): task worst case 60, session worst case 160
484
+ // 1 judge (0 extra): task worst case 60, session worst case 160 — the shipped
485
+ // defaults (96 / 240) clear both.
398
486
  expect(evaluateBudgetWarning('smart', 0, 96, 240)).toBeNull()
399
487
  // 2 judges (1 extra): task worst case 120, session worst case 320
400
488
  expect(evaluateBudgetWarning('smart', 1, 120, 320)).toBeNull()
401
489
  })
402
490
 
403
491
  it('warns on task budget below worst case requirement', () => {
404
- // 2 judges (1 extra): task worst case 120, session worst case 320
405
492
  const warning = evaluateBudgetWarning('smart', 1, 64, 350)
406
493
  expect(warning).not.toBeNull()
407
494
  expect(warning?.warnTask).toBe(true)
@@ -505,6 +592,50 @@ describe('verdict dashboard helpers', () => {
505
592
  expect(res.phaseText).toBe('两项对比')
506
593
  })
507
594
 
595
+ it('renders the track checkpoint progression', () => {
596
+ const verdict: VerdictSummary = {
597
+ phase: 'track',
598
+ outcome: 'below-threshold',
599
+ score: 0.7894736842105262,
600
+ scores: [0, 0.10526315789473684, 0.7894736842105262],
601
+ threshold: 0.8,
602
+ }
603
+ expect(formatVerdictDetails(verdict, zh).checkpointsText).toBe('检查点 0.0% → 10.5% → 78.9%')
604
+ expect(formatVerdictDetails(verdict, en).checkpointsText).toBe('Checkpoints 0.0% → 10.5% → 78.9%')
605
+ })
606
+
607
+ it('localizes the compared outcome and omits a threshold a comparison never had', () => {
608
+ const verdict: VerdictSummary = { phase: 'compare', outcome: 'compared', score: 0.9, scoreB: 0.2, winner: 'B' }
609
+ const zhRes = formatVerdictDetails(verdict, zh)
610
+ expect(zhRes.outcomeText).toBe('已比较')
611
+ expect(zhRes.scoreText).toBe('得分 90.0%')
612
+ expect(zhRes.winnerText).toBe('胜方 B')
613
+ expect(zhRes.isFailed).toBe(false)
614
+ expect(formatVerdictDetails(verdict, en).outcomeText).toBe('Compared')
615
+ // A record written by an older build still renders.
616
+ expect(formatVerdictDetails({ phase: 'compare', outcome: 'below-threshold', score: 0.2, threshold: 0.65 }, zh).isFailed).toBe(true)
617
+ })
618
+
619
+ it('renders the per-criterion breakdown of a session acceptance', () => {
620
+ const verdict: VerdictSummary = {
621
+ phase: 'final',
622
+ outcome: 'below-threshold',
623
+ score: 0.6667,
624
+ baselineScore: 0,
625
+ criteria: [{ id: 'specification', score: 1 }, { id: 'error_signals', score: 0 }],
626
+ winner: 'A',
627
+ threshold: 0.65,
628
+ }
629
+ expect(formatVerdictDetails(verdict, zh).criteriaText).toBe('标准 specification 100.0% · error_signals 0.0%')
630
+ expect(formatVerdictDetails(verdict, en).criteriaText).toBe('Criteria specification 100.0% · error_signals 0.0%')
631
+ expect(formatVerdictDetails({ outcome: 'passed', score: 1 }, zh).criteriaText).toBeUndefined()
632
+ })
633
+
634
+ it('omits the progression for a single checkpoint', () => {
635
+ expect(formatVerdictDetails({ outcome: 'below-threshold', score: 0, scores: [0] }, zh).checkpointsText).toBeUndefined()
636
+ expect(formatVerdictDetails({ outcome: 'below-threshold', score: 0 }, zh).checkpointsText).toBeUndefined()
637
+ })
638
+
508
639
  it('formats tie winner localized in zh and en', () => {
509
640
  const verdict: VerdictSummary = {
510
641
  outcome: 'tie',
package/src/client.tsx CHANGED
@@ -8,7 +8,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
8
8
  import {
9
9
  zh, en, dictionaries, toolLabels, tFormat, useLanguage, detectLanguage,
10
10
  compact, money, duration, dateTime, type I18nDict,
11
- type VerdictSummary, resolveCacheDirOnSave,
11
+ type VerdictSummary, resolveCacheDirOnSave, sameSettingValue, sectionForSave,
12
12
  WORST_CASE_ROUTE_CALLS_PER_JUDGE, WORST_CASE_FINAL_CALLS_PER_JUDGE, WORST_CASE_TASK_PER_JUDGE, WORST_CASE_SESSION_PER_JUDGE,
13
13
  computeJudgeCount, computeWorstCaseBudget, type BudgetWarningState,
14
14
  evaluateBudgetWarning, isVerdictFailed, formatPercentage, formatVerdictDetails,
@@ -26,7 +26,7 @@ import {
26
26
 
27
27
  export {
28
28
  zh, en, dictionaries, toolLabels, tFormat, useLanguage, detectLanguage, compact, money, duration, dateTime, type I18nDict,
29
- type VerdictSummary, resolveCacheDirOnSave, WORST_CASE_ROUTE_CALLS_PER_JUDGE, WORST_CASE_FINAL_CALLS_PER_JUDGE, WORST_CASE_TASK_PER_JUDGE, WORST_CASE_SESSION_PER_JUDGE,
29
+ type VerdictSummary, resolveCacheDirOnSave, sameSettingValue, sectionForSave, WORST_CASE_ROUTE_CALLS_PER_JUDGE, WORST_CASE_FINAL_CALLS_PER_JUDGE, WORST_CASE_TASK_PER_JUDGE, WORST_CASE_SESSION_PER_JUDGE,
30
30
  computeJudgeCount, computeWorstCaseBudget, type BudgetWarningState, evaluateBudgetWarning, isVerdictFailed,
31
31
  formatPercentage, formatVerdictDetails,
32
32
  }
@@ -105,6 +105,37 @@ export function VerifierSettings({ remote }: VerifierSettingsProps) {
105
105
  const [loaded,setLoaded]=useState<Loaded|null>(null); const [draft,setDraft]=useState<Values|null>(null); const [busy,setBusy]=useState(false); const [error,setError]=useState<string|null>(null); const [saved,setSaved]=useState(false); const [editing,setEditing]=useState<Record<string,string>>({})
106
106
  const load=async()=>{setError(null);try{const [m,s]=await Promise.all([remote.session.modelCatalog(),remote.settings.describe()]);if(!m.ok)throw new Error(m.error.message);if(!s.ok)throw new Error(s.error.message);const view=s.value.namespaces.find((x:SettingsNamespaceView)=>x.ns===NS);if(!view)throw new Error(t['settings.nsUnregistered']);const next={groups:m.value.groups,settings:view,writable:s.value.writable,failures:m.value.failures.map((f: { id?: string; provider?: string; name?: string; message: string })=>(f.id??f.provider??f.name??'unknown')+': '+f.message)};setLoaded(next);setDraft(values(view));setEditing({})}catch(e){setError(message(e))}}
107
107
  useEffect(()=>{void load()},[])
108
+ const dirty=useMemo(()=>loaded!==null&&draft!==null&&!sameSettingValue(draft,values(loaded.settings)),[loaded,draft])
109
+ // Another writer — the settings shell's reset, a second window, an edit to
110
+ // settings.yaml — can move this namespace under a page that never re-reads
111
+ // it, and the stale draft would then be written straight back on the next
112
+ // save. Re-read whenever the page regains focus, but only while the draft
113
+ // holds no unsaved edit: a pending edit belongs to the user, and the
114
+ // revision check on save still rejects a write that raced a moved namespace.
115
+ const latest=useRef({loaded,draft})
116
+ latest.current={loaded,draft}
117
+ useEffect(()=>{
118
+ const sync=()=>{
119
+ if(document.visibilityState==='hidden')return
120
+ const {loaded:at,draft:local}=latest.current
121
+ if(!at||!local)return
122
+ if(!sameSettingValue(local,values(at.settings)))return
123
+ void (async()=>{
124
+ try{
125
+ const s=await remote.settings.describe()
126
+ if(!s.ok)return
127
+ const view=s.value.namespaces.find((x:SettingsNamespaceView)=>x.ns===NS)
128
+ if(!view||view.revision===at.settings.revision)return
129
+ const next:Loaded={...at,settings:view,writable:s.value.writable}
130
+ latest.current={loaded:next,draft:values(view)}
131
+ setLoaded(next);setDraft(values(view));setEditing({});setSaved(false)
132
+ }catch{/* keep the last good view; the reload button still forces a read */}
133
+ })()
134
+ }
135
+ window.addEventListener('focus',sync)
136
+ document.addEventListener('visibilitychange',sync)
137
+ return ()=>{window.removeEventListener('focus',sync);document.removeEventListener('visibilitychange',sync)}
138
+ },[remote])
108
139
  const models=useMemo(()=>loaded?.groups.find(g=>g.id===draft?.provider)?.models??[],[loaded,draft?.provider])
109
140
  const selected=models.find(m=>m.id===draft?.model); const efforts=selected?.reasoning?.efforts??[]
110
141
  const patch=<K extends keyof Values>(key:K,value:Values[K])=>{setSaved(false);setDraft(v=>v?{...v,[key]:value}:v)}
@@ -141,7 +172,30 @@ export function VerifierSettings({ remote }: VerifierSettingsProps) {
141
172
  draft.autoMaxModelCallsPerSession,
142
173
  )
143
174
  }, [draft?.autoVerifyMode, draft?.extraJudges.length, draft?.autoMaxModelCallsPerTask, draft?.autoMaxModelCallsPerSession])
144
- const save=async()=>{if(!loaded||!draft||conflict)return;setBusy(true);setSaved(false);setError(null);try{const section={...record(loaded.settings.user),...draft,extraJudges:serializeExtraJudges(draft.extraJudges)};if(!draft.reasoningEffort)delete section.reasoningEffort;if(!draft.label||!draft.label.trim())delete section.label;else section.label=draft.label.trim();const resolvedCacheDir=resolveCacheDirOnSave(draft.cacheDir,values(loaded.settings).cacheDir);if(!resolvedCacheDir)delete (section as {cacheDir?:string}).cacheDir;else section.cacheDir=resolvedCacheDir;const res=await remote.settings.update(NS,section as never,loaded.settings.revision);if(!res.ok)throw new Error(res.error.message);setLoaded(v=>v?{...v,settings:res.value}:v);setDraft(values(res.value));setEditing({});setSaved(true)}catch(e){setError(message(e))}finally{setBusy(false)}}
175
+ // The saved section holds only real overrides: a draft field equal to the
176
+ // composition base is dropped so a later plugin default still reaches this
177
+ // install (see sectionForSave).
178
+ const save=async()=>{
179
+ if(!loaded||!draft||conflict)return
180
+ setBusy(true);setSaved(false);setError(null)
181
+ try{
182
+ // An explicit undefined means "clear the stored override": sectionForSave
183
+ // starts from the previous user layer, so simply omitting a key would let
184
+ // the stale override outlive the save that cleared it.
185
+ const editable:Record<string,unknown>={
186
+ ...draft,
187
+ extraJudges:serializeExtraJudges(draft.extraJudges),
188
+ reasoningEffort:draft.reasoningEffort||undefined,
189
+ label:draft.label?.trim()||undefined,
190
+ cacheDir:resolveCacheDirOnSave(draft.cacheDir,values(loaded.settings).cacheDir),
191
+ }
192
+ const base=loaded.settings.base===undefined?undefined:record(loaded.settings.base)
193
+ const section=sectionForSave(record(loaded.settings.user),editable,base)
194
+ const res=await remote.settings.update(NS,section as never,loaded.settings.revision)
195
+ if(!res.ok)throw new Error(res.error.message)
196
+ setLoaded(v=>v?{...v,settings:res.value}:v);setDraft(values(res.value));setEditing({});setSaved(true)
197
+ }catch(e){setError(message(e))}finally{setBusy(false)}
198
+ }
145
199
  if(!loaded||!draft)return <div style={shell}><h2 style={settingsHeading}>{t['settings.title']}</h2><p style={settingsIntro}>{error??t['settings.loading']}</p>{error&&<div><Button variant="outline" onClick={()=>void load()}>{t['settings.retry']}</Button></div>}</div>
146
200
  // Fractional settings are typed character by character, so the raw text is
147
201
  // kept while the field has focus: a controlled type="number" input rewrites
@@ -275,7 +329,8 @@ export function VerifierSettings({ remote }: VerifierSettingsProps) {
275
329
  </section>
276
330
 
277
331
  {loaded.failures.length>0&&<div style={{padding:'10px 12px',borderRadius:8,background:'var(--dsw-alias-state-warn-bg)',color:'var(--dsw-alias-state-warn-label)',fontSize:12,lineHeight:'18px'}}><div style={{fontWeight:500,marginBottom:3}}>{t['settings.catalogFailures']}</div>{loaded.failures.map(x=><div key={x}>{x}</div>)}</div>}
278
- {error&&<p style={{margin:0,fontSize:12,lineHeight:'18px',color:'var(--dsw-alias-state-error-primary)'}}>{error}</p>}{saved&&<p style={{margin:0,fontSize:12,lineHeight:'18px',color:'var(--dsw-alias-state-success-primary)'}}>{t['settings.saved']}</p>}
332
+ {error&&<p style={{margin:0,fontSize:12,lineHeight:'18px',color:'var(--dsw-alias-state-error-primary)'}}>{error}</p>}{saved&&!dirty&&<p style={{margin:0,fontSize:12,lineHeight:'18px',color:'var(--dsw-alias-state-success-primary)'}}>{t['settings.saved']}</p>}
333
+ {dirty&&<p style={{margin:0,fontSize:12,lineHeight:'18px',color:'var(--dsw-alias-state-warn-label)'}}>{t['settings.unsaved']}</p>}
279
334
  <div style={{display:'flex',justifyContent:'flex-end',gap:8,paddingTop:4}}><Button variant="outline" disabled={busy} onClick={()=>void load()}>{t['settings.reload']}</Button><Button variant="primary" disabled={busy||!loaded.writable||Boolean(conflict)} onClick={()=>void save()}>{busy?t['settings.saving']:t['settings.save']}</Button></div>
280
335
  </div>
281
336
  }
@@ -447,6 +502,8 @@ export function StatisticsPage({ sessionId, rpc, isGlobal }: StatisticsPageProps
447
502
  {verdictInfo.outcomeText && <span style={{ padding: '1px 5px', borderRadius: 4, fontWeight: 500, fontSize: 11, background: verdictInfo.isFailed ? 'rgba(231,101,101,.16)' : item.verdict.outcome === 'tie' ? 'rgba(227,189,99,.16)' : 'rgba(89,201,133,.16)', color: verdictInfo.isFailed ? '#e76565' : item.verdict.outcome === 'tie' ? '#e3bd63' : '#77d49b', border: `1px solid ${verdictInfo.isFailed ? 'rgba(231,101,101,.3)' : item.verdict.outcome === 'tie' ? 'rgba(227,189,99,.3)' : 'rgba(89,201,133,.3)'}` }}>{verdictInfo.outcomeText}</span>}
448
503
  {verdictInfo.phaseText && <span style={{ padding: '1px 5px', borderRadius: 4, background: 'var(--dsw-surface-sunken)', color: 'var(--dsw-text-secondary)', border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.1))' }}>{verdictInfo.phaseText}</span>}
449
504
  {verdictInfo.scoreText && <span style={{ color: (typeof item.verdict.threshold === 'number' && typeof item.verdict.score === 'number' && item.verdict.score < item.verdict.threshold) ? '#e76565' : 'var(--dsw-text-primary)' }}>{verdictInfo.scoreText}</span>}
505
+ {verdictInfo.checkpointsText && <span style={{ color: 'var(--dsw-text-secondary)' }}>{verdictInfo.checkpointsText}</span>}
506
+ {verdictInfo.criteriaText && <span style={{ color: 'var(--dsw-text-secondary)' }}>{verdictInfo.criteriaText}</span>}
450
507
  {verdictInfo.winnerText && <span style={{ color: 'var(--dsw-text-secondary)' }}>{verdictInfo.winnerText}</span>}
451
508
  </div>}
452
509
  </div>
@@ -411,8 +411,9 @@ describe('config - temperature', () => {
411
411
  describe('config - automatic verification repeats and budget', () => {
412
412
  it('scores routing once and the final acceptance twice by default', () => {
413
413
  const resolved = resolveConfig({})
414
- // Routing stays a cheap single round; the gate that decides turn completion runs
415
- // two rounds so the second one swaps A/B positions and cancels position bias.
414
+ // Routing stays a cheap single round in the config; only compare is rounded up to an
415
+ // even count at run time (it judges a single pair, so an odd count would leave the
416
+ // A/B order uncorrected). select and track keep the configured round.
416
417
  expect(resolved.autoVerifyRepeats).toBe(1)
417
418
  expect(resolved.autoVerifyFinalRepeats).toBe(2)
418
419
  })
@@ -427,7 +428,9 @@ describe('config - automatic verification repeats and budget', () => {
427
428
  })
428
429
 
429
430
  it('leaves headroom in the task model-call budget for the final acceptance', () => {
430
- // 54 calls for an eight-candidate tournament + 6 for the final acceptance per judge.
431
+ // 54 calls for an eight-candidate tournament (one round; the engine orients each
432
+ // pair instead of doubling the rounds) + 6 for the final acceptance per judge.
431
433
  expect(resolveConfig({}).autoMaxModelCallsPerTask).toBe(96)
434
+ expect(resolveConfig({}).autoMaxModelCallsPerSession).toBe(240)
432
435
  })
433
436
  })
@@ -4,7 +4,8 @@ import { join } from 'node:path'
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest'
5
5
  import type { VerifierClientConfig } from './caller.ts'
6
6
  import { ScoreCache, SingleFlight, type CachedPairScore } from './cache.ts'
7
- import { VerifierEngine } from './engine.ts'
7
+ import { VerifierEngine, orientRoundPairs } from './engine.ts'
8
+ import { pivotRoundPairs } from './core.ts'
8
9
  import { TopLogprobCapabilityCache } from './top-logprobs.ts'
9
10
 
10
11
  function chunks(text: string) { return [{ type: 'block-start', index: 0, blockType: 'text' }, { type: 'text-delta', index: 0, text }, { type: 'block-end', index: 0, block: { type: 'text', text } }, { type: 'usage', usage: { inputTokens: 7, cacheReadTokens: 3, outputTokens: 4, reasoningTokens: 2 } }, { type: 'finish', reason: { kind: 'stop' } }] as any[] }
@@ -72,6 +73,44 @@ describe('VerifierEngine tournament', () => {
72
73
  })
73
74
  })
74
75
 
76
+ describe('pivot round orientation', () => {
77
+ it('gives every ring leader both slots instead of always trajectory B', () => {
78
+ // Regression: the pivot round emitted [candidate, pivot] for every edge, so the ring
79
+ // leaders sat in trajectory B in ALL their extra matches — a judge that merely
80
+ // prefers slot A then averaged 0.36 for the pivots against 0.60 for everyone else
81
+ // and pushed the leaders to the bottom of the final ranking.
82
+ const key = ([a, b]: readonly [number, number]) => (a < b ? a + ',' + b : b + ',' + a)
83
+ for (const count of [4, 5, 6, 8]) {
84
+ const pivots = [0, 1]
85
+ const raw = pivotRoundPairs(count, pivots)
86
+ const oriented = orientRoundPairs(raw)
87
+ // Same unordered pairs — only the presentation order may change.
88
+ expect(oriented).toHaveLength(raw.length)
89
+ expect(oriented.map(key).sort()).toEqual(raw.map(key).sort())
90
+ expect(new Set(oriented.map(key)).size).toBe(oriented.length)
91
+ const slotA = new Map<number, number>()
92
+ const slotB = new Map<number, number>()
93
+ for (const [a, b] of oriented) {
94
+ slotA.set(a, (slotA.get(a) ?? 0) + 1)
95
+ slotB.set(b, (slotB.get(b) ?? 0) + 1)
96
+ }
97
+ for (const pivot of pivots) {
98
+ const a = slotA.get(pivot) ?? 0
99
+ const b = slotB.get(pivot) ?? 0
100
+ expect(a, 'count=' + count + ' pivot=' + pivot + ' must not always sit in slot B').toBeGreaterThan(0)
101
+ expect(Math.abs(a - b), 'count=' + count + ' pivot=' + pivot + ' slot balance').toBeLessThanOrEqual(1)
102
+ }
103
+ }
104
+ })
105
+
106
+ it('leaves a two-candidate select with exactly one match', () => {
107
+ // The orientation must not invent or duplicate matches.
108
+ const oriented = orientRoundPairs([[0, 1]])
109
+ expect(oriented).toHaveLength(1)
110
+ expect([oriented[0]![0], oriented[0]![1]].sort()).toEqual([0, 1])
111
+ })
112
+ })
113
+
75
114
  describe('VerifierEngine cache identity', () => {
76
115
  it('does not reuse an explicit-tag cache entry for top-logprobs scoring', async () => {
77
116
  const dir = mkdtempSync(join(tmpdir(), 'dsh-verifier-engine-'))
package/src/engine.ts CHANGED
@@ -60,6 +60,56 @@ function median(values: readonly number[]): number {
60
60
  function blankStats(): RunStats { return { ...emptyUsage(), cacheHits: 0, cacheMisses: 0, estimatedCostUsd: 0, topLogprobScores: 0, explicitTagScores: 0 } }
61
61
  function unorderedPair(a: number, b: number): string { return a < b ? a + ',' + b : b + ',' + a }
62
62
 
63
+ /**
64
+ * Deterministic A/B slot for one pivot-round pair, balanced by construction.
65
+ *
66
+ * The round used to emit `[candidate, pivot]` for every edge, so the ring leaders sat
67
+ * in trajectory B in all of their extra matches: with a judge that merely prefers slot A
68
+ * the pivots averaged 0.36 against 0.60 for everyone else and sank in the final ranking.
69
+ * Alternating on the sum of the two RANKS — not the raw indices: the pivot set is
70
+ * selected by score, so its indices can share a parity and a parity rule would then
71
+ * handicap half the field — gives every pivot and every candidate both slots within one
72
+ * edge of each other, without a second model call.
73
+ *
74
+ * Deliberately NOT in core.ts: `pivotRoundPairs` is compared against the upstream Python
75
+ * reference in `parity.test.ts`, so the orientation stays an orchestration-side decision.
76
+ * @param pair - one unordered pair from the pivot round.
77
+ * @param pivotRanks - pivot index → its rank among the pivots.
78
+ * @param nonPivotRanks - candidate index → its rank among the non-pivots.
79
+ * @returns The pair in the order it should be presented.
80
+ */
81
+ export function orientRoundPairs(pairs: readonly (readonly [number, number])[]): Array<[number, number]> {
82
+ const balance = new Map<number, number>()
83
+ return pairs.map(([a, b]) => {
84
+ const first = balance.get(a) ?? 0
85
+ const second = balance.get(b) ?? 0
86
+ // Slot A goes to whichever endpoint has held it less often; an equal count keeps the
87
+ // incoming order, so the result is deterministic for a given pair sequence.
88
+ const [x, y] = second < first ? [b, a] : [a, b]
89
+ balance.set(x, (balance.get(x) ?? 0) + 1)
90
+ balance.set(y, (balance.get(y) ?? 0) - 1)
91
+ return [x, y] as [number, number]
92
+ })
93
+ }
94
+
95
+ /**
96
+ * Arbitrary but fixed A/B slot for the single pair of a two-candidate select.
97
+ *
98
+ * There is only one match, so nothing can cancel a preference; the mix at least stops
99
+ * candidate 0 from always sitting in slot A. Real comparisons go through `compare`,
100
+ * where `routedRepeats` runs an even number of rounds instead.
101
+ * @param a - first candidate index of the pair.
102
+ * @param b - second candidate index of the pair.
103
+ * @returns The pair in the order it should be presented.
104
+ */
105
+ function orientPair(a: number, b: number): [number, number] {
106
+ const lo = Math.min(a, b)
107
+ const hi = Math.max(a, b)
108
+ let mixed = Math.imul(lo + 1, 0x9e3779b1) ^ Math.imul(hi + 1, 0x85ebca77)
109
+ mixed = Math.imul(mixed ^ (mixed >>> 15), 0x2545f491)
110
+ return ((mixed ^ (mixed >>> 13)) & 1) === 0 ? [a, b] : [b, a]
111
+ }
112
+
63
113
  function judgeLabel(client: VerifierClientConfig): string {
64
114
  return client.label?.trim() || client.provider + '/' + client.model
65
115
  }
@@ -422,10 +472,12 @@ export class VerifierEngine {
422
472
  return { index: 0, best: options.candidates[0]!, scores: [1], ranking: [0], pivots: [0], comparisons: 0, calls: 0, stats: blankStats(), judges }
423
473
  }
424
474
  if (options.candidates.length === 2) {
425
- // ringCycle(2) would judge the single unordered pair in both directions; play it once.
426
- const { rewards, judgeRewards, judgeOk, judgeErrors, judgeCalls, stats } = await this.scorePairs(options, [[0, 1]], signal)
475
+ // ringCycle(2) would judge the single unordered pair in both directions; play it
476
+ // once, in the orientation orientPair() picks so the slot is not always candidate 0.
477
+ const single = orientPair(0, 1)
478
+ const { rewards, judgeRewards, judgeOk, judgeErrors, judgeCalls, stats } = await this.scorePairs(options, [single], signal)
427
479
  const wins = [0, 0]; const counts = [0, 0]
428
- accumulatePairs([[0, 1]], rewards, wins, counts)
480
+ accumulatePairs([single], rewards, wins, counts)
429
481
  const ranked = rankScores(wins, counts); const index = ranked[0]!.index
430
482
  const judges: JudgeScore[] = this.clients.map((client, k) => {
431
483
  const isOk = judgeOk[k]!
@@ -464,7 +516,7 @@ export class VerifierEngine {
464
516
  // pivot→neighbour edges). Dropping those duplicates keeps each unordered pair to a
465
517
  // single match so wins/counts are not double-weighted and no pair is judged twice.
466
518
  const ringPairs = new Set(ring.map(pair => unorderedPair(pair[0], pair[1])))
467
- const rounds = pivotRoundPairs(options.candidates.length, pivots).filter(pair => !ringPairs.has(unorderedPair(pair[0], pair[1])))
519
+ const rounds = orientRoundPairs(pivotRoundPairs(options.candidates.length, pivots).filter(pair => !ringPairs.has(unorderedPair(pair[0], pair[1]))))
468
520
  const roundScores = await this.scorePairs(options, rounds, signal)
469
521
  const allRewards = new Map([...ringScores.rewards, ...roundScores.rewards])
470
522
  const wins = new Array<number>(options.candidates.length).fill(0)