Skip to main content

agentic_codebase/temporal/
coupling.rs

1//! Coupling detection between code units.
2//!
3//! Detects temporal coupling (files that change together frequently) by
4//! analysing co-change patterns in the commit history.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11use super::history::ChangeHistory;
12use crate::graph::CodeGraph;
13
14/// Options for coupling detection.
15#[derive(Debug, Clone)]
16pub struct CouplingOptions {
17    /// Minimum number of co-changes to consider a coupling (default 3).
18    pub min_cochanges: usize,
19    /// Minimum coupling strength to report (default 0.5).
20    pub min_strength: f32,
21    /// Maximum number of couplings to return (0 = unlimited).
22    pub limit: usize,
23}
24
25impl Default for CouplingOptions {
26    fn default() -> Self {
27        Self {
28            min_cochanges: 3,
29            min_strength: 0.5,
30            limit: 0,
31        }
32    }
33}
34
35/// The type of coupling detected.
36#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub enum CouplingType {
38    /// Files change together in the same commits.
39    CoChange,
40    /// Files share similar bug patterns.
41    SharedBugs,
42    /// An explicit graph edge already exists between units in these files.
43    ExplicitEdge,
44}
45
46impl std::fmt::Display for CouplingType {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::CoChange => write!(f, "co-change"),
50            Self::SharedBugs => write!(f, "shared-bugs"),
51            Self::ExplicitEdge => write!(f, "explicit-edge"),
52        }
53    }
54}
55
56/// A detected coupling between two file paths.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Coupling {
59    /// First file path.
60    pub path_a: PathBuf,
61    /// Second file path.
62    pub path_b: PathBuf,
63    /// Number of commits where both files were changed.
64    pub cochange_count: usize,
65    /// Coupling strength (0.0 to 1.0).
66    pub strength: f32,
67    /// The type of coupling detected.
68    pub coupling_type: CouplingType,
69}
70
71/// Detects temporal coupling patterns from change history.
72#[derive(Debug, Clone)]
73pub struct CouplingDetector {
74    /// Configuration options.
75    options: CouplingOptions,
76}
77
78impl CouplingDetector {
79    /// Create a new coupling detector with default options.
80    pub fn new() -> Self {
81        Self {
82            options: CouplingOptions::default(),
83        }
84    }
85
86    /// Create a new coupling detector with custom options.
87    pub fn with_options(options: CouplingOptions) -> Self {
88        Self { options }
89    }
90
91    /// Detect all couplings from the change history.
92    ///
93    /// Optionally cross-references with a [`CodeGraph`] to annotate couplings
94    /// that already have explicit edges.
95    pub fn detect_all(&self, history: &ChangeHistory, graph: Option<&CodeGraph>) -> Vec<Coupling> {
96        let matrix = self.build_cochange_matrix(history);
97        let mut couplings = Vec::new();
98
99        for ((path_a, path_b), count) in &matrix {
100            if *count < self.options.min_cochanges {
101                continue;
102            }
103
104            // Strength = co-change count / max(changes(a), changes(b)).
105            let changes_a = history.change_count(path_a).max(1);
106            let changes_b = history.change_count(path_b).max(1);
107            let max_changes = changes_a.max(changes_b) as f32;
108            let strength = (*count as f32 / max_changes).min(1.0);
109
110            if strength < self.options.min_strength {
111                continue;
112            }
113
114            let coupling_type = if let Some(g) = graph {
115                if self.has_explicit_edge(g, path_a, path_b) {
116                    CouplingType::ExplicitEdge
117                } else {
118                    CouplingType::CoChange
119                }
120            } else {
121                CouplingType::CoChange
122            };
123
124            couplings.push(Coupling {
125                path_a: path_a.clone(),
126                path_b: path_b.clone(),
127                cochange_count: *count,
128                strength,
129                coupling_type,
130            });
131        }
132
133        // Sort by strength descending.
134        couplings.sort_by(|a, b| {
135            b.strength
136                .partial_cmp(&a.strength)
137                .unwrap_or(std::cmp::Ordering::Equal)
138        });
139
140        if self.options.limit > 0 {
141            couplings.truncate(self.options.limit);
142        }
143
144        couplings
145    }
146
147    /// Build a co-change matrix from the history.
148    ///
149    /// For each commit that touches multiple files, counts the number of
150    /// commits where each pair of files was changed together.
151    fn build_cochange_matrix(&self, history: &ChangeHistory) -> HashMap<(PathBuf, PathBuf), usize> {
152        let mut matrix: HashMap<(PathBuf, PathBuf), usize> = HashMap::new();
153
154        for commit_id in history.all_commits() {
155            let changes = history.files_in_commit(commit_id);
156            let mut paths: Vec<&Path> = changes.iter().map(|c| c.path.as_path()).collect();
157            paths.sort();
158            paths.dedup();
159
160            // For each pair of files in this commit, increment the co-change count.
161            for i in 0..paths.len() {
162                for j in (i + 1)..paths.len() {
163                    let key = (paths[i].to_path_buf(), paths[j].to_path_buf());
164                    *matrix.entry(key).or_insert(0) += 1;
165                }
166            }
167        }
168
169        matrix
170    }
171
172    /// Check if there is an explicit graph edge between units in two files.
173    fn has_explicit_edge(&self, graph: &CodeGraph, path_a: &Path, path_b: &Path) -> bool {
174        let units_a = self.find_units_for_path(graph, path_a);
175        let units_b = self.find_units_for_path(graph, path_b);
176
177        for &a_id in &units_a {
178            for edge in graph.edges_from(a_id) {
179                if units_b.contains(&edge.target_id) {
180                    return true;
181                }
182            }
183        }
184        false
185    }
186
187    /// Find all unit IDs that belong to a given file path.
188    fn find_units_for_path(&self, graph: &CodeGraph, path: &Path) -> Vec<u64> {
189        graph
190            .find_units_by_path(path)
191            .iter()
192            .map(|u| u.id)
193            .collect()
194    }
195}
196
197impl Default for CouplingDetector {
198    fn default() -> Self {
199        Self::new()
200    }
201}