brainos_hippocampus/
compactor.rs1use std::time::Duration;
15
16use async_trait::async_trait;
17use chrono::Utc;
18use tracing::{debug, info};
19
20use crate::graph::{EpisodicGraph, GraphError};
21
22#[derive(Debug, Clone)]
24pub struct CompactConfig {
25 pub half_life: Duration,
30 pub eviction_cutoff: f32,
34}
35
36impl Default for CompactConfig {
37 fn default() -> Self {
38 Self {
39 half_life: Duration::from_secs(7 * 24 * 3600),
40 eviction_cutoff: 0.05,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct CompactStats {
48 pub scanned: usize,
50 pub decayed: usize,
52 pub evicted: usize,
55}
56
57#[async_trait]
60pub trait Compactor: Send + Sync {
61 async fn compact(&self, store: &dyn EpisodicGraph) -> Result<CompactStats, GraphError>;
62}
63
64pub struct DefaultCompactor {
66 config: CompactConfig,
67}
68
69impl DefaultCompactor {
70 pub fn new(config: CompactConfig) -> Self {
71 Self { config }
72 }
73
74 pub fn config(&self) -> &CompactConfig {
75 &self.config
76 }
77
78 pub fn decayed_weight(initial_weight: f32, elapsed_secs: f64, half_life_secs: f64) -> f32 {
82 if half_life_secs <= 0.0 || elapsed_secs <= 0.0 {
83 return initial_weight;
84 }
85 let factor = 0.5_f64.powf(elapsed_secs / half_life_secs);
89 (initial_weight as f64 * factor) as f32
90 }
91}
92
93#[async_trait]
94impl Compactor for DefaultCompactor {
95 async fn compact(&self, store: &dyn EpisodicGraph) -> Result<CompactStats, GraphError> {
96 let now = Utc::now();
97 let half_life_secs = self.config.half_life.as_secs_f64();
98 let cutoff = self.config.eviction_cutoff;
99 let mut stats = CompactStats::default();
100
101 let nodes = store.list_all_nodes()?;
102 for node in nodes {
103 stats.scanned += 1;
104 let elapsed = (now - node.created_at).num_milliseconds() as f64 / 1000.0;
105 let new_weight = Self::decayed_weight(node.weight, elapsed, half_life_secs);
106 if new_weight < cutoff {
107 debug!(
108 node_id = %node.id,
109 weight = new_weight,
110 cutoff = cutoff,
111 "compactor evicting node"
112 );
113 if store.delete_node(&node.id)? {
114 stats.evicted += 1;
115 }
116 } else if (new_weight - node.weight).abs() > f32::EPSILON {
117 store.update_weight(&node.id, new_weight)?;
118 stats.decayed += 1;
119 }
120 }
121 info!(
122 scanned = stats.scanned,
123 decayed = stats.decayed,
124 evicted = stats.evicted,
125 "compactor pass complete"
126 );
127 Ok(stats)
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use crate::graph::{Edge, EdgeKind, Node, NodeKind, SqliteGraph};
135 use chrono::Duration as ChronoDuration;
136 use storage::SqlitePool;
137
138 fn store() -> SqliteGraph {
139 SqliteGraph::new(SqlitePool::open_memory().expect("memory pool"))
140 }
141
142 fn make_node(weight: f32, age_secs: i64) -> Node {
143 let mut n = Node::new(NodeKind::new("t"), serde_json::json!({}), "personal", None);
144 n.weight = weight;
145 n.created_at = Utc::now() - ChronoDuration::seconds(age_secs);
146 n
147 }
148
149 #[test]
150 fn decay_math_halves_at_half_life() {
151 let w = DefaultCompactor::decayed_weight(1.0, 60.0, 60.0);
152 assert!(
153 (w - 0.5).abs() < 1e-5,
154 "weight after one half-life should be 0.5, got {w}"
155 );
156 }
157
158 #[test]
159 fn decay_math_quarters_after_two_half_lives() {
160 let w = DefaultCompactor::decayed_weight(1.0, 120.0, 60.0);
161 assert!((w - 0.25).abs() < 1e-5, "two half-lives → 0.25, got {w}");
162 }
163
164 #[test]
165 fn decay_math_zero_elapsed_is_identity() {
166 let w = DefaultCompactor::decayed_weight(0.73, 0.0, 60.0);
167 assert!((w - 0.73).abs() < 1e-6);
168 }
169
170 #[test]
171 fn decay_math_zero_half_life_is_identity() {
172 let w = DefaultCompactor::decayed_weight(0.5, 99.0, 0.0);
175 assert!((w - 0.5).abs() < 1e-6);
176 }
177
178 #[tokio::test]
179 async fn compactor_decays_recent_node_in_place() {
180 let g = store();
181 let n = make_node(1.0, 5);
183 g.add_node(&n).unwrap();
184 let compactor = DefaultCompactor::new(CompactConfig {
185 half_life: Duration::from_secs(10),
186 eviction_cutoff: 0.0,
187 });
188 let stats = compactor.compact(&g).await.unwrap();
189 assert_eq!(stats.scanned, 1);
190 assert_eq!(stats.decayed, 1);
191 assert_eq!(stats.evicted, 0);
192 let got = g.get_node(&n.id).unwrap().expect("node remains");
193 assert!(
194 got.weight < 1.0 && got.weight > 0.5,
195 "weight {} should be decayed",
196 got.weight
197 );
198 }
199
200 #[tokio::test]
201 async fn compactor_evicts_node_below_cutoff() {
202 let g = store();
203 let n = make_node(1.0, 1000);
205 g.add_node(&n).unwrap();
206 let compactor = DefaultCompactor::new(CompactConfig {
207 half_life: Duration::from_secs(10),
208 eviction_cutoff: 0.05,
209 });
210 let stats = compactor.compact(&g).await.unwrap();
211 assert_eq!(stats.evicted, 1);
212 assert!(g.get_node(&n.id).unwrap().is_none());
213 }
214
215 #[tokio::test]
216 async fn compactor_evict_cascades_edges() {
217 let g = store();
218 let old = make_node(1.0, 1000);
219 let young = make_node(1.0, 0);
220 g.add_node(&old).unwrap();
221 g.add_node(&young).unwrap();
222 g.add_edge(&Edge::new(&old.id, &young.id, EdgeKind::new("k")))
223 .unwrap();
224 assert_eq!(g.incoming(&young.id).unwrap().len(), 1);
226
227 let compactor = DefaultCompactor::new(CompactConfig {
228 half_life: Duration::from_secs(10),
229 eviction_cutoff: 0.05,
230 });
231 let stats = compactor.compact(&g).await.unwrap();
232 assert!(stats.evicted >= 1);
233 assert!(g.incoming(&young.id).unwrap().is_empty());
235 }
236
237 #[tokio::test]
238 async fn compactor_preserves_vector_id_reference_on_decayed_node() {
239 let g = store();
240 let mut n = make_node(1.0, 5);
241 n.vector_id = Some("vec-keep-me".into());
242 g.add_node(&n).unwrap();
243 let compactor = DefaultCompactor::new(CompactConfig {
244 half_life: Duration::from_secs(10),
245 eviction_cutoff: 0.0,
246 });
247 compactor.compact(&g).await.unwrap();
248 let got = g.get_node(&n.id).unwrap().expect("node remains");
249 assert_eq!(got.vector_id.as_deref(), Some("vec-keep-me"));
250 }
251
252 #[tokio::test]
253 async fn compactor_empty_graph_returns_zero_stats() {
254 let g = store();
255 let compactor = DefaultCompactor::new(CompactConfig::default());
256 let stats = compactor.compact(&g).await.unwrap();
257 assert_eq!(stats, CompactStats::default());
258 }
259
260 #[tokio::test]
261 async fn compactor_mixed_decays_and_evicts() {
262 let g = store();
263 let young = make_node(1.0, 1);
264 let old = make_node(1.0, 1000);
265 g.add_node(&young).unwrap();
266 g.add_node(&old).unwrap();
267 let compactor = DefaultCompactor::new(CompactConfig {
268 half_life: Duration::from_secs(10),
269 eviction_cutoff: 0.05,
270 });
271 let stats = compactor.compact(&g).await.unwrap();
272 assert_eq!(stats.scanned, 2);
273 assert_eq!(stats.decayed, 1);
274 assert_eq!(stats.evicted, 1);
275 }
276}