Skip to main content

brainos_hippocampus/
compactor.rs

1//! Graph compactor — applies half-life decay to every node's weight
2//! and prunes nodes whose decayed weight falls below the cutoff.
3//!
4//! Cron-driven: the existing `orchestrate` scheduler invokes this on
5//! a configured cadence. Each invocation is independent — the decay
6//! math is referenced to `now - node.created_at`, not to the time of
7//! the last compaction, so missed cycles self-correct.
8//!
9//! Embeddings (the `vector_id` link on each node) are **not** touched
10//! here. Vector reclamation is a separate maintenance task because
11//! the vector store may share embeddings across nodes once
12//! deduplication ships.
13
14use std::time::Duration;
15
16use async_trait::async_trait;
17use chrono::Utc;
18use tracing::{debug, info};
19
20use crate::graph::{EpisodicGraph, GraphError};
21
22/// Tuning knobs for the [`DefaultCompactor`].
23#[derive(Debug, Clone)]
24pub struct CompactConfig {
25    /// Half-life: a node's weight halves every this many seconds of
26    /// wall-clock since `created_at`. Default 7 days — short enough
27    /// to surface decay during a development week, long enough that
28    /// production memories stay around weeks before pruning.
29    pub half_life: Duration,
30    /// Nodes whose decayed weight falls strictly below this are
31    /// pruned. Default `0.05` — about 4.3 half-lives after a node
32    /// starts at weight `1.0`.
33    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/// One compaction cycle's summary.
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct CompactStats {
48    /// Total nodes inspected.
49    pub scanned: usize,
50    /// Nodes whose weight was updated in place.
51    pub decayed: usize,
52    /// Nodes pruned because their decayed weight fell below the
53    /// cutoff. Edges cascade per the migration v20 FK.
54    pub evicted: usize,
55}
56
57/// Compactor trait — async so future impls (cold-tier export, vector
58/// store cleanup) can do real I/O off the reactor thread.
59#[async_trait]
60pub trait Compactor: Send + Sync {
61    async fn compact(&self, store: &dyn EpisodicGraph) -> Result<CompactStats, GraphError>;
62}
63
64/// Pure half-life decay compactor.
65pub 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    /// Compute the decayed weight at `now` for a node that started at
79    /// `initial_weight` `elapsed_secs` ago. Pure function, exposed
80    /// for tests pinning the decay curve.
81    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        // weight halves every half_life seconds:
86        //   w(t) = w0 * 0.5 ^ (t / half_life)
87        // which is the same shape as exp(-t * ln(2) / half_life).
88        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        // Defensive: half_life <= 0 returns the input unchanged
173        // rather than dividing by zero.
174        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        // 5s old, half_life 10s → weight should drop ~30% (0.5^0.5 ≈ 0.707).
182        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        // 1000s old, half_life 10s → weight ≈ 0.5^100 → effectively 0.
204        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        // Sanity: edge exists before compaction.
225        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        // The edge cascaded with the deleted src node.
234        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}