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,323 @@
|
|||||||
|
# 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.
|
||||||
|
"""SM 2.0 heuristics for improper completion and OR-splits.
|
||||||
|
|
||||||
|
Heuristic 1 (fix_improper_completion): For each AND-split
|
||||||
|
with a loop-edge (leading to a topologically earlier node),
|
||||||
|
create a preceding XOR-split and move the loop-edge to it.
|
||||||
|
|
||||||
|
Heuristic 2 (detect_or_splits): For each AND-split, check
|
||||||
|
pairwise whether successor activities are both concurrent
|
||||||
|
and mutually exclusive in different traces. If the majority
|
||||||
|
of pairs qualify, convert the AND-split to 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
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
from split_miner.bpmn import (
|
||||||
|
BPMNModel,
|
||||||
|
Gateway,
|
||||||
|
GatewayType,
|
||||||
|
Node,
|
||||||
|
Task,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fix_improper_completion(
|
||||||
|
model: BPMNModel,
|
||||||
|
) -> None:
|
||||||
|
"""Fix improper completion from AND-split loop-edges.
|
||||||
|
|
||||||
|
For each AND-split with an outgoing edge that is a
|
||||||
|
loop-edge (target is topologically before the source
|
||||||
|
in the process graph), insert a preceding XOR-split
|
||||||
|
and move the loop-edge to the XOR-split.
|
||||||
|
|
||||||
|
:param model: The BPMN model to modify in place.
|
||||||
|
"""
|
||||||
|
topo_order: dict[Node, int] = (
|
||||||
|
_compute_topo_order(model)
|
||||||
|
)
|
||||||
|
# Collect AND-splits (list avoids mutation issues).
|
||||||
|
and_splits: list[Gateway] = [
|
||||||
|
gw for gw in model.gateways.values()
|
||||||
|
if gw.gateway_type == GatewayType.AND
|
||||||
|
and len(model.outgoing_edges(gw)) > 1
|
||||||
|
]
|
||||||
|
for gw in and_splits:
|
||||||
|
loop_targets: list[Node] = []
|
||||||
|
for _, tgt in model.outgoing_edges(gw):
|
||||||
|
gw_order: int = topo_order.get(gw, -1)
|
||||||
|
tgt_order: int = topo_order.get(tgt, -1)
|
||||||
|
if tgt_order <= gw_order:
|
||||||
|
loop_targets.append(tgt)
|
||||||
|
if not loop_targets:
|
||||||
|
continue
|
||||||
|
# Create XOR-split before AND-split.
|
||||||
|
xor: Gateway = model.create_gateway(
|
||||||
|
GatewayType.XOR
|
||||||
|
)
|
||||||
|
# Redirect incoming edges of AND-split to XOR.
|
||||||
|
in_edges: set[tuple[Node, Node]] = (
|
||||||
|
model.incoming_edges(gw)
|
||||||
|
)
|
||||||
|
for src, _ in in_edges:
|
||||||
|
model.redirect_edge_target(src, gw, xor)
|
||||||
|
# XOR -> AND (forward path)
|
||||||
|
model.add_edge(xor, gw)
|
||||||
|
# Move loop-edges from AND to XOR.
|
||||||
|
for tgt in loop_targets:
|
||||||
|
model.remove_edge(gw, tgt)
|
||||||
|
model.add_edge(xor, tgt)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_topo_order(
|
||||||
|
model: BPMNModel,
|
||||||
|
) -> dict[Node, int]:
|
||||||
|
"""Compute topological order via BFS from start.
|
||||||
|
|
||||||
|
Nodes reachable from start get increasing order.
|
||||||
|
Nodes not reachable get order -1.
|
||||||
|
|
||||||
|
:param model: The BPMN model.
|
||||||
|
:return: A dict mapping node to topological order.
|
||||||
|
"""
|
||||||
|
order: dict[Node, int] = {}
|
||||||
|
visited: set[Node] = set()
|
||||||
|
queue: list[Node] = [model.start]
|
||||||
|
idx: int = 0
|
||||||
|
while queue:
|
||||||
|
node: Node = queue.pop(0)
|
||||||
|
if node in visited:
|
||||||
|
continue
|
||||||
|
visited.add(node)
|
||||||
|
order[node] = idx
|
||||||
|
idx += 1
|
||||||
|
for succ in sorted(
|
||||||
|
model.successors(node),
|
||||||
|
key=lambda n: (
|
||||||
|
type(n).__name__, n.node_id
|
||||||
|
),
|
||||||
|
):
|
||||||
|
if succ not in visited:
|
||||||
|
queue.append(succ)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def detect_or_splits(
|
||||||
|
model: BPMNModel,
|
||||||
|
traces: dict[
|
||||||
|
tuple[tuple[Node, str], ...], int
|
||||||
|
],
|
||||||
|
) -> None:
|
||||||
|
"""Detect AND-splits that should be OR-splits.
|
||||||
|
|
||||||
|
For each AND-split, examine successor activities
|
||||||
|
pairwise. A pair is "eligible for inclusiveness"
|
||||||
|
if they are concurrent in some traces and mutually
|
||||||
|
exclusive in others, with at least 1 exclusive
|
||||||
|
observation per 2 concurrent observations (or
|
||||||
|
vice-versa).
|
||||||
|
|
||||||
|
If the majority of pairs are eligible, convert the
|
||||||
|
AND-split (and its corresponding AND-join) to OR.
|
||||||
|
|
||||||
|
:param model: The BPMN model to modify in place.
|
||||||
|
:param traces: The lifecycle traces.
|
||||||
|
"""
|
||||||
|
# Pre-compute activity presence per trace.
|
||||||
|
trace_activities: list[
|
||||||
|
tuple[set[Node], int]
|
||||||
|
] = []
|
||||||
|
trace_overlaps: list[
|
||||||
|
tuple[set[tuple[Node, Node]], int]
|
||||||
|
] = []
|
||||||
|
for trace, count in traces.items():
|
||||||
|
activities: set[Node] = set()
|
||||||
|
overlaps: set[tuple[Node, Node]] = set()
|
||||||
|
active: set[Node] = set()
|
||||||
|
for node, lifecycle in trace:
|
||||||
|
if lifecycle == "start":
|
||||||
|
for other in active:
|
||||||
|
overlaps.add(
|
||||||
|
_ordered_pair(other, node)
|
||||||
|
)
|
||||||
|
active.add(node)
|
||||||
|
elif lifecycle == "end":
|
||||||
|
active.discard(node)
|
||||||
|
activities.add(node)
|
||||||
|
trace_activities.append(
|
||||||
|
(activities, count)
|
||||||
|
)
|
||||||
|
trace_overlaps.append(
|
||||||
|
(overlaps, count)
|
||||||
|
)
|
||||||
|
|
||||||
|
and_splits: list[Gateway] = [
|
||||||
|
gw for gw in model.gateways.values()
|
||||||
|
if gw.gateway_type == GatewayType.AND
|
||||||
|
and len(model.outgoing_edges(gw)) > 1
|
||||||
|
]
|
||||||
|
|
||||||
|
for gw in and_splits:
|
||||||
|
# Get successor tasks (skip gateways).
|
||||||
|
succ_tasks: list[Task] = _get_leaf_tasks(
|
||||||
|
model, gw
|
||||||
|
)
|
||||||
|
if len(succ_tasks) < 2:
|
||||||
|
continue
|
||||||
|
# Check pairwise.
|
||||||
|
eligible: int = 0
|
||||||
|
total: int = 0
|
||||||
|
for i, a in enumerate(succ_tasks):
|
||||||
|
for b in succ_tasks[i + 1:]:
|
||||||
|
total += 1
|
||||||
|
pair: tuple[Node, Node] = (
|
||||||
|
_ordered_pair(a, b)
|
||||||
|
)
|
||||||
|
conc: int = 0
|
||||||
|
excl: int = 0
|
||||||
|
for j, (acts, cnt) in enumerate(
|
||||||
|
trace_activities
|
||||||
|
):
|
||||||
|
a_in: bool = a in acts
|
||||||
|
b_in: bool = b in acts
|
||||||
|
ovlps: set[
|
||||||
|
tuple[Node, Node]
|
||||||
|
] = trace_overlaps[j][0]
|
||||||
|
if a_in and b_in:
|
||||||
|
if pair in ovlps:
|
||||||
|
conc += cnt
|
||||||
|
elif a_in or b_in:
|
||||||
|
excl += cnt
|
||||||
|
if conc > 0 and excl > 0:
|
||||||
|
# Footnote 4: at least 1 per 2
|
||||||
|
hi: int = max(conc, excl)
|
||||||
|
lo: int = min(conc, excl)
|
||||||
|
if hi <= 2 * lo:
|
||||||
|
eligible += 1
|
||||||
|
if total > 0 and eligible > total / 2:
|
||||||
|
gw.gateway_type = GatewayType.OR
|
||||||
|
# Find and update corresponding join.
|
||||||
|
_update_corresponding_join(model, gw)
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_pair(
|
||||||
|
a: Node, b: Node
|
||||||
|
) -> tuple[Node, Node]:
|
||||||
|
"""Return the pair in canonical order.
|
||||||
|
|
||||||
|
:param a: First node.
|
||||||
|
:param b: Second node.
|
||||||
|
:return: The ordered pair.
|
||||||
|
"""
|
||||||
|
key_a: tuple[str, str] = (
|
||||||
|
type(a).__name__, a.node_id
|
||||||
|
)
|
||||||
|
key_b: tuple[str, str] = (
|
||||||
|
type(b).__name__, b.node_id
|
||||||
|
)
|
||||||
|
if key_a <= key_b:
|
||||||
|
return (a, b)
|
||||||
|
return (b, a)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_leaf_tasks(
|
||||||
|
model: BPMNModel, gw: Gateway
|
||||||
|
) -> list[Task]:
|
||||||
|
"""Get the leaf Task successors of a gateway.
|
||||||
|
|
||||||
|
Follows through intermediate gateways to find
|
||||||
|
the actual Task nodes.
|
||||||
|
|
||||||
|
:param model: The BPMN model.
|
||||||
|
:param gw: The gateway.
|
||||||
|
:return: The leaf tasks.
|
||||||
|
"""
|
||||||
|
result: list[Task] = []
|
||||||
|
queue: list[Node] = list(model.successors(gw))
|
||||||
|
visited: set[Node] = set()
|
||||||
|
while queue:
|
||||||
|
node: Node = queue.pop(0)
|
||||||
|
if node in visited:
|
||||||
|
continue
|
||||||
|
visited.add(node)
|
||||||
|
if isinstance(node, Task):
|
||||||
|
result.append(node)
|
||||||
|
elif isinstance(node, Gateway):
|
||||||
|
queue.extend(model.successors(node))
|
||||||
|
return sorted(
|
||||||
|
result,
|
||||||
|
key=lambda t: t.node_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_corresponding_join(
|
||||||
|
model: BPMNModel, split_gw: Gateway
|
||||||
|
) -> None:
|
||||||
|
"""Find and update the corresponding join gateway.
|
||||||
|
|
||||||
|
Finds the AND-join that merges the paths from
|
||||||
|
the split gateway and converts it to OR.
|
||||||
|
|
||||||
|
:param model: The BPMN model.
|
||||||
|
:param split_gw: The split gateway (now OR).
|
||||||
|
"""
|
||||||
|
# BFS from split to find the convergence point.
|
||||||
|
for node in _bfs_nodes(model, split_gw):
|
||||||
|
if not isinstance(node, Gateway):
|
||||||
|
continue
|
||||||
|
if node == split_gw:
|
||||||
|
continue
|
||||||
|
if node.gateway_type != GatewayType.AND:
|
||||||
|
continue
|
||||||
|
in_edges: set[tuple[Node, Node]] = (
|
||||||
|
model.incoming_edges(node)
|
||||||
|
)
|
||||||
|
if len(in_edges) > 1:
|
||||||
|
node.gateway_type = GatewayType.OR
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _bfs_nodes(
|
||||||
|
model: BPMNModel, start: Node
|
||||||
|
) -> Iterator[Node]:
|
||||||
|
"""BFS traversal from a starting node.
|
||||||
|
|
||||||
|
:param model: The BPMN model.
|
||||||
|
:param start: The starting node.
|
||||||
|
:return: Iterator of nodes in BFS order.
|
||||||
|
"""
|
||||||
|
visited: set[Node] = set()
|
||||||
|
queue: list[Node] = [start]
|
||||||
|
while queue:
|
||||||
|
node: Node = queue.pop(0)
|
||||||
|
if node in visited:
|
||||||
|
continue
|
||||||
|
visited.add(node)
|
||||||
|
yield node
|
||||||
|
for succ in model.successors(node):
|
||||||
|
if succ not in visited:
|
||||||
|
queue.append(succ)
|
||||||
+111
-1
@@ -26,6 +26,12 @@ SM 1.0 paper):
|
|||||||
4. Splits discovery
|
4. Splits discovery
|
||||||
5. Joins discovery
|
5. Joins discovery
|
||||||
6. OR-joins minimization
|
6. OR-joins minimization
|
||||||
|
|
||||||
|
Also provides Split Miner 2.0 which adds:
|
||||||
|
- Refined DFG using activity lifecycle (Definition 6)
|
||||||
|
- True concurrency via lifecycle overlap (Equation 5)
|
||||||
|
- Heuristic 1: fix improper completion from loop-edges
|
||||||
|
- Heuristic 2: detect OR-splits from mutual exclusiveness
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -41,8 +47,20 @@ from split_miner.bpmn import (
|
|||||||
from split_miner.concurrency import PrunedDFG
|
from split_miner.concurrency import PrunedDFG
|
||||||
from split_miner.dfg import DirectlyFollowsGraph
|
from split_miner.dfg import DirectlyFollowsGraph
|
||||||
from split_miner.filtering import FilteredDFG
|
from split_miner.filtering import FilteredDFG
|
||||||
|
from split_miner.heuristics import (
|
||||||
|
detect_or_splits,
|
||||||
|
fix_improper_completion,
|
||||||
|
)
|
||||||
from split_miner.joins import discover_joins
|
from split_miner.joins import discover_joins
|
||||||
from split_miner.or_minimization import replace_or_joins
|
from split_miner.or_minimization import (
|
||||||
|
replace_or_joins,
|
||||||
|
)
|
||||||
|
from split_miner.refined_concurrency import (
|
||||||
|
RefinedPrunedDFG,
|
||||||
|
)
|
||||||
|
from split_miner.refined_dfg import (
|
||||||
|
RefinedDirectlyFollowsGraph,
|
||||||
|
)
|
||||||
from split_miner.splits import discover_splits
|
from split_miner.splits import discover_splits
|
||||||
|
|
||||||
|
|
||||||
@@ -117,6 +135,98 @@ def split_miner(
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def split_miner_2(
|
||||||
|
traces: dict[
|
||||||
|
tuple[tuple[str, str], ...], int
|
||||||
|
],
|
||||||
|
epsilon: float = 0.33,
|
||||||
|
eta: float = 0.8,
|
||||||
|
) -> BPMNModel:
|
||||||
|
"""Run Split Miner 2.0 to discover a BPMN model.
|
||||||
|
|
||||||
|
Uses lifecycle-aware traces (start/end events) for
|
||||||
|
refined DFG construction and true concurrency
|
||||||
|
discovery, plus heuristics for improper completion
|
||||||
|
and OR-split detection.
|
||||||
|
|
||||||
|
:param traces: The input event log as a dict mapping
|
||||||
|
each trace (tuple of (activity_label, lifecycle)
|
||||||
|
pairs where lifecycle is "start" or "end") to
|
||||||
|
its frequency.
|
||||||
|
:param epsilon: Concurrency threshold (0 to 1).
|
||||||
|
Minimum overlap ratio to consider two activities
|
||||||
|
concurrent (Equation 5).
|
||||||
|
:param eta: Filtering percentile (0 to 1).
|
||||||
|
Lower values retain more edges.
|
||||||
|
:return: The discovered BPMN process model.
|
||||||
|
"""
|
||||||
|
# Pre-process: convert string traces to Node traces
|
||||||
|
task_map: dict[str, Task] = {}
|
||||||
|
node_traces: dict[
|
||||||
|
tuple[tuple[Node, str], ...], int
|
||||||
|
] = {}
|
||||||
|
for trace, count in traces.items():
|
||||||
|
if not trace:
|
||||||
|
continue
|
||||||
|
node_list: list[tuple[Node, str]] = []
|
||||||
|
for label, lifecycle in trace:
|
||||||
|
if label not in task_map:
|
||||||
|
task_map[label] = Task(
|
||||||
|
label, label
|
||||||
|
)
|
||||||
|
node_list.append(
|
||||||
|
(task_map[label], lifecycle)
|
||||||
|
)
|
||||||
|
node_trace: tuple[
|
||||||
|
tuple[Node, str], ...
|
||||||
|
] = tuple(node_list)
|
||||||
|
node_traces[node_trace] = (
|
||||||
|
node_traces.get(node_trace, 0) + count
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 1: Refined DFG (Definition 6)
|
||||||
|
dfg: RefinedDirectlyFollowsGraph = (
|
||||||
|
RefinedDirectlyFollowsGraph(node_traces)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Refined concurrency (Equation 5)
|
||||||
|
pdfg: RefinedPrunedDFG = RefinedPrunedDFG(
|
||||||
|
dfg, node_traces, epsilon
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Filtering (shared with SM 1.0)
|
||||||
|
fdfg: FilteredDFG = FilteredDFG(
|
||||||
|
pdfg, eta # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4-6: Convert to BPMN and discover gateways
|
||||||
|
start: StartEvent = StartEvent("start")
|
||||||
|
end: EndEvent = EndEvent("end")
|
||||||
|
model: BPMNModel = _build_initial_model(
|
||||||
|
fdfg, start, end
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Splits discovery
|
||||||
|
discover_splits(model, fdfg.is_concurrent)
|
||||||
|
|
||||||
|
# Step 5: Joins discovery
|
||||||
|
discover_joins(model)
|
||||||
|
|
||||||
|
# Step 6: OR-joins minimization
|
||||||
|
replace_or_joins(model)
|
||||||
|
|
||||||
|
# Heuristic 1: Fix improper completion
|
||||||
|
fix_improper_completion(model)
|
||||||
|
|
||||||
|
# Heuristic 2: Detect OR-splits
|
||||||
|
detect_or_splits(model, node_traces)
|
||||||
|
|
||||||
|
# Restore self-loops (last step, same as SM 1.0)
|
||||||
|
_restore_self_loops(model, dfg.self_loops)
|
||||||
|
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
def _build_initial_model(
|
def _build_initial_model(
|
||||||
fdfg: FilteredDFG,
|
fdfg: FilteredDFG,
|
||||||
start: StartEvent,
|
start: StartEvent,
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
# 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.
|
||||||
|
"""Refined concurrency discovery (SM 2.0, Equation 5).
|
||||||
|
|
||||||
|
Uses activity lifecycle overlap to discover true concurrency:
|
||||||
|
two activities A and B are concurrent iff
|
||||||
|
2·|A⊓B| / (|A|+|B|) >= epsilon, where |A⊓B| is the number
|
||||||
|
of traces where A and B have overlapping lifecycles,
|
||||||
|
and |A| is the number of traces containing A.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
from split_miner.bpmn import Node
|
||||||
|
from split_miner.refined_dfg import (
|
||||||
|
RefinedDirectlyFollowsGraph,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_pair(
|
||||||
|
a: Node, b: Node
|
||||||
|
) -> tuple[Node, Node]:
|
||||||
|
"""Return the pair in canonical order.
|
||||||
|
|
||||||
|
Ensures (A, B) and (B, A) map to the same
|
||||||
|
canonical tuple, preventing double-counting.
|
||||||
|
|
||||||
|
:param a: First node.
|
||||||
|
:param b: Second node.
|
||||||
|
:return: The canonical pair.
|
||||||
|
"""
|
||||||
|
key_a: tuple[str, str] = (
|
||||||
|
type(a).__name__, a.node_id
|
||||||
|
)
|
||||||
|
key_b: tuple[str, str] = (
|
||||||
|
type(b).__name__, b.node_id
|
||||||
|
)
|
||||||
|
if key_a <= key_b:
|
||||||
|
return (a, b)
|
||||||
|
return (b, a)
|
||||||
|
|
||||||
|
|
||||||
|
class RefinedPrunedDFG:
|
||||||
|
"""A pruned DFG using lifecycle-based concurrency.
|
||||||
|
|
||||||
|
Uses Equation 5 from the SM 2.0 paper to detect
|
||||||
|
true concurrency via overlapping activity lifecycles,
|
||||||
|
then prunes edges between concurrent activities.
|
||||||
|
|
||||||
|
:param dfg: The refined DFG.
|
||||||
|
:param traces: The lifecycle traces.
|
||||||
|
:param epsilon: The concurrency threshold (0 to 1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dfg: RefinedDirectlyFollowsGraph,
|
||||||
|
traces: dict[
|
||||||
|
tuple[tuple[Node, str], ...], int
|
||||||
|
],
|
||||||
|
epsilon: float,
|
||||||
|
) -> None:
|
||||||
|
"""Build a pruned DFG with lifecycle concurrency.
|
||||||
|
|
||||||
|
:param dfg: The refined DFG.
|
||||||
|
:param traces: The lifecycle traces.
|
||||||
|
:param epsilon: The concurrency threshold.
|
||||||
|
"""
|
||||||
|
self.__dfg: RefinedDirectlyFollowsGraph = dfg
|
||||||
|
self.__concurrent: set[
|
||||||
|
tuple[Node, Node]
|
||||||
|
] = set()
|
||||||
|
self.__edges: set[tuple[Node, Node]] = set()
|
||||||
|
self.__edge_freq: dict[
|
||||||
|
tuple[Node, Node], int
|
||||||
|
] = {}
|
||||||
|
self.__discover_concurrency(traces, epsilon)
|
||||||
|
self.__build_pruned_edges()
|
||||||
|
|
||||||
|
def __discover_concurrency(
|
||||||
|
self,
|
||||||
|
traces: dict[
|
||||||
|
tuple[tuple[Node, str], ...], int
|
||||||
|
],
|
||||||
|
epsilon: float,
|
||||||
|
) -> None:
|
||||||
|
"""Discover concurrency via lifecycle overlap.
|
||||||
|
|
||||||
|
For each pair of activities, count the number
|
||||||
|
of trace instances where their lifecycles overlap
|
||||||
|
(one starts before the other ends). Apply
|
||||||
|
Equation 5: 2·|A⊓B| / (|A|+|B|) >= epsilon.
|
||||||
|
|
||||||
|
:param traces: The lifecycle traces.
|
||||||
|
:param epsilon: The concurrency threshold.
|
||||||
|
"""
|
||||||
|
# Count overlaps and activity occurrences.
|
||||||
|
overlap_count: dict[
|
||||||
|
tuple[Node, Node], int
|
||||||
|
] = {}
|
||||||
|
activity_count: dict[Node, int] = {}
|
||||||
|
for trace, count in traces.items():
|
||||||
|
self.__count_overlaps_in_trace(
|
||||||
|
trace, count,
|
||||||
|
overlap_count, activity_count,
|
||||||
|
)
|
||||||
|
# Apply Equation 5 for each pair.
|
||||||
|
nodes: list[Node] = sorted(
|
||||||
|
self.__dfg.nodes,
|
||||||
|
key=lambda n: (
|
||||||
|
type(n).__name__, n.node_id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for i, a in enumerate(nodes):
|
||||||
|
for b in nodes[i + 1:]:
|
||||||
|
pair: tuple[Node, Node] = (
|
||||||
|
_canonical_pair(a, b)
|
||||||
|
)
|
||||||
|
ab_overlap: int = (
|
||||||
|
overlap_count.get(pair, 0)
|
||||||
|
)
|
||||||
|
a_count: int = activity_count.get(
|
||||||
|
a, 0
|
||||||
|
)
|
||||||
|
b_count: int = activity_count.get(
|
||||||
|
b, 0
|
||||||
|
)
|
||||||
|
if a_count + b_count == 0:
|
||||||
|
continue
|
||||||
|
ratio: float = (
|
||||||
|
2.0 * ab_overlap
|
||||||
|
/ (a_count + b_count)
|
||||||
|
)
|
||||||
|
if ratio >= epsilon:
|
||||||
|
self.__concurrent.add((a, b))
|
||||||
|
self.__concurrent.add((b, a))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __count_overlaps_in_trace(
|
||||||
|
trace: tuple[tuple[Node, str], ...],
|
||||||
|
count: int,
|
||||||
|
overlap_count: dict[
|
||||||
|
tuple[Node, Node], int
|
||||||
|
],
|
||||||
|
activity_count: dict[Node, int],
|
||||||
|
) -> None:
|
||||||
|
"""Count lifecycle overlaps in a single trace.
|
||||||
|
|
||||||
|
An overlap between A and B occurs when one starts
|
||||||
|
before the other ends. Track active activities
|
||||||
|
(started but not yet ended) to detect overlaps.
|
||||||
|
|
||||||
|
:param trace: The lifecycle trace.
|
||||||
|
:param count: The trace frequency.
|
||||||
|
:param overlap_count: Accumulated overlap counts
|
||||||
|
(mutated).
|
||||||
|
:param activity_count: Accumulated activity
|
||||||
|
counts (mutated).
|
||||||
|
"""
|
||||||
|
# Track which activities are currently active
|
||||||
|
# (started but not ended).
|
||||||
|
active: set[Node] = set()
|
||||||
|
# Track pairs already counted as overlapping
|
||||||
|
# in this trace (canonical order to avoid
|
||||||
|
# double-counting when self-loops cause
|
||||||
|
# overlap in both directions).
|
||||||
|
overlapped: set[tuple[Node, Node]] = set()
|
||||||
|
# Track activities seen in this trace (for
|
||||||
|
# per-trace counting per Equation 5).
|
||||||
|
seen: set[Node] = set()
|
||||||
|
for node, lifecycle in trace:
|
||||||
|
if lifecycle == "start":
|
||||||
|
# This activity overlaps with all
|
||||||
|
# currently active activities.
|
||||||
|
for other in active:
|
||||||
|
pair: tuple[Node, Node] = (
|
||||||
|
_canonical_pair(
|
||||||
|
other, node
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if pair not in overlapped:
|
||||||
|
overlapped.add(pair)
|
||||||
|
active.add(node)
|
||||||
|
elif lifecycle == "end":
|
||||||
|
active.discard(node)
|
||||||
|
seen.add(node)
|
||||||
|
# Count each activity once per trace
|
||||||
|
# (Equation 5: |A| = number of traces
|
||||||
|
# containing A).
|
||||||
|
for node in seen:
|
||||||
|
activity_count[node] = (
|
||||||
|
activity_count.get(node, 0) + count
|
||||||
|
)
|
||||||
|
# Add overlap counts (canonical pairs).
|
||||||
|
for pair in overlapped:
|
||||||
|
overlap_count[pair] = (
|
||||||
|
overlap_count.get(pair, 0) + count
|
||||||
|
)
|
||||||
|
|
||||||
|
def __build_pruned_edges(self) -> None:
|
||||||
|
"""Build the pruned edge set.
|
||||||
|
|
||||||
|
Remove edges between concurrent activities.
|
||||||
|
For non-concurrent bidirectional pairs, remove
|
||||||
|
the less frequent edge.
|
||||||
|
"""
|
||||||
|
for a, b in self.__dfg.edges:
|
||||||
|
freq: int = self.__dfg.df_frequency(a, b)
|
||||||
|
if (a, b) in self.__concurrent:
|
||||||
|
continue
|
||||||
|
if (b, a) in self.__dfg.edges:
|
||||||
|
rev_freq: int = (
|
||||||
|
self.__dfg.df_frequency(b, a)
|
||||||
|
)
|
||||||
|
if freq < rev_freq:
|
||||||
|
continue
|
||||||
|
self.__edges.add((a, b))
|
||||||
|
self.__edge_freq[(a, b)] = freq
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nodes(self) -> set[Node]:
|
||||||
|
"""The set of nodes.
|
||||||
|
|
||||||
|
:return: The nodes.
|
||||||
|
"""
|
||||||
|
return self.__dfg.nodes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def edges(self) -> set[tuple[Node, Node]]:
|
||||||
|
"""The set of pruned edges.
|
||||||
|
|
||||||
|
:return: The edges.
|
||||||
|
"""
|
||||||
|
return set(self.__edges)
|
||||||
|
|
||||||
|
def edge_frequency(
|
||||||
|
self, a: Node, b: Node
|
||||||
|
) -> int:
|
||||||
|
"""Return the frequency of a pruned edge.
|
||||||
|
|
||||||
|
:param a: The source node.
|
||||||
|
:param b: The target node.
|
||||||
|
:return: The frequency, or 0 if not present.
|
||||||
|
"""
|
||||||
|
return self.__edge_freq.get((a, b), 0)
|
||||||
|
|
||||||
|
def is_concurrent(
|
||||||
|
self, a: Node, b: Node
|
||||||
|
) -> bool:
|
||||||
|
"""Check if two nodes are concurrent.
|
||||||
|
|
||||||
|
:param a: The first node.
|
||||||
|
:param b: The second node.
|
||||||
|
:return: True if a || b.
|
||||||
|
"""
|
||||||
|
return (a, b) in self.__concurrent
|
||||||
|
|
||||||
|
@property
|
||||||
|
def concurrent_pairs(
|
||||||
|
self,
|
||||||
|
) -> set[tuple[Node, Node]]:
|
||||||
|
"""The set of concurrent pairs (both directions).
|
||||||
|
|
||||||
|
:return: The concurrent pairs.
|
||||||
|
"""
|
||||||
|
return set(self.__concurrent)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sources(self) -> set[Node]:
|
||||||
|
"""The source nodes.
|
||||||
|
|
||||||
|
:return: The source nodes.
|
||||||
|
"""
|
||||||
|
return self.__dfg.sources
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sinks(self) -> set[Node]:
|
||||||
|
"""The sink nodes.
|
||||||
|
|
||||||
|
:return: The sink nodes.
|
||||||
|
"""
|
||||||
|
return self.__dfg.sinks
|
||||||
|
|
||||||
|
def outgoing(
|
||||||
|
self, node: Node
|
||||||
|
) -> set[tuple[Node, Node]]:
|
||||||
|
"""Return the outgoing edges of a node.
|
||||||
|
|
||||||
|
:param node: The node.
|
||||||
|
:return: The outgoing edges.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
(a, b) for (a, b) in self.__edges
|
||||||
|
if a == node
|
||||||
|
}
|
||||||
|
|
||||||
|
def incoming(
|
||||||
|
self, node: Node
|
||||||
|
) -> set[tuple[Node, Node]]:
|
||||||
|
"""Return the incoming edges of a node.
|
||||||
|
|
||||||
|
:param node: The node.
|
||||||
|
:return: The incoming edges.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
(a, b) for (a, b) in self.__edges
|
||||||
|
if b == node
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# 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.
|
||||||
|
"""Refined Directly-Follows Graph (SM 2.0, Definition 6).
|
||||||
|
|
||||||
|
Uses activity lifecycle (start/end) events to build the
|
||||||
|
directly-follows relation: 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
|
||||||
|
|
||||||
|
from split_miner.bpmn import Node
|
||||||
|
|
||||||
|
|
||||||
|
class RefinedDirectlyFollowsGraph:
|
||||||
|
"""A refined DFG using activity lifecycle events.
|
||||||
|
|
||||||
|
Built from lifecycle-aware traces per Definition 6 in the
|
||||||
|
SM 2.0 paper. Each trace event is a (Node, lifecycle)
|
||||||
|
pair where lifecycle is ``"start"`` or ``"end"``.
|
||||||
|
|
||||||
|
:param traces: The event log as a dict mapping each
|
||||||
|
lifecycle trace to its frequency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
traces: dict[
|
||||||
|
tuple[tuple[Node, str], ...], int
|
||||||
|
],
|
||||||
|
) -> None:
|
||||||
|
"""Build a refined DFG from lifecycle traces.
|
||||||
|
|
||||||
|
:param traces: The event log as a dict mapping
|
||||||
|
each lifecycle trace to its frequency.
|
||||||
|
"""
|
||||||
|
self.__nodes: set[Node] = set()
|
||||||
|
self.__sources: set[Node] = set()
|
||||||
|
self.__sinks: set[Node] = set()
|
||||||
|
self.__df_freq: dict[
|
||||||
|
tuple[Node, Node], int
|
||||||
|
] = {}
|
||||||
|
self.__self_loops: set[Node] = set()
|
||||||
|
self.__build(traces)
|
||||||
|
|
||||||
|
def __build(
|
||||||
|
self,
|
||||||
|
traces: dict[
|
||||||
|
tuple[tuple[Node, str], ...], int
|
||||||
|
],
|
||||||
|
) -> None:
|
||||||
|
"""Build the refined DFG from lifecycle traces.
|
||||||
|
|
||||||
|
For each trace, scan for end events. After each
|
||||||
|
end event, collect all start events that occur
|
||||||
|
before the next end event. These form the
|
||||||
|
directly-follows pairs per Definition 6.
|
||||||
|
|
||||||
|
:param traces: The event log.
|
||||||
|
"""
|
||||||
|
for trace, count in traces.items():
|
||||||
|
if not trace:
|
||||||
|
continue
|
||||||
|
# Collect nodes and find sources/sinks.
|
||||||
|
activities: set[Node] = set()
|
||||||
|
for node, _ in trace:
|
||||||
|
activities.add(node)
|
||||||
|
self.__nodes |= activities
|
||||||
|
# Source: first activity to start.
|
||||||
|
for node, lifecycle in trace:
|
||||||
|
if lifecycle == "start":
|
||||||
|
self.__sources.add(node)
|
||||||
|
break
|
||||||
|
# Sink: last activity to end.
|
||||||
|
for node, lifecycle in reversed(trace):
|
||||||
|
if lifecycle == "end":
|
||||||
|
self.__sinks.add(node)
|
||||||
|
break
|
||||||
|
# Detect self-loops: activity with multiple
|
||||||
|
# complete lifecycles in a trace.
|
||||||
|
end_counts: dict[Node, int] = {}
|
||||||
|
for node, lifecycle in trace:
|
||||||
|
if lifecycle == "end":
|
||||||
|
end_counts[node] = (
|
||||||
|
end_counts.get(node, 0) + 1
|
||||||
|
)
|
||||||
|
for node, cnt in end_counts.items():
|
||||||
|
if cnt > 1:
|
||||||
|
self.__self_loops.add(node)
|
||||||
|
# Definition 6: ax ->r ay iff ay starts
|
||||||
|
# after ax ends with no other end event
|
||||||
|
# between.
|
||||||
|
self.__scan_trace(trace, count)
|
||||||
|
|
||||||
|
def __scan_trace(
|
||||||
|
self,
|
||||||
|
trace: tuple[tuple[Node, str], ...],
|
||||||
|
count: int,
|
||||||
|
) -> None:
|
||||||
|
"""Scan a single trace for refined DF relations.
|
||||||
|
|
||||||
|
Walk through events. When we see an end event
|
||||||
|
for activity ax, record ax as a "pending source".
|
||||||
|
When we see a start event for ay, create edges
|
||||||
|
from all pending sources to ay. When we see
|
||||||
|
another end event, clear all pending sources
|
||||||
|
(since the new end event is "between").
|
||||||
|
|
||||||
|
:param trace: The lifecycle trace.
|
||||||
|
:param count: The trace frequency.
|
||||||
|
"""
|
||||||
|
pending: set[Node] = set()
|
||||||
|
for node, lifecycle in trace:
|
||||||
|
if lifecycle == "end":
|
||||||
|
# A new end event clears previous
|
||||||
|
# pending sources (they now have an
|
||||||
|
# end event between them and any
|
||||||
|
# future start).
|
||||||
|
pending.clear()
|
||||||
|
pending.add(node)
|
||||||
|
elif lifecycle == "start":
|
||||||
|
# All pending sources directly-follow
|
||||||
|
# to this activity.
|
||||||
|
for src in pending:
|
||||||
|
if src != node:
|
||||||
|
pair: tuple[Node, Node] = (
|
||||||
|
src, node
|
||||||
|
)
|
||||||
|
self.__df_freq[pair] = (
|
||||||
|
self.__df_freq.get(
|
||||||
|
pair, 0
|
||||||
|
)
|
||||||
|
+ count
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nodes(self) -> set[Node]:
|
||||||
|
"""The set of nodes.
|
||||||
|
|
||||||
|
:return: The nodes.
|
||||||
|
"""
|
||||||
|
return set(self.__nodes)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def edges(self) -> set[tuple[Node, Node]]:
|
||||||
|
"""The set of edges with positive frequency.
|
||||||
|
|
||||||
|
:return: The edges.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
(a, b) for (a, b), freq
|
||||||
|
in self.__df_freq.items()
|
||||||
|
if freq > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def df_frequency(
|
||||||
|
self, a: Node, b: Node
|
||||||
|
) -> int:
|
||||||
|
"""Return the directly-follows frequency.
|
||||||
|
|
||||||
|
:param a: The source node.
|
||||||
|
:param b: The target node.
|
||||||
|
:return: The frequency.
|
||||||
|
"""
|
||||||
|
return self.__df_freq.get((a, b), 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def self_loops(self) -> set[Node]:
|
||||||
|
"""The set of self-loop nodes.
|
||||||
|
|
||||||
|
An activity is a self-loop if it completes
|
||||||
|
(has an end event) more than once in any trace.
|
||||||
|
|
||||||
|
:return: The self-loop nodes.
|
||||||
|
"""
|
||||||
|
return set(self.__self_loops)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sources(self) -> set[Node]:
|
||||||
|
"""The source nodes (first to start in traces).
|
||||||
|
|
||||||
|
:return: The source nodes.
|
||||||
|
"""
|
||||||
|
return set(self.__sources)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sinks(self) -> set[Node]:
|
||||||
|
"""The sink nodes (last to end in traces).
|
||||||
|
|
||||||
|
:return: The sink nodes.
|
||||||
|
"""
|
||||||
|
return set(self.__sinks)
|
||||||
|
|
||||||
|
def outgoing(
|
||||||
|
self, node: Node
|
||||||
|
) -> set[tuple[Node, Node]]:
|
||||||
|
"""Return the outgoing edges of a node.
|
||||||
|
|
||||||
|
:param node: The node.
|
||||||
|
:return: The outgoing edges.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
(a, b) for (a, b) in self.edges
|
||||||
|
if a == node
|
||||||
|
}
|
||||||
|
|
||||||
|
def incoming(
|
||||||
|
self, node: Node
|
||||||
|
) -> set[tuple[Node, Node]]:
|
||||||
|
"""Return the incoming edges of a node.
|
||||||
|
|
||||||
|
:param node: The node.
|
||||||
|
:return: The incoming edges.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
(a, b) for (a, b) in self.edges
|
||||||
|
if b == node
|
||||||
|
}
|
||||||
@@ -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