+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+Wordle! PLAY, AUTOPLAY, CHEAT, PRECOMPUTE and SELFTEST.
+"""
+
+import enum
+import hashlib
+import logging
+import math
+import multiprocessing
+import random
+import re
+import string
+import sys
+import time
+from collections import Counter, defaultdict
+from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple
+
+from pyutils import ansi, bootstrap, config, list_utils, string_utils
+from pyutils.collectionz.shared_dict import SharedDict
+from pyutils.files import file_utils
+from pyutils.parallelize import executors
+from pyutils.parallelize import parallelize as par
+from pyutils.parallelize import smart_future
+from pyutils.typez import histogram
+
+logger = logging.getLogger(__name__)
+args = config.add_commandline_args(
+ f'Wordle! ({__file__})',
+ 'Args related to cheating at Wordle.',
+)
+args.add_argument(
+ '--mode',
+ type=str,
+ default='PLAY',
+ choices=['CHEAT', 'AUTOPLAY', 'SELFTEST', 'PRECOMPUTE', 'PLAY'],
+ metavar='MODE',
+ help="""RAW|
+Our mode of operation:
+
+ PLAY = play wordle with me! Pick a random solution or
+ specify a solution with --template.
+
+ CHEAT = given a --template and, optionally, --letters_in_word
+ and/or --letters_to_avoid, return the best guess word;
+
+ AUTOPLAY = given a complete word in --template, guess it step
+ by step showing work;
+
+ SELFTEST = autoplay every possible solution keeping track of
+ wins/losses and average number of guesses;
+
+PRECOMPUTE = populate hash table with optimal guesses.
+ """,
+)
+args.add_argument(
+ '--template',
+ type=str,
+ help='The current board in PLAY, CHEAT, AUTOPLAY mode. Use _\'s for unknown letters.',
+)
+args.add_argument(
+ '--letters_to_avoid',
+ type=str,
+ help='In CHEAT mode, the set of letters known to not be in the solution.',
+ metavar='LETTERS',
+)
+args.add_argument(
+ '--letters_in_word',
+ type=str,
+ help="""RAW|
+Letters known to be in the solution but whose positions are not yet known.
+
+For example:
+
+ t0i23 => says we tried a 't' as the first letter (0) so we
+ know it's in the word and not there. We also know
+ there's an 'i' which is not the middle letter (2)
+ and is not the 4th letter (3). Note the zero-based
+ position counting semantics (i.e. the first letter
+ is letter 0).
+ e34f0 => mean we know there's an 'e' and an 'f'; we've attempted
+ former in positions 3-4 (recall, 4 is the last spot in
+ a standard 5 letter wordle) and the latter in the
+ first spot (0) already.
+ """,
+ metavar='<LETTER><ZERO-BASED_POSITION(S)_ALREADY_TRIED>...',
+)
+args.add_argument(
+ '--solutions_file',
+ type=str,
+ default='/home/scott/bin/wordle_solutions.txt',
+ help='Where can I find a valid word list for solutions?',
+)
+args.add_argument(
+ '--guesses_file',
+ type=str,
+ default='/home/scott/bin/wordle_guesses.txt',
+ help='Where can I find a valid word list for guesses?',
+)
+args.add_argument(
+ '--hash_file',
+ type=str,
+ default='/home/scott/bin/wordle_hash.txt',
+ help='Where can I find my precomputed hash file?',
+)
+
+# Type aliases for code readability
+Position = int
+Letter = str
+Word = str
+Fprint = str
+Bucket = int
+
+
+class Hint(enum.IntFlag):
+ """Green, yellow or gray?"""
+
+ GRAY_WRONG = 0
+ YELLOW_LETTER_RIGHT_POSITION_WRONG = 1
+ GREEN_LETTER_IN_RIGHT_POSITION = 2
+
+
+class WordStateUndoType(enum.IntFlag):
+ """Used to record guess undo information by type."""
+
+ LETTER_IN_SOLUTION_MIN_CHANGE = 1
+ LETTER_IN_SOLUTION_MAX_CHANGE = 2
+ LETTER_IN_SOLUTION_ADDED_WITH_MIN_MAX = 3
+ YELLOW_LETTER_ADDED = 4
+ GREEN_LETTER_ADDED = 5
+ GRAY_LETTER_ADDED = 6
+
+
+class WordStateUndo(NamedTuple):
+ """A record per guess containing all the info needed to undo it."""
+
+ guess: Word
+ hints: Dict[Position, Hint]
+ mods: List[Dict[str, Any]]
+
+
+class WordState(object):
+ """Keeps track of the current board state, previous guesses and hints,
+ and which letters are known/unknown/eliminated, etc...
+
+ """
+
+ def __init__(
+ self,
+ solution_length: int,
+ max_letter_population_per_word: Dict[Letter, int],
+ *,
+ letters_at_known_positions: Optional[Dict[Position, Letter]] = None,
+ letters_at_unknown_positions: Optional[Dict[Letter, Set[Position]]] = None,
+ letters_excluded: Optional[Set[Letter]] = None,
+ ):
+ """Initialize the WordState given the length of the solution word
+ (number of letters), maximum number of times a given letter
+ may appear in the solution, and, optionally, some letter
+ restrictions. All positions below are zero-based.
+
+ letters_at_known_positions: position => letter map
+ letters_at_unknown_positions: letter => set(position(s) tried) map
+ letters_excluded: set of letters known to not be in the word
+
+ """
+ super().__init__()
+ assert solution_length > 0
+ self.solution_length: int = solution_length
+
+ # All info necessary to undo a guess later. We'll add an entry
+ # for every guess in a stack.
+ self.undo_info: List[WordStateUndo] = []
+
+ # We're going to use internal methods to set up the initial
+ # state and they all require an undo record to populate... but
+ # there's no initial guess and we'll never undo beyond here.
+ # So create a fake undo record for them to scribble on and
+ # throw it away later.
+ fake_undo: WordStateUndo = WordStateUndo(
+ guess="fake, will be thrown away",
+ hints={},
+ mods=[],
+ )
+
+ # Across all solutions, how many times did each letter appear
+ # per word, at max. For each letter we learn is in the word
+ # we'll track a min..max valid occurrances; this is the
+ # initial max. It's the max times a given letter appears in
+ # any word in the solution set.
+ assert max_letter_population_per_word
+ self.max_letter_population_per_word = max_letter_population_per_word
+
+ # The min..max population for every letter we know in the solution.
+ # The List[int] here has two entries: min and max.
+ self.letters_in_solution: Dict[Letter, List[int]] = {}
+
+ # Green letters by where they are.
+ self.letters_at_known_positions: Dict[Position, Letter] = {}
+ if letters_at_known_positions is not None:
+ for n, letter in letters_at_known_positions.items():
+ self.record_green(n, letter, fake_undo)
+
+ # Yellow letters to where we've already tried them (i.e. where
+ # the cannot be.)
+ self.letters_at_unknown_positions: Dict[Letter, Set[Position]] = defaultdict(
+ set
+ )
+ if letters_at_unknown_positions is not None:
+ for letter, tried_pos in letters_at_unknown_positions.items():
+ for n in tried_pos:
+ self.record_yellow(n, letter, fake_undo)
+
+ # Excluded letters discovered so far.
+ self.letters_excluded: Set[Letter] = set()
+ if letters_excluded is not None:
+ for letter in letters_excluded:
+ self.record_gray(letter, fake_undo)
+
+ def get_template(self) -> str:
+ """Return a representation of the board, e.g. __a_e"""
+
+ template = '_' * self.solution_length
+ for n in self.letters_at_known_positions:
+ letter = self.letters_at_known_positions[n]
+ template = template[0:n] + letter + template[n + 1 :]
+ return template
+
+ def get_alphabet(self) -> str:
+ """Return a colorized set of letters eliminated and still viable."""
+
+ letters_remaining = set(string.ascii_lowercase)
+ green_letters = set()
+ yellow_letters = set()
+ for letter in self.letters_excluded:
+ letters_remaining.remove(letter)
+ for letter in self.letters_at_known_positions.values():
+ assert letter in letters_remaining
+ green_letters.add(letter)
+ for letter in self.letters_at_unknown_positions.keys():
+ if len(self.letters_at_unknown_positions[letter]) > 0:
+ assert letter in letters_remaining
+ yellow_letters.add(letter)
+ ret = ''
+ for letter in string.ascii_lowercase:
+ if letter in letters_remaining:
+ if letter in green_letters:
+ ret += ansi.bg('forest green')
+ ret += ansi.fg('black')
+ ret += letter
+ ret += ansi.reset()
+ elif letter in yellow_letters:
+ ret += ansi.bg('energy yellow')
+ ret += ansi.fg('black')
+ ret += letter
+ ret += ansi.reset()
+ else:
+ ret += letter
+ else:
+ ret += ansi.fg('black olive')
+ ret += letter
+ ret += ansi.reset()
+ # ret += ' '
+ return ret
+
+ def __repr__(self) -> str:
+ return self.get_alphabet()
+
+ def adjust_existing_letters_in_solution_maxes(
+ self, max_max: int, undo: WordStateUndo
+ ) -> None:
+ """We've determined, based on info about letters we know to be in the
+ solution and their min..max counts, what the maximum max of
+ any letter should be. Check all letters we know to be in the
+ solution and maybe tighten their maxes. If we do, remember it
+ for later undo purposes.
+
+ """
+ for letter in self.letters_in_solution.keys():
+ old_max = self.letters_in_solution[letter][1]
+ new_max = min(max_max, old_max)
+ if old_max != new_max:
+ self.letters_in_solution[letter][1] = new_max
+ undo.mods.append(
+ {
+ "type": WordStateUndoType.LETTER_IN_SOLUTION_MAX_CHANGE,
+ "letter": letter,
+ "old_max": old_max,
+ }
+ )
+
+ def record_letter_in_solution(
+ self,
+ letter: Letter,
+ undo: WordStateUndo,
+ exact_count: Optional[int] = None,
+ ) -> None:
+ """Letter is a (maybe new, may existing in a special case, see below)
+ letter that we know is in the solution. For each letter we
+ know is in the solution, we track a min..max window indicating
+ how many times that letter is permitted to occur in the
+ solution. Manage those here.
+
+ """
+
+ # If letter is already here, allow it to change bounds if it
+ # came with an exact_count.
+ if letter in self.letters_in_solution:
+ if exact_count is not None:
+ old_min = self.letters_in_solution[letter][0]
+ new_min = exact_count
+ if old_min != new_min:
+ self.letters_in_solution[letter][0] = new_min
+ undo.mods.append(
+ {
+ "type": WordStateUndoType.LETTER_IN_SOLUTION_MIN_CHANGE,
+ "letter": letter,
+ "old_min": old_min,
+ }
+ )
+ old_max = self.letters_in_solution[letter][1]
+ new_max = exact_count
+ if old_max != new_max:
+ self.letters_in_solution[letter][1] = new_max
+ undo.mods.append(
+ {
+ "type": WordStateUndoType.LETTER_IN_SOLUTION_MAX_CHANGE,
+ "letter": letter,
+ "old_max": old_max,
+ }
+ )
+
+ # Also... since we now know exactly how many of this
+ # letter there are, maybe tighten the rest's maxes.
+ if exact_count != 1:
+ num_letters_known = len(self.letters_in_solution) - 1 + exact_count
+ max_max = self.solution_length - (num_letters_known - 1)
+ self.adjust_existing_letters_in_solution_maxes(max_max, undo)
+ return
+
+ # If we're here, letter is a newly discovered letter in the
+ # solution. Such a letter will likely affect the max count of
+ # existing letters since, as new letters are discovered, there
+ # is less room for the other ones in the solution.
+ if exact_count is None:
+ num_letters_known = len(self.letters_in_solution) + 1
+ else:
+ num_letters_known = len(self.letters_in_solution) + exact_count
+ max_max = self.solution_length - (num_letters_known - 1)
+ self.adjust_existing_letters_in_solution_maxes(max_max, undo)
+
+ # Finally, add the new letter's record with its initial min/max.
+ if exact_count is None:
+ initial_max = min(
+ max_max,
+ self.max_letter_population_per_word[letter],
+ )
+ initial_min = 1
+ else:
+ initial_max = exact_count
+ initial_min = exact_count
+ self.letters_in_solution[letter] = [initial_min, initial_max]
+ undo.mods.append(
+ {
+ "type": WordStateUndoType.LETTER_IN_SOLUTION_ADDED_WITH_MIN_MAX,
+ "min": initial_min,
+ "max": initial_max,
+ "letter": letter,
+ }
+ )
+
+ def record_yellow(
+ self,
+ n: Position,
+ letter: Letter,
+ undo: WordStateUndo,
+ num_correct_doppelgangers: Optional[int] = None,
+ ) -> None:
+ """We've discovered a new yellow letter or a new place where an
+ existing yellow letter is still yellow. If
+ num_correct_doppelgangers is non-None, it means this letter is
+ wrong (gray) in the current location but right (green or
+ yellow) in other locations and we now know the precise count
+ of this letter in the solution -- tell the
+ record_letter_in_solution method.
+
+ """
+ if n not in self.letters_at_unknown_positions[letter]:
+ if (
+ len(self.letters_at_unknown_positions[letter]) == 0
+ or num_correct_doppelgangers is not None
+ ):
+ self.record_letter_in_solution(letter, undo, num_correct_doppelgangers)
+ self.letters_at_unknown_positions[letter].add(n)
+ undo.mods.append(
+ {
+ "type": WordStateUndoType.YELLOW_LETTER_ADDED,
+ "letter": letter,
+ "position": n,
+ }
+ )
+
+ def record_green(self, n: Position, letter: Letter, undo: WordStateUndo):
+ """We found a new green letter."""
+
+ if n not in self.letters_at_known_positions:
+ self.letters_at_known_positions[n] = letter
+ undo.mods.append(
+ {
+ "type": WordStateUndoType.GREEN_LETTER_ADDED,
+ "letter": letter,
+ "position": n,
+ }
+ )
+ self.record_letter_in_solution(letter, undo)
+
+ def record_gray(self, letter: Letter, undo: WordStateUndo) -> None:
+ """We found a new gray letter."""
+
+ if letter not in self.letters_excluded:
+ self.letters_excluded.add(letter)
+ undo.mods.append(
+ {
+ "type": WordStateUndoType.GRAY_LETTER_ADDED,
+ "letter": letter,
+ }
+ )
+
+ def record_guess_and_hint(self, guess: Word, hints: Dict[Position, Hint]):
+ """Make a guess and change state based on the hint. Remember how to
+ undo everything later.
+
+ """
+ undo = WordStateUndo(guess=guess, hints=hints, mods=[])
+ for n, letter in enumerate(guess):
+ hint = hints[n]
+
+ if hint is Hint.GRAY_WRONG:
+ # Exclude letters we got WRONG _unless_ we guessed the
+ # same letter elsewhere and got a yellow/green there.
+ # In this case the WRONG hint is not saying this
+ # letter isn't in the word (it is) but rather that
+ # there aren't N+1 instances of this letter. In this
+ # edge case we _do_ know that the letter is not valid
+ # in this position, though; treat it as yellow.
+ num_correct_doppelgangers = 0
+ for k, other in enumerate(guess):
+ if other == letter and n != k and hints[k] != Hint.GRAY_WRONG:
+ num_correct_doppelgangers += 1
+ if num_correct_doppelgangers == 0:
+ self.record_gray(letter, undo)
+ else:
+ # Treat this as a yellow; we know letter can't go
+ # here or it would have been yellow/green.
+ self.record_yellow(n, letter, undo, num_correct_doppelgangers)
+ elif hint is Hint.YELLOW_LETTER_RIGHT_POSITION_WRONG:
+ self.record_yellow(n, letter, undo)
+ elif hint is Hint.GREEN_LETTER_IN_RIGHT_POSITION:
+ self.record_green(n, letter, undo)
+ self.undo_info.append(undo)
+
+ def undo_guess(self) -> None:
+ """Unmake a guess and revert back to a previous state."""
+
+ assert len(self.undo_info) > 0
+ undo = self.undo_info[-1]
+ self.undo_info = self.undo_info[:-1]
+
+ # We iterate the mods in reverse order so we never have weird
+ # apply/undo ordering artifacts.
+ for mod in reversed(undo.mods):
+ mod_type = mod['type']
+ letter = mod['letter']
+ if mod_type is WordStateUndoType.GRAY_LETTER_ADDED:
+ self.letters_excluded.remove(letter)
+ elif mod_type is WordStateUndoType.YELLOW_LETTER_ADDED:
+ pos = mod['position']
+ self.letters_at_unknown_positions[letter].remove(pos)
+ elif mod_type is WordStateUndoType.GREEN_LETTER_ADDED:
+ pos = mod['position']
+ del self.letters_at_known_positions[pos]
+ elif mod_type is WordStateUndoType.LETTER_IN_SOLUTION_MIN_CHANGE:
+ old_min = mod['old_min']
+ self.letters_in_solution[letter][0] = old_min
+ elif mod_type is WordStateUndoType.LETTER_IN_SOLUTION_MAX_CHANGE:
+ old_max = mod['old_max']
+ self.letters_in_solution[letter][1] = old_max
+ elif mod_type is WordStateUndoType.LETTER_IN_SOLUTION_ADDED_WITH_MIN_MAX:
+ del self.letters_in_solution[letter]
+
+
+class AutoplayOracle(object):
+ """The class that knows the right solution and can give hints in
+ response to guesses.
+
+ """
+
+ def __init__(self, solution: Word):
+ super().__init__()
+ self.solution = solution.lower()
+ self.solution_letter_count = Counter(self.solution)
+ self.solution_letter_to_pos = defaultdict(set)
+ for n, letter in enumerate(self.solution):
+ self.solution_letter_to_pos[letter].add(n)
+ self.hint_quota_by_letter = Counter(self.solution)
+
+ def judge_guess(self, guess: Word) -> Dict[Position, Hint]:
+ """Returns a mapping from position -> Hint to indicate
+ whether each letter in the guess string is green, yellow
+ or gray.
+
+ """
+ assert len(guess) == len(self.solution)
+ hint_by_pos: Dict[Position, Hint] = {}
+ letter_to_pos = defaultdict(set)
+ hint_quota_by_letter = self.hint_quota_by_letter.copy()
+
+ for position, letter in enumerate(guess):
+ letter_to_pos[letter].add(position)
+
+ if letter == self.solution[position]: # green
+ if hint_quota_by_letter[letter] == 0:
+ # We must tell them this letter is green in this
+ # position but we've exhausted our quota of hints
+ # for this letter. There must exist some yellow
+ # hint we previously gave that we need to turn to
+ # gray (wrong).
+ for other in letter_to_pos[letter]:
+ if other != position:
+ if (
+ hint_by_pos[other]
+ == Hint.YELLOW_LETTER_RIGHT_POSITION_WRONG
+ ):
+ hint_by_pos[other] = Hint.GRAY_WRONG
+ break
+ hint_by_pos[position] = Hint.GREEN_LETTER_IN_RIGHT_POSITION
+
+ elif self.solution_letter_count[letter] > 0: # yellow
+ if hint_quota_by_letter[letter] == 0:
+ # We're out of hints for this letter. All the
+ # other hints must be green or yellow. Tell them
+ # this one is gray (wrong).
+ hint_by_pos[position] = Hint.GRAY_WRONG
+ else:
+ hint_by_pos[position] = Hint.YELLOW_LETTER_RIGHT_POSITION_WRONG
+
+ else: # gray
+ hint_by_pos[position] = Hint.GRAY_WRONG
+ if hint_quota_by_letter[letter] > 0:
+ hint_quota_by_letter[letter] -= 1
+ return hint_by_pos
+
+
+class AutoPlayer(object):
+ """This is the class that knows how to guess given the current game
+ state along with several support methods.
+
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.solution_length = None
+
+ # Guess judge
+ self.oracle = None
+
+ # Board state tracker and move undoer
+ self.word_state = None
+
+ # The position hash has known best guesses for some subset of
+ # remaining words. See --mode=PRECOMPUTE for how to populate.
+ self.position_hash = {}
+ filename = config.config['hash_file']
+ if filename is not None and file_utils.file_is_readable(filename):
+ logger.debug(f'Initializing position hash from {filename}...')
+ with open(filename, 'r') as rf:
+ for line in rf:
+ line = line[:-1]
+ line = line.strip()
+ line = re.sub(r'#.*$', '', line)
+ if len(line) == 0:
+ continue
+ (key, word) = line.split(':')
+ (count, fprint) = key.split('@')
+ count = count.strip()
+ count = int(count)
+ fprint = fprint.strip()
+ word = word.strip()
+ self.position_hash[(count, fprint)] = word
+ logger.debug(f'...hash contains {len(self.position_hash)} entries.')
+
+ # All legal solutions pre-sorted by length.
+ self.all_possible_solutions_by_length = defaultdict(list)
+ filename = config.config['solutions_file']
+ if filename is not None and file_utils.file_is_readable(filename):
+ logger.debug(f'Initializing valid solution word list from {filename}...')
+ with open(filename) as rf:
+ for word in rf:
+ word = word[:-1]
+ word = word.lower()
+ self.all_possible_solutions_by_length[len(word)].append(word)
+ else:
+ logger.error('A valid --solutions_file is required.')
+ sys.exit(0)
+
+ # All legal guesses pre-sorted by length.
+ self.all_possible_guesses_by_length = defaultdict(list)
+ filename = config.config['guesses_file']
+ if filename is not None and file_utils.file_is_readable(filename):
+ logger.debug(f'Initializing legal guess word list from {filename}...')
+ with open(filename) as rf:
+ for word in rf:
+ word = word[:-1]
+ word = word.lower()
+ self.all_possible_guesses_by_length[len(word)].append(word)
+ else:
+ logger.error('A valid --guesses_file is required.')
+ sys.exit(0)
+
+ def new_word(
+ self,
+ length: int,
+ oracle: Optional[AutoplayOracle],
+ word_state: Optional[WordState],
+ ) -> None:
+ """Here's a new word to guess. Reset state to play a new game. On
+ principle we don't give this class the solution. Instead, we
+ just give it length to indicate the number of characters in
+ the solution. Guesses will be turned into hints by the
+ oracle. The current game state is tracked by the
+ word_state.
+
+ """
+ self.solution_length = length
+ self.oracle = oracle
+ self.word_state = word_state
+
+ def get_all_possible_solutions(self) -> List[Word]:
+ """Given the current known word state, compute the subset of all
+ possible solutions that are still possible. Note: this method
+ guarantees to return the possible solutions list in sorted
+ order.
+
+ """
+
+ def is_possible_solution(solution: Word, word_state: WordState) -> bool:
+ """Note: very perf sensitive code; inner loop."""
+
+ letters_seen: Dict[Letter, int] = defaultdict(int)
+ for n, letter in enumerate(solution):
+ # The word can't contain letters we know aren't in the
+ # solution.
+ if letter in word_state.letters_excluded:
+ return False
+
+ # If we already tried this letter in this position and
+ # it wasn't green, this isn't a possible solution.
+ if n in word_state.letters_at_unknown_positions[letter]:
+ return False
+
+ # If we know a letter is in a position, solution words
+ # must have that letter in that position.
+ if (
+ n in word_state.letters_at_known_positions
+ and letter != word_state.letters_at_known_positions[n]
+ ):
+ return False
+ letters_seen[letter] += 1
+
+ # Finally, the word must include all letters presently
+ # known to be in the solution to be viable.
+ for letter, min_max in word_state.letters_in_solution.items():
+ num_seen = letters_seen[letter]
+ if num_seen < min_max[0]:
+ return False
+ elif num_seen > min_max[1]:
+ return False
+ return True
+
+ possible_solutions = []
+ for word in self.all_possible_solutions_by_length[self.solution_length]:
+ if is_possible_solution(word, self.word_state):
+ possible_solutions.append(word)
+
+ # Note: because self.all_possible_solutions_by_length is sorted
+ # and we iterated it in order, possible_solutions is also sorted
+ # already.
+ # assert possible_solutions == sorted(possible_solutions)
+ return possible_solutions
+
+ def get_frequency_and_frequency_by_position_tables(
+ self,
+ possible_solutions: List[Word],
+ ) -> Tuple[Dict[Letter, float], List[Dict[Letter, float]]]:
+ """This method is used by heuristic mode. It returns two tables:
+
+ 1. The frequency count by letter for words in possible_solutions
+ to encourage guesses composed of common letters.
+ 2. A letter-in-position bonus term to encourage guesses with
+ letters in positions that occur frequently in the solutions.
+
+ """
+ template = self.word_state.get_template()
+ pfreq: List[Dict[Letter, float]] = []
+ pop_letters: List[Dict[Letter, int]] = []
+ for n in range(len(template)):
+ pop_letters.append(defaultdict(int))
+ pfreq.append({})
+
+ freq: Dict[Letter, float] = {}
+ for word in possible_solutions:
+ letter_filter = set()
+ for n, letter in enumerate(word):
+
+ # Only count frequency of letters still to be filled in.
+ if template[n] != '_':
+ continue
+
+ # Do not give a full frequency bonus to repeated letters.
+ if letter not in letter_filter:
+ freq[letter] = freq.get(letter, 0) + 1
+ letter_filter.add(letter)
+ else:
+ freq[letter] += 0.1
+ pop_letters[n][letter] += 1 # type: ignore
+
+ # Reward guesses that place the most popular letter in a position.
+ # Save the top 3 letters per position.
+ # don't give a bonus if letters spread all over the place?
+ # only give a bonus to a given letter in one position?
+ total_letters_in_position = len(possible_solutions)
+ for n in range(len(template)):
+ for letter, count in sorted(pop_letters[n].items(), key=lambda x: -x[1]):
+ if count <= 1:
+ break
+ normalized = count / total_letters_in_position
+ assert 0.0 < normalized <= 1.0
+ if normalized > 0.08:
+ pfreq[n][letter] = normalized
+ else:
+ break
+ return (freq, pfreq)
+
+ def dump_frequency_data(
+ self,
+ possible_solutions: List[Word],
+ letter_frequency: Dict[Letter, float],
+ letter_position_frequency: List[Dict[Letter, float]],
+ ) -> None:
+ """Just logs the frequency tables we computed above."""
+
+ logger.debug('Unknown letter(s) frequency table: ')
+ out = ''
+ for letter, weight in sorted(letter_frequency.items(), key=lambda x: -x[1]):
+ out += f'{letter}:{weight}, '
+ if len(out):
+ out = out[:-2]
+ logger.debug(out)
+
+ logger.debug('Unknown letter-in-position bonus table: ')
+ out = ''
+ for n in range(len(possible_solutions[0])):
+ pop = letter_position_frequency[n]
+ for letter, weight in sorted(pop.items(), key=lambda x: -x[1]):
+ out += f'pos{n}:{letter}@{weight:.5f}, '
+ if len(out):
+ out = out[:-2]
+ logger.debug(out)
+
+ def position_in_hash(self, num_potential_solutions: int, fprint: Fprint):
+ """Is a position in our hash table?"""
+ return (num_potential_solutions, fprint) in self.position_hash
+
+ def guess_word(self) -> Optional[Word]:
+ """Compute a guess word and return it. Returns None on error."""
+
+ template = self.word_state.get_template()
+ possible_solutions = self.get_all_possible_solutions()
+ num_possible_solutions = len(possible_solutions)
+ fprint = hashlib.md5(possible_solutions.__repr__().encode('ascii')).hexdigest()
+
+ n = num_possible_solutions
+ logger.debug(
+ string_utils.make_contractions(
+ f'There {string_utils.is_are(n)} {n} word{string_utils.pluralize(n)} '
+ + f'({template} @ {fprint}).'
+ )
+ )
+ if num_possible_solutions < 30:
+ logger.debug(
+ string_utils.make_contractions(
+ f'{string_utils.capitalize_first_letter(string_utils.it_they(n))} '
+ + f'{string_utils.is_are(n)}: {possible_solutions}'
+ )
+ )
+ logger.debug(
+ f'Letter count restrictions: {self.word_state.letters_in_solution}'
+ )
+ if num_possible_solutions == 0:
+ logger.error('No possible solutions?!')
+ print('No possible solutions?!', file=sys.stderr)
+ print(self.word_state)
+ print(self.word_state.letters_in_solution)
+ return None
+
+ elif num_possible_solutions == 1:
+ return possible_solutions[0]
+
+ # Check the hash table for a precomputed best guess.
+ elif self.position_in_hash(num_possible_solutions, fprint):
+ guess = self.position_hash[(num_possible_solutions, fprint)]
+ logger.debug(f'hash hit: {guess}')
+ return guess
+
+ # If there are just a few solutions possible, brute force the
+ # guess. This is expensive: for every possible solution it
+ # computes the entropy of every possible guess. Not fast for
+ # large numbers of solutions.
+ elif num_possible_solutions < 20:
+ logger.debug(
+ f'Only {num_possible_solutions} solutions; using brute force strategy.'
+ )
+ return self.brute_force_internal(
+ possible_solutions,
+ self.all_possible_guesses_by_length[len(possible_solutions[0])],
+ 'brute_force',
+ )
+
+ # A hybrid approach: narrow down the guess list using
+ # heuristic scoring and then compute the entropy of the topN
+ # guesses via brute force.
+ elif num_possible_solutions < 100:
+ logger.debug(
+ f'Only {num_possible_solutions} solutions; using hybrid strategy.'
+ )
+ return self.hybrid_search(possible_solutions)
+
+ # There are a lot of words left; score the guesses based on
+ # fast heuristics (i.e. letter frequency).
+ else:
+ logger.debug(
+ f'There are {num_possible_solutions} solutions; using fast heuristics.'
+ )
+ return self.heuristics_search(possible_solutions)
+
+ def brute_force_internal(
+ self,
+ possible_solutions: List[Word],
+ all_guesses: List[Word],
+ label: str,
+ ) -> Optional[Word]:
+ """Assume every possible solution is the answer, in turn. For each
+ one, try all_guesses and pay attention to the hint we would
+ get back. Compute the entropy of each guess and return the
+ guess with the highest entropy -- i.e. the guess that gives us
+ the most information on average; or, the guess whose results
+ would take the most bits to represent.
+
+ Note, this is expensive. O(num_possible_solutions * all_guesses)
+
+ """
+ num_possible_solutions = len(possible_solutions)
+ if num_possible_solutions == 1:
+ return possible_solutions[0]
+ possible_solutions_set = set(possible_solutions) # for O(1) lookups
+
+ # Buckets count distinct outcomes of a guess. e.g. if a guess
+ # yields the hint G_Y__ that outcome is assigned a bucket
+ # number and we will increment the count of how many times
+ # that outcome happened for this guess across all possible
+ # solutions.
+ bucket_population_by_guess: Dict[Word, Dict[Bucket, int]] = {}
+ for guess in all_guesses:
+ bucket_population_by_guess[guess] = defaultdict(int)
+
+ # Pretend that each possible solution is the real solution
+ # and make every possible guess for each.
+ for solution in possible_solutions:
+ oracle = AutoplayOracle(solution)
+ for guess in all_guesses:
+ hints = oracle.judge_guess(guess)
+
+ # Note: this is a really good way to make sure that
+ # make/unmake moves works:
+ #
+ # before = self.word_state.__repr__()
+ self.word_state.record_guess_and_hint(guess, hints)
+ # during = self.word_state.__repr__()
+
+ # Map the hint returned into a bucket number and
+ # keep track of population per bucket. Below:
+ #
+ # n is a position := {0, 1, 2, 3, 4}
+ # hint[n] := {1, 2, 3}
+ #
+ bucket: Bucket = 0
+ for n in range(len(guess)):
+ bucket += hints[n] * (3**n)
+ bucket_population_by_guess[guess][bucket] += 1
+ self.word_state.undo_guess()
+
+ # after = self.word_state.__repr__()
+ # if before != after:
+ # print(f'BEFORE: {before}')
+ # print(f' WORD: {colorize_guess(guess, hints)}')
+ # print(f' HINT: {hints}')
+ # print(f'DURING: {during}')
+ # print(f' AFTER: {after}')
+ # assert False
+
+ # Compute the entropy of every guess across all possible
+ # solutions:
+ #
+ # https://markmliu.medium.com/what-in-the-wordle-5dc5ed94fe2
+ # https://machinelearningmastery.com/what-is-information-entropy/
+ # https://en.wikipedia.org/wiki/Entropy_(information_theory)
+ best_entropy = None
+ best_guess = None
+ entropy: Dict[Word, float] = {}
+ for guess in all_guesses:
+ entropy[guess] = 0.0
+ for bucket in bucket_population_by_guess[guess]:
+
+ # We counted how many times this outcome occurred.
+ # The probabilty of this outcome = count / total.
+ p = float(bucket_population_by_guess[guess][bucket])
+ p /= num_possible_solutions
+ entropy[guess] += p * math.log2(p)
+ entropy[guess] = -entropy[guess]
+
+ if best_entropy is None:
+ best_entropy = entropy[guess]
+ best_guess = guess
+ else:
+
+ # We always choose the guess with the highest entropy
+ # because this guess gives us the most information in
+ # the average case, i.e. it takes the most bits to
+ # represent the average outcome of this guess.
+ #
+ # However, in practice, usually several guesses tie
+ # for best. Prefer guesses that are also potential
+ # solutions (not just legal guesses)
+ if entropy[guess] > best_entropy or (
+ entropy[guess] == best_entropy and guess in possible_solutions_set
+ ):
+ best_entropy = entropy[guess]
+ best_guess = guess
+
+ # This is just logging the results. Display the guesses with
+ # the highest entropy but also display the entropy of every
+ # possible solution, too, even if they are not best.
+ possible_solutions_seen = 0
+ best_entropy = None
+ best_count = 0
+ for n, (guess, guess_entropy) in enumerate(
+ sorted(entropy.items(), key=lambda x: -x[1])
+ ):
+ if best_entropy is None:
+ best_entropy = guess_entropy
+ if guess in possible_solutions_set:
+ possible_solutions_seen += 1
+ logger.debug(
+ f'{label}: #{n}: {guess} with {guess_entropy:.5f} bits <--'
+ )
+ elif guess_entropy == best_entropy and best_count < 15:
+ logger.debug(f'{label}: #{n}: {guess} with {guess_entropy:.5f} bits')
+ best_count += 1
+ logger.debug(f'{label}: best guess is {best_guess}.')
+ return best_guess
+
+ def hybrid_search(self, possible_solutions: List[Word]) -> Optional[Word]:
+ """Use heuristic scoring to reduce the number of guesses before
+ calling brute force search.
+
+ """
+ (freq, pfreq) = self.get_frequency_and_frequency_by_position_tables(
+ possible_solutions
+ )
+ self.dump_frequency_data(possible_solutions, freq, pfreq)
+ scored_guesses = self.assign_scores(possible_solutions, freq, pfreq)
+ best_guess = None
+
+ topn_guesses = []
+ count = 1
+ for guess, score in sorted(scored_guesses.items(), key=lambda x: -x[1]):
+ logger.debug(f'hybrid: heuristic #{count} = {guess} with {score:.5f}')
+ topn_guesses.append(guess)
+ count += 1
+ if count > 10:
+ break
+
+ best_guess = self.brute_force_internal(
+ possible_solutions, topn_guesses, 'hybrid: brute_force'
+ )
+ return best_guess
+
+ def assign_scores(
+ self,
+ possible_solutions: List[Word],
+ freq: Dict[Letter, float],
+ pfreq: List[Dict[Letter, float]],
+ ) -> Dict[Word, float]:
+ """Given some letter frequency and letter+position bonus data, assign
+ a score to every possible guess.
+
+ """
+ num_possible_solutions = len(possible_solutions)
+ assert num_possible_solutions > 1
+ template = self.word_state.get_template()
+
+ # Give every word a score composed of letter frequencies and letter
+ # in position frequencies. This score attempts to approximate the
+ # result of brute_force_search less expensively.
+ word_scores: Dict[Word, float] = {}
+ for guess in possible_solutions:
+ freq_term = 0.0
+ pfreq_term = 0.0
+
+ letter_filter = set()
+ for n, letter in enumerate(guess):
+
+ # Ignore letters we already know.
+ if template[n] != '_':
+ continue
+
+ # Is there a bonus for this letter in this position? (pfreq)
+ pop = pfreq[n]
+ if letter in pop:
+ pfreq_term += pop[letter] * num_possible_solutions / 4
+
+ # Don't count freq bonus more than once per letter. (freq)
+ if letter in letter_filter:
+ continue
+ freq_term += freq.get(letter, 0.0)
+ letter_filter.add(letter)
+ score = freq_term + pfreq_term
+ word_scores[guess] = score
+ return word_scores
+
+ def heuristics_search(self, possible_solutions: List[Word]) -> Word:
+ """The dumbest but fastest search. Use fast heuristics to score each
+ possible guess and then guess the one with the highest
+ score.
+
+ """
+ (freq, pfreq) = self.get_frequency_and_frequency_by_position_tables(
+ possible_solutions
+ )
+ self.dump_frequency_data(possible_solutions, freq, pfreq)
+ scored_guesses = self.assign_scores(possible_solutions, freq, pfreq)
+ best_guess = None
+ for n, (guess, score) in enumerate(
+ sorted(scored_guesses.items(), key=lambda x: -x[1])
+ ):
+ if best_guess is None:
+ best_guess = guess
+ if n < 20:
+ logger.debug(f'heuristic: #{n}: {guess} with {score:.5f}')
+ assert best_guess is not None
+ return best_guess
+
+
+def colorize_guess(guess: Word, hints: Dict[Position, Hint]) -> str:
+ """Use hints to make the letters green/yellow/gray."""
+ ret = ''
+ for n, letter in enumerate(guess):
+ hint = hints[n]
+ if hint is Hint.GRAY_WRONG:
+ ret += ansi.bg('mist gray')
+ elif hint is Hint.YELLOW_LETTER_RIGHT_POSITION_WRONG:
+ ret += ansi.bg('energy yellow')
+ elif hint is Hint.GREEN_LETTER_IN_RIGHT_POSITION:
+ ret += ansi.bg('forest green')
+ ret += ansi.fg('black')
+ ret += letter
+ ret += ansi.reset()
+ return ret
+
+
+def get_max_letter_population() -> Dict[Letter, int]:
+ filename = config.config['solutions_file']
+ max_letter_population_per_word: Dict[Letter, int] = defaultdict(int)
+ if filename is not None and file_utils.file_is_readable(filename):
+ logger.debug(
+ 'Figuring out all letters\' max frequency in the solution space...'
+ )
+ with open(filename) as rf:
+ for word in rf:
+ word = word[:-1]
+ word = word.lower()
+ letter_pop = Counter(word)
+ for letter, pop in letter_pop.items():
+ if pop > max_letter_population_per_word[letter]:
+ max_letter_population_per_word[letter] = pop
+ else:
+ logger.error('A valid --solutions_file is required.')
+ sys.exit(0)
+ return max_letter_population_per_word
+
+
+def autoplay(
+ solution: Word,
+ oracle: AutoplayOracle,
+ word_state: WordState,
+ player: AutoPlayer,
+ quiet: bool = False,
+):
+ """Make guesses one by one until arriving at a known solution."""
+ if not quiet:
+ logger.debug('Autoplayer mode...')
+ player.new_word(len(solution), oracle, word_state)
+
+ guesses = []
+ while True:
+ guess = player.guess_word()
+ if guess is not None:
+ hints = oracle.judge_guess(guess)
+ word_state.record_guess_and_hint(guess, hints)
+ guesses.append(guess)
+ if not quiet:
+ colorized_guess = colorize_guess(guess, hints)
+ print(f'Guess #{len(guesses)}: {colorized_guess} => {word_state}')
+ if guess == solution:
+ break
+ if guess is None:
+ logger.error(f'"{solution}" is not in my --solutions_file.')
+ break
+ return guesses
+
+
+def cheat():
+ """Cheater! Given a board state, determine the best guess. Note that
+ in this mode the solution is not known.
+
+ """
+ logger.debug('Cheater mode...')
+
+ template = config.config['template']
+ assert template
+
+ # Extract known letter positions from the template.
+ template = template.lower()
+ letters_at_known_positions = {}
+ for n, letter in enumerate(template):
+ if letter != '_':
+ letters_at_known_positions[n] = letter
+
+ # Initialize set of letters to be avoided.
+ avoid = config.config['letters_to_avoid']
+ if not avoid:
+ avoid = ''
+ avoid = avoid.lower()
+ letters_to_avoid = set([letter for letter in avoid])
+
+ # Initialize the set of letters we know are in the solution but
+ # not where, yet.
+ in_word = config.config['letters_in_word']
+ if not in_word:
+ in_word = ''
+ in_word = in_word.lower()
+
+ # This is parsing out the --letters_in_word argument. The
+ # format is:
+ #
+ # <letter><1 or more zero-based positions we already tried it>
+ #
+ # So, if we know an E is in the word (i.e. it's yellow) and
+ # we tried it in the first and third letter already:
+ #
+ # e02
+ #
+ # Note: 0 means "the first letter", i.e. position is zero based.
+ #
+ # You can stack letters this way. e.g.:
+ #
+ # e02a3
+ letters_in_word_at_unknown_position = defaultdict(set)
+ last_letter = None
+ for letter in in_word:
+ if letter.isdigit():
+ assert last_letter
+ letters_in_word_at_unknown_position[last_letter].add(int(letter))
+ elif letter.isalpha():
+ last_letter = letter
+
+ max_letter_pop_per_word = get_max_letter_population()
+ word_state = WordState(
+ len(template),
+ max_letter_pop_per_word,
+ letters_at_known_positions=letters_at_known_positions,
+ letters_at_unknown_positions=letters_in_word_at_unknown_position,
+ letters_excluded=letters_to_avoid,
+ )
+
+ player = AutoPlayer()
+ player.new_word(len(template), None, word_state)
+ return player.guess_word()
+
+
+def selftest():
+ """Autoplay every possible solution and pay attention to statistics."""
+
+ logger.debug('Selftest mode...')
+ total_guesses = 0
+ total_words = 0
+ max_guesses = None
+ max_guesses_words = []
+ every_guess = set()
+ hist = histogram.SimpleHistogram(
+ [(1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (6, 7), (7, 100)]
+ )
+ top_guess_number = defaultdict(dict)
+ num_losses = 0
+
+ player = AutoPlayer()
+ with open(config.config['solutions_file'], 'r') as rf:
+ contents = rf.readlines()
+
+ max_letter_pop_per_word = get_max_letter_population()
+ start = time.time()
+ for word in contents:
+ word = word[:-1]
+ word = word.lower()
+ if len(word) != 5:
+ logger.warning(
+ f'Found word "{word}" in solutions file that is not 5 letters in length. Skipping it.'
+ )
+ continue
+ oracle = AutoplayOracle(word)
+ word_state = WordState(len(word), max_letter_pop_per_word)
+ player.new_word(len(word), oracle, word_state)
+
+ total_words += 1
+ runtime = time.time() - start
+ print(
+ f'{total_words} / {len(contents)} ("{word}") = {total_words/len(contents)*100.0:.2f}% | {total_guesses/total_words:.3f} guesses/word | {runtime:.1f}s @ {runtime/total_words:.3f}s/word\r',
+ end='',
+ )
+ if total_words % 100 == 0:
+ print(f'\nAfter {total_words} words:')
+ print(
+ f'...I made {total_guesses} guesses; ({total_guesses/total_words:.3f}/word)'
+ )
+ print(
+ f'...Max guesses was {max_guesses} for {max_guesses_words}; I lost {num_losses} times.'
+ )
+ print(f'...I made {len(every_guess)} total distinct "interior" guesses.')
+ print()
+
+ guesses = autoplay(word, oracle, word_state, player, True)
+ guess_count = len(guesses)
+ if guess_count > 6:
+ num_losses += 1
+ hist.add_item(guess_count)
+ total_guesses += guess_count
+ for n, guess in enumerate(guesses):
+ tops = top_guess_number[n]
+ tops[guess] = tops.get(guess, 0) + 1
+ if n != len(guesses) - 1:
+ every_guess.add(guess)
+ if max_guesses is None or guess_count > max_guesses:
+ max_guesses = guess_count
+ max_guesses_words = [word]
+ elif guess_count == max_guesses:
+ max_guesses_words.append(word)
+ print("\nFinal Report:")
+ print("-------------")
+ print(f'On {total_words} words:')
+ print(
+ f'...I made {total_guesses} guesses; ({total_guesses / total_words:.3f} / word)'
+ )
+ print(
+ f'...Max guesses was {max_guesses} for {max_guesses_words}; I lost {num_losses} times.'
+ )
+ print(f'...I made {len(every_guess)} total distinct "interior" guesses.')
+ print()
+ for n in range(0, 8):
+ top_guesses = top_guess_number[n]
+ num = 0
+ out = ''
+ print(f'Top guesses #{n+1}: ', end='')
+ for guess, count in sorted(top_guesses.items(), key=lambda x: -x[1]):
+ out += f'{guess}@{count}, '
+ num += 1
+ if num > 8:
+ break
+ out = out[:-2]
+ print(out)
+ print()
+ print(hist)
+
+
+def do_words(
+ solutions: List[Word],
+ shard_num: int,
+ shared_cache_name: str,
+ lock: multiprocessing.RLock,
+ max_letter_pop_per_word: Dict[Letter, int],
+):
+ """Work on precomputing one shard of the solution space, in parallel."""
+
+ logger.debug(f'Shard {shard_num} owns solutions {solutions[0]}..{solutions[-1]}.')
+ player = AutoPlayer()
+ length = len(solutions[0])
+ shared_cache = SharedDict(shared_cache_name, 0, lock)
+ begin = solutions[0]
+ end = solutions[-1]
+
+ passes = 0
+ while True:
+ num_computed = 0
+ passes += 1
+ assert passes < 10
+ local_cache: Dict[Tuple[int, Fprint], Word] = {}
+
+ for n, solution in enumerate(solutions):
+ oracle = AutoplayOracle(solution)
+ word_state = WordState(length, max_letter_pop_per_word)
+ player.new_word(length, oracle, word_state)
+ guesses = []
+
+ # Make guesses until the next guess is not in the hash or
+ # the shared dict.
+ while True:
+ remaining_words = player.get_all_possible_solutions()
+ num_remaining_words = len(remaining_words)
+ if num_remaining_words <= 1:
+ break
+
+ sig_remaining_words = hashlib.md5(
+ remaining_words.__repr__().encode('ascii')
+ ).hexdigest()
+ key = (num_remaining_words, sig_remaining_words)
+
+ if player.position_in_hash(num_remaining_words, sig_remaining_words):
+ provenance = 'game_hash'
+ guess = player.guess_word()
+ elif key in local_cache:
+ provenance = 'local_cache'
+ guess = local_cache[key]
+ elif key in shared_cache:
+ provenance = 'shared_cache'
+ guess = shared_cache[key]
+ else:
+ provenance = 'computed'
+ guess = player.brute_force_internal(
+ remaining_words,
+ player.all_possible_guesses_by_length[length],
+ 'precompute',
+ )
+ local_cache[key] = guess
+ shared_cache[key] = guess
+ num_computed += 1
+ assert guess
+ guesses.append(guess)
+ hints = oracle.judge_guess(guess)
+ word_state.record_guess_and_hint(guess, hints)
+ print(
+ f'shard{shard_num}: '
+ + f'{passes}/{n}/{len(solutions)}/{solution}> '
+ + f'{num_remaining_words} @ {sig_remaining_words}: {guesses} # {provenance}'
+ )
+
+ # When we can make it through a pass over all solutions and
+ # never miss the hash or shared dict, we're done.
+ if num_computed == 0:
+ print(
+ f'{ansi.bg("green")}{ansi.fg("black")}'
+ + f'shard{shard_num}: "{begin}".."{end}" is finished!'
+ + f'{ansi.reset()}'
+ )
+ break
+ shared_cache.close()
+ return f'(shard {shard_num} done)'
+
+
+def precompute():
+ """Precompute the best guess in every situation via the expensive
+ brute force / entropy method. Break the solutions list into
+ shards and execute each one in parallel. Persist the concatenated
+ results in a file.
+
+ """
+ with open(config.config['solutions_file'], 'r') as rf:
+ contents = rf.readlines()
+ all_words = []
+ length = None
+ for word in contents:
+ word = word[:-1]
+ word = word.lower()
+ if length is None:
+ length = len(word)
+ else:
+ assert len(word) == length
+ all_words.append(word)
+
+ max_letter_pop_per_word = get_max_letter_population()
+ shards = []
+ logger.debug('Sharding words into groups of 10.')
+ for subset in list_utils.shard(all_words, 10):
+ shards.append([x for x in subset])
+
+ logger.debug('Kicking off helper pool.')
+
+ # Shared cache is a dict that is slow to read/write but is visible
+ # to all shards so that they can benefit from results already
+ # computed in one of the other workers. 10 pages (~40kb) of
+ # memory.
+ shared_cache = SharedDict("wordle_shared_dict", 4096 * 10)
+ results = []
+ try:
+ for n, shard in enumerate(shards):
+ results.append(
+ do_words(
+ shard,
+ n,
+ shared_cache.get_name(),
+ SharedDict.LOCK,
+ max_letter_pop_per_word,
+ )
+ )
+ smart_future.wait_all(results)
+
+ with open(config.config['hash_file'], 'a') as wf:
+ for key, value in shared_cache.items():
+ print(f'{key[0]} @ {key[1]}: {value}', file=wf)
+ finally:
+ shared_cache.close()
+ shared_cache.cleanup()
+ executors.DefaultExecutors().process_pool().shutdown()
+
+
+def get_legal_guesses() -> Set[Word]:
+ legal_guesses = set()
+ with open(config.config['guesses_file'], 'r') as rf:
+ contents = rf.readlines()
+ for line in contents:
+ line = line[:-1]
+ line = line.lower()
+ legal_guesses.add(line)
+ return legal_guesses
+
+
+def get_solution() -> Word:
+ if config.config['template'] is not None:
+ solution = config.config['template']
+ print(ansi.clear())
+ else:
+ with open(config.config['solutions_file'], 'r') as rf:
+ contents = rf.readlines()
+ solution = random.choice(contents)
+ solution = solution[:-1]
+ solution = solution.lower()
+ solution = solution.strip()
+ return solution
+
+
+def give_hint(num_hints: int, player: AutoPlayer, word_state: WordState):
+ """Give a smart(?) hint to the guesser."""
+
+ possible_solutions = player.get_all_possible_solutions()
+ n = len(possible_solutions)
+ if num_hints == 1:
+ print(
+ f'There {string_utils.is_are(n)} {n} possible solution{string_utils.pluralize(n)}.'
+ )
+ elif num_hints == 2:
+ (freq, _) = player.get_frequency_and_frequency_by_position_tables(
+ possible_solutions
+ )
+ hint_letters = sorted(freq.items(), key=lambda x: -x[1])
+ good_hints = set(string.ascii_lowercase)
+ for letter in word_state.letters_at_unknown_positions.keys():
+ if len(word_state.letters_at_unknown_positions[letter]) > 0:
+ if letter in good_hints:
+ good_hints.remove(letter)
+ for letter in word_state.letters_at_known_positions.values():
+ if letter in good_hints:
+ good_hints.remove(letter)
+ limit = 2
+ if n < 10:
+ limit = 1
+ for letter, _ in hint_letters:
+ if letter in good_hints:
+ print(
+ f'"{letter}" is popular in the possible solution{string_utils.pluralize(n)}.'
+ )
+ limit -= 1
+ if limit == 0:
+ break
+ elif num_hints == 3:
+ limit = 3
+ if n < 10:
+ limit = 1
+ for possibility in random.sample(possible_solutions, min(limit, n)):
+ print(f'Maybe something like "{possibility}"?')
+ elif num_hints >= 4:
+ best_guess = player.guess_word()
+ print(f'Try "{best_guess}"')
+
+
+def play() -> None:
+ """Allow users to play and color in the letter for them."""
+
+ legal_guesses = get_legal_guesses()
+ solution = get_solution()
+ oracle = AutoplayOracle(solution)
+ max_letter_pop_per_word = get_max_letter_population()
+ word_state = WordState(len(solution), max_letter_pop_per_word)
+ player = AutoPlayer()
+ player.new_word(len(solution), oracle, word_state)
+
+ num_guesses = 0
+ prompt = 'Guess #_/6 (or "?" for a hint): '
+ padding = ' ' * len(prompt)
+ colorized_guess = "â–¢" * len(solution)
+ guess = None
+ num_hints = 0
+
+ while True:
+ # Print current state + template.
+ print(padding + colorized_guess + " " + word_state.__repr__())
+ if guess == solution:
+ print('Nice!')
+ break
+ elif num_guesses >= 6:
+ print('Better luck next time.')
+ print(padding + f'{solution}')
+ break
+
+ # Get a guess / command.
+ guess = input(prompt.replace('_', str(num_guesses + 1))).lower().strip()
+
+ # Parse it.
+ if guess == '?':
+ num_hints += 1
+ give_hint(num_hints, player, word_state)
+ continue
+ elif guess == '#brute':
+ remaining_words = player.get_all_possible_solutions()
+ brute = player.brute_force_internal(
+ remaining_words,
+ player.all_possible_guesses_by_length[len(solution)],
+ 'precompute',
+ )
+ print(word_state)
+ print(brute)
+ continue
+ elif len(guess) != len(solution) or guess not in legal_guesses:
+ print(f'"{guess}" is not a legal guess, try again.')
+ continue
+
+ # If we get here it was a guess. Process it.
+ num_guesses += 1
+ hints = oracle.judge_guess(guess)
+ colorized_guess = colorize_guess(guess, hints)
+ word_state.record_guess_and_hint(guess, hints)
+ num_hints = 0
+
+
+# The bootstrap.initialize decorator takes care of parsing our commandline
+# flags and populating config. It can also do cool things like time and
+# profile the code run within it, audit imports, profile memory usage,
+# and break into pdb on unhandled exception.
+def main() -> Optional[int]:
+ mode = config.config['mode'].upper().strip()
+ if mode == 'AUTOPLAY':
+ solution = config.config['template']
+ assert solution
+ solution = solution.lower()
+ oracle = AutoplayOracle(solution)
+ max_letter_pop_per_word = get_max_letter_population()
+ word_state = WordState(len(solution), max_letter_pop_per_word)
+ player = AutoPlayer()
+ autoplay(solution, oracle, word_state, player)
+ return None
+ elif mode == 'CHEAT':
+ return cheat()
+ elif mode == 'PLAY':
+ play()
+ return None
+ elif mode == 'SELFTEST':
+ selftest()
+ return None
+ elif mode == 'PRECOMPUTE':
+ precompute()
+ return None
+ raise Exception('wtf?')
+
+
+if __name__ == '__main__':
+ main()