mockforge_contracts/contract_drift/forecasting/
statistical_model.rs1use super::types::{ForecastStatistics, PatternAnalysis};
7use chrono::{DateTime, Duration, Utc};
8
9pub struct StatisticalModel;
11
12impl StatisticalModel {
13 pub fn new() -> Self {
15 Self
16 }
17
18 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 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 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 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 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 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 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 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}