Skip to main content

oxilean_std/coinduction/
functions.rs

1//! Coinductive proofs and greatest fixed points — implementations and tests.
2
3use super::types::{
4    BisimulationRelation, CoalgebraMap, Codata, CoinductiveProof, GreibachNormalForm, LazyStream,
5    StreamApprox, StreamNode,
6};
7
8// ── LazyStream ────────────────────────────────────────────────────────────────
9
10impl<T: Clone> LazyStream<T> {
11    /// Create a stream with an explicit prefix and repeating cycle.
12    ///
13    /// If `cycle` is empty the stream is treated as a finite prefix followed
14    /// by an infinite repetition of the last prefix element; if the prefix is
15    /// also empty the stream returns the default value if T: Default, but
16    /// callers should guarantee `cycle` is non-empty for infinite streams.
17    pub fn new(prefix: Vec<T>, cycle: Vec<T>) -> Self {
18        LazyStream { prefix, cycle }
19    }
20
21    /// Create the constant stream `[val, val, val, ...]`.
22    pub fn constant(val: T) -> Self {
23        LazyStream {
24            prefix: Vec::new(),
25            cycle: vec![val],
26        }
27    }
28
29    /// Create a stream that repeats `vals` forever: `vals\[0\], vals\[1\], ..., vals[n-1], vals\[0\], ...`.
30    ///
31    /// Panics if `vals` is empty.
32    pub fn cycle(vals: Vec<T>) -> Self {
33        assert!(!vals.is_empty(), "cycle must be non-empty");
34        LazyStream {
35            prefix: Vec::new(),
36            cycle: vals,
37        }
38    }
39
40    /// Return the first `n` elements of the stream.
41    pub fn take(&self, n: usize) -> Vec<T> {
42        (0..n).map(|i| self.nth(i)).collect()
43    }
44
45    /// Return the `n`-th element (0-indexed).
46    pub fn nth(&self, n: usize) -> T {
47        if n < self.prefix.len() {
48            self.prefix[n].clone()
49        } else if self.cycle.is_empty() {
50            // Fallback: repeat last prefix element (should not happen in normal use)
51            self.prefix
52                .last()
53                .cloned()
54                .expect("LazyStream: both prefix and cycle are empty")
55        } else {
56            let cycle_idx = (n - self.prefix.len()) % self.cycle.len();
57            self.cycle[cycle_idx].clone()
58        }
59    }
60
61    /// Apply `f` element-wise, producing a new `LazyStream<U>`.
62    pub fn map<U: Clone, F: Fn(&T) -> U>(&self, f: F) -> LazyStream<U> {
63        LazyStream {
64            prefix: self.prefix.iter().map(|x| f(x)).collect(),
65            cycle: self.cycle.iter().map(|x| f(x)).collect(),
66        }
67    }
68
69    /// Point-wise combine two streams with a binary function.
70    ///
71    /// The resulting stream has:
72    /// - prefix length = max of the two prefixes, extended by cycling the shorter cycle
73    /// - cycle length = lcm of the two cycle lengths
74    pub fn zip<U: Clone, V: Clone, F: Fn(&T, &U) -> V>(
75        &self,
76        other: &LazyStream<U>,
77        f: F,
78    ) -> LazyStream<V> {
79        // Compute LCM of cycle lengths for the combined cycle
80        let lcm_len = if self.cycle.is_empty() || other.cycle.is_empty() {
81            0
82        } else {
83            lcm(self.cycle.len(), other.cycle.len())
84        };
85
86        // Unified prefix length: max of both prefix lengths
87        let prefix_len = self.prefix.len().max(other.prefix.len());
88        let combined_prefix: Vec<V> = (0..prefix_len)
89            .map(|i| f(&self.nth(i), &other.nth(i)))
90            .collect();
91
92        let combined_cycle: Vec<V> = (0..lcm_len)
93            .map(|i| f(&self.nth(prefix_len + i), &other.nth(prefix_len + i)))
94            .collect();
95
96        LazyStream {
97            prefix: combined_prefix,
98            cycle: combined_cycle,
99        }
100    }
101
102    /// Convert the stream to a finite unrolled approximation of depth `n`.
103    pub fn to_approx(&self, n: usize) -> StreamApprox<T> {
104        self.to_approx_at(0, n)
105    }
106
107    fn to_approx_at(&self, offset: usize, depth: usize) -> StreamApprox<T> {
108        if depth == 0 {
109            StreamApprox::Nil
110        } else {
111            let head = self.nth(offset);
112            StreamApprox::Cons(Box::new(StreamNode {
113                head,
114                tail: Box::new(self.to_approx_at(offset + 1, depth - 1)),
115            }))
116        }
117    }
118}
119
120// ── greatest common divisor / least common multiple ──────────────────────────
121
122fn gcd(a: usize, b: usize) -> usize {
123    if b == 0 {
124        a
125    } else {
126        gcd(b, a % b)
127    }
128}
129
130fn lcm(a: usize, b: usize) -> usize {
131    if a == 0 || b == 0 {
132        0
133    } else {
134        a / gcd(a, b) * b
135    }
136}
137
138// ── CoalgebraMap ──────────────────────────────────────────────────────────────
139
140impl<S: Clone + Eq, O: Clone + Eq> CoalgebraMap<S, O> {
141    /// Create an empty coalgebra map.
142    pub fn new() -> Self {
143        CoalgebraMap {
144            transitions: Vec::new(),
145        }
146    }
147
148    /// Add a transition `src --obs--> dst`.
149    pub fn add_transition(&mut self, src: S, obs: O, dst: S) {
150        self.transitions.push((src, obs, dst));
151    }
152
153    /// Return all transitions out of `state`.
154    pub fn transitions_from(&self, state: &S) -> Vec<(&O, &S)> {
155        self.transitions
156            .iter()
157            .filter(|(s, _, _)| s == state)
158            .map(|(_, o, d)| (o, d))
159            .collect()
160    }
161}
162
163// ── BisimulationRelation ──────────────────────────────────────────────────────
164
165impl<S: Clone + Eq> BisimulationRelation<S> {
166    /// Create an empty bisimulation relation.
167    pub fn new() -> Self {
168        BisimulationRelation { pairs: Vec::new() }
169    }
170
171    /// Add a pair `(s, t)` to the candidate relation.
172    pub fn add_pair(&mut self, s: S, t: S) {
173        if !self.pairs.iter().any(|(a, b)| *a == s && *b == t) {
174            self.pairs.push((s, t));
175        }
176    }
177
178    /// Return true if `(s, t)` is in the relation.
179    pub fn contains(&self, s: &S, t: &S) -> bool {
180        self.pairs.iter().any(|(a, b)| a == s && b == t)
181    }
182
183    /// Number of pairs.
184    pub fn len(&self) -> usize {
185        self.pairs.len()
186    }
187
188    /// True if no pairs.
189    pub fn is_empty(&self) -> bool {
190        self.pairs.is_empty()
191    }
192
193    /// Verify that `self` is a valid bisimulation w.r.t. `coalgebra`.
194    ///
195    /// A relation R is a bisimulation if, for every (s, t) ∈ R:
196    /// - for every transition s --o--> s', there exists t --o--> t' with (s', t') ∈ R
197    /// - for every transition t --o--> t', there exists s --o--> s' with (s', t') ∈ R
198    pub fn check<O: Clone + Eq>(&self, coalgebra: &CoalgebraMap<S, O>) -> bool {
199        for (s, t) in &self.pairs {
200            // Check forward simulation: s ⊆ t
201            for (obs, s_next) in coalgebra.transitions_from(s) {
202                let matched = coalgebra
203                    .transitions_from(t)
204                    .into_iter()
205                    .any(|(o2, t_next)| o2 == obs && self.contains(s_next, t_next));
206                if !matched {
207                    return false;
208                }
209            }
210            // Check backward simulation: t ⊆ s
211            for (obs, t_next) in coalgebra.transitions_from(t) {
212                let matched = coalgebra
213                    .transitions_from(s)
214                    .into_iter()
215                    .any(|(o2, s_next)| o2 == obs && self.contains(s_next, t_next));
216                if !matched {
217                    return false;
218                }
219            }
220        }
221        true
222    }
223}
224
225// ── Greatest fixed-point approximation ───────────────────────────────────────
226
227/// Compute an approximation of the greatest bisimulation on `coalgebra` by
228/// iterative subset refinement.
229///
230/// Starts with the total relation (all pairs) and removes pairs that violate
231/// the bisimulation condition until a fixed point is reached, up to
232/// `max_iters` refinement steps.
233pub fn greatest_fixed_point_approx<S: Clone + Eq>(
234    coalgebra: &CoalgebraMap<S, String>,
235    max_iters: usize,
236) -> BisimulationRelation<S> {
237    // Collect all states
238    let mut states: Vec<S> = Vec::new();
239    for (s, _, d) in &coalgebra.transitions {
240        if !states.contains(s) {
241            states.push(s.clone());
242        }
243        if !states.contains(d) {
244            states.push(d.clone());
245        }
246    }
247
248    // Start with the total relation
249    let mut relation = BisimulationRelation::new();
250    for s in &states {
251        for t in &states {
252            relation.add_pair(s.clone(), t.clone());
253        }
254    }
255
256    for _ in 0..max_iters {
257        let mut next = BisimulationRelation::new();
258        for (s, t) in &relation.pairs {
259            // Check if (s, t) satisfies the bisimulation condition w.r.t. `relation`
260            let s_ok = coalgebra.transitions_from(s).iter().all(|(obs, s_next)| {
261                coalgebra
262                    .transitions_from(t)
263                    .iter()
264                    .any(|(o2, t_next)| *o2 == *obs && relation.contains(s_next, t_next))
265            });
266            let t_ok = coalgebra.transitions_from(t).iter().all(|(obs, t_next)| {
267                coalgebra
268                    .transitions_from(s)
269                    .iter()
270                    .any(|(o2, s_next)| *o2 == *obs && relation.contains(s_next, t_next))
271            });
272            if s_ok && t_ok {
273                next.add_pair(s.clone(), t.clone());
274            }
275        }
276        if next.pairs.len() == relation.pairs.len() {
277            return next; // fixed point reached
278        }
279        relation = next;
280    }
281    relation
282}
283
284/// Try to prove that states `s1` and `s2` are bisimilar w.r.t. `coalgebra`.
285///
286/// Returns a `CoinductiveProof` if a valid bisimulation containing `(s1, s2)`
287/// is found, or `None` if the iterative approximation fails to find one.
288pub fn prove_bisimilar<S: Clone + Eq + std::fmt::Debug>(
289    s1: S,
290    s2: S,
291    coalgebra: &CoalgebraMap<S, String>,
292) -> Option<CoinductiveProof<S>> {
293    let max_iters = 100;
294    let gfp = greatest_fixed_point_approx(coalgebra, max_iters);
295    let mut progress = Vec::new();
296    progress.push(format!(
297        "Computed GFP with {} pairs after up to {} iterations",
298        gfp.pairs.len(),
299        max_iters
300    ));
301    if gfp.contains(&s1, &s2) {
302        progress.push(format!(
303            "Found ({:?}, {:?}) in the greatest bisimulation",
304            s1, s2
305        ));
306        Some(CoinductiveProof {
307            relation: gfp,
308            progress,
309        })
310    } else {
311        None
312    }
313}
314
315// ── GreibachNormalForm ────────────────────────────────────────────────────────
316
317impl GreibachNormalForm {
318    /// Create a GNF grammar from a list of rules `(lhs, terminal, rhs)`.
319    pub fn new(rules: Vec<(String, char, Vec<String>)>) -> Self {
320        GreibachNormalForm { rules }
321    }
322
323    /// Return all rules for non-terminal `nt`.
324    pub fn rules_for(&self, nt: &str) -> Vec<(char, &[String])> {
325        self.rules
326            .iter()
327            .filter(|(lhs, _, _)| lhs == nt)
328            .map(|(_, terminal, rhs)| (*terminal, rhs.as_slice()))
329            .collect()
330    }
331
332    /// Check whether the string `input` is derivable from non-terminal `start`.
333    ///
334    /// Uses a simple top-down recursive descent (exponential in general, but
335    /// fine for small grammars in tests).
336    pub fn derives(&self, start: &str, input: &[char]) -> bool {
337        self.derives_stack(vec![start.to_string()], input)
338    }
339
340    fn derives_stack(&self, stack: Vec<String>, input: &[char]) -> bool {
341        if stack.is_empty() {
342            return input.is_empty();
343        }
344        if input.is_empty() {
345            return false;
346        }
347        let top = &stack[0];
348        let rest_stack = stack[1..].to_vec();
349        for (terminal, rhs) in self.rules_for(top) {
350            if terminal == input[0] {
351                let mut new_stack: Vec<String> = rhs.to_vec();
352                new_stack.extend_from_slice(&rest_stack);
353                if self.derives_stack(new_stack, &input[1..]) {
354                    return true;
355                }
356            }
357        }
358        false
359    }
360}
361
362// ── Codata ────────────────────────────────────────────────────────────────────
363
364impl<F: 'static> Codata<F> {
365    /// Construct a `Codata` from an unfolding thunk.
366    pub fn new(unfold: impl Fn() -> F + 'static) -> Self {
367        Codata {
368            unfold: Box::new(unfold),
369        }
370    }
371
372    /// Unfold one layer of the codata.
373    pub fn unfold(&self) -> F {
374        (self.unfold)()
375    }
376}
377
378// ── fibonacci_stream ──────────────────────────────────────────────────────────
379
380/// Produce the Fibonacci stream `0, 1, 1, 2, 3, 5, 8, ...` as a `LazyStream<u64>`.
381///
382/// The stream is computed eagerly for `n` elements and stored as a prefix with
383/// a zero-length cycle; callers that need truly infinite access should call
384/// `fibonacci_stream_n` with a sufficient count.
385pub fn fibonacci_stream() -> LazyStream<u64> {
386    // Compute enough Fibonacci numbers to populate both prefix and cycle.
387    // We store 94 values (the full u64-range Fibonacci sequence without overflow).
388    let mut fibs = Vec::with_capacity(94);
389    let (mut a, mut b) = (0u64, 1u64);
390    while let Some(next) = a.checked_add(b) {
391        fibs.push(a);
392        a = b;
393        b = next;
394        if fibs.len() >= 94 {
395            break;
396        }
397    }
398    fibs.push(a); // last non-overflowing value
399                  // Represent as prefix + empty cycle (finite model of infinite stream)
400                  // For `nth` purposes, out-of-range accesses will repeat the last element.
401    LazyStream::new(fibs, Vec::new())
402}
403
404/// Fibonacci stream with a specific `n`-element prefix; requests beyond `n`
405/// elements repeat the last value.
406pub fn fibonacci_stream_n(n: usize) -> Vec<u64> {
407    fibonacci_stream().take(n)
408}
409
410// ── Tests ─────────────────────────────────────────────────────────────────────
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    // ── LazyStream tests ──────────────────────────────────────────────────────
417
418    #[test]
419    fn constant_stream_take() {
420        let s = LazyStream::constant(42u32);
421        let v = s.take(5);
422        assert_eq!(v, vec![42, 42, 42, 42, 42]);
423    }
424
425    #[test]
426    fn cycle_stream() {
427        let s = LazyStream::cycle(vec![1u32, 2, 3]);
428        assert_eq!(s.take(7), vec![1, 2, 3, 1, 2, 3, 1]);
429    }
430
431    #[test]
432    fn nth_within_prefix() {
433        let s = LazyStream::new(vec![10u32, 20, 30], vec![1, 2]);
434        assert_eq!(s.nth(0), 10);
435        assert_eq!(s.nth(1), 20);
436        assert_eq!(s.nth(2), 30);
437    }
438
439    #[test]
440    fn nth_into_cycle() {
441        let s = LazyStream::new(vec![10u32], vec![1, 2, 3]);
442        assert_eq!(s.nth(1), 1);
443        assert_eq!(s.nth(2), 2);
444        assert_eq!(s.nth(3), 3);
445        assert_eq!(s.nth(4), 1); // wraps
446    }
447
448    #[test]
449    fn take_empty() {
450        let s = LazyStream::constant(0u32);
451        assert_eq!(s.take(0), Vec::<u32>::new());
452    }
453
454    #[test]
455    fn map_stream() {
456        let s = LazyStream::cycle(vec![1u32, 2, 3]);
457        let doubled = s.map(|x| x * 2);
458        assert_eq!(doubled.take(6), vec![2, 4, 6, 2, 4, 6]);
459    }
460
461    #[test]
462    fn zip_streams_same_cycle() {
463        let s1 = LazyStream::cycle(vec![1u32, 2, 3]);
464        let s2 = LazyStream::cycle(vec![10u32, 20, 30]);
465        let sums = s1.zip(&s2, |a, b| a + b);
466        assert_eq!(sums.take(6), vec![11, 22, 33, 11, 22, 33]);
467    }
468
469    #[test]
470    fn zip_streams_different_cycles() {
471        let s1 = LazyStream::cycle(vec![1u32, 2]);
472        let s2 = LazyStream::cycle(vec![10u32, 20, 30]);
473        let sums = s1.zip(&s2, |a, b| a + b);
474        // LCM(2,3) = 6, so cycle has 6 elements
475        let v = sums.take(6);
476        assert_eq!(v[0], 11); // 1+10
477        assert_eq!(v[1], 22); // 2+20
478        assert_eq!(v[2], 31); // 1+30
479        assert_eq!(v[3], 12); // 2+10
480        assert_eq!(v[4], 21); // 1+20
481        assert_eq!(v[5], 32); // 2+30
482    }
483
484    #[test]
485    fn stream_with_prefix_and_cycle() {
486        let s = LazyStream::new(vec![0u32, 1], vec![2, 3]);
487        assert_eq!(s.take(8), vec![0, 1, 2, 3, 2, 3, 2, 3]);
488    }
489
490    #[test]
491    fn to_approx_depth_0() {
492        let s = LazyStream::constant(5u32);
493        let a = s.to_approx(0);
494        assert!(matches!(a, StreamApprox::Nil));
495    }
496
497    #[test]
498    fn to_approx_depth_3() {
499        let s = LazyStream::cycle(vec![1u32, 2, 3]);
500        let a = s.to_approx(3);
501        if let StreamApprox::Cons(node) = a {
502            assert_eq!(node.head, 1);
503            if let StreamApprox::Cons(n2) = *node.tail {
504                assert_eq!(n2.head, 2);
505            } else {
506                panic!("expected Cons at depth 2");
507            }
508        } else {
509            panic!("expected Cons at depth 1");
510        }
511    }
512
513    // ── Fibonacci stream tests ────────────────────────────────────────────────
514
515    #[test]
516    fn fibonacci_first_10() {
517        let fibs = fibonacci_stream_n(10);
518        assert_eq!(fibs, vec![0, 1, 1, 2, 3, 5, 8, 13, 21, 34]);
519    }
520
521    #[test]
522    fn fibonacci_0th_element() {
523        let s = fibonacci_stream();
524        assert_eq!(s.nth(0), 0);
525    }
526
527    #[test]
528    fn fibonacci_1st_element() {
529        let s = fibonacci_stream();
530        assert_eq!(s.nth(1), 1);
531    }
532
533    #[test]
534    fn fibonacci_map() {
535        let s = fibonacci_stream();
536        let doubled: Vec<u64> = s.map(|x| x * 2).take(5);
537        assert_eq!(doubled, vec![0, 2, 2, 4, 6]);
538    }
539
540    // ── BisimulationRelation tests ────────────────────────────────────────────
541
542    #[test]
543    fn bisim_check_trivial_identity() {
544        // Two identical deterministic processes are bisimilar
545        let mut coalgebra: CoalgebraMap<u32, String> = CoalgebraMap::new();
546        coalgebra.add_transition(0, "a".to_string(), 0);
547        coalgebra.add_transition(1, "a".to_string(), 1);
548
549        let mut rel: BisimulationRelation<u32> = BisimulationRelation::new();
550        rel.add_pair(0, 1);
551        rel.add_pair(0, 0);
552        rel.add_pair(1, 1);
553
554        assert!(rel.check(&coalgebra));
555    }
556
557    #[test]
558    fn bisim_check_invalid() {
559        // States with different observations are NOT bisimilar
560        let mut coalgebra: CoalgebraMap<u32, String> = CoalgebraMap::new();
561        coalgebra.add_transition(0, "a".to_string(), 0);
562        coalgebra.add_transition(1, "b".to_string(), 1);
563
564        let mut rel: BisimulationRelation<u32> = BisimulationRelation::new();
565        rel.add_pair(0, 1);
566
567        assert!(!rel.check(&coalgebra));
568    }
569
570    #[test]
571    fn bisim_relation_contains() {
572        let mut rel: BisimulationRelation<i32> = BisimulationRelation::new();
573        rel.add_pair(1, 2);
574        assert!(rel.contains(&1, &2));
575        assert!(!rel.contains(&2, &1));
576    }
577
578    // ── Greatest fixed-point tests ────────────────────────────────────────────
579
580    #[test]
581    fn gfp_simple_two_state() {
582        // Two states both looping on "a" should be in the GFP
583        let mut coalgebra: CoalgebraMap<u32, String> = CoalgebraMap::new();
584        coalgebra.add_transition(0, "a".to_string(), 0);
585        coalgebra.add_transition(1, "a".to_string(), 1);
586
587        let gfp = greatest_fixed_point_approx(&coalgebra, 20);
588        assert!(gfp.contains(&0, &1));
589        assert!(gfp.contains(&1, &0));
590    }
591
592    #[test]
593    fn gfp_distinguishable_states_not_bisimilar() {
594        let mut coalgebra: CoalgebraMap<u32, String> = CoalgebraMap::new();
595        coalgebra.add_transition(0, "a".to_string(), 0);
596        coalgebra.add_transition(1, "b".to_string(), 1);
597
598        let gfp = greatest_fixed_point_approx(&coalgebra, 20);
599        assert!(!gfp.contains(&0, &1));
600    }
601
602    #[test]
603    fn prove_bisimilar_success() {
604        let mut coalgebra: CoalgebraMap<&str, String> = CoalgebraMap::new();
605        coalgebra.add_transition("p", "tick".to_string(), "p");
606        coalgebra.add_transition("q", "tick".to_string(), "q");
607
608        let proof = prove_bisimilar("p", "q", &coalgebra);
609        assert!(proof.is_some());
610        let proof = proof.expect("should be Some");
611        assert!(!proof.progress.is_empty());
612        assert!(proof.relation.contains(&"p", &"q"));
613    }
614
615    #[test]
616    fn prove_bisimilar_failure() {
617        let mut coalgebra: CoalgebraMap<&str, String> = CoalgebraMap::new();
618        coalgebra.add_transition("p", "a".to_string(), "p");
619        coalgebra.add_transition("q", "b".to_string(), "q");
620
621        let proof = prove_bisimilar("p", "q", &coalgebra);
622        assert!(proof.is_none());
623    }
624
625    // ── GreibachNormalForm tests ──────────────────────────────────────────────
626
627    #[test]
628    fn gnf_simple_grammar() {
629        // S → a S | a
630        // Represented in GNF: S → a S (rhs=[S]), S → a (rhs=[])
631        let gnf = GreibachNormalForm::new(vec![
632            ("S".to_string(), 'a', vec!["S".to_string()]),
633            ("S".to_string(), 'a', vec![]),
634        ]);
635        // "aaa" should be derivable
636        assert!(gnf.derives("S", &['a', 'a', 'a']));
637        // "b" should not be derivable
638        assert!(!gnf.derives("S", &['b']));
639        // empty string should not be derivable (GNF always consumes at least one char)
640        assert!(!gnf.derives("S", &[]));
641    }
642
643    #[test]
644    fn gnf_rules_for() {
645        let gnf = GreibachNormalForm::new(vec![
646            ("A".to_string(), 'x', vec!["B".to_string()]),
647            ("A".to_string(), 'y', vec![]),
648            ("B".to_string(), 'z', vec![]),
649        ]);
650        let a_rules = gnf.rules_for("A");
651        assert_eq!(a_rules.len(), 2);
652    }
653
654    // ── Codata tests ──────────────────────────────────────────────────────────
655
656    #[test]
657    fn codata_unfold() {
658        let c = Codata::new(|| 42u32);
659        assert_eq!(c.unfold(), 42);
660    }
661
662    #[test]
663    fn codata_unfold_returns_constant() {
664        // Codata<u32> with a constant unfolding thunk
665        let c: Codata<u32> = Codata::new(|| 99u32);
666        // Calling unfold multiple times always gives the same value
667        assert_eq!(c.unfold(), 99);
668        assert_eq!(c.unfold(), 99);
669    }
670
671    // ── Additional edge-case tests ────────────────────────────────────────────
672
673    #[test]
674    fn lcm_helper() {
675        assert_eq!(lcm(2, 3), 6);
676        assert_eq!(lcm(4, 6), 12);
677        assert_eq!(lcm(5, 5), 5);
678        assert_eq!(lcm(0, 5), 0);
679    }
680
681    #[test]
682    fn gcd_helper() {
683        assert_eq!(gcd(12, 8), 4);
684        assert_eq!(gcd(7, 3), 1);
685        assert_eq!(gcd(0, 5), 5);
686    }
687
688    #[test]
689    fn bisim_empty_relation_is_valid() {
690        let coalgebra: CoalgebraMap<u32, String> = CoalgebraMap::new();
691        let rel: BisimulationRelation<u32> = BisimulationRelation::new();
692        // Empty relation vacuously satisfies bisimulation
693        assert!(rel.check(&coalgebra));
694    }
695
696    #[test]
697    fn coalgebra_transitions_from() {
698        let mut c: CoalgebraMap<u32, String> = CoalgebraMap::new();
699        c.add_transition(0, "a".to_string(), 1);
700        c.add_transition(0, "b".to_string(), 2);
701        c.add_transition(1, "c".to_string(), 0);
702        let from_0 = c.transitions_from(&0);
703        assert_eq!(from_0.len(), 2);
704    }
705}