836 lines
22 KiB
Python
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()
|