Skip to main content

ringkernel_accnet/models/
temporal.rs

1//! Temporal analysis structures for time-series based detection.
2//!
3//! These models support seasonality detection, trend analysis, and
4//! behavioral anomaly identification.
5
6use super::HybridTimestamp;
7use rkyv::{Archive, Deserialize, Serialize};
8use uuid::Uuid;
9
10/// Time granularity for analysis.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Archive, Serialize, Deserialize)]
12#[archive(compare(PartialEq))]
13#[repr(u8)]
14pub enum TimeGranularity {
15    /// Daily granularity (365 periods/year).
16    Daily = 0,
17    /// Weekly granularity (52 periods/year).
18    Weekly = 1,
19    /// Bi-weekly granularity (26 periods/year).
20    BiWeekly = 2,
21    /// Monthly granularity (12 periods/year).
22    Monthly = 3,
23    /// Quarterly granularity (4 periods/year).
24    Quarterly = 4,
25    /// Annual granularity (1 period/year).
26    Annual = 5,
27}
28
29impl TimeGranularity {
30    /// Periods per year for this granularity.
31    pub fn periods_per_year(&self) -> u32 {
32        match self {
33            TimeGranularity::Daily => 365,
34            TimeGranularity::Weekly => 52,
35            TimeGranularity::BiWeekly => 26,
36            TimeGranularity::Monthly => 12,
37            TimeGranularity::Quarterly => 4,
38            TimeGranularity::Annual => 1,
39        }
40    }
41
42    /// Expected autocorrelation lag for seasonal detection.
43    pub fn seasonal_lag(&self) -> u32 {
44        match self {
45            TimeGranularity::Daily => 7,     // Weekly pattern
46            TimeGranularity::Weekly => 52,   // Annual pattern
47            TimeGranularity::BiWeekly => 26, // Annual pattern
48            TimeGranularity::Monthly => 12,  // Annual pattern
49            TimeGranularity::Quarterly => 4, // Annual pattern
50            TimeGranularity::Annual => 1,
51        }
52    }
53}
54
55/// Type of seasonality detected.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Archive, Serialize, Deserialize)]
57#[archive(compare(PartialEq))]
58#[repr(u8)]
59pub enum SeasonalityType {
60    /// No significant seasonality
61    None = 0,
62    /// Weekly pattern (e.g., payroll every Friday)
63    Weekly = 1,
64    /// Bi-weekly pattern
65    BiWeekly = 2,
66    /// Monthly pattern (e.g., rent, subscriptions)
67    Monthly = 3,
68    /// Quarterly pattern (e.g., tax payments, dividends)
69    Quarterly = 4,
70    /// Semi-annual pattern
71    SemiAnnual = 5,
72    /// Annual pattern (e.g., year-end adjustments)
73    Annual = 6,
74}
75
76impl SeasonalityType {
77    /// Period length for this seasonality (in days).
78    pub fn period_days(&self) -> u32 {
79        match self {
80            SeasonalityType::None => 0,
81            SeasonalityType::Weekly => 7,
82            SeasonalityType::BiWeekly => 14,
83            SeasonalityType::Monthly => 30,
84            SeasonalityType::Quarterly => 91,
85            SeasonalityType::SemiAnnual => 182,
86            SeasonalityType::Annual => 365,
87        }
88    }
89
90    /// Common business examples.
91    pub fn examples(&self) -> &'static str {
92        match self {
93            SeasonalityType::None => "No regular pattern",
94            SeasonalityType::Weekly => "Payroll, weekly invoicing",
95            SeasonalityType::BiWeekly => "Bi-weekly payroll",
96            SeasonalityType::Monthly => "Rent, subscriptions, utility payments",
97            SeasonalityType::Quarterly => "Tax payments, dividends, quarterly filings",
98            SeasonalityType::SemiAnnual => "Insurance premiums, bond interest",
99            SeasonalityType::Annual => "Year-end adjustments, depreciation",
100        }
101    }
102}
103
104/// Detected seasonal pattern for an account.
105#[derive(Debug, Clone, Archive, Serialize, Deserialize)]
106#[repr(C)]
107pub struct SeasonalPattern {
108    /// Pattern identifier
109    pub id: Uuid,
110    /// Account this pattern belongs to
111    pub account_id: u16,
112    /// Type of seasonality
113    pub seasonality_type: SeasonalityType,
114    /// Period length (in time units)
115    pub period_length: u16,
116    /// Number of complete cycles observed
117    pub cycle_count: u16,
118    /// Padding
119    pub _pad: u16,
120    /// Confidence in the pattern (0.0 - 1.0)
121    pub confidence: f32,
122    /// Peak autocorrelation value
123    pub autocorrelation_peak: f32,
124    /// Amplitude of seasonal component
125    pub seasonal_amplitude: f32,
126    /// Baseline (deseasonalized mean)
127    pub baseline: f32,
128    /// Trend coefficient (positive = increasing)
129    pub trend_coefficient: f32,
130    /// Variance in residuals
131    pub residual_variance: f32,
132    /// First observation
133    pub first_observed: HybridTimestamp,
134    /// Last observation
135    pub last_observed: HybridTimestamp,
136    /// Flags
137    pub flags: SeasonalPatternFlags,
138}
139
140/// Flags for seasonal patterns.
141#[derive(Debug, Clone, Copy, Default, Archive, Serialize, Deserialize)]
142#[repr(transparent)]
143pub struct SeasonalPatternFlags(pub u32);
144
145impl SeasonalPatternFlags {
146    /// Flag: Pattern is statistically significant.
147    pub const IS_STATISTICALLY_SIGNIFICANT: u32 = 1 << 0;
148    /// Flag: Pattern shows upward trend.
149    pub const HAS_UPWARD_TREND: u32 = 1 << 1;
150    /// Flag: Pattern shows downward trend.
151    pub const HAS_DOWNWARD_TREND: u32 = 1 << 2;
152    /// Flag: Pattern is stable over time.
153    pub const IS_STABLE: u32 = 1 << 3;
154    /// Flag: Structural break detected in pattern.
155    pub const HAS_STRUCTURAL_BREAK: u32 = 1 << 4;
156}
157
158impl SeasonalPattern {
159    /// Create a new seasonal pattern for an account.
160    pub fn new(account_id: u16, seasonality_type: SeasonalityType) -> Self {
161        Self {
162            id: Uuid::new_v4(),
163            account_id,
164            seasonality_type,
165            period_length: seasonality_type.period_days() as u16,
166            cycle_count: 0,
167            _pad: 0,
168            confidence: 0.0,
169            autocorrelation_peak: 0.0,
170            seasonal_amplitude: 0.0,
171            baseline: 0.0,
172            trend_coefficient: 0.0,
173            residual_variance: 0.0,
174            first_observed: HybridTimestamp::zero(),
175            last_observed: HybridTimestamp::zero(),
176            flags: SeasonalPatternFlags(0),
177        }
178    }
179
180    /// Check if pattern is strong (confidence > 0.9).
181    pub fn is_strong(&self) -> bool {
182        self.confidence > 0.9
183    }
184
185    /// Check if pattern is statistically significant.
186    pub fn is_significant(&self) -> bool {
187        self.flags.0 & SeasonalPatternFlags::IS_STATISTICALLY_SIGNIFICANT != 0
188    }
189}
190
191/// Behavioral baseline for anomaly detection.
192/// Captures "normal" patterns for an account.
193#[derive(Debug, Clone, Archive, Serialize, Deserialize)]
194#[repr(C)]
195pub struct BehavioralBaseline {
196    /// Baseline identifier
197    pub id: Uuid,
198    /// Account this baseline belongs to
199    pub account_id: u16,
200    /// Number of historical periods used
201    pub period_count: u16,
202    /// Padding
203    pub _pad: u32,
204
205    // === Central tendency ===
206    /// Arithmetic mean
207    pub mean: f64,
208    /// Median (50th percentile)
209    pub median: f64,
210
211    // === Dispersion ===
212    /// Standard deviation
213    pub std_dev: f64,
214    /// Median Absolute Deviation (robust to outliers)
215    pub mad: f64,
216
217    // === Distribution shape ===
218    /// First quartile (25th percentile)
219    pub q1: f64,
220    /// Third quartile (75th percentile)
221    pub q3: f64,
222    /// Interquartile range (Q3 - Q1)
223    pub iqr: f64,
224    /// Minimum observed value
225    pub min_value: f64,
226    /// Maximum observed value
227    pub max_value: f64,
228    /// 5th percentile
229    pub p5: f64,
230    /// 95th percentile
231    pub p95: f64,
232
233    // === Distribution characteristics ===
234    /// Skewness (0 = symmetric, >0 = right tail, <0 = left tail)
235    pub skewness: f64,
236    /// Kurtosis (3 = normal, >3 = heavy tails)
237    pub kurtosis: f64,
238
239    // === Activity patterns ===
240    /// Average transaction count per period
241    pub avg_transaction_count: f64,
242    /// Std dev of transaction counts
243    pub transaction_count_std_dev: f64,
244
245    // === Timestamps ===
246    /// When baseline was last computed
247    pub last_updated: HybridTimestamp,
248    /// Start of baseline period
249    pub period_start: HybridTimestamp,
250    /// End of baseline period
251    pub period_end: HybridTimestamp,
252}
253
254impl BehavioralBaseline {
255    /// Create a new behavioral baseline for an account.
256    pub fn new(account_id: u16) -> Self {
257        Self {
258            id: Uuid::new_v4(),
259            account_id,
260            period_count: 0,
261            _pad: 0,
262            mean: 0.0,
263            median: 0.0,
264            std_dev: 0.0,
265            mad: 0.0,
266            q1: 0.0,
267            q3: 0.0,
268            iqr: 0.0,
269            min_value: 0.0,
270            max_value: 0.0,
271            p5: 0.0,
272            p95: 0.0,
273            skewness: 0.0,
274            kurtosis: 0.0,
275            avg_transaction_count: 0.0,
276            transaction_count_std_dev: 0.0,
277            last_updated: HybridTimestamp::zero(),
278            period_start: HybridTimestamp::zero(),
279            period_end: HybridTimestamp::zero(),
280        }
281    }
282
283    /// Compute z-score for a value.
284    pub fn z_score(&self, value: f64) -> f64 {
285        if self.std_dev > 0.0 {
286            (value - self.mean) / self.std_dev
287        } else {
288            0.0
289        }
290    }
291
292    /// Compute modified z-score (robust, uses MAD).
293    pub fn modified_z_score(&self, value: f64) -> f64 {
294        if self.mad > 0.0 {
295            0.6745 * (value - self.median) / self.mad
296        } else {
297            0.0
298        }
299    }
300
301    /// Check if value is an IQR outlier.
302    pub fn is_iqr_outlier(&self, value: f64) -> bool {
303        let lower_fence = self.q1 - 1.5 * self.iqr;
304        let upper_fence = self.q3 + 1.5 * self.iqr;
305        value < lower_fence || value > upper_fence
306    }
307
308    /// Check if value is a percentile outlier.
309    pub fn is_percentile_outlier(&self, value: f64) -> bool {
310        value < self.p5 || value > self.p95
311    }
312
313    /// Multi-method anomaly check.
314    pub fn is_anomaly(&self, value: f64) -> (bool, f32) {
315        let mut votes = 0;
316        let mut max_severity = 0.0f32;
317
318        // Method 1: Z-score > 3
319        let z = self.z_score(value).abs();
320        if z > 3.0 {
321            votes += 1;
322            max_severity = max_severity.max((z / 5.0) as f32);
323        }
324
325        // Method 2: Modified z-score > 3.5
326        let mz = self.modified_z_score(value).abs();
327        if mz > 3.5 {
328            votes += 1;
329            max_severity = max_severity.max((mz / 5.0) as f32);
330        }
331
332        // Method 3: IQR outlier
333        if self.is_iqr_outlier(value) {
334            votes += 1;
335            let iqr_deviation = if value < self.q1 {
336                (self.q1 - value) / self.iqr
337            } else {
338                (value - self.q3) / self.iqr
339            };
340            max_severity = max_severity.max(iqr_deviation as f32);
341        }
342
343        // Method 4: Percentile outlier
344        if self.is_percentile_outlier(value) {
345            votes += 1;
346        }
347
348        // Anomaly if >= 2 methods agree
349        let is_anomaly = votes >= 2;
350        let score = match votes {
351            2 => 0.5,
352            3 => 0.75,
353            4 => 1.0,
354            _ => 0.0,
355        } * max_severity.min(1.0);
356
357        (is_anomaly, score)
358    }
359}
360
361/// Time series metrics for trend/volatility analysis.
362#[derive(Debug, Clone, Archive, Serialize, Deserialize)]
363#[repr(C)]
364pub struct TimeSeriesMetrics {
365    /// Metrics identifier
366    pub id: Uuid,
367    /// Account this belongs to
368    pub account_id: u16,
369    /// Number of periods in the series
370    pub period_count: u16,
371    /// Time granularity
372    pub granularity: TimeGranularity,
373    /// Padding
374    pub _pad: [u8; 3],
375
376    // === Trend ===
377    /// Linear trend slope (OLS)
378    pub trend_coefficient: f64,
379    /// Trend intercept
380    pub trend_intercept: f64,
381    /// R-squared for trend fit
382    pub trend_r_squared: f64,
383
384    // === Volatility ===
385    /// Standard deviation of values
386    pub volatility: f64,
387    /// Coefficient of variation (std_dev / mean)
388    pub coefficient_of_variation: f64,
389
390    // === Moving averages ===
391    /// Simple moving average (last period)
392    pub sma: f64,
393    /// Exponential moving average (last period)
394    pub ema: f64,
395
396    // === Current state ===
397    /// Most recent value
398    pub current_value: f64,
399    /// Forecasted next value
400    pub forecasted_value: f64,
401    /// Forecast confidence interval (95%)
402    pub forecast_ci: f64,
403
404    // === Timestamps ===
405    /// Start of the time series period.
406    pub period_start: HybridTimestamp,
407
408    // === Flags ===
409    /// Time series property flags.
410    pub flags: TimeSeriesFlags,
411}
412
413/// Flags for time series properties.
414#[derive(Debug, Clone, Copy, Default, Archive, Serialize, Deserialize)]
415#[repr(transparent)]
416pub struct TimeSeriesFlags(pub u32);
417
418impl TimeSeriesFlags {
419    /// Flag: Series has a statistically significant trend.
420    pub const HAS_SIGNIFICANT_TREND: u32 = 1 << 0;
421    /// Flag: Series is increasing (upward trend).
422    pub const IS_INCREASING: u32 = 1 << 1;
423    /// Flag: Series is decreasing (downward trend).
424    pub const IS_DECREASING: u32 = 1 << 2;
425    /// Flag: Series has high volatility.
426    pub const IS_HIGH_VOLATILITY: u32 = 1 << 3;
427    /// Flag: Series is stationary (no trend).
428    pub const IS_STATIONARY: u32 = 1 << 4;
429}
430
431impl TimeSeriesMetrics {
432    /// Create new time series metrics for an account.
433    pub fn new(account_id: u16, granularity: TimeGranularity) -> Self {
434        Self {
435            id: Uuid::new_v4(),
436            account_id,
437            period_count: 0,
438            granularity,
439            _pad: [0; 3],
440            trend_coefficient: 0.0,
441            trend_intercept: 0.0,
442            trend_r_squared: 0.0,
443            volatility: 0.0,
444            coefficient_of_variation: 0.0,
445            sma: 0.0,
446            ema: 0.0,
447            current_value: 0.0,
448            forecasted_value: 0.0,
449            forecast_ci: 0.0,
450            period_start: HybridTimestamp::zero(),
451            flags: TimeSeriesFlags(0),
452        }
453    }
454
455    /// Check if there's a significant upward trend.
456    pub fn is_increasing(&self) -> bool {
457        self.flags.0 & TimeSeriesFlags::IS_INCREASING != 0
458    }
459
460    /// Check if there's a significant downward trend.
461    pub fn is_decreasing(&self) -> bool {
462        self.flags.0 & TimeSeriesFlags::IS_DECREASING != 0
463    }
464
465    /// Check if volatility is high (CV > 0.5).
466    pub fn is_volatile(&self) -> bool {
467        self.flags.0 & TimeSeriesFlags::IS_HIGH_VOLATILITY != 0
468    }
469}
470
471/// An alert generated by temporal analysis.
472#[derive(Debug, Clone)]
473pub struct TemporalAlert {
474    /// Alert identifier
475    pub id: Uuid,
476    /// Account involved
477    pub account_id: u16,
478    /// Alert type
479    pub alert_type: TemporalAlertType,
480    /// Severity (0.0 - 1.0)
481    pub severity: f32,
482    /// Value that triggered the alert
483    pub trigger_value: f64,
484    /// Expected value (baseline)
485    pub expected_value: f64,
486    /// Deviation (trigger - expected)
487    pub deviation: f64,
488    /// When the alert was raised
489    pub timestamp: HybridTimestamp,
490    /// Human-readable message
491    pub message: String,
492}
493
494/// Types of temporal alerts.
495#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
496pub enum TemporalAlertType {
497    /// Value outside normal range
498    Anomaly,
499    /// Sudden trend change
500    TrendBreak,
501    /// Missing expected seasonal activity
502    SeasonalDeviation,
503    /// Activity after long dormancy
504    DormantActivation,
505    /// Rapid value changes
506    HighVolatility,
507    /// Unusual transaction frequency
508    FrequencyAnomaly,
509}
510
511impl TemporalAlertType {
512    /// Get icon character for this alert type.
513    pub fn icon(&self) -> &'static str {
514        match self {
515            TemporalAlertType::Anomaly => "⚠️",
516            TemporalAlertType::TrendBreak => "📈",
517            TemporalAlertType::SeasonalDeviation => "📅",
518            TemporalAlertType::DormantActivation => "💤",
519            TemporalAlertType::HighVolatility => "📊",
520            TemporalAlertType::FrequencyAnomaly => "🔢",
521        }
522    }
523
524    /// Get description of this alert type.
525    pub fn description(&self) -> &'static str {
526        match self {
527            TemporalAlertType::Anomaly => "Value outside normal range",
528            TemporalAlertType::TrendBreak => "Significant trend change detected",
529            TemporalAlertType::SeasonalDeviation => "Deviation from seasonal pattern",
530            TemporalAlertType::DormantActivation => "Activity on dormant account",
531            TemporalAlertType::HighVolatility => "Unusually high value volatility",
532            TemporalAlertType::FrequencyAnomaly => "Unusual transaction frequency",
533        }
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn test_baseline_anomaly_detection() {
543        let mut baseline = BehavioralBaseline::new(0);
544        baseline.mean = 100.0;
545        baseline.std_dev = 10.0;
546        baseline.median = 100.0;
547        baseline.mad = 7.5;
548        baseline.q1 = 90.0;
549        baseline.q3 = 110.0;
550        baseline.iqr = 20.0;
551        baseline.p5 = 80.0;
552        baseline.p95 = 120.0;
553
554        // Normal value
555        let (is_anomaly, _) = baseline.is_anomaly(105.0);
556        assert!(!is_anomaly);
557
558        // Clear outlier
559        let (is_anomaly, score) = baseline.is_anomaly(200.0);
560        assert!(is_anomaly);
561        assert!(score > 0.5);
562    }
563
564    #[test]
565    fn test_z_score() {
566        let mut baseline = BehavioralBaseline::new(0);
567        baseline.mean = 100.0;
568        baseline.std_dev = 10.0;
569
570        let z = baseline.z_score(130.0);
571        assert!((z - 3.0).abs() < 0.01);
572    }
573
574    #[test]
575    fn test_seasonality_period() {
576        assert_eq!(SeasonalityType::Monthly.period_days(), 30);
577        assert_eq!(SeasonalityType::Quarterly.period_days(), 91);
578        assert_eq!(SeasonalityType::Annual.period_days(), 365);
579    }
580}