Skip to main content

libpetri_verification/
state_class_graph.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2
3use libpetri_core::input::{self, In};
4use libpetri_core::output::enumerate_branches;
5use libpetri_core::petri_net::PetriNet;
6
7use crate::dbm::Dbm;
8use crate::environment::EnvironmentAnalysisMode;
9use crate::marking_state::{MarkingState, MarkingStateBuilder};
10use crate::state_class::StateClass;
11
12/// Edge in the state class graph representing a transition firing.
13#[derive(Debug, Clone)]
14pub struct StateClassEdge {
15    pub from: usize,
16    pub to: usize,
17    pub transition_name: String,
18    pub branch_index: usize,
19}
20
21/// State Class Graph implementing the Berthomieu-Diaz (1991) algorithm.
22///
23/// BFS exploration with deduplication by canonical key (marking + firing domain).
24#[derive(Debug)]
25pub struct StateClassGraph {
26    classes: Vec<StateClass>,
27    edges: Vec<StateClassEdge>,
28    successors: HashMap<usize, Vec<usize>>,
29    predecessors: HashMap<usize, Vec<usize>>,
30    complete: bool,
31}
32
33impl StateClassGraph {
34    pub fn new() -> Self {
35        Self {
36            classes: Vec::new(),
37            edges: Vec::new(),
38            successors: HashMap::new(),
39            predecessors: HashMap::new(),
40            complete: true,
41        }
42    }
43
44    /// Builds the state class graph for a Time Petri Net using BFS exploration.
45    pub fn build(net: &PetriNet, initial_marking: &MarkingState, max_classes: usize) -> Self {
46        Self::build_with_env(
47            net,
48            initial_marking,
49            max_classes,
50            &[],
51            &EnvironmentAnalysisMode::Ignore,
52        )
53    }
54
55    /// Builds with environment place support.
56    pub fn build_with_env(
57        net: &PetriNet,
58        initial_marking: &MarkingState,
59        max_classes: usize,
60        env_places: &[&str],
61        env_mode: &EnvironmentAnalysisMode,
62    ) -> Self {
63        let env_set: HashSet<&str> = env_places.iter().copied().collect();
64        let enabled = find_enabled_transitions(net, initial_marking, &env_set, env_mode);
65        let clock_names: Vec<String> = enabled.clone();
66        let lower_bounds: Vec<f64> = enabled
67            .iter()
68            .map(|name| {
69                let t = net
70                    .transitions()
71                    .iter()
72                    .find(|t| t.name() == name.as_str())
73                    .unwrap();
74                t.timing().earliest() as f64 / 1000.0
75            })
76            .collect();
77        let upper_bounds: Vec<f64> = enabled
78            .iter()
79            .map(|name| {
80                let t = net
81                    .transitions()
82                    .iter()
83                    .find(|t| t.name() == name.as_str())
84                    .unwrap();
85                t.timing().latest() as f64 / 1000.0
86            })
87            .collect();
88
89        let initial_dbm = Dbm::create(clock_names, &lower_bounds, &upper_bounds);
90        let initial_dbm = initial_dbm.let_time_pass();
91        let initial_class = StateClass::new(initial_marking.clone(), initial_dbm, enabled);
92
93        let mut graph = StateClassGraph::new();
94        let initial_key = initial_class.canonical_key();
95
96        graph.classes.push(initial_class);
97        let mut class_keys: HashMap<String, usize> = HashMap::new();
98        class_keys.insert(initial_key, 0);
99        graph.successors.insert(0, Vec::new());
100        graph.predecessors.insert(0, Vec::new());
101
102        let mut queue: VecDeque<usize> = VecDeque::new();
103        queue.push_back(0);
104
105        while let Some(current_idx) = queue.pop_front() {
106            if graph.classes.len() >= max_classes {
107                graph.complete = false;
108                break;
109            }
110
111            let current = graph.classes[current_idx].clone();
112
113            for (clock_idx, transition_name) in current.enabled_transitions.iter().enumerate() {
114                let transition = net
115                    .transitions()
116                    .iter()
117                    .find(|t| t.name() == transition_name.as_str())
118                    .unwrap();
119
120                // Expand XOR branches into virtual transitions
121                let virtual_transitions = expand_transition(transition);
122
123                for (branch_index, output_places) in virtual_transitions {
124                    let successor = compute_successor(
125                        net,
126                        &current,
127                        clock_idx,
128                        transition_name,
129                        &output_places,
130                        &env_set,
131                        env_mode,
132                    );
133
134                    if successor.is_empty() {
135                        continue;
136                    }
137
138                    let key = successor.canonical_key();
139
140                    let target_idx = if let Some(&existing_idx) = class_keys.get(&key) {
141                        existing_idx
142                    } else {
143                        let idx = graph.classes.len();
144                        class_keys.insert(key, idx);
145                        graph.classes.push(successor);
146                        graph.successors.insert(idx, Vec::new());
147                        graph.predecessors.insert(idx, Vec::new());
148                        queue.push_back(idx);
149                        idx
150                    };
151
152                    graph.edges.push(StateClassEdge {
153                        from: current_idx,
154                        to: target_idx,
155                        transition_name: transition_name.clone(),
156                        branch_index,
157                    });
158                    graph
159                        .successors
160                        .entry(current_idx)
161                        .or_default()
162                        .push(target_idx);
163                    graph
164                        .predecessors
165                        .entry(target_idx)
166                        .or_default()
167                        .push(current_idx);
168                }
169            }
170        }
171
172        graph
173    }
174
175    /// Returns the number of state classes.
176    pub fn class_count(&self) -> usize {
177        self.classes.len()
178    }
179
180    /// Returns the state classes.
181    pub fn classes(&self) -> &[StateClass] {
182        &self.classes
183    }
184
185    /// Returns all edges.
186    pub fn edges(&self) -> &[StateClassEdge] {
187        &self.edges
188    }
189
190    /// Returns whether the graph was fully explored (not truncated).
191    pub fn is_complete(&self) -> bool {
192        self.complete
193    }
194
195    /// Returns successor class indices for a given class.
196    pub fn successors(&self, class_idx: usize) -> &[usize] {
197        self.successors
198            .get(&class_idx)
199            .map_or(&[], |v| v.as_slice())
200    }
201
202    /// Returns predecessor class indices for a given class.
203    pub fn predecessors(&self, class_idx: usize) -> &[usize] {
204        self.predecessors
205            .get(&class_idx)
206            .map_or(&[], |v| v.as_slice())
207    }
208
209    /// Checks if a marking is reachable (exists in any state class).
210    pub fn is_reachable(&self, marking: &MarkingState) -> bool {
211        self.classes.iter().any(|sc| sc.marking == *marking)
212    }
213
214    /// Returns all unique reachable markings.
215    pub fn reachable_markings(&self) -> Vec<&MarkingState> {
216        let mut seen = HashSet::new();
217        let mut result = Vec::new();
218        for sc in &self.classes {
219            let key = sc.marking.canonical_key();
220            if seen.insert(key) {
221                result.push(&sc.marking);
222            }
223        }
224        result
225    }
226
227    /// Returns the edge count.
228    pub fn edge_count(&self) -> usize {
229        self.edges.len()
230    }
231
232    /// Returns the enabled transition names for a state class.
233    pub fn enabled_transitions(&self, class_idx: usize) -> &[String] {
234        &self.classes[class_idx].enabled_transitions
235    }
236
237    /// Returns edges from a given class that fired a specific transition name.
238    pub fn branch_edges(&self, class_idx: usize, transition_name: &str) -> Vec<&StateClassEdge> {
239        self.edges
240            .iter()
241            .filter(|e| e.from == class_idx && e.transition_name == transition_name)
242            .collect()
243    }
244}
245
246impl Default for StateClassGraph {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252// ==================== Internal Functions ====================
253
254fn find_enabled_transitions(
255    net: &PetriNet,
256    marking: &MarkingState,
257    env_places: &HashSet<&str>,
258    env_mode: &EnvironmentAnalysisMode,
259) -> Vec<String> {
260    let mut enabled = Vec::new();
261    for t in net.transitions() {
262        if is_enabled(t, marking, env_places, env_mode) {
263            enabled.push(t.name().to_string());
264        }
265    }
266    enabled
267}
268
269fn is_enabled(
270    transition: &libpetri_core::transition::Transition,
271    marking: &MarkingState,
272    env_places: &HashSet<&str>,
273    env_mode: &EnvironmentAnalysisMode,
274) -> bool {
275    for spec in transition.input_specs() {
276        let place_name = spec.place_name();
277        let is_env = env_places.contains(place_name);
278
279        if is_env {
280            match env_mode {
281                EnvironmentAnalysisMode::AlwaysAvailable => continue, // always satisfied
282                EnvironmentAnalysisMode::Bounded { max_tokens } => {
283                    // Consider up to max_tokens available
284                    let required = input::required_count(spec);
285                    let available = marking.count(place_name) + max_tokens;
286                    if available < required {
287                        return false;
288                    }
289                }
290                EnvironmentAnalysisMode::Ignore => {
291                    let required = input::required_count(spec);
292                    if marking.count(place_name) < required {
293                        return false;
294                    }
295                }
296            }
297        } else {
298            let required = input::required_count(spec);
299            if marking.count(place_name) < required {
300                return false;
301            }
302        }
303    }
304    for arc in transition.reads() {
305        if marking.count(arc.place.name()) < 1 {
306            return false;
307        }
308    }
309    for arc in transition.inhibitors() {
310        if marking.count(arc.place.name()) > 0 {
311            return false;
312        }
313    }
314    true
315}
316
317fn expand_transition(
318    transition: &libpetri_core::transition::Transition,
319) -> Vec<(usize, HashSet<String>)> {
320    if let Some(out_spec) = transition.output_spec() {
321        let branches = enumerate_branches(out_spec);
322        if branches.len() <= 1 {
323            let places: HashSet<String> = branches
324                .into_iter()
325                .flat_map(|b| b.into_iter().map(|p| p.name().to_string()))
326                .collect();
327            vec![(0, places)]
328        } else {
329            branches
330                .into_iter()
331                .enumerate()
332                .map(|(i, b)| {
333                    let places: HashSet<String> =
334                        b.into_iter().map(|p| p.name().to_string()).collect();
335                    (i, places)
336                })
337                .collect()
338        }
339    } else {
340        vec![(0, HashSet::new())]
341    }
342}
343
344fn compute_successor(
345    net: &PetriNet,
346    current: &StateClass,
347    fired_clock: usize,
348    fired_name: &str,
349    output_places: &HashSet<String>,
350    env_places: &HashSet<&str>,
351    env_mode: &EnvironmentAnalysisMode,
352) -> StateClass {
353    let transition = net
354        .transitions()
355        .iter()
356        .find(|t| t.name() == fired_name)
357        .unwrap();
358
359    // 1. Compute new marking
360    let new_marking = fire_transition_marking(
361        &current.marking,
362        transition,
363        output_places,
364        env_places,
365        env_mode,
366    );
367
368    // 2. Determine persistent and newly enabled transitions
369    let new_enabled_all = find_enabled_transitions(net, &new_marking, env_places, env_mode);
370
371    let mut persistent = Vec::new();
372    let mut persistent_indices = Vec::new();
373    for (i, name) in current.enabled_transitions.iter().enumerate() {
374        if name != fired_name && new_enabled_all.contains(name) {
375            persistent.push(name.clone());
376            persistent_indices.push(i);
377        }
378    }
379
380    let persistent_set: HashSet<&String> = persistent.iter().collect();
381    let newly_enabled: Vec<String> = new_enabled_all
382        .into_iter()
383        .filter(|n| !persistent_set.contains(n))
384        .collect();
385
386    // 3. Compute successor DBM
387    let new_lower_bounds: Vec<f64> = newly_enabled
388        .iter()
389        .map(|name| {
390            let t = net
391                .transitions()
392                .iter()
393                .find(|t| t.name() == name.as_str())
394                .unwrap();
395            t.timing().earliest() as f64 / 1000.0
396        })
397        .collect();
398    let new_upper_bounds: Vec<f64> = newly_enabled
399        .iter()
400        .map(|name| {
401            let t = net
402                .transitions()
403                .iter()
404                .find(|t| t.name() == name.as_str())
405                .unwrap();
406            t.timing().latest() as f64 / 1000.0
407        })
408        .collect();
409
410    let new_dbm = current.dbm.fire_transition(
411        fired_clock,
412        &newly_enabled,
413        &new_lower_bounds,
414        &new_upper_bounds,
415        &persistent_indices,
416    );
417    let new_dbm = new_dbm.let_time_pass();
418
419    let mut all_enabled = persistent;
420    all_enabled.extend(newly_enabled);
421
422    StateClass::new(new_marking, new_dbm, all_enabled)
423}
424
425fn fire_transition_marking(
426    marking: &MarkingState,
427    transition: &libpetri_core::transition::Transition,
428    output_places: &HashSet<String>,
429    env_places: &HashSet<&str>,
430    env_mode: &EnvironmentAnalysisMode,
431) -> MarkingState {
432    let mut builder = MarkingStateBuilder::new();
433
434    // Copy current marking
435    for (place, count) in marking.places() {
436        builder = builder.tokens(place, count);
437    }
438
439    // Consume from inputs (skip env places in AlwaysAvailable/Bounded modes)
440    for spec in transition.input_specs() {
441        let place_name = spec.place_name();
442        let is_env = env_places.contains(place_name);
443
444        if is_env && *env_mode != EnvironmentAnalysisMode::Ignore {
445            // Don't consume from environment places in AlwaysAvailable/Bounded modes
446            continue;
447        }
448
449        let to_consume = input_consume_count(spec);
450        let current = marking.count(place_name);
451        let remaining = current.saturating_sub(to_consume);
452        builder = builder.tokens(place_name, remaining);
453    }
454
455    // Reset places
456    for arc in transition.resets() {
457        builder = builder.tokens(arc.place.name(), 0);
458    }
459
460    let result = builder.build();
461
462    // Produce to outputs
463    let mut output_builder = MarkingStateBuilder::new();
464    for (place, count) in result.places() {
465        output_builder = output_builder.tokens(place, count);
466    }
467    for place in output_places {
468        let current = result.count(place);
469        output_builder = output_builder.tokens(place.as_str(), current + 1);
470    }
471
472    output_builder.build()
473}
474
475fn input_consume_count(spec: &In) -> usize {
476    match spec {
477        In::One { .. } => 1,
478        In::Exactly { count, .. } => *count,
479        In::All { .. } => 1, // Analysis: consume minimum (1 token)
480        In::AtLeast { minimum, .. } => *minimum,
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use libpetri_core::input::one;
488    use libpetri_core::output::out_place;
489    use libpetri_core::place::Place;
490    use libpetri_core::transition::Transition;
491
492    #[test]
493    fn build_simple_chain() {
494        let p1 = Place::<i32>::new("p1");
495        let p2 = Place::<i32>::new("p2");
496        let t = Transition::builder("t1")
497            .input(one(&p1))
498            .output(out_place(&p2))
499            .build();
500        let net = PetriNet::builder("chain").transition(t).build();
501
502        let initial = MarkingStateBuilder::new().tokens("p1", 1).build();
503        let scg = StateClassGraph::build(&net, &initial, 1000);
504
505        assert!(scg.is_complete());
506        assert!(scg.class_count() >= 2); // initial + after firing
507        assert!(!scg.edges().is_empty());
508
509        // p2 should be reachable
510        let target = MarkingStateBuilder::new().tokens("p2", 1).build();
511        assert!(scg.is_reachable(&target));
512    }
513
514    #[test]
515    fn build_cycle() {
516        let p1 = Place::<i32>::new("p1");
517        let p2 = Place::<i32>::new("p2");
518        let t1 = Transition::builder("t1")
519            .input(one(&p1))
520            .output(out_place(&p2))
521            .build();
522        let t2 = Transition::builder("t2")
523            .input(one(&p2))
524            .output(out_place(&p1))
525            .build();
526        let net = PetriNet::builder("cycle").transitions([t1, t2]).build();
527
528        let initial = MarkingStateBuilder::new().tokens("p1", 1).build();
529        let scg = StateClassGraph::build(&net, &initial, 1000);
530
531        assert!(scg.is_complete());
532        // Two markings: p1=1 and p2=1, cycling
533        assert_eq!(scg.reachable_markings().len(), 2);
534    }
535
536    #[test]
537    fn build_truncated() {
538        let p1 = Place::<i32>::new("p1");
539        let p2 = Place::<i32>::new("p2");
540        let t1 = Transition::builder("t1")
541            .input(one(&p1))
542            .output(out_place(&p2))
543            .build();
544        let t2 = Transition::builder("t2")
545            .input(one(&p2))
546            .output(out_place(&p1))
547            .build();
548        let net = PetriNet::builder("cycle").transitions([t1, t2]).build();
549
550        let initial = MarkingStateBuilder::new().tokens("p1", 1).build();
551        let scg = StateClassGraph::build(&net, &initial, 1); // max 1 class
552
553        assert!(!scg.is_complete());
554    }
555
556    #[test]
557    fn build_no_enabled_transitions() {
558        let p1 = Place::<i32>::new("p1");
559        let p2 = Place::<i32>::new("p2");
560        let t = Transition::builder("t1")
561            .input(one(&p1))
562            .output(out_place(&p2))
563            .build();
564        let net = PetriNet::builder("test").transition(t).build();
565
566        // No tokens in p1 — nothing enabled
567        let initial = MarkingState::new();
568        let scg = StateClassGraph::build(&net, &initial, 1000);
569
570        assert!(scg.is_complete());
571        assert_eq!(scg.class_count(), 1); // just the initial class
572        assert!(scg.edges().is_empty());
573    }
574
575    #[test]
576    fn build_fork_net() {
577        let p = Place::<i32>::new("p");
578        let a = Place::<i32>::new("a");
579        let b = Place::<i32>::new("b");
580
581        let t = Transition::builder("t1")
582            .input(one(&p))
583            .output(libpetri_core::output::and(vec![
584                out_place(&a),
585                out_place(&b),
586            ]))
587            .build();
588        let net = PetriNet::builder("fork").transition(t).build();
589
590        let initial = MarkingStateBuilder::new().tokens("p", 1).build();
591        let scg = StateClassGraph::build(&net, &initial, 1000);
592
593        let target = MarkingStateBuilder::new()
594            .tokens("a", 1)
595            .tokens("b", 1)
596            .build();
597        assert!(scg.is_reachable(&target));
598    }
599
600    #[test]
601    fn build_xor_net() {
602        let p = Place::<i32>::new("p");
603        let a = Place::<i32>::new("a");
604        let b = Place::<i32>::new("b");
605
606        let t = Transition::builder("t1")
607            .input(one(&p))
608            .output(libpetri_core::output::xor(vec![
609                out_place(&a),
610                out_place(&b),
611            ]))
612            .build();
613        let net = PetriNet::builder("xor").transition(t).build();
614
615        let initial = MarkingStateBuilder::new().tokens("p", 1).build();
616        let scg = StateClassGraph::build(&net, &initial, 1000);
617
618        // Both branches should be reachable
619        let target_a = MarkingStateBuilder::new().tokens("a", 1).build();
620        let target_b = MarkingStateBuilder::new().tokens("b", 1).build();
621        assert!(scg.is_reachable(&target_a));
622        assert!(scg.is_reachable(&target_b));
623    }
624
625    #[test]
626    fn build_inhibitor_net() {
627        let p1 = Place::<i32>::new("p1");
628        let p2 = Place::<i32>::new("p2");
629        let p_inh = Place::<i32>::new("inh");
630
631        let t = Transition::builder("t1")
632            .input(one(&p1))
633            .inhibitor(libpetri_core::arc::inhibitor(&p_inh))
634            .output(out_place(&p2))
635            .build();
636        let net = PetriNet::builder("test").transition(t).build();
637
638        // With inhibitor present — transition disabled
639        let initial_blocked = MarkingStateBuilder::new()
640            .tokens("p1", 1)
641            .tokens("inh", 1)
642            .build();
643        let scg = StateClassGraph::build(&net, &initial_blocked, 1000);
644        assert_eq!(scg.class_count(), 1); // no transitions fire
645        assert!(scg.edges().is_empty());
646
647        // Without inhibitor — transition enabled
648        let initial_free = MarkingStateBuilder::new().tokens("p1", 1).build();
649        let scg2 = StateClassGraph::build(&net, &initial_free, 1000);
650        assert!(scg2.class_count() >= 2);
651        let target = MarkingStateBuilder::new().tokens("p2", 1).build();
652        assert!(scg2.is_reachable(&target));
653    }
654
655    #[test]
656    fn build_with_timing_constraints() {
657        let p1 = Place::<i32>::new("p1");
658        let p2 = Place::<i32>::new("p2");
659        let p3 = Place::<i32>::new("p3");
660
661        let t1 = Transition::builder("t1")
662            .input(one(&p1))
663            .output(out_place(&p2))
664            .timing(libpetri_core::timing::delayed(100))
665            .build();
666        let t2 = Transition::builder("t2")
667            .input(one(&p2))
668            .output(out_place(&p3))
669            .timing(libpetri_core::timing::window(50, 200))
670            .build();
671
672        let net = PetriNet::builder("timed").transitions([t1, t2]).build();
673
674        let initial = MarkingStateBuilder::new().tokens("p1", 1).build();
675        let scg = StateClassGraph::build(&net, &initial, 1000);
676
677        assert!(scg.is_complete());
678        let target = MarkingStateBuilder::new().tokens("p3", 1).build();
679        assert!(scg.is_reachable(&target));
680
681        // Check that DBMs have timing information
682        let initial_class = &scg.classes()[0];
683        assert!(!initial_class.dbm.is_empty());
684    }
685
686    #[test]
687    fn build_concurrent_transitions() {
688        // Two transitions enabled concurrently with different timings
689        let p1 = Place::<i32>::new("p1");
690        let p2 = Place::<i32>::new("p2");
691        let out_a = Place::<i32>::new("a");
692        let out_b = Place::<i32>::new("b");
693
694        let t1 = Transition::builder("t1")
695            .input(one(&p1))
696            .output(out_place(&out_a))
697            .timing(libpetri_core::timing::delayed(100))
698            .build();
699        let t2 = Transition::builder("t2")
700            .input(one(&p2))
701            .output(out_place(&out_b))
702            .timing(libpetri_core::timing::delayed(200))
703            .build();
704
705        let net = PetriNet::builder("concurrent")
706            .transitions([t1, t2])
707            .build();
708
709        let initial = MarkingStateBuilder::new()
710            .tokens("p1", 1)
711            .tokens("p2", 1)
712            .build();
713        let scg = StateClassGraph::build(&net, &initial, 1000);
714
715        assert!(scg.is_complete());
716        // Both transitions should fire
717        let target = MarkingStateBuilder::new()
718            .tokens("a", 1)
719            .tokens("b", 1)
720            .build();
721        assert!(scg.is_reachable(&target));
722    }
723
724    #[test]
725    fn build_env_always_available() {
726        let p_env = Place::<i32>::new("env");
727        let p_ready = Place::<i32>::new("ready");
728        let p_out = Place::<i32>::new("out");
729
730        let t = Transition::builder("t1")
731            .input(one(&p_env))
732            .input(one(&p_ready))
733            .output(out_place(&p_out))
734            .build();
735
736        let net = PetriNet::builder("env").transition(t).build();
737
738        let initial = MarkingStateBuilder::new().tokens("ready", 1).build();
739
740        // Without env mode — env place has no tokens, transition blocked
741        let scg_no_env = StateClassGraph::build(&net, &initial, 100);
742        assert_eq!(scg_no_env.class_count(), 1);
743
744        // With always-available — transition should fire
745        let scg_env = StateClassGraph::build_with_env(
746            &net,
747            &initial,
748            100,
749            &["env"],
750            &EnvironmentAnalysisMode::AlwaysAvailable,
751        );
752        assert!(scg_env.class_count() >= 2);
753        let target = MarkingStateBuilder::new().tokens("out", 1).build();
754        assert!(scg_env.is_reachable(&target));
755    }
756
757    #[test]
758    fn build_env_always_available_cycles() {
759        // With always-available env, a cycle where env is consumed should work indefinitely
760        let p_env = Place::<i32>::new("env");
761        let p_ready = Place::<i32>::new("ready");
762        let p_out = Place::<i32>::new("out");
763
764        let t1 = Transition::builder("process")
765            .input(one(&p_env))
766            .input(one(&p_ready))
767            .output(out_place(&p_out))
768            .build();
769        let t2 = Transition::builder("reset")
770            .input(one(&p_out))
771            .output(out_place(&p_ready))
772            .build();
773
774        let net = PetriNet::builder("env-cycle").transitions([t1, t2]).build();
775
776        let initial = MarkingStateBuilder::new().tokens("ready", 1).build();
777
778        let scg = StateClassGraph::build_with_env(
779            &net,
780            &initial,
781            100,
782            &["env"],
783            &EnvironmentAnalysisMode::AlwaysAvailable,
784        );
785
786        assert!(scg.is_complete());
787        // Should cycle between ready and out states
788        assert!(scg.class_count() >= 2);
789        let target = MarkingStateBuilder::new().tokens("out", 1).build();
790        assert!(scg.is_reachable(&target));
791    }
792
793    #[test]
794    fn edge_count_matches_edges() {
795        let p1 = Place::<i32>::new("p1");
796        let p2 = Place::<i32>::new("p2");
797        let t = Transition::builder("t1")
798            .input(one(&p1))
799            .output(out_place(&p2))
800            .build();
801        let net = PetriNet::builder("test").transition(t).build();
802
803        let initial = MarkingStateBuilder::new().tokens("p1", 1).build();
804        let scg = StateClassGraph::build(&net, &initial, 1000);
805
806        assert_eq!(scg.edge_count(), scg.edges().len());
807    }
808
809    #[test]
810    fn enabled_transitions_returns_names() {
811        let p1 = Place::<i32>::new("p1");
812        let p2 = Place::<i32>::new("p2");
813        let t = Transition::builder("t1")
814            .input(one(&p1))
815            .output(out_place(&p2))
816            .build();
817        let net = PetriNet::builder("test").transition(t).build();
818
819        let initial = MarkingStateBuilder::new().tokens("p1", 1).build();
820        let scg = StateClassGraph::build(&net, &initial, 1000);
821
822        let enabled = scg.enabled_transitions(0);
823        assert!(enabled.contains(&"t1".to_string()));
824    }
825
826    #[test]
827    fn branch_edges_for_xor() {
828        let p = Place::<i32>::new("p");
829        let a = Place::<i32>::new("a");
830        let b = Place::<i32>::new("b");
831
832        let t = Transition::builder("t1")
833            .input(one(&p))
834            .output(libpetri_core::output::xor(vec![
835                out_place(&a),
836                out_place(&b),
837            ]))
838            .build();
839        let net = PetriNet::builder("xor").transition(t).build();
840
841        let initial = MarkingStateBuilder::new().tokens("p", 1).build();
842        let scg = StateClassGraph::build(&net, &initial, 1000);
843
844        let edges = scg.branch_edges(0, "t1");
845        assert_eq!(edges.len(), 2); // two XOR branches
846        let branch_indices: HashSet<usize> = edges.iter().map(|e| e.branch_index).collect();
847        assert!(branch_indices.contains(&0));
848        assert!(branch_indices.contains(&1));
849    }
850
851    #[test]
852    fn predecessors_and_successors() {
853        let p1 = Place::<i32>::new("p1");
854        let p2 = Place::<i32>::new("p2");
855        let t = Transition::builder("t1")
856            .input(one(&p1))
857            .output(out_place(&p2))
858            .build();
859        let net = PetriNet::builder("test").transition(t).build();
860
861        let initial = MarkingStateBuilder::new().tokens("p1", 1).build();
862        let scg = StateClassGraph::build(&net, &initial, 1000);
863
864        // Initial class (0) should have successors
865        assert!(!scg.successors(0).is_empty());
866        // Successor should have predecessors pointing back to 0
867        let succ = scg.successors(0)[0];
868        assert!(scg.predecessors(succ).contains(&0));
869    }
870}