Skip to main content

mockforge_contracts/contract_drift/forecasting/
statistical_model.rs

1//! Statistical model for forecasting
2//!
3//! This module provides time series analysis and statistical methods
4//! for predicting future contract changes.
5
6use super::types::{ForecastStatistics, PatternAnalysis};
7use chrono::{DateTime, Duration, Utc};
8
9/// Statistical model for forecasting
10pub struct StatisticalModel;
11
12impl StatisticalModel {
13    /// Create a new statistical model
14    pub fn new() -> Self {
15        Self
16    }
17
18    /// Calculate forecast statistics from pattern analysis
19    pub fn calculate_statistics(
20        &self,
21        analysis: &PatternAnalysis,
22        workspace_id: Option<String>,
23        service_id: Option<String>,
24        service_name: Option<String>,
25        endpoint: Option<String>,
26        method: Option<String>,
27    ) -> ForecastStatistics {
28        let aggregation_level = if endpoint.is_some() && method.is_some() {
29            super::types::ForecastAggregationLevel::Endpoint
30        } else if service_id.is_some() || service_name.is_some() {
31            super::types::ForecastAggregationLevel::Service
32        } else {
33            super::types::ForecastAggregationLevel::Workspace
34        };
35
36        let window_days =
37            (analysis.window_end - analysis.window_start).num_seconds() as f64 / 86400.0;
38
39        let change_frequency = if window_days > 0.0 {
40            analysis.total_changes as f64 / window_days
41        } else {
42            0.0
43        };
44
45        let breaking_change_frequency = if window_days > 0.0 {
46            analysis.total_breaking_changes as f64 / window_days
47        } else {
48            0.0
49        };
50
51        let pattern_signatures: Vec<_> = analysis
52            .patterns
53            .iter()
54            .map(|p| super::types::PatternSignature {
55                pattern_type: p.pattern_type.clone(),
56                frequency_days: p.frequency_days,
57                first_occurrence: analysis.window_start,
58                last_occurrence: p.last_occurrence,
59                occurrence_count: p.occurrence_count,
60                confidence: p.confidence,
61                metadata: std::collections::HashMap::new(),
62            })
63            .collect();
64
65        let detected_pattern_types: Vec<_> =
66            analysis.patterns.iter().map(|p| p.pattern_type.clone()).collect();
67
68        ForecastStatistics {
69            workspace_id,
70            service_id,
71            service_name,
72            endpoint,
73            method,
74            aggregation_level,
75            time_window_days: window_days as u32,
76            change_frequency,
77            breaking_change_frequency,
78            volatility_score: analysis.volatility_score,
79            pattern_signatures,
80            detected_pattern_types,
81            last_change_date: if analysis.total_changes > 0 {
82                Some(analysis.window_end)
83            } else {
84                None
85            },
86            last_breaking_change_date: if analysis.total_breaking_changes > 0 {
87                Some(analysis.window_end)
88            } else {
89                None
90            },
91            total_changes: analysis.total_changes,
92            total_breaking_changes: analysis.total_breaking_changes,
93            window_start: analysis.window_start,
94            window_end: analysis.window_end,
95            calculated_at: Utc::now(),
96        }
97    }
98
99    /// Predict probability of change in next N days
100    pub fn predict_change_probability(
101        &self,
102        analysis: &PatternAnalysis,
103        forecast_window_days: u32,
104    ) -> f64 {
105        if analysis.patterns.is_empty() {
106            return self.predict_from_frequency(analysis, forecast_window_days);
107        }
108
109        let mut probabilities = Vec::new();
110
111        for pattern in &analysis.patterns {
112            let prob = self.predict_from_pattern(pattern, forecast_window_days);
113            probabilities.push(prob * pattern.confidence);
114        }
115
116        if probabilities.is_empty() {
117            self.predict_from_frequency(analysis, forecast_window_days)
118        } else {
119            probabilities.iter().sum::<f64>() / probabilities.len() as f64
120        }
121    }
122
123    /// Predict probability from historical frequency
124    fn predict_from_frequency(&self, analysis: &PatternAnalysis, forecast_window_days: u32) -> f64 {
125        if analysis.avg_change_interval_days == 0.0 {
126            return 0.0;
127        }
128
129        let lambda = 1.0 / analysis.avg_change_interval_days;
130        let t = forecast_window_days as f64;
131        let prob = 1.0 - (-lambda * t).exp();
132
133        prob.clamp(0.0, 1.0)
134    }
135
136    /// Predict probability from pattern
137    fn predict_from_pattern(
138        &self,
139        pattern: &super::types::ForecastPattern,
140        forecast_window_days: u32,
141    ) -> f64 {
142        let days_since_last = (Utc::now() - pattern.last_occurrence).num_seconds() as f64 / 86400.0;
143        let forecast_days = forecast_window_days as f64;
144
145        if days_since_last >= pattern.frequency_days {
146            return 0.8;
147        }
148
149        let days_until_expected = pattern.frequency_days - days_since_last;
150
151        if forecast_days >= days_until_expected {
152            let overlap = forecast_days - days_until_expected.max(0.0);
153            let overlap_ratio = overlap / forecast_days;
154            0.5 + (overlap_ratio * 0.4)
155        } else {
156            let ratio = forecast_days / days_until_expected;
157            ratio * 0.3
158        }
159    }
160
161    /// Predict probability of breaking change
162    pub fn predict_break_probability(
163        &self,
164        analysis: &PatternAnalysis,
165        forecast_window_days: u32,
166    ) -> f64 {
167        if let Some(avg_breaking_interval) = analysis.avg_breaking_change_interval_days {
168            if avg_breaking_interval > 0.0 {
169                let lambda = 1.0 / avg_breaking_interval;
170                let t = forecast_window_days as f64;
171                let prob = 1.0 - (-lambda * t).exp();
172                return prob.clamp(0.0, 1.0);
173            }
174        }
175
176        if analysis.total_changes > 0 {
177            let breaking_ratio =
178                analysis.total_breaking_changes as f64 / analysis.total_changes as f64;
179            let change_prob = self.predict_change_probability(analysis, forecast_window_days);
180            change_prob * breaking_ratio
181        } else {
182            0.0
183        }
184    }
185
186    /// Predict next expected change date
187    pub fn predict_next_change_date(&self, analysis: &PatternAnalysis) -> Option<DateTime<Utc>> {
188        if analysis.patterns.is_empty() {
189            if analysis.avg_change_interval_days > 0.0 {
190                let last_change = analysis.window_end;
191                return Some(
192                    last_change + Duration::days(analysis.avg_change_interval_days as i64),
193                );
194            }
195            return None;
196        }
197
198        let best_pattern = analysis.patterns.iter().max_by(|a, b| {
199            a.confidence.partial_cmp(&b.confidence).unwrap_or(std::cmp::Ordering::Equal)
200        })?;
201
202        let days_since_last =
203            (Utc::now() - best_pattern.last_occurrence).num_seconds() as f64 / 86400.0;
204
205        if days_since_last >= best_pattern.frequency_days {
206            Some(Utc::now() + Duration::days(7))
207        } else {
208            let days_until_next = best_pattern.frequency_days - days_since_last;
209            Some(best_pattern.last_occurrence + Duration::days(days_until_next as i64))
210        }
211    }
212
213    /// Predict next expected breaking change date
214    pub fn predict_next_break_date(&self, analysis: &PatternAnalysis) -> Option<DateTime<Utc>> {
215        if let Some(avg_breaking_interval) = analysis.avg_breaking_change_interval_days {
216            if avg_breaking_interval > 0.0 {
217                let last_breaking = analysis.window_end;
218                return Some(last_breaking + Duration::days(avg_breaking_interval as i64));
219            }
220        }
221
222        let breaking_pattern = analysis
223            .patterns
224            .iter()
225            .find(|p| matches!(p.pattern_type, super::types::PatternType::BreakingChange))?;
226
227        let days_since_last =
228            (Utc::now() - breaking_pattern.last_occurrence).num_seconds() as f64 / 86400.0;
229
230        if days_since_last >= breaking_pattern.frequency_days {
231            Some(Utc::now() + Duration::days(7))
232        } else {
233            let days_until_next = breaking_pattern.frequency_days - days_since_last;
234            Some(breaking_pattern.last_occurrence + Duration::days(days_until_next as i64))
235        }
236    }
237
238    /// Calculate forecast confidence
239    pub fn calculate_confidence(&self, analysis: &PatternAnalysis, min_incidents: usize) -> f64 {
240        if analysis.total_changes < min_incidents {
241            return 0.3;
242        }
243
244        let data_factor = (analysis.total_changes.min(20) as f64 / 20.0).min(1.0);
245
246        let pattern_factor = if !analysis.patterns.is_empty() {
247            analysis.patterns.iter().map(|p| p.confidence).sum::<f64>()
248                / analysis.patterns.len() as f64
249        } else {
250            0.5
251        };
252
253        let consistency_factor = 1.0 - (analysis.volatility_score * 0.3).min(0.5);
254
255        (data_factor * 0.4 + pattern_factor * 0.4 + consistency_factor * 0.2).min(1.0)
256    }
257}
258
259impl Default for StatisticalModel {
260    fn default() -> Self {
261        Self::new()
262    }
263}