Skip to main content

agentic_time/
decay.rs

1//! Decay modeling — value/relevance degradation over time.
2
3use chrono::{DateTime, Duration as ChronoDuration, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::TemporalId;
7
8/// Models how value/relevance decays over time.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct DecayModel {
11    /// Unique identifier.
12    pub id: TemporalId,
13
14    /// What this decay applies to.
15    pub label: String,
16
17    /// Initial value (at t=0).
18    pub initial_value: f64,
19
20    /// Current calculated value.
21    pub current_value: f64,
22
23    /// Reference time (when value was initial_value).
24    pub reference_time: DateTime<Utc>,
25
26    /// Decay function.
27    pub decay_type: DecayType,
28
29    /// Minimum value (floor).
30    pub floor: f64,
31
32    /// When last calculated.
33    pub last_calculated: DateTime<Utc>,
34
35    /// Tags.
36    pub tags: Vec<String>,
37}
38
39/// Type of decay function.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub enum DecayType {
42    /// Linear decay: value = initial - (rate * time).
43    Linear {
44        /// Units lost per second.
45        rate: f64,
46    },
47
48    /// Exponential decay: value = initial * e^(-λt).
49    Exponential {
50        /// Decay constant (λ).
51        lambda: f64,
52    },
53
54    /// Half-life decay: value = initial * (0.5)^(t/half_life).
55    HalfLife {
56        /// Half-life in seconds.
57        half_life_secs: i64,
58    },
59
60    /// Step decay: drops at specific intervals.
61    Step {
62        /// Drop amount at each interval.
63        drop_amount: f64,
64        /// Interval between drops in seconds.
65        interval_secs: i64,
66    },
67
68    /// Custom decay curve.
69    Custom {
70        /// Points defining the curve (time_offset_secs, value).
71        points: Vec<(i64, f64)>,
72    },
73}
74
75impl DecayModel {
76    /// Create a new decay model.
77    pub fn new(label: impl Into<String>, initial_value: f64, decay_type: DecayType) -> Self {
78        let now = Utc::now();
79        Self {
80            id: TemporalId::new(),
81            label: label.into(),
82            initial_value,
83            current_value: initial_value,
84            reference_time: now,
85            decay_type,
86            floor: 0.0,
87            last_calculated: now,
88            tags: Vec::new(),
89        }
90    }
91
92    /// Calculate value at a specific time.
93    pub fn calculate_value(&self, at: DateTime<Utc>) -> f64 {
94        let elapsed_secs = (at - self.reference_time).num_seconds() as f64;
95
96        if elapsed_secs < 0.0 {
97            return self.initial_value;
98        }
99
100        let decayed = match &self.decay_type {
101            DecayType::Linear { rate } => self.initial_value - (rate * elapsed_secs),
102            DecayType::Exponential { lambda } => {
103                self.initial_value * (-lambda * elapsed_secs).exp()
104            }
105            DecayType::HalfLife { half_life_secs } => {
106                let half_life = *half_life_secs as f64;
107                self.initial_value * (0.5_f64).powf(elapsed_secs / half_life)
108            }
109            DecayType::Step {
110                drop_amount,
111                interval_secs,
112            } => {
113                let interval = *interval_secs as f64;
114                let intervals = (elapsed_secs / interval).floor();
115                self.initial_value - (drop_amount * intervals)
116            }
117            DecayType::Custom { points } => self.interpolate_custom(elapsed_secs as i64, points),
118        };
119
120        decayed.max(self.floor)
121    }
122
123    fn interpolate_custom(&self, elapsed: i64, points: &[(i64, f64)]) -> f64 {
124        if points.is_empty() {
125            return self.initial_value;
126        }
127
128        let mut prev = (0i64, self.initial_value);
129        for &(t, v) in points {
130            if t > elapsed {
131                let ratio = (elapsed - prev.0) as f64 / (t - prev.0) as f64;
132                return prev.1 + ratio * (v - prev.1);
133            }
134            prev = (t, v);
135        }
136
137        prev.1
138    }
139
140    /// Update current_value to now.
141    pub fn update(&mut self) {
142        let now = Utc::now();
143        self.current_value = self.calculate_value(now);
144        self.last_calculated = now;
145    }
146
147    /// Time until value reaches threshold.
148    pub fn time_until(&self, threshold: f64) -> Option<ChronoDuration> {
149        if threshold <= self.floor {
150            return None; // Will never reach below floor
151        }
152
153        if self.current_value <= threshold {
154            return Some(ChronoDuration::zero());
155        }
156
157        let secs = match &self.decay_type {
158            DecayType::Linear { rate } => {
159                if *rate <= 0.0 {
160                    return None;
161                }
162                (self.initial_value - threshold) / rate
163            }
164            DecayType::Exponential { lambda } => {
165                if *lambda <= 0.0 {
166                    return None;
167                }
168                -(threshold / self.initial_value).ln() / lambda
169            }
170            DecayType::HalfLife { half_life_secs } => {
171                let half_life = *half_life_secs as f64;
172                half_life * (threshold / self.initial_value).log2().abs()
173            }
174            _ => return None, // Complex calculation for Step/Custom
175        };
176
177        Some(ChronoDuration::seconds(secs as i64))
178    }
179
180    /// Reset decay (refresh the value back to initial).
181    pub fn refresh(&mut self) {
182        self.reference_time = Utc::now();
183        self.current_value = self.initial_value;
184        self.last_calculated = self.reference_time;
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn test_linear_decay() {
194        let d = DecayModel::new("test", 100.0, DecayType::Linear { rate: 1.0 });
195        let future = d.reference_time + ChronoDuration::seconds(50);
196        assert!((d.calculate_value(future) - 50.0).abs() < 0.01);
197    }
198
199    #[test]
200    fn test_floor() {
201        let mut d = DecayModel::new("test", 100.0, DecayType::Linear { rate: 1.0 });
202        d.floor = 10.0;
203        let far_future = d.reference_time + ChronoDuration::seconds(200);
204        assert!((d.calculate_value(far_future) - 10.0).abs() < 0.01);
205    }
206
207    #[test]
208    fn test_exponential_decay() {
209        let d = DecayModel::new("test", 100.0, DecayType::Exponential { lambda: 0.01 });
210        let at = d.reference_time + ChronoDuration::seconds(100);
211        // 100 * e^(-0.01 * 100) = 100 * e^(-1) ≈ 36.79
212        let val = d.calculate_value(at);
213        assert!((val - 36.79).abs() < 1.0);
214    }
215
216    #[test]
217    fn test_half_life_decay() {
218        let d = DecayModel::new(
219            "test",
220            100.0,
221            DecayType::HalfLife {
222                half_life_secs: 3600,
223            },
224        );
225        let at = d.reference_time + ChronoDuration::seconds(3600);
226        let val = d.calculate_value(at);
227        assert!((val - 50.0).abs() < 0.01);
228    }
229}