diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..450b35d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,45 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'optimle'", + "cargo": { + "args": [ + "build", + "--bin=optimle", + "--package=optimle" + ], + "filter": { + "name": "optimle", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'optimle'", + "cargo": { + "args": [ + "test", + "--no-run", + "--bin=optimle", + "--package=optimle" + ], + "filter": { + "name": "optimle", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/src/constraints.rs b/src/constraints.rs new file mode 100644 index 0000000..c78139e --- /dev/null +++ b/src/constraints.rs @@ -0,0 +1,211 @@ +use std::collections::HashMap; +use std::convert::TryInto; +use crate::guess::*; + +pub trait Constraints { + fn push(&mut self, result: GuessResult); + fn check(&self, word: Word) -> bool; +} + +#[derive(Clone)] +pub struct LetterCount { + letter_minimums: HashMap, + letter_maximums: HashMap, + empty: bool, +} + +impl LetterCount { + pub fn new() -> Self { + LetterCount { + letter_minimums: HashMap::new(), + letter_maximums: HashMap::new(), + empty: true, + } + } +} + +impl Constraints for LetterCount { + fn push(&mut self, result: GuessResult) { + self.empty = false; + + let mut letter_matches = HashMap::new(); + for a in 0..N { + letter_matches + .entry(result.word[a]) + .or_insert(Vec::new()) + .push(result.matches[a]); + } + + for (letter, matches) in letter_matches { + let nowheres = matches.iter().filter(|&m| *m == LetterMatch::NOWHERE).count(); + let somewheres = matches.len() - nowheres; + self.letter_minimums.insert( + letter, + std::cmp::max( + *self.letter_minimums.get(&letter).unwrap_or(&0), + somewheres, + ), + ); + if nowheres > 0 { + self.letter_maximums.insert( + letter, + std::cmp::min( + *self.letter_maximums.get(&letter).unwrap_or(&N), + somewheres, + ), + ); + } + } + } + + fn check(&self, word: Word) -> bool { + if self.empty { return true; } + + let mut letter_counts = HashMap::new(); + for a in 0..N { + *letter_counts.entry(word[a]).or_insert(0) += 1; + } + + for (letter, minimum) in &self.letter_minimums { + let letter_count = letter_counts.get(&letter).unwrap_or(&0); + if letter_count < minimum { + return false; + } + } + for (letter, maximum) in &self.letter_maximums { + let letter_count = letter_counts.get(&letter).unwrap_or(&0); + if letter_count > maximum { + return false; + } + } + + true + } +} + +#[derive(Clone)] +pub struct LetterPosition { + heres: [Option; N], + elsewheres: [Vec; N], + empty: bool, +} + +impl LetterPosition { + pub fn new() -> Self { + Self { + heres: [None; N], + elsewheres: vec![Vec::new(); N].try_into().expect("failed to initialize [Vec; N]"), + empty: true, + } + } +} + +impl Constraints for LetterPosition { + fn push(&mut self, result: GuessResult) { + self.empty = false; + + for a in 0..N { + match result.matches[a] { + LetterMatch::HERE => self.heres[a] = Some(result.word[a]), + LetterMatch::ELSEWHERE => { + self.elsewheres[a].push(result.word[a]); + for b in 0..N { + if a != b && result.word[b] == result.word[a] && result.matches[b] == LetterMatch::NOWHERE { + self.elsewheres[b].push(result.word[a]); + } + } + }, + LetterMatch::NOWHERE => (), + } + } + } + + fn check(&self, word: Word) -> bool { + if self.empty { return true; } + + for a in 0..N { + if let Some(here) = self.heres[a] { + if word[a] != here { + return false; + } + } + if self.elsewheres[a].contains(&word[a]) { + return false; + } + } + + true + } +} + +#[derive(Clone)] +pub struct Total { + count: LetterCount, + position: LetterPosition, +} + +impl Total { + pub fn new() -> Self { + Self { + count: LetterCount::new(), + position: LetterPosition::new(), + } + } +} + +impl Constraints for Total { + fn push(&mut self, result: GuessResult) { + self.count.push(result); + self.position.push(result); + } + + fn check(&self, word: Word) -> bool { + self.count.check(word) && self.position.check(word) + } +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_constrains_nowheres() { + let mut constraints = Total::new(); + constraints.push(GuessResult { + word: word!("loose"), + matches: [LetterMatch::NOWHERE; 5], + }); + + assert_eq!(false, constraints.check(word!("blink"))); // no L + assert_eq!(false, constraints.check(word!("ghost"))); // no O + assert_eq!(true, constraints.check(word!("chain"))); + } + + #[test] + fn it_constraints_elsewheres() { + let mut constraints = Total::new(); + constraints.push(GuessResult { + word: word!("loose"), + matches: [LetterMatch::NOWHERE, LetterMatch::ELSEWHERE, LetterMatch::NOWHERE, LetterMatch::NOWHERE, LetterMatch::NOWHERE], + }); + + assert_eq!(false, constraints.check(word!("brood"))); // too many O's + assert_eq!(false, constraints.check(word!("going"))); // O can't go here + assert_eq!(false, constraints.check(word!("abort"))); // O can't go here + assert_eq!(true, constraints.check(word!("favor"))); + } + + #[test] + fn it_constrains_heres() { + let mut constraints = Total::new(); + constraints.push(GuessResult { + word: word!("loose"), + matches: [LetterMatch::NOWHERE, LetterMatch::HERE, LetterMatch::HERE, LetterMatch::HERE, LetterMatch::HERE], + }); + + assert_eq!(false, constraints.check(word!("house"))); // no U here + assert_eq!(false, constraints.check(word!("loose"))); // no L here + assert_eq!(true, constraints.check(word!("goose"))); + } +} diff --git a/src/evaluator.rs b/src/evaluator.rs new file mode 100644 index 0000000..a3652d4 --- /dev/null +++ b/src/evaluator.rs @@ -0,0 +1,163 @@ +use std::convert::TryInto; +use std::collections::HashSet; + +use crate::guess::*; +use crate::constraints; +use constraints::Constraints; + +pub trait Evaluator { + fn push(&mut self, _result: GuessResult) {} + fn evaluate(&self, word: Word) -> Matches; +} + +pub struct SecretEvaluator { + secret: Word, +} + +impl SecretEvaluator { + pub fn new(secret: Word) -> Self { + Self { + secret + } + } +} + +impl Evaluator for SecretEvaluator { + fn evaluate(&self, word: Word) -> Matches { + guess(&word, &self.secret) + } +} + +pub struct AdversarialEvaluator { + possible_words: Dictionary, + constraints: constraints::Total, +} + +impl AdversarialEvaluator { + pub fn new(dictionary: &Dictionary) -> Self { + Self { + possible_words: dictionary.to_vec(), + constraints: constraints::Total::new(), + } + } +} + +impl Evaluator for AdversarialEvaluator { + fn evaluate(&self, word: Word) -> Matches { + let mut evaluated_matches: HashSet> = HashSet::new(); + let mut best_secret: Option> = None; + let mut best_possible_words_count: Option = None; + let mut resulting_constraints = constraints::Total::new(); + + for possible_secret in &self.possible_words { + let matches = guess(&word, &possible_secret); + if !evaluated_matches.insert(matches) { continue; } + + resulting_constraints.clone_from(&self.constraints); + resulting_constraints.push(GuessResult { word, matches }); + + let possible_words_count = if *possible_secret == word { + 0 // Special case to avoid giving the win when 2 possible words are left + } else { + self.possible_words + .iter() + .filter(|&word| resulting_constraints.check(*word)) + .count() + }; + + if best_possible_words_count.is_none() || possible_words_count > best_possible_words_count.unwrap() { + best_possible_words_count = Some(possible_words_count); + best_secret = Some(*possible_secret); + } + } + + return guess(&word, &best_secret.unwrap()); + } + + fn push(&mut self, result: GuessResult) { + self.constraints.push(result); + let constraints = &self.constraints; + self.possible_words.retain(|&word| constraints.check(word)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_evaluates_adversarially() { + let dictionary: Vec> = vec![ + word!("am"), word!("an"), word!("as"), word!("at"), word!("ax"), + word!("in"), word!("is"), word!("it"), + word!("on"), word!("ox"), + ]; + + let adversary = AdversarialEvaluator::new(&dictionary); + + // Locking in the 'a' leaves 4 possibilities; + // excluding it would leave 2 with the 'n' or 3 without + assert_eq!( + [LetterMatch::HERE, LetterMatch::NOWHERE], + adversary.evaluate(word!("an")), + ); + + // The 'm' doesn't eliminate any non-'a' words, + // so excluding the 'a' leaves all 5 of them + assert_eq!( + [LetterMatch::NOWHERE, LetterMatch::NOWHERE], + adversary.evaluate(word!("am")), + ); + } + + #[test] + fn it_adheres_to_constraints() { + let dictionary: Vec> = vec![ + word!("am"), word!("an"), word!("as"), word!("at"), word!("ax"), + word!("in"), word!("is"), word!("it"), + word!("on"), word!("ox"), + ]; + + let mut adversary = AdversarialEvaluator::new(&dictionary); + adversary.push(GuessResult { + word: word!("an"), + matches: [LetterMatch::NOWHERE, LetterMatch::NOWHERE], + }); + + // We have already excluded both of these letters + assert_eq!( + [LetterMatch::NOWHERE, LetterMatch::NOWHERE], + adversary.evaluate(word!("an")), + ); + + // Locking in the 'i' leaves 2 possibilities; + // excluding it would only leave 'ox' + assert_eq!( + [LetterMatch::HERE, LetterMatch::NOWHERE], + adversary.evaluate(word!("in")), + ); + + // Play it out + adversary.push(GuessResult { + word: word!("in"), + matches: [LetterMatch::HERE, LetterMatch::NOWHERE], + }); + + // Leaves 'it' + assert_eq!( + [LetterMatch::HERE, LetterMatch::NOWHERE], + adversary.evaluate(word!("is")), + ); + + adversary.push(GuessResult { + word: word!("is"), + matches: [LetterMatch::HERE, LetterMatch::NOWHERE], + }); + + // No choice but to accept + assert_eq!( + [LetterMatch::HERE, LetterMatch::HERE], + adversary.evaluate(word!("it")), + ); + } +} \ No newline at end of file diff --git a/src/game.rs b/src/game.rs new file mode 100644 index 0000000..64e1402 --- /dev/null +++ b/src/game.rs @@ -0,0 +1,19 @@ +use crate::guess::*; +use crate::guesser; +use crate::evaluator; + +pub struct Game<'a, const N: usize> { + pub guesser: &'a mut dyn guesser::Guesser, + pub evaluator: &'a mut dyn evaluator::Evaluator, +} + +impl<'a, const N: usize> Game<'a, N> { + pub fn next(&mut self) -> GuessResult { + let word = self.guesser.guess(); + let matches = self.evaluator.evaluate(word); + let result = GuessResult { word, matches }; + self.guesser.push(result); + self.evaluator.push(result); + result + } +} diff --git a/src/guess.rs b/src/guess.rs new file mode 100644 index 0000000..b1116c4 --- /dev/null +++ b/src/guess.rs @@ -0,0 +1,109 @@ +use std::convert::TryInto; + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum LetterMatch { + NOWHERE, + ELSEWHERE, + HERE, +} + +pub type Word = [char; N]; +pub type Matches = [LetterMatch; N]; + +pub fn guess(word: &Word, secret: &Word) -> Matches { + let mut result = [LetterMatch::NOWHERE; N]; + let mut matched_secret_letters = [false; N]; + + // Find exact matches + for a in 0..N { + if word[a] == secret[a] { + result[a] = LetterMatch::HERE; + matched_secret_letters[a] = true; + } + } + + // Find wrong-position matches + for a in 0..N { + for b in 0..N { + if a == b || result[a] != LetterMatch::NOWHERE || matched_secret_letters[b] { + continue; + } + if word[a] == secret[b] { + result[a] = LetterMatch::ELSEWHERE; + matched_secret_letters[b] = true; + break; + } + } + } + + result +} + +#[derive(Copy, Clone)] +pub struct GuessResult { + pub word: Word, + pub matches: Matches, +} + +impl GuessResult { + pub fn correct(&self) -> bool { + for a in 0..N { + if self.matches[a] != LetterMatch::HERE { + return false; + } + } + true + } +} + +impl std::fmt::Display for GuessResult { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let mut result = Vec::new(); + for a in 0..N { + result.push(match self.matches[a] { + LetterMatch::ELSEWHERE => format!("({})", self.word[a]), + LetterMatch::HERE => format!("[{}]", self.word[a]), + LetterMatch::NOWHERE => format!(" {} ", self.word[a]), + }) + } + write!(f, "{}", result.join("")) + } +} + +pub type GuessHistory = Vec>; +pub type Dictionary = Vec>; + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_matches_correct_guess() { + let secret: Word<5> = word!("loose"); + assert_eq!([LetterMatch::HERE; 5], guess(&secret, &secret)); + } + + #[test] + fn it_matches_all_elsewhere() { + let secret = word!("loose"); + let word = word!("seloo"); // lol + assert_eq!([LetterMatch::ELSEWHERE; 5], guess(&word, &secret)); + + } + + #[test] + fn it_matches_all_nowhere() { + let secret = word!("loose"); + let word = word!("brink"); + assert_eq!([LetterMatch::NOWHERE; 5], guess(&word, &secret)); + } + + #[test] + fn it_matches_close_guess() { + let secret = word!("loose"); + let word = word!("loves"); + let expected = [LetterMatch::HERE, LetterMatch::HERE, LetterMatch::NOWHERE, LetterMatch::ELSEWHERE, LetterMatch::ELSEWHERE]; + assert_eq!(expected, guess(&word, &secret)); + } +} diff --git a/src/guesser.rs b/src/guesser.rs new file mode 100644 index 0000000..7a4bba6 --- /dev/null +++ b/src/guesser.rs @@ -0,0 +1,41 @@ +use rand::seq::SliceRandom; + +use crate::guess::*; +use crate::constraints; + +pub trait Guesser { + fn push(&mut self, _result: GuessResult) {} + fn guess(&self) -> Word; +} + +pub struct RandomConstraintGuesser<'a, const N: usize> { + possible_words: Dictionary, + history: GuessHistory, + constraints: &'a mut dyn constraints::Constraints, +} + +impl<'a, const N: usize> RandomConstraintGuesser<'a, N> { + pub fn new( + dictionary: &Dictionary, + constraints: &'a mut dyn constraints::Constraints, + ) -> Self { + Self { + possible_words: dictionary.to_vec(), + history: GuessHistory::new(), + constraints, + } + } +} + +impl<'a, const N: usize> Guesser for RandomConstraintGuesser<'a, N> { + fn guess(&self) -> Word { + *self.possible_words.choose(&mut rand::thread_rng()).unwrap() + } + + fn push(&mut self, result: GuessResult) { + self.history.push(result); + self.constraints.push(result); + let constraints = &self.constraints; + self.possible_words.retain(|&word| constraints.check(word)); + } +} diff --git a/src/main.rs b/src/main.rs index 22aff18..c4e95a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,230 +1,35 @@ -use std::collections::HashMap; use std::convert::TryInto; use rand::seq::SliceRandom; -#[derive(Debug, Copy, Clone, Eq, PartialEq)] -enum LetterMatch { - NOWHERE, - ELSEWHERE, - HERE, -} - -type Word = [char; N]; -type Matches = [LetterMatch; N]; - -fn guess(word: &Word, secret: &Word) -> Matches { - let mut result = [LetterMatch::NOWHERE; N]; - let mut matched_secret_letters = [false; N]; - - // Find exact matches - for a in 0..N { - if word[a] == secret[a] { - result[a] = LetterMatch::HERE; - matched_secret_letters[a] = true; - } - } - - // Find wrong-position matches - for a in 0..N { - for b in 0..N { - if a == b || matched_secret_letters[b] { - continue; - } - if word[a] == secret[b] { - result[a] = LetterMatch::ELSEWHERE; - matched_secret_letters[b] = true; - } - } - } - - result -} - -#[derive(Copy, Clone)] -struct GuessResult { - word: Word, - matches: Matches, -} - -impl GuessResult { - fn correct(&self) -> bool { - for a in 0..N { - if self.matches[a] != LetterMatch::HERE { - return false; - } - } - true - } -} - -impl std::fmt::Display for GuessResult { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - let mut result = Vec::new(); - for a in 0..N { - result.push(match self.matches[a] { - LetterMatch::ELSEWHERE => format!("({})", self.word[a]), - LetterMatch::HERE => format!("[{}]", self.word[a]), - LetterMatch::NOWHERE => format!(" {} ", self.word[a]), - }) - } - write!(f, "{}", result.join("")) - } -} - -type GuessHistory = Vec>; -type Dictionary = Vec>; - -trait Evaluator { - fn push(&mut self, _result: GuessResult) {} - fn evaluate(&self, word: Word) -> Matches; -} - -trait Guesser { - fn push(&mut self, _result: GuessResult) {} - fn guess(&self) -> Word; -} - - -struct SecretEvaluator { - secret: Word, -} - -impl SecretEvaluator { - pub fn new(secret: Word) -> Self { - Self { - secret +#[macro_export] +macro_rules! word { + ( $x:expr ) => { + { + $x.chars() + .collect::>() + .as_slice() + .try_into() + .unwrap() } } } -impl Evaluator for SecretEvaluator { - fn evaluate(&self, word: Word) -> [LetterMatch; N] { - guess(&word, &self.secret) - } -} +mod guess; +use guess::*; + +mod constraints; +mod game; +mod guesser; +mod evaluator; -struct LetterConstraints { - letter_minimums: HashMap, - letter_maximums: HashMap, - empty: bool, -} - -impl LetterConstraints { - pub fn new() -> Self { - Self { - letter_minimums: HashMap::new(), - letter_maximums: HashMap::new(), - empty: true, - } - } - - pub fn check(&self, word: Word) -> bool { - if self.empty { return true; } - - let mut letter_counts = HashMap::new(); - for a in 0..N { - *letter_counts.entry(word[a]).or_insert(0) += 1; - } - - for (letter, count) in letter_counts { - if count < *self.letter_minimums.get(&letter).unwrap_or(&0) - || count > *self.letter_maximums.get(&letter).unwrap_or(&N) { - return false; - } - } - - true - } -} - -impl LetterConstraints { - fn push(&mut self, result: GuessResult) { - self.empty = false; - - let mut letter_matches = HashMap::new(); - for a in 0..N { - letter_matches - .entry(result.word[a]) - .or_insert(Vec::new()) - .push(result.matches[a]); - } - - for (letter, matches) in letter_matches { - let nowheres = matches.iter().filter(|&m| *m == LetterMatch::NOWHERE).count(); - let somewheres = matches.len() - nowheres; - self.letter_minimums.insert( - letter, - std::cmp::max( - *self.letter_minimums.get(&letter).unwrap_or(&0), - somewheres, - ), - ); - if nowheres > 0 { - self.letter_maximums.insert( - letter, - std::cmp::min( - *self.letter_maximums.get(&letter).unwrap_or(&N), - somewheres, - ), - ); - } - } - } -} - - -struct RandomEasyGuesser { - dictionary: Dictionary, - history: GuessHistory, - constraints: LetterConstraints, -} - -impl RandomEasyGuesser { - pub fn new(dictionary: Dictionary) -> Self { - Self { - dictionary, - history: GuessHistory::new(), - constraints: LetterConstraints::new(), - } - } -} - -impl Guesser for RandomEasyGuesser { - fn guess(&self) -> Word { - *self.dictionary.choose(&mut rand::thread_rng()).unwrap() - } - - fn push(&mut self, result: GuessResult) { - self.history.push(result); - self.constraints.push(result); - let constraints = &self.constraints; - self.dictionary.retain(|&word| constraints.check(word)); - } -} - - -struct Game<'a, const N: usize> { - guesser: &'a mut dyn Guesser, - evaluator: &'a mut dyn Evaluator, -} - -impl<'a, const N: usize> Game<'a, N> { - fn next(&mut self) -> GuessResult { - let word = self.guesser.guess(); - let matches = self.evaluator.evaluate(word); - let result = GuessResult { word, matches }; - self.guesser.push(result); - self.evaluator.push(result); - result - } -} - -fn play(dictionary: Dictionary) { +fn play(dictionary: &Dictionary) { let secret = *dictionary.choose(&mut rand::thread_rng()).unwrap(); - let mut game = Game { - evaluator: &mut SecretEvaluator::new(secret), - guesser: &mut RandomEasyGuesser::new(dictionary), + println!("The secret is {:?}", secret); + let mut constraints = constraints::Total::new(); + let mut game = game::Game { + evaluator: &mut evaluator::AdversarialEvaluator::new(dictionary), + guesser: &mut guesser::RandomConstraintGuesser::new(dictionary, &mut constraints), }; loop { let result = game.next(); @@ -250,5 +55,7 @@ fn load_words(contents: &'static str) -> Vec> { fn main() { let common_words: Vec> = load_words(include_str!("words/common.txt")); - play(common_words); + loop { + play(&common_words); + } }