# 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 refined concurrency discovery (SM 2.0, Equation 5). The refined concurrency oracle uses activity lifecycle overlap: two activities A and B are concurrent iff 2·|A⊓B| / (|A|+|B|) >= epsilon, where |A⊓B| is the number of overlapping lifecycle instances. Reference: Augusto, A., Dumas, M., & La Rosa, M. (2021). Automated Discovery of Process Models with True Concurrency and Inclusive Choices. Section 3.2, Equation 5. """ from __future__ import annotations import unittest from split_miner.bpmn import Task from split_miner.refined_concurrency import RefinedPrunedDFG 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], dict[tuple[tuple[Task, str], ...], int], ]: """Build the paper's example Lrho_x traces. Four traces with activities A-F, where B/C and D/E have overlapping lifecycles. :return: The tasks and the lifecycle traces. """ 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, } return t, traces class TestNoOverlap(unittest.TestCase): """Tests with purely sequential activities. No overlapping lifecycles means no concurrency. """ def setUp(self) -> None: """Set up sequential trace As Ae Bs Be Cs Ce. :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, } dfg: RefinedDirectlyFollowsGraph = ( RefinedDirectlyFollowsGraph(traces) ) self.__pruned: RefinedPrunedDFG = ( RefinedPrunedDFG(dfg, traces, 0.5) ) def test_no_concurrent_pairs(self) -> None: """No concurrent pairs when sequential. :return: None. """ self.assertEqual( self.__pruned.concurrent_pairs, set() ) def test_edges_preserved(self) -> None: """All DFG edges preserved (no pruning needed). :return: None. """ a: Task = self.__tasks["A"] b: Task = self.__tasks["B"] c: Task = self.__tasks["C"] self.assertEqual( self.__pruned.edges, {(a, b), (b, c)}, ) class TestFullOverlap(unittest.TestCase): """Tests with fully overlapping lifecycles. B and C always overlap: 2·|B⊓C|/(|B|+|C|) = 1.0. """ def setUp(self) -> None: """Set up: As Ae Bs Cs Be Ce (B and C overlap). :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, } dfg: RefinedDirectlyFollowsGraph = ( RefinedDirectlyFollowsGraph(traces) ) self.__pruned: RefinedPrunedDFG = ( RefinedPrunedDFG(dfg, traces, 0.5) ) def test_concurrent(self) -> None: """B and C are concurrent (ratio=1.0 >= 0.5). :return: None. """ b: Task = self.__tasks["B"] c: Task = self.__tasks["C"] self.assertTrue( self.__pruned.is_concurrent(b, c) ) self.assertTrue( self.__pruned.is_concurrent(c, b) ) def test_a_not_concurrent_with_b(self) -> None: """A is not concurrent with B (no overlap). :return: None. """ a: Task = self.__tasks["A"] b: Task = self.__tasks["B"] self.assertFalse( self.__pruned.is_concurrent(a, b) ) class TestPartialOverlap(unittest.TestCase): """Tests with partial overlap across traces. B and C overlap in 1 of 2 traces. Ratio = 2·1/(2+2) = 0.5. """ def setUp(self) -> None: """Set up two traces: one overlapping, one not. Trace 1: As Ae Bs Cs Be Ce (overlap) Trace 2: As Ae Bs Be Cs Ce (no overlap) :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"] self.__traces: dict[ tuple[tuple[Task, str], ...], int ] = { # Trace 1: B and C overlap ((a, S), (a, E), (b, S), (c, S), (b, E), (c, E)): 1, # Trace 2: B and C sequential ((a, S), (a, E), (b, S), (b, E), (c, S), (c, E)): 1, } def test_concurrent_at_threshold(self) -> None: """B||C with epsilon=0.5 (ratio=0.5 >= 0.5). :return: None. """ dfg: RefinedDirectlyFollowsGraph = ( RefinedDirectlyFollowsGraph(self.__traces) ) pruned: RefinedPrunedDFG = ( RefinedPrunedDFG(dfg, self.__traces, 0.5) ) b: Task = self.__tasks["B"] c: Task = self.__tasks["C"] self.assertTrue( pruned.is_concurrent(b, c) ) def test_not_concurrent_above_threshold( self, ) -> None: """B not ||C with epsilon=0.6 (ratio=0.5 < 0.6). :return: None. """ dfg: RefinedDirectlyFollowsGraph = ( RefinedDirectlyFollowsGraph(self.__traces) ) pruned: RefinedPrunedDFG = ( RefinedPrunedDFG(dfg, self.__traces, 0.6) ) b: Task = self.__tasks["B"] c: Task = self.__tasks["C"] self.assertFalse( pruned.is_concurrent(b, c) ) class TestPaperExample(unittest.TestCase): """Tests concurrency on the paper's Lrho_x example. B/C and D/E overlap in all 4 traces. Ratio = 2·4/(4+4) = 1.0. """ def setUp(self) -> None: """Set up the paper's example. :return: None. """ self.__tasks: dict[str, Task] traces: dict[ tuple[tuple[Task, str], ...], int ] self.__tasks, traces = ( _make_paper_example() ) dfg: RefinedDirectlyFollowsGraph = ( RefinedDirectlyFollowsGraph(traces) ) self.__pruned: RefinedPrunedDFG = ( RefinedPrunedDFG(dfg, traces, 0.5) ) def test_b_c_concurrent(self) -> None: """B and C are concurrent. :return: None. """ self.assertTrue( self.__pruned.is_concurrent( self.__tasks["B"], self.__tasks["C"], ) ) def test_d_e_concurrent(self) -> None: """D and E are concurrent. :return: None. """ self.assertTrue( self.__pruned.is_concurrent( self.__tasks["D"], self.__tasks["E"], ) ) def test_a_b_not_concurrent(self) -> None: """A and B are not concurrent. :return: None. """ self.assertFalse( self.__pruned.is_concurrent( self.__tasks["A"], self.__tasks["B"], ) ) def test_b_d_not_concurrent(self) -> None: """B and D are not concurrent. :return: None. """ self.assertFalse( self.__pruned.is_concurrent( self.__tasks["B"], self.__tasks["D"], ) ) def test_edges_no_concurrent_edges(self) -> None: """No edges between concurrent pairs. B->D, B->E, C->D, C->E exist in the DFG but B||C and D||E, so no edges between B/C or between D/E should appear. :return: None. """ b: Task = self.__tasks["B"] c: Task = self.__tasks["C"] d: Task = self.__tasks["D"] e: Task = self.__tasks["E"] # No edges between concurrent B/C self.assertNotIn( (b, c), self.__pruned.edges ) self.assertNotIn( (c, b), self.__pruned.edges ) # No edges between concurrent D/E self.assertNotIn( (d, e), self.__pruned.edges ) self.assertNotIn( (e, d), self.__pruned.edges ) def test_pruned_edges(self) -> None: """Pruned DFG has expected edges. A->B, A->C, B->D, B->E, C->D, C->E, D->F, E->F (same as refined DFG since no concurrent pairs have direct edges in the refined DFG). :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.__pruned.edges, expected ) class TestTraceFrequency(unittest.TestCase): """Tests that trace frequency affects overlap count. A trace with frequency 3 where B and C overlap contributes 3 to |B⊓C|. """ def setUp(self) -> None: """Set up traces with varying frequencies. Trace 1 (freq 3): B and C overlap. Trace 2 (freq 7): B and C sequential. |B⊓C|=3, |B|=10, |C|=10. Ratio = 2·3/(10+10) = 0.3. :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"] self.__traces: dict[ tuple[tuple[Task, str], ...], int ] = { # Overlap, frequency 3 ((a, S), (a, E), (b, S), (c, S), (b, E), (c, E)): 3, # Sequential, frequency 7 ((a, S), (a, E), (b, S), (b, E), (c, S), (c, E)): 7, } def test_concurrent_low_epsilon(self) -> None: """B||C with epsilon=0.3 (ratio=0.3 >= 0.3). :return: None. """ dfg: RefinedDirectlyFollowsGraph = ( RefinedDirectlyFollowsGraph(self.__traces) ) pruned: RefinedPrunedDFG = ( RefinedPrunedDFG( dfg, self.__traces, 0.3 ) ) b: Task = self.__tasks["B"] c: Task = self.__tasks["C"] self.assertTrue( pruned.is_concurrent(b, c) ) def test_not_concurrent_high_epsilon( self, ) -> None: """B not ||C with epsilon=0.5 (ratio=0.3 < 0.5). :return: None. """ dfg: RefinedDirectlyFollowsGraph = ( RefinedDirectlyFollowsGraph(self.__traces) ) pruned: RefinedPrunedDFG = ( RefinedPrunedDFG( dfg, self.__traces, 0.5 ) ) b: Task = self.__tasks["B"] c: Task = self.__tasks["C"] self.assertFalse( pruned.is_concurrent(b, c) ) class TestSelfLoopActivityCount(unittest.TestCase): """Tests that |A| counts traces, not completions. Per Equation 5, |A| is the number of traces containing activity A. An activity that completes multiple times in a single trace (self-loop) should still count as 1 for that trace, not as the number of completions. """ def setUp(self) -> None: """Set up traces where B has a self-loop. Trace 1 (freq 1): B completes twice, overlaps with C during its first execution. As Ae Bs Cs Be Ce Bs Be |B⊓C| = 1 (1 trace with overlap). |B| = 1 (1 trace containing B, NOT 2). |C| = 1 (1 trace containing C). Ratio = 2·1/(1+1) = 1.0. With the bug (counting completions): |B| = 2, ratio = 2·1/(2+1) = 0.67. :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"] self.__traces: dict[ tuple[tuple[Task, str], ...], int ] = { ((a, S), (a, E), (b, S), (c, S), (b, E), (c, E), (b, S), (b, E)): 1, } def test_concurrent_with_self_loop( self, ) -> None: """B||C even though B has a self-loop. Ratio is 2·1/(1+1) = 1.0, not 2·1/(2+1) = 0.67. With per-trace counting, B||C at epsilon=0.9. :return: None. """ dfg: RefinedDirectlyFollowsGraph = ( RefinedDirectlyFollowsGraph( self.__traces ) ) pruned: RefinedPrunedDFG = ( RefinedPrunedDFG( dfg, self.__traces, 0.9 ) ) b: Task = self.__tasks["B"] c: Task = self.__tasks["C"] self.assertTrue( pruned.is_concurrent(b, c) ) def test_not_concurrent_bug_threshold( self, ) -> None: """Verify the ratio is 1.0, not 0.67. If the bug existed (counting completions), epsilon=0.9 would fail since 0.67 < 0.9. This test passes because |B|=1 (per-trace), giving ratio=1.0 >= 0.9. :return: None. """ dfg: RefinedDirectlyFollowsGraph = ( RefinedDirectlyFollowsGraph( self.__traces ) ) # Even at very high epsilon, should be # concurrent since ratio is 1.0. pruned: RefinedPrunedDFG = ( RefinedPrunedDFG( dfg, self.__traces, 1.0 ) ) b: Task = self.__tasks["B"] c: Task = self.__tasks["C"] self.assertTrue( pruned.is_concurrent(b, c) ) class TestSelfLoopOverlapCount(unittest.TestCase): """Tests that |A⊓B| counts at most once per trace. Per Equation 5, |A⊓B| is the number of traces where A and B have overlapping lifecycles. When a self-loop activity overlaps with another activity in both "directions" within one trace, it should still count as 1 overlap, not 2. """ def setUp(self) -> None: """Set up traces where self-loop causes overlap. Trace 1 (freq 1): A overlaps B, then A restarts while B is still active. As Bs Ae As Be Ae Events: - (A,s): active={A} - (B,s): overlap A-B, active={A,B} - (A,e): active={B} - (A,s): overlap B-A, active={A,B} - (B,e): active={A} - (A,e): active={} Correct: |A⊓B| = 1 (one trace with overlap). Bug: overlap counted as 2 (both directions). Trace 2 (freq 1): A and B sequential. As Ae Bs Be Correct totals: |A⊓B|=1, |A|=2, |B|=2. Ratio = 2·1/(2+2) = 0.5. Bug totals: |A⊓B|=2 (double-counted). Bug ratio = 2·2/(2+2) = 1.0. :return: None. """ self.__tasks: dict[str, Task] = ( _make_tasks("A", "B") ) a: Task = self.__tasks["A"] b: Task = self.__tasks["B"] self.__traces: dict[ tuple[tuple[Task, str], ...], int ] = { # Trace 1: A self-loops, overlaps B ((a, S), (b, S), (a, E), (a, S), (b, E), (a, E)): 1, # Trace 2: sequential ((a, S), (a, E), (b, S), (b, E)): 1, } def test_not_concurrent_at_high_epsilon( self, ) -> None: """A not ||B with epsilon=0.6 (ratio=0.5). Correct ratio is 2·1/(2+2) = 0.5 < 0.6. With the bug (double-counted overlap), ratio would be 2·2/(2+2) = 1.0 >= 0.6. :return: None. """ dfg: RefinedDirectlyFollowsGraph = ( RefinedDirectlyFollowsGraph( self.__traces ) ) pruned: RefinedPrunedDFG = ( RefinedPrunedDFG( dfg, self.__traces, 0.6 ) ) a: Task = self.__tasks["A"] b: Task = self.__tasks["B"] self.assertFalse( pruned.is_concurrent(a, b) ) def test_concurrent_at_low_epsilon( self, ) -> None: """A||B with epsilon=0.5 (ratio=0.5 >= 0.5). :return: None. """ dfg: RefinedDirectlyFollowsGraph = ( RefinedDirectlyFollowsGraph( self.__traces ) ) pruned: RefinedPrunedDFG = ( RefinedPrunedDFG( dfg, self.__traces, 0.5 ) ) a: Task = self.__tasks["A"] b: Task = self.__tasks["B"] self.assertTrue( pruned.is_concurrent(a, b) ) if __name__ == "__main__": unittest.main()