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}