Skip to main content

libpetri_verification/
analyzer.rs

1use std::collections::HashSet;
2
3use libpetri_core::output::enumerate_branches;
4use libpetri_core::petri_net::PetriNet;
5
6use crate::environment::EnvironmentAnalysisMode;
7use crate::marking_state::MarkingState;
8use crate::scc::{compute_sccs, find_terminal_sccs};
9use crate::state_class_graph::StateClassGraph;
10
11/// Result of liveness analysis.
12#[derive(Debug)]
13pub struct LivenessResult {
14    pub state_class_graph: StateClassGraph,
15    pub all_sccs: Vec<Vec<usize>>,
16    pub terminal_sccs: Vec<Vec<usize>>,
17    pub goal_classes: HashSet<usize>,
18    pub can_reach_goal: HashSet<usize>,
19    pub is_goal_live: bool,
20    pub is_l4_live: bool,
21    pub is_complete: bool,
22    pub report: String,
23}
24
25/// XOR branch coverage information for a single transition.
26#[derive(Debug)]
27pub struct XorBranchInfo {
28    pub transition_name: String,
29    pub total_branches: usize,
30    pub taken_branches: HashSet<usize>,
31    pub untaken_branches: HashSet<usize>,
32}
33
34/// Result of XOR branch analysis.
35#[derive(Debug)]
36pub struct XorBranchAnalysis {
37    pub branches: Vec<XorBranchInfo>,
38}
39
40impl XorBranchAnalysis {
41    pub fn unreachable_branches(&self) -> Vec<(&str, &HashSet<usize>)> {
42        self.branches
43            .iter()
44            .filter(|b| !b.untaken_branches.is_empty())
45            .map(|b| (b.transition_name.as_str(), &b.untaken_branches))
46            .collect()
47    }
48
49    pub fn is_xor_complete(&self) -> bool {
50        self.branches.iter().all(|b| b.untaken_branches.is_empty())
51    }
52
53    pub fn report(&self) -> String {
54        if self.branches.is_empty() {
55            return "No XOR transitions in net.".to_string();
56        }
57
58        let mut lines = Vec::new();
59        lines.push("XOR Branch Coverage Analysis".to_string());
60        lines.push("============================\n".to_string());
61
62        for info in &self.branches {
63            lines.push(format!("Transition: {}", info.transition_name));
64            lines.push(format!("  Branches: {}", info.total_branches));
65            lines.push(format!(
66                "  Taken: [{:?}]",
67                info.taken_branches.iter().collect::<Vec<_>>()
68            ));
69
70            if info.untaken_branches.is_empty() {
71                lines.push("  All branches reachable".to_string());
72            } else {
73                lines.push(format!(
74                    "  UNREACHABLE: [{:?}]",
75                    info.untaken_branches.iter().collect::<Vec<_>>()
76                ));
77            }
78            lines.push(String::new());
79        }
80
81        if self.is_xor_complete() {
82            lines.push("RESULT: All XOR branches are reachable.".to_string());
83        } else {
84            lines.push("RESULT: Some XOR branches are unreachable!".to_string());
85        }
86
87        lines.join("\n")
88    }
89}
90
91/// Formal analyzer for Time Petri Nets using the State Class Graph method.
92pub struct TimePetriNetAnalyzer<'a> {
93    net: &'a PetriNet,
94    initial_marking: MarkingState,
95    goal_places: Vec<String>,
96    max_classes: usize,
97    env_places: Vec<String>,
98    env_mode: EnvironmentAnalysisMode,
99}
100
101impl<'a> TimePetriNetAnalyzer<'a> {
102    pub fn for_net(net: &'a PetriNet) -> TimePetriNetAnalyzerBuilder<'a> {
103        TimePetriNetAnalyzerBuilder {
104            net,
105            initial_marking: MarkingState::new(),
106            goal_places: Vec::new(),
107            max_classes: 100_000,
108            env_places: Vec::new(),
109            env_mode: EnvironmentAnalysisMode::Ignore,
110        }
111    }
112
113    /// Performs formal liveness analysis.
114    pub fn analyze(&self) -> LivenessResult {
115        let mut report = Vec::new();
116        report.push("=== TIME PETRI NET FORMAL ANALYSIS ===\n".to_string());
117        report.push("Method: State Class Graph (Berthomieu-Diaz 1991)".to_string());
118        report.push(format!("Net: {}", self.net.name()));
119        report.push(format!("Places: {}", self.net.places().len()));
120        report.push(format!("Transitions: {}", self.net.transitions().len()));
121        report.push(format!("Goal places: [{}]\n", self.goal_places.join(", ")));
122
123        // Phase 1: Build State Class Graph
124        report.push("Phase 1: Building State Class Graph...".to_string());
125        if !self.env_places.is_empty() {
126            report.push(format!("  Environment places: {}", self.env_places.len()));
127            report.push(format!("  Environment mode: {:?}", self.env_mode));
128        }
129        let env_refs: Vec<&str> = self.env_places.iter().map(|s| s.as_str()).collect();
130        let scg = StateClassGraph::build_with_env(
131            self.net,
132            &self.initial_marking,
133            self.max_classes,
134            &env_refs,
135            &self.env_mode,
136        );
137        report.push(format!("  State classes: {}", scg.class_count()));
138        report.push(format!("  Edges: {}", scg.edge_count()));
139        report.push(format!(
140            "  Complete: {}",
141            if scg.is_complete() {
142                "YES"
143            } else {
144                "NO (truncated)"
145            }
146        ));
147
148        if !scg.is_complete() {
149            report.push(format!(
150                "  WARNING: State class graph truncated at {} classes. Analysis may be incomplete.",
151                self.max_classes
152            ));
153        }
154        report.push(String::new());
155
156        // Phase 2: Identify goal state classes
157        report.push("Phase 2: Identifying goal state classes...".to_string());
158        let goal_place_refs: Vec<&str> = self.goal_places.iter().map(|s| s.as_str()).collect();
159        let mut goal_classes = HashSet::new();
160        for (idx, sc) in scg.classes().iter().enumerate() {
161            if sc.marking.has_tokens_in_any(&goal_place_refs) {
162                goal_classes.insert(idx);
163            }
164        }
165        report.push(format!("  Goal state classes: {}\n", goal_classes.len()));
166
167        // Phase 3: Compute SCCs
168        report.push("Phase 3: Computing Strongly Connected Components...".to_string());
169        let successor_fn = |idx: usize| scg.successors(idx).to_vec();
170        let all_sccs = compute_sccs(scg.class_count(), successor_fn);
171        let terminal_sccs = find_terminal_sccs(scg.class_count(), successor_fn);
172
173        report.push(format!("  Total SCCs: {}", all_sccs.len()));
174        report.push(format!("  Terminal SCCs: {}\n", terminal_sccs.len()));
175
176        // Phase 4: Check goal liveness
177        report.push("Phase 4: Verifying Goal Liveness...".to_string());
178        report
179            .push("  Property: From every reachable state, a goal state is reachable".to_string());
180
181        let mut terminal_without_goal = 0;
182        for scc in &terminal_sccs {
183            if !scc.iter().any(|idx| goal_classes.contains(idx)) {
184                terminal_without_goal += 1;
185            }
186        }
187
188        let can_reach_goal = compute_backward_reachability(&scg, &goal_classes);
189        let states_not_reaching_goal = scg.class_count() - can_reach_goal.len();
190
191        report.push(format!(
192            "  Terminal SCCs with goal: {}",
193            terminal_sccs.len() - terminal_without_goal
194        ));
195        report.push(format!(
196            "  Terminal SCCs without goal: {}",
197            terminal_without_goal
198        ));
199        report.push(format!(
200            "  States that can reach goal: {}/{}\n",
201            can_reach_goal.len(),
202            scg.class_count()
203        ));
204
205        let is_goal_live = terminal_without_goal == 0 && states_not_reaching_goal == 0;
206
207        // Phase 5: Check classical liveness (L4)
208        report.push("Phase 5: Verifying Classical Liveness (L4)...".to_string());
209        report
210            .push("  Property: Every transition can fire from every reachable marking".to_string());
211
212        let all_transition_names: HashSet<&str> =
213            self.net.transitions().iter().map(|t| t.name()).collect();
214
215        let mut terminal_missing_transitions = 0;
216        for scc in &terminal_sccs {
217            let scc_set: HashSet<usize> = scc.iter().copied().collect();
218            let mut transitions_in_scc: HashSet<String> = HashSet::new();
219
220            for &class_idx in scc {
221                for t_name in scg.enabled_transitions(class_idx) {
222                    let edges = scg.branch_edges(class_idx, t_name);
223                    for edge in edges {
224                        if scc_set.contains(&edge.to) {
225                            transitions_in_scc.insert(t_name.clone());
226                        }
227                    }
228                }
229            }
230
231            let mut missing_any = false;
232            for &t_name in &all_transition_names {
233                if !transitions_in_scc.contains(t_name) {
234                    missing_any = true;
235                    break;
236                }
237            }
238            if missing_any {
239                terminal_missing_transitions += 1;
240                let missing: Vec<&str> = all_transition_names
241                    .iter()
242                    .filter(|&&t| !transitions_in_scc.contains(t))
243                    .copied()
244                    .collect();
245                report.push(format!(
246                    "  Terminal SCC missing transitions: [{}]",
247                    missing.join(", ")
248                ));
249            }
250        }
251
252        let is_l4_live = terminal_missing_transitions == 0 && scg.is_complete();
253
254        // Summary
255        report.push("\n=== ANALYSIS RESULT ===\n".to_string());
256
257        if is_goal_live && scg.is_complete() {
258            report.push("GOAL LIVENESS VERIFIED".to_string());
259            report.push(
260                "  From every reachable state class, a goal marking is reachable.".to_string(),
261            );
262        } else if is_goal_live {
263            report.push("GOAL LIVENESS LIKELY (incomplete proof)".to_string());
264        } else {
265            report.push("GOAL LIVENESS VIOLATION".to_string());
266            if terminal_without_goal > 0 {
267                report.push(format!(
268                    "  {} terminal SCC(s) have no goal state.",
269                    terminal_without_goal
270                ));
271            }
272            if states_not_reaching_goal > 0 {
273                report.push(format!(
274                    "  {} state class(es) cannot reach goal.",
275                    states_not_reaching_goal
276                ));
277            }
278        }
279
280        report.push(String::new());
281
282        if is_l4_live {
283            report.push("CLASSICAL LIVENESS (L4) VERIFIED".to_string());
284        } else {
285            report.push("CLASSICAL LIVENESS (L4) NOT VERIFIED".to_string());
286            if terminal_missing_transitions > 0 {
287                report.push("  Some terminal SCCs don't contain all transitions.".to_string());
288            }
289            if !scg.is_complete() {
290                report.push("  (State class graph incomplete - cannot prove L4)".to_string());
291            }
292        }
293
294        let is_complete = scg.is_complete();
295
296        LivenessResult {
297            state_class_graph: scg,
298            all_sccs,
299            terminal_sccs,
300            goal_classes,
301            can_reach_goal,
302            is_goal_live,
303            is_l4_live,
304            is_complete,
305            report: report.join("\n"),
306        }
307    }
308
309    /// Analyzes XOR branch coverage for a built state class graph.
310    pub fn analyze_xor_branches(net: &PetriNet, scg: &StateClassGraph) -> XorBranchAnalysis {
311        let mut branches = Vec::new();
312
313        for transition in net.transitions() {
314            let out_spec = match transition.output_spec() {
315                Some(spec) => spec,
316                None => continue,
317            };
318
319            let all_branches = enumerate_branches(out_spec);
320            if all_branches.len() <= 1 {
321                continue;
322            }
323
324            let mut taken = HashSet::new();
325            for class_idx in 0..scg.class_count() {
326                for edge in scg.branch_edges(class_idx, transition.name()) {
327                    taken.insert(edge.branch_index);
328                }
329            }
330
331            let untaken: HashSet<usize> = (0..all_branches.len())
332                .filter(|i| !taken.contains(i))
333                .collect();
334
335            branches.push(XorBranchInfo {
336                transition_name: transition.name().to_string(),
337                total_branches: all_branches.len(),
338                taken_branches: taken,
339                untaken_branches: untaken,
340            });
341        }
342
343        XorBranchAnalysis { branches }
344    }
345}
346
347fn compute_backward_reachability(scg: &StateClassGraph, goals: &HashSet<usize>) -> HashSet<usize> {
348    let mut reachable: HashSet<usize> = goals.clone();
349    let mut queue: Vec<usize> = goals.iter().copied().collect();
350
351    while let Some(current) = queue.pop() {
352        for &pred in scg.predecessors(current) {
353            if reachable.insert(pred) {
354                queue.push(pred);
355            }
356        }
357    }
358
359    reachable
360}
361
362/// Builder for TimePetriNetAnalyzer.
363pub struct TimePetriNetAnalyzerBuilder<'a> {
364    net: &'a PetriNet,
365    initial_marking: MarkingState,
366    goal_places: Vec<String>,
367    max_classes: usize,
368    env_places: Vec<String>,
369    env_mode: EnvironmentAnalysisMode,
370}
371
372impl<'a> TimePetriNetAnalyzerBuilder<'a> {
373    pub fn initial_marking(mut self, marking: MarkingState) -> Self {
374        self.initial_marking = marking;
375        self
376    }
377
378    pub fn goal_place(mut self, place_name: &str) -> Self {
379        self.goal_places.push(place_name.to_string());
380        self
381    }
382
383    pub fn goal_places(mut self, names: &[&str]) -> Self {
384        for name in names {
385            self.goal_places.push(name.to_string());
386        }
387        self
388    }
389
390    pub fn max_classes(mut self, max: usize) -> Self {
391        self.max_classes = max;
392        self
393    }
394
395    pub fn env_place(mut self, place_name: &str) -> Self {
396        self.env_places.push(place_name.to_string());
397        self
398    }
399
400    pub fn env_places(mut self, names: &[&str]) -> Self {
401        for name in names {
402            self.env_places.push(name.to_string());
403        }
404        self
405    }
406
407    pub fn env_mode(mut self, mode: EnvironmentAnalysisMode) -> Self {
408        self.env_mode = mode;
409        self
410    }
411
412    pub fn build(self) -> TimePetriNetAnalyzer<'a> {
413        assert!(
414            !self.goal_places.is_empty(),
415            "At least one goal place must be specified"
416        );
417        TimePetriNetAnalyzer {
418            net: self.net,
419            initial_marking: self.initial_marking,
420            goal_places: self.goal_places,
421            max_classes: self.max_classes,
422            env_places: self.env_places,
423            env_mode: self.env_mode,
424        }
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::marking_state::MarkingStateBuilder;
432    use libpetri_core::input::one;
433    use libpetri_core::output::{out_place, xor};
434    use libpetri_core::place::Place;
435    use libpetri_core::transition::Transition;
436
437    #[test]
438    fn goal_liveness_circular_net() {
439        let p_a = Place::<i32>::new("A");
440        let p_b = Place::<i32>::new("B");
441
442        let t1 = Transition::builder("t1")
443            .input(one(&p_a))
444            .output(out_place(&p_b))
445            .build();
446        let t2 = Transition::builder("t2")
447            .input(one(&p_b))
448            .output(out_place(&p_a))
449            .build();
450
451        let net = PetriNet::builder("circular").transitions([t1, t2]).build();
452
453        let result = TimePetriNetAnalyzer::for_net(&net)
454            .initial_marking(MarkingStateBuilder::new().tokens("A", 1).build())
455            .goal_place("B")
456            .max_classes(100)
457            .build()
458            .analyze();
459
460        assert!(result.is_goal_live);
461        assert!(result.is_complete);
462        assert!(result.state_class_graph.class_count() >= 2);
463        assert!(result.report.contains("GOAL LIVENESS VERIFIED"));
464    }
465
466    #[test]
467    fn goal_liveness_violation_dead_end() {
468        let p_a = Place::<i32>::new("A");
469        let p_b = Place::<i32>::new("B");
470        let p_goal = Place::<i32>::new("Goal");
471
472        let t1 = Transition::builder("t1")
473            .input(one(&p_a))
474            .output(out_place(&p_b))
475            .build();
476
477        let net = PetriNet::builder("deadend").transition(t1).build();
478        let _ = &p_goal; // Goal place not connected
479
480        let result = TimePetriNetAnalyzer::for_net(&net)
481            .initial_marking(MarkingStateBuilder::new().tokens("A", 1).build())
482            .goal_place("Goal")
483            .max_classes(100)
484            .build()
485            .analyze();
486
487        assert!(!result.is_goal_live);
488        assert!(result.report.contains("GOAL LIVENESS VIOLATION"));
489    }
490
491    #[test]
492    fn l4_liveness_circular_net() {
493        let p_a = Place::<i32>::new("A");
494        let p_b = Place::<i32>::new("B");
495
496        let t1 = Transition::builder("t1")
497            .input(one(&p_a))
498            .output(out_place(&p_b))
499            .build();
500        let t2 = Transition::builder("t2")
501            .input(one(&p_b))
502            .output(out_place(&p_a))
503            .build();
504
505        let net = PetriNet::builder("circular").transitions([t1, t2]).build();
506
507        let result = TimePetriNetAnalyzer::for_net(&net)
508            .initial_marking(MarkingStateBuilder::new().tokens("A", 1).build())
509            .goal_place("A")
510            .max_classes(100)
511            .build()
512            .analyze();
513
514        assert!(result.is_l4_live);
515        assert!(result.report.contains("CLASSICAL LIVENESS (L4) VERIFIED"));
516    }
517
518    #[test]
519    fn l4_violation_dead_end() {
520        let p_a = Place::<i32>::new("A");
521        let p_b = Place::<i32>::new("B");
522
523        let t1 = Transition::builder("t1")
524            .input(one(&p_a))
525            .output(out_place(&p_b))
526            .build();
527
528        let net = PetriNet::builder("deadend").transition(t1).build();
529
530        let result = TimePetriNetAnalyzer::for_net(&net)
531            .initial_marking(MarkingStateBuilder::new().tokens("A", 1).build())
532            .goal_place("B")
533            .max_classes(100)
534            .build()
535            .analyze();
536
537        assert!(!result.is_l4_live);
538    }
539
540    #[test]
541    fn xor_branch_analysis() {
542        let p0 = Place::<i32>::new("start");
543        let p_a = Place::<i32>::new("branchA");
544        let p_b = Place::<i32>::new("branchB");
545        let p_end = Place::<i32>::new("end");
546
547        let t_choice = Transition::builder("choice")
548            .input(one(&p0))
549            .output(xor(vec![out_place(&p_a), out_place(&p_b)]))
550            .build();
551        let t_a = Transition::builder("fromA")
552            .input(one(&p_a))
553            .output(out_place(&p_end))
554            .build();
555        let t_b = Transition::builder("fromB")
556            .input(one(&p_b))
557            .output(out_place(&p_end))
558            .build();
559
560        let net = PetriNet::builder("xor")
561            .transitions([t_choice, t_a, t_b])
562            .build();
563
564        let marking = MarkingStateBuilder::new().tokens("start", 1).build();
565        let scg = StateClassGraph::build(&net, &marking, 100);
566        let analysis = TimePetriNetAnalyzer::analyze_xor_branches(&net, &scg);
567
568        assert_eq!(analysis.branches.len(), 1);
569        assert_eq!(analysis.branches[0].transition_name, "choice");
570        assert_eq!(analysis.branches[0].total_branches, 2);
571        assert_eq!(analysis.branches[0].taken_branches.len(), 2);
572        assert!(analysis.branches[0].untaken_branches.is_empty());
573        assert!(analysis.is_xor_complete());
574        assert!(analysis.unreachable_branches().is_empty());
575    }
576
577    #[test]
578    fn xor_report_generation() {
579        let p0 = Place::<i32>::new("start");
580        let p_a = Place::<i32>::new("branchA");
581        let p_b = Place::<i32>::new("branchB");
582
583        let t_choice = Transition::builder("choice")
584            .input(one(&p0))
585            .output(xor(vec![out_place(&p_a), out_place(&p_b)]))
586            .build();
587
588        let net = PetriNet::builder("xor").transition(t_choice).build();
589
590        let marking = MarkingStateBuilder::new().tokens("start", 1).build();
591        let scg = StateClassGraph::build(&net, &marking, 100);
592        let analysis = TimePetriNetAnalyzer::analyze_xor_branches(&net, &scg);
593
594        let report = analysis.report();
595        assert!(report.contains("XOR Branch Coverage"));
596        assert!(report.contains("choice"));
597    }
598
599    #[test]
600    fn no_xor_report() {
601        let p_a = Place::<i32>::new("A");
602        let p_b = Place::<i32>::new("B");
603
604        let t1 = Transition::builder("t1")
605            .input(one(&p_a))
606            .output(out_place(&p_b))
607            .build();
608
609        let net = PetriNet::builder("no-xor").transition(t1).build();
610
611        let marking = MarkingStateBuilder::new().tokens("A", 1).build();
612        let scg = StateClassGraph::build(&net, &marking, 100);
613        let analysis = TimePetriNetAnalyzer::analyze_xor_branches(&net, &scg);
614
615        assert_eq!(analysis.report(), "No XOR transitions in net.");
616    }
617
618    #[test]
619    #[should_panic(expected = "At least one goal place")]
620    fn builder_requires_goal_places() {
621        let p_a = Place::<i32>::new("A");
622        let t1 = Transition::builder("t1").input(one(&p_a)).build();
623        let net = PetriNet::builder("test").transition(t1).build();
624
625        TimePetriNetAnalyzer::for_net(&net)
626            .initial_marking(MarkingState::new())
627            .build();
628    }
629
630    #[test]
631    fn always_available_env_enables_transitions() {
632        let p_ready = Place::<i32>::new("ready");
633        let p_env = Place::<i32>::new("input");
634        let p_out = Place::<i32>::new("output");
635
636        let t1 = Transition::builder("process")
637            .input(one(&p_ready))
638            .input(one(&p_env))
639            .output(out_place(&p_out))
640            .build();
641        let t2 = Transition::builder("reset")
642            .input(one(&p_out))
643            .output(out_place(&p_ready))
644            .build();
645
646        let net = PetriNet::builder("env-net").transitions([t1, t2]).build();
647
648        let result = TimePetriNetAnalyzer::for_net(&net)
649            .initial_marking(MarkingStateBuilder::new().tokens("ready", 1).build())
650            .goal_place("output")
651            .env_place("input")
652            .env_mode(EnvironmentAnalysisMode::AlwaysAvailable)
653            .max_classes(100)
654            .build()
655            .analyze();
656
657        assert!(result.is_goal_live);
658        assert!(result.report.contains("Environment places"));
659    }
660
661    #[test]
662    fn ignore_env_treats_as_regular() {
663        let p_env = Place::<i32>::new("input");
664        let p_out = Place::<i32>::new("output");
665
666        let t1 = Transition::builder("process")
667            .input(one(&p_env))
668            .output(out_place(&p_out))
669            .build();
670
671        let net = PetriNet::builder("env-net").transition(t1).build();
672
673        let result = TimePetriNetAnalyzer::for_net(&net)
674            .initial_marking(MarkingState::new())
675            .goal_place("output")
676            .env_place("input")
677            .env_mode(EnvironmentAnalysisMode::Ignore)
678            .max_classes(100)
679            .build()
680            .analyze();
681
682        // No tokens in env place + ignore = not enabled → no goal reached
683        assert!(!result.is_goal_live);
684    }
685
686    #[test]
687    fn bounded_env_limits_tokens() {
688        let p_ready = Place::<i32>::new("ready");
689        let p_env = Place::<i32>::new("input");
690        let p_out = Place::<i32>::new("output");
691
692        let t1 = Transition::builder("process")
693            .input(one(&p_ready))
694            .input(one(&p_env))
695            .output(out_place(&p_out))
696            .build();
697        let t2 = Transition::builder("reset")
698            .input(one(&p_out))
699            .output(out_place(&p_ready))
700            .build();
701
702        let net = PetriNet::builder("env-net").transitions([t1, t2]).build();
703
704        let result = TimePetriNetAnalyzer::for_net(&net)
705            .initial_marking(MarkingStateBuilder::new().tokens("ready", 1).build())
706            .goal_place("output")
707            .env_place("input")
708            .env_mode(EnvironmentAnalysisMode::Bounded { max_tokens: 1 })
709            .max_classes(100)
710            .build()
711            .analyze();
712
713        assert!(result.is_complete);
714    }
715
716    #[test]
717    fn comprehensive_report() {
718        let p_a = Place::<i32>::new("A");
719        let p_b = Place::<i32>::new("B");
720
721        let t1 = Transition::builder("t1")
722            .input(one(&p_a))
723            .output(out_place(&p_b))
724            .build();
725        let t2 = Transition::builder("t2")
726            .input(one(&p_b))
727            .output(out_place(&p_a))
728            .build();
729
730        let net = PetriNet::builder("circular").transitions([t1, t2]).build();
731
732        let result = TimePetriNetAnalyzer::for_net(&net)
733            .initial_marking(MarkingStateBuilder::new().tokens("A", 1).build())
734            .goal_place("A")
735            .max_classes(100)
736            .build()
737            .analyze();
738
739        assert!(result.report.contains("TIME PETRI NET FORMAL ANALYSIS"));
740        assert!(result.report.contains("Berthomieu-Diaz"));
741        assert!(result.report.contains("State classes:"));
742        assert!(result.report.contains("Terminal SCCs:"));
743        assert!(result.report.contains("ANALYSIS RESULT"));
744    }
745}