diff --git a/src/utils/codebreaking.test.tsx b/src/codebreaking/crib-analysis.test.tsx similarity index 58% rename from src/utils/codebreaking.test.tsx rename to src/codebreaking/crib-analysis.test.tsx index be5eb44..1129a10 100644 --- a/src/utils/codebreaking.test.tsx +++ b/src/codebreaking/crib-analysis.test.tsx @@ -1,75 +1,19 @@ import { initialReflectorState } from '../features/reflector'; import { initialRotorState } from '../features/rotors/features'; -import type { - PlugboardCable, - ReflectorState, - RotorState, -} from '../types/interfaces'; -import type { CribSearchResult, MenuEdge } from './codebreaking'; +import type { ReflectorState, RotorState } from '../types/interfaces'; +import { encryptLetter, stepRotors } from '../utils/enigma'; +import type { MenuEdge } from './crib-analysis'; import { buildMenuEdges, - cribSearchAsync, - encryptString, findCribPositions, propagateMenuConstraints, -} from './codebreaking'; -import { encryptLetter, stepRotors } from './enigma'; +} from './crib-analysis'; const rotorI: RotorState = initialRotorState.available[1]!; const rotorII: RotorState = initialRotorState.available[2]!; const rotorIII: RotorState = initialRotorState.available[3]!; const reflectorB: ReflectorState = initialReflectorState.reflectors[2]!; -const emptyPlugboard: PlugboardCable = {}; - -describe('encryptString', () => { - it('encrypts a string through the full signal path', () => { - const rotors = [rotorIII, rotorII, rotorI]; - const result = encryptString('AAA', rotors, emptyPlugboard, reflectorB); - expect(result).toHaveLength(3); - expect(result).toMatch(/^[A-Z]+$/); - }); - - it('produces consistent output for same config', () => { - const rotors = [rotorIII, rotorII, rotorI]; - const result1 = encryptString('HELLO', rotors, emptyPlugboard, reflectorB); - const result2 = encryptString('HELLO', rotors, emptyPlugboard, reflectorB); - expect(result1).toBe(result2); - }); - - it('decrypts back to original (Enigma symmetry)', () => { - const rotors = [rotorIII, rotorII, rotorI]; - const encrypted = encryptString( - 'HELLO', - rotors, - emptyPlugboard, - reflectorB, - ); - const decrypted = encryptString( - encrypted, - rotors, - emptyPlugboard, - reflectorB, - ); - expect(decrypted).toBe('HELLO'); - }); - - it('no letter encrypts to itself', () => { - const rotors = [rotorIII, rotorII, rotorI]; - const plaintext = 'AAAAAAAAAA'; - const result = encryptString(plaintext, rotors, emptyPlugboard, reflectorB); - for (let i = 0; i < plaintext.length; i++) { - expect(result[i]).not.toBe(plaintext[i]); - } - }); -}); - -// Limit rotors to III, II, I for faster crib search tests (6 perms vs 60) -const limitedRotors = { - 1: initialRotorState.available[1]!, - 2: initialRotorState.available[2]!, - 3: initialRotorState.available[3]!, -}; const buildScramblerTableAt = ( rotors: RotorState[], @@ -222,123 +166,6 @@ describe('propagateMenuConstraints', () => { }); }); -describe('cribSearchAsync', () => { - it('finds a matching result with derived plugboard when crib is present', async () => { - jest.useRealTimers(); - const plaintext = 'WITHTION'; - const crib = 'WITH'; - const rotors = [ - { ...rotorIII, config: { ...rotorIII.config, currentIndex: 0 } }, - { ...rotorII, config: { ...rotorII.config, currentIndex: 0 } }, - { ...rotorI, config: { ...rotorI.config, currentIndex: 0 } }, - ]; - const ciphertext = encryptString( - plaintext, - rotors, - emptyPlugboard, - reflectorB, - ); - const segment = ciphertext.slice(0, crib.length); - - const results: CribSearchResult[] = await cribSearchAsync( - ciphertext, - crib, - limitedRotors, - { 2: initialReflectorState.reflectors[2]! }, - () => {}, - ); - - expect(results.length).toBeGreaterThan(0); - expect(results[0]!.derivedPlugboard).toBeDefined(); - expect(results[0]!.cribPosition).toBeGreaterThanOrEqual(0); - expect(results[0]!.nlpScore).toBeGreaterThanOrEqual(0); - expect(results[0]!.nlpScore).toBeLessThanOrEqual(100); - - // Verify the correct config is self-consistent: with an empty plugboard every - // stecker value must map to itself (verified via the exported lower-level API). - const tablesAtPos0 = Array.from({ length: crib.length }, (_, i) => - buildScramblerTableAt(rotors, i + 1), - ); - const edges = buildMenuEdges(crib, segment, tablesAtPos0, 0); - const stecker = propagateMenuConstraints(edges, crib[0]!, crib[0]!); - expect(stecker).not.toBeNull(); - for (const [key, value] of stecker!.entries()) { - expect(key).toBe(value); - } - }, 60000); - - it('includes derivedPlugboard in every result', async () => { - jest.useRealTimers(); - const rotors = [ - { ...rotorIII, config: { ...rotorIII.config, currentIndex: 0 } }, - { ...rotorII, config: { ...rotorII.config, currentIndex: 0 } }, - { ...rotorI, config: { ...rotorI.config, currentIndex: 0 } }, - ]; - const ciphertext = encryptString( - 'WITHTION', - rotors, - emptyPlugboard, - reflectorB, - ); - - const results = await cribSearchAsync( - ciphertext, - 'WITH', - limitedRotors, - { 2: initialReflectorState.reflectors[2]! }, - () => {}, - ); - - for (const result of results) { - expect(result.derivedPlugboard).toBeDefined(); - expect(typeof result.derivedPlugboard).toBe('object'); - } - }, 60000); - - it('returns empty array when crib is longer than ciphertext', async () => { - jest.useRealTimers(); - const progressValues: number[] = []; - - const results = await cribSearchAsync( - 'ABC', - 'HELLO', - limitedRotors, - { 2: initialReflectorState.reflectors[2]! }, - (p) => progressValues.push(p), - ); - - expect(results).toEqual([]); - expect(progressValues[progressValues.length - 1]).toBe(1); - }); - - it('calls onProgress from 0 to 1', async () => { - jest.useRealTimers(); - const rotors = [ - { ...rotorIII, config: { ...rotorIII.config, currentIndex: 0 } }, - { ...rotorII, config: { ...rotorII.config, currentIndex: 0 } }, - { ...rotorI, config: { ...rotorI.config, currentIndex: 0 } }, - ]; - const ciphertext = encryptString( - 'WITHTION', - rotors, - emptyPlugboard, - reflectorB, - ); - const progressValues: number[] = []; - - await cribSearchAsync( - ciphertext, - 'WITH', - limitedRotors, - { 2: initialReflectorState.reflectors[2]! }, - (p) => progressValues.push(p), - ); - - expect(progressValues.length).toBeGreaterThan(0); - expect(progressValues[progressValues.length - 1]).toBe(1); - }, 60000); -}); - describe('findCribPositions', () => { it('eliminates positions where crib letter matches ciphertext letter', () => { const ciphertext = 'ABCDEF'; diff --git a/src/codebreaking/crib-analysis.tsx b/src/codebreaking/crib-analysis.tsx new file mode 100644 index 0000000..87a55ac --- /dev/null +++ b/src/codebreaking/crib-analysis.tsx @@ -0,0 +1,201 @@ +import type { PlugboardCable } from '../types/interfaces'; +import { ALPHABET_SIZE, indexToLetter, letterToIndex } from './enigma-int'; + +const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +export interface MenuEdge { + letterA: string; + letterB: string; + scramblerTable: string[]; +} + +export const findCribPositions = ( + ciphertext: string, + crib: string, +): number[] => { + const validPositions: number[] = []; + const maxPosition = ciphertext.length - crib.length; + + for (let pos = 0; pos <= maxPosition; pos++) { + let isValid = true; + for (let i = 0; i < crib.length; i++) { + if (crib[i] === ciphertext[pos + i]) { + isValid = false; + break; + } + } + if (isValid) { + validPositions.push(pos); + } + } + + return validPositions; +}; + +export const buildMenuEdges = ( + crib: string, + ciphertextSegment: string, + scramblerTables: string[][], + cribOffset: number, +): MenuEdge[] => + crib.split('').map((p, i) => ({ + letterA: p, + letterB: ciphertextSegment[i]!, + scramblerTable: scramblerTables[cribOffset + i]!, + })); + +export const propagateMenuConstraints = ( + edges: MenuEdge[], + seedLetter: string, + seedValue: string, +): Map | null => { + const stecker = new Map(); + const queue: [string, string][] = [[seedLetter, seedValue]]; + + while (queue.length > 0) { + const [letter, value] = queue.shift()!; + + if (stecker.has(letter)) { + if (stecker.get(letter) !== value) return null; + continue; + } + stecker.set(letter, value); + + // Welchman diagonal board: plugboard connections are always reciprocal + queue.push([value, letter]); + + for (const edge of edges) { + if (edge.letterA === letter) { + queue.push([ + edge.letterB, + edge.scramblerTable[ALPHABET.indexOf(value)]!, + ]); + } else if (edge.letterB === letter) { + queue.push([ + edge.letterA, + edge.scramblerTable[ALPHABET.indexOf(value)]!, + ]); + } + } + } + + return stecker; +}; + +export interface MenuAdjInt { + adjOther: number[][]; + adjStep: number[][]; + testLetter: number; +} + +export const buildMenuAdjInt = (crib: string, segment: string): MenuAdjInt => { + const adjOther: number[][] = Array.from({ length: ALPHABET_SIZE }, () => []); + const adjStep: number[][] = Array.from({ length: ALPHABET_SIZE }, () => []); + const degree = new Uint8Array(ALPHABET_SIZE); + + for (let i = 0; i < crib.length; i++) { + const a = letterToIndex(crib[i]!); + const b = letterToIndex(segment[i]!); + adjOther[a]!.push(b); + adjStep[a]!.push(i); + adjOther[b]!.push(a); + adjStep[b]!.push(i); + degree[a]!++; + degree[b]!++; + } + + let testLetter = 0; + let maxDeg = 0; + for (let i = 0; i < ALPHABET_SIZE; i++) { + if (degree[i]! > maxDeg) { + maxDeg = degree[i]!; + testLetter = i; + } + } + + return { adjOther, adjStep, testLetter }; +}; + +const BFS_BUF_SIZE = 512; + +export const propagateIntHypothesis = ( + seedLetter: number, + seedValue: number, + adjOther: number[][], + adjStep: number[][], + posScramblerFlat: Uint8Array, + stecker: Int8Array, + queueBuf: Int16Array, +): boolean => { + stecker.fill(-1); + let head = 0; + let tail = 0; + + queueBuf[tail++] = seedLetter; + queueBuf[tail++] = seedValue; + + while (head < tail) { + const letter = queueBuf[head++]!; + const value = queueBuf[head++]!; + + if (stecker[letter] !== -1) { + if (stecker[letter] !== value) return false; + continue; + } + stecker[letter] = value; + + // Welchman diagonal board: plugboard connections are reciprocal + queueBuf[tail++] = value; + queueBuf[tail++] = letter; + + const neighbors = adjOther[letter]!; + const steps = adjStep[letter]!; + for (let ni = 0; ni < neighbors.length; ni++) { + queueBuf[tail++] = neighbors[ni]!; + queueBuf[tail++] = posScramblerFlat[steps[ni]! * ALPHABET_SIZE + value]!; + } + } + + return true; +}; + +export const allocateBfsBuffers = (): { + stecker: Int8Array; + queueBuf: Int16Array; +} => ({ + stecker: new Int8Array(ALPHABET_SIZE), + queueBuf: new Int16Array(BFS_BUF_SIZE), +}); + +export const steckerIntToPlugboard = (stecker: Int8Array): PlugboardCable => { + const plugboard: PlugboardCable = {}; + for (let i = 0; i < ALPHABET_SIZE; i++) { + const v = stecker[i]!; + if (v !== -1 && v !== i) { + plugboard[indexToLetter(i)] = indexToLetter(v); + } + } + return plugboard; +}; + +export const steckerVerifiesCrib = ( + stecker: Int8Array, + segmentCodes: Uint8Array, + cribCodes: Uint8Array, + posScramblerFlat: Uint8Array, +): boolean => { + let verified = 0; + for (let i = 0; i < cribCodes.length; i++) { + let x: number = segmentCodes[i]!; + const pb1 = stecker[x]!; + if (pb1 === -1) continue; + x = pb1; + x = posScramblerFlat[i * ALPHABET_SIZE + x]!; + const pb2 = stecker[x]!; + if (pb2 === -1) continue; + x = pb2; + if (x !== cribCodes[i]!) return false; + verified++; + } + return verified > 0; +}; diff --git a/src/codebreaking/crib-search.test.tsx b/src/codebreaking/crib-search.test.tsx new file mode 100644 index 0000000..c1752c0 --- /dev/null +++ b/src/codebreaking/crib-search.test.tsx @@ -0,0 +1,198 @@ +import { initialReflectorState } from '../features/reflector'; +import { initialRotorState } from '../features/rotors/features'; +import type { + PlugboardCable, + ReflectorState, + RotorState, +} from '../types/interfaces'; +import { encryptLetter, stepRotors } from '../utils/enigma'; +import { buildMenuEdges, propagateMenuConstraints } from './crib-analysis'; +import type { CribSearchResult } from './crib-search'; +import { cribSearchAsync, encryptString } from './crib-search'; + +const rotorI: RotorState = initialRotorState.available[1]!; +const rotorII: RotorState = initialRotorState.available[2]!; +const rotorIII: RotorState = initialRotorState.available[3]!; + +const reflectorB: ReflectorState = initialReflectorState.reflectors[2]!; +const emptyPlugboard: PlugboardCable = {}; + +// Limit rotors to III, II, I for faster crib search tests (6 perms vs 60) +const limitedRotors = { + 1: initialRotorState.available[1]!, + 2: initialRotorState.available[2]!, + 3: initialRotorState.available[3]!, +}; + +const buildScramblerTableAt = ( + rotors: RotorState[], + steps: number, +): string[] => { + const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + let current = rotors; + for (let i = 0; i < steps; i++) { + current = stepRotors(current); + } + return ALPHABET.split('').map((letter) => + encryptLetter(letter, current, {}, reflectorB), + ); +}; + +describe('encryptString', () => { + it('encrypts a string through the full signal path', () => { + const rotors = [rotorIII, rotorII, rotorI]; + const result = encryptString('AAA', rotors, emptyPlugboard, reflectorB); + expect(result).toHaveLength(3); + expect(result).toMatch(/^[A-Z]+$/); + }); + + it('produces consistent output for same config', () => { + const rotors = [rotorIII, rotorII, rotorI]; + const result1 = encryptString('HELLO', rotors, emptyPlugboard, reflectorB); + const result2 = encryptString('HELLO', rotors, emptyPlugboard, reflectorB); + expect(result1).toBe(result2); + }); + + it('decrypts back to original (Enigma symmetry)', () => { + const rotors = [rotorIII, rotorII, rotorI]; + const encrypted = encryptString( + 'HELLO', + rotors, + emptyPlugboard, + reflectorB, + ); + const decrypted = encryptString( + encrypted, + rotors, + emptyPlugboard, + reflectorB, + ); + expect(decrypted).toBe('HELLO'); + }); + + it('no letter encrypts to itself', () => { + const rotors = [rotorIII, rotorII, rotorI]; + const plaintext = 'AAAAAAAAAA'; + const result = encryptString(plaintext, rotors, emptyPlugboard, reflectorB); + for (let i = 0; i < plaintext.length; i++) { + expect(result[i]).not.toBe(plaintext[i]); + } + }); +}); + +describe('cribSearchAsync', () => { + it('finds a matching result with derived plugboard when crib is present', async () => { + jest.useRealTimers(); + const plaintext = 'WITHTION'; + const crib = 'WITH'; + const rotors = [ + { ...rotorIII, config: { ...rotorIII.config, currentIndex: 0 } }, + { ...rotorII, config: { ...rotorII.config, currentIndex: 0 } }, + { ...rotorI, config: { ...rotorI.config, currentIndex: 0 } }, + ]; + const ciphertext = encryptString( + plaintext, + rotors, + emptyPlugboard, + reflectorB, + ); + const segment = ciphertext.slice(0, crib.length); + + const results: CribSearchResult[] = await cribSearchAsync( + ciphertext, + crib, + limitedRotors, + { 2: initialReflectorState.reflectors[2]! }, + () => {}, + ); + + expect(results.length).toBeGreaterThan(0); + expect(results[0]!.derivedPlugboard).toBeDefined(); + expect(results[0]!.cribPosition).toBeGreaterThanOrEqual(0); + expect(results[0]!.nlpScore).toBeGreaterThanOrEqual(0); + expect(results[0]!.nlpScore).toBeLessThanOrEqual(100); + + // Verify the correct config is self-consistent: with an empty plugboard every + // stecker value must map to itself (verified via the exported lower-level API). + const tablesAtPos0 = Array.from({ length: crib.length }, (_, i) => + buildScramblerTableAt(rotors, i + 1), + ); + const edges = buildMenuEdges(crib, segment, tablesAtPos0, 0); + const stecker = propagateMenuConstraints(edges, crib[0]!, crib[0]!); + expect(stecker).not.toBeNull(); + for (const [key, value] of stecker!.entries()) { + expect(key).toBe(value); + } + }, 60000); + + it('includes derivedPlugboard in every result', async () => { + jest.useRealTimers(); + const rotors = [ + { ...rotorIII, config: { ...rotorIII.config, currentIndex: 0 } }, + { ...rotorII, config: { ...rotorII.config, currentIndex: 0 } }, + { ...rotorI, config: { ...rotorI.config, currentIndex: 0 } }, + ]; + const ciphertext = encryptString( + 'WITHTION', + rotors, + emptyPlugboard, + reflectorB, + ); + + const results = await cribSearchAsync( + ciphertext, + 'WITH', + limitedRotors, + { 2: initialReflectorState.reflectors[2]! }, + () => {}, + ); + + for (const result of results) { + expect(result.derivedPlugboard).toBeDefined(); + expect(typeof result.derivedPlugboard).toBe('object'); + } + }, 60000); + + it('returns empty array when crib is longer than ciphertext', async () => { + jest.useRealTimers(); + const progressValues: number[] = []; + + const results = await cribSearchAsync( + 'ABC', + 'HELLO', + limitedRotors, + { 2: initialReflectorState.reflectors[2]! }, + (p) => progressValues.push(p), + ); + + expect(results).toEqual([]); + expect(progressValues[progressValues.length - 1]).toBe(1); + }); + + it('calls onProgress from 0 to 1', async () => { + jest.useRealTimers(); + const rotors = [ + { ...rotorIII, config: { ...rotorIII.config, currentIndex: 0 } }, + { ...rotorII, config: { ...rotorII.config, currentIndex: 0 } }, + { ...rotorI, config: { ...rotorI.config, currentIndex: 0 } }, + ]; + const ciphertext = encryptString( + 'WITHTION', + rotors, + emptyPlugboard, + reflectorB, + ); + const progressValues: number[] = []; + + await cribSearchAsync( + ciphertext, + 'WITH', + limitedRotors, + { 2: initialReflectorState.reflectors[2]! }, + (p) => progressValues.push(p), + ); + + expect(progressValues.length).toBeGreaterThan(0); + expect(progressValues[progressValues.length - 1]).toBe(1); + }, 60000); +}); diff --git a/src/codebreaking/crib-search.tsx b/src/codebreaking/crib-search.tsx new file mode 100644 index 0000000..9d8caff --- /dev/null +++ b/src/codebreaking/crib-search.tsx @@ -0,0 +1,489 @@ +import type { + PlugboardCable, + ReflectorState, + RotorState, +} from '../types/interfaces'; +import { encryptLetter, stepRotors } from '../utils/enigma'; +import { nlpConfidence } from '../utils/nlp'; +import type { MenuAdjInt } from './crib-analysis'; +import { + allocateBfsBuffers, + buildMenuAdjInt, + findCribPositions, + propagateIntHypothesis, + steckerIntToPlugboard, + steckerVerifiesCrib, +} from './crib-analysis'; +import type { RotorIntTables } from './enigma-int'; +import { + ALPHABET_SIZE, + buildReflectorIntMap, + buildRotorIntTables, + computeScramblerEntry, + intStepRotors, + stringToIndexArray, +} from './enigma-int'; + +export interface CribSearchResult { + rotorIds: number[]; + reflectorName: string; + startingPositions: number[]; + cribPosition: number; + decryptedText: string; + nlpScore: number; + derivedPlugboard: PlugboardCable; +} + +const MAX_CRIB_RESULTS = 100; + +const createRotorWithPosition = ( + rotor: RotorState, + position: number, +): RotorState => ({ + ...rotor, + config: { ...rotor.config, currentIndex: position }, +}); + +export const encryptString = ( + plaintext: string, + rotors: RotorState[], + plugboard: PlugboardCable, + reflector: ReflectorState, +): string => { + let currentRotors = rotors; + let result = ''; + + for (const letter of plaintext) { + currentRotors = stepRotors(currentRotors); + result += encryptLetter(letter, currentRotors, plugboard, reflector); + } + + return result; +}; + +const generateRotorPermutations = (rotorIds: number[]): number[][] => { + const permutations: number[][] = []; + for (let i = 0; i < rotorIds.length; i++) { + for (let j = 0; j < rotorIds.length; j++) { + if (j === i) continue; + for (let k = 0; k < rotorIds.length; k++) { + if (k === i || k === j) continue; + permutations.push([rotorIds[i]!, rotorIds[j]!, rotorIds[k]!]); + } + } + } + return permutations; +}; + +interface SearchContext { + ciphertext: string; + crib: string; + cribCodes: Uint8Array; + validPositions: number[]; + permutations: number[][]; + reflectorIds: number[]; + allRotors: { [id: number]: RotorState }; + allReflectors: { [id: number]: ReflectorState }; + rotorIntTables: { [id: number]: RotorIntTables }; + reflectorIntMaps: { [id: number]: Uint8Array }; + menusArray: MenuAdjInt[]; + segCodesArray: Uint8Array[]; + neededScramblerSteps: number[]; + maxStep: number; + scramblerSlots: Uint8Array[]; + posScramblerFlat: Uint8Array; + stecker: Int8Array; + queueBuf: Int16Array; + rPos: Uint8Array; + mPos: Uint8Array; + lPos: Uint8Array; + stepOut: Uint8Array; +} + +const buildSearchContext = ( + ciphertext: string, + crib: string, + allRotors: { [id: number]: RotorState }, + allReflectors: { [id: number]: ReflectorState }, + validPositions: number[], +): SearchContext => { + const rotorIds = Object.keys(allRotors).map(Number); + const permutations = generateRotorPermutations(rotorIds); + const reflectorIds = Object.keys(allReflectors).map(Number); + + const rotorIntTables: { [id: number]: RotorIntTables } = {}; + for (const id of rotorIds) { + rotorIntTables[id] = buildRotorIntTables(allRotors[id]!); + } + + const reflectorIntMaps: { [id: number]: Uint8Array } = {}; + for (const id of reflectorIds) { + reflectorIntMaps[id] = buildReflectorIntMap(allReflectors[id]!); + } + + const cribCodes = stringToIndexArray(crib); + + const menusArray: MenuAdjInt[] = []; + const segCodesArray: Uint8Array[] = []; + for (const pos of validPositions) { + const segment = ciphertext.slice(pos, pos + crib.length); + menusArray.push(buildMenuAdjInt(crib, segment)); + segCodesArray.push( + stringToIndexArray(ciphertext.slice(pos, pos + crib.length)), + ); + } + + const neededScramblerStepSet = new Set(); + for (const pos of validPositions) { + for (let i = 0; i < crib.length; i++) { + neededScramblerStepSet.add(pos + i + 1); + } + } + const neededScramblerSteps = [...neededScramblerStepSet]; + const maxStep = Math.max(...neededScramblerSteps); + + const rPos = new Uint8Array(maxStep + 1); + const mPos = new Uint8Array(maxStep + 1); + const lPos = new Uint8Array(maxStep + 1); + const stepOut = new Uint8Array(3); + const scramblerSlots: Uint8Array[] = Array.from( + { length: maxStep + 1 }, + () => new Uint8Array(ALPHABET_SIZE), + ); + const posScramblerFlat = new Uint8Array(crib.length * ALPHABET_SIZE); + const { stecker, queueBuf } = allocateBfsBuffers(); + + return { + ciphertext, + crib, + cribCodes, + validPositions, + permutations, + reflectorIds, + allRotors, + allReflectors, + rotorIntTables, + reflectorIntMaps, + menusArray, + segCodesArray, + neededScramblerSteps, + maxStep, + scramblerSlots, + posScramblerFlat, + stecker, + queueBuf, + rPos, + mPos, + lPos, + stepOut, + }; +}; + +const computeAllRotorPositions = ( + p0: number, + p1: number, + p2: number, + maxStep: number, + rStep: number, + mStep: number, + rPos: Uint8Array, + mPos: Uint8Array, + lPos: Uint8Array, + stepOut: Uint8Array, +): void => { + rPos[0] = p0; + mPos[0] = p1; + lPos[0] = p2; + for (let s = 1; s <= maxStep; s++) { + intStepRotors( + rPos[s - 1]!, + mPos[s - 1]!, + lPos[s - 1]!, + rStep, + mStep, + stepOut, + ); + rPos[s] = stepOut[0]!; + mPos[s] = stepOut[1]!; + lPos[s] = stepOut[2]!; + } +}; + +const precomputeScramblerLookups = ( + ctx: SearchContext, + rFwd: Uint8Array, + mFwd: Uint8Array, + lFwd: Uint8Array, + rInv: Uint8Array, + mInv: Uint8Array, + lInv: Uint8Array, + reflMap: Uint8Array, +): void => { + for (let si = 0; si < ctx.neededScramblerSteps.length; si++) { + const step = ctx.neededScramblerSteps[si]!; + const slot = ctx.scramblerSlots[step]!; + const ro = ctx.rPos[step]!; + const mo = ctx.mPos[step]!; + const lo = ctx.lPos[step]!; + for (let j = 0; j < ALPHABET_SIZE; j++) { + slot[j] = computeScramblerEntry( + j, + ro, + mo, + lo, + rFwd, + mFwd, + lFwd, + rInv, + mInv, + lInv, + reflMap, + ); + } + } +}; + +const testCribPosition = (ctx: SearchContext, pi: number): Int8Array | null => { + const pos = ctx.validPositions[pi]!; + const menu = ctx.menusArray[pi]!; + const segCodes = ctx.segCodesArray[pi]!; + + for (let i = 0; i < ctx.crib.length; i++) { + ctx.posScramblerFlat.set( + ctx.scramblerSlots[pos + i + 1]!, + i * ALPHABET_SIZE, + ); + } + + for (let hypothesis = 0; hypothesis < ALPHABET_SIZE; hypothesis++) { + if ( + !propagateIntHypothesis( + menu.testLetter, + hypothesis, + menu.adjOther, + menu.adjStep, + ctx.posScramblerFlat, + ctx.stecker, + ctx.queueBuf, + ) + ) { + continue; + } + + if ( + !steckerVerifiesCrib( + ctx.stecker, + segCodes, + ctx.cribCodes, + ctx.posScramblerFlat, + ) + ) { + continue; + } + + return ctx.stecker; + } + + return null; +}; + +const collectResult = ( + ctx: SearchContext, + stecker: Int8Array, + perm: number[], + rId: number, + mId: number, + lId: number, + p0: number, + p1: number, + p2: number, + pos: number, + reflector: ReflectorState, +): CribSearchResult => { + const derivedPlugboard = steckerIntToPlugboard(stecker); + const fullRotors = [ + createRotorWithPosition(ctx.allRotors[rId]!, p0), + createRotorWithPosition(ctx.allRotors[mId]!, p1), + createRotorWithPosition(ctx.allRotors[lId]!, p2), + ]; + const decryptedText = encryptString( + ctx.ciphertext, + fullRotors, + derivedPlugboard, + reflector, + ); + return { + rotorIds: perm, + reflectorName: reflector.name, + startingPositions: [p0, p1, p2], + cribPosition: pos, + decryptedText, + nlpScore: nlpConfidence(decryptedText), + derivedPlugboard, + }; +}; + +export const cribSearchAsync = ( + ciphertext: string, + crib: string, + allRotors: { [id: number]: RotorState }, + allReflectors: { [id: number]: ReflectorState }, + onProgress: (progress: number) => void, + isCancelled?: () => boolean, + knownCribPosition?: number, +): Promise => { + const validPositions = + knownCribPosition !== undefined + ? [knownCribPosition] + : findCribPositions(ciphertext, crib); + + if (validPositions.length === 0) { + onProgress(1); + return Promise.resolve([]); + } + + const ctx = buildSearchContext( + ciphertext, + crib, + allRotors, + allReflectors, + validPositions, + ); + + const allResults: CribSearchResult[] = []; + const totalTicks = + ctx.permutations.length * ctx.reflectorIds.length * ALPHABET_SIZE; + + return new Promise((resolve) => { + let permIndex = 0; + let reflIndex = 0; + let p0 = 0; + let ticksDone = 0; + + const seedTables = + ctx.rotorIntTables[Object.keys(allRotors).map(Number)[0]!]!; + let cachedPermIndex = -1; + let rId = 0, + mId = 0, + lId = 0; + let rStep = 0, + mStep = 0; + let rFwd = seedTables.fwd, + rInv = seedTables.inv; + let mFwd = seedTables.fwd, + mInv = seedTables.inv; + let lFwd = seedTables.fwd, + lInv = seedTables.inv; + + const syncPermCache = () => { + if (permIndex === cachedPermIndex) return; + cachedPermIndex = permIndex; + [rId, mId, lId] = ctx.permutations[permIndex]! as [ + number, + number, + number, + ]; + rStep = allRotors[rId]!.config.stepIndex; + mStep = allRotors[mId]!.config.stepIndex; + rFwd = ctx.rotorIntTables[rId]!.fwd; + rInv = ctx.rotorIntTables[rId]!.inv; + mFwd = ctx.rotorIntTables[mId]!.fwd; + mInv = ctx.rotorIntTables[mId]!.inv; + lFwd = ctx.rotorIntTables[lId]!.fwd; + lInv = ctx.rotorIntTables[lId]!.inv; + }; + + const processSlice = () => { + if (isCancelled?.() === true) { + resolve([]); + return; + } + + if (permIndex >= ctx.permutations.length) { + onProgress(1); + setTimeout(() => { + if (isCancelled?.() === true) { + resolve([]); + return; + } + allResults.sort((a, b) => b.nlpScore - a.nlpScore); + resolve(allResults.slice(0, MAX_CRIB_RESULTS)); + }, 0); + return; + } + + syncPermCache(); + + const reflectorId = ctx.reflectorIds[reflIndex]!; + const reflMap = ctx.reflectorIntMaps[reflectorId]!; + const reflector = allReflectors[reflectorId]!; + const perm = ctx.permutations[permIndex]!; + + for (let p1 = 0; p1 < ALPHABET_SIZE; p1++) { + for (let p2 = 0; p2 < ALPHABET_SIZE; p2++) { + computeAllRotorPositions( + p0, + p1, + p2, + ctx.maxStep, + rStep, + mStep, + ctx.rPos, + ctx.mPos, + ctx.lPos, + ctx.stepOut, + ); + + precomputeScramblerLookups( + ctx, + rFwd, + mFwd, + lFwd, + rInv, + mInv, + lInv, + reflMap, + ); + + for (let pi = 0; pi < validPositions.length; pi++) { + const matchedStecker = testCribPosition(ctx, pi); + if (matchedStecker) { + allResults.push( + collectResult( + ctx, + matchedStecker, + perm, + rId, + mId, + lId, + p0, + p1, + p2, + validPositions[pi]!, + reflector, + ), + ); + break; + } + } + } + } + + ticksDone++; + p0++; + if (p0 >= ALPHABET_SIZE) { + p0 = 0; + reflIndex++; + if (reflIndex >= ctx.reflectorIds.length) { + reflIndex = 0; + permIndex++; + } + } + onProgress(ticksDone / totalTicks); + + setTimeout(processSlice, 0); + }; + + setTimeout(processSlice, 0); + }); +}; diff --git a/src/codebreaking/enigma-int.tsx b/src/codebreaking/enigma-int.tsx new file mode 100644 index 0000000..da5f9dc --- /dev/null +++ b/src/codebreaking/enigma-int.tsx @@ -0,0 +1,104 @@ +import type { ReflectorState, RotorState } from '../types/interfaces'; + +export const ALPHABET_SIZE = 26; +const ASCII_A = 65; + +// Lookup table replacing `% 26` for values in [0, 51]. +const MOD26 = new Uint8Array(52); +for (let i = 0; i < 52; i++) MOD26[i] = i % 26; + +export const letterToIndex = (char: string): number => + char.charCodeAt(0) - ASCII_A; + +export const indexToLetter = (index: number): string => + String.fromCharCode(index + ASCII_A); + +export interface RotorIntTables { + fwd: Uint8Array; + inv: Uint8Array; +} + +export const buildRotorIntTables = (rotor: RotorState): RotorIntTables => { + const fwd = new Uint8Array(ALPHABET_SIZE); + const inv = new Uint8Array(ALPHABET_SIZE); + for (let i = 0; i < ALPHABET_SIZE; i++) { + const fi = letterToIndex(rotor.config.mappedLetters[i]!); + fwd[i] = fi; + inv[fi] = i; + } + return { fwd, inv }; +}; + +export const buildReflectorIntMap = (reflector: ReflectorState): Uint8Array => { + const m = new Uint8Array(ALPHABET_SIZE); + for (let i = 0; i < ALPHABET_SIZE; i++) { + m[i] = letterToIndex(reflector.config.mapping[i]!); + } + return m; +}; + +export const stringToIndexArray = (s: string): Uint8Array => { + const codes = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) { + codes[i] = letterToIndex(s[i]!); + } + return codes; +}; + +// Integer rotor stepping — mirrors stepRotors() without object allocation. +export const intStepRotors = ( + r: number, + m: number, + l: number, + rStep: number, + mStep: number, + out: Uint8Array, +): void => { + if (m === mStep) { + out[0] = MOD26[r + 1]!; + out[1] = MOD26[m + 1]!; + out[2] = MOD26[l + 1]!; + } else if (r === rStep) { + out[0] = MOD26[r + 1]!; + out[1] = MOD26[m + 1]!; + out[2] = l; + } else { + out[0] = MOD26[r + 1]!; + out[1] = m; + out[2] = l; + } +}; + +export const rotorPassForward = ( + fwd: Uint8Array, + x: number, + offset: number, +): number => MOD26[fwd[MOD26[x + offset]!]! - offset + ALPHABET_SIZE]!; + +export const rotorPassInverse = ( + inv: Uint8Array, + x: number, + offset: number, +): number => MOD26[inv[MOD26[x + offset]!]! - offset + ALPHABET_SIZE]!; + +export const computeScramblerEntry = ( + j: number, + ro: number, + mo: number, + lo: number, + rFwd: Uint8Array, + mFwd: Uint8Array, + lFwd: Uint8Array, + rInv: Uint8Array, + mInv: Uint8Array, + lInv: Uint8Array, + reflMap: Uint8Array, +): number => { + let x = rotorPassForward(rFwd, j, ro); + x = rotorPassForward(mFwd, x, mo); + x = rotorPassForward(lFwd, x, lo); + x = reflMap[x]!; + x = rotorPassInverse(lInv, x, lo); + x = rotorPassInverse(mInv, x, mo); + return rotorPassInverse(rInv, x, ro); +}; diff --git a/src/codebreaking/index.tsx b/src/codebreaking/index.tsx new file mode 100644 index 0000000..c0d7dcb --- /dev/null +++ b/src/codebreaking/index.tsx @@ -0,0 +1,8 @@ +export type { MenuEdge } from './crib-analysis'; +export { + buildMenuEdges, + findCribPositions, + propagateMenuConstraints, +} from './crib-analysis'; +export type { CribSearchResult } from './crib-search'; +export { cribSearchAsync, encryptString } from './crib-search'; diff --git a/src/components/pages/breakCipher/BreakCipher.test.tsx b/src/components/pages/breakCipher/BreakCipher.test.tsx index dbccac5..a1d0749 100644 --- a/src/components/pages/breakCipher/BreakCipher.test.tsx +++ b/src/components/pages/breakCipher/BreakCipher.test.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import type { CribSearchResult } from '../../../codebreaking'; +import { cribSearchAsync } from '../../../codebreaking'; import { BRUTE_FORCE_RESULT_CARD, CANCEL_SEARCH_BUTTON, @@ -12,8 +14,6 @@ import { RESULTS_CONTAINER, RUN_ANALYSIS_BUTTON, } from '../../../constants/selectors'; -import type { CribSearchResult } from '../../../utils/codebreaking'; -import { cribSearchAsync } from '../../../utils/codebreaking'; import { act, fireEvent, @@ -33,8 +33,8 @@ jest.mock('@react-navigation/native', () => { }; }); -jest.mock('../../../utils/codebreaking', () => { - const actual = jest.requireActual('../../../utils/codebreaking'); +jest.mock('../../../codebreaking', () => { + const actual = jest.requireActual('../../../codebreaking'); return { ...actual, cribSearchAsync: jest.fn( diff --git a/src/components/pages/breakCipher/BreakCipher.tsx b/src/components/pages/breakCipher/BreakCipher.tsx index 3f3fc31..521ec3f 100644 --- a/src/components/pages/breakCipher/BreakCipher.tsx +++ b/src/components/pages/breakCipher/BreakCipher.tsx @@ -6,19 +6,25 @@ import { Button, IconButton, TextInput } from 'react-native-paper'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useDispatch, useSelector } from 'react-redux'; +import type { CribSearchResult } from '../../../codebreaking'; +import { findCribPositions } from '../../../codebreaking'; import { CANCEL_LABEL, CIPHERTEXT_LABEL, COMMON_CRIBS_HINT, CRIB_LABEL, + CRIB_POSITION_LABEL, DECRYPTED_TEXT_LABEL, DERIVED_PLUGBOARD_LABEL, INFO_CRIB_ANALYSIS_CONTENT, INFO_CRIB_ANALYSIS_TITLE, + NEXT_PAGE_LABEL, NLP_CONFIDENCE_LABEL, NO_CRIB_RESULTS_FALLBACK, + PAGE_LABEL, POSITION_LABEL, POSITIONS_LABEL, + PREVIOUS_PAGE_LABEL, RANKING_RESULTS_LABEL, REFLECTOR_LABEL, RESULTS_TITLE, @@ -35,9 +41,13 @@ import { COPY_MESSAGE_BUTTON, CRIB_INPUT, CRIB_POSITION_CARD, + CRIB_POSITION_INPUT, DECRYPTED_TEXT_DISPLAY, INFO_BUTTON, + NEXT_PAGE_BUTTON, NLP_SCORE_DISPLAY, + PAGE_INDICATOR, + PREVIOUS_PAGE_BUTTON, PROGRESS_BAR, RESULTS_CONTAINER, RUN_ANALYSIS_BUTTON, @@ -50,8 +60,6 @@ import { import type { AppDispatch, RootState } from '../../../store/store'; import type { ColorPalette } from '../../../theme/colors'; import { useThemeColors } from '../../../theme/useThemeColors'; -import type { CribSearchResult } from '../../../utils/codebreaking'; -import { findCribPositions } from '../../../utils/codebreaking'; import { CopyButton } from '../../common'; import { InfoSidebar } from '../../InfoSidebar'; @@ -197,6 +205,18 @@ const makeStyles = (colors: ColorPalette, bottomInset: number = 0) => fontSize: 12, fontWeight: 'bold', }, + paginationRow: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginTop: 8, + marginBottom: 4, + gap: 12, + }, + pageIndicator: { + color: colors.textSecondary, + fontSize: 14, + }, }); const NlpBadge: FunctionComponent<{ score: number; testIdSuffix: string }> = ({ @@ -278,50 +298,89 @@ const RunButton: FunctionComponent<{ ); }; +const RESULTS_PER_PAGE = 10; + const CribSearchResults: FunctionComponent<{ results: CribSearchResult[]; }> = ({ results }) => { + const [page, setPage] = useState(0); const colors = useThemeColors(); const styles = useMemo(() => makeStyles(colors), [colors]); + + const totalPages = Math.ceil(results.length / RESULTS_PER_PAGE); + const pageStart = page * RESULTS_PER_PAGE; + const pageResults = results.slice(pageStart, pageStart + RESULTS_PER_PAGE); + return ( {RESULTS_TITLE} - {results.map((result, index) => ( - - - {POSITION_LABEL}: {result.cribPosition} - - - {ROTOR_ORDER_LABEL}: {result.rotorIds.join(', ')} - - - {REFLECTOR_LABEL}: {result.reflectorName} - - - {POSITIONS_LABEL}:{' '} - {result.startingPositions.map((p) => ALPHABET[p]).join(', ')} - - - {DERIVED_PLUGBOARD_LABEL}:{' '} - {formatDerivedPlugboard(result.derivedPlugboard)} - - { + const globalIndex = pageStart + i; + return ( + - {DECRYPTED_TEXT_LABEL}: {result.decryptedText} + + {POSITION_LABEL}: {result.cribPosition} + + + {ROTOR_ORDER_LABEL}: {result.rotorIds.join(', ')} + + + {REFLECTOR_LABEL}: {result.reflectorName} + + + {POSITIONS_LABEL}:{' '} + {result.startingPositions.map((p) => ALPHABET[p]).join(', ')} + + + {DERIVED_PLUGBOARD_LABEL}:{' '} + {formatDerivedPlugboard(result.derivedPlugboard)} + + + {DECRYPTED_TEXT_LABEL}: {result.decryptedText} + + + + + ); + })} + {totalPages > 1 && ( + + + + {PAGE_LABEL} {page + 1} / {totalPages} - - + - ))} + )} ); }; @@ -385,6 +444,7 @@ export const BreakCipher: FunctionComponent = () => { const [infoVisible, setInfoVisible] = useState(false); const [ciphertext, setCiphertext] = useState(''); const [crib, setCrib] = useState(''); + const [cribPosition, setCribPosition] = useState(''); const [expandedPosition, setExpandedPosition] = useState(null); const colors = useThemeColors(); @@ -415,12 +475,24 @@ export const BreakCipher: FunctionComponent = () => { dispatch(searchCancelled()); }, [dispatch]); + const parsedCribPosition = useMemo(() => { + const trimmed = cribPosition.trim(); + if (trimmed === '') return undefined; + const parsed = parseInt(trimmed, 10); + return Number.isNaN(parsed) || parsed < 0 ? undefined : parsed; + }, [cribPosition]); + const handleRun = useCallback(() => { const sanitizedCiphertext = sanitizeInput(ciphertext); const sanitizedCrib = sanitizeInput(crib); if (!sanitizedCiphertext || !sanitizedCrib) return; - runCribAnalysis(sanitizedCiphertext, sanitizedCrib, dispatch); - }, [ciphertext, crib, dispatch]); + runCribAnalysis( + sanitizedCiphertext, + sanitizedCrib, + dispatch, + parsedCribPosition, + ); + }, [ciphertext, crib, dispatch, parsedCribPosition]); const toggleExpandedPosition = useCallback( (pos: number) => { @@ -466,6 +538,20 @@ export const BreakCipher: FunctionComponent = () => { theme={{ colors: { onSurfaceVariant: colors.textSecondary } }} /> + + {COMMON_CRIBS_HINT} { cancelled = false; dispatch(searchStarted()); @@ -26,6 +27,7 @@ export const runCribAnalysis = ( initialReflectorState.reflectors, (p) => dispatch(progressUpdated(p)), isCancelled, + knownCribPosition, ).then((results) => { if (!cancelled) dispatch(cribSearchCompleted({ results, ciphertext, crib })); diff --git a/src/utils/codebreaking.tsx b/src/utils/codebreaking.tsx deleted file mode 100644 index e844918..0000000 --- a/src/utils/codebreaking.tsx +++ /dev/null @@ -1,589 +0,0 @@ -import type { - PlugboardCable, - ReflectorState, - RotorState, -} from '../types/interfaces'; -import { encryptLetter, stepRotors } from './enigma'; -import { nlpConfidence } from './nlp'; - -export interface CribSearchResult { - rotorIds: number[]; - reflectorName: string; - startingPositions: number[]; - cribPosition: number; - decryptedText: string; - nlpScore: number; - derivedPlugboard: PlugboardCable; -} - -export interface MenuEdge { - letterA: string; - letterB: string; - scramblerTable: string[]; -} - -const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - -const createRotorWithPosition = ( - rotor: RotorState, - position: number, -): RotorState => ({ - ...rotor, - config: { ...rotor.config, currentIndex: position }, -}); - -export const encryptString = ( - plaintext: string, - rotors: RotorState[], - plugboard: PlugboardCable, - reflector: ReflectorState, -): string => { - let currentRotors = rotors; - let result = ''; - - for (const letter of plaintext) { - currentRotors = stepRotors(currentRotors); - result += encryptLetter(letter, currentRotors, plugboard, reflector); - } - - return result; -}; - -const generateRotorPermutations = (rotorIds: number[]): number[][] => { - const permutations: number[][] = []; - for (let i = 0; i < rotorIds.length; i++) { - for (let j = 0; j < rotorIds.length; j++) { - if (j === i) continue; - for (let k = 0; k < rotorIds.length; k++) { - if (k === i || k === j) continue; - permutations.push([rotorIds[i]!, rotorIds[j]!, rotorIds[k]!]); - } - } - } - return permutations; -}; - -const MAX_CRIB_RESULTS = 20; - -export const findCribPositions = ( - ciphertext: string, - crib: string, -): number[] => { - const validPositions: number[] = []; - const maxPosition = ciphertext.length - crib.length; - - for (let pos = 0; pos <= maxPosition; pos++) { - let isValid = true; - for (let i = 0; i < crib.length; i++) { - if (crib[i] === ciphertext[pos + i]) { - isValid = false; - break; - } - } - if (isValid) { - validPositions.push(pos); - } - } - - return validPositions; -}; - -// ─── Bombe helpers (exported for unit testing) ─────────────────────────────── - -export const buildMenuEdges = ( - crib: string, - ciphertextSegment: string, - scramblerTables: string[][], - cribOffset: number, -): MenuEdge[] => - crib.split('').map((p, i) => ({ - letterA: p, - letterB: ciphertextSegment[i]!, - scramblerTable: scramblerTables[cribOffset + i]!, - })); - -export const propagateMenuConstraints = ( - edges: MenuEdge[], - seedLetter: string, - seedValue: string, -): Map | null => { - const stecker = new Map(); - const queue: [string, string][] = [[seedLetter, seedValue]]; - - while (queue.length > 0) { - const [letter, value] = queue.shift()!; - - if (stecker.has(letter)) { - if (stecker.get(letter) !== value) return null; - continue; - } - stecker.set(letter, value); - - // Welchman diagonal board: plugboard connections are always reciprocal - queue.push([value, letter]); - - for (const edge of edges) { - if (edge.letterA === letter) { - queue.push([ - edge.letterB, - edge.scramblerTable[ALPHABET.indexOf(value)]!, - ]); - } else if (edge.letterB === letter) { - // Scrambler is self-inverse, so inverse equals forward application - queue.push([ - edge.letterA, - edge.scramblerTable[ALPHABET.indexOf(value)]!, - ]); - } - } - } - - return stecker; -}; - -// ─── Fast integer path for crib search ─────────────────────────────────────── - -// Lookup table replacing `% 26` for values in [0, 51]. -// Inputs: (letter_index + offset) ∈ [0,50] and (fwd_value - offset + 26) ∈ [1,51]. -const MOD26 = new Uint8Array(52); -for (let i = 0; i < 52; i++) MOD26[i] = i % 26; - -interface RotorIntTables { - fwd: Uint8Array; - inv: Uint8Array; -} - -const buildRotorIntTables = (rotor: RotorState): RotorIntTables => { - const fwd = new Uint8Array(26); - const inv = new Uint8Array(26); - for (let i = 0; i < 26; i++) { - const fi = rotor.config.mappedLetters[i]!.charCodeAt(0) - 65; - fwd[i] = fi; - inv[fi] = i; - } - return { fwd, inv }; -}; - -// Integer rotor stepping — mirrors stepRotors() without object allocation. -// rStep/mStep are the notch positions for right and middle rotors. -const intStepRotors = ( - r: number, - m: number, - l: number, - rStep: number, - mStep: number, - out: Uint8Array, -): void => { - if (m === mStep) { - out[0] = MOD26[r + 1]!; - out[1] = MOD26[m + 1]!; - out[2] = MOD26[l + 1]!; - } else if (r === rStep) { - out[0] = MOD26[r + 1]!; - out[1] = MOD26[m + 1]!; - out[2] = l; - } else { - out[0] = MOD26[r + 1]!; - out[1] = m; - out[2] = l; - } -}; - -interface MenuAdjInt { - adjOther: number[][]; - adjStep: number[][]; - testLetter: number; -} - -const buildMenuAdjInt = (crib: string, segment: string): MenuAdjInt => { - const adjOther: number[][] = Array.from({ length: 26 }, () => []); - const adjStep: number[][] = Array.from({ length: 26 }, () => []); - const degree = new Uint8Array(26); - - for (let i = 0; i < crib.length; i++) { - const a = crib.charCodeAt(i) - 65; - const b = segment.charCodeAt(i) - 65; - adjOther[a]!.push(b); - adjStep[a]!.push(i); - adjOther[b]!.push(a); - adjStep[b]!.push(i); - degree[a]!++; - degree[b]!++; - } - - let testLetter = 0; - let maxDeg = 0; - for (let i = 0; i < 26; i++) { - if (degree[i]! > maxDeg) { - maxDeg = degree[i]!; - testLetter = i; - } - } - - return { adjOther, adjStep, testLetter }; -}; - -// BFS constraint propagation using a pre-allocated Int16Array ring buffer. -// posScramblerFlat is a flat Uint8Array of shape [cribLen × 26]: -// posScramblerFlat[step * 26 + inputIdx] = outputIdx -// This single flat dereference is faster than a nested Uint8Array[]. -const BFS_BUF_SIZE = 512; - -const propagateIntHypothesis = ( - seedLetter: number, - seedValue: number, - adjOther: number[][], - adjStep: number[][], - posScramblerFlat: Uint8Array, - stecker: Int8Array, - queueBuf: Int16Array, -): boolean => { - stecker.fill(-1); - let head = 0; - let tail = 0; - - queueBuf[tail++] = seedLetter; - queueBuf[tail++] = seedValue; - - while (head < tail) { - const letter = queueBuf[head++]!; - const value = queueBuf[head++]!; - - if (stecker[letter] !== -1) { - if (stecker[letter] !== value) return false; - continue; - } - stecker[letter] = value; - - // Welchman diagonal board: plugboard connections are reciprocal - queueBuf[tail++] = value; - queueBuf[tail++] = letter; - - const neighbors = adjOther[letter]!; - const steps = adjStep[letter]!; - for (let ni = 0; ni < neighbors.length; ni++) { - queueBuf[tail++] = neighbors[ni]!; - queueBuf[tail++] = posScramblerFlat[steps[ni]! * 26 + value]!; - } - } - - return true; -}; - -const steckerIntToPlugboard = (stecker: Int8Array): PlugboardCable => { - const plugboard: PlugboardCable = {}; - for (let i = 0; i < 26; i++) { - const v = stecker[i]!; - if (v !== -1 && v !== i) { - plugboard[ALPHABET[i]!] = ALPHABET[v]!; - } - } - return plugboard; -}; - -// Integer-only crib verification — avoids string-based encryptLetter calls. -// Uses the precomputed scrambler tables and the BFS-derived stecker directly. -// stecker values: -1 = identity (unswapped), 0-25 = swapped letter index. -const steckerVerifiesCrib = ( - stecker: Int8Array, - segmentCodes: Uint8Array, - cribCodes: Uint8Array, - posScramblerFlat: Uint8Array, -): boolean => { - for (let i = 0; i < cribCodes.length; i++) { - let x: number = segmentCodes[i]!; - const pb1 = stecker[x]!; - if (pb1 !== -1) x = pb1; - x = posScramblerFlat[i * 26 + x]!; - const pb2 = stecker[x]!; - if (pb2 !== -1) x = pb2; - if (x !== cribCodes[i]!) return false; - } - return true; -}; - -// ─── Production Bombe ──────────────────────────────────────────────────────── - -export const cribSearchAsync = ( - ciphertext: string, - crib: string, - allRotors: { [id: number]: RotorState }, - allReflectors: { [id: number]: ReflectorState }, - onProgress: (progress: number) => void, - isCancelled?: () => boolean, -): Promise => { - const validPositions = findCribPositions(ciphertext, crib); - - if (validPositions.length === 0) { - onProgress(1); - return Promise.resolve([]); - } - - const rotorIds = Object.keys(allRotors).map(Number); - const permutations = generateRotorPermutations(rotorIds); - const allResults: CribSearchResult[] = []; - - // ── One-time setup (outside all rotor loops) ────────────────────────────── - - const rotorIntTables: { [id: number]: RotorIntTables } = {}; - for (const id of rotorIds) { - rotorIntTables[id] = buildRotorIntTables(allRotors[id]!); - } - - const reflectorIntMaps: { [id: number]: Uint8Array } = {}; - for (const id of Object.keys(allReflectors).map(Number)) { - const m = new Uint8Array(26); - for (let i = 0; i < 26; i++) { - m[i] = allReflectors[id]!.config.mapping[i]!.charCodeAt(0) - 65; - } - reflectorIntMaps[id] = m; - } - - const cribCodes = new Uint8Array(crib.length); - for (let i = 0; i < crib.length; i++) { - cribCodes[i] = crib.charCodeAt(i) - 65; - } - - // Menu adjacency structures are static (depend only on crib + ciphertext) - const menusByPos = new Map(); - const segmentCodesByPos = new Map(); - for (const pos of validPositions) { - const segment = ciphertext.slice(pos, pos + crib.length); - menusByPos.set(pos, buildMenuAdjInt(crib, segment)); - const codes = new Uint8Array(crib.length); - for (let i = 0; i < crib.length; i++) { - codes[i] = ciphertext.charCodeAt(pos + i) - 65; - } - segmentCodesByPos.set(pos, codes); - } - - // Identify which scrambler steps are needed across all valid crib positions - const neededScramblerStepSet = new Set(); - for (const pos of validPositions) { - for (let i = 0; i < crib.length; i++) { - neededScramblerStepSet.add(pos + i + 1); - } - } - const neededScramblerSteps = [...neededScramblerStepSet]; - const maxStep = Math.max(...neededScramblerSteps); - - // Preallocate scratch buffers — reused across all rotor setting iterations - const rPos = new Uint8Array(maxStep + 1); - const mPos = new Uint8Array(maxStep + 1); - const lPos = new Uint8Array(maxStep + 1); - const stepOut = new Uint8Array(3); - // One Uint8Array(26) scrambler slot per step, rewritten each rotor setting - const scramblerSlots: Uint8Array[] = Array.from( - { length: maxStep + 1 }, - () => new Uint8Array(26), - ); - // Flat scrambler buffer for one crib position: [cribLen × 26] bytes. - // posScramblerFlat[step * 26 + inputIdx] = outputIdx. - const posScramblerFlat = new Uint8Array(crib.length * 26); - // Pre-extracted arrays to avoid Map.get() inside the 26³ loop - const menusArray = validPositions.map((pos) => menusByPos.get(pos)!); - const segCodesArray = validPositions.map( - (pos) => segmentCodesByPos.get(pos)!, - ); - const stecker = new Int8Array(26); - const queueBuf = new Int16Array(BFS_BUF_SIZE); - - // Break work into slices of 26×26 positions (one p0 value per setTimeout tick) - // instead of 26³ per tick — yields ~26× more often so the UI stays responsive. - const reflectorIds = Object.keys(allReflectors).map(Number); - const totalTicks = permutations.length * reflectorIds.length * 26; - - return new Promise((resolve) => { - let permIndex = 0; - let reflIndex = 0; - let p0 = 0; - let ticksDone = 0; - - // Per-permutation tables — recomputed only when permIndex changes. - // Initialised from the first rotor's tables; syncPermCache overwrites - // them before any loop body runs so the seed values are never used. - const seedTables = rotorIntTables[rotorIds[0]!]!; - let cachedPermIndex = -1; - let rId = 0, - mId = 0, - lId = 0; - let rStep = 0, - mStep = 0; - let rFwd = seedTables.fwd, - rInv = seedTables.inv; - let mFwd = seedTables.fwd, - mInv = seedTables.inv; - let lFwd = seedTables.fwd, - lInv = seedTables.inv; - - const syncPermCache = () => { - if (permIndex === cachedPermIndex) return; - cachedPermIndex = permIndex; - [rId, mId, lId] = permutations[permIndex]! as [number, number, number]; - rStep = allRotors[rId]!.config.stepIndex; - mStep = allRotors[mId]!.config.stepIndex; - rFwd = rotorIntTables[rId]!.fwd; - rInv = rotorIntTables[rId]!.inv; - mFwd = rotorIntTables[mId]!.fwd; - mInv = rotorIntTables[mId]!.inv; - lFwd = rotorIntTables[lId]!.fwd; - lInv = rotorIntTables[lId]!.inv; - }; - - const processSlice = () => { - if (isCancelled?.() === true) { - resolve([]); - return; - } - - if (permIndex >= permutations.length) { - onProgress(1); - setTimeout(() => { - if (isCancelled?.() === true) { - resolve([]); - return; - } - allResults.sort((a, b) => b.nlpScore - a.nlpScore); - resolve(allResults.slice(0, MAX_CRIB_RESULTS)); - }, 0); - return; - } - - syncPermCache(); - - const reflectorId = reflectorIds[reflIndex]!; - const reflMap = reflectorIntMaps[reflectorId]!; - const reflector = allReflectors[reflectorId]!; - const perm = permutations[permIndex]!; - - for (let p1 = 0; p1 < 26; p1++) { - for (let p2 = 0; p2 < 26; p2++) { - // Compute rotor positions at every step up to maxStep using - // pure integer arithmetic — no RotorState object allocation. - rPos[0] = p0; - mPos[0] = p1; - lPos[0] = p2; - for (let s = 1; s <= maxStep; s++) { - intStepRotors( - rPos[s - 1]!, - mPos[s - 1]!, - lPos[s - 1]!, - rStep, - mStep, - stepOut, - ); - rPos[s] = stepOut[0]!; - mPos[s] = stepOut[1]!; - lPos[s] = stepOut[2]!; - } - - // Precompute the 26-entry scrambler lookup for each needed step. - // Uses MOD26 table instead of % 26 to avoid expensive modulo ops. - for (let si = 0; si < neededScramblerSteps.length; si++) { - const step = neededScramblerSteps[si]!; - const slot = scramblerSlots[step]!; - const ro = rPos[step]!, - mo = mPos[step]!, - lo = lPos[step]!; - for (let j = 0; j < 26; j++) { - let x = MOD26[rFwd[MOD26[j + ro]!]! - ro + 26]!; - x = MOD26[mFwd[MOD26[x + mo]!]! - mo + 26]!; - x = MOD26[lFwd[MOD26[x + lo]!]! - lo + 26]!; - x = reflMap[x]!; - x = MOD26[lInv[MOD26[x + lo]!]! - lo + 26]!; - x = MOD26[mInv[MOD26[x + mo]!]! - mo + 26]!; - slot[j] = MOD26[rInv[MOD26[x + ro]!]! - ro + 26]!; - } - } - - for (let pi = 0; pi < validPositions.length; pi++) { - const pos = validPositions[pi]!; - const menu = menusArray[pi]!; - const segCodes = segCodesArray[pi]!; - - // Copy scrambler rows for this crib position into a flat buffer - // [step * 26 .. step * 26 + 25] for single-dereference BFS access. - for (let i = 0; i < crib.length; i++) { - posScramblerFlat.set(scramblerSlots[pos + i + 1]!, i * 26); - } - - // Try all 26 stecker hypotheses for the most-connected letter. - // The diagonal board and menu loops reject most hypotheses in - // just a few BFS steps, making this very fast in practice. - for (let hypothesis = 0; hypothesis < 26; hypothesis++) { - if ( - !propagateIntHypothesis( - menu.testLetter, - hypothesis, - menu.adjOther, - menu.adjStep, - posScramblerFlat, - stecker, - queueBuf, - ) - ) { - continue; - } - - // Fast integer verification: apply stecker→scrambler→stecker - // to each cipher letter and confirm it matches the crib. - // Uses the already-computed posScramblerFlat — no string ops. - if ( - !steckerVerifiesCrib( - stecker, - segCodes, - cribCodes, - posScramblerFlat, - ) - ) { - continue; - } - - const derivedPlugboard = steckerIntToPlugboard(stecker); - - const fullRotors = [ - createRotorWithPosition(allRotors[rId]!, p0), - createRotorWithPosition(allRotors[mId]!, p1), - createRotorWithPosition(allRotors[lId]!, p2), - ]; - const decryptedText = encryptString( - ciphertext, - fullRotors, - derivedPlugboard, - reflector, - ); - allResults.push({ - rotorIds: perm, - reflectorName: reflector.name, - startingPositions: [p0, p1, p2], - cribPosition: pos, - decryptedText, - nlpScore: nlpConfidence(decryptedText), - derivedPlugboard, - }); - break; - } - } - } - } - - // Advance to next p0 slice - ticksDone++; - p0++; - if (p0 >= 26) { - p0 = 0; - reflIndex++; - if (reflIndex >= reflectorIds.length) { - reflIndex = 0; - permIndex++; - } - } - onProgress(ticksDone / totalTicks); - - setTimeout(processSlice, 0); - }; - - // Defer first tick so React can render the searching state before work begins - setTimeout(processSlice, 0); - }); -}; diff --git a/src/utils/index.tsx b/src/utils/index.tsx index eb64f2f..9417039 100644 --- a/src/utils/index.tsx +++ b/src/utils/index.tsx @@ -1,3 +1,2 @@ -export * from './codebreaking'; export * from './enigma'; export * from './string-utils';