sweet-search 0.0.1 → 2.3.0

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.
Files changed (161) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +23 -0
  3. package/core/cli.js +51 -0
  4. package/core/config.js +27 -0
  5. package/core/embedding/embedding-cache.js +467 -0
  6. package/core/embedding/embedding-local-model.js +845 -0
  7. package/core/embedding/embedding-remote.js +492 -0
  8. package/core/embedding/embedding-service.js +712 -0
  9. package/core/embedding/embedding-telemetry.js +219 -0
  10. package/core/embedding/index.js +40 -0
  11. package/core/graph/community-detector.js +294 -0
  12. package/core/graph/graph-expansion.js +839 -0
  13. package/core/graph/graph-extractor.js +2304 -0
  14. package/core/graph/graph-search.js +2148 -0
  15. package/core/graph/hcgs-generator.js +666 -0
  16. package/core/graph/index.js +16 -0
  17. package/core/graph/leiden-algorithm.js +547 -0
  18. package/core/graph/relationship-resolver.js +366 -0
  19. package/core/graph/repo-map.js +408 -0
  20. package/core/graph/summary-manager.js +549 -0
  21. package/core/indexing/artifact-builder.js +1054 -0
  22. package/core/indexing/ast-chunker.js +709 -0
  23. package/core/indexing/chunking/chunk-builder.js +170 -0
  24. package/core/indexing/chunking/markdown-chunker.js +503 -0
  25. package/core/indexing/chunking/plaintext-chunker.js +104 -0
  26. package/core/indexing/dedup/dedup-phase.js +159 -0
  27. package/core/indexing/dedup/exemplar-selector.js +65 -0
  28. package/core/indexing/document-chunker.js +56 -0
  29. package/core/indexing/incremental-parser.js +390 -0
  30. package/core/indexing/incremental-tracker.js +761 -0
  31. package/core/indexing/index-codebase-v21.js +472 -0
  32. package/core/indexing/index-maintainer.mjs +1674 -0
  33. package/core/indexing/index.js +90 -0
  34. package/core/indexing/indexer-ann.js +1077 -0
  35. package/core/indexing/indexer-build.js +742 -0
  36. package/core/indexing/indexer-phases.js +800 -0
  37. package/core/indexing/indexer-pool.js +764 -0
  38. package/core/indexing/indexer-sparse-gram.js +98 -0
  39. package/core/indexing/indexer-utils.js +536 -0
  40. package/core/indexing/indexer-worker.js +148 -0
  41. package/core/indexing/li-skip-policy.js +225 -0
  42. package/core/indexing/merkle-tracker.js +244 -0
  43. package/core/indexing/model-pool.js +166 -0
  44. package/core/infrastructure/code-graph-repository.js +120 -0
  45. package/core/infrastructure/codebase-repository.js +131 -0
  46. package/core/infrastructure/config/dedup.js +54 -0
  47. package/core/infrastructure/config/embedding.js +298 -0
  48. package/core/infrastructure/config/graph.js +80 -0
  49. package/core/infrastructure/config/index.js +82 -0
  50. package/core/infrastructure/config/indexing.js +8 -0
  51. package/core/infrastructure/config/platform.js +254 -0
  52. package/core/infrastructure/config/ranking.js +221 -0
  53. package/core/infrastructure/config/search.js +396 -0
  54. package/core/infrastructure/config/translation.js +89 -0
  55. package/core/infrastructure/config/vector-store.js +114 -0
  56. package/core/infrastructure/constants.js +86 -0
  57. package/core/infrastructure/coreml-cascade.js +909 -0
  58. package/core/infrastructure/coreml-cascade.json +46 -0
  59. package/core/infrastructure/coreml-provider.js +81 -0
  60. package/core/infrastructure/db-utils.js +69 -0
  61. package/core/infrastructure/dedup-hashing.js +83 -0
  62. package/core/infrastructure/hardware-capability.js +332 -0
  63. package/core/infrastructure/index.js +104 -0
  64. package/core/infrastructure/language-patterns/maps.js +121 -0
  65. package/core/infrastructure/language-patterns/registry-core.js +323 -0
  66. package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
  67. package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
  68. package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
  69. package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
  70. package/core/infrastructure/language-patterns/registry.js +19 -0
  71. package/core/infrastructure/language-patterns.js +141 -0
  72. package/core/infrastructure/llm-provider.js +733 -0
  73. package/core/infrastructure/manifest.json +46 -0
  74. package/core/infrastructure/maxsim.wasm +0 -0
  75. package/core/infrastructure/model-fetcher.js +423 -0
  76. package/core/infrastructure/model-registry.js +214 -0
  77. package/core/infrastructure/native-inference.js +587 -0
  78. package/core/infrastructure/native-resolver.js +187 -0
  79. package/core/infrastructure/native-sparse-gram.js +257 -0
  80. package/core/infrastructure/native-tokenizer.js +160 -0
  81. package/core/infrastructure/onnx-mutex.js +45 -0
  82. package/core/infrastructure/onnx-session-utils.js +261 -0
  83. package/core/infrastructure/ort-pipeline.js +111 -0
  84. package/core/infrastructure/project-detector.js +102 -0
  85. package/core/infrastructure/quantization.js +410 -0
  86. package/core/infrastructure/simd-distance.js +502 -0
  87. package/core/infrastructure/simd-distance.wasm +0 -0
  88. package/core/infrastructure/tree-sitter-provider.js +665 -0
  89. package/core/infrastructure/webgpu-maxsim.js +222 -0
  90. package/core/query/index.js +35 -0
  91. package/core/query/intent-detector.js +201 -0
  92. package/core/query/intent-router.js +156 -0
  93. package/core/query/query-router-catboost.js +222 -0
  94. package/core/query/query-router-ml.js +266 -0
  95. package/core/query/query-router.js +213 -0
  96. package/core/ranking/cascaded-scorer.js +379 -0
  97. package/core/ranking/flashrank.js +810 -0
  98. package/core/ranking/index.js +49 -0
  99. package/core/ranking/late-interaction-index.js +2383 -0
  100. package/core/ranking/late-interaction-model.js +812 -0
  101. package/core/ranking/local-reranker.js +374 -0
  102. package/core/ranking/mmr.js +379 -0
  103. package/core/ranking/quality-scorer.js +363 -0
  104. package/core/search/context-expander.js +1167 -0
  105. package/core/search/dedup/sibling-expander.js +327 -0
  106. package/core/search/index.js +16 -0
  107. package/core/search/search-boost.js +259 -0
  108. package/core/search/search-cli.js +544 -0
  109. package/core/search/search-format.js +282 -0
  110. package/core/search/search-fusion.js +327 -0
  111. package/core/search/search-hybrid.js +204 -0
  112. package/core/search/search-pattern-chunks.js +337 -0
  113. package/core/search/search-pattern-planner.js +439 -0
  114. package/core/search/search-pattern-prefilter.js +412 -0
  115. package/core/search/search-pattern-ripgrep.js +663 -0
  116. package/core/search/search-pattern.js +463 -0
  117. package/core/search/search-postprocess.js +452 -0
  118. package/core/search/search-semantic.js +706 -0
  119. package/core/search/search-server.js +554 -0
  120. package/core/search/session-daemon-prewarm.mjs +164 -0
  121. package/core/search/session-warmup.js +595 -0
  122. package/core/search/sweet-search.js +632 -0
  123. package/core/search/warmup-metrics.js +532 -0
  124. package/core/start-server.js +6 -0
  125. package/core/training/query-router/features/extractor.js +762 -0
  126. package/core/training/query-router/features/multilingual-patterns.js +431 -0
  127. package/core/training/query-router/features/text-segmenter.js +303 -0
  128. package/core/training/query-router/features/unicode-utils.js +383 -0
  129. package/core/training/query-router/output/v45_router_d4.js +11521 -0
  130. package/core/training/query-router/output/v46_router_d4.js +11498 -0
  131. package/core/vector-store/binary-heap.js +227 -0
  132. package/core/vector-store/binary-hnsw-index.js +1004 -0
  133. package/core/vector-store/float-vector-store.js +234 -0
  134. package/core/vector-store/hnsw-index.js +580 -0
  135. package/core/vector-store/index.js +39 -0
  136. package/core/vector-store/seismic-index.js +498 -0
  137. package/core/vocabulary/index.js +84 -0
  138. package/core/vocabulary/vocab-constants.js +20 -0
  139. package/core/vocabulary/vocab-miner-extractors.js +375 -0
  140. package/core/vocabulary/vocab-miner-nl.js +404 -0
  141. package/core/vocabulary/vocab-miner-utils.js +146 -0
  142. package/core/vocabulary/vocab-miner.js +574 -0
  143. package/core/vocabulary/vocab-prewarm-cli.js +110 -0
  144. package/core/vocabulary/vocab-ranker.js +492 -0
  145. package/core/vocabulary/vocab-warmer.js +523 -0
  146. package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
  147. package/core/vocabulary/vocabulary-utils.js +704 -0
  148. package/crates/wasm-router/pkg/package.json +13 -0
  149. package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
  150. package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
  151. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
  152. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
  153. package/mcp/config-gen.js +121 -0
  154. package/mcp/server.js +335 -0
  155. package/mcp/tool-handlers.js +476 -0
  156. package/package.json +131 -9
  157. package/scripts/benchmark-harness.js +794 -0
  158. package/scripts/init.js +1058 -0
  159. package/scripts/smoke-test.js +435 -0
  160. package/scripts/uninstall.js +478 -0
  161. package/scripts/verify-runtime.js +176 -0
@@ -0,0 +1,706 @@
1
+ /**
2
+ * Search Semantic Module
3
+ *
4
+ * Extracted from sweet-search.js (SOLID refactor).
5
+ * Contains the 3-stage semantic search pipeline and standard semantic search.
6
+ *
7
+ * Functions that use `this` are regular function declarations (not arrows)
8
+ * so they work correctly when wired onto SweetSearch.prototype.
9
+ *
10
+ * Rescoring Fix changes:
11
+ * Phase 0: Enhanced per-stage instrumentation, score-distribution signals
12
+ * Phase 1: Batched normalized-dot Stage 2 scoring (no per-candidate norms)
13
+ * Phase 2: Fixed Stage 2.5 dimension mismatch, float store direct access
14
+ * Phase 3: Adaptive oversampling (replaces fixed 200/200 pools)
15
+ */
16
+
17
+ import {
18
+ getBinaryEmbedding,
19
+ getEmbedding,
20
+ truncateForHNSW,
21
+ floatToInt8,
22
+ normalizedFloatToInt8,
23
+ int8CosineSimilarity,
24
+ int8BatchDotScores,
25
+ } from '../embedding/embedding-service.js';
26
+ import { EMBEDDING_CONFIG, BINARY_HNSW_CONFIG } from '../infrastructure/config/index.js';
27
+
28
+ const CASCADE_DEFERRED_STATS = { skipped: true, reason: 'cascade_deferred', provider: null, documents: 0, tokens: 0 };
29
+
30
+ function cascadeDefer(candidates, stats, searchPath, k = 50) {
31
+ const results = candidates.slice(0, k).map(r => ({ ...r, searchPath }));
32
+ stats.rerank = CASCADE_DEFERRED_STATS;
33
+ return { results, stats };
34
+ }
35
+
36
+ // =============================================================================
37
+ // Phase 0: Score-Spread Analysis (shared signal source)
38
+ // =============================================================================
39
+
40
+ // Thresholds shared between shouldSkipRerank and adaptive pool sizing.
41
+ // Single source of truth — no parallel heuristic stacks.
42
+ const SCORE_SPREAD = {
43
+ topGapThreshold: 0.10, // Gap above this = clear winner
44
+ spreadThreshold: 0.08, // Spread below this = tight cluster / ambiguous
45
+ };
46
+
47
+ /**
48
+ * Analyze score-spread signals from a set of scores. O(n) single pass —
49
+ * no sort needed since we only need top-1, top-2, min, mean, and variance.
50
+ *
51
+ * Used by both shouldSkipRerank (to skip CE) and adaptive pool sizing
52
+ * (to shrink/widen candidate pools). Single computation, reused everywhere.
53
+ */
54
+ function analyzeScoreSpread(scores) {
55
+ if (!scores || scores.length < 2) return null;
56
+ const n = scores.length;
57
+
58
+ // Single O(n) pass: top1, top2, min, sum, sumSq
59
+ let top1 = -Infinity, top2 = -Infinity, min = Infinity, sum = 0;
60
+ for (let i = 0; i < n; i++) {
61
+ const s = scores[i];
62
+ sum += s;
63
+ if (s > top1) { top2 = top1; top1 = s; }
64
+ else if (s > top2) { top2 = s; }
65
+ if (s < min) min = s;
66
+ }
67
+ const mean = sum / n;
68
+ let variance = 0;
69
+ for (let i = 0; i < n; i++) variance += (scores[i] - mean) ** 2;
70
+ variance /= n;
71
+
72
+ const topGap = top1 - top2;
73
+ const spread = top1 - min;
74
+
75
+ return {
76
+ top1, top2, topGap, spread, mean,
77
+ stdDev: Math.sqrt(variance),
78
+ count: n,
79
+ isDecisive: topGap > SCORE_SPREAD.topGapThreshold,
80
+ isAmbiguous: spread < SCORE_SPREAD.spreadThreshold,
81
+ };
82
+ }
83
+
84
+ // =============================================================================
85
+ // Phase 3: Adaptive Pool Sizing
86
+ // =============================================================================
87
+
88
+ /**
89
+ * Compute adaptive Stage 2 pool size based on k and pre-computed score signals.
90
+ * Uses the same isDecisive/isAmbiguous signals as shouldSkipRerank.
91
+ */
92
+ function adaptiveStage2Pool(k, analysis, config) {
93
+ const {
94
+ minStage2 = 40,
95
+ maxStage2 = 400,
96
+ oversample1 = 10,
97
+ } = config;
98
+
99
+ let base = Math.max(minStage2, k * oversample1);
100
+
101
+ if (analysis) {
102
+ if (analysis.isDecisive) {
103
+ base = Math.max(minStage2, Math.floor(base * 0.6));
104
+ return { size: Math.min(base, maxStage2), reason: `shrink_decisive (gap=${analysis.topGap.toFixed(3)})` };
105
+ }
106
+ if (analysis.isAmbiguous) {
107
+ base = Math.min(maxStage2, Math.floor(base * 1.5));
108
+ return { size: base, reason: `widen_ambiguous (spread=${analysis.spread.toFixed(3)})` };
109
+ }
110
+ }
111
+
112
+ return { size: Math.min(base, maxStage2), reason: 'default' };
113
+ }
114
+
115
+ /**
116
+ * Compute adaptive Stage 2.5 pool size.
117
+ * Always smaller than Stage 2 pool — float rescoring is more expensive.
118
+ */
119
+ function adaptiveStage2_5Pool(k, analysis, config) {
120
+ const {
121
+ minStage2_5 = 20,
122
+ maxStage2_5 = 200,
123
+ oversample2 = 5,
124
+ } = config;
125
+
126
+ let base = Math.max(minStage2_5, k * oversample2);
127
+
128
+ if (analysis && analysis.isDecisive) {
129
+ base = Math.max(minStage2_5, Math.floor(base * 0.6));
130
+ return { size: Math.min(base, maxStage2_5), reason: `shrink_decisive (gap=${analysis.topGap.toFixed(3)})` };
131
+ }
132
+
133
+ return { size: Math.min(base, maxStage2_5), reason: 'default' };
134
+ }
135
+
136
+ // =============================================================================
137
+ // 3-Stage Semantic Search
138
+ // =============================================================================
139
+
140
+ /**
141
+ * 3-Stage Semantic Search Pipeline
142
+ *
143
+ * Performance targets (after rescoring fix):
144
+ * Stage 1 (Binary): ~100us for 1000 candidates
145
+ * Stage 2 (Int8): ~200-500us for adaptive candidates (batched normalized dot)
146
+ * Stage 2.5 (Float): ~500us for adaptive candidates (direct-access store)
147
+ * Stage 3 (Rerank): ~50-100ms for 20 candidates
148
+ * Total: <150ms end-to-end
149
+ *
150
+ * Returns: { results: Array, stats: Object } with embedding/rerank stats for CostTracker
151
+ *
152
+ * Uses `this` extensively.
153
+ */
154
+ export async function semanticSearch3Stage(query, options = {}) {
155
+ const { k = 10, rerank = true, useLateInteraction = this.useLateInteraction } = options;
156
+ const stats = { stages: {} };
157
+ const adaptiveConfig = BINARY_HNSW_CONFIG.retrieval.adaptive || {};
158
+
159
+ // Generate binary embedding (with caching)
160
+ const embedStart = performance.now();
161
+ const embedResult = await getBinaryEmbedding(query);
162
+ stats.embed_us = Math.round((performance.now() - embedStart) * 1000);
163
+ this.log(`Embedding: ${stats.embed_us}us (${embedResult.source})`);
164
+
165
+ // P0 FIX: Add embedding stats for CostTracker
166
+ stats.embedding = {
167
+ source: embedResult.source || (embedResult.cached ? 'cache' : 'api'),
168
+ tokens: embedResult.tokens || Math.ceil(query.length / 4),
169
+ provider: EMBEDDING_CONFIG.provider,
170
+ cached: embedResult.cached || embedResult.source === 'vocabulary' || embedResult.source === 'lru' || false,
171
+ latency_us: stats.embed_us,
172
+ };
173
+
174
+ // Stage 1: Binary HNSW search
175
+ // Pass floatQuery for asymmetric distance during graph traversal
176
+ const stage1Start = performance.now();
177
+ const truncatedFloat = truncateForHNSW(embedResult.float);
178
+ const stage1Result = await this.binaryHnswIndex.search(
179
+ embedResult.binary, this.stage1Candidates, { floatQuery: truncatedFloat }
180
+ );
181
+ const stage1Scores = stage1Result.results.map(r => r.score);
182
+ const stage1Analysis = analyzeScoreSpread(stage1Scores);
183
+ stats.stages.binary = {
184
+ latency_us: stage1Result.latency_us,
185
+ candidates: stage1Result.results.length,
186
+ scoreDistribution: stage1Analysis,
187
+ };
188
+ this.log(`Stage 1 (Binary): ${stage1Result.latency_us}us, ${stage1Result.results.length} candidates`);
189
+
190
+ if (stage1Result.results.length === 0) {
191
+ stats.rerank = {
192
+ skipped: true,
193
+ reason: 'no_candidates',
194
+ provider: null,
195
+ documents: 0,
196
+ tokens: 0,
197
+ };
198
+ return { results: [], stats };
199
+ }
200
+
201
+ // -------------------------------------------------------------------------
202
+ // Stage 2: Int8 Rescore (Phase 1 — batched normalized dot product)
203
+ //
204
+ // Key optimization: use normalizedFloatToInt8 (matches index-time quantizer)
205
+ // and raw dot product scoring. Since both query and document int8 vectors
206
+ // are quantized from L2-normalized floats, dot/(127²) ≈ cosine.
207
+ // No per-candidate norm computation needed.
208
+ // -------------------------------------------------------------------------
209
+ const stage2Start = performance.now();
210
+ const useBatchedDot = BINARY_HNSW_CONFIG.retrieval.useBatchedDot !== false;
211
+
212
+ // Phase 3: Adaptive Stage 2 pool size (uses shared score-spread analysis)
213
+ const stage2Pool = adaptiveStage2Pool(k, stage1Analysis, adaptiveConfig);
214
+ const stage2Count = Math.min(stage2Pool.size, stage1Result.results.length);
215
+
216
+ // Collect int8 vectors for scoring
217
+ const stage2Candidates = stage1Result.results.slice(0, stage2Count);
218
+ const int8Vectors = [];
219
+ const validIndices = [];
220
+ let missingInt8Count = 0;
221
+
222
+ for (let i = 0; i < stage2Candidates.length; i++) {
223
+ const int8Vector = this.binaryHnswIndex.getInt8Vector(stage2Candidates[i].id);
224
+ if (int8Vector) {
225
+ int8Vectors.push(int8Vector);
226
+ validIndices.push(i);
227
+ } else {
228
+ stage2Candidates[i].int8Score = 0.0;
229
+ stage2Candidates[i].missingInt8 = true;
230
+ missingInt8Count++;
231
+ }
232
+ }
233
+
234
+ if (useBatchedDot) {
235
+ // Phase 1 NEW PATH: Batched normalized dot product
236
+ // Use same quantizer as index time. No per-candidate norms.
237
+ const queryInt8 = normalizedFloatToInt8(truncatedFloat);
238
+ stats.queryInt8 = queryInt8;
239
+ if (int8Vectors.length > 0) {
240
+ const batchScores = int8BatchDotScores(queryInt8, int8Vectors);
241
+ for (let j = 0; j < validIndices.length; j++) {
242
+ stage2Candidates[validIndices[j]].int8Score = batchScores[j];
243
+ }
244
+ }
245
+ } else {
246
+ // Phase 1 OLD PATH (fallback): Per-candidate int8 cosine similarity
247
+ const queryInt8 = floatToInt8(truncatedFloat);
248
+ stats.queryInt8 = queryInt8;
249
+ for (let j = 0; j < validIndices.length; j++) {
250
+ stage2Candidates[validIndices[j]].int8Score = int8CosineSimilarity(queryInt8, int8Vectors[j]);
251
+ }
252
+ }
253
+
254
+ if (missingInt8Count > 0) {
255
+ this.log(`Warning: ${missingInt8Count} candidates missing int8 vectors (given neutral score)`);
256
+ }
257
+
258
+ // Sort by int8 score
259
+ let scoredCandidates = [...stage2Candidates];
260
+ scoredCandidates.sort((a, b) => b.int8Score - a.int8Score);
261
+
262
+ const int8Scores = scoredCandidates.filter(c => !c.missingInt8).map(c => c.int8Score);
263
+ const int8Analysis = analyzeScoreSpread(int8Scores);
264
+ stats.stages.int8 = {
265
+ latency_us: Math.round((performance.now() - stage2Start) * 1000),
266
+ candidates: scoredCandidates.length,
267
+ missingVectors: missingInt8Count,
268
+ poolSize: stage2Count,
269
+ poolReason: stage2Pool.reason,
270
+ scoringPath: useBatchedDot ? 'batched-dot' : 'per-candidate-cosine',
271
+ scoreDistribution: int8Analysis,
272
+ };
273
+ this.log(`Stage 2 (Int8): ${stats.stages.int8.latency_us}us, ${scoredCandidates.length} rescored (pool: ${stage2Count}, ${stage2Pool.reason}, ${useBatchedDot ? 'batched' : 'legacy'})`);
274
+
275
+ // -------------------------------------------------------------------------
276
+ // Stage 2.5: Float Rescore (Phase 2 — fixed dimension, direct-access store)
277
+ //
278
+ // Fixes from the plan:
279
+ // 1. Query and document vectors scored at same intended dimension
280
+ // (both use truncateForHNSW output, no silent Math.min truncation)
281
+ // 2. Float vectors loaded from direct-access store (not SQLite)
282
+ // 3. SQLite retained as fallback if float store not available
283
+ // -------------------------------------------------------------------------
284
+ const stage2_5Pool = adaptiveStage2_5Pool(k, int8Analysis, adaptiveConfig);
285
+ const stage2_5Count = Math.min(stage2_5Pool.size, scoredCandidates.length);
286
+
287
+ if (stage2_5Count > 0 && embedResult.float) {
288
+ const stage2_5Start = performance.now();
289
+ try {
290
+ const pool = scoredCandidates.slice(0, stage2_5Count);
291
+ // Phase 2 fix: query at intended dimension (truncated + normalized)
292
+ const queryFloat = truncatedFloat;
293
+ const poolIds = pool.map(c => c.id);
294
+
295
+ let floatVectors = null;
296
+ let floatSource = 'none';
297
+ let missingFloatCount = 0;
298
+
299
+ // Prefer direct-access float store (Phase 2)
300
+ if (this.floatVectorStore && this.floatVectorStore.loaded) {
301
+ const result = this.floatVectorStore.batchScore(queryFloat, poolIds);
302
+ if (result.scores.size > 0) {
303
+ for (const c of pool) {
304
+ const score = result.scores.get(c.id);
305
+ if (score !== undefined) {
306
+ c.floatScore = score;
307
+ } else {
308
+ c.floatScore = c.int8Score; // fallback
309
+ }
310
+ }
311
+ // result.missing is the authoritative count (IDs not in store)
312
+ missingFloatCount = result.missing;
313
+ floatSource = 'float-store';
314
+ }
315
+ }
316
+
317
+ // Fallback: SQLite _loadFloatVectors (if float store unavailable)
318
+ if (floatSource === 'none' && this._loadFloatVectors) {
319
+ floatVectors = await this._loadFloatVectors(poolIds);
320
+ if (floatVectors && floatVectors.size > 0) {
321
+ for (const c of pool) {
322
+ const fv = floatVectors.get(c.id);
323
+ if (fv) {
324
+ // Phase 2 fix: dimension mismatch is a correctness bug, not a
325
+ // recoverable condition. Fail loud so it gets fixed at index time.
326
+ if (fv.length !== queryFloat.length) {
327
+ throw new Error(
328
+ `Stage 2.5 dimension mismatch: query=${queryFloat.length}, doc=${fv.length} (id=${c.id}). ` +
329
+ 'Re-index to align stored vectors with current hnswDimension.'
330
+ );
331
+ }
332
+ let dot = 0;
333
+ for (let i = 0; i < queryFloat.length; i++) dot += queryFloat[i] * fv[i];
334
+ c.floatScore = dot;
335
+ } else {
336
+ c.floatScore = c.int8Score; // fallback
337
+ missingFloatCount++;
338
+ }
339
+ }
340
+ floatSource = 'sqlite-fallback';
341
+ }
342
+ }
343
+
344
+ if (floatSource !== 'none') {
345
+ pool.sort((a, b) => b.floatScore - a.floatScore);
346
+ scoredCandidates = pool;
347
+ }
348
+
349
+ const floatScores = pool.filter(c => c.floatScore !== undefined).map(c => c.floatScore);
350
+ stats.stages.floatRescore = {
351
+ latency_us: Math.round((performance.now() - stage2_5Start) * 1000),
352
+ candidates: pool.length,
353
+ poolSize: stage2_5Count,
354
+ poolReason: stage2_5Pool.reason,
355
+ source: floatSource,
356
+ missingVectors: missingFloatCount,
357
+ scoreDistribution: analyzeScoreSpread(floatScores),
358
+ };
359
+ this.log(`Stage 2.5 (Float): ${stats.stages.floatRescore.latency_us}us, ${pool.length} rescored (${floatSource}, pool: ${stage2_5Count})`);
360
+ } catch (err) {
361
+ // Dimension mismatches are correctness bugs — propagate, don't swallow.
362
+ if (err.message.includes('dimension mismatch')) throw err;
363
+ this.log(`Stage 2.5 skipped: ${err.message}`);
364
+ }
365
+ }
366
+
367
+ // CASCADE MODE: Return broad candidate set, let postprocess handle scoring.
368
+ if (this.cascadeEnabled) {
369
+ return cascadeDefer(scoredCandidates, stats, 'semantic-3stage', options.cascadeK);
370
+ }
371
+
372
+ // =========================================================================
373
+ // FLAG OFF: Existing Stage 3 rerank path, completely unchanged.
374
+ // =========================================================================
375
+
376
+ // EARLY EXIT: Use score spread analysis to skip reranking
377
+ const topCandidatesWithInt8 = scoredCandidates
378
+ .slice(0, Math.min(10, scoredCandidates.length))
379
+ .filter(c => !c.missingInt8);
380
+ const topInt8Scores = topCandidatesWithInt8.map(c => c.floatScore ?? c.int8Score);
381
+ const skipAnalysis = this.shouldSkipRerank(topInt8Scores, { highConfidence: 0.90 });
382
+
383
+ if (skipAnalysis.skip) {
384
+ this.log(`Early exit: ${skipAnalysis.reason} (scores: ${topInt8Scores.slice(0, 3).map(s => s.toFixed(3)).join(', ')})`);
385
+
386
+ stats.rerank = {
387
+ skipped: true,
388
+ reason: skipAnalysis.reason,
389
+ provider: null,
390
+ documents: 0,
391
+ tokens: 0,
392
+ };
393
+
394
+ const results = scoredCandidates.slice(0, k).map(r => ({
395
+ ...r,
396
+ searchPath: 'semantic-3stage',
397
+ earlyExit: true,
398
+ skipReason: skipAnalysis.reason,
399
+ }));
400
+
401
+ return { results, stats };
402
+ }
403
+
404
+ // Late interaction moved to post-expansion pipeline (Phase 6).
405
+ // See search-postprocess.js — runs after graph expansion so expanded
406
+ // candidates also benefit from MaxSim scoring.
407
+
408
+ // Stage 3: Rerank (if enabled AND a reranker is actually available)
409
+ let results = scoredCandidates;
410
+ if (rerank && scoredCandidates.length > k && this.reranker.isAnyAvailable?.()) {
411
+ try {
412
+ const stage3Start = performance.now();
413
+ const topCandidates = scoredCandidates.slice(0, this.stage3Candidates);
414
+
415
+ // Load full document content for reranking
416
+ const documents = await this.loadDocumentContent(topCandidates);
417
+
418
+ const rerankResult = await this.reranker.rerank(query, documents, k);
419
+ results = rerankResult.results.map((r, i) => ({
420
+ ...topCandidates[r.originalIndex],
421
+ rerankScore: r.localRerankerScore || r.jinaScore || r.voyageScore || r.flashRankScore,
422
+ originalScore: topCandidates[r.originalIndex].int8Score,
423
+ binaryScore: topCandidates[r.originalIndex].score,
424
+ lateInteractionScore: topCandidates[r.originalIndex].lateInteractionScore,
425
+ preLateInteractionScore: topCandidates[r.originalIndex].preLateInteractionScore,
426
+ newRank: i + 1,
427
+ }));
428
+
429
+ stats.stages.rerank = {
430
+ latency_ms: rerankResult.latency_ms,
431
+ model: rerankResult.model,
432
+ candidates: topCandidates.length,
433
+ };
434
+
435
+ stats.rerank = {
436
+ skipped: false,
437
+ provider: rerankResult.model || 'direct-cross-encoder',
438
+ documents: topCandidates.length,
439
+ tokens: Math.ceil(query.length / 4) + (topCandidates.length * 150),
440
+ latency_ms: rerankResult.latency_ms,
441
+ };
442
+
443
+ this.log(`Stage 3 (Rerank): ${rerankResult.latency_ms}ms (${rerankResult.model})`);
444
+ } catch (err) {
445
+ this.log(`Rerank failed: ${err.message}`);
446
+ results = scoredCandidates.slice(0, k);
447
+
448
+ stats.rerank = {
449
+ skipped: true,
450
+ reason: `error: ${err.message}`,
451
+ provider: null,
452
+ documents: 0,
453
+ tokens: 0,
454
+ };
455
+ }
456
+ } else {
457
+ results = scoredCandidates.slice(0, k);
458
+
459
+ stats.rerank = {
460
+ skipped: true,
461
+ reason: rerank ? 'insufficient_candidates' : 'disabled',
462
+ provider: null,
463
+ documents: 0,
464
+ tokens: 0,
465
+ };
466
+ }
467
+
468
+ const formattedResults = results.map(r => ({
469
+ ...r,
470
+ searchPath: 'semantic-3stage',
471
+ }));
472
+
473
+ // Return both results and stats for proper propagation
474
+ return { results: formattedResults, stats };
475
+ }
476
+
477
+ // =============================================================================
478
+ // Standard Semantic Search (fallback)
479
+ // =============================================================================
480
+
481
+ /**
482
+ * Standard Semantic Search (fallback when binary index not available)
483
+ *
484
+ * Returns: { results: Array, stats: Object } with embedding/rerank stats for CostTracker
485
+ *
486
+ * Uses `this` extensively.
487
+ */
488
+ export async function semanticSearchStandard(query, options = {}) {
489
+ const { k = 10, rerank = true } = options;
490
+ const stats = { stages: {} };
491
+
492
+ // Generate query embedding (with caching)
493
+ const embedStart = performance.now();
494
+ const embedResult = await getEmbedding(query, { isQuery: true });
495
+ const fullEmbedding = embedResult.embedding || embedResult; // Handle both new and old API
496
+ const embedLatency_us = embedResult.latency_us || Math.round((performance.now() - embedStart) * 1000);
497
+ const cacheStatus = embedResult.source || 'unknown';
498
+ this.log(`Embedding: ${embedLatency_us}us (${cacheStatus})`);
499
+
500
+ // P0 FIX: Add embedding stats for CostTracker
501
+ stats.embedding = {
502
+ source: embedResult.source || (embedResult.cached ? 'cache' : 'api'),
503
+ tokens: embedResult.tokens || Math.ceil(query.length / 4),
504
+ provider: EMBEDDING_CONFIG.provider,
505
+ cached: embedResult.cached || embedResult.source === 'vocabulary' || embedResult.source === 'lru' || false,
506
+ latency_us: embedLatency_us,
507
+ };
508
+
509
+ // Truncate to HNSW dimension (1024d -> 512d Matryoshka)
510
+ const queryEmbedding = truncateForHNSW(fullEmbedding);
511
+ stats.queryInt8 = normalizedFloatToInt8(queryEmbedding);
512
+
513
+ let candidates;
514
+
515
+ if (this.hasHnswIndex) {
516
+ // ADAPTIVE CANDIDATE SIZING: Reduce candidates for simple queries
517
+ const baseNumCandidates = rerank ? Math.max(k * 10, 100) : k;
518
+ const numCandidates = this.getAdaptiveCandidateCount(query, baseNumCandidates);
519
+
520
+ const hnswResult = await this.hnswIndex.search(queryEmbedding, numCandidates);
521
+ candidates = hnswResult.results;
522
+ this.log(`HNSW: ${hnswResult.latency_us}us for ${hnswResult.k} candidates (adaptive: ${numCandidates})`);
523
+ } else if (this.hasCodebaseIndex) {
524
+ // Fallback: O(N) scan from SQLite
525
+ candidates = await this.vectorScan(queryEmbedding, rerank ? 100 : k);
526
+ this.log(`Vector scan: ${candidates.length} candidates`);
527
+ } else {
528
+ stats.rerank = {
529
+ skipped: true,
530
+ reason: 'no_index_available',
531
+ provider: null,
532
+ documents: 0,
533
+ tokens: 0,
534
+ };
535
+ return { results: [], stats };
536
+ }
537
+
538
+ if (candidates.length === 0) {
539
+ stats.rerank = {
540
+ skipped: true,
541
+ reason: 'no_candidates',
542
+ provider: null,
543
+ documents: 0,
544
+ tokens: 0,
545
+ };
546
+ return { results: [], stats };
547
+ }
548
+
549
+ // CASCADE MODE: Return broad candidate set, let postprocess handle scoring.
550
+ if (this.cascadeEnabled) {
551
+ return cascadeDefer(candidates, stats, 'semantic');
552
+ }
553
+
554
+ // =========================================================================
555
+ // FLAG OFF: Existing rerank path, completely unchanged.
556
+ // =========================================================================
557
+
558
+ // EARLY EXIT: Use score spread analysis to skip reranking
559
+ const topScores = candidates.slice(0, Math.min(10, candidates.length)).map(c => c.score);
560
+ const skipAnalysis = this.shouldSkipRerank(topScores, { highConfidence: 0.92 });
561
+
562
+ if (skipAnalysis.skip) {
563
+ this.log(`Early exit: ${skipAnalysis.reason} (scores: ${topScores.slice(0, 3).map(s => s.toFixed(3)).join(', ')})`);
564
+
565
+ stats.rerank = {
566
+ skipped: true,
567
+ reason: skipAnalysis.reason,
568
+ provider: null,
569
+ documents: 0,
570
+ tokens: 0,
571
+ };
572
+
573
+ const results = candidates.slice(0, k).map(r => ({
574
+ ...r,
575
+ searchPath: 'semantic',
576
+ earlyExit: true,
577
+ skipReason: skipAnalysis.reason,
578
+ }));
579
+
580
+ return { results, stats };
581
+ }
582
+
583
+ // Rerank if requested and we have candidates
584
+ let results = candidates;
585
+ if (rerank && candidates.length > k && this.reranker.isAnyAvailable?.()) {
586
+ try {
587
+ const rerankStart = Date.now();
588
+
589
+ // Prepare documents for reranking
590
+ const documents = await this.loadDocumentContent(candidates);
591
+
592
+ const rerankResult = await this.reranker.rerank(query, documents, k);
593
+ results = rerankResult.results.map((r, i) => ({
594
+ ...candidates[r.originalIndex],
595
+ rerankScore: r.localRerankerScore || r.jinaScore || r.voyageScore || r.flashRankScore,
596
+ originalScore: candidates[r.originalIndex].score,
597
+ newRank: i + 1,
598
+ }));
599
+
600
+ stats.rerank = {
601
+ skipped: false,
602
+ provider: rerankResult.model || 'direct-cross-encoder',
603
+ documents: candidates.length,
604
+ tokens: Math.ceil(query.length / 4) + (candidates.length * 150),
605
+ latency_ms: rerankResult.latency_ms,
606
+ };
607
+
608
+ this.log(`Rerank: ${rerankResult.latency_ms}ms (${rerankResult.model})`);
609
+ } catch (err) {
610
+ this.log(`Rerank failed: ${err.message}`);
611
+ results = candidates.slice(0, k);
612
+
613
+ stats.rerank = {
614
+ skipped: true,
615
+ reason: `error: ${err.message}`,
616
+ provider: null,
617
+ documents: 0,
618
+ tokens: 0,
619
+ };
620
+ }
621
+ } else {
622
+ stats.rerank = {
623
+ skipped: true,
624
+ reason: rerank ? 'insufficient_candidates' : 'disabled',
625
+ provider: null,
626
+ documents: 0,
627
+ tokens: 0,
628
+ };
629
+ }
630
+
631
+ const formattedResults = results.map(r => ({
632
+ ...r,
633
+ searchPath: 'semantic',
634
+ }));
635
+
636
+ return { results: formattedResults, stats };
637
+ }
638
+
639
+ // =============================================================================
640
+ // Helpers
641
+ // =============================================================================
642
+
643
+ /**
644
+ * Score spread analysis for intelligent rerank skipping.
645
+ * Pure function — does not reference `this`. On prototype for call-site convenience.
646
+ */
647
+ export function shouldSkipRerank(scores, options = {}) {
648
+ const {
649
+ topGapThreshold = SCORE_SPREAD.topGapThreshold,
650
+ spreadThreshold = SCORE_SPREAD.spreadThreshold,
651
+ highConfidence = 0.85,
652
+ minResults = 3,
653
+ minScoreThreshold = 0.50,
654
+ } = options;
655
+
656
+ if (!scores || scores.length < minResults) {
657
+ return { skip: false, reason: 'insufficient_results' };
658
+ }
659
+
660
+ const sorted = [...scores].sort((a, b) => b - a);
661
+ const topGap = sorted[0] - sorted[1];
662
+ const spread = sorted[0] - sorted[sorted.length - 1];
663
+ const topScores = sorted.slice(0, Math.min(3, sorted.length));
664
+
665
+ // Check 0: Never skip if scores are too low
666
+ if (sorted[0] < minScoreThreshold) {
667
+ return { skip: false, reason: `low_scores (max=${sorted[0].toFixed(3)}, threshold=${minScoreThreshold})` };
668
+ }
669
+
670
+ // Check 1: Clear winner
671
+ if (topGap > topGapThreshold) {
672
+ return { skip: true, reason: `clear_winner (gap=${topGap.toFixed(3)})` };
673
+ }
674
+
675
+ // Check 2: Tight cluster
676
+ if (spread < spreadThreshold) {
677
+ return { skip: true, reason: `tight_cluster (spread=${spread.toFixed(3)})` };
678
+ }
679
+
680
+ // Check 3: All high confidence matches
681
+ if (topScores.every(s => s > highConfidence)) {
682
+ return { skip: true, reason: `high_confidence (min=${Math.min(...topScores).toFixed(3)})` };
683
+ }
684
+
685
+ return { skip: false, reason: 'needs_rerank' };
686
+ }
687
+
688
+ /**
689
+ * Adaptive candidate count based on query complexity
690
+ */
691
+ export function getAdaptiveCandidateCount(query, baseCount) {
692
+ const trimmed = query.trim();
693
+
694
+ // Very short queries (likely identifiers): use 50% of base
695
+ if (trimmed.length < 15) {
696
+ return Math.max(Math.floor(baseCount * 0.5), 20);
697
+ }
698
+
699
+ // Short queries without question words: use 75% of base
700
+ if (trimmed.length < 30 && !/\b(how|what|where|why|when|which)\b/i.test(trimmed)) {
701
+ return Math.max(Math.floor(baseCount * 0.75), 30);
702
+ }
703
+
704
+ // Complex queries (questions, long): use full base
705
+ return baseCount;
706
+ }