Skip to main content

srcgraph_metrics/
cyclomatic.rs

1//! Per-method cyclomatic complexity + bimodality detection.
2//!
3//! Reads the `cyclomaticComplexity` JSON blob already attached to each class
4//! node by the visiting-tool extractor (shape: `{"methods": [{"name": str,
5//! "complexity": int}, …]}`) and rolls it up into per-class distribution
6//! statistics plus a heuristic bimodality test that flags classes whose
7//! complexity distribution suggests they should be split.
8//!
9//! The bimodality heuristic mirrors the Python reference at
10//! `visiting/graph-theory-code-analysis-tool/analysis/cyclomatic.py`: sort the
11//! values, find the largest consecutive gap, and call the distribution bimodal
12//! when that gap is ≥ `max(3, 3 × median_gap)` and there are at least 2 values
13//! on each side. This is not the full Hartigan dip test (which needs a
14//! resampling-based critical value); the field is named `dip_score` for
15//! continuity with the Python panel but is a normalized gap ratio in [0, 1].
16//!
17//! See `DESIGN.md` (Phase 2) at the workspace root.
18
19use petgraph::Graph;
20use serde::{Deserialize, Serialize};
21
22use srcgraph_core::{ClassNode, EdgeKind};
23
24/// One row of the `cyclomaticComplexity.methods` array.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct MethodCc {
27    #[serde(default)]
28    pub name: String,
29    /// Defaults to 1 (a method with no branches has CC = 1).
30    #[serde(default = "one")]
31    pub complexity: u32,
32}
33
34fn one() -> u32 {
35    1
36}
37
38/// Distribution stats over a class's per-method CC values.
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct CcStats {
41    pub mean: f64,
42    pub median: u32,
43    pub min: u32,
44    pub max: u32,
45    pub std: f64,
46    /// Number of methods (`values.len()`).
47    pub total: usize,
48}
49
50/// Bimodality verdict over a class's per-method CC values.
51#[derive(Debug, Clone, Serialize, Deserialize, Default)]
52pub struct Bimodality {
53    pub is_bimodal: bool,
54    /// 1 or 2.
55    pub num_modes: u8,
56    /// Normalized gap ratio in [0, 1]; higher = more bimodal. Named for
57    /// continuity with the Python `dip_score` field — not the true Hartigan
58    /// dip statistic.
59    pub dip_score: f64,
60    /// Complexity value at which the split occurs, when `is_bimodal` is true.
61    pub split_point: Option<u32>,
62}
63
64/// Full cyclomatic readout for one class.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct CyclomaticReport {
67    pub class_id: String,
68    /// `None` when the node has no `cyclomaticComplexity` blob (syntax-only
69    /// extraction) or the blob fails to parse.
70    pub methods: Option<Vec<MethodCc>>,
71    pub stats: Option<CcStats>,
72    pub bimodality: Option<Bimodality>,
73}
74
75/// Parse a `cyclomaticComplexity` blob, returning the `methods` array.
76pub fn parse_methods(blob: &serde_json::Value) -> Option<Vec<MethodCc>> {
77    let arr = blob.get("methods")?.as_array()?;
78    let mut out = Vec::with_capacity(arr.len());
79    for v in arr {
80        let m: MethodCc = serde_json::from_value(v.clone()).ok()?;
81        out.push(m);
82    }
83    Some(out)
84}
85
86/// Mean / median / min / max / std / total over a complexity series.
87///
88/// Returns the default (all zeros, total = 0) for an empty input.
89pub fn compute_stats(values: &[u32]) -> CcStats {
90    let n = values.len();
91    if n == 0 {
92        return CcStats::default();
93    }
94    let sum: u64 = values.iter().map(|v| *v as u64).sum();
95    let mean = sum as f64 / n as f64;
96    let mut sorted: Vec<u32> = values.to_vec();
97    sorted.sort_unstable();
98    let median = sorted[n / 2];
99    let max = *sorted.last().unwrap();
100    let min = sorted[0];
101    let variance: f64 = values
102        .iter()
103        .map(|v| {
104            let d = *v as f64 - mean;
105            d * d
106        })
107        .sum::<f64>()
108        / n as f64;
109    CcStats {
110        mean,
111        median,
112        min,
113        max,
114        std: variance.sqrt(),
115        total: n,
116    }
117}
118
119/// Heuristic bimodality test. Mirrors the Python reference: sort, find the
120/// largest consecutive gap, declare the distribution bimodal when that gap is
121/// at least `max(3, 3 × median_gap)` with ≥ 2 values on either side.
122pub fn detect_bimodality(values: &[u32]) -> Bimodality {
123    if values.len() < 4 {
124        return Bimodality {
125            is_bimodal: false,
126            num_modes: 1,
127            dip_score: 0.0,
128            split_point: None,
129        };
130    }
131    let mut sorted: Vec<u32> = values.to_vec();
132    sorted.sort_unstable();
133    let gaps: Vec<u32> = sorted.windows(2).map(|w| w[1] - w[0]).collect();
134
135    let max_gap = *gaps.iter().max().unwrap_or(&0);
136    if max_gap == 0 {
137        return Bimodality {
138            is_bimodal: false,
139            num_modes: 1,
140            dip_score: 0.0,
141            split_point: None,
142        };
143    }
144    let max_gap_idx = gaps.iter().position(|g| *g == max_gap).unwrap();
145
146    let mut gaps_sorted = gaps.clone();
147    gaps_sorted.sort_unstable();
148    let median_gap = gaps_sorted[gaps_sorted.len() / 2];
149
150    let mean_gap = gaps.iter().map(|g| *g as f64).sum::<f64>() / gaps.len() as f64;
151    let dip_score = if mean_gap > 0.0 {
152        ((max_gap as f64 / (mean_gap + 0.01)) / 5.0).min(1.0)
153    } else {
154        0.0
155    };
156
157    let left_count = max_gap_idx + 1;
158    let right_count = sorted.len() - left_count;
159    let threshold = std::cmp::max(3, median_gap.saturating_mul(3));
160    let is_bimodal = max_gap >= threshold && left_count >= 2 && right_count >= 2;
161
162    Bimodality {
163        is_bimodal,
164        num_modes: if is_bimodal { 2 } else { 1 },
165        // Round to 4 decimals to match the Python output exactly.
166        dip_score: (dip_score * 10_000.0).round() / 10_000.0,
167        split_point: if is_bimodal {
168            Some(sorted[max_gap_idx])
169        } else {
170            None
171        },
172    }
173}
174
175/// Compute the full per-class cyclomatic readout for every node in `graph`.
176///
177/// Pulls the `cyclomaticComplexity` JSON blob via `Self::cyclomatic_complexity`
178/// (added to the `ClassNode` trait). Nodes without a blob yield a report with
179/// all `Option` fields set to `None`.
180pub fn compute_cyclomatic<N, E>(graph: &Graph<N, E>) -> Vec<CyclomaticReport>
181where
182    N: ClassNode,
183    E: EdgeKind,
184{
185    graph
186        .node_indices()
187        .map(|nx| {
188            let node = &graph[nx];
189            let class_id = node.id().to_owned();
190            let Some(blob) = node.cyclomatic_complexity() else {
191                return CyclomaticReport {
192                    class_id,
193                    methods: None,
194                    stats: None,
195                    bimodality: None,
196                };
197            };
198            let Some(methods) = parse_methods(blob) else {
199                return CyclomaticReport {
200                    class_id,
201                    methods: None,
202                    stats: None,
203                    bimodality: None,
204                };
205            };
206            let values: Vec<u32> = methods.iter().map(|m| m.complexity).collect();
207            let stats = compute_stats(&values);
208            let bimodality = detect_bimodality(&values);
209            CyclomaticReport {
210                class_id,
211                methods: Some(methods),
212                stats: Some(stats),
213                bimodality: Some(bimodality),
214            }
215        })
216        .collect()
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use srcgraph_core::{EdgeType, OwnedClassNode, OwnedGraph};
223    use petgraph::Graph;
224    use serde_json::json;
225
226    fn class(id: &str, cc: Option<serde_json::Value>) -> OwnedClassNode {
227        OwnedClassNode {
228            id: id.to_owned(),
229            name: id.to_owned(),
230            namespace: "test".to_owned(),
231            line_count: 10,
232            method_count: 1,
233            halstead_eta1: 0,
234            halstead_eta2: 0,
235            halstead_n1: 0,
236            halstead_n2: 0,
237            method_connectivity: None,
238            method_fingerprints: None,
239            method_tokens: None,
240            call_sequences: None,
241            cyclomatic_complexity: cc,
242            path_conditions: None,
243            invariants: None,
244            error_messages: None,
245            magic_numbers: None,
246            dead_code: None,
247            tenant_branches: None,
248            state_transitions: None,
249        }
250    }
251
252    #[test]
253    fn parse_methods_extracts_array() {
254        let blob = json!({"methods": [
255            {"name": "foo", "complexity": 1},
256            {"name": "bar", "complexity": 7}
257        ]});
258        let m = parse_methods(&blob).unwrap();
259        assert_eq!(m.len(), 2);
260        assert_eq!(m[0].name, "foo");
261        assert_eq!(m[1].complexity, 7);
262    }
263
264    #[test]
265    fn parse_methods_defaults_missing_complexity_to_one() {
266        let blob = json!({"methods": [{"name": "ctor"}]});
267        let m = parse_methods(&blob).unwrap();
268        assert_eq!(m[0].complexity, 1);
269    }
270
271    #[test]
272    fn compute_stats_empty_is_zeros() {
273        let s = compute_stats(&[]);
274        assert_eq!(s.total, 0);
275        assert_eq!(s.mean, 0.0);
276    }
277
278    #[test]
279    fn compute_stats_basic_distribution() {
280        // [1, 2, 3, 4, 5] — mean 3, median 3, min 1, max 5, var = 2, std ≈ 1.4142
281        let s = compute_stats(&[1, 2, 3, 4, 5]);
282        assert_eq!(s.total, 5);
283        assert!((s.mean - 3.0).abs() < 1e-12);
284        assert_eq!(s.median, 3);
285        assert_eq!(s.min, 1);
286        assert_eq!(s.max, 5);
287        assert!((s.std - 2_f64.sqrt()).abs() < 1e-12);
288    }
289
290    #[test]
291    fn bimodality_short_input_is_unimodal() {
292        // Fewer than 4 values → unimodal.
293        let b = detect_bimodality(&[1, 20, 3]);
294        assert!(!b.is_bimodal);
295        assert_eq!(b.num_modes, 1);
296        assert_eq!(b.split_point, None);
297    }
298
299    #[test]
300    fn bimodality_flat_distribution_is_unimodal() {
301        // All identical — every gap is 0.
302        let b = detect_bimodality(&[5, 5, 5, 5, 5]);
303        assert!(!b.is_bimodal);
304        assert_eq!(b.dip_score, 0.0);
305    }
306
307    #[test]
308    fn bimodality_two_clusters_detected() {
309        // Two clear clusters: {1,2,2,3} and {20,21,22,23}. Median gap is ~1,
310        // max gap is 17 — well above the `max(3, 3×median_gap)` threshold.
311        let b = detect_bimodality(&[1, 2, 2, 3, 20, 21, 22, 23]);
312        assert!(b.is_bimodal);
313        assert_eq!(b.num_modes, 2);
314        assert_eq!(b.split_point, Some(3));
315        assert!(b.dip_score > 0.0);
316    }
317
318    #[test]
319    fn bimodality_unbalanced_split_rejected() {
320        // The split lies one-in-from-the-edge (only one value on the right) —
321        // the Python heuristic requires ≥ 2 on each side, so we reject.
322        let b = detect_bimodality(&[1, 2, 3, 4, 50]);
323        assert!(!b.is_bimodal);
324    }
325
326    #[test]
327    fn compute_cyclomatic_walks_graph_and_handles_missing_blob() {
328        let mut g: OwnedGraph = Graph::new();
329        let a = g.add_node(class(
330            "A",
331            Some(json!({"methods": [
332                {"name": "m1", "complexity": 1},
333                {"name": "m2", "complexity": 2},
334                {"name": "m3", "complexity": 2},
335                {"name": "m4", "complexity": 30}
336            ]})),
337        ));
338        let b = g.add_node(class("B", None));
339        g.add_edge(a, b, EdgeType::MethodCall);
340
341        let reports = compute_cyclomatic(&g);
342        assert_eq!(reports.len(), 2);
343
344        let r_a = reports.iter().find(|r| r.class_id == "A").unwrap();
345        let stats_a = r_a.stats.as_ref().unwrap();
346        assert_eq!(stats_a.total, 4);
347        assert_eq!(stats_a.max, 30);
348        // A's distribution is heavily bimodal: {1,2,2} and {30}. Only one on
349        // the right, so the heuristic does *not* call it bimodal — left/right
350        // count guard is doing its job.
351        assert!(!r_a.bimodality.as_ref().unwrap().is_bimodal);
352
353        let r_b = reports.iter().find(|r| r.class_id == "B").unwrap();
354        assert!(r_b.methods.is_none());
355        assert!(r_b.stats.is_none());
356        assert!(r_b.bimodality.is_none());
357    }
358}