ringkernel_accnet/kernels/
temporal.rs1use crate::models::{
9 AccountingNetwork, BehavioralBaseline, HybridTimestamp, SeasonalPattern, TemporalAlert,
10 TemporalAlertType, TimeGranularity,
11};
12use std::collections::HashMap;
13
14#[derive(Debug, Clone)]
16pub struct TemporalConfig {
17 pub block_size: u32,
19 pub z_score_threshold: f64,
21 pub min_baseline_points: usize,
23 pub granularity: TimeGranularity,
25}
26
27impl Default for TemporalConfig {
28 fn default() -> Self {
29 Self {
30 block_size: 256,
31 z_score_threshold: 3.0,
32 min_baseline_points: 30,
33 granularity: TimeGranularity::Daily,
34 }
35 }
36}
37
38#[derive(Debug, Clone, Default)]
40pub struct TemporalResult {
41 pub baselines: HashMap<u16, BehavioralBaseline>,
43 pub seasonal_patterns: Vec<SeasonalPattern>,
45 pub alerts: Vec<TemporalAlert>,
47 pub stats: TemporalStats,
49}
50
51#[derive(Debug, Clone, Default)]
53pub struct TemporalStats {
54 pub periods_analyzed: usize,
56 pub baselines_computed: usize,
58 pub anomalies_detected: usize,
60}
61
62pub struct TemporalKernel {
64 config: TemporalConfig,
65}
66
67impl TemporalKernel {
68 pub fn new(config: TemporalConfig) -> Self {
70 Self { config }
71 }
72
73 pub fn analyze(&self, network: &AccountingNetwork) -> TemporalResult {
75 let mut result = TemporalResult::default();
76
77 let account_series = self.build_time_series(network);
79
80 for (account_idx, series) in &account_series {
82 if series.len() >= self.config.min_baseline_points {
83 let baseline = self.compute_baseline(*account_idx, series);
84 result.baselines.insert(*account_idx, baseline);
85 result.stats.baselines_computed += 1;
86 }
87 }
88
89 for (account_idx, series) in &account_series {
91 if let Some(baseline) = result.baselines.get(account_idx) {
92 for (timestamp, value) in series {
93 let (is_anomaly, score) = baseline.is_anomaly(*value);
94 if is_anomaly {
95 result.alerts.push(TemporalAlert {
96 id: uuid::Uuid::new_v4(),
97 account_id: *account_idx,
98 alert_type: TemporalAlertType::Anomaly,
99 severity: score,
100 trigger_value: *value,
101 expected_value: baseline.mean,
102 deviation: (*value - baseline.mean).abs() / baseline.std_dev.max(0.001),
103 timestamp: *timestamp,
104 message: format!(
105 "Anomalous activity: {:.2} (expected {:.2} ± {:.2})",
106 value,
107 baseline.mean,
108 baseline.std_dev * 2.0
109 ),
110 });
111 result.stats.anomalies_detected += 1;
112 }
113 }
114 }
115 }
116
117 result.stats.periods_analyzed = account_series.values().map(|s| s.len()).sum();
118
119 result
120 }
121
122 fn build_time_series(
124 &self,
125 network: &AccountingNetwork,
126 ) -> HashMap<u16, Vec<(HybridTimestamp, f64)>> {
127 let mut series: HashMap<u16, Vec<(HybridTimestamp, f64)>> = HashMap::new();
128
129 for flow in &network.flows {
130 let amount = flow.amount.to_f64();
131
132 series
134 .entry(flow.source_account_index)
135 .or_default()
136 .push((flow.timestamp, -amount));
137
138 series
140 .entry(flow.target_account_index)
141 .or_default()
142 .push((flow.timestamp, amount));
143 }
144
145 for series_vec in series.values_mut() {
147 series_vec.sort_by_key(|(ts, _)| ts.physical);
148 }
149
150 series
151 }
152
153 fn compute_baseline(
155 &self,
156 account_id: u16,
157 series: &[(HybridTimestamp, f64)],
158 ) -> BehavioralBaseline {
159 let values: Vec<f64> = series.iter().map(|(_, v)| *v).collect();
160 let n = values.len() as f64;
161
162 let mean = values.iter().sum::<f64>() / n;
163 let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
164 let std_dev = variance.sqrt();
165
166 let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
167 let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
168
169 let mut sorted = values.clone();
171 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
172
173 let p5_idx = ((n * 0.05) as usize).min(sorted.len().saturating_sub(1));
174 let p25_idx = ((n * 0.25) as usize).min(sorted.len().saturating_sub(1));
175 let p50_idx = ((n * 0.50) as usize).min(sorted.len().saturating_sub(1));
176 let p75_idx = ((n * 0.75) as usize).min(sorted.len().saturating_sub(1));
177 let p95_idx = ((n * 0.95) as usize).min(sorted.len().saturating_sub(1));
178
179 let median = sorted.get(p50_idx).copied().unwrap_or(mean);
180 let q1 = sorted.get(p25_idx).copied().unwrap_or(min);
181 let q3 = sorted.get(p75_idx).copied().unwrap_or(max);
182 let iqr = q3 - q1;
183
184 let mut abs_deviations: Vec<f64> = values.iter().map(|v| (v - median).abs()).collect();
186 abs_deviations.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
187 let mad_idx = ((abs_deviations.len() as f64 * 0.5) as usize)
188 .min(abs_deviations.len().saturating_sub(1));
189 let mad = abs_deviations.get(mad_idx).copied().unwrap_or(0.0);
190
191 let mut baseline = BehavioralBaseline::new(account_id);
192 baseline.mean = mean;
193 baseline.std_dev = std_dev;
194 baseline.min_value = min;
195 baseline.max_value = max;
196 baseline.median = median;
197 baseline.mad = mad;
198 baseline.q1 = q1;
199 baseline.q3 = q3;
200 baseline.iqr = iqr;
201 baseline.p5 = sorted.get(p5_idx).copied().unwrap_or(min);
202 baseline.p95 = sorted.get(p95_idx).copied().unwrap_or(max);
203 baseline.period_count = series.len() as u16;
204
205 baseline
206 }
207}
208
209impl Default for TemporalKernel {
210 fn default() -> Self {
211 Self::new(TemporalConfig::default())
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn test_temporal_kernel_creation() {
221 let kernel = TemporalKernel::default();
222 assert_eq!(kernel.config.z_score_threshold, 3.0);
223 }
224
225 #[test]
226 fn test_baseline_computation() {
227 let kernel = TemporalKernel::default();
228 let series: Vec<(HybridTimestamp, f64)> = (0..100)
229 .map(|i| (HybridTimestamp::new(i * 1000, 0), (i % 10) as f64))
230 .collect();
231
232 let baseline = kernel.compute_baseline(0, &series);
233 assert!(baseline.mean >= 0.0);
234 assert!(baseline.std_dev >= 0.0);
235 assert_eq!(baseline.period_count, 100);
236 }
237}