first version draft

This commit is contained in:
2026-08-18 20:22:48 +02:00
parent 48fa1052ad
commit 992890ebb2
12 changed files with 16395 additions and 7 deletions
+33
View File
@@ -0,0 +1,33 @@
use std::{env::args, path::Path};
use wordle_solver::{game::Game, solver::Solver, word_list::WordList};
pub fn main() {
let valid_words = WordList::from_file(Path::new("valid-words.txt")).unwrap();
let target_words = WordList::from_file(Path::new("answers.txt")).unwrap();
let mut game = Game::new(target_words.random_word());
let mut solver = Solver::new(valid_words, target_words);
if let Some(first_guess) = args().skip(1).next() {
if let Some(pat) = game.guess(&first_guess) {
solver.apply_guess(pat);
println!("{}", solver.tabular_format());
} else {
println!("Found solution: {}", first_guess);
}
}
loop {
let best_word = &solver.evaluate_all_words().first().unwrap().clone().word;
println!("{}", solver.best_word_format());
if let Some(pat) = game.guess(best_word) {
solver.apply_guess(pat);
println!("{}", solver.tabular_format());
} else {
println!("Found solution: {}", best_word);
break;
}
}
}
+18 -2
View File
@@ -1,5 +1,21 @@
use wordle_solver::foo;
use std::path::Path;
use wordle_solver::{pattern::Pattern, solver::Solver, word_list::WordList};
pub fn main() {
foo()
let wl = WordList::from_file(Path::new("valid-words.csv")).unwrap();
let mut solver = Solver::from_single_word_list(wl);
solver.apply_guess(Pattern::from_guess("CRANE", "CASTS").unwrap());
println!("{}", solver.tabular_format());
println!("{}", solver.best_word_format());
solver.apply_guess(Pattern::from_guess("CRANE", "WORKS").unwrap());
println!("{}", solver.tabular_format());
println!("{}", solver.best_word_format());
solver.apply_guess(Pattern::from_guess("CRANE", "PAPER").unwrap());
println!("{}", solver.tabular_format());
println!("{}", solver.best_word_format());
}
-3
View File
@@ -1,3 +0,0 @@
pub fn foo() {
println!("Hello from wordle-solver");
}
+25
View File
@@ -0,0 +1,25 @@
use crate::pattern::Pattern;
pub struct Game {
score: u64,
word: String,
}
impl Game {
pub fn new(word: &str) -> Game {
Game {
score: 0,
word: word.to_string(),
}
}
pub fn guess(&mut self, word: &str) -> Option<Pattern> {
self.score += 1;
if word == self.word {
None
} else {
Some(Pattern::from_guess(&self.word, word).unwrap())
}
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod game;
pub mod pattern;
pub mod solver;
pub mod word_list;
+270
View File
@@ -0,0 +1,270 @@
use colored::Colorize;
use derive_more::Display;
#[derive(Display, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Hash)]
pub enum CharStatus {
GREY,
YELLOW,
GREEN,
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct Pattern {
chars: Vec<char>,
stats: Vec<CharStatus>,
}
impl Pattern {
pub fn ansi_format(&self) -> String {
self.chars
.iter()
.zip(self.stats.iter())
.map(|(c, s)| match s {
CharStatus::GREEN => c.to_string().green().to_string(),
CharStatus::YELLOW => c.to_string().yellow().to_string(),
CharStatus::GREY => c.to_string().truecolor(100, 100, 100).to_string(),
})
.collect()
}
pub fn try_new(word: &str, stats: Vec<CharStatus>) -> Result<Pattern, String> {
if word.len() != stats.len() {
Err(format!(
"Word length ({}) does not match stats length ({})",
word.len(),
stats.len()
))
} else {
Ok(Pattern {
chars: word.chars().collect(),
stats,
})
}
}
pub fn from_guess(word: &str, guess: &str) -> Result<Pattern, String> {
if word.len() != guess.len() {
return Err(format!(
"Word length ({}) does not match guess length ({})",
word.len(),
guess.len()
));
}
let guess: Vec<char> = guess.chars().collect();
let chars: Vec<char> = word.chars().collect();
let mut stats: Vec<CharStatus> = vec![CharStatus::GREY; chars.len()];
let mut rest = chars.clone();
// Greens
for i in 0..chars.len() {
if chars[i] == guess[i] {
stats[i] = CharStatus::GREEN;
rest[i] = ' ';
}
}
// Yellows
for i in 0..chars.len() {
if let Some((j, _)) = rest.iter().enumerate().find(|(_, x)| **x == guess[i]) {
stats[i] = CharStatus::YELLOW;
rest[j] = ' ';
}
}
Ok(Pattern {
chars: guess,
stats,
})
}
pub fn matches(&self, word: &Vec<char>) -> bool {
let mut rest = word.clone();
if word.len() != self.chars.len() {
return false;
}
// Green + trivial yellow check
for (i, s) in self.stats.iter().enumerate() {
match *s {
CharStatus::GREEN => {
if self.chars[i] != rest[i] {
return false;
}
rest[i] = ' ';
}
CharStatus::YELLOW => {
if self.chars[i] == rest[i] {
return false;
}
}
CharStatus::GREY => {}
}
}
// Yellow check
for (i, s) in self.stats.iter().enumerate() {
if *s != CharStatus::YELLOW {
continue;
}
if let Some((j, _)) = rest.iter().enumerate().find(|(_, x)| **x == self.chars[i]) {
rest[j] = ' ';
} else {
return false;
}
}
// Grey check
for (i, s) in self.stats.iter().enumerate() {
if *s != CharStatus::GREY {
continue;
}
if let Some(_) = rest.iter().find(|x| **x == self.chars[i]) {
return false;
}
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pattern_from_guess() {
assert_eq!(
Pattern::from_guess("abcde", "abcde"),
Pattern::try_new(
"abcde",
vec![
CharStatus::GREEN,
CharStatus::GREEN,
CharStatus::GREEN,
CharStatus::GREEN,
CharStatus::GREEN,
]
)
);
assert_eq!(
Pattern::from_guess("abcde", "eabcd"),
Pattern::try_new(
"abcde",
vec![
CharStatus::YELLOW,
CharStatus::YELLOW,
CharStatus::YELLOW,
CharStatus::YELLOW,
CharStatus::YELLOW,
]
)
);
assert_eq!(
Pattern::from_guess("abcde", "fghij"),
Pattern::try_new(
"abcde",
vec![
CharStatus::GREY,
CharStatus::GREY,
CharStatus::GREY,
CharStatus::GREY,
CharStatus::GREY,
]
)
);
assert_eq!(
Pattern::from_guess("aabbc", "bbaab"),
Pattern::try_new(
"aabbc",
vec![
CharStatus::YELLOW,
CharStatus::YELLOW,
CharStatus::YELLOW,
CharStatus::YELLOW,
CharStatus::GREY,
]
)
);
}
#[test]
fn pattern_match_1() {
let p1 = Pattern::try_new(
"abcde",
vec![
CharStatus::GREY,
CharStatus::GREY,
CharStatus::GREY,
CharStatus::GREY,
CharStatus::GREY,
],
)
.unwrap();
assert!(p1.matches(&"fghij".chars().collect()));
assert!(!p1.matches(&"f".chars().collect()));
}
#[test]
fn pattern_match_2() {
let p1 = Pattern::try_new(
"abcde",
vec![
CharStatus::GREY,
CharStatus::GREY,
CharStatus::GREEN,
CharStatus::GREY,
CharStatus::GREY,
],
)
.unwrap();
assert!(!p1.matches(&"fghij".chars().collect()));
assert!(!p1.matches(&"abcde".chars().collect()));
assert!(p1.matches(&"ffcff".chars().collect()));
assert!(p1.matches(&"ccccc".chars().collect()));
}
#[test]
fn pattern_match_3() {
let p1 = Pattern::try_new(
"abcde",
vec![
CharStatus::GREEN,
CharStatus::GREEN,
CharStatus::GREEN,
CharStatus::GREEN,
CharStatus::GREEN,
],
)
.unwrap();
assert!(!p1.matches(&"fghij".chars().collect()));
assert!(p1.matches(&"abcde".chars().collect()));
assert!(!p1.matches(&"abcdf".chars().collect()));
assert!(!p1.matches(&"abced".chars().collect()));
}
#[test]
fn pattern_match_4() {
let p1 = Pattern::try_new(
"aabbcc",
vec![
CharStatus::YELLOW,
CharStatus::YELLOW,
CharStatus::YELLOW,
CharStatus::GREY,
CharStatus::YELLOW,
CharStatus::GREEN,
],
)
.unwrap();
assert!(p1.matches(&"bcaafc".chars().collect()));
assert!(!p1.matches(&"bfaafc".chars().collect()));
assert!(!p1.matches(&"bbaafc".chars().collect()));
assert!(!p1.matches(&"aabfcc".chars().collect()));
}
}
+154
View File
@@ -0,0 +1,154 @@
use crate::pattern::Pattern;
use indicatif::ParallelProgressIterator;
use rayon::iter::{ParallelBridge, ParallelIterator};
use crate::word_list::WordList;
use derive_more::Display;
use tabular::{Row, Table};
struct Guess {
pattern: Pattern,
information_gained: f64,
}
pub struct Solver {
valid_words: WordList,
possible_solutions: WordList,
guesses: Vec<Guess>,
best_words: Vec<WordStats>,
}
#[derive(Display, Clone)]
#[display(
"WordStats({}, p={:.02}%, E[I]={:.02}, E[s]={:.02})",
word,
solution_probability*100.0,
expected_information_gained,
expected_score_after_guess
)]
pub struct WordStats {
pub word: String,
pub solution_probability: f64,
pub expected_information_gained: f64,
pub expected_score_after_guess: f64,
}
impl Solver {
pub fn from_single_word_list(word_list: WordList) -> Solver {
Solver {
valid_words: word_list.clone(),
possible_solutions: word_list,
guesses: vec![],
best_words: vec![],
}
}
pub fn new(valid_words: WordList, possible_solutions: WordList) -> Solver {
Solver {
valid_words,
possible_solutions,
guesses: vec![],
best_words: vec![],
}
}
pub fn apply_guess(&mut self, guess: Pattern) {
let new_possible_solutions = self.possible_solutions.apply_guess(&guess);
let information_gained = self.possible_solutions.equal_likelieness_entropy()
- new_possible_solutions.equal_likelieness_entropy();
self.possible_solutions = new_possible_solutions;
self.guesses.push(Guess {
pattern: guess,
information_gained,
});
}
pub fn evaluate_word(&self, word: &str) -> WordStats {
let solution_probability = self.possible_solutions.word_probability(word);
let expected_information_gained = self.possible_solutions.entropy_if_guessed(word);
let score = (self.guesses.len() + 1) as f64;
let expected_score_after_guess = score * solution_probability
+ (1.0 - solution_probability)
* (score
+ ((self.valid_words.equal_likelieness_entropy()
- expected_information_gained)
.log2()
+ 1.0));
WordStats {
word: word.to_string(),
solution_probability,
expected_information_gained,
expected_score_after_guess,
}
}
pub fn evaluate_all_words(&mut self) -> Vec<WordStats> {
self.best_words = self
.valid_words
.words()
.par_bridge()
.progress_count(self.valid_words.len() as u64)
.map(|w| self.evaluate_word(w))
.collect();
self.best_words.sort_by(|a, b| {
a.expected_score_after_guess
.total_cmp(&b.expected_score_after_guess)
});
self.best_words.clone()
}
pub fn best_word_format(&self) -> String {
let mut table = Table::new("#{:<} {:>} | {:<} {:<} {:<}");
table.add_row(
Row::new()
.with_cell("Best")
.with_cell("Word")
.with_cell("p[solution]")
.with_cell("E[I]")
.with_cell("E[score]"),
);
for (i, ws) in self.best_words.iter().take(10).enumerate() {
table.add_row(
Row::new()
.with_cell(i + 1)
.with_cell(&ws.word)
.with_cell(ws.solution_probability)
.with_cell(ws.expected_information_gained)
.with_cell(ws.expected_score_after_guess),
);
}
table.to_string()
}
pub fn tabular_format(&self) -> String {
let mut table = Table::new("{:<} | {:<}");
table.add_row(Row::new().with_cell("Guess").with_cell("Info-Gained"));
for g in &self.guesses {
table.add_row(
Row::new()
.with_ansi_cell(g.pattern.ansi_format())
.with_cell(format!("{:.04}", g.information_gained)),
);
}
format!(
"Solution Space: {}, Uncertainty: {:.02} bits\n{}",
self.possible_solutions.len(),
self.possible_solutions.equal_likelieness_entropy(),
table
)
}
}
+118
View File
@@ -0,0 +1,118 @@
use rand::{rng, seq::IteratorRandom};
use std::{
collections::{hash_set, HashSet},
fs::{self},
io::{self, BufRead},
path::Path,
};
use derive_more::Display;
use crate::pattern::Pattern;
#[derive(Display, Clone)]
#[display("{:?}", words)]
pub struct WordList {
words: HashSet<String>,
}
impl WordList {
pub fn len(&self) -> usize {
self.words.len()
}
pub fn from_file(file: &Path) -> Result<WordList, String> {
let file = fs::File::open(file).map_err(|e| e.to_string())?;
let mut len = None;
let words: Result<HashSet<String>, String> = io::BufReader::new(file)
.lines()
.map_while(Result::ok)
.map(|s| s.trim_ascii().to_ascii_uppercase())
.filter(|s| !s.is_empty())
.map(|s| {
if !s.chars().all(|c| c.is_ascii_alphabetic()) {
return Err(format!("Word {} is not ascii alphabetic", s));
}
if let Some(len) = len {
if len == s.len() {
return Ok(s);
} else {
return Err(format!(
"Word {} does not match initial length of {}",
s, len
));
}
} else {
len = Some(s.len());
return Ok(s);
}
})
.collect();
Ok(WordList { words: words? })
}
pub fn apply_guess(&self, pat: &Pattern) -> WordList {
WordList {
words: self
.words
.iter()
.filter(|w| pat.matches(&w.chars().collect()))
.map(Clone::clone)
.collect(),
}
}
pub fn count_matches(&self, pat: &Pattern) -> usize {
self.words
.iter()
.filter(|w| pat.matches(&w.chars().collect()))
.count()
}
pub fn entropy_if_guessed(&self, word: &str) -> f64 {
let mut e = 0.0;
let mut observed_patterns = HashSet::new();
for w in self.words.iter() {
let pat = Pattern::from_guess(w, word).unwrap();
if observed_patterns.contains(&pat) {
continue;
}
let m = self.count_matches(&pat);
observed_patterns.insert(pat);
if m != 0 {
let p = m as f64 / self.words.len() as f64;
e += p * (1.0 / p).log2();
}
}
e
}
pub fn word_probability(&self, word: &str) -> f64 {
if self.words.contains(word) {
1.0 / self.words.len() as f64
} else {
0.0
}
}
pub fn contains(&self, word: &str) -> bool {
self.words.contains(word)
}
pub fn words<'a>(&'a self) -> hash_set::Iter<'a, String> {
self.words.iter()
}
pub fn equal_likelieness_entropy(&self) -> f64 {
let p = 1.0 / self.words.len() as f64;
-p.log2()
}
pub fn random_word<'a>(&'a self) -> &'a str {
self.words.iter().choose(&mut rng()).unwrap()
}
}