@goodandready/dsh-moa 0.2.70 → 0.2.72

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,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
+
@@ -1,3 +1,4 @@
1
+ import { buildAstCompositeMergeDirective } from '../moa-ast-merge.js'
1
2
  import { runVisualMoaConsensus } from '../moa-visual-consensus.js'
2
3
  import crypto from 'node:crypto'
3
4
  import { isTestEnvironment } from '../history.js'
@@ -12,6 +13,8 @@ import { stagePeerCritique } from './peer-critique.js'
12
13
  import { stageMultiJudge } from './multi-judge.js'
13
14
  import { stageSynthesis } from './synthesis.js'
14
15
  import { stagePromotion } from './promotion.js'
16
+ import { applySyntaxFilter, formatSyntaxDirective } from './syntax-filter.js'
17
+ import { stageEarlyConsensus } from './early-consensus.js'
15
18
  import { emitEvent, MOA_EVENTS } from '../events.js'
16
19
 
17
20
  export async function runMoAPipeline({
@@ -76,7 +79,7 @@ export async function runMoAPipeline({
76
79
  })
77
80
  return candStage.failurePayload
78
81
  }
79
- const { referenceOutputs, successfulRefs } = candStage
82
+ const { referenceOutputs, successfulRefs, adversarialTddResult } = candStage
80
83
 
81
84
  for (const ref of referenceOutputs) {
82
85
  emitEvent(ctx, MOA_EVENTS.CANDIDATE_COMPLETE, {
@@ -89,6 +92,16 @@ export async function runMoAPipeline({
89
92
  })
90
93
  }
91
94
 
95
+ // Pre-Judge Stage 2.5: Zero-Token Local Syntax Gate (#303)
96
+ const syntaxResult = applySyntaxFilter(successfulRefs, { enabled: ctxStage.localPreflightCheck })
97
+ emitEvent(ctx, MOA_EVENTS.SYNTAX_VALIDATION, {
98
+ runId,
99
+ filterApplied: syntaxResult.filterApplied,
100
+ validCount: syntaxResult.validCandidates.length,
101
+ invalidCount: syntaxResult.invalidCandidates.length,
102
+ })
103
+ const syntaxDirective = syntaxResult.filterApplied ? formatSyntaxDirective(referenceOutputs, { blind: ctxStage.isBlindEvaluation }) : null
104
+
92
105
  // Stage 3: Clarifying Questions Branch
93
106
  if (ctxStage.needsQuestions) {
94
107
  return await stageClarifyingQuestions({
@@ -102,7 +115,8 @@ export async function runMoAPipeline({
102
115
  // Stage 4: Fast Mode (1 candidate bypass)
103
116
  if (ctxStage.isFastMode) {
104
117
  const fastRes = await stageFastMode({
105
- successfulRefs, referenceOutputs,
118
+ successfulRefs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
119
+ referenceOutputs,
106
120
  allowCandidateOverride: ctxStage.allowCandidateOverride,
107
121
  promoteOpts, cwd, runId, signal, userPrompt, preset,
108
122
  isRefinement: ctxStage.isRefinement, historyFilePath, startTime,
@@ -115,6 +129,32 @@ export async function runMoAPipeline({
115
129
  return fastRes.payload
116
130
  }
117
131
 
132
+ // Pre-Judge Stage 4.5: Early Exit upon Semantic Consensus (#299)
133
+ if (ctxStage.earlyExitConsensusEnabled && successfulRefs.length > 1) {
134
+ const earlyRes = await stageEarlyConsensus({
135
+ successfulRefs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
136
+ referenceOutputs,
137
+ preset,
138
+ userPrompt,
139
+ prices,
140
+ maxTokens: ctxStage.maxTokens,
141
+ cwd,
142
+ runId,
143
+ signal,
144
+ promoteOpts,
145
+ allowCandidateOverride: ctxStage.allowCandidateOverride,
146
+ isRefinement: ctxStage.isRefinement,
147
+ historyFilePath,
148
+ startTime,
149
+ collectedCtx: ctxStage.collectedCtx,
150
+ liveCanvas,
151
+ ctx,
152
+ })
153
+ if (earlyRes.consensusReached) {
154
+ return earlyRes.payload
155
+ }
156
+ }
157
+
118
158
  if (signal?.aborted) {
119
159
  await cleanMoaWorkspaces(cwd, { runId })
120
160
  return { error: new Error('Turn aborted') }
@@ -123,7 +163,8 @@ export async function runMoAPipeline({
123
163
  // Stage 5: Consilium Round 2 (Peer Critique)
124
164
  const peerRes = await stagePeerCritique({
125
165
  isPeerCritiqueEnabled: ctxStage.isPeerCritiqueEnabled,
126
- successfulRefs, referenceOutputs, preset, prices,
166
+ successfulRefs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
167
+ referenceOutputs, preset, prices,
127
168
  maxTokens: ctxStage.maxTokens, primaryJudge: ctxStage.primaryJudge,
128
169
  userPrompt, isBlindEvaluation: ctxStage.isBlindEvaluation,
129
170
  refTemp: ctxStage.refTemp, refTimeoutSec: ctxStage.refTimeoutSec,
@@ -147,7 +188,9 @@ export async function runMoAPipeline({
147
188
 
148
189
  // Stage 6: Multi-Judge Panel
149
190
  const mjRes = await stageMultiJudge({
150
- preset, successfulRefs, referenceOutputs, primaryJudge: ctxStage.primaryJudge,
191
+ preset,
192
+ successfulRefs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
193
+ referenceOutputs, primaryJudge: ctxStage.primaryJudge,
151
194
  userPrompt, judgeCriteria: ctxStage.judgeCriteria, prices, maxTokens: ctxStage.maxTokens,
152
195
  callLlm, signal, onProgress, cwd, runId, isRefinement: ctxStage.isRefinement,
153
196
  isFastMode: ctxStage.isFastMode, collectedCtx: ctxStage.collectedCtx, startTime,
@@ -155,9 +198,13 @@ export async function runMoAPipeline({
155
198
  if (!mjRes.ok) return mjRes.abortPayload
156
199
  const multiJudgeResult = mjRes.multiJudgeResult
157
200
 
158
- const compositeMergeDirective = (preset?.composite_merge_enabled && successfulRefs.length > 1)
159
- ? buildCompositeBlockDirectives(successfulRefs, { blind: Boolean(preset.blind_evaluation) })
160
- : null
201
+ let compositeMergeDirective = null
202
+ if (preset?.ast_composite_merge_enabled && successfulRefs.length > 1) {
203
+ const astMerge = await buildAstCompositeMergeDirective(successfulRefs, { ctx, blind: Boolean(preset.blind_evaluation) })
204
+ compositeMergeDirective = astMerge.directive
205
+ } else if (preset?.composite_merge_enabled && successfulRefs.length > 1) {
206
+ compositeMergeDirective = buildCompositeBlockDirectives(successfulRefs, { blind: Boolean(preset.blind_evaluation) })
207
+ }
161
208
  let visualResult = null
162
209
  if (preset?.visual_moa_enabled) {
163
210
  visualResult = await runVisualMoaConsensus({
@@ -171,11 +218,13 @@ export async function runMoAPipeline({
171
218
  const rawFileDirective = (preset?.interactive_workbench_enabled)
172
219
  ? formatFileScoresDirective()
173
220
  : null
174
- const fileScoringDirective = [rawFileDirective, visualResult?.directive].filter(Boolean).join("\n\n") || null
221
+ const fileScoringDirective = [rawFileDirective, visualResult?.directive, adversarialTddResult?.directive, syntaxDirective].filter(Boolean).join("\n\n") || null
175
222
 
176
223
  // Stage 7: Synthesis Phase
177
224
  const synthRes = await stageSynthesis({
178
- userPrompt, referenceOutputs, successfulRefs, judgeCriteria: ctxStage.judgeCriteria,
225
+ userPrompt, referenceOutputs,
226
+ successfulRefs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
227
+ judgeCriteria: ctxStage.judgeCriteria,
179
228
  isCuratorSynthesis: ctxStage.isCuratorSynthesis, isBlindEvaluation: ctxStage.isBlindEvaluation,
180
229
  priorTurnBaseline: ctxStage.priorTurnBaseline, compositeMergeDirective, fileScoringDirective,
181
230
  multiJudgeResult, preset, prices, maxTokens: ctxStage.maxTokens, isFastMode: ctxStage.isFastMode,
@@ -195,7 +244,9 @@ export async function runMoAPipeline({
195
244
 
196
245
  // Stage 8: Evaluation, Winner & Promotion
197
246
  const promRes = await stagePromotion({
198
- synthesizedText, referenceOutputs, successfulRefs, multiJudgeResult,
247
+ synthesizedText, referenceOutputs,
248
+ successfulRefs: syntaxResult.validCandidates.length > 0 ? syntaxResult.validCandidates : successfulRefs,
249
+ multiJudgeResult,
199
250
  isCuratorSynthesis: ctxStage.isCuratorSynthesis,
200
251
  allowCandidateOverride: ctxStage.allowCandidateOverride,
201
252
  promoteOpts, cwd, runId, signal, liveCanvas, startTime, chosenJudge, aggUsage,
@@ -209,3 +260,4 @@ export async function runMoAPipeline({
209
260
  }
210
261
  return promRes.payload
211
262
  }
263
+