@goodandready/dsh-moa 0.2.71 → 0.2.73
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 +94 -0
- package/lib/events.js +5 -0
- package/lib/moa-adversarial-tdd.js +4 -1
- package/lib/moa-ast-merge.js +4 -1
- package/lib/moa-schema.js +23 -0
- package/lib/moa-swarm.js +13 -4
- package/lib/pipeline/candidates.js +16 -3
- package/lib/pipeline/context-pruner.js +323 -0
- package/lib/pipeline/context.js +11 -0
- package/lib/pipeline/early-consensus.js +324 -0
- package/lib/pipeline/runner.js +89 -6
- package/lib/pipeline/semantic-cache.js +348 -0
- package/lib/pipeline/syntax-filter.js +324 -0
- package/lib/routes/cache.js +42 -0
- package/lib/routes.js +3 -0
- package/lib/tui-settings.js +2 -1
- package/package.json +1 -1
|
@@ -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
|
@@ -38,6 +38,13 @@ export async function stageProjectContext({
|
|
|
38
38
|
const isBlindEvaluation = Boolean(preset?.blind_evaluation)
|
|
39
39
|
const isPeerCritiqueEnabled = Boolean(preset?.peer_critique_enabled)
|
|
40
40
|
const allowCandidateOverride = Boolean(preset?.allow_candidate_override)
|
|
41
|
+
const localPreflightCheck = preset?.local_preflight_check !== false
|
|
42
|
+
const earlyExitConsensusEnabled = Boolean(preset?.early_exit_consensus_enabled)
|
|
43
|
+
const earlyExitThreshold = typeof preset?.early_exit_threshold === 'number' ? preset.early_exit_threshold : 0.95
|
|
44
|
+
const contextPruningEnabled = preset?.context_pruning_enabled !== false
|
|
45
|
+
const contextPruningDiffLines = typeof preset?.context_pruning_diff_lines === 'number' ? preset.context_pruning_diff_lines : 5
|
|
46
|
+
const semanticCacheEnabled = Boolean(preset?.semantic_cache_enabled)
|
|
47
|
+
const semanticCacheTtlHours = typeof preset?.semantic_cache_ttl_hours === 'number' ? preset.semantic_cache_ttl_hours : 168
|
|
41
48
|
|
|
42
49
|
const collectedCtx = await collectProjectContext(cwd, 16000)
|
|
43
50
|
const isRefinement = Boolean(collectedCtx?.files?.length > 0 && isRefinementTask(userPrompt, collectedCtx.files))
|
|
@@ -123,5 +130,9 @@ export async function stageProjectContext({
|
|
|
123
130
|
allowCandidateOverride, collectedCtx, isRefinement, projectContext,
|
|
124
131
|
effectiveReferences, quotaSubstitutions, priorTurnBaseline, candidateSystemPrompt,
|
|
125
132
|
needsQuestions, enrichedMessages,
|
|
133
|
+
localPreflightCheck, earlyExitConsensusEnabled, earlyExitThreshold,
|
|
134
|
+
contextPruningEnabled, contextPruningDiffLines,
|
|
135
|
+
semanticCacheEnabled, semanticCacheTtlHours,
|
|
126
136
|
}
|
|
127
137
|
}
|
|
138
|
+
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Early Exit upon Semantic Consensus (#299)
|
|
3
|
+
* Detects convergence between reference candidates and bypasses judge round when consensus >= threshold.
|
|
4
|
+
* Saves up to 60-80% tokens and latency on requests where candidates converge.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { logger } from '../logger.js'
|
|
8
|
+
import { promoteCandidateWorkspace, cleanMoaWorkspaces } from '../workspace/fs.js'
|
|
9
|
+
import { createPromotedPreview } from '../live-canvas.js'
|
|
10
|
+
import { recordMoaRunAsync, candidatesForHistory } from '../history.js'
|
|
11
|
+
import { emitEvent, MOA_EVENTS } from '../events.js'
|
|
12
|
+
import { estimateTokenCost } from '../pricing.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalizes text/code by stripping comments and normalizing whitespace for consensus comparison.
|
|
16
|
+
* @param {string} text
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function normalizeForConsensus(text = '') {
|
|
20
|
+
if (typeof text !== 'string') return ''
|
|
21
|
+
return text
|
|
22
|
+
// Strip block comments /* ... */
|
|
23
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
24
|
+
// Strip JS/TS single line comments // ...
|
|
25
|
+
.replace(/\/\/.*$/gm, '')
|
|
26
|
+
// Strip Python/Shell comments # ...
|
|
27
|
+
.replace(/(^|\s)#.*$/gm, '$1')
|
|
28
|
+
// Normalize newlines
|
|
29
|
+
.replace(/\r?\n/g, '\n')
|
|
30
|
+
// Collapse horizontal whitespace
|
|
31
|
+
.replace(/[ \t]+/g, ' ')
|
|
32
|
+
// Collapse multi-blank lines
|
|
33
|
+
.replace(/\n\s*\n/g, '\n')
|
|
34
|
+
.trim()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Calculates Sørensen-Dice similarity coefficient between two code/text snippets (0.0 to 1.0).
|
|
39
|
+
* @param {string} textA
|
|
40
|
+
* @param {string} textB
|
|
41
|
+
* @returns {number}
|
|
42
|
+
*/
|
|
43
|
+
export function calculateSimilarity(textA = '', textB = '') {
|
|
44
|
+
const normA = normalizeForConsensus(textA)
|
|
45
|
+
const normB = normalizeForConsensus(textB)
|
|
46
|
+
|
|
47
|
+
if (normA === normB) return 1.0
|
|
48
|
+
if (!normA || !normB) return 0.0
|
|
49
|
+
|
|
50
|
+
const tokensA = normA.match(/[\w$]+|[^\s\w]/g) || []
|
|
51
|
+
const tokensB = normB.match(/[\w$]+|[^\s\w]/g) || []
|
|
52
|
+
|
|
53
|
+
if (tokensA.length === 0 && tokensB.length === 0) return 1.0
|
|
54
|
+
if (tokensA.length === 0 || tokensB.length === 0) return 0.0
|
|
55
|
+
|
|
56
|
+
const freqA = new Map()
|
|
57
|
+
for (const t of tokensA) freqA.set(t, (freqA.get(t) || 0) + 1)
|
|
58
|
+
|
|
59
|
+
const freqB = new Map()
|
|
60
|
+
for (const t of tokensB) freqB.set(t, (freqB.get(t) || 0) + 1)
|
|
61
|
+
|
|
62
|
+
let intersection = 0
|
|
63
|
+
for (const [token, countA] of freqA.entries()) {
|
|
64
|
+
const countB = freqB.get(token) || 0
|
|
65
|
+
intersection += Math.min(countA, countB)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const dice = (2 * intersection) / (tokensA.length + tokensB.length)
|
|
69
|
+
return Number(dice.toFixed(4))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Checks if candidates have reached semantic consensus above threshold.
|
|
74
|
+
* @param {Array<object>} candidates
|
|
75
|
+
* @param {number} [threshold=0.95]
|
|
76
|
+
* @returns {{ reached: boolean, similarity: number, winnerIndex: number, consensusIndices: number[], winnerCandidate: object | null, reason: string }}
|
|
77
|
+
*/
|
|
78
|
+
export function checkSemanticConsensus(candidates = [], threshold = 0.95) {
|
|
79
|
+
if (!Array.isArray(candidates) || candidates.length < 2) {
|
|
80
|
+
return {
|
|
81
|
+
reached: false,
|
|
82
|
+
similarity: 0,
|
|
83
|
+
winnerIndex: 0,
|
|
84
|
+
consensusIndices: [],
|
|
85
|
+
winnerCandidate: null,
|
|
86
|
+
reason: 'Insufficient candidates for consensus check',
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const n = candidates.length
|
|
91
|
+
let maxSim = 0
|
|
92
|
+
let bestPair = [0, 1]
|
|
93
|
+
const matrix = Array.from({ length: n }, () => new Float32Array(n))
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < n; i++) {
|
|
96
|
+
matrix[i][i] = 1.0
|
|
97
|
+
for (let j = i + 1; j < n; j++) {
|
|
98
|
+
const sim = calculateSimilarity(candidates[i].text || '', candidates[j].text || '')
|
|
99
|
+
matrix[i][j] = sim
|
|
100
|
+
matrix[j][i] = sim
|
|
101
|
+
if (sim > maxSim) {
|
|
102
|
+
maxSim = sim
|
|
103
|
+
bestPair = [i, j]
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (maxSim < threshold) {
|
|
109
|
+
return {
|
|
110
|
+
reached: false,
|
|
111
|
+
similarity: maxSim,
|
|
112
|
+
winnerIndex: 0,
|
|
113
|
+
consensusIndices: [],
|
|
114
|
+
winnerCandidate: null,
|
|
115
|
+
reason: `Max pairwise similarity ${maxSim} is below consensus threshold ${threshold}`,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Find consensus cluster matching threshold with the best pair
|
|
120
|
+
const clusterSet = new Set(bestPair)
|
|
121
|
+
for (let k = 0; k < n; k++) {
|
|
122
|
+
if (k === bestPair[0] || k === bestPair[1]) continue
|
|
123
|
+
if (matrix[k][bestPair[0]] >= threshold && matrix[k][bestPair[1]] >= threshold) {
|
|
124
|
+
clusterSet.add(k)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const consensusIndices = Array.from(clusterSet).map((idx) => candidates[idx].index ?? idx)
|
|
129
|
+
const clusterCandidates = Array.from(clusterSet).map((idx) => candidates[idx])
|
|
130
|
+
|
|
131
|
+
// Select winner from consensus cluster (prioritize valid syntax, then lowest error count)
|
|
132
|
+
let winner = clusterCandidates[0]
|
|
133
|
+
for (const c of clusterCandidates) {
|
|
134
|
+
if (c.syntaxValidation?.valid && !winner.syntaxValidation?.valid) {
|
|
135
|
+
winner = c
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
reached: true,
|
|
141
|
+
similarity: maxSim,
|
|
142
|
+
winnerIndex: winner.index ?? 1,
|
|
143
|
+
consensusIndices,
|
|
144
|
+
winnerCandidate: winner,
|
|
145
|
+
reason: `Semantic consensus reached (${maxSim} >= ${threshold}) across candidates ${consensusIndices.join(', ')}`,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Estimates saved tokens and cost from bypassing the judge round.
|
|
151
|
+
* @param {Array<object>} candidates
|
|
152
|
+
* @param {string} prompt
|
|
153
|
+
* @param {number} [maxTokens=4096]
|
|
154
|
+
* @param {object} [prices={}]
|
|
155
|
+
* @returns {{ skippedTokens: number, skippedCostUsd: number }}
|
|
156
|
+
*/
|
|
157
|
+
export function estimateSkippedJudgeUsage(candidates = [], prompt = '', maxTokens = 4096, prices = {}) {
|
|
158
|
+
const promptChars = (prompt?.length || 0) + candidates.reduce((sum, c) => sum + (c.text?.length || 0), 0)
|
|
159
|
+
const estimatedInputTokens = Math.max(100, Math.round(promptChars / 3.8))
|
|
160
|
+
const estimatedOutputTokens = Math.min(Math.max(200, Math.round(maxTokens * 0.25)), 1000)
|
|
161
|
+
const skippedTokens = estimatedInputTokens + estimatedOutputTokens
|
|
162
|
+
|
|
163
|
+
const costEst = estimateTokenCost({ provider: 'default', model: 'judge' }, {
|
|
164
|
+
inputTokens: estimatedInputTokens,
|
|
165
|
+
outputTokens: estimatedOutputTokens,
|
|
166
|
+
}, prices)
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
skippedTokens,
|
|
170
|
+
skippedCostUsd: Number((costEst?.costUsd || 0.005).toFixed(5)),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Evaluates candidates for early consensus and executes early exit bypass if reached.
|
|
176
|
+
* @param {object} params
|
|
177
|
+
* @returns {Promise<{ ok: boolean, consensusReached: boolean, payload?: object, error?: Error, abort?: boolean }>}
|
|
178
|
+
*/
|
|
179
|
+
export function stageEarlyConsensus({
|
|
180
|
+
successfulRefs = [],
|
|
181
|
+
referenceOutputs = [],
|
|
182
|
+
preset = {},
|
|
183
|
+
userPrompt = '',
|
|
184
|
+
prices = {},
|
|
185
|
+
maxTokens = 4096,
|
|
186
|
+
cwd = process.cwd(),
|
|
187
|
+
runId = '',
|
|
188
|
+
signal = null,
|
|
189
|
+
promoteOpts = {},
|
|
190
|
+
allowCandidateOverride = false,
|
|
191
|
+
isRefinement = false,
|
|
192
|
+
historyFilePath = null,
|
|
193
|
+
startTime = Date.now(),
|
|
194
|
+
collectedCtx = null,
|
|
195
|
+
liveCanvas = null,
|
|
196
|
+
ctx = null,
|
|
197
|
+
}) {
|
|
198
|
+
const enabled = Boolean(preset?.early_exit_consensus_enabled)
|
|
199
|
+
const threshold = typeof preset?.early_exit_threshold === 'number' ? preset.early_exit_threshold : 0.95
|
|
200
|
+
|
|
201
|
+
if (!enabled || successfulRefs.length < 2) {
|
|
202
|
+
return Promise.resolve({ ok: true, consensusReached: false })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const consensus = checkSemanticConsensus(successfulRefs, threshold)
|
|
206
|
+
if (!consensus.reached || !consensus.winnerCandidate) {
|
|
207
|
+
return Promise.resolve({ ok: true, consensusReached: false, similarity: consensus.similarity })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const winner = consensus.winnerCandidate
|
|
211
|
+
emitEvent(ctx, MOA_EVENTS.EARLY_CONSENSUS, {
|
|
212
|
+
runId,
|
|
213
|
+
similarity: consensus.similarity,
|
|
214
|
+
threshold,
|
|
215
|
+
winnerIndex: consensus.winnerIndex,
|
|
216
|
+
winnerModel: winner.label,
|
|
217
|
+
consensusCandidates: consensus.consensusIndices,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// Promote winning candidate files
|
|
221
|
+
let promotedFiles = []
|
|
222
|
+
let promoteErr = null
|
|
223
|
+
const runPromotion = async () => {
|
|
224
|
+
if (winner.files?.length > 0) {
|
|
225
|
+
try {
|
|
226
|
+
promotedFiles = await promoteCandidateWorkspace(cwd, winner.index, {
|
|
227
|
+
...promoteOpts,
|
|
228
|
+
runId,
|
|
229
|
+
keepMoa: allowCandidateOverride,
|
|
230
|
+
})
|
|
231
|
+
} catch (err) {
|
|
232
|
+
promoteErr = err
|
|
233
|
+
}
|
|
234
|
+
} else if (!allowCandidateOverride) {
|
|
235
|
+
await cleanMoaWorkspaces(cwd, { runId })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (promoteErr) {
|
|
239
|
+
if (!allowCandidateOverride) await cleanMoaWorkspaces(cwd, { runId })
|
|
240
|
+
if (signal?.aborted || promoteErr?.message?.includes('aborted')) {
|
|
241
|
+
return { ok: false, consensusReached: true, abort: true, error: new Error('Turn aborted') }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const skippedUsage = estimateSkippedJudgeUsage(successfulRefs, userPrompt, maxTokens, prices)
|
|
246
|
+
const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs, {
|
|
247
|
+
winningIndex: winner.index,
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0)
|
|
251
|
+
const totalCostUsd = Number(referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0).toFixed(5))
|
|
252
|
+
const durationMs = Date.now() - startTime
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
await recordMoaRunAsync({
|
|
256
|
+
id: runId,
|
|
257
|
+
cwd,
|
|
258
|
+
prompt: userPrompt,
|
|
259
|
+
preset: preset?.name || 'default',
|
|
260
|
+
isRefinement,
|
|
261
|
+
isEarlyConsensus: true,
|
|
262
|
+
candidates: candidatesForHistory(referenceOutputs),
|
|
263
|
+
aggregator: 'Early Consensus (Bypassed)',
|
|
264
|
+
winnerIndex: winner.index,
|
|
265
|
+
winnerModel: winner.label,
|
|
266
|
+
promotedFiles,
|
|
267
|
+
totalTokens,
|
|
268
|
+
totalCostUsd,
|
|
269
|
+
skippedJudgeTokens: skippedUsage.skippedTokens,
|
|
270
|
+
durationMs,
|
|
271
|
+
}, historyFilePath || undefined)
|
|
272
|
+
} catch (histErr) {
|
|
273
|
+
logger.warn('[dsh-moa] Failed to record early consensus in history:', histErr?.message || histErr)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
emitEvent(ctx, MOA_EVENTS.RUN_FINISH, {
|
|
277
|
+
runId,
|
|
278
|
+
durationMs,
|
|
279
|
+
totalTokens,
|
|
280
|
+
totalCostUsd,
|
|
281
|
+
promotedFiles,
|
|
282
|
+
winnerIndex: winner.index,
|
|
283
|
+
isEarlyConsensus: true,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
consensusReached: true,
|
|
289
|
+
payload: {
|
|
290
|
+
kind: 'synthesis',
|
|
291
|
+
content: winner.text,
|
|
292
|
+
aggregator: 'Early Consensus (Bypassed)',
|
|
293
|
+
references: referenceOutputs,
|
|
294
|
+
presetName: preset?.name || 'default',
|
|
295
|
+
isRefinement,
|
|
296
|
+
isEarlyConsensus: true,
|
|
297
|
+
winningIndex: winner.index,
|
|
298
|
+
winnerModel: winner.label,
|
|
299
|
+
promotedFiles,
|
|
300
|
+
runId,
|
|
301
|
+
consensusInfo: {
|
|
302
|
+
similarity: consensus.similarity,
|
|
303
|
+
threshold,
|
|
304
|
+
consensusIndices: consensus.consensusIndices,
|
|
305
|
+
},
|
|
306
|
+
...(livePreview ? { liveCanvas: livePreview } : {}),
|
|
307
|
+
usage: {
|
|
308
|
+
totalTokens,
|
|
309
|
+
totalCostUsd,
|
|
310
|
+
skippedJudgeTokens: skippedUsage.skippedTokens,
|
|
311
|
+
skippedJudgeCostUsd: skippedUsage.skippedCostUsd,
|
|
312
|
+
candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
|
|
313
|
+
aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
314
|
+
},
|
|
315
|
+
skippedFiles: collectedCtx?.skippedFiles || 0,
|
|
316
|
+
skippedList: collectedCtx?.skippedList || [],
|
|
317
|
+
durationMs,
|
|
318
|
+
},
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return runPromotion()
|
|
323
|
+
}
|
|
324
|
+
|