1use chrono::Utc;
4use serde::{Deserialize, Serialize};
5
6use super::error::MemoryResult;
7use super::index::MemoryIndex;
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct CompactionPolicy {
12 pub max_age_days: Option<u64>,
14 pub max_entries: Option<usize>,
16 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#[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
38pub fn compact_index(
43 index: &mut MemoryIndex,
44 policy: &CompactionPolicy,
45) -> MemoryResult<CompactionResult> {
46 let mut removed_ids = Vec::new();
47
48 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 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 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 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}