Skip to main content

kardo_core/analysis/
staleness.rs

1//! Freshness scoring based on time decay and code-doc coupling.
2
3use serde::Serialize;
4
5use crate::git::GitFileInfo;
6use crate::git::CouplingIssue;
7
8/// Result of freshness analysis for a single file.
9#[derive(Debug, Clone, Serialize)]
10pub struct FileFreshness {
11    /// File path relative to the project root.
12    pub relative_path: String,
13    /// Freshness score for this file (0.0-1.0), after time decay and coupling penalty.
14    pub score: f64,
15    /// Days since the file was last modified in git, if known.
16    pub days_since_modified: Option<i64>,
17    /// Score reduction applied due to code-doc coupling drift.
18    pub coupling_penalty: f64,
19    /// Weight multiplier based on file importance (e.g. CLAUDE.md = 3.0).
20    pub importance_weight: f64,
21}
22
23/// Aggregate freshness result for the project.
24#[derive(Debug, Clone, Serialize)]
25pub struct FreshnessResult {
26    /// Weighted average freshness score across all files (0.0-1.0).
27    pub score: f64,
28    /// Per-file freshness breakdowns.
29    pub file_scores: Vec<FileFreshness>,
30}
31
32/// Computes freshness scores from git history and coupling data.
33pub struct StalenessAnalyzer;
34
35/// Importance weight for a file path.
36///
37/// Critical AI-config files (CLAUDE.md, AGENTS.md) get higher weight
38/// because their staleness impacts AI quality more than peripheral docs.
39fn importance_weight(path: &str) -> f64 {
40    let lower = path.to_lowercase();
41    let filename = lower.rsplit('/').next().unwrap_or(&lower);
42
43    match filename {
44        "claude.md" | "agents.md" => 3.0,
45        _ if lower == ".claude/instructions" => 2.0,
46        "readme.md" => 1.5,
47        ".cursorrules" | ".clinerules" | ".windsurfrules" => 1.5,
48        ".mcp.json" => 1.5,
49        _ => 1.0,
50    }
51}
52
53impl StalenessAnalyzer {
54    /// Calculate freshness score for a single file based on days since last modification.
55    ///
56    /// Decay curve:
57    /// - 0-7 days: 100% (fully fresh)
58    /// - 7-30 days: linear decay from 100% to 60%
59    /// - 30-90 days: steeper decay from 60% to 20%
60    /// - >90 days: floor at 10%
61    fn time_decay(days: i64) -> f64 {
62        if days <= 7 {
63            1.0
64        } else if days <= 30 {
65            // Linear from 1.0 to 0.6 over 23 days
66            1.0 - 0.4 * ((days - 7) as f64 / 23.0)
67        } else if days <= 90 {
68            // Steeper from 0.6 to 0.2 over 60 days
69            0.6 - 0.4 * ((days - 30) as f64 / 60.0)
70        } else {
71            0.1
72        }
73    }
74
75    /// Analyze freshness across all files.
76    ///
77    /// `git_infos`: per-file git metadata
78    /// `coupling_issues`: detected code-doc coupling issues (from CouplingDetector)
79    pub fn analyze(
80        git_infos: &[GitFileInfo],
81        coupling_issues: &[CouplingIssue],
82    ) -> FreshnessResult {
83        if git_infos.is_empty() {
84            return FreshnessResult {
85                score: 0.5,
86                file_scores: vec![],
87            };
88        }
89
90        let mut file_scores = Vec::with_capacity(git_infos.len());
91
92        for info in git_infos {
93            let base_score = match info.days_since_modified {
94                Some(days) => Self::time_decay(days),
95                None => 0.5, // Unknown — neutral score
96            };
97
98            // Apply coupling penalty: if this doc has a coupling issue, reduce score
99            let coupling_penalty = coupling_issues
100                .iter()
101                .filter(|issue| issue.doc_path == info.relative_path)
102                .map(|issue| {
103                    // Penalty proportional to gap_days, capped at 0.3
104                    (issue.gap_days as f64 / 90.0).min(0.3)
105                })
106                .fold(0.0_f64, |acc, p| (acc + p).min(0.3));
107
108            let final_score = (base_score - coupling_penalty).max(0.0);
109            let weight = importance_weight(&info.relative_path);
110
111            file_scores.push(FileFreshness {
112                relative_path: info.relative_path.clone(),
113                score: final_score,
114                days_since_modified: info.days_since_modified,
115                coupling_penalty,
116                importance_weight: weight,
117            });
118        }
119
120        // Weighted average: important files (CLAUDE.md, AGENTS.md) count more
121        let avg_score = if file_scores.is_empty() {
122            0.5
123        } else {
124            let weighted_sum: f64 = file_scores.iter().map(|f| f.score * f.importance_weight).sum();
125            let weight_sum: f64 = file_scores.iter().map(|f| f.importance_weight).sum();
126            weighted_sum / weight_sum
127        };
128
129        FreshnessResult {
130            score: avg_score,
131            file_scores,
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use std::path::PathBuf;
140
141    fn make_info(path: &str, days: Option<i64>) -> GitFileInfo {
142        GitFileInfo {
143            path: PathBuf::from(path),
144            relative_path: path.to_string(),
145            last_modified: Some("2026-01-01T00:00:00+00:00".to_string()),
146            commit_count: 1,
147            last_author: Some("Test".to_string()),
148            days_since_modified: days,
149        }
150    }
151
152    #[test]
153    fn test_fresh_files() {
154        let infos = vec![make_info("README.md", Some(0))];
155        let result = StalenessAnalyzer::analyze(&infos, &[]);
156        assert!((result.score - 1.0).abs() < 0.01);
157    }
158
159    #[test]
160    fn test_week_old_files() {
161        let infos = vec![make_info("README.md", Some(7))];
162        let result = StalenessAnalyzer::analyze(&infos, &[]);
163        assert!((result.score - 1.0).abs() < 0.01);
164    }
165
166    #[test]
167    fn test_month_old_files() {
168        let infos = vec![make_info("README.md", Some(30))];
169        let result = StalenessAnalyzer::analyze(&infos, &[]);
170        assert!((result.score - 0.6).abs() < 0.05);
171    }
172
173    #[test]
174    fn test_very_old_files() {
175        let infos = vec![make_info("README.md", Some(100))];
176        let result = StalenessAnalyzer::analyze(&infos, &[]);
177        assert!((result.score - 0.1).abs() < 0.05);
178    }
179
180    #[test]
181    fn test_coupling_penalty() {
182        let infos = vec![make_info("docs/api.md", Some(0))];
183        let coupling = vec![CouplingIssue {
184            source_path: "src/api.rs".to_string(),
185            doc_path: "docs/api.md".to_string(),
186            source_last_modified: "2026-01-02T00:00:00+00:00".to_string(),
187            doc_last_modified: "2026-01-01T00:00:00+00:00".to_string(),
188            gap_days: 30,
189        }];
190        let result = StalenessAnalyzer::analyze(&infos, &coupling);
191        // Base 1.0 - penalty ~0.33 (capped at 0.3)
192        assert!(result.score < 0.75);
193        assert!(result.score > 0.5);
194    }
195
196    #[test]
197    fn test_empty_files() {
198        let result = StalenessAnalyzer::analyze(&[], &[]);
199        assert!((result.score - 0.5).abs() < 0.01);
200    }
201
202    #[test]
203    fn test_mixed_freshness() {
204        let infos = vec![
205            make_info("README.md", Some(0)),   // 1.0, weight 1.5
206            make_info("old.md", Some(100)),     // 0.1, weight 1.0
207        ];
208        let result = StalenessAnalyzer::analyze(&infos, &[]);
209        // Weighted: (1.0*1.5 + 0.1*1.0) / (1.5 + 1.0) = 1.6/2.5 = 0.64
210        assert!((result.score - 0.64).abs() < 0.05);
211    }
212
213    #[test]
214    fn test_claude_md_staleness_weighted_higher() {
215        // CLAUDE.md is stale (100 days → score 0.1, weight 3.0)
216        // other.md is fresh (0 days → score 1.0, weight 1.0)
217        // Weighted: (0.1*3.0 + 1.0*1.0) / (3.0 + 1.0) = 1.3/4.0 = 0.325
218        // Without weighting: (0.1 + 1.0) / 2 = 0.55
219        // Weighted result is LOWER because stale CLAUDE.md matters more.
220        let infos = vec![
221            make_info("CLAUDE.md", Some(100)),
222            make_info("other.md", Some(0)),
223        ];
224        let result = StalenessAnalyzer::analyze(&infos, &[]);
225        assert!(result.score < 0.45, "Stale CLAUDE.md should pull score down, got {}", result.score);
226    }
227
228    #[test]
229    fn test_importance_weight_values() {
230        assert!((importance_weight("CLAUDE.md") - 3.0).abs() < 0.01);
231        assert!((importance_weight("AGENTS.md") - 3.0).abs() < 0.01);
232        assert!((importance_weight(".claude/instructions") - 2.0).abs() < 0.01);
233        assert!((importance_weight("README.md") - 1.5).abs() < 0.01);
234        assert!((importance_weight(".cursorrules") - 1.5).abs() < 0.01);
235        assert!((importance_weight("docs/guide.md") - 1.0).abs() < 0.01);
236    }
237}