Exact rates of the typos that ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, ABA routing and card Luhn check digits fail to catch, with a verified example of each one.
Ops and data teams treat a valid check digit as proof that a hand-keyed identifier is right. It is not. ISIN turns each letter into two digits before it runs Luhn, so swapping two letters never changes the checksum: US0378331005 and SU0378331005 are both valid. Luhn misses 09↔90, SEDOL's weights cannot tell 1 from B, and CUSIP never catches abc → cba. People repeat these facts, but nobody publishes exact miss rates per scheme and error class, or colliding pairs you can check. Without those numbers you cannot decide which fields need a second check.
One small spec per scheme. A scheme (src/schemes.ts) holds:
- the allowed characters at each position;
- a textbook validator, written the way the standard describes it (expand letters, then run Luhn on the digit string;
BigInt(expanded) % 97n === 1n; and so on); - a deterministic finite automaton that reads the identifier one character at a time and accepts exactly the valid codes.
The automata are small:
| Scheme | Automaton state | States |
|---|---|---|
| CUSIP, FIGI, SEDOL, ABA, card Luhn | running weighted sum mod 10 | 10 |
| IBAN (DE, GB, NL, BE) | weighted sum mod 97. Each position is all-digit or all-letter, so its power of ten is fixed. | 97 |
| LEI | remainder mod 97, read left to right; a letter multiplies by 100 instead of 10 | 97 |
| ISIN | (Luhn sum mod 10, digit parity), read right to left; a letter shifts the parity | 20 |
Reading ISIN from the right is what makes the letter expansion tractable. Every digit's Luhn weight depends only on how many digits lie to its right, and the automaton has already counted those.
Error model (src/errors.ts). There are five classes: single substitution (a→b), adjacent transposition (ab→ba), jump transposition (abc→cba), twin error (aa→bb) and phonetic error (1a↔a0, as in 13↔30). An error is applicable to a valid code when the result differs from the original and still has the right character class at every position. Errors that break the format are left out, because a format check already catches them. The rate measures what the check digit adds on top of that.
Exact counting by dynamic programming (src/engine.ts). An error at position p rewrites a window of 1–3 characters. Outside the window, the original and the corrupted code are identical, so:
forward[k][s]counts the prefixes of k characters that reach state s. Both codes share this prefix.- Inside the window, every (original window, corrupted window) pair is enumerated from state s. This gives a pair of states (s₁, s₂).
back2[k][s₁·S+s₂]counts the suffixes that drive both s₁ and s₂ to acceptance. The two codes read the same suffix characters, so one table over state pairs covers them.back1[k][s]does the same for the original alone, which gives the denominator.
undetected(p) = Σ_s forward[p][s] · Σ_(t,u) back2[p+w][ δ(s,t), δ(s,u) ]
applicable(p) = Σ_s forward[p][s] · Σ_(t,u) back1[p+w][ δ(s,t) ]
All counts are BigInt. LEI has 36¹⁸·10² strings, and the counts reach 10³¹. Every error class at every position of every scheme takes about 3 seconds in total. No sampling is involved.
Worked example (ISIN, adjacent transposition at position 1). U=30 and S=28, so US… expands to 3028… and SU… to 2830…. The swap moves a two-digit block by exactly two digits, and every digit keeps its Luhn parity, so the sum cannot change. The DP says the miss rate is 100% at positions 1–2 and 62.54% over the whole ISIN. Letter–digit swaps shift the parity of everything to their left, so they fall in between.
Witnesses (src/witness.ts). For each class, the witness finder takes the position with the most undetected errors. It then fixes characters one at a time: body first, check characters last, and at each position the character of a published identifier (Apple's ISIN, CUSIP, FIGI and LEI; the IBAN registry examples) when it can. After each choice, a reachability version of the same DP confirms that an undetected pair still exists. The result is the lexicographically first undetected pair under that preference order, so it stays as close to a real identifier as the blind spot allows. Every witness is then re-checked with the textbook validator on both sides, and a classifier confirms the two strings differ by exactly the claimed error at the claimed position.
Requires Node.js 20 or later. There are no runtime dependencies.
git clone <this repository> digitblind && cd digitblind
npm install
npm run buildFull report, Markdown on stdout, or JSON with exact numerators and denominators as strings:
node dist/src/cli.js report # all schemes
node dist/src/cli.js report --scheme isin # one scheme
node dist/src/cli.js report --json --out REPORT.json
npm run report # regenerates REPORT.md and REPORT.json$ node dist/src/cli.js report --scheme card
...
| Scheme | Length | Single substitution | OCR confusion | Keyboard slip | Adjacent transposition | Jump transposition | Twin error | Phonetic error |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| ISIN | 12 | 7.3221% | 5.8087% | 5.9404% | 62.5439% | 51.3756% | 14.4620% | 12.5000% |
| CUSIP | 9 | 7.3818% | 2.8653% | 1.6116% | 7.4278% | 100.0000% | 12.0254% | 12.5000% |
| SEDOL | 7 | 7.3733% | **19.0174%** | 4.3812% | 17.2652% | 36.3341% | 37.8370% | 0% |
| FIGI | 12 | 7.2548% | 4.8135% | 0% | 11.3271% | 95.3439% | 12.4171% | 8.6634% |
| LEI | 20 | 0.3936% | 0.5939% | 0.1616% | 0.4452% | 0.4144% | 0.4004% | 0% |
| IBAN DE | 22 | 0% | 0% | 0% | 0% | 0% | 0% | 0% |
| IBAN GB | 22 | 0% | 0% | 0% | 0% | 0% | 0% | 0% |
| IBAN NL | 18 | 0% | 0% | 0% | 0% | 0% | 0% | 0% |
| IBAN BE | 16 | 0% | 0% | 0% | 0% | 0% | 0% | 0% |
| ABA routing | 9 | 0% | 0% | 0% | 11.1111% | 11.1111% | 44.4444% | 0% |
| Card (Luhn) | 16 | 0% | 0% | 0% | 2.2222% | 100.0000% | 6.6667% | 12.5000% |
Findings that go beyond the usual folklore:
- **ISIN misses 7.3% of single substitutions**, even though Luhn catches every substitution on plain digits. Replacing a digit with a letter adds a digit and shifts the parity of everything to its left: `US0378331005` → `USH378331005` is valid.
- **LEI (MOD 97-10) is not perfect.** Digit↔digit and letter↔letter substitutions are always caught, but a digit↔letter substitution changes the length of the expanded number, and 0.39% of substitutions slip through: `H0UPKR0MPOU8FGXBT358` → `HMUPKR0MPOU8FGXBT358`. The IBAN formats sampled here fix letters and digits by position, so they really do reach 0%.
- **CUSIP and card Luhn miss every jump transposition.** Positions two apart carry the same weight.
- **SEDOL misses a substitution exactly when the two values agree mod 10** (`B0YBKJ7` → `10YBKJ7`), and every twin error at positions 4 and 6.
- **SEDOL cannot tell 1 from L, or 6 from G, anywhere in the code.** `L` is 21 and `1` is 1; `G` is 16 and `6` is 6. Both gaps are exact multiples of ten, and the check is mod 10, so no weight at any position can see the difference: `10YBKJ7` and `L0YBKJ7` are both valid. Those are also two of the commonest confusions in printed upper-case alphanumeric, which is why SEDOL misses **19.0%** of OCR confusions against 7.4% of substitutions drawn at random.
- **The random-substitution rate is a poor guide to real exposure, in both directions.** CUSIP misses 7.4% of arbitrary substitutions but only 2.9% of OCR confusions; SEDOL is the other way round by a factor of 2.6; FIGI misses no keyboard slip at all and 4.8% of OCR confusions. Which way a scheme fails depends on how its codes get corrupted, so the single headline number in the folklore answers a question nobody has.
**How the numbers are checked.** `npm run bench` runs two independent checks and fails if either disagrees:
1. *Brute force on shortened schemes.* These are ISIN with a 1-character body (length 4), CUSIP and SEDOL of length 4, FIGI of length 6 (3 free positions), LEI of length 4, IBAN `DE`+2n and `GB`+1a1n, ABA and card Luhn of length 5. Every string the format allows is enumerated, every applicable error is applied to every valid code, and the textbook validator is re-run. **All 63 scheme × class combinations match the DP exactly at every position.** The test suite also runs this on 40 randomly shaped small schemes, with fast-check capped at `numRuns: 40, endOnFailure: true`.
2. *Seeded Monte Carlo at full length.* 200,000 codes per scheme are drawn uniformly from all valid codes by walking the automaton weighted by `back1`. Each code gets one uniformly drawn candidate error, and the textbook validator is applied. **All 77 estimates fall within 3σ of the exact rate** (largest |z| = 2.00, FIGI phonetic errors), and every class the DP puts at 0% has exactly 0 misses in the samples.
Measured on an Apple M2 (8 GB) with Node.js v24.12.0. `npm run report` takes about 3.1 s for all 11 schemes, including witness search. `npm run bench` takes about 18 s: 2.0 s brute force and 15.8 s Monte Carlo. The full output is in [BENCHMARK.md](BENCHMARK.md), and the default seed (20260916) reproduces it exactly.
## Design notes
The main decision was to describe each scheme as an automaton rather than as "weights plus a modulus". Weights cannot express ISIN or LEI: in both, a character's effective weight depends on how many letters come after it. That dependence is the most interesting blind spot here, so a weights-only model would miss the point. An automaton handles it with 20 or 97 states. The counting engine then needs nothing specific to any scheme, and the same tables drive exact counting, uniform sampling for Monte Carlo and the reachability search for witnesses. The cost is the pair table: it has S² entries per position. That is 9,409 for mod 97, which is fine, but a scheme needing thousands of states would be slow. That is why IBAN is modelled as a positional weighted sum, which works only for formats where each position is all-digit or all-letter.
The second decision was the denominator. Counting substitutions of any character at any position would inflate detection with errors that a length or character-class check already rejects: a letter in a digit-only IBAN, a vowel in a SEDOL. Leaving them out answers the practical question: this identifier already passed format validation, so how much does the check digit add? Each validator is written twice, once as a textbook implementation and once as an automaton. Brute force and Monte Carlo judge with the textbook version, so a bug in an automaton shows up as a mismatch rather than being counted consistently on both sides. A test with a deliberately broken Luhn automaton confirms the mismatch appears.
## Limitations
- **Uniform over format-valid codes, not over real issuance.** Real ISIN country codes are a small subset of the 676 letter pairs, and CUSIP issuer numbers, LEI prefixes and routing numbers all have structure. Rates for real portfolios will differ, most for ISIN, where the share of letters drives the result.
- **Within a class, every error still counts equally.** OCR confusions and keyboard slips are now their own classes, but inside each one all pairs are weighted the same, and a code has only one error at a time. The OCR set (`0ODQ`, `1IL7`, `2Z`, `5S`, `6G`, `8B`, `UV`) and the QWERTY layout are stated in `src/errors.ts` rather than measured from a corpus; a different scanner or keyboard gives different numbers.
- **Scheme coverage is partial.** CUSIP's private-placement characters `*@#` are not included. FIGI is modelled only with Bloomberg's `BBG` prefix. Only four IBAN country formats are covered, and national check digits inside the BBAN (for example Belgium's own mod 97 pair) are not modelled, so the IBAN rates describe the ISO 13616 check alone. IBAN formats with mixed alphanumeric (`c`) segments, such as FR or CH, are rejected by the spec parser rather than handled.
- **MOD 97-10 check digits 00, 01 and 99 are accepted** whenever they satisfy the equation, as ISO 7064 verification does. Issuers only emit 02–98.
- **The Monte Carlo sampler uses the automaton tables** to draw valid codes. Its judgement of whether an error is missed uses the textbook validator, but it is not a fully independent reimplementation. The brute-force check is.
## License
MIT. See [LICENSE](LICENSE).