Skip to content

Commit b4f19b4

Browse files
feat(risk): lead change risk with the bug-fix history of the files it touches (#1593)
The 0-10 change-risk score restates the size of the diff. Lines added carries a coefficient 7.6x the next largest, and scoring by lines added alone reproduces the full score to within 0.12-0.16 points on every repo measured. On a set of 47 within-repo pairs across four repos — a small edit to a file that keeps breaking against a large mechanical change to files that never have — the score ranks the dangerous change above the boring one in 0 of them. So `repowise risk` and `get_change_risk` now lead with `fix_history`: the recency-weighted bug-fix record of the files a change touches, which files carry it, and where that sits among the repo's own fix-bearing files. Ranking by that signal alone gets 46 of the same 47 pairs. It comes from one `git log` walk, memoized per repository state, so it needs no index, no database and no coverage data, and it is read from before the change being scored rather than after. The score is kept and reported as what it measures — diff size and spread, named in `score_measures` — rather than as a verdict on danger. Model constants are unchanged. A refit was measured and rejected rather than shipped. Regrouping the corpus to PR granularity and adding two size-orthogonal features scored worse: pooled leave-one-repo-out AUC 0.769 against 0.776 for the current feature set and 0.780 for a churn-only baseline, with lines added alone matching or beating the fitted model in five of six repos. The labels are size-biased — a commit is marked defect-inducing when a later fix blames a line it wrote, and a bigger commit writes more lines — so accuracy against them rewards predicting size and cannot see a signal built to ignore it. `docs/layers/CHANGE_RISK.md` is rewritten around this, including what the keyword-based fix classifier misses and why the ranking set is a falsification test rather than an accuracy estimate.
1 parent 04f33ae commit b4f19b4

22 files changed

Lines changed: 923 additions & 115 deletions

File tree

docs/agent/MCP_TOOLS.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -432,11 +432,18 @@ shape of the live diff and needs no index refresh.
432432
| `exclude_patterns` | list[string] | No | Gitignore-style paths to omit; combined with root `.riskignore` rules |
433433
| `baseline` | int | No | Recent commits to sample for percentile ranking (default `200`; `0` disables percentile ranking) |
434434

435-
**Returns:** A repo-relative `risk_percentile`, `review_priority`, and
436-
`classification` — triage on these — plus the raw `score` and the `score_unit`
437-
it is calibrated on (a single commit, so a PR-sized range reads high by
438-
construction). `fallback_band` carries the absolute band and appears only when
439-
no baseline was available. `working_tree` says whether uncommitted work was the
435+
**Returns:** `fix_history` first — the recency-weighted bug-fix record of the
436+
files the change touches, with `files` naming where the pressure sits and
437+
`percentile` ranking it against the repo's own fix-bearing files. Triage on
438+
this: it is the part that separates a small edit to a fragile file from a large
439+
edit to a safe one. `available` is false when the history walk could not run.
440+
441+
`score` measures diff size and spread, not where the change lands — see
442+
`score_measures` — and `score_unit` names the unit it is calibrated on (a single
443+
commit, so a PR-sized range reads high by construction). `risk_percentile`,
444+
`review_priority` and `classification` rank that same diff shape against recent
445+
commits. `fallback_band` carries the absolute band and appears only when no
446+
baseline was available. `working_tree` says whether uncommitted work was the
440447
subject. `baseline_sample_size` reports how many filtered commits informed the
441448
percentile; `features`, `drivers`, and combined `exclude_patterns` make the
442449
result auditable.

docs/layers/CHANGE_RISK.md

Lines changed: 134 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
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
1014
repowise risk # score uncommitted work, else HEAD
@@ -39,7 +43,66 @@ those patterns apply automatically and are combined with any command-line
3943
flags. 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

44107
The model uses Kamei-style *change* metrics (Kamei et al., "A large-scale
45108
empirical study of just-in-time quality assurance"):
@@ -64,30 +127,55 @@ log-compressed features (`logit = intercept + Σ coefᵢ·zᵢ`), so every featu
64127
push on the risk is exact and reported as an attributable driver (the same
65128
linear / 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+
91179
Each **driver** is reported relative to *the model's baseline commit* (the
92180
calibration-corpus mean), not this repo, so a `+19 / −1` change can legitimately
93181
read "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
134222
commits as labels, time-ordered evaluation with a right-censoring gap, and a
135223
leave-one-repo-out comparison to the churn-only baseline). On a 7-repo,
136224
5-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

examples/risk/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@ repowise risk main..HEAD # whole branch / PR as one change
2323
repowise risk HEAD~5..HEAD # recent local work
2424
```
2525

26-
The headline is **repo-relative**: percentile and review priority
27-
(`Below typical` / `Typical` / `Elevated`) among recent commits. The raw
28-
0–10 model score is secondary context.
26+
The headline is **fix history**: which of the touched files have needed bug
27+
fixes before, recency-weighted, and where that sits among this repo's
28+
fix-bearing files. Below it, the diff shape — percentile and review priority
29+
(`Below typical` / `Typical` / `Elevated`) among recent commits, and the 0–10
30+
score, which measures how large and spread out the change is rather than how
31+
dangerous.
2932

3033
## 2. Narrow what counts
3134

packages/api-client/src/risk.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,35 @@ export interface RiskRangeParams {
1818
baseline?: number;
1919
}
2020

21+
export interface FixHistoryFile {
22+
path: string;
23+
churn: number;
24+
/** Prior bug fixes on this file, recency-weighted (a year ago counts a half). */
25+
fix_pressure: number;
26+
}
27+
28+
/**
29+
* Bug-fix history of the files a change touches — the part of the answer that
30+
* does not grow with the diff, and the part `score` cannot see.
31+
*/
32+
export interface FixHistory {
33+
/** False when the history walk could not run — not the same as "no fixes". */
34+
available: boolean;
35+
/** Churn-weighted mean fix pressure across the changed files. */
36+
density: number;
37+
/** Rank against this repo's own fix-bearing files; null if too few to rank. */
38+
percentile: number | null;
39+
files: FixHistoryFile[];
40+
}
41+
2142
export interface RiskRangeResponse {
2243
base: string;
2344
head: string;
45+
/** Where the change lands. Read before `score`. */
46+
fix_history: FixHistory;
2447
score: number;
48+
/** What `score` measures: diff size and spread, not where the change lands. */
49+
score_measures: string;
2550
/** The unit `score` is calibrated on: a single commit, not a whole range. */
2651
score_unit: string;
2752
risk_percentile: number | null;

0 commit comments

Comments
 (0)