1use serde::Serialize;
10use std::cmp::Ordering;
11
12use super::memory_archive::{ArchiveConfig, MemoryStore, archive_items};
13
14pub fn reclaim_target(max: usize, headroom_pct: f32) -> usize {
19 if max == 0 {
20 return 0;
21 }
22 let pct = headroom_pct.clamp(0.0, 0.95);
23 let drop = ((max as f32) * pct).ceil() as usize;
24 max.saturating_sub(drop)
25}
26
27pub fn should_reclaim(len: usize, max: usize, enabled: bool) -> bool {
31 enabled && max > 0 && len >= max
32}
33
34pub fn reclaim_preview(len: usize, max: usize, headroom_pct: f32, enabled: bool) -> usize {
37 if !should_reclaim(len, max, enabled) {
38 return 0;
39 }
40 len.saturating_sub(reclaim_target(max, headroom_pct))
41}
42
43pub fn reclaim_store<T, F>(
52 store: MemoryStore,
53 scope: Option<&str>,
54 items: &mut Vec<T>,
55 max: usize,
56 headroom_pct: f32,
57 enabled: bool,
58 mut retention_cmp: F,
59) -> Result<Vec<T>, String>
60where
61 T: Serialize,
62 F: FnMut(&T, &T) -> Ordering,
63{
64 if !should_reclaim(items.len(), max, enabled) {
65 return Ok(Vec::new());
66 }
67 let target = reclaim_target(max, headroom_pct);
68 let drop_count = items.len().saturating_sub(target);
69 if drop_count == 0 {
70 return Ok(Vec::new());
71 }
72
73 let mut ranked: Vec<usize> = (0..items.len()).collect();
77 ranked.sort_by(|&a, &b| retention_cmp(&items[a], &items[b]));
78 let mut evict: Vec<usize> = ranked[target..].to_vec();
79 evict.sort_unstable();
80
81 let candidates: Vec<&T> = evict.iter().map(|&idx| &items[idx]).collect();
84 archive_items(store, scope, &candidates, &ArchiveConfig::from_env())?;
85
86 let mut archived: Vec<T> = Vec::with_capacity(evict.len());
89 for &idx in evict.iter().rev() {
90 archived.push(items.remove(idx));
91 }
92 archived.reverse();
93 Ok(archived)
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use chrono::Utc;
100 use serde::Deserialize;
101
102 fn with_temp_data_dir<T>(f: impl FnOnce() -> T) -> T {
103 let _lock = crate::core::data_dir::test_env_lock();
104 let dir = std::env::temp_dir().join(format!(
105 "lctx-capacity-{}-{}",
106 std::process::id(),
107 Utc::now().timestamp_nanos_opt().unwrap_or(0)
108 ));
109 let _ = std::fs::create_dir_all(&dir);
110 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
111 let out = f();
112 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
113 let _ = std::fs::remove_dir_all(&dir);
114 out
115 }
116
117 #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
118 struct Item {
119 rank: u32,
120 }
121
122 #[test]
123 fn reclaim_target_matches_legacy_quarter_reclaim() {
124 let legacy = |max: usize| max.saturating_sub(max.div_ceil(4));
127 for max in [1usize, 2, 3, 4, 5, 6, 7, 8, 10, 100, 200, 1000] {
128 assert_eq!(
129 reclaim_target(max, 0.25),
130 legacy(max),
131 "mismatch at max={max}"
132 );
133 }
134 }
135
136 #[test]
137 fn reclaim_target_zero_headroom_keeps_all() {
138 assert_eq!(reclaim_target(200, 0.0), 200);
139 }
140
141 #[test]
142 fn reclaim_target_is_clamped() {
143 assert!(reclaim_target(100, 9.9) >= 5);
145 }
146
147 #[test]
148 fn should_reclaim_hysteresis() {
149 assert!(!should_reclaim(99, 100, true), "under cap: no reclaim");
150 assert!(should_reclaim(100, 100, true), "at cap: reclaim");
151 assert!(should_reclaim(150, 100, true), "over cap: reclaim");
152 assert!(!should_reclaim(150, 100, false), "disabled: no reclaim");
153 assert!(!should_reclaim(150, 0, true), "max 0: no reclaim");
154 }
155
156 #[test]
157 fn reclaim_store_is_lossless_and_keeps_best() {
158 with_temp_data_dir(|| {
159 let mut items: Vec<Item> = (0..8).map(|rank| Item { rank }).collect();
161 let archived = reclaim_store(
162 MemoryStore::Patterns,
163 Some("p"),
164 &mut items,
165 8,
166 0.25,
167 true,
168 |a, b| a.rank.cmp(&b.rank),
169 )
170 .expect("reclaim succeeds");
171 assert_eq!(items.len(), 6);
173 assert_eq!(archived.len(), 2);
174 let mut all: Vec<u32> = items.iter().chain(&archived).map(|i| i.rank).collect();
176 all.sort_unstable();
177 assert_eq!(all, (0..8).collect::<Vec<_>>());
178 assert_eq!(
180 archived.iter().map(|i| i.rank).collect::<Vec<_>>(),
181 vec![6, 7]
182 );
183 });
184 }
185
186 #[test]
187 fn reclaim_store_noop_under_cap() {
188 with_temp_data_dir(|| {
189 let mut items: Vec<Item> = (0..3).map(|rank| Item { rank }).collect();
190 let archived = reclaim_store(
191 MemoryStore::History,
192 Some("p"),
193 &mut items,
194 10,
195 0.25,
196 true,
197 |a, b| a.rank.cmp(&b.rank),
198 )
199 .expect("reclaim succeeds");
200 assert!(archived.is_empty());
201 assert_eq!(items.len(), 3);
202 });
203 }
204
205 #[test]
206 fn reclaim_store_respects_disabled() {
207 with_temp_data_dir(|| {
208 let mut items: Vec<Item> = (0..20).map(|rank| Item { rank }).collect();
209 let archived = reclaim_store(
210 MemoryStore::Procedures,
211 Some("p"),
212 &mut items,
213 10,
214 0.25,
215 false,
216 |a, b| a.rank.cmp(&b.rank),
217 )
218 .expect("reclaim succeeds");
219 assert!(archived.is_empty());
220 assert_eq!(
221 items.len(),
222 20,
223 "disabled reclaim leaves the store untouched"
224 );
225 });
226 }
227
228 #[test]
229 fn reclaim_store_preserves_items_when_archive_persistence_fails() {
230 let _lock = crate::core::data_dir::test_env_lock();
231 let path = std::env::temp_dir().join(format!(
232 "lctx-capacity-file-{}-{}",
233 std::process::id(),
234 Utc::now().timestamp_nanos_opt().unwrap_or(0)
235 ));
236 std::fs::write(&path, b"not a directory").expect("create blocking file");
237 crate::test_env::set_var("LEAN_CTX_DATA_DIR", path.to_str().unwrap());
238
239 let mut items: Vec<Item> = (0..8).map(|rank| Item { rank }).collect();
240 let original = items.clone();
241 let result = reclaim_store(
242 MemoryStore::Patterns,
243 Some("p"),
244 &mut items,
245 8,
246 0.25,
247 true,
248 |a, b| a.rank.cmp(&b.rank),
249 );
250
251 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
252 let _ = std::fs::remove_file(path);
253 assert!(result.is_err(), "archive persistence must fail");
254 assert_eq!(items, original, "failed reclaim must not mutate live items");
255 }
256}