srcgraph_metrics/
entropy.rs1use petgraph::Graph;
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21
22use srcgraph_core::{ClassNode, EdgeKind};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct TokenEntropy {
27 pub class_id: String,
28 pub entropy: Option<f64>,
30 pub distinct: usize,
32 pub total: usize,
34}
35
36pub 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
48pub 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
59pub 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
77pub 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 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 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}