Skip to main content

lean_ctx/tools/
ctx_semantic_search.rs

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