Skip to main content

lean_ctx/core/bm25_index/
mod.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::UNIX_EPOCH;
4
5use serde::{Deserialize, Serialize};
6mod build;
7mod chunking;
8pub use chunking::*;
9mod coordinator;
10pub use coordinator::{SearchIndexBuildProgress, get_or_start_build};
11#[cfg(test)]
12mod tests;
13
14const MAX_BM25_FILES: usize = 5000;
15const CHUNK_COUNT_WARNING: usize = 50_000;
16const ZSTD_LEVEL: i32 = 9;
17
18const DEFAULT_BM25_IGNORES: &[&str] = &[
19    "vendor/**",
20    "dist/**",
21    "build/**",
22    "public/vendor/**",
23    "public/js/**",
24    "public/css/**",
25    "public/build/**",
26    ".next/**",
27    ".nuxt/**",
28    "__pycache__/**",
29    "*.min.js",
30    "*.min.css",
31    "*.bundle.js",
32    "*.chunk.js",
33];
34
35fn max_bm25_cache_bytes() -> u64 {
36    // Single source of truth: `Config::bm25_max_cache_mb_effective` (env override
37    // › explicit config › disk-budget › generous default). Decoupled from the RAM
38    // profile so large repos persist instead of rebuilding forever (issue #249).
39    let mb = std::env::var("LEAN_CTX_BM25_MAX_CACHE_MB")
40        .ok()
41        .and_then(|v| v.parse::<u64>().ok())
42        .unwrap_or_else(|| crate::core::config::Config::load().bm25_max_cache_mb_effective());
43    mb * 1024 * 1024
44}
45
46/// Effective on-disk ceiling (bytes) for the persisted BM25 index. Single source
47/// of truth shared with `doctor` so its "oversized index" warning matches what
48/// `save`/`load` actually enforce.
49pub fn persist_ceiling_bytes() -> u64 {
50    max_bm25_cache_bytes()
51}
52
53/// Outcome of persisting a BM25 index to disk. Distinguishes a real write from a
54/// size-capped refusal so callers never mistake "refused to persist" for
55/// success (the bug behind the perpetual "index warming" report, issue #249).
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum SaveOutcome {
58    /// Written to disk. Carries the compressed (zstd) size in bytes.
59    Persisted { compressed_bytes: u64 },
60    /// Built fine but NOT written — the compressed size exceeds the disk
61    /// ceiling. The in-memory index is still usable for this process; callers
62    /// should surface the remedy (raise the cap / add ignore patterns) instead
63    /// of silently rebuilding on every call.
64    SkippedTooLarge {
65        compressed_bytes: u64,
66        limit_bytes: u64,
67    },
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
71pub struct CodeChunk {
72    pub file_path: String,
73    pub symbol_name: String,
74    pub kind: ChunkKind,
75    pub start_line: usize,
76    pub end_line: usize,
77    pub content: String,
78    #[serde(default)]
79    pub tokens: Vec<String>,
80    pub token_count: usize,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84pub enum ChunkKind {
85    Function,
86    Struct,
87    Impl,
88    Module,
89    Class,
90    Method,
91    Other,
92    // -- External source kinds (Context Engine) --
93    Issue,
94    PullRequest,
95    WikiPage,
96    DbSchema,
97    ApiEndpoint,
98    Ticket,
99    ExternalOther,
100}
101
102#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
103pub struct IndexedFileState {
104    pub mtime_ms: u64,
105    pub size_bytes: u64,
106}
107
108impl IndexedFileState {
109    fn from_path(path: &Path) -> Option<Self> {
110        let meta = path.metadata().ok()?;
111        let size_bytes = meta.len();
112        let mtime_ms = meta
113            .modified()
114            .ok()
115            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
116            .map(|d| d.as_millis() as u64)?;
117        Some(Self {
118            mtime_ms,
119            size_bytes,
120        })
121    }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct BM25Index {
126    pub chunks: Vec<CodeChunk>,
127    pub inverted: HashMap<String, Vec<(usize, f64)>>,
128    pub avg_doc_len: f64,
129    pub doc_count: usize,
130    pub doc_freqs: HashMap<String, usize>,
131    #[serde(default)]
132    pub files: HashMap<String, IndexedFileState>,
133    /// True once `shrink_resident_content_to_snippet` has trimmed each chunk's
134    /// `content` down to the snippet lines. Resident-only RAM-saving state: never
135    /// persisted (`skip`) so the on-disk index keeps full content, and a reload
136    /// always starts as `false`. Guards the embedding pass against re-embedding
137    /// truncated bodies (see `ensure_embeddings`).
138    #[serde(default, skip)]
139    pub content_truncated: bool,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct SearchResult {
144    pub chunk_idx: usize,
145    pub score: f64,
146    pub file_path: String,
147    pub symbol_name: String,
148    pub kind: ChunkKind,
149    pub start_line: usize,
150    pub end_line: usize,
151    pub snippet: String,
152}
153
154const BM25_K1: f64 = 1.2;
155const BM25_B: f64 = 0.75;
156
157fn structural_query_boost(query: &str, chunk: &CodeChunk) -> f64 {
158    let raw_terms: std::collections::HashSet<String> = query
159        .split(|c: char| !(c.is_alphanumeric() || c == '_'))
160        .filter(|term| term.len() >= 3)
161        .map(str::to_ascii_lowercase)
162        .collect();
163    let path_terms: std::collections::HashSet<String> = tokenize(&chunk.file_path)
164        .into_iter()
165        .map(|term| term.to_ascii_lowercase())
166        .collect();
167
168    let mut boost = 0.0;
169    if raw_terms.contains(&chunk.symbol_name.to_ascii_lowercase()) {
170        boost += 32.0;
171    }
172    boost += raw_terms.intersection(&path_terms).count() as f64 * 8.0;
173
174    let has_identifier = query.split_whitespace().any(|term| {
175        let has_lower = term.chars().any(char::is_lowercase);
176        let has_upper = term.chars().any(char::is_uppercase);
177        (has_lower && has_upper) || term.contains('_') || term.contains('.')
178    });
179    if has_identifier {
180        let extension = std::path::Path::new(&chunk.file_path)
181            .extension()
182            .and_then(std::ffi::OsStr::to_str)
183            .unwrap_or_default()
184            .to_ascii_lowercase();
185        if matches!(extension.as_str(), "md" | "mdx" | "rst" | "txt" | "adoc") {
186            boost -= 8.0;
187        } else if !extension.is_empty() {
188            boost += 4.0;
189        }
190    }
191    boost
192}
193
194impl Default for BM25Index {
195    fn default() -> Self {
196        Self::new()
197    }
198}
199
200impl BM25Index {
201    pub fn new() -> Self {
202        Self {
203            chunks: Vec::new(),
204            inverted: HashMap::new(),
205            avg_doc_len: 0.0,
206            doc_count: 0,
207            doc_freqs: HashMap::new(),
208            files: HashMap::new(),
209            content_truncated: false,
210        }
211    }
212
213    /// Approximate heap memory used by this index in bytes.
214    pub fn memory_usage_bytes(&self) -> usize {
215        let chunks_size: usize = self
216            .chunks
217            .iter()
218            .map(|c| {
219                c.content.len()
220                    + c.file_path.len()
221                    + c.symbol_name.len()
222                    + c.tokens.iter().map(String::len).sum::<usize>()
223                    + 64
224            })
225            .sum();
226        let inverted_size: usize = self
227            .inverted
228            .iter()
229            .map(|(k, v)| k.len() + v.len() * 16 + 32)
230            .sum();
231        let files_size: usize = self.files.keys().map(|k| k.len() + 24).sum();
232        let freqs_size: usize = self.doc_freqs.keys().map(|k| k.len() + 16).sum();
233        chunks_size + inverted_size + files_size + freqs_size
234    }
235
236    /// Drops all in-memory data, effectively freeing heap. Index can be re-loaded from disk.
237    pub fn unload(&mut self) {
238        let usage = self.memory_usage_bytes();
239        self.chunks = Vec::new();
240        self.inverted = HashMap::new();
241        self.doc_freqs = HashMap::new();
242        self.files = HashMap::new();
243        self.avg_doc_len = 0.0;
244        self.doc_count = 0;
245        tracing::info!(
246            "[bm25] unloaded index, freed ~{:.1}MB",
247            usage as f64 / 1_048_576.0
248        );
249    }
250
251    /// Shrinks each resident chunk's `content` to its first `keep_lines` lines,
252    /// reclaiming the RAM held by the full source bodies once the embedding pass
253    /// has already consumed them. The search path only ever reads
254    /// `content.lines().take(5)` for snippets, so the trimmed copy is functionally
255    /// complete for BM25/dense/hybrid result rendering.
256    ///
257    /// Further shrinks resident content (chunks are already truncated to 10 lines
258    /// during `add_chunk`; this method can tighten that further). Sets
259    /// `content_truncated` so a later `ensure_embeddings` against this same
260    /// resident index skips re-embedding (which would otherwise feed truncated
261    /// bodies to the embedder).
262    ///
263    /// Idempotent and only ever shrinks: chunks shorter than `keep_lines` are
264    /// left untouched.
265    pub fn shrink_resident_content_to_snippet(&mut self, keep_lines: usize) {
266        let before = self.memory_usage_bytes();
267        for chunk in &mut self.chunks {
268            // Cheap line-count gate: only allocate a new string when the body is
269            // actually longer than the snippet window.
270            if chunk.content.lines().nth(keep_lines).is_some() {
271                let trimmed: String = chunk
272                    .content
273                    .lines()
274                    .take(keep_lines)
275                    .collect::<Vec<_>>()
276                    .join("\n");
277                chunk.content = trimmed;
278                // Reclaim the spare capacity left by the larger original body.
279                chunk.content.shrink_to_fit();
280            }
281        }
282        self.content_truncated = true;
283        let after = self.memory_usage_bytes();
284        tracing::debug!(
285            "[bm25] shrank resident content to {keep_lines} lines/chunk, freed ~{:.1}MB",
286            before.saturating_sub(after) as f64 / 1_048_576.0
287        );
288    }
289
290    /// Builds an index from explicit chunks (unit tests; avoids filesystem walking).
291    #[cfg(test)]
292    pub(crate) fn from_chunks_for_test(chunks: Vec<CodeChunk>) -> Self {
293        let mut index = Self::new();
294        for mut chunk in chunks {
295            if chunk.token_count == 0 {
296                chunk.token_count = tokenize(&chunk.content).len();
297            }
298            index.add_chunk(chunk);
299        }
300        index.finalize();
301        index
302    }
303
304    pub fn build_from_directory(root: &Path) -> Self {
305        Self::build_from_directory_inner(root, &HashMap::new())
306    }
307
308    /// Like `build_from_directory` but reuses file content from a prior scan
309    /// (e.g. the graph index walk) to avoid redundant disk reads.
310    pub fn build_with_content_hint(root: &Path, content_hint: &HashMap<String, String>) -> Self {
311        Self::build_from_directory_inner(root, content_hint)
312    }
313
314    fn build_from_directory_inner(root: &Path, content_hint: &HashMap<String, String>) -> Self {
315        let root_str = root.to_string_lossy();
316        if !super::graph_index::is_safe_scan_root_public(&root_str) {
317            tracing::warn!("[bm25: scan aborted for unsafe root {root_str}]");
318            return Self::new();
319        }
320        let files = list_code_files(root);
321
322        // #933: parallel fast path for the common case. The per-file parse +
323        // tokenize work is pure and thread-safe, so we fan it across a rayon pool
324        // and merge sequentially in file order — measured 2.85 s → 0.69 s (~4x) on
325        // the lean-ctx repo. Under memory pressure (or for tiny corpora, where pool
326        // setup is not worth it) we fall back to the sequential build, which carries
327        // the incremental early-break. #685 adds admission control: a corpus whose
328        // estimated peak would blow past the guardian's hard threshold degrades to
329        // the sequential build up front instead of OOMing mid-fan-out. Both paths
330        // produce an identical index (see `build` module + determinism tests).
331        if files.len() >= build::PARALLEL_MIN_FILES
332            && !crate::core::memory_guard::is_under_pressure()
333            && !crate::core::memory_guard::abort_requested()
334            && crate::core::index_admission::admit_files(
335                crate::core::index_admission::BuildKind::Bm25,
336                root,
337                &files,
338            )
339            .parallel_ok
340        {
341            return Self::build_parallel(root, content_hint, &files);
342        }
343        Self::build_sequential(root, content_hint, &files)
344    }
345
346    /// Group a previous index's chunks by file, each file's list sorted by
347    /// (start_line, end_line, symbol_name) — the deterministic reuse order shared
348    /// by both incremental rebuild paths (and the equivalence test, so it feeds
349    /// them identical inputs).
350    fn group_prev_chunks_by_file(prev: &BM25Index) -> HashMap<String, Vec<CodeChunk>> {
351        let mut old_by_file: HashMap<String, Vec<CodeChunk>> = HashMap::new();
352        for c in &prev.chunks {
353            old_by_file
354                .entry(c.file_path.clone())
355                .or_default()
356                .push(c.clone());
357        }
358        for v in old_by_file.values_mut() {
359            v.sort_by(|a, b| {
360                a.start_line
361                    .cmp(&b.start_line)
362                    .then_with(|| a.end_line.cmp(&b.end_line))
363                    .then_with(|| a.symbol_name.cmp(&b.symbol_name))
364            });
365        }
366        old_by_file
367    }
368
369    pub fn rebuild_incremental(root: &Path, prev: &BM25Index) -> Self {
370        let old_by_file = Self::group_prev_chunks_by_file(prev);
371        let files = list_code_files(root);
372
373        // #581: mirror `build()`'s dispatch. The edit loop is the hottest path in
374        // daily use, and its serial cost is dominated by re-tokenizing the *many
375        // unchanged* files' reused chunks — not the few changed ones. So the
376        // parallel path fans the entire tokenization (changed files via
377        // `prepare_file`, unchanged via re-`prepare_chunk`) across the rayon pool
378        // and merges sequentially in file order. The sequential path keeps the
379        // per-file memory-pressure early-break and serves small corpora / pressure.
380        // #685: oversized corpora degrade to sequential via admission control.
381        // Both produce an identical index (see determinism tests).
382        if files.len() >= build::PARALLEL_MIN_FILES
383            && !crate::core::memory_guard::is_under_pressure()
384            && !crate::core::memory_guard::abort_requested()
385            && crate::core::index_admission::admit_files(
386                crate::core::index_admission::BuildKind::Bm25,
387                root,
388                &files,
389            )
390            .parallel_ok
391        {
392            return Self::rebuild_incremental_parallel(root, prev, &old_by_file, &files);
393        }
394        Self::rebuild_incremental_sequential(root, prev, &old_by_file, &files)
395    }
396
397    /// Sequential incremental rebuild with per-file memory-pressure guards. Reuses
398    /// unchanged files' chunks and re-extracts changed ones. Used for small
399    /// corpora and as the safe fallback under memory pressure.
400    pub(crate) fn rebuild_incremental_sequential(
401        root: &Path,
402        prev: &BM25Index,
403        old_by_file: &HashMap<String, Vec<CodeChunk>>,
404        files: &[String],
405    ) -> Self {
406        let mut index = Self::new();
407        const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024;
408
409        for (i, rel) in files.iter().enumerate() {
410            if i.is_multiple_of(500) && crate::core::memory_guard::is_under_pressure() {
411                tracing::warn!(
412                    "[bm25: stopping incremental rebuild at file {i}/{} due to memory pressure]",
413                    files.len()
414                );
415                break;
416            }
417
418            let abs = root.join(rel);
419            let Some(state) = IndexedFileState::from_path(&abs) else {
420                continue;
421            };
422
423            let unchanged = prev.files.get(rel).is_some_and(|old| *old == state);
424            if unchanged
425                && let Some(chunks) = old_by_file.get(rel)
426                && chunks.first().is_some_and(|c| !c.content.is_empty())
427            {
428                for chunk in chunks {
429                    index.add_chunk(chunk.clone());
430                }
431                index.files.insert(rel.clone(), state);
432                continue;
433            }
434
435            if state.size_bytes > MAX_FILE_SIZE_BYTES {
436                continue;
437            }
438            let content = if crate::core::extractors::is_binary_document(&abs) {
439                match std::fs::read(&abs) {
440                    Ok(bytes) => crate::core::extractors::extract(&abs, &bytes).text,
441                    Err(_) => continue,
442                }
443            } else {
444                match std::fs::read_to_string(&abs) {
445                    Ok(c) => c,
446                    Err(_) => continue,
447                }
448            };
449            if content.is_empty() {
450                continue;
451            }
452            let mut chunks = extract_chunks(rel, &content);
453            chunks.sort_by(|a, b| {
454                a.start_line
455                    .cmp(&b.start_line)
456                    .then_with(|| a.end_line.cmp(&b.end_line))
457                    .then_with(|| a.symbol_name.cmp(&b.symbol_name))
458            });
459            for chunk in chunks {
460                index.add_chunk(chunk);
461            }
462            index.files.insert(rel.clone(), state);
463        }
464
465        index.finalize();
466        index
467    }
468
469    fn add_chunk(&mut self, mut chunk: CodeChunk) {
470        let idx = self.chunks.len();
471
472        let enriched = enrich_for_bm25(&chunk);
473        let tokens = tokenize(&enriched);
474        for token in &tokens {
475            let lower = token.to_lowercase();
476            let postings = self.inverted.entry(lower.clone()).or_default();
477            if postings.last().map(|(last_idx, _)| *last_idx) != Some(idx) {
478                *self.doc_freqs.entry(lower).or_insert(0) += 1;
479            }
480            postings.push((idx, 1.0));
481        }
482
483        // #790: truncate content to snippet AFTER tokenization — full text was
484        // used for BM25 scoring above; stored content is only for result display.
485        const SNIPPET_LINES: usize = 10;
486        if chunk.content.lines().nth(SNIPPET_LINES).is_some() {
487            chunk.content = chunk
488                .content
489                .lines()
490                .take(SNIPPET_LINES)
491                .collect::<Vec<_>>()
492                .join("\n");
493            chunk.content.shrink_to_fit();
494        }
495
496        self.chunks.push(CodeChunk {
497            token_count: tokens.len(),
498            tokens: Vec::new(),
499            ..chunk
500        });
501    }
502
503    fn finalize(&mut self) {
504        self.doc_count = self.chunks.len();
505        if self.doc_count == 0 {
506            return;
507        }
508
509        let total_len: usize = self.chunks.iter().map(|c| c.token_count).sum();
510        self.avg_doc_len = total_len as f64 / self.doc_count as f64;
511    }
512
513    pub fn search(&self, query: &str, top_k: usize) -> Vec<SearchResult> {
514        let query_tokens = tokenize(query);
515        if query_tokens.is_empty() || self.doc_count == 0 {
516            return Vec::new();
517        }
518
519        // Pre-allocated score array: O(1) per-access vs HashMap overhead.
520        // Kolmogorov-optimal: minimal allocation for the scoring operation.
521        let n = self.chunks.len();
522        let mut scores = vec![0.0f64; n];
523        let mut touched = Vec::with_capacity(n.min(256));
524
525        for token in &query_tokens {
526            let lower = token.to_lowercase();
527            let df = *self.doc_freqs.get(&lower).unwrap_or(&0) as f64;
528            if df == 0.0 {
529                continue;
530            }
531
532            let idf = ((self.doc_count as f64 - df + 0.5) / (df + 0.5) + 1.0).ln();
533
534            if let Some(postings) = self.inverted.get(&lower) {
535                for &(idx, weight) in postings {
536                    let doc_len = self.chunks[idx].token_count as f64;
537                    let norm_len = doc_len / self.avg_doc_len.max(1.0);
538                    let bm25 = idf * (weight * (BM25_K1 + 1.0))
539                        / (weight + BM25_K1 * (1.0 - BM25_B + BM25_B * norm_len));
540
541                    if scores[idx] == 0.0 {
542                        touched.push(idx);
543                    }
544                    scores[idx] += bm25;
545                }
546            }
547        }
548
549        for &idx in &touched {
550            scores[idx] += structural_query_boost(query, &self.chunks[idx]);
551        }
552
553        let mut results: Vec<SearchResult> = touched
554            .iter()
555            .filter(|&&idx| scores[idx] > 0.0)
556            .map(|&idx| {
557                let chunk = &self.chunks[idx];
558                let snippet = chunk.content.lines().take(5).collect::<Vec<_>>().join("\n");
559                SearchResult {
560                    chunk_idx: idx,
561                    score: scores[idx],
562                    file_path: chunk.file_path.clone(),
563                    symbol_name: chunk.symbol_name.clone(),
564                    kind: chunk.kind.clone(),
565                    start_line: chunk.start_line,
566                    end_line: chunk.end_line,
567                    snippet,
568                }
569            })
570            .collect();
571
572        results.sort_by(|a, b| {
573            b.score
574                .partial_cmp(&a.score)
575                .unwrap_or(std::cmp::Ordering::Equal)
576                .then_with(|| a.file_path.cmp(&b.file_path))
577                .then_with(|| a.symbol_name.cmp(&b.symbol_name))
578                .then_with(|| a.start_line.cmp(&b.start_line))
579                .then_with(|| a.end_line.cmp(&b.end_line))
580        });
581        results.truncate(top_k);
582        results
583    }
584
585    pub fn save(&self, root: &Path) -> std::io::Result<SaveOutcome> {
586        if self.chunks.len() > CHUNK_COUNT_WARNING {
587            tracing::warn!(
588                "[bm25] index has {} chunks (threshold {}), consider adding extra_ignore_patterns",
589                self.chunks.len(),
590                CHUNK_COUNT_WARNING
591            );
592        }
593
594        let dir = index_dir(root);
595        std::fs::create_dir_all(&dir)?;
596
597        // #790: stream postcard → zstd → file to avoid holding the full
598        // serialized buffer in memory. Temp-file + rename for atomicity.
599        let target = dir.join("bm25_index.bin.zst");
600        let tmp = dir.join("bm25_index.bin.zst.tmp");
601        {
602            let file = std::fs::File::create(&tmp)?;
603            let buf_writer = std::io::BufWriter::new(file);
604            let mut encoder = zstd::Encoder::new(buf_writer, ZSTD_LEVEL)
605                .map_err(|e| std::io::Error::other(format!("zstd encoder init: {e}")))?;
606            postcard::to_io(self, &mut encoder)
607                .map_err(|e| std::io::Error::other(e.to_string()))?;
608            encoder.finish()?;
609        }
610
611        let compressed_bytes = std::fs::metadata(&tmp)?.len();
612        let max_bytes = max_bm25_cache_bytes();
613        if compressed_bytes > max_bytes {
614            let _ = std::fs::remove_file(&tmp);
615            tracing::warn!(
616                "[bm25] compressed index too large ({:.1} MB, limit {:.0} MB), refusing to persist: {}",
617                compressed_bytes as f64 / 1_048_576.0,
618                max_bytes / (1024 * 1024),
619                dir.display()
620            );
621            return Ok(SaveOutcome::SkippedTooLarge {
622                compressed_bytes,
623                limit_bytes: max_bytes,
624            });
625        }
626
627        tracing::info!(
628            "[bm25] index: {:.1} MB zstd compressed",
629            compressed_bytes as f64 / 1_048_576.0,
630        );
631
632        std::fs::rename(&tmp, &target)?;
633
634        let _ = std::fs::remove_file(dir.join("bm25_index.bin"));
635        let _ = std::fs::remove_file(dir.join("bm25_index.json"));
636
637        let _ = std::fs::write(
638            dir.join("project_root.txt"),
639            root.to_string_lossy().as_bytes(),
640        );
641
642        Ok(SaveOutcome::Persisted { compressed_bytes })
643    }
644
645    pub fn load(root: &Path) -> Option<Self> {
646        let dir = index_dir(root);
647        let max_bytes = max_bm25_cache_bytes();
648
649        let zst_path = dir.join("bm25_index.bin.zst");
650        if zst_path.exists() {
651            let meta = std::fs::metadata(&zst_path).ok()?;
652            if meta.len() > max_bytes {
653                tracing::warn!(
654                    "[bm25] compressed index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
655                    meta.len() as f64 / 1_073_741_824.0,
656                    max_bytes / (1024 * 1024),
657                    zst_path.display()
658                );
659                let quarantined = zst_path.with_extension("zst.quarantined");
660                let _ = std::fs::rename(&zst_path, &quarantined);
661                return None;
662            }
663            let compressed = std::fs::read(&zst_path).ok()?;
664            let max_decompressed = max_bytes * 20; // allow 20x expansion ratio
665            let data = bounded_zstd_decode(&compressed, max_decompressed)?;
666            let idx: Self = postcard::from_bytes(&data).ok()?;
667            return Some(idx);
668        }
669
670        let bin_path = dir.join("bm25_index.bin");
671        if bin_path.exists() {
672            let meta = std::fs::metadata(&bin_path).ok()?;
673            if meta.len() > max_bytes {
674                tracing::warn!(
675                    "[bm25] index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
676                    meta.len() as f64 / 1_073_741_824.0,
677                    max_bytes / (1024 * 1024),
678                    bin_path.display()
679                );
680                let quarantined = bin_path.with_extension("bin.quarantined");
681                let _ = std::fs::rename(&bin_path, &quarantined);
682                return None;
683            }
684            let data = std::fs::read(&bin_path).ok()?;
685            let idx: Self = postcard::from_bytes(&data).ok()?;
686            // Auto-migrate: compress legacy .bin to .bin.zst
687            if let Ok(compressed) = zstd::encode_all(data.as_slice(), ZSTD_LEVEL) {
688                let zst_tmp = zst_path.with_extension("zst.tmp");
689                if std::fs::write(&zst_tmp, &compressed).is_ok()
690                    && std::fs::rename(&zst_tmp, &zst_path).is_ok()
691                {
692                    tracing::info!(
693                        "[bm25] migrated {:.1} MB → {:.1} MB zstd",
694                        data.len() as f64 / 1_048_576.0,
695                        compressed.len() as f64 / 1_048_576.0
696                    );
697                    let _ = std::fs::remove_file(&bin_path);
698                }
699            }
700            return Some(idx);
701        }
702
703        let json_path = dir.join("bm25_index.json");
704        if json_path.exists() {
705            let meta = std::fs::metadata(&json_path).ok()?;
706            if meta.len() > max_bytes {
707                tracing::warn!(
708                    "[bm25] index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
709                    meta.len() as f64 / 1_073_741_824.0,
710                    max_bytes / (1024 * 1024),
711                    json_path.display()
712                );
713                let quarantined = json_path.with_extension("json.quarantined");
714                let _ = std::fs::rename(&json_path, &quarantined);
715                return None;
716            }
717            let data = std::fs::read_to_string(&json_path).ok()?;
718            return serde_json::from_str(&data).ok();
719        }
720
721        None
722    }
723
724    pub fn load_or_build(root: &Path) -> Self {
725        Self::load_or_build_inner(root, false)
726    }
727
728    /// Like `load_or_build` but uses a fast sentinel-sampling staleness check
729    /// that skips the expensive full directory walk for new-file detection.
730    pub fn load_or_build_fast(root: &Path) -> Self {
731        Self::load_or_build_inner(root, true)
732    }
733
734    fn load_or_build_inner(root: &Path, fast_stale: bool) -> Self {
735        if !is_safe_bm25_root(root) {
736            return Self::default();
737        }
738        if let Some(idx) = Self::load(root) {
739            let stale = if fast_stale {
740                bm25_index_looks_stale_fast(&idx, root)
741            } else {
742                bm25_index_looks_stale(&idx, root)
743            };
744            if !stale {
745                return idx;
746            }
747            tracing::debug!(
748                "[bm25_index: stale index detected for {}; rebuilding]",
749                root.display()
750            );
751            let rebuilt = if idx.files.is_empty() {
752                Self::build_from_directory(root)
753            } else {
754                Self::rebuild_incremental(root, &idx)
755            };
756            let _ = rebuilt.save(root);
757            return rebuilt;
758        }
759
760        let built = Self::build_from_directory(root);
761        let _ = built.save(root);
762        built
763    }
764
765    pub fn index_file_path(root: &Path) -> PathBuf {
766        let dir = index_dir(root);
767        let zst = dir.join("bm25_index.bin.zst");
768        if zst.exists() {
769            return zst;
770        }
771        let bin = dir.join("bm25_index.bin");
772        if bin.exists() {
773            return bin;
774        }
775        dir.join("bm25_index.json")
776    }
777
778    /// Ingest external `ContentChunk`s into the BM25 index.
779    /// Converts each chunk to a `CodeChunk` (backward-compatible) and
780    /// rebuilds the inverted index. Returns the number of chunks ingested.
781    pub fn ingest_content_chunks(
782        &mut self,
783        chunks: impl IntoIterator<Item = super::content_chunk::ContentChunk>,
784    ) -> usize {
785        let mut count = 0usize;
786        for cc in chunks {
787            self.add_chunk(cc.into());
788            count += 1;
789        }
790        if count > 0 {
791            self.finalize();
792        }
793        count
794    }
795
796    /// Number of chunks originating from external providers.
797    pub fn external_chunk_count(&self) -> usize {
798        self.chunks
799            .iter()
800            .filter(|c| c.file_path.contains("://"))
801            .count()
802    }
803
804    /// Remove every chunk whose `file_path` starts with `prefix` (e.g.
805    /// `health://`) and rebuild the inverted index. Lets a recomputed source
806    /// (like the code-health fabric) evict its prior pass so stale entries never
807    /// linger in search. Returns the number of chunks removed.
808    pub fn remove_chunks_with_prefix(&mut self, prefix: &str) -> usize {
809        let before = self.chunks.len();
810        self.chunks.retain(|c| !c.file_path.starts_with(prefix));
811        let removed = before - self.chunks.len();
812        if removed > 0 {
813            self.finalize();
814        }
815        removed
816    }
817}
818
819fn is_safe_bm25_root(root: &Path) -> bool {
820    super::graph_index::is_safe_scan_root_public(&root.to_string_lossy())
821}
822
823fn bm25_index_looks_stale(index: &BM25Index, root: &Path) -> bool {
824    bm25_index_looks_stale_inner(index, root, false)
825}
826
827/// Fast staleness check: samples a subset of tracked files and skips the
828/// expensive `list_code_files()` walk for new-file detection.
829pub fn bm25_index_looks_stale_fast(index: &BM25Index, root: &Path) -> bool {
830    bm25_index_looks_stale_inner(index, root, true)
831}
832
833fn bm25_index_looks_stale_inner(index: &BM25Index, root: &Path, fast: bool) -> bool {
834    if index.chunks.is_empty() {
835        return false;
836    }
837
838    if index.files.is_empty() {
839        let mut seen = std::collections::HashSet::<&str>::new();
840        for chunk in &index.chunks {
841            let rel = chunk.file_path.trim_start_matches(['/', '\\']);
842            if rel.is_empty() {
843                continue;
844            }
845            if !seen.insert(rel) {
846                continue;
847            }
848            if !root.join(rel).exists() {
849                return true;
850            }
851        }
852        return false;
853    }
854
855    if fast {
856        let sample_size = index.files.len().min(SENTINEL_SAMPLE_SIZE);
857        let step = if index.files.len() > sample_size {
858            index.files.len() / sample_size
859        } else {
860            1
861        };
862        for (i, (rel, old_state)) in index.files.iter().enumerate() {
863            if i % step != 0 {
864                continue;
865            }
866            let abs = root.join(rel);
867            if !abs.exists() {
868                return true;
869            }
870            let Some(cur) = IndexedFileState::from_path(&abs) else {
871                return true;
872            };
873            if &cur != old_state {
874                return true;
875            }
876        }
877        return false;
878    }
879
880    for (rel, old_state) in &index.files {
881        let abs = root.join(rel);
882        if !abs.exists() {
883            return true;
884        }
885        let Some(cur) = IndexedFileState::from_path(&abs) else {
886            return true;
887        };
888        if &cur != old_state {
889            return true;
890        }
891    }
892
893    for rel in list_code_files(root) {
894        if !index.files.contains_key(&rel) {
895            return true;
896        }
897    }
898
899    false
900}
901
902const SENTINEL_SAMPLE_SIZE: usize = 10;
903
904fn bounded_zstd_decode(compressed: &[u8], max_bytes: u64) -> Option<Vec<u8>> {
905    use std::io::Read;
906    let mut decoder = zstd::Decoder::new(compressed).ok()?;
907    let mut buf = Vec::new();
908    let mut chunk = vec![0u8; 65536];
909    let mut total = 0u64;
910    loop {
911        let n = decoder.read(&mut chunk).ok()?;
912        if n == 0 {
913            break;
914        }
915        total += n as u64;
916        if total > max_bytes {
917            tracing::warn!(
918                "[bm25] decompressed index exceeds limit ({:.0} MB > {:.0} MB), aborting load",
919                total as f64 / (1024.0 * 1024.0),
920                max_bytes as f64 / (1024.0 * 1024.0)
921            );
922            return None;
923        }
924        buf.extend_from_slice(&chunk[..n]);
925    }
926    Some(buf)
927}
928
929fn index_dir(root: &Path) -> PathBuf {
930    crate::core::index_namespace::vectors_dir(root)
931}
932
933fn list_code_files(root: &Path) -> Vec<String> {
934    let cfg = crate::core::config::Config::load();
935    // #735: the declared corpus filter ([index] config + CLI overlay) decides
936    // membership before anything is chunked; the semantic index chunks this
937    // corpus, so it inherits the same universe.
938    let filter = crate::core::index_filter::IndexFileFilter::resolve(&cfg);
939
940    let walker = ignore::WalkBuilder::new(root)
941        .hidden(true)
942        .git_ignore(filter.respect_gitignore)
943        .git_global(filter.respect_gitignore)
944        .git_exclude(filter.respect_gitignore)
945        .require_git(false)
946        .max_depth(Some(20))
947        .filter_entry(crate::core::walk_filter::keep_entry)
948        .build();
949
950    let mut ignore_patterns: Vec<glob::Pattern> = DEFAULT_BM25_IGNORES
951        .iter()
952        .filter_map(|p| glob::Pattern::new(p).ok())
953        .collect();
954    ignore_patterns.extend(
955        cfg.extra_ignore_patterns
956            .iter()
957            .filter_map(|p| glob::Pattern::new(p).ok()),
958    );
959
960    let mut files: Vec<String> = Vec::new();
961    let mut filtered_out = 0usize;
962    for entry in walker.flatten() {
963        let path = entry.path();
964        if !path.is_file() {
965            continue;
966        }
967        if !crate::core::ingestion::is_ingestible(path) {
968            continue;
969        }
970        let rel = path
971            .strip_prefix(root)
972            .unwrap_or(path)
973            .to_string_lossy()
974            .to_string();
975        if rel.is_empty() {
976            continue;
977        }
978        if ignore_patterns.iter().any(|p| p.matches(&rel)) {
979            continue;
980        }
981        // Match on forward slashes so globs behave identically on Windows;
982        // the index key keeps the platform separator (existing indexes stay
983        // valid).
984        if filter.is_excluded(&rel.replace('\\', "/")) {
985            filtered_out += 1;
986            continue;
987        }
988        if files.len() >= MAX_BM25_FILES {
989            tracing::warn!(
990                "[bm25] file cap reached ({MAX_BM25_FILES}), skipping remaining files in {}",
991                root.display()
992            );
993            break;
994        }
995        files.push(rel);
996    }
997
998    if filtered_out > 0 {
999        tracing::info!(
1000            "[bm25] index filter excluded {filtered_out} files ({})",
1001            filter.summary().unwrap_or_default()
1002        );
1003    }
1004
1005    files.sort();
1006    files.dedup();
1007    files
1008}
1009
1010pub fn is_code_file(path: &Path) -> bool {
1011    let ext = path
1012        .extension()
1013        .and_then(|e| e.to_str())
1014        .unwrap_or("")
1015        .to_lowercase();
1016    matches!(
1017        ext.as_str(),
1018        "rs" | "ts"
1019            | "tsx"
1020            | "js"
1021            | "jsx"
1022            | "py"
1023            | "go"
1024            | "java"
1025            | "c"
1026            | "cc"
1027            | "cpp"
1028            | "h"
1029            | "hpp"
1030            | "rb"
1031            | "cs"
1032            | "kt"
1033            | "swift"
1034            | "php"
1035            | "scala"
1036            | "sql"
1037            | "ex"
1038            | "exs"
1039            | "zig"
1040            | "lua"
1041            | "dart"
1042            | "vue"
1043            | "svelte"
1044    )
1045}