mockforge_contracts/contract_drift/forecasting/
pattern_analyzer.rs1use super::types::{ForecastPattern, PatternAnalysis, PatternType};
7use chrono::{DateTime, Utc};
8use mockforge_foundation::incidents_types::{DriftIncident, IncidentType};
9
10pub struct PatternAnalyzer {
12 min_occurrences: usize,
14 confidence_threshold: f64,
16}
17
18impl PatternAnalyzer {
19 pub fn new(min_occurrences: usize, confidence_threshold: f64) -> Self {
21 Self {
22 min_occurrences,
23 confidence_threshold,
24 }
25 }
26
27 pub fn analyze_patterns(
29 &self,
30 incidents: &[DriftIncident],
31 window_start: DateTime<Utc>,
32 window_end: DateTime<Utc>,
33 ) -> PatternAnalysis {
34 if incidents.is_empty() {
35 return PatternAnalysis {
36 patterns: Vec::new(),
37 volatility_score: 0.0,
38 avg_change_interval_days: 0.0,
39 avg_breaking_change_interval_days: None,
40 total_changes: 0,
41 total_breaking_changes: 0,
42 window_start,
43 window_end,
44 };
45 }
46
47 let mut sorted_incidents: Vec<_> = incidents
49 .iter()
50 .filter(|inc| {
51 let detected =
52 DateTime::<Utc>::from_timestamp(inc.detected_at, 0).unwrap_or_else(Utc::now);
53 detected >= window_start && detected <= window_end
54 })
55 .collect();
56 sorted_incidents.sort_by_key(|inc| inc.detected_at);
57
58 let intervals = self.calculate_intervals(&sorted_incidents);
60 let breaking_intervals = self.calculate_breaking_intervals(&sorted_incidents);
61
62 let patterns = self.detect_patterns(&sorted_incidents, &intervals);
64
65 let volatility_score = self.calculate_volatility(&intervals, window_start, window_end);
67
68 let avg_change_interval_days = if !intervals.is_empty() {
70 intervals.iter().sum::<f64>() / intervals.len() as f64
71 } else {
72 0.0
73 };
74
75 let avg_breaking_change_interval_days = if !breaking_intervals.is_empty() {
76 Some(breaking_intervals.iter().sum::<f64>() / breaking_intervals.len() as f64)
77 } else {
78 None
79 };
80
81 let total_breaking_changes = sorted_incidents
82 .iter()
83 .filter(|inc| inc.incident_type == IncidentType::BreakingChange)
84 .count();
85
86 PatternAnalysis {
87 patterns,
88 volatility_score,
89 avg_change_interval_days,
90 avg_breaking_change_interval_days,
91 total_changes: sorted_incidents.len(),
92 total_breaking_changes,
93 window_start,
94 window_end,
95 }
96 }
97
98 fn calculate_intervals(&self, incidents: &[&DriftIncident]) -> Vec<f64> {
100 if incidents.len() < 2 {
101 return Vec::new();
102 }
103
104 let mut intervals = Vec::new();
105 for i in 1..incidents.len() {
106 let prev_time = DateTime::<Utc>::from_timestamp(incidents[i - 1].detected_at, 0)
107 .unwrap_or_else(Utc::now);
108 let curr_time = DateTime::<Utc>::from_timestamp(incidents[i].detected_at, 0)
109 .unwrap_or_else(Utc::now);
110
111 let duration = curr_time.signed_duration_since(prev_time);
112 let days = duration.num_seconds() as f64 / 86400.0;
113 intervals.push(days);
114 }
115
116 intervals
117 }
118
119 fn calculate_breaking_intervals(&self, incidents: &[&DriftIncident]) -> Vec<f64> {
121 let breaking: Vec<_> = incidents
122 .iter()
123 .filter(|inc| inc.incident_type == IncidentType::BreakingChange)
124 .collect();
125
126 if breaking.len() < 2 {
127 return Vec::new();
128 }
129
130 let mut intervals = Vec::new();
131 for i in 1..breaking.len() {
132 let prev_time = DateTime::<Utc>::from_timestamp(breaking[i - 1].detected_at, 0)
133 .unwrap_or_else(Utc::now);
134 let curr_time = DateTime::<Utc>::from_timestamp(breaking[i].detected_at, 0)
135 .unwrap_or_else(Utc::now);
136
137 let duration = curr_time.signed_duration_since(prev_time);
138 let days = duration.num_seconds() as f64 / 86400.0;
139 intervals.push(days);
140 }
141
142 intervals
143 }
144
145 fn detect_patterns(
147 &self,
148 incidents: &[&DriftIncident],
149 intervals: &[f64],
150 ) -> Vec<ForecastPattern> {
151 let mut patterns = Vec::new();
152
153 if intervals.is_empty() {
154 return patterns;
155 }
156
157 patterns.extend(self.detect_regular_patterns(intervals, incidents));
158 patterns.extend(self.detect_breaking_patterns(incidents));
159 patterns.extend(self.detect_field_patterns(incidents));
160
161 patterns.retain(|p| p.confidence >= self.confidence_threshold);
163
164 patterns
165 }
166
167 fn detect_regular_patterns(
169 &self,
170 intervals: &[f64],
171 incidents: &[&DriftIncident],
172 ) -> Vec<ForecastPattern> {
173 let mut patterns = Vec::new();
174
175 if intervals.len() < self.min_occurrences {
176 return patterns;
177 }
178
179 let avg_interval = intervals.iter().sum::<f64>() / intervals.len() as f64;
180 let variance = intervals.iter().map(|x| (x - avg_interval).powi(2)).sum::<f64>()
181 / intervals.len() as f64;
182 let stddev = variance.sqrt();
183
184 if (6.0..=8.0).contains(&avg_interval) && stddev < 2.0 {
186 if let Some(last) = incidents.last() {
187 let last_time =
188 DateTime::<Utc>::from_timestamp(last.detected_at, 0).unwrap_or_else(Utc::now);
189 let confidence = self.calculate_pattern_confidence(intervals, avg_interval, stddev);
190 patterns.push(ForecastPattern {
191 pattern_type: PatternType::WeeklyUpdate,
192 frequency_days: avg_interval,
193 last_occurrence: last_time,
194 confidence,
195 occurrence_count: intervals.len() + 1,
196 frequency_stddev: stddev,
197 });
198 }
199 }
200
201 if (28.0..=32.0).contains(&avg_interval) && stddev < 5.0 {
203 if let Some(last) = incidents.last() {
204 let last_time =
205 DateTime::<Utc>::from_timestamp(last.detected_at, 0).unwrap_or_else(Utc::now);
206 let confidence = self.calculate_pattern_confidence(intervals, avg_interval, stddev);
207 patterns.push(ForecastPattern {
208 pattern_type: PatternType::MonthlyMaintenance,
209 frequency_days: avg_interval,
210 last_occurrence: last_time,
211 confidence,
212 occurrence_count: intervals.len() + 1,
213 frequency_stddev: stddev,
214 });
215 }
216 }
217
218 if (88.0..=92.0).contains(&avg_interval) && stddev < 10.0 {
220 if let Some(last) = incidents.last() {
221 let last_time =
222 DateTime::<Utc>::from_timestamp(last.detected_at, 0).unwrap_or_else(Utc::now);
223 let confidence = self.calculate_pattern_confidence(intervals, avg_interval, stddev);
224 patterns.push(ForecastPattern {
225 pattern_type: PatternType::QuarterlyRefactor,
226 frequency_days: avg_interval,
227 last_occurrence: last_time,
228 confidence,
229 occurrence_count: intervals.len() + 1,
230 frequency_stddev: stddev,
231 });
232 }
233 }
234
235 patterns
236 }
237
238 fn detect_breaking_patterns(&self, incidents: &[&DriftIncident]) -> Vec<ForecastPattern> {
240 let breaking: Vec<&DriftIncident> = incidents
241 .iter()
242 .filter(|inc| inc.incident_type == IncidentType::BreakingChange)
243 .copied()
244 .collect();
245
246 if breaking.len() < self.min_occurrences {
247 return Vec::new();
248 }
249
250 let intervals = self.calculate_breaking_intervals(&breaking);
251 if intervals.is_empty() {
252 return Vec::new();
253 }
254
255 let avg_interval = intervals.iter().sum::<f64>() / intervals.len() as f64;
256 let variance = intervals.iter().map(|x| (x - avg_interval).powi(2)).sum::<f64>()
257 / intervals.len() as f64;
258 let stddev = variance.sqrt();
259
260 if let Some(last) = breaking.last() {
261 let last_time =
262 DateTime::<Utc>::from_timestamp(last.detected_at, 0).unwrap_or_else(Utc::now);
263 let confidence = self.calculate_pattern_confidence(&intervals, avg_interval, stddev);
264 vec![ForecastPattern {
265 pattern_type: PatternType::BreakingChange,
266 frequency_days: avg_interval,
267 last_occurrence: last_time,
268 confidence,
269 occurrence_count: breaking.len(),
270 frequency_stddev: stddev,
271 }]
272 } else {
273 Vec::new()
274 }
275 }
276
277 fn detect_field_patterns(&self, incidents: &[&DriftIncident]) -> Vec<ForecastPattern> {
279 let field_additions: Vec<&DriftIncident> = incidents
280 .iter()
281 .filter(|inc| {
282 inc.details
283 .as_object()
284 .and_then(|obj| obj.get("change_type"))
285 .and_then(|v| v.as_str())
286 .map(|s| s.contains("field_added") || s.contains("field_addition"))
287 .unwrap_or(false)
288 })
289 .copied()
290 .collect();
291
292 if field_additions.len() >= self.min_occurrences {
293 let intervals = self.calculate_intervals(&field_additions);
294 if !intervals.is_empty() {
295 let avg_interval = intervals.iter().sum::<f64>() / intervals.len() as f64;
296 let variance = intervals.iter().map(|x| (x - avg_interval).powi(2)).sum::<f64>()
297 / intervals.len() as f64;
298 let stddev = variance.sqrt();
299
300 if let Some(last) = field_additions.last() {
301 let last_time = DateTime::<Utc>::from_timestamp(last.detected_at, 0)
302 .unwrap_or_else(Utc::now);
303 let confidence =
304 self.calculate_pattern_confidence(&intervals, avg_interval, stddev);
305 return vec![ForecastPattern {
306 pattern_type: PatternType::FieldAddition,
307 frequency_days: avg_interval,
308 last_occurrence: last_time,
309 confidence,
310 occurrence_count: field_additions.len(),
311 frequency_stddev: stddev,
312 }];
313 }
314 }
315 }
316
317 Vec::new()
318 }
319
320 fn calculate_pattern_confidence(
322 &self,
323 intervals: &[f64],
324 avg_interval: f64,
325 stddev: f64,
326 ) -> f64 {
327 if intervals.is_empty() || avg_interval == 0.0 {
328 return 0.0;
329 }
330
331 let occurrence_factor = (intervals.len().min(10) as f64 / 10.0).min(1.0);
332 let consistency_factor = (1.0 - (stddev / avg_interval).min(1.0)).max(0.0);
333
334 (occurrence_factor * 0.4 + consistency_factor * 0.6).min(1.0)
335 }
336
337 fn calculate_volatility(
339 &self,
340 intervals: &[f64],
341 window_start: DateTime<Utc>,
342 window_end: DateTime<Utc>,
343 ) -> f64 {
344 if intervals.is_empty() {
345 return 0.0;
346 }
347
348 let window_days = (window_end - window_start).num_seconds() as f64 / 86400.0;
349 if window_days == 0.0 {
350 return 0.0;
351 }
352
353 let change_count = intervals.len() + 1;
354 let frequency = change_count as f64 / window_days;
355
356 let avg_interval = intervals.iter().sum::<f64>() / intervals.len() as f64;
357 let variance = intervals.iter().map(|x| (x - avg_interval).powi(2)).sum::<f64>()
358 / intervals.len() as f64;
359 let coefficient_of_variation = if avg_interval > 0.0 {
360 variance.sqrt() / avg_interval
361 } else {
362 0.0
363 };
364
365 let frequency_score = (frequency * 30.0).min(1.0);
366 let variance_score = coefficient_of_variation.min(1.0);
367
368 (frequency_score * 0.6 + variance_score * 0.4).min(1.0)
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use chrono::Duration;
376 use mockforge_foundation::incidents_types::{IncidentSeverity, IncidentStatus};
377
378 fn create_test_incident(
379 id: &str,
380 detected_at: i64,
381 incident_type: IncidentType,
382 ) -> DriftIncident {
383 DriftIncident {
384 id: id.to_string(),
385 budget_id: None,
386 workspace_id: None,
387 endpoint: "/api/test".to_string(),
388 method: "GET".to_string(),
389 incident_type,
390 severity: IncidentSeverity::Medium,
391 status: IncidentStatus::Open,
392 detected_at,
393 resolved_at: None,
394 details: serde_json::json!({}),
395 external_ticket_id: None,
396 external_ticket_url: None,
397 created_at: detected_at,
398 updated_at: detected_at,
399 sync_cycle_id: None,
400 contract_diff_id: None,
401 before_sample: None,
402 after_sample: None,
403 fitness_test_results: Vec::new(),
404 affected_consumers: None,
405 protocol: None,
406 }
407 }
408
409 #[test]
410 fn test_analyze_empty_incidents() {
411 let analyzer = PatternAnalyzer::new(3, 0.5);
412 let window_start = Utc::now() - Duration::days(90);
413 let window_end = Utc::now();
414 let analysis = analyzer.analyze_patterns(&[], window_start, window_end);
415
416 assert_eq!(analysis.total_changes, 0);
417 assert_eq!(analysis.volatility_score, 0.0);
418 }
419
420 #[test]
421 fn test_detect_weekly_pattern() {
422 let analyzer = PatternAnalyzer::new(3, 0.5);
423 let now = Utc::now();
424 let mut incidents = Vec::new();
425
426 for i in 0..5 {
427 let timestamp = (now - Duration::days(i * 7)).timestamp();
428 incidents.push(create_test_incident(
429 &format!("inc_{}", i),
430 timestamp,
431 IncidentType::ThresholdExceeded,
432 ));
433 }
434
435 let window_start = now - Duration::days(35);
436 let window_end = now;
437 let analysis = analyzer.analyze_patterns(&incidents, window_start, window_end);
438
439 assert!(analysis.volatility_score > 0.0);
440 assert!(!analysis.patterns.is_empty());
441 }
442}