11# Change risk (` repowise risk ` )
22
3- ` repowise risk ` scores a ** change** (a commit or a ` base..head ` range) for
4- defect risk from the shape of its diff, not the health of any file. It is a
5- just-in-time / pre-merge signal: complementary to ` repowise health ` (which
6- scores files), and useful as a PR gate because it fires on risky * small* changes
7- a file-level delta misses.
3+ ` repowise risk ` reports on a ** change** (a commit or a ` base..head ` range): the
4+ bug-fix history of the files it touches, and the shape of its diff. It is a
5+ just-in-time / pre-merge signal, complementary to ` repowise health ` , which
6+ scores files rather than changes.
7+
8+ ** Read ` fix_history ` first, not ` score ` .** The 0–10 score measures how big and
9+ spread out a change is. It does not measure where the change lands, and the two
10+ are not the same question — see [ What the score does and does not
11+ buy] ( #what-the-score-does-and-does-not-buy ) .
812
913``` bash
1014repowise risk # score uncommitted work, else HEAD
@@ -39,7 +43,66 @@ those patterns apply automatically and are combined with any command-line
3943flags. For example, ` tests/ ` excludes that directory recursively, while
4044` test_*.py ` excludes matching test filenames anywhere in the repository.
4145
42- ## What it measures
46+ ## Fix history: where the change lands
47+
48+ The first block in the result is ` fix_history ` , and it is the one to act on. It
49+ answers a question the diff shape cannot: ** have these files broken before?**
50+
51+ ```
52+ These files have broken before · 82nd percentile of this repo's fix-bearing files
53+ ┌──────────────────────────────────────────┬───────┬─────────────┐
54+ │ File │ Lines │ Prior fixes │
55+ ├──────────────────────────────────────────┼───────┼─────────────┤
56+ │ core/pipeline/persist.py │ 40 │ 21.6 │
57+ │ cli/commands/update_cmd/command.py │ 6 │ 19.3 │
58+ └──────────────────────────────────────────┴───────┴─────────────┘
59+ ```
60+
61+ - ** Prior fixes** is a count of bug-fix commits that previously touched that
62+ file, ** recency-weighted against the change's own date** : a fix from a year
63+ earlier counts a half, from two years a quarter. So the number is
64+ "recent-equivalent fixes", not a raw tally, and a file that broke constantly
65+ and then settled decays away. Anchoring to the change rather than to today
66+ means the same commit scores the same on every re-run.
67+ - ** ` density ` ** is the churn-weighted mean of those per-file numbers. Weighting
68+ by churn means the file a change mostly edits dominates the answer rather than
69+ a one-line drive-by next door. It is a * ratio* , so unlike the score it does
70+ not grow with the size of the diff: one line in a file fixed twenty times
71+ outranks a thousand lines in files never fixed at all.
72+ - ** ` percentile ` ** ranks that density against the repository's own fix-bearing
73+ files, since a bare "3.4 decayed fixes" means nothing on its own. Both sides
74+ are the same unit, so the comparison is like for like. It is ` null ` when the
75+ repo has fewer than eight files with any fix history, or when the change
76+ touches none of it.
77+
78+ This comes from one ` git log ` walk (up to 20 000 commits, memoized per
79+ repository state) using the same bug-fix classifier the indexer uses. It needs
80+ no index, no database and no coverage data, so it is available on a repository
81+ ` repowise ` has never indexed.
82+
83+ Fix history is read from ** before** the change being scored: a commit is ranked
84+ against the fixes that had already landed when it was written, never against
85+ fixes it caused. For a range, the record is read at the fork point the diff
86+ starts from, not at the base branch's current tip.
87+
88+ > ** Caveat, stated rather than buried.** The bug-fix classifier is keyword-based
89+ > and shared with the indexer, so ` fix_history ` under-reports in two ways.
90+ >
91+ > It matches ` fix ` , ` bug ` , ` patch ` , ` resolves ` , ` closes #N ` , ` fixes #N ` — and
92+ > misses conventions outside that set. Django's ` Fixed #12345 ` is the notable
93+ > one: on a 4 000-commit sample it classifies 5 commits as fixes where roughly
94+ > 1 800 use that prefix. (Django also uses ` Fixed #N ` for features, so the
95+ > subject line alone cannot separate the two — which is why the classifier has
96+ > not simply been widened.)
97+ >
98+ > It also ** excludes** any subject containing ` docs ` , ` typo ` , ` bump ` , ` deps ` ,
99+ > ` chore ` , ` lint ` , ` format ` or ` style ` . That keeps cosmetic commits out, but
100+ > drops genuine fixes like "fix: docs build crash" with them.
101+ >
102+ > Where the classifier fires the ranking is good; where a project's convention
103+ > falls outside it, ` fix_history ` reads lower than the truth.
104+
105+ ## What the diff-shape score measures
43106
44107The model uses Kamei-style * change* metrics (Kamei et al., "A large-scale
45108empirical study of just-in-time quality assurance"):
@@ -64,30 +127,55 @@ log-compressed features (`logit = intercept + Σ coefᵢ·zᵢ`), so every featu
64127push on the risk is exact and reported as an attributable driver (the same
65128linear / per-finding-attributable contract the file health score holds).
66129
67- ## How to read the result
68-
69- The headline signal is ** repo-relative** . The raw 0–10 score is anchored to the
70- offline calibration corpus, and that corpus is ** individual commits** (baseline:
71- 10.5 lines added, 1.7 files). A squash-merged PR, a ` base..head ` range, or any
72- repo whose typical commit is large is several commits' worth of diff read
73- against a one-commit scale, so the absolute band skews high: two-thirds of
74- commits can read "high" while ranking perfectly normally for * that* repo. The
75- * ranking* is sound; the absolute band is not portable. The payload states the
76- assumption in ` score_unit ` .
77-
78- So the surfaces lead with where the change sits in its ** own repo's**
79- distribution:
80-
81- - ** Review priority** / ** classification** : ` Below typical ` / ` Typical ` /
82- ` Elevated ` (terciles of the repo's own commit-risk distribution). This is the
83- signal to triage on.
84- - ** Percentile** : "riskier than N% of this repo's commits".
85- - ** Raw model score** (0–10): kept for transparency but shown as a secondary,
86- clearly corpus-anchored number, not the thing to act on.
130+ ## What the score does and does not buy
131+
132+ The score is a ** diff-size statistic** . That is a measured claim, not a hedge:
133+
134+ - ` la ` (lines added) carries a coefficient 7.6× the next largest, and scoring by
135+ ` la ` alone reproduces the full seven-feature score to within 0.12–0.16 points
136+ on every repository tried.
137+ - On a hand-picked set of small-but-dangerous changes versus large-but-boring
138+ ones (47 within-repo pairs across repowise, flask, django and zod), the score
139+ ranks the dangerous change above the boring one in ** 0 of 47** pairs. Ranking
140+ by fix density alone gets ** 46 of 47** .
141+
142+ That set is constructed, not held out: the pairs were chosen so that the
143+ dangerous change is always the smaller one, which means ranking by lines added
144+ scores 0 by construction and any signal genuinely independent of size scores
145+ near-perfectly. It is a falsification test — "can the score ever do this?" —
146+ and not an accuracy estimate. Its value is that the score failed it
147+ completely, on cases a reviewer would call obvious.
148+
149+ A refit was measured and rejected rather than shipped. Regrouping the corpus to
150+ PR granularity (` --first-parent ` merge spans) and adding two size-orthogonal
151+ features made accuracy * worse* : pooled leave-one-repo-out AUC 0.769 for the
152+ refit against 0.776 for the current feature set and 0.780 for a churn-only
153+ baseline. Per repository, ** lines added alone matches or beats the fitted model
154+ in five of six repos** . The reason is the labels: a commit is marked
155+ defect-inducing when a later bug-fix's blame points back at a line it wrote, and
156+ a larger commit writes more lines, so the label is itself size-biased. Any
157+ deliberately size-orthogonal feature scores near chance against it — fix density
158+ lands at 0.46–0.57 AUC — which is a fact about the labels, not about the
159+ feature. So the model constants are unchanged and the score is reported as what
160+ it demonstrably is.
161+
162+ ` score_measures ` states this in the payload. Read the result in this order:
163+
164+ - ** ` fix_history ` ** : where the change lands. The signal to triage on.
165+ - ** Review priority** / ** classification** / ** percentile** : where this change's
166+ * diff shape* sits in the repo's own distribution. Useful for "is this a big
167+ one for us", not for "is this a dangerous one".
168+ - ** ` score ` ** (0–10): diff size and spread, corpus-anchored to a single commit.
87169- ** ` fallback_band ` ** : the absolute ` low ` / ` moderate ` / ` high ` band. Present
88170 * only* when there was no baseline to rank against (a shallow repo, or
89171 ` --baseline 0 ` ), which is why it is not a peer of the review priority.
90172
173+ The score's absolute band is also ** unit-blind** . Its corpus is individual
174+ commits (baseline: 10.5 lines added, 1.7 files), so a squash-merged PR or a
175+ ` base..head ` range is several commits' worth of diff read against a one-commit
176+ scale and skews high: two-thirds of commits can read "high" while ranking
177+ normally for * that* repo. The payload states the assumption in ` score_unit ` .
178+
91179Each ** driver** is reported relative to * the model's baseline commit* (the
92180calibration-corpus mean), not this repo, so a ` +19 / −1 ` change can legitimately
93181read "more lines added than baseline" while still ranking ` Below typical ` for a
@@ -134,14 +222,26 @@ Constants are learned offline against the defect corpus (AG-SZZ bug-inducing
134222commits as labels, time-ordered evaluation with a right-censoring gap, and a
135223leave-one-repo-out comparison to the churn-only baseline). On a 7-repo,
1362245-language slice the pooled leave-one-repo-out AUC is ** 0.772 vs 0.766 for
137- churn-only** (Δ +0.0068, 95% CI [ -0.0003, +0.0131] ): competitive with churn
138- across the corpus and stronger on some repos (clap +0.053 on a time-ordered
139- split). Diff size dominates the fit, with change entropy risky and author
140- experience protective, both literature-consistent. Only the learned constants
141- ship; the runtime stays deterministic and zero-LLM.
142-
143- Recalibrate via ` repowise-bench/health-defect/jit_calibration.py ` ; the constants
144- live in ` packages/core/src/repowise/core/analysis/change_risk/model.py ` .
225+ churn-only** (Δ +0.0068, 95% CI [ -0.0003, +0.0131] ).
226+
227+ Read that number for what it is. A churn-only baseline scores 0.766 on the same
228+ labels, and lines-added alone scores higher still, so the margin measures very
229+ little. It is reported because it is the number the constants were selected on,
230+ not as evidence the score ranks danger — for that claim, see
231+ [ What the score does and does not buy] ( #what-the-score-does-and-does-not-buy ) ,
232+ where it fails.
233+
234+ ** ` fix_history ` carries no AUC of its own, deliberately.** Its evidence is the
235+ 47-pair ranking gate (46/47) and the fact that the files it ranks highest in
236+ this repository are the ones with the longest bug-fix records. It scores near
237+ chance against the SZZ labels, which — as above — is a property of those labels.
238+ Quoting a number from a benchmark that structurally cannot see the signal would
239+ be worse than quoting none.
240+
241+ Only learned constants ship; the runtime stays deterministic, zero-LLM, and
242+ free of new dependencies. Recalibrate via
243+ ` repowise-bench/health-defect/jit_calibration.py ` ; the constants live in
244+ ` packages/core/src/repowise/core/analysis/change_risk/model.py ` .
145245
146246## Cross-repo change risk (workspace mode)
147247
0 commit comments