Luhn misses 100% of jump transpositions. Swap any two digits that sit one apart on a bank card number and the checksum guarding every card payment on earth is mathematically incapable of noticing. Not most of the time — every time.
This repo measures that, and nine other schemes, by brute force.
For ten real check-digit schemes, generate valid identifiers and inject every classical transcription error at every position, then count how many still validate. 3,800,668 mutations in total.
| error type | what it is | share of real errors* |
|---|---|---|
| single | one character wrong | ~79% |
| adjacent swap | two neighbouring characters exchanged | ~10% |
| jump swap | characters two apart exchanged | ~1% |
| twin | a doubled pair both changed the same way | ~0.5% |
* from Verhoeff's 1969 study of transcription errors, which is also where the taxonomy comes from.
| scheme | single | adjacent | jump | twin |
|---|---|---|---|---|
| Luhn — bank cards, IMEI | 0 | 2.10% | 100% | 6.36% |
| ISO 6346 — shipping containers | 5.74% | 3.85% | 2.95% | 3.96% |
| EAN-13 — barcodes, ISBN-13 | 0 | 11.25% | 100% | 11.11% |
| ISBN-10 | 0 | 0 | 0 | 11.16% |
| IBAN | 0 | 0 | 0 | 0 |
| VIN | 7.48% | 8.51% | 7.20% | 19.39% |
| NHS number | 0 | 0 | 0 | 10.74% |
| Australian TFN | 0 | 0 | 0 | 0 |
| Verhoeff | 0 | 0 | 6.18% | 4.62% |
| Damm | 0 | 0 | 10.04% | 8.88% |
Three things worth pulling out:
- Luhn and EAN-13 miss every jump transposition. Their weights alternate, so two positions one apart share a weight parity and the sum is unchanged. Structural, not statistical — and confirmed exhaustively rather than sampled.
- IBAN and the Australian tax file number were the only schemes that let nothing through. Both beat the number on your bank card.
- VIN is worst on single characters. Its transliteration maps several letters onto
one value —
AandJboth count as 1 — so substituting one for another cannot change the checksum.
Luhn's adjacent-swap blind spot is exactly 0↔9, confirmed across all 90 ordered
digit pairs.
python3 test_schemes.py # pin every scheme to its published test vectors
python3 measure.py # ~30s, writes results.json
python3 build_page.py # regenerate index.html from atlas.htmlNo dependencies. Python 3.8+.
schemes.py implements each scheme from its published definition. test_schemes.py
pins all ten to published test vectors — the Luhn classic, GB82WEST12345698765432,
a VIN with an X check character, Verhoeff 2363, Damm 5724 — and it runs before
anything is measured, because the whole value here is that the numbers are right.
checkdigit.js is the browser port, cross-checked against the Python on identical
inputs; the two agree on all 40 scheme × error-type cells.
Miss rates are measured over generated identifiers, not real-world ones, and real identifiers are not uniformly distributed — card numbers carry issuer prefixes, containers carry owner codes. Rates for the structured schemes would shift somewhat on real corpora. The 100% jump figures would not: those follow from the weighting.
Multi-error mistakes are out of scope, as is anything about fraud. A check digit catches typos, never forgery.
Every identifier shown is randomly generated. None is a real card, account, patient or vehicle.
MIT.