Skip to main content

ocel_mine/
alpha.rs

1//! Alpha algorithm: per-type Petri net discovery (the educational tier).
2//!
3//! The classic α-miner (van der Aalst): footprint relations over one object
4//! type's traces → maximal (A, B) causal pairs → places. Its textbook limits
5//! are surfaced as warnings instead of hidden: length-1 loops (self-loops)
6//! cannot be modeled, length-2 loops confuse the causal relation, and there is
7//! no noise tolerance. For real logs prefer the inductive miner.
8
9use std::collections::{BTreeSet, HashSet};
10
11use ocel::Ocel;
12use serde::Serialize;
13
14use crate::trace;
15
16/// Pair enumeration is exponential in the number of activities; refuse beyond
17/// this rather than hang.
18const MAX_ACTIVITIES: usize = 20;
19
20/// A place: incoming and outgoing transitions (empty inputs = source place,
21/// empty outputs = sink place).
22#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct Place {
25    pub id: String,
26    pub inputs: Vec<String>,
27    pub outputs: Vec<String>,
28}
29
30/// The discovered Petri net of one object type.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct PetriNet {
34    pub object_type: String,
35    pub transitions: Vec<String>,
36    pub places: Vec<Place>,
37    /// Honest limits hit on this log (self-loops, size cutoff, ...).
38    pub warnings: Vec<String>,
39}
40
41fn extend_independent(
42    from: usize,
43    n: usize,
44    independent: &[Vec<bool>],
45    current: &mut Vec<u16>,
46    out: &mut Vec<Vec<u16>>,
47) {
48    for next in from..n {
49        // ∀ a1, a2 ∈ A: a1 # a2 — including a1 = a2, so a self-looping
50        // activity can never join a place (its transition stays disconnected)
51        if independent[next][next]
52            && current
53                .iter()
54                .all(|&a| independent[a as usize][next] && independent[next][a as usize])
55        {
56            current.push(u16::try_from(next).expect("checked size"));
57            out.push(current.clone());
58            extend_independent(next + 1, n, independent, current, out);
59            current.pop();
60        }
61    }
62}
63
64/// All non-empty subsets of `0..n` that are pairwise `independent`.
65fn independent_subsets(n: usize, independent: &[Vec<bool>]) -> Vec<Vec<u16>> {
66    let mut out = Vec::new();
67    let mut current: Vec<u16> = Vec::new();
68    extend_independent(0, n, independent, &mut current, &mut out);
69    out
70}
71
72/// X of the alpha algorithm: pairs (A, B) with A, B pairwise-independent and
73/// A x B fully causal, reduced to the maximal ones (Y).
74fn maximal_pairs(
75    n: usize,
76    causal: &[Vec<bool>],
77    independent: &[Vec<bool>],
78) -> Vec<(Vec<u16>, Vec<u16>)> {
79    let mut pairs: Vec<(Vec<u16>, Vec<u16>)> = Vec::new();
80    for a_set in independent_subsets(n, independent) {
81        let common: Vec<usize> = (0..n)
82            .filter(|&b| a_set.iter().all(|&a| causal[a as usize][b]))
83            .collect();
84        if common.is_empty() {
85            continue;
86        }
87        let sub_independent: Vec<Vec<bool>> = common
88            .iter()
89            .map(|&x| common.iter().map(|&y| independent[x][y]).collect())
90            .collect();
91        for b_local in independent_subsets(common.len(), &sub_independent) {
92            let b_set: Vec<u16> = b_local
93                .iter()
94                .map(|&i| u16::try_from(common[i as usize]).expect("checked size"))
95                .collect();
96            pairs.push((a_set.clone(), b_set));
97        }
98    }
99
100    let as_sets: Vec<(HashSet<u16>, HashSet<u16>)> = pairs
101        .iter()
102        .map(|(a, b)| {
103            (
104                a.iter().copied().collect::<HashSet<u16>>(),
105                b.iter().copied().collect::<HashSet<u16>>(),
106            )
107        })
108        .collect();
109    pairs
110        .iter()
111        .enumerate()
112        .filter(|&(i, _)| {
113            !as_sets.iter().enumerate().any(|(j, (a2, b2))| {
114                j != i
115                    && as_sets[i].0.is_subset(a2)
116                    && as_sets[i].1.is_subset(b2)
117                    && (as_sets[i].0.len() < a2.len() || as_sets[i].1.len() < b2.len())
118            })
119        })
120        .map(|(_, p)| p.clone())
121        .collect()
122}
123
124/// Discover a Petri net for `object_type` with the alpha algorithm.
125#[must_use]
126pub fn alpha(log: &Ocel, object_type: &str) -> PetriNet {
127    let traces = trace::build(log, object_type);
128    let n = traces.activity_names.len();
129    let mut warnings: Vec<String> = Vec::new();
130
131    let mut follows = vec![vec![false; n]; n];
132    let mut starts: BTreeSet<u16> = BTreeSet::new();
133    let mut ends: BTreeSet<u16> = BTreeSet::new();
134    for steps in &traces.steps {
135        let (Some(&(first, _)), Some(&(last, _))) = (steps.first(), steps.last()) else {
136            continue;
137        };
138        starts.insert(first);
139        ends.insert(last);
140        for pair in steps.windows(2) {
141            follows[pair[0].0 as usize][pair[1].0 as usize] = true;
142        }
143    }
144
145    let name = |id: u16| traces.activity_names[id as usize].to_owned();
146    let transitions: Vec<String> = {
147        let mut t: Vec<String> = (0..n)
148            .map(|i| traces.activity_names[i].to_owned())
149            .collect();
150        t.sort_unstable();
151        t
152    };
153
154    for (i, row) in follows.iter().enumerate() {
155        if row[i] {
156            warnings.push(format!(
157                "self-loop on '{}' cannot be modeled by the alpha algorithm; use the inductive miner",
158                traces.activity_names[i]
159            ));
160        }
161    }
162    if n > MAX_ACTIVITIES {
163        warnings.push(format!(
164            "{n} activities exceed the alpha pair-enumeration cutoff ({MAX_ACTIVITIES}); returning transitions only"
165        ));
166        return PetriNet {
167            object_type: object_type.to_owned(),
168            transitions,
169            places: Vec::new(),
170            warnings,
171        };
172    }
173
174    let mut causal = vec![vec![false; n]; n];
175    let mut independent = vec![vec![false; n]; n];
176    for a in 0..n {
177        for b in 0..n {
178            causal[a][b] = follows[a][b] && !follows[b][a];
179            independent[a][b] = !follows[a][b] && !follows[b][a];
180        }
181    }
182
183    let maximal = maximal_pairs(n, &causal, &independent);
184
185    let mut places: Vec<Place> = maximal
186        .iter()
187        .map(|(a_set, b_set)| Place {
188            id: String::new(),
189            inputs: a_set.iter().map(|&a| name(a)).collect(),
190            outputs: b_set.iter().map(|&b| name(b)).collect(),
191        })
192        .collect();
193    for place in &mut places {
194        place.inputs.sort_unstable();
195        place.outputs.sort_unstable();
196    }
197    places.sort_unstable_by(|a, b| (&a.inputs, &a.outputs).cmp(&(&b.inputs, &b.outputs)));
198    for (i, place) in places.iter_mut().enumerate() {
199        place.id = format!("p{}", i + 1);
200    }
201    places.insert(
202        0,
203        Place {
204            id: "source".into(),
205            inputs: Vec::new(),
206            outputs: {
207                let mut v: Vec<String> = starts.iter().map(|&a| name(a)).collect();
208                v.sort_unstable();
209                v
210            },
211        },
212    );
213    places.push(Place {
214        id: "sink".into(),
215        inputs: {
216            let mut v: Vec<String> = ends.iter().map(|&a| name(a)).collect();
217            v.sort_unstable();
218            v
219        },
220        outputs: Vec::new(),
221    });
222
223    PetriNet {
224        object_type: object_type.to_owned(),
225        transitions,
226        places,
227        warnings,
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::test_util::log_from_sequences;
235
236    #[test]
237    fn textbook_l1_yields_the_known_net() {
238        // van der Aalst L1: [<a,b,c,d>^3, <a,c,b,d>^2, <a,e,d>]
239        let log = log_from_sequences(&[
240            &["a", "b", "c", "d"],
241            &["a", "b", "c", "d"],
242            &["a", "b", "c", "d"],
243            &["a", "c", "b", "d"],
244            &["a", "c", "b", "d"],
245            &["a", "e", "d"],
246        ]);
247        let net = alpha(&log, "case");
248        assert!(net.warnings.is_empty());
249        assert_eq!(net.transitions, ["a", "b", "c", "d", "e"]);
250
251        let shapes: Vec<(Vec<&str>, Vec<&str>)> = net
252            .places
253            .iter()
254            .map(|p| {
255                (
256                    p.inputs.iter().map(String::as_str).collect(),
257                    p.outputs.iter().map(String::as_str).collect(),
258                )
259            })
260            .collect();
261        let expected: Vec<(Vec<&str>, Vec<&str>)> = vec![
262            (vec![], vec!["a"]), // source
263            (vec!["a"], vec!["b", "e"]),
264            (vec!["a"], vec!["c", "e"]),
265            (vec!["b", "e"], vec!["d"]),
266            (vec!["c", "e"], vec!["d"]),
267            (vec!["d"], vec![]), // sink
268        ];
269        assert_eq!(shapes, expected);
270    }
271
272    #[test]
273    fn self_loops_produce_warnings_and_disconnect() {
274        let log = log_from_sequences(&[&["a", "b", "b", "c"]]);
275        let net = alpha(&log, "case");
276        assert!(net.warnings.iter().any(|w| w.contains("'b'")));
277        // ∀ a1,a2 ∈ A: a1 # a2 fails for b (b > b), so no place touches it —
278        // the transition stays disconnected, exactly like the textbook and
279        // PM4Py
280        assert!(net
281            .places
282            .iter()
283            .all(|p| !p.inputs.contains(&"b".to_owned()) && !p.outputs.contains(&"b".to_owned())));
284    }
285
286    #[test]
287    fn empty_type_yields_empty_net() {
288        let log = log_from_sequences(&[]);
289        let net = alpha(&log, "case");
290        assert!(net.transitions.is_empty());
291        // just source and sink, both empty
292        assert_eq!(net.places.len(), 2);
293    }
294}