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)