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            ) {
543                if !self.alergia_compatible(left_target, right_target, alpha, seen) {
544                    return false;
545                }
546            }
547        }
548
549        true
550    }
551
552    fn outgoing_symbols(&self, left: usize, right: usize) -> BTreeSet<Symbol> {
553        self.transitions[left]
554            .keys()
555            .chain(self.transitions[right].keys())
556            .chain(self.transition_counts[left].keys())
557            .chain(self.transition_counts[right].keys())
558            .cloned()
559            .collect()
560    }
561
562    fn productive_states(&self) -> BTreeSet<usize> {
563        let mut reverse = vec![Vec::<usize>::new(); self.states.len()];
564        for state in self.active_sorted() {
565            for target in self.transitions[state].values() {
566                let target = self.representative(*target);
567                if self.states[target].active {
568                    reverse[target].push(state);
569                }
570            }
571        }
572
573        let mut productive = BTreeSet::new();
574        let mut queue = self
575            .active_sorted()
576            .into_iter()
577            .filter(|state| self.states[*state].accepting && !self.states[*state].rejecting)
578            .collect::<VecDeque<_>>();
579
580        while let Some(state) = queue.pop_front() {
581            if !productive.insert(state) {
582                continue;
583            }
584            for predecessor in &reverse[state] {
585                queue.push_back(*predecessor);
586            }
587        }
588
589        productive
590    }
591
592    fn into_inferred(
593        self,
594        include_probabilities: bool,
595        merge_history: Vec<MergeEvent>,
596    ) -> InferredAutomaton {
597        let productive = self.productive_states();
598        let mut active = self
599            .active_sorted()
600            .into_iter()
601            .filter(|state| productive.contains(state) || *state == self.representative(0))
602            .collect::<Vec<_>>();
603        if active.is_empty() {
604            active.push(self.representative(0));
605        }
606
607        let mut state_map = vec![None; self.states.len()];
608        for (new, old) in active.iter().enumerate() {
609            state_map[*old] = Some(new);
610        }
611
612        let mut states = Vec::new();
613        let mut transitions = Vec::new();
614        let mut final_probabilities = Vec::new();
615        let mut transition_probabilities = Vec::new();
616
617        for old in active {
618            let state = &self.states[old];
619            states.push(InferredState {
620                accepting: state.accepting && !state.rejecting,
621                rejecting: state.rejecting,
622                arrival_count: state.arrival_count,
623                final_count: state.final_count,
624            });
625
626            let mut remapped_transitions = BTreeMap::new();
627            let mut remapped_probabilities = BTreeMap::new();
628            for (symbol, target) in &self.transitions[old] {
629                let target = self.representative(*target);
630                let Some(target) = state_map[target] else {
631                    continue;
632                };
633                remapped_transitions.insert(symbol.clone(), target);
634
635                if include_probabilities && state.arrival_count > 0 {
636                    let count = self.transition_counts[old]
637                        .get(symbol)
638                        .copied()
639                        .unwrap_or_default()
640                        .min(state.arrival_count);
641                    if let Some(probability) =
642                        ProbabilisticTruthValue::from_ratio(count, state.arrival_count)
643                    {
644                        remapped_probabilities.insert(symbol.clone(), probability);
645                    }
646                }
647            }
648            transitions.push(remapped_transitions);
649            transition_probabilities.push(remapped_probabilities);
650
651            let final_probability = if include_probabilities && state.arrival_count > 0 {
652                ProbabilisticTruthValue::from_ratio(
653                    state.final_count.min(state.arrival_count),
654                    state.arrival_count,
655                )
656            } else {
657                None
658            };
659            final_probabilities.push(final_probability);
660        }
661
662        InferredAutomaton {
663            states,
664            transitions,
665            final_probabilities,
666            transition_probabilities,
667            merge_history,
668        }
669    }
670}
671
672#[derive(Clone, Debug)]
673struct MergeAttempt {
674    machine: WorkAutomaton,
675    event: MergeEvent,
676}
677
678fn rpni_step(
679    machine: &mut WorkAutomaton,
680    red: &mut BTreeSet<usize>,
681    blue: &[usize],
682    merge_history: &mut Vec<MergeEvent>,
683) {
684    let blue_state = blue[0];
685    for red_state in machine.red_sorted(red) {
686        if let Some(attempt) = machine.try_merge(red_state, blue_state, None) {
687            *machine = attempt.machine;
688            merge_history.push(attempt.event);
689            return;
690        }
691    }
692
693    red.insert(blue_state);
694}
695
696fn edsm_step(
697    machine: &mut WorkAutomaton,
698    red: &mut BTreeSet<usize>,
699    blue: &[usize],
700    merge_history: &mut Vec<MergeEvent>,
701) {
702    let mut best = None::<MergeAttempt>;
703    let red_states = machine.red_sorted(red);
704
705    for blue_state in blue {
706        for red_state in &red_states {
707            let Some(attempt) = machine.try_merge(*red_state, *blue_state, None) else {
708                continue;
709            };
710
711            let is_better = match &best {
712                Some(current) => {
713                    attempt.event.evidence > current.event.evidence
714                        || (attempt.event.evidence == current.event.evidence
715                            && merge_tie_key(&attempt.event) < merge_tie_key(&current.event))
716                }
717                None => true,
718            };
719
720            if is_better {
721                best = Some(attempt);
722            }
723        }
724    }
725
726    if let Some(attempt) = best {
727        *machine = attempt.machine;
728        merge_history.push(attempt.event);
729    } else {
730        red.insert(blue[0]);
731    }
732}
733
734fn alergia_step(
735    machine: &mut WorkAutomaton,
736    red: &mut BTreeSet<usize>,
737    blue: &[usize],
738    alpha: f64,
739    merge_history: &mut Vec<MergeEvent>,
740) {
741    let blue_state = blue[0];
742    for red_state in machine.red_sorted(red) {
743        if let Some(attempt) = machine.try_merge(red_state, blue_state, Some(alpha)) {
744            *machine = attempt.machine;
745            merge_history.push(attempt.event);
746            return;
747        }
748    }
749
750    red.insert(blue_state);
751}
752
753const fn merge_tie_key(event: &MergeEvent) -> (usize, usize) {
754    (event.source, event.target)
755}
756
757fn normalised_red(machine: &WorkAutomaton, red: &BTreeSet<usize>) -> BTreeSet<usize> {
758    red.iter()
759        .filter_map(|state| machine.active_representative(*state))
760        .collect()
761}
762
763fn blue_states(machine: &WorkAutomaton, red: &BTreeSet<usize>) -> Vec<usize> {
764    let mut blue = BTreeSet::new();
765
766    for red_state in machine.red_sorted(red) {
767        for target in machine.transitions[red_state].values() {
768            if let Some(target) = machine.active_representative(*target) {
769                if !red.contains(&target) {
770                    blue.insert(target);
771                }
772            }
773        }
774    }
775
776    let mut blue = blue.into_iter().collect::<Vec<_>>();
777    blue.sort_by_key(|state| (machine.states[*state].rank, *state));
778    blue
779}
780
781fn canonical_ranks(apta: &Apta) -> Vec<usize> {
782    let mut paths = vec![Vec::<Symbol>::new(); apta.states.len()];
783    let mut queue = VecDeque::from([0usize]);
784    let mut seen = BTreeSet::from([0usize]);
785
786    while let Some(state) = queue.pop_front() {
787        for (symbol, target) in &apta.transitions[state] {
788            if seen.insert(*target) {
789                paths[*target] = paths[state]
790                    .iter()
791                    .cloned()
792                    .chain([symbol.clone()])
793                    .collect();
794                queue.push_back(*target);
795            }
796        }
797    }
798
799    let mut ordered = (0..apta.states.len()).collect::<Vec<_>>();
800    ordered.sort_by_key(|state| (paths[*state].len(), paths[*state].clone(), *state));
801
802    let mut ranks = vec![0usize; apta.states.len()];
803    for (rank, state) in ordered.into_iter().enumerate() {
804        ranks[state] = rank;
805    }
806    ranks
807}
808
809fn proportions_compatible(
810    left_count: u64,
811    left_total: u64,
812    right_count: u64,
813    right_total: u64,
814    alpha: f64,
815) -> bool {
816    if left_total == 0 || right_total == 0 {
817        return left_count == right_count;
818    }
819
820    let left = ratio(left_count, left_total);
821    let right = ratio(right_count, right_total);
822    let bound = compatibility_bound(alpha, left_total, right_total);
823
824    (left - right).abs() <= bound
825}
826
827fn compatibility_bound(alpha: f64, left_total: u64, right_total: u64) -> f64 {
828    let confidence = normalised_alpha(alpha);
829    let significance = (1.0 - confidence).max(f64::MIN_POSITIVE);
830    let multiplier = (0.5 * (2.0 / significance).ln()).sqrt();
831
832    multiplier * (1.0 / count_to_f64(left_total).sqrt() + 1.0 / count_to_f64(right_total).sqrt())
833}
834
835fn normalised_alpha(alpha: f64) -> f64 {
836    if alpha.is_finite() {
837        alpha.clamp(0.000_001, 0.999_999)
838    } else {
839        0.5
840    }
841}
842
843fn ratio(count: u64, total: u64) -> f64 {
844    count_to_f64(count) / count_to_f64(total)
845}
846
847fn count_to_f64(count: u64) -> f64 {
848    f64::from(u32::try_from(count).unwrap_or(u32::MAX))
849}
850
851fn state_name(state: usize) -> String {
852    format!("q{state}")
853}