Skip to main content

big_code_analysis/vcs/
score.rs

1//! Composite risk-score formulas.
2//!
3//! Two scores are offered. The default **weighted** score is a
4//! log-scaled weighted sum with categorical multiplicative bumps; the
5//! **percentile** score re-ranks every signal to its position within
6//! the analyzed set and averages. Both are *ordinal*: only relative
7//! ranks carry meaning, never the absolute magnitude.
8//!
9//! # Literature
10//!
11//! The term weights and thresholds are grounded in the defect- and
12//! vulnerability-prediction literature synthesised on issue #328:
13//!
14//! - Recent churn and recent commit count carry the highest weight —
15//!   Nagappan & Ball's relative-churn measures and the just-in-time
16//!   defect-prediction line both find recent change activity the
17//!   strongest single signal (Firefox `LinesChanged` PD 85,
18//!   `NumChanges` PD 86).
19//! - The author factor is multiplied by an ownership-dilution term
20//!   `(1 - ownership_top_share)`: diffuse ownership predicts defects
21//!   (Avelino DoA / truck-factor; Bird et al.).
22//! - Categorical developer-count bumps encode the RHEL4 finding that
23//!   files touched by ≥9 developers were ~16× more likely to harbour a
24//!   vulnerability, with a softer bump at the 6-developer mark.
25//! - A new-file bump reflects the Chromium observation that newly
26//!   added features carry elevated risk.
27//! - Bug-fix and security-fix commit counts feed a log-scaled additive
28//!   term, security fixes double-weighted (Sentence-Level VFC studies;
29//!   PySecDB).
30//! - File size enters as `ln1p(sloc)^2 / 100` with a full coefficient of
31//!   1.0, so it is a meaningful contributor rather than a tiny tie-breaker:
32//!   ~0.33 at 300 SLOC, ~0.48 at 1k, ~0.85 at 10k, and >1.0 past ~50k SLOC.
33//!   Those magnitudes are comparable to the churn terms (a recency-churn
34//!   "point" is `0.30*ln1p(50)` ≈ 1.18) and dwarf the entropy (≤0.15) and
35//!   long-churn (≈0.27 at heavy churn) terms. Large files are only weakly
36//!   correlated with defects, but the squared-log scaling keeps size a
37//!   first-class additive signal across realistic file sizes.
38//! - **v2** adds two recent-window process-entropy terms (issue #330):
39//!   *change entropy* (Hassan 2009; file-level Pearson 0.54 with defects
40//!   on Apache projects) and *co-change graph entropy* (arXiv 2504.18511,
41//!   2025; combining the two improved AUROC in 82.5% of cases). Both
42//!   enter additively, weighted below recent churn/commits but on par
43//!   with the fix term — they are bounded per file and complement, rather
44//!   than restate, the v1 churn/commit signals.
45//!
46//! Bumping *either* formula in any way **must** increment
47//! [`RISK_SCORE_VERSION`] so downstream consumers can detect the
48//! change. Both the weighted sum ([`weighted`]) and the percentile
49//! blend ([`apply_percentile`]) stamp and are cache-keyed on that one
50//! constant, so it versions both — see its doc for the full contract.
51
52use super::stats::Stats;
53
54/// Version of the risk-score computation, covering **both** formulas.
55/// A single `risk_score_version` is stamped on every file's output
56/// regardless of the active [`super::RiskFormula`] (the weighted sum or
57/// the percentile blend), and the persistent VCS cache keys reuse on it
58/// (see [`super::cache`]). Increment on any change that can alter an
59/// emitted `risk_score`, including:
60///
61/// - the weighted formula ([`weighted`]): its term set, weights, or
62///   categorical bumps below; and
63/// - the percentile blend ([`apply_percentile`]): its extractor set,
64///   mid-rank scaling, or normalization.
65///
66/// `2` added the change- and co-change-entropy terms to both formulas
67/// (issue #330).
68pub const RISK_SCORE_VERSION: u32 = 2;
69
70/// RHEL4 high-developer-count threshold (~16× vulnerability likelihood).
71const HIGH_DEV_THRESHOLD: u32 = 9;
72/// RHEL4 softer developer-count threshold.
73const MID_DEV_THRESHOLD: u32 = 6;
74/// Multiplicative bump applied at or above [`HIGH_DEV_THRESHOLD`].
75const HIGH_DEV_BONUS: f64 = 0.35;
76/// Multiplicative bump applied at or above [`MID_DEV_THRESHOLD`].
77const MID_DEV_BONUS: f64 = 0.15;
78/// Multiplicative bump for a file first seen within the recent window.
79const NEW_FILE_BONUS: f64 = 0.15;
80/// Absolute tolerance for treating two percentile signals as tied. All
81/// signals are integer counts or ratios in `[0, 1]`, so `1e-9` is far
82/// below any meaningful difference yet absorbs floating-point ratio drift.
83const TIE_TOLERANCE: f64 = 1e-9;
84
85/// Raw signals consumed by the weighted formula. Decoupled from
86/// [`Stats`] so the formula is unit-testable on synthetic inputs
87/// without constructing a full stats record.
88#[derive(Clone, Copy, Debug)]
89pub struct ScoreInput {
90    /// Lines added + deleted in the recent window.
91    pub churn_recent: u64,
92    /// Lines added + deleted in the long window.
93    pub churn_long: u64,
94    /// Distinct commits in the recent window.
95    pub commits_recent: u32,
96    /// Distinct commits in the long window.
97    pub commits_long: u32,
98    /// Distinct authors in the long window.
99    pub authors_long: u32,
100    /// Top-author share of edits in `[0, 1]`.
101    pub ownership_top_share: f64,
102    /// Long-window bug-fix commit count.
103    pub bug_fix_commits: u32,
104    /// Long-window security-fix commit count.
105    pub security_fix_commits: u32,
106    /// Source lines of the file at the target ref (tie-breaker).
107    pub sloc: u64,
108    /// Days since the file's first in-window commit (capped at window).
109    pub age_days: u32,
110    /// Recent-window length in days (new-file threshold).
111    pub recent_window_days: u32,
112    /// Recent-window change entropy (Hassan HCM share, in bits).
113    pub change_entropy_recent: f64,
114    /// Recent-window co-change graph entropy (in bits).
115    pub cochange_entropy_recent: f64,
116}
117
118/// Compute the weighted composite risk score for one file.
119///
120/// See the module docs for the term-by-term literature grounding. The
121/// `f64` casts of count fields are exact for every realistic input
122/// (counts never exceed 2^53) and the score is ordinal, so the lints
123/// are allowed locally with that justification.
124#[must_use]
125#[allow(clippy::cast_precision_loss)]
126pub fn weighted(input: &ScoreInput) -> f64 {
127    let recency_churn = ln1p(input.churn_recent as f64);
128    let long_churn = ln1p(input.churn_long as f64);
129    let recency_count = ln1p(f64::from(input.commits_recent));
130    let long_count = ln1p(f64::from(input.commits_long));
131    let author_factor = ln1p(f64::from(input.authors_long));
132    let dilution = (1.0 - input.ownership_top_share).clamp(0.0, 1.0);
133    let fix_factor =
134        ln1p(f64::from(input.bug_fix_commits) + 2.0 * f64::from(input.security_fix_commits));
135    // Squared log over 100, entering `base` with coefficient 1.0. This is a
136    // meaningful contributor, not a tie-breaker: ~0.85 at 10k SLOC and >1.0
137    // past ~50k SLOC, comparable to the recency-churn term (≈1.18 per
138    // churn-point) and well above the entropy (≤0.15) and long-churn terms.
139    let size_factor = ln1p(input.sloc as f64).powi(2) / 100.0;
140    // Recent-window process entropy (v2): scattered changes (Hassan) and
141    // wide co-change blast radius (arXiv 2504.18511) both predict defects.
142    // Already log-scaled (bits), so they enter linearly; the `.max(0.0)`
143    // is defensive against an upstream negative.
144    let entropy_factor =
145        0.10 * input.change_entropy_recent.max(0.0) + 0.05 * input.cochange_entropy_recent.max(0.0);
146
147    let new_file_bonus = if input.age_days < input.recent_window_days {
148        NEW_FILE_BONUS
149    } else {
150        0.0
151    };
152    let dev_bonus = if input.authors_long >= HIGH_DEV_THRESHOLD {
153        HIGH_DEV_BONUS
154    } else if input.authors_long >= MID_DEV_THRESHOLD {
155        MID_DEV_BONUS
156    } else {
157        0.0
158    };
159
160    let base = 0.30 * recency_churn
161        + 0.25 * recency_count
162        + 0.15 * long_count
163        + 0.15 * author_factor * (1.0 + dilution)
164        + 0.10 * fix_factor
165        + 0.05 * long_churn
166        + entropy_factor
167        + size_factor;
168
169    base * (1.0 + dev_bonus + new_file_bonus)
170}
171
172/// `ln(1 + x)` guarding against a negative argument from clock skew or
173/// an upstream miscount; the domain of every caller is non-negative, so
174/// the clamp is purely defensive. Shared with the JIT formula
175/// ([`super::jit`]) so the two scores log-scale counts identically.
176pub(super) fn ln1p(x: f64) -> f64 {
177    (1.0 + x.max(0.0)).ln()
178}
179
180/// Recompute every file's `risk_score` as the mean percentile rank of
181/// its signals within the analyzed set (the `--risk-formula percentile`
182/// mode).
183///
184/// Each signal is ranked independently using the *mid-rank* of ties
185/// (so identical values share a percentile), scaled to `[0, 100]`, and
186/// the per-file mean across signals becomes the score. With fewer than
187/// two files percentiles are undefined, so the weighted score is left
188/// in place.
189///
190/// The `u64 → f64` signal casts are exact for every realistic churn
191/// count (well under 2^53) and the result is ordinal, so the precision
192/// lint is allowed for the whole pass.
193///
194/// The resulting `risk_score` is stamped with and cache-keyed on
195/// [`RISK_SCORE_VERSION`] (the same constant as the weighted formula),
196/// so any change to this blend — the extractor set, the mid-rank
197/// scaling, or the `/ signal_count * 100` normalization — **must**
198/// increment that constant.
199#[allow(clippy::cast_precision_loss)]
200pub fn apply_percentile(stats: &mut [Stats]) {
201    if stats.len() < 2 {
202        return;
203    }
204    // Signals contributing to the percentile blend. Ownership enters as
205    // its dilution `(1 - share)` so "more diffuse" ranks higher, matching
206    // the weighted formula's direction. The two recent-window entropy
207    // signals join in v2 (issue #330), so both formulas read the new data.
208    let extractors: [fn(&Stats) -> f64; 10] = [
209        |s| s.churn_recent as f64,
210        |s| s.churn_long as f64,
211        |s| f64::from(s.commits_recent),
212        |s| f64::from(s.commits_long),
213        |s| f64::from(s.authors_long),
214        |s| 1.0 - s.ownership_top_share,
215        |s| f64::from(s.bug_fix_commits),
216        |s| f64::from(s.security_fix_commits),
217        |s| s.change_entropy_recent.max(0.0),
218        |s| s.cochange_entropy_recent.max(0.0),
219    ];
220
221    let n = stats.len();
222    let mut blended = vec![0.0_f64; n];
223    for extract in extractors {
224        let values: Vec<f64> = stats.iter().map(extract).collect();
225        for (i, &v) in values.iter().enumerate() {
226            // Tie tolerance: integer-derived signals compare bit-exact,
227            // but the dilution signal `1 - top/total` can land ~1 ULP
228            // apart for mathematically-equal ratios (e.g. 2/6 vs 1/3).
229            // `f64::EPSILON` is the ULP at magnitude 1.0 (too tight here),
230            // so use a small absolute tolerance — and apply it to BOTH
231            // filters so a near-equal value is counted as tied, never as
232            // both "less" and "equal" (which could push `pct` past 1.0).
233            let less = values.iter().filter(|&&o| o < v - TIE_TOLERANCE).count();
234            let equal = values
235                .iter()
236                .filter(|&&o| (o - v).abs() < TIE_TOLERANCE)
237                .count();
238            // Mid-rank: average position among equal values, in [0, 1].
239            let pct = (less as f64 + (equal as f64 - 1.0) / 2.0) / (n as f64 - 1.0);
240            blended[i] += pct.clamp(0.0, 1.0);
241        }
242    }
243    let signal_count = extractors.len() as f64;
244    for (s, blend) in stats.iter_mut().zip(blended) {
245        s.risk_score = (blend / signal_count) * 100.0;
246    }
247}
248
249#[cfg(test)]
250#[path = "score_tests.rs"]
251mod tests;