The long hunt
A 108-million-candidate server search, and the statistics that stop it fooling itself.
A long-running cipher search over the systems Elgar could plausibly have used in July 1897, with the statistics needed to tell a real result from the best of a very large number of lottery tickets. One Python file, no dependencies, built to be started and left alone for weeks: it checkpoints, resumes exactly, bounds its own memory, and writes a status file every 30 seconds for a live dashboard.
Which ciphers, and why those
The constraint that does the most work is the date and the medium: a short personal note written by hand in 1897 by a musician, not a cryptographer, for a friend who was expected to read it. That rules out anything needing tables, machines or bookkeeping.
Always present: substitution. Every candidate is solved with a simulated annealer over the 24-symbols → letters map. A substitution solver absorbs any fixed relabelling — a different grid fill, a once-off rotation of the whole alphabet, “the orientation is the letter and the arc count is a modifier” are all the same cipher to it. That is what makes the space finite instead of astronomical.
| family | what it is | why it is plausible in 1897 |
|---|---|---|
| order | the seven ways of taking the glyphs off the page: forwards, reversed, each line reversed, boustrophedon, lines 3-2-1, by columns, spiral | costs nothing to do and nothing to undo |
| snowflake | a key that turns as you write — 7 reading orders × 10 advance schedules × 7 step sizes × 2 directions × 9 mirror axes × 6 ring permutations × 2 ring periods | trivial with a paper disc, and the 3 rings × 8 directions symbol set is a clock face |
| ringspin | the three rings turn at different rates — one notch inner, three outer | what concentric rings are for; Alberti's disc had two and they moved independently |
| keyspin | the amount of turn cycles through a keyword written as numbers | a Vigenère on the disc — the most likely thing a literate amateur would do |
| autokey | the turn is dictated by the symbol just decoded | self-keying: no written key to lose or be found, and trivial by hand |
| railfence | zigzag over 2–12 rails, read row by row | a schoolboy cipher, in every puzzle book |
| columnar | write in rows, read columns in a keyword's order — every key of every width 2–9 (409,112 keys) | the most widely used hand transposition of the century |
| columnar2 | the same twice with different keys — widths 2–6 on each side (760,384 pairs) | the standard way of making a columnar hard |
| route | write into a rectangle, read along a spiral or diagonal | used in the American Civil War, well known by 1897 |
| grille | turning grille (Fleissner) — a card with holes, turned four times; sizes 10 and 12, 250,000 cards each | in print since 1881, a parlour favourite |
| decimate | take every n-th symbol, wrapping | what a grille does, without the card |
| grid | write into a rectangle and read it out — 87 = 3 × 29 exactly | the first thing anyone would try with a three-line note |
| nulls | some marks carry nothing: drop every k-th, or a head or tail | the oldest trick there is |
| composite | a transposition followed by a rotating key | the one combination a hand-worker could manage, and the one a solver cannot undo in either order alone |
| composite2 | a transposition applied twice with different rules | cheap by hand, and it defeats a single-stage undo |
Languages
Period correspondence and German by default; plain English and “compressed English” (a vowel-reduced proxy for the shorthand hypothesis) are opt-in. Each has its own model and its own control texts; raw scores are never compared across them. German is a full 3+4-gram model over 2.5 M letters of 19th-century prose, with controls from a held-out 15%.
Deliberately not implemented
Playfair needs an even length and 87 is odd (one null fixes that, so the nulls family covers it). Bifid / Nihilist: Delastelle published in 1895, too fresh and fiddly. Book ciphers: unfalsifiable without the book. Anything needing a machine. And more than four interleaved alphabets leaves under 20 letters per alphabet — unsolvable in principle, so the engine refuses; small k is covered by the snowflake schedules.
How a long brute-force is done properly
Indexed space, not a list
Every candidate has an integer address decoded arithmetically. A space of 10⁹ costs no memory and “resume at 431,905,772” is exact.
Round-robin over families
One candidate from each family in
turn. Proportional scheduling sounds fairer and is not: the snowflake family is 105,840 of 107,217 candidates, so it
was permanently the least explored by fraction and took 798 of the first 800 slots while decimate was
never touched. Round-robin gives every hypothesis immediate coverage; small families finish within minutes and the
large one inherits the rest of the run.
Dedupe by stream
Different settings routinely produce the identical symbol stream — about 44% of the rotation space collapses. Each stream is hashed and solved once; past a memory cap it degrades to re-solving duplicates rather than dying.
Work is parallel, state is not
Workers solve; the parent owns
the cursors, the leaderboard and the checkpoint. One writer means a kill -9 can lose the last interval,
never corrupt the state. Status and checkpoint are written to a temp file and atomically renamed into place.
The status file ticks on its own clock
A daemon thread writes it regardless of what the search is doing — found the hard way, when a long deepening pass looked from outside exactly like a dead process.
Survey cheap, then deepen
Coverage is the scarce resource, so the survey is deliberately thin and the deepening pass carries the quality. Measured letter recovery on a planted English message:
| effort | recovery |
|---|---|
| 8 × 4,000 (survey) | median 8%, spread 1–91% |
| 96 × 8,000 (deepened) | median 95%, spread 95–97% |
Effort is two numbers, not one. Restarts buy independent attempts; iterations buy a slower cooling schedule, and no number of short badly-cooled runs substitutes for one well-cooled one — dropping the survey from 6,000 to 4,000 iterations cost 19 points of deepened recovery even at 96 restarts. The deepening pass raises both, and the baseline is matched on both.
The run never ends
When the enumeration is exhausted the hunt
drops to a refine phase and goes on deepening the leaders and re-checking the verdict for as long as the
process lives. A search that stops has stopped improving.
Graceful death
SIGTERM and SIGINT checkpoint and exit
cleanly. The process writes its own pid (a launcher capturing $! from setsid nohup …
& records the wrong one whenever setsid forks), and the run script treats a status file that is still
advancing as proof of life.
The engine is verified against the browser's
The server
contains a hand port of core.js, and a hand port is exactly the code that looks right and quietly
searches a different space. lab/crosscheck.py compares the unwound streams cell by cell: 4,107
transforms, identical — every reading order, schedule, step, direction, mirror axis, ring permutation and
period. Anything the server finds can be reproduced in the browser decipher tool.
Never trust the maximum
The best of a million candidates is a large number even when nothing is there. Three corrections are applied, and every one was added because its absence produced a false positive during development.
Matched baseline
A score is compared only against controls of the same language, length, restarts and
iterations. The last two were added after the hunt reported solution for English and
German at once — the deepening pass had re-solved leaders at twelve times the effort while they were still
judged against a one-times baseline. Length is the one people forget: an n-gram score is a per-gram mean, and
shorter text is easier to fit. The nulls family halves the text — judged against 87-character controls it
swept the leaderboard at z = +3.07 on the first live run. Calibration is now built per length, on demand.
Permutation null
The winning stream is shuffled and re-solved at the same effort — same symbols, same frequencies, same length, no linguistic order. That is what the solver squeezes out of noise, and it is what best-of-N is applied to.
An easy thing to get backwards: the control texts are the alternative hypothesis (what a correct answer scores), not the null. A true solve of 87 characters lands in the middle of the control range, z ≈ 0 — not at the top. Testing against best-of-N of the controls would reject a correct answer.
Best-of-N, and Šidák on top
The null's tail is fitted with a normal, so P(max < x) = Φ((x−μ)/σ)n — an empirical resample cannot extrapolate past its own largest observation, and best-of-a-million lives well past it. Then Šidák charges for the number of languages tried: pfamily = 1 − (1 − p)L. Running three languages is three lottery tickets.
Reading the verdict — a claim needs both conditions
| verdict | meaning |
|---|---|
| solution | Above chance (beats the shuffled null after paying for every candidate and every language) and reads (inside the range real text of that language reaches, z > −2). The only verdict that is a decipherment. |
| structure, but not this language | Above chance, does not read. The solver found non-random structure and dressed it in letters — a finding about the stream, not a message. The commonest false positive in this subject. |
| in range, but not above chance | Reads, but shuffling gets there too. The fit is a property of the model, not evidence. |
| nothing | Neither. |
The instrument has to suit the message
lab/bakeoff.py asks the question that decides whether the search can work at all: at this search
size, could the model detect the message if it were there? Every candidate raises the score the winner must reach;
at 108 M candidates × 2 languages the best-of-N bar is 5.8 noise-SD. Detection power, giving each model
the same plaintext and comparing it against its own noise floor:
| if the plaintext is… | English | correspondence | bar |
|---|---|---|---|
| a personal letter | 4.7 ✗ | 8.0 ✓ | 5.8 |
| business correspondence | 4.1 ✗ | 7.7 ✓ | 5.8 |
| technical prose | 7.5 ✓ | 7.0 ✓ | 5.8 |
Generic English misses the bar for both registers Dorabella could plausibly be. The search was structurally
unable to detect the message even if it were sitting in the space — not because the space was too small, but
because the instrument was wrong for the register. Widening the search would have made this worse. So the default
target became let: a 3 M-letter corpus of period correspondence (Carroll's letters of 1898, Dickens,
George Eliot, Darwin, Austen, Keats, plus model letters, a letter-writing manual and merchant correspondence), filtered
to the sentences that read like letters, with controls held out.
The search budget beyond which nothing can be believed
lab/odds.py: for each language there is a budget N* beyond which the best coincidence outscores
a genuine solution.
| language | correct answer | noise | gap | N* |
|---|---|---|---|---|
| English | −3.994 | −5.084 | +1.09 | 1.2 × 10¹⁶ |
| German | −3.857 | −4.989 | +1.13 | 1.2 × 10¹⁶ |
| compressed English | −3.634 | −3.769 | +0.135 | 414 |
At the project's full search size — 108 M candidates × 3 languages = 326 M — the best-of-N null reaches −4.566 for English against a correct answer's −3.994. The truth still wins by 0.57, so the search is worth running and a positive English or German result would mean something. For compressed English it does not: past about 414 candidates the coincidence wins, and it charges the other languages a Šidák penalty for the privilege. That is why it is not a default target.
| language | real vs shuffled separation | power |
|---|---|---|
| English | +0.83 | strong |
| German | +0.91 | strong |
| compressed English | +0.12 | weak — treat what it produces with more suspicion, not less |
Watching for words
The hunt can be started with a list of words — ./run.sh words ELGAR,DORA,MUSIC,DEAR — and
lists every plaintext containing one, with the position and the transform. Beside the count is the number chance
alone supplies, and that is the point. A live example from testing:
hits 2 expected 4.23 ratio 0.47x over 96 candidates
Two sightings of AND — fewer than chance gives. The count only starts to mean something
when it runs well above the expected figure, and even then the candidate still has to pass the verdict. The expected
figure assumes uniform letters, so real solver output (language-shaped) produces common words rather more often: treat
it as a floor, not a forecast. A positional template is deliberately not offered here — each transform
reorders the stream, so “position 34 is D” means a different glyph for every candidate. A word
transfers; a position does not.
How long it runs
| family | candidates |
|---|---|
composite (transposition + rotating key) | 105,310,800 |
composite2, columnar2, grille, columnar | ~2.7 M |
| everything else | ~110 k |
| total | 108,080,995 |
| workers | whole space |
|---|---|
| 4 | ~2.5 years |
| 8 | ~1.3 years |
| 16 | ~0.6 years |
Measured throughput is 0.34 candidates/s per core (8 restarts, three languages, CPython; PyPy is 5–10× faster with no changes). That length is deliberate: the space is larger than any single run, the hunt never exits, and it takes one candidate from each family in turn — so a run that covers a fraction still covers every hypothesis. To finish a space instead of sampling one, drop the giant family: the remaining 2,770,195 candidates take about twelve days on eight cores. Widening the transposition catalogue (columnar width 7) multiplies the composite family by six.
Running for months
Memory is bounded by design. The only structure that grows with the run is the dedupe cache, capped at 3 M
stream hashes (roughly 200–250 MB); the leaderboard, history, log and word hits are all capped too, and the
systemd unit sets MemoryMax=4G. Disk: the status file is rewritten every 30 seconds — about
86,000 atomic writes of ~20 KB a month — and the checkpoint every 5 minutes.
Restarts cost almost nothing. Verified against a hard kill -9 mid-run, which is what an OOM kill
or a power cut looks like:
restarts recorded 1 tested 1400 (checkpoint held 512) total work 265s vs 161s this run -> prior work carried baselines reused 34 -> not rebuilt leaders survived 25 ledger survived 16 dimensions, 63 matrix cells
- A backup checkpoint. Every save moves the previous one to
.bak; a full disk or bad sector can no longer cost the whole run. - A space fingerprint. Candidates are addressed by index, so resizing a family shifts every address after it. The checkpoint carries a hash of the families, the ciphertext and the languages, and refuses to resume against a different one — otherwise it would search different candidates while reporting the old coverage, wrong in a way that looks right for months.
Restart=always. The hunt is designed never to finish, so any exit that is not a deliberate stop is something going wrong.- States have names:
starting,running,deepening,verdict,refine,stopping— a pause in throughput always has a visible reason.
The status file is written mode 0644 on purpose: mkstemp() creates 0600 and
os.replace() keeps it, so a hunt running as root would leave the file readable by root alone — and
the dashboard, running as www-data, would show nothing while the hunt worked perfectly.
The dashboard
A single page, served by a tiny stdlib server that validates the JSON before handing it over and retries once if it catches a write in progress — so the page can never render half a file. It binds to localhost and is reached through an SSH tunnel, or through nginx with TLS and basic auth; bound openly it would show the ciphertext, candidate plaintexts and recovered keys to anyone. Five tabs, in the order a person needs them:
| tab | what it shows |
|---|---|
| What is this | For somebody arriving cold: the cipher, what the program does to it in four steps, how to read a result. |
| Verdict | The answer per language with both conditions spelled out, the best candidate and the score history. |
| What has been tried | The point of the page: every setting the search varies with how many candidates used it and the best they reached, an order × schedule heatmap with untried cells hatched, and a Markdown report to copy into an issue. |
| Leaders | The top candidates, with the length and effort each was judged against (a ×6 badge marks deepened ones). |
| Detail | Progress, throughput, run metadata, the calibration table and the log. |
The connection dot is the honest one: live when the file is fresh, amber after 75 s (probably a
deepening or verdict cycle, which the state field names), red after 150 s (probably stopped), and
server unreachable when the server itself is down. The best-score chart only ever rises, because it is a
maximum over everything tried — a rising line is the search working, not evidence.
All command-line options
| option | default | what it does |
|---|---|---|
| --log | /search_log.log | JSON status, rewritten every --interval s |
| --interval | 30 | seconds between status writes |
| --checkpoint | ./checkpoint.json | resume state |
| --checkpoint-every | 300 | seconds between checkpoints |
| --workers | cores − 1 | solver processes |
| --restarts | 8 | annealing restarts per candidate per language |
| --iters | 4000 | annealing iterations per restart |
| --targets | let,de | period correspondence + German; en and sh are opt-in |
| --families | all | which families to search; list prints them with sizes and exits |
| --deepen-every | 600 | seconds between deepening cycles |
| --deepen-top | 20 | leaders eligible for deepening |
| --deepen-batch | 4 | leaders deepened per cycle |
| --deepen-mult | 12 | restart multiplier when deepening |
| --deepen-iters | 2 | iteration multiplier when deepening |
| --file-mode | 644 | octal mode for the status and checkpoint files |
| --verdict-every | 900 | seconds between permutation-null verdicts |
| --cal-samples | 16 | control texts per language per length |
| --null-samples | 16 | shuffles for the permutation null |
| --seen-cap | 3000000 | dedupe cache limit (memory bound) |
| --contains | words to watch for in the plaintext | |
| --pidfile | where the process records its own pid | |
| --reset | discard the checkpoint and start over | |
| --selftest | verify the engine and exit |
What to expect
Almost certainly: nothing. Dorabella has resisted since 1897, and the work on this site has already ruled out a regular rotating key, found no repeating key of period 2–22, and established that no substitution under any transform tested reads as English at matched effort.
The hunt is built so that if it does find something it will be possible to tell — and, more
importantly, so that it will not fool anyone when it does not. The verdict is designed to say nothing
loudly rather than solution softly. If it ever reports solution, the status file holds the
family, the exact transform, the key and the plaintext: reproduce it in the browser decipher tool first, then check
the plaintext for anachronisms — “DORABELLA” itself is one, since Elgar did not use the name before
September 1898.