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 clauseTPFPFNprecisionrecall
on (shipped)3001.0001.000
off (ablated)3001.0001.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 keptin-degree clauseTPFPprecisionrecall
100% (140066)on301.0001.000
100% (140066)off301.0001.000
90% (126004)on310.7501.000
90% (126004)off3360.0771.000
70% (98197)on260.2500.667
70% (98197)off21270.0160.667
50% (69897)on180.1110.333
50% (69897)off11750.0060.333
25% (35134)on150.1670.333
25% (35134)off11770.0060.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