diff --git a/rust/src/tools/ctx_semantic_search/dense.rs b/rust/src/tools/ctx_semantic_search/dense.rs index 92282d6fe..d6ec3e5df 100644 --- a/rust/src/tools/ctx_semantic_search/dense.rs +++ b/rust/src/tools/ctx_semantic_search/dense.rs @@ -64,6 +64,26 @@ pub(crate) fn dense_build_hint(pending: usize, compact: bool) -> String { } } +/// #1259: is the default BM25 ranking running *because* the dense index was +/// never built? Cheap enough for the hot path — a file-existence check, no +/// engine load. Callers use it to label the degradation instead of reporting +/// lexical hits as "semantic". False when dense is deliberately off (#686): +/// there is nothing to build then. +pub(crate) fn dense_index_missing(root: &Path) -> bool { + #[cfg(feature = "embeddings")] + { + HybridConfig::from_config().dense_enabled + && !crate::core::index_namespace::vectors_dir(root) + .join("embeddings.bin") + .exists() + } + #[cfg(not(feature = "embeddings"))] + { + let _ = root; + false + } +} + pub(crate) fn hybrid_search_mode( query: &str, root: &Path, diff --git a/rust/src/tools/ctx_semantic_search/mod.rs b/rust/src/tools/ctx_semantic_search/mod.rs index 022fe57e5..7f551bcf8 100644 --- a/rust/src/tools/ctx_semantic_search/mod.rs +++ b/rust/src/tools/ctx_semantic_search/mod.rs @@ -43,6 +43,9 @@ pub fn handle( }; let compact = crp_mode.is_tdd(); + // #1259: separate "caller asked for BM25" from "BM25 is what the default + // resolves to" — only the latter is a silent degradation worth labelling. + let mode_defaulted = mode.is_none(); let mode = mode.unwrap_or("bm25").to_lowercase(); let workspace = workspace.unwrap_or(false); let artifacts = artifacts.unwrap_or(false); @@ -74,15 +77,30 @@ pub fn handle( } results.truncate(top_k); + // #1259: never call a lexical fallback "semantic". When no mode was + // requested and the dense index was never built, name the + // degradation and the one command that fixes it. + let degraded = mode_defaulted && dense_index_missing(root); let header = if compact { format!( - "semantic_search(bm25,{top_k}) → {} results, {} chunks indexed\n", + "semantic_search({},{top_k}) → {} results, {} chunks indexed\n", + if degraded { + "bm25,dense-not-built" + } else { + "bm25" + }, results.len(), index.doc_count ) } else { format!( - "Semantic search (BM25): \"{}\" ({} results from {} indexed chunks)\n", + "{}: \"{}\" ({} results from {} indexed chunks)\n", + if degraded { + "Lexical search (BM25 — dense index not built, \ + run: lean-ctx index build-semantic)" + } else { + "Semantic search (BM25)" + }, truncate_query(query, 60), results.len(), index.doc_count, diff --git a/rust/src/tools/ctx_semantic_search/tests.rs b/rust/src/tools/ctx_semantic_search/tests.rs index 78e7296c3..035079955 100644 --- a/rust/src/tools/ctx_semantic_search/tests.rs +++ b/rust/src/tools/ctx_semantic_search/tests.rs @@ -20,6 +20,22 @@ mod filter_tests { } } +#[cfg(test)] +mod dense_availability_tests { + #[allow(clippy::wildcard_imports)] + use super::super::*; + + /// #1259: a project that never built embeddings must be reported as + /// degraded, so the default path can say so instead of calling BM25 hits + /// "semantic". + #[test] + fn missing_embeddings_bin_is_reported_as_degraded() { + let dir = std::env::temp_dir().join("lean-ctx-dense-missing-test"); + let _ = std::fs::create_dir_all(&dir); + assert_eq!(dense_index_missing(&dir), cfg!(feature = "embeddings")); + } +} + #[cfg(test)] mod root_resolution_tests { #[allow(clippy::wildcard_imports)]