initial commit

This commit is contained in:
2026-07-06 07:56:56 -04:00
commit 70ead4b520
17 changed files with 13974 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Hard-mode Wordle solving assistant."""
+4
View File
@@ -0,0 +1,4 @@
from .cli import run
if __name__ == "__main__":
run()
+222
View File
@@ -0,0 +1,222 @@
"""Interactive REPL: enter each guess and its result, get ranked suggestions back."""
import argparse
import random
from collections import Counter
from .game import Clue, score_guess, str_to_pattern
from .solver import MAX_GUESSES, WORD_LEN, Solver, rank_guesses
from .wager import Wager
from .words import load_answers, load_extra_guesses
HELP_TEXT = """\
Enter each letter followed by its result digit, e.g.:
c2r1a0n0e0
Digits mean correct/present/absent, not literal color:
2 = right letter, right spot (orange in colorblind mode)
1 = right letter, wrong spot (blue in colorblind mode)
0 = letter not in word (black in colorblind mode)
In `/practice` mode, just type the guessed word — no digits needed,
the tool knows the secret and scores it for you.
Commands:
help show this text
reset clear the solver (new puzzle, keeps wager totals)
quit exit
/practice pick a secret word and let you guess it (hard-mode enforced)
/round start a new wager round: clears solver + round total, exits practice
/price reset per-letter $ values to the default $1/$2/$3
/price <g> <y> <b> set per-letter $ values, e.g. `/price 1 2 3`
"""
def parse_line(line: str):
line = line.replace(" ", "")
if len(line) != 2 * WORD_LEN:
raise ValueError(f"expected {WORD_LEN} letter+digit pairs, e.g. `c2r1a0n0e0`")
guess = line[0::2].lower()
result = line[1::2]
if not guess.isalpha():
raise ValueError(f"expected {WORD_LEN} letter+digit pairs, e.g. `c2r1a0n0e0`")
return guess, str_to_pattern(result)
def hard_mode_violation(guess: str, green: dict, min_count: Counter):
"""Return a human-readable reason `guess` breaks hard mode, or None if it's legal."""
for i, ch in green.items():
if guess[i] != ch:
return f"position {i + 1} must be '{ch.upper()}'"
counts = Counter(guess)
for ch, need in min_count.items():
if counts[ch] < need:
return f"must include letter '{ch.upper()}' (need at least {need})"
return None
def format_result(guess: str, pattern) -> str:
labels = {Clue.GREEN: "correct", Clue.YELLOW: "present", Clue.GREY: "absent"}
return " ".join(f"{ch.upper()}:{labels[clue]}" for ch, clue in zip(guess, pattern))
def handle_slash_command(line: str, solver: Solver, wager: Wager, state: dict, answers: list, opts) -> None:
parts = line.split()
cmd = parts[0].lower()
if cmd == "/practice":
solver.reset()
wager.new_round()
state["secret"] = random.choice(answers)
state["guesses_made"] = 0
print(f"Practice round started — I picked a secret word. You have {MAX_GUESSES} guesses.")
print(f"Just type the {WORD_LEN}-letter word you're guessing (hard mode enforced).")
print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight)
return
if cmd == "/round":
solver.reset()
wager.new_round()
state["secret"] = None
state["guesses_made"] = 0
print(f"New round. Prices: {wager.prices_str()} | session total: ${wager.session_total}")
print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight)
return
if cmd == "/price":
args = parts[1:]
if not args:
wager.set_prices(1, 2, 3)
print(f"Prices reset to default. {wager.prices_str()}")
elif len(args) == 3 and all(a.lstrip("-").isdigit() for a in args):
wager.set_prices(*(int(a) for a in args))
print(f"Prices updated. {wager.prices_str()}")
else:
print("Usage: `/price` (reset to $1/$2/$3) or `/price <correct> <present> <absent>`")
return
print(f"Round total so far: ${wager.round_total} | session total: ${wager.session_total}")
return
print(f"Unknown command: {cmd} (try 'help')")
def print_suggestions(solver: Solver, top_n: int, position_weight: float, presence_weight: float):
n_remaining = len(solver.candidates)
print(f"\n{n_remaining} possible answer(s) remain.")
if n_remaining <= 20:
print(" " + ", ".join(sorted(solver.candidates)))
pool = solver.hard_mode_pool()
suggestions = rank_guesses(pool, solver.candidates, top_n=top_n,
position_weight=position_weight, presence_weight=presence_weight)
if not suggestions:
print("No hard-mode-legal words left in the word list (check your entered results).")
return
print(f"\nTop {len(suggestions)} suggestion(s) (hard-mode legal):")
for rank, (word, score, is_answer) in enumerate(suggestions, start=1):
tag = "possible answer" if is_answer else "scout"
print(f" {rank:>2}. {word:<6} (heuristic {score:.3f}, {tag})")
def run(args=None):
parser = argparse.ArgumentParser(description="Hard-mode Wordle solving assistant.")
parser.add_argument("--top", type=int, default=10, help="number of suggestions to show (default: 10)")
parser.add_argument("--position-weight", type=float, default=1.0,
help="weight on per-position letter frequency (default: 1.0)")
parser.add_argument("--presence-weight", type=float, default=1.0,
help="weight on overall letter presence frequency (default: 1.0)")
opts = parser.parse_args(args)
answers = load_answers()
extra_guesses = load_extra_guesses()
solver = Solver(answers, extra_guesses)
wager = Wager()
state = {"secret": None, "guesses_made": 0}
print("Wordle hard-mode assistant. Type 'help' for instructions.")
print(f"Wager prices: {wager.prices_str()}")
print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight)
while True:
try:
line = input("\n> ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not line:
continue
if line.lower() in ("quit", "exit", "q"):
break
if line.lower() == "help":
print(HELP_TEXT)
continue
if line.lower() == "reset":
solver.reset()
state["secret"] = None
state["guesses_made"] = 0
print("Reset.")
print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight)
continue
if line.startswith("/"):
handle_slash_command(line, solver, wager, state, answers, opts)
continue
if state["secret"] is not None:
guess = line.lower()
if len(guess) != WORD_LEN or not guess.isalpha():
print(f"Guess must be a {WORD_LEN}-letter word.")
continue
if guess not in solver.all_words:
print("Not in word list.")
continue
green, min_count = solver.hard_mode_constraints()
violation = hard_mode_violation(guess, green, min_count)
if violation:
print(f"Hard mode violation: {violation}")
continue
pattern = score_guess(guess, state["secret"])
solver.apply(guess, pattern)
state["guesses_made"] += 1
cost = wager.add(pattern)
print(format_result(guess, pattern))
print(f"Cost: ${cost} | round total: ${wager.round_total} | session total: ${wager.session_total}")
if pattern == (Clue.GREEN,) * WORD_LEN:
print(f"Solved in {state['guesses_made']} guess(es)! The word was {guess.upper()} \U0001F389")
print("'/practice' for another word, '/round' to switch back to manual mode.")
state["secret"] = None
continue
if state["guesses_made"] >= MAX_GUESSES:
print(f"Out of guesses. The word was {state['secret'].upper()}.")
state["secret"] = None
continue
print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight)
continue
try:
guess, pattern = parse_line(line)
except ValueError as e:
print(f"Couldn't parse that: {e}")
continue
solver.apply(guess, pattern)
cost = wager.add(pattern)
print(f"Cost: ${cost} | round total: ${wager.round_total} | session total: ${wager.session_total}")
if pattern == (Clue.GREEN,) * WORD_LEN:
print(f"Solved: {guess.upper()} \U0001F389 ('/round' to start a new round)")
continue
if not solver.candidates:
print("No remaining candidates match all the results entered so far — "
"double check the guess/result pairs, or 'reset' to start over.")
continue
print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight)
if __name__ == "__main__":
run()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
"""Feedback simulation matching Wordle's own coloring algorithm."""
from enum import IntEnum
class Clue(IntEnum):
GREY = 0
YELLOW = 1
GREEN = 2
CLUE_CHARS = {Clue.GREEN: "G", Clue.YELLOW: "Y", Clue.GREY: "B"}
# Accept a few common shorthand alphabets when parsing user input.
_CHAR_TO_CLUE = {
"G": Clue.GREEN, "2": Clue.GREEN,
"Y": Clue.YELLOW, "1": Clue.YELLOW,
"B": Clue.GREY, "0": Clue.GREY, ".": Clue.GREY, "-": Clue.GREY,
}
def score_guess(guess: str, answer: str) -> tuple:
"""Return the clue Wordle would show for `guess` against the true `answer`.
Two-pass algorithm: greens first, then yellows against leftover letters,
so duplicate letters are handled the same way the real game does.
"""
n = len(guess)
result = [Clue.GREY] * n
remaining = list(answer)
for i in range(n):
if guess[i] == remaining[i]:
result[i] = Clue.GREEN
remaining[i] = None
for i in range(n):
if result[i] == Clue.GREEN:
continue
letter = guess[i]
if letter in remaining:
result[i] = Clue.YELLOW
remaining[remaining.index(letter)] = None
return tuple(result)
def pattern_to_str(pattern) -> str:
return "".join(CLUE_CHARS[c] for c in pattern)
def pattern_to_digits(pattern) -> str:
return "".join(str(int(c)) for c in pattern)
def str_to_pattern(s: str) -> tuple:
s = s.strip()
try:
return tuple(_CHAR_TO_CLUE[ch.upper()] for ch in s)
except KeyError as e:
raise ValueError(f"Unrecognized clue character: {e.args[0]!r}") from None
+122
View File
@@ -0,0 +1,122 @@
"""Candidate tracking and a frequency/positional heuristic for ranking guesses."""
from collections import Counter
from dataclasses import dataclass, field
from .game import Clue, score_guess
WORD_LEN = 5
MAX_GUESSES = 6
@dataclass
class GuessRecord:
guess: str
pattern: tuple
@dataclass
class Solver:
answers: list
extra_guesses: list
history: list = field(default_factory=list)
candidates: list = field(init=False)
def __post_init__(self):
self.candidates = list(self.answers)
@property
def all_words(self) -> list:
return self.answers + self.extra_guesses
def apply(self, guess: str, pattern: tuple) -> None:
"""Record a guess/result and narrow the remaining candidates to those
consistent with it (by literally re-simulating Wordle's own coloring)."""
guess = guess.lower()
self.history.append(GuessRecord(guess, pattern))
self.candidates = [w for w in self.candidates if score_guess(guess, w) == pattern]
def reset(self) -> None:
self.history.clear()
self.candidates = list(self.answers)
def is_solved(self) -> bool:
return bool(self.history) and self.history[-1].pattern == (Clue.GREEN,) * WORD_LEN
def hard_mode_constraints(self):
"""Derive the two hard-mode requirements from history so far:
fixed green letters by position, and minimum required count per
letter from any green/yellow marks seen."""
green = {}
min_count = Counter()
for rec in self.history:
marks_this_guess = Counter()
for i, (ch, clue) in enumerate(zip(rec.guess, rec.pattern)):
if clue == Clue.GREEN:
green[i] = ch
marks_this_guess[ch] += 1
elif clue == Clue.YELLOW:
marks_this_guess[ch] += 1
for ch, count in marks_this_guess.items():
if count > min_count[ch]:
min_count[ch] = count
return green, min_count
def hard_mode_pool(self) -> list:
"""Every known word that is a *legal* next guess under hard-mode rules:
green letters stay in place, and every yellow/green letter seen so far
appears at least as many times as it has been confirmed."""
if not self.history:
return list(self.all_words)
green, min_count = self.hard_mode_constraints()
pool = []
for w in self.all_words:
if any(w[i] != ch for i, ch in green.items()):
continue
counts = Counter(w)
if any(counts[ch] < need for ch, need in min_count.items()):
continue
pool.append(w)
return pool
def letter_stats(words: list):
"""Per-position and overall-presence letter frequencies across `words`."""
n = len(words)
position_freq = [Counter() for _ in range(WORD_LEN)]
presence_freq = Counter()
for w in words:
for i, ch in enumerate(w):
position_freq[i][ch] += 1
for ch in set(w):
presence_freq[ch] += 1
position_freq = [{ch: c / n for ch, c in pf.items()} for pf in position_freq]
presence_freq = {ch: c / n for ch, c in presence_freq.items()}
return position_freq, presence_freq
def score_word(word, position_freq, presence_freq, position_weight=1.0, presence_weight=1.0, repeat_penalty=0.85):
"""Higher score = letters that are both common overall and likely in this
position, favoring new letters over repeats since repeats teach us less."""
score = 0.0
seen = set()
for i, ch in enumerate(word):
score += position_weight * position_freq[i].get(ch, 0.0)
gain = presence_weight * presence_freq.get(ch, 0.0)
score += gain if ch not in seen else -repeat_penalty * gain
seen.add(ch)
return score
def rank_guesses(pool, candidates, top_n=10, position_weight=1.0, presence_weight=1.0):
"""Rank `pool` words by the heuristic, computed from letter stats over the
still-possible `candidates`. Returns (word, score, is_possible_answer)."""
position_freq, presence_freq = letter_stats(candidates)
candidate_set = set(candidates)
scored = [
(score_word(w, position_freq, presence_freq, position_weight, presence_weight), w)
for w in pool
]
scored.sort(key=lambda pair: (-pair[0], pair[1]))
return [(w, s, w in candidate_set) for s, w in scored[:top_n]]
+39
View File
@@ -0,0 +1,39 @@
"""Tracks a $-per-letter wager score alongside the solver.
Default rule: $1 per correct-spot letter, $2 per present-but-wrong-spot
letter, $3 per absent letter — tune with the `/price` command if your
wager uses different numbers.
"""
from dataclasses import dataclass, field
from .game import Clue
DEFAULT_PRICES = {Clue.GREEN: 1, Clue.YELLOW: 2, Clue.GREY: 3}
@dataclass
class Wager:
prices: dict = field(default_factory=lambda: dict(DEFAULT_PRICES))
round_total: int = 0
session_total: int = 0
def cost_of(self, pattern) -> int:
return sum(self.prices[clue] for clue in pattern)
def add(self, pattern) -> int:
cost = self.cost_of(pattern)
self.round_total += cost
self.session_total += cost
return cost
def new_round(self) -> None:
self.round_total = 0
def set_prices(self, green: int, yellow: int, grey: int) -> None:
self.prices = {Clue.GREEN: green, Clue.YELLOW: yellow, Clue.GREY: grey}
def prices_str(self) -> str:
return (f"correct=${self.prices[Clue.GREEN]} "
f"present=${self.prices[Clue.YELLOW]} "
f"absent=${self.prices[Clue.GREY]}")
+27
View File
@@ -0,0 +1,27 @@
"""Loading of the bundled word lists.
Both files come from the original NYT Wordle source: `answers.txt` is the
2,315-word possible-answer list, `guesses.txt` is the additional ~10,657
words accepted as valid guesses but never used as answers.
"""
from pathlib import Path
DATA_DIR = Path(__file__).parent / "data"
def _load(filename: str) -> list:
path = DATA_DIR / filename
return [w.strip().lower() for w in path.read_text().splitlines() if w.strip()]
def load_answers() -> list:
return _load("answers.txt")
def load_extra_guesses() -> list:
return _load("guesses.txt")
def load_all_valid_words() -> list:
return load_answers() + load_extra_guesses()