Skip to main content

big_code_analysis/vcs/
stats.rs

1//! Per-file change-history statistics: the public [`Stats`] output and
2//! the internal [`Accumulator`] that the backend feeds during a walk.
3
4use std::collections::{HashMap, HashSet};
5
6use super::bus_factor::AuthorContribution;
7use super::classify::Classification;
8use super::identity::AuthorId;
9use super::options::{Options, SECONDS_PER_DAY};
10use super::score::{self, RISK_SCORE_VERSION, ScoreInput};
11
12/// Output-shape version for the `vcs` block. Bump on any change to the
13/// serialized field set. `2` added the change- and co-change-entropy
14/// fields (issue #330).
15pub const VCS_SCHEMA_VERSION: u32 = 2;
16
17/// Per-file change-history metrics.
18///
19/// One record per tracked, non-binary, non-symlink file present at the
20/// target ref. A tracked file with no activity in the window is emitted
21/// with zero counts — distinct from an untracked file, which has no
22/// `vcs` block at all.
23///
24/// All scores are *ordinal*: rank files by them, do not read absolute
25/// magnitudes. The serialized shape lives in [`crate::wire::Vcs`]; this
26/// struct is the compute-side source the wire form is projected from.
27#[derive(Clone, Debug, Default, PartialEq)]
28pub struct Stats {
29    /// Output-shape version ([`VCS_SCHEMA_VERSION`]).
30    pub vcs_schema_version: u32,
31    /// Composite-formula version ([`RISK_SCORE_VERSION`]).
32    pub risk_score_version: u32,
33    /// Long window length, in days.
34    pub long_window_days: u32,
35    /// Recent window length, in days.
36    pub recent_window_days: u32,
37    /// Distinct commits touching the file in the long window.
38    pub commits_long: u32,
39    /// Distinct commits touching the file in the recent window.
40    pub commits_recent: u32,
41    /// Σ(added + deleted) lines in the long window.
42    pub churn_long: u64,
43    /// Σ(added + deleted) lines in the recent window.
44    pub churn_recent: u64,
45    /// Distinct canonical author identities in the long window.
46    pub authors_long: u32,
47    /// Distinct canonical author identities in the recent window.
48    pub authors_recent: u32,
49    /// Top-author share of edits in the long window, in `[0, 1]`.
50    pub ownership_top_share: f64,
51    /// `commits_recent / commits_long`, clamped to `[0, 1]`.
52    pub burst: f64,
53    /// Long-window commits whose message matched a bug-fix keyword.
54    pub bug_fix_commits: u32,
55    /// Long-window commits whose message matched a security keyword.
56    pub security_fix_commits: u32,
57    /// Long-window commits whose subject is a revert / rollback.
58    pub revert_commits: u32,
59    /// Days since the file's first in-window commit (capped at window).
60    pub age_days: u32,
61    /// Days since the file's most recent in-window commit.
62    pub last_modified_days: u32,
63    /// Change entropy over the long window (Hassan 2009 History
64    /// Complexity Metric, in bits): the file's accumulated share
65    /// `Σ pᵢ·Hᵢ` of the churn-distribution entropy of every commit it
66    /// took part in. Higher = the file participates in more scattered,
67    /// distributed changes. `0.0` means it only ever changed alone.
68    pub change_entropy_long: f64,
69    /// Change entropy restricted to the recent window.
70    pub change_entropy_recent: f64,
71    /// Co-change graph entropy over the long window (arXiv 2504.18511,
72    /// 2025, in bits): the Shannon entropy of the file's co-change
73    /// edge-weight distribution. Higher = its changes ripple across many
74    /// different partners. `0.0` is *computed*, not missing — the file
75    /// has no co-change neighbours (only single-file commits).
76    pub cochange_entropy_long: f64,
77    /// Co-change graph entropy restricted to the recent window.
78    pub cochange_entropy_recent: f64,
79    /// Composite risk score (weighted or percentile, per options).
80    pub risk_score: f64,
81    /// Complexity × recent-churn hotspot score; `Some` only when AST
82    /// metrics were computed alongside the history.
83    pub hotspot_score: Option<f64>,
84    /// SHA-256-hashed canonical author identities, sorted; `Some` only
85    /// under `--emit-author-details`.
86    pub author_ids: Option<Vec<String>>,
87}
88
89/// Mutable per-file accumulator threaded through the history walk.
90///
91/// Holds the raw sets and counters; [`Accumulator::finalize`] collapses
92/// them into a [`Stats`]. Author *edits* credit every participant of a
93/// touching commit (author plus `Co-authored-by` trailers) one edit, so
94/// the distinct-author count and the ownership ratio both account for
95/// co-authorship.
96#[derive(Clone, Debug, Default)]
97pub struct Accumulator {
98    sloc: u64,
99    commits_long: u32,
100    commits_recent: u32,
101    churn_long: u64,
102    churn_recent: u64,
103    /// Accumulated change-entropy contribution `Σ pᵢ·Hᵢ` over the long
104    /// window; the recent counterpart sums only recent-window commits.
105    change_entropy_long: f64,
106    change_entropy_recent: f64,
107    /// Per-identity edit credits in the long window (ownership + count).
108    author_edits_long: HashMap<AuthorId, u32>,
109    /// Identities credited within the recent window (count only).
110    authors_recent: HashSet<AuthorId>,
111    bug_fix_commits: u32,
112    security_fix_commits: u32,
113    revert_commits: u32,
114    oldest_touch: Option<i64>,
115    newest_touch: Option<i64>,
116    /// Participants of the file's earliest observed in-window commit (the
117    /// `FirstAuthorship` input for the bus factor, issue #332). Tracked
118    /// against the minimum timestamp rather than walk order, so an
119    /// out-of-order (clock-skewed) commit cannot misattribute creation.
120    first_authors: Vec<AuthorId>,
121}
122
123/// One commit's effect on one file, as handed to [`Accumulator::record`].
124pub struct ChangeRecord<'a> {
125    /// Added + deleted lines this commit applied to the file.
126    pub churn: u64,
127    /// Commit timestamp (Unix seconds, clamped to `now` for skew).
128    pub commit_time: i64,
129    /// Whether the commit falls inside the recent window.
130    pub in_recent: bool,
131    /// Keyword classification of the commit message.
132    pub class: Classification,
133    /// Non-empty, bot-filtered participant identities for this commit.
134    pub authors: &'a [AuthorId],
135    /// This commit's change-entropy contribution to *this* file: the
136    /// file's churn share of the commit times the commit's churn-
137    /// distribution entropy (`pᵢ·H`), in bits. Zero for a single-file
138    /// commit (`H = 0`) or a zero-churn touch (`pᵢ = 0`). The backend
139    /// computes it once the whole commit's churn distribution is known.
140    pub change_entropy: f64,
141}
142
143impl Accumulator {
144    /// Start an accumulator for a file of `sloc` source lines.
145    #[must_use]
146    pub fn new(sloc: u64) -> Self {
147        Self {
148            sloc,
149            ..Self::default()
150        }
151    }
152
153    /// Fold one commit's effect on this file into the running totals.
154    pub fn record(&mut self, change: &ChangeRecord<'_>) {
155        // `saturating_add` everywhere, matching every other vcs accumulator:
156        // a real history never approaches these bounds, but a debug build's
157        // overflow check would panic, and the crate forbids panics in
158        // non-test code (AGENTS.md).
159        self.commits_long = self.commits_long.saturating_add(1);
160        self.churn_long = self.churn_long.saturating_add(change.churn);
161        self.change_entropy_long += change.change_entropy;
162        self.bug_fix_commits = self
163            .bug_fix_commits
164            .saturating_add(u32::from(change.class.bug_fix));
165        self.security_fix_commits = self
166            .security_fix_commits
167            .saturating_add(u32::from(change.class.security_fix));
168        self.revert_commits = self
169            .revert_commits
170            .saturating_add(u32::from(change.class.revert));
171        for id in change.authors {
172            // Avoid cloning the identity on the common repeat-author path;
173            // only a first-seen author allocates a map key.
174            if let Some(count) = self.author_edits_long.get_mut(id) {
175                *count = count.saturating_add(1);
176            } else {
177                self.author_edits_long.insert(id.clone(), 1);
178            }
179        }
180        // A strictly-earlier commit (re)sets first authorship; tracking
181        // the minimum timestamp directly keeps this correct even if the
182        // walk visits an older-named or clock-skewed commit out of order.
183        let is_new_oldest = self.oldest_touch.is_none_or(|t| change.commit_time < t);
184        if is_new_oldest {
185            self.oldest_touch = Some(change.commit_time);
186            self.first_authors.clear();
187            self.first_authors.extend_from_slice(change.authors);
188        }
189        self.newest_touch = Some(match self.newest_touch {
190            Some(t) => t.max(change.commit_time),
191            None => change.commit_time,
192        });
193        if change.in_recent {
194            self.commits_recent = self.commits_recent.saturating_add(1);
195            self.churn_recent = self.churn_recent.saturating_add(change.churn);
196            self.change_entropy_recent += change.change_entropy;
197            self.authors_recent.extend(change.authors.iter().cloned());
198        }
199    }
200
201    /// The per-developer authorship inputs for the bus-factor aggregate
202    /// (issue #332), or `None` when the file saw no in-window activity (no
203    /// authorship signal, so it is excluded from the bus-factor
204    /// denominator).
205    ///
206    /// `deliveries` is each developer's participation count (`DL`);
207    /// accepted-changes (`AC`) is derived per file by the aggregate, and
208    /// `first_authorship` flags participants of the earliest observed
209    /// commit. Identities are cloned because the aggregate outlives the
210    /// accumulator the walk discards.
211    #[must_use]
212    pub(crate) fn authorship(&self) -> Option<Vec<AuthorContribution>> {
213        if self.author_edits_long.is_empty() {
214            return None;
215        }
216        Some(
217            self.author_edits_long
218                .iter()
219                .map(|(author, &deliveries)| AuthorContribution {
220                    author: author.clone(),
221                    deliveries,
222                    first_authorship: self.first_authors.contains(author),
223                })
224                .collect(),
225        )
226    }
227
228    /// Collapse the accumulator into the serializable [`Stats`].
229    ///
230    /// `now` is the reference timestamp (wall clock or `--as-of`).
231    /// `cochange_long` / `cochange_recent` are the file's co-change graph
232    /// entropies (computed by the backend from the whole-walk graph, which
233    /// the per-file accumulator cannot see), folded into the score here so
234    /// the weighted formula stays the single risk-score authority. The
235    /// resulting risk score uses the weighted formula; percentile
236    /// re-ranking is a whole-set pass applied later by the backend.
237    #[must_use]
238    pub fn finalize(
239        &self,
240        now: i64,
241        options: &Options,
242        cochange_long: f64,
243        cochange_recent: f64,
244    ) -> Stats {
245        let long_window_days = options.long_window_days();
246        let authors_long = u32::try_from(self.author_edits_long.len()).unwrap_or(u32::MAX);
247        let authors_recent = u32::try_from(self.authors_recent.len()).unwrap_or(u32::MAX);
248
249        // The per-author counts are already saturating_add-clamped; the
250        // cross-author sum must be too — std `Sum<u32>` panics on overflow
251        // in debug, and the vcs crate forbids panics in non-test code.
252        let total_edits: u32 = self
253            .author_edits_long
254            .values()
255            .copied()
256            .fold(0u32, u32::saturating_add);
257        let top_edits = self.author_edits_long.values().copied().max().unwrap_or(0);
258        let ownership_top_share = if total_edits > 0 {
259            f64::from(top_edits) / f64::from(total_edits)
260        } else {
261            0.0
262        };
263
264        let burst = if self.commits_long > 0 {
265            (f64::from(self.commits_recent) / f64::from(self.commits_long)).clamp(0.0, 1.0)
266        } else {
267            0.0
268        };
269
270        // Age is capped at the long window: the walk only observes
271        // in-window commits, so a file older than the window reports
272        // the window length rather than its true creation date (true
273        // first-commit detection is the per-function/full-history
274        // follow-up, #329).
275        let age_days = self.oldest_touch.map_or(long_window_days, |t| {
276            days_between(t, now).min(long_window_days)
277        });
278        let last_modified_days = self
279            .newest_touch
280            .map_or(long_window_days, |t| days_between(t, now));
281
282        let risk_score = score::weighted(&ScoreInput {
283            churn_recent: self.churn_recent,
284            churn_long: self.churn_long,
285            commits_recent: self.commits_recent,
286            commits_long: self.commits_long,
287            authors_long,
288            ownership_top_share,
289            bug_fix_commits: self.bug_fix_commits,
290            security_fix_commits: self.security_fix_commits,
291            sloc: self.sloc,
292            age_days,
293            recent_window_days: options.recent_window_days(),
294            change_entropy_recent: self.change_entropy_recent,
295            cochange_entropy_recent: cochange_recent,
296        });
297
298        let author_ids = options.emit_author_details.then(|| {
299            let key = options.author_hash_key.as_ref();
300            let mut ids: Vec<String> = self
301                .author_edits_long
302                .keys()
303                .map(|author| author.emit_hashed(key))
304                .collect();
305            ids.sort_unstable();
306            ids
307        });
308
309        Stats {
310            vcs_schema_version: VCS_SCHEMA_VERSION,
311            risk_score_version: RISK_SCORE_VERSION,
312            long_window_days,
313            recent_window_days: options.recent_window_days(),
314            commits_long: self.commits_long,
315            commits_recent: self.commits_recent,
316            churn_long: self.churn_long,
317            churn_recent: self.churn_recent,
318            authors_long,
319            authors_recent,
320            ownership_top_share,
321            burst,
322            bug_fix_commits: self.bug_fix_commits,
323            security_fix_commits: self.security_fix_commits,
324            revert_commits: self.revert_commits,
325            age_days,
326            last_modified_days,
327            change_entropy_long: self.change_entropy_long,
328            change_entropy_recent: self.change_entropy_recent,
329            cochange_entropy_long: cochange_long,
330            cochange_entropy_recent: cochange_recent,
331            risk_score,
332            hotspot_score: None,
333            author_ids,
334        }
335    }
336}
337
338/// Whole days between an earlier timestamp and `now`, clamped at zero so
339/// a future-dated commit (clock skew) reads as "today" rather than a
340/// negative age.
341fn days_between(earlier: i64, now: i64) -> u32 {
342    let delta = (now - earlier).max(0);
343    u32::try_from(delta / SECONDS_PER_DAY).unwrap_or(u32::MAX)
344}
345
346#[cfg(test)]
347#[path = "stats_tests.rs"]
348mod tests;