Skip to main content

ocel_mine/
inductive.rs

1//! Inductive miner: per-type process tree discovery (the practical tier).
2//!
3//! Basic IM (Leemans): recursively detect exclusive / sequence / parallel /
4//! loop cuts on the sub-log's directly-follows graph and split the log; the
5//! fall-through is the flower model, so the result is always a sound tree.
6//!
7//! `noise_threshold > 0` adds `IMf`-style frequency filtering: at every
8//! recursion step, directly-follows edges below the fraction of their source
9//! activity's strongest outgoing edge are ignored, and start/end activities
10//! are filtered the same way relative to the strongest one. Cuts run on the
11//! filtered graph, splits on the full log, so the tree stays sound. This is
12//! the frequency filter of `IMf`, not the complete `IMf` fall-through set.
13
14use std::collections::HashMap;
15
16use ocel::Ocel;
17use serde::Serialize;
18
19use crate::trace;
20
21/// A process tree node.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23#[serde(tag = "type", rename_all = "camelCase")]
24pub enum ProcessTree {
25    /// A leaf activity.
26    Activity { label: String },
27    /// The silent step.
28    Tau,
29    /// Children in order.
30    Sequence { children: Vec<ProcessTree> },
31    /// Exactly one child happens.
32    Exclusive { children: Vec<ProcessTree> },
33    /// All children happen, interleaved.
34    Parallel { children: Vec<ProcessTree> },
35    /// `children[0]` is the body; the rest are redo parts.
36    Loop { children: Vec<ProcessTree> },
37}
38
39/// Sub-log: variant -> multiplicity (activities are interned ids).
40pub(crate) type Log = HashMap<Vec<u16>, usize>;
41
42struct Miner<'a> {
43    names: &'a [&'a str],
44    noise_threshold: f64,
45}
46
47/// Directly-follows abstraction of a sub-log.
48pub(crate) struct Graph {
49    pub(crate) alphabet: Vec<u16>,
50    pub(crate) follows: HashMap<(u16, u16), usize>,
51    pub(crate) starts: Vec<u16>,
52    pub(crate) ends: Vec<u16>,
53}
54
55/// Keep the ids whose count reaches `noise` × the strongest count.
56// counts are far below 2^53, so the usize → f64 casts are exact
57#[allow(clippy::cast_precision_loss)]
58fn frequent(counts: &HashMap<u16, usize>, noise: f64) -> Vec<u16> {
59    let strongest = counts.values().copied().max().unwrap_or(0);
60    let mut kept: Vec<u16> = counts
61        .iter()
62        .filter(|&(_, &count)| count as f64 >= strongest as f64 * noise)
63        .map(|(&a, _)| a)
64        .collect();
65    kept.sort_unstable();
66    kept
67}
68
69impl Graph {
70    pub(crate) fn build(log: &Log, noise_threshold: f64) -> Graph {
71        let mut follows: HashMap<(u16, u16), usize> = HashMap::new();
72        let mut alphabet: Vec<u16> = Vec::new();
73        let mut start_counts: HashMap<u16, usize> = HashMap::new();
74        let mut end_counts: HashMap<u16, usize> = HashMap::new();
75        for (sequence, &count) in log {
76            let (Some(&first), Some(&last)) = (sequence.first(), sequence.last()) else {
77                continue;
78            };
79            *start_counts.entry(first).or_insert(0) += count;
80            *end_counts.entry(last).or_insert(0) += count;
81            for &a in sequence {
82                if !alphabet.contains(&a) {
83                    alphabet.push(a);
84                }
85            }
86            for pair in sequence.windows(2) {
87                *follows.entry((pair[0], pair[1])).or_insert(0) += count;
88            }
89        }
90        alphabet.sort_unstable();
91        if noise_threshold > 0.0 {
92            let mut max_outgoing: HashMap<u16, usize> = HashMap::new();
93            for (&(a, _), &count) in &follows {
94                let strongest = max_outgoing.entry(a).or_insert(0);
95                *strongest = (*strongest).max(count);
96            }
97            // counts are far below 2^53, so the usize → f64 casts are exact
98            #[allow(clippy::cast_precision_loss)]
99            follows.retain(|&(a, _), &mut count| {
100                count as f64 >= max_outgoing[&a] as f64 * noise_threshold
101            });
102        }
103        Graph {
104            alphabet,
105            follows,
106            starts: frequent(&start_counts, noise_threshold),
107            ends: frequent(&end_counts, noise_threshold),
108        }
109    }
110
111    pub(crate) fn has(&self, a: u16, b: u16) -> bool {
112        self.follows.contains_key(&(a, b))
113    }
114
115    /// Connected components under `linked`; returns activity -> component id.
116    pub(crate) fn components(&self, linked: impl Fn(u16, u16) -> bool) -> HashMap<u16, usize> {
117        let mut component: HashMap<u16, usize> = HashMap::new();
118        let mut next = 0usize;
119        for &seed in &self.alphabet {
120            if component.contains_key(&seed) {
121                continue;
122            }
123            let mut stack = vec![seed];
124            component.insert(seed, next);
125            while let Some(a) = stack.pop() {
126                for &b in &self.alphabet {
127                    if !component.contains_key(&b) && (linked(a, b) || linked(b, a)) {
128                        component.insert(b, next);
129                        stack.push(b);
130                    }
131                }
132            }
133            next += 1;
134        }
135        component
136    }
137}
138
139pub(crate) fn count_groups(assignment: &HashMap<u16, usize>) -> usize {
140    let mut seen: Vec<usize> = assignment.values().copied().collect();
141    seen.sort_unstable();
142    seen.dedup();
143    seen.len()
144}
145
146impl Miner<'_> {
147    fn mine(&self, log: &Log) -> ProcessTree {
148        if log.is_empty() {
149            return ProcessTree::Tau;
150        }
151        if log.keys().any(Vec::is_empty) {
152            let rest: Log = log
153                .iter()
154                .filter(|(k, _)| !k.is_empty())
155                .map(|(k, &v)| (k.clone(), v))
156                .collect();
157            if rest.is_empty() {
158                return ProcessTree::Tau;
159            }
160            return ProcessTree::Exclusive {
161                children: vec![ProcessTree::Tau, self.mine(&rest)],
162            };
163        }
164
165        let graph = Graph::build(log, self.noise_threshold);
166        if graph.alphabet.len() == 1 {
167            let activity = ProcessTree::Activity {
168                label: self.names[graph.alphabet[0] as usize].to_owned(),
169            };
170            if log.keys().all(|k| k.len() == 1) {
171                return activity;
172            }
173            // <a>, <a,a>, ... : loop with a silent redo
174            return ProcessTree::Loop {
175                children: vec![activity, ProcessTree::Tau],
176            };
177        }
178
179        if let Some(tree) = self.exclusive_cut(log, &graph) {
180            return tree;
181        }
182        if let Some(tree) = self.sequence_cut(log, &graph) {
183            return tree;
184        }
185        if let Some(tree) = self.parallel_cut(log, &graph) {
186            return tree;
187        }
188        if let Some(tree) = self.loop_cut(log, &graph) {
189            return tree;
190        }
191        if let Some(tree) = self.once_per_trace(log, &graph) {
192            return tree;
193        }
194
195        // fall-through: the flower model
196        let mut children = vec![ProcessTree::Tau];
197        children.extend(graph.alphabet.iter().map(|&a| ProcessTree::Activity {
198            label: self.names[a as usize].to_owned(),
199        }));
200        ProcessTree::Loop { children }
201    }
202
203    /// Fall-through before the flower: an activity occurring exactly once in
204    /// every trace runs concurrently to whatever the rest does. One activity
205    /// per pass (the alphabetically first, like `PM4Py`) so the cuts get
206    /// another chance on the remainder; nested parallels are flattened.
207    fn once_per_trace(&self, log: &Log, graph: &Graph) -> Option<ProcessTree> {
208        let mut candidates: Vec<u16> = graph.alphabet.clone();
209        for sequence in log.keys() {
210            candidates.retain(|&a| sequence.iter().filter(|&&x| x == a).count() == 1);
211            if candidates.is_empty() {
212                return None;
213            }
214        }
215        let chosen = candidates
216            .into_iter()
217            .min_by_key(|&a| self.names[a as usize])?;
218        let mut rest: Log = Log::new();
219        for (sequence, &count) in log {
220            let projected: Vec<u16> = sequence.iter().copied().filter(|&a| a != chosen).collect();
221            *rest.entry(projected).or_insert(0) += count;
222        }
223        let mut children = vec![ProcessTree::Activity {
224            label: self.names[chosen as usize].to_owned(),
225        }];
226        match self.mine(&rest) {
227            ProcessTree::Parallel { children: nested } => children.extend(nested),
228            other => children.push(other),
229        }
230        Some(ProcessTree::Parallel { children })
231    }
232
233    fn exclusive_cut(&self, log: &Log, graph: &Graph) -> Option<ProcessTree> {
234        let component = graph.components(|a, b| graph.has(a, b));
235        let k = count_groups(&component);
236        if k < 2 {
237            return None;
238        }
239        let mut parts: Vec<Log> = vec![Log::new(); k];
240        for (sequence, &count) in log {
241            // majority component wins; under noise filtering a trace can
242            // touch several components — its foreign activities are noise
243            let mut votes = vec![0usize; k];
244            for &a in sequence {
245                votes[component[&a]] += 1;
246            }
247            let mut part = 0;
248            for (i, &v) in votes.iter().enumerate() {
249                if v > votes[part] {
250                    part = i;
251                }
252            }
253            let filtered: Vec<u16> = sequence
254                .iter()
255                .copied()
256                .filter(|a| component[a] == part)
257                .collect();
258            *parts[part].entry(filtered).or_insert(0) += count;
259        }
260        Some(ProcessTree::Exclusive {
261            children: parts.iter().map(|p| self.mine(p)).collect(),
262        })
263    }
264
265    fn sequence_cut(&self, log: &Log, graph: &Graph) -> Option<ProcessTree> {
266        // strongly connected: mutual reachability
267        let reach = reachability(graph);
268        let mutual = |a: u16, b: u16| reach[&(a, b)] && reach[&(b, a)];
269        let unordered = |a: u16, b: u16| !reach[&(a, b)] && !reach[&(b, a)];
270        // group = same SCC or pairwise unordered
271        let component = graph.components(|a, b| mutual(a, b) || unordered(a, b));
272        let k = count_groups(&component);
273        if k < 2 {
274            return None;
275        }
276        // order groups by reachability
277        let mut groups: Vec<usize> = (0..k).collect();
278        let representative: HashMap<usize, u16> =
279            graph.alphabet.iter().map(|&a| (component[&a], a)).collect();
280        groups.sort_by(|&g1, &g2| {
281            let (a, b) = (representative[&g1], representative[&g2]);
282            if reach[&(a, b)] {
283                std::cmp::Ordering::Less
284            } else if reach[&(b, a)] {
285                std::cmp::Ordering::Greater
286            } else {
287                std::cmp::Ordering::Equal
288            }
289        });
290        let position: HashMap<usize, usize> =
291            groups.iter().enumerate().map(|(i, &g)| (g, i)).collect();
292
293        let mut parts: Vec<Log> = vec![Log::new(); k];
294        for (sequence, &count) in log {
295            let mut segments: Vec<Vec<u16>> = vec![Vec::new(); k];
296            for &a in sequence {
297                segments[position[&component[&a]]].push(a);
298            }
299            for (i, segment) in segments.into_iter().enumerate() {
300                *parts[i].entry(segment).or_insert(0) += count;
301            }
302        }
303        Some(ProcessTree::Sequence {
304            children: parts.iter().map(|p| self.mine(p)).collect(),
305        })
306    }
307
308    fn parallel_cut(&self, log: &Log, graph: &Graph) -> Option<ProcessTree> {
309        // linked when NOT both-directions (cannot be separated into parallel groups)
310        let component = graph.components(|a, b| !(graph.has(a, b) && graph.has(b, a)));
311        let k = count_groups(&component);
312        if k < 2 {
313            return None;
314        }
315        let mut groups: Vec<Vec<u16>> = vec![Vec::new(); k];
316        for &a in &graph.alphabet {
317            groups[component[&a]].push(a);
318        }
319        // a group without a start or an end activity cannot stand alone;
320        // merge it into a neighbor instead of giving the cut up (all
321        // cross-component pairs are mutual, so the partition stays valid)
322        groups.sort_by_key(Vec::len);
323        let mut i = 0;
324        while i < groups.len() && groups.len() > 1 {
325            let has_start = groups[i].iter().any(|a| graph.starts.contains(a));
326            let has_end = groups[i].iter().any(|a| graph.ends.contains(a));
327            if has_start && has_end {
328                i += 1;
329                continue;
330            }
331            let group = groups.remove(i);
332            let target = i.saturating_sub(1);
333            groups[target].extend(group);
334        }
335        if groups.len() < 2 {
336            return None;
337        }
338
339        let index: HashMap<u16, usize> = groups
340            .iter()
341            .enumerate()
342            .flat_map(|(i, group)| group.iter().map(move |&a| (a, i)))
343            .collect();
344        let mut parts: Vec<Log> = vec![Log::new(); groups.len()];
345        for (sequence, &count) in log {
346            let mut projections: Vec<Vec<u16>> = vec![Vec::new(); groups.len()];
347            for &a in sequence {
348                projections[index[&a]].push(a);
349            }
350            for (i, projection) in projections.into_iter().enumerate() {
351                *parts[i].entry(projection).or_insert(0) += count;
352            }
353        }
354        Some(ProcessTree::Parallel {
355            children: parts.iter().map(|p| self.mine(p)).collect(),
356        })
357    }
358
359    fn loop_cut(&self, log: &Log, graph: &Graph) -> Option<ProcessTree> {
360        // connectivity without the potential loop-back edges: everything
361        // leaving an end activity or entering a start activity is cut away
362        let component = graph.components(|a, b| {
363            graph.has(a, b) && !graph.ends.contains(&a) && !graph.starts.contains(&b)
364        });
365        // the body is every component holding a start or end
366        let body_groups: Vec<usize> = graph
367            .starts
368            .iter()
369            .chain(graph.ends.iter())
370            .map(|a| component[a])
371            .collect();
372        let is_body = |a: u16| body_groups.contains(&component[&a]);
373        let redo_groups: Vec<usize> = {
374            let mut g: Vec<usize> = graph
375                .alphabet
376                .iter()
377                .map(|&a| component[&a])
378                .filter(|g| !body_groups.contains(g))
379                .collect();
380            g.sort_unstable();
381            g.dedup();
382            g
383        };
384        if redo_groups.is_empty() {
385            return None;
386        }
387        // boundary condition: body -> redo only from ends, redo -> body only to starts
388        for &(a, b) in graph.follows.keys() {
389            match (is_body(a), is_body(b)) {
390                (true, false) if !graph.ends.contains(&a) => return None,
391                (false, true) if !graph.starts.contains(&b) => return None,
392                _ => {}
393            }
394        }
395
396        let redo_index: HashMap<usize, usize> = redo_groups
397            .iter()
398            .enumerate()
399            .map(|(i, &g)| (g, i + 1))
400            .collect();
401        let mut parts: Vec<Log> = vec![Log::new(); redo_groups.len() + 1];
402        for (sequence, &count) in log {
403            let mut current: Vec<u16> = Vec::new();
404            let mut current_part = 0usize;
405            for &a in sequence {
406                let part = if is_body(a) {
407                    0
408                } else {
409                    redo_index[&component[&a]]
410                };
411                if part != current_part {
412                    *parts[current_part]
413                        .entry(std::mem::take(&mut current))
414                        .or_insert(0) += count;
415                    current_part = part;
416                }
417                current.push(a);
418            }
419            *parts[current_part].entry(current).or_insert(0) += count;
420            // a trace must end in the body; if it ended in a redo part, record
421            // an implicit empty body completion
422            if current_part != 0 {
423                *parts[0].entry(Vec::new()).or_insert(0) += count;
424            }
425        }
426        Some(ProcessTree::Loop {
427            children: parts.iter().map(|p| self.mine(p)).collect(),
428        })
429    }
430}
431
432/// Pairwise reachability over the directly-follows relation.
433pub(crate) fn reachability(graph: &Graph) -> HashMap<(u16, u16), bool> {
434    let n = graph.alphabet.len();
435    let index: HashMap<u16, usize> = graph
436        .alphabet
437        .iter()
438        .enumerate()
439        .map(|(i, &a)| (a, i))
440        .collect();
441    let mut reach = vec![vec![false; n]; n];
442    for &(a, b) in graph.follows.keys() {
443        reach[index[&a]][index[&b]] = true;
444    }
445    for k in 0..n {
446        let via = reach[k].clone();
447        for row in &mut reach {
448            if row[k] {
449                for (j, &reachable) in via.iter().enumerate() {
450                    if reachable {
451                        row[j] = true;
452                    }
453                }
454            }
455        }
456    }
457    let mut out = HashMap::new();
458    for (&a, &i) in &index {
459        for (&b, &j) in &index {
460            out.insert((a, b), reach[i][j]);
461        }
462    }
463    out
464}
465
466/// Discover a process tree for `object_type` with the inductive miner.
467///
468/// `noise_threshold` of `0.0` is the exact basic miner; higher values (`IMf`
469/// territory is around `0.2`) ignore infrequent directly-follows edges so the
470/// mainstream structure survives noisy logs. Objects without events are
471/// ignored (they are not part of the process).
472#[must_use]
473pub fn inductive(log: &Ocel, object_type: &str, noise_threshold: f64) -> ProcessTree {
474    let traces = trace::build(log, object_type);
475    let mut variants: Log = HashMap::new();
476    for steps in &traces.steps {
477        if steps.is_empty() {
478            continue;
479        }
480        let sequence: Vec<u16> = steps.iter().map(|&(a, _)| a).collect();
481        *variants.entry(sequence).or_insert(0) += 1;
482    }
483    let miner = Miner {
484        names: &traces.activity_names,
485        noise_threshold,
486    };
487    miner.mine(&variants)
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use crate::test_util::log_from_sequences;
494
495    fn activity(label: &str) -> ProcessTree {
496        ProcessTree::Activity {
497            label: label.into(),
498        }
499    }
500
501    #[test]
502    fn sequence_of_three() {
503        let log = log_from_sequences(&[&["a", "b", "c"], &["a", "b", "c"]]);
504        let tree = inductive(&log, "case", 0.0);
505        assert_eq!(
506            tree,
507            ProcessTree::Sequence {
508                children: vec![activity("a"), activity("b"), activity("c")]
509            }
510        );
511    }
512
513    #[test]
514    fn exclusive_choice() {
515        let log = log_from_sequences(&[&["a", "b"], &["c", "d"]]);
516        let tree = inductive(&log, "case", 0.0);
517        let ProcessTree::Exclusive { children } = tree else {
518            panic!("expected exclusive root");
519        };
520        assert_eq!(children.len(), 2);
521    }
522
523    #[test]
524    fn parallel_pair() {
525        let log = log_from_sequences(&[&["a", "b"], &["b", "a"]]);
526        let tree = inductive(&log, "case", 0.0);
527        assert_eq!(
528            tree,
529            ProcessTree::Parallel {
530                children: vec![activity("a"), activity("b")]
531            }
532        );
533    }
534
535    #[test]
536    fn loop_with_redo() {
537        let log = log_from_sequences(&[&["a"], &["a", "b", "a"], &["a", "b", "a", "b", "a"]]);
538        let tree = inductive(&log, "case", 0.0);
539        assert_eq!(
540            tree,
541            ProcessTree::Loop {
542                children: vec![activity("a"), activity("b")]
543            }
544        );
545    }
546
547    #[test]
548    fn optional_tail_becomes_xor_tau() {
549        let log = log_from_sequences(&[&["a", "b"], &["a"]]);
550        let tree = inductive(&log, "case", 0.0);
551        assert_eq!(
552            tree,
553            ProcessTree::Sequence {
554                children: vec![
555                    activity("a"),
556                    ProcessTree::Exclusive {
557                        children: vec![ProcessTree::Tau, activity("b")]
558                    }
559                ]
560            }
561        );
562    }
563
564    #[test]
565    fn once_per_trace_beats_the_flower() {
566        // no cut applies, but b happens exactly once per trace
567        let log = log_from_sequences(&[&["a", "b", "a"], &["a", "a", "b"]]);
568        let tree = inductive(&log, "case", 0.0);
569        assert_eq!(
570            tree,
571            ProcessTree::Parallel {
572                children: vec![
573                    activity("b"),
574                    ProcessTree::Loop {
575                        children: vec![activity("a"), ProcessTree::Tau]
576                    },
577                ]
578            }
579        );
580    }
581
582    #[test]
583    fn noise_threshold_ignores_rare_swap() {
584        // ten a,b,c and one b,a,c: the swap makes a and b look concurrent
585        let mut sequences: Vec<&[&str]> = vec![&["a", "b", "c"]; 10];
586        sequences.push(&["b", "a", "c"]);
587        let log = log_from_sequences(&sequences);
588
589        let noisy = inductive(&log, "case", 0.0);
590        assert_eq!(
591            noisy,
592            ProcessTree::Sequence {
593                children: vec![
594                    ProcessTree::Parallel {
595                        children: vec![activity("a"), activity("b")]
596                    },
597                    activity("c"),
598                ]
599            }
600        );
601        // with filtering, the rare b->a edge and the rare start b are ignored
602        let filtered = inductive(&log, "case", 0.2);
603        assert_eq!(
604            filtered,
605            ProcessTree::Sequence {
606                children: vec![activity("a"), activity("b"), activity("c")]
607            }
608        );
609    }
610
611    #[test]
612    fn textbook_l1_structure() {
613        let log = log_from_sequences(&[
614            &["a", "b", "c", "d"],
615            &["a", "b", "c", "d"],
616            &["a", "b", "c", "d"],
617            &["a", "c", "b", "d"],
618            &["a", "c", "b", "d"],
619            &["a", "e", "d"],
620        ]);
621        let tree = inductive(&log, "case", 0.0);
622        assert_eq!(
623            tree,
624            ProcessTree::Sequence {
625                children: vec![
626                    activity("a"),
627                    ProcessTree::Exclusive {
628                        children: vec![
629                            ProcessTree::Parallel {
630                                children: vec![activity("b"), activity("c")]
631                            },
632                            activity("e"),
633                        ]
634                    },
635                    activity("d"),
636                ]
637            }
638        );
639    }
640}