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.
- package/README.md +15 -11
- package/lib/client.js +137 -13
- package/lib/client.js.map +1 -1
- package/lib/index.js +377 -115
- package/lib/index.js.map +1 -1
- package/lib/types/auto.d.ts +32 -12
- package/lib/types/auto.d.ts.map +1 -1
- package/lib/types/auto.js +50 -38
- package/lib/types/auto.js.map +1 -1
- package/lib/types/client-i18n.d.ts +50 -1
- package/lib/types/client-i18n.d.ts.map +1 -1
- package/lib/types/client-i18n.js +83 -4
- package/lib/types/client-i18n.js.map +1 -1
- package/lib/types/client.d.ts +2 -2
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/client.js +81 -32
- package/lib/types/client.js.map +1 -1
- package/lib/types/engine.d.ts +19 -0
- package/lib/types/engine.d.ts.map +1 -1
- package/lib/types/engine.js +54 -4
- package/lib/types/engine.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +32 -43
- package/lib/types/index.js.map +1 -1
- package/lib/types/router.d.ts +31 -0
- package/lib/types/router.d.ts.map +1 -1
- package/lib/types/router.js +181 -17
- package/lib/types/router.js.map +1 -1
- package/lib/types/statistics.d.ts +28 -0
- package/lib/types/statistics.d.ts.map +1 -1
- package/lib/types/statistics.js +101 -0
- package/lib/types/statistics.js.map +1 -1
- package/package.json +1 -1
- package/src/auto.test.ts +42 -23
- package/src/auto.ts +55 -46
- package/src/client-i18n.ts +93 -4
- package/src/client.test.ts +134 -3
- package/src/client.tsx +61 -4
- package/src/config.test.ts +6 -3
- package/src/engine.test.ts +40 -1
- package/src/engine.ts +56 -4
- package/src/index.ts +34 -44
- package/src/router.test.ts +191 -3
- package/src/router.ts +171 -14
- package/src/statistics.test.ts +116 -0
- package/src/statistics.ts +112 -0
package/src/client-i18n.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
package/src/client.test.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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>
|
package/src/config.test.ts
CHANGED
|
@@ -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
|
|
415
|
-
//
|
|
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
|
|
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
|
})
|
package/src/engine.test.ts
CHANGED
|
@@ -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
|
|
426
|
-
|
|
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([
|
|
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)
|