Skip to main content

jugar_probar/playbook/
state_machine.rs

1//! State machine validation and analysis.
2//!
3//! Implements reachability analysis, orphan detection, and determinism checks
4//! following TLA+ and model checking principles.
5//! Reference: Lamport, "Specifying Systems" (2002)
6
7use super::schema::{Playbook, Transition};
8use std::collections::{HashMap, HashSet, VecDeque};
9
10/// Result of state machine validation.
11#[derive(Debug, Clone)]
12pub struct ValidationResult {
13    /// Whether the state machine is valid
14    pub is_valid: bool,
15    /// List of detected issues
16    pub issues: Vec<ValidationIssue>,
17    /// Reachability information
18    pub reachability: ReachabilityInfo,
19    /// Determinism analysis
20    pub determinism: DeterminismInfo,
21}
22
23/// Information about state reachability.
24#[derive(Debug, Clone, Default)]
25pub struct ReachabilityInfo {
26    /// States reachable from initial state
27    pub reachable_states: HashSet<String>,
28    /// States that cannot be reached (orphans)
29    pub orphaned_states: HashSet<String>,
30    /// Final states that are reachable
31    pub reachable_final_states: HashSet<String>,
32    /// Whether at least one final state is reachable
33    pub can_reach_final: bool,
34}
35
36/// Information about state machine determinism.
37#[derive(Debug, Clone, Default)]
38pub struct DeterminismInfo {
39    /// Whether the machine is deterministic
40    pub is_deterministic: bool,
41    /// Non-deterministic transitions (same source + event)
42    pub non_deterministic_pairs: Vec<(String, String)>,
43}
44
45/// Types of validation issues.
46#[derive(Debug, Clone)]
47pub enum ValidationIssue {
48    /// State is not reachable from initial state
49    OrphanedState { state_id: String },
50    /// No path to any final state
51    NoPathToFinal { from_state: String },
52    /// Dead-end state (non-final with no outgoing transitions)
53    DeadEndState { state_id: String },
54    /// Non-deterministic transitions
55    NonDeterministic {
56        state_id: String,
57        event: String,
58        transitions: Vec<String>,
59    },
60    /// Missing event handler
61    UnhandledEvent { state_id: String, event: String },
62    /// Self-loop without guard (potential infinite loop)
63    UnguardedSelfLoop { transition_id: String },
64}
65
66impl ValidationIssue {
67    /// Get the severity of this issue.
68    pub fn severity(&self) -> IssueSeverity {
69        match self {
70            ValidationIssue::OrphanedState { .. } => IssueSeverity::Error,
71            ValidationIssue::NoPathToFinal { .. } => IssueSeverity::Warning,
72            ValidationIssue::DeadEndState { .. } => IssueSeverity::Error,
73            ValidationIssue::NonDeterministic { .. } => IssueSeverity::Warning,
74            ValidationIssue::UnhandledEvent { .. } => IssueSeverity::Info,
75            ValidationIssue::UnguardedSelfLoop { .. } => IssueSeverity::Warning,
76        }
77    }
78}
79
80/// Severity levels for validation issues.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
82pub enum IssueSeverity {
83    Info,
84    Warning,
85    Error,
86}
87
88/// State machine validator.
89pub struct StateMachineValidator<'a> {
90    playbook: &'a Playbook,
91}
92
93impl<'a> StateMachineValidator<'a> {
94    /// Create a new validator for the given playbook.
95    pub fn new(playbook: &'a Playbook) -> Self {
96        Self { playbook }
97    }
98
99    /// Perform full validation of the state machine.
100    pub fn validate(&self) -> ValidationResult {
101        contract_pre_playbook_state_machine!();
102        let mut issues = Vec::new();
103
104        // Compute reachability
105        let reachability = self.compute_reachability();
106
107        // Report orphaned states
108        for state_id in &reachability.orphaned_states {
109            issues.push(ValidationIssue::OrphanedState {
110                state_id: state_id.clone(),
111            });
112        }
113
114        // Check for dead-end states
115        self.check_dead_ends(&reachability, &mut issues);
116
117        // Check determinism
118        let determinism = self.check_determinism(&mut issues);
119
120        // Check for unguarded self-loops
121        self.check_self_loops(&mut issues);
122
123        // Check paths to final states
124        self.check_final_reachability(&reachability, &mut issues);
125
126        let has_errors = issues.iter().any(|i| i.severity() == IssueSeverity::Error);
127
128        ValidationResult {
129            is_valid: !has_errors,
130            issues,
131            reachability,
132            determinism,
133        }
134    }
135
136    /// Compute which states are reachable from the initial state using BFS.
137    fn compute_reachability(&self) -> ReachabilityInfo {
138        contract_pre_playbook_state_machine!();
139        let mut reachable = HashSet::new();
140        let mut queue = VecDeque::new();
141
142        // Start from initial state
143        queue.push_back(self.playbook.machine.initial.clone());
144        reachable.insert(self.playbook.machine.initial.clone());
145
146        // BFS traversal
147        while let Some(current) = queue.pop_front() {
148            for transition in &self.playbook.machine.transitions {
149                if transition.from == current && !reachable.contains(&transition.to) {
150                    reachable.insert(transition.to.clone());
151                    queue.push_back(transition.to.clone());
152                }
153            }
154        }
155
156        // Find orphaned states
157        let all_states: HashSet<_> = self.playbook.machine.states.keys().cloned().collect();
158        let orphaned: HashSet<_> = all_states.difference(&reachable).cloned().collect();
159
160        // Find reachable final states
161        let final_states: HashSet<_> = reachable
162            .iter()
163            .filter(|id| {
164                self.playbook
165                    .machine
166                    .states
167                    .get(*id)
168                    .is_some_and(|s| s.final_state)
169            })
170            .cloned()
171            .collect();
172
173        ReachabilityInfo {
174            reachable_states: reachable,
175            orphaned_states: orphaned,
176            can_reach_final: !final_states.is_empty(),
177            reachable_final_states: final_states,
178        }
179    }
180
181    /// Check for dead-end states (non-final states with no outgoing transitions).
182    fn check_dead_ends(&self, reachability: &ReachabilityInfo, issues: &mut Vec<ValidationIssue>) {
183        // Build outgoing transition map
184        let mut outgoing: HashMap<&str, Vec<&Transition>> = HashMap::new();
185        for transition in &self.playbook.machine.transitions {
186            outgoing
187                .entry(transition.from.as_str())
188                .or_default()
189                .push(transition);
190        }
191
192        // Check each reachable non-final state
193        for state_id in &reachability.reachable_states {
194            if let Some(state) = self.playbook.machine.states.get(state_id) {
195                if !state.final_state && !outgoing.contains_key(state_id.as_str()) {
196                    issues.push(ValidationIssue::DeadEndState {
197                        state_id: state_id.clone(),
198                    });
199                }
200            }
201        }
202    }
203
204    /// Check for non-deterministic transitions.
205    fn check_determinism(&self, issues: &mut Vec<ValidationIssue>) -> DeterminismInfo {
206        contract_pre_playbook_state_machine!();
207        // Group transitions by (source_state, event)
208        let mut transition_map: HashMap<(String, String), Vec<&Transition>> = HashMap::new();
209
210        for transition in &self.playbook.machine.transitions {
211            transition_map
212                .entry((transition.from.clone(), transition.event.clone()))
213                .or_default()
214                .push(transition);
215        }
216
217        let mut non_deterministic_pairs: Vec<(String, String)> = Vec::new();
218
219        // Find non-deterministic cases (multiple transitions for same state+event without guards)
220        for (key, trans_vec) in &transition_map {
221            let (state_id, event) = key;
222            if trans_vec.len() > 1 {
223                // Check if all have guards (makes it deterministic)
224                let all_guarded = trans_vec.iter().all(|t| t.guard.is_some());
225                if !all_guarded {
226                    non_deterministic_pairs.push((state_id.clone(), event.clone()));
227                    issues.push(ValidationIssue::NonDeterministic {
228                        state_id: state_id.clone(),
229                        event: event.clone(),
230                        transitions: trans_vec.iter().map(|t| t.id.clone()).collect(),
231                    });
232                }
233            }
234        }
235
236        DeterminismInfo {
237            is_deterministic: non_deterministic_pairs.is_empty(),
238            non_deterministic_pairs,
239        }
240    }
241
242    /// Check for unguarded self-loops.
243    fn check_self_loops(&self, issues: &mut Vec<ValidationIssue>) {
244        for transition in &self.playbook.machine.transitions {
245            if transition.from == transition.to && transition.guard.is_none() {
246                issues.push(ValidationIssue::UnguardedSelfLoop {
247                    transition_id: transition.id.clone(),
248                });
249            }
250        }
251    }
252
253    /// Check if all reachable states can reach a final state.
254    fn check_final_reachability(
255        &self,
256        reachability: &ReachabilityInfo,
257        issues: &mut Vec<ValidationIssue>,
258    ) {
259        if reachability.reachable_final_states.is_empty() {
260            return; // No final states defined, skip this check
261        }
262
263        // Compute reverse reachability from final states
264        let mut can_reach_final = reachability.reachable_final_states.clone();
265        let mut changed = true;
266
267        while changed {
268            changed = false;
269            for transition in &self.playbook.machine.transitions {
270                if can_reach_final.contains(&transition.to)
271                    && !can_reach_final.contains(&transition.from)
272                {
273                    can_reach_final.insert(transition.from.clone());
274                    changed = true;
275                }
276            }
277        }
278
279        // Report states that cannot reach final
280        for state_id in &reachability.reachable_states {
281            if !can_reach_final.contains(state_id) {
282                issues.push(ValidationIssue::NoPathToFinal {
283                    from_state: state_id.clone(),
284                });
285            }
286        }
287    }
288}
289
290/// Generate a state diagram in DOT format for visualization.
291pub fn to_dot(playbook: &Playbook) -> String {
292    let mut dot = String::new();
293    dot.push_str("digraph StateMachine {\n");
294    dot.push_str("  rankdir=LR;\n");
295    dot.push_str("  node [shape=ellipse];\n");
296
297    // Mark initial state
298    dot.push_str(&format!(
299        "  __start [shape=point];\n  __start -> \"{}\";\n",
300        playbook.machine.initial
301    ));
302
303    // Add states
304    for (id, state) in &playbook.machine.states {
305        let shape = if state.final_state {
306            "doublecircle"
307        } else {
308            "ellipse"
309        };
310        dot.push_str(&format!("  \"{}\" [shape={}];\n", id, shape));
311    }
312
313    // Add transitions
314    for transition in &playbook.machine.transitions {
315        let label = if let Some(guard) = &transition.guard {
316            format!("{} [{}]", transition.event, guard)
317        } else {
318            transition.event.clone()
319        };
320        dot.push_str(&format!(
321            "  \"{}\" -> \"{}\" [label=\"{}\"];\n",
322            transition.from, transition.to, label
323        ));
324    }
325
326    dot.push_str("}\n");
327    dot
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::playbook::schema::Playbook;
334
335    const VALID_PLAYBOOK: &str = r#"
336version: "1.0"
337machine:
338  id: "test"
339  initial: "start"
340  states:
341    start:
342      id: "start"
343    middle:
344      id: "middle"
345    end:
346      id: "end"
347      final_state: true
348  transitions:
349    - id: "t1"
350      from: "start"
351      to: "middle"
352      event: "next"
353    - id: "t2"
354      from: "middle"
355      to: "end"
356      event: "finish"
357"#;
358
359    #[test]
360    fn test_valid_state_machine() {
361        let playbook = Playbook::from_yaml(VALID_PLAYBOOK).expect("parse");
362        let validator = StateMachineValidator::new(&playbook);
363        let result = validator.validate();
364
365        assert!(result.is_valid);
366        assert!(result.reachability.orphaned_states.is_empty());
367        assert!(result.reachability.can_reach_final);
368        assert!(result.determinism.is_deterministic);
369    }
370
371    #[test]
372    fn test_detect_orphaned_state() {
373        let yaml = r#"
374version: "1.0"
375machine:
376  id: "test"
377  initial: "start"
378  states:
379    start:
380      id: "start"
381    orphan:
382      id: "orphan"
383    end:
384      id: "end"
385      final_state: true
386  transitions:
387    - id: "t1"
388      from: "start"
389      to: "end"
390      event: "finish"
391"#;
392        let playbook = Playbook::from_yaml(yaml).expect("parse");
393        let validator = StateMachineValidator::new(&playbook);
394        let result = validator.validate();
395
396        assert!(!result.is_valid);
397        assert!(result.reachability.orphaned_states.contains("orphan"));
398        assert!(result.issues.iter().any(
399            |i| matches!(i, ValidationIssue::OrphanedState { state_id } if state_id == "orphan")
400        ));
401    }
402
403    #[test]
404    fn test_detect_dead_end() {
405        let yaml = r#"
406version: "1.0"
407machine:
408  id: "test"
409  initial: "start"
410  states:
411    start:
412      id: "start"
413    dead_end:
414      id: "dead_end"
415  transitions:
416    - id: "t1"
417      from: "start"
418      to: "dead_end"
419      event: "go"
420"#;
421        let playbook = Playbook::from_yaml(yaml).expect("parse");
422        let validator = StateMachineValidator::new(&playbook);
423        let result = validator.validate();
424
425        assert!(!result.is_valid);
426        assert!(result.issues.iter().any(
427            |i| matches!(i, ValidationIssue::DeadEndState { state_id } if state_id == "dead_end")
428        ));
429    }
430
431    #[test]
432    fn test_detect_non_deterministic() {
433        let yaml = r#"
434version: "1.0"
435machine:
436  id: "test"
437  initial: "start"
438  states:
439    start:
440      id: "start"
441    end1:
442      id: "end1"
443      final_state: true
444    end2:
445      id: "end2"
446      final_state: true
447  transitions:
448    - id: "t1"
449      from: "start"
450      to: "end1"
451      event: "go"
452    - id: "t2"
453      from: "start"
454      to: "end2"
455      event: "go"
456"#;
457        let playbook = Playbook::from_yaml(yaml).expect("parse");
458        let validator = StateMachineValidator::new(&playbook);
459        let result = validator.validate();
460
461        assert!(!result.determinism.is_deterministic);
462        assert!(result.issues.iter().any(
463            |i| matches!(i, ValidationIssue::NonDeterministic { state_id, event, .. } if state_id == "start" && event == "go")
464        ));
465    }
466
467    #[test]
468    fn test_dot_generation() {
469        let playbook = Playbook::from_yaml(VALID_PLAYBOOK).expect("parse");
470        let dot = to_dot(&playbook);
471
472        assert!(dot.contains("digraph StateMachine"));
473        assert!(dot.contains("__start"));
474        assert!(dot.contains("doublecircle")); // final state
475        assert!(dot.contains("\"start\" -> \"middle\""));
476    }
477}