The ablation table
The third clause is the claim, so it gets measured rather than asserted.
Ground truth is the fixture set. poisoned.package-lock.json contains exactly
three planted names — expres, lodahs, chalck — and the other five npm
fixtures contain none, so any finding outside the planted set is a false positive
by construction. 3,925 packages across six files.
$ make ablation
Against the full corpus, the clause is worth nothing
| in-degree clause | TP | FP | FN | precision | recall |
|---|---|---|---|---|---|
| on (shipped) | 3 | 0 | 0 | 1.000 | 1.000 |
| off (ablated) | 3 | 0 | 0 | 1.000 | 1.000 |
Identical. Both configurations find all three planted names and flag nothing else across 3,925 packages.
That is a real result and it belongs at the top of this page rather than buried at the bottom. It also measures the wrong thing. The 140,066-name corpus contains every package in every fixture, so clause 1 alone is sufficient and no other clause can possibly show a difference. Perfect scores here say the corpus is good, not that the rule is.
No corpus is ever complete
npm accepts thousands of new names a day. This corpus is a snapshot taken on one
afternoon — 2026-08-28, one curl run, written up in corpus/PROVENANCE.md. A
package published the day after that snapshot is, to clause 1, indistinguishable
from a package that does not exist.
So the question worth measuring is what happens as clause 1 degrades. Delete a fraction of the corpus and watch which clause is still holding the rule up. The thinning is a seeded xorshift, so the table is reproducible rather than different every run.
| corpus kept | in-degree clause | TP | FP | precision | recall |
|---|---|---|---|---|---|
| 100% (140066) | on | 3 | 0 | 1.000 | 1.000 |
| 100% (140066) | off | 3 | 0 | 1.000 | 1.000 |
| 90% (126004) | on | 3 | 1 | 0.750 | 1.000 |
| 90% (126004) | off | 3 | 36 | 0.077 | 1.000 |
| 70% (98197) | on | 2 | 6 | 0.250 | 0.667 |
| 70% (98197) | off | 2 | 127 | 0.016 | 0.667 |
| 50% (69897) | on | 1 | 8 | 0.111 | 0.333 |
| 50% (69897) | off | 1 | 175 | 0.006 | 0.333 |
| 25% (35134) | on | 1 | 5 | 0.167 | 0.333 |
| 25% (35134) | off | 1 | 177 | 0.006 | 0.333 |
Read the 90% row first, because it is the realistic one. Ten percent of the corpus missing is roughly what a few months of registry growth looks like. The clause takes false positives from 36 down to 1 — a 36-fold cut — and recall stays at 1.000. Nothing was traded for it.
At 70% the ratio is 127 to 6, about 21-fold. At 25% it is 177 to 5, about 35-fold. The clause never makes precision worse at any level, and it never costs a true positive relative to running without it; the ablation test asserts both of those rather than leaving them to the reader's eye.
These numbers moved when the length budget landed, and one of them moved the wrong way
Every false-positive count on this table is roughly a third of what it was
before distance::CHARS_PER_EDIT, because most of what a thinned corpus used to
hand clause 3 were short names sitting near something by arithmetic. Those never
reach clause 3 now. Precision improves at every level.
Recall got worse, and it is not a rounding artefact. At 70% the table used to read 3 true positives and now reads 2; at 50% and 25% it used to read 2 and now reads 1. That is the length budget refusing an edit the old threshold granted.
The honest reading is that the finding it lost was luck rather than detection. By
70% the thinning has deleted express itself, so expres no longer has its real
parent in the corpus and what it was matching was espree — a name it has nothing
to do with, as the section below works through. The budget declines to spend an
edit reaching a stranger. But it is a recall change, it is a cost, and a page
that only published the precision half of it would be doing the thing this
repository keeps saying it will not do.
The verdict outlives the explanation
Something more interesting than the headline number falls out of the decay run, and it took reproducing the thinning by hand to see.
The thinning is deterministic — seed 0x5EED1234, the same xorshift, over
corpus/npm.txt in order — so you can ask exactly which names it deleted. At 90%
all three parents survive. At 70% express is gone. At 50% and 25% lodash is
gone too, and only chalk is left.
Now look at what the tool reports at 70%. express has been deleted, and expres
is still flagged — matching espree at distance 2. At 50% and 25% it matches
rxpress. The finding is still correct, and its stated reason is not.
70% expres -> espree (d=2)
50% expres -> rxpress (d=2)
25% expres -> rxpress (d=2)
So the two halves of a finding decay at different rates. The verdict — this
name has no evidence behind it — survives corpus loss much better than the
explanation — this name is a typo of that one. By the time clause 1 has lost
30% of its coverage, the "nearest real name" printed in detail is a name the
typo has nothing to do with.
The practical reading: treat d=1 from "chalk" as the rule showing its working,
not as an identification. It is the closest surviving corpus entry, which is only
the actual parent when the corpus still contains the actual parent.
The recall drop is not the clause
Recall falls from 1.000 to 0.667 at 50% and stays there at 25%, in both columns. That is worth being clear about, because a careless reading blames the in-degree clause for it.
It is clause 2 failing, not clause 3. Thinning at 50% deleted lodash itself.
lodahs is still absent from the corpus and still has no parent, but there is no
longer a real name within distance 2 for it to be a typo of, so clause 2 finds
nothing and the rule stays quiet. Losing a planted name's parent loses the planted
name.
expres kept firing through the same loss because a coincidental neighbour
existed. lodahs had none. Which of the two happens is luck about the shape of
the registry, not a property of the rule.
That is a corpus-coverage failure. It shows up identically whether the in-degree clause is on or off, which is exactly what you would expect from a failure that has nothing to do with it. A tool whose corpus is half gone has bigger problems than which clause is enabled.
Running it
The decay table scans the fixtures ten times against a 140,000-name corpus and
took about two minutes before the prefilter and takes four seconds now, and it
keeps its own target because it is still not something to pay on every cargo test:
$ make ablation
cargo test --release --test ablation -- --nocapture --include-ignored
...
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.03s
CI runs it on every push, because the README quotes its numbers. The fast table
runs on every cargo test.
$ cargo test --test ablation -- --nocapture