@goodandready/dsh-moa 0.2.72 → 0.2.74

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.
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Cross-Candidate Unchanged Base Context Pruning (#305)
3
+ *
4
+ * Replaces large, mostly-unchanged candidate file blocks with compact unified diffs
5
+ * containing only modified hunks and a configurable number of context lines.
6
+ * This drastically cuts Judge prompt token overhead (often by 70-85%) on large files
7
+ * while preserving full context for winner promotion.
8
+ */
9
+
10
+ import { computeLineDiff } from '../workspace/diff.js'
11
+ import { isSafeRelativePath } from '../workspace/security.js'
12
+
13
+ /**
14
+ * Extracts code blocks along with their raw Markdown text from candidate output.
15
+ *
16
+ * @param {string} text
17
+ * @returns {Array<{ relativePath: string, content: string, rawBlock: string }>}
18
+ */
19
+ export function extractCandidateFileBlocks(text) {
20
+ if (!text || typeof text !== 'string') return []
21
+ const blocks = []
22
+ const seenPaths = new Set()
23
+
24
+ const patterns = [
25
+ /```[\w-]*\s+(?:file|path|filepath)=["']?([^\s"'\n\r]+)["']?[\r\n]+([\s\S]*?)```/gi,
26
+ /(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
27
+ /#{1,4}\s+(?:File|Path):\s*([^\s\n\r]+)[\r\n]+```[\w-]*[\r\n]+([\s\S]*?)```/gi,
28
+ /```[\w-]*[\r\n]+(?:\/\/|#|<!--)\s*(?:File|Path):\s*([^\s\n\r>]+)(?:\s*-->)?[\r\n]+([\s\S]*?)```/gi,
29
+ ]
30
+
31
+ for (const regex of patterns) {
32
+ let match
33
+ while ((match = regex.exec(text)) !== null) {
34
+ const rawPath = match[1].trim()
35
+ if (rawPath && isSafeRelativePath(rawPath) && !seenPaths.has(rawPath)) {
36
+ seenPaths.add(rawPath)
37
+ blocks.push({
38
+ relativePath: rawPath,
39
+ content: match[2].trimEnd() + '\n',
40
+ rawBlock: match[0],
41
+ })
42
+ }
43
+ }
44
+ }
45
+
46
+ return blocks
47
+ }
48
+
49
+
50
+ /**
51
+ * Generates unified diff hunks from line diff items with a specified context window.
52
+ *
53
+ * @param {string} oldText - Original baseline file content
54
+ * @param {string} newText - Candidate's proposed file content
55
+ * @param {object} [options]
56
+ * @param {number} [options.contextLines=5] - Unchanged lines before and after changes
57
+ * @param {string} [options.filePath='file'] - Relative file path for header
58
+ * @returns {string} Standard unified diff representation
59
+ */
60
+ export function generateUnifiedDiff(oldText = '', newText = '', options = {}) {
61
+ const { contextLines = 5, filePath = 'file' } = options
62
+ const trimmedOld = String(oldText || '').replace(/[\r\n]+$/, '')
63
+ const trimmedNew = String(newText || '').replace(/[\r\n]+$/, '')
64
+ if (trimmedOld === trimmedNew) return ''
65
+
66
+ const diffItems = computeLineDiff(trimmedOld, trimmedNew)
67
+ if (!diffItems || diffItems.length === 0) return ''
68
+
69
+ // Group diff items into hunks with contextLines
70
+ const changeIndices = []
71
+ for (let i = 0; i < diffItems.length; i++) {
72
+ if (diffItems[i].type !== 'same') {
73
+ changeIndices.push(i)
74
+ }
75
+ }
76
+
77
+ if (changeIndices.length === 0) return ''
78
+
79
+ // Cluster change ranges that are closer than 2 * contextLines
80
+ const clusters = []
81
+ let currentCluster = {
82
+ start: Math.max(0, changeIndices[0] - contextLines),
83
+ end: Math.min(diffItems.length - 1, changeIndices[0] + contextLines),
84
+ }
85
+
86
+ for (let k = 1; k < changeIndices.length; k++) {
87
+ const idx = changeIndices[k]
88
+ const nextStart = Math.max(0, idx - contextLines)
89
+ const nextEnd = Math.min(diffItems.length - 1, idx + contextLines)
90
+
91
+ if (nextStart <= currentCluster.end + 1) {
92
+ currentCluster.end = Math.max(currentCluster.end, nextEnd)
93
+ } else {
94
+ clusters.push(currentCluster)
95
+ currentCluster = { start: nextStart, end: nextEnd }
96
+ }
97
+ }
98
+ clusters.push(currentCluster)
99
+
100
+ const hunkOutputs = []
101
+ for (const cluster of clusters) {
102
+ const slice = diffItems.slice(cluster.start, cluster.end + 1)
103
+ let oldStart = null
104
+ let newStart = null
105
+ let oldCount = 0
106
+ let newCount = 0
107
+ const hunkLines = []
108
+
109
+ for (const item of slice) {
110
+ if (item.type === 'same') {
111
+ if (oldStart === null && item.oldNum !== undefined) oldStart = item.oldNum
112
+ if (newStart === null && item.newNum !== undefined) newStart = item.newNum
113
+ oldCount++
114
+ newCount++
115
+ hunkLines.push(` ${item.line}`)
116
+ } else if (item.type === 'removed') {
117
+ if (oldStart === null && item.oldNum !== undefined) oldStart = item.oldNum
118
+ oldCount++
119
+ hunkLines.push(`-${item.line}`)
120
+ } else if (item.type === 'added') {
121
+ if (newStart === null && item.newNum !== undefined) newStart = item.newNum
122
+ newCount++
123
+ hunkLines.push(`+${item.line}`)
124
+ }
125
+ }
126
+
127
+ const header = `@@ -${oldStart || 1},${oldCount} +${newStart || 1},${newCount} @@`
128
+ hunkOutputs.push([header, ...hunkLines].join('\n'))
129
+ }
130
+
131
+ const prefix = `--- a/${filePath}\n+++ b/${filePath}`
132
+ return `${prefix}\n${hunkOutputs.join('\n')}`
133
+ }
134
+
135
+ /**
136
+ * Checks whether a candidate's file output qualifies for context pruning.
137
+ *
138
+ * @param {string} originalText - Original file content
139
+ * @param {string} candidateText - Proposed file content
140
+ * @param {object} [options]
141
+ * @param {number} [options.minLines=25] - Minimum file length to consider pruning
142
+ * @param {number} [options.maxChangeRatio=0.65] - Maximum proportion of changed lines
143
+ * @returns {boolean}
144
+ */
145
+ export function shouldPruneFile(originalText, candidateText, options = {}) {
146
+ const { minLines = 25, maxChangeRatio = 0.65 } = options
147
+ if (typeof originalText !== 'string' || typeof candidateText !== 'string') return false
148
+ const trimmedOld = originalText.replace(/[\r\n]+$/, '')
149
+ const trimmedNew = candidateText.replace(/[\r\n]+$/, '')
150
+ if (trimmedOld === trimmedNew) return false
151
+
152
+ const oldLines = trimmedOld.split(/\r?\n/)
153
+ if (oldLines.length < minLines) return false
154
+
155
+ const diffItems = computeLineDiff(trimmedOld, trimmedNew)
156
+ let changed = 0
157
+ for (const item of diffItems) {
158
+ if (item.type !== 'same') changed++
159
+ }
160
+
161
+ const ratio = changed / Math.max(1, diffItems.length)
162
+ return ratio > 0 && ratio <= maxChangeRatio
163
+ }
164
+
165
+ /**
166
+ * Prunes unmodified base context from a single candidate's Markdown output.
167
+ *
168
+ * @param {string} candidateText - Raw candidate text containing file blocks
169
+ * @param {Map<string, string>|Record<string, string>} baseFiles - Map of relative paths to baseline file contents
170
+ * @param {object} [options]
171
+ * @param {number} [options.contextLines=5]
172
+ * @param {number} [options.minLines=25]
173
+ * @returns {{ text: string, prunedFiles: string[], savedChars: number, originalChars: number }}
174
+ */
175
+ export function pruneCandidateOutput(candidateText = '', baseFiles = {}, options = {}) {
176
+ if (typeof candidateText !== 'string' || !candidateText.trim()) {
177
+ return { text: candidateText, prunedFiles: [], savedChars: 0, originalChars: 0 }
178
+ }
179
+
180
+ const { contextLines = 5, minLines = 25 } = options
181
+ const baseMap = baseFiles instanceof Map
182
+ ? baseFiles
183
+ : new Map(Object.entries(baseFiles || {}))
184
+
185
+ if (baseMap.size === 0) {
186
+ return { text: candidateText, prunedFiles: [], savedChars: 0, originalChars: 0 }
187
+ }
188
+
189
+ const fileBlocks = extractCandidateFileBlocks(candidateText)
190
+ if (!Array.isArray(fileBlocks) || fileBlocks.length === 0) {
191
+ return { text: candidateText, prunedFiles: [], savedChars: 0, originalChars: 0 }
192
+ }
193
+
194
+ let prunedText = candidateText
195
+ const prunedFiles = []
196
+ let savedChars = 0
197
+
198
+ for (const block of fileBlocks) {
199
+ const relPath = block.relativePath || block.path
200
+ if (!relPath || !baseMap.has(relPath)) continue
201
+
202
+ const baseContent = baseMap.get(relPath)
203
+ if (!shouldPruneFile(baseContent, block.content, { minLines })) continue
204
+
205
+ const unifiedDiff = generateUnifiedDiff(baseContent, block.content, {
206
+ contextLines,
207
+ filePath: relPath,
208
+ })
209
+
210
+ if (!unifiedDiff) continue
211
+
212
+ const originalBlockText = block.rawBlock || block.block || `\`\`\`${block.language || ''}\n${block.content}\n\`\`\``
213
+ const replacementBlock = `\`\`\`diff\n// Unified diff patch for ${relPath} (context pruned)\n${unifiedDiff}\n\`\`\``
214
+
215
+ if (replacementBlock.length < originalBlockText.length) {
216
+ prunedText = prunedText.replace(originalBlockText, replacementBlock)
217
+ savedChars += (originalBlockText.length - replacementBlock.length)
218
+ prunedFiles.push(relPath)
219
+ }
220
+ }
221
+
222
+ return {
223
+ text: prunedText,
224
+ prunedFiles,
225
+ savedChars,
226
+ originalChars: candidateText.length,
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Prunes unmodified context across all successful candidate outputs.
232
+ *
233
+ * @param {Array<object>} candidates - List of candidate response objects
234
+ * @param {Array<object>|Map<string, string>|Record<string, string>} projectFiles - Project context files
235
+ * @param {object} [options]
236
+ * @param {boolean} [options.enabled=true]
237
+ * @param {number} [options.contextLines=5]
238
+ * @returns {{ candidates: Array<object>, totalSavedChars: number, totalSavedTokens: number, prunedFiles: string[], isPruned: boolean }}
239
+ */
240
+ export function pruneCandidateEnsemble(candidates = [], projectFiles = [], options = {}) {
241
+ const { enabled = true, contextLines = 5, minLines = 25 } = options
242
+ if (!enabled || !Array.isArray(candidates) || candidates.length === 0) {
243
+ return {
244
+ candidates,
245
+ totalSavedChars: 0,
246
+ totalSavedTokens: 0,
247
+ prunedFiles: [],
248
+ isPruned: false,
249
+ }
250
+ }
251
+
252
+ // Build base files map
253
+ const baseMap = new Map()
254
+ if (Array.isArray(projectFiles)) {
255
+ for (const f of projectFiles) {
256
+ if (f && f.path && typeof f.content === 'string') {
257
+ baseMap.set(f.path, f.content)
258
+ } else if (f && f.relativePath && typeof f.content === 'string') {
259
+ baseMap.set(f.relativePath, f.content)
260
+ }
261
+ }
262
+ } else if (projectFiles instanceof Map) {
263
+ for (const [k, v] of projectFiles.entries()) baseMap.set(k, v)
264
+ } else if (projectFiles && typeof projectFiles === 'object') {
265
+ for (const [k, v] of Object.entries(projectFiles)) baseMap.set(k, v)
266
+ }
267
+
268
+ if (baseMap.size === 0) {
269
+ return {
270
+ candidates,
271
+ totalSavedChars: 0,
272
+ totalSavedTokens: 0,
273
+ prunedFiles: [],
274
+ isPruned: false,
275
+ }
276
+ }
277
+
278
+ let totalSavedChars = 0
279
+ const allPrunedFiles = new Set()
280
+
281
+ const prunedCandidates = candidates.map((cand) => {
282
+ if (!cand || typeof cand.text !== 'string') return cand
283
+ const result = pruneCandidateOutput(cand.text, baseMap, { contextLines, minLines })
284
+ if (result.savedChars > 0) {
285
+ totalSavedChars += result.savedChars
286
+ result.prunedFiles.forEach((f) => allPrunedFiles.add(f))
287
+ return {
288
+ ...cand,
289
+ text: result.text,
290
+ prunedStats: {
291
+ savedChars: result.savedChars,
292
+ prunedFiles: result.prunedFiles,
293
+ },
294
+ }
295
+ }
296
+ return cand
297
+ })
298
+
299
+ const totalSavedTokens = Math.round(totalSavedChars / 4)
300
+ return {
301
+ candidates: prunedCandidates,
302
+ totalSavedChars,
303
+ totalSavedTokens,
304
+ prunedFiles: Array.from(allPrunedFiles),
305
+ isPruned: totalSavedChars > 0,
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Emits a structured instruction directive for the Judge when context pruning was applied.
311
+ *
312
+ * @param {Array<string>} [prunedFiles=[]] - List of pruned file paths
313
+ * @param {number} [savedTokens=0] - Estimated token count saved
314
+ * @returns {string}
315
+ */
316
+ export function formatPruningDirective(prunedFiles = [], savedTokens = 0) {
317
+ if (!Array.isArray(prunedFiles) || prunedFiles.length === 0) return ''
318
+ const fileList = prunedFiles.map((f) => `\`${f}\``).join(', ')
319
+ return `### Context Pruning Notice
320
+ To minimize token bloat and accelerate evaluation, large baseline files with localized modifications (${fileList}) have been pruned into standard unified diff patches (---/+++ with context hunks).
321
+ Evaluate the precision, correctness, and edge-case safety of the added and removed lines without penalizing candidates for using diff format.`
322
+ }
323
+
@@ -1,3 +1,5 @@
1
+ import { expandSelfConsistencySlots } from './self-consistency.js'
2
+ import { emitEvent, MOA_EVENTS } from '../events.js'
1
3
  import { balanceCandidateQuotas } from '../moa-quota-balancer.js'
2
4
  import { collectProjectContext, formatProjectContext, isRefinementTask } from '../workspace/scanner.js'
3
5
  import { applyBudgetGuardrails, createBudgetAbortPayload } from '../moa-budget.js'
@@ -27,7 +29,40 @@ export async function stageProjectContext({
27
29
  const refTemp = typeof preset?.reference_temperature === 'number' ? preset.reference_temperature : 0.6
28
30
  const aggTemp = typeof preset?.aggregator_temperature === 'number' ? preset.aggregator_temperature : 0.4
29
31
  const judgeCriteria = preset?.judge_criteria || ''
30
- let isFastMode = referenceModels.length === 1 && !preset?.curator_synthesis
32
+ const selfConsistencyEnabled = Boolean(preset?.self_consistency_enabled)
33
+ const selfConsistencySamples = typeof preset?.self_consistency_samples === 'number' ? preset.self_consistency_samples : 3
34
+ const selfConsistencyTemperatures = Array.isArray(preset?.self_consistency_temperatures)
35
+ ? preset.self_consistency_temperatures
36
+ : [0.2, 0.6, 0.9]
37
+ const escalationPolicy = preset?.escalation_policy || 'single'
38
+ const escalationDisagreementThreshold = typeof preset?.escalation_disagreement_threshold === 'number'
39
+ ? preset.escalation_disagreement_threshold
40
+ : 0.40
41
+ const escalationJudge = (preset?.escalation_judge?.model) ? preset.escalation_judge : null
42
+
43
+ let effectiveReferenceModels = referenceModels
44
+ let selfConsistencyExpanded = false
45
+ if (selfConsistencyEnabled) {
46
+ const scRes = expandSelfConsistencySlots(referenceModels, {
47
+ enabled: true,
48
+ samples: selfConsistencySamples,
49
+ temperatures: selfConsistencyTemperatures,
50
+ })
51
+ if (scRes.isExpanded) {
52
+ effectiveReferenceModels = scRes.references
53
+ selfConsistencyExpanded = true
54
+ emitEvent(ctx, MOA_EVENTS.SELF_CONSISTENCY_EXPAND, {
55
+ originalModel: referenceModels[0]?.model,
56
+ sampleCount: scRes.sampleCount,
57
+ temperatures: selfConsistencyTemperatures.slice(0, scRes.sampleCount),
58
+ })
59
+ if (typeof onProgress === 'function') {
60
+ onProgress(`🎲 *Self-Consistency: Expanded single model into ${scRes.sampleCount} diverse temperature samples (${selfConsistencyTemperatures.slice(0, scRes.sampleCount).join(', ')})*\n`)
61
+ }
62
+ }
63
+ }
64
+
65
+ let isFastMode = effectiveReferenceModels.length === 1 && !preset?.curator_synthesis
31
66
  const isCuratorSynthesis = Boolean(preset?.curator_synthesis)
32
67
  const isStreamAggregator = preset?.stream_aggregator !== false
33
68
  const isQuorumEnabled = Boolean(preset?.quorum_enabled)
@@ -41,6 +76,10 @@ export async function stageProjectContext({
41
76
  const localPreflightCheck = preset?.local_preflight_check !== false
42
77
  const earlyExitConsensusEnabled = Boolean(preset?.early_exit_consensus_enabled)
43
78
  const earlyExitThreshold = typeof preset?.early_exit_threshold === 'number' ? preset.early_exit_threshold : 0.95
79
+ const contextPruningEnabled = preset?.context_pruning_enabled !== false
80
+ const contextPruningDiffLines = typeof preset?.context_pruning_diff_lines === 'number' ? preset.context_pruning_diff_lines : 5
81
+ const semanticCacheEnabled = Boolean(preset?.semantic_cache_enabled)
82
+ const semanticCacheTtlHours = typeof preset?.semantic_cache_ttl_hours === 'number' ? preset.semantic_cache_ttl_hours : 168
44
83
 
45
84
  const collectedCtx = await collectProjectContext(cwd, 16000)
46
85
  const isRefinement = Boolean(collectedCtx?.files?.length > 0 && isRefinementTask(userPrompt, collectedCtx.files))
@@ -61,7 +100,7 @@ export async function stageProjectContext({
61
100
  }
62
101
 
63
102
  const budgetCheck = applyBudgetGuardrails({
64
- references: referenceModels,
103
+ references: effectiveReferenceModels,
65
104
  enabled: preset?.budget_guard_enabled,
66
105
  maxBudgetUsd: preset?.max_budget_usd,
67
106
  action: preset?.budget_action || 'trim',
@@ -127,6 +166,10 @@ export async function stageProjectContext({
127
166
  effectiveReferences, quotaSubstitutions, priorTurnBaseline, candidateSystemPrompt,
128
167
  needsQuestions, enrichedMessages,
129
168
  localPreflightCheck, earlyExitConsensusEnabled, earlyExitThreshold,
169
+ contextPruningEnabled, contextPruningDiffLines,
170
+ semanticCacheEnabled, semanticCacheTtlHours,
171
+ selfConsistencyEnabled, selfConsistencyExpanded, selfConsistencySamples, selfConsistencyTemperatures,
172
+ escalationPolicy, escalationDisagreementThreshold, escalationJudge,
130
173
  }
131
174
  }
132
-
175
+
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Dynamic Consensus Threshold & Multi-Judge Escalation (#300)
3
+ *
4
+ * Detects divergence among candidate outputs (Disagreement Metric).
5
+ * When candidate consensus is weak or contradictory, automatically escalates:
6
+ * - auto_upgrade: upgrades from lightweight/fast judge to flagship/senior judge
7
+ * - council_majority: convenes a 3-judge panel with majority voting
8
+ */
9
+
10
+ import { calculateSimilarity } from './early-consensus.js'
11
+
12
+ /**
13
+ * Calculates the Disagreement Metric (0.0 to 1.0) across candidate outputs.
14
+ * 0.0 means perfect consensus (identical solutions).
15
+ * 1.0 means total divergence (completely distinct solutions).
16
+ *
17
+ * @param {Array<object>} candidateOutputs
18
+ * @returns {number} Disagreement metric between 0.0 and 1.0
19
+ */
20
+ export function calculateDisagreementMetric(candidateOutputs = []) {
21
+ if (!Array.isArray(candidateOutputs) || candidateOutputs.length < 2) {
22
+ return 0.0
23
+ }
24
+
25
+ const validTexts = candidateOutputs
26
+ .filter((c) => c && typeof c.text === 'string' && c.text.trim())
27
+ .map((c) => c.text)
28
+
29
+ if (validTexts.length < 2) return 0.0
30
+
31
+ let totalSim = 0
32
+ let pairs = 0
33
+
34
+ for (let i = 0; i < validTexts.length; i++) {
35
+ for (let j = i + 1; j < validTexts.length; j++) {
36
+ totalSim += calculateSimilarity(validTexts[i], validTexts[j])
37
+ pairs++
38
+ }
39
+ }
40
+
41
+ if (pairs === 0) return 0.0
42
+ const avgSim = totalSim / pairs
43
+ const disagreement = Math.max(0.0, Math.min(1.0, 1.0 - avgSim))
44
+ return Number(disagreement.toFixed(4))
45
+ }
46
+
47
+ /**
48
+ * Evaluates whether candidate divergence warrants judge escalation.
49
+ *
50
+ * @param {object} params
51
+ * @param {Array<object>} params.candidateOutputs - Array of successful candidate results
52
+ * @param {object} params.primaryJudge - Primary configured judge slot
53
+ * @param {Array<object>} [params.fallbackJudges=[]] - Chain of backup judges
54
+ * @param {object} [params.escalationJudge] - Explicit flagship upgrade judge
55
+ * @param {string} [params.policy='single'] - 'single' | 'auto_upgrade' | 'council_majority'
56
+ * @param {number} [params.threshold=0.40] - Disagreement threshold to trigger escalation
57
+ * @returns {object} Escalation decision and effective judge(s)
58
+ */
59
+ export function evaluateJudgeEscalation({
60
+ candidateOutputs = [],
61
+ primaryJudge = { provider: '', model: '' },
62
+ fallbackJudges = [],
63
+ escalationJudge = null,
64
+ policy = 'single',
65
+ threshold = 0.40,
66
+ } = {}) {
67
+ const normPolicy = String(policy || 'single').toLowerCase()
68
+
69
+ if (normPolicy === 'single' || !Array.isArray(candidateOutputs) || candidateOutputs.length < 2) {
70
+ return {
71
+ escalated: false,
72
+ escalatedType: 'none',
73
+ judge: primaryJudge,
74
+ councilJudges: [primaryJudge],
75
+ disagreement: 0.0,
76
+ threshold,
77
+ reason: 'Standard single judge evaluation',
78
+ }
79
+ }
80
+
81
+ const disagreement = calculateDisagreementMetric(candidateOutputs)
82
+ const isTriggered = disagreement >= threshold
83
+
84
+ if (!isTriggered) {
85
+ return {
86
+ escalated: false,
87
+ escalatedType: 'none',
88
+ judge: primaryJudge,
89
+ councilJudges: [primaryJudge],
90
+ disagreement,
91
+ threshold,
92
+ reason: `Consensus adequate: disagreement ${(disagreement * 100).toFixed(1)}% < threshold ${(threshold * 100).toFixed(1)}%`,
93
+ }
94
+ }
95
+
96
+ if (normPolicy === 'auto_upgrade') {
97
+ // Pick designated escalationJudge, or the first available fallbackJudge
98
+ let targetJudge = null
99
+ if (escalationJudge && escalationJudge.model) {
100
+ targetJudge = escalationJudge
101
+ } else if (Array.isArray(fallbackJudges) && fallbackJudges.length > 0 && fallbackJudges[0]?.model) {
102
+ targetJudge = fallbackJudges[0]
103
+ } else {
104
+ targetJudge = primaryJudge
105
+ }
106
+
107
+ const upgraded = targetJudge.model !== primaryJudge.model || targetJudge.provider !== primaryJudge.provider
108
+ return {
109
+ escalated: upgraded,
110
+ escalatedType: upgraded ? 'auto_upgrade' : 'none',
111
+ judge: targetJudge,
112
+ originalJudge: primaryJudge,
113
+ councilJudges: [targetJudge],
114
+ disagreement,
115
+ threshold,
116
+ reason: upgraded
117
+ ? `High candidate divergence (${(disagreement * 100).toFixed(1)}% >= ${(threshold * 100).toFixed(1)}%): upgraded judge to ${targetJudge.provider ? targetJudge.provider + ':' : ''}${targetJudge.model}`
118
+ : `High divergence detected (${(disagreement * 100).toFixed(1)}%), but no senior fallback judge configured`,
119
+ }
120
+ }
121
+
122
+ if (normPolicy === 'council_majority') {
123
+ const council = [primaryJudge]
124
+ if (escalationJudge && escalationJudge.model && escalationJudge.model !== primaryJudge.model) {
125
+ council.push(escalationJudge)
126
+ }
127
+ if (Array.isArray(fallbackJudges)) {
128
+ for (const fb of fallbackJudges) {
129
+ if (council.length >= 3) break
130
+ if (fb && fb.model && !council.some((c) => c.model === fb.model && c.provider === fb.provider)) {
131
+ council.push(fb)
132
+ }
133
+ }
134
+ }
135
+
136
+ const hasMulti = council.length > 1
137
+ return {
138
+ escalated: hasMulti,
139
+ escalatedType: hasMulti ? 'council_majority' : 'none',
140
+ judge: council[0],
141
+ originalJudge: primaryJudge,
142
+ councilJudges: council,
143
+ disagreement,
144
+ threshold,
145
+ reason: hasMulti
146
+ ? `High candidate divergence (${(disagreement * 100).toFixed(1)}% >= ${(threshold * 100).toFixed(1)}%): assembled ${council.length}-judge council`
147
+ : `High divergence detected, but fewer than 2 judges available for council`,
148
+ }
149
+ }
150
+
151
+ return {
152
+ escalated: false,
153
+ escalatedType: 'none',
154
+ judge: primaryJudge,
155
+ councilJudges: [primaryJudge],
156
+ disagreement,
157
+ threshold,
158
+ reason: 'Standard single judge',
159
+ }
160
+ }