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;