Skip to main content

mockforge_contracts/contract_drift/forecasting/
pattern_analyzer.rs

1//! Pattern analysis for forecasting
2//!
3//! This module analyzes historical drift incidents to detect patterns
4//! that can be used for predicting future changes.
5
6use super::types::{ForecastPattern, PatternAnalysis, PatternType};
7use chrono::{DateTime, Utc};
8use mockforge_foundation::incidents_types::{DriftIncident, IncidentType};
9
10/// Pattern analyzer for detecting change patterns
11pub struct PatternAnalyzer {
12    /// Minimum occurrences to consider a pattern valid
13    min_occurrences: usize,
14    /// Confidence threshold for patterns
15    confidence_threshold: f64,
16}
17
18impl PatternAnalyzer {
19    /// Create a new pattern analyzer
20    pub fn new(min_occurrences: usize, confidence_threshold: f64) -> Self {
21        Self {
22            min_occurrences,
23            confidence_threshold,
24        }
25    }
26
27    /// Analyze historical incidents to detect patterns
28    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        // Sort incidents by detection time
48        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        // Calculate intervals between changes
59        let intervals = self.calculate_intervals(&sorted_incidents);
60        let breaking_intervals = self.calculate_breaking_intervals(&sorted_incidents);
61
62        // Detect patterns
63        let patterns = self.detect_patterns(&sorted_incidents, &intervals);
64
65        // Calculate volatility (based on frequency and variance)
66        let volatility_score = self.calculate_volatility(&intervals, window_start, window_end);
67
68        // Calculate averages
69        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    /// Calculate time intervals between incidents
99    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    /// Calculate time intervals between breaking changes
120    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    /// Detect patterns in incidents
146    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        // Filter by confidence threshold
162        patterns.retain(|p| p.confidence >= self.confidence_threshold);
163
164        patterns
165    }
166
167    /// Detect regular patterns (weekly, monthly, quarterly)
168    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        // Check for weekly pattern (6-8 days)
185        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        // Check for monthly pattern (28-32 days)
202        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        // Check for quarterly pattern (88-92 days)
219        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    /// Detect breaking change patterns
239    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    /// Detect field-related patterns from incident details
278    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    /// Calculate pattern confidence based on consistency
321    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    /// Calculate volatility score
338    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}