Skip to main content

agentic_codebase/temporal/
stability.rs

1//! Stability score calculation.
2//!
3//! Analyses change history to compute a stability score for code units.
4//! Higher scores indicate more stable (less frequently changing) code.
5
6use std::path::Path;
7
8use serde::{Deserialize, Serialize};
9
10use super::history::ChangeHistory;
11
12/// Options for stability analysis.
13#[derive(Debug, Clone)]
14pub struct StabilityOptions {
15    /// Weight for change frequency factor (default 0.25).
16    pub change_frequency_weight: f32,
17    /// Weight for bugfix ratio factor (default 0.25).
18    pub bugfix_ratio_weight: f32,
19    /// Weight for recent activity factor (default 0.20).
20    pub recent_activity_weight: f32,
21    /// Weight for author concentration factor (default 0.15).
22    pub author_concentration_weight: f32,
23    /// Weight for churn factor (default 0.15).
24    pub churn_weight: f32,
25    /// Timestamp considered "now" for recency calculations (0 = use current time).
26    pub now_timestamp: u64,
27    /// Window (in seconds) for "recent" activity (default: 30 days).
28    pub recent_window_secs: u64,
29}
30
31impl Default for StabilityOptions {
32    fn default() -> Self {
33        Self {
34            change_frequency_weight: 0.25,
35            bugfix_ratio_weight: 0.25,
36            recent_activity_weight: 0.20,
37            author_concentration_weight: 0.15,
38            churn_weight: 0.15,
39            now_timestamp: 0,
40            recent_window_secs: 30 * 24 * 3600,
41        }
42    }
43}
44
45/// A single factor contributing to the stability score.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct StabilityFactor {
48    /// Factor name.
49    pub name: String,
50    /// Factor value (0.0 = unstable, 1.0 = stable).
51    pub value: f32,
52    /// Weight applied to this factor in the overall score.
53    pub weight: f32,
54    /// Human-readable description.
55    pub description: String,
56}
57
58/// A recommendation based on stability analysis.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct StabilityRecommendation {
61    /// Recommendation priority (lower = more urgent).
62    pub priority: u32,
63    /// Short summary.
64    pub summary: String,
65    /// Detailed explanation.
66    pub detail: String,
67}
68
69/// Result of a stability analysis for a single file or code unit.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct StabilityResult {
72    /// The file path analysed.
73    pub path: String,
74    /// Overall stability score (0.0 = very unstable, 1.0 = very stable).
75    pub overall_score: f32,
76    /// Individual contributing factors.
77    pub factors: Vec<StabilityFactor>,
78    /// Recommendations for improving stability.
79    pub recommendations: Vec<StabilityRecommendation>,
80}
81
82/// Analyses code unit stability based on change history patterns.
83#[derive(Debug, Clone)]
84pub struct StabilityAnalyzer {
85    /// Configuration options.
86    options: StabilityOptions,
87}
88
89impl StabilityAnalyzer {
90    /// Create a new stability analyser with default options.
91    pub fn new() -> Self {
92        Self {
93            options: StabilityOptions::default(),
94        }
95    }
96
97    /// Create a new stability analyser with custom options.
98    pub fn with_options(options: StabilityOptions) -> Self {
99        Self { options }
100    }
101
102    /// Calculate stability for a file path given its change history.
103    ///
104    /// Returns a [`StabilityResult`] with the overall score and contributing factors.
105    /// If the path has no history, returns a perfect stability score of 1.0.
106    pub fn calculate_stability(&self, path: &Path, history: &ChangeHistory) -> StabilityResult {
107        let change_count = history.change_count(path);
108
109        // No history means perfectly stable.
110        if change_count == 0 {
111            return StabilityResult {
112                path: path.display().to_string(),
113                overall_score: 1.0,
114                factors: vec![StabilityFactor {
115                    name: "no_history".to_string(),
116                    value: 1.0,
117                    weight: 1.0,
118                    description: "No change history recorded; assumed stable.".to_string(),
119                }],
120                recommendations: Vec::new(),
121            };
122        }
123
124        let mut factors = Vec::new();
125
126        // Factor 1: Change frequency — fewer changes = more stable.
127        // Normalise: score = 1 / (1 + log2(change_count)).
128        let freq_score = 1.0 / (1.0 + (change_count as f32).log2());
129        factors.push(StabilityFactor {
130            name: "change_frequency".to_string(),
131            value: freq_score,
132            weight: self.options.change_frequency_weight,
133            description: format!(
134                "{} total changes recorded; frequency score {:.2}.",
135                change_count, freq_score
136            ),
137        });
138
139        // Factor 2: Bugfix ratio — fewer bugfixes = more stable.
140        let bugfix_count = history.bugfix_count(path);
141        let bugfix_ratio = if change_count > 0 {
142            bugfix_count as f32 / change_count as f32
143        } else {
144            0.0
145        };
146        let bugfix_score = 1.0 - bugfix_ratio;
147        factors.push(StabilityFactor {
148            name: "bugfix_ratio".to_string(),
149            value: bugfix_score,
150            weight: self.options.bugfix_ratio_weight,
151            description: format!(
152                "{} of {} changes were bugfixes ({:.0}%); bugfix score {:.2}.",
153                bugfix_count,
154                change_count,
155                bugfix_ratio * 100.0,
156                bugfix_score
157            ),
158        });
159
160        // Factor 3: Recent activity — less recent activity = more stable.
161        let now = if self.options.now_timestamp > 0 {
162            self.options.now_timestamp
163        } else {
164            crate::types::now_micros() / 1_000_000
165        };
166        let cutoff = now.saturating_sub(self.options.recent_window_secs);
167        let changes = history.changes_for_path(path);
168        let recent_count = changes.iter().filter(|c| c.timestamp >= cutoff).count();
169        let recent_ratio = if change_count > 0 {
170            recent_count as f32 / change_count as f32
171        } else {
172            0.0
173        };
174        let recent_score = 1.0 - recent_ratio.min(1.0);
175        factors.push(StabilityFactor {
176            name: "recent_activity".to_string(),
177            value: recent_score,
178            weight: self.options.recent_activity_weight,
179            description: format!(
180                "{} of {} changes were recent (within window); recency score {:.2}.",
181                recent_count, change_count, recent_score
182            ),
183        });
184
185        // Factor 4: Author concentration — more authors = less stable.
186        let authors = history.authors_for_path(path);
187        let author_count = authors.len().max(1);
188        let author_score = 1.0 / (author_count as f32);
189        factors.push(StabilityFactor {
190            name: "author_concentration".to_string(),
191            value: author_score,
192            weight: self.options.author_concentration_weight,
193            description: format!(
194                "{} unique authors; concentration score {:.2}.",
195                author_count, author_score
196            ),
197        });
198
199        // Factor 5: Churn — less churn = more stable.
200        let churn = history.total_churn(path);
201        let churn_score = 1.0 / (1.0 + (churn as f32).log2().max(0.0));
202        factors.push(StabilityFactor {
203            name: "churn".to_string(),
204            value: churn_score,
205            weight: self.options.churn_weight,
206            description: format!(
207                "{} total lines churned; churn score {:.2}.",
208                churn, churn_score
209            ),
210        });
211
212        // Compute weighted average.
213        let overall_score: f32 = factors.iter().map(|f| f.value * f.weight).sum::<f32>()
214            / factors.iter().map(|f| f.weight).sum::<f32>().max(0.001);
215        let overall_score = overall_score.clamp(0.0, 1.0);
216
217        // Generate recommendations.
218        let mut recommendations = Vec::new();
219        if bugfix_ratio > 0.5 {
220            recommendations.push(StabilityRecommendation {
221                priority: 1,
222                summary: "High bugfix ratio".to_string(),
223                detail: format!(
224                    "Over {:.0}% of changes are bugfixes. Consider refactoring for reliability.",
225                    bugfix_ratio * 100.0
226                ),
227            });
228        }
229        if recent_count > 5 {
230            recommendations.push(StabilityRecommendation {
231                priority: 2,
232                summary: "High recent activity".to_string(),
233                detail: format!(
234                    "{} changes in the recent window. This file may be in active flux.",
235                    recent_count
236                ),
237            });
238        }
239        if author_count > 5 {
240            recommendations.push(StabilityRecommendation {
241                priority: 3,
242                summary: "Many authors".to_string(),
243                detail: format!(
244                    "{} authors have modified this file. Consider assigning ownership.",
245                    author_count
246                ),
247            });
248        }
249
250        StabilityResult {
251            path: path.display().to_string(),
252            overall_score,
253            factors,
254            recommendations,
255        }
256    }
257}
258
259impl Default for StabilityAnalyzer {
260    fn default() -> Self {
261        Self::new()
262    }
263}