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        let root_key = root.to_string_lossy().to_string();
409        let total = files.len() as u64;
410        crate::core::index_progress::report_bm25(&root_key, 0, total);
411
412        for (i, rel) in files.iter().enumerate() {
413            if i.is_multiple_of(16) || i + 1 == files.len() {
414                crate::core::index_progress::report_bm25(&root_key, (i + 1) as u64, total);
415            }
416            if i.is_multiple_of(500) && crate::core::memory_guard::is_under_pressure() {
417                tracing::warn!(
418                    "[bm25: stopping incremental rebuild at file {i}/{} due to memory pressure]",
419                    files.len()
420                );
421                break;
422            }
423
424            let abs = root.join(rel);
425            let Some(state) = IndexedFileState::from_path(&abs) else {
426                continue;
427            };
428
429            let unchanged = prev.files.get(rel).is_some_and(|old| *old == state);
430            if unchanged
431                && let Some(chunks) = old_by_file.get(rel)
432                && chunks.first().is_some_and(|c| !c.content.is_empty())
433            {
434                for chunk in chunks {
435                    index.add_chunk(chunk.clone());
436                }
437                index.files.insert(rel.clone(), state);
438                continue;
439            }
440
441            if state.size_bytes > MAX_FILE_SIZE_BYTES {
442                continue;
443            }
444            let content = if crate::core::extractors::is_binary_document(&abs) {
445                match std::fs::read(&abs) {
446                    Ok(bytes) => crate::core::extractors::extract(&abs, &bytes).text,
447                    Err(_) => continue,
448                }
449            } else {
450                match std::fs::read_to_string(&abs) {
451                    Ok(c) => c,
452                    Err(_) => continue,
453                }
454            };
455            if content.is_empty() {
456                continue;
457            }
458            let mut chunks = extract_chunks(rel, &content);
459            chunks.sort_by(|a, b| {
460                a.start_line
461                    .cmp(&b.start_line)
462                    .then_with(|| a.end_line.cmp(&b.end_line))
463                    .then_with(|| a.symbol_name.cmp(&b.symbol_name))
464            });
465            for chunk in chunks {
466                index.add_chunk(chunk);
467            }
468            index.files.insert(rel.clone(), state);
469        }
470
471        index.finalize();
472        index
473    }
474
475    fn add_chunk(&mut self, mut chunk: CodeChunk) {
476        let idx = self.chunks.len();
477
478        let enriched = enrich_for_bm25(&chunk);
479        let tokens = tokenize(&enriched);
480        for token in &tokens {
481            let lower = token.to_lowercase();
482            let postings = self.inverted.entry(lower.clone()).or_default();
483            if postings.last().map(|(last_idx, _)| *last_idx) != Some(idx) {
484                *self.doc_freqs.entry(lower).or_insert(0) += 1;
485            }
486            postings.push((idx, 1.0));
487        }
488
489        // #790: truncate content to snippet AFTER tokenization — full text was
490        // used for BM25 scoring above; stored content is only for result display.
491        const SNIPPET_LINES: usize = 10;
492        if chunk.content.lines().nth(SNIPPET_LINES).is_some() {
493            chunk.content = chunk
494                .content
495                .lines()
496                .take(SNIPPET_LINES)
497                .collect::<Vec<_>>()
498                .join("\n");
499            chunk.content.shrink_to_fit();
500        }
501
502        self.chunks.push(CodeChunk {
503            token_count: tokens.len(),
504            tokens: Vec::new(),
505            ..chunk
506        });
507    }
508
509    fn finalize(&mut self) {
510        self.doc_count = self.chunks.len();
511        if self.doc_count == 0 {
512            return;
513        }
514
515        let total_len: usize = self.chunks.iter().map(|c| c.token_count).sum();
516        self.avg_doc_len = total_len as f64 / self.doc_count as f64;
517    }
518
519    pub fn search(&self, query: &str, top_k: usize) -> Vec<SearchResult> {
520        let query_tokens = tokenize(query);
521        if query_tokens.is_empty() || self.doc_count == 0 {
522            return Vec::new();
523        }
524
525        // Pre-allocated score array: O(1) per-access vs HashMap overhead.
526        // Kolmogorov-optimal: minimal allocation for the scoring operation.
527        let n = self.chunks.len();
528        let mut scores = vec![0.0f64; n];
529        let mut touched = Vec::with_capacity(n.min(256));
530
531        for token in &query_tokens {
532            let lower = token.to_lowercase();
533            let df = *self.doc_freqs.get(&lower).unwrap_or(&0) as f64;
534            if df == 0.0 {
535                continue;
536            }
537
538            let idf = ((self.doc_count as f64 - df + 0.5) / (df + 0.5) + 1.0).ln();
539
540            if let Some(postings) = self.inverted.get(&lower) {
541                for &(idx, weight) in postings {
542                    let doc_len = self.chunks[idx].token_count as f64;
543                    let norm_len = doc_len / self.avg_doc_len.max(1.0);
544                    let bm25 = idf * (weight * (BM25_K1 + 1.0))
545                        / (weight + BM25_K1 * (1.0 - BM25_B + BM25_B * norm_len));
546
547                    if scores[idx] == 0.0 {
548                        touched.push(idx);
549                    }
550                    scores[idx] += bm25;
551                }
552            }
553        }
554
555        for &idx in &touched {
556            scores[idx] += structural_query_boost(query, &self.chunks[idx]);
557        }
558
559        let mut results: Vec<SearchResult> = touched
560            .iter()
561            .filter(|&&idx| scores[idx] > 0.0)
562            .map(|&idx| {
563                let chunk = &self.chunks[idx];
564                let snippet = chunk.content.lines().take(5).collect::<Vec<_>>().join("\n");
565                SearchResult {
566                    chunk_idx: idx,
567                    score: scores[idx],
568                    file_path: chunk.file_path.clone(),
569                    symbol_name: chunk.symbol_name.clone(),
570                    kind: chunk.kind.clone(),
571                    start_line: chunk.start_line,
572                    end_line: chunk.end_line,
573                    snippet,
574                }
575            })
576            .collect();
577
578        results.sort_by(|a, b| {
579            b.score
580                .partial_cmp(&a.score)
581                .unwrap_or(std::cmp::Ordering::Equal)
582                .then_with(|| a.file_path.cmp(&b.file_path))
583                .then_with(|| a.symbol_name.cmp(&b.symbol_name))
584                .then_with(|| a.start_line.cmp(&b.start_line))
585                .then_with(|| a.end_line.cmp(&b.end_line))
586        });
587        results.truncate(top_k);
588        results
589    }
590
591    pub fn save(&self, root: &Path) -> std::io::Result<SaveOutcome> {
592        if self.chunks.len() > CHUNK_COUNT_WARNING {
593            tracing::warn!(
594                "[bm25] index has {} chunks (threshold {}), consider adding extra_ignore_patterns",
595                self.chunks.len(),
596                CHUNK_COUNT_WARNING
597            );
598        }
599
600        let dir = index_dir(root);
601        std::fs::create_dir_all(&dir)?;
602
603        // #790: stream postcard → zstd → file to avoid holding the full
604        // serialized buffer in memory. Temp-file + rename for atomicity.
605        let target = dir.join("bm25_index.bin.zst");
606        let tmp = dir.join("bm25_index.bin.zst.tmp");
607        {
608            let file = std::fs::File::create(&tmp)?;
609            let buf_writer = std::io::BufWriter::new(file);
610            let mut encoder = zstd::Encoder::new(buf_writer, ZSTD_LEVEL)
611                .map_err(|e| std::io::Error::other(format!("zstd encoder init: {e}")))?;
612            postcard::to_io(self, &mut encoder)
613                .map_err(|e| std::io::Error::other(e.to_string()))?;
614            encoder.finish()?;
615        }
616
617        let compressed_bytes = std::fs::metadata(&tmp)?.len();
618        let max_bytes = max_bm25_cache_bytes();
619        if compressed_bytes > max_bytes {
620            let _ = std::fs::remove_file(&tmp);
621            tracing::warn!(
622                "[bm25] compressed index too large ({:.1} MB, limit {:.0} MB), refusing to persist: {}",
623                compressed_bytes as f64 / 1_048_576.0,
624                max_bytes / (1024 * 1024),
625                dir.display()
626            );
627            return Ok(SaveOutcome::SkippedTooLarge {
628                compressed_bytes,
629                limit_bytes: max_bytes,
630            });
631        }
632
633        tracing::info!(
634            "[bm25] index: {:.1} MB zstd compressed",
635            compressed_bytes as f64 / 1_048_576.0,
636        );
637
638        std::fs::rename(&tmp, &target)?;
639
640        let _ = std::fs::remove_file(dir.join("bm25_index.bin"));
641        let _ = std::fs::remove_file(dir.join("bm25_index.json"));
642
643        let _ = std::fs::write(
644            dir.join("project_root.txt"),
645            root.to_string_lossy().as_bytes(),
646        );
647
648        Ok(SaveOutcome::Persisted { compressed_bytes })
649    }
650
651    pub fn load(root: &Path) -> Option<Self> {
652        let dir = index_dir(root);
653        let max_bytes = max_bm25_cache_bytes();
654
655        let zst_path = dir.join("bm25_index.bin.zst");
656        if zst_path.exists() {
657            let meta = std::fs::metadata(&zst_path).ok()?;
658            if meta.len() > max_bytes {
659                tracing::warn!(
660                    "[bm25] compressed index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
661                    meta.len() as f64 / 1_073_741_824.0,
662                    max_bytes / (1024 * 1024),
663                    zst_path.display()
664                );
665                let quarantined = zst_path.with_extension("zst.quarantined");
666                let _ = std::fs::rename(&zst_path, &quarantined);
667                return None;
668            }
669            let compressed = std::fs::read(&zst_path).ok()?;
670            let max_decompressed = max_bytes * 20; // allow 20x expansion ratio
671            let data = bounded_zstd_decode(&compressed, max_decompressed)?;
672            let idx: Self = postcard::from_bytes(&data).ok()?;
673            return Some(idx);
674        }
675
676        let bin_path = dir.join("bm25_index.bin");
677        if bin_path.exists() {
678            let meta = std::fs::metadata(&bin_path).ok()?;
679            if meta.len() > max_bytes {
680                tracing::warn!(
681                    "[bm25] index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
682                    meta.len() as f64 / 1_073_741_824.0,
683                    max_bytes / (1024 * 1024),
684                    bin_path.display()
685                );
686                let quarantined = bin_path.with_extension("bin.quarantined");
687                let _ = std::fs::rename(&bin_path, &quarantined);
688                return None;
689            }
690            let data = std::fs::read(&bin_path).ok()?;
691            let idx: Self = postcard::from_bytes(&data).ok()?;
692            // Auto-migrate: compress legacy .bin to .bin.zst
693            if let Ok(compressed) = zstd::encode_all(data.as_slice(), ZSTD_LEVEL) {
694                let zst_tmp = zst_path.with_extension("zst.tmp");
695                if std::fs::write(&zst_tmp, &compressed).is_ok()
696                    && std::fs::rename(&zst_tmp, &zst_path).is_ok()
697                {
698                    tracing::info!(
699                        "[bm25] migrated {:.1} MB → {:.1} MB zstd",
700                        data.len() as f64 / 1_048_576.0,
701                        compressed.len() as f64 / 1_048_576.0
702                    );
703                    let _ = std::fs::remove_file(&bin_path);
704                }
705            }
706            return Some(idx);
707        }
708
709        let json_path = dir.join("bm25_index.json");
710        if json_path.exists() {
711            let meta = std::fs::metadata(&json_path).ok()?;
712            if meta.len() > max_bytes {
713                tracing::warn!(
714                    "[bm25] index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
715                    meta.len() as f64 / 1_073_741_824.0,
716                    max_bytes / (1024 * 1024),
717                    json_path.display()
718                );
719                let quarantined = json_path.with_extension("json.quarantined");
720                let _ = std::fs::rename(&json_path, &quarantined);
721                return None;
722            }
723            let data = std::fs::read_to_string(&json_path).ok()?;
724            return serde_json::from_str(&data).ok();
725        }
726
727        None
728    }
729
730    pub fn load_or_build(root: &Path) -> Self {
731        Self::load_or_build_inner(root, false)
732    }
733
734    /// Like `load_or_build` but uses a fast sentinel-sampling staleness check
735    /// that skips the expensive full directory walk for new-file detection.
736    pub fn load_or_build_fast(root: &Path) -> Self {
737        Self::load_or_build_inner(root, true)
738    }
739
740    fn load_or_build_inner(root: &Path, fast_stale: bool) -> Self {
741        if !is_safe_bm25_root(root) {
742            return Self::default();
743        }
744        if let Some(idx) = Self::load(root) {
745            let stale = if fast_stale {
746                bm25_index_looks_stale_fast(&idx, root)
747            } else {
748                bm25_index_looks_stale(&idx, root)
749            };
750            if !stale {
751                return idx;
752            }
753            tracing::debug!(
754                "[bm25_index: stale index detected for {}; rebuilding]",
755                root.display()
756            );
757            let rebuilt = if idx.files.is_empty() {
758                Self::build_from_directory(root)
759            } else {
760                Self::rebuild_incremental(root, &idx)
761            };
762            let _ = rebuilt.save(root);
763            return rebuilt;
764        }
765
766        let built = Self::build_from_directory(root);
767        let _ = built.save(root);
768        built
769    }
770
771    pub fn index_file_path(root: &Path) -> PathBuf {
772        let dir = index_dir(root);
773        let zst = dir.join("bm25_index.bin.zst");
774        if zst.exists() {
775            return zst;
776        }
777        let bin = dir.join("bm25_index.bin");
778        if bin.exists() {
779            return bin;
780        }
781        dir.join("bm25_index.json")
782    }
783
784    /// Ingest external `ContentChunk`s into the BM25 index.
785    /// Converts each chunk to a `CodeChunk` (backward-compatible) and
786    /// rebuilds the inverted index. Returns the number of chunks ingested.
787    pub fn ingest_content_chunks(
788        &mut self,
789        chunks: impl IntoIterator<Item = super::content_chunk::ContentChunk>,
790    ) -> usize {
791        let mut count = 0usize;
792        for cc in chunks {
793            self.add_chunk(cc.into());
794            count += 1;
795        }
796        if count > 0 {
797            self.finalize();
798        }
799        count
800    }
801
802    /// Number of chunks originating from external providers.
803    pub fn external_chunk_count(&self) -> usize {
804        self.chunks
805            .iter()
806            .filter(|c| c.file_path.contains("://"))
807            .count()
808    }
809
810    /// Remove every chunk whose `file_path` starts with `prefix` (e.g.
811    /// `health://`) and rebuild the inverted index. Lets a recomputed source
812    /// (like the code-health fabric) evict its prior pass so stale entries never
813    /// linger in search. Returns the number of chunks removed.
814    pub fn remove_chunks_with_prefix(&mut self, prefix: &str) -> usize {
815        let before = self.chunks.len();
816        self.chunks.retain(|c| !c.file_path.starts_with(prefix));
817        let removed = before - self.chunks.len();
818        if removed > 0 {
819            self.finalize();
820        }
821        removed
822    }
823}
824
825fn is_safe_bm25_root(root: &Path) -> bool {
826    super::graph_index::is_safe_scan_root_public(&root.to_string_lossy())
827}
828
829fn bm25_index_looks_stale(index: &BM25Index, root: &Path) -> bool {
830    bm25_index_looks_stale_inner(index, root, false)
831}
832
833/// Fast staleness check: samples a subset of tracked files and skips the
834/// expensive `list_code_files()` walk for new-file detection.
835pub fn bm25_index_looks_stale_fast(index: &BM25Index, root: &Path) -> bool {
836    bm25_index_looks_stale_inner(index, root, true)
837}
838
839fn bm25_index_looks_stale_inner(index: &BM25Index, root: &Path, fast: bool) -> bool {
840    if index.chunks.is_empty() {
841        return false;
842    }
843
844    if index.files.is_empty() {
845        let mut seen = std::collections::HashSet::<&str>::new();
846        for chunk in &index.chunks {
847            let rel = chunk.file_path.trim_start_matches(['/', '\\']);
848            if rel.is_empty() {
849                continue;
850            }
851            if !seen.insert(rel) {
852                continue;
853            }
854            if !root.join(rel).exists() {
855                return true;
856            }
857        }
858        return false;
859    }
860
861    if fast {
862        let sample_size = index.files.len().min(SENTINEL_SAMPLE_SIZE);
863        let step = if index.files.len() > sample_size {
864            index.files.len() / sample_size
865        } else {
866            1
867        };
868        for (i, (rel, old_state)) in index.files.iter().enumerate() {
869            if i % step != 0 {
870                continue;
871            }
872            let abs = root.join(rel);
873            if !abs.exists() {
874                return true;
875            }
876            let Some(cur) = IndexedFileState::from_path(&abs) else {
877                return true;
878            };
879            if &cur != old_state {
880                return true;
881            }
882        }
883        return false;
884    }
885
886    for (rel, old_state) in &index.files {
887        let abs = root.join(rel);
888        if !abs.exists() {
889            return true;
890        }
891        let Some(cur) = IndexedFileState::from_path(&abs) else {
892            return true;
893        };
894        if &cur != old_state {
895            return true;
896        }
897    }
898
899    for rel in list_code_files(root) {
900        if !index.files.contains_key(&rel) {
901            return true;
902        }
903    }
904
905    false
906}
907
908const SENTINEL_SAMPLE_SIZE: usize = 10;
909
910fn bounded_zstd_decode(compressed: &[u8], max_bytes: u64) -> Option<Vec<u8>> {
911    use std::io::Read;
912    let mut decoder = zstd::Decoder::new(compressed).ok()?;
913    let mut buf = Vec::new();
914    let mut chunk = vec![0u8; 65536];
915    let mut total = 0u64;
916    loop {
917        let n = decoder.read(&mut chunk).ok()?;
918        if n == 0 {
919            break;
920        }
921        total += n as u64;
922        if total > max_bytes {
923            tracing::warn!(
924                "[bm25] decompressed index exceeds limit ({:.0} MB > {:.0} MB), aborting load",
925                total as f64 / (1024.0 * 1024.0),
926                max_bytes as f64 / (1024.0 * 1024.0)
927            );
928            return None;
929        }
930        buf.extend_from_slice(&chunk[..n]);
931    }
932    Some(buf)
933}
934
935fn index_dir(root: &Path) -> PathBuf {
936    crate::core::index_namespace::vectors_dir(root)
937}
938
939fn list_code_files(root: &Path) -> Vec<String> {
940    let cfg = crate::core::config::Config::load();
941    // #735: the declared corpus filter ([index] config + CLI overlay) decides
942    // membership before anything is chunked; the semantic index chunks this
943    // corpus, so it inherits the same universe.
944    let filter = crate::core::index_filter::IndexFileFilter::resolve(&cfg);
945
946    let walker = ignore::WalkBuilder::new(root)
947        .hidden(true)
948        .git_ignore(filter.respect_gitignore)
949        .git_global(filter.respect_gitignore)
950        .git_exclude(filter.respect_gitignore)
951        .require_git(false)
952        .max_depth(Some(20))
953        .filter_entry(crate::core::walk_filter::keep_entry)
954        .build();
955
956    let mut ignore_patterns: Vec<glob::Pattern> = DEFAULT_BM25_IGNORES
957        .iter()
958        .filter_map(|p| glob::Pattern::new(p).ok())
959        .collect();
960    ignore_patterns.extend(
961        cfg.extra_ignore_patterns
962            .iter()
963            .filter_map(|p| glob::Pattern::new(p).ok()),
964    );
965
966    let mut files: Vec<String> = Vec::new();
967    let mut filtered_out = 0usize;
968    for entry in walker.flatten() {
969        let path = entry.path();
970        if !path.is_file() {
971            continue;
972        }
973        if !crate::core::ingestion::is_ingestible(path) {
974            continue;
975        }
976        let rel = path
977            .strip_prefix(root)
978            .unwrap_or(path)
979            .to_string_lossy()
980            .to_string();
981        if rel.is_empty() {
982            continue;
983        }
984        if ignore_patterns.iter().any(|p| p.matches(&rel)) {
985            continue;
986        }
987        // Match on forward slashes so globs behave identically on Windows;
988        // the index key keeps the platform separator (existing indexes stay
989        // valid).
990        if filter.is_excluded(&rel.replace('\\', "/")) {
991            filtered_out += 1;
992            continue;
993        }
994        if files.len() >= MAX_BM25_FILES {
995            tracing::warn!(
996                "[bm25] file cap reached ({MAX_BM25_FILES}), skipping remaining files in {}",
997                root.display()
998            );
999            break;
1000        }
1001        files.push(rel);
1002    }
1003
1004    if filtered_out > 0 {
1005        tracing::info!(
1006            "[bm25] index filter excluded {filtered_out} files ({})",
1007            filter.summary().unwrap_or_default()
1008        );
1009    }
1010
1011    files.sort();
1012    files.dedup();
1013    files
1014}
1015
1016pub fn is_code_file(path: &Path) -> bool {
1017    let ext = path
1018        .extension()
1019        .and_then(|e| e.to_str())
1020        .unwrap_or("")
1021        .to_lowercase();
1022    matches!(
1023        ext.as_str(),
1024        "rs" | "ts"
1025            | "tsx"
1026            | "js"
1027            | "jsx"
1028            | "py"
1029            | "go"
1030            | "java"
1031            | "c"
1032            | "cc"
1033            | "cpp"
1034            | "h"
1035            | "hpp"
1036            | "rb"
1037            | "cs"
1038            | "kt"
1039            | "swift"
1040            | "php"
1041            | "scala"
1042            | "sql"
1043            | "ex"
1044            | "exs"
1045            | "zig"
1046            | "lua"
1047            | "dart"
1048            | "vue"
1049            | "svelte"
1050    )
1051}