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,492 @@
1
+ /**
2
+ * Vocabulary Ranker Module
3
+ *
4
+ * Scores and classifies mined vocabulary terms for cache pre-warming.
5
+ * Two ranking streams:
6
+ *
7
+ * Stream A: Identifier Ranking (BM25 + PageRank + heuristic multipliers)
8
+ * → Produces scored, classified identifiers for lexical + semantic warmup
9
+ *
10
+ * Stream B: Community Phrase Ranking (c-TF-IDF scores + PageRank boost)
11
+ * → Produces NL query phrases with question variants for semantic warmup
12
+ *
13
+ * Pure computation module — no I/O, no external dependencies.
14
+ */
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Constants
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * BM25 tuning constants for prewarm vocabulary ranking only.
22
+ * NOTE: These are NOT used for live FTS5 search. SQLite FTS5 uses its own
23
+ * built-in BM25 defaults (k1=1.2, b=0.75). Column weights are configured
24
+ * via bm25() arguments in graph-search.js. Do not add custom rescoring
25
+ * unless benchmark data proves a real win — column weights and lexical
26
+ * normalization matter far more than k1/b tuning.
27
+ */
28
+ const K1 = 1.5; // Higher saturation for code (many repeated identifiers)
29
+ const B = 0.5; // Lower length penalty (code file sizes vary widely)
30
+
31
+ /** Heuristic multipliers applied on top of BM25 score */
32
+ const MULTIPLIERS = {
33
+ exports: 3.0, // Public API surface
34
+ pagerank: 2.5, // High code-graph connectivity (PageRank > 0.1)
35
+ filepath: 2.0, // Appears in file/directory names
36
+ crossfile: 1.5, // Appears in multiple files
37
+ named: 1.2, // camelCase / PascalCase named entity
38
+ dependency: 1.0, // Appears in dependency names
39
+ string: 0.8, // String literal / error message
40
+ private: 0.3, // Single occurrence, private scope
41
+ };
42
+
43
+ /** Depth presets: { identifiers, phrases } caps */
44
+ const DEPTH_CUTOFFS = {
45
+ light: { identifiers: 200, phrases: 100 },
46
+ medium: { identifiers: 1000, phrases: 500 },
47
+ deep: { identifiers: 2000, phrases: 1000 },
48
+ };
49
+
50
+ /** Default phrases-per-community limit */
51
+ const DEFAULT_PHRASES_PER_COMMUNITY = 15;
52
+
53
+ /** Question templates for semantic warmup phrases */
54
+ const QUESTION_TEMPLATES = [
55
+ phrase => `how does ${phrase} work`,
56
+ phrase => `where is ${phrase}`,
57
+ phrase => `what handles ${phrase}`,
58
+ phrase => `find ${phrase}`,
59
+ ];
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Pattern Detection (for classifyWarmMode)
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const RE_PASCAL_CASE = /^[A-Z][a-z]+(?:[A-Z][a-z]+)+$/;
66
+ const RE_SCREAMING_SNAKE = /^[A-Z][A-Z0-9_]+$/;
67
+ const RE_CAMEL_CASE = /^[a-z]+(?:[A-Z][a-z]+)+$/;
68
+ const RE_SNAKE_CASE = /^[a-z][a-z0-9_]+$/;
69
+ const RE_DOTTED_PATH = /^[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)+$/;
70
+
71
+ // Code-like tokens: contain uppercase transitions, underscores, dots, or digits mixed with letters
72
+ const RE_CODE_TOKEN = /[A-Z]{2}|[a-z][A-Z]|_|\.\w|[a-z]\d|\d[a-z]/;
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // BM25 Helpers
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Compute IDF-like rarity score for a term given total bucket count and frequency.
80
+ * NOTE: In prewarm mining this is source-bucket rarity (import/export/graph-entity...)
81
+ * unless caller supplies a real `options.totalFiles`.
82
+ * @param {number} totalDocs - N (total buckets, or total files if provided)
83
+ * @param {number} docFreq - df (bucket frequency)
84
+ * @returns {number} IDF score
85
+ */
86
+ function computeIDF(totalDocs, docFreq) {
87
+ return Math.log((totalDocs) / (1 + docFreq));
88
+ }
89
+
90
+ /**
91
+ * Compute BM25 score for a single term.
92
+ * @param {number} tf - term frequency (occurrences across mining)
93
+ * @param {number} idf - precomputed IDF
94
+ * @param {number} docLen - approximate document length (source count)
95
+ * @param {number} avgDocLen - average document length
96
+ * @returns {number} BM25 score
97
+ */
98
+ function bm25Score(tf, idf, docLen, avgDocLen) {
99
+ const numerator = tf * (K1 + 1);
100
+ const denominator = tf + K1 * (1 - B + B * (docLen / avgDocLen));
101
+ return idf * (numerator / denominator);
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Heuristic Multiplier Computation
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * Determine the best heuristic multiplier for a term based on its sources/metadata.
110
+ * @param {object} termInfo - { term, score, source, type, sources }
111
+ * @param {number} pageRankScore - PageRank score for this term (0 if absent)
112
+ * @returns {number} combined heuristic multiplier
113
+ */
114
+ function computeHeuristicMultiplier(termInfo, pageRankScore) {
115
+ const sources = termInfo.sources || (termInfo.source ? [termInfo.source] : []);
116
+ const type = termInfo.type || '';
117
+
118
+ // Start with 1.0 (neutral)
119
+ let multiplier = 1.0;
120
+
121
+ // Exports / public API
122
+ if (type === 'export' || type === 'exports' || sources.includes('exports')) {
123
+ multiplier = Math.max(multiplier, MULTIPLIERS.exports);
124
+ }
125
+
126
+ // High PageRank connectivity
127
+ if (pageRankScore > 0.1) {
128
+ multiplier = Math.max(multiplier, MULTIPLIERS.pagerank);
129
+ }
130
+
131
+ // File/directory name
132
+ if (type === 'filepath' || sources.includes('filepath') || sources.includes('filename')) {
133
+ multiplier = Math.max(multiplier, MULTIPLIERS.filepath);
134
+ }
135
+
136
+ // Cross-file (appears in multiple files)
137
+ if (sources.length > 1 || type === 'crossfile') {
138
+ multiplier = Math.max(multiplier, MULTIPLIERS.crossfile);
139
+ }
140
+
141
+ // Named entity (camelCase / PascalCase)
142
+ if (RE_CAMEL_CASE.test(termInfo.term) || RE_PASCAL_CASE.test(termInfo.term)) {
143
+ multiplier = Math.max(multiplier, MULTIPLIERS.named);
144
+ }
145
+
146
+ // Dependency name
147
+ if (type === 'dependency' || sources.includes('dependency')) {
148
+ multiplier = Math.max(multiplier, MULTIPLIERS.dependency);
149
+ }
150
+
151
+ // String literal / error message
152
+ if (type === 'string' || type === 'error' || sources.includes('string')) {
153
+ multiplier = Math.max(multiplier, MULTIPLIERS.string);
154
+ }
155
+
156
+ // Single occurrence, private
157
+ const totalOccurrences = termInfo.score || 1;
158
+ if (totalOccurrences <= 1 && (type === 'private' || sources.length <= 1)) {
159
+ // Only apply private penalty if no stronger signal
160
+ if (multiplier <= 1.0) {
161
+ multiplier = MULTIPLIERS.private;
162
+ }
163
+ }
164
+
165
+ return multiplier;
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Warm Mode Classification
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /**
173
+ * Classify how a term should be pre-warmed in the embedding cache.
174
+ *
175
+ * - "lexical": exact FTS5 match is sufficient (code identifiers, paths)
176
+ * - "semantic": needs embedding (natural language phrases)
177
+ * - "both": high-value hub entity worth caching both ways
178
+ *
179
+ * @param {string} term - the vocabulary term
180
+ * @param {number} [pageRankScore=0] - optional PageRank score for the term
181
+ * @returns {'lexical' | 'semantic' | 'both'}
182
+ */
183
+ export function classifyWarmMode(term, pageRankScore = 0) {
184
+ // High-PageRank hub entity → cache both lexical and semantic
185
+ if (pageRankScore > 0.1) return 'both';
186
+
187
+ // PascalCase (e.g., UserService, AuthController)
188
+ if (RE_PASCAL_CASE.test(term)) return 'lexical';
189
+
190
+ // SCREAMING_SNAKE (e.g., MAX_RETRIES, DEFAULT_TIMEOUT)
191
+ if (RE_SCREAMING_SNAKE.test(term)) return 'lexical';
192
+
193
+ // Dotted path (e.g., config.database.host)
194
+ if (RE_DOTTED_PATH.test(term)) return 'lexical';
195
+
196
+ // snake_case single identifier
197
+ if (RE_SNAKE_CASE.test(term)) return 'lexical';
198
+
199
+ // camelCase
200
+ if (RE_CAMEL_CASE.test(term)) return 'lexical';
201
+
202
+ // Tokenize by whitespace
203
+ const tokens = term.split(/\s+/).filter(t => t.length > 0);
204
+
205
+ // Short terms (≤2 tokens) that look like code → lexical
206
+ if (tokens.length <= 2) {
207
+ // If any token looks code-like, treat as lexical
208
+ if (tokens.some(t => RE_CODE_TOKEN.test(t))) return 'lexical';
209
+ // Single-word terms default to lexical (likely an identifier)
210
+ if (tokens.length === 1) return 'lexical';
211
+ }
212
+
213
+ // NL phrase: 3+ tokens, mostly lowercase with no code tokens
214
+ if (tokens.length >= 3) {
215
+ const codeTokenCount = tokens.filter(t => RE_CODE_TOKEN.test(t)).length;
216
+ if (codeTokenCount === 0) return 'semantic';
217
+ // Mix of NL and code → both
218
+ if (codeTokenCount <= 1) return 'both';
219
+ }
220
+
221
+ // Default: lexical (safer for code search)
222
+ return 'lexical';
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Stream A: Identifier Ranking
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /**
230
+ * Rank identifiers using BM25 + PageRank + heuristic multipliers.
231
+ *
232
+ * @param {Array<{term: string, score: number, source: string, type: string, sources?: string[]}>} minedTerms
233
+ * Output from vocab-miner.js
234
+ * @param {Map<string, number>} pageRankScores
235
+ * Entity name → PageRank score (from repo-map.js)
236
+ * @param {object} [options]
237
+ * @param {'light'|'medium'|'deep'} [options.depth='medium']
238
+ * @param {number} [options.topN] - explicit cap (overrides depth)
239
+ * @param {number} [options.totalFiles] - total files in codebase (for IDF; estimated if absent)
240
+ * @returns {{ identifiers: Array<{term: string, score: number, warm_mode: string, sources: string[], type: string}>, totalMined: number }}
241
+ */
242
+ export function rankIdentifiers(minedTerms, pageRankScores, options = {}) {
243
+ const depth = options.depth || 'medium';
244
+ const cutoff = options.topN || DEPTH_CUTOFFS[depth]?.identifiers || DEPTH_CUTOFFS.medium.identifiers;
245
+
246
+ if (!minedTerms || minedTerms.length === 0) {
247
+ return { identifiers: [], totalMined: 0 };
248
+ }
249
+
250
+ // --- Aggregate terms: group duplicates, accumulate tf & sources ---
251
+ const termMap = new Map(); // term → { tf, sources: Set, type, docFreq }
252
+ for (const entry of minedTerms) {
253
+ const key = entry.term;
254
+ if (!termMap.has(key)) {
255
+ termMap.set(key, {
256
+ term: key,
257
+ tf: 0,
258
+ sources: new Set(),
259
+ type: entry.type || '',
260
+ docFreq: 0,
261
+ });
262
+ }
263
+ const agg = termMap.get(key);
264
+ agg.tf += (entry.score || 1);
265
+ if (entry.source) agg.sources.add(entry.source);
266
+ if (entry.sources) {
267
+ for (const s of entry.sources) agg.sources.add(s);
268
+ }
269
+ // Approximate docFreq from unique sources
270
+ agg.docFreq = agg.sources.size;
271
+ }
272
+
273
+ // --- BM25 prerequisites ---
274
+ const totalFiles = options.totalFiles || estimateTotalSourceBuckets(termMap);
275
+ const terms = [...termMap.values()];
276
+
277
+ // Average "document length" ≈ average source count per term
278
+ const totalDocLen = terms.reduce((sum, t) => sum + t.sources.size, 0);
279
+ const avgDocLen = terms.length > 0 ? totalDocLen / terms.length : 1;
280
+
281
+ // Pre-compute IDFs
282
+ for (const t of terms) {
283
+ t.idf = computeIDF(totalFiles, t.docFreq);
284
+ }
285
+
286
+ // --- Score each term ---
287
+ const scored = [];
288
+ for (const t of terms) {
289
+ const prScore = pageRankScores?.get(t.term) || 0;
290
+ const bm25 = bm25Score(t.tf, t.idf, t.sources.size, avgDocLen);
291
+ const heuristic = computeHeuristicMultiplier(
292
+ { term: t.term, score: t.tf, sources: [...t.sources], type: t.type },
293
+ prScore
294
+ );
295
+ const finalScore = bm25 * heuristic * (1 + prScore);
296
+ const warmMode = classifyWarmMode(t.term, prScore);
297
+
298
+ scored.push({
299
+ term: t.term,
300
+ score: finalScore,
301
+ warm_mode: warmMode,
302
+ sources: [...t.sources],
303
+ type: t.type,
304
+ });
305
+ }
306
+
307
+ // --- Sort descending by score, then take top-N ---
308
+ scored.sort((a, b) => b.score - a.score);
309
+ const identifiers = scored.slice(0, cutoff);
310
+
311
+ return { identifiers, totalMined: minedTerms.length };
312
+ }
313
+
314
+ /**
315
+ * Estimate total source-bucket count from term source diversity.
316
+ * This is an approximation used when real file counts are unavailable.
317
+ * Pass `options.totalFiles` to rankIdentifiers() for true file-based BM25 IDF.
318
+ * @param {Map} termMap
319
+ * @returns {number}
320
+ */
321
+ function estimateTotalSourceBuckets(termMap) {
322
+ const allSources = new Set();
323
+ for (const t of termMap.values()) {
324
+ for (const s of t.sources) allSources.add(s);
325
+ }
326
+ // Source-type buckets are small but stable; keep floor at 1.
327
+ return Math.max(allSources.size, 1);
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // Stream B: Community Phrase Ranking
332
+ // ---------------------------------------------------------------------------
333
+
334
+ /**
335
+ * Rank community phrases using c-TF-IDF scores + PageRank boost.
336
+ *
337
+ * @param {Array<{communityId: number, phrases: Array<{text: string, score: number, type?: string}>}>} communityPhrases
338
+ * Output from vocab-miner.js
339
+ * @param {Map<string, number>} pageRankScores
340
+ * Entity name → PageRank score
341
+ * @param {object} [options]
342
+ * @param {number} [options.maxPhrasesPerCommunity] - K per community (default 15)
343
+ * @param {number} [options.maxTotalPhrases] - total cap (default from depth)
344
+ * @param {'light'|'medium'|'deep'} [options.depth='medium']
345
+ * @returns {{ phrases: Array<{phrase: string, communityId: number, score: number, variants: string[]}>, totalMined: number }}
346
+ */
347
+ export function rankCommunityPhrases(communityPhrases, pageRankScores, options = {}) {
348
+ const depth = options.depth || 'medium';
349
+ const maxPerCommunity = options.maxPhrasesPerCommunity || DEFAULT_PHRASES_PER_COMMUNITY;
350
+ const maxTotal = options.maxTotalPhrases || DEPTH_CUTOFFS[depth]?.phrases || DEPTH_CUTOFFS.medium.phrases;
351
+
352
+ if (!communityPhrases || communityPhrases.length === 0) {
353
+ return { phrases: [], totalMined: 0 };
354
+ }
355
+
356
+ // Pre-filter high-PR entities ONCE before the phrase loop (O(N*M) → O(N+M)).
357
+ // Build a pre-lowercased array so each phrase only iterates the small filtered set.
358
+ const highPrEntities = []; // { nameLower: string, score: number }
359
+ if (pageRankScores) {
360
+ for (const [entity, prScore] of pageRankScores) {
361
+ if (prScore > 0.01) {
362
+ highPrEntities.push({ nameLower: entity.toLowerCase(), score: prScore });
363
+ }
364
+ }
365
+ }
366
+
367
+ let totalMined = 0;
368
+ const allPhrases = []; // { phrase, communityId, score }
369
+
370
+ for (const community of communityPhrases) {
371
+ const cid = community.communityId;
372
+ const phrases = community.phrases || [];
373
+ totalMined += phrases.length;
374
+
375
+ // Score each phrase: base c-TF-IDF score + PageRank boost for entity mentions
376
+ const scored = phrases.map(p => {
377
+ let boost = 0;
378
+ if (highPrEntities.length > 0) {
379
+ // Check if phrase text contains any high-PageRank entity name.
380
+ // Uses pre-lowercased filtered set — avoids repeated toLowerCase() on all N entries.
381
+ const textLower = p.text.toLowerCase();
382
+ for (const { nameLower, score: prScore } of highPrEntities) {
383
+ if (textLower.includes(nameLower)) {
384
+ boost = Math.max(boost, prScore);
385
+ }
386
+ }
387
+ }
388
+ return {
389
+ phrase: p.text,
390
+ communityId: cid,
391
+ score: (p.score || 0) * (1 + boost),
392
+ };
393
+ });
394
+
395
+ // Sort by score descending, take top-K per community
396
+ scored.sort((a, b) => b.score - a.score);
397
+ allPhrases.push(...scored.slice(0, maxPerCommunity));
398
+ }
399
+
400
+ // Deduplicate across communities: keep highest-scoring occurrence
401
+ const deduped = deduplicatePhrases(allPhrases);
402
+
403
+ // Sort globally by score, take top-N
404
+ deduped.sort((a, b) => b.score - a.score);
405
+ const topPhrases = deduped.slice(0, maxTotal);
406
+
407
+ // Generate question variants
408
+ const withVariants = topPhrases.map(p => ({
409
+ phrase: p.phrase,
410
+ communityId: p.communityId,
411
+ score: p.score,
412
+ variants: QUESTION_TEMPLATES.map(fn => fn(p.phrase)),
413
+ }));
414
+
415
+ return { phrases: withVariants, totalMined };
416
+ }
417
+
418
+ /**
419
+ * Deduplicate phrases: when the same phrase appears in multiple communities,
420
+ * keep only the highest-scoring occurrence.
421
+ * @param {Array<{phrase: string, communityId: number, score: number}>} phrases
422
+ * @returns {Array<{phrase: string, communityId: number, score: number}>}
423
+ */
424
+ function deduplicatePhrases(phrases) {
425
+ const best = new Map(); // normalized phrase → best entry
426
+ for (const p of phrases) {
427
+ const key = p.phrase.toLowerCase().trim();
428
+ const existing = best.get(key);
429
+ if (!existing || p.score > existing.score) {
430
+ best.set(key, p);
431
+ }
432
+ }
433
+ return [...best.values()];
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Combined Ranking
438
+ // ---------------------------------------------------------------------------
439
+
440
+ /**
441
+ * Run both ranking streams and return unified result.
442
+ *
443
+ * @param {Array} minedTerms - from vocab-miner.js
444
+ * @param {Array} communityPhrases - from vocab-miner.js
445
+ * @param {Map<string, number>} pageRankScores - from repo-map.js
446
+ * @param {object} [options]
447
+ * @param {'light'|'medium'|'deep'} [options.depth='medium']
448
+ * @param {number} [options.topN] - explicit identifier cap
449
+ * @param {number} [options.maxPhrasesPerCommunity]
450
+ * @param {number} [options.maxTotalPhrases]
451
+ * @param {number} [options.totalFiles]
452
+ * @returns {{ identifiers: Array, phrases: Array }}
453
+ */
454
+ export function rankAll(minedTerms, communityPhrases, pageRankScores, options = {}) {
455
+ const { identifiers, totalMined: idTotal } = rankIdentifiers(
456
+ minedTerms, pageRankScores, options
457
+ );
458
+ const { phrases, totalMined: phTotal } = rankCommunityPhrases(
459
+ communityPhrases, pageRankScores, options
460
+ );
461
+
462
+ return {
463
+ identifiers,
464
+ phrases,
465
+ stats: {
466
+ totalIdentifiersMined: idTotal,
467
+ totalPhrasesMined: phTotal,
468
+ identifiersReturned: identifiers.length,
469
+ phrasesReturned: phrases.length,
470
+ depth: options.depth || 'medium',
471
+ },
472
+ };
473
+ }
474
+
475
+ // ---------------------------------------------------------------------------
476
+ // Exports (for testing internals)
477
+ // ---------------------------------------------------------------------------
478
+
479
+ export const _internals = {
480
+ computeIDF,
481
+ bm25Score,
482
+ computeHeuristicMultiplier,
483
+ deduplicatePhrases,
484
+ estimateTotalSourceBuckets,
485
+ // Backward compatibility export name
486
+ estimateTotalFiles: estimateTotalSourceBuckets,
487
+ MULTIPLIERS,
488
+ DEPTH_CUTOFFS,
489
+ QUESTION_TEMPLATES,
490
+ K1,
491
+ B,
492
+ };