<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: 이준김</title>
    <description>The latest articles on DEV Community by 이준김 (@textmachine).</description>
    <link>https://dev.to/textmachine</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4088842%2Fb4eef027-5763-407a-95d8-99ed3c865591.png</url>
      <title>DEV Community: 이준김</title>
      <link>https://dev.to/textmachine</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/textmachine"/>
    <language>en</language>
    <item>
      <title>You don't search for the key, you search for English: how cipher auto-solvers actually work</title>
      <dc:creator>이준김</dc:creator>
      <pubDate>Fri, 21 Aug 2026 21:53:08 +0000</pubDate>
      <link>https://dev.to/textmachine/you-dont-search-for-the-key-you-search-for-english-how-cipher-auto-solvers-actually-work-42lc</link>
      <guid>https://dev.to/textmachine/you-dont-search-for-the-key-you-search-for-english-how-cipher-auto-solvers-actually-work-42lc</guid>
      <description>&lt;p&gt;You've figured out &lt;em&gt;which&lt;/em&gt; cipher you're staring at — say a monoalphabetic cryptogram, or a Vigenère — but you don't have the key. No keyword, no shift, no crib. Manually, this is where people grind for hours. Automatically, a good solver recovers it in about a second. Here's how that actually works, so the tool isn't a black box.&lt;/p&gt;

&lt;p&gt;The whole game rests on one idea: &lt;strong&gt;you don't search for the key, you search for English.&lt;/strong&gt; A wrong key produces gibberish; the right key produces text that looks like a real language. So if you can &lt;em&gt;score&lt;/em&gt; how English-like a candidate decryption is, breaking the cipher becomes an optimization problem — find the key that maximizes the score. Everything below is a variation on that theme.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoring function is the secret, and single letters aren't enough
&lt;/h2&gt;

&lt;p&gt;The naive score is letter frequency: real English is ~12.7% E, ~9% T, and so on, so reward decryptions whose letter distribution matches. This is &lt;strong&gt;too weak&lt;/strong&gt;. A decryption that's 95% correct can score &lt;em&gt;as well as or better than&lt;/em&gt; the true plaintext on single-letter counts alone, because shuffling a few letters barely moves the histogram. The search then happily settles on a near-miss garble and calls it done.&lt;/p&gt;

&lt;p&gt;The fix is &lt;strong&gt;n-grams&lt;/strong&gt; — scoring &lt;em&gt;sequences&lt;/em&gt; of letters, not single ones. English is far richer in some letter-pairs and triples (TH, HE, IN, ER; THE, AND, ING) than in others (QZ, JX, VKZ). Any decoding error injects rare, low-probability pairs and triples, which a bigram or trigram score punishes hard. So the fitness function is the &lt;strong&gt;sum of log-probabilities of every trigram&lt;/strong&gt; in the candidate plaintext, using a frequency table built from a large English corpus. Truth scores strictly higher than any near-miss, which is exactly what you need to climb toward.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A useful diagnostic if you ever build one of these: if your solver lands on garbage, check whether &lt;code&gt;score(true plaintext) &amp;gt; score(found)&lt;/code&gt;. If truth scores &lt;em&gt;higher&lt;/em&gt;, your fitness function is fine and your &lt;strong&gt;search&lt;/strong&gt; is stuck — don't tune the scorer, fix the optimizer (next section). If truth scores &lt;em&gt;lower&lt;/em&gt;, the scorer itself is too weak (you're probably on single letters — go to trigrams).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Cracking a monoalphabetic substitution (cryptogram)
&lt;/h2&gt;

&lt;p&gt;A simple substitution maps each letter to another, fixed for the whole message. There are 26! ≈ 4×10²⁶ possible alphabets — brute force is hopeless. But the scoring trick makes it tractable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Seed with frequency analysis.&lt;/strong&gt; Count letters in the ciphertext; map the most common cipher letter to E, the next to T, and so on. This is usually 30–60% correct — a decent starting point, not the answer. (You can do this step by hand with a &lt;a href="https://textmachine.org/en/text-tools/frequency-analysis" rel="noopener noreferrer"&gt;frequency analysis tool&lt;/a&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Improve by local search.&lt;/strong&gt; Swap two letters in the key, re-score, keep the swap if the score went up. Repeat. This is hill-climbing — and on its own it &lt;strong&gt;gets stuck in local optima&lt;/strong&gt;: a key that's better than all its neighbors but still wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escape local optima with simulated annealing.&lt;/strong&gt; The fix is to &lt;em&gt;sometimes accept a worse swap&lt;/em&gt;, with a probability that starts high and "cools" toward zero. Early on the search roams freely and jumps out of bad valleys; late on it behaves like pure hill-climbing and locks onto the peak. Run a few random restarts and keep the best result. This reliably recovers normal English prose.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's precisely what the &lt;a href="https://textmachine.org/en/text-tools/substitution-solver" rel="noopener noreferrer"&gt;substitution cipher solver&lt;/a&gt; does — frequency-seeded, then simulated annealing on trigram fitness — and it recovers both the message &lt;em&gt;and&lt;/em&gt; the full cipher alphabet with no key or crib. Paste a cryptogram and it solves in well under a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cracking a Vigenère without the keyword
&lt;/h2&gt;

&lt;p&gt;Vigenère uses a repeating keyword, so it's polyalphabetic — letter frequencies are smeared flat and the substitution trick above doesn't directly apply. You break it in two stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find the key &lt;em&gt;length&lt;/em&gt;.&lt;/strong&gt; Two classic methods. &lt;a href="https://textmachine.org/en/text-tools/kasiski-examination" rel="noopener noreferrer"&gt;&lt;strong&gt;Kasiski examination&lt;/strong&gt;&lt;/a&gt; looks for repeated sequences in the ciphertext and measures the distances between them — those distances tend to be multiples of the key length. The &lt;a href="https://textmachine.org/en/text-tools/index-of-coincidence-calculator" rel="noopener noreferrer"&gt;&lt;strong&gt;Index of Coincidence&lt;/strong&gt;&lt;/a&gt; approach tries each candidate length and watches for the one where the slices look like natural (peaky) English. Run both, because they fail in different ways: Kasiski needs repeats, which short or repetitive text may not supply, while the IoC needs enough letters per column to be stable. When the two agree you almost certainly have the right length; when they disagree you do not have it yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Solve each column independently.&lt;/strong&gt; Once you know the key length L, every L-th letter was enciphered with the &lt;em&gt;same&lt;/em&gt; shift — so the ciphertext splits into L columns, and &lt;strong&gt;each column is just a Caesar cipher.&lt;/strong&gt; Solve each one by frequency / chi-squared against English (only 26 shifts per column), and you've recovered the keyword letter by letter.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The robust way to drive this — and what the &lt;a href="https://textmachine.org/en/text-tools/vigenere-solver" rel="noopener noreferrer"&gt;Vigenère solver&lt;/a&gt; does — is to &lt;strong&gt;solve at every plausible key length, then rank the resulting decryptions by English fitness&lt;/strong&gt; and present the best, rather than committing to a single length guessed from a threshold (which fails on repetitive plaintext). A monoalphabetic message naturally collapses to a one-letter key, so the same tool degrades gracefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  When automatic solving struggles
&lt;/h2&gt;

&lt;p&gt;Auto-solvers are statistical, so they need enough text to be confident:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Too short.&lt;/strong&gt; Under ~40–50 letters there often isn't enough signal; the trigram statistics are noisy. Get more ciphertext if you can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not English.&lt;/strong&gt; The fitness table is language-specific. A French or German plaintext needs a French/German n-gram model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homophones, nulls, or padding.&lt;/strong&gt; Homophonic substitution (several cipher symbols per plaintext letter) and inserted null characters break the one-to-one assumption — identify and strip those first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's not actually a simple substitution/Vigenère.&lt;/strong&gt; If the solver can't find anything English-like at any setting, re-check the cipher type — start again with the &lt;a href="https://textmachine.org/en/text-tools/cipher-identifier" rel="noopener noreferrer"&gt;cipher identifier&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two-minute version
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identify&lt;/strong&gt; the cipher (character set, IoC, structure) — or confirm it's a cryptogram / Vigenère.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paste it into the matching auto-solver&lt;/strong&gt; — &lt;a href="https://textmachine.org/en/text-tools/substitution-solver" rel="noopener noreferrer"&gt;substitution&lt;/a&gt; for cryptograms, &lt;a href="https://textmachine.org/en/text-tools/vigenere-solver" rel="noopener noreferrer"&gt;Vigenère&lt;/a&gt; for keyword ciphers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read off the plaintext and the recovered key.&lt;/strong&gt; If it stalls, check the message length and language, and re-confirm the cipher type.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No key, no problem — the statistics of English do the work for you. All of these run entirely in your browser; nothing you paste is uploaded.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>algorithms</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I shipped a Kasiski calculator with 21 passing tests. It got 3 of 5 key lengths wrong.</title>
      <dc:creator>이준김</dc:creator>
      <pubDate>Fri, 21 Aug 2026 21:33:46 +0000</pubDate>
      <link>https://dev.to/textmachine/i-shipped-a-kasiski-calculator-with-21-passing-tests-it-got-3-of-5-key-lengths-wrong-123l</link>
      <guid>https://dev.to/textmachine/i-shipped-a-kasiski-calculator-with-21-passing-tests-it-got-3-of-5-key-lengths-wrong-123l</guid>
      <description>&lt;p&gt;Kasiski examination is the oldest way to break a Vigenère cipher. Find sequences&lt;br&gt;
that repeat in the ciphertext, measure the gaps between them, and factor the&lt;br&gt;
gaps. Because the key repeats on a fixed cycle, a repeat that comes from the&lt;br&gt;
&lt;em&gt;same plaintext encrypted at the same key offset&lt;/em&gt; sits at a distance that is a&lt;br&gt;
multiple of the key length. Factor enough of those distances and the key length&lt;br&gt;
should fall out.&lt;/p&gt;

&lt;p&gt;The standard procedure, in every textbook I have read and every implementation I&lt;br&gt;
have written, ends like this: &lt;strong&gt;tally how many distances each candidate factor&lt;br&gt;
divides, and take the factor with the biggest tally.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That last step is wrong. Not subtly wrong on adversarial input — wrong on&lt;br&gt;
ordinary English prose, most of the time, in a way that a unit test suite will&lt;br&gt;
cheerfully certify as correct.&lt;/p&gt;
&lt;h2&gt;
  
  
  The tests were green and the tool was broken
&lt;/h2&gt;

&lt;p&gt;My implementation had 21 unit tests. All passing. Then I ran it on five&lt;br&gt;
realistic ciphertexts, enciphered with five realistic keys, and read the number&lt;br&gt;
the page prints in the largest font:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;True key length&lt;/th&gt;
&lt;th&gt;What it answered&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5 ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3&lt;/strong&gt; ❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;7 ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3&lt;/strong&gt; ❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2&lt;/strong&gt; ❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of five. And look at the failures: 3 divides 6, 3 divides 9, 2 divides&lt;br&gt;
13... no it does not. 2 divides &lt;em&gt;nothing&lt;/em&gt; about 13. It just wins anyway.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the fixtures lied
&lt;/h2&gt;

&lt;p&gt;Every fixture in those 21 tests was a hand-built string, and I built each one so&lt;br&gt;
that its repeat distances were clean multiples of the key length. That is what&lt;br&gt;
the method is &lt;em&gt;supposed&lt;/em&gt; to produce, so it felt like the honest thing to test.&lt;/p&gt;

&lt;p&gt;It is not. On real text, most repeated trigrams are coincidences. &lt;code&gt;THE&lt;/code&gt; appears&lt;br&gt;
everywhere; two occurrences of &lt;code&gt;THE&lt;/code&gt; at unrelated key offsets encipher&lt;br&gt;
differently, but plenty of other short sequences collide by accident and&lt;br&gt;
contribute a distance that carries no information about the key at all.&lt;/p&gt;

&lt;p&gt;A hand-built fixture has &lt;strong&gt;no coincidental repeats&lt;/strong&gt;. So it never exercises the&lt;br&gt;
one thing the ranking step exists to survive.&lt;/p&gt;
&lt;h2&gt;
  
  
  What each factor scores for nothing
&lt;/h2&gt;

&lt;p&gt;Here is the arithmetic the tally step skips.&lt;/p&gt;

&lt;p&gt;Take a set of distances with no signal in it whatsoever — pure noise. How many&lt;br&gt;
does the factor 2 divide? &lt;strong&gt;Half of them.&lt;/strong&gt; How many does 3 divide? &lt;strong&gt;A third.&lt;/strong&gt;&lt;br&gt;
How many does 13 divide? One in thirteen.&lt;/p&gt;

&lt;p&gt;So the raw tally is not a measurement of evidence. It is a measurement of&lt;br&gt;
evidence &lt;em&gt;plus&lt;/em&gt; a free head start that shrinks as the factor grows. Ranking&lt;br&gt;
small factors against large ones on that number is like ranking sprinters&lt;br&gt;
against marathoners by how long they were on the track.&lt;/p&gt;

&lt;p&gt;The fix is to divide the head start out. Call it &lt;strong&gt;lift&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lift(f) = f × (distances divisible by f) / (total distances)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lift = 1.0&lt;/code&gt; means "exactly what chance would give you". &lt;code&gt;lift = 3.2&lt;/code&gt; means&lt;br&gt;
"3.2× more than chance". Now every candidate is on the same scale.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lift fixes half of it, and creates the other half
&lt;/h2&gt;

&lt;p&gt;Let &lt;code&gt;D&lt;/code&gt; be the total number of distances and &lt;code&gt;p&lt;/code&gt; the fraction of them that are&lt;br&gt;
genuine — actual multiples of the true key length &lt;code&gt;L&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For a proper divisor &lt;code&gt;d&lt;/code&gt; of &lt;code&gt;L&lt;/code&gt;:&lt;/strong&gt; every genuine distance is divisible by &lt;code&gt;d&lt;/code&gt;&lt;br&gt;
(since &lt;code&gt;d | L | kL&lt;/code&gt;), and the noise contributes &lt;code&gt;1/d&lt;/code&gt; of the rest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hits/D  = p + (1−p)/d
lift(d) = d × (p + (1−p)/d) = p·d + (1−p)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since &lt;code&gt;d &amp;lt; L&lt;/code&gt;, that is strictly less than &lt;code&gt;lift(L) = p·L + (1−p)&lt;/code&gt;. Divisors&lt;br&gt;
solved. Lift demotes them automatically, with no special case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For a multiple &lt;code&gt;kL&lt;/code&gt;:&lt;/strong&gt; a genuine distance &lt;code&gt;mL&lt;/code&gt; is divisible by &lt;code&gt;kL&lt;/code&gt; only when&lt;br&gt;
&lt;code&gt;k | m&lt;/code&gt;, so roughly &lt;code&gt;p/k&lt;/code&gt; of them survive, and the noise contributes&lt;br&gt;
&lt;code&gt;(1−p)/(kL)&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hits/D    = p/k + (1−p)/(kL)
lift(kL)  = kL × (p/k + (1−p)/(kL)) = p·L + (1−p)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is &lt;code&gt;lift(L)&lt;/code&gt;. &lt;strong&gt;Exactly.&lt;/strong&gt; Not approximately, not usually — the multiples&lt;br&gt;
of the true key length tie with the true key length on lift, forever, at every&lt;br&gt;
sample size. Lift cannot break that tie, because there is nothing left in it to&lt;br&gt;
break the tie &lt;em&gt;with&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The tie-breaker is the noise, not the signal
&lt;/h2&gt;

&lt;p&gt;The two candidates have the same expected lift. What differs is how far each is&lt;br&gt;
&lt;em&gt;entitled&lt;/em&gt; to wander on its own.&lt;/p&gt;

&lt;p&gt;A factor &lt;code&gt;f&lt;/code&gt; divides a random distance with probability &lt;code&gt;1/f&lt;/code&gt;. Over &lt;code&gt;D&lt;/code&gt;&lt;br&gt;
distances that is a binomial, and after scaling by &lt;code&gt;f&lt;/code&gt; the standard deviation of&lt;br&gt;
the lift works out to &lt;code&gt;sqrt((f−1)/D)&lt;/code&gt;. Larger factors are noisier — of course&lt;br&gt;
they are; they are estimating a rarer event from the same sample.&lt;/p&gt;

&lt;p&gt;So measure each candidate in units of its own noise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;z(f) = (lift(f) − 1) / sqrt((f − 1) / D)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Substitute the two cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;z(L)  = p·(L−1) / sqrt((L−1)/D)  = p·sqrt((L−1)·D)
z(kL) = p·(L−1) / sqrt((kL−1)/D) &amp;lt; z(L)      because kL − 1 &amp;gt; L − 1
z(d)  = p·(d−1) / sqrt((d−1)/D)  = p·sqrt((d−1)·D) &amp;lt; z(L)   because d &amp;lt; L
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;z(L)&lt;/code&gt; is strictly the maximum.&lt;/strong&gt; Divisors lose because their lift is smaller;&lt;br&gt;
multiples lose because their noise is larger. One formula, both failure&lt;br&gt;
directions, and — the part I care about most — &lt;strong&gt;no tuned constants.&lt;/strong&gt; The&lt;br&gt;
previous version of this code had a hand-picked threshold and a "skip obvious&lt;br&gt;
artefacts" list. Both are now gone, because the arithmetic does their job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Same engine, same fixtures, sweeping every key length from 2 to 16 on ordinary&lt;br&gt;
prose:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sample size&lt;/th&gt;
&lt;th&gt;Exact hits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;~900 letters&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15 / 15&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~330 letters&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13 / 15&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two misses at 330 letters are the long keys, which is honest: a 15-letter&lt;br&gt;
key over 330 letters gives you 22 cycles, and there is genuinely not enough&lt;br&gt;
evidence there. The tool now says so instead of confidently printing 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one bug I would have shipped anyway
&lt;/h2&gt;

&lt;p&gt;There was a second defect underneath, and it is worth naming because it is a&lt;br&gt;
&lt;em&gt;shape&lt;/em&gt;, not a fact about ciphers.&lt;/p&gt;

&lt;p&gt;The old code ranked first, then walked the ranked list skipping artefacts. That&lt;br&gt;
loop only ever demotes an artefact that appears &lt;strong&gt;after&lt;/strong&gt; the real answer. So&lt;br&gt;
whichever artefact happened to outrank the real answer got taken first, and the&lt;br&gt;
rule that existed to catch exactly that never ran.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reduce the field before you sort it, never during.&lt;/strong&gt; I have now found this same&lt;br&gt;
inverted-order bug in two separate tools in the same codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things I would tell past me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Before ranking by a count, ask what each candidate scores for nothing, and
divide it out.&lt;/strong&gt; A leaderboard over quantities with different baselines is
not a leaderboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then ask how far that corrected figure wanders on its own, and divide that
out too.&lt;/strong&gt; Half the tie-breaks live in the variance, not the mean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hand-built fixture cannot test a statistic.&lt;/strong&gt; Sweep the whole parameter
range on real input. The arithmetic still deserves its unit test — just do
not mistake that for a test of the statistic.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Both tools run entirely in the browser, no upload, no account:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://textmachine.org/en/text-tools/kasiski-examination" rel="noopener noreferrer"&gt;Kasiski examination&lt;/a&gt;
— shows the lift and the z-score for every factor, so you can see the ranking
rather than trust it.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://textmachine.org/en/text-tools/index-of-coincidence-calculator" rel="noopener noreferrer"&gt;Index of coincidence calculator&lt;/a&gt;
— the other route to key length, and a good cross-check. If Kasiski and the IC
disagree, you have not found the key length yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Paste a Vigenère ciphertext into both. If they agree, split the text into that&lt;br&gt;
many columns and each column is a plain Caesar shift, which&lt;br&gt;
&lt;a href="https://textmachine.org/en/text-tools/frequency-analysis" rel="noopener noreferrer"&gt;frequency analysis&lt;/a&gt;&lt;br&gt;
finishes in a few seconds.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>testing</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
