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`.
50pub 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    // Rank a copy of the indices by retention (best-kept first); the worst
73    // `drop_count` are evicted. Order-preserving: only the chosen indices are
74    // removed, so the kept items keep their original relative order and a reclaim
75    // never reshuffles the live store as a side effect. `sort_by` is stable, so
76    // ties resolve to original order for deterministic eviction.
77    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(); // restore original order for the archived payload
87
88    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        // The pre-#995 target was `max - ceil(max/4)`. headroom 0.25 must match
123        // it exactly across a range of caps, including the awkward small ones.
124        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        // Absurd headroom never drops the store below ~5%.
142        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            // rank 0 = best kept (retention_cmp ascending by rank).
158            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            // 8 -> keep 6, archive 2.
169            assert_eq!(items.len(), 6);
170            assert_eq!(archived.len(), 2);
171            // Lossless: union of kept + archived == original set.
172            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            // Worst (highest rank) were archived.
176            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}