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;