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 let Some(limit) = super::memory_guard::rss_limit_bytes() else {
83 return Admission::admitted();
85 };
86 let rss = super::memory_guard::get_rss_bytes().unwrap_or(0);
87 admit_with(kind, corpus_bytes, rss, limit)
88}
89
90fn admit_with(kind: BuildKind, corpus_bytes: u64, rss_bytes: u64, limit_bytes: u64) -> Admission {
92 let estimated = corpus_bytes.saturating_mul(kind.expansion_factor());
93 let hard_threshold = limit_bytes.saturating_mul(3) / 2;
94 let available = hard_threshold.saturating_sub(rss_bytes);
95
96 if estimated <= available {
97 return Admission::admitted();
98 }
99
100 Admission {
101 parallel_ok: false,
102 reason: Some(format!(
103 "{} corpus {:.0} MB × {} ≈ {:.0} MB estimated peak exceeds the {:.0} MB headroom \
104 (RSS {:.0} MB, hard limit {:.0} MB = 1.5× max_ram_percent) — degrading to the \
105 sequential build with memory-pressure breaks",
106 kind.label(),
107 corpus_bytes as f64 / 1_048_576.0,
108 kind.expansion_factor(),
109 estimated as f64 / 1_048_576.0,
110 available as f64 / 1_048_576.0,
111 rss_bytes as f64 / 1_048_576.0,
112 hard_threshold as f64 / 1_048_576.0,
113 )),
114 }
115}
116
117#[must_use]
123pub fn corpus_bytes_capped(root: &Path, files: &[String], cap: u64) -> u64 {
124 let mut total: u64 = 0;
125 for rel in files {
126 if let Ok(meta) = std::fs::metadata(root.join(rel)) {
127 let len = meta.len();
128 if len > BUILDER_MAX_FILE_BYTES {
129 continue;
130 }
131 total = total.saturating_add(len);
132 if total > cap {
133 return total;
134 }
135 }
136 }
137 total
138}
139
140#[must_use]
143pub fn admit_files(kind: BuildKind, root: &Path, files: &[String]) -> Admission {
144 let Some(limit) = super::memory_guard::rss_limit_bytes() else {
145 return Admission::admitted();
146 };
147 let rss = super::memory_guard::get_rss_bytes().unwrap_or(0);
148 let available = (limit.saturating_mul(3) / 2).saturating_sub(rss);
149 let bail_cap = available / kind.expansion_factor().max(1);
152 let corpus = corpus_bytes_capped(root, files, bail_cap);
153 let admission = admit_with(kind, corpus, rss, limit);
154 if let Some(ref reason) = admission.reason {
155 tracing::warn!("[index_admission] {reason}");
156 }
157 admission
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 const MB: u64 = 1_048_576;
165
166 #[test]
167 fn small_corpus_is_admitted() {
168 let a = admit_with(BuildKind::Bm25, 50 * MB, 500 * MB, 2_048 * MB);
170 assert!(a.parallel_ok);
171 assert!(a.reason.is_none());
172 }
173
174 #[test]
175 fn oversized_corpus_degrades_to_sequential() {
176 let a = admit_with(BuildKind::Bm25, 8 * 1024 * MB, 1024 * MB, 4_810 * MB);
178 assert!(!a.parallel_ok);
179 let reason = a.reason.expect("denial carries a reason");
180 assert!(reason.contains("sequential build"), "reason: {reason}");
181 }
182
183 #[test]
184 fn graph_factor_is_smaller_than_bm25() {
185 let corpus = 3 * 1024 * MB;
187 let rss = 1024 * MB;
188 let limit = 4_810 * MB;
189 assert!(!admit_with(BuildKind::Bm25, corpus, rss, limit).parallel_ok);
190 assert!(admit_with(BuildKind::GraphScan, corpus, rss, limit).parallel_ok);
191 }
192
193 #[test]
194 fn high_rss_shrinks_headroom() {
195 let corpus = 500 * MB;
197 let limit = 2_048 * MB;
198 assert!(admit_with(BuildKind::Bm25, corpus, 100 * MB, limit).parallel_ok);
199 assert!(!admit_with(BuildKind::Bm25, corpus, 3_900 * MB, limit).parallel_ok);
200 }
201
202 #[test]
203 fn corpus_walk_skips_oversized_and_missing_files() {
204 let tmp = tempfile::tempdir().unwrap();
205 std::fs::write(tmp.path().join("small.rs"), vec![b'x'; 1000]).unwrap();
206 std::fs::write(
207 tmp.path().join("big.bin"),
208 vec![b'x'; (BUILDER_MAX_FILE_BYTES + 1) as usize],
209 )
210 .unwrap();
211 let files = vec![
212 "small.rs".to_string(),
213 "big.bin".to_string(),
214 "missing.rs".to_string(),
215 ];
216 assert_eq!(corpus_bytes_capped(tmp.path(), &files, u64::MAX), 1000);
217 }
218
219 #[test]
220 fn corpus_walk_bails_early_at_cap() {
221 let tmp = tempfile::tempdir().unwrap();
222 for i in 0..10 {
223 std::fs::write(tmp.path().join(format!("f{i}.rs")), vec![b'x'; 1000]).unwrap();
224 }
225 let files: Vec<String> = (0..10).map(|i| format!("f{i}.rs")).collect();
226 let total = corpus_bytes_capped(tmp.path(), &files, 2_500);
228 assert!(total > 2_500 && total < 10_000, "total: {total}");
229 }
230}