Skip to main content

lean_ctx/core/
memory_capacity.rs

1//! Single memory capacity manager (#995 Phase 2).
2//!
3//! One reclaim formula and one generic, archive-backed, hysteresis reclaim used
4//! by every store. Replaces the duplicated `reclaim_target_capacity` and the
5//! per-store hard drops (history drain, procedure/pattern truncate): eviction is
6//! now lossless everywhere because the dropped tail is archived (and restorable)
7//! before removal.
8
9use serde::Serialize;
10use std::cmp::Ordering;
11
12use super::memory_archive::{ArchiveConfig, MemoryStore, archive_items};
13
14/// Live count to settle at after a reclaim: drop `ceil(max * headroom_pct)`
15/// items so a busy store keeps real headroom instead of churning right at its
16/// cap. `headroom_pct = 0.25` reproduces the prior `max - ceil(max/4)` target
17/// byte-for-byte.
18pub 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
27/// Whether a store at `len` should reclaim now. Hysteresis: trigger only at/above
28/// the cap rather than continuously keeping N% free, so a store does not reclaim
29/// on every write once it nears capacity.
30pub fn should_reclaim(len: usize, max: usize, enabled: bool) -> bool {
31    enabled && max > 0 && len >= max
32}
33
34/// How many items a [`reclaim_store`] would archive for a store at `len`, without
35/// touching the store or the archive. Powers dry-run previews (#995 Phase 6).
36pub 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
43/// Generic, archive-backed, hysteresis reclaim.
44///
45/// When `items.len() >= max`, sort by `retention_cmp` (best-kept first) and
46/// archive + drop the tail down to [`reclaim_target`]. The dropped items are
47/// archived under `store`/`scope` *before* removal, so the reclaim is lossless
48/// and restorable. Returns the archived items. No-op when disabled, under cap,
49/// or `max == 0`. If persistence fails, returns the error and leaves `items`
50/// unchanged.
51pub 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    // Rank a copy of the indices by retention (best-kept first); the worst
74    // `drop_count` are evicted. `sort_by` is stable, so ties resolve to original
75    // order for deterministic eviction.
76    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    // Persist borrowed candidates before mutating the live store. Serializing
82    // references avoids requiring `T: Clone` while preserving archive order.
83    let candidates: Vec<&T> = evict.iter().map(|&idx| &items[idx]).collect();
84    archive_items(store, scope, &candidates, &ArchiveConfig::from_env())?;
85
86    // Order-preserving: only chosen indices are removed, so retained items keep
87    // their original relative order. Reverse removal avoids index shifts.
88    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        // The pre-#995 target was `max - ceil(max/4)`. headroom 0.25 must match
125        // it exactly across a range of caps, including the awkward small ones.
126        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        // Absurd headroom never drops the store below ~5%.
144        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            // rank 0 = best kept (retention_cmp ascending by rank).
160            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            // 8 -> keep 6, archive 2.
172            assert_eq!(items.len(), 6);
173            assert_eq!(archived.len(), 2);
174            // Lossless: union of kept + archived == original set.
175            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            // Worst (highest rank) were archived.
179            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}