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 carry_over;
8mod chunking;
9pub use chunking::*;
10mod coordinator;
11pub mod coverage;
12mod evict;
13pub(crate) mod external_origin;
14pub use coordinator::{SearchIndexBuildProgress, get_or_start_build};
15mod provider_origin;
16pub use provider_origin::ChunkOrigins;
17mod served_ranking;
18pub mod source_gate;
19#[cfg(test)]
20mod tests;
21mod walk;
22pub use walk::is_code_file;
23use walk::{admitted_scoped_source_paths, list_code_files};
24#[cfg(test)]
25use walk::{list_code_files_with, list_scoped_code_files};
26
27pub(crate) const MAX_BM25_FILES: usize = 5000;
28const CHUNK_COUNT_WARNING: usize = 50_000;
29const ZSTD_LEVEL: i32 = 9;
30
31const DEFAULT_BM25_IGNORES: &[&str] = &[
32    "vendor/**",
33    "dist/**",
34    "build/**",
35    "public/vendor/**",
36    "public/js/**",
37    "public/css/**",
38    "public/build/**",
39    ".next/**",
40    ".nuxt/**",
41    "__pycache__/**",
42    "*.min.js",
43    "*.min.css",
44    "*.bundle.js",
45    "*.chunk.js",
46];
47
48fn max_bm25_cache_bytes() -> u64 {
49    // Single source of truth: `Config::bm25_max_cache_mb_effective` (env override
50    // › explicit config › disk-budget › generous default). Decoupled from the RAM
51    // profile so large repos persist instead of rebuilding forever (issue #249).
52    let mb = std::env::var("LEAN_CTX_BM25_MAX_CACHE_MB")
53        .ok()
54        .and_then(|v| v.parse::<u64>().ok())
55        .unwrap_or_else(|| crate::core::config::Config::load().bm25_max_cache_mb_effective());
56    mb * 1024 * 1024
57}
58
59/// Effective on-disk ceiling (bytes) for the persisted BM25 index. Single source
60/// of truth shared with `doctor` so its "oversized index" warning matches what
61/// `save`/`load` actually enforce.
62pub fn persist_ceiling_bytes() -> u64 {
63    max_bm25_cache_bytes()
64}
65
66/// Outcome of persisting a BM25 index to disk. Distinguishes a real write from a
67/// size-capped refusal so callers never mistake "refused to persist" for
68/// success (the bug behind the perpetual "index warming" report, issue #249).
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum SaveOutcome {
71    /// Written to disk. Carries the compressed (zstd) size in bytes.
72    Persisted { compressed_bytes: u64 },
73    /// Built fine but NOT written — the compressed size exceeds the disk
74    /// ceiling. The in-memory index is still usable for this process; callers
75    /// should surface the remedy (raise the cap / add ignore patterns) instead
76    /// of silently rebuilding on every call.
77    SkippedTooLarge {
78        compressed_bytes: u64,
79        limit_bytes: u64,
80    },
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84pub struct CodeChunk {
85    pub file_path: String,
86    pub symbol_name: String,
87    pub kind: ChunkKind,
88    pub start_line: usize,
89    pub end_line: usize,
90    pub content: String,
91    #[serde(default)]
92    pub tokens: Vec<String>,
93    pub token_count: usize,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
97pub enum ChunkKind {
98    Function,
99    Struct,
100    Impl,
101    Module,
102    Class,
103    Method,
104    Other,
105    // -- External source kinds (Context Engine) --
106    Issue,
107    PullRequest,
108    WikiPage,
109    DbSchema,
110    ApiEndpoint,
111    Ticket,
112    ExternalOther,
113}
114
115#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
116pub struct IndexedFileState {
117    pub mtime_ms: u64,
118    pub size_bytes: u64,
119}
120
121impl IndexedFileState {
122    fn from_path(path: &Path) -> Option<Self> {
123        let meta = path.metadata().ok()?;
124        let size_bytes = meta.len();
125        let mtime_ms = meta
126            .modified()
127            .ok()
128            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
129            .map(|d| d.as_millis() as u64)?;
130        Some(Self {
131            mtime_ms,
132            size_bytes,
133        })
134    }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct BM25Index {
139    pub chunks: Vec<CodeChunk>,
140    pub inverted: HashMap<String, Vec<(usize, f64)>>,
141    pub avg_doc_len: f64,
142    pub doc_count: usize,
143    pub doc_freqs: HashMap<String, usize>,
144    #[serde(default)]
145    pub files: HashMap<String, IndexedFileState>,
146    /// Content fingerprint of every directory that contained an indexed file
147    /// when this index was built, keyed by root-relative path (`""` = the root
148    /// itself). Sampling tracked *files* can never reveal a file that was never
149    /// indexed, so the fast staleness check would keep serving a partial index
150    /// forever after new files appear (#1724). Creating or removing an entry
151    /// bumps its parent directory's mtime on every filesystem we support, so
152    /// comparing this snapshot detects added/deleted files in one stat per
153    /// directory instead of a full ingest walk.
154    #[serde(default)]
155    pub dirs: HashMap<String, u64>,
156    /// Digest of the Context Gateway policy the chunk contents were admitted
157    /// under. An index built under another policy (or before admission
158    /// existed — its postcard layout lacks this field and no longer loads) is
159    /// rebuilt in full, never reused (G5, E3).
160    #[serde(default)]
161    pub admission_policy: Option<String>,
162    /// True once `shrink_resident_content_to_snippet` has trimmed each chunk's
163    /// `content` down to the snippet lines. Resident-only RAM-saving state: never
164    /// persisted (`skip`) so the on-disk index keeps full content, and a reload
165    /// always starts as `false`. Guards the embedding pass against re-embedding
166    /// truncated bodies (see `ensure_embeddings`).
167    #[serde(default, skip)]
168    pub content_truncated: bool,
169    /// Project root of an index its store loaded or handed out (`load`,
170    /// `load_or_build*`): `search` then serves a chunk only while the admitted
171    /// reader still returns its text (`source_gate`, G04). Resident only;
172    /// `None` for a view built in the running call.
173    #[serde(default, skip)]
174    pub source_root: Option<PathBuf>,
175    /// Where each chunk without a readable text file came from, by chunk
176    /// `file_path`. A reference, never a grant (`provider_origin`). Kept
177    /// beside the index file, not in it: the postcard layout is unchanged.
178    #[serde(default, skip)]
179    pub provider_origins: std::sync::Arc<ChunkOrigins>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct SearchResult {
184    pub chunk_idx: usize,
185    pub score: f64,
186    pub file_path: String,
187    pub symbol_name: String,
188    pub kind: ChunkKind,
189    pub start_line: usize,
190    pub end_line: usize,
191    pub snippet: String,
192}
193
194const BM25_K1: f64 = 1.2;
195const BM25_B: f64 = 0.75;
196
197fn structural_query_boost(query: &str, chunk: &CodeChunk) -> f64 {
198    let raw_terms: std::collections::HashSet<String> = query
199        .split(|c: char| !(c.is_alphanumeric() || c == '_'))
200        .filter(|term| term.len() >= 3)
201        .map(str::to_ascii_lowercase)
202        .collect();
203    let path_terms: std::collections::HashSet<String> = tokenize(&chunk.file_path)
204        .into_iter()
205        .map(|term| term.to_ascii_lowercase())
206        .collect();
207
208    let mut boost = 0.0;
209    if raw_terms.contains(&chunk.symbol_name.to_ascii_lowercase()) {
210        boost += 32.0;
211    }
212    boost += raw_terms.intersection(&path_terms).count() as f64 * 8.0;
213
214    let has_identifier = query.split_whitespace().any(|term| {
215        let has_lower = term.chars().any(char::is_lowercase);
216        let has_upper = term.chars().any(char::is_uppercase);
217        (has_lower && has_upper) || term.contains('_') || term.contains('.')
218    });
219    if has_identifier {
220        let extension = std::path::Path::new(&chunk.file_path)
221            .extension()
222            .and_then(std::ffi::OsStr::to_str)
223            .unwrap_or_default()
224            .to_ascii_lowercase();
225        if matches!(extension.as_str(), "md" | "mdx" | "rst" | "txt" | "adoc") {
226            boost -= 8.0;
227        } else if !extension.is_empty() {
228            boost += 4.0;
229        }
230    }
231    boost
232}
233
234impl Default for BM25Index {
235    fn default() -> Self {
236        Self::new()
237    }
238}
239
240/// Sources admitted, then chunked in parallel, per batch of a fresh view.
241const ADMITTED_BATCH: usize = 256;
242/// Chunks one file may add to a fresh view.
243const FILE_CHUNK_COUNT: usize = 4096;
244
245type ChunkedFile = (Vec<CodeChunk>, usize, usize);
246
247/// The chunks of one admitted file in line order, charged against the given
248/// budgets, as a fresh view adds them.
249fn extract_sorted_chunks(
250    path: &str,
251    content: &str,
252    chunk_bytes: &mut usize,
253    chunk_count: &mut usize,
254) -> Result<Vec<CodeChunk>, String> {
255    let mut chunks = extract_admitted_chunks(path, content, chunk_bytes, chunk_count)?;
256    chunks.sort_by(|a, b| {
257        (a.start_line, a.end_line, &a.symbol_name).cmp(&(b.start_line, b.end_line, &b.symbol_name))
258    });
259    Ok(chunks)
260}
261
262/// Each admitted file's own byte allowance and its chunks under that
263/// allowance and the per-file chunk count, computed in parallel: chunking is
264/// a pure function of path and text. `None` for a file the memory guard
265/// stopped before it was chunked; the fold then chunks it itself.
266fn parallel_admitted_chunks(
267    admitted: &[(&str, String)],
268) -> Vec<(usize, Option<Result<ChunkedFile, String>>)> {
269    use rayon::prelude::*;
270    admitted
271        .par_iter()
272        .map(|(path, content)| {
273            let full_allowance =
274                (64 * 1024 * 1024usize).min(content.len().saturating_mul(5).max(64 * 1024));
275            if crate::core::memory_guard::abort_requested() {
276                return (full_allowance, None);
277            }
278            let mut bytes = full_allowance;
279            let mut count = FILE_CHUNK_COUNT;
280            let result = extract_sorted_chunks(path, content, &mut bytes, &mut count)
281                .map(|chunks| (chunks, full_allowance - bytes, FILE_CHUNK_COUNT - count));
282            (full_allowance, Some(result))
283        })
284        .collect()
285}
286
287/// One load–change–save of a persisted index at a time in this process. Two
288/// writers (two provider ingests a moment apart, an eviction after a search)
289/// otherwise each saved their own copy through the one temp file and could
290/// leave an index no reader loads, or drop the other's chunks.
291static PERSIST: std::sync::Mutex<()> = std::sync::Mutex::new(());
292
293pub(crate) fn persist_lock() -> std::sync::MutexGuard<'static, ()> {
294    PERSIST
295        .lock()
296        .unwrap_or_else(std::sync::PoisonError::into_inner)
297}
298
299impl BM25Index {
300    pub fn new() -> Self {
301        Self {
302            chunks: Vec::new(),
303            inverted: HashMap::new(),
304            avg_doc_len: 0.0,
305            doc_count: 0,
306            doc_freqs: HashMap::new(),
307            files: HashMap::new(),
308            dirs: HashMap::new(),
309            admission_policy: None,
310            content_truncated: false,
311            source_root: None,
312            provider_origins: std::sync::Arc::default(),
313        }
314    }
315
316    /// Approximate heap memory used by this index in bytes.
317    pub fn memory_usage_bytes(&self) -> usize {
318        let chunks_size: usize = self
319            .chunks
320            .iter()
321            .map(|c| {
322                c.content.len()
323                    + c.file_path.len()
324                    + c.symbol_name.len()
325                    + c.tokens.iter().map(String::len).sum::<usize>()
326                    + 64
327            })
328            .sum();
329        let inverted_size: usize = self
330            .inverted
331            .iter()
332            .map(|(k, v)| k.len() + v.len() * 16 + 32)
333            .sum();
334        let files_size: usize = self.files.keys().map(|k| k.len() + 24).sum();
335        let dirs_size: usize = self.dirs.keys().map(|k| k.len() + 16).sum();
336        let freqs_size: usize = self.doc_freqs.keys().map(|k| k.len() + 16).sum();
337        chunks_size + inverted_size + files_size + dirs_size + freqs_size
338    }
339
340    /// Drops all in-memory data, effectively freeing heap. Index can be re-loaded from disk.
341    pub fn unload(&mut self) {
342        let usage = self.memory_usage_bytes();
343        self.chunks = Vec::new();
344        self.inverted = HashMap::new();
345        self.doc_freqs = HashMap::new();
346        self.files = HashMap::new();
347        self.dirs = HashMap::new();
348        self.provider_origins = std::sync::Arc::default();
349        self.avg_doc_len = 0.0;
350        self.doc_count = 0;
351        tracing::info!(
352            "[bm25] unloaded index, freed ~{:.1}MB",
353            usage as f64 / 1_048_576.0
354        );
355    }
356
357    /// Shrinks each resident chunk's `content` to its first `keep_lines` lines,
358    /// reclaiming the RAM held by the full source bodies once the embedding pass
359    /// has already consumed them. Query previews can only select lines still
360    /// resident, so this trades preview coverage for memory in cached indexes.
361    /// Fresh admitted retrieval views retain their separately budgeted content.
362    ///
363    /// Further shrinks resident content (chunks are already truncated to 10 lines
364    /// during `add_chunk`; this method can tighten that further). Sets
365    /// `content_truncated` so a later `ensure_embeddings` against this same
366    /// resident index skips re-embedding (which would otherwise feed truncated
367    /// bodies to the embedder).
368    ///
369    /// Idempotent and only ever shrinks: chunks shorter than `keep_lines` are
370    /// left untouched.
371    pub fn shrink_resident_content_to_snippet(&mut self, keep_lines: usize) {
372        let before = self.memory_usage_bytes();
373        for chunk in &mut self.chunks {
374            // Cheap line-count gate: only allocate a new string when the body is
375            // actually longer than the snippet window.
376            if chunk.content.lines().nth(keep_lines).is_some() {
377                let trimmed: String = chunk
378                    .content
379                    .lines()
380                    .take(keep_lines)
381                    .collect::<Vec<_>>()
382                    .join("\n");
383                chunk.content = trimmed;
384                // Reclaim the spare capacity left by the larger original body.
385                chunk.content.shrink_to_fit();
386            }
387        }
388        self.content_truncated = true;
389        let after = self.memory_usage_bytes();
390        tracing::debug!(
391            "[bm25] shrank resident content to {keep_lines} lines/chunk, freed ~{:.1}MB",
392            before.saturating_sub(after) as f64 / 1_048_576.0
393        );
394    }
395
396    /// Builds an index from explicit chunks (unit tests; avoids filesystem walking).
397    #[cfg(any(test, feature = "test-support"))]
398    pub fn from_chunks_for_test(chunks: Vec<CodeChunk>) -> Self {
399        let mut index = Self::new();
400        for mut chunk in chunks {
401            if chunk.token_count == 0 {
402                chunk.token_count = tokenize(&chunk.content).len();
403            }
404            index.add_chunk(chunk);
405        }
406        index.finalize();
407        index
408    }
409
410    pub fn build_from_directory(root: &Path) -> Self {
411        Self::build_from_directory_inner(root, &HashMap::new())
412    }
413
414    /// Ephemeral retrieval view: admit each complete original source before
415    /// tokenization. Never consult metadata-only caches or persist this view.
416    ///
417    /// `subdir` / `in_scope` narrow the walk itself (see
418    /// `walk::list_scoped_code_files`), so the file budget counts only sources the
419    /// caller asked about.
420    pub fn build_from_admitted_sources(
421        root: &Path,
422        subdir: Option<&str>,
423        in_scope: &dyn Fn(&str) -> bool,
424        admit: impl FnMut(&str) -> Result<Option<String>, String>,
425    ) -> Result<Self, String> {
426        let rules = CorpusRules::load();
427        Self::build_scoped_from_admitted_sources(root, &rules, subdir, in_scope, admit)
428    }
429
430    fn build_scoped_from_admitted_sources(
431        root: &Path,
432        rules: &CorpusRules,
433        subdir: Option<&str>,
434        in_scope: &dyn Fn(&str) -> bool,
435        mut admit: impl FnMut(&str) -> Result<Option<String>, String>,
436    ) -> Result<Self, String> {
437        if !is_safe_bm25_root(root) {
438            return Err("source root is not safe to scan".into());
439        }
440        let mut index = Self::new();
441        let mut chunk_bytes = 64 * 1024 * 1024;
442        let mut chunk_count = 100_000;
443        // Memory a finished search dropped is not pressure: return it first.
444        if crate::core::memory_guard::is_under_pressure() {
445            crate::core::memory_guard::reclaim_now();
446        }
447        let paths = admitted_scoped_source_paths(root, rules, subdir, in_scope)?;
448        // Admission runs here, on the request's thread and in its scopes, one
449        // source after the other. Only the chunking of what was admitted — a
450        // pure function of path and text — runs in parallel, per batch; the
451        // fold below applies the budgets in path order exactly as a
452        // sequential build would, so the view is the same either way.
453        for batch in paths.chunks(ADMITTED_BATCH) {
454            let mut admitted = Vec::with_capacity(batch.len());
455            for path in batch {
456                // The working set of one search is freed when it ends: it is
457                // refused at the hard level, not at the soft target the
458                // guardian keeps idle memory under (the build reclaimed above).
459                if crate::core::memory_guard::abort_requested() {
460                    return Err("source search withheld under memory pressure".into());
461                }
462                let Some(content) = admit(path)? else {
463                    continue;
464                };
465                if looks_minified(&content) {
466                    continue;
467                }
468                admitted.push((path.as_str(), content));
469            }
470            let chunked = parallel_admitted_chunks(&admitted);
471            for ((path, content), (full_allowance, unbounded)) in admitted.iter().zip(chunked) {
472                let allowance = chunk_bytes.min(full_allowance);
473                if !crate::core::index_admission::admit(
474                    crate::core::index_admission::BuildKind::Bm25,
475                    allowance as u64,
476                )
477                .parallel_ok
478                {
479                    return Err("source search exceeds available memory headroom".into());
480                }
481                let count_allowance = chunk_count.min(FILE_CHUNK_COUNT);
482                // The parallel result holds only where this file's budgets
483                // were not capped by what earlier files used; otherwise it is
484                // recomputed here under the capped budgets.
485                let (chunks, used_bytes, used_count) = match unbounded {
486                    Some(Ok(result))
487                        if allowance == full_allowance && count_allowance == FILE_CHUNK_COUNT =>
488                    {
489                        result
490                    }
491                    Some(Err(error))
492                        if allowance == full_allowance && count_allowance == FILE_CHUNK_COUNT =>
493                    {
494                        return Err(error);
495                    }
496                    _ => {
497                        let mut file_chunk_bytes = allowance;
498                        let mut file_chunk_count = count_allowance;
499                        let chunks = extract_sorted_chunks(
500                            path,
501                            content,
502                            &mut file_chunk_bytes,
503                            &mut file_chunk_count,
504                        )?;
505                        (
506                            chunks,
507                            allowance - file_chunk_bytes,
508                            count_allowance - file_chunk_count,
509                        )
510                    }
511                };
512                chunk_bytes -= used_bytes;
513                chunk_count -= used_count;
514                for chunk in chunks {
515                    if crate::core::memory_guard::abort_requested() {
516                        return Err("source search withheld under memory pressure".into());
517                    }
518                    // This ephemeral view already charges the full admitted chunk
519                    // against its byte budget; retain it for query-centred previews.
520                    index.add_chunk_with_content_limit(chunk, None);
521                }
522            }
523        }
524        index.finalize();
525        Ok(index)
526    }
527
528    /// Like `build_from_directory` but reuses file content from a prior scan
529    /// (e.g. the graph index walk) to avoid redundant disk reads.
530    pub fn build_with_content_hint(root: &Path, content_hint: &HashMap<String, String>) -> Self {
531        Self::build_from_directory_inner(root, content_hint)
532    }
533
534    fn build_from_directory_inner(root: &Path, content_hint: &HashMap<String, String>) -> Self {
535        let root_str = root.to_string_lossy();
536        if !super::graph_index::is_safe_scan_root_public(&root_str) {
537            tracing::warn!("[bm25: scan aborted for unsafe root {root_str}]");
538            return Self::new();
539        }
540        let files = list_code_files(root);
541        // Snapshot directory mtimes *before* the (slow) chunking pass: a file
542        // that lands while we build then leaves its directory newer than the
543        // snapshot, so the next staleness check sees it instead of silently
544        // pinning the partial tree (#1724).
545        let dirs = dir_states(root, &files);
546        // One policy for the whole build, recorded with the index (G5).
547        let admission = crate::core::context_admission::stores::StoreAdmission::current();
548
549        // #933: parallel fast path for the common case. The per-file parse +
550        // tokenize work is pure and thread-safe, so we fan it across a rayon pool
551        // and merge sequentially in file order — measured 2.85 s → 0.69 s (~4x) on
552        // the lean-ctx repo. Under memory pressure (or for tiny corpora, where pool
553        // setup is not worth it) we fall back to the sequential build, which carries
554        // the incremental early-break. #685 adds admission control: a corpus whose
555        // estimated peak would blow past the guardian's hard threshold degrades to
556        // the sequential build up front instead of OOMing mid-fan-out. Both paths
557        // produce an identical index (see `build` module + determinism tests).
558        if files.len() >= build::PARALLEL_MIN_FILES
559            && !crate::core::memory_guard::is_under_pressure()
560            && !crate::core::memory_guard::abort_requested()
561            && crate::core::index_admission::admit_files(
562                crate::core::index_admission::BuildKind::Bm25,
563                root,
564                &files,
565            )
566            .parallel_ok
567        {
568            let mut index = Self::build_parallel(root, content_hint, &files, &admission);
569            index.dirs = dirs;
570            index.admission_policy = Some(admission.digest().to_owned());
571            return index;
572        }
573        let mut index = Self::build_sequential(root, content_hint, &files, &admission);
574        index.dirs = dirs;
575        index.admission_policy = Some(admission.digest().to_owned());
576        index
577    }
578
579    /// Group a previous index's chunks by file, each file's list sorted by
580    /// (start_line, end_line, symbol_name) — the deterministic reuse order shared
581    /// by both incremental rebuild paths (and the equivalence test, so it feeds
582    /// them identical inputs).
583    fn group_prev_chunks_by_file(prev: &BM25Index) -> HashMap<String, Vec<CodeChunk>> {
584        let mut old_by_file: HashMap<String, Vec<CodeChunk>> = HashMap::new();
585        for c in &prev.chunks {
586            old_by_file
587                .entry(c.file_path.clone())
588                .or_default()
589                .push(c.clone());
590        }
591        for v in old_by_file.values_mut() {
592            v.sort_by(|a, b| {
593                a.start_line
594                    .cmp(&b.start_line)
595                    .then_with(|| a.end_line.cmp(&b.end_line))
596                    .then_with(|| a.symbol_name.cmp(&b.symbol_name))
597            });
598        }
599        old_by_file
600    }
601
602    pub fn rebuild_incremental(root: &Path, prev: &BM25Index) -> Self {
603        // Reused chunks must have been admitted under the current policy.
604        let admission = crate::core::context_admission::stores::StoreAdmission::current();
605        if prev.admission_policy.as_deref() != Some(admission.digest()) {
606            return Self::build_from_directory(root);
607        }
608        let old_by_file = Self::group_prev_chunks_by_file(prev);
609        let files = list_code_files(root);
610        let dirs = dir_states(root, &files);
611
612        // #581: mirror `build()`'s dispatch. The edit loop is the hottest path in
613        // daily use, and its serial cost is dominated by re-tokenizing the *many
614        // unchanged* files' reused chunks — not the few changed ones. So the
615        // parallel path fans the entire tokenization (changed files via
616        // `prepare_file`, unchanged via re-`prepare_chunk`) across the rayon pool
617        // and merges sequentially in file order. The sequential path keeps the
618        // per-file memory-pressure early-break and serves small corpora / pressure.
619        // #685: oversized corpora degrade to sequential via admission control.
620        // Both produce an identical index (see determinism tests).
621        if files.len() >= build::PARALLEL_MIN_FILES
622            && !crate::core::memory_guard::is_under_pressure()
623            && !crate::core::memory_guard::abort_requested()
624            && crate::core::index_admission::admit_files(
625                crate::core::index_admission::BuildKind::Bm25,
626                root,
627                &files,
628            )
629            .parallel_ok
630        {
631            let mut index =
632                Self::rebuild_incremental_parallel(root, prev, &old_by_file, &files, &admission);
633            index.dirs = dirs;
634            index.admission_policy = Some(admission.digest().to_owned());
635            return index;
636        }
637        let mut index =
638            Self::rebuild_incremental_sequential(root, prev, &old_by_file, &files, &admission);
639        index.dirs = dirs;
640        index.admission_policy = Some(admission.digest().to_owned());
641        index
642    }
643
644    /// Sequential incremental rebuild with per-file memory-pressure guards. Reuses
645    /// unchanged files' chunks and re-extracts changed ones. Used for small
646    /// corpora and as the safe fallback under memory pressure.
647    pub(crate) fn rebuild_incremental_sequential(
648        root: &Path,
649        prev: &BM25Index,
650        old_by_file: &HashMap<String, Vec<CodeChunk>>,
651        files: &[String],
652        admission: &crate::core::context_admission::stores::StoreAdmission,
653    ) -> Self {
654        let mut index = Self::new();
655        const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024;
656        let root_key = root.to_string_lossy().to_string();
657        let total = files.len() as u64;
658        crate::core::index_progress::report_bm25(&root_key, 0, total);
659
660        for (i, rel) in files.iter().enumerate() {
661            if i.is_multiple_of(16) || i + 1 == files.len() {
662                crate::core::index_progress::report_bm25(&root_key, (i + 1) as u64, total);
663            }
664            if i.is_multiple_of(500) && crate::core::memory_guard::is_under_pressure() {
665                tracing::warn!(
666                    "[bm25: stopping incremental rebuild at file {i}/{} due to memory pressure]",
667                    files.len()
668                );
669                break;
670            }
671
672            let abs = root.join(rel);
673            let Some(state) = IndexedFileState::from_path(&abs) else {
674                continue;
675            };
676
677            let unchanged = prev.files.get(rel).is_some_and(|old| *old == state);
678            if unchanged
679                && let Some(chunks) = old_by_file.get(rel)
680                && chunks.first().is_some_and(|c| !c.content.is_empty())
681            {
682                for chunk in chunks {
683                    index.add_chunk(chunk.clone());
684                }
685                index.files.insert(rel.clone(), state);
686                continue;
687            }
688
689            if state.size_bytes > MAX_FILE_SIZE_BYTES {
690                continue;
691            }
692            let content = if crate::core::extractors::is_binary_document(&abs) {
693                match std::fs::read(&abs) {
694                    Ok(bytes) => crate::core::extractors::extract(&abs, &bytes).text,
695                    Err(_) => continue,
696                }
697            } else {
698                // An incompletely decoded source (replaced or dropped bytes)
699                // is never a complete source: it does not enter the index.
700                match crate::core::text_decode::read_text_checked(&abs) {
701                    Ok(decoded) if decoded.complete() => decoded.text,
702                    _ => continue,
703                }
704            };
705            if content.is_empty() {
706                continue;
707            }
708            // Derived store: only admitted text, never restricted (E3).
709            let Some(content) = admission.admit(&content, &abs) else {
710                continue;
711            };
712            // #1739: see `build::prepare_file` — same predicate, same position.
713            if looks_minified(&content) {
714                tracing::debug!("[bm25: skipping minified payload {rel}]");
715                continue;
716            }
717            let mut chunks = extract_chunks(rel, &content);
718            chunks.sort_by(|a, b| {
719                a.start_line
720                    .cmp(&b.start_line)
721                    .then_with(|| a.end_line.cmp(&b.end_line))
722                    .then_with(|| a.symbol_name.cmp(&b.symbol_name))
723            });
724            for chunk in chunks {
725                index.add_chunk(chunk);
726            }
727            index.files.insert(rel.clone(), state);
728        }
729
730        index.finalize();
731        index
732    }
733
734    fn add_chunk(&mut self, chunk: CodeChunk) {
735        self.add_chunk_with_content_limit(chunk, Some(10));
736    }
737
738    fn add_chunk_with_content_limit(&mut self, mut chunk: CodeChunk, keep_lines: Option<usize>) {
739        let idx = self.chunks.len();
740
741        let enriched = enrich_for_bm25(&chunk);
742        let tokens = chunking::index_terms(&enriched);
743        for token in &tokens {
744            let lower = token.to_lowercase();
745            let postings = self.inverted.entry(lower.clone()).or_default();
746            if postings.last().map(|(last_idx, _)| *last_idx) != Some(idx) {
747                *self.doc_freqs.entry(lower).or_insert(0) += 1;
748            }
749            postings.push((idx, 1.0));
750        }
751
752        // #790: truncate content to snippet AFTER tokenization — full text was
753        // used for BM25 scoring above; stored content is only for result display.
754        if let Some(keep_lines) = keep_lines.filter(|&n| chunk.content.lines().nth(n).is_some()) {
755            chunk.content = chunk
756                .content
757                .lines()
758                .take(keep_lines)
759                .collect::<Vec<_>>()
760                .join("\n");
761            chunk.content.shrink_to_fit();
762        }
763
764        self.chunks.push(CodeChunk {
765            token_count: tokens.len(),
766            tokens: Vec::new(),
767            ..chunk
768        });
769    }
770
771    fn finalize(&mut self) {
772        self.doc_count = self.chunks.len();
773        if self.doc_count == 0 {
774            return;
775        }
776
777        let total_len: usize = self.chunks.iter().map(|c| c.token_count).sum();
778        self.avg_doc_len = total_len as f64 / self.doc_count as f64;
779    }
780
781    /// Every chunk matching `query`, best first. With `excluded` (one flag
782    /// per chunk) the flagged chunks do not enter the ranking at all: they
783    /// get no score and are left out of the corpus statistics (document
784    /// count, average length, document frequencies), so the result is the
785    /// ranking of an index that never held them (`served_ranking`).
786    fn rank(&self, query: &str, excluded: Option<&[bool]>) -> Vec<SearchResult> {
787        let query_tokens = tokenize(query);
788        if query_tokens.is_empty() || self.doc_count == 0 {
789            return Vec::new();
790        }
791        let (doc_count, avg_doc_len) = match excluded {
792            None => (self.doc_count, self.avg_doc_len),
793            Some(excluded) => self.served_corpus(excluded),
794        };
795        if doc_count == 0 {
796            return Vec::new();
797        }
798
799        // Pre-allocated score array: O(1) per-access vs HashMap overhead.
800        // Kolmogorov-optimal: minimal allocation for the scoring operation.
801        let n = self.chunks.len();
802        let mut scores = vec![0.0f64; n];
803        let mut touched = Vec::with_capacity(n.min(256));
804
805        for token in &query_tokens {
806            let lower = token.to_lowercase();
807            let Some(postings) = self.inverted.get(&lower) else {
808                continue;
809            };
810            let df = match excluded {
811                None => *self.doc_freqs.get(&lower).unwrap_or(&0),
812                Some(excluded) => served_ranking::doc_freq(postings, excluded),
813            };
814            let df = df as f64;
815            if df == 0.0 {
816                continue;
817            }
818
819            let idf = ((doc_count as f64 - df + 0.5) / (df + 0.5) + 1.0).ln();
820
821            for &(idx, weight) in postings {
822                if excluded.is_some_and(|excluded| served_ranking::is_excluded(excluded, idx)) {
823                    continue;
824                }
825                let doc_len = self.chunks[idx].token_count as f64;
826                let norm_len = doc_len / avg_doc_len.max(1.0);
827                let bm25 = idf * (weight * (BM25_K1 + 1.0))
828                    / (weight + BM25_K1 * (1.0 - BM25_B + BM25_B * norm_len));
829
830                if scores[idx] == 0.0 {
831                    touched.push(idx);
832                }
833                scores[idx] += bm25;
834            }
835        }
836
837        for &idx in &touched {
838            scores[idx] += structural_query_boost(query, &self.chunks[idx]);
839        }
840
841        let mut results: Vec<SearchResult> = touched
842            .iter()
843            .filter(|&&idx| scores[idx] > 0.0)
844            .map(|&idx| {
845                let chunk = &self.chunks[idx];
846                let snippet = query_snippet(&chunk.content, &query_tokens);
847                SearchResult {
848                    chunk_idx: idx,
849                    score: scores[idx],
850                    file_path: chunk.file_path.clone(),
851                    symbol_name: chunk.symbol_name.clone(),
852                    kind: chunk.kind.clone(),
853                    start_line: chunk.start_line,
854                    end_line: chunk.end_line,
855                    snippet,
856                }
857            })
858            .collect();
859
860        results.sort_by(|a, b| {
861            b.score
862                .partial_cmp(&a.score)
863                .unwrap_or(std::cmp::Ordering::Equal)
864                .then_with(|| a.file_path.cmp(&b.file_path))
865                .then_with(|| a.symbol_name.cmp(&b.symbol_name))
866                .then_with(|| a.start_line.cmp(&b.start_line))
867                .then_with(|| a.end_line.cmp(&b.end_line))
868        });
869        results
870    }
871
872    pub fn save(&self, root: &Path) -> std::io::Result<SaveOutcome> {
873        if self.chunks.len() > CHUNK_COUNT_WARNING {
874            tracing::warn!(
875                "[bm25] index has {} chunks (threshold {}), consider adding extra_ignore_patterns",
876                self.chunks.len(),
877                CHUNK_COUNT_WARNING
878            );
879        }
880
881        let dir = index_dir(root);
882        std::fs::create_dir_all(&dir)?;
883
884        // #790: stream postcard → zstd → file to avoid holding the full
885        // serialized buffer in memory. Temp-file + rename for atomicity.
886        let target = dir.join("bm25_index.bin.zst");
887        let tmp = dir.join("bm25_index.bin.zst.tmp");
888        {
889            let file = std::fs::File::create(&tmp)?;
890            let buf_writer = std::io::BufWriter::new(file);
891            let mut encoder = zstd::Encoder::new(buf_writer, ZSTD_LEVEL)
892                .map_err(|e| std::io::Error::other(format!("zstd encoder init: {e}")))?;
893            postcard::to_io(self, &mut encoder)
894                .map_err(|e| std::io::Error::other(e.to_string()))?;
895            encoder.finish()?;
896        }
897
898        let compressed_bytes = std::fs::metadata(&tmp)?.len();
899        let max_bytes = max_bm25_cache_bytes();
900        if compressed_bytes > max_bytes {
901            let _ = std::fs::remove_file(&tmp);
902            tracing::warn!(
903                "[bm25] compressed index too large ({:.1} MB, limit {:.0} MB), refusing to persist: {}",
904                compressed_bytes as f64 / 1_048_576.0,
905                max_bytes / (1024 * 1024),
906                dir.display()
907            );
908            return Ok(SaveOutcome::SkippedTooLarge {
909                compressed_bytes,
910                limit_bytes: max_bytes,
911            });
912        }
913
914        tracing::info!(
915            "[bm25] index: {:.1} MB zstd compressed",
916            compressed_bytes as f64 / 1_048_576.0,
917        );
918
919        std::fs::rename(&tmp, &target)?;
920
921        let _ = std::fs::remove_file(dir.join("bm25_index.bin"));
922        let _ = std::fs::remove_file(dir.join("bm25_index.json"));
923
924        let _ = std::fs::write(
925            dir.join("project_root.txt"),
926            root.to_string_lossy().as_bytes(),
927        );
928        self.save_provider_origins(&dir);
929
930        Ok(SaveOutcome::Persisted { compressed_bytes })
931    }
932
933    /// The persisted index, only when it was admitted under the current
934    /// admission policy: chunks admitted under another policy (or a legacy
935    /// index built from raw sources) are never served or reused (G5, E3).
936    pub fn load(root: &Path) -> Option<Self> {
937        let admission = crate::core::context_admission::stores::StoreAdmission::current();
938        // The directory name is a hash of this checkout; the root recorded at
939        // save is the authority. An index recorded for another root is
940        // rebuilt instead of served. (A restored hosted bundle carries no
941        // record; its directory is already this checkout's partition.)
942        let canonical = |path: &Path| crate::core::pathutil::safe_canonicalize_bounded(path, 2000);
943        if std::fs::read_to_string(index_dir(root).join("project_root.txt"))
944            .is_ok_and(|stored| canonical(Path::new(stored.trim())) != canonical(root))
945        {
946            return None;
947        }
948        Self::load_persisted(root)
949            .filter(|idx| idx.admission_policy.as_deref() == Some(admission.digest()))
950            .map(|idx| idx.rooted_at(root).with_provider_origins(&index_dir(root)))
951    }
952
953    fn load_persisted(root: &Path) -> Option<Self> {
954        let dir = index_dir(root);
955        let max_bytes = max_bm25_cache_bytes();
956
957        let zst_path = dir.join("bm25_index.bin.zst");
958        if zst_path.exists() {
959            let meta = std::fs::metadata(&zst_path).ok()?;
960            if meta.len() > max_bytes {
961                tracing::warn!(
962                    "[bm25] compressed index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
963                    meta.len() as f64 / 1_073_741_824.0,
964                    max_bytes / (1024 * 1024),
965                    zst_path.display()
966                );
967                let quarantined = zst_path.with_extension("zst.quarantined");
968                let _ = std::fs::rename(&zst_path, &quarantined);
969                return None;
970            }
971            let compressed = std::fs::read(&zst_path).ok()?;
972            let max_decompressed = max_decompressed_bytes(meta.len());
973            let data = bounded_zstd_decode(&compressed, max_decompressed)?;
974            let idx: Self = postcard::from_bytes(&data).ok()?;
975            return Some(idx);
976        }
977
978        let bin_path = dir.join("bm25_index.bin");
979        if bin_path.exists() {
980            let meta = std::fs::metadata(&bin_path).ok()?;
981            if meta.len() > max_bytes {
982                tracing::warn!(
983                    "[bm25] index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
984                    meta.len() as f64 / 1_073_741_824.0,
985                    max_bytes / (1024 * 1024),
986                    bin_path.display()
987                );
988                let quarantined = bin_path.with_extension("bin.quarantined");
989                let _ = std::fs::rename(&bin_path, &quarantined);
990                return None;
991            }
992            let data = std::fs::read(&bin_path).ok()?;
993            let idx: Self = postcard::from_bytes(&data).ok()?;
994            // Auto-migrate: compress legacy .bin to .bin.zst
995            if let Ok(compressed) = zstd::encode_all(data.as_slice(), ZSTD_LEVEL) {
996                let zst_tmp = zst_path.with_extension("zst.tmp");
997                if std::fs::write(&zst_tmp, &compressed).is_ok()
998                    && std::fs::rename(&zst_tmp, &zst_path).is_ok()
999                {
1000                    tracing::info!(
1001                        "[bm25] migrated {:.1} MB → {:.1} MB zstd",
1002                        data.len() as f64 / 1_048_576.0,
1003                        compressed.len() as f64 / 1_048_576.0
1004                    );
1005                    let _ = std::fs::remove_file(&bin_path);
1006                }
1007            }
1008            return Some(idx);
1009        }
1010
1011        let json_path = dir.join("bm25_index.json");
1012        if json_path.exists() {
1013            let meta = std::fs::metadata(&json_path).ok()?;
1014            if meta.len() > max_bytes {
1015                tracing::warn!(
1016                    "[bm25] index too large ({:.1} GB, limit {:.0} MB), quarantining: {}",
1017                    meta.len() as f64 / 1_073_741_824.0,
1018                    max_bytes / (1024 * 1024),
1019                    json_path.display()
1020                );
1021                let quarantined = json_path.with_extension("json.quarantined");
1022                let _ = std::fs::rename(&json_path, &quarantined);
1023                return None;
1024            }
1025            let data = std::fs::read_to_string(&json_path).ok()?;
1026            return serde_json::from_str(&data).ok();
1027        }
1028
1029        None
1030    }
1031
1032    pub fn load_or_build(root: &Path) -> Self {
1033        Self::load_or_build_inner(root, false)
1034    }
1035
1036    /// Like `load_or_build` but uses a fast sentinel-sampling staleness check
1037    /// that skips the expensive full directory walk for new-file detection.
1038    pub fn load_or_build_fast(root: &Path) -> Self {
1039        Self::load_or_build_inner(root, true)
1040    }
1041
1042    fn load_or_build_inner(root: &Path, fast_stale: bool) -> Self {
1043        if !is_safe_bm25_root(root) {
1044            return Self::default();
1045        }
1046        if let Some(idx) = Self::load(root) {
1047            let stale = if fast_stale {
1048                bm25_index_looks_stale_fast(&idx, root)
1049            } else {
1050                bm25_index_looks_stale(&idx, root)
1051            };
1052            if !stale {
1053                return idx;
1054            }
1055            tracing::debug!(
1056                "[bm25_index: stale index detected for {}; rebuilding]",
1057                root.display()
1058            );
1059            let rebuilt = if idx.files.is_empty() {
1060                Self::build_from_directory(root)
1061            } else {
1062                Self::rebuild_incremental(root, &idx)
1063            }
1064            .carrying_external_from(&idx);
1065            let _ = rebuilt.save(root);
1066            return rebuilt.rooted_at(root);
1067        }
1068
1069        let built = Self::build_from_directory(root);
1070        let _ = built.save(root);
1071        built.rooted_at(root)
1072    }
1073
1074    pub fn index_file_path(root: &Path) -> PathBuf {
1075        let dir = index_dir(root);
1076        let zst = dir.join("bm25_index.bin.zst");
1077        if zst.exists() {
1078            return zst;
1079        }
1080        let bin = dir.join("bm25_index.bin");
1081        if bin.exists() {
1082            return bin;
1083        }
1084        dir.join("bm25_index.json")
1085    }
1086
1087    /// Chunk count of the persisted index without loading it (#1914): postcard
1088    /// writes `chunks` — the first field — as a varint length prefix, so only
1089    /// the first few decompressed bytes are read. `None` for the legacy JSON
1090    /// format or an unreadable file; `Some(0)` for an index that holds nothing.
1091    pub fn persisted_chunk_count(root: &Path) -> Option<u64> {
1092        use std::io::Read;
1093        let path = Self::index_file_path(root);
1094        let file = std::fs::File::open(&path).ok()?;
1095        let mut head: Box<dyn Read> = match path.extension().and_then(|e| e.to_str()) {
1096            Some("zst") => Box::new(zstd::Decoder::new(file).ok()?),
1097            Some("bin") => Box::new(file),
1098            _ => return None,
1099        };
1100        let mut count = 0u64;
1101        for shift in (0..64).step_by(7) {
1102            let mut byte = [0u8; 1];
1103            head.read_exact(&mut byte).ok()?;
1104            count |= u64::from(byte[0] & 0x7f) << shift;
1105            if byte[0] & 0x80 == 0 {
1106                return Some(count);
1107            }
1108        }
1109        None
1110    }
1111}
1112
1113fn is_safe_bm25_root(root: &Path) -> bool {
1114    super::graph_index::is_safe_scan_root_public(&root.to_string_lossy())
1115}
1116
1117fn bm25_index_looks_stale(index: &BM25Index, root: &Path) -> bool {
1118    bm25_index_looks_stale_inner(index, root, false)
1119}
1120
1121/// Fast staleness check: samples a subset of tracked files and skips the
1122/// expensive `list_code_files()` walk for new-file detection.
1123pub fn bm25_index_looks_stale_fast(index: &BM25Index, root: &Path) -> bool {
1124    bm25_index_looks_stale_inner(index, root, true)
1125}
1126
1127fn bm25_index_looks_stale_inner(index: &BM25Index, root: &Path, fast: bool) -> bool {
1128    if index.chunks.is_empty() {
1129        return false;
1130    }
1131
1132    // An index persisted before secret-like paths were excluded from the walk
1133    // must be rebuilt, which drops them (GHSA-rxv5-4c3x-g4q6 A).
1134    if index
1135        .files
1136        .keys()
1137        .any(|rel| crate::core::io_boundary::is_secret_like(Path::new(rel)).is_some())
1138    {
1139        return true;
1140    }
1141
1142    if index.files.is_empty() {
1143        let mut seen = std::collections::HashSet::<&str>::new();
1144        for chunk in &index.chunks {
1145            let rel = chunk.file_path.trim_start_matches(['/', '\\']);
1146            if rel.is_empty() {
1147                continue;
1148            }
1149            if !seen.insert(rel) {
1150                continue;
1151            }
1152            if !root.join(rel).exists() {
1153                return true;
1154            }
1155        }
1156        return false;
1157    }
1158
1159    if fast {
1160        let sample_size = index.files.len().min(SENTINEL_SAMPLE_SIZE);
1161        let step = if index.files.len() > sample_size {
1162            index.files.len() / sample_size
1163        } else {
1164            1
1165        };
1166        for (i, (rel, old_state)) in index.files.iter().enumerate() {
1167            if i % step != 0 {
1168                continue;
1169            }
1170            let abs = root.join(rel);
1171            if !abs.exists() {
1172                return true;
1173            }
1174            let Some(cur) = IndexedFileState::from_path(&abs) else {
1175                return true;
1176            };
1177            if &cur != old_state {
1178                return true;
1179            }
1180        }
1181        // Sampling tracked files says nothing about files that were never
1182        // indexed, so without this the fast path serves a partial tree forever
1183        // (#1724). The directory snapshot closes that hole for one stat per
1184        // indexed directory.
1185        return dirs_look_stale(index, root);
1186    }
1187
1188    for (rel, old_state) in &index.files {
1189        let abs = root.join(rel);
1190        if !abs.exists() {
1191            return true;
1192        }
1193        let Some(cur) = IndexedFileState::from_path(&abs) else {
1194            return true;
1195        };
1196        if &cur != old_state {
1197            return true;
1198        }
1199    }
1200
1201    for rel in list_code_files(root) {
1202        if !index.files.contains_key(&rel) {
1203            return true;
1204        }
1205    }
1206
1207    false
1208}
1209
1210const SENTINEL_SAMPLE_SIZE: usize = 10;
1211
1212/// True when recorded directory fingerprints no longer match the filesystem —
1213/// i.e. a file was added to (or removed from) the indexed tree.
1214///
1215/// An index persisted before directory snapshots existed carries no `dirs`, so
1216/// its freshness cannot be proven cheaply: it is reported stale exactly once,
1217/// and the resulting rebuild records the snapshot for every later call.
1218fn dirs_look_stale(index: &BM25Index, root: &Path) -> bool {
1219    if index.dirs.is_empty() {
1220        return true;
1221    }
1222    for (rel, recorded) in &index.dirs {
1223        let abs = if rel.is_empty() {
1224            root.to_path_buf()
1225        } else {
1226            root.join(rel)
1227        };
1228        if dir_fingerprint(&abs) != Some(*recorded) {
1229            return true;
1230        }
1231    }
1232    false
1233}
1234
1235fn dir_fingerprint(path: &Path) -> Option<u64> {
1236    use std::hash::{Hash, Hasher};
1237
1238    let meta = std::fs::metadata(path).ok()?;
1239    let modified = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
1240    let mut entries = std::fs::read_dir(path)
1241        .ok()?
1242        .filter_map(Result::ok)
1243        .map(|entry| entry.file_name().to_string_lossy().into_owned())
1244        .collect::<Vec<_>>();
1245    entries.sort_unstable();
1246    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1247    modified.as_nanos().hash(&mut hasher);
1248    entries.hash(&mut hasher);
1249    Some(hasher.finish())
1250}
1251
1252/// Content snapshot of the root plus every ancestor directory of an indexed file.
1253/// Ancestors are included so a brand-new top-level directory (whose own mtime
1254/// was never recorded) is still caught through its recorded parent.
1255fn dir_states(root: &Path, files: &[String]) -> HashMap<String, u64> {
1256    let mut rels: std::collections::HashSet<String> = std::collections::HashSet::new();
1257    rels.insert(String::new());
1258    for rel in files {
1259        let mut cur = Path::new(rel);
1260        while let Some(parent) = cur.parent() {
1261            let key = parent.to_string_lossy().to_string();
1262            // Once a directory is known, so are all of its ancestors.
1263            if !rels.insert(key) {
1264                break;
1265            }
1266            cur = parent;
1267        }
1268    }
1269    rels.into_iter()
1270        .filter_map(|rel| {
1271            let abs = if rel.is_empty() {
1272                root.to_path_buf()
1273            } else {
1274                root.join(&rel)
1275            };
1276            dir_fingerprint(&abs).map(|fingerprint| (rel, fingerprint))
1277        })
1278        .collect()
1279}
1280
1281/// Streams the persisted index out, refusing anything past `max_bytes`.
1282///
1283/// The buffer never grows beyond `max_bytes`, so the ceiling passed in *is* the
1284/// worst-case allocation — which is why #1739 hinged on that ceiling being a
1285/// sane number rather than on this loop. Our own writer streams postcard into
1286/// the encoder (#790) and so never records a frame content size, which rules
1287/// out a cheap header pre-check.
1288fn bounded_zstd_decode(compressed: &[u8], max_bytes: u64) -> Option<Vec<u8>> {
1289    use std::io::Read;
1290    let mut decoder = zstd::Decoder::new(compressed).ok()?;
1291    let mut buf = Vec::new();
1292    let mut chunk = vec![0u8; 65536];
1293    let mut total = 0u64;
1294    loop {
1295        let n = decoder.read(&mut chunk).ok()?;
1296        if n == 0 {
1297            break;
1298        }
1299        total += n as u64;
1300        if total > max_bytes {
1301            tracing::warn!(
1302                "[bm25] decompressed index exceeds limit ({:.0} MB > {:.0} MB), aborting load",
1303                total as f64 / (1024.0 * 1024.0),
1304                max_bytes as f64 / (1024.0 * 1024.0)
1305            );
1306            return None;
1307        }
1308        buf.extend_from_slice(&chunk[..n]);
1309    }
1310    Some(buf)
1311}
1312
1313/// Worst-case RAM this process will spend decompressing a persisted index.
1314///
1315/// #1739: this was `max_bm25_cache_bytes() * 20` — twenty times a **disk**
1316/// budget, with no reference to the file at hand or to the machine's memory. On
1317/// the default 512 MB disk budget that authorised a 10 GB allocation for any
1318/// index, however small the file; because `ctx_search` reloads on demand,
1319/// repeated calls stacked to 20 GB RSS and froze the host.
1320///
1321/// The index is decompressed into RAM, so RAM is what has to bound it, and a
1322/// given file cannot plausibly expand past a fixed ratio of its own size.
1323fn max_decompressed_bytes(compressed_bytes: u64) -> u64 {
1324    /// postcard + zstd on an index this shape expands well under this.
1325    const MAX_EXPANSION_RATIO: u64 = 20;
1326    /// Keeps small indices loadable on hosts that report little or no RAM.
1327    const FLOOR: u64 = 64 * 1024 * 1024;
1328
1329    let file_derived = compressed_bytes
1330        .saturating_mul(MAX_EXPANSION_RATIO)
1331        .max(FLOOR);
1332    match crate::core::memory_guard::rss_limit_bytes() {
1333        Some(limit) => file_derived.min(limit.max(FLOOR)),
1334        None => file_derived,
1335    }
1336}
1337
1338fn index_dir(root: &Path) -> PathBuf {
1339    crate::core::index_namespace::vectors_dir(root)
1340}
1341
1342/// Walk depth of the corpus walk. Deeper files are never indexed.
1343const CORPUS_MAX_DEPTH: usize = 20;
1344
1345/// The rules that decide corpus membership, loaded once. Shared by
1346/// [`list_code_files`] and the `index why` diagnostic ([`coverage`]), so the
1347/// explanation can never drift from what the indexer actually does.
1348pub(crate) struct CorpusRules {
1349    filter: crate::core::index_filter::IndexFileFilter,
1350    /// `DEFAULT_BM25_IGNORES` followed by `extra_ignore_patterns`, with the
1351    /// source text kept so a diagnostic can name the pattern that matched.
1352    ignore_patterns: Vec<(String, glob::Pattern)>,
1353    max_files: usize,
1354}
1355
1356impl CorpusRules {
1357    pub(crate) fn load() -> Self {
1358        Self::from_config(&crate::core::config::Config::load())
1359    }
1360
1361    fn from_config(cfg: &crate::core::config::Config) -> Self {
1362        // #735: the declared corpus filter ([index] config + CLI overlay) decides
1363        // membership before anything is chunked; the semantic index chunks this
1364        // corpus, so it inherits the same universe.
1365        let filter = crate::core::index_filter::IndexFileFilter::resolve(cfg);
1366        let ignore_patterns = DEFAULT_BM25_IGNORES
1367            .iter()
1368            .map(|p| (*p).to_string())
1369            .chain(cfg.extra_ignore_patterns.iter().cloned())
1370            .filter_map(|p| glob::Pattern::new(&p).ok().map(|g| (p, g)))
1371            .collect();
1372        // Mirrors graph_index_max_files: 0 = unlimited, default 5000 (MAX_BM25_FILES).
1373        let max_files = if cfg.bm25_max_files == 0 {
1374            usize::MAX
1375        } else {
1376            cfg.bm25_max_files as usize
1377        };
1378        Self {
1379            filter,
1380            ignore_patterns,
1381            max_files,
1382        }
1383    }
1384
1385    /// The corpus walker: gitignore per the filter, bounded depth. Callers add
1386    /// their own `filter_entry`.
1387    fn walker(&self, root: &Path) -> ignore::WalkBuilder {
1388        let mut builder = ignore::WalkBuilder::new(root);
1389        builder
1390            // #1792: the BM25 corpus is authoritative for `ctx_compose`/`ctx_overview`,
1391            // so omitting tracked dotfiles made "no match" indistinguishable from
1392            // "not indexed".
1393            .hidden(crate::core::walk_filter::SKIP_HIDDEN_IN_CONTENT_WALK)
1394            .git_ignore(self.filter.respect_gitignore)
1395            .git_global(self.filter.respect_gitignore)
1396            .git_exclude(self.filter.respect_gitignore)
1397            .require_git(false)
1398            .max_depth(Some(CORPUS_MAX_DEPTH));
1399        builder
1400    }
1401
1402    /// First default/extra ignore pattern matching `rel`.
1403    fn ignore_pattern_for(&self, rel: &str) -> Option<&str> {
1404        self.ignore_patterns
1405            .iter()
1406            .find(|(_, p)| p.matches(rel))
1407            .map(|(src, _)| src.as_str())
1408    }
1409
1410    /// Whether the declared `[index]` filter drops `rel`. Globs match on
1411    /// forward slashes so they behave identically on Windows; the index key
1412    /// keeps the platform separator (existing indexes stay valid).
1413    fn filter_excludes(&self, rel: &str) -> bool {
1414        self.filter.is_excluded(&rel.replace('\\', "/"))
1415    }
1416}