Skip to main content

meta_language/grammar/inference/
state_merging.rs

1//! State-merging regular inference for labelled symbol examples.
2//!
3//! The implementation follows the classic PTA/APTA plus red-blue state-merging
4//! shape used by RPNI, EDSM, and ALERGIA. It is a clean-room Rust
5//! implementation derived from the public issue specification and the cited
6//! papers/permissive references named there; no GPL or LGPL implementation is
7//! linked, vendored, or consulted by this crate.
8
9use std::collections::{BTreeMap, BTreeSet, VecDeque};
10
11use crate::grammar::{Grammar, GrammarExpr, GrammarFormat, GrammarRule};
12use crate::semantics::ProbabilisticTruthValue;
13
14/// A token or character symbol consumed by the regular learner.
15pub type Symbol = String;
16
17/// Labelled examples over a token or character alphabet.
18#[derive(Clone, Debug, Default, PartialEq, Eq)]
19pub struct Sample {
20    /// Strings known to be in the target language.
21    pub positives: Vec<Vec<Symbol>>,
22    /// Strings known not to be in the target language.
23    pub negatives: Vec<Vec<Symbol>>,
24}
25
26impl Sample {
27    /// Builds a labelled sample from positive and negative symbol strings.
28    #[must_use]
29    pub const fn new(positives: Vec<Vec<Symbol>>, negatives: Vec<Vec<Symbol>>) -> Self {
30        Self {
31            positives,
32            negatives,
33        }
34    }
35
36    /// Builds a positive-only sample.
37    #[must_use]
38    pub const fn positive_only(positives: Vec<Vec<Symbol>>) -> Self {
39        Self {
40            positives,
41            negatives: Vec::new(),
42        }
43    }
44}
45
46/// One state in an augmented prefix-tree acceptor.
47#[derive(Clone, Debug, Default, PartialEq, Eq)]
48pub struct AptaState {
49    /// Whether at least one positive example ends in this state.
50    pub accepting: bool,
51    /// Whether at least one negative example ends in this state.
52    pub rejecting: bool,
53    /// Number of positive examples whose path visits this state.
54    pub arrival_count: u64,
55    /// Number of positive examples ending in this state.
56    pub final_count: u64,
57}
58
59/// Augmented prefix-tree acceptor built directly from a [`Sample`].
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct Apta {
62    /// APTA states. State `0` is always the root.
63    pub states: Vec<AptaState>,
64    /// Deterministic outgoing transitions for each state.
65    pub transitions: Vec<BTreeMap<Symbol, usize>>,
66    /// Positive-example edge frequencies for stochastic ALERGIA output.
67    pub transition_counts: Vec<BTreeMap<Symbol, u64>>,
68}
69
70impl Apta {
71    /// Builds an APTA from `sample`, sharing all common prefixes.
72    #[must_use]
73    pub fn from_sample(sample: &Sample) -> Self {
74        let mut apta = Self::new();
75
76        for positive in &sample.positives {
77            apta.insert(positive, true);
78        }
79        for negative in &sample.negatives {
80            apta.insert(negative, false);
81        }
82
83        apta
84    }
85
86    fn new() -> Self {
87        Self {
88            states: vec![AptaState::default()],
89            transitions: vec![BTreeMap::new()],
90            transition_counts: vec![BTreeMap::new()],
91        }
92    }
93
94    fn insert(&mut self, symbols: &[Symbol], positive: bool) {
95        let mut state = 0usize;
96        if positive {
97            self.states[state].arrival_count = self.states[state].arrival_count.saturating_add(1);
98        }
99
100        for symbol in symbols {
101            let next = if let Some(next) = self.transitions[state].get(symbol).copied() {
102                next
103            } else {
104                let next = self.states.len();
105                self.states.push(AptaState::default());
106                self.transitions.push(BTreeMap::new());
107                self.transition_counts.push(BTreeMap::new());
108                self.transitions[state].insert(symbol.clone(), next);
109                next
110            };
111
112            if positive {
113                *self.transition_counts[state]
114                    .entry(symbol.clone())
115                    .or_default() += 1;
116                self.states[next].arrival_count = self.states[next].arrival_count.saturating_add(1);
117            }
118            state = next;
119        }
120
121        if positive {
122            self.states[state].accepting = true;
123            self.states[state].final_count = self.states[state].final_count.saturating_add(1);
124        } else {
125            self.states[state].rejecting = true;
126        }
127    }
128}
129
130/// State-merging strategy used by [`infer_dfa`].
131#[derive(Clone, Copy, Debug, PartialEq)]
132pub enum MergeStrategy {
133    /// Regular Positive and Negative Inference: first consistent red-blue merge.
134    Rpni,
135    /// Evidence-driven state merging: highest-scoring consistent red-blue merge.
136    Edsm,
137    /// Positive-only stochastic merging with an ALERGIA-style Hoeffding test.
138    ///
139    /// `alpha` is a compatibility confidence in `(0, 1)`. Lower values are
140    /// stricter in this crate's API and therefore permit fewer merges.
141    Alergia {
142        /// Compatibility confidence controlling the Hoeffding bound.
143        alpha: f64,
144    },
145}
146
147/// Public state in an inferred partial DFA.
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct InferredState {
150    /// Whether this state accepts when input ends here.
151    pub accepting: bool,
152    /// Whether this state was backed by negative evidence during inference.
153    pub rejecting: bool,
154    /// Number of positive examples whose path visits this state.
155    pub arrival_count: u64,
156    /// Number of positive examples ending in this state.
157    pub final_count: u64,
158}
159
160/// One accepted state merge performed by a learner.
161#[derive(Clone, Debug, PartialEq, Eq)]
162pub struct MergeEvent {
163    /// Target red state receiving the source state.
164    pub target: usize,
165    /// Source blue state merged into the target.
166    pub source: usize,
167    /// Overlapping accept/reject evidence observed during recursive folding.
168    pub evidence: usize,
169}
170
171/// Inferred deterministic finite automaton.
172///
173/// The automaton is partial: absent transitions reject. States that cannot reach
174/// an accepting state are pruned after learning, while their negative labels are
175/// still used internally to reject inconsistent merges.
176#[derive(Clone, Debug, PartialEq, Eq)]
177pub struct InferredAutomaton {
178    /// Productive DFA states. State `0` is the initial state.
179    pub states: Vec<InferredState>,
180    /// Deterministic transitions for each state.
181    pub transitions: Vec<BTreeMap<Symbol, usize>>,
182    /// Final-state probabilities for stochastic ALERGIA output.
183    pub final_probabilities: Vec<Option<ProbabilisticTruthValue>>,
184    /// Transition probabilities for stochastic ALERGIA output.
185    pub transition_probabilities: Vec<BTreeMap<Symbol, ProbabilisticTruthValue>>,
186    /// Accepted merge history in deterministic order.
187    pub merge_history: Vec<MergeEvent>,
188}
189
190impl InferredAutomaton {
191    /// Returns `true` when `input` is accepted by the inferred automaton.
192    #[must_use]
193    pub fn accepts(&self, input: &[Symbol]) -> bool {
194        let mut state = 0usize;
195
196        for symbol in input {
197            let Some(next) = self
198                .transitions
199                .get(state)
200                .and_then(|transitions| transitions.get(symbol))
201            else {
202                return false;
203            };
204            state = *next;
205        }
206
207        self.states
208            .get(state)
209            .is_some_and(|state| state.accepting && !state.rejecting)
210    }
211
212    /// Convenience helper for character-level automata.
213    #[must_use]
214    pub fn accepts_text(&self, text: &str) -> bool {
215        let symbols = text
216            .chars()
217            .map(|value| value.to_string())
218            .collect::<Vec<_>>();
219        self.accepts(&symbols)
220    }
221
222    /// Converts the automaton into a right-linear grammar.
223    #[must_use]
224    pub fn to_grammar(&self) -> Grammar {
225        let mut grammar = Grammar::new().with_source_format(GrammarFormat::Inferred);
226        if self.states.is_empty() {
227            return grammar;
228        }
229
230        for state in 0..self.states.len() {
231            grammar.add_rule(GrammarRule::new(
232                state_name(state),
233                self.state_expression(state),
234            ));
235        }
236        grammar.set_start(state_name(0));
237        grammar
238    }
239
240    fn state_expression(&self, state: usize) -> GrammarExpr {
241        let mut alternatives = Vec::new();
242
243        if self.states[state].accepting && !self.states[state].rejecting {
244            alternatives.push(GrammarExpr::Empty);
245        }
246
247        for (symbol, target) in &self.transitions[state] {
248            alternatives.push(GrammarExpr::Sequence(vec![
249                GrammarExpr::Terminal(symbol.clone()),
250                GrammarExpr::NonTerminal(state_name(*target)),
251            ]));
252        }
253
254        match alternatives.as_slice() {
255            [only] => only.clone(),
256            _ => GrammarExpr::Choice {
257                ordered: false,
258                alternatives,
259            },
260        }
261    }
262}
263
264/// Infers a regular automaton from labelled examples.
265#[must_use]
266pub fn infer_dfa(sample: &Sample, strategy: MergeStrategy) -> InferredAutomaton {
267    let apta = Apta::from_sample(sample);
268    let mut machine = WorkAutomaton::from_apta(&apta);
269    let mut merge_history = Vec::new();
270    let mut red = BTreeSet::from([0usize]);
271
272    loop {
273        red = normalised_red(&machine, &red);
274        let blue = blue_states(&machine, &red);
275        if blue.is_empty() {
276            break;
277        }
278
279        match strategy {
280            MergeStrategy::Rpni => rpni_step(&mut machine, &mut red, &blue, &mut merge_history),
281            MergeStrategy::Edsm => edsm_step(&mut machine, &mut red, &blue, &mut merge_history),
282            MergeStrategy::Alergia { alpha } => {
283                alergia_step(&mut machine, &mut red, &blue, alpha, &mut merge_history);
284            }
285        }
286    }
287
288    machine.into_inferred(
289        matches!(strategy, MergeStrategy::Alergia { .. }),
290        merge_history,
291    )
292}
293
294#[derive(Clone, Debug, PartialEq, Eq)]
295struct WorkState {
296    accepting: bool,
297    rejecting: bool,
298    arrival_count: u64,
299    final_count: u64,
300    rank: usize,
301    active: bool,
302}
303
304#[derive(Clone, Debug, PartialEq, Eq)]
305struct WorkAutomaton {
306    states: Vec<WorkState>,
307    transitions: Vec<BTreeMap<Symbol, usize>>,
308    transition_counts: Vec<BTreeMap<Symbol, u64>>,
309    parent: Vec<usize>,
310}
311
312impl WorkAutomaton {
313    fn from_apta(apta: &Apta) -> Self {
314        let ranks = canonical_ranks(apta);
315        Self {
316            states: apta
317                .states
318                .iter()
319                .enumerate()
320                .map(|(index, state)| WorkState {
321                    accepting: state.accepting,
322                    rejecting: state.rejecting,
323                    arrival_count: state.arrival_count,
324                    final_count: state.final_count,
325                    rank: ranks[index],
326                    active: true,
327                })
328                .collect(),
329            transitions: apta.transitions.clone(),
330            transition_counts: apta.transition_counts.clone(),
331            parent: (0..apta.states.len()).collect(),
332        }
333    }
334
335    fn representative(&self, mut state: usize) -> usize {
336        while self.parent[state] != state {
337            state = self.parent[state];
338        }
339        state
340    }
341
342    fn active_representative(&self, state: usize) -> Option<usize> {
343        let representative = self.representative(state);
344        self.states
345            .get(representative)
346            .filter(|state| state.active)
347            .map(|_| representative)
348    }
349
350    fn active_sorted(&self) -> Vec<usize> {
351        let mut states = self
352            .states
353            .iter()
354            .enumerate()
355            .filter_map(|(index, state)| state.active.then_some(index))
356            .collect::<Vec<_>>();
357        states.sort_by_key(|state| (self.states[*state].rank, *state));
358        states
359    }
360
361    fn red_sorted(&self, red: &BTreeSet<usize>) -> Vec<usize> {
362        let mut states = red
363            .iter()
364            .filter_map(|state| self.active_representative(*state))
365            .collect::<BTreeSet<_>>()
366            .into_iter()
367            .collect::<Vec<_>>();
368        states.sort_by_key(|state| (self.states[*state].rank, *state));
369        states
370    }
371
372    fn try_merge(&self, target: usize, source: usize, alpha: Option<f64>) -> Option<MergeAttempt> {
373        if target == source {
374            return None;
375        }
376
377        if let Some(alpha) = alpha {
378            let mut seen = BTreeSet::new();
379            if !self.alergia_compatible(target, source, alpha, &mut seen) {
380                return None;
381            }
382        }
383
384        let mut candidate = self.clone();
385        let mut evidence = 0usize;
386        if !candidate.merge_into(target, source, &mut evidence) {
387            return None;
388        }
389        candidate.normalise_transitions();
390
391        Some(MergeAttempt {
392            machine: candidate,
393            event: MergeEvent {
394                target,
395                source,
396                evidence,
397            },
398        })
399    }
400
401    fn merge_into(&mut self, target: usize, source: usize, evidence: &mut usize) -> bool {
402        let target = self.representative(target);
403        let source = self.representative(source);
404        if target == source {
405            return true;
406        }
407        if !self.states[target].active || !self.states[source].active {
408            return false;
409        }
410
411        if (self.states[target].accepting && self.states[source].rejecting)
412            || (self.states[target].rejecting && self.states[source].accepting)
413        {
414            return false;
415        }
416        if self.states[target].accepting && self.states[source].accepting {
417            *evidence = evidence.saturating_add(1);
418        }
419        if self.states[target].rejecting && self.states[source].rejecting {
420            *evidence = evidence.saturating_add(1);
421        }
422
423        self.states[target].accepting |= self.states[source].accepting;
424        self.states[target].rejecting |= self.states[source].rejecting;
425        self.states[target].arrival_count = self.states[target]
426            .arrival_count
427            .saturating_add(self.states[source].arrival_count);
428        self.states[target].final_count = self.states[target]
429            .final_count
430            .saturating_add(self.states[source].final_count);
431
432        let source_transitions = self.transitions[source].clone();
433        let source_counts = self.transition_counts[source].clone();
434        self.states[source].active = false;
435        self.parent[source] = target;
436
437        for (symbol, source_next) in source_transitions {
438            let source_next = self.representative(source_next);
439            let source_count = source_counts.get(&symbol).copied().unwrap_or_default();
440
441            if let Some(target_next) = self.transitions[target].get(&symbol).copied() {
442                let target_next = self.representative(target_next);
443                *self.transition_counts[target]
444                    .entry(symbol.clone())
445                    .or_default() += source_count;
446
447                if target_next != source_next
448                    && !self.merge_into(target_next, source_next, evidence)
449                {
450                    return false;
451                }
452                let target_next = self.representative(target_next);
453                self.transitions[target].insert(symbol, target_next);
454            } else {
455                self.transitions[target].insert(symbol.clone(), source_next);
456                self.transition_counts[target].insert(symbol, source_count);
457            }
458        }
459
460        self.transitions[source].clear();
461        self.transition_counts[source].clear();
462        true
463    }
464
465    fn normalise_transitions(&mut self) {
466        for state in 0..self.states.len() {
467            if !self.states[state].active {
468                continue;
469            }
470            let transitions = self.transitions[state].clone();
471            self.transitions[state].clear();
472            for (symbol, target) in transitions {
473                let target = self.representative(target);
474                self.transitions[state].insert(symbol, target);
475            }
476        }
477    }
478
479    fn alergia_compatible(
480        &self,
481        left: usize,
482        right: usize,
483        alpha: f64,
484        seen: &mut BTreeSet<(usize, usize)>,
485    ) -> bool {
486        let left = self.representative(left);
487        let right = self.representative(right);
488        if left == right {
489            return true;
490        }
491
492        let key = if left < right {
493            (left, right)
494        } else {
495            (right, left)
496        };
497        if !seen.insert(key) {
498            return true;
499        }
500
501        let left_state = &self.states[left];
502        let right_state = &self.states[right];
503        if (left_state.accepting && right_state.rejecting)
504            || (left_state.rejecting && right_state.accepting)
505        {
506            return false;
507        }
508
509        if !proportions_compatible(
510            left_state.final_count,
511            left_state.arrival_count,
512            right_state.final_count,
513            right_state.arrival_count,
514            alpha,
515        ) {
516            return false;
517        }
518
519        for symbol in self.outgoing_symbols(left, right) {
520            let left_count = self.transition_counts[left]
521                .get(&symbol)
522                .copied()
523                .unwrap_or_default();
524            let right_count = self.transition_counts[right]
525                .get(&symbol)
526                .copied()
527                .unwrap_or_default();
528
529            if !proportions_compatible(
530                left_count,
531                left_state.arrival_count,
532                right_count,
533                right_state.arrival_count,
534                alpha,
535            ) {
536                return false;
537            }
538
539            if let (Some(left_target), Some(right_target)) = (
540                self.transitions[left].get(&symbol).copied(),
541                self.transitions[right].get(&symbol).copied(),
542            ) && !self.alergia_compatible(left_target, right_target, alpha, seen)
543            {
544                return false;
545            }
546        }
547
548        true
549    }
550
551    fn outgoing_symbols(&self, left: usize, right: usize) -> BTreeSet<Symbol> {
552        self.transitions[left]
553            .keys()
554            .chain(self.transitions[right].keys())
555            .chain(self.transition_counts[left].keys())
556            .chain(self.transition_counts[right].keys())
557            .cloned()
558            .collect()
559    }
560
561    fn productive_states(&self) -> BTreeSet<usize> {
562        let mut reverse = vec![Vec::<usize>::new(); self.states.len()];
563        for state in self.active_sorted() {
564            for target in self.transitions[state].values() {
565                let target = self.representative(*target);
566                if self.states[target].active {
567                    reverse[target].push(state);
568                }
569            }
570        }
571
572        let mut productive = BTreeSet::new();
573        let mut queue = self
574            .active_sorted()
575            .into_iter()
576            .filter(|state| self.states[*state].accepting && !self.states[*state].rejecting)
577            .collect::<VecDeque<_>>();
578
579        while let Some(state) = queue.pop_front() {
580            if !productive.insert(state) {
581                continue;
582            }
583            for predecessor in &reverse[state] {
584                queue.push_back(*predecessor);
585            }
586        }
587
588        productive
589    }
590
591    fn into_inferred(
592        self,
593        include_probabilities: bool,
594        merge_history: Vec<MergeEvent>,
595    ) -> InferredAutomaton {
596        let productive = self.productive_states();
597        let mut active = self
598            .active_sorted()
599            .into_iter()
600            .filter(|state| productive.contains(state) || *state == self.representative(0))
601            .collect::<Vec<_>>();
602        if active.is_empty() {
603            active.push(self.representative(0));
604        }
605
606        let mut state_map = vec![None; self.states.len()];
607        for (new, old) in active.iter().enumerate() {
608            state_map[*old] = Some(new);
609        }
610
611        let mut states = Vec::new();
612        let mut transitions = Vec::new();
613        let mut final_probabilities = Vec::new();
614        let mut transition_probabilities = Vec::new();
615
616        for old in active {
617            let state = &self.states[old];
618            states.push(InferredState {
619                accepting: state.accepting && !state.rejecting,
620                rejecting: state.rejecting,
621                arrival_count: state.arrival_count,
622                final_count: state.final_count,
623            });
624
625            let mut remapped_transitions = BTreeMap::new();
626            let mut remapped_probabilities = BTreeMap::new();
627            for (symbol, target) in &self.transitions[old] {
628                let target = self.representative(*target);
629                let Some(target) = state_map[target] else {
630                    continue;
631                };
632                remapped_transitions.insert(symbol.clone(), target);
633
634                if include_probabilities && state.arrival_count > 0 {
635                    let count = self.transition_counts[old]
636                        .get(symbol)
637                        .copied()
638                        .unwrap_or_default()
639                        .min(state.arrival_count);
640                    if let Some(probability) =
641                        ProbabilisticTruthValue::from_ratio(count, state.arrival_count)
642                    {
643                        remapped_probabilities.insert(symbol.clone(), probability);
644                    }
645                }
646            }
647            transitions.push(remapped_transitions);
648            transition_probabilities.push(remapped_probabilities);
649
650            let final_probability = if include_probabilities && state.arrival_count > 0 {
651                ProbabilisticTruthValue::from_ratio(
652                    state.final_count.min(state.arrival_count),
653                    state.arrival_count,
654                )
655            } else {
656                None
657            };
658            final_probabilities.push(final_probability);
659        }
660
661        InferredAutomaton {
662            states,
663            transitions,
664            final_probabilities,
665            transition_probabilities,
666            merge_history,
667        }
668    }
669}
670
671#[derive(Clone, Debug)]
672struct MergeAttempt {
673    machine: WorkAutomaton,
674    event: MergeEvent,
675}
676
677fn rpni_step(
678    machine: &mut WorkAutomaton,
679    red: &mut BTreeSet<usize>,
680    blue: &[usize],
681    merge_history: &mut Vec<MergeEvent>,
682) {
683    let blue_state = blue[0];
684    for red_state in machine.red_sorted(red) {
685        if let Some(attempt) = machine.try_merge(red_state, blue_state, None) {
686            *machine = attempt.machine;
687            merge_history.push(attempt.event);
688            return;
689        }
690    }
691
692    red.insert(blue_state);
693}
694
695fn edsm_step(
696    machine: &mut WorkAutomaton,
697    red: &mut BTreeSet<usize>,
698    blue: &[usize],
699    merge_history: &mut Vec<MergeEvent>,
700) {
701    let mut best = None::<MergeAttempt>;
702    let red_states = machine.red_sorted(red);
703
704    for blue_state in blue {
705        for red_state in &red_states {
706            let Some(attempt) = machine.try_merge(*red_state, *blue_state, None) else {
707                continue;
708            };
709
710            let is_better = match &best {
711                Some(current) => {
712                    attempt.event.evidence > current.event.evidence
713                        || (attempt.event.evidence == current.event.evidence
714                            && merge_tie_key(&attempt.event) < merge_tie_key(&current.event))
715                }
716                None => true,
717            };
718
719            if is_better {
720                best = Some(attempt);
721            }
722        }
723    }
724
725    if let Some(attempt) = best {
726        *machine = attempt.machine;
727        merge_history.push(attempt.event);
728    } else {
729        red.insert(blue[0]);
730    }
731}
732
733fn alergia_step(
734    machine: &mut WorkAutomaton,
735    red: &mut BTreeSet<usize>,
736    blue: &[usize],
737    alpha: f64,
738    merge_history: &mut Vec<MergeEvent>,
739) {
740    let blue_state = blue[0];
741    for red_state in machine.red_sorted(red) {
742        if let Some(attempt) = machine.try_merge(red_state, blue_state, Some(alpha)) {
743            *machine = attempt.machine;
744            merge_history.push(attempt.event);
745            return;
746        }
747    }
748
749    red.insert(blue_state);
750}
751
752const fn merge_tie_key(event: &MergeEvent) -> (usize, usize) {
753    (event.source, event.target)
754}
755
756fn normalised_red(machine: &WorkAutomaton, red: &BTreeSet<usize>) -> BTreeSet<usize> {
757    red.iter()
758        .filter_map(|state| machine.active_representative(*state))
759        .collect()
760}
761
762fn blue_states(machine: &WorkAutomaton, red: &BTreeSet<usize>) -> Vec<usize> {
763    let mut blue = BTreeSet::new();
764
765    for red_state in machine.red_sorted(red) {
766        for target in machine.transitions[red_state].values() {
767            if let Some(target) = machine.active_representative(*target)
768                && !red.contains(&target)
769            {
770                blue.insert(target);
771            }
772        }
773    }
774
775    let mut blue = blue.into_iter().collect::<Vec<_>>();
776    blue.sort_by_key(|state| (machine.states[*state].rank, *state));
777    blue
778}
779
780fn canonical_ranks(apta: &Apta) -> Vec<usize> {
781    let mut paths = vec![Vec::<Symbol>::new(); apta.states.len()];
782    let mut queue = VecDeque::from([0usize]);
783    let mut seen = BTreeSet::from([0usize]);
784
785    while let Some(state) = queue.pop_front() {
786        for (symbol, target) in &apta.transitions[state] {
787            if seen.insert(*target) {
788                paths[*target] = paths[state]
789                    .iter()
790                    .cloned()
791                    .chain([symbol.clone()])
792                    .collect();
793                queue.push_back(*target);
794            }
795        }
796    }
797
798    let mut ordered = (0..apta.states.len()).collect::<Vec<_>>();
799    ordered.sort_by_key(|state| (paths[*state].len(), paths[*state].clone(), *state));
800
801    let mut ranks = vec![0usize; apta.states.len()];
802    for (rank, state) in ordered.into_iter().enumerate() {
803        ranks[state] = rank;
804    }
805    ranks
806}
807
808fn proportions_compatible(
809    left_count: u64,
810    left_total: u64,
811    right_count: u64,
812    right_total: u64,
813    alpha: f64,
814) -> bool {
815    if left_total == 0 || right_total == 0 {
816        return left_count == right_count;
817    }
818
819    let left = ratio(left_count, left_total);
820    let right = ratio(right_count, right_total);
821    let bound = compatibility_bound(alpha, left_total, right_total);
822
823    (left - right).abs() <= bound
824}
825
826fn compatibility_bound(alpha: f64, left_total: u64, right_total: u64) -> f64 {
827    let confidence = normalised_alpha(alpha);
828    let significance = (1.0 - confidence).max(f64::MIN_POSITIVE);
829    let multiplier = (0.5 * (2.0 / significance).ln()).sqrt();
830
831    multiplier * (1.0 / count_to_f64(left_total).sqrt() + 1.0 / count_to_f64(right_total).sqrt())
832}
833
834const fn normalised_alpha(alpha: f64) -> f64 {
835    if alpha.is_finite() {
836        alpha.clamp(0.000_001, 0.999_999)
837    } else {
838        0.5
839    }
840}
841
842fn ratio(count: u64, total: u64) -> f64 {
843    count_to_f64(count) / count_to_f64(total)
844}
845
846fn count_to_f64(count: u64) -> f64 {
847    f64::from(u32::try_from(count).unwrap_or(u32::MAX))
848}
849
850fn state_name(state: usize) -> String {
851    format!("q{state}")
852}