Files
wordle-solver/src/wordle.py
2022-02-01 02:32:35 +00:00

227 lines
7.4 KiB
Python

import copy
import logging
import random
from dataclasses import dataclass, field
from enum import Enum, auto
from itertools import repeat
from multiprocessing import Pool
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
from pathlib import Path
import string
WORD_LENGTH = 5 # TODO: Make this configurable at runtime
log = logging.getLogger(__name__)
Letter = chr
Position = int
Word = str
WordlePositionState = Optional[Letter]
WordleBoardState = List[WordlePositionState]
class WordleLetterConstraint(Enum):
UNKNOWN = auto() # No guess made with this letter yet
EXCLUDED = auto() # Letter has been guessed and is known to not be in the word
INCLUDED = auto() # Letter has been guessed and is known to be in the word
@dataclass
class WordleState:
board: WordleBoardState
answer: Word
letter_constraints: Dict[Letter, WordleLetterConstraint] = field(
default_factory=lambda: {
c: WordleLetterConstraint.UNKNOWN for c in string.ascii_lowercase
}
)
letters_eliminated: List[Dict[Letter, None]] = field(
default_factory=lambda: [dict() for i in range(WORD_LENGTH)]
)
class DictionaryProvider:
def __init__(self, limit: Optional[int]=None):
self.limit = limit
self.dictionary = self._get_dictionary(self._word_filter(WORD_LENGTH))
if limit:
self.dictionary = random.sample(self.dictionary, limit)
def provide(self) -> List[Word]:
return self.dictionary.copy()
def _get_dictionary(self, filter_fun: Callable[str, bool] = None) -> List[Word]:
l = []
if not filter_fun:
filter_fun = lambda s: True
with open("/usr/share/dict/words") as d:
return [
word
for word in filter(filter_fun, map(lambda w: w.strip(), d.readlines()))
]
def _word_filter(self, length: int):
def f(word: str):
return (
len(word) == length
and all(map(lambda x: x in string.ascii_lowercase, word))
and not all(map(lambda c: c in "xiv", word)) # hack to remove some roman numerals
)
return f
class WordleWordPlayable:
def is_word_playable(self, state: WordleState, word: Word):
for idx, ch in enumerate(word):
# Position is not already solved
if state.board[idx] is not None and state.board[idx] != ch:
return False
# Letter is not already excluded
constraint = state.letter_constraints[ch]
if constraint == WordleLetterConstraint.EXCLUDED:
return False
if ch in state.letters_eliminated[idx]:
return False
for letter, constraint in state.letter_constraints.items():
if constraint == WordleLetterConstraint.INCLUDED and letter not in word:
return False
return True
class WordleRemainingWordsStateEvaluator:
"""
A class which takes a WordleState and returns a score for the state
"""
def __init__(self, dictionary_provider: DictionaryProvider, word_playable: WordleWordPlayable):
self.dictionary = dictionary_provider.provide()
self.word_playable = word_playable
def evaluate(self, state: WordleState) -> int:
"""
Get a score for the given word state
"""
return -sum([1 for word in self.dictionary if self.word_playable.is_word_playable(state, word)])
@dataclass
class WordleMoveProcessor:
def process(self, state: WordleState, guess: Word) -> WordleState:
new_state = WordleState(
board=state.board,
letter_constraints=state.letter_constraints.copy(),
answer=state.answer,
letters_eliminated=[ d.copy() for d in state.letters_eliminated ],
)
for idx, ch in enumerate(guess):
if ch in new_state.answer:
new_state.letter_constraints[ch] = WordleLetterConstraint.INCLUDED
else:
new_state.letter_constraints[ch] = WordleLetterConstraint.EXCLUDED
if new_state.answer[idx] == ch:
new_state.board[idx] = ch
else:
new_state.letters_eliminated[idx][ch] = None
return new_state
class WordleMoveCalculator:
"""
A class which calculates some number of optimal moves based upon the WordleStateEvaluator
"""
def __init__(
self,
dictionary_provider: DictionaryProvider,
state_evaluator, # TODO: parent class WordleStateEvaluator,
move_processor: WordleMoveProcessor,
word_playable: WordleWordPlayable,
):
self.dictionary = dictionary_provider.provide()
self.state_evaluator = state_evaluator
self.move_processor = move_processor
self.word_playable = word_playable
def calculate(
self, state: WordleState, evaluation_depth: int, limit: Optional[int]=None,
) -> List[Tuple[Word, int]]:
guesses = []
words = [
word for word in self.dictionary if self.word_playable.is_word_playable(state, word)
]
with Pool(4) as p:
move_rankings = p.starmap(self.calc_guess, zip(repeat(state, len(words)), words))
move_rankings = sorted(move_rankings, key=lambda x: x[1], reverse=True)
if limit:
move_rankings = move_rankings[0:limit]
return move_rankings
def calc_guess(self, state: WordleState, guess: Word):
# TODO: Don't recompute me, already determined in `calculate`
words = [
word for word in self.dictionary if self.word_playable.is_word_playable(state, word)
]
word_total = 0
for answer in words:
temp_state = WordleState(
board=state.board.copy(),
letter_constraints=state.letter_constraints.copy(),
answer=answer,
letters_eliminated=[ d.copy() for d in state.letters_eliminated ],
)
new_state = self.move_processor.process(temp_state, guess)
word_total += self.state_evaluator.evaluate(temp_state)
word_score = word_total / len(words)
return (guess, word_score)
def main():
logging.basicConfig(format='%(asctime)s %(message)s', level=logging.INFO)
dictionary_provider = DictionaryProvider(limit=200)
word_playable = WordleWordPlayable()
state_evaluator = WordleRemainingWordsStateEvaluator(dictionary_provider, word_playable)
move_processor = WordleMoveProcessor()
move_calculator = WordleMoveCalculator(
dictionary_provider, state_evaluator, move_processor, word_playable
)
answer = random.choice(dictionary_provider.provide())
state = WordleState(board=[None] * WORD_LENGTH, answer=answer)
iteration = 1
guesses = [] # type: List[str]
while not all(state.board):
log.info("Round: %d", iteration)
log.debug("State: %s", str(state))
moves = move_calculator.calculate(state, 1)
log.info("Best Moves: %s", moves[:5])
log.info("Num words remaining: %d", len(moves))
guess = moves[0][0]
log.info("Guess: %s", guess)
state = move_processor.process(state, guess)
log.info("Board: %s", str(state.board))
guesses.append(guess)
iteration += 1
print(f"Answer: {answer}")
print(f"Iterations: {iteration - 1}")
print(f"Guesses: {str(', '.join(guesses))}")
if __name__ == "__main__":
main()