worlde_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 worlde_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
20let wordle = WordleBuilder::new().build();
21
22// Search with the current Wordle
23let mut stream = set.search_with_state(wordle.clone()).into_stream();
24
25// Select a guess from the stream
26// In the example we take the first one
27let (guess, guess_state) = stream.next().unwrap();
28// The guess is an Option<SolveState> where a None represents an invalid match
29// The fst crate should take care to not return invalid matches, so it should be safe to unwrap
30let guess_state = guess_state.unwrap();
31
32// In the first round, the guess is the first word, since all are valid
33assert_eq!(guess, b"crush");
34
35// Present the guess to the game and gather feedback
36let mut next = WordleBuilder::from(wordle, guess_state);
37
38// Let's say the correct word is 'party'
39
40// The first letter 'c' is not in the word at all, it can _never_ be a part of the solution
41next.never(guess[0]);
42
43// The second letter 'r' is in the word, but in the _wrong position_
44// The position is 0-based, corresponding to the byte index
45next.wrong_pos(1, guess[1]);
46
47// None of the following letters are part of the solution, we can eliminate them in bulk
48next.never_all(&guess[2..]);
49
50
51// let's try the next round
52let wordle = next.build();
53
54let mut stream = set.search_with_state(wordle.clone()).into_stream();
55let (guess, guess_state) = stream.next().unwrap();
56let guess_state = guess_state.unwrap();
57
58// the next valid guess is 'morty' as 'crush' is eliminated, as is 'solid'
59assert_eq!(guess, b"morty");
60
61// Present the guess to the game for feedback
62let mut next = WordleBuilder::from(wordle, guess_state);
63// 'm' and 'o' are not in 'party'
64next.never_all(&guess[..2]);
65
66// The remaining letters are all in their correct position
67next.correct_pos(2, guess[2]);
68next.correct_pos(3, guess[3]);
69next.correct_pos(4, guess[4]);
70
71// Let's try the final round
72let wordle = next.build();
73let mut stream = set.search_with_state(wordle.clone()).into_stream();
74let (guess, guess_state) = stream.next().unwrap();
75let guess_state = guess_state.unwrap();
76
77// Only 'party' remains as a candidate that fulfills all requirements
78assert_eq!(guess, b"party");
79
80// after asking the game, we can verify that we have arrived at a solution
81let mut solution = WordleBuilder::from(wordle, guess_state);
82solution.correct_pos(0, guess[0]);
83solution.correct_pos(1, guess[1]);
84
85// We don't need to add all the remaining characters, as they are already known to be correct
86let solution = solution.build();
87assert!(solution.is_solved());
88assert_eq!(solution.decode_str(), String::from("party"));
89```
90*/
91
92#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
93#![warn(
94    bad_style,
95    const_err,
96    dead_code,
97    improper_ctypes,
98    missing_copy_implementations,
99    missing_debug_implementations,
100    missing_docs,
101    no_mangle_generic_items,
102    non_shorthand_field_patterns,
103    overflowing_literals,
104    path_statements,
105    patterns_in_fns_without_body,
106    private_in_public,
107    rust_2018_idioms,
108    trivial_casts,
109    trivial_numeric_casts,
110    unconditional_recursion,
111    unsafe_code,
112    unused_allocation,
113    unused_comparisons,
114    unused_crate_dependencies,
115    unused_extern_crates,
116    unused_import_braces,
117    unused_parens,
118    unused_qualifications,
119    unused_results,
120    unused,
121    while_true
122)]
123
124use fst::Automaton;
125use std::num::NonZeroU32;
126
127pub mod types;
128
129use types::{Constraint, Letter, LetterList, LetterSet};
130
131/// An automaton that matches valid guesses
132#[allow(missing_copy_implementations)]
133#[derive(Clone, Debug)]
134pub struct Wordle {
135    never: LetterSet,
136    eventually: LetterList,
137    positions: [Constraint; 5],
138}
139
140const _: () = assert!(
141    std::mem::size_of::<Wordle>() == 28,
142    "Worlde should be 28 bytes"
143);
144
145impl Wordle {
146    /// Create a new automaton without any constraints
147    #[must_use]
148    pub const fn new() -> Self {
149        Self {
150            never: LetterSet::new(),
151            eventually: LetterList::new(),
152            positions: [Constraint::new(); 5],
153        }
154    }
155
156    /// Check if the automaton would only match the full solution
157    #[must_use]
158    pub fn is_solved(&self) -> bool {
159        self.positions.iter().all(|p| !p.is_free())
160    }
161
162    /// Test if the automaton matches the solution for a single position
163    ///
164    /// # Panics
165    ///
166    /// Panics if the `pos` is out of bounds (5..)
167    #[must_use]
168    pub const fn has_solution_at(&self, pos: usize) -> bool {
169        !self.positions[pos].is_free()
170    }
171
172    /// Iterate over all bytes in the full solution
173    ///
174    /// # Panics
175    ///
176    /// Panics if the automaton does not have a solution, i.e. [`Wordle::is_solved()`] must return `true`
177    pub fn decode(self) -> impl Iterator<Item = u8> {
178        let mut ev = LetterList::new();
179        for pos in self.positions {
180            assert!(!pos.is_free(), "Trying to decode an unsolved wordle");
181            ev = ev.add(pos.must_letter());
182        }
183
184        ev.iter().map(u8::from)
185    }
186
187    /// Create a new string from the full solution
188    ///
189    /// # Panics
190    ///
191    /// Panics if the automaton does not have a solution, i.e. [`Wordle::is_solved()`] must return `true`
192    #[must_use]
193    pub fn decode_str(self) -> String {
194        self.decode().map(char::from).collect()
195    }
196}
197
198/// A Builder to create instances of the [`Wordle`] automaton.
199///
200/// # Examples
201///
202/// ```rust
203/// # use wordle_automaton::WordleBuilder;
204/// let wordle = WordleBuilder::new().never_all(b"nope").correct_pos(1, b'f').wrong_pos(2, b'x').build();
205/// ```
206#[derive(Clone, Debug)]
207pub struct WordleBuilder(Wordle);
208
209impl WordleBuilder {
210    /// Create a new builder without any constraints
211    #[must_use]
212    pub const fn new() -> Self {
213        Self(Wordle::new())
214    }
215
216    /// Create a new builder based on existing constraints
217    #[must_use]
218    pub const fn from(mut wordle: Wordle, state: SolveState) -> Self {
219        wordle.eventually = state.eventually;
220        Self(wordle)
221    }
222
223    /// Signal that the given `letter` is never part of any solution on any position
224    ///
225    /// This corresponds to the darkened result is the game
226    pub fn never(&mut self, letter: u8) -> &mut Self {
227        self.0.never = self.0.never.add(Letter::new(letter));
228        self
229    }
230
231    /// Signal that any of the given letters are never part of any solution on any position
232    ///
233    /// This is equivalent to calling [`WordleBuilder::never()`] on every item of the iterator
234    pub fn never_all(&mut self, letters: impl AsRef<[u8]>) -> &mut Self {
235        self.0.never = letters
236            .as_ref()
237            .iter()
238            .copied()
239            .map(Letter::new)
240            .fold(self.0.never, LetterSet::add);
241        self
242    }
243
244    /// Signal that the given letter is correct for the given position
245    ///
246    /// This is equivalent to the green result in the game
247    pub fn correct_pos(&mut self, pos: usize, letter: u8) -> &mut Self {
248        self.0.positions[pos] = self.0.positions[pos].must(Letter::new(letter));
249        self
250    }
251
252    /// Signal that the given letter is in a wrong position but part of the solution
253    ///
254    /// This is equivalent to the yellow result in the game
255    pub fn wrong_pos(&mut self, pos: usize, letter: u8) -> &mut Self {
256        let letter = Letter::new(letter);
257        self.0.eventually = self.0.eventually.add(letter);
258        self.0.positions[pos] = self.0.positions[pos].must_not(letter);
259        self
260    }
261
262    /// Build the final automaton for the added constraints
263    pub fn build(&mut self) -> Wordle {
264        std::mem::replace(&mut self.0, Wordle::new())
265    }
266
267    /// Return the current automaton
268    ///
269    /// The automaton is in a valid state, i.e. can be used to search in an FST
270    #[must_use]
271    pub const fn current(&self) -> &Wordle {
272        &self.0
273    }
274}
275
276/// The state to use during automaton matching
277///
278/// Cannot be used directly, but it is returned from [`fst::Set::search_with_state`] and can be
279/// passed to [`WordleBuilder::from`] to refine a search.
280#[derive(Copy, Clone, Debug)]
281pub struct SolveState {
282    pos: NonZeroU32,
283    eventually: LetterList,
284}
285
286const _: () = assert!(
287    std::mem::size_of::<SolveState>() == 8,
288    "SolveState should be 8 bytes"
289);
290const _: () = assert!(
291    std::mem::size_of::<Option<SolveState>>() == 8,
292    "SolveState should have a niche"
293);
294
295impl Automaton for Wordle {
296    type State = Option<SolveState>;
297
298    fn start(&self) -> Self::State {
299        Some(SolveState {
300            pos: NonZeroU32::new(1).unwrap(),
301            eventually: self.eventually,
302        })
303    }
304
305    fn is_match(&self, state: &Self::State) -> bool {
306        state.map_or(false, |s| s.pos.get() == 6)
307    }
308
309    fn accept(&self, state: &Self::State, byte: u8) -> Self::State {
310        // we are already a non-match
311        let state = (*state)?;
312
313        // cannot match past 5 letters
314        let pos = state.pos.get();
315        if pos == 6 {
316            return None;
317        }
318
319        // byte is invalid, i.e. not [a-zA-Z]
320        let letter = Letter::try_new(byte)?;
321
322        // letter is never a possibility
323        if self.never.contains(letter) {
324            return None;
325        }
326
327        let current_pos = (pos - 1) as usize;
328
329        let slot = self.positions[current_pos];
330        // letter is not possible in the current position
331        if !slot.accept(letter) {
332            return None;
333        }
334
335        // check if the letter is one that we eventually need to add
336        let (removed, eventually) = state.eventually.remove(letter);
337        if !removed {
338            // need to check that we have enough free slots to eventually fill those
339            let need_to_fill = eventually.len();
340            let available = self.positions[current_pos + 1..]
341                .iter()
342                .map(|p| u32::from(p.is_free()))
343                .sum::<u32>();
344
345            // there are not enough slots to eventually fill all undecided ones
346            if available < need_to_fill {
347                return None;
348            }
349        }
350
351        let pos = NonZeroU32::new(pos + 1).expect("Adding one to any value cannot be zero");
352        Some(SolveState { pos, eventually })
353    }
354
355    fn can_match(&self, state: &Self::State) -> bool {
356        state.is_some()
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use fst::{IntoStreamer, Set};
364
365    #[test]
366    fn test_no_conditions() {
367        let wordle = WordleBuilder::new().build();
368
369        test_fst(wordle, ["abcd", "abcde", "abcdef"], &["abcde"]);
370    }
371
372    #[test]
373    fn test_never_accept_a() {
374        let wordle = WordleBuilder::new().never(b'a').build();
375
376        test_fst(wordle, ["abcde", "bcdef", "cdefa"], &["bcdef"]);
377    }
378
379    #[test]
380    fn test_never_accept_abcd() {
381        let wordle = WordleBuilder::new().never_all("abcd").build();
382
383        test_fst(wordle, ["abcde", "cdefg", "efghi"], &["efghi"]);
384    }
385
386    #[test]
387    fn test_require_a_in_third_pos() {
388        let wordle = WordleBuilder::new().correct_pos(2, b'a').build();
389
390        test_fst(
391            wordle,
392            ["abcde", "bacde", "bcade", "bcdae", "bcdea"],
393            &["bcade"],
394        );
395    }
396
397    #[test]
398    fn test_dont_allow_a_in_third_pos() {
399        let wordle = WordleBuilder::new().wrong_pos(2, b'a').build();
400
401        test_fst(
402            wordle,
403            ["abcde", "bacde", "bcade", "bcdae", "bcdea"],
404            &["abcde", "bacde", "bcdae", "bcdea"],
405        );
406    }
407
408    #[test]
409    fn test_use_partially_solved_letter() {
410        let wordle = WordleBuilder::new()
411            .correct_pos(0, b'a')
412            .correct_pos(1, b'b')
413            .correct_pos(2, b'c')
414            // if we disallow d at the fourth pos, we must require it at the last position
415            .wrong_pos(3, b'd')
416            .build();
417
418        test_fst(wordle, ["abcde", "abced", "abcef"], &["abced"]);
419    }
420
421    fn test_fst<'a>(
422        wordle: Wordle,
423        words: impl IntoIterator<Item = &'a str>,
424        expected: &[&'a str],
425    ) {
426        fn inner_test<'a>(
427            wordle: Wordle,
428            words: impl IntoIterator<Item = &'a str>,
429            expected: &[&'a str],
430        ) -> fst::Result<()> {
431            let set = Set::from_iter(words)?;
432            let stream = set.search(wordle).into_stream();
433            let matches = stream.into_strs()?;
434
435            assert_eq!(&matches[..], expected);
436
437            Ok(())
438        }
439
440        inner_test(wordle, words, expected).unwrap();
441    }
442}