kmsp42.com
Proof of Function
DOWNLOAD vernier.py

Complete Python 3 implementation of the five-stave Vernier cipher system. Run with --demo for an automatic walkthrough. Press F in the menu to generate a printable field card with encoding tables, codebook, mod-10 tables, and pre-keyed pad sheets with worksheets. No dependencies beyond the standard library.

[ VIEW SOURCE // 1673 LINES ]

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Vernier Field Cipher System
kmsp42.com // rev 2.0

Interactive encoder/decoder implementing the five-stave hybrid OTP system.
TRNG from /dev/random mixed with timing jitter and user-sourced entropy.

Usage: python3 vernier.py
"""

import os
import sys
import time
import hashlib
import struct
import platform
import threading
from typing import List, Dict, Tuple, Optional

# ═══════════════════════════════════════════════════════════════════════════
# TERMINAL / COLOR
# ═══════════════════════════════════════════════════════════════════════════

USE_COLOR = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()

# Enable ANSI on Windows if possible
if platform.system() == 'Windows':
    try:
        import ctypes
        kernel = ctypes.windll.kernel32
        kernel.SetConsoleMode(kernel.GetStdHandle(-11), 7)
        USE_COLOR = True
    except Exception:
        USE_COLOR = False

def _c(code: str, text: str) -> str:
    return f'\033[{code}m{text}\033[0m' if USE_COLOR else text

def gold(t):    return _c('38;5;179', t)
def dim(t):     return _c('2',         t)
def bright(t):  return _c('1',         t)
def orange(t):  return _c('38;5;208',  t)
def blue(t):    return _c('38;5;75',   t)
def green(t):   return _c('38;5;77',   t)
def red(t):     return _c('38;5;196',  t)
def purple(t):  return _c('38;5;141',  t)
def gray(t):    return _c('38;5;240',  t)
def cyan(t):    return _c('38;5;87',   t)

# ═══════════════════════════════════════════════════════════════════════════
# Vernier ENCODING TABLES
# ═══════════════════════════════════════════════════════════════════════════

# Primary tier: 8 most frequent English letters -- single digit codes 0-7
PRIMARY: Dict[str, int] = {
    'E': 0, 'T': 1, 'A': 2, 'O': 3,
    'I': 4, 'N': 5, 'S': 6, 'H': 7,
}

# Secondary tier: next 10 letters -- two digit codes 80-89 (prefix 8)
SECONDARY: Dict[str, int] = {
    'R': 80, 'D': 81, 'L': 82, 'C': 83, 'U': 84,
    'M': 85, 'W': 86, 'F': 87, 'G': 88, 'Y': 89,
}

# Tertiary tier: remaining rare letters + period -- two digit codes 90-98 (prefix 9, second 0-8)
TERTIARY: Dict[str, int] = {
    'P': 90, 'B': 91, 'V': 92, 'K': 93, 'J': 94,
    'X': 95, 'Q': 96, 'Z': 97, '.': 98,
}

# Numerals: digit d -> [9, 9, d] (prefix 99, third digit is numeral)
# Spaces are stripped before encoding -- no code needed

# Reverse lookup tables
_REV_PRIMARY   = {v: k for k, v in PRIMARY.items()}
_REV_SECONDARY = {v: k for k, v in SECONDARY.items()}
_REV_TERTIARY  = {v: k for k, v in TERTIARY.items()}

# All encodable chars
ALL_ENCODABLE = set(PRIMARY) | set(SECONDARY) | set(TERTIARY) | set('0123456789. ')

# ZM sequence in digits (end-of-message marker)
# Z=97=[9,7], M=85=[8,5]  -->  [9,7,8,5,3,1]
ZM_DIGITS = [9, 7, 8, 5, 3, 1]  # 6-digit marker (1-in-1M false match)

# ═══════════════════════════════════════════════════════════════════════════
# CODEBOOK
# ═══════════════════════════════════════════════════════════════════════════

# Full codebook (all 26 codes — backward compatible)
CODEBOOK: Dict[str, str] = {
    'ZA': 'CONFIRMED',
    'ZB': 'ABORT',
    'ZC': 'PROCEED',
    'ZD': 'DANGER',
    'ZE': 'DELAY',
    'ZF': 'COMPLETE',
    'ZG': 'CONFIRM LAST',
    'ZH': 'EMERGENCY',
    'ZI': 'NOTHING TO REPORT',
    'ZJ': 'AS PLANNED',
    'ZK': 'PRIMARY LOCATION',
    'ZL': 'ALTERNATE LOCATION',
    'ZM': 'END OF MESSAGE',       # RESERVED -- never reassign
    'ZN': 'MEET',
    'ZO': 'TOMORROW',
    'ZP': 'TODAY',
    'ZQ': 'LOCATION FOLLOWS',
    'ZR': 'TIME FOLLOWS',
    'ZS': 'SWITCH TO ALTERNATE',
    'ZT': 'DEAD DROP',
    'ZU': 'SURVEILLANCE DETECTED',
    'ZV': 'CLEAR',
    'ZW': 'RECEIVED AND UNDERSTOOD',
    'ZX': 'DOCUMENT FOLLOWS',
    'ZY': 'COORDINATES FOLLOW',
    'ZZ': 'NAME FOLLOWS',
}

# ── FIELD ESSENTIAL: 10 codes to memorize. The rest are on the card. ──
FIELD_ESSENTIAL = {
    'ZA': 'CONFIRMED',
    'ZB': 'ABORT',
    'ZD': 'DANGER',
    'ZH': 'EMERGENCY',
    'ZI': 'NOTHING TO REPORT',
    'ZM': 'END OF MESSAGE',
    'ZN': 'MEET',
    'ZO': 'TOMORROW',
    'ZP': 'TODAY',
    'ZW': 'RECEIVED AND UNDERSTOOD',
}

# Reverse: phrase -> code (for suggestion matching)
_CB_REVERSE: Dict[str, str] = {v: k for k, v in CODEBOOK.items() if k != 'ZM'}

# Routine circuit codes -- most useful to memorize
ROUTINE_CODES = {'ZI', 'ZW', 'ZA'}

# ═══════════════════════════════════════════════════════════════════════════
# KEYWORD DIGIT TABLE — print on pad sheet, one glance per letter
# ═══════════════════════════════════════════════════════════════════════════

KEYWORD_TABLE: Dict[str, int] = {chr(i): (i - ord('A') + 1) % 10 for i in range(ord('A'), ord('Z') + 1)}
# A=1 B=2 C=3 D=4 E=5 F=6 G=7 H=8 I=9 J=0 K=1 L=2 M=3 N=4 O=5 P=6 Q=7 R=8 S=9 T=0 U=1 V=2 W=3 X=4 Y=5 Z=6

# ═══════════════════════════════════════════════════════════════════════════
# BLOCK CLASSES
# ═══════════════════════════════════════════════════════════════════════════

BLOCK_CLASSES: Dict[str, Dict] = {
    'S': {'prefix': [0, 0], 'size': 50,  'groups': 10, 'max_content': 44},
    'M': {'prefix': [0, 1], 'size': 100, 'groups': 20, 'max_content': 94},
    'L': {'prefix': [0, 2], 'size': 200, 'groups': 40, 'max_content': 194},
}

def select_block_class(content_digit_count: int) -> str:
    """Select block class. Default to M (100 digits) — one fewer decision under stress.
    Only use S for very short messages, L only when M overflows."""
    if content_digit_count <= 94:
        return 'M'    # Default: 100 digits. Simpler than choosing.
    elif content_digit_count <= 194:
        return 'L'
    return 'SPLIT'

# ═══════════════════════════════════════════════════════════════════════════
# TRNG -- TRUE RANDOM NUMBER GENERATION
# ═══════════════════════════════════════════════════════════════════════════

class EntropyPool:
    """
    Collects entropy from multiple sources.

    Sources (combined via SHA-256):
      /dev/random  -- Linux hardware entropy pool (blocks until sufficient)
      /dev/urandom -- macOS CSPRNG (cryptographically equivalent on modern kernels)
      timing jitter -- nanosecond deltas between user events
      os.urandom   -- OS CSPRNG as mixing baseline
    """

    def __init__(self):
        self._pool: bytearray = bytearray()
        self._lock = threading.Lock()
        self._last_ns: int = time.time_ns()
        self.events: int = 0
        self.dev_bytes: int = 0

    def add_bytes(self, data: bytes) -> None:
        with self._lock:
            self._pool.extend(data)

    def add_timing(self) -> None:
        """Record a timing event. Each call adds 8 bytes of jitter entropy."""
        now = time.time_ns()
        with self._lock:
            delta = now - self._last_ns
            # Pack nanosecond delta + current timestamp for maximum jitter capture
            self._pool.extend(struct.pack('<QQ', delta, now))
            self._last_ns = now
            self.events += 1

    def size(self) -> int:
        with self._lock:
            return len(self._pool)

    def derive(self, n_bytes: int) -> bytes:
        """
        Derive n_bytes from the pool using a SHA-256 chain.
        Output is deterministically derived from the pool contents.
        Pool must contain genuine entropy for output to be secure.
        """
        with self._lock:
            seed = bytes(self._pool)

        # Final mix: hash the pool with os.urandom for defense-in-depth
        seed = hashlib.sha256(seed + os.urandom(32)).digest()

        result = b''
        ctr = 0
        while len(result) < n_bytes:
            block = hashlib.sha256(seed + struct.pack('<I', ctr)).digest()
            result += block
            ctr += 1
        return result[:n_bytes]


def _dev_random_reader(pool: EntropyPool, target: int, stop: threading.Event) -> None:
    """Background thread: drain /dev/random into the entropy pool."""
    try:
        # /dev/random on Linux blocks until the kernel entropy pool has enough bits.
        # On macOS, /dev/urandom is the correct choice (equivalent quality).
        # On Windows, fall through to os.urandom below.
        if platform.system() == 'Linux':
            dev_path = '/dev/random'
        elif platform.system() == 'Darwin':
            dev_path = '/dev/urandom'
        else:
            raise OSError('No device path on this platform')

        collected = 0
        with open(dev_path, 'rb') as f:
            while collected < target and not stop.is_set():
                want = min(32, target - collected)
                chunk = f.read(want)
                if chunk:
                    pool.add_bytes(chunk)
                    pool.dev_bytes += len(chunk)
                    collected += len(chunk)

    except OSError:
        pass
    finally:
        # Always supplement with os.urandom regardless of device availability
        pool.add_bytes(os.urandom(target))
        stop.set()


def bytes_to_uniform_digits(raw: bytes) -> List[int]:
    """
    Convert raw bytes to uniformly distributed digits 0-9.

    Uses rejection sampling: accept byte b only if b < 250.
    250 = 25 x 10, so b % 10 is exactly uniform for b in 0-249.
    Rejection rate: 6/256 = ~2.3%.
    """
    return [b % 10 for b in raw if b < 250]


def collect_entropy(n_digits: int, silent: bool = False) -> List[int]:
    """
    Collect n_digits of cryptographically random digits (0-9).

    Blocks until sufficient entropy is gathered from all sources.
    Interactive: prompts user to type/move mouse for timing entropy.
    """
    pool = EntropyPool()
    stop_event = threading.Event()

    # We need ~1.025x bytes to account for rejection sampling
    target_bytes = int(n_digits * 1.05) + 96

    # Start /dev/random reader in background
    reader = threading.Thread(
        target=_dev_random_reader,
        args=(pool, target_bytes, stop_event),
        daemon=True
    )
    reader.start()

    if not silent:
        _rule()
        print(f'\n  {gold("// ENTROPY COLLECTION")}')
        _rule()
        print(f'\n  Generating {orange(str(n_digits))} random digits.')
        print(f'\n  {dim("Sources:")}')
        dev = '/dev/random' if platform.system() == 'Linux' else '/dev/urandom'
        print(f'  {dim("  ")}  {green(dev):<22} {dim("-- hardware entropy pool")}')
        print(f'  {dim("  ")}  {green("timing jitter"):<22} {dim("-- nanosecond keystroke intervals")}')
        print(f'  {dim("  ")}  {green("os.urandom"):<22} {dim("-- CSPRNG baseline mixing")}')

        print(f'\n  {orange("While gathering:")} type random characters, vary speed and rhythm.')
        print(f'  {dim("Each keystroke timing adds entropy to the pool.")}')
        print(f'  {dim("Press")} {gold("ENTER")} {dim("when done (minimum 5 keystrokes recommended).")}')
        print()

    # Collect timing entropy interactively
    _collect_keystroke_entropy(pool, silent)

    # Wait for /dev/random reader (timeout 8s -- don't block forever)
    reader.join(timeout=8.0)

    # Final timing sample + os.urandom mixing
    pool.add_timing()
    pool.add_bytes(os.urandom(32))

    if not silent:
        print(f'\n  {dim("Pool size:")} {gold(str(pool.size()))} bytes  '
              f'{dim("Events:")} {gold(str(pool.events))}  '
              f'{dim("Device bytes:")} {gold(str(pool.dev_bytes))}')

    # Generate digits with overflow headroom
    raw = pool.derive(target_bytes * 3)
    digits = bytes_to_uniform_digits(raw)

    # Should always have enough, but safety loop just in case
    while len(digits) < n_digits:
        extra = pool.derive(64)
        digits.extend(bytes_to_uniform_digits(extra))

    result = digits[:n_digits]

    if not silent:
        check = _uniformity_check(result)
        print(f'  {dim("Uniformity:")} {check}')

    return result


def _collect_keystroke_entropy(pool: EntropyPool, silent: bool) -> None:
    """Read characters one at a time to capture keystroke timing."""
    if not silent:
        print(f'  {gray(">")} ', end='', flush=True)

    try:
        import termios
        import tty

        fd = sys.stdin.fileno()
        old = termios.tcgetattr(fd)
        try:
            tty.setraw(fd)
            while True:
                ch = sys.stdin.read(1)
                pool.add_timing()
                pool.add_bytes(bytes([ord(ch) & 0xFF]))

                if ch in ('\r', '\n'):
                    break
                elif ch == '\x03':        # Ctrl-C
                    termios.tcsetattr(fd, termios.TCSADRAIN, old)
                    raise KeyboardInterrupt
                elif ch == '\x7f':        # Backspace -- still good entropy
                    if not silent:
                        print(dim('<'), end='', flush=True)
                else:
                    if not silent:
                        print(dim('*'), end='', flush=True)
        finally:
            termios.tcsetattr(fd, termios.TCSADRAIN, old)
    except (ImportError, Exception):
        # Windows or non-tty fallback
        try:
            line = input()
            pool.add_timing()
            pool.add_bytes(line.encode())
        except (EOFError, KeyboardInterrupt):
            pass

    if not silent:
        print()


def _uniformity_check(digits: List[int]) -> str:
    """Simple chi-squared-style uniformity test on digit distribution."""
    if not digits:
        return red('NO DATA')
    counts = [digits.count(i) for i in range(10)]
    expected = len(digits) / 10.0
    max_dev = max(abs(c - expected) / expected * 100 for c in counts)
    if max_dev < 15.0:
        return green(f'PASS  (max deviation {max_dev:.1f}%)')
    elif max_dev < 30.0:
        return orange(f'MARGINAL  (max deviation {max_dev:.1f}%)')
    else:
        return red(f'FAIL  (max deviation {max_dev:.1f}% -- regenerate)')

# ═══════════════════════════════════════════════════════════════════════════
# Vernier ENCODE / DECODE
# ═══════════════════════════════════════════════════════════════════════════

def encode_char(ch: str) -> List[int]:
    """Encode a single character to a list of Vernier digits."""
    ch = ch.upper()
    if ch in PRIMARY:
        return [PRIMARY[ch]]
    if ch in SECONDARY:
        v = SECONDARY[ch]
        return [v // 10, v % 10]           # e.g. R=80 -> [8, 0]
    if ch in TERTIARY:
        v = TERTIARY[ch]
        return [v // 10, v % 10]           # e.g. Z=97 -> [9, 7]
    if ch.isdigit():
        return [9, 9, int(ch)]             # e.g. '6' -> [9, 9, 6]
    return []                              # space and unknowns stripped


def encode_string(text: str) -> List[int]:
    """Encode a full string to Vernier digit stream. Spaces are stripped."""
    digits: List[int] = []
    for ch in text.upper():
        if ch != ' ':
            digits.extend(encode_char(ch))
    return digits


def decode_digits(digits: List[int]) -> Tuple[str, int]:
    """
    Decode a Vernier digit stream to a string.

    Returns (decoded_text, zm_position) where zm_position is the index
    of the first ZM marker in the digit stream, or -1 if not found.
    Decoding stops at ZM.
    """
    result: List[str] = []
    i = 0
    zm_pos = -1

    while i < len(digits):
        # Detect ZM = [9,7,8,5,3,1]
        if (i + 3 < len(digits) and
                digits[i] == 9 and digits[i+1] == 7 and
                digits[i+2] == 8 and digits[i+3] == 5):
            zm_pos = i
            break

        d = digits[i]

        if 0 <= d <= 7:
            # Primary: single digit
            result.append(_REV_PRIMARY.get(d, '?'))
            i += 1

        elif d == 8:
            # Secondary: two digits
            if i + 1 < len(digits):
                code = 80 + digits[i + 1]
                result.append(_REV_SECONDARY.get(code, '?'))
                i += 2
            else:
                i += 1  # truncated -- skip

        elif d == 9:
            if i + 1 >= len(digits):
                i += 1
                continue

            d2 = digits[i + 1]

            if d2 == 9:
                # Numeral: three digits [9, 9, x]
                if i + 2 < len(digits):
                    result.append(str(digits[i + 2]))
                    i += 3
                else:
                    i += 2
            else:
                # Tertiary: two digits [9, x] where x in 0-8
                code = 90 + d2
                result.append(_REV_TERTIARY.get(code, '?'))
                i += 2
        else:
            i += 1  # should not occur (digits are 0-9)

    return ''.join(result), zm_pos


def expand_codebook(text: str) -> str:
    """Expand Z-code pairs in decoded text to their full phrases."""
    out: List[str] = []
    i = 0
    while i < len(text):
        if text[i] == 'Z' and i + 1 < len(text):
            code = 'Z' + text[i + 1]
            if code in CODEBOOK and code != 'ZM':
                out.append(f'[{CODEBOOK[code]}]')
                i += 2
                continue
        out.append(text[i])
        i += 1
    return ''.join(out)


# ═══════════════════════════════════════════════════════════════════════════
# CODEBOOK INTERACTIVE APPLICATION
# ═══════════════════════════════════════════════════════════════════════════

def apply_codebook_interactive(text: str) -> str:
    """
    Suggest codebook substitutions and apply with user confirmation.
    Returns modified text with Z-codes substituted where accepted.
    """
    print(f'\n  {gold("// CODEBOOK APPLICATION")}')
    _rule()
    print(f'  Original: {bright(text)}\n')

    working = text.upper()

    # Sort by phrase length descending so longer matches take priority
    candidates = sorted(
        [(phrase, code) for code, phrase in CODEBOOK.items() if code != 'ZM'],
        key=lambda x: len(x[0]),
        reverse=True
    )

    applied: List[str] = []
    skipped: List[str] = []

    for phrase, code in candidates:
        if phrase in working:
            tag = cyan('(routine)') if code in ROUTINE_CODES else ''
            digits = encode_string('Z' + code[1])  # show encoded cost
            cost = f'{dim(str(len(digits)))} digits'
            print(f'  {gold("Match:")} {orange(phrase):<32} {green(code)} {tag}')
            print(f'  {dim("Encoded as:")} {dim(str(digits))} ({cost})')
            ans = input(f'  {dim("Apply? [Y/n]")} ').strip().lower()
            if ans != 'n':
                working = working.replace(phrase, code, 1)
                applied.append(f'{code}={phrase}')
                print(f'  {green("Applied.")}')
            else:
                skipped.append(phrase)
            print()

    # Clean up spacing
    working = ' '.join(working.split())

    if applied:
        print(f'  {green("Applied:")} {", ".join(applied)}')
    else:
        print(f'  {dim("No substitutions applied.")}')
    if skipped:
        print(f'  {dim("Skipped:")} {", ".join(skipped)}')

    print(f'  {dim("Working text:")} {bright(working)}')
    return working


# ═══════════════════════════════════════════════════════════════════════════
# MOD-10 ARITHMETIC
# ═══════════════════════════════════════════════════════════════════════════

def add_mod10(p: int, k: int) -> int:
    return (p + k) % 10

def sub_mod10(c: int, k: int) -> int:
    return (c - k + 10) % 10

def encrypt_stream(plain: List[int], key: List[int]) -> List[int]:
    """Apply (P+K) mod 10 across parallel digit streams."""
    return [add_mod10(p, k) for p, k in zip(plain, key)]

def decrypt_stream(cipher: List[int], key: List[int]) -> List[int]:
    """Apply (C-K+10) mod 10 across parallel digit streams."""
    return [sub_mod10(c, k) for c, k in zip(cipher, key)]

# ═══════════════════════════════════════════════════════════════════════════
# KEYWORD LAYER (Stave 5)
# ═══════════════════════════════════════════════════════════════════════════

def keyword_to_stream(keyword: str, length: int) -> List[int]:
    """
    Convert keyword to a cycling digit stream.
    Conversion: (alphabet position) mod 10
    A=1, B=2, ... J=0(10%10), K=1, ... Z=6(26%10)
    """
    stream: List[int] = []
    for ch in keyword.upper():
        if ch.isalpha():
            pos = ord(ch) - ord('A') + 1   # A=1 ... Z=26
            stream.append(pos % 10)
    if not stream:
        return [0] * length
    result: List[int] = []
    while len(result) < length:
        result.extend(stream)
    return result[:length]


def keyword_stream_preview(keyword: str) -> str:
    """Show the digit stream a keyword produces (first 20 digits, cycling)."""
    stream = keyword_to_stream(keyword, 20)
    return ' '.join(str(d) for d in stream) + '  ...'

# ═══════════════════════════════════════════════════════════════════════════
# DISPLAY HELPERS
# ═══════════════════════════════════════════════════════════════════════════

def _clear():
    os.system('clear' if platform.system() != 'Windows' else 'cls')

def _rule(width: int = 48):
    print(dim('  ' + '\u2500' * width))

def _header(subtitle: str = ''):
    print()
    print(gold('  \u2554' + '\u2550' * 46 + '\u2557'))
    print(gold('  \u2551') + bright('  Vernier FIELD CIPHER SYSTEM') + ' ' * 16 + gold('\u2551'))
    print(gold('  \u2551') + dim('  kmsp42.com // rev 2.0') + ' ' * 24 + gold('\u2551'))
    if subtitle:
        pad = 46 - len(subtitle) - 2
        print(gold('  \u2551') + orange(f'  {subtitle}') + ' ' * max(0, pad) + gold('\u2551'))
    print(gold('  \u255a' + '\u2550' * 46 + '\u255d'))
    print()

def _groups(digits: List[int], group_sz: int = 5, line_width: int = 10) -> str:
    """Format digit list into grouped lines (line_width groups per line)."""
    flat = ''.join(str(d) for d in digits)
    groups = [flat[i:i+group_sz] for i in range(0, len(flat), group_sz)]
    lines = []
    for i in range(0, len(groups), line_width):
        lines.append('  '.join(groups[i:i+line_width]))
    return '\n  '.join(lines)

def _print_row(label: str, digits: List[int], color_fn=None, max_digits: int = 0):
    """Print a labeled row of digit groups."""
    cf = color_fn or (lambda x: x)
    show = digits[:max_digits] if max_digits else digits
    continuation = dim('  ...') if max_digits and len(digits) > max_digits else ''
    print(f'  {gold(label + ":")}')
    print(f'  {cf(_groups(show))}' + continuation)

def _wait(msg: str = 'Press ENTER to continue...'):
    input(f'\n  {dim(msg)}')

# ═══════════════════════════════════════════════════════════════════════════
# REFERENCE SCREENS
# ═══════════════════════════════════════════════════════════════════════════

def show_encoding_table():
    _clear()
    _header('RADIX ENCODING TABLE')

    print(f'  {gold("PRIMARY")}  {dim("(0-7, single digit)")}')
    print(f'  {dim("Mnemonic: Eat Tangerines And Oranges In Nice Spanish Hotels")}')
    print()
    row = '  '
    for ch, d in PRIMARY.items():
        row += f'{bright(ch)}{dim("=")}{green(str(d))}   '
    print(row)

    print(f'\n  {gold("SECONDARY")}  {dim("(prefix 8, codes 80-89)")}')
    print(f'  {dim("Mnemonic: Really Dull Lessons Can Undermine Many Willing Fields, Gaining Yesterday")}')
    print()
    row = '  '
    for ch, d in SECONDARY.items():
        row += f'{bright(ch)}{dim("=")}{blue(str(d))}  '
    print(row)

    print(f'\n  {gold("TERTIARY")}  {dim("(prefix 9, codes 90-98)")}')
    print(f'  {dim("Mnemonic: Please Be Very Kind, Just eXercise Quietly, Zealously.")}')
    print()
    row = '  '
    for ch, d in TERTIARY.items():
        row += f'{bright(ch)}{dim("=")}{orange(str(d))}  '
    print(row)

    print(f'\n  {gold("NUMERALS")}  {dim("(prefix 99, codes 990-999)")}')
    print(f'  {dim("Rule: 990 + digit")}')
    print()
    row = '  '
    for i in range(10):
        row += f'{bright(str(i))}{dim("=")}{purple(str(990+i))}  '
    print(row)

    print(f'\n  {gold("ZM END MARKER")}  {dim("= [9,7,8,5,3,1]")}  {dim("(Z=97, M=85)")}')

    _rule()
    print(f'\n  {gold("PARSING RULE:")}')
    print(f'  {dim("d in 0-7")}        {dim("-->")}  complete. {green("One digit.")}')
    print(f'  {dim("d = 8")}           {dim("-->")}  read one more. {blue("8x secondary table.")}')
    print(f'  {dim("d = 9, x in 0-8")} {dim("-->")}  two digits total. {orange("9x tertiary table.")}')
    print(f'  {dim("d = 9, x = 9")}   {dim("-->")}  read one more. {purple("Numeral = that digit.")}')
    print()
    print(f'  {cyan("No lookahead required at any position. Each decision is final.")}')
    _wait()


def show_codebook():
    _clear()
    _header('CODEBOOK // Z-PREFIX SYSTEM')
    print(f'  {dim("Z encodes as [9,7]. Any Z+letter pair is a codebook entry.")}')
    print(f'  {dim("ZM is reserved as end-of-message. Never reassign.")}')
    print(f'  {gold("★")} {dim("= FIELD ESSENTIAL (memorize these 10)")}')
    _rule()
    print()

    items = [(k, v) for k, v in CODEBOOK.items()]
    for i in range(0, len(items), 2):
        lk, lv = items[i]
        tag_l = gold(' ★') if lk in FIELD_ESSENTIAL else ''
        ls = f'  {gold(lk)}  {dim(lv):<34}{tag_l}'
        if i + 1 < len(items):
            rk, rv = items[i + 1]
            tag_r = gold(' ★') if rk in FIELD_ESSENTIAL else ''
            rs = f'{gold(rk)}  {dim(rv)}{tag_r}'
            print(f'{ls:<52}  {rs}')
        else:
            print(ls)

    _rule()
    print(f'\n  {gold("Routine circuit")} {dim("(all fit in Short block -- 50 digits):")}')
    print(f'  {green("ZI")} {dim("ZM")}  =  nothing to report')
    print(f'  {green("ZW")} {dim("ZM")}  =  received and understood')
    print(f'  {green("ZG")} {dim("ZM")}  =  confirm last message received correctly')
    print()
    print(f'  {dim("Encoding cost per Z-code entry:")}')
    for code in ['ZA', 'ZD', 'ZN', 'ZU', 'ZI', 'ZH']:
        digs = encode_string('Z' + code[1])
        meaning = CODEBOOK[code]
        print(f'  {gold(code)} {dim("=>")} {dim(str(digs))} {dim(f"({len(digs)} digits)  [{meaning}]")}')
    _wait()


def show_block_classes():
    _clear()
    _header('BLOCK CLASSES')
    print(f'  {"Class":<8}  {"Prefix":<8}  {"Block":<8}  {"Max Content":<14}  {"Groups":<8}  Dice Rolls')
    _rule()
    print(f'  {green("S SHORT"):<8}  {dim("00"):<8}  {dim("50"):<8}  {dim("44 digits"):<14}  {dim("10"):<8}  {dim("50")}')
    print(f'  {gold("M MEDIUM"):<8}  {dim("01"):<8}  {dim("100"):<8}  {dim("94 digits"):<14}  {dim("20"):<8}  {dim("100")}')
    print(f'  {orange("L LONG"):<8}  {dim("02"):<8}  {dim("200"):<8}  {dim("194 digits"):<14}  {dim("40"):<8}  {dim("200")}')
    _rule()
    print(f'\n  {gold("Structure:")}')
    print(f'  {dim("[OFFSET 2][SALT 4][RANDOM FILL][CONTENT][ZM 6][PADDING] = block size (25, 50, or 100)")}')
    print(f'\n  {gold("Decision rule:")}')
    print(f'  {dim("Encode message fully. Count digits. Select class. Then roll dice.")}')
    print(f'  {dim("Key block size matches class (50 / 100 / 200 rolls).")}')
    print(f'\n  {gold("Typical messages:")}')
    print(f'  {green("ZI ZM")}              = 8 digits   --> {green("Short")}')
    print(f'  {green("ZH ZB ZM")}           = 11 digits  --> {green("Short")}')
    print(f'  {gold("ZN ZP ZK ZA ZM")}       = 16 digits  --> {gold("Short")}')
    print(f'  {gold("Mixed report ~60 digits")}            --> {gold("Medium")}')
    print(f'  {orange("Coordinates + instructions ~150 d")} --> {orange("Long")}')
    print(f'\n  {dim("Over 194 digits: split across two pad sheets.")}')
    _wait()


def show_mod_table():
    _clear()
    _header('MOD-10 CIPHER TABLE')
    print(f'  {dim("Row = plaintext digit (P). Column = key digit (K). Cell = cipher digit (C).")}')
    print(f'  {dim("Decrypt: find cipher value in key row, column header = plaintext.")}')
    print()

    # Header row
    hdr = f'  {dim("P\\K"):>5}'
    for j in range(10):
        hdr += f'  {blue(str(j)):>4}'
    print(hdr)
    _rule(52)

    for i in range(10):
        row_str = f'  {gold(str(i)):>5}'
        for j in range(10):
            val = (i + j) % 10
            cell = green(str(val)) if i == j else dim(str(val))
            row_str += f'  {cell:>4}'
        print(row_str)

    _rule(52)
    print(f'\n  {gold("ENCRYPT:")}  C = (P + K) mod 10')
    print(f'  {gold("DECRYPT:")}  P = (C - K + 10) mod 10')
    print(f'\n  {dim("Shaded diagonal (green): P = K. Cipher equals plaintext. Key digit is zero.")}')
    print(f'  {dim("Zero is equally likely from a random source -- not a security flaw.")}')
    _wait()

# ═══════════════════════════════════════════════════════════════════════════
# ENCRYPTION WORKFLOW
# ═══════════════════════════════════════════════════════════════════════════

def encrypt_workflow():
    _clear()
    _header('ENCRYPT MESSAGE')

    # ── Step 1: Plaintext input ──────────────────────────────────────────
    print(f'  {orange("STEP 1")}  {dim("/")}  Input plaintext')
    _rule()
    print(f'  {dim("Type or paste your message. Press ENTER when done.")}')
    print(f'  {dim("Use plain English. Codebook suggestions follow.")}')
    print()
    plaintext = input(f'  {gold("> ")}').strip()
    if not plaintext:
        print(red('\n  No input. Cancelling.'))
        time.sleep(1)
        return

    # ── Step 2: Codebook ─────────────────────────────────────────────────
    print(f'\n  {orange("STEP 2")}  {dim("/")}  Apply codebook')
    _rule()
    coded = apply_codebook_interactive(plaintext)

    # ── Step 3: RADIX encode ─────────────────────────────────────────────
    print(f'\n  {orange("STEP 3")}  {dim("/")}  Vernier encode')
    _rule()

    # Show per-character encoding
    stripped = coded.replace(' ', '')
    print(f'  {dim("Encoding:")} {bright(stripped)}\n')

    encoded: List[int] = []
    char_table: List[Tuple[str, List[int]]] = []
    for ch in stripped.upper():
        d = encode_char(ch) if ch != ' ' else []
        encoded.extend(d)
        char_table.append((ch, d))

    # Print encoding table (compact)
    line = '  '
    for ch, d in char_table:
        entry = f'{bright(ch)}{dim("->")}{cyan("".join(str(x) for x in d))}  '
        if len(line) + len(entry) > 72:
            print(line)
            line = '  '
        line += entry
    if line.strip():
        print(line)

    # Append ZM — ALWAYS
    content = encoded + ZM_DIGITS
    print(f'\n  {gold(">>> APPEND ZM END-OF-MESSAGE: 9 7 8 5 <<<")}')
    print(f'  {dim("Content + ZM:")}')
    _print_row('ENCODED', content, blue)
    print(f'  {dim("Content digit count:")} {gold(str(len(content)))}')

    # ── Step 4: Block class selection ───────────────────────────────────
    print(f'\n  {orange("STEP 4")}  {dim("/")}  Select block class')
    _rule()

    cls = select_block_class(len(content))

    if cls == 'SPLIT':
        print(f'  {red("Message too long!")} {len(content)} digits exceeds 194 digit maximum.')
        print(f'  {dim("Split into two messages on separate pad sheets.")}')
        _wait()
        return

    bc     = BLOCK_CLASSES[cls]
    bsize  = bc['size']
    prefix = bc['prefix']
    null_n = bsize - 2 - len(content)

    cls_color = {
        'S': green, 'M': gold, 'L': orange
    }.get(cls, gold)

    print(f'  Content: {gold(str(len(content)))} digits  -->  class {cls_color(cls)}')
    print(f'  Block size: {dim(str(bsize))} digits  |  Prefix: {dim(str(prefix))}  '
          f'|  Groups: {dim(str(bc["groups"]))}  |  Null fill: {dim(str(null_n))}')

    # ── Step 5: Optional keyword layer ──────────────────────────────────
    print(f'\n  {orange("STEP 5")}  {dim("/")}  Keyword layer (optional)')
    _rule()
    print(f'  {dim("Apply a memorized keyword for defense against pad physical compromise.")}')
    print(f'  {dim("Leave blank to skip Stave 5.")}')
    print(f'\n  {dim("KEYWORD DIGIT TABLE (one glance per letter):")}')
    print(f'  {blue("A=1 B=2 C=3 D=4 E=5 F=6 G=7 H=8 I=9 J=0")}')
    print(f'  {blue("K=1 L=2 M=3 N=4 O=5 P=6 Q=7 R=8 S=9 T=0")}')
    print(f'  {blue("U=1 V=2 W=3 X=4 Y=5 Z=6")}')
    print()
    kw_raw = input(f'  {gold("Keyword (ENTER to skip): ")}').strip()
    use_kw = bool(kw_raw)
    if use_kw:
        preview = keyword_stream_preview(kw_raw)
        print(f'  {dim("Keyword digit stream:")} {blue(preview)}')
        print(f'  {green("Stave 5 active.")}')
    else:
        print(f'  {dim("Stave 5 skipped.")}')

    # ── Step 6: Key generation ───────────────────────────────────────────
    print(f'\n  {orange("STEP 6")}  {dim("/")}  Generate key material ({bsize} digits)')
    _rule()
    key_digits = collect_entropy(bsize)
    _print_row('KEY', key_digits, blue)

    # ── Step 7: Build full plaintext stream and encrypt ──────────────────
    print(f'\n  {orange("STEP 7")}  {dim("/")}  Encrypt')
    _rule()

    # Full plaintext = prefix + encoded_content (ZM included)
    plain_active = prefix + content

    # Apply keyword layer before OTP if requested
    if use_kw:
        kw_stream = keyword_to_stream(kw_raw, len(plain_active))
        plain_kw = [add_mod10(p, k) for p, k in zip(plain_active, kw_stream)]
        print(f'  {dim("After keyword layer:")}')
        _print_row('P+KW', plain_kw[:30], purple, max_digits=30)
    else:
        plain_kw = plain_active

    # OTP encryption
    key_active   = key_digits[:len(plain_kw)]
    cipher_active = encrypt_stream(plain_kw, key_active)

    # Null fill: plaintext = 0, so cipher = key digit
    null_key    = key_digits[len(plain_kw):]
    cipher_null = null_key[:]   # (0 + k) mod 10 = k

    cipher_full = cipher_active + cipher_null

    # Display
    _print_row('PLAIN',  plain_active[:20], dim,   max_digits=20)
    _print_row('KEY',    key_digits[:20],   blue,  max_digits=20)
    _print_row('CIPHER', cipher_full,       green)

    verify = cipher_full[:5]

    # ── Step 8: Output ───────────────────────────────────────────────────
    print(f'\n  {orange("STEP 8")}  {dim("/")}  Transmit')
    _rule()
    print(f'\n  {gold("CIPHERTEXT:")}')
    print(f'  {green(_groups(cipher_full))}')
    print(f'\n  {gold("VERIFY STRIP")} {dim("(first 5 cipher digits):")} '
          f'{orange(_groups(verify))}')
    print(f'\n  {dim("Class:")} {cls_color(cls)}  '
          f'{dim("Digits:")} {gold(str(len(cipher_full)))}  '
          f'{dim("Groups:")} {gold(str(bc["groups"]))}')

    # ── Step 9: Save option ──────────────────────────────────────────────
    _rule()
    print(f'\n  {gold("Save key block to file?")}')
    print(f'  {red("Key file is sensitive. Share only via physical exchange.")}')
    print(f'  {dim("The receiver needs this to decrypt.")}')
    print()
    save = input(f'  {gold("Save? [y/N] ")}').strip().lower()
    if save == 'y':
        fname = input(f'  {gold("Filename [vernier_pad.txt]: ")}').strip() or 'vernier_pad.txt'
        try:
            with open(fname, 'w') as f:
                f.write('Vernier PAD SHEET\n')
                f.write('kmsp42.com // rev 2.0\n')
                f.write('=' * 48 + '\n')
                f.write(f'BLOCK CLASS:  {cls}\n')
                f.write(f'BLOCK SIZE:   {bsize}\n')
                f.write(f'GROUPS:       {bc["groups"]}\n')
                f.write(f'VERIFY STRIP: {"".join(str(d) for d in verify)}\n')
                f.write(f'KEYWORD:      {"YES (memorized only -- not stored)" if use_kw else "NO"}\n')
                f.write('KEY DIGITS:\n')
                f.write(_groups(key_digits) + '\n')
                f.write('=' * 48 + '\n')
                f.write('DESTROY THIS FILE AFTER COUNTERPART HAS THE KEY.\n')
            print(f'  {green("Saved:")} {gold(fname)}')
            print(f'  {red("Destroy file once key is physically exchanged.")}')
        except OSError as e:
            print(f'  {red("Save failed:")} {e}')

    _wait()


# ═══════════════════════════════════════════════════════════════════════════
# DECRYPTION WORKFLOW
# ═══════════════════════════════════════════════════════════════════════════

def decrypt_workflow():
    _clear()
    _header('DECRYPT MESSAGE')

    # ── Step 1: Ciphertext ───────────────────────────────────────────────
    print(f'  {orange("STEP 1")}  {dim("/")}  Enter ciphertext')
    _rule()
    print(f'  {dim("Paste received digit groups. Spaces/newlines are ignored.")}')
    print()
    raw = input(f'  {gold("Ciphertext: ")}').strip()
    cipher_digits = [int(c) for c in raw if c.isdigit()]

    if not cipher_digits:
        print(red('\n  No digits found. Cancelling.'))
        time.sleep(1)
        return

    # Detect block class
    detected_cls = None
    for cls, bc in BLOCK_CLASSES.items():
        if len(cipher_digits) == bc['size']:
            detected_cls = cls
            break

    if detected_cls:
        cls_color = {'S': green, 'M': gold, 'L': orange}[detected_cls]
        print(f'  {dim("Block class detected:")} {cls_color(detected_cls)} '
              f'{dim(f"({len(cipher_digits)} digits)")}')
    else:
        print(f'  {orange("Non-standard block size:")} {len(cipher_digits)} digits')

    _print_row('CIPHER', cipher_digits, green)

    # ── Step 2: Key digits ───────────────────────────────────────────────
    print(f'\n  {orange("STEP 2")}  {dim("/")}  Enter key digits')
    _rule()
    load = input(f'  {gold("Load from file? [y/N] ")}').strip().lower()

    key_digits: List[int] = []

    if load == 'y':
        fname = input(f'  {gold("Filename: ")}').strip()
        try:
            in_key = False
            raw_key = ''
            with open(fname, 'r') as f:
                for line in f:
                    if line.startswith('KEY DIGITS:'):
                        in_key = True
                        continue
                    if in_key:
                        raw_key += line
                        if line.startswith('='):
                            break
            key_digits = [int(c) for c in raw_key if c.isdigit()]
            print(f'  {green("Loaded")} {gold(str(len(key_digits)))} key digits from {gold(fname)}')
        except OSError as e:
            print(f'  {red("Load failed:")} {e}')

    if not key_digits:
        print(f'  {dim("Enter key digits (spaces are ignored):")}')
        raw_key = input(f'  {gold("Key: ")}').strip()
        key_digits = [int(c) for c in raw_key if c.isdigit()]

    if not key_digits:
        print(red('\n  No key digits. Cancelling.'))
        time.sleep(1)
        return

    if len(key_digits) < len(cipher_digits):
        print(f'  {orange("Warning:")} key ({len(key_digits)}) shorter than ciphertext ({len(cipher_digits)})')
        key_digits += [0] * (len(cipher_digits) - len(key_digits))

    _print_row('KEY', key_digits[:len(cipher_digits)], blue)

    # ── Step 3: Keyword layer ────────────────────────────────────────────
    print(f'\n  {orange("STEP 3")}  {dim("/")}  Keyword layer')
    _rule()
    print(f'  {dim("Enter keyword if Stave 5 was active during encryption.")}')
    kw_raw = input(f'  {gold("Keyword (ENTER to skip): ")}').strip()
    use_kw = bool(kw_raw)

    # ── Step 4: Decrypt ──────────────────────────────────────────────────
    print(f'\n  {orange("STEP 4")}  {dim("/")}  Decrypt')
    _rule()

    key_slice = key_digits[:len(cipher_digits)]
    plain_raw = decrypt_stream(cipher_digits, key_slice)

    # Remove keyword layer if active
    if use_kw:
        kw_stream = keyword_to_stream(kw_raw, len(plain_raw))
        plain_raw = [sub_mod10(p, k) for p, k in zip(plain_raw, kw_stream)]
        print(f'  {dim("Keyword layer removed.")}')

    _print_row('PLAIN RAW', plain_raw[:40], dim, max_digits=40)

    # Read block class prefix (first two digits)
    if len(plain_raw) >= 2:
        p_val = plain_raw[0] * 10 + plain_raw[1]
        prefix_map = {0: 'S', 1: 'M', 2: 'L'}
        if p_val in prefix_map:
            pfx_cls = prefix_map[p_val]
            pfx_color = {'S': green, 'M': gold, 'L': orange}[pfx_cls]
            print(f'  {dim("Prefix:")} {dim(str(plain_raw[:2]))} --> class {pfx_color(pfx_cls)}')
            content_digits = plain_raw[2:]
        else:
            print(f'  {orange("Unexpected prefix digits:")} {plain_raw[:2]}')
            content_digits = plain_raw
    else:
        content_digits = plain_raw

    # ── Step 5: RADIX decode ─────────────────────────────────────────────
    print(f'\n  {orange("STEP 5")}  {dim("/")}  RADIX decode')
    _rule()

    decoded, zm_pos = decode_digits(content_digits)

    if zm_pos >= 0:
        print(f'  {dim("ZM marker found at content position")} {gold(str(zm_pos))} '
              f'{dim("-- truncating null fill.")}')
    else:
        print(f'  {orange("ZM marker not found.")} '
              f'{dim("Wrong key, wrong block class, or transmission error.")}')

    expanded = expand_codebook(decoded)

    print()
    print(f'  {gold("DECODED RAW:")}')
    print(f'  {blue(decoded)}')
    print()
    print(f'  {gold("PLAINTEXT:")}')
    print(f'  {bright(expanded)}')

    # Show codebook expansions detail
    if any(f'Z{c}' in decoded for c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'):
        _rule()
        print(f'\n  {gold("Codebook expansions:")}')
        i = 0
        while i < len(decoded):
            if decoded[i] == 'Z' and i + 1 < len(decoded):
                code = 'Z' + decoded[i+1]
                if code in CODEBOOK:
                    print(f'  {green(code)} --> {CODEBOOK[code]}')
                i += 2
            else:
                i += 1

    _wait()

# ═══════════════════════════════════════════════════════════════════════════
# QUICK ENCODE / DECODE UTILITIES
# ═══════════════════════════════════════════════════════════════════════════

def quick_encode():
    """Encode a string to RADIX digits without encryption."""
    _clear()
    _header('QUICK ENCODE -- No Encryption')
    print(f'  {dim("Encodes text to Vernier digits only. For practice and testing.")}')
    _rule()
    text = input(f'\n  {gold("Text: ")}').strip()
    if not text:
        return
    enc = encode_string(text)
    print(f'\n  {gold("Input:")}'
          f'  {bright(text.upper())}')
    print(f'\n  {gold("RADIX digits:")}')
    _print_row('OUT', enc, green)
    print(f'\n  {dim("Count:")} {gold(str(len(enc)))} digits')

    # Also show block class this would require
    cls = select_block_class(len(enc) + 4)  # +4 for ZM
    print(f'  {dim("Block class (with ZM):")} {gold(cls)}')
    _wait()


def quick_decode():
    """Decode a RADIX digit string without decryption."""
    _clear()
    _header('QUICK DECODE -- No Decryption')
    print(f'  {dim("Decodes Vernier digit stream only. For practice and testing.")}')
    _rule()
    raw = input(f'\n  {gold("Digits: ")}').strip()
    digits = [int(c) for c in raw if c.isdigit()]
    if not digits:
        return
    decoded, zm_pos = decode_digits(digits)
    expanded = expand_codebook(decoded)
    print(f'\n  {gold("Decoded raw:")}   {blue(decoded)}')
    print(f'\n  {gold("Plaintext:")}     {bright(expanded)}')
    if zm_pos >= 0:
        print(f'\n  {dim("ZM at position")} {gold(str(zm_pos))}')
    _wait()


def keyword_tool():
    """Preview keyword digit stream."""
    _clear()
    _header('KEYWORD STREAM PREVIEW')
    print(f'  {dim("Converts a keyword to its cycling mod-10 digit stream.")}')
    print(f'  {dim("Use this to verify both parties have the same stream before operation.")}')
    _rule()
    kw = input(f'\n  {gold("Keyword: ")}').strip()
    if not kw:
        return
    stream = keyword_to_stream(kw, 50)
    print(f'\n  {dim("Keyword:")} {bright(kw.upper())}')
    print()
    chars = list(kw.upper())
    detail = '  '
    for ch in chars:
        if ch.isalpha():
            pos = ord(ch) - ord('A') + 1
            d = pos % 10
            detail += f'{bright(ch)}{dim("->")}{blue(str(d))}  '
    print(detail)
    print(f'\n  {dim("Cycling stream (50 digits):")}')
    _print_row('STREAM', stream, blue)
    check = _uniformity_check(stream)
    print(f'\n  {dim("Distribution:")} {check}')
    _wait()

# ═══════════════════════════════════════════════════════════════════════════
# PRINTABLE FIELD CARD + WORKSHEET GENERATOR
# ═══════════════════════════════════════════════════════════════════════════

def generate_field_card():
    """Generate a printable text field card with everything needed for hand operation."""
    _clear()
    _header('GENERATE FIELD CARD')
    print(f'  {dim("Generates a printable text file with:")}')
    print(f'  {dim("  - RADIX encoding table")}')
    print(f'  {dim("  - Essential codebook (10 codes)")}')
    print(f'  {dim("  - Keyword digit table")}')
    print(f'  {dim("  - Mod-10 addition/subtraction tables")}')
    print(f'  {dim("  - Pre-ruled encryption worksheet with key + null digits")}')
    print()

    sheets = 1
    raw = input(f'  {gold("How many pad sheets? [1] ")}').strip()
    if raw.isdigit() and int(raw) > 0:
        sheets = min(int(raw), 10)

    fname = input(f'  {gold("Filename [vernier_field_card.txt]: ")}').strip() or 'vernier_field_card.txt'

    import random as _rng

    try:
        with open(fname, 'w') as f:
            w = f.write

            # ── Header ──
            w('=' * 60 + '\n')
            w('  Vernier FIELD CARD // kmsp42.com rev 2.0\n')
            w('  PRINT THIS. LAMINATE THE CARD. DESTROY PAD SHEETS AFTER USE.\n')
            w('=' * 60 + '\n\n')

            # ── RADIX Encoding Table ──
            w('─── RADIX ENCODING TABLE ───────────────────────────────\n\n')
            w('  SINGLE DIGIT (0-7):  most common letters\n')
            w('  ┌───┬───┬───┬───┬───┬───┬───┬───┐\n')
            w('  │ E │ T │ A │ O │ I │ N │ S │ H │\n')
            w('  │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │\n')
            w('  └───┴───┴───┴───┴───┴───┴───┴───┘\n\n')
            w('  TWO DIGIT (8x):  medium frequency\n')
            w('  ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐\n')
            w('  │ R  │ D  │ L  │ C  │ U  │ M  │ W  │ F  │ G  │ Y  │\n')
            w('  │ 80 │ 81 │ 82 │ 83 │ 84 │ 85 │ 86 │ 87 │ 88 │ 89 │\n')
            w('  └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘\n\n')
            w('  TWO DIGIT (9x):  rare letters + period\n')
            w('  ┌────┬────┬────┬────┬────┬────┬────┬────┬────┐\n')
            w('  │ P  │ B  │ V  │ K  │ J  │ X  │ Q  │ Z  │ .  │\n')
            w('  │ 90 │ 91 │ 92 │ 93 │ 94 │ 95 │ 96 │ 97 │ 98 │\n')
            w('  └────┴────┴────┴────┴────┴────┴────┴────┴────┘\n\n')
            w('  NUMERALS: digit d → 99d  (e.g. 6 → 996)\n\n')
            w('  PARSING: 0-7=done | 8=read 1 more | 9(0-8)=done | 99=read 1 more\n')
            w('  END OF MESSAGE: ALWAYS APPEND  9 7 8 5  (ZM)\n\n')

            # ── Essential Codebook ──
            w('─── ESSENTIAL CODEBOOK (memorize these 10) ──────────────\n\n')
            for code, phrase in FIELD_ESSENTIAL.items():
                w(f'  {code}  {phrase}\n')
            w('\n')

            # ── Keyword Digit Table ──
            w('─── KEYWORD DIGIT TABLE ─────────────────────────────────\n\n')
            w('  A=1  B=2  C=3  D=4  E=5  F=6  G=7  H=8  I=9  J=0\n')
            w('  K=1  L=2  M=3  N=4  O=5  P=6  Q=7  R=8  S=9  T=0\n')
            w('  U=1  V=2  W=3  X=4  Y=5  Z=6\n\n')

            # ── Mod-10 Tables ──
            w('─── MOD-10 ADDITION TABLE ───────────────────────────────\n')
            w('  ENCRYPT: find P row, K column, read cipher digit\n\n')
            w('    + │ 0  1  2  3  4  5  6  7  8  9\n')
            w('  ────┼──────────────────────────────\n')
            for r in range(10):
                row = '  '.join(str((r + c) % 10) for c in range(10))
                w(f'    {r} │ {row}\n')
            w('\n')

            w('─── MOD-10 SUBTRACTION TABLE ────────────────────────────\n')
            w('  DECRYPT: find C row, K column, read plain digit\n\n')
            w('    - │ 0  1  2  3  4  5  6  7  8  9\n')
            w('  ────┼──────────────────────────────\n')
            for r in range(10):
                row = '  '.join(str((r - c + 10) % 10) for c in range(10))
                w(f'    {r} │ {row}\n')
            w('\n')

            # ── Pad Sheets ──
            for sheet_num in range(1, sheets + 1):
                w('=' * 60 + '\n')
                w(f'  PAD SHEET {sheet_num:02d}  //  100 DIGITS  //  SINGLE USE\n')
                w('  DESTROY AFTER ENCRYPTING OR DECRYPTING ONE MESSAGE.\n')
                w('=' * 60 + '\n\n')

                # Generate 100 truly random key digits
                pool = EntropyPool()
                pool.collect_urandom(200)
                key = pool.extract_digits(100)

                # Pre-generate 100 null digits too
                pool2 = EntropyPool()
                pool2.collect_urandom(200)
                nulls = pool2.extract_digits(100)

                w('  KEY DIGITS (100):\n')
                flat = ''.join(str(d) for d in key)
                for i in range(0, 100, 50):
                    groups = [flat[j:j+5] for j in range(i, min(i+50, 100), 5)]
                    w('  ' + '  '.join(groups) + '\n')
                w('\n')

                w('  NULL FILL DIGITS (use to pad short messages):\n')
                flat_n = ''.join(str(d) for d in nulls)
                for i in range(0, 100, 50):
                    groups = [flat_n[j:j+5] for j in range(i, min(i+50, 100), 5)]
                    w('  ' + '  '.join(groups) + '\n')
                w('\n')

                # Pre-ruled worksheet
                w('  ─── ENCRYPTION WORKSHEET ───\n\n')
                w('  PLAINTEXT:  _____ _____ _____ _____ _____ _____ _____ _____ _____ _____\n')
                w('              _____ _____ _____ _____ _____ _____ _____ _____ _____ _____\n\n')
                w('  + KEYWORD:  _____ _____ _____ _____ _____ _____ _____ _____ _____ _____\n')
                w('              _____ _____ _____ _____ _____ _____ _____ _____ _____ _____\n\n')
                w('  = P+KW:    _____ _____ _____ _____ _____ _____ _____ _____ _____ _____\n')
                w('              _____ _____ _____ _____ _____ _____ _____ _____ _____ _____\n\n')
                w('  + KEY:     (copy from KEY DIGITS above)\n\n')
                w('  = CIPHER:  _____ _____ _____ _____ _____ _____ _____ _____ _____ _____\n')
                w('              _____ _____ _____ _____ _____ _____ _____ _____ _____ _____\n\n')
                w('  VERIFY STRIP (first 5 cipher digits): _____\n')
                w('  Remember: APPEND 9 7 8 5 (ZM) TO YOUR ENCODED TEXT BEFORE ENCRYPTING\n\n')

        print(f'\n  {green("Saved:")} {gold(fname)}')
        print(f'  {dim(f"Contains: encoding table, codebook, keyword table, mod-10 tables, {sheets} pad sheet(s) with worksheets")}')
        print(f'  {red("Print the card. Laminate it. Destroy pad sheets after single use.")}')
    except OSError as e:
        print(f'  {red("Save failed:")} {e}')

    _wait()


# ═══════════════════════════════════════════════════════════════════════════
# AUTOMATIC DEMO — FULL ENCRYPT/DECRYPT CYCLE
# ═══════════════════════════════════════════════════════════════════════════

def demo_workflow():
    """Run the complete five-stave encrypt/decrypt cycle automatically with narration."""
    _clear()
    _header('AUTOMATIC DEMO — SOUP TO NUTS')

    # ── Setup ────────────────────────────────────────────────────────────
    sample   = 'MEET AT DEAD DROP TOMORROW'
    keyword  = 'CIPHER'

    print(f'  {gold("This demo encrypts a message through all five staves,")}')
    print(f'  {gold("then decrypts it back, showing every intermediate step.")}')
    print()
    print(f'  {dim("Plaintext:")}  {bright(sample)}')
    print(f'  {dim("Keyword:")}    {bright(keyword)}')
    print(f'  {dim("Codebook:")}   active')
    print(f'  {dim("Block size:")} auto-selected')
    _rule()
    input(f'\n  {dim("Press ENTER to begin...")}')

    # ══════════════════════════════════════════════════════════════════════
    # ENCRYPTION
    # ══════════════════════════════════════════════════════════════════════

    print(f'\n  {orange("═══ ENCRYPTION ═══")}')

    # ── Stave 1: Codebook ────────────────────────────────────────────────
    print(f'\n  {orange("STAVE 1")}  {dim("/")}  Codebook compression')
    _rule()
    print(f'  {dim("Original:")}    {bright(sample)}')

    # Apply codebook manually: MEET -> ZN, DEAD DROP -> ZT, TOMORROW -> ZO
    coded = 'ZN AT ZT ZO'
    # Build the mapping display
    print(f'  {dim("MEET")}         {dim("->")} {cyan("ZN")} {dim("(codebook)")}'  )
    print(f'  {dim("DEAD DROP")}    {dim("->")} {cyan("ZT")} {dim("(codebook)")}')
    print(f'  {dim("TOMORROW")}     {dim("->")} {cyan("ZO")} {dim("(codebook)")}')
    print(f'  {dim("After codebook:")} {bright(coded)}')
    print(f'  {dim("Compression:")} {green(f"{len(sample)} chars -> {len(coded.replace(chr(32), str()))} chars")}')
    input(f'\n  {dim("ENTER to continue...")}')

    # ── Stave 2: Vernier Encoding ───────────────────────────────────────
    print(f'\n  {orange("STAVE 2")}  {dim("/")}  Vernier variable-length encoding')
    _rule()
    stripped = coded.replace(' ', '')
    print(f'  {dim("Input:")} {bright(stripped)}')
    print()

    # Show per-character encoding
    encoded: List[int] = []
    for ch in stripped.upper():
        d = encode_char(ch)
        encoded.extend(d)
        code_str = ''.join(str(x) for x in d)
        length_desc = {1: 'single', 2: 'double', 3: 'triple'}.get(len(d), '?')
        tier = ''
        if ch in PRIMARY:    tier = 'primary (0-7)'
        elif ch in SECONDARY: tier = 'secondary (8x)'
        elif ch in TERTIARY:  tier = 'tertiary (9x)'
        print(f'    {bright(ch)}  {dim("->")}  {cyan(code_str):>5}  '
              f'{dim(f"[{length_desc}, {tier}]")}')

    # Append end-of-message marker
    content = encoded + ZM_DIGITS
    print(f'\n  {dim("End-of-message marker (ZM):")} {cyan("978531")}')
    print(f'\n  {dim("Full digit stream:")}')
    _print_row('ENCODED', content, cyan)
    print(f'  {dim("Digit count:")} {gold(str(len(content)))}')
    print(f'\n  {dim("Parsing rule:")} {green("0-7 = 1 digit  |  8x = 2 digits  |  9(0-8) = 2 digits  |  99x = 3 digits")}')
    input(f'\n  {dim("ENTER to continue...")}')

    # ── Block class selection ────────────────────────────────────────────
    print(f'\n  {orange("BLOCK")}  {dim("/")}  Select block class')
    _rule()
    cls = select_block_class(len(content))
    bc  = BLOCK_CLASSES[cls]
    bsize  = bc['size']
    prefix = bc['prefix']
    null_n = bsize - 2 - len(content)
    print(f'  {dim("Content:")} {gold(str(len(content)))} digits')
    print(f'  {dim("Block class:")} {green(cls)} ({bsize} digits, {bc["groups"]} groups)')
    print(f'  {dim("Prefix:")} {cyan("".join(str(d) for d in prefix))}')
    print(f'  {dim("Null fill:")} {gold(str(null_n))} random digits')

    # Build full plaintext
    plain_active = prefix + content

    # ── Stave 5: Keyword ─────────────────────────────────────────────────
    print(f'\n  {orange("STAVE 5")}  {dim("/")}  Keyword defense')
    _rule()
    print(f'  {dim("Keyword:")} {bright(keyword)}')
    kw_stream = keyword_to_stream(keyword, len(plain_active))
    print(f'  {dim("Keyword -> digit stream:")}')
    kw_preview = ' '.join(str(d) for d in kw_stream[:20])
    print(f'    {blue(kw_preview)} {dim("... (lagged Fibonacci, non-repeating)")}')
    print()
    print(f'  {dim("Before keyword:")}')
    _print_row('PLAIN', plain_active, dim)
    print(f'  {dim("Keyword stream:")}')
    _print_row('KW',    kw_stream[:len(plain_active)], blue)

    plain_kw = [add_mod10(p, k) for p, k in zip(plain_active, kw_stream)]
    print(f'  {dim("After keyword (P + KW) mod 10:")}')
    _print_row('P+KW', plain_kw, purple)
    input(f'\n  {dim("ENTER to continue...")}')

    # ── Stave 3: One-Time Pad ────────────────────────────────────────────
    print(f'\n  {orange("STAVE 3")}  {dim("/")}  One-time pad encryption')
    _rule()
    print(f'  {dim("Generating")} {gold(str(bsize))} {dim("truly random key digits...")}')
    key_digits = collect_entropy(bsize)
    print()

    # Active encryption
    key_active    = key_digits[:len(plain_kw)]
    cipher_active = encrypt_stream(plain_kw, key_active)

    # Null fill (random)
    null_key     = key_digits[len(plain_kw):]
    cipher_null  = null_key[:]  # (0 + k) mod 10 = k

    cipher_full  = cipher_active + cipher_null

    print(f'  {dim("P+KW digits:")}')
    _print_row('P+KW', plain_kw, purple)
    print(f'  {dim("OTP key:")}')
    _print_row('KEY',  key_digits, blue)
    print(f'  {dim("Cipher (P+KW + KEY) mod 10:")}')
    _print_row('CIPHER', cipher_full, green)
    print()
    print(f'  {dim("Null-filled:")} {gold(str(null_n))} {dim("digits at end are indistinguishable from ciphertext")}')
    input(f'\n  {dim("ENTER to continue...")}')

    # ── Stave 4: Block formatting ────────────────────────────────────────
    print(f'\n  {orange("STAVE 4")}  {dim("/")}  Fixed-size block discipline')
    _rule()
    print(f'  {dim("All messages are")} {gold(str(bsize))} {dim("digits regardless of content length.")}')
    print(f'  {dim("An eavesdropper cannot determine message length.")}')
    print()
    print(f'  {gold("CIPHERTEXT FOR TRANSMISSION:")}')
    print()
    groups_str = _groups(cipher_full)
    print(f'  {green(groups_str)}')
    print()
    verify = cipher_full[:5]
    print(f'  {gold("VERIFY STRIP")} {dim("(first 5):")} {orange(_groups(verify))}')
    print(f'  {dim("Class:")} {green(cls)}  {dim("Digits:")} {gold(str(len(cipher_full)))}  '
          f'{dim("Groups:")} {gold(str(bc["groups"]))}')

    _rule()
    print(f'\n  {bright("Encryption complete. All 5 layers applied.")}')
    print(f'  {dim("The ciphertext above is what gets transmitted.")}')
    input(f'\n  {dim("ENTER for decryption...")}')

    # ══════════════════════════════════════════════════════════════════════
    # DECRYPTION — reverse all layers
    # ══════════════════════════════════════════════════════════════════════

    _clear()
    _header('AUTOMATIC DEMO — DECRYPTION')

    print(f'  {orange("═══ DECRYPTION ═══")}')
    print(f'\n  {dim("Received ciphertext:")}')
    print(f'  {green(groups_str)}')
    print(f'  {dim("Key material (from pad sheet):")}')
    _print_row('KEY', key_digits, blue)

    # ── Strip OTP ────────────────────────────────────────────────────────
    print(f'\n  {orange("STAVE 3")}  {dim("/")}  Remove one-time pad')
    _rule()
    decrypted_full = decrypt_stream(cipher_full, key_digits)
    print(f'  {dim("(CIPHER - KEY + 10) mod 10:")}')
    _print_row('DEC', decrypted_full, purple)

    # ── Strip keyword ────────────────────────────────────────────────────
    print(f'\n  {orange("STAVE 5")}  {dim("/")}  Remove keyword')
    _rule()
    print(f'  {dim("Keyword:")} {bright(keyword)}')
    kw_full = keyword_to_stream(keyword, len(decrypted_full))
    plain_recovered = [sub_mod10(d, k) for d, k in zip(decrypted_full, kw_full)]
    print(f'  {dim("(DEC - KW + 10) mod 10:")}')
    _print_row('PLAIN', plain_recovered, cyan)

    # ── Parse prefix and content ─────────────────────────────────────────
    print(f'\n  {orange("BLOCK")}  {dim("/")}  Parse block prefix')
    _rule()
    pfx = plain_recovered[:2]
    body = plain_recovered[2:]
    pfx_str = ''.join(str(d) for d in pfx)
    detected_cls = None
    for k, v in BLOCK_CLASSES.items():
        if v['prefix'] == pfx:
            detected_cls = k
            break
    print(f'  {dim("Prefix:")} {cyan(pfx_str)} {dim("-> class")} {green(detected_cls or "?")}')

    # ── RADIX decode ─────────────────────────────────────────────────────
    print(f'\n  {orange("STAVE 2")}  {dim("/")}  Vernier decode')
    _rule()
    decoded, consumed = decode_digits(body)
    print(f'  {dim("Digit stream -> characters:")}')

    # Show the parsing process
    pos = 0
    parse_body = body[:consumed] if consumed > 0 else body
    while pos < len(parse_body):
        d0 = parse_body[pos]
        if d0 <= 7:
            ch = _REV_PRIMARY.get(d0, '?')
            print(f'    {cyan(str(d0)):>8}  {dim("->")}  {bright(ch)}  {dim("[single digit, primary]")}')
            pos += 1
        elif d0 == 8:
            if pos + 1 < len(parse_body):
                code = d0 * 10 + parse_body[pos + 1]
                ch = _REV_SECONDARY.get(code, '?')
                print(f'    {cyan(str(d0) + str(parse_body[pos+1])):>8}  {dim("->")}  {bright(ch)}  {dim("[two digit, secondary]")}')
                pos += 2
            else:
                break
        elif d0 == 9:
            if pos + 1 < len(parse_body):
                d1 = parse_body[pos + 1]
                if d1 <= 8:
                    code = d0 * 10 + d1
                    ch = _REV_TERTIARY.get(code, '?')
                    if ch == '?' and code == 97 and pos + 2 < len(parse_body) and parse_body[pos+2] == 8 and pos + 3 < len(parse_body) and parse_body[pos+3] == 5:
                        # ZM marker
                        print(f'    {cyan("978531"):>8}  {dim("->")}  {gold("ZM")}  {dim("[end-of-message]")}')
                        break
                    print(f'    {cyan(str(d0) + str(d1)):>8}  {dim("->")}  {bright(ch)}  {dim("[two digit, tertiary]")}')
                    pos += 2
                elif d1 == 9:
                    if pos + 2 < len(parse_body):
                        numeral = parse_body[pos + 2]
                        print(f'    {cyan(str(d0) + str(d1) + str(numeral)):>8}  {dim("->")}  {bright(str(numeral))}  {dim("[three digit, numeral]")}')
                        pos += 3
                    else:
                        break
            else:
                break
        else:
            pos += 1

    print(f'\n  {dim("Decoded text:")} {bright(decoded)}')

    # ── Codebook expansion ───────────────────────────────────────────────
    print(f'\n  {orange("STAVE 1")}  {dim("/")}  Codebook expansion')
    _rule()
    expanded = decoded
    for code, phrase in CODEBOOK.items():
        if code in expanded and code != 'ZM':
            expanded = expanded.replace(code, phrase)
            print(f'    {cyan(code)}  {dim("->")}  {bright(phrase)}')
    print(f'\n  {dim("Final plaintext:")} {bright(expanded)}')

    # ── Verify ───────────────────────────────────────────────────────────
    _rule()
    original_clean = sample.replace(' ', '').upper()
    recovered_clean = expanded.replace(' ', '').upper()
    if original_clean == recovered_clean:
        print(f'\n  {green("✓ INTEGRITY VERIFIED")} — decrypted plaintext matches original')
    else:
        print(f'\n  {red("✗ MISMATCH")}')
        print(f'    {dim("Original:")}  {sample}')
        print(f'    {dim("Recovered:")} {expanded}')

    print(f'\n  {dim("All 5 layers reversed successfully.")}')
    print(f'  {dim("The cipher is information-theoretically secure: no computation,")}')
    print(f'  {dim("classical or quantum, can break it without the key material.")}')
    _wait()


# ═══════════════════════════════════════════════════════════════════════════
# MAIN MENU
# ═══════════════════════════════════════════════════════════════════════════

MENU_ITEMS = [
    ('1', 'Encrypt message',         encrypt_workflow,    bright),
    ('2', 'Decrypt message',         decrypt_workflow,    bright),
    (None, None, None, None),
    ('3', 'Quick encode (no OTP)',   quick_encode,        dim),
    ('4', 'Quick decode (no OTP)',   quick_decode,        dim),
    ('5', 'Keyword stream preview',  keyword_tool,        dim),
    (None, None, None, None),
    ('6', 'Encoding table',          show_encoding_table, dim),
    ('7', 'Codebook',                show_codebook,       dim),
    ('8', 'Block classes',           show_block_classes,  dim),
    ('9', 'Mod-10 cipher table',     show_mod_table,      dim),
    (None, None, None, None),
    ('D', 'Demo (full cycle)',       demo_workflow,       gold),
    ('F', 'Field card + pad sheets', generate_field_card, gold),
    ('Q', 'Quit',                    None,                gray),
]


def main():
    try:
        while True:
            _clear()
            _header()
            for key, label, _, color_fn in MENU_ITEMS:
                if key is None:
                    print(dim('  ' + '\u2500' * 32))
                else:
                    print(f'  {gold(key)}  {color_fn(label)}')
            print()

            choice = input(f'  {gold("> ")}').strip().lower()

            for key, _, fn, _ in MENU_ITEMS:
                if key and choice == key.lower():
                    if fn:
                        fn()
                    break
            else:
                if choice in ('exit', 'quit', 'q'):
                    break

    except KeyboardInterrupt:
        pass
    finally:
        _clear()
        print(f'\n  {dim("Vernier // session ended cleanly")}\n')
        sys.exit(0)


if __name__ == '__main__':
    if '--demo' in sys.argv:
        demo_workflow()
    else:
        main()
Hand Cipher Systems // Synthesis Document
Vernier
A Five-Stave Cipher System
// REV 1.1   BEST-OF-BREED HYBRID // VARIABLE BLOCK CLASSES

A synthesis of VIC cipher straddling checkerboard efficiency, SOE Worked Out Key direct-letter philosophy, mod-10 arithmetic simplicity, and operational codebook compression. Designed from first principles around three criteria: unambiguous parsing under stress, minimum mental operations per character, and layered physical and cryptographic security. Full information-theoretic security. No electronics required.

THREAT MODELNATION-STATE
ELECTRONICSNONE
SECURITYINFO-THEORETIC
ARITHMETICMOD-10
KEY TYPEONE-TIME PAD
LAYERS5

01 Why a New System

Every existing hand cipher system trades one problem for another. This document derives a synthesis by identifying the specific failure modes of each approach and taking only what each does best.

SystemWhat It Does BestIts Failure Mode
Soviet VIC / Mod-10Simple arithmetic (one 10x10 table)Encoding step (A=01) is slow and error-prone
SOE WOK / Mod-26No encoding step -- letters in, letters out26x26 Vigenere table is large and slow under stress
Straddling CheckerboardFrequency-weighted single-digit codesAmbiguous parsing when prefix digits overlap with single-digit codes
LC4Stateful -- no key material consumedComplex operations, high error rate under stress
SolitaireKey is a deck of cards -- innocuous~8 operations per character, extreme error rate
Previous hybrid (this doc series)Checkerboard + mod-10 combinedRow 0 digits (1,2,3) overlap with row prefix digits (1,2,3) -- ambiguous

The Core Design Problem

The previous system in this series had a subtle but serious flaw: Row 0 used single digits 1, 2, 3 for E, T, A -- but 1, 2, 3 were also row prefixes for two-digit codes. Decoding required lookahead: "is this 1 the letter E, or the start of a code like 15=M?" Under stress, that decision is a source of error.

Vernier solves this with a single rule that requires no lookahead and no decision-making:

The Parsing Rule If the first digit is 0 through 7: the code is complete. One digit.
If the first digit is 8: read one more digit. Two digits total.
If the first digit is 9, second digit 0-8: two digits total.
If the first digit is 9, second digit 9: read one more digit. Three digits total (numeral).

At every position you make exactly one check. No lookahead. No ambiguity.

02 System Stack Overview

1
CODEBOOK
Replaces common phrases with agreed abbreviations before encoding. Optional but significant.
EFFICIENCY
2
RADIX ENCODING
Converts characters to digit stream via unambiguous frequency-weighted mapping.
OPERABILITY
3
MOD-10 OTP
Adds one-time key digit stream mod-10. Information-theoretic security layer.
CRYPTOGRAPHIC
4
BLOCK DISCIPLINE
Fixed 200-digit blocks. All messages padded identically. Eliminates length as signal.
TRAFFIC
5
KEYWORD DEFENSE
Memorized keyword stream applied before OTP. Protects against pad physical compromise.
PHYSICAL

Each layer addresses a different threat. Layers 1 and 4 address operational efficiency and traffic analysis. Stave 2 addresses speed and error rate. Stave 3 provides cryptographic security. Stave 5 provides physical security when key material may be at risk. Layers 3 through 5 together mean an adversary must simultaneously defeat a cryptographic proof, recover physical key material, and extract a memorized secret to read traffic.

Precedence Rule The cipher (Stave 3) is never the failure point. In every historically documented OTP compromise, the failure was operational: key reuse, retained material, or human intelligence. The rules in Section 13 address exactly those failures. No cryptographic sophistication substitutes for operational discipline.

03 Stave 1 -- Codebook

Stave 1 // Efficiency // Pre-Encoding

The codebook operates before encoding. It replaces standard operational phrases with short letter codes, which then go through normal RADIX encoding. This is a human judgment step, not an automatic substitution. The encoder decides when a phrase matches a codebook entry closely enough to apply it.

Design Choice: Letter Codes, Not Digit Codes

Previous iterations used digit codes (9001, 9002, etc.) for the codebook. Vernier uses two-letter codes instead. This eliminates the need for a separate codebook prefix digit and allows codebook entries to flow through the same RADIX encoding as the rest of the message. The two-letter codes are chosen from rare letter pairs that do not appear in normal English text.

The letter pair Z followed by any letter signals a codebook entry. Z is the rarest letter in English (0.07% frequency). ZZ, ZA, ZB, etc. are essentially nonexistent in operational vocabulary. After decoding, the receiver scans for any Z and treats Z+letter as a codebook lookup.

CodeMeaningCodeMeaning
ZACONFIRMED / YES ZNMEET
ZBABORT / CANCEL ZOTOMORROW
ZCPROCEED ZPTODAY
ZDDANGER / COMPROMISED ZQLOCATION FOLLOWS
ZEDELAY ZRTIME FOLLOWS
ZFCOMPLETE / DONE ZSSWITCH TO ALTERNATE
ZGCONFIRM LAST -- previous msg received correctly ZTDEAD DROP
ZHEMERGENCY ZUSURVEILLANCE DETECTED
ZINOTHING TO REPORT / NO CHANGE ZVCLEAR / ALL CLEAR
ZJAS PLANNED ZWRECEIVED AND UNDERSTOOD
ZKPRIMARY LOCATION ZXDOCUMENT FOLLOWS
ZLALTERNATE LOCATION ZYCOORDINATES FOLLOW
ZMEND OF MESSAGE (reserved -- never reassign) ZZNAME FOLLOWS
ZG / ZI / ZW are revised from previous version. ZG=CONFIRM LAST enables check-in circuit. ZI=NOTHING TO REPORT covers routine nil-report messages. ZW=RECEIVED AND UNDERSTOOD disambiguates from ZA=CONFIRMED (which confirms an instruction, not a receipt).

Encoding of Codebook Entries

Z is encoded as 97 (tertiary position 7). The following letter uses its normal RADIX code. A two-letter codebook entry costs 3-4 digits versus 10-40 digits for the full phrase.

ZA (CONFIRMED): Z=97  A=2       --> 3 digits
ZD (DANGER):    Z=97  D=81      --> 4 digits
ZU (SURV DET):  Z=97  U=84      --> 4 digits
ZN (MEET):      Z=97  N=5       --> 3 digits

Example message using only codebook:
"SURVEILLANCE DETECTED ABORT SWITCH TO ALTERNATE MEET TOMORROW"
= ZU ZB ZS ZN ZO
= 97 84 | 97 91 | 97 S=6 | 97 5 | 97 O=3
= 9784 9791 976 975 973
= 19 digits

Fully spelled out (no codebook):
= ~130 digits

Savings: ~85%

The ZM End Marker

ZM (Z=97, M=85) marks the end of real content. Everything after ZM in the block is null padding. Receiver stops decoding at ZM. This replaces the previous 9000 convention with a letter-space marker that flows naturally through the encoding system.

The Routine Check-In Circuit

Three codebook entries cover the entire class of routine check-in messages, enabling a nil-report or acknowledgment circuit that costs one Short block (50 digits) regardless of operational status:

Sender: nothing to report    -->  ZI ZM  (encrypted, Short block, 50 digits)
Receiver confirms receipt    -->  ZW ZM  (encrypted, Short block, 50 digits)
Sender asks: did you get it? -->  ZG ZM  (encrypted, Short block, 50 digits)

A pure check-in:   ZI ZM = 97 4 97 85 = 8 digits content, padded to 50
An acknowledgment: ZW ZM = 97 86 97 85 = 8 digits content, padded to 50

An adversary observing traffic sees two 50-digit Short block transmissions.
They cannot distinguish ZI (nothing to report) from ZH (emergency) by length.
Both fit in a Short block. Both look identical.
Exercise 3.1 -- Codebook Application
EASY

Encode the following operational intent using codebook entries (refer to the table above). Then determine how many digits the codebook version produces versus the spelled-out version.

Intent: Danger. Abort. Proceed to alternate location. Confirmed.
Codebook version:
  DANGER              = ZD
  ABORT               = ZB
  PROCEED             = ZC
  ALTERNATE LOCATION  = ZL
  CONFIRMED           = ZA
  END                 = ZM

Pre-encoding string: ZD ZB ZC ZL ZA ZM

RADIX encoding:
  Z=97  D=81  --> 9781
  Z=97  B=91  --> 9791
  Z=97  C=83  --> 9783
  Z=97  L=82  --> 9782
  Z=97  A=2   --> 972
  Z=97  M=85  --> 978531

Digit string: 9781 9791 9783 9782 972 978531
= 23 digits

Fully spelled (DANGERABORTPROCEEDTOALTERNATELOCATIONCONFIRMED):
D=81 A=2 N=5 G=88 E=0 R=80 = 6 codes...
~50 chars x ~1.5 avg = ~75 digits

Savings: ~70%

04 Stave 2 -- RADIX Encoding

Stave 2 // Operability // Character to Digit

RADIX encoding converts plaintext characters to a digit stream using a three-tier frequency-weighted system. The critical innovation over all previous approaches: the digit that begins any code uniquely determines how many more digits to read. Zero decision-making required.

Space Handling

RADIX strips spaces before encoding. Words run together in the digit stream. The receiver infers word boundaries from context after decoding -- this was standard practice in telegraph and cipher communications for over a century. Removing space encoding eliminates one code type entirely and reduces average digit length.

"MEET AT DAWN" --> strip spaces --> "MEETATDAWN" --> encode

The Encoding Table

PRIMARY -- digits 0-7 -- one digit each -- top 8 English letters
E
0
T
1
A
2
O
3
I
4
N
5
S
6
H
7
SECONDARY -- prefix 8 -- two digits each (80-89)
R
80
D
81
L
82
C
83
U
84
M
85
W
86
F
87
G
88
Y
89
TERTIARY -- prefix 9 -- two or three digits (90-99)
P
90
B
91
V
92
K
93
J
94
X
95
Q
96
Z
97
.
98
NUM
99+
NUMERALS -- prefix 99 -- three digits each (990-999)
0
990
1
991
2
992
3
993
4
994
5
995
6
996
7
997
8
998
9
999

The Parsing Rule -- Visual

See digit d:

  d in {0,1,2,3,4,5,6,7}  ──>  DONE. Single-digit code.
                                E T A O I N S H

  d = 8                    ──>  Read one more digit x.
                                Look up 8x in secondary table.
                                R D L C U M W F G Y

  d = 9, next digit 0-8   ──>  Two digits 9x.
                                Look up in tertiary table.
                                P B V K J X Q Z .

  d = 9, next digit = 9   ──>  Read one MORE digit x.
                                That digit x IS the numeral.
                                990=0 991=1 ... 999=9

Mnemonics

PRIMARY (0-7):
  E T A O I N S H
  "Eat Tangerines And Oranges In Nice Spanish Hotels"
  The classic English frequency sequence. Already memorized by most cipher students.

SECONDARY (8x, 80-89):
  R D L C U M W F G Y
  "Really Dull Lessons Can Undermine Many Willing Field Graduates, Yes"
  Next 10 letters by frequency. R is 9th most common in English.

TERTIARY (9x, 90-98):
  P B V K J X Q Z .
  "Please Be Very Kind, Just eXercise Quietly, Zealously."
  Rare letters in rough frequency order. Period at the end.

NUMERALS (99x):
  990-999 = digits 0-9. Just add 990.
  "Ninety-nine plus the digit."

Efficiency Analysis

English letter frequencies in typical text:

  Primary (ETAOINSH):   ~57% of letters x 1 digit  = 0.57 avg digits
  Secondary (RDLCUMWFGY): ~26% of letters x 2 digits = 0.52 avg digits
  Tertiary (PBVKJXQZ.):   ~17% of letters x 2 digits = 0.34 avg digits

  Average (letter-only text): ~1.43 digits per character

Compare:
  A=01 simple encoding:    2.00 digits/char
  Previous row system:     1.35 digits/char (but ambiguous parsing)
  RADIX (letter only):     1.43 digits/char (unambiguous parsing)
  RADIX with codebook:    ~0.4 digits/char for codebook-heavy messages

RADIX trades 0.08 digits/char for complete parsing unambiguity.
That is the right trade for stress operation.
Exercise 4.1 -- Primary Tier Drill
EASY

Encode the following words using only primary-tier codes (all letters should be from ETAOINSH). Time yourself. Target: under 5 seconds per word after practice.

INTENSE  ANTIHERO  NOTATION  SENTHRONE  NINETEEN
INTENSE:   I=4 N=5 T=1 E=0 N=5 S=6 E=0       --> 4510560   (7 digits, 7 chars)
ANTIHERO:  A=2 N=5 T=1 I=4 H=7 E=0 R=80 O=3  --> 25147 0803 (9 digits, 8 chars)
           (R is secondary -- 80)
NOTATION:  N=5 O=3 T=1 A=2 T=1 I=4 O=3 N=5   --> 53121435  (8 digits, 8 chars)
SENTHRONE: S=6 E=0 N=5 T=1 H=7 R=80 O=3 N=5 E=0 --> 60517 8035 0 (10 digits, 9 chars)
NINETEEN:  N=5 I=4 N=5 E=0 T=1 E=0 E=0 N=5   --> 54501005  (8 digits, 8 chars)

Note: ANTIHERO and SENTHRONE contain R (secondary).
Average efficiency for all-primary words: 1.0 digit/char.
Exercise 4.2 -- Mixed Tier Encoding
EASY

Encode the following phrase. Strip spaces first. Identify which tier each letter falls into before writing its code.

REPORT CONFIRMED
Strip spaces: REPORTCONFIRMED

R = 80  (secondary)
E = 0   (primary)
P = 90  (tertiary)
O = 3   (primary)
R = 80  (secondary)
T = 1   (primary)
C = 83  (secondary)
O = 3   (primary)
N = 5   (primary)
F = 87  (secondary)
I = 4   (primary)
R = 80  (secondary)
M = 85  (secondary)
E = 0   (primary)
D = 81  (secondary)

Digit string: 80 0 90 3 80 1 83 3 5 87 4 80 85 0 81
Grouped by 5: 80090 38013 33587 48085 081

15 characters --> 23 digits
Average: 1.53 digits/char (slightly above baseline due to R appearing 3x)
Exercise 4.3 -- Decode Digit Stream
MEDIUM

Decode the following digit stream back to plaintext. Apply the parsing rule at each digit: 0-7 = done; 8 = read one more; 9 then 0-8 = two digits; 99 = read one more for numeral. Insert word breaks by inference.

6 0 5 81 80 3 90 4 5 1 0 81
6  --> S (primary)
0  --> E (primary)
5  --> N (primary)
81 --> D (secondary: 8, then 1 = position 1 = D)
80 --> R (secondary: 8, then 0 = position 0 = R)
3  --> O (primary)
90 --> P (tertiary: 9, then 0 = position 0 = P)
4  --> I (primary)
5  --> N (primary)
1  --> T (primary)
0  --> E (primary)
81 --> D (secondary)

Decoded: SENDROPPINTED? Reparse for word breaks:
  SEND REPORTED? 
  S E N D R O P I N T E D

Plaintext: SEND ROINTED

Wait -- SENDR + OPIN + TED?
Let's try: S-E-N-D | R-O-P-I-N-T-E-D = SEND ROINTED
Or:        S-E-N-D-R-O-P-I-N-T-E-D = SENDROPIN TED?

Most natural: SEND REPORTED... but P not R at position 6.
Actual sequence: S E N D R O P I N T E D
= "SEND ROPIN TED" -- likely "SEND DROP INTENDED" with some letters elided
or simply "SENDROPINTED" decoded exactly as above, word breaks from context.
Exercise 4.4 -- Numerals in Stream
MEDIUM

Encode the following string including numerals. Confirm each numeral costs exactly 3 digits (99 + the digit).

NORTH AT 0600
Strip spaces: NORTHAT0600

N=5 O=3 R=80 T=1 H=7 A=2 T=1
0=990 6=996 0=990 0=990

Digit string: 5 3 80 1 7 2 1 990 996 990 990
Grouped:      53801 72199 09969 9099 0

11 characters --> 18 digits
Letters only (7 chars): 9 digits = 1.29 digits/char
Numerals (4 chars):     12 digits = 3 digits/char
Combined: 18/11 = 1.64 digits/char

Numerals inflate the average significantly.
Use codebook entries for times where possible (ZR = TIME FOLLOWS,
then spell only the actual time in numerals).

05 Stave 3 -- Mod-10 OTP

Stave 3 // Cryptographic // Information-Theoretic Security

The cipher operation is unchanged from the Soviet standard: modular addition in base 10, no carries. The key material is a truly random digit stream generated by d10 dice and recorded on a one-time pad sheet. This layer provides information-theoretic security -- not computational security. No amount of compute can break it if the key is random and used once.

ENCRYPT:  C = (P + K) mod 10
DECRYPT:  P = (C - K + 10) mod 10

The Cipher Table

P\K 01234 56789
00123456789
11234567890
22345678901
33456789012
44567890123
55678901234
66789012345
77890123456
88901234567
99012345678

Row = plaintext digit, column = key digit, cell = cipher digit. The shaded diagonal (P=K) marks zero-key positions where ciphertext equals plaintext. This is not a flaw -- a zero key digit is as probable as any other, and its occurrence is not visible in the ciphertext stream.

Decryption Using the Same Table

Given: C=3, K=7
Find row K=7: 7 8 9 0 1 2 3 4 5 6
Locate C=3 in that row: position 6
Column header = 6 = plaintext digit

Verify: (6+7) mod 10 = 13 mod 10 = 3. Correct.

The table is its own inverse. Encryption and decryption use
identical lookup mechanics -- find key row, find value in row,
read column header.

Key Material Generation

Requirements:
  d10 dice (precision casino-grade preferred)
  One die = one digit per roll
  Record immediately, do not group mentally first
  Generate full block (200 digits) before writing message
  Never reject a valid roll for any reason
  Both parties generate identical copies (carbon / simultaneous)
  Verify digit-for-digit after generation
Exercise 5.1 -- Mod-10 Table Speed Drill
EASY

Using only the table (no mental arithmetic), encrypt and decrypt the following digit pairs. Do not count -- look up. Time yourself. Target: under 2 seconds per pair.

Encrypt: P=3, K=8  -->  C=?
Encrypt: P=7, K=7  -->  C=?
Encrypt: P=0, K=9  -->  C=?
Encrypt: P=9, K=1  -->  C=?
Decrypt: C=2, K=5  -->  P=?
Decrypt: C=0, K=3  -->  P=?
Decrypt: C=5, K=9  -->  P=?
Encrypt P=3, K=8: row 3, col 8 = 1   | verify: (3+8)%10=1 ✓
Encrypt P=7, K=7: row 7, col 7 = 4   | verify: (7+7)%10=4 ✓
Encrypt P=0, K=9: row 0, col 9 = 9   | verify: (0+9)%10=9 ✓
Encrypt P=9, K=1: row 9, col 1 = 0   | verify: (9+1)%10=0 ✓
Decrypt C=2, K=5: row 5, find 2 --> col 7 = P=7   | verify: (7+5)%10=2 ✓
Decrypt C=0, K=3: row 3, find 0 --> col 7 = P=7   | verify: (7+3)%10=0 ✓
Decrypt C=5, K=9: row 9, find 5 --> col 6 = P=6   | verify: (6+9)%10=5 ✓

06 Stave 4 -- Block Discipline

Stave 4 // Traffic Analysis // Variable Block Classes

Fixed 200-digit blocks are blunt. A 3-digit codebook message padded to 200 wastes 194 digits of key material and 194 digits of transmission time. Vernier uses three agreed block classes, selected by the encoder based on message length. The class is signaled by a two-digit encrypted prefix at the start of every transmission.

Why This Works An adversary watching traffic sees three possible message sizes. That reveals approximately 1.58 bits of information per message -- short, medium, or long. That is acceptable. What they cannot see is where within each class the actual content ends. A 3-digit codebook message and a 44-digit mixed message both produce identical 50-digit Short block transmissions.

The Three Block Classes

ClassPrefix (plaintext)Block SizeMax ContentDice RollsGroups
S -- SHORT 0050 digits 44 digits (50 - 2 prefix - 4 ZM) 5010
M -- MEDIUM 01100 digits 94 digits (100 - 2 prefix - 4 ZM) 10020
L -- LONG 02200 digits 194 digits (200 - 2 prefix - 4 ZM) 20040

Block Structure

[OFFSET 2][SALT 4][RANDOM FILL][CONTENT][ZM 978531][PADDING] = BLOCK SIZE (25, 50, or 100)

Short:   00 [content up to 44 digits] [978531] [null fill] = 50 digits
Medium:  01 [content up to 94 digits] [978531] [null fill] = 100 digits
Long:    02 [content up to 194 digits][978531] [null fill] = 200 digits

The prefix is encrypted as part of the block.
The receiver decrypts digits 1-2, reads the class,
then decrypts the remainder of that class size.

The Decision Rule

Encode the message content first (without prefix). Count the digits. Then select the class. This means encoding happens before key generation -- you need the block size before you know how many dice to roll.

Content 0 - 44 digits    -->  Short   (50 rolls)
Content 45 - 94 digits   -->  Medium  (100 rolls)
Content 95 - 194 digits  -->  Long    (200 rolls)
Over 194 digits          -->  Split across two pad sheets
Revised Procedure Order This changes the encryption procedure. Step order is now:
1. Apply codebook, encode, count digits, select class
2. Roll dice for that class size only
3. Prepend class prefix, encrypt, transmit

The old Rule 6 ("generate key before knowing the message") still applies in spirit -- you encode first but do not begin adding key digits until the full encoding is written. The key is not influenced by plaintext content, only by its length class.

Typical Message Classes

SHORT (most common with codebook):
  Routine nil-report:     ZI ZM          = 8 digits   Short
  Single acknowledgment:  ZW ZM          = 8 digits   Short
  Emergency abort:        ZH ZB ZM       = 11 digits  Short
  Confirmed meet:         ZN ZA ZM       = 10 digits  Short
  Full check-in:          ZN ZP ZK ZA ZM = 16 digits  Short

MEDIUM (mixed codebook + spelled):
  Meet tomorrow north gate at 0800:      ~30 digits   Short
  Report with two location references:   ~55 digits   Medium
  Message with names/callsigns:          ~70 digits   Medium

LONG (heavily spelled or numeric):
  Detailed coordinates + instructions:   ~120 digits  Long
  Multi-part report with numerals:       ~150 digits  Long

Null Fill Source

The null fill after ZM is the continuation of the actual key digit stream. The receiver decrypts it and gets noise, but stops at ZM. Null digits cost key material but are already generated as part of the block roll -- no extra work.

When to Use Fixed 200

If operating against an adversary with deep traffic analysis capability who could correlate message class against known events, revert to a single fixed 200-digit block. Security always beats efficiency at the top of the threat model. The variable class system is the default for most operational scenarios.

07 Stave 5 -- Keyword Defense

Stave 5 // Physical Security // Pad Compromise Defense

If an adversary photographs or copies your pad sheet without your knowledge, the OTP layer alone no longer provides security -- they have the key. Stave 5 adds a memorized keyword stream that is applied before the OTP addition. The keyword lives only in memory, never on any physical medium.

Threat Model for This Layer This layer is specifically for scenarios where pad sheets may be stored somewhere (a waterproof tube, a cache, a safe house) rather than generated and immediately used. If pads are generated in person immediately before use and destroyed immediately after, Stave 5 adds negligible benefit. If pads must be pre-generated and stored, Stave 5 is essential.

Keyword to Digit Stream

Convert any agreed word to a cycling digit stream using position in the alphabet mod 10. This is done once, memorized, and the paper is destroyed.

Conversion: position in alphabet mod 10
A=1 B=2 C=3 D=4 E=5 F=6 G=7 H=8 I=9 J=0
K=1 L=2 M=3 N=4 O=5 P=6 Q=7 R=8 S=9 T=0
U=1 V=2 W=3 X=4 Y=5 Z=6

Example keyword: SEDATION
S=9 E=5 D=4 A=1 T=0 I=9 O=5 N=4

Keyword digit stream: 9 5 4 1 0 9 5 4 | 9 5 4 1 0 9 5 4 | ...
(cycles from the beginning when exhausted)

Combined Operation

ENCRYPT:
  Step 1: Encode plaintext --> digit stream P
  Step 2: Add keyword stream K1 mod-10 --> intermediate I
  Step 3: Add OTP key stream K2 mod-10 --> ciphertext C

  C = (P + K1 + K2) mod 10

DECRYPT:
  Step 1: Subtract OTP key stream K2 mod-10 --> intermediate I
  Step 2: Subtract keyword stream K1 mod-10 --> plaintext P

  P = (C - K2 - K1 + 20) mod 10

  (The +20 ensures no negative values before mod)
Warning -- Keyword Secrecy The keyword is never written on the pad sheet, never stored anywhere, and never transmitted. If forgotten, messages encrypted with it are unrecoverable even with the pad. Both parties must memorize the keyword from the same source and verify they have the same digit stream before operational use. Test on a practice message before relying on it.

Keyword Choice Criteria

Must:
  Be memorizable under stress
  Produce non-obvious digit stream (avoid AAAA, ABCD patterns)
  Be 6-12 characters (longer cycles are better)
  Contain no repeated letters (optional but cleaner)

Avoid:
  Names, dates, places associated with you or the operation
  Words appearing in any document you carry
  Common words that an adversary might guess systematically

Good candidates: SEDATION, OUTSHINE, HOARIEST, EARTHLINGS
Test: convert to digit stream, check for obvious runs (0000, 1234)
Exercise 7.1 -- Keyword Stream Generation
EASY

Convert the keyword OUTSHINE to its digit stream. Then write out the first 20 digits of the cycling stream.

Keyword: OUTSHINE
O=5 U=1 T=0 S=9 H=8 I=9 N=4 E=5

Keyword stream: 5 1 0 9 8 9 4 5
Cycling 20:     5 1 0 9 8 9 4 5 | 5 1 0 9 8 9 4 5 | 5 1 0 9

Check for runs: no four identical consecutive digits. Good.
Entropy appears distributed. Acceptable keyword.

08 Encryption Procedure

09 Decryption Procedure

10 Worked Examples

Example A -- Codebook-Heavy Message

// EXAMPLE A // "SURVEILLANCE DETECTED. ABORT. SWITCH TO ALTERNATE. CONFIRM."
INTENT
Surveillance detected. Abort operation. Switch to alternate location. Confirmed.
CODEBOOK
ZU ZB ZS ZA ZM
STRIP SPC
ZUZBZSZA ZM (no spaces needed -- codebook entries are already compact)
RADIX ENC
ZU: Z=97 U=84 --> 9784
ZB: Z=97 B=91 --> 9791
ZS: Z=97 S=6 --> 976
ZA: Z=97 A=2 --> 972
ZM: Z=97 M=85 --> 978531
PLAIN STR
9 7 8 4 9 7 9 1 9 7 6 9 7 2 9 7 8 5
KEY (dice)
4 1 3 7 2 8 0 5 6 3 1 4 2 9 0 8 5 1 [182 more for full block]
ADD mod 10
9+4=3 | 7+1=8 | 8+3=1 | 4+7=1 | 9+2=1 | 7+8=5 | 9+0=9 | 1+5=6
9+6=5 | 7+3=0 | 6+1=7 | 9+4=3 | 7+2=9 | 2+9=1 | 9+0=9 | 7+8=5
8+5=3 | 5+1=6
[continue key digits for null fill to 200 total]
CIPHER
3 8 1 1 1 5 9 6 5 0 7 3 9 1 9 5 3 6 [182 null digits]
GROUPS
38111 59650 73919 536__ [pad to 200]
VERIFY
38111

Example B -- Mixed Message with Numerals

// EXAMPLE B // "MEET TOMORROW NORTH GATE AT 0800"
CODEBOOK
ZN ZO (MEET TOMORROW applied, rest spelled)
STRIP SPC
ZNZONORTHGATEAT0800ZM
RADIX ENC
Z=97 N=5 --> 975 (ZN=MEET)
Z=97 O=3 --> 973 (ZO=TOMORROW)
N=5 O=3 R=80 T=1 H=7 --> 5 3 80 1 7
G=88 A=2 T=1 E=0 --> 88 2 1 0
A=2 T=1 --> 2 1
0=990 8=998 0=990 0=990 --> 990 998 990 990
ZM: 97 85
PLAIN STR
9 7 5 9 7 3 5 3 80 1 7 88 2 1 0 2 1 990 998 990 990 97 85
DIGIT CT
30 digits of real content + 170 null fill = 200 total
KEY
3 2 4 6 1 8 7 9 5 0 3 4 6 8 5 9 2 7 3 1 6 0 8 5 4 2 7 3 9 1 [170 more...]
CIPHER
9+3=2 | 7+2=9 | 5+4=9 | 9+6=5 | 7+1=8 | 3+8=1 | 5+7=2 | 3+9=2
8+5=3 | 0+0=0 | 1+3=4 | 7+4=1 | 8+6=4 | 8+8=6 | 2+5=7 | 1+9=0
0+2=2 | 2+7=9 | 1+3=4 | 9+1=0 | 9+6=5 | 0+0=0 | 9+8=7 | 9+5=4
0+4=4 | 9+2=1 | 0+7=7 | 9+3=2 | 7+9=6 | 8+1=9 [170 null digits...]
GROUPS
29958 12223 04146 70229 40507 44172 69 [pad to 200]
VERIFY
29958

11 Exercises

Exercise 11.1 -- Full Encrypt (No Keyword)
MEDIUM

Encrypt the following message using Layers 1-4 (no keyword layer). Use the key digits provided. Apply codebook where applicable. Produce ciphertext groups and a verify strip.

Message:  CONFIRMED PROCEED TO PRIMARY LOCATION TOMORROW

Key digits (first 40):
  7 3 2 8 4 1 9 0 5 6  3 8 2 1 4  7 0 9 3 5
  1 4 7 2 8  3 5 0 6 1  4 9 2 7 3  8 0 5 1 6
Step 1 -- Codebook:
  CONFIRMED        = ZA
  PROCEED          = ZC
  PRIMARY LOCATION = ZK
  TOMORROW         = ZO
  End              = ZM

Pre-encoded: ZA ZC ZK ZO ZM

Step 2 -- RADIX encode:
  ZA: 97 2
  ZC: 97 83
  ZK: 97 93
  ZO: 97 3
  ZM: 97 85

Digit string: 9 7 2 9 7 83 9 7 93 9 7 3 9 7 85
= 9 7 2 9 7 8 3 9 7 9 3 9 7 3 9 7 8 5
= 18 digits of content + 182 null

Step 3 -- Add key mod-10:
Plain: 9 7 2 9 7 8 3 9 7 9 3 9 7 3 9 7 8 5
Key:   7 3 2 8 4 1 9 0 5 6 3 8 2 1 4 7 0 9

9+7=6 | 7+3=0 | 2+2=4 | 9+8=7 | 7+4=1 | 8+1=9 | 3+9=2 | 9+0=9
7+5=2 | 9+6=5 | 3+3=6 | 9+8=7 | 7+2=9 | 3+1=4 | 9+4=3 | 7+7=4
8+0=8 | 5+9=4

Cipher (content): 6 0 4 7 1 9 2 9 2 5 6 7 9 4 3 4 8 4
[Continue: key digits 19-200 written as null fill into Q3]

Groups of 5: 60471 92925 67943 484__ [null fill groups]

Verify strip: 60471
Exercise 11.2 -- Full Decrypt
MEDIUM

Decrypt the following ciphertext using the key provided. Recover the full plaintext including codebook expansion. This is the output of Exercise 11.1.

Received: 60471 92925 67943 484[null]

Key digits:
  7 3 2 8 4 1 9 0 5 6  3 8 2 1 4  7 0 9 ...
Step 1 -- Subtract key mod-10:
Cipher: 6 0 4 7 1 9 2 9 2 5 6 7 9 4 3 4 8 4
Key:    7 3 2 8 4 1 9 0 5 6 3 8 2 1 4 7 0 9

6-7+10=9 | 0-3+10=7 | 4-2=2 | 7-8+10=9 | 1-4+10=7 | 9-1=8 | 2-9+10=3
9-0=9 | 2-5+10=7 | 5-6+10=9 | 6-3=3 | 7-8+10=9 | 9-2=7 | 4-1=3
3-4+10=9 | 4-7+10=7 | 8-0=8 | 4-9+10=5

Decoded digits: 9 7 2 9 7 8 3 9 7 9 3 9 7 3 9 7 8 5

Step 2 -- RADIX decode:
97 --> Z (tertiary: 9, pos 7)
2  --> A (primary)
ZA = CONFIRMED

97 --> Z
83 --> C (secondary: 8, pos 3)
ZC = PROCEED

97 --> Z
93 --> K (tertiary: 9, pos 3)
ZK = PRIMARY LOCATION

97 --> Z
3  --> O (primary)
ZO = TOMORROW

97 --> Z
85 --> M (secondary: 8, pos 5)
ZM = END OF MESSAGE

Plaintext: CONFIRMED PROCEED TO PRIMARY LOCATION TOMORROW
Exercise 11.3 -- With Keyword Layer
HARD

Encrypt the following message using all five staves including the keyword defense layer. Use keyword SEDATION (digit stream: 9 5 4 1 0 9 5 4, cycling).

Message:  ABORT DANGER EMERGENCY SWITCH TO ALTERNATE

Key digits (K2, OTP, first 30):
  8 2 4 7 3 1 0 9 5 6  2 4 8 3 7  0 5 1 6 2  4 7 8 3 0  5 9 2 1 4
Step 1 -- Codebook:
  ABORT         = ZB
  DANGER        = ZD
  EMERGENCY     = ZH
  SWITCH TO ALTERNATE = ZS
  End           = ZM

Pre-encoded: ZB ZD ZH ZS ZM

Step 2 -- RADIX encode:
  ZB: 97 91
  ZD: 97 81
  ZH: 97 7
  ZS: 97 6
  ZM: 97 85

Digit stream (P): 9 7 9 1 9 7 8 1 9 7 7 9 7 6 9 7 8 5

Step 3 -- Stave 5: Add keyword K1 (SEDATION = 9 5 4 1 0 9 5 4, cycling)
K1 cycling for 18 digits: 9 5 4 1 0 9 5 4 | 9 5 4 1 0 9 5 4 | 9 5

P:  9 7 9 1 9 7 8 1 9 7 7 9 7 6 9 7 8 5
K1: 9 5 4 1 0 9 5 4 9 5 4 1 0 9 5 4 9 5

After Stave 5 (I = (P+K1) mod 10):
9+9=8 | 7+5=2 | 9+4=3 | 1+1=2 | 9+0=9 | 7+9=6 | 8+5=3 | 1+4=5
9+9=8 | 7+5=2 | 7+4=1 | 9+1=0 | 7+0=7 | 6+9=5 | 9+5=4 | 7+4=1
8+9=7 | 5+5=0

Intermediate (I): 8 2 3 2 9 6 3 5 8 2 1 0 7 5 4 1 7 0

Step 4 -- Add OTP key K2:
I:  8 2 3 2 9 6 3 5 8 2 1 0 7 5 4 1 7 0
K2: 8 2 4 7 3 1 0 9 5 6 2 4 8 3 7 0 5 1

8+8=6 | 2+2=4 | 3+4=7 | 2+7=9 | 9+3=2 | 6+1=7 | 3+0=3 | 5+9=4
8+5=3 | 2+6=8 | 1+2=3 | 0+4=4 | 7+8=5 | 5+3=8 | 4+7=1 | 1+0=1
7+5=2 | 0+1=1

Ciphertext: 6 4 7 9 2 7 3 4 3 8 3 4 5 8 1 1 2 1
[Continue null fill to 200]

Groups: 64792 73438 34581 121__ [null fill]
Verify: 64792
Exercise 11.4 -- Speed Target
HARD

Complete a full encrypt cycle (Layers 1-4) on the following message, rolling your own key digits. Time yourself from first roll to verify strip.

Message:  MEET TODAY PRIMARY LOCATION ZERO EIGHT HUNDRED CONFIRMED

Benchmark targets:

First attempt:       under 20 minutes
After 2 weeks:       under 10 minutes
Operational minimum: under 6 minutes

Note which step consumes most time. That step determines your next practice priority. Most operators find RADIX encoding is fastest after 1-2 weeks, and the mod-10 addition is the remaining bottleneck -- which means the table lookup needs more drilling.

Exercise 11.5 -- End-to-End with Partner
HARD

Complete a full encrypt-decrypt cycle with a partner using two independently prepared pad sheets. The sender encrypts the message below and transmits only the ciphertext groups. The receiver decrypts and recovers the plaintext. Compare results. Any discrepancy indicates a transcription error -- locate it using the group where decryption first produces nonsense.

Message (sender composes, does not reveal to receiver until after):
  [Choose any operational message using codebook and spelled content,
   10-50 characters, including at least one numeral]

After successful completion, swap roles and repeat with a new pad sheet and a receiver-composed message.

12 Error Handling

Error Propagation Property

Vernier with mod-10 OTP has highly localized error propagation. A single corrupted digit in the ciphertext corrupts exactly the characters whose RADIX codes include that digit position. Because many characters encode as 1-digit primary codes, a single error often corrupts only one or two characters.

This is significantly better than shift-register ciphers where one error corrupts the entire subsequent message. Error localization is one of the practical arguments for mod-10 OTP over more complex stateful systems.

Error Detection

During decryption, errors manifest as:

1. Invalid RADIX codes
   A sequence that violates the parsing rule.
   Example: 8 followed by nothing (truncated group).
   Indicates: transmission error in that 5-digit group.

2. Nonsense decoded text
   Valid RADIX but incoherent plaintext.
   Example: QXZJV -- unlikely in operational vocabulary.
   Indicates: error has produced valid-looking but wrong codes.

3. ZM never appearing
   Null fill continues indefinitely without end marker.
   Indicates: error before or at ZM position,
   or wrong pad sheet.

Error Recovery Protocol

1. Note which 5-digit group first produces nonsense.
2. Request retransmission of that group and the two following.
3. If retransmission is not possible:
   -- Try decoding the group with +1 and -1 on each digit in sequence
   -- Single-digit errors in 1-digit primary codes are likely
   -- The context of surrounding decoded text constrains possibilities
4. If more than one error: request full retransmission.
5. Never send "did not understand" -- send "request group XX and following."
Critical -- Do Not Speculate Over Channel Never transmit "your message said ABORT but I think you meant PROCEED." If a message is garbled, request retransmission using pre-agreed error protocol signals. Speculating over the channel confirms that you received and attempted to decrypt a message -- useful intelligence for an observer even if they cannot read the content.

13 Operational Rules

Rule 1 -- Key Reuse Is Total Failure Each pad sheet is used once and once only. Two messages encrypted with the same key stream can be combined to eliminate the key entirely, leaving a superimposition of two plaintexts readable by frequency analysis. VENONA (1943-1980) broke Soviet OTP traffic exclusively through this failure. No operational need, time pressure, or shortage of material justifies reuse. If you are out of pad material: go silent.
Rule 2 -- Destroy Immediately After Use The pad sheet is destroyed as soon as the message is sent and the verify strip is recorded. The Cuban Wasp Network (1998) was prosecuted in part because used pad sheets were retained rather than destroyed. Physical possession of used key material is simultaneously a legal and cryptographic liability. Water first. Flame if water unavailable. Confirm no legible fragment.
Rule 3 -- Key Exchange Is Physical Only Key material is never transmitted by any electronic or written channel. It is generated in person, copied via carbon or simultaneous writing with verbal verification, and physically separated. The only secure key distribution is physical. The Wormhole protocol or any similar electronic system is categorically inappropriate for OTP key exchange.
Rule 4 -- Verify Before Every Message Confirm pad sheet number and verify strip before beginning encryption or decryption. A sheet mismatch discovered before encryption costs seconds. Discovered after it costs the entire key block plus operational time.
Rule 5 -- Hard Surface, No Paper Below Write on glass, ceramic, or hard plastic. Never on stacked paper. Impression evidence (ESDA) is routinely exploited. This is not theoretical -- it recovered key material in multiple documented Cold War cases.
Rule 6 -- Encode First, Then Generate Key Apply the codebook and RADIX-encode the complete message before rolling a single die. This determines the block class and therefore how many dice to roll. The key is never generated until encoding is complete and written down. This preserves the spirit of the original rule -- key digits are not influenced by plaintext content -- while accounting for the block class selection step. The encoded message is written before key generation begins. Do not roll while composing.
Rule 7 -- The Cipher Is Never the Weak Point In every publicly documented OTP failure, the cipher held. People, procedures, and physical security failed. Spending time optimizing the cipher beyond what this document provides returns less than spending the same time drilling operational discipline and destruction speed.

14 Quick Reference

RADIX Encoding -- Memorize This

PRIMARY  (single digit, 0-7):
  E=0  T=1  A=2  O=3  I=4  N=5  S=6  H=7
  "Eat Tangerines And Oranges In Nice Spanish Hotels"

SECONDARY  (prefix 8, two digits, 80-89):
  R=80  D=81  L=82  C=83  U=84  M=85  W=86  F=87  G=88  Y=89
  "Really Dull Lessons Can Undermine Many Willing Fields, Gaining Yesterday"

TERTIARY  (prefix 9, two digits, 90-98):
  P=90  B=91  V=92  K=93  J=94  X=95  Q=96  Z=97  .=98

NUMERALS  (prefix 99, three digits, 990-999):
  0=990  1=991  2=992  3=993  4=994  5=995  6=996  7=997  8=998  9=999
  Rule: 990 + digit

Parsing Rule

d in 0-7      -->  complete. One digit.
d = 8         -->  read one more. 8x table.
d = 9, x 0-8 -->  two digits. 9x table.
d = 9, x = 9 -->  read one more. Numeral = that digit.

Codebook (Z-prefix) // Rev 1.1

ZA=CONFIRMED    ZB=ABORT        ZC=PROCEED      ZD=DANGER
ZE=DELAY        ZF=COMPLETE     ZG=CONFIRM LAST ZH=EMERGENCY
ZI=NO CHANGE    ZJ=AS PLANNED   ZK=PRI LOC      ZL=ALT LOC
ZM=END MSG*     ZN=MEET         ZO=TOMORROW     ZP=TODAY
ZQ=LOC FLWS     ZR=TIME FLWS    ZS=SW ALT       ZT=DEAD DROP
ZU=SURV DET     ZV=CLEAR        ZW=RCVD+UNDSTD  ZX=DOC FLWS
ZY=COORD FLWS   ZZ=NAME FLWS

* ZM is reserved as end-of-message marker. Never reassign.

Routine circuit:
  ZI ZM = nothing to report    ZW ZM = received and understood
  ZG ZM = confirm last message received correctly

Block Classes

Class S -- SHORT   prefix 00   50 digits  (10 groups)   0-44 content digits
Class M -- MEDIUM  prefix 01  100 digits  (20 groups)  45-94 content digits
Class L -- LONG    prefix 02  200 digits  (40 groups)  95-194 content digits

Structure: [OFFSET 2][SALT 4][FILL][CONTENT][ZM 6][PADDING] = 25, 50, or 100
Prefix is encrypted as part of the block.

Decision: encode full message first, count digits, select class, then roll.

Cipher Operations

ENCRYPT  C = (P + K) mod 10
DECRYPT  P = (C - K + 10) mod 10

WITH KEYWORD:
ENCRYPT  I = (P + K1) mod 10  -->  C = (I + K2) mod 10
DECRYPT  I = (C - K2 + 10) mod 10  -->  P = (I - K1 + 10) mod 10

Checklist

BEFORE:
  [ ] Pad ID confirmed with counterpart
  [ ] Verify strip from last exchange matches
  [ ] Hard surface prepared, no paper below
  [ ] Water container nearby

ENCRYPT:
  [ ] Codebook applied, raw text in Q2 upper
  [ ] RADIX encoded in Q2 lower, ZM appended
  [ ] Digit count done, block class selected (S/M/L)
  [ ] Class prefix (00/01/02) prepended in Q2
  [ ] Key block rolled for class size (50/100/200) in Q1
  [ ] Keyword applied if using Stave 5
  [ ] Mod-10 addition to Q3, null fill to block size
  [ ] Verify strip recorded

AFTER:
  [ ] Destroy pad sheet immediately
  [ ] No legible fragment confirmed