Skip to main content

csm_memory/
singularity_decay.rs

1//! Association decay: reinforcement and pruning for weighted forgetting (ADR-0025).
2
3use csm_core_lib::error::{MemoryError, Result};
4use csm_core_lib::hyperdim::Hypervector;
5
6use crate::singularity::{DecayCurve, Singularity, unix_now_secs};
7
8impl<H: Hypervector + 'static> Singularity<H> {
9    /// Reinforce an association by resetting its `created_at` timestamp to now.
10    /// This effectively refreshes the association so decay starts over.
11    pub fn reinforce_association(&mut self, ns: &str, from: &str, to: &str) -> Result<()> {
12        let ns_state = self.ensure_namespace(ns)?;
13        let neighbors =
14            ns_state
15                .associations
16                .get_mut(from)
17                .ok_or_else(|| MemoryError::NotFound {
18                    entity: "Association".to_string(),
19                    id: format!("{from} -> {to}"),
20                })?;
21        let entry = neighbors.get_mut(to).ok_or_else(|| MemoryError::NotFound {
22            entity: "Association".to_string(),
23            id: format!("{from} -> {to}"),
24        })?;
25        entry.1 = unix_now_secs();
26        Ok(())
27    }
28
29    /// Prune associations whose decayed strength falls below `threshold`.
30    /// Returns the number of associations removed.
31    ///
32    /// Missing namespaces are a no-op (returns 0) so prune never creates an
33    /// empty namespace solely to count removals.
34    pub fn prune_decayed_associations(
35        &mut self,
36        ns: &str,
37        curve: DecayCurve,
38        threshold: f32,
39    ) -> usize {
40        let now = unix_now_secs();
41        let Some(ns_state) = self.namespaces.get_mut(ns) else {
42            return 0;
43        };
44        let mut removed = 0usize;
45        for neighbors in ns_state.associations.values_mut() {
46            let before = neighbors.len();
47            neighbors.retain(|_, (strength, created_at)| {
48                let elapsed = now.saturating_sub(*created_at);
49                curve.apply(*strength, elapsed) >= threshold
50            });
51            removed += before - neighbors.len();
52        }
53        // Remove empty neighbor maps
54        ns_state.associations.retain(|_, v| !v.is_empty());
55        removed
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
62    use super::*;
63    use crate::ConceptBuilder;
64    use crate::singularity::SingularityConfig;
65    use csm_core_lib::HVec10240;
66
67    fn make_singularity() -> Singularity<HVec10240> {
68        Singularity::new(SingularityConfig::default())
69    }
70
71    fn inject(sing: &mut Singularity<HVec10240>, ns: &str, id: &str) {
72        let concept = ConceptBuilder::new(id)
73            .with_vector(HVec10240::random())
74            .build()
75            .unwrap();
76        sing.inject(ns, concept).unwrap();
77    }
78
79    #[test]
80    fn reinforce_resets_created_at() {
81        let mut sing = make_singularity();
82        let ns = "_default";
83        inject(&mut sing, ns, "a");
84        inject(&mut sing, ns, "b");
85        sing.associate(ns, "a", "b", 0.8).unwrap();
86
87        // Reinforcing should succeed
88        assert!(sing.reinforce_association(ns, "a", "b").is_ok());
89    }
90
91    #[test]
92    fn reinforce_nonexistent_returns_error() {
93        let mut sing = make_singularity();
94        let ns = "_default";
95        inject(&mut sing, ns, "a");
96
97        assert!(sing.reinforce_association(ns, "a", "b").is_err());
98        assert!(sing.reinforce_association(ns, "x", "y").is_err());
99    }
100
101    #[test]
102    fn prune_removes_decayed_associations() {
103        let mut sing = make_singularity();
104        let ns = "_default";
105        inject(&mut sing, ns, "a");
106        inject(&mut sing, ns, "b");
107        inject(&mut sing, ns, "c");
108        sing.associate(ns, "a", "b", 0.9).unwrap();
109        sing.associate(ns, "a", "c", 0.9).unwrap();
110
111        // With no decay, nothing should be pruned
112        let removed = sing.prune_decayed_associations(ns, DecayCurve::None, 0.5);
113        assert_eq!(removed, 0);
114
115        // With a step decay that drops 1.0 immediately, everything should be pruned
116        let curve = DecayCurve::Step {
117            threshold_seconds: 0,
118            drop: 1.0,
119        };
120        let removed = sing.prune_decayed_associations(ns, curve, 0.5);
121        assert_eq!(removed, 2);
122        assert!(sing.get_associations(ns, "a").is_empty());
123    }
124}