Skip to main content

big_code_analysis/vcs/
bus_factor.rs

1//! Directory- and repo-level **bus factor** (a.k.a. truck factor) over a
2//! change-history walk, via the Avelino *Degree-of-Authorship* (DoA)
3//! heuristic (issue #332).
4//!
5//! Where [`score`](crate::vcs::score) ranks *files* and
6//! [`ownership_top_share`](crate::vcs::Stats::ownership_top_share)
7//! captures concentration *within* one file, the bus factor measures
8//! concentration *across a set of files*: the minimum number of
9//! developers whose departure would leave more than a configurable
10//! fraction of the set without a knowledgeable maintainer.
11//!
12//! # The Avelino DoA heuristic
13//!
14//! Avelino, Passos, Hora & Valente, *A Novel Approach for Estimating
15//! Truck Factors* (ICPC 2016), score each developer's authorship of each
16//! file with a regression fitted on a manually-validated corpus:
17//!
18//! ```text
19//! DOA(d, f) = 3.293 + 1.098·FA + 0.164·DL − 0.321·ln(1 + AC)
20//! ```
21//!
22//! where, for developer `d` and file `f`:
23//!
24//! - `FA` (*first authorship*) is `1` when `d` created `f`, else `0`;
25//! - `DL` (*deliveries*) is the number of changes `d` made to `f`;
26//! - `AC` (*accepted changes*) is the number of changes **other**
27//!   developers made to `f`.
28//!
29//! A developer is an **author** (authority) of `f` when their DoA,
30//! normalised by the file's maximum DoA, is at least
31//! [`DOA_NORMALIZED_THRESHOLD`] (`0.75` in the paper). The truck factor
32//! is then computed greedily: repeatedly remove the developer who
33//! authors the most still-covered files until more than
34//! `coverage_threshold` (default [`DEFAULT_COVERAGE_THRESHOLD`], `0.5`
35//! per Avelino) of the files are *orphaned* (have no remaining author).
36//! The number of developers removed is the bus factor.
37//!
38//! # Relation to the issue's restatement
39//!
40//! Issue #332 restates the formula as
41//! `N₁·FA + N₂·ln(1+DL) + N₃·ln(1+AC)`. This module uses the paper's
42//! **published, validated coefficients** (linear `DL`, not logged),
43//! because the issue also pins "thresholds from the paper": the fitted
44//! `0.164` `DL` coefficient is only meaningful against a linear `DL`
45//! term, so logging it would mis-apply the regression. Normalisation
46//! makes the score scale-free regardless, so a single prolific author
47//! still dominates their file.
48//!
49//! # The result is ordinal-but-actionable
50//!
51//! Unlike the per-file `risk_score`, the bus factor is a small integer
52//! with a direct reading: "this many key departures abandon the
53//! subsystem". It still inherits the heuristic's caveats — a young repo,
54//! or one with many single-author files, skews it downward (every file
55//! has exactly one author, so one departure orphans it). Any change to
56//! the formula, thresholds, or the grouping **must** bump
57//! [`BUS_FACTOR_SCHEMA_VERSION`].
58//!
59//! # Co-authorship and windows
60//!
61//! `DL` counts every commit a developer *participated in* (author plus
62//! `Co-authored-by` trailers), matching how
63//! [`ownership_top_share`](crate::vcs::Stats) already credits edits, so a
64//! co-authored commit credits each participant one delivery. `FA` and the
65//! whole computation see only the history *within the long window*, so
66//! "first authorship" means the earliest **observed** commit, not
67//! necessarily the file's true creation (true creation is the
68//! full-history follow-up, #329). Bot identities are filtered upstream,
69//! before authorship ever reaches this module.
70
71use std::collections::HashMap;
72use std::path::{Path, PathBuf};
73
74use serde::Serialize;
75
76use super::identity::{AuthorHashKey, AuthorId};
77
78/// Output-shape / algorithm version for the bus-factor aggregate. Bump on
79/// any change to the formula, thresholds, grouping, or serialized fields.
80pub const BUS_FACTOR_SCHEMA_VERSION: u32 = 2;
81
82/// Avelino DoA regression intercept.
83const DOA_INTERCEPT: f64 = 3.293;
84/// Weight on first authorship (`FA`).
85const DOA_FA_WEIGHT: f64 = 1.098;
86/// Weight on deliveries (`DL`, linear).
87const DOA_DL_WEIGHT: f64 = 0.164;
88/// Weight on the log of accepted changes (`ln(1 + AC)`); subtracted.
89const DOA_AC_WEIGHT: f64 = 0.321;
90
91/// Normalised-DoA threshold above which a developer counts as an author
92/// of a file (the paper's `0.75`). A file's top contributor always
93/// reaches `1.0`, so every file with any history has at least one author.
94pub const DOA_NORMALIZED_THRESHOLD: f64 = 0.75;
95
96/// Default fraction of files that must be orphaned for the greedy removal
97/// to stop — the Avelino coverage threshold (`0.5`).
98pub const DEFAULT_COVERAGE_THRESHOLD: f64 = 0.5;
99
100/// One developer's authorship inputs for a single file.
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct AuthorContribution {
103    /// Canonical author identity (already bot-filtered, mailmap-resolved).
104    pub author: AuthorId,
105    /// Number of in-window commits this developer participated in for the
106    /// file (`DL`).
107    pub deliveries: u32,
108    /// Whether this developer authored the file's earliest observed
109    /// in-window commit (`FA`).
110    pub first_authorship: bool,
111}
112
113/// One file's complete authorship, the unit the aggregate consumes.
114///
115/// Only files with at least one in-window contribution are represented;
116/// a tracked-but-inactive file carries no authorship signal and is
117/// excluded from the bus-factor denominator (its inclusion would orphan
118/// trivially and skew the result).
119#[derive(Clone, Debug, PartialEq, Eq)]
120pub struct FileAuthorship {
121    /// Repository-relative path (drives directory grouping).
122    pub path: PathBuf,
123    /// Per-developer contributions to this file (non-empty).
124    pub contributions: Vec<AuthorContribution>,
125}
126
127/// Bus factor for one set of files (the repo, or one directory).
128#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
129pub struct GroupBusFactor {
130    /// Developers whose combined departure orphans more than the coverage
131    /// threshold of the group's files. `0` for an empty group.
132    pub bus_factor: u32,
133    /// Files considered (those with in-window authorship).
134    pub files: u32,
135    /// Distinct developers who author at least one file in the group.
136    pub authors: u32,
137    /// SHA-256-hashed identities of the removed key developers, in
138    /// removal order; `Some` only under `--emit-author-details`.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub key_author_ids: Option<Vec<String>>,
141}
142
143/// Bus factor for one directory grouping.
144#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
145pub struct DirectoryBusFactor {
146    /// Repository-relative directory (forward-slash separated).
147    pub directory: String,
148    /// The directory's bus factor over every file recursively beneath it.
149    #[serde(flatten)]
150    pub group: GroupBusFactor,
151}
152
153/// The full bus-factor aggregate: repo-level plus per-directory.
154///
155/// Field order keeps the scalars before the nested tables so the report
156/// serialises cleanly to TOML (values must precede tables); JSON / YAML
157/// readers are order-insensitive.
158#[derive(Clone, Debug, PartialEq, Serialize)]
159pub struct BusFactor {
160    /// Schema / algorithm version ([`BUS_FACTOR_SCHEMA_VERSION`]).
161    pub bus_factor_schema_version: u32,
162    /// Coverage (abandonment) threshold actually applied, in `(0, 1)`.
163    pub coverage_threshold: f64,
164    /// Normalised-DoA authorship threshold ([`DOA_NORMALIZED_THRESHOLD`]).
165    pub doa_threshold: f64,
166    /// Bus factor over the whole repository.
167    pub repo: GroupBusFactor,
168    /// Bus factor for each top-level directory and each of its immediate
169    /// subdirectories, sorted by directory path.
170    pub by_directory: Vec<DirectoryBusFactor>,
171}
172
173/// The `vcs_aggregate` block: whole-walk aggregates surfaced alongside the
174/// per-file `vcs` data. A wrapper so future aggregates can join the
175/// bus factor without another top-level field.
176#[derive(Clone, Debug, PartialEq, Serialize)]
177pub struct VcsAggregate {
178    /// Directory- and repo-level bus factor (issue #332).
179    pub bus_factor: BusFactor,
180}
181
182/// Compute the bus-factor aggregate over every file's authorship.
183///
184/// `coverage_threshold` is clamped into the open interval `(0, 1)`
185/// defensively (front ends validate it); `emit_author_details` opts the
186/// hashed key-developer lists into the output. `author_hash_key`, when
187/// present, hardens those emitted ids into a keyed HMAC (issue #956)
188/// without affecting the bus-factor count or the tie-break order, both of
189/// which stay keyed on the unkeyed digest so they remain key-independent
190/// and cache-replay-stable.
191#[must_use]
192pub fn compute(
193    authorship: &[FileAuthorship],
194    coverage_threshold: f64,
195    emit_author_details: bool,
196    author_hash_key: Option<&AuthorHashKey>,
197) -> BusFactor {
198    let coverage = clamp_threshold(coverage_threshold);
199
200    // Enforce the documented non-empty-`contributions` invariant defensively:
201    // `FileAuthorship` is `pub`, so an external caller could pass a file with
202    // no contributions. Such a file has no author, would orphan trivially,
203    // and silently inflates the bus factor — drop it from the denominator
204    // rather than trust the precondition (in-tree callers never produce one).
205    let files: Vec<&FileAuthorship> = authorship
206        .iter()
207        .filter(|f| !f.contributions.is_empty())
208        .collect();
209
210    // Resolve each file's authors (the per-file DoA pass) exactly once;
211    // every group — the repo and each directory the file belongs to —
212    // then reuses these author lists rather than re-scoring the file.
213    let resolved: Vec<Vec<&AuthorId>> = files.iter().map(|f| authors_of_file(f)).collect();
214
215    let repo_files: Vec<&[&AuthorId]> = resolved.iter().map(Vec::as_slice).collect();
216    let repo = group_bus_factor(&repo_files, coverage, emit_author_details, author_hash_key);
217
218    let mut groups: HashMap<PathBuf, Vec<usize>> = HashMap::new();
219    for (idx, file) in files.iter().enumerate() {
220        for key in directory_keys(&file.path) {
221            groups.entry(key).or_default().push(idx);
222        }
223    }
224    let mut by_directory: Vec<DirectoryBusFactor> = groups
225        .into_iter()
226        .filter_map(|(dir, indices)| {
227            // A directory used as an output identifier must be UTF-8 (the
228            // path rules forbid lossy conversion); a non-UTF-8 directory is
229            // dropped from `by_directory` but still counts toward `repo`.
230            let directory = path_to_forward_slash(&dir)?;
231            let files: Vec<&[&AuthorId]> =
232                indices.iter().map(|&i| resolved[i].as_slice()).collect();
233            let group = group_bus_factor(&files, coverage, emit_author_details, author_hash_key);
234            Some(DirectoryBusFactor { directory, group })
235        })
236        .collect();
237    by_directory.sort_by(|a, b| a.directory.cmp(&b.directory));
238
239    BusFactor {
240        bus_factor_schema_version: BUS_FACTOR_SCHEMA_VERSION,
241        coverage_threshold: coverage,
242        doa_threshold: DOA_NORMALIZED_THRESHOLD,
243        repo,
244        by_directory,
245    }
246}
247
248/// Avelino DoA score for one contribution given the file's accepted-change
249/// count (deliveries by other developers). Domain-safe: `AC ≥ 0`, so the
250/// log never sees a non-positive argument.
251fn doa(contribution: &AuthorContribution, accepted_changes: u32) -> f64 {
252    DOA_INTERCEPT
253        + DOA_FA_WEIGHT * f64::from(contribution.first_authorship)
254        + DOA_DL_WEIGHT * f64::from(contribution.deliveries)
255        - DOA_AC_WEIGHT * (1.0 + f64::from(accepted_changes)).ln()
256}
257
258/// One developer in a group's working set: a stable hashed id (for
259/// deterministic tie-breaks and the optional key-author list) and the
260/// indices of the files they author.
261struct GroupAuthor {
262    hashed: String,
263    authored_files: Vec<usize>,
264}
265
266/// Compute the bus factor for one set of files, given each file's
267/// already-resolved author list (see [`authors_of_file`]).
268///
269/// Developers are greedily removed (most-authored-files first, ties broken
270/// by hashed id) until more than `coverage` of the files are orphaned; the
271/// count removed is the bus factor.
272fn group_bus_factor(
273    files: &[&[&AuthorId]],
274    coverage: f64,
275    emit: bool,
276    key: Option<&AuthorHashKey>,
277) -> GroupBusFactor {
278    let total_files = files.len();
279    if total_files == 0 {
280        return GroupBusFactor::default();
281    }
282
283    // Author registry for this group: canonical id → working-set index.
284    let mut author_index: HashMap<&AuthorId, usize> = HashMap::new();
285    let mut authors: Vec<GroupAuthor> = Vec::new();
286    // Remaining (not-yet-removed) author count per file; a file is
287    // orphaned when this hits zero.
288    let mut remaining_authors = vec![0u32; total_files];
289
290    for (file_idx, file_authors) in files.iter().enumerate() {
291        for &author in *file_authors {
292            let idx = *author_index.entry(author).or_insert_with(|| {
293                authors.push(GroupAuthor {
294                    hashed: author.hashed(),
295                    authored_files: Vec::new(),
296                });
297                authors.len() - 1
298            });
299            authors[idx].authored_files.push(file_idx);
300            remaining_authors[file_idx] += 1;
301        }
302    }
303
304    let bus_factor = greedy_truck_factor(&authors, &mut remaining_authors, coverage, emit, key);
305    GroupBusFactor {
306        bus_factor: bus_factor.removed,
307        files: u32::try_from(total_files).unwrap_or(u32::MAX),
308        authors: u32::try_from(authors.len()).unwrap_or(u32::MAX),
309        key_author_ids: bus_factor.key_authors,
310    }
311}
312
313/// The developers who author `file`: those whose DoA, normalised by the
314/// file's maximum DoA, clears [`DOA_NORMALIZED_THRESHOLD`].
315///
316/// The maximum DoA is normally positive (the intercept alone is `3.293`),
317/// but a file with a colossal accepted-change count can drive every DoA
318/// non-positive, where ratio normalisation is meaningless; that case
319/// falls back to crediting the single highest-DoA contributor, so every
320/// file with any history still has exactly one or more authors (never
321/// zero, which would orphan it spuriously).
322fn authors_of_file(file: &FileAuthorship) -> Vec<&AuthorId> {
323    let total_deliveries: u32 = file
324        .contributions
325        .iter()
326        .map(|c| c.deliveries)
327        .fold(0u32, u32::saturating_add);
328    let scored: Vec<(f64, &AuthorId)> = file
329        .contributions
330        .iter()
331        .map(|c| {
332            let accepted = total_deliveries.saturating_sub(c.deliveries);
333            (doa(c, accepted), &c.author)
334        })
335        .collect();
336    let max_doa = scored.iter().map(|&(d, _)| d).fold(f64::MIN, f64::max);
337
338    if max_doa <= 0.0 {
339        // Degenerate (a colossal accepted-change count drove every DoA
340        // non-positive): credit the single highest-DoA contributor. Break
341        // exact-DoA ties on the hashed id (ascending), so the pick is
342        // independent of `contributions` order — which arrives in
343        // non-deterministic HashMap order from the accumulator. A bare
344        // `max_by` on DoA alone is last-wins and would let two processes
345        // pick different authors for the same input.
346        return scored
347            .iter()
348            .min_by(|a, b| {
349                // Smaller is "better": negate the DoA ordering so the
350                // largest DoA sorts first, then the smallest hashed id.
351                b.0.total_cmp(&a.0)
352                    .then_with(|| a.1.hashed().cmp(&b.1.hashed()))
353            })
354            .map(|&(_, author)| vec![author])
355            .unwrap_or_default();
356    }
357    scored
358        .into_iter()
359        .filter(|&(d, _)| d / max_doa >= DOA_NORMALIZED_THRESHOLD)
360        .map(|(_, author)| author)
361        .collect()
362}
363
364/// Outcome of the greedy removal: the number of developers removed and,
365/// optionally, their hashed ids in removal order.
366struct TruckFactor {
367    removed: u32,
368    key_authors: Option<Vec<String>>,
369}
370
371/// Greedily remove the developer authoring the most still-covered files
372/// (ties broken by hashed id, ascending, for determinism) until more than
373/// `coverage` of the files are orphaned. Bounded by the author count, so
374/// it always terminates.
375fn greedy_truck_factor(
376    authors: &[GroupAuthor],
377    remaining_authors: &mut [u32],
378    coverage: f64,
379    emit: bool,
380    key: Option<&AuthorHashKey>,
381) -> TruckFactor {
382    let total_files = remaining_authors.len();
383    #[allow(clippy::cast_precision_loss)] // file counts never approach 2^53
384    let target = coverage * total_files as f64;
385    let mut orphaned = 0usize;
386    let mut removed = 0u32;
387    let mut removed_set = vec![false; authors.len()];
388    let mut key_authors = emit.then(Vec::new);
389
390    #[allow(clippy::cast_precision_loss)]
391    while orphaned as f64 <= target {
392        let Some(pick) = pick_top_author(authors, &removed_set, remaining_authors) else {
393            break; // no remaining developer covers any still-covered file
394        };
395        removed_set[pick] = true;
396        removed = removed.saturating_add(1);
397        if let Some(ids) = key_authors.as_mut() {
398            // Emit the unkeyed digest, or its keyed HMAC when a key is set.
399            // Tie-breaking above still uses the unkeyed `hashed`, so the
400            // removal order — and the bus-factor count — is key-independent.
401            let digest = &authors[pick].hashed;
402            ids.push(key.map_or_else(|| digest.clone(), |k| k.apply(digest)));
403        }
404        for &file_idx in &authors[pick].authored_files {
405            if let Some(count) = remaining_authors.get_mut(file_idx)
406                && *count > 0
407            {
408                *count -= 1;
409                if *count == 0 {
410                    orphaned += 1;
411                }
412            }
413        }
414    }
415
416    TruckFactor {
417        removed,
418        key_authors,
419    }
420}
421
422/// The not-yet-removed developer authoring the most still-covered files
423/// (a file is still covered while its remaining-author count is positive),
424/// ties broken by ascending hashed id. `None` when no remaining developer
425/// covers any still-covered file.
426fn pick_top_author(
427    authors: &[GroupAuthor],
428    removed_set: &[bool],
429    remaining_authors: &[u32],
430) -> Option<usize> {
431    let mut best: Option<(usize, usize)> = None; // (covered-count, author index)
432    for (idx, author) in authors.iter().enumerate() {
433        if removed_set[idx] {
434            continue;
435        }
436        let covered = author
437            .authored_files
438            .iter()
439            .filter(|&&f| remaining_authors[f] > 0)
440            .count();
441        if covered == 0 {
442            continue;
443        }
444        let better = match best {
445            None => true,
446            Some((best_covered, best_idx)) => {
447                covered > best_covered
448                    || (covered == best_covered && authors[idx].hashed < authors[best_idx].hashed)
449            }
450        };
451        if better {
452            best = Some((covered, idx));
453        }
454    }
455    best.map(|(_, idx)| idx)
456}
457
458/// The directory grouping keys for a file: its top-level directory
459/// (depth 1) and the immediate subdirectory beneath it (depth 2), if any.
460/// A root-level file (no directory component) yields no keys and so only
461/// contributes to the repo-level group.
462fn directory_keys(path: &Path) -> Vec<PathBuf> {
463    let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) else {
464        return Vec::new();
465    };
466    let mut components = parent.components().map(std::path::Component::as_os_str);
467    let Some(first) = components.next() else {
468        return Vec::new();
469    };
470    let mut keys = Vec::with_capacity(2);
471    keys.push(PathBuf::from(first));
472    if let Some(second) = components.next() {
473        let mut depth2 = PathBuf::from(first);
474        depth2.push(second);
475        keys.push(depth2);
476    }
477    keys
478}
479
480/// Render a repo-relative directory as a forward-slash string for output,
481/// returning `None` (so the caller drops it) for a non-UTF-8 path rather
482/// than mangling an identifier — mirrors the CLI's `path_to_string`.
483fn path_to_forward_slash(path: &Path) -> Option<String> {
484    path.to_str()
485        .map(|s| s.replace(std::path::MAIN_SEPARATOR, "/"))
486}
487
488/// Clamp a coverage threshold into the open interval `(0, 1)`. A degenerate
489/// `0` would make the first removal "exceed" the threshold (bus factor
490/// always 1) and a `1` would never be exceeded (bus factor = every author);
491/// the nudge keeps both extremes meaningful.
492fn clamp_threshold(threshold: f64) -> f64 {
493    const MIN: f64 = 1e-6;
494    const MAX: f64 = 1.0 - 1e-6;
495    if threshold.is_nan() {
496        DEFAULT_COVERAGE_THRESHOLD
497    } else {
498        threshold.clamp(MIN, MAX)
499    }
500}
501
502#[cfg(test)]
503#[path = "bus_factor_tests.rs"]
504mod tests;