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