227 lines
7.4 KiB
Python
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()
|