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,423 @@
|
||||
# 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 SM 2.0 heuristics (Section 3.3).
|
||||
|
||||
Heuristic 1: AND-split with loop-edge -> preceding XOR-split
|
||||
to fix improper completion.
|
||||
Heuristic 2: AND-split with pairwise mutual exclusiveness
|
||||
-> OR-split.
|
||||
|
||||
Reference:
|
||||
Augusto, A., Dumas, M., & La Rosa, M. (2021).
|
||||
Automated Discovery of Process Models with True
|
||||
Concurrency and Inclusive Choices. Section 3.3.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from split_miner.bpmn import (
|
||||
BPMNModel,
|
||||
EndEvent,
|
||||
Gateway,
|
||||
GatewayType,
|
||||
Node,
|
||||
StartEvent,
|
||||
Task,
|
||||
)
|
||||
from split_miner.heuristics import (
|
||||
fix_improper_completion,
|
||||
detect_or_splits,
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
class TestFixImproperCompletion(unittest.TestCase):
|
||||
"""Tests Heuristic 1: fix improper completion.
|
||||
|
||||
For each AND-split with a loop-edge (leading to a
|
||||
topologically earlier node), create a preceding
|
||||
XOR-split to carry the loop-edge.
|
||||
"""
|
||||
|
||||
def _make_model_with_and_loop(
|
||||
self,
|
||||
) -> tuple[BPMNModel, dict[str, Node]]:
|
||||
"""Build a model with AND-split + loop-edge.
|
||||
|
||||
Structure:
|
||||
start -> a -> AND-split -> b -> join -> e
|
||||
-> c -> join
|
||||
-> a (loop-edge)
|
||||
join -> e -> end
|
||||
|
||||
The AND-split has 3 outgoing: b, c, a.
|
||||
Edge AND-split -> a is a loop-edge (goes
|
||||
back to the topologically earlier node a).
|
||||
|
||||
:return: The model and named nodes.
|
||||
"""
|
||||
start: StartEvent = StartEvent("start")
|
||||
end: EndEvent = EndEvent("end")
|
||||
model: BPMNModel = BPMNModel(start, end)
|
||||
|
||||
tasks: dict[str, Task] = _make_tasks(
|
||||
"a", "b", "c", "e"
|
||||
)
|
||||
for t in tasks.values():
|
||||
model.add_task(t)
|
||||
|
||||
and_split: Gateway = model.create_gateway(
|
||||
GatewayType.AND
|
||||
)
|
||||
and_join: Gateway = model.create_gateway(
|
||||
GatewayType.AND
|
||||
)
|
||||
|
||||
model.add_edge(start, tasks["a"])
|
||||
model.add_edge(tasks["a"], and_split)
|
||||
model.add_edge(and_split, tasks["b"])
|
||||
model.add_edge(and_split, tasks["c"])
|
||||
# Loop-edge: AND-split -> a (back to a)
|
||||
model.add_edge(and_split, tasks["a"])
|
||||
model.add_edge(tasks["b"], and_join)
|
||||
model.add_edge(tasks["c"], and_join)
|
||||
model.add_edge(and_join, tasks["e"])
|
||||
model.add_edge(tasks["e"], end)
|
||||
|
||||
nodes: dict[str, Node] = {
|
||||
"start": start, "end": end,
|
||||
"and_split": and_split,
|
||||
"and_join": and_join,
|
||||
}
|
||||
nodes.update(tasks)
|
||||
return model, nodes
|
||||
|
||||
def test_and_split_preserved(self) -> None:
|
||||
"""AND-split still exists after fix.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
model: BPMNModel
|
||||
nodes: dict[str, Node]
|
||||
model, nodes = (
|
||||
self._make_model_with_and_loop()
|
||||
)
|
||||
fix_improper_completion(model)
|
||||
and_split: Node = nodes["and_split"]
|
||||
self.assertIn(and_split, model.all_nodes)
|
||||
|
||||
def test_xor_split_created(self) -> None:
|
||||
"""A new XOR-split is created before AND-split.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
model: BPMNModel
|
||||
nodes: dict[str, Node]
|
||||
model, nodes = (
|
||||
self._make_model_with_and_loop()
|
||||
)
|
||||
fix_improper_completion(model)
|
||||
and_split: Node = nodes["and_split"]
|
||||
# AND-split should now have a single
|
||||
# predecessor that is the new XOR-split.
|
||||
preds: set[Node] = model.predecessors(
|
||||
and_split
|
||||
)
|
||||
xor_preds: list[Node] = [
|
||||
p for p in preds
|
||||
if isinstance(p, Gateway)
|
||||
and p.gateway_type == GatewayType.XOR
|
||||
]
|
||||
self.assertEqual(len(xor_preds), 1)
|
||||
|
||||
def test_loop_edge_moved_to_xor(self) -> None:
|
||||
"""Loop-edge originates from XOR-split, not AND.
|
||||
|
||||
The loop-edge to a should now be routed through
|
||||
the XOR-split: XOR -> a (loop), XOR -> AND -> b,c.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
model: BPMNModel
|
||||
nodes: dict[str, Node]
|
||||
model, nodes = (
|
||||
self._make_model_with_and_loop()
|
||||
)
|
||||
fix_improper_completion(model)
|
||||
and_split: Node = nodes["and_split"]
|
||||
a: Task = nodes["a"]
|
||||
# a should NOT be a successor of AND-split
|
||||
and_successors: set[Node] = (
|
||||
model.successors(and_split)
|
||||
)
|
||||
self.assertNotIn(a, and_successors)
|
||||
|
||||
def test_and_keeps_non_loop_edges(self) -> None:
|
||||
"""AND-split keeps its non-loop outgoing edges.
|
||||
|
||||
b and c should still be successors of AND-split.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
model: BPMNModel
|
||||
nodes: dict[str, Node]
|
||||
model, nodes = (
|
||||
self._make_model_with_and_loop()
|
||||
)
|
||||
fix_improper_completion(model)
|
||||
and_split: Node = nodes["and_split"]
|
||||
b: Task = nodes["b"]
|
||||
c: Task = nodes["c"]
|
||||
successors: set[Node] = (
|
||||
model.successors(and_split)
|
||||
)
|
||||
self.assertIn(b, successors)
|
||||
self.assertIn(c, successors)
|
||||
|
||||
def test_no_change_without_loop(self) -> None:
|
||||
"""No changes when AND-split has no loop-edge.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
start: StartEvent = StartEvent("start")
|
||||
end: EndEvent = EndEvent("end")
|
||||
model: BPMNModel = BPMNModel(start, end)
|
||||
tasks: dict[str, Task] = _make_tasks(
|
||||
"a", "b", "c"
|
||||
)
|
||||
for t in tasks.values():
|
||||
model.add_task(t)
|
||||
and_split: Gateway = model.create_gateway(
|
||||
GatewayType.AND
|
||||
)
|
||||
model.add_edge(start, and_split)
|
||||
model.add_edge(and_split, tasks["a"])
|
||||
model.add_edge(and_split, tasks["b"])
|
||||
model.add_edge(tasks["a"], tasks["c"])
|
||||
model.add_edge(tasks["b"], tasks["c"])
|
||||
model.add_edge(tasks["c"], end)
|
||||
edges_before: set[tuple[Node, Node]] = (
|
||||
set(model.edges)
|
||||
)
|
||||
fix_improper_completion(model)
|
||||
self.assertEqual(model.edges, edges_before)
|
||||
|
||||
|
||||
class TestDetectOrSplits(unittest.TestCase):
|
||||
"""Tests Heuristic 2: detect OR-splits.
|
||||
|
||||
For each AND-split, check if successor activities
|
||||
are pairwise both concurrent and mutually exclusive
|
||||
in different traces. If the majority of pairs
|
||||
qualify, convert AND to OR.
|
||||
"""
|
||||
|
||||
def _make_or_candidate_model(
|
||||
self,
|
||||
) -> tuple[
|
||||
BPMNModel,
|
||||
dict[str, Node],
|
||||
dict[tuple[tuple[Task, str], ...], int],
|
||||
]:
|
||||
"""Build a model with an AND-split that should be OR.
|
||||
|
||||
Model: start -> a -> AND-split -> b -> join
|
||||
-> c -> join
|
||||
-> d -> join
|
||||
join -> e -> end
|
||||
|
||||
Traces (from paper's Lrho_y):
|
||||
- {As,Ae,Bs,Cs,Ds,Be,De,Ce,Es,Ee} x3
|
||||
(B,C,D all present, overlapping)
|
||||
- {As,Ae,Cs,Ds,Ce,De,Es,Ee} x2
|
||||
(no B - B and C mutually exclusive)
|
||||
- {As,Ae,Bs,Ds,De,Be,Es,Ee} x1
|
||||
(no C - B and C mutually exclusive)
|
||||
|
||||
:return: The model, nodes, and traces.
|
||||
"""
|
||||
start: StartEvent = StartEvent("start")
|
||||
end: EndEvent = EndEvent("end")
|
||||
model: BPMNModel = BPMNModel(start, end)
|
||||
|
||||
tasks: dict[str, Task] = _make_tasks(
|
||||
"a", "b", "c", "d", "e"
|
||||
)
|
||||
for t in tasks.values():
|
||||
model.add_task(t)
|
||||
|
||||
and_split: Gateway = model.create_gateway(
|
||||
GatewayType.AND
|
||||
)
|
||||
and_join: Gateway = model.create_gateway(
|
||||
GatewayType.AND
|
||||
)
|
||||
|
||||
model.add_edge(start, tasks["a"])
|
||||
model.add_edge(tasks["a"], and_split)
|
||||
model.add_edge(and_split, tasks["b"])
|
||||
model.add_edge(and_split, tasks["c"])
|
||||
model.add_edge(and_split, tasks["d"])
|
||||
model.add_edge(tasks["b"], and_join)
|
||||
model.add_edge(tasks["c"], and_join)
|
||||
model.add_edge(tasks["d"], and_join)
|
||||
model.add_edge(and_join, tasks["e"])
|
||||
model.add_edge(tasks["e"], end)
|
||||
|
||||
a: Task = tasks["a"]
|
||||
b: Task = tasks["b"]
|
||||
c: Task = tasks["c"]
|
||||
d: Task = tasks["d"]
|
||||
e: Task = tasks["e"]
|
||||
|
||||
traces: dict[
|
||||
tuple[tuple[Task, str], ...], int
|
||||
] = {
|
||||
# All three present, overlapping
|
||||
((a, S), (a, E), (b, S), (c, S),
|
||||
(d, S), (b, E), (d, E), (c, E),
|
||||
(e, S), (e, E)): 3,
|
||||
# No B (B,C mutually exclusive)
|
||||
((a, S), (a, E), (c, S), (d, S),
|
||||
(c, E), (d, E),
|
||||
(e, S), (e, E)): 2,
|
||||
# No C (B,C mutually exclusive)
|
||||
((a, S), (a, E), (b, S), (d, S),
|
||||
(d, E), (b, E),
|
||||
(e, S), (e, E)): 1,
|
||||
}
|
||||
|
||||
nodes: dict[str, Node] = {
|
||||
"start": start, "end": end,
|
||||
"and_split": and_split,
|
||||
"and_join": and_join,
|
||||
}
|
||||
nodes.update(tasks)
|
||||
return model, nodes, traces
|
||||
|
||||
def test_and_becomes_or(self) -> None:
|
||||
"""AND-split is converted to OR-split.
|
||||
|
||||
B,C: concurrent 3x, exclusive 3x -> eligible.
|
||||
B,D: concurrent 4x, exclusive 2x -> eligible.
|
||||
C,D: concurrent 5x, exclusive 1x -> NOT eligible.
|
||||
2/3 pairs eligible -> majority -> OR.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
model: BPMNModel
|
||||
nodes: dict[str, Node]
|
||||
traces: dict[
|
||||
tuple[tuple[Task, str], ...], int
|
||||
]
|
||||
model, nodes, traces = (
|
||||
self._make_or_candidate_model()
|
||||
)
|
||||
and_split: Gateway = nodes["and_split"]
|
||||
detect_or_splits(model, traces)
|
||||
self.assertEqual(
|
||||
and_split.gateway_type, GatewayType.OR,
|
||||
)
|
||||
|
||||
def test_join_also_becomes_or(self) -> None:
|
||||
"""Corresponding join is also converted to OR.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
model: BPMNModel
|
||||
nodes: dict[str, Node]
|
||||
traces: dict[
|
||||
tuple[tuple[Task, str], ...], int
|
||||
]
|
||||
model, nodes, traces = (
|
||||
self._make_or_candidate_model()
|
||||
)
|
||||
and_join: Gateway = nodes["and_join"]
|
||||
detect_or_splits(model, traces)
|
||||
self.assertEqual(
|
||||
and_join.gateway_type, GatewayType.OR,
|
||||
)
|
||||
|
||||
def test_no_change_when_always_concurrent(
|
||||
self,
|
||||
) -> None:
|
||||
"""AND-split stays AND when no mutual exclusion.
|
||||
|
||||
If all successors always co-occur, no pair
|
||||
is mutually exclusive, so AND stays AND.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
start: StartEvent = StartEvent("start")
|
||||
end: EndEvent = EndEvent("end")
|
||||
model: BPMNModel = BPMNModel(start, end)
|
||||
tasks: dict[str, Task] = _make_tasks(
|
||||
"a", "b", "c"
|
||||
)
|
||||
for t in tasks.values():
|
||||
model.add_task(t)
|
||||
and_split: Gateway = model.create_gateway(
|
||||
GatewayType.AND
|
||||
)
|
||||
and_join: Gateway = model.create_gateway(
|
||||
GatewayType.AND
|
||||
)
|
||||
model.add_edge(start, tasks["a"])
|
||||
model.add_edge(tasks["a"], and_split)
|
||||
model.add_edge(and_split, tasks["b"])
|
||||
model.add_edge(and_split, tasks["c"])
|
||||
model.add_edge(tasks["b"], and_join)
|
||||
model.add_edge(tasks["c"], and_join)
|
||||
model.add_edge(and_join, end)
|
||||
a: Task = tasks["a"]
|
||||
b: Task = tasks["b"]
|
||||
c: Task = tasks["c"]
|
||||
traces: dict[
|
||||
tuple[tuple[Task, str], ...], int
|
||||
] = {
|
||||
((a, S), (a, E), (b, S), (c, S),
|
||||
(b, E), (c, E)): 5,
|
||||
}
|
||||
detect_or_splits(model, traces)
|
||||
self.assertEqual(
|
||||
and_split.gateway_type, GatewayType.AND,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,693 @@
|
||||
# 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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,332 @@
|
||||
# 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.
|
||||
"""Integration tests for Split Miner 2.0 pipeline.
|
||||
|
||||
Verifies that split_miner_2() produces valid BPMN models
|
||||
from lifecycle-aware event logs.
|
||||
|
||||
Reference:
|
||||
Augusto, A., Dumas, M., & La Rosa, M. (2021).
|
||||
Automated Discovery of Process Models with True
|
||||
Concurrency and Inclusive Choices.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from split_miner.bpmn import (
|
||||
BPMNModel,
|
||||
Gateway,
|
||||
GatewayType,
|
||||
Node,
|
||||
StartEvent,
|
||||
Task,
|
||||
)
|
||||
from split_miner.miner import split_miner_2
|
||||
|
||||
S: str = "start"
|
||||
"""Lifecycle start constant."""
|
||||
E: str = "end"
|
||||
"""Lifecycle end constant."""
|
||||
|
||||
|
||||
class TestSequentialPipeline(unittest.TestCase):
|
||||
"""Tests SM 2.0 with a simple sequential log.
|
||||
|
||||
A -> B -> C with no concurrency.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up sequential lifecycle traces.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
traces: dict[
|
||||
tuple[tuple[str, str], ...], int
|
||||
] = {
|
||||
(("A", S), ("A", E),
|
||||
("B", S), ("B", E),
|
||||
("C", S), ("C", E)): 5,
|
||||
}
|
||||
self.__model: BPMNModel = split_miner_2(
|
||||
traces
|
||||
)
|
||||
|
||||
def test_has_start_and_end(self) -> None:
|
||||
"""Model has start and end events.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertIsNotNone(self.__model.start)
|
||||
self.assertIsNotNone(self.__model.end)
|
||||
|
||||
def test_has_three_tasks(self) -> None:
|
||||
"""Model has 3 tasks (A, B, C).
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(
|
||||
len(self.__model.tasks), 3
|
||||
)
|
||||
|
||||
def test_no_gateways(self) -> None:
|
||||
"""Sequential model has no gateways.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(
|
||||
len(self.__model.gateways), 0
|
||||
)
|
||||
|
||||
|
||||
class TestConcurrentPipeline(unittest.TestCase):
|
||||
"""Tests SM 2.0 with concurrent activities.
|
||||
|
||||
A -> (B || C) -> D where B and C always overlap.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up concurrent lifecycle traces.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
traces: dict[
|
||||
tuple[tuple[str, str], ...], int
|
||||
] = {
|
||||
(("A", S), ("A", E),
|
||||
("B", S), ("C", S),
|
||||
("B", E), ("C", E),
|
||||
("D", S), ("D", E)): 5,
|
||||
}
|
||||
self.__model: BPMNModel = split_miner_2(
|
||||
traces
|
||||
)
|
||||
|
||||
def test_has_four_tasks(self) -> None:
|
||||
"""Model has 4 tasks (A, B, C, D).
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(
|
||||
len(self.__model.tasks), 4
|
||||
)
|
||||
|
||||
def test_has_gateways(self) -> None:
|
||||
"""Model has gateway(s) for concurrency.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertGreater(
|
||||
len(self.__model.gateways), 0
|
||||
)
|
||||
|
||||
def test_has_and_split(self) -> None:
|
||||
"""Model has an AND-split for B || C.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
and_splits: list[Gateway] = [
|
||||
gw for gw
|
||||
in self.__model.gateways.values()
|
||||
if gw.gateway_type == GatewayType.AND
|
||||
and len(
|
||||
self.__model.outgoing_edges(gw)
|
||||
) > 1
|
||||
]
|
||||
self.assertGreater(len(and_splits), 0)
|
||||
|
||||
|
||||
class TestPaperExample(unittest.TestCase):
|
||||
"""Tests SM 2.0 on the paper's Lrho_x example.
|
||||
|
||||
Should produce a valid BPMN model with A-F.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up the paper's example.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
traces: dict[
|
||||
tuple[tuple[str, str], ...], int
|
||||
] = {
|
||||
(("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,
|
||||
(("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,
|
||||
(("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,
|
||||
(("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,
|
||||
}
|
||||
self.__model: BPMNModel = split_miner_2(
|
||||
traces
|
||||
)
|
||||
|
||||
def test_has_six_tasks(self) -> None:
|
||||
"""Model has 6 tasks (A through F).
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertEqual(
|
||||
len(self.__model.tasks), 6
|
||||
)
|
||||
|
||||
def test_completes_without_error(self) -> None:
|
||||
"""Pipeline completes without error.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.assertIsNotNone(self.__model)
|
||||
|
||||
def test_start_connects_to_a(self) -> None:
|
||||
"""Start event connects to task A.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
start_succs: set[Node] = (
|
||||
self.__model.successors(
|
||||
self.__model.start
|
||||
)
|
||||
)
|
||||
# Either directly to A or through a gateway
|
||||
reachable: set[str] = set()
|
||||
queue: list[Node] = list(start_succs)
|
||||
visited: set[Node] = set()
|
||||
while queue:
|
||||
n: Node = queue.pop(0)
|
||||
if n in visited:
|
||||
continue
|
||||
visited.add(n)
|
||||
if isinstance(n, Task):
|
||||
reachable.add(n.node_id)
|
||||
elif isinstance(n, Gateway):
|
||||
queue.extend(
|
||||
self.__model.successors(n)
|
||||
)
|
||||
self.assertIn("A", reachable)
|
||||
|
||||
|
||||
class TestSelfLoopPipeline(unittest.TestCase):
|
||||
"""Tests SM 2.0 with a self-loop activity.
|
||||
|
||||
A -> B (self-loop) -> C. B repeats in the same
|
||||
trace without going through other activities.
|
||||
The self-loop XOR back-edge is the only source of
|
||||
gateways around B.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up lifecycle traces with self-loop on B.
|
||||
|
||||
Trace: As Ae Bs Be Bs Be Cs Ce
|
||||
B completes twice. No other loops exist,
|
||||
so only self-loop restoration adds gateways
|
||||
around B.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
traces: dict[
|
||||
tuple[tuple[str, str], ...], int
|
||||
] = {
|
||||
(("A", S), ("A", E),
|
||||
("B", S), ("B", E),
|
||||
("B", S), ("B", E),
|
||||
("C", S), ("C", E)): 5,
|
||||
}
|
||||
self.__model: BPMNModel = split_miner_2(
|
||||
traces
|
||||
)
|
||||
|
||||
def test_self_loop_xor_join_before_b(
|
||||
self,
|
||||
) -> None:
|
||||
"""Task B has an XOR-join predecessor.
|
||||
|
||||
The self-loop restoration inserts an XOR-join
|
||||
before B. Without restoration, B connects
|
||||
directly to its predecessor (A or start).
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
b: Task = self.__model.get_task("B")
|
||||
preds: set[Node] = (
|
||||
self.__model.predecessors(b)
|
||||
)
|
||||
xor_joins: list[Gateway] = [
|
||||
p for p in preds
|
||||
if isinstance(p, Gateway)
|
||||
and p.gateway_type == GatewayType.XOR
|
||||
and len(
|
||||
self.__model.incoming_edges(p)
|
||||
) > 1
|
||||
]
|
||||
self.assertGreater(
|
||||
len(xor_joins), 0,
|
||||
"Self-loop task B should have an "
|
||||
"XOR-join predecessor",
|
||||
)
|
||||
|
||||
def test_self_loop_xor_split_after_b(
|
||||
self,
|
||||
) -> None:
|
||||
"""Task B has an XOR-split successor.
|
||||
|
||||
The self-loop restoration inserts an XOR-split
|
||||
after B with a back-edge to the XOR-join.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
b: Task = self.__model.get_task("B")
|
||||
succs: set[Node] = (
|
||||
self.__model.successors(b)
|
||||
)
|
||||
xor_splits: list[Gateway] = [
|
||||
s for s in succs
|
||||
if isinstance(s, Gateway)
|
||||
and s.gateway_type == GatewayType.XOR
|
||||
and len(
|
||||
self.__model.outgoing_edges(s)
|
||||
) > 1
|
||||
]
|
||||
self.assertGreater(
|
||||
len(xor_splits), 0,
|
||||
"Self-loop task B should have an "
|
||||
"XOR-split successor",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user