Skip to main content

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}