sweet-search 2.6.13 → 2.6.14

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.
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { readFileRange } from './search-pattern-chunks.js';
22
+ import { computeSufficiencyVerdict } from './query-sufficiency.js';
22
23
  import { statSync } from 'fs';
23
24
  import path from 'path';
24
25
 
@@ -1130,15 +1131,34 @@ function unresolvedExternalRefs(code, ownSymbolName, headerContext, neighborsRen
1130
1131
  * or in the surfaced neighbours list
1131
1132
  * - high_confidence : score gap puts top-1 well ahead of top-2
1132
1133
  *
1133
- * sufficient := complete_symbol
1134
- * AND (header_resolved OR neighbors_present OR self_contained_strict)
1135
- * AND (high_confidence OR header_resolved OR neighbors_present)
1134
+ * Query-conditioned rule (July 2026 — full200 bench forensics showed the
1135
+ * structural rule mislabels systemically: `confidence=low sufficient=YES`
1136
+ * was the modal trailer, 156×, and reasons were structural in ~all cases):
1137
+ * the verdict is now 3-valued and REQUIRES positive query-match evidence
1138
+ * in top-1 (see core/search/query-sufficiency.js):
1139
+ *
1140
+ * yes := strong query evidence (exact anchor hit or high subtoken
1141
+ * overlap) AND (confidence=high OR (confidence=medium AND
1142
+ * structural resolution))
1143
+ * no := no results, or a query with real anchors finding nothing
1144
+ * in top-1 (confirmed absence)
1145
+ * unknown := everything ambiguous (incl. the old structural-only YES,
1146
+ * now reported as reason=well_formed_only)
1147
+ *
1148
+ * Structural facts (complete_symbol / header_resolved / neighbors_present /
1149
+ * self_contained_strict) are kept as diagnostic reasons and as the
1150
+ * resolution gate for medium-confidence YES — they can no longer produce
1151
+ * YES on their own. `sufficient` (boolean) stays = (verdict === 'yes') for
1152
+ * every structured consumer (MCP zod schema, bench counters).
1136
1153
  *
1137
1154
  * @param {object} topResult
1138
1155
  * @param {{ confidence: string }} confidenceInfo
1139
- * @returns {{ sufficient: boolean, reasons: string[], unresolvedExternalCount: number }}
1156
+ * @param {{ query?: string, regex?: string, lowerResults?: object[] }} [queryContext]
1157
+ * @returns {{ sufficient: boolean, verdict: 'yes'|'no'|'unknown',
1158
+ * sufficiencyReason: string, reasons: string[],
1159
+ * unresolvedExternalCount: number, evidence: object|null }}
1140
1160
  */
1141
- export function computeSufficiency(topResult, confidenceInfo) {
1161
+ export function computeSufficiency(topResult, confidenceInfo, queryContext = {}) {
1142
1162
  const reasons = [];
1143
1163
 
1144
1164
  const isComplete = !!(topResult.symbol &&
@@ -1168,16 +1188,31 @@ export function computeSufficiency(topResult, confidenceInfo) {
1168
1188
 
1169
1189
  if (confidenceInfo?.confidence === 'high') reasons.push('high_confidence');
1170
1190
 
1171
- // Tightened rule: complete symbol is necessary but NOT sufficient.
1172
- // We require at least one resolution reason AND at least one specificity
1173
- // reason. This stops a bare "complete + high_confidence" from claiming
1174
- // sufficient when the body still references a dozen helpers we never
1175
- // surfaced (the validation-pipeline failure mode).
1176
1191
  const hasResolution = hasHeader || hasNeighbors || reasons.includes('self_contained_strict');
1177
- const hasSpecificity = confidenceInfo?.confidence === 'high' || hasHeader || hasNeighbors;
1178
- const sufficient = isComplete && hasResolution && hasSpecificity;
1179
1192
 
1180
- return { sufficient, reasons, unresolvedExternalCount: unresolvedCount };
1193
+ // Query-conditioned verdict: structural facts are the resolution gate,
1194
+ // never the whole answer. See query-sufficiency.js for the fusion rule.
1195
+ const { verdict, reason: sufficiencyReason, evidence } = computeSufficiencyVerdict({
1196
+ topResult,
1197
+ confidenceInfo,
1198
+ query: queryContext.query || '',
1199
+ regex: queryContext.regex || '',
1200
+ structural: { isComplete, hasResolution },
1201
+ lowerResults: queryContext.lowerResults || [],
1202
+ });
1203
+
1204
+ if (evidence?.exactHit) reasons.push('query_literal_matched');
1205
+ else if (evidence?.strength === 'strong') reasons.push('query_token_overlap');
1206
+ else if (evidence?.strength === 'none') reasons.push('no_query_evidence');
1207
+
1208
+ return {
1209
+ sufficient: verdict === 'yes',
1210
+ verdict,
1211
+ sufficiencyReason,
1212
+ reasons,
1213
+ unresolvedExternalCount: unresolvedCount,
1214
+ evidence,
1215
+ };
1181
1216
  }
1182
1217
 
1183
1218
  // =============================================================================
@@ -2287,15 +2322,27 @@ export function packageForAgent(rankedResults, searchStats, opts) {
2287
2322
 
2288
2323
  const packagingMs = Math.round(performance.now() - start);
2289
2324
 
2290
- // Sufficiency signal for top-1. Tightened in 2026-05: requires resolution
2291
- // (header_resolved OR neighbors_present OR self_contained_strict) instead
2292
- // of the old "complete_symbol + high_confidence" rule.
2325
+ // Sufficiency signal for top-1. Query-conditioned since 2026-07: the
2326
+ // 3-valued verdict requires positive query-match evidence; structural
2327
+ // packaging facts remain as reasons + the medium-confidence resolution
2328
+ // gate. Runs for summary-only top-1 too (verdict can be 'no'/'unknown'
2329
+ // there; never 'yes' without code).
2293
2330
  let sufficient = false;
2331
+ let sufficiencyVerdict = 'no';
2332
+ let sufficiencyReason = 'no_results';
2294
2333
  let sufficiencyReasons = [];
2295
2334
  let unresolvedExternalCount = 0;
2296
- if (agentResults.length > 0 && agentResults[0].code) {
2297
- const sufficiency = computeSufficiency(agentResults[0], confidenceInfo);
2335
+ if (agentResults.length > 0) {
2336
+ const sufficiency = computeSufficiency(agentResults[0], confidenceInfo, {
2337
+ query,
2338
+ regex,
2339
+ // Code-bearing lower ranks (full/preview) — soften a false 'no' when
2340
+ // the pack's answer sits below top-1; never used to grant YES.
2341
+ lowerResults: agentResults.slice(1, 4).filter(r => r.code),
2342
+ });
2298
2343
  sufficient = sufficiency.sufficient;
2344
+ sufficiencyVerdict = sufficiency.verdict;
2345
+ sufficiencyReason = sufficiency.sufficiencyReason;
2299
2346
  sufficiencyReasons = sufficiency.reasons;
2300
2347
  unresolvedExternalCount = sufficiency.unresolvedExternalCount || 0;
2301
2348
  }
@@ -2317,6 +2364,8 @@ export function packageForAgent(rankedResults, searchStats, opts) {
2317
2364
  confidence: confidenceInfo.confidence,
2318
2365
  confidenceReason: confidenceInfo.confidenceReason,
2319
2366
  sufficient,
2367
+ sufficiencyVerdict,
2368
+ sufficiencyReason,
2320
2369
  sufficiencyReasons,
2321
2370
  unresolvedExternalCount,
2322
2371
 
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Query-conditioned sufficiency evidence (2026-07).
3
+ *
4
+ * Answers ONE question, cheaply: does the top-1 packaged hit contain
5
+ * positive evidence that it matches what the QUERY asked for? This is the
6
+ * signal the structural sufficiency rule (complete_symbol / header_resolved /
7
+ * neighbors_present) never looked at — a perfectly well-formed function that
8
+ * has nothing to do with the query must not earn `sufficient=YES`.
9
+ *
10
+ * Literature anchor: Sufficient Context (Joren et al., ICLR 2025) — a naive
11
+ * lexical answer-presence check alone reaches F1 0.81 / precision 0.87 on
12
+ * sufficiency; fusing it (AND, not weighted sum) with an independent
13
+ * confidence signal is the high-precision pattern. See
14
+ * eval/task-completion-bench/analysis/sufficiency-redesign-2026-07-06.md.
15
+ *
16
+ * Constraints honoured here:
17
+ * - engine-local, query-time only: pure string work over the already-
18
+ * retrieved top-1 (haystack capped), no I/O, no LLM, sub-millisecond;
19
+ * - only ever invoked from packageForAgent (agent formats) — NL/GCSN
20
+ * ranking paths never reach this module;
21
+ * - identifier-SHAPE heuristics, not capture-filtering stopword lists
22
+ * (CLAUDE.md rule): the only stopword set below is a stable ~30-entry
23
+ * English query-tokenization list.
24
+ */
25
+
26
+ // Query-tokenization stopwords (standard English IR practice — the "OK to
27
+ // keep" category). Filters function words from the subtoken-overlap signal;
28
+ // never used to filter identifier captures.
29
+ const EVIDENCE_STOPWORDS = new Set([
30
+ 'a', 'an', 'and', 'are', 'can', 'could', 'did', 'do', 'does', 'for',
31
+ 'from', 'how', 'in', 'into', 'is', 'it', 'its', 'not', 'of', 'on', 'or',
32
+ 'should', 'that', 'the', 'this', 'to', 'was', 'were', 'what', 'when',
33
+ 'where', 'which', 'with',
34
+ ]);
35
+
36
+ function envFloat(name, fallback) {
37
+ const raw = process.env[name];
38
+ if (raw == null || raw === '') return fallback;
39
+ const parsed = Number.parseFloat(raw);
40
+ return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : fallback;
41
+ }
42
+
43
+ // Thresholds are parameterized (env-overridable for offline calibration
44
+ // sweeps) — tuned on the task-level dev split of the full200 replay, never
45
+ // hand-picked against held-out. See the design note for the discipline.
46
+ export function sufficiencyThresholds() {
47
+ return {
48
+ overlapStrong: envFloat('SS_SUFF_OVERLAP_STRONG', 0.6),
49
+ overlapNone: envFloat('SS_SUFF_OVERLAP_NONE', 0.2),
50
+ minInformativeSubtokens: 2,
51
+ haystackCap: 32768,
52
+ };
53
+ }
54
+
55
+ const WORD_CHAR = /[A-Za-z0-9_$]/;
56
+
57
+ function isWordChar(ch) {
58
+ return ch != null && WORD_CHAR.test(ch);
59
+ }
60
+
61
+ /**
62
+ * Word-boundary substring check without compiling a regex per token.
63
+ * A match counts only when the char before and after the occurrence are
64
+ * not identifier chars ("Chunker" must not match inside "ChunkerParams"
65
+ * unless the query token IS ChunkerParams).
66
+ */
67
+ export function containsToken(haystack, token, { caseSensitive = true } = {}) {
68
+ if (!haystack || !token) return false;
69
+ const hay = caseSensitive ? haystack : haystack.toLowerCase();
70
+ const needle = caseSensitive ? token : token.toLowerCase();
71
+ let from = 0;
72
+ while (from <= hay.length - needle.length) {
73
+ const idx = hay.indexOf(needle, from);
74
+ if (idx === -1) return false;
75
+ const before = idx > 0 ? hay[idx - 1] : null;
76
+ const after = idx + needle.length < hay.length ? hay[idx + needle.length] : null;
77
+ if (!isWordChar(before) && !isWordChar(after)) return true;
78
+ from = idx + 1;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ /**
84
+ * Identifier-shape heuristic (shape, not a word list): a token reads as a
85
+ * code identifier when it carries structure natural language words don't —
86
+ * camelCase humps, underscores/$, digits mixed into letters, dotted/pathy
87
+ * compounds, or ALL_CAPS constants. Pure lowercase single words ("parse",
88
+ * "config") are NOT identifier-shaped; they flow into the softer
89
+ * subtoken-overlap signal instead.
90
+ */
91
+ export function looksLikeIdentifierToken(token) {
92
+ if (!token || token.length < 3) return false;
93
+ if (/[_$]/.test(token)) return true;
94
+ if (/[a-z][A-Z]/.test(token)) return true; // camelCase hump
95
+ if (/[A-Za-z]\d|\d[A-Za-z]/.test(token)) return true; // letters+digits
96
+ if (/[.:/\\-]/.test(token) && /[A-Za-z]{2,}/.test(token)) return true; // dotted/pathy
97
+ if (/^[A-Z][A-Z0-9]{2,}$/.test(token)) return true; // SCREAMING caps
98
+ if (/^[A-Z][a-z]+[A-Z]/.test(token)) return true; // PascalCase
99
+ return false;
100
+ }
101
+
102
+ /**
103
+ * Pull literal runs out of a user regex (ss-find / ss-grep patterns):
104
+ * unescape metachar escapes, drop anchors, split on regex syntax, keep
105
+ * word-ish runs of length ≥ 3. "\\bChunkerParams\\b" → ["ChunkerParams"].
106
+ */
107
+ export function regexLiteralRuns(regexSource) {
108
+ if (!regexSource) return [];
109
+ const unescaped = String(regexSource)
110
+ .replace(/\\[bBdDsSwWAZz]/g, ' ') // char-class/anchor escapes → separator
111
+ .replace(/\\([^A-Za-z0-9])/g, '$1'); // \. \( … → literal char
112
+ const runs = unescaped.split(/[\^$.|?*+()[\]{}<>=!,\s]+/);
113
+ const out = [];
114
+ for (const run of runs) {
115
+ const trimmed = run.replace(/^[-:/\\]+|[-:/\\]+$/g, '');
116
+ if (trimmed.length >= 3 && /[A-Za-z]/.test(trimmed)) out.push(trimmed);
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * Split text into normalized informative subtokens: identifier chunks are
123
+ * split on case humps / non-alnum, lowercased, stopword- and length-filtered.
124
+ * Single-pass character scanner — this runs over the top-1 haystack on every
125
+ * agent-format search, so it must stay well under a millisecond at 32KB.
126
+ */
127
+ export function informativeSubtokens(text) {
128
+ const out = new Set();
129
+ if (!text) return out;
130
+ const s = String(text);
131
+ const n = s.length;
132
+ let start = -1; // current subtoken start
133
+ let hasAlpha = false; // subtoken contains a letter (pure digits dropped)
134
+ const flush = (end) => {
135
+ if (start === -1) return;
136
+ if (hasAlpha && end - start >= 3) {
137
+ const norm = s.slice(start, end).toLowerCase();
138
+ if (!EVIDENCE_STOPWORDS.has(norm)) out.add(norm);
139
+ }
140
+ start = -1;
141
+ hasAlpha = false;
142
+ };
143
+ let prevLower = false; // previous char was a lowercase letter or digit
144
+ let prevUpper = false; // previous char was uppercase
145
+ for (let i = 0; i < n; i++) {
146
+ const c = s.charCodeAt(i);
147
+ const isLower = c >= 97 && c <= 122;
148
+ const isUpper = c >= 65 && c <= 90;
149
+ const isDigit = c >= 48 && c <= 57;
150
+ if (isLower || isUpper || isDigit) {
151
+ // camelCase hump: aB starts a new subtoken; ABc splits before the 'Bc'.
152
+ if (start !== -1 && isUpper && prevLower) {
153
+ flush(i);
154
+ } else if (start !== -1 && isLower && prevUpper && i - start >= 2) {
155
+ const split = i - 1; // "HTTPServer" → "http" + "server"
156
+ if (split - start >= 3) {
157
+ const norm = s.slice(start, split).toLowerCase();
158
+ if (!EVIDENCE_STOPWORDS.has(norm)) out.add(norm);
159
+ }
160
+ start = split;
161
+ hasAlpha = true;
162
+ }
163
+ if (start === -1) { start = i; hasAlpha = false; }
164
+ if (isLower || isUpper) hasAlpha = true;
165
+ prevLower = isLower || isDigit;
166
+ prevUpper = isUpper;
167
+ } else {
168
+ flush(i);
169
+ prevLower = false;
170
+ prevUpper = false;
171
+ }
172
+ }
173
+ flush(n);
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * Extract the query's evidence anchors.
179
+ *
180
+ * @param {string} query natural-language or symbol query
181
+ * @param {string} [regex] optional user regex (ss-find)
182
+ * @returns {{ anchors: string[], subtokens: Set<string> }}
183
+ * anchors: exact-match candidates (quoted literals, identifier-shaped
184
+ * tokens, regex literal runs) — checked verbatim with word boundaries;
185
+ * subtokens: normalized informative subtokens for the overlap signal.
186
+ */
187
+ export function extractQueryEvidence(query, regex) {
188
+ const q = String(query || '');
189
+ const anchors = [];
190
+ const seen = new Set();
191
+ const push = (tok) => {
192
+ if (tok && tok.length >= 3 && !seen.has(tok)) { seen.add(tok); anchors.push(tok); }
193
+ };
194
+
195
+ // Quoted literals (error strings, config keys) — strongest anchors.
196
+ const quoteRe = /"([^"\n]{3,120})"|'([^'\n]{3,120})'|`([^`\n]{3,120})`/g;
197
+ let m;
198
+ while ((m = quoteRe.exec(q)) !== null) push(m[1] || m[2] || m[3]);
199
+
200
+ // Identifier-shaped tokens from the query text.
201
+ for (const raw of q.split(/[\s,;()"'`]+/)) {
202
+ const tok = raw.replace(/^[^A-Za-z0-9_$]+|[^A-Za-z0-9_$)]+$/g, '');
203
+ if (looksLikeIdentifierToken(tok)) push(tok);
204
+ }
205
+
206
+ // Literal runs from the regex (the exact thing the caller greps for).
207
+ for (const run of regexLiteralRuns(regex)) push(run);
208
+
209
+ return { anchors, subtokens: informativeSubtokens(q) };
210
+ }
211
+
212
+ /**
213
+ * Assess query-match evidence in the top-1 packaged result.
214
+ *
215
+ * @param {string} query
216
+ * @param {string} regex
217
+ * @param {object} topResult packaged agent result (symbol, file, code,
218
+ * headerContext, summary)
219
+ * @returns {{ strength: 'strong'|'partial'|'none', exactHit: boolean,
220
+ * matchedAnchor: string|null, overlap: number,
221
+ * informativeCount: number }}
222
+ */
223
+ export function assessQueryEvidence(query, regex, topResult) {
224
+ const t = sufficiencyThresholds();
225
+ const { anchors, subtokens } = extractQueryEvidence(query, regex);
226
+
227
+ const haystackParts = [
228
+ topResult?.symbol || '',
229
+ topResult?.file || '',
230
+ topResult?.headerContext || '',
231
+ topResult?.summary || '',
232
+ topResult?.code || '',
233
+ ];
234
+ const haystack = haystackParts.join('\n').slice(0, t.haystackCap);
235
+
236
+ let exactHit = false;
237
+ let matchedAnchor = null;
238
+ for (const anchor of anchors) {
239
+ // Case-sensitive when the anchor carries case information; boundary-
240
+ // checked either way so "Chunker" can't match inside "ChunkerParams".
241
+ const caseSensitive = /[A-Z]/.test(anchor);
242
+ if (containsToken(haystack, anchor, { caseSensitive })) {
243
+ exactHit = true;
244
+ matchedAnchor = anchor;
245
+ break;
246
+ }
247
+ }
248
+
249
+ const informativeCount = subtokens.size;
250
+ let overlap = 0;
251
+ if (informativeCount > 0) {
252
+ const hayTokens = informativeSubtokens(haystack);
253
+ let matched = 0;
254
+ for (const tok of subtokens) if (hayTokens.has(tok)) matched++;
255
+ overlap = matched / informativeCount;
256
+ }
257
+
258
+ let strength = 'partial';
259
+ if (exactHit || (overlap >= t.overlapStrong && informativeCount >= t.minInformativeSubtokens)) {
260
+ strength = 'strong';
261
+ } else if (informativeCount >= t.minInformativeSubtokens && overlap <= t.overlapNone) {
262
+ // Only a query with enough informative tokens can CONFIRM absence —
263
+ // a 1-token NL query never earns a discouraging 'none'.
264
+ strength = 'none';
265
+ }
266
+
267
+ return { strength, exactHit, matchedAnchor, overlap, informativeCount };
268
+ }
269
+
270
+ /**
271
+ * Fuse query-match evidence with the engine's confidence bucket and the
272
+ * structural packaging facts into a 3-valued verdict.
273
+ *
274
+ * Rule (design note §2; stricter-YES direction):
275
+ * - YES requires strong evidence AND (confidence=high, or confidence=medium
276
+ * with resolved structural context). Packaging alone can never say YES;
277
+ * confidence=low can never say YES.
278
+ * - 'no' is reserved for confirmed absence (no results, or a query with
279
+ * real anchors/subtokens finding nothing in top-1). The borg-style
280
+ * false-no — literal present with a clear margin — becomes YES.
281
+ * - everything ambiguous is 'unknown', never a false binary.
282
+ *
283
+ * @param {object} args
284
+ * @param {object|null} args.topResult
285
+ * @param {{confidence: string}|null} args.confidenceInfo
286
+ * @param {string} args.query
287
+ * @param {string} [args.regex]
288
+ * @param {{isComplete: boolean, hasResolution: boolean}} args.structural
289
+ * @param {object[]} [args.lowerResults] code-bearing results below top-1
290
+ * (full/preview tiers) — consulted ONLY to soften a would-be 'no': when
291
+ * the pack's answer sits at rank 2-3, a flat 'no' would falsely push the
292
+ * agent away from a pack that contains it. YES stays top-1-strict.
293
+ * @returns {{ verdict: 'yes'|'no'|'unknown', reason: string,
294
+ * evidence: object|null }}
295
+ */
296
+ export function computeSufficiencyVerdict({ topResult, confidenceInfo, query, regex, structural, lowerResults = [] }) {
297
+ if (!topResult) {
298
+ return { verdict: 'no', reason: 'no_results', evidence: null };
299
+ }
300
+
301
+ const evidence = assessQueryEvidence(query, regex, topResult);
302
+ const conf = confidenceInfo?.confidence || 'low';
303
+ const structuralOk = !!(structural?.isComplete && structural?.hasResolution);
304
+
305
+ if (evidence.strength === 'none') {
306
+ for (const r of lowerResults) {
307
+ if (!r || (!r.code && !r.symbol)) continue;
308
+ const lower = assessQueryEvidence(query, regex, r);
309
+ if (lower.strength === 'strong') {
310
+ return { verdict: 'unknown', reason: 'evidence_below_top1', evidence };
311
+ }
312
+ }
313
+ return { verdict: 'no', reason: 'no_query_evidence', evidence };
314
+ }
315
+
316
+ if (!topResult.code) {
317
+ // Summary-only top-1: the agent holds no code to answer from.
318
+ return { verdict: 'unknown', reason: 'top_summary_only', evidence };
319
+ }
320
+
321
+ if (evidence.strength === 'strong') {
322
+ if (conf === 'high') {
323
+ return { verdict: 'yes', reason: evidence.exactHit ? 'query_evidence_clear_margin' : 'query_overlap_clear_margin', evidence };
324
+ }
325
+ if (conf === 'medium') {
326
+ // Dev-split calibration (full200 replay): gating medium on structural
327
+ // resolution cost recall (0.35 vs 0.41) for no precision gain (0.706
328
+ // vs 0.700) — strong evidence + a medium margin is enough. Low never
329
+ // reaches yes: re-admitting low+exactHit dropped precision to 0.61.
330
+ return { verdict: 'yes', reason: 'query_evidence_moderate_margin', evidence };
331
+ }
332
+ return { verdict: 'unknown', reason: 'evidence_without_margin', evidence };
333
+ }
334
+
335
+ // Partial evidence — the old structural-YES shape lands here.
336
+ return {
337
+ verdict: 'unknown',
338
+ reason: structuralOk ? 'well_formed_only' : 'partial_query_evidence',
339
+ evidence,
340
+ };
341
+ }
@@ -192,3 +192,18 @@ export function extractPositional(args) {
192
192
  if (bad) return { pattern: undefined, unknownFlag: bad };
193
193
  return { pattern: args[0], unknownFlag: null };
194
194
  }
195
+
196
+ // --- trailer rendering --------------------------------------------------------
197
+
198
+ // Sufficiency trailer segment: 3-valued verdict (YES / no / unknown) + a
199
+ // compact why-token, e.g. ` sufficient=YES (query_evidence_clear_margin)` vs
200
+ // ` sufficient=unknown (well_formed_only)`. Falls back to the legacy boolean
201
+ // when the engine predates sufficiencyVerdict. The full line shape stays
202
+ // `# confidence=<bucket> (<reason>) sufficient=<verdict> (<why>)`.
203
+ export function renderSufficiency(response) {
204
+ const verdict = response.sufficiencyVerdict
205
+ ? (response.sufficiencyVerdict === 'yes' ? 'YES' : response.sufficiencyVerdict)
206
+ : (response.sufficient ? 'YES' : 'no');
207
+ const why = response.sufficiencyReason ? ` (${response.sufficiencyReason})` : '';
208
+ return ` sufficient=${verdict}${why}`;
209
+ }
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url';
16
16
  import {
17
17
  parseBoolFlag, parseValueFlag, parsePositiveIntFlag,
18
18
  buildGrepPattern, stripInertFlags, normalizeArgs, extractPositional,
19
- parseLineRange, looksLikeOption,
19
+ parseLineRange, looksLikeOption, renderSufficiency,
20
20
  } from './_ss-argparse.mjs';
21
21
  import { renderGrepBody } from '../../../core/search/grep-output-shaping.js';
22
22
 
@@ -222,7 +222,7 @@ async function cmdFind(rawArgs) {
222
222
  ` budget=${response.tokenBudget} used=${response.tokensUsed} subMode=${response.subMode ?? format}\n`);
223
223
  if (response.confidence) {
224
224
  process.stdout.write(`# confidence=${response.confidence}${response.confidenceReason ? ' (' + response.confidenceReason + ')' : ''}` +
225
- `${response.sufficient ? ' sufficient=YES' : ' sufficient=no'}\n`);
225
+ `${renderSufficiency(response)}\n`);
226
226
  }
227
227
 
228
228
  // Per-result blocks — identical shape to ss-search's agent packaging.
@@ -417,7 +417,7 @@ async function cmdAgentSearch(rawArgs) {
417
417
  ` results=${response.results.length} subMode=${response.subMode}\n`);
418
418
  if (response.confidence) {
419
419
  process.stdout.write(`# confidence=${response.confidence}${response.confidenceReason ? ' (' + response.confidenceReason + ')' : ''}` +
420
- `${response.sufficient ? ' sufficient=YES' : ' sufficient=no'}\n`);
420
+ `${renderSufficiency(response)}\n`);
421
421
  }
422
422
 
423
423
  // Per-result blocks
@@ -474,6 +474,8 @@ async function cmdAgentSearch(rawArgs) {
474
474
  headerCount,
475
475
  confidence: response.confidence || null,
476
476
  sufficient: response.sufficient ?? null,
477
+ sufficiencyVerdict: response.sufficiencyVerdict ?? null,
478
+ sufficiencyReason: response.sufficiencyReason ?? null,
477
479
  sufficiencyReasons: Array.isArray(response.sufficiencyReasons) ? response.sufficiencyReasons : null,
478
480
  unresolvedExternalCount: typeof response.unresolvedExternalCount === 'number'
479
481
  ? response.unresolvedExternalCount : null,
@@ -42,6 +42,8 @@ export const SearchOutputSchema = z.object({
42
42
  confidence: z.enum(['high', 'medium', 'low']).optional(),
43
43
  confidenceReason: z.string().optional(),
44
44
  sufficient: z.boolean().optional(),
45
+ sufficiencyVerdict: z.enum(['yes', 'no', 'unknown']).optional(),
46
+ sufficiencyReason: z.string().optional(),
45
47
  sufficiencyReasons: z.array(z.string()).optional(),
46
48
  packagingMs: z.number().optional(),
47
49
  latencyMs: z.number().optional(),
@@ -166,6 +168,8 @@ export async function handleSearch({ query, k, mode, structural, regex, format,
166
168
  confidence: searchResult.confidence,
167
169
  confidenceReason: searchResult.confidenceReason,
168
170
  sufficient: searchResult.sufficient,
171
+ ...(searchResult.sufficiencyVerdict ? { sufficiencyVerdict: searchResult.sufficiencyVerdict } : {}),
172
+ ...(searchResult.sufficiencyReason ? { sufficiencyReason: searchResult.sufficiencyReason } : {}),
169
173
  sufficiencyReasons: searchResult.sufficiencyReasons || [],
170
174
  packagingMs: searchResult.packagingMs,
171
175
  latencyMs: searchResult.latencyMs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sweet-search",
3
- "version": "2.6.13",
3
+ "version": "2.6.14",
4
4
  "description": "Sweet Search - SOTA Hybrid Code Search Engine with WASM CatBoost Query Router, Semantic/Lexical/Structural Search, and Multilingual Support",
5
5
  "type": "module",
6
6
  "main": "core/search/sweet-search.js",
@@ -167,13 +167,13 @@
167
167
  "vitest": "^4.0.16"
168
168
  },
169
169
  "optionalDependencies": {
170
- "@sweet-search/native-darwin-arm64": "2.6.13",
171
- "@sweet-search/native-darwin-x64": "2.6.13",
172
- "@sweet-search/native-linux-arm64-gnu": "2.6.13",
173
- "@sweet-search/native-linux-arm64-gnu-cuda": "2.6.13",
174
- "@sweet-search/native-linux-x64-gnu": "2.6.13",
175
- "@sweet-search/native-linux-x64-gnu-cuda": "2.6.13",
176
- "@sweet-search/bg-priority": "2.6.13"
170
+ "@sweet-search/native-darwin-arm64": "2.6.14",
171
+ "@sweet-search/native-darwin-x64": "2.6.14",
172
+ "@sweet-search/native-linux-arm64-gnu": "2.6.14",
173
+ "@sweet-search/native-linux-arm64-gnu-cuda": "2.6.14",
174
+ "@sweet-search/native-linux-x64-gnu": "2.6.14",
175
+ "@sweet-search/native-linux-x64-gnu-cuda": "2.6.14",
176
+ "@sweet-search/bg-priority": "2.6.14"
177
177
  },
178
178
  "engines": {
179
179
  "node": ">=18.0.0"