first version draft
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
pub fn foo() {
|
||||
println!("Hello from wordle-solver");
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod game;
|
||||
pub mod pattern;
|
||||
pub mod solver;
|
||||
pub mod word_list;
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user