agentic_codebase/temporal/
prophecy.rs1use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use super::coupling::{CouplingDetector, CouplingOptions};
12use super::history::ChangeHistory;
13use super::stability::StabilityAnalyzer;
14use crate::graph::CodeGraph;
15
16#[derive(Debug, Clone)]
18pub struct ProphecyOptions {
19 pub top_k: usize,
21 pub min_risk: f32,
23 pub now_timestamp: u64,
25 pub recent_window_secs: u64,
27}
28
29impl Default for ProphecyOptions {
30 fn default() -> Self {
31 Self {
32 top_k: 20,
33 min_risk: 0.3,
34 now_timestamp: 0,
35 recent_window_secs: 30 * 24 * 3600,
36 }
37 }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub enum PredictionType {
43 BugRisk,
45 ChangeVelocity,
47 ComplexityGrowth,
49 CouplingRisk,
51}
52
53impl std::fmt::Display for PredictionType {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 Self::BugRisk => write!(f, "bug-risk"),
57 Self::ChangeVelocity => write!(f, "change-velocity"),
58 Self::ComplexityGrowth => write!(f, "complexity-growth"),
59 Self::CouplingRisk => write!(f, "coupling-risk"),
60 }
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct Prediction {
67 pub path: String,
69 pub risk_score: f32,
71 pub prediction_type: PredictionType,
73 pub reason: String,
75 pub factors: Vec<(String, f32)>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
81pub enum AlertType {
82 Hotspot,
84 CouplingHub,
86 SystemicInstability,
88}
89
90impl std::fmt::Display for AlertType {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 match self {
93 Self::Hotspot => write!(f, "hotspot"),
94 Self::CouplingHub => write!(f, "coupling-hub"),
95 Self::SystemicInstability => write!(f, "systemic-instability"),
96 }
97 }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct EcosystemAlert {
103 pub alert_type: AlertType,
105 pub severity: f32,
107 pub message: String,
109 pub affected_paths: Vec<String>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ProphecyResult {
116 pub predictions: Vec<Prediction>,
118 pub alerts: Vec<EcosystemAlert>,
120 pub average_risk: f32,
122 pub files_analysed: usize,
124}
125
126#[derive(Debug, Clone)]
128pub struct ProphecyEngine {
129 options: ProphecyOptions,
131}
132
133impl ProphecyEngine {
134 pub fn new() -> Self {
136 Self {
137 options: ProphecyOptions::default(),
138 }
139 }
140
141 pub fn with_options(options: ProphecyOptions) -> Self {
143 Self { options }
144 }
145
146 pub fn predict(&self, history: &ChangeHistory, graph: Option<&CodeGraph>) -> ProphecyResult {
151 let all_paths = history.all_paths();
152 let mut predictions = Vec::new();
153 let mut total_risk = 0.0_f32;
154
155 let stability_analyzer = StabilityAnalyzer::new();
156 let coupling_detector = CouplingDetector::with_options(CouplingOptions {
157 min_cochanges: 2,
158 min_strength: 0.3,
159 limit: 0,
160 });
161 let couplings = coupling_detector.detect_all(history, graph);
162
163 for path in &all_paths {
164 let stability = stability_analyzer.calculate_stability(path, history);
165
166 let velocity = self.calculate_velocity(path, history);
168
169 let bugfix_trend = self.calculate_bugfix_trend(path, history);
171
172 let complexity_growth = self.calculate_complexity_growth(path, history);
174
175 let coupling_risk = self.calculate_coupling_risk(path, &couplings);
177
178 let risk_score = (velocity * 0.30
180 + bugfix_trend * 0.30
181 + complexity_growth * 0.15
182 + coupling_risk * 0.25)
183 .clamp(0.0, 1.0);
184
185 total_risk += risk_score;
186
187 let factors = vec![
189 ("velocity".to_string(), velocity),
190 ("bugfix_trend".to_string(), bugfix_trend),
191 ("complexity_growth".to_string(), complexity_growth),
192 ("coupling_risk".to_string(), coupling_risk),
193 ];
194
195 let prediction_type = if bugfix_trend >= velocity
196 && bugfix_trend >= complexity_growth
197 && bugfix_trend >= coupling_risk
198 {
199 PredictionType::BugRisk
200 } else if coupling_risk >= velocity && coupling_risk >= complexity_growth {
201 PredictionType::CouplingRisk
202 } else if complexity_growth >= velocity {
203 PredictionType::ComplexityGrowth
204 } else {
205 PredictionType::ChangeVelocity
206 };
207
208 let reason = match &prediction_type {
209 PredictionType::BugRisk => format!(
210 "High bugfix trend ({:.2}) with stability score {:.2}.",
211 bugfix_trend, stability.overall_score
212 ),
213 PredictionType::ChangeVelocity => format!(
214 "High change velocity ({:.2}); file changes frequently.",
215 velocity
216 ),
217 PredictionType::ComplexityGrowth => format!(
218 "Complexity growth signal ({:.2}) from increasing churn.",
219 complexity_growth
220 ),
221 PredictionType::CouplingRisk => format!(
222 "Coupling risk ({:.2}); many co-changing dependencies.",
223 coupling_risk
224 ),
225 };
226
227 if risk_score >= self.options.min_risk {
228 predictions.push(Prediction {
229 path: path.display().to_string(),
230 risk_score,
231 prediction_type,
232 reason,
233 factors,
234 });
235 }
236 }
237
238 predictions.sort_by(|a, b| {
240 b.risk_score
241 .partial_cmp(&a.risk_score)
242 .unwrap_or(std::cmp::Ordering::Equal)
243 });
244
245 if self.options.top_k > 0 {
246 predictions.truncate(self.options.top_k);
247 }
248
249 let files_analysed = all_paths.len();
250 let average_risk = if files_analysed > 0 {
251 total_risk / files_analysed as f32
252 } else {
253 0.0
254 };
255
256 let alerts = self.generate_alerts(history, &predictions, average_risk);
258
259 ProphecyResult {
260 predictions,
261 alerts,
262 average_risk,
263 files_analysed,
264 }
265 }
266
267 fn calculate_velocity(&self, path: &Path, history: &ChangeHistory) -> f32 {
269 let changes = history.changes_for_path(path);
270 if changes.is_empty() {
271 return 0.0;
272 }
273
274 let now = self.effective_now();
275 let cutoff = now.saturating_sub(self.options.recent_window_secs);
276 let recent_count = changes.iter().filter(|c| c.timestamp >= cutoff).count();
277 let total_count = changes.len();
278
279 let recent_ratio = recent_count as f32 / total_count.max(1) as f32;
281 let freq_factor = (recent_count as f32 / 5.0).min(1.0);
282 (recent_ratio * 0.5 + freq_factor * 0.5).min(1.0)
283 }
284
285 fn calculate_bugfix_trend(&self, path: &Path, history: &ChangeHistory) -> f32 {
287 let changes = history.changes_for_path(path);
288 if changes.is_empty() {
289 return 0.0;
290 }
291
292 let bugfix_count = changes.iter().filter(|c| c.is_bugfix).count();
293 let total = changes.len();
294 let ratio = bugfix_count as f32 / total as f32;
295
296 let now = self.effective_now();
298 let cutoff = now.saturating_sub(self.options.recent_window_secs);
299 let recent_bugfixes = changes
300 .iter()
301 .filter(|c| c.is_bugfix && c.timestamp >= cutoff)
302 .count();
303 let recent_total = changes.iter().filter(|c| c.timestamp >= cutoff).count();
304 let recent_ratio = if recent_total > 0 {
305 recent_bugfixes as f32 / recent_total as f32
306 } else {
307 0.0
308 };
309
310 (ratio * 0.4 + recent_ratio * 0.6).min(1.0)
312 }
313
314 fn calculate_complexity_growth(&self, path: &Path, history: &ChangeHistory) -> f32 {
316 let changes = history.changes_for_path(path);
317 if changes.is_empty() {
318 return 0.0;
319 }
320
321 let total_added: u64 = changes.iter().map(|c| c.lines_added as u64).sum();
323 let total_deleted: u64 = changes.iter().map(|c| c.lines_deleted as u64).sum();
324
325 let net_growth = if total_added > total_deleted {
326 (total_added - total_deleted) as f32
327 } else {
328 0.0
329 };
330
331 let growth_signal = net_growth / (net_growth + 100.0);
333 growth_signal.min(1.0)
334 }
335
336 fn calculate_coupling_risk(&self, path: &Path, couplings: &[super::coupling::Coupling]) -> f32 {
338 let path_str = path.to_path_buf();
339 let relevant: Vec<f32> = couplings
340 .iter()
341 .filter(|c| c.path_a == path_str || c.path_b == path_str)
342 .map(|c| c.strength)
343 .collect();
344
345 if relevant.is_empty() {
346 return 0.0;
347 }
348
349 let avg_strength: f32 = relevant.iter().sum::<f32>() / relevant.len() as f32;
351 let count_factor = (relevant.len() as f32).sqrt() / 3.0;
352 (avg_strength * 0.6 + count_factor.min(1.0) * 0.4).min(1.0)
353 }
354
355 fn effective_now(&self) -> u64 {
357 if self.options.now_timestamp > 0 {
358 self.options.now_timestamp
359 } else {
360 crate::types::now_micros() / 1_000_000
361 }
362 }
363
364 fn generate_alerts(
366 &self,
367 history: &ChangeHistory,
368 predictions: &[Prediction],
369 average_risk: f32,
370 ) -> Vec<EcosystemAlert> {
371 let mut alerts = Vec::new();
372
373 if average_risk > 0.6 {
375 let affected: Vec<String> =
376 predictions.iter().take(5).map(|p| p.path.clone()).collect();
377 alerts.push(EcosystemAlert {
378 alert_type: AlertType::SystemicInstability,
379 severity: average_risk.min(1.0),
380 message: format!(
381 "Systemic instability detected: average risk {:.2} across {} files.",
382 average_risk,
383 history.all_paths().len()
384 ),
385 affected_paths: affected,
386 });
387 }
388
389 for pred in predictions.iter().filter(|p| p.risk_score > 0.7) {
391 alerts.push(EcosystemAlert {
392 alert_type: AlertType::Hotspot,
393 severity: pred.risk_score,
394 message: format!(
395 "Hotspot detected: {} (risk {:.2}).",
396 pred.path, pred.risk_score
397 ),
398 affected_paths: vec![pred.path.clone()],
399 });
400 }
401
402 alerts
403 }
404}
405
406impl Default for ProphecyEngine {
407 fn default() -> Self {
408 Self::new()
409 }
410}