File size: 6,805 Bytes
00a77fa 77ec8ec 00a77fa 77ec8ec 00a77fa 77ec8ec 00a77fa 77ec8ec 00a77fa 77ec8ec 00a77fa 77ec8ec 00a77fa 77ec8ec 00a77fa 77ec8ec 00a77fa 77ec8ec 00a77fa 77ec8ec | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 | """End-to-end benchmark of the auto-solvers: given ciphertext of a known type,
how often does each solver actually recover the plaintext?
Usage:
python scripts/benchmark_solvers.py [--trials 30] [--seed 42]
"""
from __future__ import annotations
import argparse
import random
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
sys.stdout.reconfigure(encoding="utf-8")
from core import (
ALPHABET,
affine_encrypt,
beaufort_auto_solve,
beaufort_decrypt,
best_affine_candidates,
best_caesar_candidates,
best_rail_fence_candidates,
caesar_encrypt,
clean_letters,
columnar_auto_solve,
columnar_transposition_encrypt,
hill_climb_substitution,
rail_fence_encrypt,
substitution_encrypt,
vigenere_auto_solve,
vigenere_encrypt,
)
SENTENCES = [
"THE HISTORY OF CRYPTOGRAPHY IS FULL OF CLEVER IDEAS AND SPECTACULAR FAILURES THAT SHAPED MODERN SECURITY",
"MEET ME AT THE OLD LIBRARY AFTER MIDNIGHT AND BRING THE DOCUMENTS WITH YOU",
"THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG WHILE THE FARMER WATCHES FROM THE BARN",
"GENERAL ORDERS REQUIRE ALL UNITS TO ADVANCE AT DAWN TOWARD THE EASTERN BRIDGE",
"SCIENCE ADVANCES ONE CAREFUL EXPERIMENT AT A TIME AND EVERY FAILURE TEACHES SOMETHING NEW",
"THE MUSEUM EXHIBIT OPENS NEXT WEEK AND FEATURES CIPHER MACHINES FROM THE SECOND WORLD WAR",
"PLEASE DELIVER THE PACKAGE TO THE HARBOR MASTER BEFORE THE EVENING TIDE ARRIVES",
"KNOWLEDGE OF LETTER FREQUENCIES ALLOWS ANALYSTS TO BREAK SIMPLE SUBSTITUTION QUICKLY",
]
# Deliberately NOT in core.COMMON_KEYWORDS, so the Vigenère/Beaufort numbers
# measure the statistical attack itself, not dictionary lookups. (The keyword
# dictionary is a genuine help on common keys, but benchmarking against keys it
# already contains would be measuring the test, not the solver.)
KEYS = ["MARBLE", "PENCIL", "GARDEN", "PLANET", "COPPER", "WALNUT"]
def truncate(s: str, n: int) -> str:
return clean_letters(s)[:n]
def bench(trials: int, seed: int) -> dict:
rng = random.Random(seed)
lengths = [40, 80, 160, 300]
results: dict[str, dict[int, list[int]]] = {}
def record(solver: str, n: int, ok: bool):
results.setdefault(solver, {}).setdefault(n, []).append(int(ok))
for _ in range(trials):
# Diverse natural text: concatenated distinct sentences. (Repeating
# one sentence to reach the target length produces pathological n-gram
# statistics that no frequency-based method should be judged on.)
pool = SENTENCES[:]
rng.shuffle(pool)
sent = " ".join(pool)
for n in lengths:
pt = truncate(sent, n)
shift = rng.randrange(1, 26)
ct = caesar_encrypt(pt, shift)
cands = best_caesar_candidates(ct, 1)
record("caesar_auto", n, bool(cands) and cands[0][3] == pt)
a = rng.choice([3, 5, 7, 9, 11, 15, 17, 19, 21, 23, 25])
b = rng.randrange(26)
ct = affine_encrypt(pt, a, b)
acands = best_affine_candidates(ct, 1)
record("affine_auto", n, bool(acands) and acands[0][4] == pt)
key = rng.choice(KEYS)
ct = vigenere_encrypt(pt, key)
sols = vigenere_auto_solve(ct)
got = clean_letters(sols[0][1]) if sols else ""
record("vigenere_auto", n, got == pt)
key = rng.choice(KEYS)
ct = beaufort_decrypt(pt, key) # Beaufort encrypt == decrypt
sols = beaufort_auto_solve(ct)
got = clean_letters(sols[0][1]) if sols else ""
record("beaufort_auto", n, got == pt)
key = rng.choice(KEYS)
ct = columnar_transposition_encrypt(pt, key)
sols = columnar_auto_solve(ct)
padded = pt + "X" * ((-len(pt)) % len(key))
got = clean_letters(sols[0][1]) if sols else ""
record("columnar_auto", n, got == padded)
rails = rng.randrange(2, 8)
ct = rail_fence_encrypt(pt, rails)
rcands = best_rail_fence_candidates(ct, max_rails=10)
record("railfence_auto", n, bool(rcands) and rcands[0][1] == pt)
if n >= 160: # hill climb needs length; only bench realistic sizes
mapping = list(ALPHABET)
rng.shuffle(mapping)
ct = substitution_encrypt(pt, "".join(mapping))
decoded, _key, _score = hill_climb_substitution(ct)
decoded = clean_letters(decoded)
# Success = ≥ 90% of letters correct (hill climb converges close)
correct = sum(p == q for p, q in zip(decoded, pt, strict=False)) / len(pt)
record("substitution_hillclimb", n, correct >= 0.90)
print(f"{'solver':24s}" + "".join(f" n={n:<5d}" for n in lengths))
rates: dict[tuple[str, int], float] = {}
for solver, by_n in results.items():
row = f"{solver:24s}"
for n in lengths:
if n in by_n:
vals = by_n[n]
rate = sum(vals) / len(vals)
rates[(solver, n)] = rate
row += f" {100*rate:5.1f}%"
else:
row += " —"
print(row)
return rates
# Minimum success rates the solvers must clear at the given length. Set well
# below observed performance so normal variance doesn't cause false failures,
# but high enough to catch a real regression (e.g. a solver dropping to 0%).
_GATE = {
("caesar_auto", 80): 0.90,
("affine_auto", 160): 0.90,
("vigenere_auto", 160): 0.80,
("beaufort_auto", 160): 0.80,
("columnar_auto", 160): 0.50,
("railfence_auto", 160): 0.90,
("substitution_hillclimb", 300): 0.70,
}
def assert_gate(rates: dict[tuple[str, int], float]) -> None:
failures = []
for (solver, n), floor in _GATE.items():
got = rates.get((solver, n))
if got is None or got < floor:
failures.append(f"{solver}@{n}: {got if got is not None else 'missing'} < {floor}")
if failures:
print("SOLVER REGRESSION GATE FAILED:")
for f in failures:
print(f" {f}")
sys.exit(1)
print("solver regression gate: PASS")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--trials", type=int, default=30)
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--assert-gate", action="store_true",
help="Exit non-zero if any solver falls below its minimum success rate.")
args = ap.parse_args()
rates = bench(args.trials, args.seed)
if args.assert_gate:
assert_gate(rates)
|