This commit is contained in:
ashastral 2022-02-26 21:41:21 -08:00
parent 0023a73b83
commit 7e2fa309cd
7 changed files with 613 additions and 218 deletions

45
.vscode/launch.json vendored Normal file
View file

@ -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}"
}
]
}

211
src/constraints.rs Normal file
View file

@ -0,0 +1,211 @@
use std::collections::HashMap;
use std::convert::TryInto;
use crate::guess::*;
pub trait Constraints<const N: usize> {
fn push(&mut self, result: GuessResult<N>);
fn check(&self, word: Word<N>) -> bool;
}
#[derive(Clone)]
pub struct LetterCount<const N: usize> {
letter_minimums: HashMap<char, usize>,
letter_maximums: HashMap<char, usize>,
empty: bool,
}
impl<const N: usize> LetterCount<N> {
pub fn new() -> Self {
LetterCount {
letter_minimums: HashMap::new(),
letter_maximums: HashMap::new(),
empty: true,
}
}
}
impl<const N: usize> Constraints<N> for LetterCount<N> {
fn push(&mut self, result: GuessResult<N>) {
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<N>) -> 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<const N: usize> {
heres: [Option<char>; N],
elsewheres: [Vec<char>; N],
empty: bool,
}
impl<const N: usize> LetterPosition<N> {
pub fn new() -> Self {
Self {
heres: [None; N],
elsewheres: vec![Vec::new(); N].try_into().expect("failed to initialize [Vec<char>; N]"),
empty: true,
}
}
}
impl<const N: usize> Constraints<N> for LetterPosition<N> {
fn push(&mut self, result: GuessResult<N>) {
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<N>) -> 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<const N: usize> {
count: LetterCount<N>,
position: LetterPosition<N>,
}
impl<const N: usize> Total<N> {
pub fn new() -> Self {
Self {
count: LetterCount::new(),
position: LetterPosition::new(),
}
}
}
impl<const N: usize> Constraints<N> for Total<N> {
fn push(&mut self, result: GuessResult<N>) {
self.count.push(result);
self.position.push(result);
}
fn check(&self, word: Word<N>) -> 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")));
}
}

163
src/evaluator.rs Normal file
View file

@ -0,0 +1,163 @@
use std::convert::TryInto;
use std::collections::HashSet;
use crate::guess::*;
use crate::constraints;
use constraints::Constraints;
pub trait Evaluator<const N: usize> {
fn push(&mut self, _result: GuessResult<N>) {}
fn evaluate(&self, word: Word<N>) -> Matches<N>;
}
pub struct SecretEvaluator<const N: usize> {
secret: Word<N>,
}
impl<const N: usize> SecretEvaluator<N> {
pub fn new(secret: Word<N>) -> Self {
Self {
secret
}
}
}
impl<const N: usize> Evaluator<N> for SecretEvaluator<N> {
fn evaluate(&self, word: Word<N>) -> Matches<N> {
guess(&word, &self.secret)
}
}
pub struct AdversarialEvaluator<const N: usize> {
possible_words: Dictionary<N>,
constraints: constraints::Total<N>,
}
impl<const N: usize> AdversarialEvaluator<N> {
pub fn new(dictionary: &Dictionary<N>) -> Self {
Self {
possible_words: dictionary.to_vec(),
constraints: constraints::Total::new(),
}
}
}
impl<const N: usize> Evaluator<N> for AdversarialEvaluator<N> {
fn evaluate(&self, word: Word<N>) -> Matches<N> {
let mut evaluated_matches: HashSet<Matches<N>> = HashSet::new();
let mut best_secret: Option<Word<N>> = None;
let mut best_possible_words_count: Option<usize> = 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<N>) {
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<Word<2>> = 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<Word<2>> = 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")),
);
}
}

19
src/game.rs Normal file
View file

@ -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<N>,
pub evaluator: &'a mut dyn evaluator::Evaluator<N>,
}
impl<'a, const N: usize> Game<'a, N> {
pub fn next(&mut self) -> GuessResult<N> {
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
}
}

109
src/guess.rs Normal file
View file

@ -0,0 +1,109 @@
use std::convert::TryInto;
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub enum LetterMatch {
NOWHERE,
ELSEWHERE,
HERE,
}
pub type Word<const N: usize> = [char; N];
pub type Matches<const N: usize> = [LetterMatch; N];
pub fn guess<const N: usize>(word: &Word<N>, secret: &Word<N>) -> Matches<N> {
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<const N: usize> {
pub word: Word<N>,
pub matches: Matches<N>,
}
impl<const N: usize> GuessResult<N> {
pub fn correct(&self) -> bool {
for a in 0..N {
if self.matches[a] != LetterMatch::HERE {
return false;
}
}
true
}
}
impl<const N: usize> std::fmt::Display for GuessResult<N> {
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<const N: usize> = Vec<GuessResult<N>>;
pub type Dictionary<const N: usize> = Vec<Word<N>>;
#[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));
}
}

41
src/guesser.rs Normal file
View file

@ -0,0 +1,41 @@
use rand::seq::SliceRandom;
use crate::guess::*;
use crate::constraints;
pub trait Guesser<const N: usize> {
fn push(&mut self, _result: GuessResult<N>) {}
fn guess(&self) -> Word<N>;
}
pub struct RandomConstraintGuesser<'a, const N: usize> {
possible_words: Dictionary<N>,
history: GuessHistory<N>,
constraints: &'a mut dyn constraints::Constraints<N>,
}
impl<'a, const N: usize> RandomConstraintGuesser<'a, N> {
pub fn new(
dictionary: &Dictionary<N>,
constraints: &'a mut dyn constraints::Constraints<N>,
) -> Self {
Self {
possible_words: dictionary.to_vec(),
history: GuessHistory::new(),
constraints,
}
}
}
impl<'a, const N: usize> Guesser<N> for RandomConstraintGuesser<'a, N> {
fn guess(&self) -> Word<N> {
*self.possible_words.choose(&mut rand::thread_rng()).unwrap()
}
fn push(&mut self, result: GuessResult<N>) {
self.history.push(result);
self.constraints.push(result);
let constraints = &self.constraints;
self.possible_words.retain(|&word| constraints.check(word));
}
}

View file

@ -1,230 +1,35 @@
use std::collections::HashMap;
use std::convert::TryInto; use std::convert::TryInto;
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
#[derive(Debug, Copy, Clone, Eq, PartialEq)] #[macro_export]
enum LetterMatch { macro_rules! word {
NOWHERE, ( $x:expr ) => {
ELSEWHERE, {
HERE, $x.chars()
} .collect::<Vec<char>>()
.as_slice()
type Word<const N: usize> = [char; N]; .try_into()
type Matches<const N: usize> = [LetterMatch; N]; .unwrap()
fn guess<const N: usize>(word: &Word<N>, secret: &Word<N>) -> Matches<N> {
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 mod guess;
} use guess::*;
#[derive(Copy, Clone)] mod constraints;
struct GuessResult<const N: usize> { mod game;
word: Word<N>, mod guesser;
matches: Matches<N>, mod evaluator;
}
impl<const N: usize> GuessResult<N> {
fn correct(&self) -> bool {
for a in 0..N {
if self.matches[a] != LetterMatch::HERE {
return false;
}
}
true
}
}
impl<const N: usize> std::fmt::Display for GuessResult<N> {
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<const N: usize> = Vec<GuessResult<N>>;
type Dictionary<const N: usize> = Vec<Word<N>>;
trait Evaluator<const N: usize> {
fn push(&mut self, _result: GuessResult<N>) {}
fn evaluate(&self, word: Word<N>) -> Matches<N>;
}
trait Guesser<const N: usize> {
fn push(&mut self, _result: GuessResult<N>) {}
fn guess(&self) -> Word<N>;
}
struct SecretEvaluator<const N: usize> { fn play<const N: usize>(dictionary: &Dictionary<N>) {
secret: Word<N>,
}
impl<const N: usize> SecretEvaluator<N> {
pub fn new(secret: Word<N>) -> Self {
Self {
secret
}
}
}
impl<const N: usize> Evaluator<N> for SecretEvaluator<N> {
fn evaluate(&self, word: Word<N>) -> [LetterMatch; N] {
guess(&word, &self.secret)
}
}
struct LetterConstraints<const N: usize> {
letter_minimums: HashMap<char, usize>,
letter_maximums: HashMap<char, usize>,
empty: bool,
}
impl<const N: usize> LetterConstraints<N> {
pub fn new() -> Self {
Self {
letter_minimums: HashMap::new(),
letter_maximums: HashMap::new(),
empty: true,
}
}
pub fn check(&self, word: Word<N>) -> 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<const N: usize> LetterConstraints<N> {
fn push(&mut self, result: GuessResult<N>) {
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<const N: usize> {
dictionary: Dictionary<N>,
history: GuessHistory<N>,
constraints: LetterConstraints<N>,
}
impl<const N: usize> RandomEasyGuesser<N> {
pub fn new(dictionary: Dictionary<N>) -> Self {
Self {
dictionary,
history: GuessHistory::new(),
constraints: LetterConstraints::new(),
}
}
}
impl<const N: usize> Guesser<N> for RandomEasyGuesser<N> {
fn guess(&self) -> Word<N> {
*self.dictionary.choose(&mut rand::thread_rng()).unwrap()
}
fn push(&mut self, result: GuessResult<N>) {
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<N>,
evaluator: &'a mut dyn Evaluator<N>,
}
impl<'a, const N: usize> Game<'a, N> {
fn next(&mut self) -> GuessResult<N> {
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<const N: usize>(dictionary: Dictionary<N>) {
let secret = *dictionary.choose(&mut rand::thread_rng()).unwrap(); let secret = *dictionary.choose(&mut rand::thread_rng()).unwrap();
let mut game = Game { println!("The secret is {:?}", secret);
evaluator: &mut SecretEvaluator::new(secret), let mut constraints = constraints::Total::new();
guesser: &mut RandomEasyGuesser::new(dictionary), let mut game = game::Game {
evaluator: &mut evaluator::AdversarialEvaluator::new(dictionary),
guesser: &mut guesser::RandomConstraintGuesser::new(dictionary, &mut constraints),
}; };
loop { loop {
let result = game.next(); let result = game.next();
@ -250,5 +55,7 @@ fn load_words<const N: usize>(contents: &'static str) -> Vec<Word<N>> {
fn main() { fn main() {
let common_words: Vec<Word<5>> = load_words(include_str!("words/common.txt")); let common_words: Vec<Word<5>> = load_words(include_str!("words/common.txt"));
play(common_words); loop {
play(&common_words);
}
} }