George V. Reilly

Exploring Wordle

Unless YOUVE LIVED UNDER ROCKS, you've heard of Wordle, the online word game that has become wildly popular since late 2021. You've probably seen people posting their Wordle games as grids of little green, yellow, and black (or white) emojis on social media.

Wordle 797 4/6

⬛ ⬛ ⬛ ⬛ 🟨
🟨 ⬛ 🟩 ⬛ ⬛
⬛ ⬛ 🟩 🟨 ⬛
🟩 🟩 🟩 🟩 🟩

The problem that I want to address in this post is:

Given some GUESS=SCORE pairs for Wordle and a word list, pro­gram­mat­i­cal­ly find all the words from the list that are eligible as answers.

Let's look at this four-round game for Wordle 797:

J U D G E JUDGE=....e
C H E S T CHEST=c.E..
W R E C K WRECK=..Ec.
O C E A N OCEAN=OCEAN

The letters of each guess are colored Green, Yellow, or Black (dark-gray).

(This definition of “absent” turns out to be inadequate, as you will discover later.)

The GUESS=SCORE notation is intended to be clear to read and also easier to write than Greens and Yellows. For example:

GUESS=SCORE
CHEST=c.E..
C H E S T

Deducing Con­straints

What can we deduce from the first three rows of guesses, JUDGE=....e CHEST=c.E.. WRECK=..Ec.?

There is a set of valid letters, C and E, that are either present (yellow 🟨) or correct (green 🟩). Both E and C start out as present, but E later finds its correct position, while C does not.

There is a set of invalid letters that are known to be absent from the answer (black ⬛): J, U, D, G, H, S, T, W, R, and K.

The remaining letters of the alphabet are currently unknown. When they are played, they will turn into valid or invalid letters. Unless we already have all five correct letters, we will draw candidate letters from the unknown pool.

Fur­ther­more, we know something about letter positions. The correct letters are in the correct positions, while the present letters are in the wrong positions.

A candidate word must:

  1. include all valid letters — C and E
  2. exclude all invalid letters — JUDGHSTWRK
  3. match all “correct” positions — 3:E
  4. not match any “present” positions — 1:C, 4:C, or 5:E

These con­straints narrow the possible choices from the word list.

The obvious way to solve this with a computer is to codify the con­straints provided by previous guess–s­core pairs and run through the entire list of words to find eligible words. But no human solves Wordle by me­thod­i­cal­ly examining thousands of words. Instead, you rack your brain for “what ends in SE and has an M?” or “I've tried A, E, and I; will O or U work?” or “What are the most likely letters left on the keyboard at the bottom?”

This article will show you how to solve Wordle pro­gram­mat­i­cal­ly. It won't help you much in playing Wordle by hand, though you may understand more about the game when you're finished reading.

Pro­to­typ­ing with Pipes

Let's prototype the above con­straints with a series of grep's in a Unix pipeline tailored to this OCEAN example:

# JUDGE=....e CHEST=c.E.. WRECK=..Ec.

grep '^.....$' /usr/share/dict/words |  # Extract five-letter words
    tr 'a-z' 'A-Z' |                    # Translate each word to uppercase
    grep '^..E..$' |                    # Match CORRECT positions
    awk '/C/ && /E/' |                  # Match ALL of VALID set, CORRECT|PRESENT
    grep -v '[JUDGHSTWRK]' |            # Exclude INVALID set
    grep '^[^C]..[^C][^E]$'             # Exclude PRESENT positions

gives:

ICENI
ILEAC
OCEAN
OLEIC

(This was in Bash, on macOS 13.6. Zsh doesn't like the comments in the middle of the multi-line pipeline, so you may have to omit them. Other operating systems will have different versions of /usr/share/dict/words that may not have all of these obscure words.)

We can accomplish this with only the simplest features of regular ex­pres­sions: the dot metachar­ac­ter (.), character classes ([JUD...]) and negated character classes ([^E]), and the ^ and $ anchors. Awk gives us regex con­junc­tions, allowing us to match all of the chars.

The above regular ex­pres­sions are a simple mechanical trans­for­ma­tion of the guess–s­core pairs. They could be simplified. For example, after grep '^..E..$', the E in awk '/C/ && /E/' is redundant. We're not going to optimize the regexes, however.

Three of the four answers—ICENI, ILEAC, and OLEIC—are far too obscure to be Wordle answers. Actual Wordle answers also exclude simple plurals (YARDS) and simple past tense (LIKED), but allow more complex plurals (BOXES) and irregular past tense (DWELT, BROKE). We make no attempt to judge if an eligible word is likely as a Wordle answer; merely that it fits.

Let's make a pipeline for Wordle 787 (INDEX):

# VOUCH=..... GRIPE=..i.e DENIM=deni. WIDEN=.iDEn

grep '^.....$' /usr/share/dict/words |
    tr 'a-z' 'A-Z' |
    grep '^..DE.$' |                    # CORRECT pos
    awk '/D/ && /E/ && /I/ && /N/' |    # VALID set
    grep -v '[VOUCHGRPMW]' |            # INVALID set
    grep '^[^D][^EI][^IN][^I][^EN]$'    # PRESENT pos

yields:

INDEX

This approach is promising, but con­struct­ing those regexes by hand is not main­tain­able.

Word Lists

There are several sources of five-letter words.

The latter two lists were extracted from the source code of the game. In the various examples below, I use the larger 15,000-word list.

Initial Python Solution

Let's attempt to solve this in Python. The first piece is to parse a list of GUESS=SCORE pairs.

def parse_guesses(guess_scores):
    invalid = set()                         # Black/Absent
    valid = set()                           # Green/Correct or Yellow/Present
    mask = [None] * 5                       # Exact match for pos (Green/Correct)
    wrong_spot = [set() for _ in range(5)]  # Wrong spot (Yellow/Present)
    for gs in guess_scores:
        guess, score = gs.split("=")
        for i, (g, s) in enumerate(zip(guess, score)):
            assert "A" <= g <= "Z", "GUESS should be uppercase"
            if "A" <= s <= "Z":
                assert g == s
                valid.add(g)
                mask[i] = g
            elif "a" <= s <= "z":
                assert g == s.upper()
                valid.add(g)
                wrong_spot[i].add(g)
            elif s == ".":
                invalid.add(g)
            else:
                raise ValueError(f"Unexpected {s} for {g}")
    return (invalid, valid, mask, wrong_spot)

Let's try it for the OCEAN guesses:

>>> invalid, valid, mask, wrong_spot = parse_guesses(
...     ["JUDGE=....e", "CHEST=c.E..", "WRECK=..Ec."])

>>> print(f"{invalid=}\n{valid=}\n{mask=}\n{wrong_spot=}")
invalid={'H', 'K', 'D', 'G', 'T', 'R', 'U', 'W', 'J', 'S'}
valid={'E', 'C'}
mask=[None, None, 'E', None, None]
wrong_spot=[{'C'}, set(), set(), {'C'}, {'E'}]

>>> for w in vocab:
...     if is_eligible(w, invalid, valid, mask, wrong_spot):
...         print(w)
...
ICENI
ILEAC
OCEAN
OLEIC

Here's the is_el­i­gi­ble function. We short-circuit the evaluation and return as soon as any condition is False.

def is_eligible(word, invalid, valid, mask, wrong_spot):
    letters = {c for c in word}
    if letters & valid != valid:
        # Missing some 'valid' letters from the word;
        # all Green/Correct and Yellow/Present letters are required
        logging.debug("!Valid: %s", word)
        return False
    elif any(m is not None and c != m for c, m in zip(word, mask)):
        # Some of the Green/Correct letters are not at their positions
        logging.debug("!Mask: %s", word)
        return False
    elif letters & invalid:
        # Some invalid (Black/Absent) letters are in the word
        logging.debug("Invalid: %s", word)
        return False
    elif any(c in ws for c, ws in zip(word, wrong_spot)):
        # We have valid letters in the wrong position (Yellow/Present)
        logging.debug("WrongSpot: %s", word)
        return False
    else:
        logging.debug("Got: %s", word)
        return True

Converting to Classes

Returning four parallel col­lec­tions from a function is a code smell. Let's refactor these functions into a WordleGuess­es class.

First, we'll need some helper classes:

WORDLE_LEN = 5

class WordleError(Exception):
   """Base exception class"""

class TileState(namedtuple("TileState", "value emoji color css_color"), Enum):
    CORRECT = 1, "\U0001F7E9", "Green",  "#6aaa64"
    PRESENT = 2, "\U0001F7E8", "Yellow", "#c9b458"
    ABSENT  = 3, "\U00002B1B", "Black",  "#838184"

@dataclass
class GuessScore:
    guess: str
    score: str
    tiles: list[TileState]

    @classmethod
    def make(cls, guess_score: str) -> "GuessScore":
        guess, score = guess_score.split("=")
        tiles = [cls.tile_state(s) for s in score]
        return cls(guess, score, tiles)

    @classmethod
    def tile_state(cls, score_tile: str) -> TileState:
        if "A" <= score_tile <= "Z":
            return TileState.CORRECT
        elif "a" <= score_tile <= "z":
            return TileState.PRESENT
        elif score_tile == ".":
            return TileState.ABSENT
        else:
            raise WordleError(f"Invalid score: {score_tile}")

    def __repr__(self):
        return f"{self.guess}={self.score}"

    def emojis(self, separator=""):
        return separator.join(t.emoji for t in self.tiles)

For brevity, I presented a minimal version of GuessScore.make above. The version in my Wordle repository has robust validation.

Let's add the main class, WordleGuess­es:

@dataclass
class WordleGuesses:
    mask: list[str | None]      # Exact match for position (Green/Correct)
    valid: set[str]             # Green/Correct or Yellow/Present
    invalid: set[str]           # Black/Absent
    wrong_spot: list[set[str]]  # Wrong spot (Yellow/Present)
    guess_scores: list[GuessScore]

    @classmethod
    def parse(cls, guess_scores: list[GuessScore]) -> "WordleGuesses":
        mask: list[str | None] = [None] * WORDLE_LEN
        valid: set[str] = set()
        invalid: set[str] = set()
        wrong_spot: list[set[str]] = [set() for _ in range(WORDLE_LEN)]

        for gs in guess_scores:
            for i, (t, g) in enumerate(zip(gs.tiles, gs.guess)):
                if t is TileState.CORRECT:
                    mask[i] = g
                    valid.add(g)
                elif t is TileState.PRESENT:
                    wrong_spot[i].add(g)
                    valid.add(g)
                elif t is TileState.ABSENT:
                    invalid.add(g)

        return cls(mask, valid, invalid, wrong_spot, guess_scores)

WordleGuess­es.parse is a bit shorter and clearer than parse_guess­es. It uses TileState at each position to classify the current tile and accumulate state in the four member col­lec­tions. Since GuessScore.make has validated the input, parse doesn't need to do any further validation.

The is_el­i­gi­ble method is es­sen­tial­ly the same as its pre­de­ces­sor:

class WordleGuesses:
    def is_eligible(self, word: str) -> bool:
        letters = {c for c in word}
        if letters & self.valid != self.valid:
            # Did not have the full set of green+yellow letters known to be valid
            logging.debug("!Valid: %s", word)
            return False
        elif any(m is not None and c != m for c, m in zip(word, self.mask)):
            # Couldn't find all the green/correct letters
            logging.debug("!Mask: %s", word)
            return False
        elif letters & self.invalid:
            # Invalid (black) letters are in the word
            logging.debug("Invalid: %s", word)
            return False
        elif any(c in ws for c, ws in zip(word, self.wrong_spot)):
            # Found some yellow letters: valid letters in wrong position
            logging.debug("WrongSpot: %s", word)
            return False
        else:
            # Potentially valid
            logging.info("Got: %s", word)
            return True

    def find_eligible(self, vocabulary: list[str]) -> list[str]:
        return [w for w in vocabulary if self.is_eligible(w)]

There's a famous story where Donald Knuth was asked by Jon Bentley to demon­strate literate pro­gram­ming by finding the K most common words from a text file. Knuth turned in an eight-page gem of WEB, which was reviewed by Doug McIlroy, who demon­strat­ed that the task could also be ac­com­plished in a six-line pipeline.

Wordle can also be solved with a six-line pipeline, but the regexes are quite difficult to type correctly and they have to be carefully hand tailored for each set of guess–s­core pairs. There is no one general six-line pipeline.

I know that I'd much rather work with these Python classes. As we'll see below, they are a solid foundation that can be built upon in many ways.

Does it Work?

Let's try it!:

# answer: ARBOR
$ ./wordle.py HARES=.ar.. GUILT=..... CROAK=.Roa. BRAVO=bRa.o
ARBOR

# answer: CACHE
$ ./wordle.py CHAIR=Cha.. CLASH=C.a.h CATCH=CA.ch
CACHE
CAHOW

# answer: TOXIC
$ ./wordle.py LEAKS=..... MIGHT=.i..t BLITZ=..it. OPTIC=o.tIC TONIC=TO.IC
TORIC
TOXIC

This looks right but there are some subtle bugs in the code.

Fifty is the new Witty

Here we expect to find FIFTY, but no words match:

# answer: FIFTY
$ ./wordle.py HARES=..... BUILT=..i.t TIMID=tI... PINTO=.I.T. WITTY=.I.TY
--None--

Let's take a look at the state of the WordleGuess­es instance:

>>> guess_scores = [GuessScore.make(gs) for gs in
        "HARES=..... BUILT=..i.t TIMID=tI... PINTO=.I.T. WITTY=.I.TY".split()]

>>> wg = WordleGuesses.parse(guess_scores)
>>> wg
WordleGuesses(mask=[None, 'I', None, 'T', 'Y'], valid={'T', 'I', 'Y'}, invalid={
'A', 'E', 'D', 'M', 'U', 'H', 'I', 'B', 'L', 'T', 'P', 'O', 'R', 'W', 'N', 'S'},
wrong_spot=[{'T'}, set(), {'I'}, set(), {'T'}], guess_scores=[GuessScore(guess='HARES',
score='.....', tiles=[<TileState.ABSENT: TileState(value=3, emoji='⬛', color='Black',
css_color='#838184')>, <TileState.ABSENT: TileState(value=3, emoji='⬛', color='Black',
css_color='#838184')>,
    ... much snipped ...

That's ugly.

Better String Rep­re­sen­ta­tion

Let's write a few helper functions to improve the __repr__:

def letter_set(s: set[str]) -> str:
    return "".join(sorted(s))

def letter_sets(ls: list[set[str]]) -> str:
    return "[" + ",".join(letter_set(e) or "-" for e in ls) + "]"

def dash_mask(mask: list[str | None]):
    return "".join(m or "-" for m in mask)

class WordleGuesses:
    def __repr__(self) -> str:
        mask = dash_mask(self.mask)
        valid = letter_set(self.valid)
        invalid = letter_set(self.invalid)
        wrong_spot = letter_sets(self.wrong_spot)
        unused = letter_set(
            set(string.ascii_uppercase) - self.valid - self.invalid)
        _guess_scores = [", ".join(f"{gs}|{gs.emojis()}"
            for gs in self.guess_scores)]
        return (
            f"WordleGuesses({mask=}, {valid=}, {invalid=},\n"
            f"    {wrong_spot=}, {unused=})"
        )

Let's run it again, printing out the instance:

# answer: FIFTY
$ ./wordle.py -v HARES=..... BUILT=..i.t TIMID=tI... PINTO=.I.T. WITTY=.I.TY
WordleGuesses(mask='-I-TY', valid='ITY', invalid='ABDEHILMNOPRSTUW',
    wrong_spot='[T,-,I,-,T]', unused='CFGJKQVXZ')
    guess_scores= ['HARES=.....|⬛⬛⬛⬛⬛, BUILT=..i.t|⬛⬛🟨⬛🟨,
        TIMID=tI...|🟨🟩⬛⬛⬛, PINTO=.I.T.|⬛🟩⬛🟩⬛, WITTY=.I.TY|⬛🟩⬛🟩🟩']
--None--

That's a huge im­prove­ment in legibility over the default string rep­re­sen­ta­tion!

There's a T in both valid and invalid—two sets that should be mutually exclusive. The first “absent” T at position 3 in WITTY has poisoned the second T at position 4, which is “correct”. The T at position 1 in TIMID and the T at position 5 in BUILT are “present” because they are the only T in those guesses.

When there are two Ts in a guess, but only one T in the answer, one of the Ts will either be “correct” or “present”. The second, su­per­flu­ous T will be “absent”.

First Attempt at Fixing the Bug

Let's modify WordleGuess­es.parse slightly to address that. When we get an ABSENT tile, we should add that letter to invalid only if it's not already in valid.

class WordleGuesses:
    @classmethod
    def parse(cls, guess_scores: list[GuessScore]) -> "WordleGuesses":
        mask: list[str | None] = [None] * WORDLE_LEN
        valid: set[str] = set()
        invalid: set[str] = set()
        wrong_spot: list[set[str]] = [set() for _ in range(WORDLE_LEN)]

        for gs in guess_scores:
            for i, (t, g) in enumerate(zip(gs.tiles, gs.guess)):
                if t is TileState.CORRECT:
                    mask[i] = g
                    valid.add(g)
                elif t is TileState.PRESENT:
                    wrong_spot[i].add(g)
                    valid.add(g)
                elif t is TileState.ABSENT:
                    if g not in valid:  # <<< new
                        invalid.add(g)

        return cls(mask, valid, invalid, wrong_spot, guess_scores)

Does it work? Yes! Now we have FIFTY.

# answer: FIFTY
$ ./wordle.py -v HARES=..... BUILT=..i.t TIMID=tI... PINTO=.I.T. WITTY=.I.TY
WordleGuesses(mask='-I-TY', valid='ITY', invalid='ABDEHLMNOPRSUW',
    wrong_spot='[T,-,I,-,T]', unused='CFGJKQVXZ')
FIFTY
JITTY
KITTY
ZITTY

But we also have JITTY, KITTY, and ZITTY, which should not been considered eligible since WITTY was eliminated for the T at position 3. We'll come back to this soon.

The Problem of Repeated Letters

There's a problem that we haven't grappled with properly yet: repeated letters in a guess or in an answer. We've made an implicit assumption that there are five distinct letters in each guess and in the answer.

Here's an example that fails with the original parse:

# answer: EMPTY
$ ./wordle.py -v LODGE=....e WIPER=..Pe. TEPEE=teP.. EXPAT=E.P.t
WordleGuesses(mask='E-P--', valid='EPT', invalid='ADEGILORWX',
    wrong_spot='[T,E,-,E,ET]', unused='BCFHJKMNQSUVYZ')
--None--

but works with the current parse:

# answer: EMPTY
$ ./wordle.py -v LODGE=....e WIPER=..Pe. TEPEE=teP.. EXPAT=E.P.t
WordleGuesses(mask='E-P--', valid='EPT', invalid='ADGILORWX',
    wrong_spot='[T,E,-,E,ET]', unused='BCFHJKMNQSUVYZ')
EMPTS
EMPTY

Note that there is no longer an E in invalid. In TEPEE=teP.., the E in position 2 is considered “present”, while the two Es in positions 4 and 5 are marked “absent”. This tells us that there is only one E in the answer. Since P is correct in position 3 of TEPEE, the E must be in position 1. This is confirmed by the subsequent EXPAT=E.P.t, where the initial E is marked “correct”.

Our previous un­der­stand­ing of “absent” was too simple. An “absent” tile can mean one of two things:

  1. This letter is not in the answer at all—the usual case.
  2. If another copy of this letter is “correct” or “present” elsewhere in the same guess (i.e., valid), the letter is su­per­flu­ous at this position. The guess has more instances of this letter than the answer does.

Consider the results here:

# answer: STYLE
$ ./wordle.py -v GROAN=..... WHILE=...LE BELLE=...LE TUPLE=t..LE STELE=ST.LE
WordleGuesses(mask='ST-LE', valid='ELST', invalid='ABGHINOPRUW',
    wrong_spot='[T,-,-,-,-]', unused='CDFJKMQVXYZ')
STELE
STYLE

STELE was an incorrect guess, so it should not have been offered as an eligible word. E is valid in position 5, but wrong in position 3.

Another example:

# answer: WRITE
$ ./wordle.py -v SABER=...er REFIT=re.it TRITE=.RITE
WordleGuesses(mask='-RITE', valid='EIRT', invalid='ABFS',
    wrong_spot='[R,E,-,EI,RT]', unused='CDGHJKLMNOPQUVWXYZ')
TRITE
URITE
WRITE

TRITE was an incorrect guess, so it should not have been offered. 4:T is valid, 1:T is wrong.

Fixing Repeated Absent Letters

We can fix this by making two passes through the tiles for each guess–s­core pair.

  1. Handle “correct” and “present” tiles as before.
  2. Add “absent” tiles to either invalid or wrong_spot.

We need the second pass to handle a case like WITTY=.I.TY, where the “absent” 3:T precedes the “correct” 4:T: the valid set must be fully updated before we process “absent” tiles.

class WordleGuesses:
    @classmethod
    def parse(cls, guess_scores: list[GuessScore]) -> "WordleGuesses":
        mask: list[str | None] = [None for _ in range(WORDLE_LEN)]
        valid: set[str] = set()
        invalid: set[str] = set()
        wrong_spot: list[set[str]] = [set() for _ in range(WORDLE_LEN)]

        for gs in guess_scores:
            # First pass for correct and present
            for i, (t, g) in enumerate(zip(gs.tiles, gs.guess)):
                if t is TileState.CORRECT:
                    mask[i] = g
                    valid.add(g)
                elif t is TileState.PRESENT:
                    wrong_spot[i].add(g)
                    valid.add(g)

            # Second pass for absent letters
            for i, (t, g) in enumerate(zip(gs.tiles, gs.guess)):
                if t is TileState.ABSENT:
                    if g in valid:
                        # There are more instances of `g` in `gs.guess`
                        # than in the answer
                        wrong_spot[i].add(g)
                    else:
                        invalid.add(g)

        return cls(mask, valid, invalid, wrong_spot, guess_scores)

We can see that valid and invalid are disjoint. The is_el­i­gi­ble method needs no changes.

Let's try the WRITE example again:

# answer: WRITE
$ ./wordle.py -v SABER=...er REFIT=re.it TRITE=.RITE
WordleGuesses(mask='-RITE', valid='EIRT', invalid='ABFS',
    wrong_spot='[RT,E,-,EI,RT]', unused='CDGHJKLMNOPQUVWXYZ')
URITE
WRITE

There is now a T in the first wrong_spot entry.

And STYLE?

# answer: STYLE
$ ./wordle.py -v GROAN=..... WHILE=...LE BELLE=...LE TUPLE=t..LE STELE=ST.LE
WordleGuesses(mask='ST-LE', valid='ELST', invalid='ABGHINOPRUW',
    wrong_spot='[T,E,EL,-,-]', unused='CDFJKMQVXYZ')
STYLE

Both the second and third wrong_spots now have an E. The “absent” 3:L from BELLE is also in the third wrong_spot.

What about some other examples?

In our previous attempt at fixing the bug, neither QUICK nor SPICK were found because the first C in CHICK was “absent” and thus marked invalid. Now, the valid and invalid sets are disjoint, there's a C in the first element of wrong_spot, and both words are found:

# answer: QUICK
$ ./wordle.py -v MORAL=..... TWINE=..I.. CHICK=..ICK
WordleGuesses(mask='--ICK', valid='CIK', invalid='AEHLMNORTW',
    wrong_spot='[C,-,-,-,-]', unused='BDFGJPQSUVXYZ')
QUICK
SPICK

As expected, we find only one answer for FIFTY now:

# answer: FIFTY
$ ./wordle.py -v HARES=..... BUILT=..i.t TIMID=tI... PINTO=.I.T. WITTY=.I.TY
WordleGuesses(mask='-I-TY', valid='ITY', invalid='ABDEHLMNOPRSUW',
    wrong_spot='[T,-,IT,I,T]', unused='CFGJKQVXZ')
FIFTY

The new T in the third element of wrong_spot blocks the rhymes for WITTY.

Further Op­ti­miza­tion of the Mask

There's still room for im­prove­ment. If you guess ANGLE=ANGle, it's im­me­di­ate­ly obvious (to a human player) that you should swap the L and E to guess ANGEL on your next turn. Or swap the P and T in SPRAT=SpRAt to guess STRAP.

Similarly, TENET=TEN.t tells you that the fourth letter of the answer must be T, while CHORE=C.OrE must have 2:R.

A more complex example:

# answer: BURLY
$ ./wordle.py -v LOWER=l...r FRAIL=.r..l BLURT=Blur.
WordleGuesses(mask='B----', valid='BLRU', invalid='AEFIOTW',
    wrong_spot='[L,LR,U,R,LR]', unused='CDGHJKMNPQSVXYZ')

The R is in the wrong spot in positions 5 (l...r), 2 (.r..l), and 4 (Blur.). The B is correct in position 1, so R must be in position 3.

The L is in the wrong spot in positions 1, 5, and 2. B is in position 1, R is now in 3, so that leaves only position 4 for L.

There remain two pos­si­bil­i­ties for U—positions 2 and 5; the in­for­ma­tion contained in mask and wrong_spot is not enough to determine where U should go.

The original mask, B----, was due to having only one “correct” letter. Using the cumulative in­for­ma­tion in the guesses and scores, we can infer a mask of B-RL-.

In all of these cases, we can find exactly one remaining position where a “present” letter can be placed. In the BURLY example, it takes two passes: we couldn't uniquely determine a place for L until we had already placed R.

Up to now, we've been treating each tile in almost complete isolation. Let's optimize the mask pro­gram­mat­i­cal­ly.

To account for repeated letters, such as the two Ts in TENET=TEN.t, we use Python's col­lec­tions.Counter as a multiset. Counter's union operation, |=, computes the maximum of cor­re­spond­ing counts.

First, we loop through all the guess–s­core pairs, building a valid multiset of the “correct” and “present” letters. Then we subtract a multiset of the “correct” letters, yielding a multiset of the “present” letters.

Second, we loop over present, trying for each letter to find a single empty position where it can be placed in the mask. If there is such a position, we update mask2, remove the letter from present, and break out of the inner loop. If there isn't (as in the two pos­si­bil­i­ties for U in BURLY), then we use the little-known break-else construct to exit from the outer loop.

Finally, we merge mask2 into self.mask. This optimize method is called from the end of WordleGuess­es.parse.

class WordleGuesses:
    def optimize(self) -> list[str | None]:
        """Use PRESENT tiles to improve `mask`."""
        mask1: list[str | None] = self.mask
        mask2: list[str | None] = [None] * WORDLE_LEN
        # Compute `valid`, a multiset of the correct and present letters in all guesses
        valid: Counter[str] = Counter()
        for gs in self.guess_scores:
            valid |= Counter(
                g for g, t in zip(gs.guess, gs.tiles) if t is not TileState.ABSENT
            )
        correct = Counter(c for c in mask1 if c is not None)
        # Compute `present`, a multiset of the valid letters
        # whose correct position is not yet known; i.e., PRESENT in any row.
        present = valid - correct
        logging.debug(f"{valid=} {correct=} {present=}")

        def available(c, i):
            "Can `c` be placed in slot `i` of `mask2`?"
            return mask1[i] is None and mask2[i] is None and c not in self.wrong_spot[i]

        while present:
            for c in present:
                positions = [i for i in range(WORDLE_LEN) if available(c, i)]
                # Is there only one position where `c` can be placed?
                if len(positions) == 1:
                    i = positions[0]
                    mask2[i] = c
                    present -= Counter(c)
                    logging.debug(f"{i+1} -> {c}")
                    break
            else:
                # We reach this for-else only if there was no `break` in the for-loop;
                # i.e., no one-element `positions` was found in `present`.
                # We must abandon the outer loop, even though `present` is not empty.
                break

        logging.debug(f"{present=} {mask2=}")

        self.mask = [m1 or m2 for m1, m2 in zip(mask1, mask2)]
        logging.info(
            f"\toptimize: {dash_mask(mask1)} | {dash_mask(mask2)}"
            f" => {dash_mask(self.mask)}"
        )
        return mask2

Here are some examples of it in action. Going from ---ET to -ESET:

# answer: BESET
$ ./wordle.py -vv CIVET=...ET EGRET=e..ET SLEET=s.eET
WordleGuesses(mask=---ET, valid=EST, invalid=CGILRV,
    wrong_spot=[ES,-,E,-,-], unused=ABDFHJKMNOPQUWXYZ)
valid=Counter({'E': 2, 'T': 1, 'S': 1}) correct=Counter({'E': 1, 'T': 1})
    present=Counter({'E': 1, 'S': 1})
2 -> E
3 -> S
present=Counter() mask2=[None, 'E', 'S', None, None]
    optimize: ---ET | -ES-- => -ESET

And from C---- to CLER-:

# answer: CLERK
$ ./wordle.py -vv SINCE=...ce CEDAR=Ce..r CRUEL=Cr.el
WordleGuesses(mask=C----, valid=CELR, invalid=ADINSU,
    wrong_spot=[-,ER,-,CE,ELR], unused=BFGHJKMOPQTVWXYZ)
valid=Counter({'C': 1, 'E': 1, 'R': 1, 'L': 1}) correct=Counter({'C': 1})
    present=Counter({'E': 1, 'R': 1, 'L': 1})
3 -> E
4 -> R
2 -> L
present=Counter() mask2=[None, 'L', 'E', 'R', None]
    optimize: C---- | -LER- => CLER-

Demanding an Ex­pla­na­tion

Would you like to know why a guess is ineligible? We can do that too.

# answer: ROUSE
$ ./wordle.py THIEF=...e. BLADE=....E GROVE=.ro.E \
    --words ROMEO PROSE STORE MURAL ROUSE --explain

WordleGuesses(mask=----E, valid=EOR, invalid=ABDFGHILTV,
    wrong_spot=[-,R,O,E,-], unused=CJKMNPQSUWXYZ)
    guess_scores: ['THIEF=...e.|⬛⬛⬛🟨⬛, BLADE=....E|⬛⬛⬛⬛🟩,
                    GROVE=.ro.E|⬛🟨🟨⬛🟩']
ROMEO   Mask: needs ----E; WrongSpot: has ---E-
PROSE   WrongSpot: has -RO--
STORE   Invalid: has -T---; WrongSpot: has --O--
MURAL   Valid: missing EO; Mask: needs ----E; Invalid: has ---AL
ROUSE   Eligible
# answer: BIRCH
$ ./wordle.py CLAIM=c..i. TRICE=.riC. \
    --words INCUR TAXIS PRICY ERICA BIRCH --explain

WordleGuesses(mask=---C-, valid=CIR, invalid=AELMT,
    wrong_spot=[C,R,I,I,-], unused=BDFGHJKNOPQSUVWXYZ)
    guess_scores: ['CLAIM=c..i.|🟨⬛⬛🟨⬛, TRICE=.riC.|⬛🟨🟨🟩⬛']
INCUR   Mask: needs ---C-
TAXIS   Valid: missing CR; Mask: needs ---C-; Invalid: has TA---; WrongSpot: has ---I-
PRICY   WrongSpot: has -RI--
ERICA   Invalid: has E---A; WrongSpot: has -RI--
BIRCH   Eligible

Here's how those ex­pla­na­tions were computed, using a variation on is_el­i­gi­ble:

class WordleGuesses:
    def is_ineligible(self, word: str) -> dict[str, str]:
        reasons = {}
        letters = {c for c in word}
        if missing := self.valid - (letters & self.valid):
            # Did not have the full set of green+yellow letters known to be valid
            reasons["Valid"] = f"missing {letter_set(missing)}"

        mask = [(m if c != m else None) for c, m in zip(word, self.mask)]
        if any(mask):
            # Couldn't find all the green/correct letters
            reasons["Mask"] = f"needs {dash_mask(mask)}"

        invalid = [(c if c in self.invalid else None) for c in word]
        if any(invalid):
            # Invalid (black) letters present at specific positions
            reasons["Invalid"] = f"has {dash_mask(invalid)}"

        wrong = [(c if c in ws else None) for c, ws in zip(word, self.wrong_spot)]
        if any(wrong):
            # Found some yellow letters: valid letters in wrong position
            reasons["WrongSpot"] = f"has {dash_mask(wrong)}"

        return reasons

    def find_explanations_(
        self, vocabulary: list[str]
    ) -> list[tuple[str, str | None]]:
        explanations = []
        for word in vocabulary:
            reasons = self.is_ineligible(word)
            why = None
            if reasons:
                why = "; ".join(
                    f"{k}: {v}" for k, v in self.is_ineligible(word).items())
            explanations.append((word, why))
        return explanations

This approach is slower than is_el­i­gi­ble, though it's not noticeable when running wordle.py for one set of guess–s­cores. I have a test tool (score.py) that runs through the 200+ games that I've recorded. Using find­_­ex­pla­na­tions, it took about 10 seconds to run. Switching to find­_el­i­gi­ble, it dropped to 2 seconds (5x im­prove­ment). By pre­filter­ing the word list with a regex made from the mask, the time drops to half a second (further 4x im­prove­ment).

pattern = re.compile("".join(m or "." for m in parsed_guesses.mask))
word_list = [w for w in vocabulary if pattern.fullmatch(w)]
eligible = parsed_guesses.find_eligible(word_list)

Finally

I thought I knew a lot about solving Wordle pro­gram­mat­i­cal­ly when I started this long post a month ago. As I wrote this, I realized that I could use a few ugly greps to accomplish the same thing as my Python code; wrote a tool to render games as HTML and emojis; spun off a couple of blog posts on multi-attribute enu­mer­a­tion and regex con­junc­tions; found and fixed several bugs with repeated letters, greatly refining my un­der­stand­ing of the nuances; rewrote the sections on repeated letters re­peat­ed­ly; added a means to explain in­el­i­gi­bil­i­ty; and had a minor epiphany about optimizing the mask pro­gram­mat­i­cal­ly.

The full code can be found in my Wordle repository.

Other Work

I found these articles after I completed the final draft of this post.

blog comments powered by Disqus
Regex Conjunctions »