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