lean_ctx/core/
index_admission.rs1use std::path::Path;
18
19const BM25_EXPANSION_FACTOR: u64 = 5;
25const GRAPH_EXPANSION_FACTOR: u64 = 2;
26
27const BUILDER_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum BuildKind {
33 Bm25,
34 GraphScan,
35}
36
37impl BuildKind {
38 fn expansion_factor(self) -> u64 {
39 match self {
40 Self::Bm25 => BM25_EXPANSION_FACTOR,
41 Self::GraphScan => GRAPH_EXPANSION_FACTOR,
42 }
43 }
44
45 fn label(self) -> &'static str {
46 match self {
47 Self::Bm25 => "bm25",
48 Self::GraphScan => "graph",
49 }
50 }
51}
52
53#[derive(Debug, Clone)]
55pub struct Admission {
56 pub parallel_ok: bool,
59 pub reason: Option<String>,
61}
62
63impl Admission {
64 fn admitted() -> Self {
65 Self {
66 parallel_ok: true,
67 reason: None,
68 }
69 }
70}
71
72#[must_use]
81pub fn admit(kind: BuildKind, corpus_bytes: u64) -> Admission {
82 if cfg!(test) {
86 return Admission::admitted();
87 }
88 let Some(limit) = super::memory_guard::rss_limit_bytes() else {
89 return Admission::admitted();
91 };
92 let rss = super::memory_guard::get_rss_bytes().unwrap_or(0);
93 admit_with(kind, corpus_bytes, rss, limit)
94}
95
96fn admit_with(kind: BuildKind, corpus_bytes: u64, rss_bytes: u64, limit_bytes: u64) -> Admission {
98 let estimated = corpus_bytes.saturating_mul(kind.expansion_factor());
99 let hard_threshold = limit_bytes.saturating_mul(3) / 2;
100 let available = hard_threshold.saturating_sub(rss_bytes);
101
102 if estimated <= available {
103 return Admission::admitted();
104 }
105
106 Admission {
107 parallel_ok: false,
108 reason: Some(format!(
109 "{} corpus {:.0} MB × {} ≈ {:.0} MB estimated peak exceeds the {:.0} MB headroom \
110 (RSS {:.0} MB, hard limit {:.0} MB = 1.5× max_ram_percent) — degrading to the \
111 sequential build with memory-pressure breaks",
112 kind.label(),
113 corpus_bytes as f64 / 1_048_576.0,
114 kind.expansion_factor(),
115 estimated as f64 / 1_048_576.0,
116 available as f64 / 1_048_576.0,
117 rss_bytes as f64 / 1_048_576.0,
118 hard_threshold as f64 / 1_048_576.0,
119 )),
120 }
121}
122
123#[must_use]
129pub fn corpus_bytes_capped(root: &Path, files: &[String], cap: u64) -> u64 {
130 let mut total: u64 = 0;
131 for rel in files {
132 if let Ok(meta) = std::fs::metadata(root.join(rel)) {
133 let len = meta.len();
134 if len > BUILDER_MAX_FILE_BYTES {
135 continue;
136 }
137 total = total.saturating_add(len);
138 if total > cap {
139 return total;
140 }
141 }
142 }
143 total
144}
145
146#[must_use]
149pub fn admit_files(kind: BuildKind, root: &Path, files: &[String]) -> Admission {
150 let Some(limit) = super::memory_guard::rss_limit_bytes() else {
151 return Admission::admitted();
152 };
153 let rss = super::memory_guard::get_rss_bytes().unwrap_or(0);
154 let available = (limit.saturating_mul(3) / 2).saturating_sub(rss);
155 let bail_cap = available / kind.expansion_factor().max(1);
158 let corpus = corpus_bytes_capped(root, files, bail_cap);
159 let admission = admit_with(kind, corpus, rss, limit);
160 if let Some(ref reason) = admission.reason {
161 tracing::warn!("[index_admission] {reason}");
162 }
163 admission
164}
165
166#[cfg(test)]
167pub mod tests {
168 use super::*;
169
170 const MB: u64 = 1_048_576;
171
172 #[test]
173 fn small_corpus_is_admitted() {
174 let a = admit_with(BuildKind::Bm25, 50 * MB, 500 * MB, 2_048 * MB);
176 assert!(a.parallel_ok);
177 assert!(a.reason.is_none());
178 }
179
180 #[test]
181 fn oversized_corpus_degrades_to_sequential() {
182 let a = admit_with(BuildKind::Bm25, 8 * 1024 * MB, 1024 * MB, 4_810 * MB);
184 assert!(!a.parallel_ok);
185 let reason = a.reason.expect("denial carries a reason");
186 assert!(reason.contains("sequential build"), "reason: {reason}");
187 }
188
189 #[test]
190 fn graph_factor_is_smaller_than_bm25() {
191 let corpus = 3 * 1024 * MB;
193 let rss = 1024 * MB;
194 let limit = 4_810 * MB;
195 assert!(!admit_with(BuildKind::Bm25, corpus, rss, limit).parallel_ok);
196 assert!(admit_with(BuildKind::GraphScan, corpus, rss, limit).parallel_ok);
197 }
198
199 #[test]
200 fn high_rss_shrinks_headroom() {
201 let corpus = 500 * MB;
203 let limit = 2_048 * MB;
204 assert!(admit_with(BuildKind::Bm25, corpus, 100 * MB, limit).parallel_ok);
205 assert!(!admit_with(BuildKind::Bm25, corpus, 3_900 * MB, limit).parallel_ok);
206 }
207
208 #[test]
209 fn corpus_walk_skips_oversized_and_missing_files() {
210 let tmp = tempfile::tempdir().unwrap();
211 std::fs::write(tmp.path().join("small.rs"), vec![b'x'; 1000]).unwrap();
212 std::fs::write(
213 tmp.path().join("big.bin"),
214 vec![b'x'; (BUILDER_MAX_FILE_BYTES + 1) as usize],
215 )
216 .unwrap();
217 let files = vec![
218 "small.rs".to_string(),
219 "big.bin".to_string(),
220 "missing.rs".to_string(),
221 ];
222 assert_eq!(corpus_bytes_capped(tmp.path(), &files, u64::MAX), 1000);
223 }
224
225 #[test]
226 fn corpus_walk_bails_early_at_cap() {
227 let tmp = tempfile::tempdir().unwrap();
228 for i in 0..10 {
229 std::fs::write(tmp.path().join(format!("f{i}.rs")), vec![b'x'; 1000]).unwrap();
230 }
231 let files: Vec<String> = (0..10).map(|i| format!("f{i}.rs")).collect();
232 let total = corpus_bytes_capped(tmp.path(), &files, 2_500);
234 assert!(total > 2_500 && total < 10_000, "total: {total}");
235 }
236}