Skip to main content

lean_ctx/core/code_health/
coupling.rs

1//! Module coupling metrics — Robert C. Martin's afferent/efferent coupling and
2//! instability.
3//!
4//! High coupling makes a change ripple across files, which inflates the agent's
5//! blast radius (and token cost) per edit. Computed from a directed dependency
6//! edge list so it is pure and deterministic; the graph wiring (interconnection
7//! phase) feeds real import edges from the property graph, while tests drive it
8//! directly without an index.
9
10use serde::Serialize;
11use std::collections::{BTreeMap, BTreeSet};
12
13/// Coupling figures for one module (file).
14#[derive(Debug, Clone, PartialEq, Serialize)]
15pub struct ModuleCoupling {
16    pub module: String,
17    /// Afferent coupling `Ca`: number of modules that depend on this one.
18    pub afferent: usize,
19    /// Efferent coupling `Ce`: number of modules this one depends on.
20    pub efferent: usize,
21    /// Instability `I = Ce / (Ca + Ce)` in `0.0..=1.0` (0 = stable, 1 = unstable).
22    pub instability: f64,
23}
24
25/// Compute per-module coupling from directed dependency edges
26/// `(dependent_module, dependency_module)`. Self-edges and duplicates are
27/// ignored. Output is sorted by module name for determinism.
28pub fn module_coupling(edges: &[(String, String)]) -> Vec<ModuleCoupling> {
29    let mut unique: BTreeSet<(&str, &str)> = BTreeSet::new();
30    for (from, to) in edges {
31        if from != to {
32            unique.insert((from.as_str(), to.as_str()));
33        }
34    }
35
36    let mut efferent: BTreeMap<&str, usize> = BTreeMap::new();
37    let mut afferent: BTreeMap<&str, usize> = BTreeMap::new();
38    let mut modules: BTreeSet<&str> = BTreeSet::new();
39    for (from, to) in &unique {
40        *efferent.entry(from).or_default() += 1;
41        *afferent.entry(to).or_default() += 1;
42        modules.insert(from);
43        modules.insert(to);
44    }
45
46    modules
47        .into_iter()
48        .map(|m| {
49            let ce = efferent.get(m).copied().unwrap_or(0);
50            let ca = afferent.get(m).copied().unwrap_or(0);
51            let instability = if ca + ce == 0 {
52                0.0
53            } else {
54                ce as f64 / (ca + ce) as f64
55            };
56            ModuleCoupling {
57                module: m.to_string(),
58                afferent: ca,
59                efferent: ce,
60                instability,
61            }
62        })
63        .collect()
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    fn edge(a: &str, b: &str) -> (String, String) {
71        (a.to_string(), b.to_string())
72    }
73
74    #[test]
75    fn computes_ca_ce_and_instability() {
76        // a -> b, a -> c, d -> a
77        let edges = [edge("a", "b"), edge("a", "c"), edge("d", "a")];
78        let cps = module_coupling(&edges);
79        let a = cps.iter().find(|c| c.module == "a").unwrap();
80        assert_eq!(a.efferent, 2, "a depends on b and c");
81        assert_eq!(a.afferent, 1, "d depends on a");
82        assert!((a.instability - (2.0 / 3.0)).abs() < 1e-9);
83    }
84
85    #[test]
86    fn dedups_and_ignores_self_edges() {
87        let edges = [edge("a", "b"), edge("a", "b"), edge("a", "a")];
88        let cps = module_coupling(&edges);
89        let a = cps.iter().find(|c| c.module == "a").unwrap();
90        assert_eq!(a.efferent, 1);
91        assert!(
92            (a.instability - 1.0).abs() < 1e-9,
93            "only outgoing => unstable"
94        );
95    }
96
97    #[test]
98    fn stable_module_has_zero_instability() {
99        let edges = [edge("x", "core"), edge("y", "core")];
100        let cps = module_coupling(&edges);
101        let core = cps.iter().find(|c| c.module == "core").unwrap();
102        assert_eq!(core.afferent, 2);
103        assert_eq!(core.efferent, 0);
104        assert!((core.instability - 0.0).abs() < 1e-9);
105    }
106
107    #[test]
108    fn deterministic_order() {
109        let edges = [edge("b", "a"), edge("c", "a")];
110        assert_eq!(module_coupling(&edges), module_coupling(&edges));
111    }
112}