Files
split-miner/tests/test_refined_concurrency.py
T

836 lines
22 KiB
Python

# 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 every completed lifecycle.
Per Equation 5, |A| is the total number of complete
lifecycle observations of activity A. An activity
that completes multiple times in a single trace
(self-loop) counts once per completion.
"""
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 overlapping lifecycle pair).
|B| = 2 (2 complete lifecycles of B).
|C| = 1 (1 complete lifecycle of C).
Ratio = 2·1/(2+1) = 0.667.
: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 with epsilon=0.6 (ratio=0.667 >= 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.assertTrue(
pruned.is_concurrent(b, c)
)
def test_not_concurrent_high_epsilon(
self,
) -> None:
"""B not ||C with epsilon=0.7 (ratio=0.667).
The second execution of B counts towards |B|,
so the ratio is 2·1/(2+1) = 0.667, not
2·1/(1+1) = 1.0.
:return: None.
"""
dfg: RefinedDirectlyFollowsGraph = (
RefinedDirectlyFollowsGraph(
self.__traces
)
)
pruned: RefinedPrunedDFG = (
RefinedPrunedDFG(
dfg, self.__traces, 0.7
)
)
b: Task = self.__tasks["B"]
c: Task = self.__tasks["C"]
self.assertFalse(
pruned.is_concurrent(b, c)
)
class TestSelfLoopOverlapCount(unittest.TestCase):
"""Tests that |A⊓B| counts every overlapping pair.
Per Equation 5, |A⊓B| is the total number of
observations of overlapping lifecycles of A and B.
When a self-loop activity overlaps another activity
twice within one trace, both observations count.
"""
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={}
|A⊓B| = 2 in this trace: the first lifecycle of
A overlaps B, and B overlaps the second
lifecycle of A.
Trace 2 (freq 1): A and B sequential.
As Ae Bs Be
Totals: |A⊓B|=2, |A|=3, |B|=2.
Ratio = 2·2/(3+2) = 0.8.
: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.9 (ratio=0.8).
:return: None.
"""
dfg: RefinedDirectlyFollowsGraph = (
RefinedDirectlyFollowsGraph(
self.__traces
)
)
pruned: RefinedPrunedDFG = (
RefinedPrunedDFG(
dfg, self.__traces, 0.9
)
)
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.8 (ratio=0.8 >= 0.8).
:return: None.
"""
dfg: RefinedDirectlyFollowsGraph = (
RefinedDirectlyFollowsGraph(
self.__traces
)
)
pruned: RefinedPrunedDFG = (
RefinedPrunedDFG(
dfg, self.__traces, 0.8
)
)
a: Task = self.__tasks["A"]
b: Task = self.__tasks["B"]
self.assertTrue(
pruned.is_concurrent(a, b)
)
class TestRepeatedExecutionCounting(
unittest.TestCase
):
"""Tests that every complete lifecycle is counted.
Per Equation 5 and footnote 3, |A| is the total
number of complete lifecycle observations of A,
so repeated executions of A within a single trace
each count towards |A|.
"""
def setUp(self) -> None:
"""Set up a trace where A executes twice.
Trace (freq 1): As Bs Be Ae As Ae
|A⊓B| = 1, |A| = 2, |B| = 1.
Ratio = 2·1/(2+1) = 0.667.
: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
] = {
((a, S), (b, S), (b, E), (a, E),
(a, S), (a, E)): 1,
}
def test_concurrent_below_ratio(self) -> None:
"""A||B with epsilon=0.6 (ratio=0.667 >= 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.assertTrue(
pruned.is_concurrent(a, b)
)
def test_not_concurrent_above_ratio(
self,
) -> None:
"""A not ||B with epsilon=0.7 (ratio=0.667).
The second execution of A counts towards |A|,
so the ratio is 2·1/(2+1) = 0.667, not
2·1/(1+1) = 1.0.
:return: None.
"""
dfg: RefinedDirectlyFollowsGraph = (
RefinedDirectlyFollowsGraph(
self.__traces
)
)
pruned: RefinedPrunedDFG = (
RefinedPrunedDFG(
dfg, self.__traces, 0.7
)
)
a: Task = self.__tasks["A"]
b: Task = self.__tasks["B"]
self.assertFalse(
pruned.is_concurrent(a, b)
)
class TestIncompleteLifecycle(unittest.TestCase):
"""Tests that incomplete lifecycles are not counted.
Per footnote 3, only complete lifecycle
observations, i.e. a start event matched by its end
event, count towards |A| and |B|.
"""
def setUp(self) -> None:
"""Set up a trace where A never ends.
Trace 1 (freq 1): As Bs Be
Trace 2 (freq 1): As Bs Be Ae
A has no matching end in trace 1, so that
lifecycle and its overlap with B are not
observed: |A| = 1, |B| = 2, |A⊓B| = 1.
Ratio = 2·1/(1+2) = 0.667.
: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
] = {
((a, S), (b, S), (b, E)): 1,
((a, S), (b, S), (b, E), (a, E)): 1,
}
def test_not_concurrent_above_ratio(
self,
) -> None:
"""A not ||B with epsilon=0.7 (ratio=0.667).
:return: None.
"""
dfg: RefinedDirectlyFollowsGraph = (
RefinedDirectlyFollowsGraph(
self.__traces
)
)
pruned: RefinedPrunedDFG = (
RefinedPrunedDFG(
dfg, self.__traces, 0.7
)
)
a: Task = self.__tasks["A"]
b: Task = self.__tasks["B"]
self.assertFalse(
pruned.is_concurrent(a, b)
)
def test_concurrent_below_ratio(self) -> None:
"""A||B with epsilon=0.6 (ratio=0.667 >= 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.assertTrue(
pruned.is_concurrent(a, b)
)
if __name__ == "__main__":
unittest.main()