wordle_automaton/lib.rs
1/*!
2Building blocks to create a Wordle solver.
3
4This crates provides the type [`Wordle`], which is an [`Automaton`].
5It accepts only states that satisfy all constraints that are provided from the game.
6
7The expected usage pattern is to search for some accepted state and refine the automaton
8based on the feedback that the game provides.
9
10# Example
11
12```rust
13use fst::{IntoStreamer, Set, Streamer};
14use wordle_automaton::WordleBuilder;
15
16// // Build an FST from a word list - we use some random words
17let set = fst::Set::from_iter(&["crush", "morty", "party", "solid"]).unwrap();
18
19// Create an empty Wordle with support for 5 letter words
20let wordle = WordleBuilder::<5>::new().build();
21
22// Search with the current Wordle
23let mut stream = set.search(wordle.clone()).into_stream();
24
25// Select a guess from the stream
26// In the example we take the first one
27let guess = stream.next().unwrap();;
28
29// In the first round, the guess is the first word, since all are valid
30assert_eq!(guess, b"crush");
31
32// Present the guess to the game and gather feedback
33let mut next = WordleBuilder::from(wordle);
34
35// Let's say the correct word is 'party'
36
37// The first letter 'c' is not in the word at all, it can _never_ be a part of the solution
38next.never(guess[0]);
39
40// The second letter 'r' is in the word, but in the _wrong position_
41// The position is 0-based, corresponding to the byte index
42next.wrong_pos(1, guess[1]);
43
44// None of the following letters are part of the solution, we can eliminate them in bulk
45next.never_all(&guess[2..]);
46
47
48// let's try the next round
49let wordle = next.build();
50
51let mut stream = set.search(wordle.clone()).into_stream();
52let guess = stream.next().unwrap();
53
54// the next valid guess is 'morty' as 'crush' is eliminated, as is 'solid'
55assert_eq!(guess, b"morty");
56
57// Present the guess to the game for feedback
58let mut next = WordleBuilder::from(wordle);
59// 'm' and 'o' are not in 'party'
60next.never_all(&guess[..2]);
61
62// The remaining letters are all in their correct position
63next.correct_pos(2, guess[2]);
64next.correct_pos(3, guess[3]);
65next.correct_pos(4, guess[4]);
66
67// Let's try the final round
68let wordle = next.build();
69let mut stream = set.search(wordle.clone()).into_stream();
70let guess = stream.next().unwrap();
71
72// Only 'party' remains as a candidate that fulfills all requirements
73assert_eq!(guess, b"party");
74
75// after asking the game, we can verify that we have arrived at a solution
76let mut solution = WordleBuilder::from(wordle);
77solution.correct_pos(0, guess[0]);
78solution.correct_pos(1, guess[1]);
79
80// We don't need to add all the remaining characters, as they are already known to be correct
81let solution = solution.build();
82assert!(solution.is_solved());
83assert_eq!(solution.decode_str(), String::from("party"));
84```
85*/
86
87#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
88#![warn(
89 bad_style,
90 const_err,
91 dead_code,
92 improper_ctypes,
93 missing_copy_implementations,
94 missing_debug_implementations,
95 missing_docs,
96 no_mangle_generic_items,
97 non_shorthand_field_patterns,
98 overflowing_literals,
99 path_statements,
100 patterns_in_fns_without_body,
101 private_in_public,
102 rust_2018_idioms,
103 trivial_casts,
104 trivial_numeric_casts,
105 unconditional_recursion,
106 unsafe_code,
107 unused_allocation,
108 unused_comparisons,
109 unused_crate_dependencies,
110 unused_extern_crates,
111 unused_import_braces,
112 unused_parens,
113 unused_qualifications,
114 unused_results,
115 unused,
116 while_true
117)]
118
119use fst::Automaton;
120use std::num::NonZeroUsize;
121
122pub mod prepare;
123mod types;
124
125pub use types::{Constraint, Letter, LetterList, LetterSet};
126
127/// An automaton that matches valid guesses
128#[allow(missing_copy_implementations)]
129#[derive(Clone, Debug)]
130pub struct Wordle<const N: usize> {
131 never: LetterSet,
132 eventually: LetterList<N>,
133 positions: [Constraint; N],
134}
135
136impl<const N: usize> Wordle<N> {
137 /// Create a new automaton without any constraints
138 #[must_use]
139 pub const fn new() -> Self {
140 Self {
141 never: LetterSet::new(),
142 eventually: LetterList::new(),
143 positions: [Constraint::new(); N],
144 }
145 }
146
147 /// Check if the automaton would only match the full solution
148 #[must_use]
149 pub fn is_solved(&self) -> bool {
150 self.positions.iter().all(|p| !p.is_free())
151 }
152
153 /// Test if the automaton matches the solution for a single position
154 ///
155 /// # Panics
156 ///
157 /// Panics if the `pos` is out of bounds (N..)
158 #[must_use]
159 pub const fn has_solution_at(&self, pos: usize) -> bool {
160 !self.positions[pos].is_free()
161 }
162
163 /// Return the solution for a single position, if available
164 ///
165 /// # Panics
166 ///
167 /// Panics if the `pos` is out of bounds (N..)
168 #[must_use]
169 pub const fn solution_at(&self, pos: usize) -> Option<u8> {
170 let cons = self.positions[pos];
171 if cons.is_free() {
172 None
173 } else {
174 Some(cons.must_letter().into_byte())
175 }
176 }
177
178 /// Iterate over all bytes in the full solution
179 ///
180 /// # Panics
181 ///
182 /// Panics if the automaton does not have a solution, i.e. [`Wordle::is_solved()`] must return `true`
183 pub fn decode(&self) -> impl Iterator<Item = u8> + '_ {
184 self.positions.iter().map(|cons| {
185 assert!(!cons.is_free(), "Trying to decode an unsolved wordle");
186 u8::from(cons.must_letter())
187 })
188 }
189
190 /// Create a new string from the full solution
191 ///
192 /// # Panics
193 ///
194 /// Panics if the automaton does not have a solution, i.e. [`Wordle::is_solved()`] must return `true`
195 #[must_use]
196 pub fn decode_str(&self) -> String {
197 self.decode().map(char::from).collect()
198 }
199
200 /// Decode a guess for a single position
201 ///
202 /// The result only makes sense if the result had been see before,
203 /// otherwise is may erroneously be reported as 'correct'.
204 ///
205 /// For a fallible variant, use `[Wordle::try_decode_guess()]`.
206 ///
207 /// # Panics
208 ///
209 /// Panics if the guess is not in [a-zA-Z]
210 /// Panics if the `pos` is out of bounds (N..)
211 #[must_use]
212 pub const fn decode_guess(&self, pos: usize, guess: u8) -> Guess {
213 let letter = Letter::new(guess);
214 if self.never.contains(letter) {
215 Guess::Absent
216 } else if self.positions[pos].accept(letter) {
217 Guess::Correct
218 } else {
219 Guess::Present
220 }
221 }
222
223 /// Decode a guess for a single position
224 ///
225 /// Returns Ok(`[Guess]`) if there is a known result about that guess.
226 ///
227 /// # Errors
228 ///
229 /// Returns an error if the guess was never seen at this position
230 ///
231 /// # Panics
232 ///
233 /// Panics if the guess is not in [a-zA-Z]
234 /// Panics if the `pos` is out of bounds (N..)
235 pub const fn try_decode_guess(&self, pos: usize, guess: u8) -> Result<Guess, GuessError> {
236 let guess = self.decode_guess(pos, guess);
237 match guess {
238 Guess::Correct if self.positions[pos].is_free() => Err(GuessError::NeverSeen),
239 Guess::Present if !self.positions[pos].is_free() => Err(GuessError::NeverSeen),
240 guess => Ok(guess),
241 }
242 }
243}
244
245/// A Builder to create instances of the [`Wordle`] automaton.
246///
247/// # Examples
248///
249/// ```rust
250/// # use wordle_automaton::WordleBuilder;
251/// let wordle = WordleBuilder::<5>::new().never_all(b"nope").correct_pos(1, b'f').wrong_pos(2, b'x').build();
252/// ```
253#[derive(Clone, Debug)]
254pub struct WordleBuilder<const N: usize>(Wordle<N>, LetterList<N>);
255
256impl<const N: usize> WordleBuilder<N> {
257 /// Create a new builder without any constraints
258 #[must_use]
259 pub const fn new() -> Self {
260 Self(Wordle::new(), LetterList::new())
261 }
262
263 /// Create a new builder based on existing constraints
264 #[must_use]
265 pub fn from(mut wordle: Wordle<N>) -> Self {
266 let old_eventually = std::mem::replace(&mut wordle.eventually, LetterList::new());
267 Self(wordle, old_eventually)
268 }
269
270 /// Signal that the given `letter` is never part of any solution on any position
271 ///
272 /// This corresponds to the darkened result is the game
273 ///
274 /// # Panics
275 /// Panics if the letter is not in [a-zA-Z]
276 pub fn never(&mut self, letter: u8) -> &mut Self {
277 self.0.never = self.0.never.add(Letter::new(letter));
278 self
279 }
280
281 /// Signal that any of the given letters are never part of any solution on any position
282 ///
283 /// This is equivalent to calling [`WordleBuilder::never()`] on every item of the iterator
284 ///
285 /// # Panics
286 /// Panics if any of the letters is not in [a-zA-Z]
287 pub fn never_all(&mut self, letters: impl AsRef<[u8]>) -> &mut Self {
288 self.0.never = letters
289 .as_ref()
290 .iter()
291 .copied()
292 .map(Letter::new)
293 .fold(self.0.never, LetterSet::add);
294 self
295 }
296
297 /// Signal that the given letter is correct for the given position
298 ///
299 /// This is equivalent to the green result in the game
300 ///
301 /// # Panics
302 /// Panics if the letter is not in [a-zA-Z]
303 /// Panics if the `pos` is out of bounds (N..)
304 pub fn correct_pos(&mut self, pos: usize, letter: u8) -> &mut Self {
305 self.0.positions[pos] = self.0.positions[pos].must(Letter::new(letter));
306 self
307 }
308
309 /// Signal that the given letter is in a wrong position but part of the solution
310 ///
311 /// This is equivalent to the yellow result in the game
312 ///
313 /// # Panics
314 /// Panics if the letter is not in [a-zA-Z]
315 /// Panics if the `pos` is out of bounds (N..)
316 pub fn wrong_pos(&mut self, pos: usize, letter: u8) -> &mut Self {
317 let letter = Letter::new(letter);
318 self.0.eventually.add(letter);
319 self.0.positions[pos] = self.0.positions[pos].must_not(letter);
320 self
321 }
322
323 /// Signal that the given letter is part of the solution
324 ///
325 /// There is no equivalent in the game. It is either a green or a yello result, without knowing
326 /// which one
327 ///
328 /// # Panics
329 /// Panics if the letter is not in [a-zA-Z]
330 pub fn eventually(&mut self, letter: u8) -> &mut Self {
331 let letter = Letter::new(letter);
332 self.0.never = self.0.never.remove(letter);
333 self.0.eventually.add(letter);
334 self
335 }
336
337 /// Build the final automaton for the added constraints
338 pub fn build(&mut self) -> Wordle<N> {
339 let mut wordle = std::mem::replace(&mut self.0, Wordle::new());
340
341 // if we signal something as 'never' but it's also green or yellow somewhere, we must
342 // remove it from 'never' as we would otherwise not find a match
343 let mut never = wordle
344 .eventually
345 .iter()
346 .fold(wordle.never, |n, l| n.remove(*l));
347
348 // we also keep the orignal never around to pass it to all undecided constraints
349 let global_never = never;
350
351 // we also collect a set of all letters that are already solved
352 let mut solved = LetterSet::new();
353
354 for cons in wordle.positions.iter_mut() {
355 // remove all letters with a known constraint from the global never set
356 if cons.is_free() {
357 never = never.remove_all(cons.must_not_letters());
358 // add all global nevers to the prohibited set of this constraint
359 // we need to do this _after_ the previous step
360 // otherwise we would always remove all never letters
361 *cons = cons.must_not_all(global_never);
362 } else {
363 never = never.remove(cons.must_letter());
364 solved = solved.add(cons.must_letter());
365 };
366 }
367
368 // we add all previous eventual letters if they are not already handles
369 for &letter in self.1.iter() {
370 // if the letter needed to be eventually in the result but is not excluded from it
371 // we cannot produce a valid result. By blocking all letters, we would never match.
372 if never.contains(letter) {
373 never = LetterSet::full();
374 }
375 // add it only if it hasn't been solved now or was already added
376 if !solved.contains(letter) {
377 wordle.eventually.add_if_absent(letter);
378 }
379 }
380
381 wordle.never = never;
382 wordle
383 }
384
385 /// Return the current automaton
386 ///
387 /// The automaton may not be in a valid state, i.e. should not be used to search in an FST
388 #[must_use]
389 pub const fn current(&self) -> &Wordle<N> {
390 &self.0
391 }
392}
393
394/// A guess result for a given letter
395#[derive(Copy, Clone, Debug, PartialEq, Eq)]
396pub enum Guess {
397 /// The letter is never part of the result ('Gray')
398 Absent,
399 /// The letter is in the result but at the wrong position ('Yellow')
400 Present,
401 /// The letter is in its correct position ('Green')
402 Correct,
403}
404
405/// Error results for a guess result for a given letter
406#[derive(Copy, Clone, Debug, PartialEq, Eq)]
407pub enum GuessError {
408 /// The letter has never been seen
409 NeverSeen,
410}
411
412#[derive(Clone, Debug)]
413#[doc(hidden)]
414pub struct SolveState<const N: usize> {
415 pos: NonZeroUsize,
416 eventually: LetterList<N>,
417}
418
419const _: () = assert!(
420 std::mem::size_of::<Option<SolveState<5>>>() == std::mem::size_of::<SolveState<5>>(),
421 "SolveState should have a niche"
422);
423
424impl<const N: usize> Automaton for Wordle<N> {
425 type State = Option<SolveState<N>>;
426
427 fn start(&self) -> Self::State {
428 Some(SolveState {
429 pos: NonZeroUsize::new(1).unwrap(),
430 eventually: self.eventually.clone(),
431 })
432 }
433
434 fn is_match(&self, state: &Self::State) -> bool {
435 state.as_ref().map_or(false, |s| s.pos.get() - 1 == N)
436 }
437
438 fn accept(&self, state: &Self::State, byte: u8) -> Self::State {
439 // we are already a non-match
440 let state = state.as_ref()?;
441
442 // cannot match past 5 letters
443 let pos = state.pos.get() - 1;
444 if pos == N {
445 return None;
446 }
447
448 // byte is invalid, i.e. not [a-zA-Z]
449 let letter = Letter::try_new(byte)?;
450
451 // letter is never a possibility
452 if self.never.contains(letter) {
453 return None;
454 }
455
456 let constraint = self.positions[pos];
457 // letter is not possible in the current position
458 if !constraint.accept(letter) {
459 return None;
460 }
461
462 // check if the letter is one that we eventually need to add
463 let mut state = state.clone();
464 let removed = state.eventually.remove(letter);
465 if !removed {
466 // need to check that we have enough free slots to eventually fill those
467 let need_to_fill = state.eventually.len();
468 let available = self.positions[pos + 1..]
469 .iter()
470 .map(|p| usize::from(p.is_free()))
471 .sum::<usize>();
472
473 // there are not enough slots to eventually fill all undecided ones
474 if available < need_to_fill {
475 return None;
476 }
477 }
478
479 // adding 1 because we store 1-based, adding another one to actually advance
480 state.pos = NonZeroUsize::new(pos + 2).expect("Adding to non-zero value is non-zero");
481 Some(state)
482 }
483
484 fn can_match(&self, state: &Self::State) -> bool {
485 !self.never.is_full() && state.as_ref().map_or(false, |s| s.pos.get() <= N)
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use fst::{IntoStreamer, Set};
493
494 #[test]
495 fn test_no_conditions() {
496 let wordle = WordleBuilder::new().build();
497
498 test_fst(wordle, ["abcd", "abcde", "abcdef"], &["abcde"]);
499 }
500
501 #[test]
502 fn test_never_accept_a() {
503 let wordle = WordleBuilder::new().never(b'a').build();
504
505 test_fst(wordle, ["abcde", "bcdef", "cdefa"], &["bcdef"]);
506 }
507
508 #[test]
509 fn test_never_accept_abcd() {
510 let wordle = WordleBuilder::new().never_all("abcd").build();
511
512 test_fst(wordle, ["abcde", "cdefg", "efghi"], &["efghi"]);
513 }
514
515 #[test]
516 fn test_require_a_in_third_pos() {
517 let wordle = WordleBuilder::new().correct_pos(2, b'a').build();
518
519 test_fst(
520 wordle,
521 ["abcde", "bacde", "bcade", "bcdae", "bcdea"],
522 &["bcade"],
523 );
524 }
525
526 #[test]
527 fn test_dont_allow_a_in_third_pos() {
528 let wordle = WordleBuilder::new().wrong_pos(2, b'a').build();
529
530 test_fst(
531 wordle,
532 ["abcde", "bacde", "bcade", "bcdae", "bcdea"],
533 &["abcde", "bacde", "bcdae", "bcdea"],
534 );
535 }
536
537 #[test]
538 fn test_use_partially_solved_letter() {
539 let wordle = WordleBuilder::new()
540 .correct_pos(0, b'a')
541 .correct_pos(1, b'b')
542 .correct_pos(2, b'c')
543 // if we disallow d at the fourth pos, we must require it at the last position
544 .wrong_pos(3, b'd')
545 .build();
546
547 test_fst(wordle, ["abcde", "abced", "abcef"], &["abced"]);
548 }
549
550 #[test]
551 fn test_guess_with_repeated_letters_as_yellow() {
552 // word is abcde, guess was abcee
553 let wordle = WordleBuilder::new()
554 .correct_pos(0, b'a')
555 .correct_pos(1, b'b')
556 .correct_pos(2, b'c')
557 .wrong_pos(3, b'e')
558 .correct_pos(4, b'e')
559 .build();
560
561 // there is actually no correct solution as e may not appear in the 4th pos
562 // which means it must appear somewhere else, but all the other positions
563 // are already determined
564
565 test_fst(wordle, ["abcde", "abcee"], &[]);
566
567 // if we free up the last slot, we get the correct solution
568 let wordle = WordleBuilder::new()
569 .correct_pos(0, b'a')
570 .correct_pos(1, b'b')
571 .correct_pos(2, b'c')
572 .wrong_pos(3, b'e')
573 .build();
574
575 test_fst(wordle, ["abcde", "abcee"], &["abcde"]);
576 }
577
578 #[test]
579 fn test_guess_with_repeated_letters_as_gray() {
580 // word is abcde, guess was abcee
581 let wordle = WordleBuilder::new()
582 .correct_pos(0, b'a')
583 .correct_pos(1, b'b')
584 .correct_pos(2, b'c')
585 .never(b'e')
586 .correct_pos(4, b'e')
587 .build();
588
589 // the e in 4th position is different from the e in last position
590 // disallowing any e while another e is known to be valid should
591 // still yield a solution
592
593 test_fst(wordle, ["abcde", "abcee"], &["abcde"]);
594 }
595
596 #[test]
597 fn test_guess_with_repeated_correct_letters_in_wrong_pos() {
598 // word is abcde, guess was acebb
599 let wordle = WordleBuilder::new()
600 .correct_pos(0, b'a')
601 .wrong_pos(1, b'c')
602 .wrong_pos(2, b'e')
603 .wrong_pos(3, b'b')
604 .never(b'b')
605 .build();
606
607 // the b in 4th position is different from the b in last position
608 // disallowing any b while another b is known to be valid eventually should
609 // still yield a solution
610
611 test_fst(wordle, ["abcde", "acebb"], &["abcde"]);
612 }
613
614 fn test_fst<'a>(
615 wordle: Wordle<5>,
616 words: impl IntoIterator<Item = &'a str>,
617 expected: &[&'a str],
618 ) {
619 fn inner_test<'a>(
620 wordle: Wordle<5>,
621 words: impl IntoIterator<Item = &'a str>,
622 expected: &[&'a str],
623 ) -> fst::Result<()> {
624 let set = Set::from_iter(words)?;
625 let stream = set.search(wordle).into_stream();
626 let matches = stream.into_strs()?;
627
628 assert_eq!(&matches[..], expected);
629
630 Ok(())
631 }
632
633 inner_test(wordle, words, expected).unwrap();
634 }
635}