Add Split Miner 2.0 implementation
Adds the Split Miner 2.0 pipeline (Augusto, Dumas & La Rosa, 2021) alongside the existing 1.0 implementation: - refined_dfg: refined DFG from activity lifecycle events (Definition 6) - refined_concurrency: true concurrency from lifecycle overlap (Equation 5) - heuristics: fix improper completion from AND-split loop-edges, and detect OR-splits from mutual exclusiveness (Section 3.3) - miner.split_miner_2: the 2.0 entry point, reusing the 1.0 filtering, splits, joins and OR-join minimization steps Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,518 @@
|
||||
# Split Miner - BPMN process discovery from event logs.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/3/12
|
||||
# AI assistance: Claude Code (Anthropic)
|
||||
|
||||
# Copyright (c) 2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
# implied. See the License for the specific language governing
|
||||
# permissions and limitations under the License.
|
||||
"""Tests for the refined DFG (Definition 6, SM 2.0).
|
||||
|
||||
The refined directly-follows relation uses activity lifecycle
|
||||
(start/end) events: activity ay directly-follows ax iff
|
||||
ay starts after ax ends with no other end events in between.
|
||||
|
||||
Reference:
|
||||
Augusto, A., Dumas, M., & La Rosa, M. (2021).
|
||||
Automated Discovery of Process Models with True
|
||||
Concurrency and Inclusive Choices. Section 3.1,
|
||||
Definition 6.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from split_miner.bpmn import Task
|
||||
from split_miner.refined_dfg import RefinedDirectlyFollowsGraph
|
||||
|
||||
S: str = "start"
|
||||
"""Lifecycle start constant."""
|
||||
E: str = "end"
|
||||
"""Lifecycle end constant."""
|
||||
|
||||
|
||||
def _make_tasks(
|
||||
*labels: str,
|
||||
) -> dict[str, Task]:
|
||||
"""Create Task objects from labels.
|
||||
|
||||
:param labels: The activity labels.
|
||||
:return: A dict mapping label to Task.
|
||||
"""
|
||||
return {
|
||||
label: Task(label, label)
|
||||
for label in labels
|
||||
}
|
||||
|
||||
|
||||
def _make_paper_example() -> tuple[
|
||||
dict[str, Task],
|
||||
RefinedDirectlyFollowsGraph,
|
||||
]:
|
||||
"""Build the DFG from the paper's example Lrho_x.
|
||||
|
||||
Four traces with activities A-F, where B/C and
|
||||
D/E have overlapping lifecycles:
|
||||
|
||||
Trace 1: As Ae Bs Cs Ce Be Es Ds De Ee Fs Fe
|
||||
Trace 2: As Ae Bs Cs Be Ce Es Ds Ee De Fs Fe
|
||||
Trace 3: As Ae Cs Bs Be Ce Ds Es De Ee Fs Fe
|
||||
Trace 4: As Ae Cs Bs Ce Be Ds Es Ee De Fs Fe
|
||||
|
||||
Expected DFG (Figure 3c): A->B, A->C, B->D, B->E,
|
||||
C->D, C->E, D->F, E->F.
|
||||
|
||||
:return: The tasks and the DFG.
|
||||
"""
|
||||
t: dict[str, Task] = _make_tasks(
|
||||
"A", "B", "C", "D", "E", "F"
|
||||
)
|
||||
a: Task = t["A"]
|
||||
b: Task = t["B"]
|
||||
c: Task = t["C"]
|
||||
d: Task = t["D"]
|
||||
e: Task = t["E"]
|
||||
f: Task = t["F"]
|
||||
|
||||
traces: dict[
|
||||
tuple[tuple[Task, str], ...], int
|
||||
] = {
|
||||
# Trace 1
|
||||
((a, S), (a, E), (b, S), (c, S),
|
||||
(c, E), (b, E), (e, S), (d, S),
|
||||
(d, E), (e, E), (f, S), (f, E)): 1,
|
||||
# Trace 2
|
||||
((a, S), (a, E), (b, S), (c, S),
|
||||
(b, E), (c, E), (e, S), (d, S),
|
||||
(e, E), (d, E), (f, S), (f, E)): 1,
|
||||
# Trace 3
|
||||
((a, S), (a, E), (c, S), (b, S),
|
||||
(b, E), (c, E), (d, S), (e, S),
|
||||
(d, E), (e, E), (f, S), (f, E)): 1,
|
||||
# Trace 4
|
||||
((a, S), (a, E), (c, S), (b, S),
|
||||
(c, E), (b, E), (d, S), (e, S),
|
||||
(e, E), (d, E), (f, S), (f, E)): 1,
|
||||
}
|
||||
dfg: RefinedDirectlyFollowsGraph = (
|
||||
RefinedDirectlyFollowsGraph(traces)
|
||||
)
|
||||
return t, dfg
|
||||
|
||||
|
||||
class TestSequentialTrace(unittest.TestCase):
|
||||
"""Tests refined DFG with purely sequential traces.
|
||||
|
||||
When activities don't overlap, the refined DFG matches
|
||||
the SM 1.0 DFG.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up a simple sequential trace.
|
||||
|
||||
Trace: As Ae Bs Be Cs Ce
|
||||
Expected: A->B, B->C.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.__tasks: dict[str, Task] = (
|
||||
_make_tasks("A", "B", "C")
|
||||
)
|
||||
a: Task = self.__tasks["A"]
|
||||
b: Task = self.__tasks["B"]
|
||||
c: Task = self.__tasks["C"]
|
||||
traces: dict[
|
||||
tuple[tuple[Task, str], ...], int
|
||||
] = {
|
||||
((a, S), (a, E),
|
||||
(b, S), (b, E),
|
||||
(c, S), (c, E)): 1,
|
||||
}
|
||||
self.__dfg: RefinedDirectlyFollowsGraph = (
|
||||
RefinedDirectlyFollowsGraph(traces)
|
||||
)
|
||||
|
||||
def test_nodes(self) -> None:
|
||||
"""DFG has 3 nodes (A, B, C).
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(len(self.__dfg.nodes), 3)
|
||||
|
||||
def test_edges(self) -> None:
|
||||
"""DFG has edges A->B and B->C.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
a: Task = self.__tasks["A"]
|
||||
b: Task = self.__tasks["B"]
|
||||
c: Task = self.__tasks["C"]
|
||||
self.assertEqual(
|
||||
self.__dfg.edges,
|
||||
{(a, b), (b, c)},
|
||||
)
|
||||
|
||||
def test_sources(self) -> None:
|
||||
"""Source is A (first activity to start).
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.__dfg.sources,
|
||||
{self.__tasks["A"]},
|
||||
)
|
||||
|
||||
def test_sinks(self) -> None:
|
||||
"""Sink is C (last activity to end).
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.__dfg.sinks,
|
||||
{self.__tasks["C"]},
|
||||
)
|
||||
|
||||
|
||||
class TestOverlappingTrace(unittest.TestCase):
|
||||
"""Tests refined DFG with overlapping lifecycles.
|
||||
|
||||
When activities overlap, no directly-follows relation
|
||||
exists between them.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up overlapping and sequential activities.
|
||||
|
||||
Trace: As Bs Ae Be Cs Ce
|
||||
A and B overlap. B ends last.
|
||||
Expected: B->C only (not A->C, since A_end
|
||||
precedes B_end which is an end event between
|
||||
A_end and C_start).
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.__tasks: dict[str, Task] = (
|
||||
_make_tasks("A", "B", "C")
|
||||
)
|
||||
a: Task = self.__tasks["A"]
|
||||
b: Task = self.__tasks["B"]
|
||||
c: Task = self.__tasks["C"]
|
||||
traces: dict[
|
||||
tuple[tuple[Task, str], ...], int
|
||||
] = {
|
||||
((a, S), (b, S), (a, E),
|
||||
(b, E), (c, S), (c, E)): 1,
|
||||
}
|
||||
self.__dfg: RefinedDirectlyFollowsGraph = (
|
||||
RefinedDirectlyFollowsGraph(traces)
|
||||
)
|
||||
|
||||
def test_no_edge_between_overlapping(self) -> None:
|
||||
"""No A->B or B->A edge (overlapping lifecycles).
|
||||
|
||||
A starts before B, and A ends before B. But
|
||||
A_end(3) > B_start(2), so A does not end before
|
||||
B starts. Similarly B does not end before A
|
||||
starts. So neither direction holds.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
a: Task = self.__tasks["A"]
|
||||
b: Task = self.__tasks["B"]
|
||||
self.assertNotIn((a, b), self.__dfg.edges)
|
||||
self.assertNotIn((b, a), self.__dfg.edges)
|
||||
|
||||
def test_last_ender_follows_to_next(self) -> None:
|
||||
"""Only B->C exists (B is the last to end).
|
||||
|
||||
B_end(4) precedes C_start(5) with no end events
|
||||
between them. A_end(3) also precedes C_start(5)
|
||||
but B_end(4) is between them, blocking A->C.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
b: Task = self.__tasks["B"]
|
||||
c: Task = self.__tasks["C"]
|
||||
self.assertIn((b, c), self.__dfg.edges)
|
||||
|
||||
def test_edge_count(self) -> None:
|
||||
"""Only 1 edge total.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(len(self.__dfg.edges), 1)
|
||||
|
||||
|
||||
class TestPaperExample(unittest.TestCase):
|
||||
"""Tests refined DFG on the paper's example Lrho_x.
|
||||
|
||||
Reference: SM 2.0 paper, Section 3.1, Figure 3(c).
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up the paper's example.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.__tasks: dict[str, Task]
|
||||
self.__dfg: RefinedDirectlyFollowsGraph
|
||||
self.__tasks, self.__dfg = (
|
||||
_make_paper_example()
|
||||
)
|
||||
|
||||
def test_nodes(self) -> None:
|
||||
"""DFG has 6 nodes (A through F).
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(len(self.__dfg.nodes), 6)
|
||||
|
||||
def test_edges(self) -> None:
|
||||
"""DFG has exactly 8 edges per Figure 3(c).
|
||||
|
||||
A->B, A->C, B->D, B->E, C->D, C->E, D->F,
|
||||
E->F.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
t: dict[str, Task] = self.__tasks
|
||||
expected: set[tuple[Task, Task]] = {
|
||||
(t["A"], t["B"]), (t["A"], t["C"]),
|
||||
(t["B"], t["D"]), (t["B"], t["E"]),
|
||||
(t["C"], t["D"]), (t["C"], t["E"]),
|
||||
(t["D"], t["F"]), (t["E"], t["F"]),
|
||||
}
|
||||
self.assertEqual(self.__dfg.edges, expected)
|
||||
|
||||
def test_edge_count(self) -> None:
|
||||
"""DFG has 8 edges.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(len(self.__dfg.edges), 8)
|
||||
|
||||
def test_no_bidirectional_edges(self) -> None:
|
||||
"""No bidirectional edges exist.
|
||||
|
||||
In the paper's example, overlapping lifecycles
|
||||
prevent any bidirectional relations.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
for src, tgt in self.__dfg.edges:
|
||||
self.assertNotIn(
|
||||
(tgt, src), self.__dfg.edges,
|
||||
f"Bidirectional edge {src}->{tgt} "
|
||||
f"and {tgt}->{src}",
|
||||
)
|
||||
|
||||
def test_sources(self) -> None:
|
||||
"""Source is A.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.__dfg.sources,
|
||||
{self.__tasks["A"]},
|
||||
)
|
||||
|
||||
def test_sinks(self) -> None:
|
||||
"""Sink is F.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.__dfg.sinks,
|
||||
{self.__tasks["F"]},
|
||||
)
|
||||
|
||||
def test_a_to_b_frequency(self) -> None:
|
||||
"""A->B occurs in all 4 traces.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.__dfg.df_frequency(
|
||||
self.__tasks["A"],
|
||||
self.__tasks["B"],
|
||||
),
|
||||
4,
|
||||
)
|
||||
|
||||
def test_a_to_c_frequency(self) -> None:
|
||||
"""A->C occurs in all 4 traces.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.__dfg.df_frequency(
|
||||
self.__tasks["A"],
|
||||
self.__tasks["C"],
|
||||
),
|
||||
4,
|
||||
)
|
||||
|
||||
def test_b_to_d_frequency(self) -> None:
|
||||
"""B->D occurs in traces 1 and 4.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.__dfg.df_frequency(
|
||||
self.__tasks["B"],
|
||||
self.__tasks["D"],
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
def test_d_to_f_frequency(self) -> None:
|
||||
"""D->F occurs in traces 2 and 4.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.__dfg.df_frequency(
|
||||
self.__tasks["D"],
|
||||
self.__tasks["F"],
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
def test_e_to_f_frequency(self) -> None:
|
||||
"""E->F occurs in traces 1 and 3.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.__dfg.df_frequency(
|
||||
self.__tasks["E"],
|
||||
self.__tasks["F"],
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
|
||||
class TestForkAfterEnd(unittest.TestCase):
|
||||
"""Tests that multiple activities can follow one end.
|
||||
|
||||
When A ends and both B and C start (with no other end
|
||||
events between), both A->B and A->C should exist.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up a fork pattern.
|
||||
|
||||
Trace: As Ae Bs Cs Be Ce
|
||||
A ends, then B and C start (neither ends before
|
||||
the other starts).
|
||||
Expected: A->B, A->C.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.__tasks: dict[str, Task] = (
|
||||
_make_tasks("A", "B", "C")
|
||||
)
|
||||
a: Task = self.__tasks["A"]
|
||||
b: Task = self.__tasks["B"]
|
||||
c: Task = self.__tasks["C"]
|
||||
traces: dict[
|
||||
tuple[tuple[Task, str], ...], int
|
||||
] = {
|
||||
((a, S), (a, E), (b, S), (c, S),
|
||||
(b, E), (c, E)): 1,
|
||||
}
|
||||
self.__dfg: RefinedDirectlyFollowsGraph = (
|
||||
RefinedDirectlyFollowsGraph(traces)
|
||||
)
|
||||
|
||||
def test_fork_edges(self) -> None:
|
||||
"""Both A->B and A->C exist.
|
||||
|
||||
A_end precedes both B_start and C_start with no
|
||||
end events in between.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
a: Task = self.__tasks["A"]
|
||||
b: Task = self.__tasks["B"]
|
||||
c: Task = self.__tasks["C"]
|
||||
self.assertEqual(
|
||||
self.__dfg.edges,
|
||||
{(a, b), (a, c)},
|
||||
)
|
||||
|
||||
|
||||
class TestSelfLoop(unittest.TestCase):
|
||||
"""Tests self-loop detection from lifecycle traces.
|
||||
|
||||
An activity that completes more than once in a trace
|
||||
is a self-loop.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up trace with self-loop: A repeats.
|
||||
|
||||
Trace: As Ae Bs Be As Ae Cs Ce
|
||||
Activity A completes twice.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.__tasks: dict[str, Task] = (
|
||||
_make_tasks("A", "B", "C")
|
||||
)
|
||||
a: Task = self.__tasks["A"]
|
||||
b: Task = self.__tasks["B"]
|
||||
c: Task = self.__tasks["C"]
|
||||
traces: dict[
|
||||
tuple[tuple[Task, str], ...], int
|
||||
] = {
|
||||
((a, S), (a, E),
|
||||
(b, S), (b, E),
|
||||
(a, S), (a, E),
|
||||
(c, S), (c, E)): 1,
|
||||
}
|
||||
self.__dfg: RefinedDirectlyFollowsGraph = (
|
||||
RefinedDirectlyFollowsGraph(traces)
|
||||
)
|
||||
|
||||
def test_a_is_self_loop(self) -> None:
|
||||
"""A is detected as a self-loop.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertIn(
|
||||
self.__tasks["A"],
|
||||
self.__dfg.self_loops,
|
||||
)
|
||||
|
||||
def test_b_not_self_loop(self) -> None:
|
||||
"""B is not a self-loop.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertNotIn(
|
||||
self.__tasks["B"],
|
||||
self.__dfg.self_loops,
|
||||
)
|
||||
|
||||
def test_no_self_loop_in_paper(self) -> None:
|
||||
"""Paper example has no self-loops.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
_, dfg = _make_paper_example()
|
||||
self.assertEqual(dfg.self_loops, set())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user