Skip to main content

code_ranker_graph/
stats.rs

1//! Per-graph aggregate stats: the mean of each tracked numeric metric across
2//! the project's file nodes. Zero/missing values are excluded from a metric's
3//! average (matching the historical behavior); a metric is emitted only when
4//! its average is positive.
5
6use crate::attrs::{attr_f64, is_external, num_attr};
7use code_ranker_plugin_api::{attrs::AttrValue, graph::Graph};
8use std::collections::BTreeMap;
9
10/// Compute the mean of each metric in `stat_keys` over all internal (file)
11/// nodes. Zero/missing values are excluded (historical behaviour); a metric is
12/// emitted only when its average is positive. The key set is data-driven — the
13/// caller passes the tier-2 stat metrics (from the registry) plus coupling keys —
14/// so this module names no metric.
15pub fn compute_stats(graph: &Graph, stat_keys: &[String]) -> BTreeMap<String, AttrValue> {
16    let mut stats = BTreeMap::new();
17    for key in stat_keys {
18        let vals: Vec<f64> = graph
19            .nodes
20            .iter()
21            .filter(|n| !is_external(n))
22            .filter_map(|n| attr_f64(n, key))
23            .filter(|v| v.is_finite() && *v > 0.0)
24            .collect();
25        if vals.is_empty() {
26            continue;
27        }
28        let avg = vals.iter().sum::<f64>() / vals.len() as f64;
29        if avg > 0.0 {
30            stats.insert(key.clone(), num_attr(avg));
31        }
32    }
33    stats
34}
35
36#[cfg(test)]
37mod tests {
38    use super::*;
39    use code_ranker_plugin_api::node::Node;
40
41    fn file(id: &str, cyclomatic: Option<i64>) -> Node {
42        let mut n = Node {
43            id: id.into(),
44            kind: "file".into(),
45            name: id.into(),
46            parent: None,
47            attrs: Default::default(),
48        };
49        if let Some(c) = cyclomatic {
50            n.attrs.insert("cyclomatic".into(), AttrValue::Int(c));
51        }
52        n
53    }
54
55    #[test]
56    fn average_excludes_zero_and_missing() {
57        let g = Graph {
58            nodes: vec![
59                file("a", Some(2)),
60                file("b", Some(4)),
61                file("z", Some(0)),
62                file("n", None),
63            ],
64            edges: vec![],
65        };
66        let s = compute_stats(&g, &["cyclomatic".to_string()]);
67        assert_eq!(s.get("cyclomatic"), Some(&AttrValue::Int(3)));
68    }
69
70    #[test]
71    fn empty_graph_has_no_stats() {
72        let g = Graph::default();
73        assert!(compute_stats(&g, &["cyclomatic".to_string()]).is_empty());
74    }
75}