use super::types::{ForecastPattern, PatternAnalysis, PatternType};
use crate::incidents::types::{DriftIncident, IncidentType};
use chrono::{DateTime, Utc};
pub struct PatternAnalyzer {
min_occurrences: usize,
confidence_threshold: f64,
}
impl PatternAnalyzer {
pub fn new(min_occurrences: usize, confidence_threshold: f64) -> Self {
Self {
min_occurrences,
confidence_threshold,
}
}
pub fn analyze_patterns(
&self,
incidents: &[DriftIncident],
window_start: DateTime<Utc>,
window_end: DateTime<Utc>,
) -> PatternAnalysis {
if incidents.is_empty() {
return PatternAnalysis {
patterns: Vec::new(),
volatility_score: 0.0,
avg_change_interval_days: 0.0,
avg_breaking_change_interval_days: None,
total_changes: 0,
total_breaking_changes: 0,
window_start,
window_end,
};
}
let mut sorted_incidents: Vec<_> = incidents
.iter()
.filter(|inc| {
let detected =
DateTime::<Utc>::from_timestamp(inc.detected_at, 0).unwrap_or_else(Utc::now);
detected >= window_start && detected <= window_end
})
.collect();
sorted_incidents.sort_by_key(|inc| inc.detected_at);
let intervals = self.calculate_intervals(&sorted_incidents);
let breaking_intervals = self.calculate_breaking_intervals(&sorted_incidents);
let patterns = self.detect_patterns(&sorted_incidents, &intervals);
let volatility_score = self.calculate_volatility(&intervals, window_start, window_end);
let avg_change_interval_days = if !intervals.is_empty() {
intervals.iter().sum::<f64>() / intervals.len() as f64
} else {
0.0
};
let avg_breaking_change_interval_days = if !breaking_intervals.is_empty() {
Some(breaking_intervals.iter().sum::<f64>() / breaking_intervals.len() as f64)
} else {
None
};
let total_breaking_changes = sorted_incidents
.iter()
.filter(|inc| inc.incident_type == IncidentType::BreakingChange)
.count();
PatternAnalysis {
patterns,
volatility_score,
avg_change_interval_days,
avg_breaking_change_interval_days,
total_changes: sorted_incidents.len(),
total_breaking_changes,
window_start,
window_end,
}
}
fn calculate_intervals(&self, incidents: &[&DriftIncident]) -> Vec<f64> {
if incidents.len() < 2 {
return Vec::new();
}
let mut intervals = Vec::new();
for i in 1..incidents.len() {
let prev_time = DateTime::<Utc>::from_timestamp(incidents[i - 1].detected_at, 0)
.unwrap_or_else(Utc::now);
let curr_time = DateTime::<Utc>::from_timestamp(incidents[i].detected_at, 0)
.unwrap_or_else(Utc::now);
let duration = curr_time.signed_duration_since(prev_time);
let days = duration.num_seconds() as f64 / 86400.0;
intervals.push(days);
}
intervals
}
fn calculate_breaking_intervals(&self, incidents: &[&DriftIncident]) -> Vec<f64> {
let breaking: Vec<_> = incidents
.iter()
.filter(|inc| inc.incident_type == IncidentType::BreakingChange)
.collect();
if breaking.len() < 2 {
return Vec::new();
}
let mut intervals = Vec::new();
for i in 1..breaking.len() {
let prev_time = DateTime::<Utc>::from_timestamp(breaking[i - 1].detected_at, 0)
.unwrap_or_else(Utc::now);
let curr_time = DateTime::<Utc>::from_timestamp(breaking[i].detected_at, 0)
.unwrap_or_else(Utc::now);
let duration = curr_time.signed_duration_since(prev_time);
let days = duration.num_seconds() as f64 / 86400.0;
intervals.push(days);
}
intervals
}
fn detect_patterns(
&self,
incidents: &[&DriftIncident],
intervals: &[f64],
) -> Vec<ForecastPattern> {
let mut patterns = Vec::new();
if intervals.is_empty() {
return patterns;
}
patterns.extend(self.detect_regular_patterns(intervals, incidents));
patterns.extend(self.detect_breaking_patterns(incidents));
patterns.extend(self.detect_field_patterns(incidents));
patterns.retain(|p| p.confidence >= self.confidence_threshold);
patterns
}
fn detect_regular_patterns(
&self,
intervals: &[f64],
incidents: &[&DriftIncident],
) -> Vec<ForecastPattern> {
let mut patterns = Vec::new();
if intervals.len() < self.min_occurrences {
return patterns;
}
let avg_interval = intervals.iter().sum::<f64>() / intervals.len() as f64;
let variance = intervals.iter().map(|x| (x - avg_interval).powi(2)).sum::<f64>()
/ intervals.len() as f64;
let stddev = variance.sqrt();
if (6.0..=8.0).contains(&avg_interval) && stddev < 2.0 {
if let Some(last) = incidents.last() {
let last_time =
DateTime::<Utc>::from_timestamp(last.detected_at, 0).unwrap_or_else(Utc::now);
let confidence = self.calculate_pattern_confidence(intervals, avg_interval, stddev);
patterns.push(ForecastPattern {
pattern_type: PatternType::WeeklyUpdate,
frequency_days: avg_interval,
last_occurrence: last_time,
confidence,
occurrence_count: intervals.len() + 1,
frequency_stddev: stddev,
});
}
}
if (28.0..=32.0).contains(&avg_interval) && stddev < 5.0 {
if let Some(last) = incidents.last() {
let last_time =
DateTime::<Utc>::from_timestamp(last.detected_at, 0).unwrap_or_else(Utc::now);
let confidence = self.calculate_pattern_confidence(intervals, avg_interval, stddev);
patterns.push(ForecastPattern {
pattern_type: PatternType::MonthlyMaintenance,
frequency_days: avg_interval,
last_occurrence: last_time,
confidence,
occurrence_count: intervals.len() + 1,
frequency_stddev: stddev,
});
}
}
if (88.0..=92.0).contains(&avg_interval) && stddev < 10.0 {
if let Some(last) = incidents.last() {
let last_time =
DateTime::<Utc>::from_timestamp(last.detected_at, 0).unwrap_or_else(Utc::now);
let confidence = self.calculate_pattern_confidence(intervals, avg_interval, stddev);
patterns.push(ForecastPattern {
pattern_type: PatternType::QuarterlyRefactor,
frequency_days: avg_interval,
last_occurrence: last_time,
confidence,
occurrence_count: intervals.len() + 1,
frequency_stddev: stddev,
});
}
}
patterns
}
fn detect_breaking_patterns(&self, incidents: &[&DriftIncident]) -> Vec<ForecastPattern> {
let breaking: Vec<&DriftIncident> = incidents
.iter()
.filter(|inc| inc.incident_type == IncidentType::BreakingChange)
.copied()
.collect();
if breaking.len() < self.min_occurrences {
return Vec::new();
}
let intervals = self.calculate_breaking_intervals(&breaking);
if intervals.is_empty() {
return Vec::new();
}
let avg_interval = intervals.iter().sum::<f64>() / intervals.len() as f64;
let variance = intervals.iter().map(|x| (x - avg_interval).powi(2)).sum::<f64>()
/ intervals.len() as f64;
let stddev = variance.sqrt();
if let Some(last) = breaking.last() {
let last_time =
DateTime::<Utc>::from_timestamp(last.detected_at, 0).unwrap_or_else(Utc::now);
let confidence = self.calculate_pattern_confidence(&intervals, avg_interval, stddev);
vec![ForecastPattern {
pattern_type: PatternType::BreakingChange,
frequency_days: avg_interval,
last_occurrence: last_time,
confidence,
occurrence_count: breaking.len(),
frequency_stddev: stddev,
}]
} else {
Vec::new()
}
}
fn detect_field_patterns(&self, incidents: &[&DriftIncident]) -> Vec<ForecastPattern> {
let field_additions: Vec<&DriftIncident> = incidents
.iter()
.filter(|inc| {
inc.details
.as_object()
.and_then(|obj| obj.get("change_type"))
.and_then(|v| v.as_str())
.map(|s| s.contains("field_added") || s.contains("field_addition"))
.unwrap_or(false)
})
.copied()
.collect();
if field_additions.len() >= self.min_occurrences {
let intervals = self.calculate_intervals(&field_additions);
if !intervals.is_empty() {
let avg_interval = intervals.iter().sum::<f64>() / intervals.len() as f64;
let variance = intervals.iter().map(|x| (x - avg_interval).powi(2)).sum::<f64>()
/ intervals.len() as f64;
let stddev = variance.sqrt();
if let Some(last) = field_additions.last() {
let last_time = DateTime::<Utc>::from_timestamp(last.detected_at, 0)
.unwrap_or_else(Utc::now);
let confidence =
self.calculate_pattern_confidence(&intervals, avg_interval, stddev);
return vec![ForecastPattern {
pattern_type: PatternType::FieldAddition,
frequency_days: avg_interval,
last_occurrence: last_time,
confidence,
occurrence_count: field_additions.len(),
frequency_stddev: stddev,
}];
}
}
}
Vec::new()
}
fn calculate_pattern_confidence(
&self,
intervals: &[f64],
avg_interval: f64,
stddev: f64,
) -> f64 {
if intervals.is_empty() || avg_interval == 0.0 {
return 0.0;
}
let occurrence_factor = (intervals.len().min(10) as f64 / 10.0).min(1.0);
let consistency_factor = (1.0 - (stddev / avg_interval).min(1.0)).max(0.0);
(occurrence_factor * 0.4 + consistency_factor * 0.6).min(1.0)
}
fn calculate_volatility(
&self,
intervals: &[f64],
window_start: DateTime<Utc>,
window_end: DateTime<Utc>,
) -> f64 {
if intervals.is_empty() {
return 0.0;
}
let window_days = (window_end - window_start).num_seconds() as f64 / 86400.0;
if window_days == 0.0 {
return 0.0;
}
let change_count = intervals.len() + 1;
let frequency = change_count as f64 / window_days;
let avg_interval = intervals.iter().sum::<f64>() / intervals.len() as f64;
let variance = intervals.iter().map(|x| (x - avg_interval).powi(2)).sum::<f64>()
/ intervals.len() as f64;
let coefficient_of_variation = if avg_interval > 0.0 {
variance.sqrt() / avg_interval
} else {
0.0
};
let frequency_score = (frequency * 30.0).min(1.0);
let variance_score = coefficient_of_variation.min(1.0);
(frequency_score * 0.6 + variance_score * 0.4).min(1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::incidents::types::{IncidentSeverity, IncidentStatus};
use chrono::Duration;
fn create_test_incident(
id: &str,
detected_at: i64,
incident_type: IncidentType,
) -> DriftIncident {
DriftIncident {
id: id.to_string(),
budget_id: None,
workspace_id: None,
endpoint: "/api/test".to_string(),
method: "GET".to_string(),
incident_type,
severity: IncidentSeverity::Medium,
status: IncidentStatus::Open,
detected_at,
resolved_at: None,
details: serde_json::json!({}),
external_ticket_id: None,
external_ticket_url: None,
created_at: detected_at,
updated_at: detected_at,
sync_cycle_id: None,
contract_diff_id: None,
before_sample: None,
after_sample: None,
fitness_test_results: Vec::new(),
affected_consumers: None,
protocol: None,
}
}
#[test]
fn test_analyze_empty_incidents() {
let analyzer = PatternAnalyzer::new(3, 0.5);
let window_start = Utc::now() - Duration::days(90);
let window_end = Utc::now();
let analysis = analyzer.analyze_patterns(&[], window_start, window_end);
assert_eq!(analysis.total_changes, 0);
assert_eq!(analysis.volatility_score, 0.0);
}
#[test]
fn test_detect_weekly_pattern() {
let analyzer = PatternAnalyzer::new(3, 0.5);
let now = Utc::now();
let mut incidents = Vec::new();
for i in 0..5 {
let timestamp = (now - Duration::days(i * 7)).timestamp();
incidents.push(create_test_incident(
&format!("inc_{}", i),
timestamp,
IncidentType::ThresholdExceeded,
));
}
let window_start = now - Duration::days(35);
let window_end = now;
let analysis = analyzer.analyze_patterns(&incidents, window_start, window_end);
assert!(analysis.volatility_score > 0.0);
assert!(!analysis.patterns.is_empty());
}
}