big_code_analysis/vcs/jit.rs
1//! Just-in-time (commit-level) defect-induction risk scoring (issue #331).
2//!
3//! Where [`score`](crate::vcs::score) ranks *files* at a ref, this module
4//! scores a single *commit* at check-in time — the unit a CI gate
5//! actually reviews. It is the static, rule-based counterpart to the
6//! machine-learning just-in-time (JIT) defect-prediction models in the
7//! literature; no model is trained or persisted, so there is nothing to
8//! re-fit as a project ages.
9//!
10//! # Why static rules rather than a model
11//!
12//! The JIT defect-prediction literature (Kamei et al., *A Large-Scale
13//! Empirical Study of Just-in-Time Quality Assurance*, IEEE TSE 39(6),
14//! 2013; the systematic survey by Zhao et al. in *ACM Computing
15//! Surveys* 55(4), 2022) is mature and consistently finds commit-level
16//! prediction high-value at check-in. The survey's key tooling caveat is
17//! that trained JIT models **lose predictive power within about a year**
18//! and must be re-trained on recent data, so a rule-based scorer with no
19//! model to drift is the most maintainable starting point. The signed
20//! direction of every term below is taken from that literature, not
21//! fitted, so the score needs no retraining.
22//!
23//! # Features (the Kamei change measures)
24//!
25//! Grouped exactly as Kamei et al. group them, with the open-source
26//! replications [Commit Guru (Rosen, Grawi & Shihab, FSE 2015 tool
27//! demo)] and [McIntosh & Kamei, *Are Fix-Inducing Changes a Moving
28//! Target?*, IEEE TSE 44(5), 2018] confirming the directions on
29//! independent corpora:
30//!
31//! - **Size** — lines added/deleted, files touched, diff hunks. Larger
32//! changes are more defect-prone (Kamei `LA`/`LD`/`NF`).
33//! - **Diffusion** — distinct subsystems and directories touched, plus
34//! the within-commit change entropy. Scattered changes are riskier
35//! (Kamei `NS`/`ND`/`Entropy`).
36//! - **History** — the touched files' priors: prior change count,
37//! distinct prior authors, prior bug- and security-fix counts, and the
38//! composite file-level [`risk_score`](crate::vcs::Stats::risk_score).
39//! Files with turbulent history induce more defects (Kamei
40//! `NDEV`/`NUC`; the file priors fold in the #328 composite).
41//! - **Experience** — the author's prior commit count, long and recent.
42//! This term is **negatively** signed: experienced authors induce
43//! *fewer* defects (Kamei `EXP`/`REXP`, the one robustly protective
44//! signal in their models).
45//! - **Purpose** — whether the commit is a fix (itself defect-prone in
46//! Kamei's `FIX`), a security fix (weighted higher here), or a revert
47//! (corrective, so dampened).
48//!
49//! # The score is ordinal
50//!
51//! [`score`] returns a non-negative composite plus its per-group
52//! [`JitContributions`] (so a consumer sees *why* a commit scored as it
53//! did). Like the file-level risk score it is **ordinal**: rank commits
54//! by it, compare a commit against a project's own distribution, but do
55//! not read the absolute magnitude as a probability. Any change to the
56//! term set or weights **must** bump [`JIT_SCORE_VERSION`].
57//!
58//! # Scope
59//!
60//! [`score`] / [`JitReport`] cover a real commit (all five groups).
61//! Scoring an arbitrary unprovenanced diff (`bca vcs jit --diff <file>`)
62//! is supported as a deliberately *partial* path (issue #580): a bare
63//! diff carries no author, parent, or file history, so only the size and
64//! diffusion groups are computable. That path produces a distinct
65//! [`JitDiffReport`] whose unavailable groups are **absent from the type**
66//! (not present as zero), and whose [`partial_risk_score`](JitDiffReport::partial_risk_score)
67//! is **not comparable** to a commit score — see [`JitDiffReport`].
68//! ML-based JIT and server-side hook integration remain out of scope per
69//! issues #331 / #580.
70
71use serde::Serialize;
72
73use super::score::ln1p;
74
75/// Version of the composite JIT formula. Increment on any change to the
76/// term set, weights, or bumps in [`score`]. Separate from the
77/// file-level [`RISK_SCORE_VERSION`](crate::vcs::score::RISK_SCORE_VERSION)
78/// so the two scores version independently.
79pub const JIT_SCORE_VERSION: u32 = 1;
80
81/// Output-shape version for a [`JitReport`]. Bump on any change to the
82/// serialized field set.
83///
84/// `2`: added the `source` discriminator to [`JitReport`] (issue #642), so
85/// commit-mode reports now self-identify like [`JitDiffReport`] already
86/// did.
87///
88/// `3`: renamed the per-commit score key `score` → `risk_score` and the
89/// per-diff `partial_score` → `partial_risk_score` (issue #591), aligning
90/// the JIT vocabulary with the per-file `risk_score`.
91pub const JIT_SCHEMA_VERSION: u32 = 3;
92
93/// Security fixes weigh twice a plain bug fix in the history term, matching
94/// the file-level formula's double weight on security-fix history.
95const SECURITY_FIX_WEIGHT: f64 = 2.0;
96/// File-level `risk_score` magnitudes land in roughly `[0, 15]`; dividing
97/// by this keeps the file-prior term on par with the `ln1p` count terms.
98const FILE_RISK_SCALE: f64 = 10.0;
99
100/// Size of the change (Kamei `LA`/`LD`/`NF`, plus diff hunks).
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
102pub struct JitSize {
103 /// Lines added across all touched text files.
104 pub lines_added: u64,
105 /// Lines deleted across all touched text files.
106 pub lines_deleted: u64,
107 /// Distinct text files the commit touched.
108 pub files_touched: u32,
109 /// Diff hunks across all touched text files.
110 pub hunks: u32,
111}
112
113/// How widely the change is spread (Kamei `NS`/`ND`/`Entropy`).
114#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
115pub struct JitDiffusion {
116 /// Distinct top-level subsystems (first path component) touched.
117 pub subsystems: u32,
118 /// Distinct directories (full parent paths) touched.
119 pub directories: u32,
120 /// Within-commit change entropy in bits — the Shannon entropy of the
121 /// commit's churn distribution across its files (Hassan 2009; reused
122 /// from [`crate::vcs::entropy`]). `0.0` for a single-file commit.
123 pub entropy: f64,
124}
125
126/// Priors of the touched files, measured from history *before* the scored
127/// commit (Kamei `NDEV`/`NUC` plus the #328 file composite).
128#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
129pub struct JitHistory {
130 /// Σ prior in-window commits over the touched files (`NUC`).
131 pub prior_changes: u32,
132 /// Largest distinct-prior-author count among the touched files — a
133 /// lower-bound proxy for Kamei `NDEV` (a true cross-file union of
134 /// author identities is not available from the per-file index, which
135 /// exposes counts, not identity sets). Reported as a feature; it does
136 /// not feed the composite score.
137 pub prior_distinct_authors: u32,
138 /// Σ prior bug-fix commits over the touched files.
139 pub prior_bug_fix_commits: u32,
140 /// Σ prior security-fix commits over the touched files.
141 pub prior_security_fix_commits: u32,
142 /// Max composite file-level `risk_score` over the touched files.
143 pub file_risk_max: f64,
144 /// Mean composite file-level `risk_score` over the touched files.
145 pub file_risk_mean: f64,
146 /// Touched files absent from history before this commit (new files;
147 /// their priors are all zero, by definition).
148 pub new_files: u32,
149}
150
151/// The author's prior activity (Kamei `EXP`/`REXP`). Higher means more
152/// experience, which **lowers** the score.
153#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
154pub struct JitExperience {
155 /// Author's prior commits in the long window, before this commit.
156 pub author_prior_commits: u32,
157 /// Author's prior commits in the recent window, before this commit.
158 pub author_recent_commits: u32,
159}
160
161/// Keyword classification of the commit message (Kamei `FIX`, plus the
162/// security and revert refinements this crate already detects).
163#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
164pub struct JitPurpose {
165 /// The message matched a bug-fix keyword.
166 pub is_fix: bool,
167 /// The message matched a security-fix keyword.
168 pub is_security_fix: bool,
169 /// The commit is a revert / rollback.
170 pub is_revert: bool,
171}
172
173/// Every numeric feature of one commit, grouped as Kamei groups them. The
174/// score's *purpose* term is supplied separately ([`JitPurpose`]) so this
175/// struct is the pure numeric feature vector.
176#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
177pub struct JitFeatures {
178 /// Size of the change.
179 pub size: JitSize,
180 /// Spread of the change.
181 pub diffusion: JitDiffusion,
182 /// Touched-file priors.
183 pub history: JitHistory,
184 /// Author experience.
185 pub experience: JitExperience,
186}
187
188/// Per-group contributions to the composite score. They sum to the score
189/// before the non-negative floor (so `experience` is typically negative);
190/// surfaced so a consumer can see which group drove the result.
191#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
192pub struct JitContributions {
193 /// Size term (≥ 0).
194 pub size: f64,
195 /// Diffusion term (≥ 0).
196 pub diffusion: f64,
197 /// History / file-prior term (≥ 0).
198 pub history: f64,
199 /// Purpose term (fix/security add, revert subtracts).
200 pub purpose: f64,
201 /// Experience term (≤ 0 — experience lowers risk).
202 pub experience: f64,
203}
204
205/// Structural facts about the scored commit.
206#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
207pub struct JitCommit {
208 /// Resolved commit id (full hex).
209 pub id: String,
210 /// Number of parents (≥ 2 means a merge; `0` a root commit).
211 pub parent_count: u32,
212 /// Whether the commit is a merge (`parent_count > 1`).
213 pub is_merge: bool,
214 /// Keyword classification of the commit message.
215 pub purpose: JitPurpose,
216}
217
218/// The full result of scoring one commit: the resolved commit, its
219/// features, the per-group contributions, and the composite score.
220///
221/// Field order keeps every top-level scalar before the nested tables so
222/// the report serializes cleanly to TOML (which requires values to
223/// precede tables); JSON / YAML readers are order-insensitive.
224#[derive(Clone, Debug, Serialize)]
225pub struct JitReport {
226 /// Output-shape version ([`JIT_SCHEMA_VERSION`]).
227 pub jit_schema_version: u32,
228 /// Composite-formula version ([`JIT_SCORE_VERSION`]).
229 pub jit_score_version: u32,
230 /// Permanent discriminator: always [`JitSource::Commit`]. Distinguishes
231 /// a full commit report from a partial [`JitDiffReport`] at a glance in
232 /// JSON / YAML, mirroring [`JitDiffReport::source`].
233 pub source: JitSource,
234 /// Long observation window, in days (priors / experience).
235 pub long_window_days: u32,
236 /// Recent observation window, in days (recent experience).
237 pub recent_window_days: u32,
238 /// Ordinal composite risk score (≥ 0). Rank commits by it; do not
239 /// read it as an absolute probability.
240 pub risk_score: f64,
241 /// The scored commit.
242 pub commit: JitCommit,
243 /// The numeric feature vector.
244 pub features: JitFeatures,
245 /// Per-group contributions to [`risk_score`](JitReport::risk_score).
246 pub contributions: JitContributions,
247}
248
249/// Compute the composite JIT risk score and its per-group breakdown.
250///
251/// The term weights and signs come from the JIT literature (see the
252/// module docs): size, diffusion, and history are positively signed;
253/// experience is negative (experienced authors induce fewer defects);
254/// purpose adds for fixes and subtracts for reverts. The contributions
255/// sum to the score before the final non-negative floor.
256///
257/// The `f64` casts of count fields are exact for every realistic input
258/// (counts never approach 2^53) and the score is ordinal, so the
259/// precision lint is allowed locally.
260#[must_use]
261#[allow(clippy::cast_precision_loss)]
262pub fn score(features: &JitFeatures, purpose: JitPurpose) -> (f64, JitContributions) {
263 let s = &features.size;
264 let d = &features.diffusion;
265 let h = &features.history;
266 let e = &features.experience;
267
268 let churn = ln1p(s.lines_added.saturating_add(s.lines_deleted) as f64);
269 let size =
270 0.30 * churn + 0.15 * ln1p(f64::from(s.files_touched)) + 0.05 * ln1p(f64::from(s.hunks));
271
272 // Subsystem / directory *spread*: a change confined to one location
273 // contributes nothing (`saturating_sub(1)` → 0), and entropy captures
274 // the within-commit scatter on top of that.
275 let diffusion = 0.15 * ln1p(f64::from(d.subsystems.saturating_sub(1)))
276 + 0.10 * ln1p(f64::from(d.directories.saturating_sub(1)))
277 + 0.15 * d.entropy.max(0.0);
278
279 let fix_history = f64::from(h.prior_bug_fix_commits)
280 + SECURITY_FIX_WEIGHT * f64::from(h.prior_security_fix_commits);
281 // Clamp the file-prior term to a finite, non-negative value: `.max(0.0)`
282 // sanitizes NaN and negatives but passes `+inf` straight through, and an
283 // inf would propagate to the total, silently breaking the documented
284 // ordinal/non-negative invariant (`score` is `pub`). Finite by
285 // construction in-tree, but the guard makes the contract robust.
286 let file_risk = if h.file_risk_max.is_finite() {
287 h.file_risk_max.max(0.0)
288 } else {
289 0.0
290 };
291 let history = 0.10 * ln1p(f64::from(h.prior_changes))
292 + 0.15 * ln1p(fix_history)
293 + 0.15 * (file_risk / FILE_RISK_SCALE);
294
295 // Experienced authors induce fewer defects (Kamei `EXP`/`REXP`), so
296 // this group subtracts.
297 let experience = -0.10 * ln1p(f64::from(e.author_prior_commits))
298 - 0.05 * ln1p(f64::from(e.author_recent_commits));
299
300 let purpose_term = purpose_contribution(purpose);
301
302 let contributions = JitContributions {
303 size,
304 diffusion,
305 history,
306 purpose: purpose_term,
307 experience,
308 };
309 let total = (size + diffusion + history + purpose_term + experience).max(0.0);
310 (total, contributions)
311}
312
313/// The result of scoring an arbitrary unified diff (issue #580).
314///
315/// A bare diff carries **no author, parent, or file history**, so only the
316/// *size* and *diffusion* feature groups are computable. The *history*,
317/// *experience*, and *purpose* groups have no input and are therefore
318/// **absent from this type entirely** — not present as zero. This is the
319/// whole point of a distinct report shape: a consumer cannot read an
320/// unavailable group as "low risk", because there is no field to read (the
321/// failure mode #580 warns about).
322///
323/// # Not comparable to a commit score
324///
325/// [`partial_risk_score`](JitDiffReport::partial_risk_score) sums only the
326/// size and diffusion contributions (both ≥ 0) and **omits** history,
327/// experience, and purpose. The full [`JitReport::risk_score`] folds those
328/// in, but the experience group is *negatively* signed (an experienced
329/// author subtracts from the total), so the full commit score is **not**
330/// strictly greater than the partial diff score — an experienced author can
331/// push it below the partial value. The two scores are therefore **not
332/// ordered or comparable**: they live on different scales. Rank diffs
333/// against other *diffs*, never against commit scores.
334/// The `source` field is a permanent `"diff"` marker so a serialized
335/// report is self-identifying.
336///
337/// Field order keeps every top-level scalar before the nested tables so
338/// the report serializes cleanly to TOML.
339#[derive(Clone, Debug, Serialize)]
340pub struct JitDiffReport {
341 /// Output-shape version ([`JIT_SCHEMA_VERSION`]). Shared with
342 /// [`JitReport`] so both jit shapes version together.
343 pub jit_schema_version: u32,
344 /// Composite-formula version ([`JIT_SCORE_VERSION`]).
345 pub jit_score_version: u32,
346 /// Permanent discriminator: always [`JitSource::Diff`]. Distinguishes a
347 /// diff-only report from a commit report at a glance in JSON / YAML.
348 pub source: JitSource,
349 /// The partial (size + diffusion only) ordinal score. **Not comparable**
350 /// to [`JitReport::risk_score`] — see the type docs.
351 pub partial_risk_score: f64,
352 /// Size of the change. Computable from a bare diff.
353 pub size: JitSize,
354 /// Spread of the change. Computable from a bare diff.
355 pub diffusion: JitDiffusion,
356 /// The two available contributions (size, diffusion). History,
357 /// experience, and purpose contributions are absent because their
358 /// inputs are absent.
359 pub contributions: JitDiffContributions,
360}
361
362/// Which input a JIT report was scored from. Serializes to a lowercase
363/// string (`"commit"` / `"diff"`) so consumers can branch on it.
364#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
365#[serde(rename_all = "lowercase")]
366pub enum JitSource {
367 /// Scored from a real commit (all five feature groups present).
368 #[default]
369 Commit,
370 /// Scored from a bare unified diff (only size + diffusion present;
371 /// issue #580).
372 Diff,
373}
374
375/// The contributions available from a bare diff: size and diffusion only.
376/// History, experience, and purpose are omitted (no input), so — unlike
377/// [`JitContributions`] — there is no zero-valued field a consumer could
378/// misread as "this group is low risk".
379#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
380pub struct JitDiffContributions {
381 /// Size term (≥ 0).
382 pub size: f64,
383 /// Diffusion term (≥ 0).
384 pub diffusion: f64,
385}
386
387/// Compute the partial (size + diffusion only) JIT score for an arbitrary
388/// diff, reusing the *same* [`score`] math as a commit so the two terms are
389/// computed by one code path. The history, experience, and purpose terms
390/// are left at their zero defaults (no input), and their zero contributions
391/// are discarded — only size and diffusion survive into the returned
392/// [`JitDiffContributions`].
393///
394/// The returned score is **not comparable** to a commit score; see
395/// [`JitDiffReport`].
396#[must_use]
397pub fn score_diff_features(size: JitSize, diffusion: JitDiffusion) -> (f64, JitDiffContributions) {
398 let features = JitFeatures {
399 size,
400 diffusion,
401 ..JitFeatures::default()
402 };
403 // Reuse the commit-scoring formula, then keep only the two terms a bare
404 // diff can supply. The default history/experience contribute exactly
405 // zero and there is no message, so the partial total is just size +
406 // diffusion (already floored at >= 0 inside `score`).
407 let (_total, contributions) = score(&features, JitPurpose::default());
408 let partial = JitDiffContributions {
409 size: contributions.size,
410 diffusion: contributions.diffusion,
411 };
412 (partial.size + partial.diffusion, partial)
413}
414
415/// Additive adjustments for `FIX` / security / revert (Kamei `FIX` is
416/// itself defect-prone, so fixes add; a revert is corrective, so it
417/// subtracts).
418fn purpose_contribution(purpose: JitPurpose) -> f64 {
419 let mut term = 0.0;
420 if purpose.is_fix {
421 term += 0.15;
422 }
423 if purpose.is_security_fix {
424 term += 0.30;
425 }
426 if purpose.is_revert {
427 term -= 0.20;
428 }
429 term
430}
431
432#[cfg(test)]
433#[path = "jit_tests.rs"]
434mod tests;