Skip to main content

wasm4pm_types/
choice_graph.rs

1//! Spec-compliant Choice Graph (Definition 1, arXiv:2505.07052).
2//!
3//! Kourani, Park, van der Aalst, "Unlocking Non-Block-Structured Decisions:
4//! Inductive Mining with Choice Graphs."
5//!
6//! A Choice Graph is a directed acyclic graph with a unique Start node (no
7//! incoming edges) and a unique End node (no outgoing edges) such that every
8//! node lies on at least one Start→End path. Interior nodes are either inline
9//! activities or references to a sub-model in a `PowlArena`.
10
11use serde::{Deserialize, Serialize};
12
13/// A node in a `ChoiceGraph`.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub enum ChoiceGraphNode {
16    /// Unique Start marker (no incoming edges).
17    Start,
18    /// Unique End marker (no outgoing edges).
19    End,
20    /// Inline activity by label. Normalized to `SubModel(_)` when added to an
21    /// arena.
22    Activity(String),
23    /// Reference to a sub-model by arena root index.
24    SubModel(u32),
25}
26
27/// Validated Choice Graph (paper Definition 1).
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ChoiceGraph {
30    pub nodes: Vec<ChoiceGraphNode>,
31    pub edges: Vec<(usize, usize)>,
32    pub start_idx: usize,
33    pub end_idx: usize,
34}
35
36/// Validation errors raised by `ChoiceGraph::new`.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub enum ChoiceGraphError {
39    NoStart,
40    NoEnd,
41    MultipleStarts,
42    MultipleEnds,
43    StartHasIncoming,
44    EndHasOutgoing,
45    EdgeOutOfBounds,
46    Cyclic,
47    NodeNotOnStartEndPath,
48}
49
50impl core::fmt::Display for ChoiceGraphError {
51    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
52        let s = match self {
53            ChoiceGraphError::NoStart => "no Start node",
54            ChoiceGraphError::NoEnd => "no End node",
55            ChoiceGraphError::MultipleStarts => "multiple Start nodes",
56            ChoiceGraphError::MultipleEnds => "multiple End nodes",
57            ChoiceGraphError::StartHasIncoming => "Start node has incoming edges",
58            ChoiceGraphError::EndHasOutgoing => "End node has outgoing edges",
59            ChoiceGraphError::EdgeOutOfBounds => "edge endpoint out of bounds",
60            ChoiceGraphError::Cyclic => "graph is cyclic",
61            ChoiceGraphError::NodeNotOnStartEndPath => {
62                "node not on any Start→End path"
63            }
64        };
65        f.write_str(s)
66    }
67}
68
69impl std::error::Error for ChoiceGraphError {}
70
71impl ChoiceGraph {
72    /// Construct and validate per Definition 1.
73    ///
74    /// Auto-discovers `start_idx` / `end_idx` from the `nodes` vec.
75    pub fn new(
76        nodes: Vec<ChoiceGraphNode>,
77        edges: Vec<(usize, usize)>,
78    ) -> Result<Self, ChoiceGraphError> {
79        // 1. Locate Start / End — exactly one of each.
80        let mut start_idx: Option<usize> = None;
81        let mut end_idx: Option<usize> = None;
82        for (i, n) in nodes.iter().enumerate() {
83            match n {
84                ChoiceGraphNode::Start => {
85                    if start_idx.is_some() {
86                        return Err(ChoiceGraphError::MultipleStarts);
87                    }
88                    start_idx = Some(i);
89                }
90                ChoiceGraphNode::End => {
91                    if end_idx.is_some() {
92                        return Err(ChoiceGraphError::MultipleEnds);
93                    }
94                    end_idx = Some(i);
95                }
96                _ => {}
97            }
98        }
99        let start_idx = start_idx.ok_or(ChoiceGraphError::NoStart)?;
100        let end_idx = end_idx.ok_or(ChoiceGraphError::NoEnd)?;
101
102        // 2. Edge bounds.
103        let n = nodes.len();
104        for &(a, b) in &edges {
105            if a >= n || b >= n {
106                return Err(ChoiceGraphError::EdgeOutOfBounds);
107            }
108        }
109
110        // 3. Start has no incoming, End has no outgoing.
111        for &(a, b) in &edges {
112            if b == start_idx {
113                return Err(ChoiceGraphError::StartHasIncoming);
114            }
115            if a == end_idx {
116                return Err(ChoiceGraphError::EndHasOutgoing);
117            }
118        }
119
120        // 4. Acyclicity (DFS with white/grey/black).
121        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
122        for &(a, b) in &edges {
123            adj[a].push(b);
124        }
125        // 0 = unvisited, 1 = on stack, 2 = done.
126        let mut color = vec![0u8; n];
127        for s in 0..n {
128            if color[s] != 0 {
129                continue;
130            }
131            let mut stack: Vec<(usize, usize)> = vec![(s, 0)];
132            color[s] = 1;
133            while let Some(&(v, i)) = stack.last() {
134                if i < adj[v].len() {
135                    let w = adj[v][i];
136                    stack.last_mut().unwrap().1 += 1;
137                    match color[w] {
138                        0 => {
139                            color[w] = 1;
140                            stack.push((w, 0));
141                        }
142                        1 => return Err(ChoiceGraphError::Cyclic),
143                        _ => {}
144                    }
145                } else {
146                    color[v] = 2;
147                    stack.pop();
148                }
149            }
150        }
151
152        // 5. Every node on some Start→End path.
153        // Reachable from Start (forward) ∩ reachable to End (backward).
154        let mut radj: Vec<Vec<usize>> = vec![Vec::new(); n];
155        for &(a, b) in &edges {
156            radj[b].push(a);
157        }
158        let reach_from_start = bfs_reach(&adj, start_idx, n);
159        let reach_to_end = bfs_reach(&radj, end_idx, n);
160        for i in 0..n {
161            if !(reach_from_start[i] && reach_to_end[i]) {
162                return Err(ChoiceGraphError::NodeNotOnStartEndPath);
163            }
164        }
165
166        Ok(ChoiceGraph {
167            nodes,
168            edges,
169            start_idx,
170            end_idx,
171        })
172    }
173
174    pub fn successors(&self, node_idx: usize) -> Vec<usize> {
175        self.edges
176            .iter()
177            .filter_map(|&(a, b)| if a == node_idx { Some(b) } else { None })
178            .collect()
179    }
180
181    pub fn predecessors(&self, node_idx: usize) -> Vec<usize> {
182        self.edges
183            .iter()
184            .filter_map(|&(a, b)| if b == node_idx { Some(a) } else { None })
185            .collect()
186    }
187
188    /// True iff there is a direct Start→End edge (the empty path).
189    pub fn has_empty_path(&self) -> bool {
190        self.edges
191            .iter()
192            .any(|&(a, b)| a == self.start_idx && b == self.end_idx)
193    }
194}
195
196fn bfs_reach(adj: &[Vec<usize>], src: usize, n: usize) -> Vec<bool> {
197    let mut seen = vec![false; n];
198    if src >= n {
199        return seen;
200    }
201    let mut q: Vec<usize> = vec![src];
202    seen[src] = true;
203    while let Some(v) = q.pop() {
204        for &w in &adj[v] {
205            if !seen[w] {
206                seen[w] = true;
207                q.push(w);
208            }
209        }
210    }
211    seen
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn minimal_valid() {
220        let cg = ChoiceGraph::new(
221            vec![ChoiceGraphNode::Start, ChoiceGraphNode::End],
222            vec![(0, 1)],
223        )
224        .unwrap();
225        assert_eq!(cg.start_idx, 0);
226        assert_eq!(cg.end_idx, 1);
227        assert!(cg.has_empty_path());
228    }
229
230    #[test]
231    fn no_start() {
232        let err = ChoiceGraph::new(vec![ChoiceGraphNode::End], vec![]).unwrap_err();
233        assert_eq!(err, ChoiceGraphError::NoStart);
234    }
235
236    #[test]
237    fn no_end() {
238        let err = ChoiceGraph::new(vec![ChoiceGraphNode::Start], vec![]).unwrap_err();
239        assert_eq!(err, ChoiceGraphError::NoEnd);
240    }
241
242    #[test]
243    fn multiple_starts() {
244        let err = ChoiceGraph::new(
245            vec![
246                ChoiceGraphNode::Start,
247                ChoiceGraphNode::Start,
248                ChoiceGraphNode::End,
249            ],
250            vec![(0, 2), (1, 2)],
251        )
252        .unwrap_err();
253        assert_eq!(err, ChoiceGraphError::MultipleStarts);
254    }
255
256    #[test]
257    fn start_has_incoming() {
258        let err = ChoiceGraph::new(
259            vec![
260                ChoiceGraphNode::Start,
261                ChoiceGraphNode::Activity("a".into()),
262                ChoiceGraphNode::End,
263            ],
264            vec![(1, 0), (0, 2)],
265        )
266        .unwrap_err();
267        assert_eq!(err, ChoiceGraphError::StartHasIncoming);
268    }
269
270    #[test]
271    fn end_has_outgoing() {
272        let err = ChoiceGraph::new(
273            vec![
274                ChoiceGraphNode::Start,
275                ChoiceGraphNode::Activity("a".into()),
276                ChoiceGraphNode::End,
277            ],
278            vec![(0, 2), (2, 1)],
279        )
280        .unwrap_err();
281        assert_eq!(err, ChoiceGraphError::EndHasOutgoing);
282    }
283
284    #[test]
285    fn edge_oob() {
286        let err = ChoiceGraph::new(
287            vec![ChoiceGraphNode::Start, ChoiceGraphNode::End],
288            vec![(0, 99)],
289        )
290        .unwrap_err();
291        assert_eq!(err, ChoiceGraphError::EdgeOutOfBounds);
292    }
293
294    #[test]
295    fn successors_predecessors() {
296        let cg = ChoiceGraph::new(
297            vec![
298                ChoiceGraphNode::Start,
299                ChoiceGraphNode::Activity("a".into()),
300                ChoiceGraphNode::End,
301            ],
302            vec![(0, 1), (1, 2)],
303        )
304        .unwrap();
305        assert_eq!(cg.successors(0), vec![1]);
306        assert_eq!(cg.predecessors(2), vec![1]);
307    }
308}