7
\$\begingroup\$

After many years away from writing in Python, I am getting back into it. Specifically, I am trying to teach myself dataclasses. As an exercise I wrote a Wordle-solving program. I downloaded the list of possible Wordle solutions and began writing.

There are two functions. The first simply asks for a five-letter string from the user that indicates what the result of our last guess was. "b" for a bad letter, "y" for a yellow letter, "g" for a green letter. It then updates our list of "bad" and "yellow" letters accordingly. If we get a 'ggggg' result indicating we've guessed the word successfully, we simply stop.

The second function loops through the list of Wordle solutions and tests them against the results we've gathered so far and produces a new guess.

I struggled a long time with this second function. Does the current word contain all "yellow" letters but not in all the positions those individual letters have been tried? Does the current word have all the right "green" letters? Is it absent of all letters that have already been deemed "bad"?

I eventually came up with the idea of raising an exception every time our guess fails a test. If we get through the loop and no exceptions have been raised, we have a new guess.

I'm not entirely happy with this and would welcome suggestions on how to make a better testing loop. Also, any other comments you have on my code, constructive or otherwise, I'd love to hear.

#!/usr/bin/python3
"""Generages guesses for the daily Wordle puzzle."""
import sys
from dataclasses import dataclass
from re import match

def get_result(last_results):
    """Find out from the user what the result of our last guess was."""
    # Keep asking for a result until we get a response that is five characters long
    # and consists of only the letters b, g, and y
    last_results.result = input("Result: ")
    while not (
        match("^[ygb]+$", last_results.result) and len(last_results.result) == 5
    ):
        last_results.result = input("Result: ")
    # Five g's means we've solved it
    if last_results.result == "ggggg":
        sys.exit()
    # Iterate over the result.  Every letter that got a "b" is added to the bad
    # letters string. A letter that got a "y" gets added to the yellow letters
    # dict, along with what position it was tried in.
    for position in range(5):
        if last_results.result[position] == "b":
            last_results.bad += last_results.guess[position]
        elif last_results.result[position] == "y":
            last_results.yellow.setdefault(last_results.guess[position], []).append(
                position
            )
    return last_results

def new_guess(last_guess):
    """Generate a new guess based on the results of our previous guesses."""
    # Create a class that allows us to cleanly break out of the loop if
    # we discover this word doesn't meet the criteria we're looking for.
    class NextWord(Exception):
        """Do-nothing class that simply catches an exception"""
    try_next_word = NextWord()
    while True:
        word = words.pop(0)
        try:
            for position in range(5):
                # Is this letter in our bad letters list?
                bad = word[position] in last_guess.bad
                # Is this letter in our list of yellow letters?
                yellow = word[position] in last_guess.yellow.keys()
                # Have we already tried this yellow letter in this position?
                tried_here = yellow and position in last_guess.yellow.get(
                    word[position]
                )
                # Was the last result for this position "g"?
                green = last_guess.result[position] == "g"
                # Does the letter in this position match what was in this position
                # in our last guess?
                matching = word[position] == last_guess.guess[position]
                if bad and not green and not yellow:
                    raise try_next_word
                if green and not matching:
                    raise try_next_word
                if yellow and tried_here:
                    raise try_next_word
            # Make certain all our yellow letters are in this word
            for yellow_letter in last_guess.yellow.keys():
                if yellow_letter not in word:
                    raise try_next_word
            # If this word hasn't failed any of the above tests, we have a new guess
            last_guess.guess = word
            break
        # If the "try_next_word" exception was raised during the above tests,
        # continue on to the next word
        except NextWord:
            continue
    return last_guess

with open("/home/localr/coding/5lw.txt", encoding="utf-8") as word_source:
    word_list = word_source.read()
words = word_list.split()

@dataclass
class ResultsOfGuesses:
    """Class for keeping track of our current guess, the last result
    of our guess, and which letters we've used in our guesses."""
    guess: str
    result: str
    bad: str
    yellow: dict

# First guess is always 'dealt'
results = ResultsOfGuesses("dealt", "", "", {})
# Get the result from testing our guess and generate a new guess
while True:
    print(results.guess)
    results = get_result(results)
    results = new_guess(results)
\$\endgroup\$

1 Answer 1

7
\$\begingroup\$

Program structure

The flow of control leaves a lot to be desired.

Exceptions should be used for exceptional situations, not as a kind of break or goto. Raising an exception incurs a rather large overhead, since the interpreter has to encode the stack trace information with the exception. (Ruby, unlike Python, does have a throw-catch feature for jumping around your code in addition to raise-rescue for exceptions, but even in Ruby, using throw-catch is bad style.)

An even more egregious problem with your flow control is the use of sys.exit(). The use of sys.exit() is rarely justifiable. It makes the code harder to understand, and it also prevents the function that contains the sys.exit() call from being reused.

Reading lines from a file

Python has built-in support for reading line-oriented data:

For reading lines from a file, you can loop over the file object. This is memory efficient, fast, and leads to simple code

… so this would be preferable to word_list.split():

with open("5lw.txt", encoding="utf-8") as word_source:
        words = [line.rstrip() for line in word_source]

Hard-coding the word length

You hard-code 5 in several places. The word length should only be mentioned in one place.

By the way,

match("^[ygb]+$", last_results.result) and len(last_results.result) == 5

… could be better written as

fullmatch("[ygb]{5}", last_results.result)

List manipulation

list.pop(0) should be avoided: removing the first item in a list requires all subsequent elements to be moved into the hole. Popping the last item from the list is much more efficient.

Handling repeated letters

The way repeated letters are indicated is a subtle point which is not very well explained in the official Wordle rules. (I actually messed this up in my initial post, worse than you did, since you at least tried to handle it with if bad and not green and not yellow:.)

This page explains Wordle's behaviour better:

If you repeat a letter more than it appears, then the excess will be highlighted in grey.

For example, suppose that the secret word is "eerie". If we run your program, it will go like this:

dealt
Result: bgbbb
feens
Result: bgybb
beigy
⋮

However, "beigy" is a bad guess, since the feedback for "\$\color{gray}{\textrm{f}}\color{green}{\textrm{e}}\color{orange}{\textrm{e}}\color{gray}{\textrm{n}}\color{gray}{\textrm{s}}\$" tells you that there are at least two occurrences of "e" in the word (one of which is in the second position, one of which is not in the third position). An example of an appropriate guess would be "verve".

Suggested solution

What exactly does a "bad", "yellow", or "good" clue mean? Instead of a ResultsOfGuesses dataclass that contains various bits of information, why not use a dataclass for each type of clue, such that the clue object knows how to accept or reject a word?

from dataclasses import dataclass
import re

@dataclass
class GreenFilter:
    letter: str
    pos: int

    def __call__(self, word):
        return word[self.pos] == self.letter

@dataclass
class YellowFilter:
    letter: str
    pos: int
    min_count: int

    def __call__(self, word):
        return word[self.pos] != self.letter and sum(1 for c in word if c == self.letter) >= self.min_count

@dataclass
class BadFilter:
    letter: str
    max_count: int

    def __call__(self, word):
        return sum(1 for c in word if c == self.letter) <= self.max_count

def main():
    with open('words', encoding="utf-8") as word_source:
        words = [line.rstrip() for line in word_source]

    # Tweak the preferred order of guesses
    words.reverse()
    words.remove('dealt')
    words.append('dealt')

    while words:
        guess = words.pop()
        print(guess)

        while True:
            feedback = input("Result: ")
            if re.fullmatch("[gyb]{5}", feedback):
                break

        filters = [
            GreenFilter(guess[pos], pos)
            for pos, clue in enumerate(feedback)
            if clue == "g"
        ]
        # "Yellow" clues indicate guessed letters, in excess of any green
        # clues for that letter, which are in the wrong position.
        for pos, clue in enumerate(feedback):
            if clue == "y":
                filters.append(
                    YellowFilter(guess[pos], pos, 1 + sum(guess[pos] == f.letter for f in filters))
                )
        # "Bad" clues indicate guessed letters which occur too many
        # times.
        filters += [
            BadFilter(guess[pos], sum(guess[pos] == f.letter for f in filters))
            for pos, clue in enumerate(feedback)
            if clue == "b"
        ]
        if all(isinstance(f, GreenFilter) for f in filters):
            break
        words = [w for w in words if all(f(w) for f in filters)]
    else:
        print("Error: no known solution!")

if __name__ == '__main__':
    main()
\$\endgroup\$
1
  • 1
    \$\begingroup\$ Thank you. Creating multiple dataclasses for each kind of guess is kinda genius. Your suggestions were very constructive and I greatly appreciate them. \$\endgroup\$ Commented May 10, 2022 at 20:38

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.