Skip to main content

lean_ctx/tools/
ctx_semantic_search.rs

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