Add split miner implementation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 07:03:52 +08:00
co-authored by Claude Opus 4.6
parent 94c97629de
commit 446e6f90fe
27 changed files with 6281 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
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.
+22
View File
@@ -0,0 +1,22 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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.
recursive-include docs *
prune docs/build
recursive-include tests *
recursive-exclude tests *.pyc
+137
View File
@@ -0,0 +1,137 @@
===========
split-miner
===========
Description
===========
**split-miner** is a Python implementation of the Split Miner algorithm
for automated discovery of BPMN process models from event logs.
Split Miner (Augusto et al., 2017/2019) produces simple process models
with low branching complexity and consistently high and balanced fitness
and precision, while being guaranteed to produce deadlock-free models
with concurrency.
This package implements Split Miner 1.0 with the following pipeline:
1. DFG and loops discovery
2. Concurrency discovery
3. Edge filtering
4. Split gateways discovery
5. Join gateways discovery (via RPST / SPQR-tree)
6. OR-joins minimization (via dominator tree)
Features:
- Pure Python --- no compiled extensions.
- Implements Split Miner 1.0 from the original papers.
- Uses SPQR-tree for correct RPST computation.
- Typed package with PEP 561 support.
- Requires Python 3.10 or later.
Installation
============
You can install split-miner with ``pip``:
::
pip install split-miner
You may also install the latest source from the
`split-miner GitHub repository`_.
::
pip install git+https://github.com/imacat/split-miner.git
Quick Start
===========
.. code-block:: python
from split_miner import BPMNModel, split_miner
# Create an event log (trace -> frequency)
traces: dict[tuple[str, ...], int] = {
("a", "b", "c", "d"): 10,
("a", "c", "b", "d"): 10,
}
# Discover a BPMN model
model: BPMNModel = split_miner(traces)
# Inspect the model
print(f"Tasks: {len(model.tasks)}")
print(f"Gateways: {len(model.gateways)}")
print(f"Edges: {len(model.edges)}")
Parameters
==========
- **epsilon** (float, 0--1): Controls concurrency detection sensitivity.
Lower values require more balanced directly-follows frequencies to
detect concurrency. Default: 0.33.
- **eta** (float, 0--1): Controls edge filtering / retention.
Lower values retain more edges, resulting in higher fitness at the
cost of lower precision. Default: 0.8.
References
==========
- A. Augusto, R. Conforti, M. Dumas, M. La Rosa, and A. Polyvyanyy,
"Split Miner: Automated Discovery of Accurate and Simple Business
Process Models from Event Logs," *Knowledge and Information Systems*,
vol. 59, no. 2, pp. 251--284, 2019.
`doi:10.1007/s10115-018-1214-x`_
- A. Augusto, R. Conforti, M. Dumas, M. La Rosa, and
A. Polyvyanyy, "Split Miner: Discovering Accurate and Simple
Business Process Models from Event Logs," *Proc. ICDM 2017*,
pp. 1--10, 2017. `doi:10.1109/ICDM.2017.9`_
Acknowledgments
===============
This project was implemented from scratch in Python based on the
original Split Miner papers.
Development was assisted by `Claude Code`_ (Anthropic).
Copyright
=========
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.
Authors
=======
| imacat
| imacat@mail.imacat.idv.tw
| 2026/3/10
.. _split-miner GitHub repository: https://github.com/imacat/split-miner
.. _doi\:10.1007/s10115-018-1214-x: https://doi.org/10.1007/s10115-018-1214-x
.. _doi\:10.1109/ICDM.2017.9: https://doi.org/10.1109/ICDM.2017.9
.. _Claude Code: https://claude.com/claude-code
+68
View File
@@ -0,0 +1,68 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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.
[project]
name = "split-miner"
dynamic = ["version"]
description = "Split Miner algorithm for BPMN process discovery from event logs"
readme = "README.rst"
requires-python = ">=3.10"
license = { text = "Apache-2.0" }
authors = [
{ name = "imacat", email = "imacat@mail.imacat.idv.tw" },
]
dependencies = [
"networkx",
"spqrtree",
]
keywords = [
"bpmn",
"process-mining",
"process-discovery",
"split-miner",
"event-log",
"workflow",
]
classifiers = [
"Development Status :: 1 - Planning",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering",
"Topic :: Software Development :: Libraries :: Python Modules",
"Typing :: Typed",
]
[project.urls]
Homepage = "https://github.com/imacat/split-miner"
Repository = "https://github.com/imacat/split-miner"
"Bug Tracker" = "https://github.com/imacat/split-miner/issues"
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools.dynamic]
version = {attr = "split_miner.VERSION"}
+37
View File
@@ -0,0 +1,37 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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.
"""Split Miner: BPMN process discovery from event logs.
This package implements the Split Miner algorithm (versions 1.0 and
2.0) for automated discovery of BPMN process models from event logs.
"""
from split_miner.bpmn import (
BPMNModel,
EndEvent,
Gateway,
GatewayType,
Node,
StartEvent,
Task,
)
from split_miner.miner import split_miner
VERSION: str = "0.0.0"
"""The package version."""
+374
View File
@@ -0,0 +1,374 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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.
"""BPMN process model data structures.
Defines the BPMN model representation (Definition 6 in the SM 1.0
paper): start/end events, tasks, gateways (AND/XOR/OR), and
sequence flows.
"""
from __future__ import annotations
from enum import Enum, auto
class GatewayType(Enum):
"""The type of a BPMN gateway."""
AND = auto()
"""Parallel gateway."""
XOR = auto()
"""Exclusive gateway."""
OR = auto()
"""Inclusive gateway."""
class Node:
"""A node in a BPMN process model.
Equality and hashing consider both the node type and ID,
so that ``Task("x")`` and ``Gateway("x")`` are distinct.
:param node_id: The unique identifier.
:param label: The display label.
"""
def __init__(
self, node_id: str, label: str | None = None
) -> None:
"""Initialize a node.
:param node_id: The unique identifier.
:param label: The display label.
"""
self.__node_id: str = node_id
self.__label: str | None = label
@property
def node_id(self) -> str:
"""The unique identifier.
:return: The node ID.
"""
return self.__node_id
@property
def label(self) -> str | None:
"""The display label.
:return: The label, or None.
"""
return self.__label
def __eq__(self, other: object) -> bool:
"""Check equality by type and node ID.
:param other: The other object.
:return: True if equal.
"""
if not isinstance(other, Node):
return NotImplemented
return (type(self) is type(other)
and self.__node_id == other.__node_id)
def __hash__(self) -> int:
"""Return the hash.
:return: The hash value.
"""
return hash((type(self), self.__node_id))
def __repr__(self) -> str:
"""Return a string representation.
:return: The string representation.
"""
if self.__label:
return (
f"{type(self).__name__}"
f"({self.__node_id!r},"
f" label={self.__label!r})"
)
return f"{type(self).__name__}({self.__node_id!r})"
class StartEvent(Node):
"""A BPMN start event."""
pass
class EndEvent(Node):
"""A BPMN end event."""
pass
class Task(Node):
"""A BPMN task (activity).
:param node_id: The unique identifier.
:param label: The activity label.
"""
def __init__(self, node_id: str, label: str) -> None:
"""Initialize a task.
:param node_id: The unique identifier.
:param label: The activity label.
"""
super().__init__(node_id, label)
class Gateway(Node):
"""A BPMN gateway.
:param node_id: The unique identifier.
:param gateway_type: The gateway type (AND/XOR/OR).
"""
def __init__(
self, node_id: str, gateway_type: GatewayType
) -> None:
"""Initialize a gateway.
:param node_id: The unique identifier.
:param gateway_type: The gateway type.
"""
super().__init__(node_id)
self.__gateway_type: GatewayType = gateway_type
@property
def gateway_type(self) -> GatewayType:
"""The gateway type.
:return: The gateway type.
"""
return self.__gateway_type
@gateway_type.setter
def gateway_type(self, value: GatewayType) -> None:
"""Set the gateway type.
:param value: The new gateway type.
"""
self.__gateway_type = value
def __repr__(self) -> str:
"""Return a string representation.
:return: The string representation.
"""
return (
f"Gateway({self.node_id!r},"
f" {self.__gateway_type.name})"
)
class BPMNModel:
"""A BPMN process model (Definition 6).
M = (i, o, T, G, Em) where i is the start event, o is
the end event, T is the set of tasks, G is the set of
gateways, and Em is the set of edges.
Edges are stored as ``tuple[Node, Node]`` pairs, so
different node types with the same ID never collide.
:param start: The start event.
:param end: The end event.
"""
def __init__(
self, start: StartEvent, end: EndEvent
) -> None:
"""Initialize a BPMN model.
:param start: The start event.
:param end: The end event.
"""
self.__start: StartEvent = start
self.__end: EndEvent = end
self.__tasks: dict[str, Task] = {}
self.__gateways: dict[str, Gateway] = {}
self.__edges: set[tuple[Node, Node]] = set()
self.__gateway_counter: int = 0
@property
def start(self) -> StartEvent:
"""The start event.
:return: The start event.
"""
return self.__start
@property
def end(self) -> EndEvent:
"""The end event.
:return: The end event.
"""
return self.__end
@property
def tasks(self) -> dict[str, Task]:
"""The tasks, keyed by node ID.
:return: The tasks.
"""
return dict(self.__tasks)
@property
def gateways(self) -> dict[str, Gateway]:
"""The gateways, keyed by node ID.
:return: The gateways.
"""
return dict(self.__gateways)
@property
def edges(self) -> set[tuple[Node, Node]]:
"""The set of edges (source node, target node).
:return: The edges.
"""
return set(self.__edges)
def add_task(self, task: Task) -> None:
"""Add a task.
:param task: The task to add.
"""
self.__tasks[task.node_id] = task
def add_gateway(self, gateway: Gateway) -> None:
"""Add a gateway.
:param gateway: The gateway to add.
"""
self.__gateways[gateway.node_id] = gateway
def create_gateway(
self, gateway_type: GatewayType
) -> Gateway:
"""Create and add a new gateway with a unique ID.
:param gateway_type: The gateway type.
:return: The new gateway.
"""
self.__gateway_counter += 1
gw: Gateway = Gateway(
f"gw_{self.__gateway_counter}", gateway_type
)
self.add_gateway(gw)
return gw
def add_edge(
self, source: Node, target: Node
) -> None:
"""Add an edge.
:param source: The source node.
:param target: The target node.
"""
self.__edges.add((source, target))
def remove_edge(
self, source: Node, target: Node
) -> None:
"""Remove an edge.
:param source: The source node.
:param target: The target node.
"""
self.__edges.discard((source, target))
def outgoing_edges(
self, node: Node
) -> set[tuple[Node, Node]]:
"""Return outgoing edges of a node.
:param node: The node.
:return: The outgoing edges.
"""
return {
(s, t) for s, t in self.__edges if s == node
}
def incoming_edges(
self, node: Node
) -> set[tuple[Node, Node]]:
"""Return incoming edges of a node.
:param node: The node.
:return: The incoming edges.
"""
return {
(s, t) for s, t in self.__edges if t == node
}
def successors(self, node: Node) -> set[Node]:
"""Return the successor nodes.
:param node: The node.
:return: The successor nodes.
"""
return {t for _, t in self.outgoing_edges(node)}
def predecessors(self, node: Node) -> set[Node]:
"""Return the predecessor nodes.
:param node: The node.
:return: The predecessor nodes.
"""
return {s for s, _ in self.incoming_edges(node)}
@property
def all_nodes(self) -> set[Node]:
"""All nodes in the model.
:return: The set of all nodes.
"""
nodes: set[Node] = {self.__start, self.__end}
nodes.update(self.__tasks.values())
nodes.update(self.__gateways.values())
return nodes
def get_task(self, label: str) -> Task:
"""Return the task with the given label.
:param label: The task label (node ID).
:return: The task.
:raises KeyError: If the task is not found.
"""
return self.__tasks[label]
def redirect_edge_target(
self,
source: Node,
old_target: Node,
new_target: Node,
) -> None:
"""Redirect an edge to a new target.
:param source: The source node.
:param old_target: The old target node.
:param new_target: The new target node.
"""
self.remove_edge(source, old_target)
self.add_edge(source, new_target)
+217
View File
@@ -0,0 +1,217 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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.
"""Concurrency discovery and pruned DFG construction.
Implements Section 3.2 of the SM 1.0 paper: concurrency detection
using Conditions 3-5, and pruned DFG construction (Definition 5).
"""
from __future__ import annotations
from split_miner.bpmn import Node
from split_miner.dfg import DirectlyFollowsGraph
class PrunedDFG:
"""A Pruned Directly-Follows Graph (Definition 5).
Removes edges between concurrent tasks and the less frequent
edge of infrequent pairs.
:param dfg: The original DFG.
:param epsilon: The concurrency threshold (0 to 1).
"""
def __init__(
self, dfg: DirectlyFollowsGraph, epsilon: float
) -> None:
"""Build a pruned DFG by detecting concurrency.
:param dfg: The original DFG.
:param epsilon: The concurrency threshold.
"""
self.__dfg: DirectlyFollowsGraph = dfg
self.__epsilon: float = epsilon
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()
self.__build_pruned_edges()
def __discover_concurrency(self) -> None:
"""Discover concurrency relations per Conditions 3-5.
Two tasks a and b are concurrent (a || b) iff:
- Condition 3: |a -> b| > 0 and |b -> a| > 0
- Condition 4: |a <-> b| + |b <-> a| = 0
- Condition 5: ||a->b| - |b->a|| / (|a->b| + |b->a|)
<= epsilon (the paper writes ``< ε`` but its
example requires ``<=``)
"""
self_loops: set[Node] = self.__dfg.self_loops
nodes: list[Node] = sorted(
self.__dfg.nodes,
key=lambda n: (
type(n).__name__, n.node_id
),
)
for i, a in enumerate(nodes):
# Skip self-loop nodes (footnote 5: "We favor
# self-loops over concurrency")
if a in self_loops:
continue
for b in nodes[i + 1:]:
if b in self_loops:
continue
ab: int = self.__dfg.df_frequency(a, b)
ba: int = self.__dfg.df_frequency(b, a)
# Condition 3
if ab <= 0 or ba <= 0:
continue
# Condition 4
sl_ab: int = (
self.__dfg.sl_frequency(a, b)
)
sl_ba: int = (
self.__dfg.sl_frequency(b, a)
)
if sl_ab + sl_ba != 0:
continue
# Condition 5
ratio: float = (
abs(ab - ba) / (ab + ba)
)
if ratio <= self.__epsilon:
self.__concurrent.add((a, b))
self.__concurrent.add((b, a))
def __build_pruned_edges(self) -> None:
"""Build the pruned edge set per Definition 5.
For concurrent pairs, remove both edges.
For non-concurrent pairs with both directions, 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:
# Remove edges between concurrent tasks
continue
if (b, a) in self.__dfg.edges:
rev_freq: int = (
self.__dfg.df_frequency(b, a)
)
if freq < rev_freq:
# Remove the less frequent edge
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 edge 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 (first task of each trace).
:return: The source nodes.
"""
return self.__dfg.sources
@property
def sinks(self) -> set[Node]:
"""The sink nodes (last task of each trace).
: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
}
+237
View File
@@ -0,0 +1,237 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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.
"""Directly-Follows Graph construction and loop detection.
Implements Section 3.1 of the SM 1.0 paper: DFG construction from
an event log, self-loop detection, and short-loop detection
(Definitions 2-4, Conditions 1-2).
"""
from __future__ import annotations
from split_miner.bpmn import Node
class DirectlyFollowsGraph:
"""A Directly-Follows Graph with frequency annotations.
Built from an event log per Definition 3 in the SM 1.0 paper.
Each node is a Node object, and each edge has a
directly-follows frequency.
:param traces: The event log as a dict mapping each
trace (tuple of Node objects) to its frequency.
"""
def __init__(
self, traces: dict[tuple[Node, ...], int]
) -> None:
"""Build a DFG from an event log.
:param traces: The event log as a dict mapping
each 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.__sl_freq: dict[
tuple[Node, Node], int
] = {}
self.__self_loops: set[Node] = set()
self.__short_loops: set[
tuple[Node, Node]
] = set()
self.__build(traces)
self.__detect_self_loops()
self.__detect_short_loops()
def __build(
self, traces: dict[tuple[Node, ...], int]
) -> None:
"""Build the DFG from an event log.
Computes directly-follows frequencies (Definition 2)
and short-loop frequencies (Definition 4).
:param traces: The event log as a dict mapping
each trace to its frequency.
"""
for trace, count in traces.items():
if not trace:
continue
self.__sources.add(trace[0])
self.__sinks.add(trace[-1])
for event in trace:
self.__nodes.add(event)
# Directly-follows frequencies
for i in range(len(trace) - 1):
pair: tuple[Node, Node] = (
trace[i], trace[i + 1]
)
self.__df_freq[pair] = (
self.__df_freq.get(pair, 0) + count
)
# Short-loop frequencies
for i in range(len(trace) - 2):
if trace[i] == trace[i + 2]:
pair = (trace[i], trace[i + 1])
self.__sl_freq[pair] = (
self.__sl_freq.get(pair, 0)
+ count
)
def __detect_self_loops(self) -> None:
"""Detect self-loops.
A self-loop exists if |a -> a| > 0.
"""
for node in self.__nodes:
if self.df_frequency(node, node) > 0:
self.__self_loops.add(node)
def __detect_short_loops(self) -> None:
"""Detect short-loops per Conditions 1-2.
A short-loop (a <-> b) exists iff:
- Condition 1: |a -> a| = 0 and |b -> b| = 0
- Condition 2: |a <-> b| + |b <-> a| != 0
"""
nodes: list[Node] = sorted(
self.__nodes,
key=lambda n: (
type(n).__name__, n.node_id
),
)
for i, a in enumerate(nodes):
for b in nodes[i + 1:]:
if (a not in self.__self_loops
and b not in self.__self_loops):
sl_ab: int = self.__sl_freq.get(
(a, b), 0
)
sl_ba: int = self.__sl_freq.get(
(b, a), 0
)
if sl_ab + sl_ba != 0:
self.__short_loops.add((a, b))
self.__short_loops.add((b, a))
@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.
Self-loop edges (a, a) are excluded per Section 3.1:
they are removed from the DFG and restored in the
output BPMN model at the end.
:return: The edges (excluding self-loops).
"""
return {
(a, b) for (a, b), freq
in self.__df_freq.items()
if freq > 0 and a != b
}
def df_frequency(
self, a: Node, b: Node
) -> int:
"""Return the directly-follows frequency |a -> b|.
:param a: The source node.
:param b: The target node.
:return: The frequency.
"""
return self.__df_freq.get((a, b), 0)
def sl_frequency(
self, a: Node, b: Node
) -> int:
"""Return the short-loop frequency |a <-> b|.
:param a: The first node.
:param b: The second node.
:return: The frequency.
"""
return self.__sl_freq.get((a, b), 0)
@property
def self_loops(self) -> set[Node]:
"""The set of self-loop nodes.
:return: The self-loop nodes.
"""
return set(self.__self_loops)
@property
def short_loops(self) -> set[tuple[Node, Node]]:
"""The set of short-loop pairs.
:return: The short-loop pairs (both directions).
"""
return set(self.__short_loops)
@property
def sources(self) -> set[Node]:
"""The source nodes (first node of each trace).
:return: The source nodes.
"""
return set(self.__sources)
@property
def sinks(self) -> set[Node]:
"""The sink nodes (last node of each trace).
: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
}
+324
View File
@@ -0,0 +1,324 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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.
"""Edge filtering for the pruned DFG.
Implements Section 3.3 of the SM 1.0 paper: Algorithms 1-3 for
filtering the pruned DFG to retain the best incoming/outgoing edges
and edges above a frequency threshold.
"""
from __future__ import annotations
import math
from collections import deque
from split_miner.bpmn import Node
from split_miner.concurrency import PrunedDFG
class FilteredDFG:
"""A filtered pruned DFG (Algorithm 1).
Retains best incoming/outgoing edges per node and edges
above the eta-percentile frequency threshold.
:param pdfg: The pruned DFG.
:param eta: The percentile value for filtering (0 to 1).
"""
def __init__(
self, pdfg: PrunedDFG, eta: float
) -> None:
"""Filter the pruned DFG.
:param pdfg: The pruned DFG.
:param eta: The percentile value (0 to 1).
"""
self.__pdfg: PrunedDFG = pdfg
self.__sources: set[Node] = pdfg.sources
self.__sinks: set[Node] = pdfg.sinks
self.__edges: set[tuple[Node, Node]] = set()
self.__edge_freq: dict[
tuple[Node, Node], int
] = {}
self.__filter(eta)
def __filter(self, eta: float) -> None:
"""Run Algorithm 1 to filter edges.
:param eta: The percentile value.
"""
nodes: set[Node] = self.__pdfg.nodes
sources: set[Node] = self.__sources
sinks: set[Node] = self.__sinks
# Initialize capacities and collect max
# frequencies
c_f: dict[Node, float] = {}
c_b: dict[Node, float] = {}
freq_values: list[int] = []
for t in nodes:
c_f[t] = 0.0
c_b[t] = 0.0
# Highest frequency of incoming edges
in_edges: set[tuple[Node, Node]] = (
self.__pdfg.incoming(t)
)
if in_edges:
f_i: int = max(
self.__pdfg.edge_frequency(a, b)
for a, b in in_edges
)
freq_values.append(f_i)
# Highest frequency of outgoing edges
out_edges: set[tuple[Node, Node]] = (
self.__pdfg.outgoing(t)
)
if out_edges:
f_o: int = max(
self.__pdfg.edge_frequency(a, b)
for a, b in out_edges
)
freq_values.append(f_o)
for s in sources:
c_f[s] = math.inf
for s in sinks:
c_b[s] = math.inf
# Compute frequency threshold (eta percentile)
f_th: float = self.__percentile(
freq_values, eta
)
# Discover best incoming and outgoing edges
e_i: dict[
Node, tuple[Node, Node] | None
] = dict.fromkeys(nodes)
e_o: dict[
Node, tuple[Node, Node] | None
] = dict.fromkeys(nodes)
self.__discover_best_incoming(
sources, c_f, e_i
)
self.__discover_best_outgoing(
sinks, c_b, e_o
)
# Collect best edges into a set for O(1) lookup
best_edges: set[tuple[Node, Node]] = set()
for t in nodes:
if e_i[t] is not None:
best_edges.add(e_i[t])
if e_o[t] is not None:
best_edges.add(e_o[t])
# Retain best edges and those above threshold
for edge in self.__pdfg.edges:
a, b = edge
freq: int = (
self.__pdfg.edge_frequency(a, b)
)
if edge in best_edges or freq > f_th:
self.__edges.add(edge)
self.__edge_freq[edge] = freq
def __discover_best_incoming(
self,
sources: set[Node],
c_f: dict[Node, float],
e_i: dict[Node, tuple[Node, Node] | None],
) -> None:
"""Algorithm 2: Discover best incoming edges.
BFS forward from sources, tracking maximum
capacity paths.
:param sources: The source nodes.
:param c_f: The forward capacities map.
:param e_i: The best incoming edges map.
"""
nodes: set[Node] = self.__pdfg.nodes
queue: deque[Node] = deque(sources)
unexplored: set[Node] = nodes - sources
while queue:
p: Node = queue.popleft()
for edge in self.__pdfg.outgoing(p):
_, n = edge
f_e: int = (
self.__pdfg.edge_frequency(p, n)
)
c_max: float = min(c_f[p], f_e)
if c_max > c_f[n]:
c_f[n] = c_max
e_i[n] = edge
# Re-explore if previously explored
if (n not in queue
and n not in unexplored):
unexplored.add(n)
if n in unexplored:
unexplored.discard(n)
queue.append(n)
def __discover_best_outgoing(
self,
sinks: set[Node],
c_b: dict[Node, float],
e_o: dict[Node, tuple[Node, Node] | None],
) -> None:
"""Algorithm 3: Discover best outgoing edges.
BFS backward from sinks, tracking maximum capacity
paths.
:param sinks: The sink nodes.
:param c_b: The backward capacities map.
:param e_o: The best outgoing edges map.
"""
nodes: set[Node] = self.__pdfg.nodes
queue: deque[Node] = deque(sinks)
unexplored: set[Node] = nodes - sinks
while queue:
n: Node = queue.popleft()
for edge in self.__pdfg.incoming(n):
p, _ = edge
f_e: int = (
self.__pdfg.edge_frequency(p, n)
)
c_max: float = min(c_b[n], f_e)
if c_max > c_b[p]:
c_b[p] = c_max
e_o[p] = edge
# Re-explore if previously explored
if (p not in queue
and p not in unexplored):
unexplored.add(p)
if p in unexplored:
unexplored.discard(p)
queue.append(p)
@staticmethod
def __percentile(
values: list[int], percentile: float
) -> float:
"""Compute the percentile of a list of values.
:param values: The values.
:param percentile: The percentile (0 to 1).
:return: The percentile value.
"""
if not values:
return 0.0
sorted_values: list[int] = sorted(values)
k: float = (
(len(sorted_values) - 1) * percentile
)
f: int = int(k)
c: int = f + 1
if c >= len(sorted_values):
return float(sorted_values[-1])
return (
sorted_values[f]
+ (k - f)
* (sorted_values[c] - sorted_values[f])
)
@property
def nodes(self) -> set[Node]:
"""The set of nodes.
:return: The nodes.
"""
return self.__pdfg.nodes
@property
def edges(self) -> set[tuple[Node, Node]]:
"""The set of filtered edges.
:return: The edges.
"""
return set(self.__edges)
def edge_frequency(
self, a: Node, b: Node
) -> int:
"""Return the frequency of a filtered 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)
@property
def sources(self) -> set[Node]:
"""The source nodes.
:return: The source nodes.
"""
return set(self.__sources)
@property
def sinks(self) -> set[Node]:
"""The sink nodes.
:return: The sink nodes.
"""
return set(self.__sinks)
def is_concurrent(
self, a: Node, b: Node
) -> bool:
"""Check if two nodes are concurrent.
Delegates to the underlying pruned DFG.
:param a: The first node.
:param b: The second node.
:return: True if a || b.
"""
return self.__pdfg.is_concurrent(a, b)
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
}
+633
View File
@@ -0,0 +1,633 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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.
"""Join gateway discovery using RPST.
Implements Section 3.6 of the SM 1.0 paper: Algorithm 8 for
discovering join gateways based on the Refined Process Structure
Tree (RPST), computed via SPQR-tree decomposition.
Uses node-splitting normalization from Polyvyanyy et al.
(2011), Section 4 to handle non-biconnected graphs.
"""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass, field
from spqrtree import MultiGraph, NodeType, SPQRTree
from split_miner.bpmn import (
BPMNModel,
Gateway,
GatewayType,
Node,
Task,
)
class _SplitProxy:
"""Proxy vertex for node-splitting normalization.
Represents one half of a split node: either the
incoming side (*x) or the outgoing side (x*), per
Polyvyanyy et al. (2011), Definition 2.
:param original: The original node being split.
:param side: "in" for incoming, "out" for outgoing.
"""
def __init__(
self, original: Node, side: str
) -> None:
"""Initialize a split proxy.
:param original: The original node.
:param side: "in" or "out".
"""
self.original: Node = original
self.side: str = side
def __repr__(self) -> str:
"""Return a string representation.
:return: The string representation.
"""
return (
f"_SplitProxy({self.original!r}, "
f"{self.side!r})"
)
def __eq__(self, other: object) -> bool:
"""Check equality.
:return: True if equal.
"""
if not isinstance(other, _SplitProxy):
return NotImplemented
return (
self.original == other.original
and self.side == other.side
)
def __hash__(self) -> int:
"""Return the hash.
:return: The hash value.
"""
return hash(
(hash(self.original), self.side)
)
@dataclass
class SESEFragment:
"""A single-entry single-exit fragment.
:param entry: The entry node.
:param exit_node: The exit node.
:param edges: The directed edges in this fragment.
:param fragment_type: The SPQR-tree node type.
:param children: The child fragments.
"""
entry: Node
exit_node: Node
edges: set[tuple[Node, Node]]
fragment_type: NodeType
children: list[SESEFragment] = field(
default_factory=list
)
@property
def nodes(self) -> set[Node]:
"""The set of nodes in this fragment.
:return: The nodes.
"""
result: set[Node] = set()
for src, tgt in self.edges:
result.add(src)
result.add(tgt)
return result
def discover_joins(model: BPMNModel) -> None:
"""Discover join gateways (Algorithm 8).
Uses the RPST (via SPQR-tree) to determine join types:
- Loop-join -> XOR
- Homogeneous SESE -> match the SESE type
- Heterogeneous SESE -> OR
:param model: The BPMN model to modify in place.
"""
fragments: list[SESEFragment] = build_rpst(model)
# Track join gateways created during processing
created_joins: set[Node] = set()
# Process fragments bottom-up
for fragment in fragments:
# Include created joins in the scope
frag_nodes: set[Node] = (
fragment.nodes | created_joins
)
for node in frag_nodes:
# Only process tasks (Algorithm 8, line 8)
if not isinstance(node, Task):
continue
# Check model's current incoming edges
# within the fragment scope
all_in: set[tuple[Node, Node]] = (
model.incoming_edges(node)
)
in_edges: set[tuple[Node, Node]] = {
(s, t) for s, t in all_in
if s in frag_nodes
}
if len(in_edges) <= 1:
continue
# Create a join gateway
gw: Gateway = model.create_gateway(
GatewayType.OR
)
created_joins.add(gw)
model.add_edge(gw, node)
# Redirect incoming edges to the gateway
for src, _ in in_edges:
model.redirect_edge_target(
src, node, gw
)
# Determine gateway type
if _is_loop_join(model, gw):
gw.gateway_type = GatewayType.XOR
else:
is_homo, gw_type = _classify_fragment(
model, fragment
)
if is_homo:
gw.gateway_type = gw_type
else:
gw.gateway_type = GatewayType.OR
def _normalize_edges(
all_nodes: set[Node],
directed_edges: set[tuple[Node, Node]],
) -> tuple[
set[tuple[Node | _SplitProxy,
Node | _SplitProxy]],
dict[_SplitProxy, Node],
dict[Node, _SplitProxy],
dict[Node, _SplitProxy],
]:
"""Apply node-splitting normalization.
Splits each node that has more than one incoming AND
more than one outgoing edge into two proxy nodes
(*x for incoming, x* for outgoing), connected by a
fresh edge. This makes C(G) biconnected per
Polyvyanyy et al. (2011), Lemma 5.
:param all_nodes: All nodes in the graph.
:param directed_edges: The directed edges.
:return: (normalized_edges, proxy_map,
proxy_in_map, proxy_out_map).
"""
in_deg: dict[Node, int] = {}
out_deg: dict[Node, int] = {}
for node in all_nodes:
in_deg[node] = 0
out_deg[node] = 0
for src, tgt in directed_edges:
out_deg[src] = out_deg.get(src, 0) + 1
in_deg[tgt] = in_deg.get(tgt, 0) + 1
to_split: set[Node] = {
n for n in all_nodes
if in_deg.get(n, 0) > 1
and out_deg.get(n, 0) > 1
}
empty_proxy: dict[_SplitProxy, Node] = {}
empty_in: dict[Node, _SplitProxy] = {}
empty_out: dict[Node, _SplitProxy] = {}
if not to_split:
return (
directed_edges, empty_proxy,
empty_in, empty_out,
)
proxy_in: dict[Node, _SplitProxy] = {
n: _SplitProxy(n, "in")
for n in to_split
}
proxy_out: dict[Node, _SplitProxy] = {
n: _SplitProxy(n, "out")
for n in to_split
}
proxy_map: dict[_SplitProxy, Node] = {}
for n in to_split:
proxy_map[proxy_in[n]] = n
proxy_map[proxy_out[n]] = n
norm_edges: set[
tuple[
Node | _SplitProxy,
Node | _SplitProxy,
]
] = set()
for src, tgt in directed_edges:
new_src: Node | _SplitProxy = (
proxy_out.get(src, src)
)
new_tgt: Node | _SplitProxy = (
proxy_in.get(tgt, tgt)
)
norm_edges.add((new_src, new_tgt))
for n in to_split:
norm_edges.add((proxy_in[n], proxy_out[n]))
return norm_edges, proxy_map, proxy_in, proxy_out
def build_rpst(
model: BPMNModel,
) -> list[SESEFragment]:
"""Build the RPST of a BPMN model via SPQR-tree.
Applies node-splitting normalization (Polyvyanyy et
al., 2011, Section 4) before SPQR-tree computation:
nodes with >1 incoming AND >1 outgoing edges are split
into two proxy nodes, making C(G) biconnected. After
computing the SPQR-tree, fragments are projected back
onto the original graph by mapping proxy nodes to their
originals and removing fresh (splitting) edges.
Falls back to a single R-type fragment if the graph is
too small or if construction fails despite
normalization.
:param model: The BPMN model.
:return: SESE fragments ordered bottom-up.
"""
start: Node = model.start
end: Node = model.end
directed_edges: set[tuple[Node, Node]] = model.edges
# Node-splitting normalization (Section 4)
norm_edges: set[
tuple[
Node | _SplitProxy,
Node | _SplitProxy,
]
]
proxy_map: dict[_SplitProxy, Node]
proxy_in: dict[Node, _SplitProxy]
proxy_out: dict[Node, _SplitProxy]
norm_edges, proxy_map, proxy_in, proxy_out = (
_normalize_edges(
model.all_nodes, directed_edges
)
)
# Build undirected multigraph
mg: MultiGraph = MultiGraph()
for src, tgt in norm_edges:
mg.add_vertex(src)
mg.add_vertex(tgt)
for src, tgt in norm_edges:
mg.add_edge(src, tgt)
# Add back-edge: end -> start (undirected).
# StartEvent has no incoming edges and EndEvent
# has no outgoing edges, so they are never split.
mg.add_edge(end, start)
# Build lookup: normalized undirected endpoint
# pair -> original directed edge.
ep_to_dir: dict[
frozenset, tuple[Node, Node]
] = {}
for src, tgt in directed_edges:
nsrc: Node | _SplitProxy = (
proxy_out.get(src, src)
)
ntgt: Node | _SplitProxy = (
proxy_in.get(tgt, tgt)
)
key: frozenset = frozenset({nsrc, ntgt})
ep_to_dir[key] = (src, tgt)
back_ep: frozenset = frozenset({start, end})
# Build SPQR-tree
if mg.num_edges() < 2 or mg.num_vertices() < 3:
return _fallback_single_fragment(model)
try:
tree: SPQRTree = SPQRTree(mg)
except (ValueError, IndexError):
return _fallback_single_fragment(model)
# Extract SESE fragments from SPQR-tree nodes
fragments: list[SESEFragment] = []
_extract_fragments(
tree.root, directed_edges, back_ep,
ep_to_dir, fragments, proxy_map
)
# Remove fragments emptied by fresh edge removal
fragments = [f for f in fragments if f.edges]
# Sort bottom-up (smallest first)
fragments.sort(key=lambda f: len(f.edges))
return fragments
def _extract_fragments(
spqr_node,
directed_edges: set[tuple[Node, Node]],
back_ep: frozenset,
ep_to_dir: dict[
frozenset, tuple[Node, Node]
],
fragments: list[SESEFragment],
proxy_map: dict[_SplitProxy, Node]
| None = None,
) -> None:
"""Recursively extract SESE fragments from SPQR-tree.
When node-splitting normalization is used, proxy_map
maps split proxy vertices back to original nodes.
Fresh edges (from splitting) are automatically
excluded because they have no entry in ep_to_dir.
:param spqr_node: The SPQR-tree node.
:param directed_edges: The original directed edges.
:param back_ep: The back-edge endpoint pair.
:param ep_to_dir: Map from endpoint pair to directed
edge.
:param fragments: The output list of fragments.
:param proxy_map: Map from split proxy to original
node, or None if no normalization was applied.
"""
if proxy_map is None:
proxy_map = {}
# Process children first (bottom-up)
for child in spqr_node.children:
_extract_fragments(
child, directed_edges, back_ep,
ep_to_dir, fragments, proxy_map
)
# Skip Q-nodes (trivial, single edge)
if spqr_node.type == NodeType.Q:
return
# Collect real (non-virtual) directed edges in this
# skeleton
frag_edges: set[tuple[Node, Node]] = set()
for edge in spqr_node.skeleton.edges:
if edge.virtual:
continue
key: frozenset = frozenset(
{edge.u, edge.v}
)
if key == back_ep:
continue
directed: tuple[Node, Node] | None = (
ep_to_dir.get(key)
)
if directed is not None:
frag_edges.add(directed)
# Also collect edges from child subtrees
for child in spqr_node.children:
_collect_subtree_edges(
child, back_ep, ep_to_dir,
frag_edges
)
if not frag_edges:
return
# Map poles back to original nodes (for
# node-splitting normalization)
poles: tuple = spqr_node.poles
u_pole: Node = proxy_map.get(
poles[0], poles[0]
)
v_pole: Node = proxy_map.get(
poles[1], poles[1]
)
entry: Node
exit_node: Node
entry, exit_node = _determine_entry_exit(
(u_pole, v_pole), frag_edges,
directed_edges
)
fragment: SESEFragment = SESEFragment(
entry=entry,
exit_node=exit_node,
edges=frag_edges,
fragment_type=spqr_node.type,
)
fragments.append(fragment)
def _collect_subtree_edges(
spqr_node,
back_ep: frozenset,
ep_to_dir: dict[
frozenset, tuple[Node, Node]
],
result: set[tuple[Node, Node]],
) -> None:
"""Collect all real directed edges in an SPQR subtree.
:param spqr_node: The SPQR-tree node.
:param back_ep: The back-edge endpoint pair.
:param ep_to_dir: Map from endpoint pair to directed
edge.
:param result: The output set of edges.
"""
for edge in spqr_node.skeleton.edges:
if edge.virtual:
continue
key: frozenset = frozenset(
{edge.u, edge.v}
)
if key == back_ep:
continue
directed: tuple[Node, Node] | None = (
ep_to_dir.get(key)
)
if directed is not None:
result.add(directed)
for child in spqr_node.children:
_collect_subtree_edges(
child, back_ep, ep_to_dir, result
)
def _determine_entry_exit(
poles: tuple,
frag_edges: set[tuple[Node, Node]],
all_edges: set[tuple[Node, Node]],
) -> tuple[Node, Node]:
"""Determine entry and exit nodes of a SESE fragment.
The entry node has incoming edges from outside the
fragment; the exit node has outgoing edges to outside.
:param poles: The two pole vertices (Node objects).
:param frag_edges: The edges in the fragment.
:param all_edges: All directed edges.
:return: (entry, exit) nodes.
"""
u: Node = poles[0]
v: Node = poles[1]
frag_nodes: set[Node] = set()
for src, tgt in frag_edges:
frag_nodes.add(src)
frag_nodes.add(tgt)
# Count incoming edges from outside the fragment
# for each pole
u_in_from_outside: int = sum(
1 for s, t in all_edges
if t == u and s not in frag_nodes
)
v_in_from_outside: int = sum(
1 for s, t in all_edges
if t == v and s not in frag_nodes
)
# Count outgoing edges from each pole into fragment
u_out_to_frag: int = sum(
1 for s, t in frag_edges if s == u
)
v_out_to_frag: int = sum(
1 for s, t in frag_edges if s == v
)
# Entry is the pole with more flow into the fragment
# Exit is the other pole
if u_out_to_frag > v_out_to_frag:
return u, v
if v_out_to_frag > u_out_to_frag:
return v, u
# Fallback: check incoming from outside
if u_in_from_outside >= v_in_from_outside:
return u, v
return v, u
def _classify_fragment(
model: BPMNModel, fragment: SESEFragment
) -> tuple[bool, GatewayType]:
"""Classify a SESE fragment as homogeneous or not.
A fragment is homogeneous if all split gateways it
contains are of the same type.
:param model: The BPMN model.
:param fragment: The fragment.
:return: (is_homogeneous, gateway_type).
"""
gateway_types: set[GatewayType] = set()
for node in fragment.nodes:
if isinstance(node, Gateway):
# Only consider split gateways
if len(model.outgoing_edges(node)) > 1:
gateway_types.add(node.gateway_type)
if len(gateway_types) == 1:
return True, next(iter(gateway_types))
if len(gateway_types) == 0:
return True, GatewayType.XOR
return False, GatewayType.OR
def _is_loop_join(
model: BPMNModel, join: Node
) -> bool:
"""Check if a join is a loop-join (Definition 12).
A join is a loop-join if there exists a predecessor p
of the join such that there is a directed path from
the join back to p (forming a cycle).
:param model: The BPMN model.
:param join: The join gateway node.
:return: True if it is a loop-join.
"""
preds: set[Node] = model.predecessors(join)
if not preds:
return False
# BFS forward from join's successor to see if we
# can reach any predecessor
succs: set[Node] = model.successors(join)
visited: set[Node] = set()
queue: deque[Node] = deque(succs)
while queue:
node: Node = queue.popleft()
if node in visited:
continue
visited.add(node)
if node in preds:
return True
for _, succ in model.outgoing_edges(node):
if succ not in visited:
queue.append(succ)
return False
def _fallback_single_fragment(
model: BPMNModel,
) -> list[SESEFragment]:
"""Create a single fragment covering the whole model.
Used when the graph is too small for SPQR-tree
decomposition, or when construction fails despite
node-splitting normalization.
:param model: The BPMN model.
:return: A single SESE fragment.
"""
fragment: SESEFragment = SESEFragment(
entry=model.start,
exit_node=model.end,
edges=model.edges,
fragment_type=NodeType.R,
)
return [fragment]
+206
View File
@@ -0,0 +1,206 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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.
"""Split Miner main pipeline.
Orchestrates the six steps of Split Miner 1.0 (Fig. 1 in the
SM 1.0 paper):
1. DFG and loops discovery
2. Concurrency discovery
3. Filtering
4. Splits discovery
5. Joins discovery
6. OR-joins minimization
"""
from __future__ import annotations
from split_miner.bpmn import (
BPMNModel,
EndEvent,
Gateway,
GatewayType,
Node,
StartEvent,
Task,
)
from split_miner.concurrency import PrunedDFG
from split_miner.dfg import DirectlyFollowsGraph
from split_miner.filtering import FilteredDFG
from split_miner.joins import discover_joins
from split_miner.or_minimization import replace_or_joins
from split_miner.splits import discover_splits
def split_miner(
traces: dict[tuple[str, ...], int],
epsilon: float = 0.33,
eta: float = 0.8,
) -> BPMNModel:
"""Run Split Miner 1.0 to discover a BPMN model.
:param traces: The input event log as a dict mapping
each trace (tuple of activity labels) to its
frequency.
:param epsilon: Concurrency threshold (0 to 1).
Lower values require more balanced frequencies
to detect concurrency.
:param eta: Filtering percentile (0 to 1).
Lower values retain more edges (higher fitness,
lower precision).
:return: The discovered BPMN process model.
"""
# Pre-process: convert string traces to Node traces
# (tasks only, no start/end events per Algorithm 4)
task_map: dict[str, Task] = {}
node_traces: dict[tuple[Node, ...], int] = {}
for trace, count in traces.items():
if not trace:
continue
node_list: list[Node] = []
for label in trace:
if label not in task_map:
task_map[label] = Task(label, label)
node_list.append(task_map[label])
node_trace: tuple[Node, ...] = tuple(
node_list
)
node_traces[node_trace] = (
node_traces.get(node_trace, 0) + count
)
# Step 1: DFG and loops discovery
dfg: DirectlyFollowsGraph = DirectlyFollowsGraph(
node_traces
)
# Step 2: Concurrency discovery -> Pruned DFG
pdfg: PrunedDFG = PrunedDFG(dfg, epsilon)
# Step 3: Filtering
fdfg: FilteredDFG = FilteredDFG(pdfg, eta)
# Step 4-6: Convert to BPMN and discover gateways
# (Algorithm 4: add start/end events after filtering)
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)
# Restore self-loops (Section 3.1)
_restore_self_loops(model, dfg.self_loops)
return model
def _build_initial_model(
fdfg: FilteredDFG,
start: StartEvent,
end: EndEvent,
) -> BPMNModel:
"""Build the initial BPMN model from a filtered DFG.
Implements Algorithm 4 (lines 1-8): create start/end
events, add tasks and filtered edges, then connect
start to sources and sinks to end.
:param fdfg: The filtered DFG.
:param start: The start event.
:param end: The end event.
:return: The initial BPMN model.
"""
model: BPMNModel = BPMNModel(start, end)
# Add tasks
for node in fdfg.nodes:
if isinstance(node, Task):
model.add_task(node)
# Add all filtered edges
for src, tgt in fdfg.edges:
model.add_edge(src, tgt)
# Connect start event to source tasks and
# sink tasks to end event (Algorithm 4, line 6)
for src in fdfg.sources:
model.add_edge(start, src)
for snk in fdfg.sinks:
model.add_edge(snk, end)
return model
def _restore_self_loops(
model: BPMNModel, self_loops: set[Node]
) -> None:
"""Restore self-loops in the BPMN model.
For each task with a self-loop, insert an XOR-join
before the task and an XOR-split after it, with a
back-edge from the split to the join.
:param model: The BPMN model to modify.
:param self_loops: The set of self-loop nodes.
"""
for node in self_loops:
if not isinstance(node, Task):
continue
if node.node_id not in model.tasks:
continue
task_node: Task = model.get_task(
node.node_id
)
# Create XOR-join before the task
join: Gateway = model.create_gateway(
GatewayType.XOR
)
# Redirect all incoming edges of task to join
in_edges: set[tuple[Node, Node]] = (
model.incoming_edges(task_node)
)
for src, _ in in_edges:
model.redirect_edge_target(
src, task_node, join
)
model.add_edge(join, task_node)
# Create XOR-split after the task
split: Gateway = model.create_gateway(
GatewayType.XOR
)
# Redirect all outgoing edges of task to split
out_edges: set[tuple[Node, Node]] = (
model.outgoing_edges(task_node)
)
for _, tgt in out_edges:
model.remove_edge(task_node, tgt)
model.add_edge(split, tgt)
model.add_edge(task_node, split)
# Back-edge for the self-loop
model.add_edge(split, join)
+236
View File
@@ -0,0 +1,236 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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.
"""OR-joins minimization.
Implements Section 3.7 of the SM 1.0 paper: Algorithm 9 for
checking OR-join semantics and replacing trivial OR-joins with
AND or XOR joins using the minimal dominator concept
(Definition 13).
"""
from __future__ import annotations
from collections import deque
import networkx as nx
from split_miner.bpmn import (
BPMNModel,
Gateway,
GatewayType,
Node,
)
def replace_or_joins(model: BPMNModel) -> None:
"""Replace trivial OR-joins with AND or XOR joins.
For each OR-join gateway, check its semantic using
Algorithm 9 and replace if trivial.
:param model: The BPMN model to modify in place.
"""
or_joins: list[Gateway] = [
gw for gw in model.gateways.values()
if (gw.gateway_type == GatewayType.OR
and len(model.incoming_edges(gw)) > 1)
]
for jo in or_joins:
semantic: GatewayType | None = (
_check_or_join_semantic(model, jo)
)
if semantic is not None:
jo.gateway_type = semantic
def _check_or_join_semantic(
model: BPMNModel, jo: Gateway
) -> GatewayType | None:
"""Check the semantic of an OR-join (Algorithm 9).
:param model: The BPMN model.
:param jo: The OR-join gateway.
:return: XOR or AND if trivial, None if non-trivial.
"""
# Find minimal dominator
d: Node | None = _find_minimal_dominator(
model, jo
)
if d is None:
return None
# Collect split gateways on paths from d to jo
g_s: set[Gateway] = _split_gateways_on_paths(
model, d, jo
)
if not g_s:
return GatewayType.XOR
# Collect outgoing edges of split gateways
e_s: set[tuple[Node, Node]] = set()
for gs in g_s:
e_s.update(model.outgoing_edges(gs))
# Build T[e] -> incoming edges of jo reachable from e
jo_in_edges: set[tuple[Node, Node]] = (
model.incoming_edges(jo)
)
t_map: dict[
tuple[Node, Node], set[tuple[Node, Node]]
] = {}
for e in e_s:
_, x = e
reachable: set[Node] = _reachable_nodes(
model, x, jo
)
t_map[e] = {
(s, t) for s, t in jo_in_edges
if s in reachable
}
# Check for XOR escape (empty T[e] from XOR source)
semantic: GatewayType | None = None
for e in e_s:
if (not t_map[e]
and isinstance(e[0], Gateway)
and e[0].gateway_type
== GatewayType.XOR):
semantic = GatewayType.XOR
# Check split relations
for gs in g_s:
gs_out: list[tuple[Node, Node]] = list(
model.outgoing_edges(gs)
)
for i, e1 in enumerate(gs_out):
for e2 in gs_out[i + 1:]:
intersection: set[tuple[Node, Node]] = (
t_map[e1] & t_map[e2]
)
s1: set[tuple[Node, Node]] = (
t_map[e1] - intersection
)
s2: set[tuple[Node, Node]] = (
t_map[e2] - intersection
)
if s1 and s2:
gs_semantic: GatewayType = (
gs.gateway_type
)
if (semantic is not None
and semantic
!= gs_semantic):
return None # Non-trivial OR
semantic = gs_semantic
return semantic
def _find_minimal_dominator(
model: BPMNModel, jo: Node
) -> Node | None:
"""Find the minimal dominator of an OR-join.
The minimal dominator is the closest split gateway
that dominates the OR-join (Definition 13).
:param model: The BPMN model.
:param jo: The OR-join gateway.
:return: The minimal dominator, or None.
"""
g: nx.DiGraph = nx.DiGraph()
for src, tgt in model.edges:
g.add_edge(src, tgt)
start: Node = model.start
if start not in g or jo not in g:
return None
dom: dict[Node, Node] = nx.immediate_dominators(
g, start
)
# Walk up the dominator tree from jo to find the
# nearest split gateway dominator
current: Node = jo
visited: set[Node] = set()
while current in dom and current not in visited:
visited.add(current)
d: Node = dom[current]
if d == current:
break
if (isinstance(d, Gateway)
and len(model.outgoing_edges(d)) > 1):
return d
current = d
return None
def _split_gateways_on_paths(
model: BPMNModel, d: Node, jo: Node
) -> set[Gateway]:
"""Find split gateways on paths from d to jo.
:param model: The BPMN model.
:param d: The dominator.
:param jo: The OR-join.
:return: The set of split gateway nodes.
"""
# BFS from d, stopping at jo
reachable: set[Node] = _reachable_nodes(
model, d, jo
)
reachable.add(d)
result: set[Gateway] = set()
for node in reachable:
if (isinstance(node, Gateway)
and len(model.outgoing_edges(node))
> 1):
result.add(node)
return result
def _reachable_nodes(
model: BPMNModel,
start: Node,
stop: Node,
) -> set[Node]:
"""Find all nodes reachable from start, stopping
at stop.
:param model: The BPMN model.
:param start: The start node.
:param stop: The stop node (not crossed).
:return: The set of reachable nodes.
"""
visited: set[Node] = set()
queue: deque[Node] = deque([start])
while queue:
node: Node = queue.popleft()
if node in visited:
continue
visited.add(node)
if node == stop:
continue
for _, succ in model.outgoing_edges(node):
if succ not in visited:
queue.append(succ)
return visited
View File
+247
View File
@@ -0,0 +1,247 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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.
"""Split gateway discovery.
Implements Section 3.5 of the SM 1.0 paper: Algorithms 5-7 for
discovering XOR-split and AND-split gateways based on concurrency
relations.
"""
from __future__ import annotations
from collections.abc import Callable
from split_miner.bpmn import (
BPMNModel,
Gateway,
GatewayType,
Node,
Task,
)
def discover_splits(
model: BPMNModel,
is_concurrent: Callable[[Node, Node], bool],
) -> None:
"""Discover split gateways (Algorithm 5).
For each node with multiple outgoing edges, discover a
hierarchy of XOR and AND split gateways. Processes all
nodes (including StartEvent) to handle multi-source
logs.
:param model: The BPMN model to modify in place.
:param is_concurrent: A function (a, b) -> bool that
checks concurrency between two Node objects.
"""
nodes: list[Node] = list(model.all_nodes)
for node in nodes:
if isinstance(node, Gateway):
continue
out_edges: set[tuple[Node, Node]] = (
model.outgoing_edges(node)
)
if len(out_edges) <= 1:
continue
# D-successors of t
d_successors: list[Node] = [
target for _, target in out_edges
]
# Build cover and future maps
# Cover: C[s] = {s} initially
# Future: F[s] = {s2 | s2 != s and s2 || s}
cover: dict[Node, set[Node]] = {}
future: dict[Node, set[Node]] = {}
for s1 in d_successors:
cover[s1] = {s1}
future[s1] = set()
for s2 in d_successors:
if s2 == s1:
continue
if is_concurrent(s1, s2):
future[s1].add(s2)
# Remove outgoing edges of t
for edge in out_edges:
model.remove_edge(edge[0], edge[1])
# Current set of d-successors (modified as
# gateways are discovered)
s_set: list[Node] = list(d_successors)
# Iteratively discover XOR and AND splits
while len(s_set) > 1:
old_len: int = len(s_set)
_discover_xor_splits(
model, s_set, cover, future
)
_discover_and_splits(
model, s_set, cover, future
)
if len(s_set) == old_len:
# No progress — remaining are OR-related.
# Group all remaining into a single OR.
_group_remaining_as_or(
model, s_set, cover, future
)
break
# Connect t to the single remaining successor
if s_set:
model.add_edge(node, s_set[0])
def _discover_xor_splits(
model: BPMNModel,
s_set: list[Node],
cover: dict[Node, set[Node]],
future: dict[Node, set[Node]],
) -> None:
"""Discover XOR-splits (Algorithm 6).
Find d-successors sharing the same future and group
them under an XOR gateway.
:param model: The BPMN model.
:param s_set: The current set of d-successors.
:param cover: The cover map.
:param future: The future map.
"""
changed: bool = True
while changed:
changed = False
x_set: list[Node] = []
c_u: set[Node] = set()
for s1 in s_set:
c_u = set(cover[s1])
found: bool = False
for s2 in s_set:
if s1 != s2 and future[s1] == future[s2]:
if not found:
x_set = [s2]
found = True
else:
x_set.append(s2)
c_u |= cover[s2]
if found:
x_set.insert(0, s1)
break
if x_set:
gw: Gateway = model.create_gateway(
GatewayType.XOR
)
for s in x_set:
model.add_edge(gw, s)
s_set.remove(s)
s_set.append(gw)
future[gw] = set(future[x_set[0]])
cover[gw] = c_u
changed = True
def _discover_and_splits(
model: BPMNModel,
s_set: list[Node],
cover: dict[Node, set[Node]],
future: dict[Node, set[Node]],
) -> None:
"""Discover AND-splits (Algorithm 7).
Find d-successors where C[s] | F[s] are the same
and group them under an AND gateway.
:param model: The BPMN model.
:param s_set: The current set of d-successors.
:param cover: The cover map.
:param future: The future map.
"""
changed: bool = True
while changed:
changed = False
a_set: list[Node] = []
c_u: set[Node] = set()
f_i: set[Node] = set()
for s1 in s_set:
cf_s1: set[Node] = cover[s1] | future[s1]
c_u = set(cover[s1])
f_i = set(future[s1])
found: bool = False
for s2 in s_set:
if s1 == s2:
continue
cf_s2: set[Node] = (
cover[s2] | future[s2]
)
if cf_s1 == cf_s2:
if not found:
a_set = [s2]
found = True
else:
a_set.append(s2)
c_u |= cover[s2]
f_i &= future[s2]
if found:
a_set.insert(0, s1)
break
if a_set:
gw: Gateway = model.create_gateway(
GatewayType.AND
)
for s in a_set:
model.add_edge(gw, s)
s_set.remove(s)
s_set.append(gw)
cover[gw] = c_u
future[gw] = f_i
changed = True
def _group_remaining_as_or(
model: BPMNModel,
s_set: list[Node],
cover: dict[Node, set[Node]],
future: dict[Node, set[Node]],
) -> None:
"""Group remaining d-successors as an OR-split.
When no XOR or AND pattern is found, group the
remaining successors under an OR gateway.
:param model: The BPMN model.
:param s_set: The current set of d-successors.
:param cover: The cover map.
:param future: The future map.
"""
if len(s_set) <= 1:
return
gw: Gateway = model.create_gateway(GatewayType.OR)
c_u: set[Node] = set()
for s in s_set:
model.add_edge(gw, s)
c_u |= cover.get(s, set())
s_set.clear()
s_set.append(gw)
cover[gw] = c_u
future[gw] = set()
+3
View File
@@ -0,0 +1,3 @@
[
[["C", "F", "D", "G", "I", "E", "B", "J", "A", "H", "C", "F", "D", "G", "I", "E", "B", "J", "A", "H", "C", "F", "D", "G", "I", "E", "B", "J", "A", "H", "C", "F", "D", "C", "F", "D"], 1]
]
+5
View File
@@ -0,0 +1,5 @@
[
[["C", "F", "D", "G", "J", "E", "B", "K", "A", "I"], 10],
[["C", "F", "D"], 5],
[["C", "F", "D", "G", "J", "E", "B", "K", "H"], 3]
]
+216
View File
@@ -0,0 +1,216 @@
[
[["D", "B", "I", "F", "H", "J", "C"], 1774],
[["D", "B", "I", "F", "H", "J", "A", "C"], 736],
[["J", "C", "H", "D", "B", "I", "F"], 252],
[["D", "B", "I", "H", "F", "J", "A", "C"], 86],
[["J", "C", "H"], 432],
[["J", "D", "B", "I", "F", "H", "A", "C"], 210],
[["J", "C", "D", "B", "I", "F", "H"], 1150],
[["H", "D", "B", "I", "F", "J", "A", "C"], 80],
[["D", "B", "H", "I", "F", "J", "A", "C"], 50],
[["J", "A", "C", "H", "D", "B", "I", "F"], 61],
[["C", "H", "J"], 194],
[["J", "A", "C", "D", "B", "I", "H", "F"], 30],
[["C", "D", "B", "I", "F", "H", "J"], 684],
[["D", "B", "I", "F", "H", "J", "E", "K", "C"], 23],
[["J", "C", "D", "B", "H", "I", "F"], 101],
[["J", "A", "C", "D", "B", "I", "F", "H"], 187],
[["H", "J", "C"], 606],
[["H", "J", "G", "A", "C"], 6],
[["J", "A", "C", "H"], 80],
[["C", "D", "B", "I", "H", "F", "J"], 53],
[["J", "H", "A", "C"], 87],
[["H", "D", "B", "I", "F", "J", "C"], 219],
[["J", "D", "B", "I", "F", "H", "C"], 16],
[["J", "E", "K", "C", "H"], 1],
[["D", "B", "H", "I", "F", "J", "C"], 96],
[["H", "J", "A", "C"], 258],
[["J", "G", "D", "B", "I", "F", "H", "A", "C"], 9],
[["J", "G", "H", "A", "C"], 2],
[["D", "H", "B", "I", "F", "J", "C"], 14],
[["A", "C", "D", "B", "I", "F", "H", "J"], 93],
[["D", "H", "B", "J", "I", "F", "A", "C"], 1],
[["H", "D", "B", "I", "F", "J", "E", "K", "C"], 1],
[["J", "C", "D", "B", "I", "H", "F"], 127],
[["D", "B", "I", "H", "F", "J", "C"], 194],
[["J", "E", "K", "A", "C", "D", "B", "I", "F", "H"], 6],
[["D", "B", "I", "F", "H", "J", "G", "A", "C"], 15],
[["D", "B", "I", "H", "J", "C"], 27],
[["J", "A", "C", "D", "B", "H", "I", "F"], 20],
[["D", "B", "I", "F", "C", "H", "J"], 8],
[["J", "A", "G", "D", "B", "I", "F", "H"], 1],
[["J", "C", "D", "B", "I", "H"], 15],
[["J", "D", "B", "H", "I", "F", "A", "C"], 16],
[["J", "C", "D", "H", "B", "I", "F"], 19],
[["D", "B", "I", "F", "H", "E", "J", "K", "C"], 6],
[["E", "K", "C", "D", "B", "I", "F", "H", "J"], 7],
[["H", "G", "J", "A", "C"], 1],
[["D", "B", "I", "F", "J", "C", "H"], 8],
[["H", "J", "C", "D", "B", "I", "F"], 13],
[["D", "B", "I", "H", "F", "J", "K", "E", "A", "C"], 1],
[["A", "C", "D", "H", "J", "B", "I", "F"], 1],
[["J", "E", "K", "C", "H", "D", "B", "I", "F"], 3],
[["D", "B", "I", "H", "J", "F", "C"], 6],
[["D", "B", "H", "J", "C"], 1],
[["J", "E", "K", "C", "D", "B", "I", "H", "F"], 1],
[["H", "J", "D", "B", "I", "F", "C"], 2],
[["J", "H", "D", "B", "I", "F", "A", "C"], 44],
[["C", "D", "B", "I", "H", "J"], 21],
[["J", "D", "H", "B", "I", "F", "A", "C"], 1],
[["D", "B", "I", "F", "H", "J", "A"], 18],
[["D", "B", "I", "F", "H", "J", "E", "C", "K"], 1],
[["J", "H", "C"], 6],
[["J", "G", "H", "D", "B", "I", "F", "A", "C"], 1],
[["A", "C", "H", "J"], 28],
[["D", "B", "I", "F", "J", "H", "A", "C"], 3],
[["J", "E", "K", "A", "C", "H", "D", "B", "I", "F"], 3],
[["J", "A", "H"], 2],
[["J", "E", "K", "H", "A", "C"], 1],
[["F", "H", "J", "C"], 6],
[["J", "E", "K", "C", "H", "F"], 1],
[["D", "B", "I", "H", "F", "J", "K", "E", "C"], 1],
[["H", "J", "G", "C"], 2],
[["D", "B", "I", "F", "H", "J", "E", "K", "A", "C"], 17],
[["H", "J", "A"], 8],
[["D", "B", "I", "F", "H", "J", "K", "E", "A", "C"], 2],
[["J", "E", "K", "D", "B", "I", "F", "H", "A", "C"], 6],
[["I", "F", "H", "J", "A", "C"], 2],
[["G", "J", "H", "A", "C"], 1],
[["H", "J", "C", "G"], 1],
[["J", "G", "A", "C", "H"], 4],
[["J", "G", "A", "C", "H", "D", "B", "I", "F"], 1],
[["J", "D", "B", "I", "H", "F", "A", "C"], 21],
[["J", "E", "K", "C", "D", "B", "I", "F", "H"], 5],
[["E", "K", "D", "B", "I", "F", "H", "J", "A", "C"], 8],
[["D", "B", "I", "F", "J", "A", "C", "H"], 1],
[["H", "D", "B", "I", "F", "K", "J", "E", "A", "C"], 1],
[["C", "H", "J", "D", "B", "I", "F"], 5],
[["K", "E", "D", "B", "I", "H", "F", "J", "A", "C"], 2],
[["E", "J", "K", "C", "H"], 3],
[["J", "E", "K", "A", "C", "H"], 2],
[["A", "C", "D", "B", "I", "H", "F", "J"], 7],
[["H", "J", "E", "K", "C"], 5],
[["J", "C", "H", "D", "B", "I"], 5],
[["G", "D", "B", "I", "F", "H", "J", "A", "C"], 5],
[["J", "H", "D", "B", "I", "A", "C"], 3],
[["J", "C", "F", "H"], 4],
[["J", "C", "H", "F"], 5],
[["G", "J", "D", "B", "I", "F", "H", "A", "C"], 2],
[["D", "B", "I", "H", "J", "A", "C"], 15],
[["K", "J", "E", "C", "D", "B", "I", "F", "H"], 2],
[["H", "J", "C", "I", "F"], 1],
[["I", "F", "H", "J", "C"], 2],
[["J", "G", "A", "C", "D", "B", "I", "F", "H"], 4],
[["E", "K", "C", "H", "J"], 2],
[["D", "B", "I", "H", "F", "J", "E", "K", "A", "C"], 5],
[["J", "G", "D", "B", "I", "H", "F", "A", "C"], 1],
[["K", "J", "E", "A", "C", "H", "D", "B", "I", "F"], 2],
[["H", "D", "B", "I", "F", "J", "E", "K", "A", "C"], 2],
[["J", "E", "K", "C", "D", "B", "H", "I", "F"], 2],
[["H", "J", "C", "F"], 2],
[["C", "F", "H", "J"], 1],
[["C", "I", "F", "H", "J"], 1],
[["J", "H", "A", "C", "I", "F"], 1],
[["E", "K", "A", "C", "D", "B", "I", "F", "H", "J"], 5],
[["C", "D", "B", "I", "H", "J", "F"], 2],
[["H", "D", "J", "B", "I", "F", "C"], 1],
[["F", "J", "C", "H"], 3],
[["J", "A", "D", "B", "I", "F", "H"], 5],
[["D", "H", "B", "I", "F", "J", "A", "C"], 2],
[["H", "D", "B", "I", "G", "J", "A", "C"], 1],
[["D", "B", "J", "I", "H", "F", "A", "C"], 1],
[["H", "D", "B", "I", "J", "C"], 4],
[["I", "H", "J", "C"], 1],
[["D", "H", "J", "B", "I", "C"], 1],
[["D", "H", "B", "I", "J", "C"], 1],
[["B", "D", "I", "F", "H", "J", "A", "C"], 1],
[["D", "B", "I", "F", "H", "K", "J", "E", "A"], 1],
[["D", "B", "I", "F", "H", "J", "G", "C"], 6],
[["H", "D", "B", "I", "F", "G", "J", "A", "C"], 2],
[["J", "A", "C", "H", "D", "B", "I"], 1],
[["F", "C", "H", "J"], 1],
[["E", "J", "K", "C", "D", "B", "I", "F", "H"], 7],
[["K", "E", "J", "C", "H", "D", "B", "I", "F"], 1],
[["C", "H", "J", "F"], 1],
[["H", "D", "B", "I", "F", "K", "J", "E", "C"], 1],
[["H", "J", "E", "K", "A", "C"], 8],
[["K", "H", "D", "B", "I", "F", "J", "E", "A", "C"], 1],
[["D", "B", "I", "F", "H", "G", "J", "A", "C"], 1],
[["K", "J", "E", "D", "B", "I", "F", "H", "A", "C"], 1],
[["D", "B", "I", "F", "K", "J", "E", "H", "A", "C"], 1],
[["D", "B", "I", "H", "J", "A"], 1],
[["E", "K", "H", "J", "A", "C"], 2],
[["D", "B", "I", "H", "F", "J", "A"], 6],
[["D", "B", "I", "F", "H", "K", "J", "E", "A", "C"], 4],
[["D", "B", "I", "F", "H", "J", "G", "E", "K", "A"], 1],
[["J", "A", "C", "D", "H", "B", "I", "F"], 2],
[["D", "B", "I", "F", "H", "J", "G", "A"], 2],
[["B", "C", "D", "I", "F", "H", "J"], 1],
[["E", "D", "B", "I", "F", "H", "J", "K", "C"], 2],
[["J", "A", "D", "B", "I", "H", "F"], 1],
[["J", "D", "B", "I", "H", "F", "A"], 1],
[["H", "J", "A", "C", "D", "B", "I", "F"], 3],
[["K", "J", "E", "A", "C", "D", "B", "I", "F", "H"], 2],
[["J", "G", "C", "D", "B", "I", "F", "H"], 4],
[["D", "B", "I", "H", "F", "J", "G", "A", "C"], 3],
[["D", "B", "I", "H", "J", "E", "K", "A", "C"], 1],
[["H", "J", "E", "K", "A"], 1],
[["J", "A", "C", "H", "F"], 1],
[["H", "F", "J", "A", "C"], 1],
[["J", "H", "A", "C", "D", "B", "I", "F"], 6],
[["H", "E", "J", "K", "C"], 1],
[["H", "J", "A", "C", "D", "B", "I"], 1],
[["D", "B", "H", "I", "F", "E", "J", "K", "C"], 1],
[["J", "G", "C", "D", "B", "I", "H"], 1],
[["H", "D", "B", "I", "J", "E", "K", "C"], 1],
[["D", "B", "I", "H", "F", "J", "E", "K", "C"], 2],
[["K", "J", "E", "A", "C", "D", "B", "I", "H", "F"], 1],
[["B", "J", "A", "C", "D", "I", "F", "H"], 1],
[["J", "D", "B", "I", "H", "F", "C"], 1],
[["K", "E", "A", "C", "D", "B", "I", "H", "F", "J"], 1],
[["K", "E", "C", "D", "B", "I", "F", "H", "J"], 1],
[["K", "E", "J", "C", "H"], 1],
[["A", "C", "D", "B", "H", "J"], 1],
[["K", "J", "E", "D", "B", "I", "H", "F", "A", "C"], 1],
[["J", "A", "C", "D", "B", "F", "I", "H"], 1],
[["J", "G", "D", "H", "B", "I", "F", "A", "C"], 1],
[["J", "C", "D", "B", "H"], 1],
[["D", "B", "H", "I", "J", "A", "C"], 1],
[["D", "B", "H", "I", "F", "J", "G", "A", "C"], 2],
[["H", "G", "J", "E", "K", "A", "C"], 2],
[["G", "J", "A", "C", "H", "D", "B", "I", "F"], 1],
[["J", "D", "B", "I", "H", "A", "C"], 1],
[["J", "E", "K", "A", "C", "D", "B", "I", "H"], 1],
[["H", "J", "K", "E", "C"], 2],
[["D", "B", "J", "I", "F", "H", "C"], 2],
[["J", "H", "C", "D", "B", "I", "F"], 1],
[["G", "J", "A", "C", "D", "B", "I", "F", "H"], 2],
[["J", "C", "G", "D", "B", "I", "F", "H"], 1],
[["J", "D", "B", "I", "F", "C", "H"], 1],
[["F", "A", "C", "H", "J"], 1],
[["D", "B", "I", "F", "H", "J", "G", "E", "K", "A", "C"], 1],
[["E", "J", "K", "C", "H", "D", "B", "I", "F"], 1],
[["D", "B", "I", "H", "J", "E", "K", "C"], 1],
[["H", "J", "E", "K", "C", "F"], 1],
[["J", "F", "H", "A", "C"], 1],
[["G", "A", "C", "D", "B", "I", "F", "H", "J"], 1],
[["F", "H", "J", "A", "C"], 1],
[["J", "H", "F", "A", "C"], 1],
[["J", "A", "D", "B", "I", "H"], 1],
[["J", "D", "B", "A", "C", "I", "F", "H"], 1],
[["J", "D", "B", "C", "I", "F", "H"], 1],
[["J", "A", "C", "D", "B", "I", "H"], 1],
[["D", "B", "I", "H", "F", "K", "J", "E", "C"], 1],
[["J", "A", "C", "D", "B", "H"], 1],
[["C", "D", "B", "H", "J"], 1],
[["D", "B", "I", "H", "J", "G", "E", "K", "A", "C"], 1],
[["A", "C", "D", "B", "I", "H", "J"], 2],
[["D", "B", "I", "F", "J", "H", "C"], 1],
[["E", "C", "K", "D", "B", "I", "F", "H", "J"], 1],
[["G", "C", "D", "B", "I", "F", "H", "J"], 1],
[["B", "C", "D", "I", "H", "F", "J"], 1],
[["D", "B", "I", "F", "H", "C", "J"], 1],
[["E", "J", "K", "C", "D", "B", "H", "I", "F"], 1],
[["J", "C", "I", "H"], 1],
[["J", "A", "C", "I", "H"], 1]
]
+8
View File
@@ -0,0 +1,8 @@
[
[["C", "L", "A", "D", "E", "H"], 1],
[["C", "L", "A", "D", "E", "J", "I", "K", "F"], 1],
[["C", "L", "G", "C", "G", "A", "C", "A", "D", "E", "J", "I", "K", "F"], 1],
[["C", "L", "G", "A", "D", "A", "C", "A", "D", "E", "J", "K", "I", "K", "I", "K", "F"], 1],
[["C", "L", "G", "A", "D", "A", "C", "A", "D", "E", "J", "I", "K", "B", "F"], 1],
[["C", "L", "A", "D", "E", "J", "K", "B", "F"], 1]
]
+129
View File
@@ -0,0 +1,129 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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 DFG construction with a 5-task example.
Uses a small event log with 5 tasks to verify DFG construction,
edge frequencies, and source/sink detection.
"""
from __future__ import annotations
import unittest
from split_miner.bpmn import Node, Task
from split_miner.dfg import DirectlyFollowsGraph
def _make_tasks(
labels: str,
) -> dict[str, Task]:
"""Create a Task for each single-character label.
:param labels: The labels as a string.
:return: A dict mapping label to Task.
"""
return {ch: Task(ch, ch) for ch in labels}
def _make_five_task_log() -> tuple[
dict[tuple[Node, ...], int], dict[str, Task]
]:
"""Build a 5-task event log.
L = {<a,b,c,d>^3, <a,c,b,d>^2, <a,e,d>^1}
:return: The event log and the task map.
"""
t: dict[str, Task] = _make_tasks("abcde")
traces: dict[tuple[Node, ...], int] = {
(t["a"], t["b"], t["c"], t["d"]): 3,
(t["a"], t["c"], t["b"], t["d"]): 2,
(t["a"], t["e"], t["d"]): 1,
}
return traces, t
class TestFiveTaskDFG(unittest.TestCase):
"""Tests for DFG with 5 tasks (a, b, c, d, e)."""
def setUp(self) -> None:
"""Set up the test.
:return: None.
"""
traces: dict[tuple[Node, ...], int]
traces, self.__t = _make_five_task_log()
self.__dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph(traces)
)
def test_nodes(self) -> None:
"""DFG has the correct 5 nodes."""
self.assertEqual(
self.__dfg.nodes,
set(self.__t.values()),
)
def test_edge_frequencies(self) -> None:
"""All 8 edge frequencies are correct."""
t: dict[str, Task] = self.__t
expected: dict[tuple[Node, Node], int] = {
(t["a"], t["b"]): 3,
(t["a"], t["c"]): 2,
(t["a"], t["e"]): 1,
(t["b"], t["c"]): 3,
(t["b"], t["d"]): 2,
(t["c"], t["b"]): 2,
(t["c"], t["d"]): 3,
(t["e"], t["d"]): 1,
}
for (src, tgt), freq in expected.items():
self.assertEqual(
self.__dfg.df_frequency(src, tgt),
freq,
f"|{src.node_id} -> {tgt.node_id}|"
f" should be {freq}"
)
def test_edges(self) -> None:
"""DFG has the correct 8 edges."""
t: dict[str, Task] = self.__t
expected: set[tuple[Node, Node]] = {
(t["a"], t["b"]), (t["a"], t["c"]),
(t["a"], t["e"]),
(t["b"], t["c"]), (t["b"], t["d"]),
(t["c"], t["b"]), (t["c"], t["d"]),
(t["e"], t["d"]),
}
self.assertEqual(self.__dfg.edges, expected)
def test_sources(self) -> None:
"""Sources include a (first task of traces)."""
self.assertIn(
self.__t["a"], self.__dfg.sources
)
def test_sinks(self) -> None:
"""Sinks include d (last task of traces)."""
self.assertIn(
self.__t["d"], self.__dfg.sinks
)
if __name__ == "__main__":
unittest.main()
+67
View File
@@ -0,0 +1,67 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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 empty and minimal event logs.
Verifies proper error handling when input data is
insufficient for the Split Miner pipeline.
"""
from __future__ import annotations
import unittest
from split_miner import split_miner
from split_miner.bpmn import Node, Task
from split_miner.dfg import DirectlyFollowsGraph
class TestEmptyLog(unittest.TestCase):
"""Tests for empty event log handling."""
def test_empty_log_dfg(self) -> None:
"""Empty log produces a DFG with no nodes."""
dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph({})
)
self.assertEqual(dfg.nodes, set())
self.assertEqual(dfg.edges, set())
def test_empty_log_no_sources(self) -> None:
"""Empty DFG has no sources."""
dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph({})
)
self.assertEqual(dfg.sources, set())
def test_empty_log_split_miner(self) -> None:
"""Split Miner handles empty log gracefully."""
model = split_miner({})
self.assertEqual(len(model.edges), 0)
def test_single_event_trace(self) -> None:
"""Single-event trace produces no edges."""
a: Task = Task("a", "a")
dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph({(a,): 1})
)
self.assertEqual(dfg.nodes, {a})
self.assertEqual(dfg.edges, set())
if __name__ == "__main__":
unittest.main()
+196
View File
@@ -0,0 +1,196 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/11
# 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 epsilon parameter.
Ported from the bpmn project's test_epsilon.py.
High epsilon values may break the graph.
"""
from __future__ import annotations
import unittest
from split_miner import split_miner
TRACES_1: dict[tuple[str, ...], int] = {
("a", "b", "f", "g", "i", "j", "k"): 1150,
("b", "f", "g", "i", "j", "k", "a"): 684,
("a", "b", "k"): 432,
("a", "b", "k", "f", "g", "i", "j"): 252,
("b", "k", "a"): 194,
("a", "b", "f", "g", "i", "k", "j"): 192,
("a", "h", "f", "g", "i", "j", "k", "b"): 190,
("a", "h", "b", "f", "g", "i", "j", "k"): 188,
("a", "h", "k", "b"): 80,
("a", "h", "b", "k"): 79,
("a", "h", "b", "k", "f", "g", "i", "j"): 61,
("b", "f", "g", "i", "k", "j", "a"): 53,
("a", "h", "b", "f", "g", "i", "k", "j"): 41,
("a", "h", "k", "f", "g", "i", "j", "b"): 40,
("a", "b", "f", "g", "k", "i", "j"): 36,
("a", "h", "f", "g", "i", "k", "j", "b"): 28,
("b", "f", "g", "i", "k", "a"): 21,
("a", "b", "f", "k", "g", "i", "j"): 19,
("a", "f", "g", "i", "j", "k", "h", "b"): 19,
("a", "f", "g", "i", "j", "k", "b"): 16,
("a", "b", "f", "g", "i", "k"): 15,
("a", "h", "b", "f", "g", "k", "i", "j"): 9,
("a", "k", "h", "b"): 7,
("a", "k", "b"): 6,
("a", "b", "k", "f", "g", "i"): 5,
("a", "b", "k", "j"): 5,
("a", "c", "e", "b", "f", "g", "i", "j",
"k"): 5,
("a", "h", "f", "g", "i", "j", "k"): 5,
("a", "h", "k", "b", "f", "g", "i", "j"): 5,
("b", "k", "a", "f", "g", "i", "j"): 5,
("a", "b", "j", "k"): 4,
("a", "d", "h", "f", "g", "i", "j", "k",
"b"): 4,
("a", "f", "g", "i", "k", "j", "h", "b"): 4,
("a", "h", "f", "g", "k", "i", "j", "b"): 4,
("a", "k", "f", "g", "i", "j", "h", "b"): 4,
("a", "c", "e", "b", "f", "g", "i", "k",
"j"): 3,
("a", "c", "e", "b", "k", "f", "g", "i",
"j"): 3,
("a", "c", "e", "h", "b", "f", "g", "i",
"j", "k"): 3,
("a", "c", "h", "e", "b", "f", "g", "i",
"j", "k"): 3,
("a", "d", "h", "b", "k"): 3,
("a", "h", "k", "f", "g", "i", "b"): 3,
("a", "c", "e", "f", "g", "i", "j", "k",
"h", "b"): 2,
("a", "c", "e", "h", "f", "g", "i", "j",
"k", "b"): 2,
("a", "c", "h", "e", "b", "k", "f", "g",
"i", "j"): 2,
("a", "c", "h", "e", "f", "g", "i", "j",
"k", "b"): 2,
("a", "d", "b", "f", "g", "i", "j", "k"): 2,
("a", "h", "b", "f", "k", "g", "i", "j"): 2,
("a", "h", "k"): 2,
("b", "f", "g", "i", "k", "a", "j"): 2,
("a", "b", "f", "g", "k"): 1,
("a", "b", "i", "k"): 1,
("a", "c", "e", "b", "k"): 1,
("a", "c", "e", "b", "k", "j"): 1,
("a", "c", "e", "h", "b", "f", "g", "i",
"k"): 1,
("a", "c", "e", "h", "b", "k"): 1,
("a", "c", "e", "h", "b", "k", "f", "g",
"i", "j"): 1,
("a", "c", "e", "h", "k", "b"): 1,
("a", "c", "h", "e", "b", "k"): 1,
("a", "d", "h", "b", "f", "g", "i", "j",
"k"): 1,
("a", "f", "g", "h", "i", "j", "k", "b"): 1,
("a", "f", "g", "i", "b", "j", "k"): 1,
("a", "f", "g", "i", "j", "b", "k"): 1,
("a", "f", "g", "i", "k", "j", "b"): 1,
("a", "f", "g", "i", "k", "j", "h"): 1,
("a", "f", "g", "k", "i", "j", "h", "b"): 1,
("a", "h", "b", "f", "g", "i", "k"): 1,
("a", "h", "b", "f", "g", "k"): 1,
("a", "h", "b", "i", "k"): 1,
("a", "h", "b", "k", "f", "g", "i"): 1,
("a", "h", "b", "k", "j"): 1,
("a", "h", "f", "g", "i", "b", "j", "k"): 1,
("a", "h", "f", "g", "i", "k"): 1,
("a", "h", "f", "g", "i", "k", "b"): 1,
("a", "h", "f", "g", "i", "k", "j"): 1,
("a", "h", "f", "k", "g", "i", "j", "b"): 1,
("a", "h", "j", "k", "b"): 1,
("a", "h", "k", "b", "i", "j"): 1,
("a", "h", "k", "j", "b"): 1,
("a", "k", "b", "f", "g", "i", "j"): 1,
("a", "k", "h", "b", "f", "g", "i", "j"): 1,
("b", "f", "g", "k", "a"): 1,
("b", "i", "j", "k", "a"): 1,
("b", "j", "k", "a"): 1,
("b", "k", "a", "j"): 1,
}
"""Traces from the KMU log, filtered and anonymized."""
TRACES_2: dict[tuple[str, ...], int] = {
("a", "g", "e", "c", "d", "f", "h"): 252,
("a", "g", "c", "d", "f", "e", "h"): 192,
("a", "b", "c", "d", "f", "h", "e", "g"): 190,
("a", "g", "c", "d", "e", "f", "h"): 36,
("a", "g", "c", "e", "d", "f", "h"): 19,
}
"""Simplified traces from TRACES_1."""
TRACES_3: dict[tuple[str, ...], int] = {
("a", "b", "d", "b", "e", "d", "c", "e",
"d", "e"): 1,
("a", "b", "d", "e", "c", "d", "e", "a",
"b", "d", "e", "a", "b", "d", "e", "c",
"d", "a", "c", "e", "d", "e", "e"): 1,
}
"""Traces from the rent data."""
class TestEpsilon(unittest.TestCase):
"""Tests for the epsilon parameter edge cases."""
def test_traces_1(self) -> None:
"""Tests TRACES_1.
:return: None.
"""
self.__test_traces(TRACES_1)
def test_traces_2(self) -> None:
"""Tests TRACES_2.
:return: None.
"""
self.__test_traces(TRACES_2)
def test_traces_3(self) -> None:
"""Tests TRACES_3.
:return: None.
"""
self.__test_traces(TRACES_3)
def __test_traces(
self,
traces: dict[tuple[str, ...], int],
) -> None:
"""Tests a trace set.
A high epsilon (0.8) prunes many edges, which can
produce a graph with cut vertices. The SPQR-tree
construction raises ValueError for non-biconnected
graphs, but build_rpst() catches this and falls
back to a single fragment. All three epsilon
values should succeed without raising.
:param traces: The traces.
:return: None.
"""
split_miner(traces, epsilon=0.8, eta=0.8)
split_miner(traces, epsilon=0.33, eta=0.8)
split_miner(traces)
if __name__ == "__main__":
unittest.main()
+430
View File
@@ -0,0 +1,430 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/11
# 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 real-world event logs.
Ported from the bpmn project's test_event_log.py.
Task labels are anonymized per log-anonymization.md.
"""
from __future__ import annotations
import json
import unittest
from pathlib import Path
from split_miner import (
BPMNModel,
Gateway,
GatewayType,
Task,
split_miner,
)
def _load_traces(
log_file: str,
) -> dict[tuple[str, ...], int]:
"""Load traces from a JSON log file.
:param log_file: The path to the log file.
:return: The traces as a dict mapping trace tuples
to frequencies.
"""
with open(log_file) as f:
raw: list[list] = json.loads(f.read())
return {tuple(x[0]): x[1] for x in raw}
def _run_split_miner(
log_file: str,
epsilon: float = 0.8,
eta: float = 0.8,
) -> BPMNModel:
"""Run split miner on a log file.
:param log_file: The path to the log file.
:param epsilon: The concurrency threshold.
:param eta: The filtering percentile.
:return: The discovered BPMN model.
"""
traces: dict[tuple[str, ...], int] = (
_load_traces(log_file)
)
return split_miner(
traces, epsilon=epsilon, eta=eta
)
def _count_gateways(
model: BPMNModel,
gw_type: GatewayType,
is_split: bool,
) -> int:
"""Count gateways of a given type and role.
:param model: The BPMN model.
:param gw_type: The gateway type.
:param is_split: True for splits, False for joins.
:return: The count.
"""
result: int = 0
for node in model.all_nodes:
if not isinstance(node, Gateway):
continue
if node.gateway_type != gw_type:
continue
if is_split:
if len(model.outgoing_edges(node)) > 1:
result += 1
else:
if len(model.incoming_edges(node)) > 1:
result += 1
return result
_LOGS_DIR: str = str(
Path(__file__).parent / "logs"
)
class TestMultiSourceSinkEventLog(unittest.TestCase):
"""Tests for multi_source_sink.json (kmu.json)."""
__LOG_FILE: str = str(
Path(_LOGS_DIR) / "multi_source_sink.json"
)
__TASKS: set[str] = {
"A", "B", "C", "D", "E",
"F", "G", "H", "I", "J", "K",
}
def test_event_log(self) -> None:
"""Tests the event log with default parameters.
:return: None.
"""
model: BPMNModel = _run_split_miner(
self.__LOG_FILE
)
self.assertEqual(len(model.all_nodes), 21)
task_labels: set[str] = {
t.label for t in model.tasks.values()
if t.label is not None
}
self.assertEqual(task_labels, self.__TASKS)
self.assertEqual(
_count_gateways(
model, GatewayType.XOR, True
), 4
)
self.assertEqual(
_count_gateways(
model, GatewayType.AND, True
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.OR, True
), 1
)
self.assertEqual(
_count_gateways(
model, GatewayType.XOR, False
), 1
)
self.assertEqual(
_count_gateways(
model, GatewayType.AND, False
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.OR, False
), 2
)
self.assertEqual(len(model.edges), 30)
def test_event_log_eta_0(self) -> None:
"""Tests the event log with eta=0.
:return: None.
"""
model: BPMNModel = _run_split_miner(
self.__LOG_FILE, eta=0
)
task_labels: set[str] = {
t.label for t in model.tasks.values()
if t.label is not None
}
self.assertEqual(task_labels, self.__TASKS)
self.assertEqual(len(model.all_nodes), 42)
self.assertEqual(
_count_gateways(
model, GatewayType.XOR, True
), 16
)
self.assertEqual(
_count_gateways(
model, GatewayType.AND, True
), 2
)
self.assertEqual(
_count_gateways(
model, GatewayType.OR, True
), 1
)
self.assertEqual(
_count_gateways(
model, GatewayType.XOR, False
), 9
)
self.assertEqual(
_count_gateways(
model, GatewayType.AND, False
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.OR, False
), 1
)
self.assertEqual(len(model.edges), 78)
def test_event_log_eta_1(self) -> None:
"""Tests the event log with eta=1.
:return: None.
"""
model: BPMNModel = _run_split_miner(
self.__LOG_FILE, eta=1
)
task_labels: set[str] = {
t.label for t in model.tasks.values()
if t.label is not None
}
self.assertEqual(task_labels, self.__TASKS)
self.assertEqual(len(model.all_nodes), 21)
self.assertEqual(
_count_gateways(
model, GatewayType.XOR, True
), 4
)
self.assertEqual(
_count_gateways(
model, GatewayType.AND, True
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.OR, True
), 1
)
self.assertEqual(
_count_gateways(
model, GatewayType.XOR, False
), 1
)
self.assertEqual(
_count_gateways(
model, GatewayType.AND, False
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.OR, False
), 2
)
self.assertEqual(len(model.edges), 30)
class TestCyclicTraceEventLog(unittest.TestCase):
"""Tests for cyclic_trace.json (ntphrf.json)."""
__LOG_FILE: str = str(
Path(_LOGS_DIR) / "cyclic_trace.json"
)
__TASKS: set[str] = {
"A", "B", "C", "D", "E",
"F", "G", "H", "I", "J",
}
def test_event_log(self) -> None:
"""Tests the event log with default parameters.
:return: None.
"""
model: BPMNModel = _run_split_miner(
self.__LOG_FILE
)
task_labels: set[str] = {
t.label for t in model.tasks.values()
if t.label is not None
}
self.assertEqual(task_labels, self.__TASKS)
self.assertEqual(len(model.all_nodes), 14)
self.assertEqual(len(model.edges), 14)
self.assertEqual(
_count_gateways(
model, GatewayType.XOR, True
), 1
)
self.assertEqual(
_count_gateways(
model, GatewayType.AND, True
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.OR, True
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.XOR, False
), 1
)
self.assertEqual(
_count_gateways(
model, GatewayType.AND, False
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.OR, False
), 0
)
class TestMultiSinkEventLog(unittest.TestCase):
"""Tests for multi_sink.json (ntp_job403.json)."""
__LOG_FILE: str = str(
Path(_LOGS_DIR) / "multi_sink.json"
)
__TASKS: set[str] = {
"A", "B", "C", "D", "E",
"F", "G", "H", "I", "J", "K",
}
def test_event_log(self) -> None:
"""Tests the event log with default parameters.
:return: None.
"""
model: BPMNModel = _run_split_miner(
self.__LOG_FILE
)
task_labels: set[str] = {
t.label for t in model.tasks.values()
if t.label is not None
}
self.assertEqual(task_labels, self.__TASKS)
self.assertEqual(len(model.all_nodes), 15)
self.assertEqual(len(model.edges), 16)
self.assertEqual(
_count_gateways(
model, GatewayType.XOR, True
), 2
)
self.assertEqual(
_count_gateways(
model, GatewayType.AND, True
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.OR, True
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.XOR, False
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.AND, False
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.OR, False
), 0
)
class TestShortLoopsEventLog(unittest.TestCase):
"""Tests for short_loops.json (lottery.json)."""
__LOG_FILE: str = str(
Path(_LOGS_DIR) / "short_loops.json"
)
__TASKS: set[str] = {
"A", "B", "C", "D", "E", "F",
"G", "H", "I", "J", "K", "L",
}
def test_event_log(self) -> None:
"""Tests the event log with default parameters.
:return: None.
"""
model: BPMNModel = _run_split_miner(
self.__LOG_FILE
)
task_labels: set[str] = {
t.label for t in model.tasks.values()
if t.label is not None
}
self.assertEqual(task_labels, self.__TASKS)
self.assertEqual(len(model.all_nodes), 21)
self.assertEqual(len(model.edges), 24)
self.assertEqual(
_count_gateways(
model, GatewayType.XOR, True
), 4
)
self.assertEqual(
_count_gateways(
model, GatewayType.AND, True
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.OR, True
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.XOR, False
), 3
)
self.assertEqual(
_count_gateways(
model, GatewayType.AND, False
), 0
)
self.assertEqual(
_count_gateways(
model, GatewayType.OR, False
), 0
)
if __name__ == "__main__":
unittest.main()
+263
View File
@@ -0,0 +1,263 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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 Fig. 5(b) joins and Fig. 6/7 OR minimization.
Tests join gateway discovery and OR-joins minimization using
manually constructed BPMN models from the SM 1.0 paper
figures.
"""
from __future__ import annotations
import unittest
from split_miner.bpmn import (
BPMNModel,
EndEvent,
Gateway,
GatewayType,
Node,
StartEvent,
Task,
)
from split_miner.joins import discover_joins
from split_miner.or_minimization import replace_or_joins
def _make_fig5b_model() -> BPMNModel:
"""Build the model from Fig. 5(b) of the paper.
Graph structure (after splits, before joins):
- start -> gx1 (XOR split)
- gx1 -> {a, b}
- a -> gx2 (XOR split)
- b -> gx3 (XOR split)
- gx2 -> {j, c}
- gx3 -> {j, d}
- j -> i
- c -> i
- d -> k
- i -> k
- k -> end
:return: The BPMN model.
"""
start: StartEvent = StartEvent("start")
end: EndEvent = EndEvent("end")
model: BPMNModel = BPMNModel(start, end)
tasks: dict[str, Task] = {}
for label in ["a", "b", "c", "d", "i", "j", "k"]:
t: Task = Task(label, label)
model.add_task(t)
tasks[label] = t
gx1: Gateway = Gateway("gx1", GatewayType.XOR)
gx2: Gateway = Gateway("gx2", GatewayType.XOR)
gx3: Gateway = Gateway("gx3", GatewayType.XOR)
model.add_gateway(gx1)
model.add_gateway(gx2)
model.add_gateway(gx3)
nodes: dict[str, Node] = {
"start": start, "end": end,
"gx1": gx1, "gx2": gx2, "gx3": gx3,
}
nodes.update(tasks)
for src, tgt in [
("start", "gx1"),
("gx1", "a"), ("gx1", "b"),
("a", "gx2"), ("b", "gx3"),
("gx2", "j"), ("gx3", "j"),
("gx2", "c"), ("gx3", "d"),
("j", "i"), ("c", "i"),
("d", "k"), ("i", "k"),
("k", "end"),
]:
model.add_edge(nodes[src], nodes[tgt])
return model
def _make_fig6_model() -> tuple[
BPMNModel, Gateway, Gateway, Gateway
]:
"""Build the model from Fig. 6 of the paper.
Graph structure (after joins, before OR minimization):
- start -> a -> gx1 (XOR split)
- gx1 -> {b, c}
- b -> ga1 (AND split)
- c -> ga2 (AND split)
- ga1 -> {d, go2}
- ga2 -> {go1, e}
- d -> go1
- e -> go2
- go1 (OR join) -> f
- go2 (OR join) -> g
- f -> go3 (OR join)
- g -> go3
- go3 -> h -> end
:return: The model and the three OR-join gateways.
"""
start: StartEvent = StartEvent("start")
end: EndEvent = EndEvent("end")
model: BPMNModel = BPMNModel(start, end)
tasks: dict[str, Task] = {}
for label in [
"a", "b", "c", "d", "e", "f", "g", "h"
]:
t: Task = Task(label, label)
model.add_task(t)
tasks[label] = t
gx1: Gateway = Gateway("gx1", GatewayType.XOR)
ga1: Gateway = Gateway("ga1", GatewayType.AND)
ga2: Gateway = Gateway("ga2", GatewayType.AND)
go1: Gateway = Gateway("go1", GatewayType.OR)
go2: Gateway = Gateway("go2", GatewayType.OR)
go3: Gateway = Gateway("go3", GatewayType.OR)
for gw in [gx1, ga1, ga2, go1, go2, go3]:
model.add_gateway(gw)
nodes: dict[str, Node] = {
"start": start, "end": end,
"gx1": gx1, "ga1": ga1, "ga2": ga2,
"go1": go1, "go2": go2, "go3": go3,
}
nodes.update(tasks)
for src, tgt in [
("start", "a"), ("a", "gx1"),
("gx1", "b"), ("gx1", "c"),
("b", "ga1"), ("c", "ga2"),
("ga1", "d"), ("ga1", "go2"),
("ga2", "go1"), ("ga2", "e"),
("d", "go1"), ("e", "go2"),
("go1", "f"), ("go2", "g"),
("f", "go3"), ("g", "go3"),
("go3", "h"), ("h", "end"),
]:
model.add_edge(nodes[src], nodes[tgt])
return model, go1, go2, go3
class TestFig5bJoins(unittest.TestCase):
"""Tests joins discovery from Fig. 5(b)."""
def test_joins_discovery(self) -> None:
"""All three joins are XOR (all splits are XOR).
After discover_joins:
- j gets an XOR join (from gx2 and gx3)
- i gets an XOR join (from j and c)
- k gets an XOR join (from d and i)
"""
model: BPMNModel = _make_fig5b_model()
discover_joins(model)
# j should have a join gateway predecessor
j: Task = model.get_task("j")
j_preds: set[Node] = model.predecessors(j)
self.assertEqual(len(j_preds), 1)
j_join: Node = next(iter(j_preds))
self.assertIsInstance(j_join, Gateway)
assert isinstance(j_join, Gateway)
self.assertEqual(
j_join.gateway_type, GatewayType.XOR,
"Join for j should be XOR (all splits "
"are XOR)"
)
# i should have a join gateway predecessor
i: Task = model.get_task("i")
i_preds: set[Node] = model.predecessors(i)
self.assertEqual(len(i_preds), 1)
i_join: Node = next(iter(i_preds))
self.assertIsInstance(i_join, Gateway)
assert isinstance(i_join, Gateway)
self.assertEqual(
i_join.gateway_type, GatewayType.XOR,
"Join for i should be XOR"
)
# k should have a join gateway predecessor
k: Task = model.get_task("k")
k_preds: set[Node] = model.predecessors(k)
self.assertEqual(len(k_preds), 1)
k_join: Node = next(iter(k_preds))
self.assertIsInstance(k_join, Gateway)
assert isinstance(k_join, Gateway)
self.assertEqual(
k_join.gateway_type, GatewayType.XOR,
"Join for k should be XOR"
)
def test_gateway_count(self) -> None:
"""6 gateways after joins (3 splits + 3 joins)."""
model: BPMNModel = _make_fig5b_model()
discover_joins(model)
self.assertEqual(len(model.gateways), 6)
class TestFig6Fig7OrMinimization(unittest.TestCase):
"""Tests OR-joins minimization (Fig. 6 -> Fig. 7)."""
def test_or_joins_minimization(self) -> None:
"""OR-joins are minimized to correct types.
After OR-joins minimization:
- go1 becomes XOR (fed by XOR split gx1)
- go2 becomes XOR (fed by XOR split gx1)
- go3 becomes AND (fed by AND splits ga1, ga2)
"""
model: BPMNModel
go1: Gateway
go2: Gateway
go3: Gateway
model, go1, go2, go3 = _make_fig6_model()
replace_or_joins(model)
self.assertEqual(
go1.gateway_type, GatewayType.XOR,
"go1 should become XOR"
)
self.assertEqual(
go2.gateway_type, GatewayType.XOR,
"go2 should become XOR"
)
self.assertEqual(
go3.gateway_type, GatewayType.AND,
"go3 should become AND"
)
def test_edge_count_unchanged(self) -> None:
"""OR minimization doesn't change edges."""
model: BPMNModel
model, _, _, _ = _make_fig6_model()
edges_before: int = len(model.edges)
replace_or_joins(model)
self.assertEqual(len(model.edges), edges_before)
if __name__ == "__main__":
unittest.main()
+246
View File
@@ -0,0 +1,246 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/11
# 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 node-splitting normalization in build_rpst().
When aggressive edge filtering produces a graph with cut
vertices, the completed version C(G) is not biconnected.
Polyvyanyy et al. (2011), Section 4 describes node-splitting
as the correct fix: split each node with >1 incoming AND >1
outgoing edges into two nodes, making C(G) biconnected.
These tests verify that build_rpst() correctly applies
node-splitting normalization instead of falling back to a
single fragment.
Reference:
Polyvyanyy, A., Vanhatalo, J., & Volzer, H. (2011).
Simplified Computation and Generalization of the
Refined Process Structure Tree. Section 4.
"""
from __future__ import annotations
import unittest
from split_miner.bpmn import (
BPMNModel,
EndEvent,
Gateway,
GatewayType,
Node,
StartEvent,
Task,
)
from split_miner.joins import SESEFragment, build_rpst
from split_miner.joins import discover_joins
def _make_cut_vertex_model() -> tuple[
BPMNModel, dict[str, Node],
]:
"""Build a model with a cut vertex at node c.
Graph structure:
- start -> a -> c -> d -> end
- c -> e -> f -> c (loop)
Node c has 2 incoming edges (from a and f) and
2 outgoing edges (to d and e). In C(G), removing c
disconnects {e, f} from the rest, making c a cut
vertex (separation point).
:return: The model and its named nodes.
"""
start: StartEvent = StartEvent("start")
end: EndEvent = EndEvent("end")
model: BPMNModel = BPMNModel(start, end)
tasks: dict[str, Task] = {}
for label in ["a", "c", "d", "e", "f"]:
t: Task = Task(label, label)
model.add_task(t)
tasks[label] = t
nodes: dict[str, Node] = {
"start": start, "end": end,
}
nodes.update(tasks)
for src, tgt in [
("start", "a"), ("a", "c"),
("c", "d"), ("d", "end"),
("c", "e"), ("e", "f"), ("f", "c"),
]:
model.add_edge(nodes[src], nodes[tgt])
return model, nodes
class TestBuildRpstCutVertex(unittest.TestCase):
"""Tests build_rpst with a cut vertex graph.
Verifies that node-splitting normalization produces
proper RPST fragments instead of a single fallback
fragment.
"""
def setUp(self) -> None:
"""Set up the cut vertex model.
:return: None.
"""
self.__model: BPMNModel
self.__nodes: dict[str, Node]
self.__model, self.__nodes = (
_make_cut_vertex_model()
)
self.__fragments: list[SESEFragment] = (
build_rpst(self.__model)
)
def test_multiple_fragments(self) -> None:
"""Produces multiple fragments, not single fallback.
With node-splitting normalization, the SPQR-tree
should decompose the graph into multiple SESE
fragments instead of falling back to a single
R-type fragment.
:return: None.
"""
self.assertGreater(len(self.__fragments), 1)
def test_all_edges_covered(self) -> None:
"""Union of fragment edges covers all model edges.
:return: None.
"""
all_frag_edges: set[tuple[Node, Node]] = set()
for f in self.__fragments:
all_frag_edges |= f.edges
self.assertEqual(
all_frag_edges, self.__model.edges
)
def test_entry_exit_are_model_nodes(self) -> None:
"""Entry and exit are original model nodes.
No split proxy nodes should appear as fragment
entry or exit.
:return: None.
"""
all_nodes: set[Node] = self.__model.all_nodes
for f in self.__fragments:
self.assertIn(f.entry, all_nodes)
self.assertIn(f.exit_node, all_nodes)
def test_fragment_nodes_are_model_nodes(self) -> None:
"""All fragment nodes are original model nodes.
No split proxy nodes should leak into fragment
node sets.
:return: None.
"""
all_nodes: set[Node] = self.__model.all_nodes
for f in self.__fragments:
for node in f.nodes:
self.assertIn(
node, all_nodes,
f"Proxy node {node!r} leaked "
f"into fragment",
)
def test_bottom_up_order(self) -> None:
"""Fragments are ordered bottom-up (small first).
:return: None.
"""
sizes: list[int] = [
len(f.edges) for f in self.__fragments
]
self.assertEqual(sizes, sorted(sizes))
class TestDiscoverJoinsCutVertex(unittest.TestCase):
"""Tests discover_joins on a graph with a cut vertex.
Verifies that join gateway discovery works correctly
when the graph requires node-splitting normalization.
"""
def setUp(self) -> None:
"""Set up and run discover_joins.
:return: None.
"""
self.__model: BPMNModel
self.__nodes: dict[str, Node]
self.__model, self.__nodes = (
_make_cut_vertex_model()
)
discover_joins(self.__model)
def test_completes_without_raising(self) -> None:
"""discover_joins completes without exception.
:return: None.
"""
# If we get here, it didn't raise.
self.assertTrue(True)
def test_join_for_c(self) -> None:
"""Task c gets a join gateway predecessor.
Task c has 2 incoming edges (from a and f),
so it should get a join gateway.
:return: None.
"""
c: Task = self.__model.get_task("c")
preds: set[Node] = (
self.__model.predecessors(c)
)
self.assertEqual(len(preds), 1)
join: Node = next(iter(preds))
self.assertIsInstance(join, Gateway)
def test_join_for_c_is_xor(self) -> None:
"""Task c's join is XOR (loop-join).
The f -> c edge creates a cycle (c -> e -> f
-> c), making this a loop-join which should be
XOR per Definition 12 of the SM 1.0 paper.
:return: None.
"""
c: Task = self.__model.get_task("c")
preds: set[Node] = (
self.__model.predecessors(c)
)
join: Node = next(iter(preds))
assert isinstance(join, Gateway)
self.assertEqual(
join.gateway_type, GatewayType.XOR,
"Loop-join for c should be XOR",
)
if __name__ == "__main__":
unittest.main()
+482
View File
@@ -0,0 +1,482 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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.
"""Detailed pipeline stage tests for the paper example.
Verifies each stage of the Split Miner pipeline using the
running example from Section 3 of the SM 1.0 paper.
"""
from __future__ import annotations
import unittest
from split_miner import (
BPMNModel,
Gateway,
GatewayType,
Node,
Task,
split_miner,
)
from split_miner.concurrency import PrunedDFG
from split_miner.dfg import DirectlyFollowsGraph
from split_miner.filtering import FilteredDFG
def _make_tasks(
labels: str,
) -> dict[str, Task]:
"""Create a Task for each single-character label.
:param labels: The labels as a string.
:return: A dict mapping label to Task.
"""
return {ch: Task(ch, ch) for ch in labels}
def _make_paper_node_log() -> tuple[
dict[tuple[Node, ...], int], dict[str, Task]
]:
"""Build the paper example log with Node objects.
:return: The Node-based traces and the task map.
"""
t: dict[str, Task] = _make_tasks("abcdefgh")
traces: dict[tuple[Node, ...], int] = {
(t["a"], t["b"], t["c"], t["g"],
t["e"], t["h"]): 10,
(t["a"], t["b"], t["c"], t["f"],
t["g"], t["h"]): 10,
(t["a"], t["b"], t["d"], t["g"],
t["e"], t["h"]): 10,
(t["a"], t["b"], t["d"], t["e"],
t["g"], t["h"]): 10,
(t["a"], t["b"], t["e"], t["c"],
t["g"], t["h"]): 10,
(t["a"], t["b"], t["e"], t["d"],
t["g"], t["h"]): 10,
(t["a"], t["c"], t["b"], t["e"],
t["g"], t["h"]): 10,
(t["a"], t["c"], t["b"], t["f"],
t["g"], t["h"]): 10,
(t["a"], t["d"], t["b"], t["e"],
t["g"], t["h"]): 10,
(t["a"], t["d"], t["b"], t["f"],
t["g"], t["h"]): 10,
}
return traces, t
def _make_paper_str_log() -> dict[
tuple[str, ...], int
]:
"""Build the paper example log with string labels.
:return: The string-based traces.
"""
return {
("a", "b", "c", "g", "e", "h"): 10,
("a", "b", "c", "f", "g", "h"): 10,
("a", "b", "d", "g", "e", "h"): 10,
("a", "b", "d", "e", "g", "h"): 10,
("a", "b", "e", "c", "g", "h"): 10,
("a", "b", "e", "d", "g", "h"): 10,
("a", "c", "b", "e", "g", "h"): 10,
("a", "c", "b", "f", "g", "h"): 10,
("a", "d", "b", "e", "g", "h"): 10,
("a", "d", "b", "f", "g", "h"): 10,
}
class TestDFGAllEdges(unittest.TestCase):
"""Tests for all DFG edge frequencies (Table 1)."""
def setUp(self) -> None:
"""Set up the test.
:return: None.
"""
traces: dict[tuple[Node, ...], int]
traces, self.__t = _make_paper_node_log()
self.__dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph(traces)
)
def test_all_edge_frequencies(self) -> None:
"""All 20 DFG edge frequencies match Table 1.
Verifies every directly-follows frequency from the
paper's example event log.
"""
t: dict[str, Task] = self.__t
expected: dict[tuple[Node, Node], int] = {
(t["a"], t["b"]): 60,
(t["a"], t["c"]): 20,
(t["a"], t["d"]): 20,
(t["b"], t["c"]): 20,
(t["b"], t["d"]): 20,
(t["b"], t["e"]): 40,
(t["b"], t["f"]): 20,
(t["c"], t["b"]): 20,
(t["c"], t["f"]): 10,
(t["c"], t["g"]): 20,
(t["d"], t["b"]): 20,
(t["d"], t["e"]): 10,
(t["d"], t["g"]): 20,
(t["e"], t["c"]): 10,
(t["e"], t["d"]): 10,
(t["e"], t["g"]): 30,
(t["e"], t["h"]): 20,
(t["f"], t["g"]): 30,
(t["g"], t["e"]): 20,
(t["g"], t["h"]): 80,
}
for (src, tgt), freq in expected.items():
self.assertEqual(
self.__dfg.df_frequency(src, tgt),
freq,
f"|{src.node_id} -> {tgt.node_id}|"
f" should be {freq}"
)
def test_all_edges(self) -> None:
"""The DFG has exactly 20 edges."""
t: dict[str, Task] = self.__t
expected: set[tuple[Node, Node]] = {
(t["a"], t["b"]), (t["a"], t["c"]),
(t["a"], t["d"]),
(t["b"], t["c"]), (t["b"], t["d"]),
(t["b"], t["e"]), (t["b"], t["f"]),
(t["c"], t["b"]), (t["c"], t["f"]),
(t["c"], t["g"]),
(t["d"], t["b"]), (t["d"], t["e"]),
(t["d"], t["g"]),
(t["e"], t["c"]), (t["e"], t["d"]),
(t["e"], t["g"]), (t["e"], t["h"]),
(t["f"], t["g"]),
(t["g"], t["e"]), (t["g"], t["h"]),
}
self.assertEqual(self.__dfg.edges, expected)
def test_edge_count(self) -> None:
"""The DFG has 20 edges."""
self.assertEqual(len(self.__dfg.edges), 20)
class TestPrunedDFGEdges(unittest.TestCase):
"""Tests for PDFG edge set (Section 3.2)."""
def setUp(self) -> None:
"""Set up the test.
:return: None.
"""
traces: dict[tuple[Node, ...], int]
traces, self.__t = _make_paper_node_log()
dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph(traces)
)
self.__pdfg: PrunedDFG = PrunedDFG(
dfg, epsilon=0.2
)
def test_pdfg_edges(self) -> None:
"""PDFG has 12 edges after concurrent pruning.
Concurrent pairs b||c, b||d, d||e, e||g are
removed along with their reverse edges.
"""
t: dict[str, Task] = self.__t
expected: set[tuple[Node, Node]] = {
(t["a"], t["b"]), (t["a"], t["c"]),
(t["a"], t["d"]),
(t["b"], t["e"]), (t["b"], t["f"]),
(t["c"], t["f"]), (t["c"], t["g"]),
(t["d"], t["g"]),
(t["e"], t["c"]), (t["e"], t["h"]),
(t["f"], t["g"]),
(t["g"], t["h"]),
}
self.assertEqual(self.__pdfg.edges, expected)
def test_pdfg_edge_count(self) -> None:
"""PDFG has 12 edges."""
self.assertEqual(len(self.__pdfg.edges), 12)
def test_concurrent_pairs(self) -> None:
"""All four concurrent pairs are detected."""
t: dict[str, Task] = self.__t
self.assertTrue(
self.__pdfg.is_concurrent(t["b"], t["c"])
)
self.assertTrue(
self.__pdfg.is_concurrent(t["b"], t["d"])
)
self.assertTrue(
self.__pdfg.is_concurrent(t["d"], t["e"])
)
self.assertTrue(
self.__pdfg.is_concurrent(t["e"], t["g"])
)
def test_not_concurrent(self) -> None:
"""Non-concurrent pairs."""
t: dict[str, Task] = self.__t
self.assertFalse(
self.__pdfg.is_concurrent(t["a"], t["b"])
)
self.assertFalse(
self.__pdfg.is_concurrent(t["c"], t["d"])
)
self.assertFalse(
self.__pdfg.is_concurrent(t["c"], t["f"])
)
class TestFilteredDFGEdges(unittest.TestCase):
"""Tests for filtered PDFG edge set (Section 3.3)."""
def setUp(self) -> None:
"""Set up the test.
:return: None.
"""
traces: dict[tuple[Node, ...], int]
traces, self.__t = _make_paper_node_log()
dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph(traces)
)
pdfg: PrunedDFG = PrunedDFG(dfg, epsilon=0.2)
self.__fdfg: FilteredDFG = FilteredDFG(
pdfg, eta=0.4
)
def test_filtered_edges(self) -> None:
"""Filtered PDFG has 10 edges.
Edges c->f and e->c are filtered out.
"""
t: dict[str, Task] = self.__t
expected: set[tuple[Node, Node]] = {
(t["a"], t["b"]), (t["a"], t["c"]),
(t["a"], t["d"]),
(t["b"], t["e"]), (t["b"], t["f"]),
(t["c"], t["g"]),
(t["d"], t["g"]),
(t["e"], t["h"]),
(t["f"], t["g"]),
(t["g"], t["h"]),
}
self.assertEqual(self.__fdfg.edges, expected)
def test_filtered_edge_count(self) -> None:
"""Filtered PDFG has 10 edges."""
self.assertEqual(len(self.__fdfg.edges), 10)
def test_removed_edges(self) -> None:
"""Edges c->f and e->c are not in filtered PDFG."""
t: dict[str, Task] = self.__t
self.assertNotIn(
(t["c"], t["f"]), self.__fdfg.edges
)
self.assertNotIn(
(t["e"], t["c"]), self.__fdfg.edges
)
class TestPaperExampleStructure(unittest.TestCase):
"""Tests for the final paper example structure."""
def setUp(self) -> None:
"""Set up the test.
:return: None.
"""
traces: dict[tuple[str, ...], int] = (
_make_paper_str_log()
)
self.__model: BPMNModel = split_miner(
traces, epsilon=0.2, eta=0.4
)
def test_node_count(self) -> None:
"""The final model has 16 nodes."""
self.assertEqual(
len(self.__model.all_nodes), 16
)
def test_edge_count(self) -> None:
"""The final model has 18 edges."""
self.assertEqual(len(self.__model.edges), 18)
def test_gateway_counts(self) -> None:
"""6 gateways: AND=1, XOR=4, OR=1."""
gw_types: list[GatewayType] = [
gw.gateway_type
for gw in self.__model.gateways.values()
]
self.assertEqual(len(gw_types), 6)
self.assertEqual(
gw_types.count(GatewayType.AND), 1
)
self.assertEqual(
gw_types.count(GatewayType.XOR), 4
)
self.assertEqual(
gw_types.count(GatewayType.OR), 1
)
def test_and_split_after_a(self) -> None:
"""Task a leads to an AND split gateway."""
a: Task = self.__model.get_task("a")
a_succs: set[Node] = (
self.__model.successors(a)
)
self.assertEqual(len(a_succs), 1)
and_gw: Node = next(iter(a_succs))
self.assertIsInstance(and_gw, Gateway)
assert isinstance(and_gw, Gateway)
self.assertEqual(
and_gw.gateway_type, GatewayType.AND
)
def test_and_split_successors(self) -> None:
"""AND split has successors b and XOR split."""
a: Task = self.__model.get_task("a")
and_gw: Node = next(
iter(self.__model.successors(a))
)
and_succs: set[Node] = (
self.__model.successors(and_gw)
)
self.assertEqual(len(and_succs), 2)
b: Task = self.__model.get_task("b")
self.assertIn(b, and_succs)
def test_xor_split_cd(self) -> None:
"""XOR split for c and d (successor of AND)."""
a: Task = self.__model.get_task("a")
and_gw: Node = next(
iter(self.__model.successors(a))
)
and_succs: set[Node] = (
self.__model.successors(and_gw)
)
b: Task = self.__model.get_task("b")
xor1: Node = (and_succs - {b}).pop()
self.assertIsInstance(xor1, Gateway)
assert isinstance(xor1, Gateway)
self.assertEqual(
xor1.gateway_type, GatewayType.XOR
)
xor1_succs: set[Node] = (
self.__model.successors(xor1)
)
c: Task = self.__model.get_task("c")
d: Task = self.__model.get_task("d")
self.assertEqual(xor1_succs, {c, d})
def test_xor_split_ef(self) -> None:
"""XOR split for e and f (after b)."""
b: Task = self.__model.get_task("b")
b_succs: set[Node] = (
self.__model.successors(b)
)
self.assertEqual(len(b_succs), 1)
xor2: Node = next(iter(b_succs))
self.assertIsInstance(xor2, Gateway)
assert isinstance(xor2, Gateway)
self.assertEqual(
xor2.gateway_type, GatewayType.XOR
)
xor2_succs: set[Node] = (
self.__model.successors(xor2)
)
e: Task = self.__model.get_task("e")
f: Task = self.__model.get_task("f")
self.assertEqual(xor2_succs, {e, f})
def test_xor_join_cd(self) -> None:
"""XOR join for c and d."""
c: Task = self.__model.get_task("c")
d: Task = self.__model.get_task("d")
c_succs: set[Node] = (
self.__model.successors(c)
)
d_succs: set[Node] = (
self.__model.successors(d)
)
self.assertEqual(len(c_succs), 1)
self.assertEqual(len(d_succs), 1)
self.assertEqual(c_succs, d_succs)
join: Node = next(iter(c_succs))
self.assertIsInstance(join, Gateway)
assert isinstance(join, Gateway)
self.assertEqual(
join.gateway_type, GatewayType.XOR
)
def test_or_join_to_g(self) -> None:
"""OR join for {XOR-join, f} leading to g."""
g: Task = self.__model.get_task("g")
g_preds: set[Node] = (
self.__model.predecessors(g)
)
self.assertEqual(len(g_preds), 1)
or_gw: Node = next(iter(g_preds))
self.assertIsInstance(or_gw, Gateway)
assert isinstance(or_gw, Gateway)
self.assertEqual(
or_gw.gateway_type, GatewayType.OR
)
or_preds: set[Node] = (
self.__model.predecessors(or_gw)
)
self.assertEqual(len(or_preds), 2)
f: Task = self.__model.get_task("f")
self.assertIn(f, or_preds)
def test_xor_join_to_h(self) -> None:
"""XOR join for {e, g} leading to h.
This was an OR-join that became XOR after
OR-joins minimization (Algorithm 9).
"""
h: Task = self.__model.get_task("h")
h_preds: set[Node] = (
self.__model.predecessors(h)
)
self.assertEqual(len(h_preds), 1)
join: Node = next(iter(h_preds))
self.assertIsInstance(join, Gateway)
assert isinstance(join, Gateway)
self.assertEqual(
join.gateway_type, GatewayType.XOR
)
join_preds: set[Node] = (
self.__model.predecessors(join)
)
self.assertEqual(len(join_preds), 2)
e: Task = self.__model.get_task("e")
g: Task = self.__model.get_task("g")
self.assertIn(e, join_preds)
self.assertIn(g, join_preds)
if __name__ == "__main__":
unittest.main()
+645
View File
@@ -0,0 +1,645 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
# 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 Split Miner algorithm.
Uses the running example from Section 3 of the SM 1.0 journal
paper (Augusto et al., 2018).
"""
from __future__ import annotations
import unittest
from collections import deque
from split_miner import (
BPMNModel,
Gateway,
GatewayType,
Node,
Task,
split_miner,
)
from split_miner.concurrency import PrunedDFG
from split_miner.dfg import DirectlyFollowsGraph
from split_miner.filtering import FilteredDFG
def _make_tasks(
labels: str,
) -> dict[str, Task]:
"""Create a Task for each single-character label.
:param labels: The labels as a string.
:return: A dict mapping label to Task.
"""
return {ch: Task(ch, ch) for ch in labels}
def _make_paper_node_log() -> tuple[
dict[tuple[Node, ...], int], dict[str, Task]
]:
"""Build the paper example log with Node objects.
:return: The Node-based traces and the task map.
"""
t: dict[str, Task] = _make_tasks("abcdefgh")
traces: dict[tuple[Node, ...], int] = {
(t["a"], t["b"], t["c"], t["g"],
t["e"], t["h"]): 10,
(t["a"], t["b"], t["c"], t["f"],
t["g"], t["h"]): 10,
(t["a"], t["b"], t["d"], t["g"],
t["e"], t["h"]): 10,
(t["a"], t["b"], t["d"], t["e"],
t["g"], t["h"]): 10,
(t["a"], t["b"], t["e"], t["c"],
t["g"], t["h"]): 10,
(t["a"], t["b"], t["e"], t["d"],
t["g"], t["h"]): 10,
(t["a"], t["c"], t["b"], t["e"],
t["g"], t["h"]): 10,
(t["a"], t["c"], t["b"], t["f"],
t["g"], t["h"]): 10,
(t["a"], t["d"], t["b"], t["e"],
t["g"], t["h"]): 10,
(t["a"], t["d"], t["b"], t["f"],
t["g"], t["h"]): 10,
}
return traces, t
def _make_paper_str_log() -> dict[
tuple[str, ...], int
]:
"""Build the paper example log with string labels.
:return: The string-based traces.
"""
return {
("a", "b", "c", "g", "e", "h"): 10,
("a", "b", "c", "f", "g", "h"): 10,
("a", "b", "d", "g", "e", "h"): 10,
("a", "b", "d", "e", "g", "h"): 10,
("a", "b", "e", "c", "g", "h"): 10,
("a", "b", "e", "d", "g", "h"): 10,
("a", "c", "b", "e", "g", "h"): 10,
("a", "c", "b", "f", "g", "h"): 10,
("a", "d", "b", "e", "g", "h"): 10,
("a", "d", "b", "f", "g", "h"): 10,
}
class TestDFGConstruction(unittest.TestCase):
"""Tests for DFG construction (Section 3.1)."""
def setUp(self) -> None:
"""Set up the test.
:return: None.
"""
traces: dict[tuple[Node, ...], int]
traces, self.__t = _make_paper_node_log()
self.__dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph(traces)
)
def test_nodes(self) -> None:
"""The DFG has the correct set of nodes."""
self.assertEqual(
self.__dfg.nodes,
set(self.__t.values()),
)
def test_sources_and_sinks(self) -> None:
"""The DFG has correct sources and sinks."""
self.assertIn(
self.__t["a"], self.__dfg.sources
)
self.assertIn(
self.__t["h"], self.__dfg.sinks
)
def test_df_frequencies(self) -> None:
"""Selected directly-follows frequencies match."""
t: dict[str, Task] = self.__t
# a -> b: appears in 6 trace types * 10 = 60
self.assertEqual(
self.__dfg.df_frequency(
t["a"], t["b"]
), 60
)
# a -> c: 2 trace types * 10 = 20
self.assertEqual(
self.__dfg.df_frequency(
t["a"], t["c"]
), 20
)
# a -> d: 2 trace types * 10 = 20
self.assertEqual(
self.__dfg.df_frequency(
t["a"], t["d"]
), 20
)
def test_no_self_loops(self) -> None:
"""The paper example has no self-loops."""
self.assertEqual(self.__dfg.self_loops, set())
def test_no_short_loops(self) -> None:
"""The paper example has no short-loops."""
self.assertEqual(
self.__dfg.short_loops, set()
)
def test_self_loop_detection(self) -> None:
"""Self-loops are correctly detected."""
t: dict[str, Task] = _make_tasks("abc")
traces: dict[tuple[Node, ...], int] = {
(t["a"], t["b"], t["b"], t["c"]): 10,
}
dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph(traces)
)
self.assertIn(t["b"], dfg.self_loops)
self.assertNotIn(t["a"], dfg.self_loops)
def test_self_loop_edges_excluded(self) -> None:
"""Self-loop edges are excluded from edges set."""
t: dict[str, Task] = _make_tasks("abc")
traces: dict[tuple[Node, ...], int] = {
(t["a"], t["b"], t["b"], t["c"]): 10,
}
dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph(traces)
)
self.assertNotIn(
(t["b"], t["b"]), dfg.edges
)
self.assertIn(
(t["a"], t["b"]), dfg.edges
)
self.assertIn(
(t["b"], t["c"]), dfg.edges
)
def test_short_loop_detection(self) -> None:
"""Short-loops are correctly detected."""
t: dict[str, Task] = _make_tasks("abcd")
traces: dict[tuple[Node, ...], int] = {
(t["a"], t["b"], t["c"],
t["b"], t["d"]): 10,
}
dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph(traces)
)
self.assertIn(
(t["b"], t["c"]), dfg.short_loops
)
self.assertIn(
(t["c"], t["b"]), dfg.short_loops
)
class TestConcurrencyDiscovery(unittest.TestCase):
"""Tests for concurrency discovery (Section 3.2)."""
def setUp(self) -> None:
"""Set up the test.
:return: None.
"""
traces: dict[tuple[Node, ...], int]
traces, self.__t = _make_paper_node_log()
dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph(traces)
)
self.__pdfg: PrunedDFG = PrunedDFG(
dfg, epsilon=0.2
)
def test_concurrent_pairs(self) -> None:
"""Correct concurrency relations with epsilon=0.2.
The paper identifies: b||c, b||d, d||e, e||g.
"""
t: dict[str, Task] = self.__t
# Check expected concurrent pairs
self.assertTrue(
self.__pdfg.is_concurrent(t["b"], t["c"])
)
self.assertTrue(
self.__pdfg.is_concurrent(t["b"], t["d"])
)
self.assertTrue(
self.__pdfg.is_concurrent(t["d"], t["e"])
)
self.assertTrue(
self.__pdfg.is_concurrent(t["e"], t["g"])
)
# Non-concurrent pairs
self.assertFalse(
self.__pdfg.is_concurrent(t["a"], t["b"])
)
self.assertFalse(
self.__pdfg.is_concurrent(t["c"], t["d"])
)
def test_self_loop_skipped_in_concurrency(
self,
) -> None:
"""Self-loop nodes are never concurrent."""
# b has a self-loop; a and b could look
# concurrent but b should be skipped.
t: dict[str, Task] = _make_tasks("abc")
traces: dict[tuple[Node, ...], int] = {
(t["a"], t["b"], t["b"],
t["a"], t["c"]): 10,
(t["a"], t["b"],
t["a"], t["c"]): 10,
}
dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph(traces)
)
pdfg: PrunedDFG = PrunedDFG(
dfg, epsilon=1.0
)
self.assertFalse(
pdfg.is_concurrent(t["a"], t["b"])
)
def test_short_loop_not_concurrent(self) -> None:
"""Short-loop pairs are not concurrent.
Condition 4 prevents short-loop pairs from being
declared concurrent.
"""
t: dict[str, Task] = _make_tasks("abcd")
traces: dict[tuple[Node, ...], int] = {
(t["a"], t["b"], t["c"],
t["b"], t["d"]): 10,
(t["a"], t["c"],
t["b"], t["d"]): 10,
}
dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph(traces)
)
pdfg: PrunedDFG = PrunedDFG(
dfg, epsilon=1.0
)
self.assertFalse(
pdfg.is_concurrent(t["b"], t["c"])
)
class TestFiltering(unittest.TestCase):
"""Tests for edge filtering (Section 3.3)."""
def setUp(self) -> None:
"""Set up the test.
:return: None.
"""
traces: dict[tuple[Node, ...], int]
traces, self.__t = _make_paper_node_log()
dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph(traces)
)
pdfg: PrunedDFG = PrunedDFG(
dfg, epsilon=0.2
)
self.__fdfg: FilteredDFG = FilteredDFG(
pdfg, eta=1.0
)
def test_filtered_edges_retain_best(self) -> None:
"""Filtered DFG retains best incoming/outgoing
edges.
Per Table 1 in the paper, edges (e,c) and (c,f)
should be dropped.
"""
t: dict[str, Task] = self.__t
edges: set[tuple[Node, Node]] = (
self.__fdfg.edges
)
# These should be retained (best edges)
self.assertIn((t["a"], t["b"]), edges)
self.assertIn((t["b"], t["e"]), edges)
self.assertIn((t["f"], t["g"]), edges)
self.assertIn((t["g"], t["h"]), edges)
def test_sources_and_sinks_preserved(self) -> None:
"""Filtering preserves sources and sinks."""
t: dict[str, Task] = self.__t
self.assertIn(
t["a"], self.__fdfg.sources
)
self.assertIn(
t["h"], self.__fdfg.sinks
)
class TestEndToEnd(unittest.TestCase):
"""End-to-end test for Split Miner."""
def test_paper_example_basic(self) -> None:
"""Split Miner produces a valid BPMN model.
The discovered model should have start/end events,
all 8 tasks, gateways, and proper connectivity.
"""
traces: dict[tuple[str, ...], int] = (
_make_paper_str_log()
)
model: BPMNModel = split_miner(
traces, epsilon=0.2, eta=0.4
)
# Has start and end
self.assertIsNotNone(model.start)
self.assertIsNotNone(model.end)
# Has all 8 tasks
task_labels: set[str] = {
t.label for t in model.tasks.values()
if t.label is not None
}
self.assertEqual(
task_labels,
{"a", "b", "c", "d", "e", "f", "g", "h"},
)
# Has edges
self.assertGreater(len(model.edges), 0)
# Start has outgoing edge
self.assertGreater(
len(model.outgoing_edges(model.start)), 0
)
# End has incoming edge
self.assertGreater(
len(model.incoming_edges(model.end)), 0
)
def test_paper_example_has_gateways(self) -> None:
"""The paper example produces split and join
gateways.
Per Fig. 3c, the model should have both XOR and
AND gateways (or OR gateways that get minimized).
"""
traces: dict[tuple[str, ...], int] = (
_make_paper_str_log()
)
model: BPMNModel = split_miner(
traces, epsilon=0.2, eta=0.4
)
self.assertGreater(len(model.gateways), 0)
gw_types: set[GatewayType] = {
gw.gateway_type
for gw in model.gateways.values()
}
# Should have at least XOR or AND gateways
self.assertTrue(
GatewayType.XOR in gw_types
or GatewayType.AND in gw_types,
f"Expected XOR or AND gateways, "
f"got {gw_types}"
)
def test_paper_example_all_tasks_connected(
self,
) -> None:
"""Every task is reachable from start.
Verifies syntactic correctness: all tasks on a
path from start to end.
"""
traces: dict[tuple[str, ...], int] = (
_make_paper_str_log()
)
model: BPMNModel = split_miner(
traces, epsilon=0.2, eta=0.4
)
# BFS from start
reachable: set[Node] = set()
queue: deque[Node] = deque([model.start])
while queue:
node: Node = queue.popleft()
if node in reachable:
continue
reachable.add(node)
for _, succ in model.outgoing_edges(node):
queue.append(succ)
# All tasks should be reachable
for task in model.tasks.values():
self.assertIn(
task, reachable,
f"Task {task.label!r} not reachable "
f"from start"
)
# End should be reachable
self.assertIn(model.end, reachable)
def test_paper_example_all_tasks_reach_end(
self,
) -> None:
"""Every task can reach the end event.
Verifies syntactic correctness by backward BFS.
"""
traces: dict[tuple[str, ...], int] = (
_make_paper_str_log()
)
model: BPMNModel = split_miner(
traces, epsilon=0.2, eta=0.4
)
# Backward BFS from end
can_reach_end: set[Node] = set()
queue: deque[Node] = deque([model.end])
while queue:
node: Node = queue.popleft()
if node in can_reach_end:
continue
can_reach_end.add(node)
for pred, _ in model.incoming_edges(node):
queue.append(pred)
# All tasks should reach end
for task in model.tasks.values():
self.assertIn(
task, can_reach_end,
f"Task {task.label!r} cannot reach end"
)
def test_simple_sequence(self) -> None:
"""A simple sequential log produces no gateways."""
traces: dict[tuple[str, ...], int] = {
("a", "b", "c"): 10,
}
model: BPMNModel = split_miner(traces)
self.assertEqual(len(model.gateways), 0)
self.assertEqual(len(model.tasks), 3)
def test_simple_xor_choice(self) -> None:
"""A log with exclusive choice produces XOR
gateways.
Log: {<a,b,d>^10, <a,c,d>^10}
Expected: a -> XOR-split -> {b, c} ->
XOR-join -> d
"""
traces: dict[tuple[str, ...], int] = {
("a", "b", "d"): 10,
("a", "c", "d"): 10,
}
model: BPMNModel = split_miner(
traces, epsilon=0.1, eta=0.4
)
# Should have tasks a, b, c, d
task_labels: set[str] = {
t.label for t in model.tasks.values()
if t.label is not None
}
self.assertEqual(
task_labels, {"a", "b", "c", "d"}
)
# Should have gateways
self.assertGreater(len(model.gateways), 0)
# All tasks reachable from start
reachable: set[Node] = set()
queue: deque[Node] = deque([model.start])
while queue:
node: Node = queue.popleft()
if node in reachable:
continue
reachable.add(node)
for _, s in model.outgoing_edges(node):
queue.append(s)
for task in model.tasks.values():
self.assertIn(task, reachable)
def test_simple_concurrency(self) -> None:
"""A log with concurrency produces AND gateways.
Log: {<a,b,c,d>^10, <a,c,b,d>^10}
b and c are concurrent.
"""
traces: dict[tuple[str, ...], int] = {
("a", "b", "c", "d"): 10,
("a", "c", "b", "d"): 10,
}
model: BPMNModel = split_miner(
traces, epsilon=1.0, eta=0.4
)
task_labels: set[str] = {
t.label for t in model.tasks.values()
if t.label is not None
}
self.assertEqual(
task_labels, {"a", "b", "c", "d"}
)
# Should have AND gateways for b||c
and_gws: list[Gateway] = [
gw for gw in model.gateways.values()
if gw.gateway_type == GatewayType.AND
]
self.assertGreater(
len(and_gws), 0,
"Expected AND gateways for concurrent "
"b and c"
)
class TestSelfLoopHandling(unittest.TestCase):
"""Tests for self-loop handling."""
def test_self_loop_restored(self) -> None:
"""Self-loops are restored in the final BPMN model.
A self-loop on task b should produce XOR-join and
XOR-split gateways around b with a back-edge.
"""
traces: dict[tuple[str, ...], int] = {
("a", "b", "c"): 10,
("a", "b", "b", "c"): 10,
("a", "b", "b", "b", "c"): 10,
}
model: BPMNModel = split_miner(traces)
# Task b should have a gateway predecessor
# and a gateway successor (the self-loop
# XOR-join and XOR-split)
b: Node = model.get_task("b")
b_preds: set[Node] = model.predecessors(b)
b_succs: set[Node] = model.successors(b)
# b should have exactly 1 predecessor (XOR-join)
# and 1 successor (XOR-split)
self.assertEqual(len(b_preds), 1)
self.assertEqual(len(b_succs), 1)
join_node: Node = next(iter(b_preds))
split_node: Node = next(iter(b_succs))
self.assertIsInstance(join_node, Gateway)
self.assertIsInstance(split_node, Gateway)
assert isinstance(join_node, Gateway)
assert isinstance(split_node, Gateway)
self.assertEqual(
join_node.gateway_type, GatewayType.XOR
)
self.assertEqual(
split_node.gateway_type, GatewayType.XOR
)
# Back-edge: split -> join
self.assertIn(
split_node,
model.predecessors(join_node)
)
class TestShortLoopHandling(unittest.TestCase):
"""Tests for short-loop handling."""
def test_short_loop_not_concurrent(self) -> None:
"""Short-loop pairs are excluded from concurrency.
If a and b form a short-loop, they must not be
declared concurrent even if they appear in both
orders.
"""
# a,b,a pattern = short-loop
t: dict[str, Task] = _make_tasks("abxy")
traces: dict[tuple[Node, ...], int] = {
(t["x"], t["a"], t["b"],
t["a"], t["y"]): 10,
(t["x"], t["b"], t["a"],
t["b"], t["y"]): 10,
(t["x"], t["a"], t["y"]): 10,
(t["x"], t["b"], t["y"]): 10,
}
dfg: DirectlyFollowsGraph = (
DirectlyFollowsGraph(traces)
)
# Should detect short-loop
self.assertIn(
(t["a"], t["b"]), dfg.short_loops
)
# Should NOT be concurrent
pdfg: PrunedDFG = PrunedDFG(
dfg, epsilon=1.0
)
self.assertFalse(
pdfg.is_concurrent(t["a"], t["b"])
)
if __name__ == "__main__":
unittest.main()
+651
View File
@@ -0,0 +1,651 @@
# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/11
# 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 SPQR-tree and RPST integration.
Tests that the spqrtree library is correctly integrated via
build_rpst(), using known graph decompositions from:
- Wikimedia SPQR tree example
- RPST paper (Polyvyanyy et al., 2011) Fig. 3(a)
- SM 1.0 paper Fig. 5(b)
References:
* https://commons.wikimedia.org/wiki/File:SPQR_tree_2.svg
* Polyvyanyy, A., Vanhatalo, J., & Voelzer, H. (2011).
Simplified computation and generalization of the refined
process structure tree. Lecture Notes in Computer
Science, 25-41.
"""
from __future__ import annotations
import unittest
from spqrtree import MultiGraph, NodeType, SPQRTree
from split_miner.bpmn import (
BPMNModel,
EndEvent,
Gateway,
GatewayType,
Node,
StartEvent,
Task,
)
from split_miner.joins import SESEFragment, build_rpst
def _make_serial_model() -> tuple[
BPMNModel, StartEvent, EndEvent,
Task, Task, Task,
]:
"""Build a serial chain: start -> a -> b -> c -> end.
:return: The model and its nodes.
"""
start: StartEvent = StartEvent("start")
end: EndEvent = EndEvent("end")
model: BPMNModel = BPMNModel(start, end)
a: Task = Task("a", "a")
b: Task = Task("b", "b")
c: Task = Task("c", "c")
for t in [a, b, c]:
model.add_task(t)
model.add_edge(start, a)
model.add_edge(a, b)
model.add_edge(b, c)
model.add_edge(c, end)
return model, start, end, a, b, c
def _make_diamond_model() -> tuple[
BPMNModel, StartEvent, EndEvent,
Task, Task,
]:
"""Build a diamond: start -> {a, b} -> end.
:return: The model and its nodes.
"""
start: StartEvent = StartEvent("start")
end: EndEvent = EndEvent("end")
model: BPMNModel = BPMNModel(start, end)
a: Task = Task("a", "a")
b: Task = Task("b", "b")
model.add_task(a)
model.add_task(b)
model.add_edge(start, a)
model.add_edge(start, b)
model.add_edge(a, end)
model.add_edge(b, end)
return model, start, end, a, b
def _make_fig5b_model() -> tuple[
BPMNModel, dict[str, Node],
]:
"""Build the model from Fig. 5(b) of the SM 1.0 paper.
Graph structure (after splits, before joins):
- start -> gx1 (XOR split)
- gx1 -> {a, b}
- a -> gx2 (XOR split), b -> gx3 (XOR split)
- gx2 -> {j, c}, gx3 -> {j, d}
- j -> i, c -> i, d -> k, i -> k
- k -> end
:return: The model and its named nodes.
"""
start: StartEvent = StartEvent("start")
end: EndEvent = EndEvent("end")
model: BPMNModel = BPMNModel(start, end)
tasks: dict[str, Task] = {}
for label in ["a", "b", "c", "d", "i", "j", "k"]:
t: Task = Task(label, label)
model.add_task(t)
tasks[label] = t
gx1: Gateway = Gateway("gx1", GatewayType.XOR)
gx2: Gateway = Gateway("gx2", GatewayType.XOR)
gx3: Gateway = Gateway("gx3", GatewayType.XOR)
model.add_gateway(gx1)
model.add_gateway(gx2)
model.add_gateway(gx3)
nodes: dict[str, Node] = {
"start": start, "end": end,
"gx1": gx1, "gx2": gx2, "gx3": gx3,
}
nodes.update(tasks)
for src, tgt in [
("start", "gx1"),
("gx1", "a"), ("gx1", "b"),
("a", "gx2"), ("b", "gx3"),
("gx2", "j"), ("gx3", "j"),
("gx2", "c"), ("gx3", "d"),
("j", "i"), ("c", "i"),
("d", "k"), ("i", "k"),
("k", "end"),
]:
model.add_edge(nodes[src], nodes[tgt])
return model, nodes
class TestSpqrTreeWikimedia(unittest.TestCase):
"""Tests SPQR-tree on the Wikimedia Commons example.
Tests the set of all SPQR-tree nodes (type + vertices)
regardless of root choice, since the unrooted tree
structure is unique but the rooting may vary.
Reference:
https://commons.wikimedia.org/wiki/File:SPQR_tree_2.svg
"""
def setUp(self) -> None:
"""Set up the Wikimedia example graph.
:return: None.
"""
mg: MultiGraph = MultiGraph()
for v in "abcdefghijklmnop":
mg.add_vertex(v)
for u, v in [
("a", "b"), ("a", "c"), ("a", "g"),
("b", "d"), ("b", "h"),
("c", "d"), ("c", "e"),
("d", "f"), ("e", "f"), ("e", "g"),
("f", "h"),
("h", "i"), ("h", "j"),
("i", "j"), ("i", "n"),
("j", "k"),
("k", "m"), ("k", "n"), ("m", "n"),
("l", "m"), ("l", "o"), ("l", "p"),
("m", "o"), ("m", "p"),
("o", "p"),
("g", "l"),
]:
mg.add_edge(u, v)
self.__tree: SPQRTree = SPQRTree(mg)
self.__all_nodes: list[
tuple[str, frozenset[str]]
] = []
_collect_all_nodes(
self.__tree.root, self.__all_nodes
)
def test_node_count(self) -> None:
"""The tree has 5 nodes (1 S, 1 P, 3 R).
:return: None.
"""
self.assertEqual(len(self.__all_nodes), 5)
def test_node_types(self) -> None:
"""Node types are P, R, R, R, S (sorted).
:return: None.
"""
types: list[str] = sorted(
t for t, _ in self.__all_nodes
)
self.assertEqual(
types, ["P", "R", "R", "R", "S"]
)
def test_s_node(self) -> None:
"""S-node has vertices {g, h, l, m}.
:return: None.
"""
s_nodes: list[frozenset[str]] = [
v for t, v in self.__all_nodes
if t == "S"
]
self.assertEqual(len(s_nodes), 1)
self.assertEqual(
s_nodes[0],
frozenset({"g", "h", "l", "m"}),
)
def test_p_node(self) -> None:
"""P-node has vertices {l, m}.
:return: None.
"""
p_nodes: list[frozenset[str]] = [
v for t, v in self.__all_nodes
if t == "P"
]
self.assertEqual(len(p_nodes), 1)
self.assertEqual(
p_nodes[0], frozenset({"l", "m"})
)
def test_r_node_1(self) -> None:
"""R-node {a,b,c,d,e,f,g,h} exists.
:return: None.
"""
r_verts: list[frozenset[str]] = [
v for t, v in self.__all_nodes
if t == "R"
]
self.assertIn(
frozenset({
"a", "b", "c", "d",
"e", "f", "g", "h",
}),
r_verts,
)
def test_r_node_2(self) -> None:
"""R-node {h,i,j,k,m,n} exists.
:return: None.
"""
r_verts: list[frozenset[str]] = [
v for t, v in self.__all_nodes
if t == "R"
]
self.assertIn(
frozenset({
"h", "i", "j", "k", "m", "n",
}),
r_verts,
)
def test_r_node_3(self) -> None:
"""R-node {l,m,o,p} exists.
:return: None.
"""
r_verts: list[frozenset[str]] = [
v for t, v in self.__all_nodes
if t == "R"
]
self.assertIn(
frozenset({"l", "m", "o", "p"}),
r_verts,
)
class TestSpqrTreeRpstFig3a(unittest.TestCase):
"""Tests SPQR-tree on RPST paper Fig. 3(a).
Reference: Polyvyanyy et al. (2011), Fig. 3(a).
Graph: s->u, u->{v,w}, v->{w,x}, w->x, x->y,
y->z (x2), z->y, z->t, plus back-edge t->s.
"""
def setUp(self) -> None:
"""Set up the RPST Fig 3a graph.
:return: None.
"""
mg: MultiGraph = MultiGraph()
for v in [
"s", "u", "v", "w", "x",
"y", "z", "t",
]:
mg.add_vertex(v)
for u, v in [
("s", "u"), ("u", "v"), ("u", "w"),
("v", "w"), ("v", "x"), ("w", "x"),
("x", "y"),
("y", "z"), ("y", "z"), ("z", "y"),
("z", "t"), ("t", "s"),
]:
mg.add_edge(u, v)
self.__tree: SPQRTree = SPQRTree(mg)
def test_root_type(self) -> None:
"""The root is an S-node.
:return: None.
"""
self.assertEqual(
self.__tree.root.type, NodeType.S
)
def test_root_vertices(self) -> None:
"""Root S-node contains {s,t,u,x,y,z}.
:return: None.
"""
verts: set[str] = _skeleton_vertices(
self.__tree.root
)
self.assertEqual(
verts, {"s", "t", "u", "x", "y", "z"}
)
def test_child_count(self) -> None:
"""The root has 2 children: R and P.
:return: None.
"""
self.assertEqual(
len(self.__tree.root.children), 2
)
def test_r_child(self) -> None:
"""R-node child has {u,v,w,x}.
:return: None.
"""
r1 = _find_child_by_vertices(
self.__tree.root, {"u", "v", "w", "x"}
)
self.assertIsNotNone(r1)
assert r1 is not None
self.assertEqual(r1.type, NodeType.R)
def test_p_child(self) -> None:
"""P-node child has {y,z}.
:return: None.
"""
p1 = _find_child_by_vertices(
self.__tree.root, {"y", "z"}
)
self.assertIsNotNone(p1)
assert p1 is not None
self.assertEqual(p1.type, NodeType.P)
def test_r_child_real_edges(self) -> None:
"""R-node has 5 real edges (the biconnected core).
:return: None.
"""
r1 = _find_child_by_vertices(
self.__tree.root, {"u", "v", "w", "x"}
)
assert r1 is not None
real: list[tuple[str, str]] = [
(e.u, e.v) for e in r1.skeleton.edges
if not e.virtual
]
self.assertEqual(len(real), 5)
def test_p_child_real_edges(self) -> None:
"""P-node has 3 real edges (y->z x2, z->y).
:return: None.
"""
p1 = _find_child_by_vertices(
self.__tree.root, {"y", "z"}
)
assert p1 is not None
real: list[tuple[str, str]] = [
(e.u, e.v) for e in p1.skeleton.edges
if not e.virtual
]
self.assertEqual(len(real), 3)
class TestBuildRpstSerial(unittest.TestCase):
"""Tests build_rpst on a serial chain."""
def setUp(self) -> None:
"""Set up a serial model: start->a->b->c->end.
:return: None.
"""
model: BPMNModel
model, _, _, _, _, _ = _make_serial_model()
self.__fragments: list[SESEFragment] = (
build_rpst(model)
)
def test_single_fragment(self) -> None:
"""A serial chain produces one S-type fragment.
:return: None.
"""
self.assertEqual(len(self.__fragments), 1)
def test_fragment_type(self) -> None:
"""The fragment is S-type (serial).
:return: None.
"""
self.assertEqual(
self.__fragments[0].fragment_type,
NodeType.S,
)
def test_fragment_edges(self) -> None:
"""The fragment contains all 4 edges.
:return: None.
"""
self.assertEqual(
len(self.__fragments[0].edges), 4
)
def test_fragment_nodes(self) -> None:
"""The fragment contains all 5 nodes.
:return: None.
"""
self.assertEqual(
len(self.__fragments[0].nodes), 5
)
class TestBuildRpstDiamond(unittest.TestCase):
"""Tests build_rpst on a diamond graph."""
def setUp(self) -> None:
"""Set up a diamond: start->{a,b}->end.
:return: None.
"""
model: BPMNModel
model, _, _, _, _ = _make_diamond_model()
self.__fragments: list[SESEFragment] = (
build_rpst(model)
)
def test_fragment_count(self) -> None:
"""Diamond produces 3 fragments (2 S + 1 P).
:return: None.
"""
self.assertEqual(len(self.__fragments), 3)
def test_has_p_fragment(self) -> None:
"""There is a P-type (parallel) fragment.
:return: None.
"""
p_frags: list[SESEFragment] = [
f for f in self.__fragments
if f.fragment_type == NodeType.P
]
self.assertEqual(len(p_frags), 1)
def test_p_fragment_covers_all(self) -> None:
"""The P-type fragment contains all 4 edges.
:return: None.
"""
p_frag: SESEFragment = [
f for f in self.__fragments
if f.fragment_type == NodeType.P
][0]
self.assertEqual(len(p_frag.edges), 4)
def test_p_fragment_nodes(self) -> None:
"""The P-type fragment contains all 4 nodes.
:return: None.
"""
p_frag: SESEFragment = [
f for f in self.__fragments
if f.fragment_type == NodeType.P
][0]
self.assertEqual(len(p_frag.nodes), 4)
def test_s_fragments(self) -> None:
"""Two S-type fragments (one per branch).
:return: None.
"""
s_frags: list[SESEFragment] = [
f for f in self.__fragments
if f.fragment_type == NodeType.S
]
self.assertEqual(len(s_frags), 2)
def test_s_fragment_edges(self) -> None:
"""Each S-type fragment has 2 edges.
:return: None.
"""
for f in self.__fragments:
if f.fragment_type == NodeType.S:
self.assertEqual(len(f.edges), 2)
def test_bottom_up_order(self) -> None:
"""Fragments are ordered bottom-up (small first).
:return: None.
"""
sizes: list[int] = [
len(f.edges) for f in self.__fragments
]
self.assertEqual(sizes, sorted(sizes))
class TestBuildRpstFig5b(unittest.TestCase):
"""Tests build_rpst on SM 1.0 paper Fig. 5(b)."""
def setUp(self) -> None:
"""Set up the Fig. 5(b) model.
:return: None.
"""
model: BPMNModel
model, _ = _make_fig5b_model()
self.__fragments: list[SESEFragment] = (
build_rpst(model)
)
def test_has_fragments(self) -> None:
"""At least one fragment is produced.
:return: None.
"""
self.assertGreater(len(self.__fragments), 0)
def test_all_edges_covered(self) -> None:
"""Union of fragment edges covers all model edges.
:return: None.
"""
model: BPMNModel
model, _ = _make_fig5b_model()
all_frag_edges: set[tuple[Node, Node]] = set()
for f in self.__fragments:
all_frag_edges |= f.edges
self.assertEqual(all_frag_edges, model.edges)
def test_has_r_fragment(self) -> None:
"""There is at least one R-type (rigid) fragment.
:return: None.
"""
r_frags: list[SESEFragment] = [
f for f in self.__fragments
if f.fragment_type == NodeType.R
]
self.assertGreater(len(r_frags), 0)
def test_bottom_up_order(self) -> None:
"""Fragments are ordered bottom-up (small first).
:return: None.
"""
sizes: list[int] = [
len(f.edges) for f in self.__fragments
]
self.assertEqual(sizes, sorted(sizes))
def test_entry_exit_are_nodes(self) -> None:
"""Entry and exit of each fragment are model nodes.
:return: None.
"""
model: BPMNModel
model, _ = _make_fig5b_model()
all_nodes: set[Node] = model.all_nodes
for f in self.__fragments:
self.assertIn(f.entry, all_nodes)
self.assertIn(f.exit_node, all_nodes)
def _collect_all_nodes(
spqr_node,
result: list[tuple[str, frozenset]],
) -> None:
"""Collect all SPQR-tree nodes as (type, vertices).
:param spqr_node: The SPQR-tree node.
:param result: The output list.
"""
verts: frozenset = frozenset(
_skeleton_vertices(spqr_node)
)
result.append((spqr_node.type.name, verts))
for child in spqr_node.children:
_collect_all_nodes(child, result)
def _skeleton_vertices(spqr_node) -> set:
"""Extract vertex set from an SPQR-tree node skeleton.
:param spqr_node: The SPQR-tree node.
:return: The set of vertices.
"""
verts: set = set()
for e in spqr_node.skeleton.edges:
verts.add(e.u)
verts.add(e.v)
return verts
def _find_child_by_vertices(
parent, target_verts: set
):
"""Find a child SPQR node by its vertex set.
:param parent: The parent SPQR-tree node.
:param target_verts: The expected vertex set.
:return: The matching child, or None.
"""
for child in parent.children:
if _skeleton_vertices(child) == target_verts:
return child
return None
if __name__ == "__main__":
unittest.main()