Skip to main content

aivcs_core/memory/
retention.rs

1//! Compaction policies for pruning stale or low-value memory entries.
2
3use chrono::Utc;
4use serde::{Deserialize, Serialize};
5
6use super::error::MemoryResult;
7use super::index::MemoryIndex;
8
9/// Policy controlling which entries are eligible for compaction.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct CompactionPolicy {
12    /// Remove entries older than this many days.
13    pub max_age_days: Option<u64>,
14    /// Keep at most this many entries (oldest removed first).
15    pub max_entries: Option<usize>,
16    /// Remove entries with fewer tokens than this threshold.
17    pub min_token_threshold: Option<usize>,
18}
19
20impl Default for CompactionPolicy {
21    fn default() -> Self {
22        Self {
23            max_age_days: Some(90),
24            max_entries: Some(1000),
25            min_token_threshold: None,
26        }
27    }
28}
29
30/// Result of a compaction pass.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct CompactionResult {
33    pub removed_count: usize,
34    pub remaining_count: usize,
35    pub removed_ids: Vec<String>,
36}
37
38/// Apply compaction policies to a memory index, removing entries in order:
39/// 1. Below min token threshold
40/// 2. Older than max age
41/// 3. Excess entries beyond max count (oldest first)
42pub fn compact_index(
43    index: &mut MemoryIndex,
44    policy: &CompactionPolicy,
45) -> MemoryResult<CompactionResult> {
46    let mut removed_ids = Vec::new();
47
48    // Phase 1: Remove entries below min token threshold
49    if let Some(min_tokens) = policy.min_token_threshold {
50        let to_remove: Vec<String> = index
51            .entries_mut()
52            .iter()
53            .filter(|(_, e)| e.token_estimate < min_tokens)
54            .map(|(id, _)| id.clone())
55            .collect();
56        for id in to_remove {
57            index.entries_mut().remove(&id);
58            removed_ids.push(id);
59        }
60    }
61
62    // Phase 2: Remove entries older than max_age_days
63    if let Some(max_age_days) = policy.max_age_days {
64        let cutoff = Utc::now() - chrono::Duration::days(max_age_days as i64);
65        let to_remove: Vec<String> = index
66            .entries_mut()
67            .iter()
68            .filter(|(_, e)| e.created_at < cutoff)
69            .map(|(id, _)| id.clone())
70            .collect();
71        for id in to_remove {
72            index.entries_mut().remove(&id);
73            removed_ids.push(id);
74        }
75    }
76
77    // Phase 3: Trim to max_entries (remove oldest first)
78    if let Some(max_entries) = policy.max_entries {
79        if index.len() > max_entries {
80            let mut entries_by_age: Vec<(String, chrono::DateTime<Utc>)> = index
81                .entries_mut()
82                .iter()
83                .map(|(id, e)| (id.clone(), e.created_at))
84                .collect();
85            // Sort oldest first, then id for deterministic tie-breaking.
86            entries_by_age
87                .sort_by(|(id_a, ts_a), (id_b, ts_b)| ts_a.cmp(ts_b).then_with(|| id_a.cmp(id_b)));
88
89            let to_remove_count = index.len() - max_entries;
90            for (id, _) in entries_by_age.into_iter().take(to_remove_count) {
91                index.entries_mut().remove(&id);
92                removed_ids.push(id);
93            }
94        }
95    }
96
97    Ok(CompactionResult {
98        removed_count: removed_ids.len(),
99        remaining_count: index.len(),
100        removed_ids,
101    })
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::memory::index::{MemoryEntry, MemoryEntryKind, MemoryIndex};
108    use chrono::Duration;
109
110    fn entry(id: &str, age_days: i64, tokens: usize) -> MemoryEntry {
111        MemoryEntry {
112            id: id.into(),
113            kind: MemoryEntryKind::RunTrace,
114            summary: format!("s {id}"),
115            content_digest: format!("d_{id}"),
116            created_at: Utc::now() - Duration::days(age_days),
117            tags: Vec::new(),
118            token_estimate: tokens,
119            relevance: 0.5,
120        }
121    }
122
123    #[test]
124    fn test_noop_policy() {
125        let mut idx = MemoryIndex::new();
126        idx.insert(entry("a", 1, 100)).unwrap();
127        let r = compact_index(
128            &mut idx,
129            &CompactionPolicy {
130                max_age_days: None,
131                max_entries: None,
132                min_token_threshold: None,
133            },
134        )
135        .unwrap();
136        assert_eq!(r.removed_count, 0);
137        assert_eq!(r.remaining_count, 1);
138    }
139
140    #[test]
141    fn test_age_removal() {
142        let mut idx = MemoryIndex::new();
143        idx.insert(entry("new", 1, 100)).unwrap();
144        idx.insert(entry("old", 100, 100)).unwrap();
145        let r = compact_index(
146            &mut idx,
147            &CompactionPolicy {
148                max_age_days: Some(30),
149                max_entries: None,
150                min_token_threshold: None,
151            },
152        )
153        .unwrap();
154        assert_eq!(r.removed_count, 1);
155        assert!(r.removed_ids.contains(&"old".to_string()));
156    }
157
158    #[test]
159    fn test_count_trimming() {
160        let mut idx = MemoryIndex::new();
161        for i in 0..5 {
162            idx.insert(entry(&format!("e{i}"), i, 100)).unwrap();
163        }
164        let r = compact_index(
165            &mut idx,
166            &CompactionPolicy {
167                max_age_days: None,
168                max_entries: Some(3),
169                min_token_threshold: None,
170            },
171        )
172        .unwrap();
173        assert_eq!(r.removed_count, 2);
174        assert_eq!(r.remaining_count, 3);
175    }
176
177    #[test]
178    fn test_count_trimming_deterministic_with_equal_timestamps() {
179        let now = Utc::now();
180        let mut idx = MemoryIndex::new();
181        for id in &["b", "a", "c"] {
182            idx.insert(MemoryEntry {
183                id: (*id).to_string(),
184                kind: MemoryEntryKind::RunTrace,
185                summary: format!("s {id}"),
186                content_digest: format!("d_{id}"),
187                created_at: now,
188                tags: Vec::new(),
189                token_estimate: 100,
190                relevance: 0.5,
191            })
192            .unwrap();
193        }
194
195        let r = compact_index(
196            &mut idx,
197            &CompactionPolicy {
198                max_age_days: None,
199                max_entries: Some(1),
200                min_token_threshold: None,
201            },
202        )
203        .unwrap();
204
205        assert_eq!(r.removed_ids, vec!["a".to_string(), "b".to_string()]);
206        assert!(idx.get("c").is_ok());
207    }
208}