Skip to main content

srcgraph_metrics/
entropy.rs

1//! Shannon entropy over per-class identifier-token frequencies.
2//!
3//! For each class, the visiting-tool extractor emits a `methodTokens` blob:
4//!
5//!   `{"methods": [{"name": str, "tokens": [str]}, …]}`
6//!
7//! We pool every token across every method in the class, build a frequency
8//! histogram, and compute
9//!
10//!   H(X) = -Σᵢ pᵢ · log₂(pᵢ)            (bits; 0·log 0 ≡ 0 by convention)
11//!
12//! Low entropy → vocabulary is concentrated on a few terms (focused class);
13//! high entropy → vocabulary is spread across many terms (sprawling class).
14//! A class with ≤ 1 distinct token has H = 0 (no uncertainty).
15//!
16//! See `DESIGN.md` (Phase 2) at the workspace root.
17
18use petgraph::Graph;
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21
22use srcgraph_core::{ClassNode, EdgeKind};
23
24/// Per-class entropy readout.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct TokenEntropy {
27    pub class_id: String,
28    /// `None` when `methodTokens` is absent or unparseable.
29    pub entropy: Option<f64>,
30    /// Number of *distinct* tokens that fed into the histogram (vocabulary size).
31    pub distinct: usize,
32    /// Total token occurrences across all methods.
33    pub total: usize,
34}
35
36/// Shannon entropy of a probability distribution (bits).
37/// Zero-probability entries are skipped (0·log 0 ≡ 0).
38pub fn shannon_entropy(probs: &[f64]) -> f64 {
39    let mut h = 0.0;
40    for &p in probs {
41        if p > 0.0 {
42            h -= p * p.log2();
43        }
44    }
45    h
46}
47
48/// Shannon entropy over a token-count histogram.
49pub fn entropy_from_counts(counts: &HashMap<String, u64>) -> f64 {
50    let total: u64 = counts.values().sum();
51    if total == 0 {
52        return 0.0;
53    }
54    let total_f = total as f64;
55    let probs: Vec<f64> = counts.values().map(|c| *c as f64 / total_f).collect();
56    shannon_entropy(&probs)
57}
58
59/// Pool every `tokens` array across every method in a `methodTokens` blob into
60/// a single frequency histogram.
61pub fn token_histogram(blob: &serde_json::Value) -> Option<HashMap<String, u64>> {
62    let methods = blob.get("methods")?.as_array()?;
63    let mut counts: HashMap<String, u64> = HashMap::new();
64    for m in methods {
65        let Some(tokens) = m.get("tokens").and_then(|v| v.as_array()) else {
66            continue;
67        };
68        for t in tokens {
69            if let Some(s) = t.as_str() {
70                *counts.entry(s.to_owned()).or_insert(0) += 1;
71            }
72        }
73    }
74    Some(counts)
75}
76
77/// Compute per-class identifier-token entropy for every node.
78pub fn compute_entropy<N, E>(graph: &Graph<N, E>) -> Vec<TokenEntropy>
79where
80    N: ClassNode,
81    E: EdgeKind,
82{
83    graph
84        .node_indices()
85        .map(|nx| {
86            let node = &graph[nx];
87            let class_id = node.id().to_owned();
88            let Some(blob) = node.method_tokens() else {
89                return TokenEntropy {
90                    class_id,
91                    entropy: None,
92                    distinct: 0,
93                    total: 0,
94                };
95            };
96            let Some(counts) = token_histogram(blob) else {
97                return TokenEntropy {
98                    class_id,
99                    entropy: None,
100                    distinct: 0,
101                    total: 0,
102                };
103            };
104            let total: u64 = counts.values().sum();
105            let distinct = counts.len();
106            let entropy = if distinct <= 1 {
107                Some(0.0)
108            } else {
109                Some(entropy_from_counts(&counts))
110            };
111            TokenEntropy {
112                class_id,
113                entropy,
114                distinct,
115                total: total as usize,
116            }
117        })
118        .collect()
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use srcgraph_core::{EdgeType, OwnedClassNode, OwnedGraph};
125    use petgraph::Graph;
126    use serde_json::json;
127
128    fn class(id: &str, tokens: Option<serde_json::Value>) -> OwnedClassNode {
129        OwnedClassNode {
130            id: id.to_owned(),
131            name: id.to_owned(),
132            namespace: "test".to_owned(),
133            line_count: 10,
134            method_count: 1,
135            halstead_eta1: 0,
136            halstead_eta2: 0,
137            halstead_n1: 0,
138            halstead_n2: 0,
139            method_connectivity: None,
140            method_fingerprints: None,
141            method_tokens: tokens,
142            call_sequences: None,
143            cyclomatic_complexity: None,
144            path_conditions: None,
145            invariants: None,
146            error_messages: None,
147            magic_numbers: None,
148            dead_code: None,
149            tenant_branches: None,
150            state_transitions: None,
151        }
152    }
153
154    #[test]
155    fn shannon_of_uniform_two_outcomes_is_one_bit() {
156        assert!((shannon_entropy(&[0.5, 0.5]) - 1.0).abs() < 1e-12);
157    }
158
159    #[test]
160    fn shannon_of_certain_distribution_is_zero() {
161        assert_eq!(shannon_entropy(&[1.0, 0.0, 0.0]), 0.0);
162    }
163
164    #[test]
165    fn shannon_of_uniform_four_is_two_bits() {
166        let p = vec![0.25; 4];
167        assert!((shannon_entropy(&p) - 2.0).abs() < 1e-12);
168    }
169
170    #[test]
171    fn entropy_from_counts_matches_uniform_formula() {
172        // Four tokens, each appearing 3 times — uniform over 4 → H = log2(4) = 2.
173        let mut counts: HashMap<String, u64> = HashMap::new();
174        counts.insert("a".into(), 3);
175        counts.insert("b".into(), 3);
176        counts.insert("c".into(), 3);
177        counts.insert("d".into(), 3);
178        assert!((entropy_from_counts(&counts) - 2.0).abs() < 1e-12);
179    }
180
181    #[test]
182    fn token_histogram_pools_across_methods() {
183        let blob = json!({"methods": [
184            {"name": "foo", "tokens": ["x", "y", "x"]},
185            {"name": "bar", "tokens": ["y", "z"]}
186        ]});
187        let h = token_histogram(&blob).unwrap();
188        assert_eq!(h["x"], 2);
189        assert_eq!(h["y"], 2);
190        assert_eq!(h["z"], 1);
191    }
192
193    #[test]
194    fn single_distinct_token_yields_zero_entropy() {
195        let blob = json!({"methods": [
196            {"name": "foo", "tokens": ["x", "x", "x"]}
197        ]});
198        let mut g: OwnedGraph = Graph::new();
199        g.add_node(class("A", Some(blob)));
200        let rows = compute_entropy(&g);
201        assert_eq!(rows[0].distinct, 1);
202        assert_eq!(rows[0].entropy, Some(0.0));
203        assert_eq!(rows[0].total, 3);
204    }
205
206    #[test]
207    fn missing_blob_yields_none() {
208        let mut g: OwnedGraph = Graph::new();
209        let a = g.add_node(class("A", None));
210        let b = g.add_node(class("B", Some(json!({"methods": [{"name": "m", "tokens": ["a", "b"]}]}))));
211        g.add_edge(a, b, EdgeType::MethodCall);
212
213        let rows = compute_entropy(&g);
214        let ra = rows.iter().find(|r| r.class_id == "A").unwrap();
215        let rb = rows.iter().find(|r| r.class_id == "B").unwrap();
216        assert!(ra.entropy.is_none());
217        assert_eq!(rb.distinct, 2);
218        assert!((rb.entropy.unwrap() - 1.0).abs() < 1e-12);
219    }
220
221    #[test]
222    fn unparseable_blob_yields_none() {
223        // Wrong shape — `methods` is a string, not an array.
224        let blob = json!({"methods": "not an array"});
225        let mut g: OwnedGraph = Graph::new();
226        g.add_node(class("A", Some(blob)));
227        let rows = compute_entropy(&g);
228        assert!(rows[0].entropy.is_none());
229    }
230}