Skip to main content

big_code_analysis/vcs/
entropy.rs

1//! Shannon-entropy machinery shared by the two process-entropy signals
2//! added in issue #330.
3//!
4//! Two distinct metrics are built on the same [`shannon_entropy`] core:
5//!
6//! - **Change entropy** (Hassan, 2009 — *Predicting Faults Using the
7//!   Complexity of Code Changes*). Per commit, the entropy of the churn
8//!   distribution across the files it touched measures how *scattered*
9//!   that change was. Each file is then credited its History-Complexity
10//!   share `pᵢ · H` of every commit it took part in (Hassan's HCM with
11//!   the modification-probability attribution factor). The git backend
12//!   computes the per-commit term; this module only supplies the entropy
13//!   core so the math is unit-testable without a repository.
14//! - **Co-change graph entropy** (arXiv 2504.18511, 2025). Files that
15//!   change in the same commit are joined by a weighted edge (weight =
16//!   number of shared commits). A file's co-change entropy is the Shannon
17//!   entropy of its edge-weight distribution: low when it always co-changes
18//!   with the same partner, high when its changes ripple across many
19//!   different files. [`CochangeGraph`] accumulates the edges during the
20//!   single history walk and computes the per-file value at finalisation.
21//!
22//! All entropies are in **bits** (base-2 logarithm), the convention in
23//! both source papers.
24
25use std::collections::HashMap;
26use std::path::{Path, PathBuf};
27
28/// Initial-import commits touch thousands of files at once; a co-change
29/// graph grows O(width²) in commit width, so commits wider than this are
30/// excluded from the graph (only — their change entropy, which is O(width),
31/// is still counted). Tuned per issue #330's worked example.
32pub const MAX_COCHANGE_COMMIT_FILES: usize = 1000;
33
34/// Shannon entropy (base 2, in bits) of a weight distribution.
35///
36/// Weights are normalised to a probability distribution `pᵢ = wᵢ / Σw`
37/// and the entropy is `-Σ pᵢ·log₂(pᵢ)`. Non-positive weights contribute
38/// nothing (a file with zero churn in a commit, or an absent edge). An
39/// empty distribution, an all-zero distribution, and a single non-zero
40/// weight all yield `0.0` — there is no uncertainty to measure.
41///
42/// Takes a cloneable iterator rather than a slice so callers on the
43/// per-commit walk (churn distribution) and per-file finalise (edge
44/// weights) feed it directly, with no intermediate `Vec`; the two passes
45/// (total, then sum) clone the iterator.
46#[must_use]
47pub fn shannon_entropy<I>(weights: I) -> f64
48where
49    I: Iterator<Item = f64> + Clone,
50{
51    let total: f64 = weights.clone().filter(|&w| w > 0.0).sum();
52    if total <= 0.0 {
53        return 0.0;
54    }
55    let mut entropy = 0.0;
56    for w in weights {
57        if w > 0.0 {
58            let p = w / total;
59            entropy -= p * p.log2();
60        }
61    }
62    // `-0.0` (from a single-weight distribution, `p = 1`, `log2 = 0`)
63    // normalises to `0.0` so serialized output never carries a signed zero.
64    entropy + 0.0
65}
66
67/// Interned identifier for a file participating in the co-change graph.
68///
69/// Newtype rather than a bare `u32` so a node index can never be confused
70/// with a co-change count (both are `u32`).
71#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
72pub struct FileId(u32);
73
74/// A sparse, undirected, weighted co-change graph built incrementally
75/// across a history walk.
76///
77/// Paths are interned to [`FileId`]s and adjacency is stored sparsely
78/// (`Vec<HashMap<FileId, u32>>` indexed by node), so memory scales with
79/// the number of *observed* co-change pairs, not with files². Two graphs
80/// are tracked in lock-step — the full long-window graph and the
81/// recent-window subgraph — so both entropy variants come from one walk.
82#[derive(Clone, Debug, Default)]
83pub struct CochangeGraph {
84    ids: HashMap<PathBuf, FileId>,
85    long: Vec<HashMap<FileId, u32>>,
86    recent: Vec<HashMap<FileId, u32>>,
87}
88
89impl CochangeGraph {
90    /// An empty graph.
91    #[must_use]
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Intern `path`, allocating a fresh node (with empty adjacency in
97    /// both graphs) the first time it is seen.
98    fn intern(&mut self, path: &Path) -> FileId {
99        if let Some(&id) = self.ids.get(path) {
100            return id;
101        }
102        // `len` never exceeds the number of distinct tracked paths, far
103        // below `u32::MAX`; saturating keeps the conversion total without
104        // an `unwrap` on a provably-unreachable overflow.
105        let id = FileId(u32::try_from(self.long.len()).unwrap_or(u32::MAX));
106        self.ids.insert(path.to_path_buf(), id);
107        self.long.push(HashMap::new());
108        self.recent.push(HashMap::new());
109        id
110    }
111
112    /// Record that every file in `paths` changed together in one commit,
113    /// adding (or strengthening) an edge between each unordered pair.
114    ///
115    /// Commits touching more than [`MAX_COCHANGE_COMMIT_FILES`] files are
116    /// skipped to bound the O(width²) edge growth of bulk imports. When
117    /// `in_recent` is set, the edges are added to the recent subgraph too.
118    pub fn record_commit<P: AsRef<Path>>(&mut self, paths: &[P], in_recent: bool) {
119        if paths.len() < 2 || paths.len() > MAX_COCHANGE_COMMIT_FILES {
120            return;
121        }
122        let ids: Vec<FileId> = paths.iter().map(|p| self.intern(p.as_ref())).collect();
123        for (i, &a) in ids.iter().enumerate() {
124            for &b in &ids[i + 1..] {
125                if a == b {
126                    continue; // defensive: duplicate path within one commit
127                }
128                bump(&mut self.long, a, b);
129                if in_recent {
130                    bump(&mut self.recent, a, b);
131                }
132            }
133        }
134    }
135
136    /// Co-change entropy `(long, recent)` for `path`, in bits.
137    ///
138    /// Returns `(0.0, 0.0)` for a path that was never recorded or that
139    /// only ever appeared in single-file commits — by definition it has
140    /// no co-change neighbours. That zero is *computed*, not "missing":
141    /// every file in a VCS walk receives a value.
142    #[must_use]
143    pub fn entropy(&self, path: &Path) -> (f64, f64) {
144        let Some(&FileId(idx)) = self.ids.get(path) else {
145            return (0.0, 0.0);
146        };
147        let idx = idx as usize;
148        (
149            node_entropy(&self.long, idx),
150            node_entropy(&self.recent, idx),
151        )
152    }
153}
154
155/// Increment the symmetric edge weight between `a` and `b` in `adjacency`.
156fn bump(adjacency: &mut [HashMap<FileId, u32>], a: FileId, b: FileId) {
157    *adjacency[a.0 as usize].entry(b).or_insert(0) += 1;
158    *adjacency[b.0 as usize].entry(a).or_insert(0) += 1;
159}
160
161/// Shannon entropy of one node's edge-weight distribution, or `0.0` when
162/// the node has no edges in this graph (an absent node, or empty
163/// adjacency — `shannon_entropy` returns `0.0` for the empty iterator).
164///
165/// The edge weights are summed in **`FileId`-sorted** order, not HashMap
166/// iteration order. `shannon_entropy`'s `-Σ p·log2(p)` is a non-associative
167/// float fold, so a HashMap's seed-dependent order would let the live walk
168/// and a cache replay — separate processes with different `RandomState`
169/// seeds — sum to ULP-divergent values, silently breaking the #334
170/// bit-identity contract. Sorting pins one canonical summation order across
171/// every process.
172fn node_entropy(adjacency: &[HashMap<FileId, u32>], idx: usize) -> f64 {
173    adjacency.get(idx).map_or(0.0, |edges| {
174        let mut weights: Vec<(FileId, u32)> = edges.iter().map(|(&id, &w)| (id, w)).collect();
175        weights.sort_unstable_by_key(|&(id, _)| id);
176        shannon_entropy(weights.into_iter().map(|(_, w)| f64::from(w)))
177    })
178}
179
180#[cfg(test)]
181#[path = "entropy_tests.rs"]
182mod tests;