Skip to main content

agentic_codebase/temporal/
prophecy.rs

1//! Predictive analysis — the prophecy engine.
2//!
3//! Uses change history, stability scores, and coupling data to predict
4//! which files are likely to cause problems (bugs, test failures, etc.)
5//! in the near future.
6
7use 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/// Options for prophecy prediction.
17#[derive(Debug, Clone)]
18pub struct ProphecyOptions {
19    /// Maximum number of predictions to return.
20    pub top_k: usize,
21    /// Minimum risk score threshold (0.0 to 1.0).
22    pub min_risk: f32,
23    /// Timestamp considered "now" (0 = use current time).
24    pub now_timestamp: u64,
25    /// Window (in seconds) for "recent" calculations (default 30 days).
26    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/// The type of prediction made.
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub enum PredictionType {
43    /// File is likely to have a bug introduced.
44    BugRisk,
45    /// File is likely to need changes soon.
46    ChangeVelocity,
47    /// File complexity is growing unsustainably.
48    ComplexityGrowth,
49    /// File has dangerous coupling with other files.
50    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/// A single prediction about a file.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct Prediction {
67    /// The file path this prediction is about.
68    pub path: String,
69    /// Risk score (0.0 = low risk, 1.0 = high risk).
70    pub risk_score: f32,
71    /// The type of prediction.
72    pub prediction_type: PredictionType,
73    /// Human-readable reason for the prediction.
74    pub reason: String,
75    /// Contributing factors and their values.
76    pub factors: Vec<(String, f32)>,
77}
78
79/// The type of ecosystem alert.
80#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
81pub enum AlertType {
82    /// A hotspot file that changes too often.
83    Hotspot,
84    /// A file that many other files are coupled with.
85    CouplingHub,
86    /// Systemic instability across the codebase.
87    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/// An ecosystem-level alert about codebase health.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct EcosystemAlert {
103    /// Alert type.
104    pub alert_type: AlertType,
105    /// Severity (0.0 = informational, 1.0 = critical).
106    pub severity: f32,
107    /// Human-readable message.
108    pub message: String,
109    /// Affected file paths.
110    pub affected_paths: Vec<String>,
111}
112
113/// Result of a prophecy prediction run.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ProphecyResult {
116    /// Predictions about individual files, sorted by risk descending.
117    pub predictions: Vec<Prediction>,
118    /// Ecosystem-level alerts.
119    pub alerts: Vec<EcosystemAlert>,
120    /// Average risk across all analysed files.
121    pub average_risk: f32,
122    /// Number of files analysed.
123    pub files_analysed: usize,
124}
125
126/// The prophecy engine: predicts future problems based on historical patterns.
127#[derive(Debug, Clone)]
128pub struct ProphecyEngine {
129    /// Configuration options.
130    options: ProphecyOptions,
131}
132
133impl ProphecyEngine {
134    /// Create a new prophecy engine with default options.
135    pub fn new() -> Self {
136        Self {
137            options: ProphecyOptions::default(),
138        }
139    }
140
141    /// Create a new prophecy engine with custom options.
142    pub fn with_options(options: ProphecyOptions) -> Self {
143        Self { options }
144    }
145
146    /// Run predictions on the codebase.
147    ///
148    /// Analyses change history and the code graph to produce predictions
149    /// and ecosystem alerts.
150    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            // Factor 1: Change velocity.
167            let velocity = self.calculate_velocity(path, history);
168
169            // Factor 2: Bugfix trend.
170            let bugfix_trend = self.calculate_bugfix_trend(path, history);
171
172            // Factor 3: Complexity growth proxy (churn as surrogate).
173            let complexity_growth = self.calculate_complexity_growth(path, history);
174
175            // Factor 4: Coupling risk.
176            let coupling_risk = self.calculate_coupling_risk(path, &couplings);
177
178            // Combine into a final risk score.
179            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            // Select the dominant prediction type.
188            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        // Sort by risk descending.
239        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        // Generate ecosystem alerts.
257        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    /// Calculate change velocity for a path (how fast it is changing recently).
268    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        // Velocity = recent_proportion * frequency factor.
280        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    /// Calculate bugfix trend for a path.
286    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        // Check if bugfixes are increasing in the recent window.
297        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        // Combine historical and recent trends.
311        (ratio * 0.4 + recent_ratio * 0.6).min(1.0)
312    }
313
314    /// Calculate complexity growth signal from churn patterns.
315    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        // Use net line additions as a proxy for complexity growth.
322        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        // Normalise: score rises as net growth increases.
332        let growth_signal = net_growth / (net_growth + 100.0);
333        growth_signal.min(1.0)
334    }
335
336    /// Calculate coupling risk from detected couplings.
337    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        // Coupling risk = average strength * sqrt(count) normalised.
350        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    /// Get the effective "now" timestamp.
356    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    /// Generate ecosystem-level alerts.
365    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        // Alert: systemic instability if average risk is high.
374        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        // Alert: hotspots (files with very high risk).
390        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}