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>(
51 store: MemoryStore,
52 scope: Option<&str>,
53 items: &mut Vec<T>,
54 max: usize,
55 headroom_pct: f32,
56 enabled: bool,
57 mut retention_cmp: F,
58) -> Vec<T>
59where
60 T: Serialize,
61 F: FnMut(&T, &T) -> Ordering,
62{
63 if !should_reclaim(items.len(), max, enabled) {
64 return Vec::new();
65 }
66 let target = reclaim_target(max, headroom_pct);
67 let drop_count = items.len().saturating_sub(target);
68 if drop_count == 0 {
69 return Vec::new();
70 }
71
72 let mut ranked: Vec<usize> = (0..items.len()).collect();
78 ranked.sort_by(|&a, &b| retention_cmp(&items[a], &items[b]));
79 let mut evict: Vec<usize> = ranked[target..].to_vec();
80 evict.sort_unstable();
81
82 let mut archived: Vec<T> = Vec::with_capacity(evict.len());
83 for &idx in evict.iter().rev() {
84 archived.push(items.remove(idx));
85 }
86 archived.reverse(); if !archived.is_empty() {
89 let _ = archive_items(store, scope, &archived, &ArchiveConfig::from_env());
90 }
91 archived
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use chrono::Utc;
98 use serde::Deserialize;
99
100 fn with_temp_data_dir<T>(f: impl FnOnce() -> T) -> T {
101 let _lock = crate::core::data_dir::test_env_lock();
102 let dir = std::env::temp_dir().join(format!(
103 "lctx-capacity-{}-{}",
104 std::process::id(),
105 Utc::now().timestamp_nanos_opt().unwrap_or(0)
106 ));
107 let _ = std::fs::create_dir_all(&dir);
108 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
109 let out = f();
110 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
111 let _ = std::fs::remove_dir_all(&dir);
112 out
113 }
114
115 #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
116 struct Item {
117 rank: u32,
118 }
119
120 #[test]
121 fn reclaim_target_matches_legacy_quarter_reclaim() {
122 let legacy = |max: usize| max.saturating_sub(max.div_ceil(4));
125 for max in [1usize, 2, 3, 4, 5, 6, 7, 8, 10, 100, 200, 1000] {
126 assert_eq!(
127 reclaim_target(max, 0.25),
128 legacy(max),
129 "mismatch at max={max}"
130 );
131 }
132 }
133
134 #[test]
135 fn reclaim_target_zero_headroom_keeps_all() {
136 assert_eq!(reclaim_target(200, 0.0), 200);
137 }
138
139 #[test]
140 fn reclaim_target_is_clamped() {
141 assert!(reclaim_target(100, 9.9) >= 5);
143 }
144
145 #[test]
146 fn should_reclaim_hysteresis() {
147 assert!(!should_reclaim(99, 100, true), "under cap: no reclaim");
148 assert!(should_reclaim(100, 100, true), "at cap: reclaim");
149 assert!(should_reclaim(150, 100, true), "over cap: reclaim");
150 assert!(!should_reclaim(150, 100, false), "disabled: no reclaim");
151 assert!(!should_reclaim(150, 0, true), "max 0: no reclaim");
152 }
153
154 #[test]
155 fn reclaim_store_is_lossless_and_keeps_best() {
156 with_temp_data_dir(|| {
157 let mut items: Vec<Item> = (0..8).map(|rank| Item { rank }).collect();
159 let archived = reclaim_store(
160 MemoryStore::Patterns,
161 Some("p"),
162 &mut items,
163 8,
164 0.25,
165 true,
166 |a, b| a.rank.cmp(&b.rank),
167 );
168 assert_eq!(items.len(), 6);
170 assert_eq!(archived.len(), 2);
171 let mut all: Vec<u32> = items.iter().chain(&archived).map(|i| i.rank).collect();
173 all.sort_unstable();
174 assert_eq!(all, (0..8).collect::<Vec<_>>());
175 assert_eq!(
177 archived.iter().map(|i| i.rank).collect::<Vec<_>>(),
178 vec![6, 7]
179 );
180 });
181 }
182
183 #[test]
184 fn reclaim_store_noop_under_cap() {
185 with_temp_data_dir(|| {
186 let mut items: Vec<Item> = (0..3).map(|rank| Item { rank }).collect();
187 let archived = reclaim_store(
188 MemoryStore::History,
189 Some("p"),
190 &mut items,
191 10,
192 0.25,
193 true,
194 |a, b| a.rank.cmp(&b.rank),
195 );
196 assert!(archived.is_empty());
197 assert_eq!(items.len(), 3);
198 });
199 }
200
201 #[test]
202 fn reclaim_store_respects_disabled() {
203 with_temp_data_dir(|| {
204 let mut items: Vec<Item> = (0..20).map(|rank| Item { rank }).collect();
205 let archived = reclaim_store(
206 MemoryStore::Procedures,
207 Some("p"),
208 &mut items,
209 10,
210 0.25,
211 false,
212 |a, b| a.rank.cmp(&b.rank),
213 );
214 assert!(archived.is_empty());
215 assert_eq!(
216 items.len(),
217 20,
218 "disabled reclaim leaves the store untouched"
219 );
220 });
221 }
222}