Skip to main content

lean_ctx/tools/
ctx_semantic_search.rs

1use std::collections::HashSet;
2use std::fmt::Write;
3use std::path::Path;
4
5use crate::core::bm25_index::{BM25Index, format_search_results};
6use crate::core::embedding_index::EmbeddingIndex;
7#[cfg(feature = "embeddings")]
8use crate::core::embeddings::EmbeddingEngine;
9use crate::core::hnsw::FlatEmbeddings;
10use crate::core::hybrid_search::{HybridConfig, HybridResult, format_hybrid_results};
11use crate::tools::CrpMode;
12
13/// Performs semantic code search using BM25, dense embeddings, or hybrid ranking.
14#[allow(clippy::too_many_arguments)]
15pub fn handle(
16    query: &str,
17    path: &str,
18    top_k: usize,
19    crp_mode: CrpMode,
20    languages: Option<&[String]>,
21    path_glob: Option<&str>,
22    mode: Option<&str>,
23    workspace: Option<bool>,
24    artifacts: Option<bool>,
25) -> String {
26    let root = Path::new(path);
27    if !root.exists() {
28        return format!("ERR: path does not exist: {path}");
29    }
30
31    let root = if root.is_file() {
32        root.parent().unwrap_or(root)
33    } else {
34        root
35    };
36
37    // Query-conditioned IB (#542): remember the latest search query as a
38    // fallback relevance signal for subsequent compressed reads.
39    if !query.trim().is_empty()
40        && let Some(mut session) = crate::core::session::SessionState::load_latest()
41        && session.last_semantic_query.as_deref() != Some(query)
42    {
43        session.last_semantic_query = Some(query.to_string());
44        let _ = session.save();
45    }
46
47    let filter = match SearchFilter::new(languages, path_glob) {
48        Ok(f) => f,
49        Err(e) => return format!("ERR: invalid filter: {e}"),
50    };
51
52    let compact = crp_mode.is_tdd();
53    let mode = mode.unwrap_or("bm25").to_lowercase();
54    let workspace = workspace.unwrap_or(false);
55    let artifacts = artifacts.unwrap_or(false);
56
57    if artifacts {
58        return artifacts_search(query, root, top_k, compact, &filter, workspace);
59    }
60    if workspace {
61        return workspace_search(query, root, top_k, compact, &filter, &mode);
62    }
63
64    let index = match load_or_refresh_bm25(root) {
65        Bm25LoadResult::Ready(idx) => idx,
66        Bm25LoadResult::Building => {
67            return "BM25 index is being built in the background. \
68                    Run ctx_semantic_search again in ~30s, or use action=reindex to wait for completion."
69                .to_string();
70        }
71    };
72    if index.doc_count == 0 {
73        return "No code files found to index.".to_string();
74    }
75
76    match mode.as_str() {
77        "bm25" => {
78            let mut results = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
79            if filter.is_active() {
80                results.retain(|x| filter.matches(&x.file_path));
81            }
82            results.truncate(top_k);
83
84            let header = if compact {
85                format!(
86                    "semantic_search(bm25,{top_k}) → {} results, {} chunks indexed\n",
87                    results.len(),
88                    index.doc_count
89                )
90            } else {
91                format!(
92                    "Semantic search (BM25): \"{}\" ({} results from {} indexed chunks)\n",
93                    truncate_query(query, 60),
94                    results.len(),
95                    index.doc_count,
96                )
97            };
98            format!("{header}{}", format_search_results(&results, compact))
99        }
100        "dense" => {
101            let out = dense_search_mode(query, root, &index, top_k, compact, &filter);
102            shrink_resident_after_embedding(root, index);
103            out
104        }
105        _ => {
106            let out = hybrid_search_mode(query, root, &index, top_k, compact, &filter);
107            shrink_resident_after_embedding(root, index);
108            out
109        }
110    }
111}
112
113/// Reclaim the RAM held by full chunk bodies in the resident BM25 cache once the
114/// dense/hybrid embedding pass has consumed and persisted them. Drops this
115/// handler's `Arc` clone first so the cache becomes the sole owner and the trim
116/// is zero-copy (see `bm25_cache::shrink_resident_to_snippet`).
117///
118/// `keep_lines = 5` matches the snippet window used everywhere results are
119/// rendered (`bm25_index::search`, `dense_backend`, `hybrid_search`). Only fires
120/// when embeddings are actually built (feature-gated); a BM25-only fallback build
121/// must keep full bodies for a later real embedding pass.
122fn shrink_resident_after_embedding(root: &Path, index: std::sync::Arc<BM25Index>) {
123    #[cfg(feature = "embeddings")]
124    {
125        // Release our clone so the cache is the sole Arc owner; otherwise the
126        // in-place trim is skipped and retried on the next search.
127        drop(index);
128        if let Some(cache) = get_thread_cache() {
129            let freed = crate::core::bm25_cache::shrink_resident_to_snippet(&cache, root, 5);
130            if freed > 0 {
131                tracing::info!(
132                    "[bm25_cache] reclaimed ~{:.1}MB of resident chunk bodies post-embedding",
133                    freed as f64 / 1_048_576.0
134                );
135            }
136        }
137    }
138    #[cfg(not(feature = "embeddings"))]
139    {
140        let _ = (root, index);
141    }
142}
143
144/// Structured single-root search used by the `semantic-search` CLI (`--json`)
145/// and any programmatic caller (editor extensions). Mirrors `handle`'s
146/// single-root logic but returns the ranked [`HybridResult`]s instead of a
147/// formatted report, so callers control their own serialization. Reuses the
148/// exact same hybrid/dense/BM25 ranking as the `ctx_semantic_search` MCP tool —
149/// no second code path to drift.
150pub fn search_hits(
151    query: &str,
152    path: &str,
153    top_k: usize,
154    mode: &str,
155    languages: Option<&[String]>,
156    path_glob: Option<&str>,
157) -> Result<Vec<HybridResult>, String> {
158    let root = Path::new(path);
159    if !root.exists() {
160        return Err(format!("path does not exist: {path}"));
161    }
162    let root = if root.is_file() {
163        root.parent().unwrap_or(root)
164    } else {
165        root
166    };
167
168    let filter =
169        SearchFilter::new(languages, path_glob).map_err(|e| format!("invalid filter: {e}"))?;
170
171    let index = BM25Index::load_or_build(root);
172    if index.doc_count == 0 {
173        return Ok(Vec::new());
174    }
175
176    let results = match mode.to_lowercase().as_str() {
177        "bm25" => bm25_hits(&index, query, top_k, &filter),
178        "dense" => {
179            #[cfg(feature = "embeddings")]
180            {
181                dense_results_for_root(query, root, &index, top_k, &filter).map(|(v, _)| v)?
182            }
183            #[cfg(not(feature = "embeddings"))]
184            {
185                return Err("dense mode requires the embeddings feature".to_string());
186            }
187        }
188        _ => {
189            #[cfg(feature = "embeddings")]
190            {
191                hybrid_results_for_root(query, root, &index, top_k, &filter).map(|(v, _)| v)?
192            }
193            #[cfg(not(feature = "embeddings"))]
194            {
195                bm25_hits(&index, query, top_k, &filter)
196            }
197        }
198    };
199
200    Ok(results)
201}
202
203fn bm25_hits(
204    index: &BM25Index,
205    query: &str,
206    top_k: usize,
207    filter: &SearchFilter,
208) -> Vec<HybridResult> {
209    let mut results = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
210    if filter.is_active() {
211        results.retain(|x| filter.matches(&x.file_path));
212    }
213    results.truncate(top_k);
214    results
215        .into_iter()
216        .map(HybridResult::from_bm25_public)
217        .collect()
218}
219
220/// Rebuilds the BM25 search index for the given directory from scratch.
221#[must_use]
222pub fn handle_reindex(path: &str) -> String {
223    let root = Path::new(path);
224    if !root.exists() {
225        return format!("ERR: path does not exist: {path}");
226    }
227    let root = if root.is_file() {
228        root.parent().unwrap_or(root)
229    } else {
230        root
231    };
232
233    let idx = BM25Index::build_from_directory(root);
234    let files = idx.files.len();
235    let chunks = idx.doc_count;
236    let _ = idx.save(root);
237
238    format!("Reindexed {path}: {files} files, {chunks} chunks")
239}
240
241#[must_use]
242pub fn handle_reindex_artifacts(path: &str, workspace: bool) -> String {
243    let root = Path::new(path);
244    if !root.exists() {
245        return format!("ERR: path does not exist: {path}");
246    }
247    let root = if root.is_file() {
248        root.parent().unwrap_or(root)
249    } else {
250        root
251    };
252
253    let mut roots: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
254    let mut warnings: Vec<String> = Vec::new();
255
256    if workspace {
257        let linked = crate::core::workspace_config::load_linked_projects(root);
258        warnings.extend(linked.warnings);
259        roots.extend(linked.roots);
260    }
261
262    let mut total_files = 0usize;
263    let mut total_chunks = 0usize;
264    for r in roots {
265        let (idx, w) = crate::core::artifact_index::rebuild_from_scratch(&r);
266        warnings.extend(w);
267        total_files += idx.files.len();
268        total_chunks += idx.doc_count;
269    }
270
271    if warnings.is_empty() {
272        format!("Reindexed artifacts: {total_files} files, {total_chunks} chunks")
273    } else {
274        format!(
275            "Reindexed artifacts: {total_files} files, {total_chunks} chunks ({} warning(s))",
276            warnings.len()
277        )
278    }
279}
280
281/// Find chunks semantically related to a given file location.
282///
283/// Marchionini (2006): Exploratory search navigates from known points.
284/// This enables "show me similar code" workflows.
285pub fn handle_find_related(
286    file_path: &str,
287    line: usize,
288    project_root: &str,
289    top_k: usize,
290    crp_mode: CrpMode,
291) -> String {
292    let root = Path::new(project_root);
293    if !root.exists() {
294        return format!("ERR: path does not exist: {project_root}");
295    }
296
297    let index = BM25Index::load_or_build(root);
298    if index.doc_count == 0 {
299        return "ERR: empty index. Try action=reindex first.".to_string();
300    }
301
302    let source_chunk = index
303        .chunks
304        .iter()
305        .find(|c| c.file_path == file_path && c.start_line <= line && c.end_line >= line);
306
307    let Some(source_chunk) = source_chunk else {
308        return format!(
309            "ERR: no indexed chunk found at {file_path}:{line}. Try action=reindex first."
310        );
311    };
312
313    let query_text = source_chunk.content.clone();
314    let source_file = source_chunk.file_path.clone();
315    let source_start = source_chunk.start_line;
316
317    let compact = crp_mode != CrpMode::Off;
318
319    let results = find_related_internal(&query_text, root, &index, top_k + 5, compact);
320
321    let mut lines: Vec<String> = results
322        .into_iter()
323        .filter(|l| !l.contains(&format!("{source_file}:{source_start}-")))
324        .take(top_k)
325        .collect();
326
327    let header = if compact {
328        format!(
329            "find_related({file_path}:{line}) → {} results\n",
330            lines.len()
331        )
332    } else {
333        format!("Find related to {file_path}:{line} (semantic similarity)\n")
334    };
335
336    lines.insert(0, header);
337    lines.join("")
338}
339
340fn find_related_internal(
341    query: &str,
342    root: &Path,
343    index: &BM25Index,
344    top_k: usize,
345    compact: bool,
346) -> Vec<String> {
347    let Ok(filter) = SearchFilter::new(None, None) else {
348        return vec!["ERR: filter init failed\n".to_string()];
349    };
350    let output = hybrid_search_mode(query, root, index, top_k, compact, &filter);
351    output.lines().map(|l| format!("{l}\n")).collect()
352}
353
354fn truncate_query(q: &str, max: usize) -> &str {
355    if q.len() <= max {
356        return q;
357    }
358    match q.char_indices().nth(max) {
359        Some((byte_idx, _)) => &q[..byte_idx],
360        None => q,
361    }
362}
363
364std::thread_local! {
365    static BM25_SHARED_CACHE: std::cell::RefCell<Option<crate::core::bm25_cache::SharedBm25Cache>> =
366        const { std::cell::RefCell::new(None) };
367}
368
369/// Set the shared BM25 cache for the current thread (called from the registered handler).
370pub fn set_thread_cache(cache: crate::core::bm25_cache::SharedBm25Cache) {
371    BM25_SHARED_CACHE.with(|c| {
372        *c.borrow_mut() = Some(cache);
373    });
374}
375
376/// Clone the current thread's shared BM25 cache, if any. Lets composer tools
377/// propagate the resident cache into a budgeted worker thread so a slow cold
378/// build warms the *same* cache instead of being wasted work.
379pub fn get_thread_cache() -> Option<crate::core::bm25_cache::SharedBm25Cache> {
380    BM25_SHARED_CACHE.with(|c| c.borrow().clone())
381}
382
383/// Result of BM25 index loading — may indicate background build in progress.
384pub(crate) enum Bm25LoadResult {
385    Ready(std::sync::Arc<BM25Index>),
386    Building,
387}
388
389fn load_or_refresh_bm25(root: &Path) -> Bm25LoadResult {
390    let cached = BM25_SHARED_CACHE.with(|c| {
391        let borrow = c.borrow();
392        borrow
393            .as_ref()
394            .and_then(|cache| crate::core::bm25_cache::get_or_background(cache, root))
395    });
396    if let Some(idx) = cached {
397        return Bm25LoadResult::Ready(idx);
398    }
399
400    let root_str = root.to_string_lossy().to_string();
401
402    if let Some(idx) = crate::core::index_orchestrator::try_load_bm25_index(&root_str) {
403        let idx = std::sync::Arc::new(idx);
404        store_in_thread_cache(root, &idx);
405        return Bm25LoadResult::Ready(idx);
406    }
407
408    if crate::core::index_orchestrator::is_building() {
409        return Bm25LoadResult::Building;
410    }
411
412    // Cold path: kick off the background build (which persists the index to
413    // disk) instead of doing an unbounded synchronous build in the MCP handler.
414    // Wait briefly so small/medium repos still return Ready on the first call;
415    // larger repos return Building and the agent retries against the warm cache
416    // once the worker has persisted the index (#150).
417    crate::core::index_orchestrator::ensure_all_background(&root_str);
418
419    let deadline = std::time::Instant::now() + bm25_cold_build_budget();
420    loop {
421        if let Some(idx) = crate::core::index_orchestrator::try_load_bm25_index(&root_str) {
422            let idx = std::sync::Arc::new(idx);
423            store_in_thread_cache(root, &idx);
424            return Bm25LoadResult::Ready(idx);
425        }
426        if std::time::Instant::now() >= deadline {
427            return Bm25LoadResult::Building;
428        }
429        std::thread::sleep(std::time::Duration::from_millis(50));
430    }
431}
432
433/// Time budget for waiting on a cold BM25 build in the MCP handler before
434/// returning `Building`. Overridable via `LEAN_CTX_BM25_COLD_BUDGET_MS`.
435fn bm25_cold_build_budget() -> std::time::Duration {
436    let ms = std::env::var("LEAN_CTX_BM25_COLD_BUDGET_MS")
437        .ok()
438        .and_then(|v| v.parse::<u64>().ok())
439        .unwrap_or(60_000);
440    std::time::Duration::from_millis(ms)
441}
442
443fn store_in_thread_cache(root: &Path, idx: &std::sync::Arc<BM25Index>) {
444    BM25_SHARED_CACHE.with(|c| {
445        let borrow = c.borrow();
446        if let Some(cache) = borrow.as_ref() {
447            let mut guard = cache
448                .lock()
449                .unwrap_or_else(std::sync::PoisonError::into_inner);
450            *guard = Some(crate::core::bm25_cache::Bm25CacheEntry {
451                root: root.to_path_buf(),
452                index: std::sync::Arc::clone(idx),
453                loaded_at: std::time::Instant::now(),
454                fingerprint: crate::core::bm25_cache::index_fingerprint(root),
455            });
456        }
457    });
458}
459
460fn filtered_candidate_k(top_k: usize, filtered: bool) -> usize {
461    if !filtered {
462        return top_k;
463    }
464    let candidates = (top_k.max(10)).saturating_mul(10);
465    candidates.clamp(50, 500)
466}
467
468const WORKSPACE_RRF_K: f64 = 60.0;
469
470fn artifacts_search(
471    query: &str,
472    root: &Path,
473    top_k: usize,
474    compact: bool,
475    filter: &SearchFilter,
476    workspace: bool,
477) -> String {
478    let mut roots: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
479    let mut warnings: Vec<String> = Vec::new();
480
481    if workspace {
482        let linked = crate::core::workspace_config::load_linked_projects(root);
483        warnings.extend(linked.warnings);
484        roots.extend(linked.roots);
485    }
486    roots.sort();
487    roots.dedup();
488
489    let mut per_project: Vec<(String, Vec<crate::core::bm25_index::SearchResult>)> = Vec::new();
490    let mut total_chunks = 0usize;
491
492    for r in &roots {
493        let label = label_for_root(r);
494        let (idx, w) = crate::core::artifact_index::load_or_build(r);
495        warnings.extend(w);
496        total_chunks += idx.doc_count;
497        if idx.doc_count == 0 {
498            continue;
499        }
500
501        let mut results = idx.search(query, filtered_candidate_k(top_k, filter.is_active()));
502        if filter.is_active() {
503            results.retain(|x| filter.matches(&x.file_path));
504        }
505        results.truncate(top_k);
506
507        for res in &mut results {
508            res.file_path = if workspace {
509                format!("[project:{label}] [artifact] {}", res.file_path)
510            } else {
511                format!("[artifact] {}", res.file_path)
512            };
513        }
514
515        per_project.push((label, results));
516    }
517
518    let mut fused: Vec<crate::core::bm25_index::SearchResult> = if per_project.len() <= 1 {
519        per_project
520            .into_iter()
521            .next()
522            .map(|(_, v)| v)
523            .unwrap_or_default()
524    } else {
525        rrf_merge_bm25(per_project, top_k)
526    };
527
528    if fused.is_empty() {
529        return "No artifact files found to index.".to_string();
530    }
531
532    fused.truncate(top_k);
533
534    let header = if compact {
535        if workspace {
536            format!(
537                "semantic_search(artifacts,workspace,{top_k}) → {} results, projects={}, {} chunks indexed\n",
538                fused.len(),
539                roots.len(),
540                total_chunks
541            )
542        } else {
543            format!(
544                "semantic_search(artifacts,{top_k}) → {} results, {} chunks indexed\n",
545                fused.len(),
546                total_chunks
547            )
548        }
549    } else if workspace {
550        format!(
551            "Semantic search (Artifacts/Workspace): \"{}\" ({} results from {} projects)\n",
552            truncate_query(query, 60),
553            fused.len(),
554            roots.len()
555        )
556    } else {
557        format!(
558            "Semantic search (Artifacts): \"{}\" ({} results)\n",
559            truncate_query(query, 60),
560            fused.len()
561        )
562    };
563
564    let mut out = format!("{header}{}", format_search_results(&fused, compact));
565    if !warnings.is_empty() && !compact {
566        let _ = writeln!(out, "\nWarnings ({}):", warnings.len());
567        for w in warnings.iter().take(20) {
568            let _ = writeln!(out, "- {w}");
569        }
570    }
571    out
572}
573
574fn workspace_search(
575    query: &str,
576    root: &Path,
577    top_k: usize,
578    compact: bool,
579    filter: &SearchFilter,
580    mode: &str,
581) -> String {
582    let linked = crate::core::workspace_config::load_linked_projects(root);
583    let mut warnings = linked.warnings;
584
585    let mut roots: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
586    roots.extend(linked.roots);
587    roots.sort();
588    roots.dedup();
589
590    let mut per_project: Vec<(String, Vec<HybridResult>)> = Vec::new();
591    let mut avg_cov: Option<f64> = None;
592    let mut cov_count = 0usize;
593
594    for r in &roots {
595        let label = label_for_root(r);
596        let index = BM25Index::load_or_build(r);
597        if index.doc_count == 0 {
598            continue;
599        }
600
601        let mut results: Vec<HybridResult> = match mode {
602            "bm25" => {
603                let mut bm25 = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
604                if filter.is_active() {
605                    bm25.retain(|x| filter.matches(&x.file_path));
606                }
607                bm25.truncate(top_k);
608                bm25.into_iter()
609                    .map(HybridResult::from_bm25_public)
610                    .collect()
611            }
612            "dense" => {
613                #[cfg(feature = "embeddings")]
614                {
615                    match dense_results_for_root(query, r, &index, top_k, filter) {
616                        Ok((v, cov)) => {
617                            avg_cov = Some(avg_cov.unwrap_or(0.0) + cov);
618                            cov_count += 1;
619                            v
620                        }
621                        Err(e) => {
622                            warnings.push(format!("[{label}] dense search failed: {e}"));
623                            let mut bm25 = index
624                                .search(query, filtered_candidate_k(top_k, filter.is_active()));
625                            if filter.is_active() {
626                                bm25.retain(|x| filter.matches(&x.file_path));
627                            }
628                            bm25.truncate(top_k);
629                            bm25.into_iter()
630                                .map(HybridResult::from_bm25_public)
631                                .collect()
632                        }
633                    }
634                }
635                #[cfg(not(feature = "embeddings"))]
636                {
637                    let _ = (&label, &warnings);
638                    let mut bm25 =
639                        index.search(query, filtered_candidate_k(top_k, filter.is_active()));
640                    if filter.is_active() {
641                        bm25.retain(|x| filter.matches(&x.file_path));
642                    }
643                    bm25.truncate(top_k);
644                    bm25.into_iter()
645                        .map(HybridResult::from_bm25_public)
646                        .collect()
647                }
648            }
649            _ => {
650                #[cfg(feature = "embeddings")]
651                {
652                    match hybrid_results_for_root(query, r, &index, top_k, filter) {
653                        Ok((v, cov)) => {
654                            avg_cov = Some(avg_cov.unwrap_or(0.0) + cov);
655                            cov_count += 1;
656                            v
657                        }
658                        Err(e) => {
659                            warnings.push(format!("[{label}] hybrid search failed: {e}"));
660                            let mut bm25 = index
661                                .search(query, filtered_candidate_k(top_k, filter.is_active()));
662                            if filter.is_active() {
663                                bm25.retain(|x| filter.matches(&x.file_path));
664                            }
665                            bm25.truncate(top_k);
666                            bm25.into_iter()
667                                .map(HybridResult::from_bm25_public)
668                                .collect()
669                        }
670                    }
671                }
672                #[cfg(not(feature = "embeddings"))]
673                {
674                    let _ = (&label, &warnings);
675                    let mut bm25 =
676                        index.search(query, filtered_candidate_k(top_k, filter.is_active()));
677                    if filter.is_active() {
678                        bm25.retain(|x| filter.matches(&x.file_path));
679                    }
680                    bm25.truncate(top_k);
681                    bm25.into_iter()
682                        .map(HybridResult::from_bm25_public)
683                        .collect()
684                }
685            }
686        };
687
688        for res in &mut results {
689            res.file_path = format!("[project:{label}] {}", res.file_path);
690        }
691        per_project.push((label, results));
692    }
693
694    let mut fused: Vec<HybridResult> = if per_project.len() <= 1 {
695        per_project
696            .into_iter()
697            .next()
698            .map(|(_, v)| v)
699            .unwrap_or_default()
700    } else {
701        rrf_merge_hybrid(per_project, top_k)
702    };
703
704    if fused.is_empty() {
705        return "No code files found to index.".to_string();
706    }
707
708    fused.truncate(top_k);
709    let cov = avg_cov.and_then(|s| {
710        if cov_count == 0 {
711            None
712        } else {
713            Some(s / cov_count as f64)
714        }
715    });
716
717    let header = if compact {
718        match (mode, cov) {
719            (_, Some(c)) => format!(
720                "semantic_search(workspace,{mode},{top_k}) → {} results, projects={}, embed_cov={:.0}%\n",
721                fused.len(),
722                roots.len(),
723                c * 100.0
724            ),
725            _ => format!(
726                "semantic_search(workspace,{mode},{top_k}) → {} results, projects={}\n",
727                fused.len(),
728                roots.len()
729            ),
730        }
731    } else {
732        format!(
733            "Workspace semantic search ({mode}): \"{}\" ({} results from {} projects)\n",
734            truncate_query(query, 60),
735            fused.len(),
736            roots.len()
737        )
738    };
739
740    let mut out = format!("{header}{}", format_hybrid_results(&fused, compact));
741    if !warnings.is_empty() && !compact {
742        out.push_str(&format!("\nWarnings ({}):\n", warnings.len()));
743        for w in warnings.iter().take(20) {
744            out.push_str(&format!("- {w}\n"));
745        }
746    }
747    out
748}
749
750fn rrf_merge_hybrid(lists: Vec<(String, Vec<HybridResult>)>, top_k: usize) -> Vec<HybridResult> {
751    use std::collections::HashMap;
752
753    let mut acc: HashMap<String, (HybridResult, f64)> = HashMap::new();
754    for (label, results) in lists {
755        for (rank, r) in results.into_iter().enumerate() {
756            let key = format!(
757                "{label}|{}|{}|{}|{}",
758                r.file_path, r.symbol_name, r.start_line, r.end_line
759            );
760            let rrf = 1.0 / (WORKSPACE_RRF_K + (rank as f64) + 1.0);
761            acc.entry(key)
762                .and_modify(|(_, s)| *s += rrf)
763                .or_insert((r, rrf));
764        }
765    }
766
767    let mut out: Vec<HybridResult> = acc
768        .into_values()
769        .map(|(mut r, s)| {
770            r.rrf_score = s;
771            r
772        })
773        .collect();
774    out.sort_by(|a, b| {
775        b.rrf_score
776            .partial_cmp(&a.rrf_score)
777            .unwrap_or(std::cmp::Ordering::Equal)
778            .then_with(|| a.file_path.cmp(&b.file_path))
779            .then_with(|| a.symbol_name.cmp(&b.symbol_name))
780            .then_with(|| a.start_line.cmp(&b.start_line))
781            .then_with(|| a.end_line.cmp(&b.end_line))
782    });
783    out.truncate(top_k);
784    out
785}
786
787fn rrf_merge_bm25(
788    lists: Vec<(String, Vec<crate::core::bm25_index::SearchResult>)>,
789    top_k: usize,
790) -> Vec<crate::core::bm25_index::SearchResult> {
791    use std::collections::HashMap;
792
793    let mut acc: HashMap<String, (crate::core::bm25_index::SearchResult, f64)> = HashMap::new();
794    for (label, results) in lists {
795        for (rank, r) in results.into_iter().enumerate() {
796            let key = format!(
797                "{label}|{}|{}|{}|{}",
798                r.file_path, r.symbol_name, r.start_line, r.end_line
799            );
800            let rrf = 1.0 / (WORKSPACE_RRF_K + (rank as f64) + 1.0);
801            acc.entry(key)
802                .and_modify(|(_, s)| *s += rrf)
803                .or_insert((r, rrf));
804        }
805    }
806
807    let mut out: Vec<crate::core::bm25_index::SearchResult> = acc
808        .into_values()
809        .map(|(mut r, s)| {
810            r.score = s;
811            r
812        })
813        .collect();
814    out.sort_by(|a, b| {
815        b.score
816            .partial_cmp(&a.score)
817            .unwrap_or(std::cmp::Ordering::Equal)
818            .then_with(|| a.file_path.cmp(&b.file_path))
819            .then_with(|| a.symbol_name.cmp(&b.symbol_name))
820            .then_with(|| a.start_line.cmp(&b.start_line))
821            .then_with(|| a.end_line.cmp(&b.end_line))
822    });
823    out.truncate(top_k);
824    out
825}
826
827#[cfg(feature = "embeddings")]
828fn dense_results_for_root(
829    query: &str,
830    root: &Path,
831    index: &BM25Index,
832    top_k: usize,
833    filter: &SearchFilter,
834) -> Result<(Vec<HybridResult>, f64), String> {
835    let (engine, mut embed_idx) = load_engine_and_index(root)?;
836    // #512: cold-start guard for the CLI/editor (`search_hits`) path — the twin of
837    // the MCP `dense_search_mode` guard. Explicit dense fails fast on a cold index
838    // rather than embed the whole corpus inline under the request.
839    if let Some(pending) = cold_start_embed_guard(&embed_idx, index) {
840        return Err(dense_build_hint(pending, true));
841    }
842    let (aligned, coverage, changed_files) =
843        ensure_embeddings(root, index, engine, &mut embed_idx)?;
844
845    let backend = crate::core::dense_backend::DenseBackendKind::try_from_env()?;
846    let filter_fn = |p: &str| filter.matches(p);
847    let filter_pred: Option<&dyn Fn(&str) -> bool> = filter
848        .is_active()
849        .then_some(&filter_fn as &dyn Fn(&str) -> bool);
850
851    let candidate_k = filtered_candidate_k(top_k, filter.is_active());
852    let mut results = crate::core::dense_backend::dense_results_as_hybrid(
853        backend,
854        root,
855        index,
856        engine,
857        &aligned,
858        &changed_files,
859        query,
860        candidate_k,
861        filter_pred,
862    )?;
863    results.truncate(top_k);
864
865    Ok((results, coverage))
866}
867
868#[cfg(feature = "embeddings")]
869fn hybrid_results_for_root(
870    query: &str,
871    root: &Path,
872    index: &BM25Index,
873    top_k: usize,
874    filter: &SearchFilter,
875) -> Result<(Vec<HybridResult>, f64), String> {
876    let (engine, mut embed_idx) = load_engine_and_index(root)?;
877    // #512: cold-start guard for the CLI/editor (`search_hits`) path — the twin of
878    // the MCP `hybrid_search_mode` guard. Degrade to BM25 on a cold index rather
879    // than embed the whole corpus inline under the request.
880    if let Some(pending) = cold_start_embed_guard(&embed_idx, index) {
881        tracing::info!(
882            pending,
883            "hybrid cold-start guard: dense index not built — degrading to BM25 \
884             (build once: lean-ctx index build-semantic)"
885        );
886        return Ok((bm25_hits(index, query, top_k, filter), 0.0));
887    }
888    let (aligned, coverage, changed_files) =
889        ensure_embeddings(root, index, engine, &mut embed_idx)?;
890
891    let backend = crate::core::dense_backend::DenseBackendKind::try_from_env()?;
892    let cfg = HybridConfig::from_config();
893    let filter_fn = |p: &str| filter.matches(p);
894    let filter_pred: Option<&dyn Fn(&str) -> bool> = filter
895        .is_active()
896        .then_some(&filter_fn as &dyn Fn(&str) -> bool);
897    let candidate_k = filtered_candidate_k(top_k, filter.is_active());
898    let graph_ranks = graph_rrf_ranks_for_search_root(root);
899    let graph_ranks_ref = graph_ranks.as_ref();
900    let mut results = crate::core::dense_backend::hybrid_results(
901        backend,
902        root,
903        index,
904        engine,
905        &aligned,
906        &changed_files,
907        query,
908        candidate_k,
909        &cfg,
910        filter_pred,
911        graph_ranks_ref,
912    )?;
913
914    if cfg.splade_weight > 0.0 {
915        let splade = crate::core::splade_retrieval::hybrid_retrieve(query, index, candidate_k);
916        if !splade.is_empty() {
917            boost_with_splade(&mut results, &splade, cfg.splade_weight);
918        }
919    }
920
921    results.truncate(top_k);
922    Ok((results, coverage))
923}
924
925/// Boost existing hybrid results with SPLADE expansion scores.
926fn boost_with_splade(
927    results: &mut [HybridResult],
928    splade: &[crate::core::splade_retrieval::SpladeResult],
929    weight: f64,
930) {
931    use std::collections::HashMap;
932    let rrf_k = 60.0_f64;
933
934    let boosts: HashMap<&str, f64> = splade
935        .iter()
936        .enumerate()
937        .map(|(rank, sr)| (sr.file_path.as_str(), weight / (rrf_k + rank as f64 + 1.0)))
938        .collect();
939
940    for r in results.iter_mut() {
941        if let Some(&boost) = boosts.get(r.file_path.as_str()) {
942            r.rrf_score += boost;
943        }
944    }
945
946    results.sort_by(|a, b| {
947        b.rrf_score
948            .partial_cmp(&a.rrf_score)
949            .unwrap_or(std::cmp::Ordering::Equal)
950    });
951}
952
953fn label_for_root(root: &Path) -> String {
954    root.file_name()
955        .and_then(|s| s.to_str())
956        .map(str::to_string)
957        .filter(|s| !s.is_empty())
958        .unwrap_or_else(|| root.to_string_lossy().to_string())
959}
960
961fn graph_rrf_ranks_for_search_root(
962    root: &Path,
963) -> Option<std::collections::HashMap<String, usize>> {
964    let root_s = root.to_string_lossy().to_string();
965    let session = crate::core::session::SessionState::load_latest_for_project_root(&root_s)?;
966
967    if session.files_touched.is_empty() {
968        return None;
969    }
970
971    let recent: Vec<String> = session
972        .files_touched
973        .iter()
974        .rev()
975        .filter(|f| path_under_search_root(&f.path, root))
976        .take(12)
977        .map(|f| f.path.clone())
978        .collect();
979
980    if recent.is_empty() {
981        return None;
982    }
983
984    crate::core::graph_context::graph_neighbor_ranks_for_recent_files(&root_s, &recent, 40, 120)
985}
986
987fn path_under_search_root(path: &str, root: &Path) -> bool {
988    let p = std::path::Path::new(path);
989    if p.is_absolute() {
990        let root_norm = crate::core::pathutil::safe_canonicalize_or_self(root);
991        let path_norm = crate::core::pathutil::safe_canonicalize_or_self(p);
992        path_norm.starts_with(&root_norm)
993    } else {
994        true
995    }
996}
997
998/// BM25 + graph + rerank (+ SPLADE) ranking with no dense signal — the body of
999/// `hybrid` semantic search when `search.dense_enabled = false` (#686). Mirrors
1000/// the local dense path (`dense_backend::hybrid_results` + the SPLADE boost in
1001/// `hybrid_search_mode`) step for step, but feeds `hybrid_search` a `None`
1002/// engine/embeddings pair, which is the same input the pipeline already handles
1003/// as its embeddings-absent fallback. Net effect: no `embeddings.json`, no embed
1004/// latency, identical fusion/rerank/SPLADE stages.
1005#[cfg(feature = "embeddings")]
1006fn bm25_graph_search(
1007    query: &str,
1008    root: &Path,
1009    index: &BM25Index,
1010    top_k: usize,
1011    compact: bool,
1012    filter: &SearchFilter,
1013    cfg: &HybridConfig,
1014) -> String {
1015    let graph_ranks = graph_rrf_ranks_for_search_root(root);
1016    let graph_enhances = graph_ranks.as_ref().is_some_and(|m| !m.is_empty());
1017
1018    let mut results = crate::core::hybrid_search::hybrid_search(
1019        query,
1020        index,
1021        None,
1022        None,
1023        top_k,
1024        cfg,
1025        graph_ranks.as_ref(),
1026    );
1027    if filter.is_active() {
1028        results.retain(|r| filter.matches(&r.file_path));
1029    }
1030    results.truncate(top_k);
1031
1032    if cfg.splade_weight > 0.0 {
1033        let splade = crate::core::splade_retrieval::hybrid_retrieve(query, index, top_k);
1034        if !splade.is_empty() {
1035            boost_with_splade(&mut results, &splade, cfg.splade_weight);
1036        }
1037    }
1038    results.truncate(top_k);
1039
1040    let graph_tag = if graph_enhances { "+graph" } else { "" };
1041    let header = if compact {
1042        format!(
1043            "semantic_search(bm25{graph_tag},{top_k}) → {} results, {} chunks indexed\n",
1044            results.len(),
1045            index.doc_count
1046        )
1047    } else {
1048        format!(
1049            "Semantic search (BM25{graph_tag}): \"{}\" ({} results from {} indexed chunks)\n",
1050            truncate_query(query, 60),
1051            results.len(),
1052            index.doc_count,
1053        )
1054    };
1055    format!("{header}{}", format_hybrid_results(&results, compact))
1056}
1057
1058/// #512: max chunks the hybrid/dense path will embed *inline* (under the
1059/// per-request watchdog) before degrading instead of embedding. A server that
1060/// started before the on-disk dense index existed would otherwise embed the
1061/// whole corpus on the first query — observed as a runaway 500%+ CPU child the
1062/// 120s watchdog abandons but cannot cancel. Tunable via
1063/// `LEAN_CTX_HYBRID_INLINE_EMBED_MAX`; `0` disables the guard (always embed
1064/// inline — the pre-#512 behavior).
1065#[cfg(feature = "embeddings")]
1066fn inline_embed_max_chunks() -> usize {
1067    const DEFAULT_MAX: usize = 2000;
1068    std::env::var("LEAN_CTX_HYBRID_INLINE_EMBED_MAX")
1069        .ok()
1070        .and_then(|v| v.trim().parse::<usize>().ok())
1071        .unwrap_or(DEFAULT_MAX)
1072}
1073
1074/// Pure budget check for the cold-start guard (#512): `max == 0` disables it,
1075/// and the budget is inclusive (`pending == max` still embeds inline).
1076#[cfg(feature = "embeddings")]
1077fn exceeds_inline_embed_budget(pending: usize, max: usize) -> bool {
1078    max > 0 && pending > max
1079}
1080
1081/// Decide whether this call would trigger a large inline embed the watchdog
1082/// cannot safely bound (#512). Returns the pending-chunk count when the call
1083/// should degrade instead of embedding inline; `None` keeps the normal path
1084/// (warm index, or an incremental embed of only a few changed chunks).
1085#[cfg(feature = "embeddings")]
1086fn cold_start_embed_guard(embed_idx: &EmbeddingIndex, index: &BM25Index) -> Option<usize> {
1087    let pending = embed_idx.pending_chunk_count(&index.chunks);
1088    exceeds_inline_embed_budget(pending, inline_embed_max_chunks()).then_some(pending)
1089}
1090
1091/// One-line, deterministic hint pointing at the out-of-band dense build. Shared
1092/// by the hybrid fallback and the dense fail-fast so the guidance never drifts.
1093#[cfg(feature = "embeddings")]
1094fn dense_build_hint(pending: usize, compact: bool) -> String {
1095    if compact {
1096        format!("[dense not built: {pending} chunks pending — run: lean-ctx index build-semantic]")
1097    } else {
1098        format!(
1099            "[lean-ctx: dense index not built ({pending} chunks would embed inline). \
1100             Build it once — no per-query embed, no cold-start hang: \
1101             lean-ctx index build-semantic]"
1102        )
1103    }
1104}
1105
1106fn hybrid_search_mode(
1107    query: &str,
1108    root: &Path,
1109    index: &BM25Index,
1110    top_k: usize,
1111    compact: bool,
1112    filter: &SearchFilter,
1113) -> String {
1114    #[cfg(feature = "embeddings")]
1115    {
1116        let cfg = HybridConfig::from_config();
1117
1118        // Dense disabled (#686): skip the embedding engine + index build/persist
1119        // and rank with BM25 + graph proximity + reranking (+ SPLADE) only — the
1120        // exact fallback the pipeline uses when embeddings are absent, so results
1121        // stay coherent while the vector footprint and embed latency disappear.
1122        if !cfg.dense_enabled {
1123            return bm25_graph_search(query, root, index, top_k, compact, filter, &cfg);
1124        }
1125
1126        let (engine, mut embed_idx) = match load_engine_and_index(root) {
1127            Ok(v) => v,
1128            Err(e) => return format!("ERR: {e}"),
1129        };
1130
1131        // #512: cold-start guard. Never embed a large corpus inline under the
1132        // request watchdog (it produces a runaway the watchdog abandons but
1133        // cannot cancel). Degrade to the BM25+graph path — the same coherent
1134        // fallback used when dense is disabled — and tell the user to build the
1135        // dense index once, out of band. Incremental embeds (few changed chunks
1136        // on a warm index) stay inline and fast.
1137        if let Some(pending) = cold_start_embed_guard(&embed_idx, index) {
1138            let base = bm25_graph_search(query, root, index, top_k, compact, filter, &cfg);
1139            return format!("{base}\n{}", dense_build_hint(pending, compact));
1140        }
1141
1142        let (aligned, coverage, changed_files) =
1143            match ensure_embeddings(root, index, engine, &mut embed_idx) {
1144                Ok(v) => v,
1145                Err(e) => return format!("ERR: {e}"),
1146            };
1147
1148        let backend = match crate::core::dense_backend::DenseBackendKind::try_from_env() {
1149            Ok(v) => v,
1150            Err(e) => return format!("ERR: {e}"),
1151        };
1152        let filter_fn = |p: &str| filter.matches(p);
1153        let filter_pred: Option<&dyn Fn(&str) -> bool> = filter
1154            .is_active()
1155            .then_some(&filter_fn as &dyn Fn(&str) -> bool);
1156        let graph_ranks = graph_rrf_ranks_for_search_root(root);
1157        let graph_ranks_ref = graph_ranks.as_ref();
1158        let mut results = match crate::core::dense_backend::hybrid_results(
1159            backend,
1160            root,
1161            index,
1162            engine,
1163            &aligned,
1164            &changed_files,
1165            query,
1166            top_k,
1167            &cfg,
1168            filter_pred,
1169            graph_ranks_ref,
1170        ) {
1171            Ok(v) => v,
1172            Err(e) => return format!("ERR: {e}"),
1173        };
1174
1175        if cfg.splade_weight > 0.0 {
1176            let splade = crate::core::splade_retrieval::hybrid_retrieve(query, index, top_k);
1177            if !splade.is_empty() {
1178                boost_with_splade(&mut results, &splade, cfg.splade_weight);
1179            }
1180        }
1181
1182        results.truncate(top_k);
1183
1184        let header = if compact {
1185            format!(
1186                "semantic_search(hybrid,{top_k}) → {} results, {} chunks, embed_cov={:.0}%\n",
1187                results.len(),
1188                index.doc_count,
1189                coverage * 100.0
1190            )
1191        } else {
1192            format!(
1193                "Semantic search (Hybrid): \"{}\" ({} results from {} indexed chunks, embeddings coverage {:.0}%)\n",
1194                truncate_query(query, 60),
1195                results.len(),
1196                index.doc_count,
1197                coverage * 100.0
1198            )
1199        };
1200
1201        format!("{header}{}", format_hybrid_results(&results, compact))
1202    }
1203    #[cfg(not(feature = "embeddings"))]
1204    {
1205        let mut results = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
1206        if filter.is_active() {
1207            results.retain(|x| filter.matches(&x.file_path));
1208        }
1209
1210        let graph_ranks = graph_rrf_ranks_for_search_root(root);
1211        if let Some(ref graph_ranks) = graph_ranks {
1212            const GRAPH_RRF_K: f64 = 60.0;
1213            for r in &mut results {
1214                if let Some(&rank) = graph_ranks.get(&r.file_path) {
1215                    r.score += 1.0 / (GRAPH_RRF_K + rank as f64 + 1.0);
1216                }
1217            }
1218            results.sort_by(|a, b| {
1219                b.score
1220                    .partial_cmp(&a.score)
1221                    .unwrap_or(std::cmp::Ordering::Equal)
1222            });
1223        }
1224
1225        results.truncate(top_k);
1226        let graph_tag = if graph_ranks.is_some() { "+graph" } else { "" };
1227        let header = if compact {
1228            format!(
1229                "semantic_search(bm25{graph_tag},{top_k}) → {} results, {} chunks indexed\n",
1230                results.len(),
1231                index.doc_count
1232            )
1233        } else {
1234            format!(
1235                "Semantic search (BM25{graph_tag}): \"{}\" ({} results from {} indexed chunks)\n",
1236                truncate_query(query, 60),
1237                results.len(),
1238                index.doc_count,
1239            )
1240        };
1241        format!("{header}{}", format_search_results(&results, compact))
1242    }
1243}
1244
1245fn dense_search_mode(
1246    query: &str,
1247    root: &Path,
1248    index: &BM25Index,
1249    top_k: usize,
1250    compact: bool,
1251    filter: &SearchFilter,
1252) -> String {
1253    #[cfg(feature = "embeddings")]
1254    {
1255        let (engine, mut embed_idx) = match load_engine_and_index(root) {
1256            Ok(v) => v,
1257            Err(e) => return format!("ERR: {e}"),
1258        };
1259
1260        // #512: explicit dense has no BM25 fallback to degrade into, so fail fast
1261        // with the same actionable hint rather than embed the whole corpus inline
1262        // under the watchdog (the cold-start runaway). A warm/incremental index
1263        // passes through untouched.
1264        if let Some(pending) = cold_start_embed_guard(&embed_idx, index) {
1265            return dense_build_hint(pending, compact);
1266        }
1267
1268        let (aligned, coverage, changed_files) =
1269            match ensure_embeddings(root, index, engine, &mut embed_idx) {
1270                Ok(v) => v,
1271                Err(e) => return format!("ERR: {e}"),
1272            };
1273
1274        let backend = match crate::core::dense_backend::DenseBackendKind::try_from_env() {
1275            Ok(v) => v,
1276            Err(e) => return format!("ERR: {e}"),
1277        };
1278
1279        let filter_fn = |p: &str| filter.matches(p);
1280        let filter_pred: Option<&dyn Fn(&str) -> bool> = filter
1281            .is_active()
1282            .then_some(&filter_fn as &dyn Fn(&str) -> bool);
1283
1284        let candidate_k = filtered_candidate_k(top_k, filter.is_active());
1285        let mut results = match crate::core::dense_backend::dense_results_as_hybrid(
1286            backend,
1287            root,
1288            index,
1289            engine,
1290            &aligned,
1291            &changed_files,
1292            query,
1293            candidate_k,
1294            filter_pred,
1295        ) {
1296            Ok(v) => v,
1297            Err(e) => return format!("ERR: {e}"),
1298        };
1299        results.truncate(top_k);
1300
1301        let header = if compact {
1302            format!(
1303                "semantic_search(dense,{top_k}) → {} results, {} chunks, embed_cov={:.0}%\n",
1304                results.len(),
1305                index.doc_count,
1306                coverage * 100.0
1307            )
1308        } else {
1309            format!(
1310                "Semantic search (Dense): \"{}\" ({} results from {} indexed chunks, embeddings coverage {:.0}%)\n",
1311                truncate_query(query, 60),
1312                results.len(),
1313                index.doc_count,
1314                coverage * 100.0
1315            )
1316        };
1317
1318        format!("{header}{}", format_hybrid_results(&results, compact))
1319    }
1320    #[cfg(not(feature = "embeddings"))]
1321    {
1322        "ERR: embeddings feature not enabled".to_string()
1323    }
1324}
1325
1326#[cfg(feature = "embeddings")]
1327fn load_engine_and_index(
1328    root: &Path,
1329) -> Result<(&'static EmbeddingEngine, EmbeddingIndex), String> {
1330    let cfg = crate::core::config::Config::load();
1331    let profile = crate::core::config::MemoryProfile::effective(&cfg);
1332    if !profile.embeddings_enabled() {
1333        return Err("embeddings disabled by memory_profile=low".into());
1334    }
1335
1336    let engine = crate::core::embeddings::shared_engine()
1337        .ok_or_else(|| "embedding engine load failed".to_string())?;
1338
1339    let model_name = engine.model_name();
1340    let mut idx = EmbeddingIndex::load(root)
1341        .unwrap_or_else(|| EmbeddingIndex::new_with_model(engine.dimensions(), model_name));
1342
1343    if let Some((stored, current)) = idx.model_mismatch(model_name) {
1344        tracing::warn!(
1345            "[embeddings] model changed: {stored} → {current}. Re-indexing all embeddings."
1346        );
1347        idx = EmbeddingIndex::new_with_model(engine.dimensions(), model_name);
1348    } else if idx.dimension_mismatch(engine.dimensions()) {
1349        tracing::warn!(
1350            "[embeddings] dimension mismatch: index={}d, engine={}d. Re-indexing.",
1351            idx.dimensions,
1352            engine.dimensions()
1353        );
1354        idx = EmbeddingIndex::new_with_model(engine.dimensions(), model_name);
1355    }
1356
1357    if idx.model_id.is_none() {
1358        idx.model_id = Some(model_name.to_string());
1359    }
1360
1361    Ok((engine, idx))
1362}
1363
1364/// Aligned embedding corpus as a single contiguous [`FlatEmbeddings`] allocation,
1365/// plus coverage and the list of files re-embedded this call. The flat row-major
1366/// layout gives sequential memory access during dot-product scoring — one
1367/// dereference instead of the two-level indirection of `Arc<[Vec<f32>]>`.
1368#[cfg(feature = "embeddings")]
1369type AlignedEmbeddings = (FlatEmbeddings, f64, Vec<String>);
1370
1371#[cfg(feature = "embeddings")]
1372fn ensure_embeddings(
1373    root: &Path,
1374    index: &BM25Index,
1375    engine: &EmbeddingEngine,
1376    embed_idx: &mut EmbeddingIndex,
1377) -> Result<AlignedEmbeddings, String> {
1378    // A resident index whose bodies were shrunk to snippets (post-embedding RAM
1379    // reclaim) must NEVER drive re-embedding: `files_needing_update` hashes
1380    // `c.content`, so truncated bodies would falsely flag every file as changed
1381    // and re-embed 5-line snippets over the full-body vectors persisted earlier
1382    // this session. Embeddings for exactly these chunks were already built and
1383    // saved before truncation, and alignment is keyed by (path, start, end) —
1384    // not content — so we just re-align here. If a file genuinely changed, the
1385    // BM25 cache fingerprint goes stale and a fresh full-content index (reloaded
1386    // from disk) replaces this one, restoring the normal re-embed path.
1387    if index.content_truncated {
1388        let aligned = embed_idx.get_aligned_flat(&index.chunks).ok_or_else(|| {
1389            "embedding alignment failed on truncated resident index; \
1390                 refusing to re-embed snippet-only bodies"
1391                .to_string()
1392        })?;
1393        let coverage = embed_idx.coverage(index.chunks.len());
1394        return Ok((aligned, coverage, Vec::new()));
1395    }
1396
1397    let mut changed_files = embed_idx.files_needing_update(&index.chunks);
1398    changed_files.sort();
1399    changed_files.dedup();
1400
1401    if !changed_files.is_empty() {
1402        let changed_set: std::collections::HashSet<&str> = changed_files
1403            .iter()
1404            .map(std::string::String::as_str)
1405            .collect();
1406
1407        let mut changed_indices: Vec<usize> = Vec::new();
1408        let mut changed_texts: Vec<&str> = Vec::new();
1409        for (i, c) in index.chunks.iter().enumerate() {
1410            if changed_set.contains(c.file_path.as_str()) {
1411                changed_indices.push(i);
1412                changed_texts.push(&c.content);
1413            }
1414        }
1415
1416        let batch_embeddings = engine
1417            .embed_batch(&changed_texts)
1418            .map_err(|e| format!("batch embed failed: {e}"))?;
1419
1420        let new_embeddings: Vec<(usize, Vec<f32>)> =
1421            changed_indices.into_iter().zip(batch_embeddings).collect();
1422
1423        embed_idx.update(&index.chunks, &new_embeddings, &changed_files, None);
1424        embed_idx
1425            .save(root)
1426            .map_err(|e| format!("save embeddings failed: {e}"))?;
1427    }
1428
1429    if let Some(aligned) = embed_idx.get_aligned_flat(&index.chunks) {
1430        let coverage = embed_idx.coverage(index.chunks.len());
1431        return Ok((aligned, coverage, changed_files));
1432    }
1433
1434    // Alignment missing: rebuild everything once via batched inference.
1435    let mut all_files: Vec<String> = index.chunks.iter().map(|c| c.file_path.clone()).collect();
1436    all_files.sort();
1437    all_files.dedup();
1438
1439    let all_texts: Vec<&str> = index.chunks.iter().map(|c| c.content.as_str()).collect();
1440    let batch_embeddings = engine
1441        .embed_batch(&all_texts)
1442        .map_err(|e| format!("batch embed failed: {e}"))?;
1443
1444    let new_embeddings: Vec<(usize, Vec<f32>)> = batch_embeddings.into_iter().enumerate().collect();
1445
1446    embed_idx.update(&index.chunks, &new_embeddings, &all_files, None);
1447    embed_idx
1448        .save(root)
1449        .map_err(|e| format!("save embeddings failed: {e}"))?;
1450
1451    let aligned = embed_idx
1452        .get_aligned_flat(&index.chunks)
1453        .ok_or_else(|| "embedding alignment failed after full rebuild".to_string())?;
1454    let coverage = embed_idx.coverage(index.chunks.len());
1455    Ok((aligned, coverage, all_files))
1456}
1457
1458struct SearchFilter {
1459    allowed_exts: Option<HashSet<String>>,
1460    path_glob: Option<glob::Pattern>,
1461}
1462
1463impl SearchFilter {
1464    fn new(languages: Option<&[String]>, path_glob: Option<&str>) -> Result<Self, String> {
1465        let allowed_exts = languages.map(normalize_languages);
1466        let path_glob = match path_glob {
1467            None => None,
1468            Some(s) if s.trim().is_empty() => None,
1469            Some(s) => Some(glob::Pattern::new(s).map_err(|e| e.msg.to_string())?),
1470        };
1471        Ok(Self {
1472            allowed_exts,
1473            path_glob,
1474        })
1475    }
1476
1477    fn is_active(&self) -> bool {
1478        self.allowed_exts.is_some() || self.path_glob.is_some()
1479    }
1480
1481    fn matches(&self, rel_path: &str) -> bool {
1482        let rel_path = rel_path.replace('\\', "/");
1483        if let Some(p) = &self.path_glob
1484            && !p.matches(&rel_path)
1485        {
1486            return false;
1487        }
1488        if let Some(exts) = &self.allowed_exts {
1489            let ext = Path::new(&rel_path)
1490                .extension()
1491                .and_then(|e| e.to_str())
1492                .unwrap_or("")
1493                .to_lowercase();
1494            if ext.is_empty() || !exts.contains(&ext) {
1495                return false;
1496            }
1497        }
1498        true
1499    }
1500}
1501
1502fn normalize_languages(langs: &[String]) -> HashSet<String> {
1503    let mut out = HashSet::new();
1504    for l in langs {
1505        let raw = l.trim().trim_start_matches('.').to_lowercase();
1506        match raw.as_str() {
1507            "rust" | "rs" => {
1508                out.insert("rs".to_string());
1509            }
1510            "ts" | "typescript" => {
1511                out.insert("ts".to_string());
1512                out.insert("tsx".to_string());
1513            }
1514            "js" | "javascript" => {
1515                out.insert("js".to_string());
1516                out.insert("jsx".to_string());
1517                out.insert("mjs".to_string());
1518                out.insert("cjs".to_string());
1519            }
1520            "py" | "python" => {
1521                out.insert("py".to_string());
1522            }
1523            "go" => {
1524                out.insert("go".to_string());
1525            }
1526            "java" => {
1527                out.insert("java".to_string());
1528            }
1529            "ruby" | "rb" => {
1530                out.insert("rb".to_string());
1531            }
1532            "php" => {
1533                out.insert("php".to_string());
1534            }
1535            "c" => {
1536                out.insert("c".to_string());
1537                out.insert("h".to_string());
1538            }
1539            "cpp" | "c++" | "cc" => {
1540                out.insert("cpp".to_string());
1541                out.insert("hpp".to_string());
1542                out.insert("cc".to_string());
1543                out.insert("hh".to_string());
1544            }
1545            "cs" | "csharp" => {
1546                out.insert("cs".to_string());
1547            }
1548            "swift" => {
1549                out.insert("swift".to_string());
1550            }
1551            "kt" | "kotlin" => {
1552                out.insert("kt".to_string());
1553                out.insert("kts".to_string());
1554            }
1555            "json" => {
1556                out.insert("json".to_string());
1557            }
1558            "yaml" | "yml" => {
1559                out.insert("yaml".to_string());
1560                out.insert("yml".to_string());
1561            }
1562            other if !other.is_empty() => {
1563                out.insert(other.to_string());
1564            }
1565            _ => {}
1566        }
1567    }
1568    out
1569}
1570
1571/// Public wrapper for eval harness: load embedding engine + index.
1572#[cfg(feature = "embeddings")]
1573pub fn load_engine_and_index_pub(
1574    root: &Path,
1575) -> Result<(&'static EmbeddingEngine, EmbeddingIndex), String> {
1576    load_engine_and_index(root)
1577}
1578
1579/// Public wrapper for eval harness: prepare embeddings for a project.
1580#[cfg(feature = "embeddings")]
1581pub fn ensure_embeddings_for_eval(
1582    root: &Path,
1583    index: &BM25Index,
1584    engine: &EmbeddingEngine,
1585    embed_idx: &mut EmbeddingIndex,
1586) -> Result<AlignedEmbeddings, String> {
1587    ensure_embeddings(root, index, engine, embed_idx)
1588}
1589
1590/// Public wrapper for eval harness: apply SPLADE boosting.
1591pub fn boost_with_splade_pub(
1592    results: &mut [HybridResult],
1593    splade: &[crate::core::splade_retrieval::SpladeResult],
1594    weight: f64,
1595) {
1596    boost_with_splade(results, splade, weight);
1597}
1598
1599#[cfg(test)]
1600mod filter_tests {
1601    use super::*;
1602
1603    #[test]
1604    fn filter_language_rust() {
1605        let f = SearchFilter::new(Some(&["rust".into()]), None).unwrap();
1606        assert!(f.matches("src/main.rs"));
1607        assert!(!f.matches("src/main.ts"));
1608    }
1609
1610    #[test]
1611    fn filter_path_glob() {
1612        let f = SearchFilter::new(None, Some("rust/src/**")).unwrap();
1613        assert!(f.matches("rust/src/core/mod.rs"));
1614        assert!(!f.matches("website/src/pages/index.astro"));
1615    }
1616}
1617
1618#[cfg(all(test, feature = "embeddings"))]
1619mod cold_start_guard_tests {
1620    use super::*;
1621
1622    #[test]
1623    fn budget_zero_disables_guard() {
1624        // 0 = "always embed inline" (pre-#512 behavior), regardless of size.
1625        assert!(!exceeds_inline_embed_budget(1_000_000, 0));
1626    }
1627
1628    #[test]
1629    fn budget_is_inclusive_and_triggers_above_threshold() {
1630        assert!(!exceeds_inline_embed_budget(0, 2000), "warm index: inline");
1631        assert!(
1632            !exceeds_inline_embed_budget(2000, 2000),
1633            "at the budget: still inline"
1634        );
1635        assert!(
1636            exceeds_inline_embed_budget(2001, 2000),
1637            "over the budget: degrade"
1638        );
1639    }
1640
1641    #[test]
1642    fn default_threshold_positive_when_env_unset() {
1643        // With the env override unset the default must be a real, positive guard.
1644        if std::env::var_os("LEAN_CTX_HYBRID_INLINE_EMBED_MAX").is_none() {
1645            assert!(inline_embed_max_chunks() >= 1);
1646        }
1647    }
1648
1649    #[test]
1650    fn dense_build_hint_always_points_at_the_cli_build() {
1651        let full = dense_build_hint(22_741, false);
1652        assert!(full.contains("lean-ctx index build-semantic"));
1653        assert!(full.contains("22741"));
1654        let compact = dense_build_hint(22_741, true);
1655        assert!(compact.contains("lean-ctx index build-semantic"));
1656        assert!(compact.contains("22741"));
1657    }
1658}
1659
1660#[cfg(test)]
1661mod determinism_tests {
1662    use super::*;
1663
1664    #[test]
1665    fn rrf_merge_hybrid_is_deterministic_on_ties() {
1666        let a = HybridResult {
1667            file_path: "a.rs".to_string(),
1668            symbol_name: "foo".to_string(),
1669            kind: crate::core::bm25_index::ChunkKind::Function,
1670            start_line: 1,
1671            end_line: 1,
1672            snippet: "a".to_string(),
1673            rrf_score: 0.0,
1674            bm25_score: None,
1675            dense_score: None,
1676            bm25_rank: None,
1677            dense_rank: None,
1678        };
1679        let b = HybridResult {
1680            file_path: "b.rs".to_string(),
1681            symbol_name: "foo".to_string(),
1682            kind: crate::core::bm25_index::ChunkKind::Function,
1683            start_line: 1,
1684            end_line: 1,
1685            snippet: "b".to_string(),
1686            rrf_score: 0.0,
1687            bm25_score: None,
1688            dense_score: None,
1689            bm25_rank: None,
1690            dense_rank: None,
1691        };
1692
1693        // Two lists with swapped ranks yield identical RRF sums for a and b.
1694        let fused = rrf_merge_hybrid(
1695            vec![
1696                ("root".to_string(), vec![a.clone(), b.clone()]),
1697                ("root".to_string(), vec![b.clone(), a.clone()]),
1698            ],
1699            10,
1700        );
1701
1702        assert_eq!(fused.len(), 2);
1703        assert_eq!(fused[0].file_path, "a.rs");
1704        assert_eq!(fused[1].file_path, "b.rs");
1705    }
1706}
1707
1708#[cfg(test)]
1709mod dense_config_tests {
1710    use super::*;
1711
1712    /// #686: dense stays on by default — the flip is opt-in, no behavior change.
1713    #[test]
1714    fn dense_enabled_defaults_true() {
1715        assert!(HybridConfig::default().dense_enabled);
1716    }
1717
1718    /// #686: `[search].dense_enabled = false` parses and leaves siblings at default.
1719    #[test]
1720    fn dense_enabled_deserializes_false() {
1721        let cfg: HybridConfig = toml::from_str("dense_enabled = false").unwrap();
1722        assert!(!cfg.dense_enabled);
1723        assert_eq!(cfg.bm25_candidates, 75);
1724        assert_eq!(cfg.splade_weight, 0.5);
1725    }
1726}
1727
1728#[cfg(all(test, feature = "embeddings"))]
1729mod dense_toggle_tests {
1730    use super::*;
1731    use crate::core::bm25_index::{BM25Index, ChunkKind, CodeChunk, tokenize};
1732
1733    fn small_index() -> BM25Index {
1734        BM25Index::from_chunks_for_test(vec![
1735            CodeChunk {
1736                file_path: "auth.rs".into(),
1737                symbol_name: "validate_token".into(),
1738                kind: ChunkKind::Function,
1739                start_line: 1,
1740                end_line: 10,
1741                content: "fn validate_token(token: &str) -> bool { check_jwt_expiry(token) }"
1742                    .into(),
1743                tokens: tokenize("fn validate_token token str bool check_jwt_expiry token"),
1744                token_count: 0,
1745            },
1746            CodeChunk {
1747                file_path: "db.rs".into(),
1748                symbol_name: "connect_database".into(),
1749                kind: ChunkKind::Function,
1750                start_line: 1,
1751                end_line: 5,
1752                content: "fn connect_database(url: &str) -> Pool { create_pool(url) }".into(),
1753                tokens: tokenize("fn connect_database url str Pool create_pool url"),
1754                token_count: 0,
1755            },
1756        ])
1757    }
1758
1759    /// #686: the dense-disabled body ranks via BM25 (+ graph + rerank + SPLADE),
1760    /// emits a BM25 header, finds the lexical match, and crucially never loads the
1761    /// embedding engine or writes `embeddings.json` — the on-disk vector footprint
1762    /// and embed latency disappear.
1763    #[test]
1764    fn bm25_graph_search_ranks_without_embeddings() {
1765        let dir = tempfile::tempdir().unwrap();
1766        let root = dir.path();
1767        let index = small_index();
1768        let cfg = HybridConfig {
1769            dense_enabled: false,
1770            ..Default::default()
1771        };
1772        let filter = SearchFilter::new(None, None).unwrap();
1773
1774        let out = bm25_graph_search(
1775            "jwt token validation",
1776            root,
1777            &index,
1778            5,
1779            false,
1780            &filter,
1781            &cfg,
1782        );
1783
1784        assert!(
1785            out.contains("Semantic search (BM25"),
1786            "expected BM25 header, got: {out}"
1787        );
1788        assert!(
1789            out.contains("validate_token"),
1790            "expected lexical match, got: {out}"
1791        );
1792        assert!(
1793            !root.join("embeddings.json").exists(),
1794            "dense-disabled path must not persist embeddings.json"
1795        );
1796    }
1797}