rs_wordle_solver/engine.rs
1use rayon::prelude::*;
2
3use crate::data::*;
4use crate::restrictions::WordRestrictions;
5use crate::results::*;
6use crate::scorers::WordScorer;
7use std::collections::HashMap;
8use std::num::NonZeroUsize;
9use std::result::Result;
10use std::sync::Arc;
11
12/// Indicates which set of words to guess from. See [`MaxScoreGuesser::new()`].
13#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub enum GuessFrom {
16 /// Choose the next guess from any unguessed word in the whole word list.
17 AllUnguessedWords,
18 /// Choose the next guess from any possible word based on the current restrictions.
19 PossibleWords,
20}
21
22/// Guesses words in order to solve a single Wordle.
23pub trait Guesser {
24 /// Updates this guesser with information about a word.
25 fn update(&mut self, result: &GuessResult) -> Result<(), WordleError>;
26
27 /// Selects a new guess for the Wordle.
28 ///
29 /// Returns `None` if no known words are possible given the known restrictions imposed by
30 /// previous calls to [`Self::update()`].
31 fn select_next_guess(&mut self) -> Option<Arc<str>>;
32
33 /// Selects a new guess for the Wordle using the requested set of possible words instead of
34 /// the default set for this guesser.
35 ///
36 /// Returns `None` if no known words are possible given the known restrictions imposed by
37 /// previous calls to [`Self::update()`].
38 fn select_next_guess_from(&mut self, from: GuessFrom) -> Option<Arc<str>>;
39
40 /// Provides read access to the remaining set of possible words in this guesser.
41 fn possible_words(&self) -> &[Arc<str>];
42}
43
44/// Attempts to guess the given word within the maximum number of guesses, using the given word
45/// guesser.
46///
47/// ```
48/// use rs_wordle_solver::GameResult;
49/// use rs_wordle_solver::RandomGuesser;
50/// use rs_wordle_solver::WordBank;
51/// use rs_wordle_solver::play_game_with_guesser;
52///
53/// let bank = WordBank::from_iterator(&["abc", "def", "ghi"]).unwrap();
54/// let mut guesser = RandomGuesser::new(bank);
55/// let result = play_game_with_guesser("def", 4, guesser.clone());
56///
57/// assert!(matches!(result, GameResult::Success(_guesses)));
58///
59/// let result = play_game_with_guesser("zzz", 4, guesser.clone());
60///
61/// assert!(matches!(result, GameResult::UnknownWord));
62///
63/// let result = play_game_with_guesser("other", 4, guesser);
64///
65/// assert!(matches!(result, GameResult::UnknownWord));
66/// ```
67pub fn play_game_with_guesser<G: Guesser>(
68 word_to_guess: &str,
69 max_num_guesses: u32,
70 mut guesser: G,
71) -> GameResult {
72 let mut turns: Vec<TurnData> = Vec::new();
73 for _ in 1..=max_num_guesses {
74 let maybe_guess = guesser.select_next_guess();
75 if maybe_guess.is_none() {
76 return GameResult::UnknownWord;
77 }
78 let guess = maybe_guess.unwrap();
79 let num_possible_words_before_guess = guesser.possible_words().len();
80 let result = get_result_for_guess(word_to_guess, guess.as_ref());
81 if result.is_err() {
82 return GameResult::UnknownWord;
83 }
84 let result = result.unwrap();
85 turns.push(TurnData {
86 num_possible_words_before_guess,
87 guess: Box::from(guess.as_ref()),
88 });
89 if result.results.iter().all(|lr| *lr == LetterResult::Correct) {
90 return GameResult::Success(GameData { turns });
91 }
92 guesser.update(&result).unwrap();
93 }
94 GameResult::Failure(GameData { turns })
95}
96
97/// Guesses at random from the possible words that meet the restrictions.
98///
99/// A sample benchmark against the `data/improved-words.txt` list performed as follows:
100///
101/// |Num guesses to win|Num games|
102/// |------------------|---------|
103/// |1|1|
104/// |2|106|
105/// |3|816|
106/// |4|1628|
107/// |5|1248|
108/// |6|518|
109/// |7|180|
110/// |8|67|
111/// |9|28|
112/// |10|7|
113/// |11|2|
114/// |12|1|
115///
116/// **Average number of guesses:** 4.49 +/- 1.26
117#[derive(Debug, Clone)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119pub struct RandomGuesser {
120 words: GroupedWords,
121 restrictions: WordRestrictions,
122}
123
124impl RandomGuesser {
125 /// Constructs a new `RandomGuesser` using the given word bank.
126 ///
127 /// This guesser always uses the `GuessFrom::PossibleWords` strategy by default.
128 ///
129 /// ```
130 /// use rs_wordle_solver::RandomGuesser;
131 /// use rs_wordle_solver::WordBank;
132 ///
133 /// let bank = WordBank::from_iterator(&["abc", "def", "ghi"]).unwrap();
134 /// let guesser = RandomGuesser::new(bank);
135 /// ```
136 pub fn new(bank: WordBank) -> RandomGuesser {
137 let word_length = bank.word_length();
138 RandomGuesser {
139 words: GroupedWords::new(bank),
140 restrictions: WordRestrictions::new(word_length as u8),
141 }
142 }
143
144 fn select_random_word(words: &[Arc<str>]) -> Option<Arc<str>> {
145 if words.is_empty() {
146 return None;
147 }
148 let random: usize = rand::random();
149 words.get(random % words.len()).map(Arc::clone)
150 }
151}
152
153impl Guesser for RandomGuesser {
154 fn update(&mut self, result: &GuessResult) -> Result<(), WordleError> {
155 self.restrictions.update(result)?;
156 self.words
157 .filter_possible_words(|word| self.restrictions.is_satisfied_by(word));
158 Ok(())
159 }
160
161 fn select_next_guess(&mut self) -> Option<Arc<str>> {
162 self.select_next_guess_from(GuessFrom::PossibleWords)
163 }
164
165 fn select_next_guess_from(&mut self, from: GuessFrom) -> Option<Arc<str>> {
166 match from {
167 GuessFrom::AllUnguessedWords => {
168 RandomGuesser::select_random_word(self.words.unguessed_words())
169 }
170 GuessFrom::PossibleWords => RandomGuesser::select_random_word(self.possible_words()),
171 }
172 }
173
174 fn possible_words(&self) -> &[Arc<str>] {
175 self.words.possible_words()
176 }
177}
178
179/// Represents a guess with a 'score' estimating how useful the guess is. Higher scores are better.
180#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
181pub struct ScoredGuess {
182 pub score: i64,
183 pub guess: Arc<str>,
184}
185
186/// Selects the next guess that maximizes the score according to the owned scorer.
187///
188/// See [`WordScorer`] for more information about possible scoring algorithms.
189#[derive(Debug, Clone)]
190#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
191pub struct MaxScoreGuesser<T>
192where
193 T: WordScorer + Clone + Sync,
194{
195 default_guess_mode: GuessFrom,
196 grouped_words: GroupedWords,
197 restrictions: WordRestrictions,
198 scorer: T,
199 parallelisation_limit: usize,
200 all_unguessed_word_scores: Option<Vec<i64>>,
201 possible_word_scores: Option<Vec<i64>>,
202}
203
204impl<T> MaxScoreGuesser<T>
205where
206 T: WordScorer + Clone + Sync,
207{
208 /// Constructs a new `MaxScoreGuesser` that will guess the word with the maximum score
209 /// according to the given [`WordScorer`]. This will only score and guess from the words in the
210 /// `word_bank` according to the given `guess_from` strategy.
211 ///
212 /// If in doubt, you probably want to use `GuessFrom::AllUnguessedWords` for better performance.
213 ///
214 /// See [`WordScorer`] for more information about possible scoring algorithms.
215 ///
216 /// ```
217 /// use std::sync::Arc;
218 /// use rs_wordle_solver::GuessFrom;
219 /// use rs_wordle_solver::Guesser;
220 /// use rs_wordle_solver::MaxScoreGuesser;
221 /// use rs_wordle_solver::WordBank;
222 /// use rs_wordle_solver::scorers::MaxEliminationsScorer;
223 ///
224 /// let bank = WordBank::from_iterator(&["azz", "bzz", "czz", "abc"]).unwrap();
225 /// let scorer = MaxEliminationsScorer::new(bank.clone());
226 /// let mut guesser = MaxScoreGuesser::new(GuessFrom::AllUnguessedWords, bank, scorer);
227 ///
228 /// assert_eq!(guesser.select_next_guess(), Some(Arc::from("abc")));
229 /// ```
230 pub fn new(guess_mode: GuessFrom, word_bank: WordBank, scorer: T) -> MaxScoreGuesser<T> {
231 let word_length = word_bank.word_length();
232 Self {
233 default_guess_mode: guess_mode,
234 grouped_words: GroupedWords::new(word_bank),
235 restrictions: WordRestrictions::new(word_length as u8),
236 scorer,
237 parallelisation_limit: std::thread::available_parallelism()
238 .map(NonZeroUsize::get)
239 .unwrap_or(1),
240 all_unguessed_word_scores: None,
241 possible_word_scores: None,
242 }
243 }
244
245 /// Sets the parallelisation limit. Various internal operations may be parallelised if operating
246 /// on lists larger than this limit. The default setting is the result of
247 /// `std::thread::available_parallelism`.
248 pub fn with_parallelisation_limit(mut self, parallelisation_limit: usize) -> Self {
249 self.parallelisation_limit = parallelisation_limit;
250 self
251 }
252
253 /// Sets the precomputed word scores based on the provided map. If the map is missing scores
254 /// for any words, they will be computed to fill the gaps. These scores will be used until the
255 /// next call to [`Self::update()`].
256 ///
257 /// This is handy to initialise the guesser with precomputed scores for the first guess. You
258 /// can retrieve this data by calling [`Self::get_or_compute_scores()`] on a newly constructed
259 /// guesser.
260 pub fn with_scores(mut self, scores: &HashMap<Arc<str>, i64>) -> Self {
261 let words_to_score = self.words_to_score(self.default_guess_mode);
262 let ordered_scores: Vec<i64> = words_to_score
263 .iter()
264 .map(|word| {
265 scores
266 .get(word)
267 .copied()
268 .unwrap_or_else(|| self.scorer.score_word(word))
269 })
270 .collect();
271 match self.default_guess_mode {
272 GuessFrom::AllUnguessedWords => {
273 self.all_unguessed_word_scores = Some(ordered_scores);
274 }
275 GuessFrom::PossibleWords => {
276 self.possible_word_scores = Some(ordered_scores);
277 }
278 }
279 self
280 }
281
282 /// Gets the score for each available guess, keyed by guess. This computes the scores if they
283 /// have not already been computed. The set of words is limited by the [`GuessFrom`] value used
284 /// in this guesser, and by the updates that have been provided so far.
285 pub fn get_or_compute_scores(&mut self) -> HashMap<Arc<str>, i64> {
286 self.compute_scores_if_unknown();
287 self.words_to_score(self.default_guess_mode)
288 .iter()
289 .zip(self.word_scores(self.default_guess_mode).unwrap().iter())
290 .map(|(word, score)| (Arc::clone(word), *score))
291 .collect()
292 }
293
294 /// Returns up-to the top `n` guesses for the wordle, based on the current state.
295 ///
296 /// Returns an empty vector if no known words are possible given the known restrictions imposed
297 /// by previous calls to [`Self::update()`].
298 pub fn select_top_n_guesses(&mut self, n: usize) -> Vec<ScoredGuess> {
299 self.select_top_n_guesses_from(n, self.default_guess_mode)
300 }
301
302 /// Returns up-to the top `n` guesses for the wordle, based on the current state and the
303 /// provided [`GuessFrom`] option.
304 ///
305 /// Returns an empty vector if no known words are possible given the known restrictions imposed
306 /// by previous calls to [`Self::update()`].
307 pub fn select_top_n_guesses_from(&mut self, n: usize, from: GuessFrom) -> Vec<ScoredGuess> {
308 self.compute_scores_if_needed_from(from);
309 let word_scores = self.word_scores(from).unwrap();
310 let mut scored_words: Vec<(&Arc<str>, i64)> = word_scores
311 .iter()
312 .zip(self.words_to_score(from).iter())
313 .map(|(score, word)| (word, *score))
314 .collect();
315
316 // Use a stable sort, because possible words come before impossible words, and we want to
317 // prioritise possible words if we're using GuessFrom::AllUnguessedWords.
318 if scored_words.len() >= self.parallelisation_limit {
319 scored_words.par_sort_by_key(|(_, score)| -score);
320 } else {
321 scored_words.sort_by_key(|(_, score)| -score);
322 }
323 scored_words
324 .iter()
325 .take(n)
326 .map(|(word, score)| ScoredGuess {
327 score: *score,
328 guess: Arc::clone(*word),
329 })
330 .collect()
331 }
332
333 /// Computes the word scores if they are not known, using the default [`GuessFrom`] provided on
334 /// construction. The result is cached into `Self` until the scorer's state changes.
335 ///
336 /// This can be useful to precompute the scores for the first guess in a base guesser, and then
337 /// clone that guesser for each use.
338 ///
339 /// ```
340 /// # use std::sync::Arc;
341 /// # use rs_wordle_solver::GuessFrom;
342 /// # use rs_wordle_solver::Guesser;
343 /// # use rs_wordle_solver::MaxScoreGuesser;
344 /// # use rs_wordle_solver::WordBank;
345 /// # use rs_wordle_solver::scorers::MaxEliminationsScorer;
346 ///
347 /// let bank = WordBank::from_iterator(&["azz", "bzz", "czz", "abc"]).unwrap();
348 /// let scorer = MaxEliminationsScorer::new(bank.clone());
349 /// let mut base_guesser = MaxScoreGuesser::new(GuessFrom::AllUnguessedWords, bank, scorer);
350 ///
351 /// // Precompute the first scores.
352 /// base_guesser.compute_scores_if_unknown();
353 ///
354 /// // Clone a new guesser for each use.
355 /// let guesser = base_guesser.clone();
356 /// ```
357 ///
358 /// You can also use the `serde` feature to serialise an instance after doing this
359 /// precomputation.
360 pub fn compute_scores_if_unknown(&mut self) {
361 self.compute_scores_if_needed_from(self.default_guess_mode);
362 }
363
364 /// Computes the word scores if they are not known. The result is cached into `Self` until the
365 /// scorer's state changes.
366 ///
367 /// This can be useful to precompute the scores for the first guess in a base guesser, and then
368 /// clone that guesser for each use.
369 ///
370 /// ```
371 /// # use std::sync::Arc;
372 /// # use rs_wordle_solver::GuessFrom;
373 /// # use rs_wordle_solver::Guesser;
374 /// # use rs_wordle_solver::MaxScoreGuesser;
375 /// # use rs_wordle_solver::WordBank;
376 /// # use rs_wordle_solver::scorers::MaxEliminationsScorer;
377 ///
378 /// let bank = WordBank::from_iterator(&["azz", "bzz", "czz", "abc"]).unwrap();
379 /// let scorer = MaxEliminationsScorer::new(bank.clone());
380 /// let mut base_guesser = MaxScoreGuesser::new(GuessFrom::AllUnguessedWords, bank, scorer);
381 ///
382 /// // Precompute the first scores.
383 /// base_guesser.compute_scores_if_needed_from(GuessFrom::PossibleWords);
384 ///
385 /// // Clone a new guesser for each use.
386 /// let guesser = base_guesser.clone();
387 /// ```
388 ///
389 /// You can also use the `serde` feature to serialise an instance after doing this
390 /// precomputation.
391 pub fn compute_scores_if_needed_from(&mut self, from: GuessFrom) {
392 let current_scores = self.word_scores(from);
393 if current_scores.is_some() {
394 return;
395 }
396 let computed_scores = Some(MaxScoreGuesser::score_words(
397 self.words_to_score(from),
398 &self.scorer,
399 self.parallelisation_limit,
400 ));
401
402 match from {
403 GuessFrom::AllUnguessedWords => {
404 self.all_unguessed_word_scores = computed_scores;
405 }
406 GuessFrom::PossibleWords => {
407 self.possible_word_scores = computed_scores;
408 }
409 }
410 }
411
412 fn score_words(
413 words_to_score: &[Arc<str>],
414 scorer: &T,
415 parallelisation_limit: usize,
416 ) -> Vec<i64> {
417 if words_to_score.len() >= parallelisation_limit {
418 words_to_score
419 .par_iter()
420 .map(|word| scorer.score_word(word))
421 .collect()
422 } else {
423 words_to_score
424 .iter()
425 .map(|word| scorer.score_word(word))
426 .collect()
427 }
428 }
429
430 /// Retrieves the requested set of word scores, if they have been precomputed, else `None`.
431 ///
432 /// If present, these are in the same order as the words returned by [`Self::words_to_score()`].
433 fn word_scores(&self, from: GuessFrom) -> Option<&[i64]> {
434 match from {
435 GuessFrom::PossibleWords => {
436 if let Some(scores) = self.possible_word_scores.as_ref() {
437 Some(scores.as_slice())
438 } else if let Some(scores) = self.all_unguessed_word_scores.as_ref() {
439 // The possible words are always at the start, so return those, except for
440 // the special case where there is only one possible word, and it has
441 // already been guessed.
442 if self.grouped_words.num_possible_words()
443 <= self.grouped_words.num_unguessed_words()
444 {
445 Some(scores.as_slice()[..self.grouped_words.num_possible_words()].as_ref())
446 } else {
447 None
448 }
449 } else {
450 None
451 }
452 }
453 GuessFrom::AllUnguessedWords => self.all_unguessed_word_scores.as_deref(),
454 }
455 }
456
457 /// Retrieves the words that need scoring, in the same order as the precomputed scores, if any.
458 ///
459 /// Note that if there are two or fewer possible words remaining, this will always return only
460 /// the possible words.
461 fn words_to_score(&self, from: GuessFrom) -> &[Arc<str>] {
462 match from {
463 // Only score possible words if we're down to the last two guesses.
464 _ if self.grouped_words.num_possible_words() <= 2 => {
465 self.grouped_words.possible_words()
466 }
467 GuessFrom::AllUnguessedWords => self.grouped_words.unguessed_words(),
468 GuessFrom::PossibleWords => self.grouped_words.possible_words(),
469 }
470 }
471}
472
473impl<T> Guesser for MaxScoreGuesser<T>
474where
475 T: WordScorer + Clone + Sync,
476{
477 fn update(&mut self, result: &GuessResult) -> Result<(), WordleError> {
478 self.all_unguessed_word_scores = None;
479 self.possible_word_scores = None;
480 self.grouped_words.remove_guess_if_present(result.guess);
481 self.restrictions.update(result)?;
482 self.grouped_words
483 .filter_possible_words(|word| self.restrictions.is_satisfied_by(word));
484 self.scorer.update(
485 result.guess,
486 &self.restrictions,
487 self.grouped_words.possible_words(),
488 )?;
489 Ok(())
490 }
491
492 fn select_next_guess(&mut self) -> Option<Arc<str>> {
493 self.select_next_guess_from(self.default_guess_mode)
494 }
495
496 fn select_next_guess_from(&mut self, from: GuessFrom) -> Option<Arc<str>> {
497 // Compute an owned instance of word_scores if the guess mode has changed.
498 self.compute_scores_if_needed_from(from);
499 let word_scores = self.word_scores(from).unwrap();
500 let words_to_score = self.words_to_score(from);
501 let (best_index, _) = if words_to_score.len() > self.parallelisation_limit {
502 word_scores.par_iter().enumerate().reduce(
503 || (usize::MAX, &i64::MIN),
504 |(best_index, best_score), (index, score)| {
505 if *score > *best_score {
506 return (index, score);
507 }
508 // Use the lower index, because it is more likely to be a possible word.
509 if *score == *best_score && index < best_index {
510 return (index, score);
511 }
512 (best_index, best_score)
513 },
514 )
515 } else {
516 let mut best_score = &i64::MIN;
517 let mut best_index = usize::MAX;
518 word_scores.iter().enumerate().for_each(|(i, score)| {
519 if *score > *best_score {
520 best_score = score;
521 best_index = i;
522 }
523 });
524 (best_index, best_score)
525 };
526 if best_index > words_to_score.len() {
527 None
528 } else {
529 Some(Arc::clone(&words_to_score[best_index]))
530 }
531 }
532
533 fn possible_words(&self) -> &[Arc<str>] {
534 self.grouped_words.possible_words()
535 }
536}