Skip to main content

mnemos/agentic/
temporal.rs

1//! Time Crystals - Periodic Behavior Patterns
2//!
3//! Implements temporal pattern detection for agents, inspired by "time crystals"
4//! that exhibit periodic behavior. This module helps agents:
5//!
6//! 1. Detect recurring patterns in their behavior over time
7//! 2. Learn optimal timing for actions (when to do what)
8//! 3. Build temporal context for decision making
9//! 4. Identify anomalies in timing patterns
10
11use chrono::{DateTime, Datelike, Timelike, Utc};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use uuid::Uuid;
15
16/// A time crystal capturing periodic behavior patterns.
17///
18/// Named after the physics concept of time crystals that exhibit
19/// periodic structure in time rather than space.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct TimeCrystal {
22    /// Unique identifier.
23    pub id: Uuid,
24
25    /// Pattern name/description.
26    pub name: String,
27
28    /// The periodic interval of this pattern.
29    pub period: TemporalPeriod,
30
31    /// Observed occurrences within the period.
32    pub occurrences: Vec<TemporalOccurrence>,
33
34    /// Computed distribution over the period.
35    pub distribution: Vec<f32>,
36
37    /// Confidence in this pattern (0.0 to 1.0).
38    pub confidence: f32,
39
40    /// When this crystal was created.
41    pub created_at: DateTime<Utc>,
42
43    /// When this crystal was last updated.
44    pub updated_at: DateTime<Utc>,
45}
46
47impl TimeCrystal {
48    /// Create a new time crystal for a given period.
49    pub fn new(name: impl Into<String>, period: TemporalPeriod) -> Self {
50        let bins = period.bin_count();
51        let now = Utc::now();
52
53        Self {
54            id: Uuid::new_v4(),
55            name: name.into(),
56            period,
57            occurrences: Vec::new(),
58            distribution: vec![0.0; bins],
59            confidence: 0.0,
60            created_at: now,
61            updated_at: now,
62        }
63    }
64
65    /// Record an occurrence at a specific time.
66    pub fn record(&mut self, timestamp: DateTime<Utc>, value: f32) {
67        let bin = self.period.time_to_bin(&timestamp);
68        self.occurrences.push(TemporalOccurrence {
69            timestamp,
70            bin,
71            value,
72        });
73        self.updated_at = Utc::now();
74        self.recompute_distribution();
75    }
76
77    /// Record an occurrence now.
78    pub fn record_now(&mut self, value: f32) {
79        self.record(Utc::now(), value);
80    }
81
82    /// Get the expected value at a given time.
83    pub fn predict(&self, timestamp: &DateTime<Utc>) -> f32 {
84        let bin = self.period.time_to_bin(timestamp);
85        self.distribution.get(bin).copied().unwrap_or(0.0)
86    }
87
88    /// Get the expected value now.
89    pub fn predict_now(&self) -> f32 {
90        self.predict(&Utc::now())
91    }
92
93    /// Find the best time within the period for high values.
94    pub fn best_time(&self) -> usize {
95        self.distribution
96            .iter()
97            .enumerate()
98            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
99            .map_or(0, |(i, _)| i)
100    }
101
102    /// Check if current time is anomalous compared to pattern.
103    pub fn is_anomalous(&self, timestamp: &DateTime<Utc>, value: f32, threshold: f32) -> bool {
104        let expected = self.predict(timestamp);
105        (value - expected).abs() > threshold
106    }
107
108    /// Recompute distribution from occurrences.
109    fn recompute_distribution(&mut self) {
110        let bins = self.period.bin_count();
111        let mut counts = vec![0.0f32; bins];
112        let mut totals = vec![0.0f32; bins];
113
114        for occ in &self.occurrences {
115            if occ.bin < bins {
116                counts[occ.bin] += 1.0;
117                totals[occ.bin] += occ.value;
118            }
119        }
120
121        // Compute averages
122        for i in 0..bins {
123            self.distribution[i] = if counts[i] > 0.0 {
124                totals[i] / counts[i]
125            } else {
126                0.0
127            };
128        }
129
130        // Update confidence based on sample size
131        let total_samples: f32 = counts.iter().sum();
132        self.confidence = (total_samples / (bins as f32 * 3.0)).min(1.0);
133    }
134
135    /// Get the period description.
136    pub fn period_description(&self) -> String {
137        match &self.period {
138            TemporalPeriod::Hourly => "hourly (by minute)".to_string(),
139            TemporalPeriod::Daily => "daily (by hour)".to_string(),
140            TemporalPeriod::Weekly => "weekly (by day)".to_string(),
141            TemporalPeriod::Monthly => "monthly (by day)".to_string(),
142            TemporalPeriod::Custom { name, .. } => format!("custom: {}", name),
143        }
144    }
145}
146
147/// An occurrence within a time crystal.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct TemporalOccurrence {
150    /// When this occurred.
151    pub timestamp: DateTime<Utc>,
152
153    /// Which bin this falls into.
154    pub bin: usize,
155
156    /// The value/intensity of this occurrence.
157    pub value: f32,
158}
159
160/// Temporal periods for pattern detection.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub enum TemporalPeriod {
163    /// Hourly pattern (60 bins for minutes).
164    Hourly,
165
166    /// Daily pattern (24 bins for hours).
167    Daily,
168
169    /// Weekly pattern (7 bins for days).
170    Weekly,
171
172    /// Monthly pattern (31 bins for days).
173    Monthly,
174
175    /// Custom period with named bins.
176    Custom {
177        /// Period name.
178        name: String,
179        /// Number of bins.
180        bins: usize,
181        /// Duration of the period.
182        duration_minutes: u64,
183    },
184}
185
186impl TemporalPeriod {
187    /// Get the number of bins for this period.
188    pub fn bin_count(&self) -> usize {
189        match self {
190            TemporalPeriod::Hourly => 60,
191            TemporalPeriod::Daily => 24,
192            TemporalPeriod::Weekly => 7,
193            TemporalPeriod::Monthly => 31,
194            TemporalPeriod::Custom { bins, .. } => *bins,
195        }
196    }
197
198    /// Convert a timestamp to a bin index.
199    pub fn time_to_bin(&self, timestamp: &DateTime<Utc>) -> usize {
200        match self {
201            TemporalPeriod::Hourly => timestamp.minute() as usize,
202            TemporalPeriod::Daily => timestamp.hour() as usize,
203            TemporalPeriod::Weekly => timestamp.weekday().num_days_from_monday() as usize,
204            TemporalPeriod::Monthly => (timestamp.day() - 1) as usize,
205            TemporalPeriod::Custom {
206                duration_minutes,
207                bins,
208                ..
209            } => {
210                let minutes_since_epoch = timestamp.timestamp() as u64 / 60;
211                ((minutes_since_epoch % duration_minutes) * (*bins as u64) / duration_minutes)
212                    as usize
213            }
214        }
215    }
216
217    /// Get a label for a bin.
218    pub fn bin_label(&self, bin: usize) -> String {
219        match self {
220            TemporalPeriod::Hourly => format!(":{:02}", bin),
221            TemporalPeriod::Daily => format!("{:02}:00", bin),
222            TemporalPeriod::Weekly => {
223                let day = match bin {
224                    0 => "Monday",
225                    1 => "Tuesday",
226                    2 => "Wednesday",
227                    3 => "Thursday",
228                    4 => "Friday",
229                    5 => "Saturday",
230                    6 => "Sunday",
231                    _ => "Unknown",
232                };
233                day.to_string()
234            }
235            TemporalPeriod::Monthly => format!("Day {}", bin + 1),
236            TemporalPeriod::Custom { name, .. } => format!("{} bin {}", name, bin),
237        }
238    }
239}
240
241/// Store for time crystals.
242pub struct TemporalMemory {
243    crystals: HashMap<String, TimeCrystal>,
244}
245
246impl TemporalMemory {
247    /// Create a new temporal memory.
248    pub fn new() -> Self {
249        Self {
250            crystals: HashMap::new(),
251        }
252    }
253
254    /// Get or create a time crystal.
255    pub fn get_or_create(&mut self, name: &str, period: TemporalPeriod) -> &mut TimeCrystal {
256        self.crystals
257            .entry(name.to_string())
258            .or_insert_with(|| TimeCrystal::new(name, period))
259    }
260
261    /// Record an event for a pattern.
262    pub fn record(&mut self, pattern_name: &str, period: TemporalPeriod, value: f32) {
263        let crystal = self.get_or_create(pattern_name, period);
264        crystal.record_now(value);
265    }
266
267    /// Get prediction for a pattern.
268    pub fn predict(&self, pattern_name: &str) -> Option<f32> {
269        self.crystals
270            .get(pattern_name)
271            .map(TimeCrystal::predict_now)
272    }
273
274    /// List all patterns.
275    pub fn list_patterns(&self) -> Vec<&str> {
276        self.crystals
277            .keys()
278            .map(std::string::String::as_str)
279            .collect()
280    }
281
282    /// Get a crystal by name.
283    pub fn get(&self, name: &str) -> Option<&TimeCrystal> {
284        self.crystals.get(name)
285    }
286
287    /// Total crystal count.
288    pub fn len(&self) -> usize {
289        self.crystals.len()
290    }
291
292    /// Check if empty.
293    pub fn is_empty(&self) -> bool {
294        self.crystals.is_empty()
295    }
296}
297
298impl Default for TemporalMemory {
299    fn default() -> Self {
300        Self::new()
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use chrono::Duration;
308
309    /// Test: Daily usage pattern tracking.
310    ///
311    /// What happens:
312    /// 1. Create a time crystal for daily patterns
313    /// 2. Record high activity during work hours (9-17)
314    /// 3. Record low activity at night
315    /// 4. Crystal learns the pattern and can predict
316    #[test]
317    fn test_daily_pattern() {
318        let mut crystal = TimeCrystal::new("coding_activity", TemporalPeriod::Daily);
319
320        // Simulate activity pattern: high during work hours
321        for hour in 0..24 {
322            let value = if (9..17).contains(&hour) {
323                1.0 // High activity during work hours
324            } else if !(6..22).contains(&hour) {
325                0.0 // No activity at night
326            } else {
327                0.3 // Low activity morning/evening
328            };
329
330            // Create timestamp for this hour
331            let timestamp = Utc::now().with_hour(hour).unwrap().with_minute(0).unwrap();
332            crystal.record(timestamp, value);
333        }
334
335        // Verify pattern learned
336        let work_hour = crystal.predict(&Utc::now().with_hour(10).unwrap().with_minute(0).unwrap());
337        let night_hour = crystal.predict(&Utc::now().with_hour(2).unwrap().with_minute(0).unwrap());
338
339        assert!(work_hour > night_hour);
340        assert!(work_hour >= 0.9); // Should be close to 1.0
341
342        // Best time should be a work hour
343        let best = crystal.best_time();
344        assert!((9..17).contains(&best));
345    }
346
347    /// Test: Weekly pattern for recurring tasks.
348    ///
349    /// What happens:
350    /// 1. Create a weekly pattern for "deploy" activity
351    /// 2. Record deploys on Tuesday and Thursday
352    /// 3. Crystal predicts high likelihood on those days
353    #[test]
354    fn test_weekly_pattern() {
355        let mut crystal = TimeCrystal::new("deploy_activity", TemporalPeriod::Weekly);
356
357        // Simulate: deploys happen on Tuesday (1) and Thursday (3)
358        for day in 0..7 {
359            let value = if day == 1 || day == 3 { 1.0 } else { 0.0 };
360            // Create timestamp for each day
361            let days_offset = day as i64 - Utc::now().weekday().num_days_from_monday() as i64;
362            let timestamp = Utc::now() + Duration::days(days_offset);
363            crystal.record(timestamp, value);
364        }
365
366        // Verify Tuesday and Thursday have high values
367        assert!(crystal.distribution[1] > 0.5); // Tuesday
368        assert!(crystal.distribution[3] > 0.5); // Thursday
369        assert!(crystal.distribution[0] < 0.1); // Monday
370        assert!(crystal.distribution[5] < 0.1); // Saturday
371    }
372
373    /// Test: Anomaly detection in patterns.
374    ///
375    /// What happens:
376    /// 1. Establish a pattern (low activity at night)
377    /// 2. Check if unusual activity is flagged
378    /// 3. Suggestor can alert on unexpected behavior
379    #[test]
380    fn test_anomaly_detection() {
381        let mut crystal = TimeCrystal::new("system_load", TemporalPeriod::Daily);
382
383        // Normal pattern: low at night, high during day
384        for _ in 0..10 {
385            for hour in 0..24 {
386                let normal_value = if (9..17).contains(&hour) { 0.8 } else { 0.1 };
387                let timestamp = Utc::now().with_hour(hour).unwrap().with_minute(0).unwrap();
388                crystal.record(timestamp, normal_value);
389            }
390        }
391
392        // Test anomaly: high load at 3am
393        let night_time = Utc::now().with_hour(3).unwrap();
394        assert!(crystal.is_anomalous(&night_time, 0.9, 0.3));
395
396        // Normal: high load at 10am
397        let day_time = Utc::now().with_hour(10).unwrap();
398        assert!(!crystal.is_anomalous(&day_time, 0.8, 0.3));
399    }
400
401    /// Test: Temporal memory for multiple patterns.
402    ///
403    /// What happens:
404    /// 1. Track multiple independent patterns
405    /// 2. Each pattern has its own period
406    /// 3. Query predictions for each
407    #[test]
408    fn test_temporal_memory() {
409        let mut memory = TemporalMemory::new();
410
411        // Track different patterns
412        memory.record("coding", TemporalPeriod::Daily, 1.0);
413        memory.record("meetings", TemporalPeriod::Daily, 0.5);
414        memory.record("deploys", TemporalPeriod::Weekly, 1.0);
415
416        assert_eq!(memory.len(), 3);
417        assert!(memory.predict("coding").is_some());
418        assert!(memory.predict("unknown").is_none());
419
420        let patterns = memory.list_patterns();
421        assert!(patterns.contains(&"coding"));
422        assert!(patterns.contains(&"meetings"));
423        assert!(patterns.contains(&"deploys"));
424    }
425}