Skip to main content

meta_language/grammar/inference/
active.rs

1//! Active regular-language inference from membership and equivalence queries.
2//!
3//! This module implements Angluin's L* observation-table learner for regular
4//! languages. It is intentionally opt-in: positive-only CFG inference does not
5//! depend on this path. Exact equivalence can be supplied by a caller-provided
6//! oracle; when only membership is available, the provided adapters use a
7//! deterministic bounded sampler as an approximate equivalence oracle.
8
9use std::collections::{btree_map::Entry, BTreeMap, BTreeSet};
10use std::error::Error;
11use std::fmt;
12
13use crate::grammar::{Grammar, GrammarExpr, GrammarFormat, GrammarParser, GrammarRule};
14use crate::{LinkNetwork, LinkType, ParseConfiguration, ParserRegistry};
15
16/// Input symbol consumed by the active learner.
17pub type Symbol = char;
18
19type Word = Vec<Symbol>;
20
21/// Predicate used by [`ParserMembershipOracle`] to decide parser acceptance.
22pub type ParserAcceptancePredicate = fn(&LinkNetwork, &str) -> bool;
23
24/// Deterministic finite automaton learned by L*.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct Dfa {
27    /// Input alphabet in transition-column order.
28    pub alphabet: Vec<Symbol>,
29    /// Number of states in the automaton.
30    pub states: usize,
31    /// Start state index.
32    pub start: usize,
33    /// Accepting flag for each state.
34    pub accepting: Vec<bool>,
35    /// Total transition function: `delta[state][symbol_index] = next_state`.
36    pub delta: Vec<Vec<usize>>,
37}
38
39impl Dfa {
40    /// Returns `true` when `word` is accepted by this DFA.
41    #[must_use]
42    pub fn accepts(&self, word: &[Symbol]) -> bool {
43        let mut state = self.start;
44        if state >= self.states {
45            return false;
46        }
47
48        for symbol in word {
49            let Some(symbol_index) = self.symbol_index(*symbol) else {
50                return false;
51            };
52            let Some(next) = self
53                .delta
54                .get(state)
55                .and_then(|row| row.get(symbol_index))
56                .copied()
57            else {
58                return false;
59            };
60            if next >= self.states {
61                return false;
62            }
63            state = next;
64        }
65
66        self.accepting.get(state).copied().unwrap_or(false)
67    }
68
69    /// Convenience helper for character-level text input.
70    #[must_use]
71    pub fn accepts_text(&self, text: &str) -> bool {
72        self.accepts(&text.chars().collect::<Vec<_>>())
73    }
74
75    /// Converts this DFA to a right-linear grammar.
76    #[must_use]
77    pub fn to_grammar(&self) -> Grammar {
78        let mut grammar = Grammar::new().with_source_format(GrammarFormat::Inferred);
79        if self.states == 0 || self.start >= self.states {
80            return grammar;
81        }
82
83        for state in 0..self.states {
84            grammar.add_rule(GrammarRule::new(
85                state_name(state),
86                self.state_expression(state),
87            ));
88        }
89        grammar.set_start(state_name(self.start));
90        grammar
91    }
92
93    fn state_expression(&self, state: usize) -> GrammarExpr {
94        let mut alternatives = Vec::new();
95
96        if self.accepting.get(state).copied().unwrap_or(false) {
97            alternatives.push(GrammarExpr::Empty);
98        }
99
100        if let Some(transitions) = self.delta.get(state) {
101            for (symbol_index, target) in transitions.iter().copied().enumerate() {
102                if target >= self.states {
103                    continue;
104                }
105                let Some(symbol) = self.alphabet.get(symbol_index) else {
106                    continue;
107                };
108                alternatives.push(GrammarExpr::Sequence(vec![
109                    GrammarExpr::Terminal(symbol.to_string()),
110                    GrammarExpr::NonTerminal(state_name(target)),
111                ]));
112            }
113        }
114
115        match alternatives.as_slice() {
116            [only] => only.clone(),
117            _ => GrammarExpr::Choice {
118                ordered: false,
119                alternatives,
120            },
121        }
122    }
123
124    fn symbol_index(&self, symbol: Symbol) -> Option<usize> {
125        self.alphabet
126            .iter()
127            .position(|candidate| *candidate == symbol)
128    }
129
130    fn validate(&self) -> Result<(), ActiveLearningError> {
131        validate_alphabet(&self.alphabet)?;
132        if self.start >= self.states {
133            return Err(ActiveLearningError::InvalidDfa {
134                reason: format!(
135                    "start state {} is outside {} states",
136                    self.start, self.states
137                ),
138            });
139        }
140        if self.accepting.len() != self.states {
141            return Err(ActiveLearningError::InvalidDfa {
142                reason: format!(
143                    "accepting vector has {} entries for {} states",
144                    self.accepting.len(),
145                    self.states
146                ),
147            });
148        }
149        if self.delta.len() != self.states {
150            return Err(ActiveLearningError::InvalidDfa {
151                reason: format!(
152                    "transition table has {} rows for {} states",
153                    self.delta.len(),
154                    self.states
155                ),
156            });
157        }
158        for (state, transitions) in self.delta.iter().enumerate() {
159            if transitions.len() != self.alphabet.len() {
160                return Err(ActiveLearningError::InvalidDfa {
161                    reason: format!(
162                        "state {state} has {} transitions for {} symbols",
163                        transitions.len(),
164                        self.alphabet.len()
165                    ),
166                });
167            }
168            if let Some(target) = transitions.iter().find(|target| **target >= self.states) {
169                return Err(ActiveLearningError::InvalidDfa {
170                    reason: format!("state {state} transitions to invalid state {target}"),
171                });
172            }
173        }
174        Ok(())
175    }
176}
177
178/// Learner and approximate-equivalence configuration.
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
180pub struct ActiveLearningConfig {
181    /// Maximum length for sampled counterexample candidates.
182    pub max_word_len: usize,
183    /// Maximum number of sampled equivalence candidates to compare.
184    pub equivalence_samples: usize,
185    /// Deterministic seed used by the bounded sampler.
186    pub seed: u64,
187    /// Requests the TTT learner. TTT is reserved for a follow-up and currently errors.
188    pub use_ttt: bool,
189    /// Maximum L* refinement rounds before returning an error.
190    pub max_iterations: usize,
191}
192
193impl Default for ActiveLearningConfig {
194    fn default() -> Self {
195        Self {
196            max_word_len: 8,
197            equivalence_samples: 256,
198            seed: 0xA17E_1EAF_DFA5_EED5,
199            use_ttt: false,
200            max_iterations: 128,
201        }
202    }
203}
204
205/// Error returned by active-learning entry points.
206#[derive(Clone, Debug, PartialEq, Eq)]
207pub enum ActiveLearningError {
208    /// The alphabet contains the same symbol more than once.
209    DuplicateSymbol {
210        /// Duplicated alphabet symbol.
211        symbol: Symbol,
212    },
213    /// The requested TTT learner is not shipped in this module yet.
214    TttUnavailable,
215    /// A configuration value prevents the learner from running.
216    InvalidConfig {
217        /// Human-readable reason.
218        reason: String,
219    },
220    /// A malformed DFA was supplied or constructed.
221    InvalidDfa {
222        /// Human-readable reason.
223        reason: String,
224    },
225    /// The L* loop did not converge within the configured refinement budget.
226    MaxIterations {
227        /// Configured iteration budget.
228        max_iterations: usize,
229    },
230}
231
232impl fmt::Display for ActiveLearningError {
233    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
234        match self {
235            Self::DuplicateSymbol { symbol } => {
236                write!(formatter, "alphabet contains duplicate symbol {symbol:?}")
237            }
238            Self::TttUnavailable => formatter.write_str("TTT active learning is not implemented"),
239            Self::InvalidConfig { reason } | Self::InvalidDfa { reason } => {
240                formatter.write_str(reason)
241            }
242            Self::MaxIterations { max_iterations } => write!(
243                formatter,
244                "active learner did not converge within {max_iterations} iterations"
245            ),
246        }
247    }
248}
249
250impl Error for ActiveLearningError {}
251
252/// Minimally adequate teacher for active regular-language learning.
253pub trait Oracle {
254    /// Input alphabet explored by the learner.
255    fn alphabet(&self) -> &[Symbol];
256
257    /// Returns `true` when `word` is in the target language.
258    fn membership(&self, word: &[Symbol]) -> bool;
259
260    /// Returns a counterexample when `hypothesis` disagrees with the target.
261    ///
262    /// Returning `None` accepts the hypothesis as equivalent. Exact oracles can
263    /// ignore `config`; approximate oracles use it for bounded deterministic
264    /// sampling.
265    fn equivalence(&self, hypothesis: &Dfa, config: &ActiveLearningConfig) -> Option<Word>;
266}
267
268/// Membership-plus-sampling oracle backed by an in-process predicate.
269#[derive(Clone, Debug)]
270pub struct SamplingEquivalenceOracle<M> {
271    alphabet: Vec<Symbol>,
272    membership: M,
273}
274
275impl<M> SamplingEquivalenceOracle<M> {
276    /// Builds a sampling oracle over `alphabet`.
277    #[must_use]
278    pub const fn new(alphabet: Vec<Symbol>, membership: M) -> Self {
279        Self {
280            alphabet,
281            membership,
282        }
283    }
284}
285
286impl<M> Oracle for SamplingEquivalenceOracle<M>
287where
288    M: Fn(&[Symbol]) -> bool,
289{
290    fn alphabet(&self) -> &[Symbol] {
291        &self.alphabet
292    }
293
294    fn membership(&self, word: &[Symbol]) -> bool {
295        (self.membership)(word)
296    }
297
298    fn equivalence(&self, hypothesis: &Dfa, config: &ActiveLearningConfig) -> Option<Word> {
299        sampled_counterexample(hypothesis, &self.alphabet, config, |word| {
300            self.membership(word)
301        })
302    }
303}
304
305/// Active-learning oracle backed by a runtime [`GrammarParser`].
306#[derive(Clone, Debug)]
307pub struct GrammarAcceptorOracle {
308    parser: GrammarParser,
309    alphabet: Vec<Symbol>,
310}
311
312impl GrammarAcceptorOracle {
313    /// Builds an oracle from a first-class grammar acceptor.
314    #[must_use]
315    pub fn new(grammar: Grammar, alphabet: Vec<Symbol>) -> Self {
316        Self {
317            parser: GrammarParser::new(grammar),
318            alphabet,
319        }
320    }
321}
322
323impl Oracle for GrammarAcceptorOracle {
324    fn alphabet(&self) -> &[Symbol] {
325        &self.alphabet
326    }
327
328    fn membership(&self, word: &[Symbol]) -> bool {
329        self.parser.accepts(&word_text(word))
330    }
331
332    fn equivalence(&self, hypothesis: &Dfa, config: &ActiveLearningConfig) -> Option<Word> {
333        sampled_counterexample(hypothesis, &self.alphabet, config, |word| {
334            self.membership(word)
335        })
336    }
337}
338
339/// Active-learning oracle backed by a [`ParserRegistry`] parser.
340#[derive(Clone, Debug)]
341pub struct ParserMembershipOracle {
342    registry: ParserRegistry,
343    language: String,
344    alphabet: Vec<Symbol>,
345    configuration: ParseConfiguration,
346    acceptance: ParserAcceptancePredicate,
347}
348
349impl ParserMembershipOracle {
350    /// Builds a parser-backed oracle with the default clean-structural predicate.
351    #[must_use]
352    pub fn new(
353        registry: ParserRegistry,
354        language: impl Into<String>,
355        alphabet: Vec<Symbol>,
356    ) -> Self {
357        Self {
358            registry,
359            language: language.into(),
360            alphabet,
361            configuration: ParseConfiguration::default(),
362            acceptance: clean_structural_acceptance,
363        }
364    }
365
366    /// Returns this oracle with a different parse configuration.
367    #[must_use]
368    pub const fn with_configuration(mut self, configuration: ParseConfiguration) -> Self {
369        self.configuration = configuration;
370        self
371    }
372
373    /// Returns this oracle with a custom acceptance predicate over parser output.
374    #[must_use]
375    pub const fn with_acceptance_predicate(
376        mut self,
377        acceptance: ParserAcceptancePredicate,
378    ) -> Self {
379        self.acceptance = acceptance;
380        self
381    }
382}
383
384impl Oracle for ParserMembershipOracle {
385    fn alphabet(&self) -> &[Symbol] {
386        &self.alphabet
387    }
388
389    fn membership(&self, word: &[Symbol]) -> bool {
390        let text = word_text(word);
391        let network = self
392            .registry
393            .parse(&text, &self.language, self.configuration);
394        (self.acceptance)(&network, &text)
395    }
396
397    fn equivalence(&self, hypothesis: &Dfa, config: &ActiveLearningConfig) -> Option<Word> {
398        sampled_counterexample(hypothesis, &self.alphabet, config, |word| {
399            self.membership(word)
400        })
401    }
402}
403
404/// Default parser acceptance predicate.
405///
406/// A parser accepts when it reconstructs the queried text, has no error or
407/// missing links, and emits at least one source-spanned structural parser link
408/// (`Grammar` or `Syntax`). The spanned structural-link check distinguishes
409/// successful registered parsers from lossless fallback tokenization and from
410/// unspanned self-description metadata.
411#[must_use]
412pub fn clean_structural_acceptance(network: &LinkNetwork, text: &str) -> bool {
413    network.reconstruct_text() == text
414        && network.verify_full_match(None).is_clean()
415        && network.links().any(|link| {
416            link.metadata().span().is_some()
417                && matches!(
418                    link.metadata().link_type(),
419                    Some(LinkType::Grammar | LinkType::Syntax)
420                )
421        })
422}
423
424/// Learns a DFA via L* against `oracle`.
425pub fn learn_dfa(
426    oracle: &dyn Oracle,
427    config: &ActiveLearningConfig,
428) -> Result<Dfa, ActiveLearningError> {
429    validate_config(config)?;
430    validate_alphabet(oracle.alphabet())?;
431    if config.use_ttt {
432        return Err(ActiveLearningError::TttUnavailable);
433    }
434
435    let mut table = ObservationTable::new(oracle);
436    for _ in 0..config.max_iterations {
437        table.close_and_consistent();
438        let hypothesis = table.hypothesis()?;
439        hypothesis.validate()?;
440
441        if let Some(counterexample) = oracle.equivalence(&hypothesis, config) {
442            table.add_counterexample(&counterexample);
443        } else {
444            return Ok(hypothesis);
445        }
446    }
447
448    Err(ActiveLearningError::MaxIterations {
449        max_iterations: config.max_iterations,
450    })
451}
452
453/// Learns a DFA via L* and lowers it to a right-linear grammar.
454pub fn learn_grammar(
455    oracle: &dyn Oracle,
456    config: &ActiveLearningConfig,
457) -> Result<Grammar, ActiveLearningError> {
458    learn_dfa(oracle, config).map(|dfa| dfa.to_grammar())
459}
460
461struct ObservationTable<'oracle> {
462    oracle: &'oracle dyn Oracle,
463    alphabet: Vec<Symbol>,
464    prefixes: Vec<Word>,
465    suffixes: Vec<Word>,
466    table: BTreeMap<(Word, Word), bool>,
467}
468
469impl<'oracle> ObservationTable<'oracle> {
470    fn new(oracle: &'oracle dyn Oracle) -> Self {
471        Self {
472            oracle,
473            alphabet: oracle.alphabet().to_vec(),
474            prefixes: vec![Vec::new()],
475            suffixes: vec![Vec::new()],
476            table: BTreeMap::new(),
477        }
478    }
479
480    fn close_and_consistent(&mut self) {
481        loop {
482            self.fill();
483            if let Some(prefix) = self.unclosed_prefix() {
484                self.add_prefix_closure(&prefix);
485                continue;
486            }
487            if let Some(suffix) = self.inconsistent_suffix() {
488                self.add_suffix_closure(&suffix);
489                continue;
490            }
491            break;
492        }
493    }
494
495    fn hypothesis(&mut self) -> Result<Dfa, ActiveLearningError> {
496        self.fill();
497
498        let prefixes = self.prefixes.clone();
499        let mut signature_states = BTreeMap::new();
500        let mut representatives = Vec::new();
501        for prefix in prefixes {
502            let signature = self.row(&prefix);
503            if let Entry::Vacant(entry) = signature_states.entry(signature) {
504                let state = representatives.len();
505                entry.insert(state);
506                representatives.push(prefix);
507            }
508        }
509
510        let states = representatives.len();
511        let start_signature = self.row(&[]);
512        let start = signature_states
513            .get(&start_signature)
514            .copied()
515            .ok_or_else(|| ActiveLearningError::InvalidDfa {
516                reason: "observation table has no start row".to_string(),
517            })?;
518
519        let mut accepting = vec![false; states];
520        let mut delta = vec![vec![0; self.alphabet.len()]; states];
521        for (state, representative) in representatives.iter().enumerate() {
522            accepting[state] = self.value(representative, &[]);
523            for (symbol_index, symbol) in self.alphabet.clone().into_iter().enumerate() {
524                let successor = extend(representative, symbol);
525                let signature = self.row(&successor);
526                let target = signature_states.get(&signature).copied().ok_or_else(|| {
527                    ActiveLearningError::InvalidDfa {
528                        reason: "closed table did not contain a successor row".to_string(),
529                    }
530                })?;
531                delta[state][symbol_index] = target;
532            }
533        }
534
535        Ok(Dfa {
536            alphabet: self.alphabet.clone(),
537            states,
538            start,
539            accepting,
540            delta,
541        })
542    }
543
544    fn fill(&mut self) {
545        let mut rows = self.prefixes.clone();
546        rows.extend(self.lower_prefixes());
547        let suffixes = self.suffixes.clone();
548        for row in rows {
549            for suffix in &suffixes {
550                self.value(&row, suffix);
551            }
552        }
553    }
554
555    fn unclosed_prefix(&mut self) -> Option<Word> {
556        let upper_signatures = self
557            .prefixes
558            .clone()
559            .into_iter()
560            .map(|prefix| self.row(&prefix))
561            .collect::<BTreeSet<_>>();
562
563        self.lower_prefixes()
564            .into_iter()
565            .find(|prefix| !upper_signatures.contains(&self.row(prefix)))
566    }
567
568    fn inconsistent_suffix(&mut self) -> Option<Word> {
569        let prefixes = self.prefixes.clone();
570        for left_index in 0..prefixes.len() {
571            for right_index in (left_index + 1)..prefixes.len() {
572                let left = &prefixes[left_index];
573                let right = &prefixes[right_index];
574                if self.row(left) != self.row(right) {
575                    continue;
576                }
577                for symbol in self.alphabet.clone() {
578                    let left_successor = extend(left, symbol);
579                    let right_successor = extend(right, symbol);
580                    if self.row(&left_successor) == self.row(&right_successor) {
581                        continue;
582                    }
583                    for suffix in self.suffixes.clone() {
584                        if self.value(&left_successor, &suffix)
585                            != self.value(&right_successor, &suffix)
586                        {
587                            let mut distinguishing = vec![symbol];
588                            distinguishing.extend(suffix);
589                            return Some(distinguishing);
590                        }
591                    }
592                }
593            }
594        }
595        None
596    }
597
598    fn lower_prefixes(&self) -> Vec<Word> {
599        let known = self.prefixes.iter().cloned().collect::<BTreeSet<_>>();
600        let mut lower = Vec::new();
601        for prefix in &self.prefixes {
602            for symbol in &self.alphabet {
603                let word = extend(prefix, *symbol);
604                if !known.contains(&word) {
605                    lower.push(word);
606                }
607            }
608        }
609        lower
610    }
611
612    fn row(&mut self, prefix: &[Symbol]) -> Vec<bool> {
613        self.suffixes
614            .clone()
615            .into_iter()
616            .map(|suffix| self.value(prefix, &suffix))
617            .collect()
618    }
619
620    fn value(&mut self, prefix: &[Symbol], suffix: &[Symbol]) -> bool {
621        let key = (prefix.to_vec(), suffix.to_vec());
622        if let Some(value) = self.table.get(&key) {
623            return *value;
624        }
625
626        let mut word = prefix.to_vec();
627        word.extend(suffix);
628        let value = self.oracle.membership(&word);
629        self.table.insert(key, value);
630        value
631    }
632
633    fn add_counterexample(&mut self, word: &[Symbol]) {
634        for length in 0..=word.len() {
635            self.add_prefix(&word[..length]);
636        }
637    }
638
639    fn add_prefix_closure(&mut self, word: &[Symbol]) {
640        for length in 0..=word.len() {
641            self.add_prefix(&word[..length]);
642        }
643    }
644
645    fn add_prefix(&mut self, word: &[Symbol]) {
646        if !self.prefixes.iter().any(|prefix| prefix == word) {
647            self.prefixes.push(word.to_vec());
648        }
649    }
650
651    fn add_suffix_closure(&mut self, suffix: &[Symbol]) {
652        for start in 0..=suffix.len() {
653            self.add_suffix(&suffix[start..]);
654        }
655    }
656
657    fn add_suffix(&mut self, suffix: &[Symbol]) {
658        if !self.suffixes.iter().any(|candidate| candidate == suffix) {
659            self.suffixes.push(suffix.to_vec());
660        }
661    }
662}
663
664fn sampled_counterexample<F>(
665    hypothesis: &Dfa,
666    alphabet: &[Symbol],
667    config: &ActiveLearningConfig,
668    membership: F,
669) -> Option<Word>
670where
671    F: Fn(&[Symbol]) -> bool,
672{
673    sample_words(
674        alphabet,
675        config.max_word_len,
676        config.equivalence_samples,
677        config.seed,
678    )
679    .into_iter()
680    .find(|word| hypothesis.accepts(word) != membership(word))
681}
682
683fn sample_words(
684    alphabet: &[Symbol],
685    max_word_len: usize,
686    equivalence_samples: usize,
687    seed: u64,
688) -> Vec<Word> {
689    if equivalence_samples == 0 {
690        return Vec::new();
691    }
692
693    let mut words = Vec::with_capacity(equivalence_samples);
694    push_unique(&mut words, Vec::new(), equivalence_samples);
695
696    for length in 1..=max_word_len {
697        for symbol in alphabet {
698            push_unique(&mut words, vec![*symbol; length], equivalence_samples);
699        }
700    }
701
702    if alphabet.is_empty() || words.len() >= equivalence_samples {
703        words.truncate(equivalence_samples);
704        return words;
705    }
706
707    let mut rng = SplitMix64::new(seed);
708    let random_target = equivalence_samples.saturating_sub(words.len()) / 2;
709    let mut random_added = 0usize;
710    let mut attempts = 0usize;
711    while random_added < random_target && attempts < equivalence_samples.saturating_mul(32) {
712        attempts += 1;
713        let length = rng.next_usize(max_word_len.saturating_add(1));
714        let mut word = Vec::with_capacity(length);
715        for _ in 0..length {
716            word.push(alphabet[rng.next_usize(alphabet.len())]);
717        }
718        let before = words.len();
719        push_unique(&mut words, word, equivalence_samples);
720        if words.len() > before {
721            random_added += 1;
722        }
723    }
724
725    fill_exhaustive(alphabet, max_word_len, equivalence_samples, &mut words);
726    words.truncate(equivalence_samples);
727    words
728}
729
730fn fill_exhaustive(alphabet: &[Symbol], max_word_len: usize, limit: usize, words: &mut Vec<Word>) {
731    let mut current = vec![Vec::new()];
732    for _ in 0..max_word_len {
733        let mut next = Vec::new();
734        for prefix in &current {
735            for symbol in alphabet {
736                let word = extend(prefix, *symbol);
737                push_unique(words, word.clone(), limit);
738                next.push(word);
739                if words.len() >= limit {
740                    return;
741                }
742            }
743        }
744        current = next;
745    }
746}
747
748fn push_unique(words: &mut Vec<Word>, word: Word, limit: usize) {
749    if words.len() < limit && !words.contains(&word) {
750        words.push(word);
751    }
752}
753
754fn validate_config(config: &ActiveLearningConfig) -> Result<(), ActiveLearningError> {
755    if config.max_iterations == 0 {
756        return Err(ActiveLearningError::InvalidConfig {
757            reason: "max_iterations must be greater than zero".to_string(),
758        });
759    }
760    Ok(())
761}
762
763fn validate_alphabet(alphabet: &[Symbol]) -> Result<(), ActiveLearningError> {
764    let mut seen = BTreeSet::new();
765    for symbol in alphabet {
766        if !seen.insert(*symbol) {
767            return Err(ActiveLearningError::DuplicateSymbol { symbol: *symbol });
768        }
769    }
770    Ok(())
771}
772
773fn extend(prefix: &[Symbol], symbol: Symbol) -> Word {
774    let mut word = prefix.to_vec();
775    word.push(symbol);
776    word
777}
778
779fn word_text(word: &[Symbol]) -> String {
780    word.iter().collect()
781}
782
783fn state_name(state: usize) -> String {
784    format!("q{state}")
785}
786
787#[derive(Clone, Copy, Debug)]
788struct SplitMix64 {
789    state: u64,
790}
791
792impl SplitMix64 {
793    const fn new(seed: u64) -> Self {
794        Self { state: seed }
795    }
796
797    fn next_u64(&mut self) -> u64 {
798        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
799        let mut value = self.state;
800        value = (value ^ (value >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
801        value = (value ^ (value >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
802        value ^ (value >> 31)
803    }
804
805    fn next_usize(&mut self, upper: usize) -> usize {
806        if upper == 0 {
807            return 0;
808        }
809        let upper = u64::try_from(upper).unwrap_or(u64::MAX);
810        usize::try_from(self.next_u64() % upper).unwrap_or(0)
811    }
812}