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.
- package/LICENSE +190 -0
- package/NOTICE +23 -0
- package/core/cli.js +51 -0
- package/core/config.js +27 -0
- package/core/embedding/embedding-cache.js +467 -0
- package/core/embedding/embedding-local-model.js +845 -0
- package/core/embedding/embedding-remote.js +492 -0
- package/core/embedding/embedding-service.js +712 -0
- package/core/embedding/embedding-telemetry.js +219 -0
- package/core/embedding/index.js +40 -0
- package/core/graph/community-detector.js +294 -0
- package/core/graph/graph-expansion.js +839 -0
- package/core/graph/graph-extractor.js +2304 -0
- package/core/graph/graph-search.js +2148 -0
- package/core/graph/hcgs-generator.js +666 -0
- package/core/graph/index.js +16 -0
- package/core/graph/leiden-algorithm.js +547 -0
- package/core/graph/relationship-resolver.js +366 -0
- package/core/graph/repo-map.js +408 -0
- package/core/graph/summary-manager.js +549 -0
- package/core/indexing/artifact-builder.js +1054 -0
- package/core/indexing/ast-chunker.js +709 -0
- package/core/indexing/chunking/chunk-builder.js +170 -0
- package/core/indexing/chunking/markdown-chunker.js +503 -0
- package/core/indexing/chunking/plaintext-chunker.js +104 -0
- package/core/indexing/dedup/dedup-phase.js +159 -0
- package/core/indexing/dedup/exemplar-selector.js +65 -0
- package/core/indexing/document-chunker.js +56 -0
- package/core/indexing/incremental-parser.js +390 -0
- package/core/indexing/incremental-tracker.js +761 -0
- package/core/indexing/index-codebase-v21.js +472 -0
- package/core/indexing/index-maintainer.mjs +1674 -0
- package/core/indexing/index.js +90 -0
- package/core/indexing/indexer-ann.js +1077 -0
- package/core/indexing/indexer-build.js +742 -0
- package/core/indexing/indexer-phases.js +800 -0
- package/core/indexing/indexer-pool.js +764 -0
- package/core/indexing/indexer-sparse-gram.js +98 -0
- package/core/indexing/indexer-utils.js +536 -0
- package/core/indexing/indexer-worker.js +148 -0
- package/core/indexing/li-skip-policy.js +225 -0
- package/core/indexing/merkle-tracker.js +244 -0
- package/core/indexing/model-pool.js +166 -0
- package/core/infrastructure/code-graph-repository.js +120 -0
- package/core/infrastructure/codebase-repository.js +131 -0
- package/core/infrastructure/config/dedup.js +54 -0
- package/core/infrastructure/config/embedding.js +298 -0
- package/core/infrastructure/config/graph.js +80 -0
- package/core/infrastructure/config/index.js +82 -0
- package/core/infrastructure/config/indexing.js +8 -0
- package/core/infrastructure/config/platform.js +254 -0
- package/core/infrastructure/config/ranking.js +221 -0
- package/core/infrastructure/config/search.js +396 -0
- package/core/infrastructure/config/translation.js +89 -0
- package/core/infrastructure/config/vector-store.js +114 -0
- package/core/infrastructure/constants.js +86 -0
- package/core/infrastructure/coreml-cascade.js +909 -0
- package/core/infrastructure/coreml-cascade.json +46 -0
- package/core/infrastructure/coreml-provider.js +81 -0
- package/core/infrastructure/db-utils.js +69 -0
- package/core/infrastructure/dedup-hashing.js +83 -0
- package/core/infrastructure/hardware-capability.js +332 -0
- package/core/infrastructure/index.js +104 -0
- package/core/infrastructure/language-patterns/maps.js +121 -0
- package/core/infrastructure/language-patterns/registry-core.js +323 -0
- package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
- package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
- package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
- package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
- package/core/infrastructure/language-patterns/registry.js +19 -0
- package/core/infrastructure/language-patterns.js +141 -0
- package/core/infrastructure/llm-provider.js +733 -0
- package/core/infrastructure/manifest.json +46 -0
- package/core/infrastructure/maxsim.wasm +0 -0
- package/core/infrastructure/model-fetcher.js +423 -0
- package/core/infrastructure/model-registry.js +214 -0
- package/core/infrastructure/native-inference.js +587 -0
- package/core/infrastructure/native-resolver.js +187 -0
- package/core/infrastructure/native-sparse-gram.js +257 -0
- package/core/infrastructure/native-tokenizer.js +160 -0
- package/core/infrastructure/onnx-mutex.js +45 -0
- package/core/infrastructure/onnx-session-utils.js +261 -0
- package/core/infrastructure/ort-pipeline.js +111 -0
- package/core/infrastructure/project-detector.js +102 -0
- package/core/infrastructure/quantization.js +410 -0
- package/core/infrastructure/simd-distance.js +502 -0
- package/core/infrastructure/simd-distance.wasm +0 -0
- package/core/infrastructure/tree-sitter-provider.js +665 -0
- package/core/infrastructure/webgpu-maxsim.js +222 -0
- package/core/query/index.js +35 -0
- package/core/query/intent-detector.js +201 -0
- package/core/query/intent-router.js +156 -0
- package/core/query/query-router-catboost.js +222 -0
- package/core/query/query-router-ml.js +266 -0
- package/core/query/query-router.js +213 -0
- package/core/ranking/cascaded-scorer.js +379 -0
- package/core/ranking/flashrank.js +810 -0
- package/core/ranking/index.js +49 -0
- package/core/ranking/late-interaction-index.js +2383 -0
- package/core/ranking/late-interaction-model.js +812 -0
- package/core/ranking/local-reranker.js +374 -0
- package/core/ranking/mmr.js +379 -0
- package/core/ranking/quality-scorer.js +363 -0
- package/core/search/context-expander.js +1167 -0
- package/core/search/dedup/sibling-expander.js +327 -0
- package/core/search/index.js +16 -0
- package/core/search/search-boost.js +259 -0
- package/core/search/search-cli.js +544 -0
- package/core/search/search-format.js +282 -0
- package/core/search/search-fusion.js +327 -0
- package/core/search/search-hybrid.js +204 -0
- package/core/search/search-pattern-chunks.js +337 -0
- package/core/search/search-pattern-planner.js +439 -0
- package/core/search/search-pattern-prefilter.js +412 -0
- package/core/search/search-pattern-ripgrep.js +663 -0
- package/core/search/search-pattern.js +463 -0
- package/core/search/search-postprocess.js +452 -0
- package/core/search/search-semantic.js +706 -0
- package/core/search/search-server.js +554 -0
- package/core/search/session-daemon-prewarm.mjs +164 -0
- package/core/search/session-warmup.js +595 -0
- package/core/search/sweet-search.js +632 -0
- package/core/search/warmup-metrics.js +532 -0
- package/core/start-server.js +6 -0
- package/core/training/query-router/features/extractor.js +762 -0
- package/core/training/query-router/features/multilingual-patterns.js +431 -0
- package/core/training/query-router/features/text-segmenter.js +303 -0
- package/core/training/query-router/features/unicode-utils.js +383 -0
- package/core/training/query-router/output/v45_router_d4.js +11521 -0
- package/core/training/query-router/output/v46_router_d4.js +11498 -0
- package/core/vector-store/binary-heap.js +227 -0
- package/core/vector-store/binary-hnsw-index.js +1004 -0
- package/core/vector-store/float-vector-store.js +234 -0
- package/core/vector-store/hnsw-index.js +580 -0
- package/core/vector-store/index.js +39 -0
- package/core/vector-store/seismic-index.js +498 -0
- package/core/vocabulary/index.js +84 -0
- package/core/vocabulary/vocab-constants.js +20 -0
- package/core/vocabulary/vocab-miner-extractors.js +375 -0
- package/core/vocabulary/vocab-miner-nl.js +404 -0
- package/core/vocabulary/vocab-miner-utils.js +146 -0
- package/core/vocabulary/vocab-miner.js +574 -0
- package/core/vocabulary/vocab-prewarm-cli.js +110 -0
- package/core/vocabulary/vocab-ranker.js +492 -0
- package/core/vocabulary/vocab-warmer.js +523 -0
- package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
- package/core/vocabulary/vocabulary-utils.js +704 -0
- package/crates/wasm-router/pkg/package.json +13 -0
- package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
- package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
- package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
- package/mcp/config-gen.js +121 -0
- package/mcp/server.js +335 -0
- package/mcp/tool-handlers.js +476 -0
- package/package.json +131 -9
- package/scripts/benchmark-harness.js +794 -0
- package/scripts/init.js +1058 -0
- package/scripts/smoke-test.js +435 -0
- package/scripts/uninstall.js +478 -0
- package/scripts/verify-runtime.js +176 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warmup Metrics & Adaptive Learning (Step 9 of Vocabulary Prewarm)
|
|
3
|
+
*
|
|
4
|
+
* Tracks per-mode cache hit rates, latency percentiles, and provides
|
|
5
|
+
* promotion/demotion rules for adaptive vocabulary management.
|
|
6
|
+
* Integrates with Step 0 telemetry from embedding-cache.js.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// WARMUP METRICS CLASS
|
|
11
|
+
// =============================================================================
|
|
12
|
+
|
|
13
|
+
export class WarmupMetrics {
|
|
14
|
+
constructor() {
|
|
15
|
+
this.lexical = { hits: 0, misses: 0, totalLatencyMs: 0, count: 0, latencies: [] };
|
|
16
|
+
this.semantic = { hits: 0, misses: 0, totalLatencyMs: 0, count: 0, latencies: [] };
|
|
17
|
+
this.hybrid = { hits: 0, misses: 0, totalLatencyMs: 0, count: 0, latencies: [] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Cache hit rate for a specific mode.
|
|
22
|
+
* @param {'lexical'|'semantic'|'hybrid'} mode
|
|
23
|
+
* @returns {number} 0-1 ratio
|
|
24
|
+
*/
|
|
25
|
+
hitRate(mode) {
|
|
26
|
+
const bucket = this[mode];
|
|
27
|
+
if (!bucket) return 0;
|
|
28
|
+
const total = bucket.hits + bucket.misses;
|
|
29
|
+
return total > 0 ? bucket.hits / total : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Overall cache hit rate across all modes.
|
|
34
|
+
* @returns {number} 0-1 ratio
|
|
35
|
+
*/
|
|
36
|
+
overallHitRate() {
|
|
37
|
+
let totalHits = 0;
|
|
38
|
+
let totalQueries = 0;
|
|
39
|
+
for (const mode of ['lexical', 'semantic', 'hybrid']) {
|
|
40
|
+
totalHits += this[mode].hits;
|
|
41
|
+
totalQueries += this[mode].hits + this[mode].misses;
|
|
42
|
+
}
|
|
43
|
+
return totalQueries > 0 ? totalHits / totalQueries : 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Average latency for a mode.
|
|
48
|
+
* @param {'lexical'|'semantic'|'hybrid'} mode
|
|
49
|
+
* @returns {number} milliseconds
|
|
50
|
+
*/
|
|
51
|
+
avgLatency(mode) {
|
|
52
|
+
const bucket = this[mode];
|
|
53
|
+
if (!bucket || bucket.count === 0) return 0;
|
|
54
|
+
return bucket.totalLatencyMs / bucket.count;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Latency percentile for a mode.
|
|
59
|
+
* @param {'lexical'|'semantic'|'hybrid'} mode
|
|
60
|
+
* @param {number} percentile - 0-100
|
|
61
|
+
* @returns {number} milliseconds
|
|
62
|
+
*/
|
|
63
|
+
latencyPercentile(mode, percentile) {
|
|
64
|
+
const bucket = this[mode];
|
|
65
|
+
if (!bucket || bucket.latencies.length === 0) return 0;
|
|
66
|
+
const sorted = [...bucket.latencies].sort((a, b) => a - b);
|
|
67
|
+
const idx = Math.min(
|
|
68
|
+
Math.floor((percentile / 100) * sorted.length),
|
|
69
|
+
sorted.length - 1,
|
|
70
|
+
);
|
|
71
|
+
return sorted[idx];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load metrics from parsed telemetry entries.
|
|
76
|
+
* @param {Array<{mode: string, hit: boolean, latencyMs: number}>} entries
|
|
77
|
+
*/
|
|
78
|
+
loadFromEntries(entries) {
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const bucket = this[entry.mode];
|
|
81
|
+
if (!bucket) continue;
|
|
82
|
+
|
|
83
|
+
bucket.count++;
|
|
84
|
+
bucket.totalLatencyMs += entry.latencyMs || 0;
|
|
85
|
+
bucket.latencies.push(entry.latencyMs || 0);
|
|
86
|
+
|
|
87
|
+
if (entry.hit) {
|
|
88
|
+
bucket.hits++;
|
|
89
|
+
} else {
|
|
90
|
+
bucket.misses++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Load from a getTelemetryReport()-style object (modes with hits/misses/count).
|
|
97
|
+
* Note: loses per-query latency granularity (no percentiles).
|
|
98
|
+
* @param {{ modes: Record<string, { hits: number, misses: number, avgLatencyMs: number, count: number }> }} report
|
|
99
|
+
*/
|
|
100
|
+
loadFromReport(report) {
|
|
101
|
+
if (!report?.modes) return;
|
|
102
|
+
for (const [mode, stats] of Object.entries(report.modes)) {
|
|
103
|
+
const bucket = this[mode];
|
|
104
|
+
if (!bucket) continue;
|
|
105
|
+
bucket.hits = stats.hits || 0;
|
|
106
|
+
bucket.misses = stats.misses || 0;
|
|
107
|
+
bucket.count = stats.count || 0;
|
|
108
|
+
bucket.totalLatencyMs = (stats.avgLatencyMs || 0) * bucket.count;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Reset all counters.
|
|
114
|
+
*/
|
|
115
|
+
reset() {
|
|
116
|
+
for (const mode of ['lexical', 'semantic', 'hybrid']) {
|
|
117
|
+
this[mode] = { hits: 0, misses: 0, totalLatencyMs: 0, count: 0, latencies: [] };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// =============================================================================
|
|
123
|
+
// CACHE HIT DEFINITIONS
|
|
124
|
+
// =============================================================================
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Determine if a query event counts as a cache hit for its mode.
|
|
128
|
+
*
|
|
129
|
+
* Lexical: FTS5 query latency < 5ms = hit
|
|
130
|
+
* Semantic: source is 'vocabulary' or 'semantic-cache' = hit
|
|
131
|
+
* Hybrid: both sub-mode hits = hit
|
|
132
|
+
*
|
|
133
|
+
* @param {object} entry - Telemetry entry
|
|
134
|
+
* @param {string} entry.mode
|
|
135
|
+
* @param {number} [entry.latencyMs]
|
|
136
|
+
* @param {string} [entry.source] - 'vocabulary', 'semantic-cache', 'api', 'local', etc.
|
|
137
|
+
* @param {boolean} [entry.lexicalHit] - For hybrid: lexical sub-hit
|
|
138
|
+
* @param {boolean} [entry.semanticHit] - For hybrid: semantic sub-hit
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
export function isWarmupHit(entry) {
|
|
142
|
+
switch (entry.mode) {
|
|
143
|
+
case 'lexical':
|
|
144
|
+
return (entry.latencyMs || Infinity) < 5;
|
|
145
|
+
case 'semantic':
|
|
146
|
+
return entry.source === 'vocabulary' || entry.source === 'semantic-cache';
|
|
147
|
+
case 'hybrid':
|
|
148
|
+
return Boolean(entry.lexicalHit && entry.semanticHit);
|
|
149
|
+
default:
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// =============================================================================
|
|
155
|
+
// PROMOTION / DEMOTION (Adaptive Learning)
|
|
156
|
+
// =============================================================================
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Find terms that should be promoted to the warmup vocabulary.
|
|
160
|
+
*
|
|
161
|
+
* Rules:
|
|
162
|
+
* - PROMOTE: Query used 3+ times and not in warmup set
|
|
163
|
+
* - FAST-PROMOTE: Cache miss for an identifier found in code (source: 'code-identifier')
|
|
164
|
+
*
|
|
165
|
+
* @param {Array<{query?: string, mode?: string, hit?: boolean, source?: string}>} telemetryEntries
|
|
166
|
+
* @param {Set<string>|Map<string,any>|{has: (t: string) => boolean}} vocabularyTerms
|
|
167
|
+
* @returns {Array<{term: string, reason: 'promote'|'fast-promote', queryCount: number}>}
|
|
168
|
+
*/
|
|
169
|
+
export function getPromotionCandidates(telemetryEntries, vocabularyTerms) {
|
|
170
|
+
const queryCounts = new Map();
|
|
171
|
+
const fastPromoteCandidates = new Set();
|
|
172
|
+
|
|
173
|
+
const hasVocab = (term) => {
|
|
174
|
+
if (!term) return true;
|
|
175
|
+
const normalized = term.toLowerCase().trim();
|
|
176
|
+
if (typeof vocabularyTerms.has === 'function') return vocabularyTerms.has(normalized);
|
|
177
|
+
return false;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
for (const entry of telemetryEntries) {
|
|
181
|
+
const query = entry.query?.toLowerCase().trim();
|
|
182
|
+
if (!query) continue;
|
|
183
|
+
|
|
184
|
+
// Count occurrences
|
|
185
|
+
queryCounts.set(query, (queryCounts.get(query) || 0) + 1);
|
|
186
|
+
|
|
187
|
+
// Fast-promote: cache miss + code identifier source
|
|
188
|
+
if (!entry.hit && entry.source === 'code-identifier' && !hasVocab(query)) {
|
|
189
|
+
fastPromoteCandidates.add(query);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const candidates = [];
|
|
194
|
+
|
|
195
|
+
// PROMOTE: 3+ uses, not in vocabulary
|
|
196
|
+
for (const [term, count] of queryCounts) {
|
|
197
|
+
if (count >= 3 && !hasVocab(term)) {
|
|
198
|
+
candidates.push({ term, reason: 'promote', queryCount: count });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// FAST-PROMOTE: code identifier misses
|
|
203
|
+
for (const term of fastPromoteCandidates) {
|
|
204
|
+
// Skip if already in promote list
|
|
205
|
+
if (candidates.some(c => c.term === term)) continue;
|
|
206
|
+
candidates.push({
|
|
207
|
+
term,
|
|
208
|
+
reason: 'fast-promote',
|
|
209
|
+
queryCount: queryCounts.get(term) || 1,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Sort by query count descending
|
|
214
|
+
candidates.sort((a, b) => b.queryCount - a.queryCount);
|
|
215
|
+
return candidates;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Find terms that should be demoted from the warmup vocabulary.
|
|
220
|
+
*
|
|
221
|
+
* Rule: Warmup term not queried in `days` days -> DEMOTE
|
|
222
|
+
*
|
|
223
|
+
* @param {Set<string>|Map<string,any>|Iterable<string>} vocabularyTerms
|
|
224
|
+
* @param {Array<{query?: string, timestamp?: string}>} telemetryEntries
|
|
225
|
+
* @param {number} [days=7] - Inactivity threshold in days
|
|
226
|
+
* @returns {Array<{term: string, reason: 'inactive', lastSeen: string|null}>}
|
|
227
|
+
*/
|
|
228
|
+
export function getDemotionCandidates(vocabularyTerms, telemetryEntries, days = 7) {
|
|
229
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
230
|
+
|
|
231
|
+
// Build a map of term -> last seen timestamp
|
|
232
|
+
const lastSeen = new Map();
|
|
233
|
+
for (const entry of telemetryEntries) {
|
|
234
|
+
const query = entry.query?.toLowerCase().trim();
|
|
235
|
+
if (!query) continue;
|
|
236
|
+
const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
237
|
+
const prev = lastSeen.get(query) || 0;
|
|
238
|
+
if (ts > prev) lastSeen.set(query, ts);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const candidates = [];
|
|
242
|
+
const terms = vocabularyTerms instanceof Map
|
|
243
|
+
? vocabularyTerms.keys()
|
|
244
|
+
: vocabularyTerms[Symbol.iterator]
|
|
245
|
+
? vocabularyTerms
|
|
246
|
+
: [];
|
|
247
|
+
|
|
248
|
+
for (const term of terms) {
|
|
249
|
+
const normalized = typeof term === 'string' ? term.toLowerCase().trim() : term;
|
|
250
|
+
const seen = lastSeen.get(normalized) || 0;
|
|
251
|
+
if (seen < cutoff) {
|
|
252
|
+
candidates.push({
|
|
253
|
+
term: normalized,
|
|
254
|
+
reason: 'inactive',
|
|
255
|
+
lastSeen: seen > 0 ? new Date(seen).toISOString() : null,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return candidates;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// =============================================================================
|
|
264
|
+
// WORKING SET SIZE ESTIMATION
|
|
265
|
+
// =============================================================================
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Estimate working set size = unique query clusters in a time window.
|
|
269
|
+
*
|
|
270
|
+
* Uses simple normalization (lowercase, trim) for clustering.
|
|
271
|
+
*
|
|
272
|
+
* @param {Array<{query?: string, timestamp?: string}>} telemetryEntries
|
|
273
|
+
* @param {number} [windowMs=604800000] - Time window (default 7 days)
|
|
274
|
+
* @param {number} [warmupSize=0] - Current warmup vocabulary size
|
|
275
|
+
* @param {number} [maxWarmup=2000] - Maximum warmup set size
|
|
276
|
+
* @returns {{ wss: number, warmupSize: number, recommendation: 'well-sized'|'expand'|'converged' }}
|
|
277
|
+
*/
|
|
278
|
+
export function estimateWorkingSetSize(
|
|
279
|
+
telemetryEntries,
|
|
280
|
+
windowMs = 7 * 24 * 60 * 60 * 1000,
|
|
281
|
+
warmupSize = 0,
|
|
282
|
+
maxWarmup = 2000,
|
|
283
|
+
) {
|
|
284
|
+
const MAX_CLUSTER_QUERIES = 1000;
|
|
285
|
+
const cutoff = Date.now() - windowMs;
|
|
286
|
+
const uniqueQueries = new Set();
|
|
287
|
+
|
|
288
|
+
for (const entry of telemetryEntries) {
|
|
289
|
+
const query = entry.query?.toLowerCase().trim();
|
|
290
|
+
if (!query) continue;
|
|
291
|
+
const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
292
|
+
if (ts >= cutoff) {
|
|
293
|
+
uniqueQueries.add(query);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// F12: Basic string-similarity clustering (Jaccard on word sets) to avoid
|
|
298
|
+
// counting near-duplicate queries as separate working set entries.
|
|
299
|
+
const queryList = [...uniqueQueries];
|
|
300
|
+
// Guard against O(n^2) blow-ups on very large telemetry windows.
|
|
301
|
+
// Deterministic truncation keeps behavior stable across runs/tests.
|
|
302
|
+
if (queryList.length > MAX_CLUSTER_QUERIES) {
|
|
303
|
+
queryList.sort();
|
|
304
|
+
queryList.length = MAX_CLUSTER_QUERIES;
|
|
305
|
+
}
|
|
306
|
+
const clusters = [];
|
|
307
|
+
const assigned = new Set();
|
|
308
|
+
|
|
309
|
+
for (let i = 0; i < queryList.length; i++) {
|
|
310
|
+
if (assigned.has(i)) continue;
|
|
311
|
+
const cluster = [queryList[i]];
|
|
312
|
+
assigned.add(i);
|
|
313
|
+
const wordsI = new Set(queryList[i].split(/\s+/));
|
|
314
|
+
for (let j = i + 1; j < queryList.length; j++) {
|
|
315
|
+
if (assigned.has(j)) continue;
|
|
316
|
+
const wordsJ = new Set(queryList[j].split(/\s+/));
|
|
317
|
+
// Jaccard similarity
|
|
318
|
+
let intersection = 0;
|
|
319
|
+
for (const w of wordsI) { if (wordsJ.has(w)) intersection++; }
|
|
320
|
+
const union = wordsI.size + wordsJ.size - intersection;
|
|
321
|
+
if (union > 0 && intersection / union >= 0.6) {
|
|
322
|
+
cluster.push(queryList[j]);
|
|
323
|
+
assigned.add(j);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
clusters.push(cluster);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const wss = clusters.length;
|
|
330
|
+
let recommendation;
|
|
331
|
+
|
|
332
|
+
if (warmupSize === 0 && wss === 0) {
|
|
333
|
+
recommendation = 'well-sized';
|
|
334
|
+
} else if (wss > warmupSize && warmupSize < maxWarmup) {
|
|
335
|
+
recommendation = 'expand';
|
|
336
|
+
} else if (wss <= warmupSize) {
|
|
337
|
+
recommendation = 'well-sized';
|
|
338
|
+
} else {
|
|
339
|
+
// wss > warmupSize but warmupSize >= maxWarmup → can't expand further
|
|
340
|
+
recommendation = 'converged';
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { wss, warmupSize, recommendation };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// =============================================================================
|
|
347
|
+
// STATS REPORT FORMATTER
|
|
348
|
+
// =============================================================================
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Render a horizontal bar chart segment.
|
|
352
|
+
* @param {number} ratio - 0 to 1
|
|
353
|
+
* @param {number} [width=10] - Number of characters
|
|
354
|
+
* @returns {string} e.g. "████████░░"
|
|
355
|
+
*/
|
|
356
|
+
function _barChart(ratio, width = 10) {
|
|
357
|
+
const filled = Math.round(ratio * width);
|
|
358
|
+
const empty = width - filled;
|
|
359
|
+
return '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Format a human-friendly elapsed-time string.
|
|
364
|
+
* @param {Date|string|number} timestamp
|
|
365
|
+
* @returns {string} e.g. "2 hours ago"
|
|
366
|
+
*/
|
|
367
|
+
function _timeAgo(timestamp) {
|
|
368
|
+
if (!timestamp) return 'never';
|
|
369
|
+
const ms = Date.now() - new Date(timestamp).getTime();
|
|
370
|
+
if (ms < 0) return 'just now';
|
|
371
|
+
const seconds = Math.floor(ms / 1000);
|
|
372
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
373
|
+
const minutes = Math.floor(seconds / 60);
|
|
374
|
+
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
|
375
|
+
const hours = Math.floor(minutes / 60);
|
|
376
|
+
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
|
377
|
+
const days = Math.floor(hours / 24);
|
|
378
|
+
return `${days} day${days !== 1 ? 's' : ''} ago`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Depth label from identifier + phrase counts.
|
|
383
|
+
* @param {number} identifiers
|
|
384
|
+
* @param {number} phrases
|
|
385
|
+
* @returns {string}
|
|
386
|
+
*/
|
|
387
|
+
function _depthLabel(identifiers, phrases) {
|
|
388
|
+
const total = identifiers + phrases;
|
|
389
|
+
if (total < 200) return 'shallow';
|
|
390
|
+
if (total < 1000) return 'medium';
|
|
391
|
+
return 'deep';
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Format the --stats output report.
|
|
396
|
+
*
|
|
397
|
+
* @param {WarmupMetrics} metrics
|
|
398
|
+
* @param {Array<{id?: string|number, size?: number, name?: string}>} [communities=[]]
|
|
399
|
+
* @param {object} [vocabInfo={}]
|
|
400
|
+
* @param {number} [vocabInfo.identifierCount=0]
|
|
401
|
+
* @param {number} [vocabInfo.phraseCount=0]
|
|
402
|
+
* @param {string} [vocabInfo.depth]
|
|
403
|
+
* @param {string|Date} [vocabInfo.lastWarmup]
|
|
404
|
+
* @param {string} [vocabInfo.warmupType] - 'incremental' | 'full'
|
|
405
|
+
* @param {number} [vocabInfo.newTerms]
|
|
406
|
+
* @param {string} [vocabInfo.graphHash]
|
|
407
|
+
* @param {string} [vocabInfo.nlHash]
|
|
408
|
+
* @param {Array<{term: string, queryCount: number}>} [vocabInfo.topMisses=[]] - promotion candidates
|
|
409
|
+
* @returns {string}
|
|
410
|
+
*/
|
|
411
|
+
export function formatStatsReport(metrics, communities = [], vocabInfo = {}) {
|
|
412
|
+
const lines = [];
|
|
413
|
+
const identifiers = vocabInfo.identifierCount || 0;
|
|
414
|
+
const phrases = vocabInfo.phraseCount || 0;
|
|
415
|
+
const depth = vocabInfo.depth || _depthLabel(identifiers, phrases);
|
|
416
|
+
const communityCount = communities.length;
|
|
417
|
+
|
|
418
|
+
lines.push('Vocabulary Warmup Statistics');
|
|
419
|
+
lines.push('\u2501'.repeat(37));
|
|
420
|
+
|
|
421
|
+
// Vocabulary info
|
|
422
|
+
lines.push(
|
|
423
|
+
`Vocabulary size: ${identifiers.toLocaleString()} identifiers + ${phrases.toLocaleString()} community phrases (${depth} depth)`,
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
// Communities
|
|
427
|
+
lines.push(
|
|
428
|
+
`Communities: ${communityCount} detected (Leiden, import-graph-based)`,
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
// Last warmup
|
|
432
|
+
const warmupAgo = _timeAgo(vocabInfo.lastWarmup);
|
|
433
|
+
const warmupDetail = vocabInfo.warmupType
|
|
434
|
+
? ` (${vocabInfo.warmupType}${vocabInfo.newTerms ? `, ${vocabInfo.newTerms} new terms` : ''})`
|
|
435
|
+
: '';
|
|
436
|
+
lines.push(`Last warmup: ${warmupAgo}${warmupDetail}`);
|
|
437
|
+
|
|
438
|
+
// Hashes
|
|
439
|
+
if (vocabInfo.graphHash) {
|
|
440
|
+
lines.push(`Import graph hash: ${vocabInfo.graphHash} (up to date)`);
|
|
441
|
+
}
|
|
442
|
+
if (vocabInfo.nlHash) {
|
|
443
|
+
lines.push(`NL content hash: ${vocabInfo.nlHash} (up to date)`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
lines.push('');
|
|
447
|
+
|
|
448
|
+
// Per-mode cache hit rates
|
|
449
|
+
const totalQueries = ['lexical', 'semantic', 'hybrid'].reduce(
|
|
450
|
+
(sum, m) => sum + metrics[m].hits + metrics[m].misses,
|
|
451
|
+
0,
|
|
452
|
+
);
|
|
453
|
+
lines.push(`Cache Hit Rates (last ${totalQueries} queries):`);
|
|
454
|
+
|
|
455
|
+
for (const mode of ['lexical', 'semantic', 'hybrid']) {
|
|
456
|
+
const bucket = metrics[mode];
|
|
457
|
+
const total = bucket.hits + bucket.misses;
|
|
458
|
+
const rate = total > 0 ? bucket.hits / total : 0;
|
|
459
|
+
const pct = (rate * 100).toFixed(1);
|
|
460
|
+
const bar = _barChart(rate);
|
|
461
|
+
const label = mode.charAt(0).toUpperCase() + mode.slice(1);
|
|
462
|
+
lines.push(` ${label.padEnd(10)} ${pct.padStart(5)}% ${bar} (${bucket.hits}/${total} queries)`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Overall
|
|
466
|
+
const overallRate = metrics.overallHitRate();
|
|
467
|
+
const overallPct = (overallRate * 100).toFixed(1);
|
|
468
|
+
const overallBar = _barChart(overallRate);
|
|
469
|
+
const overallHits = ['lexical', 'semantic', 'hybrid'].reduce((s, m) => s + metrics[m].hits, 0);
|
|
470
|
+
lines.push(` ${'Overall'.padEnd(10)} ${overallPct.padStart(5)}% ${overallBar} (${overallHits}/${totalQueries} queries)`);
|
|
471
|
+
|
|
472
|
+
lines.push('');
|
|
473
|
+
|
|
474
|
+
// Latency percentiles
|
|
475
|
+
lines.push('Latency (p50 / p95):');
|
|
476
|
+
for (const mode of ['lexical', 'semantic', 'hybrid']) {
|
|
477
|
+
const p50 = Math.round(metrics.latencyPercentile(mode, 50));
|
|
478
|
+
const p95 = Math.round(metrics.latencyPercentile(mode, 95));
|
|
479
|
+
const label = mode.charAt(0).toUpperCase() + mode.slice(1);
|
|
480
|
+
lines.push(` ${label.padEnd(10)} ${p50}ms / ${p95}ms`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
lines.push('');
|
|
484
|
+
|
|
485
|
+
// Top cache misses (promotion candidates)
|
|
486
|
+
const topMisses = vocabInfo.topMisses || [];
|
|
487
|
+
if (topMisses.length > 0) {
|
|
488
|
+
lines.push(`Top ${Math.min(topMisses.length, 5)} Cache Misses (candidates for next warmup):`);
|
|
489
|
+
for (let i = 0; i < Math.min(topMisses.length, 5); i++) {
|
|
490
|
+
const m = topMisses[i];
|
|
491
|
+
lines.push(` ${i + 1}. "${m.term}" (queried ${m.queryCount}x, not in vocab)`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// F5: Community breakdown with phrases and topEntities
|
|
496
|
+
if (communities.length > 0) {
|
|
497
|
+
lines.push('');
|
|
498
|
+
lines.push('Communities (Leiden):');
|
|
499
|
+
for (let i = 0; i < Math.min(communities.length, 10); i++) {
|
|
500
|
+
const c = communities[i];
|
|
501
|
+
const name = c.name || `community-${c.id ?? i}`;
|
|
502
|
+
const fileCount = c.fileIds?.length || c.size || c.entityCount || 0;
|
|
503
|
+
const phraseList = (c.phrases || []).slice(0, 3).map(p => `"${p}"`).join(', ');
|
|
504
|
+
const detail = phraseList ? ` \u2014 phrases: ${phraseList}` : '';
|
|
505
|
+
lines.push(` ${name}: ${fileCount} files${detail}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return lines.join('\n');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// =============================================================================
|
|
513
|
+
// TELEMETRY ENTRY PARSING HELPERS
|
|
514
|
+
// =============================================================================
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Parse raw JSONL telemetry file content into entry objects.
|
|
518
|
+
* @param {string} content - Raw JSONL text
|
|
519
|
+
* @param {number} [lastN=0] - Limit to last N entries (0 = all)
|
|
520
|
+
* @returns {Array<object>}
|
|
521
|
+
*/
|
|
522
|
+
export function parseTelemetryEntries(content, lastN = 0) {
|
|
523
|
+
const lines = content.split('\n').filter(Boolean);
|
|
524
|
+
const subset = lastN > 0 ? lines.slice(-lastN) : lines;
|
|
525
|
+
const entries = [];
|
|
526
|
+
for (const line of subset) {
|
|
527
|
+
try {
|
|
528
|
+
entries.push(JSON.parse(line));
|
|
529
|
+
} catch { /* skip malformed */ }
|
|
530
|
+
}
|
|
531
|
+
return entries;
|
|
532
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Minimal server-start entry point — avoids the circular import in sweet-search.js.
|
|
3
|
+
// Used by the Rust CLI's auto_start_server() to spawn the background server.
|
|
4
|
+
|
|
5
|
+
import { startServer } from './search/search-server.js';
|
|
6
|
+
await startServer();
|