CSC110 / assignments / a2 / a2_part3.py
a2_part3.py
Raw
"""CSC110 Fall 2022 Assignment 2, Part 3: Wordle!

Module Description
==================
This Python file contains the starter code for Part 3 of this assignment.
For more information, please see the assignment handout.

Copyright and Usage Information
===============================

This file is provided solely for the personal and private use of students
taking CSC110 at the University of Toronto St. George campus. All forms of
distribution of this code, whether as given or with any changes, are
expressly prohibited. For more information on copyright for CSC110 materials,
please consult our Course Syllabus.

This file is Copyright (c) 2022 David Liu, Tom Fairgrieve, and Angela Zavaleta Bernuy.
"""
from python_ta.contracts import check_contracts

from a2_wordle_helpers import ALL_STATUSES, INCORRECT, CORRECT, WRONG_POSITION, cartesian_product_general
import a2_visualizer


###################################################################################################
# Part 3(a)
###################################################################################################
@check_contracts
def is_correct_char(answer: str, guess: str, i: int) -> bool:
    """Return whether the character status of guess[i] with respect to answer is CORRECT.

    Preconditions:
    - len(answer) == len(guess)
    - 0 <= i < len(answer)

    >>> is_correct_char('teach', 'adieu', 3)
    False
    >>> is_correct_char('teaching', 'reacting', 1)
    True
    """
    return guess[i] == answer[i]


@check_contracts
def is_wrong_position_char(answer: str, guess: str, i: int) -> bool:
    """Return whether the character status of guess[i] with respect to answer is WRONG_POSITION.

    Preconditions:
    - len(answer) == len(guess)
    - 0 <= i < len(answer)

    >>> is_wrong_position_char('teach', 'adieu', 3)
    True
    >>> is_wrong_position_char('teaching', 'reacting', 1)
    False
    >>> is_wrong_position_char('reacting', 'reacting', 1)
    False
    >>> is_wrong_position_char('hello', 'hoops', 1)
    True
    >>> is_wrong_position_char('hello', 'hoops', 2)
    True
    """
    if {j for j in range(0, len(guess)
                         ) if j < len(guess) and j != i and guess[i] == answer[j] and guess[j] != answer[j]}:
        checking_any_including_zero = True
    else:
        checking_any_including_zero = False
    return guess[i] != answer[i] and checking_any_including_zero


@check_contracts
def is_incorrect_char(answer: str, guess: str, i: int) -> bool:
    """Return whether the character status of guess[i] with respect to answer is INCORRECT.

    Preconditions:
    - len(answer) == len(guess)
    - 0 <= i < len(answer)

    >>> is_incorrect_char('teach', 'adieu', 1)
    True
    >>> is_incorrect_char('teaching', 'reacting', 1)
    False
    >>> is_incorrect_char('hello', 'keeps', 2)
    True

    HINT: you can use the previous two status functions to implement this one.
    """
    return not is_correct_char(answer, guess, i) and not is_wrong_position_char(answer, guess, i)


@check_contracts
def get_character_status(answer: str, guess: str, i: int) -> str:
    """Return the character status of guess[i] with respect to answer.

    The return value is one of the three values {INCORRECT, WRONG_POSITION, CORRECT}.
    (These values are imported from the a2_helpers.py module for you already.)

    Preconditions:
    - len(answer) == len(guess)
    - 0 <= i < len(answer)

    >>> get_character_status('teach', 'adieu', 1) == INCORRECT
    True
    >>> get_character_status('teaching', 'reacting', 1) == CORRECT
    True
    """
    if is_correct_char(answer, guess, i):
        return CORRECT
    elif is_wrong_position_char(answer, guess, i):
        return WRONG_POSITION
    # elif is_incorrect_char(answer, guess, i):           Since this causes PythonTA error... I'm using else.
    else:
        return INCORRECT


@check_contracts
def get_guess_status(answer: str, guess: str) -> list[str]:
    """Return the guess status of the given guess with respect to answer.

    The return value is a list with the same length as guess, whose
    elements are all in the set {INCORRECT, WRONG_POSITION, CORRECT}.

    Preconditions:
    - answer != ''
    - len(answer) == len(guess)

    >>> example_status = get_guess_status('teach', 'adieu')
    >>> example_status == [WRONG_POSITION, INCORRECT, INCORRECT, WRONG_POSITION, INCORRECT]
    True
    """
    return [get_character_status(answer, guess, index_value) for index_value in range(0, len(guess))]


@check_contracts
def get_guesses_statuses(answer: str, guesses: list[str]) -> list[list[str]]:
    """Return the guess statuses of each given guess with respect to answer.

    The return value is a list with the same length as guesses, where each status has
    elements that are all in the set {INCORRECT, WRONG_POSITION, CORRECT}.

    Preconditions:
    - answer != ''
    - all({len(answer) == len(guess) for guess in guesses})

    >>> example_statuses = get_guesses_statuses('teach', ['adieu'])
    >>> example_statuses == [[WRONG_POSITION, INCORRECT, INCORRECT, WRONG_POSITION, INCORRECT]]
    True
    >>> example_statuses = get_guesses_statuses('teach', ['adieu', 'adieu'])
    >>> example_statuses == [[WRONG_POSITION, INCORRECT, INCORRECT, WRONG_POSITION, INCORRECT],
    ... [WRONG_POSITION, INCORRECT, INCORRECT, WRONG_POSITION, INCORRECT]]
    True
    """
    return [get_guess_status(answer, guesses[i]) for i in range(0, len(guesses))]


@check_contracts
def part3a_example(answer: str, guesses: list[str]) -> None:
    """Visualize the Wordle game for the given answer and guesses.

    Complete this function in two steps:

    1. First, use your get_guesses_statuses function to compute the statuses of each given guess.
    2. Then, call a2_visualizer.draw_wordle to display the result! (You will need to read
       the docstring of that function, in a2_visualizer.py, to understand how to use it.)

    NOTE: You do *NOT* need a return statement in this function. The return type annotation
    is "None", which is a special annotation meaning this function doesn't return anything.
    When you call this function in the Python console, you should see a Pygame window appear,
    like in some of the examples in Assignment 1. But after you close the Pygame window, nothing
    should display in the Python console, since this function doesn't return anything.

    # part3a_example('hello', ['reach', 'allow', 'keeps', 'hoops'])

    """
    statuses = get_guesses_statuses(answer, guesses)
    a2_visualizer.draw_wordle(answer, guesses, statuses)


###################################################################################################
# Part 3(b)
###################################################################################################
@check_contracts
def is_correct_single(word: str, guess: str, status: list[str]) -> bool:
    """Return whether the given word is a correct answer for the given guess and status.

    Preconditions:
    - len(word) == len(guess) == len(status)
    - _is_valid_status(status)
    - word != ''

    Note: the second precondition makes uses of a helper function at the bottom of this file,
    which checks that a guess status consists only of the elements {INCORRECT, WRONG_POSITION, CORRECT}.

    >>> is_correct_single('later', 'tower', [WRONG_POSITION, INCORRECT, INCORRECT, CORRECT, CORRECT])
    True
    >>> is_correct_single('later', 'tower', [INCORRECT] * 5)
    False
    """
    return get_guess_status(word, guess) == status


@check_contracts
def is_correct_multiple(word: str, guesses: list[str], statuses: list[list[str]]) -> bool:
    """Return whether the given word is a correct answer for the given guesses and statuses.

    Preconditions:
    - len(guesses) == len(statuses)
    - all({len(word) == len(guess) for guess in guesses})
    - all({len(word) == len(status) for status in statuses})
    - all({_is_valid_status(status) for status in statuses})
    - word != ''

    >>> example_guesses = ['tower', 'lower', 'power', 'round']
    >>> example_statuses = [
    ...     [WRONG_POSITION, INCORRECT, INCORRECT, CORRECT, CORRECT],
    ...     [CORRECT, INCORRECT, INCORRECT, CORRECT, CORRECT],
    ...     [INCORRECT, INCORRECT, INCORRECT, CORRECT, CORRECT],
    ...     [WRONG_POSITION, INCORRECT, INCORRECT, INCORRECT, INCORRECT]
    ... ]
    >>> is_correct_multiple('later', example_guesses, example_statuses)
    True
    """
    set_of_values = [is_correct_single(word, guesses[i], statuses[i]) for i in range(0, len(guesses))]
    return len(set_of_values) == sum(set_of_values)


@check_contracts
def find_correct_answers(word_set: set[str],
                         guesses: list[str], statuses: list[list[str]]) -> list[str]:
    """Return the list of words (from word_set) that are correct answer for the given guesses and statuses.

    The returned list should be in alphabetical order---use the built-in `sorted` function to achieve this.

    Preconditions:
    - all words in word_set have the same non-zero length
    - all({guess in word_set for guess in guesses})
    - len(guesses) == len(statuses)
    - all({len(guesses[i]) == len(statuses[i]) for i in range(0, len(guesses))})
    - all({_is_valid_status(status) for status in statuses})

    >>> example_word_set = {'later', 'liter', 'tower', 'lower', 'power', 'round', 'tiger'}
    >>> example_guesses = ['tower', 'lower', 'power', 'round']
    >>> example_statuses = [
    ...     [WRONG_POSITION, INCORRECT, INCORRECT, CORRECT, CORRECT],
    ...     [CORRECT, INCORRECT, INCORRECT, CORRECT, CORRECT],
    ...     [INCORRECT, INCORRECT, INCORRECT, CORRECT, CORRECT],
    ...     [WRONG_POSITION, INCORRECT, INCORRECT, INCORRECT, INCORRECT]
    ... ]
    >>> find_correct_answers(example_word_set, example_guesses, example_statuses)
    ['later', 'liter']
    """
    word_set = list(word_set)
    return sorted([word_set[i] for i in range(0, len(word_set)) if is_correct_multiple(word_set[i], guesses, statuses)])


@check_contracts
def part3b_example(word_set_file: str, guesses: list[str], statuses: list[list[str]]) -> None:
    """Visualize the Wordle game (with correct answers!) for the given guesses and statuses.

    Complete this function in two steps:

    1. Compute the correct answers for the given guesses and statuses, using the word set
        that's read in from word_set_file. (We've provided the code for reading in the
        words from the file for this function.)
    2. Then, call a2_visualizer.draw_wordle_answers to display the result!
       Note that this visualization is a bit more sophisticated than the one you used in
       part3a_example, as this one lets the user flip through the possible correct answers
       using the left/right arrow keys.

    Preconditions:
        - all words in the word_set_file have the same non-zero length
        - all guesses appear in the word_set_file
        - guesses and statuses satisfy all preconditions of find_correct_answers

    NOTE: Like part3a_example, this function shouldn't have a return statement.

    # example_guesses = ['their', 'those']
    # example_statuses = [[CORRECT, INCORRECT, WRONG_POSITION, WRONG_POSITION, INCORRECT],
    #                      [CORRECT, INCORRECT, INCORRECT, WRONG_POSITION, WRONG_POSITION]]
    # part3b_example('assets/word-sets/possible_words_100.txt', example_guesses, example_statuses)
    """
    with open(word_set_file) as f:
        # word_set is assigned to a set[str] containing the words in the file
        word_set = {str.strip(w) for w in f.readlines()}

    # Complete this function by deleting the ... and following the instructions in the docstring
    answers = find_correct_answers(word_set, guesses, statuses)
    a2_visualizer.draw_wordle_answers(answers, guesses, statuses)


###################################################################################################
# Part 3(c)
###################################################################################################
@check_contracts
def find_correct_guesses_single(word_set: set[str], answer: str, status: list[str]) -> list[str]:
    """Return the list of guesses from word_set that are consistent with the answer and status.

    The returned list should be in alphabetical order---as you did above, use the `sorted` function to
    achieve this.

    Preconditions:
    - answer != ''
    - answer in word_set
    - all words in word_set have the same non-zero length
    - len(answer) == len(status)
    - _is_valid_status(status)

    >>> example_word_set = {'later', 'liter', 'tower', 'lower', 'power', 'round', 'tiger'}
    >>> example_status = [WRONG_POSITION, INCORRECT, INCORRECT, CORRECT, CORRECT]
    >>> find_correct_guesses_single(example_word_set, 'later', example_status)
    ['tiger', 'tower']
    """
    word_set = list(word_set)
    return sorted([str(word_set[i]) for i in range(0, len(word_set)) if get_guess_status(answer, word_set[i]
                                                                                         ) == list(status)])


@check_contracts
def find_correct_guesses_multiple(word_set: set[str],
                                  answer: str, statuses: list[list[str]]) -> list[list[str]]:
    """Return the possible guess words from word_set that are consistent with the answer and statuses.

    The returned value is a list of lists, where each of the inner lists is a sequence of words that yields
    the given statuses with respect to the given answer.

    IMPORTANT: Call the sorted function on the list of lists before returning it. This will ensure
    that the inner lists are sorted alphabetically by their first words, breaking ties by comparing
    their second words, etc.

    Preconditions:
    - answer != ''
    - answer in word_set
    - all words in word_set have the same non-zero length
    - all({len(answer) == len(status) for status in statuses})
    - all({_is_valid_status(status) for status in statuses})

    >>> example_word_set = {'later', 'liter', 'tower', 'lower', 'power', 'round', 'tiger'}
    >>> example_statuses = [
    ...     [WRONG_POSITION, INCORRECT, INCORRECT, CORRECT, CORRECT],
    ...     [CORRECT, INCORRECT, INCORRECT, CORRECT, CORRECT]
    ... ]
    >>> find_correct_guesses_multiple(example_word_set, 'later', example_statuses)
    [['tiger', 'lower'], ['tower', 'lower']]

    Note that ['tiger', 'lower'] comes before ['tower', 'lower'] because 'tiger' comes before
    'tower' alphabetically.
    """
    return sorted(cartesian_product_general([find_correct_guesses_single(word_set, answer, statuses[i]
                                                                         ) for i in range(0, len(statuses))]))


@check_contracts
def part3c_example(word_set_file: str, answer: str, statuses: list[list[str]]) -> None:
    """Visualize the Wordle game (with reverse-engineered guesses!) for the given answer and statuses.

    Complete this function in three steps (similar to part3b_example):

    1. First, *read in the words in word_set_file*. You can reuse the same code from
       part3b_example for this step.
    2. Then, compute the possible guesses for the given answer and statuses.
    3. Then, call a2_visualizer.draw_wordle_guesses to display the result!

    Preconditions:
        - answer appears in the word_set_file
        - all words in the word_set_file have the same non-zero length
        - answer and statuses satisfy the preconditions of find_correct_guesses_multiple

    # >>> example_statuses = [
    # ...     [WRONG_POSITION, INCORRECT, INCORRECT, CORRECT, CORRECT],
    # ...     [CORRECT, INCORRECT, INCORRECT, CORRECT, CORRECT]
    # ... ]
    # >>> part3c_example('assets/word-sets/possible_words_100.txt', 'later', example_statuses)
    """
    with open(word_set_file) as f:
        word_set = {str.strip(w) for w in f.readlines()}

    # use 'assets/word-sets/possible_words_100.txt' for word_set_file
    guesses = find_correct_guesses_multiple(word_set, answer, statuses)
    a2_visualizer.draw_wordle_guesses(answer, guesses, statuses)


###################################################################################################
# Part 3(d)
###################################################################################################
@check_contracts
def information_score(answer: str, guess: str) -> float:
    """Return the information score of guess with respect to answer.

    See assignment handout for the formula for information score.

    Preconditions:
    - len(answer) == len(guess)
    - answer != ''
    >>> information_score('later', 'tiger')
    2.5
    """
    return get_guess_status(answer, guess).count(CORRECT) + 0.5 * get_guess_status(answer, guess).count(WRONG_POSITION)


@check_contracts
def find_correct_answers_and_scores(word_set: set[str],
                                    guesses: list[str], statuses: list[list[str]]) -> dict[str, float]:
    """Return a mapping from possible correct answers to their average information score (see handout for details).

    You MUST call find_correct_answers in this function. We strongly encourage you to also define at least
    one new helper function to break down this computation.

    Preconditions:
    - all words in word_set have the same non-zero length
    - all({guess in word_set for guess in guesses})
    - len(guesses) == len(statuses)
    - all({len(guesses[i]) == len(statuses[i]) for i in range(0, len(guesses))})
    - all({_is_valid_status(status) for status in statuses})
    - (NEW!) there is at least one possible correct answer

    NOTE: we haven't provided "example" code for testing this function. You may choose to do your testing
    in the Python console, by writing doctests, and/or by writing test cases or an "example" function
    similar to part3b_example/part3c_example. For the latter two testing options, please write them
    in a separate file (that will not be submitted) rather than including them in this file.
    >>> example_word_set = {'later', 'liter', 'tower', 'lower', 'power', 'round', 'tiger'}
    >>> example_guesses = ['tower', 'lower', 'power', 'round']
    >>> example_statuses = [
    ...     [WRONG_POSITION, INCORRECT, INCORRECT, CORRECT, CORRECT],
    ...     [CORRECT, INCORRECT, INCORRECT, CORRECT, CORRECT],
    ...     [INCORRECT, INCORRECT, INCORRECT, CORRECT, CORRECT],
    ...     [WRONG_POSITION, INCORRECT, INCORRECT, INCORRECT, INCORRECT]
    ... ]
    >>> find_correct_answers_and_scores(example_word_set, example_guesses, example_statuses)
    {'later': 4.5, 'liter': 4.5}
    """
    def avg(answer: str) -> list[list[str]]:
        return_value = cartesian_product_general([answers, [answer]])
        calc = [information_score(answer, return_value[i][0]) for i in range(0, len(return_value))]
        return sum(calc) / len(calc)
    answers = find_correct_answers(word_set, guesses, statuses)
    return {answers[i]: avg(answers[i]) for i in range(0, len(answers))}


###################################################################################################
# Additional helper function (for some preconditions)
###################################################################################################
@check_contracts
def _is_valid_status(status: list[str]) -> bool:
    """Return whether s is a valid status.

    A valid status is a list that contains only the three statuses in ALL_STATUSES.
    This function is used in some of the precondition expressions in this file.
    You should not change this function.
    """
    return all({char_status in ALL_STATUSES for char_status in status})


if __name__ == '__main__':
    import doctest
    doctest.testmod(verbose=True)

    # When you are ready to check your work with python_ta, uncomment the following lines.
    # (In PyCharm, select the lines below and press Ctrl/Cmd + / to toggle comments.)
    import python_ta
    python_ta.check_all(config={
        'max-line-length': 120,
        'extra-imports': ['a2_wordle_helpers', 'a2_visualizer'],
        'disable': ['use-a-generator'],
        'allowed-io': ['part3b_example', 'part3c_example']
    })