1use std::collections::HashMap;
15
16use ocel::Ocel;
17use serde::Serialize;
18
19use crate::trace;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23#[serde(tag = "type", rename_all = "camelCase")]
24pub enum ProcessTree {
25 Activity { label: String },
27 Tau,
29 Sequence { children: Vec<ProcessTree> },
31 Exclusive { children: Vec<ProcessTree> },
33 Parallel { children: Vec<ProcessTree> },
35 Loop { children: Vec<ProcessTree> },
37}
38
39pub(crate) type Log = HashMap<Vec<u16>, usize>;
41
42struct Miner<'a> {
43 names: &'a [&'a str],
44 noise_threshold: f64,
45}
46
47pub(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#[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 #[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 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 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 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 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 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 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 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 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 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 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 let component = graph.components(|a, b| {
363 graph.has(a, b) && !graph.ends.contains(&a) && !graph.starts.contains(&b)
364 });
365 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 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 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
432pub(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#[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 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 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 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}