Skip to main content

converge_knowledge/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(|(i, _)| i)
100            .unwrap_or(0)
101    }
102
103    /// Check if current time is anomalous compared to pattern.
104    pub fn is_anomalous(&self, timestamp: &DateTime<Utc>, value: f32, threshold: f32) -> bool {
105        let expected = self.predict(timestamp);
106        (value - expected).abs() > threshold
107    }
108
109    /// Recompute distribution from occurrences.
110    fn recompute_distribution(&mut self) {
111        let bins = self.period.bin_count();
112        let mut counts = vec![0.0f32; bins];
113        let mut totals = vec![0.0f32; bins];
114
115        for occ in &self.occurrences {
116            if occ.bin < bins {
117                counts[occ.bin] += 1.0;
118                totals[occ.bin] += occ.value;
119            }
120        }
121
122        // Compute averages
123        for i in 0..bins {
124            self.distribution[i] = if counts[i] > 0.0 {
125                totals[i] / counts[i]
126            } else {
127                0.0
128            };
129        }
130
131        // Update confidence based on sample size
132        let total_samples: f32 = counts.iter().sum();
133        self.confidence = (total_samples / (bins as f32 * 3.0)).min(1.0);
134    }
135
136    /// Get the period description.
137    pub fn period_description(&self) -> String {
138        match &self.period {
139            TemporalPeriod::Hourly => "hourly (by minute)".to_string(),
140            TemporalPeriod::Daily => "daily (by hour)".to_string(),
141            TemporalPeriod::Weekly => "weekly (by day)".to_string(),
142            TemporalPeriod::Monthly => "monthly (by day)".to_string(),
143            TemporalPeriod::Custom { name, .. } => format!("custom: {}", name),
144        }
145    }
146}
147
148/// An occurrence within a time crystal.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct TemporalOccurrence {
151    /// When this occurred.
152    pub timestamp: DateTime<Utc>,
153
154    /// Which bin this falls into.
155    pub bin: usize,
156
157    /// The value/intensity of this occurrence.
158    pub value: f32,
159}
160
161/// Temporal periods for pattern detection.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub enum TemporalPeriod {
164    /// Hourly pattern (60 bins for minutes).
165    Hourly,
166
167    /// Daily pattern (24 bins for hours).
168    Daily,
169
170    /// Weekly pattern (7 bins for days).
171    Weekly,
172
173    /// Monthly pattern (31 bins for days).
174    Monthly,
175
176    /// Custom period with named bins.
177    Custom {
178        /// Period name.
179        name: String,
180        /// Number of bins.
181        bins: usize,
182        /// Duration of the period.
183        duration_minutes: u64,
184    },
185}
186
187impl TemporalPeriod {
188    /// Get the number of bins for this period.
189    pub fn bin_count(&self) -> usize {
190        match self {
191            TemporalPeriod::Hourly => 60,
192            TemporalPeriod::Daily => 24,
193            TemporalPeriod::Weekly => 7,
194            TemporalPeriod::Monthly => 31,
195            TemporalPeriod::Custom { bins, .. } => *bins,
196        }
197    }
198
199    /// Convert a timestamp to a bin index.
200    pub fn time_to_bin(&self, timestamp: &DateTime<Utc>) -> usize {
201        match self {
202            TemporalPeriod::Hourly => timestamp.minute() as usize,
203            TemporalPeriod::Daily => timestamp.hour() as usize,
204            TemporalPeriod::Weekly => timestamp.weekday().num_days_from_monday() as usize,
205            TemporalPeriod::Monthly => (timestamp.day() - 1) as usize,
206            TemporalPeriod::Custom {
207                duration_minutes,
208                bins,
209                ..
210            } => {
211                let minutes_since_epoch = timestamp.timestamp() as u64 / 60;
212                ((minutes_since_epoch % duration_minutes) * (*bins as u64) / duration_minutes)
213                    as usize
214            }
215        }
216    }
217
218    /// Get a label for a bin.
219    pub fn bin_label(&self, bin: usize) -> String {
220        match self {
221            TemporalPeriod::Hourly => format!(":{:02}", bin),
222            TemporalPeriod::Daily => format!("{:02}:00", bin),
223            TemporalPeriod::Weekly => {
224                let day = match bin {
225                    0 => "Monday",
226                    1 => "Tuesday",
227                    2 => "Wednesday",
228                    3 => "Thursday",
229                    4 => "Friday",
230                    5 => "Saturday",
231                    6 => "Sunday",
232                    _ => "Unknown",
233                };
234                day.to_string()
235            }
236            TemporalPeriod::Monthly => format!("Day {}", bin + 1),
237            TemporalPeriod::Custom { name, .. } => format!("{} bin {}", name, bin),
238        }
239    }
240}
241
242/// Store for time crystals.
243pub struct TemporalMemory {
244    crystals: HashMap<String, TimeCrystal>,
245}
246
247impl TemporalMemory {
248    /// Create a new temporal memory.
249    pub fn new() -> Self {
250        Self {
251            crystals: HashMap::new(),
252        }
253    }
254
255    /// Get or create a time crystal.
256    pub fn get_or_create(&mut self, name: &str, period: TemporalPeriod) -> &mut TimeCrystal {
257        self.crystals
258            .entry(name.to_string())
259            .or_insert_with(|| TimeCrystal::new(name, period))
260    }
261
262    /// Record an event for a pattern.
263    pub fn record(&mut self, pattern_name: &str, period: TemporalPeriod, value: f32) {
264        let crystal = self.get_or_create(pattern_name, period);
265        crystal.record_now(value);
266    }
267
268    /// Get prediction for a pattern.
269    pub fn predict(&self, pattern_name: &str) -> Option<f32> {
270        self.crystals.get(pattern_name).map(|c| c.predict_now())
271    }
272
273    /// List all patterns.
274    pub fn list_patterns(&self) -> Vec<&str> {
275        self.crystals.keys().map(|s| s.as_str()).collect()
276    }
277
278    /// Get a crystal by name.
279    pub fn get(&self, name: &str) -> Option<&TimeCrystal> {
280        self.crystals.get(name)
281    }
282
283    /// Total crystal count.
284    pub fn len(&self) -> usize {
285        self.crystals.len()
286    }
287
288    /// Check if empty.
289    pub fn is_empty(&self) -> bool {
290        self.crystals.is_empty()
291    }
292}
293
294impl Default for TemporalMemory {
295    fn default() -> Self {
296        Self::new()
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use chrono::Duration;
304
305    /// Test: Daily usage pattern tracking.
306    ///
307    /// What happens:
308    /// 1. Create a time crystal for daily patterns
309    /// 2. Record high activity during work hours (9-17)
310    /// 3. Record low activity at night
311    /// 4. Crystal learns the pattern and can predict
312    #[test]
313    fn test_daily_pattern() {
314        let mut crystal = TimeCrystal::new("coding_activity", TemporalPeriod::Daily);
315
316        // Simulate activity pattern: high during work hours
317        for hour in 0..24 {
318            let value = if hour >= 9 && hour < 17 {
319                1.0 // High activity during work hours
320            } else if hour >= 22 || hour < 6 {
321                0.0 // No activity at night
322            } else {
323                0.3 // Low activity morning/evening
324            };
325
326            // Create timestamp for this hour
327            let timestamp = Utc::now().with_hour(hour).unwrap().with_minute(0).unwrap();
328            crystal.record(timestamp, value);
329        }
330
331        // Verify pattern learned
332        let work_hour = crystal.predict(&Utc::now().with_hour(10).unwrap().with_minute(0).unwrap());
333        let night_hour = crystal.predict(&Utc::now().with_hour(2).unwrap().with_minute(0).unwrap());
334
335        assert!(work_hour > night_hour);
336        assert!(work_hour >= 0.9); // Should be close to 1.0
337
338        // Best time should be a work hour
339        let best = crystal.best_time();
340        assert!(best >= 9 && best < 17);
341    }
342
343    /// Test: Weekly pattern for recurring tasks.
344    ///
345    /// What happens:
346    /// 1. Create a weekly pattern for "deploy" activity
347    /// 2. Record deploys on Tuesday and Thursday
348    /// 3. Crystal predicts high likelihood on those days
349    #[test]
350    fn test_weekly_pattern() {
351        let mut crystal = TimeCrystal::new("deploy_activity", TemporalPeriod::Weekly);
352
353        // Simulate: deploys happen on Tuesday (1) and Thursday (3)
354        for day in 0..7 {
355            let value = if day == 1 || day == 3 { 1.0 } else { 0.0 };
356            // Create timestamp for each day
357            let days_offset = day as i64 - Utc::now().weekday().num_days_from_monday() as i64;
358            let timestamp = Utc::now() + Duration::days(days_offset);
359            crystal.record(timestamp, value);
360        }
361
362        // Verify Tuesday and Thursday have high values
363        assert!(crystal.distribution[1] > 0.5); // Tuesday
364        assert!(crystal.distribution[3] > 0.5); // Thursday
365        assert!(crystal.distribution[0] < 0.1); // Monday
366        assert!(crystal.distribution[5] < 0.1); // Saturday
367    }
368
369    /// Test: Anomaly detection in patterns.
370    ///
371    /// What happens:
372    /// 1. Establish a pattern (low activity at night)
373    /// 2. Check if unusual activity is flagged
374    /// 3. Agent can alert on unexpected behavior
375    #[test]
376    fn test_anomaly_detection() {
377        let mut crystal = TimeCrystal::new("system_load", TemporalPeriod::Daily);
378
379        // Normal pattern: low at night, high during day
380        for _ in 0..10 {
381            for hour in 0..24 {
382                let normal_value = if hour >= 9 && hour < 17 { 0.8 } else { 0.1 };
383                let timestamp = Utc::now().with_hour(hour).unwrap().with_minute(0).unwrap();
384                crystal.record(timestamp, normal_value);
385            }
386        }
387
388        // Test anomaly: high load at 3am
389        let night_time = Utc::now().with_hour(3).unwrap();
390        assert!(crystal.is_anomalous(&night_time, 0.9, 0.3));
391
392        // Normal: high load at 10am
393        let day_time = Utc::now().with_hour(10).unwrap();
394        assert!(!crystal.is_anomalous(&day_time, 0.8, 0.3));
395    }
396
397    /// Test: Temporal memory for multiple patterns.
398    ///
399    /// What happens:
400    /// 1. Track multiple independent patterns
401    /// 2. Each pattern has its own period
402    /// 3. Query predictions for each
403    #[test]
404    fn test_temporal_memory() {
405        let mut memory = TemporalMemory::new();
406
407        // Track different patterns
408        memory.record("coding", TemporalPeriod::Daily, 1.0);
409        memory.record("meetings", TemporalPeriod::Daily, 0.5);
410        memory.record("deploys", TemporalPeriod::Weekly, 1.0);
411
412        assert_eq!(memory.len(), 3);
413        assert!(memory.predict("coding").is_some());
414        assert!(memory.predict("unknown").is_none());
415
416        let patterns = memory.list_patterns();
417        assert!(patterns.contains(&"coding"));
418        assert!(patterns.contains(&"meetings"));
419        assert!(patterns.contains(&"deploys"));
420    }
421}