Wordle Solver

This project started off as a guilty pleasure: I would relax with coffee in the morning and solve a Wordle puzzle, sometimes sharing my result with a friend afterwards. (But never — EVER — on facebook!)

In the beginning: Habit Forming

However, being the uninspired hack that I am, I began to settle on go-to words that I would repeatedly use to start solving the puzzle, rather than trying new, unique, fresh words each time. And, of course, I found that certain words would stand a better shot at revealing common letters than others.

I also decided that — in the rare event that I would ever solve a Wordle puzzle in one or two attempts — that this would be more an indication of luck, rather than skill. So I committed to always try the same two words, regardless of the outcome of the first guess.

Formed Habit (for better or worse): Always try the words ROUSE and PAINT as the first two guesses.

Phase Two: Automating the Next Steps

After some time playing with these two starting words, I often wasn't sure what to do after these initial guesses. In some cases, the next guess would be pretty obvious; often times not.

So I went about building up a short Python script which would go through all possible five-letter words and eliminate those that were excluded, based on the outcome of my first two guesses. (If you'd like to jump right into the details, the code discussed in this post can be found on GitHub here.) Let's take a look at the wordle_solver_basic.py script and see how it works:

# contents of wordle_solver_basic.py
import logging

logging.basicConfig(
    format="%(levelname)s: %(message)s", level=logging.DEBUG
)

import wordle

guesses = [
    ("rouse", "_O___"),
    ("paint", "__in_"),
]

matching_words = wordle.find_matching_words(
    wordle.ALL_WORDS, guesses
)

print(matching_words)

We'll take a look at the contents of wordle.py and how the matching words are found in a moment, but let me first explain the convention used in encoding the outcome of each guess:

  • Uppercase letter (O): a letter that is both appearing in the word and in the right location

  • Lowercase letter (i or n): a letter that appears in the word, but not in the right location

  • Underscore (_ or any non-alphabetic character, really): a letter which is not found in the word

With that out of the way, let's dive into some details! First of all, the global variable ALL_WORDS is a list of words read in from a file compiled by Donald Knuth named sgb-words.txt (which can be found here). This is done as follows:

# partial contents of wordle.py
with open("./sgb-words.txt") as f:
    ALL_WORDS = [w.strip() for w in f.readlines()]

Next, let's look at the definition of the primary function called in wordle_solver.py:

# partial contents of wordle.py
def find_matching_words(words, guesses):

    # Step 1
    known_letters = get_known_letters_from_guesses(guesses)
    words = remove_words_without_known_letters(words, known_letters)

    # Step 2
    forbidden_letters = get_forbidden_letters_from_guesses(guesses)
    words = remove_forbidden_chars(words, forbidden_letters)

    # Step 3
    words = filter_letters_in_forbidden_positions(words, guesses)

    # Step 4
    words = filter_words_without_letters_in_known_positions(
        words, guesses
    )

    return words

This function does several things:

  1. It retrieves the known letters from the guesses and removes any words missing them

    (Function definitions here)
    # partial contents of wordle.py
    def get_known_letters_from_guesses(guesses):
        guess_results = "".join([x[1] for x in guesses])
        unique_knowns = "".join(
            set([x.lower() for x in guess_results if x.isalpha()])
        )
        log.info("Unique known letters: %s", unique_knowns)
        return unique_knowns
    
    
    def remove_words_without_known_letters(words, known_letters):
        result = [
            w for w in words if all([x in w for x in known_letters])
        ]
        log.debug(
            "After removing words without known letters:\n  %s", result
        )
        return result
    
  2. It retrieves all forbidden letters from the guesses and removes any words containing them

    (Function definitions here)
    # partial contents of wordle.py
    def get_forbidden_letters_from_guesses(guesses):
        forbidden_letters = set()
        for guess, guess_result in guesses:
            for guess_letter, result in zip(guess, guess_result):
                if not result.is_alpha():
                    forbidden_letters.add(guess_letter)
        forbidden_letters = "".join(forbidden_letters)
        log.info("Unique forbidden letters: %s", forbidden_letters)
        return forbidden_letters
    
    
    def remove_words_with_forbidden_letters(words, forbidden_letters):
        result = [
            w
            for w in words
            if all([x not in w for x in forbidden_letters])
        ]
        log.debug(
            "After removing words with forbidden letters:\n  %s", result
        )
        return result
    
  3. It filters out any words with letters in forbidden positions

    (Function definition here)
    # partial contents of wordle.py
    def filter_letters_in_forbidden_positions(words, guesses):
        forbidden_char_positions = []
        for guess in guesses:
            forbidden_char_positions += [
                (x, i) for i, x in enumerate(guess[1]) if x.islower()
            ]
        result = [
            w
            for w in words
            if all(
                w[pos] != char for char, pos in forbidden_char_positions
            )
        ]
        log.debug(
            "After filtering words with letters in forbidden positions:"
            "\n  %s",
            result,
        )
        return result
    
  4. It filters out any words that don't have the appropriate letter in a known position

    (Function definition here)
    # partial contents of wordle.py
    def filter_words_without_letters_in_known_positions(words, guesses):
        known_chars = []
        for guess in guesses:
            known_chars += [
                (x.lower(), i)
                for i, x in enumerate(guess[1])
                if x.isupper()
            ]
        result = [
            w
            for w in words
            if all(w[pos] == char for char, pos in known_chars)
        ]
        log.debug(
            "After filtering words without letters in known positions:"
            "\n  %s",
            result,
        )
        return result
    

As an example, let's say I run wordle_solver.py, with the guess results

guesses = [
    ("rouse", "_O___"),
    ("paint", "__in_"),
]

as given above. The output produced by the four steps outlined above is:

INFO: Unique known letters: oni
DEBUG: After removing words without known letters:
  ['going', 'point', 'doing', 'noise', 'piano', 'minor', 'union', 'joint', 'coins', 'lions', 'noisy', 'robin', 'joins', 'onion', 'owing', 'tonic', 'loins', 'irony', 'irons', 'bison', 'pinto', 'amino', 'sonic', 'groin', 'conic', 'bingo', 'ingot', 'icons', 'rhino', 'rosin', 'lingo', 'ionic', 'jingo', 'toxin', 'anion', 'monic', 'scion', 'winos', 'dingo', 'intro', 'opine', 'piton', 'pinko', 'oinks', 'envoi', 'nitro', 'pions', 'noire', 'oring', 'quoin', 'ikons', 'oinky', 'koine', 'inode', 'login', 'chino']
INFO: Unique forbidden letters: pseruat
DEBUG: After removing words with forbidden letters:
  ['going', 'doing', 'onion', 'owing', 'conic', 'bingo', 'lingo', 'ionic', 'jingo', 'monic', 'dingo', 'oinky', 'login', 'chino']
DEBUG: After filtering words with letters in forbidden positions:
  ['conic', 'bingo', 'lingo', 'ionic', 'jingo', 'monic', 'dingo', 'oinky', 'login']
DEBUG: After filtering words without letters in known positions:
  ['conic', 'ionic', 'monic', 'login']
['conic', 'ionic', 'monic', 'login']

So, we see that the list of viable words dwindles down until there are only four left. And, since we get a total of six guesses, I would usually just declare Wordle victory at this point and move on with my day! (Some people — such as my friend mentioned earlier — refer to this as "cheating". I disagree.)

Sometimes this list would be quite long, other times short. Let's explore this in more detail by counting the number of Wordle puzzles we could successfully solve using my two favourite words I'd settled on, ROUSE and PAINT. As it turns out, the results aren't so impressive: out of the 2315 known Wordle answers, only ~42% of them would leave behind four or fewer viable guesses after whittling down Donald Knuth's list of possible words as described above.

Let's work through how I came to the conclusion above. Within wordle.py we also define the following function:

# partial contents of wordle.py
def count_matching_words(words, *guess_words, iterate=False):
    guess_results = []
    for word in words:
        guesses = []
        log.info("Next word: %s", word)
        for guess_word in guess_words:
            guesses.append(
                (guess_word, compare_words(guess_word, word))
            )
            log.debug("Guesses updated: %s", guesses)
        matches = find_matching_words(ALL_WORDS, guesses)
        if iterate:
            pass # To be discussed later
        log.info("Word: %s\t Number of matches: %s", word, len(matches))
        guess_results.append(len(matches))
    log.info("Number of matched words:\n  %s", guess_results)
    log.info("  Min: %s", min(guess_results))
    log.info("  Max: %s", max(guess_results))
    return guess_results

This function is set up to take in a list of sample words, as well as a number of guess words, and generate a list with one entry for each sample word, where each entry represents the number of remaining sgb-words, given the guess words used as input. (The details of the compare_words() function are omitted here, but can be found in the repo for this project here. It does as expected: it generates, for example, the string __in_ if the guess word was paint and the sample word was conic.) We can then take these counts and see which of them fall below the threshold needed to solve the Wordle puzzle.

A convenient means of visualizing these results is to generate a histogram.

(Details can be found here.)
# contents of count_matches.py
import matplotlib.pyplot as plt
import numpy as np

import wordle as wd

word1 = "rouse"
word2 = "paint"

guess_results = wd.count_matching_words(wd.WORDLE_WORDS, word1, word2)

bins = np.arange(0, 80, 5)
frequencies, bins = np.histogram(guess_results, bins=bins)

plt.style.use("ggplot")
plt.figure(figsize=(8, 8), tight_layout=True)
plt.subplots(layout="constrained")

midpoints = (bins[1:] + bins[:-1]) / 2
plt.bar(midpoints[:1], frequencies[:1], width=5, color="green")
plt.bar(midpoints[1:], frequencies[1:], width=5, color="red")

plt.suptitle("Match Number Histogram using 'ROUSE' and 'PAINT'")
plt.xlabel(
    "Number of viable remaining matches in bins [0-5), [5-10), etc."
)
plt.ylabel("Number of words per bin")
plt.savefig("wordle_rouse_paint_count.png")

For the choices ROUSE and PAINT, we get the following:

../../images/wordle_rouse_paint_count.png

Figure 1: Histogram of viable word numbers. Roughly 42% (972 / 2315) of Wordle words leave four or fewer viable words behind, once making the guesses ROUSE and PAINT

All of this begs the question: if we avail ourselves to the perfomance-enhancing drug that is computation when solving Wordle puzzles, what is the optimal strategy? Are there better choices of words as first guesses? What if we optimize future guesses based on the frequencies with which letters appear in the remaining words? By the end of this post, I intend to answer these questions and more.

Phase Three: The Art of Making the Third Guess

From here, I started to wonder: are there more optimal words to be using as my starting guesses? If so, what are the best two words to start off with when solving Wordle puzzles?

Assumption: It is more beneficial in the long run to commit to the same starting guesses for each puzzle, rather than choosing a different second guess in the second round, based on the outcome of the first guess.

So, I settled on a strategy:

Phase n: Game Theory

Endgame: Testing Assumptions

TODO:

  • Update with links to code snippets, rather than inline python (more readable)

  • Discuss how to choose the next word using letter frequencies

  • Discuss iterate=True case with ROUSE, PAINT

  • Perform search for optimal starting words

  • Test assumption about making two starting guesses without adapting strategy