@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.
- package/README.md +11 -0
- package/README.ru.md +11 -0
- package/README.zh.md +11 -0
- package/lib/client.js +183 -2
- package/lib/events.js +5 -1
- package/lib/moa-schema.js +29 -1
- package/lib/pipeline/candidates.js +16 -3
- package/lib/pipeline/context-pruner.js +323 -0
- package/lib/pipeline/context.js +46 -3
- package/lib/pipeline/escalation.js +160 -0
- package/lib/pipeline/runner.js +99 -9
- package/lib/pipeline/self-consistency.js +135 -0
- package/lib/pipeline/semantic-cache.js +348 -0
- package/lib/routes/cache.js +42 -0
- package/lib/routes.js +3 -0
- package/package.json +1 -2
|
@@ -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
|
+
|
package/lib/pipeline/context.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
+
}
|