[09] The long hunt
[09] · INVESTIGATION

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.

families15cipher hypotheses
candidates108M108,080,995
targetsletters + GermanEnglish, shorthand opt-in
throughput0.34candidates / s / core
whole space~2.5 yon 4 cores
engine check4,107transforms = browser

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.

familywhat it iswhy it is plausible in 1897
orderthe seven ways of taking the glyphs off the page: forwards, reversed, each line reversed, boustrophedon, lines 3-2-1, by columns, spiralcosts nothing to do and nothing to undo
snowflakea 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 periodstrivial with a paper disc, and the 3 rings × 8 directions symbol set is a clock face
ringspinthe three rings turn at different rates — one notch inner, three outerwhat concentric rings are for; Alberti's disc had two and they moved independently
keyspinthe amount of turn cycles through a keyword written as numbersa Vigenère on the disc — the most likely thing a literate amateur would do
autokeythe turn is dictated by the symbol just decodedself-keying: no written key to lose or be found, and trivial by hand
railfencezigzag over 2–12 rails, read row by rowa schoolboy cipher, in every puzzle book
columnarwrite 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
columnar2the same twice with different keys — widths 2–6 on each side (760,384 pairs)the standard way of making a columnar hard
routewrite into a rectangle, read along a spiral or diagonalused in the American Civil War, well known by 1897
grilleturning grille (Fleissner) — a card with holes, turned four times; sizes 10 and 12, 250,000 cards eachin print since 1881, a parlour favourite
decimatetake every n-th symbol, wrappingwhat a grille does, without the card
gridwrite into a rectangle and read it out — 87 = 3 × 29 exactlythe first thing anyone would try with a three-line note
nullssome marks carry nothing: drop every k-th, or a head or tailthe oldest trick there is
compositea transposition followed by a rotating keythe one combination a hand-worker could manage, and the one a solver cannot undo in either order alone
composite2a transposition applied twice with different rulescheap 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

01

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.

02

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.

03

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.

04

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.

05

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.

06

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:

effortrecovery
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.

07

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.

08

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.

09

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

verdictmeaning
solutionAbove 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 languageAbove 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 chanceReads, but shuffling gets there too. The fit is a property of the model, not evidence.
nothingNeither.

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…Englishcorrespondencebar
a personal letter4.7 ✗8.0 ✓5.8
business correspondence4.1 ✗7.7 ✓5.8
technical prose7.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.

languagecorrect answernoisegapN*
English−3.994−5.084+1.091.2 × 10¹⁶
German−3.857−4.989+1.131.2 × 10¹⁶
compressed English−3.634−3.769+0.135414

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.

languagereal vs shuffled separationpower
English+0.83strong
German+0.91strong
compressed English+0.12weak — 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

familycandidates
composite (transposition + rotating key)105,310,800
composite2, columnar2, grille, columnar~2.7 M
everything else~110 k
total108,080,995
workerswhole 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:

kill -9 then restart
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:

tabwhat it shows
What is thisFor somebody arriving cold: the cipher, what the program does to it in four steps, how to read a result.
VerdictThe answer per language with both conditions spelled out, the best candidate and the score history.
What has been triedThe 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.
LeadersThe top candidates, with the length and effort each was judged against (a ×6 badge marks deepened ones).
DetailProgress, 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
optiondefaultwhat it does
--log/search_log.logJSON status, rewritten every --interval s
--interval30seconds between status writes
--checkpoint./checkpoint.jsonresume state
--checkpoint-every300seconds between checkpoints
--workerscores − 1solver processes
--restarts8annealing restarts per candidate per language
--iters4000annealing iterations per restart
--targetslet,deperiod correspondence + German; en and sh are opt-in
--familiesallwhich families to search; list prints them with sizes and exits
--deepen-every600seconds between deepening cycles
--deepen-top20leaders eligible for deepening
--deepen-batch4leaders deepened per cycle
--deepen-mult12restart multiplier when deepening
--deepen-iters2iteration multiplier when deepening
--file-mode644octal mode for the status and checkpoint files
--verdict-every900seconds between permutation-null verdicts
--cal-samples16control texts per language per length
--null-samples16shuffles for the permutation null
--seen-cap3000000dedupe cache limit (memory bound)
--containswords to watch for in the plaintext
--pidfilewhere the process records its own pid
--resetdiscard the checkpoint and start over
--selftestverify 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.