diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..6b90e00
--- /dev/null
+++ b/MANIFEST.in
@@ -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
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..2c85c67
--- /dev/null
+++ b/README.rst
@@ -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
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..6b13fe1
--- /dev/null
+++ b/pyproject.toml
@@ -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"}
diff --git a/src/split_miner/__init__.py b/src/split_miner/__init__.py
new file mode 100644
index 0000000..1b1355f
--- /dev/null
+++ b/src/split_miner/__init__.py
@@ -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."""
diff --git a/src/split_miner/bpmn.py b/src/split_miner/bpmn.py
new file mode 100644
index 0000000..50507e7
--- /dev/null
+++ b/src/split_miner/bpmn.py
@@ -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)
diff --git a/src/split_miner/concurrency.py b/src/split_miner/concurrency.py
new file mode 100644
index 0000000..31d84c6
--- /dev/null
+++ b/src/split_miner/concurrency.py
@@ -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
+ }
diff --git a/src/split_miner/dfg.py b/src/split_miner/dfg.py
new file mode 100644
index 0000000..ee982c6
--- /dev/null
+++ b/src/split_miner/dfg.py
@@ -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
+ }
diff --git a/src/split_miner/filtering.py b/src/split_miner/filtering.py
new file mode 100644
index 0000000..9128859
--- /dev/null
+++ b/src/split_miner/filtering.py
@@ -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
+ }
diff --git a/src/split_miner/joins.py b/src/split_miner/joins.py
new file mode 100644
index 0000000..a0d046b
--- /dev/null
+++ b/src/split_miner/joins.py
@@ -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]
diff --git a/src/split_miner/miner.py b/src/split_miner/miner.py
new file mode 100644
index 0000000..ef89a62
--- /dev/null
+++ b/src/split_miner/miner.py
@@ -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)
diff --git a/src/split_miner/or_minimization.py b/src/split_miner/or_minimization.py
new file mode 100644
index 0000000..b78c733
--- /dev/null
+++ b/src/split_miner/or_minimization.py
@@ -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
diff --git a/src/split_miner/py.typed b/src/split_miner/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/src/split_miner/splits.py b/src/split_miner/splits.py
new file mode 100644
index 0000000..016e1a9
--- /dev/null
+++ b/src/split_miner/splits.py
@@ -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()
diff --git a/tests/logs/cyclic_trace.json b/tests/logs/cyclic_trace.json
new file mode 100644
index 0000000..bb0da4e
--- /dev/null
+++ b/tests/logs/cyclic_trace.json
@@ -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]
+]
diff --git a/tests/logs/multi_sink.json b/tests/logs/multi_sink.json
new file mode 100644
index 0000000..7c31f41
--- /dev/null
+++ b/tests/logs/multi_sink.json
@@ -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]
+]
diff --git a/tests/logs/multi_source_sink.json b/tests/logs/multi_source_sink.json
new file mode 100644
index 0000000..c35c592
--- /dev/null
+++ b/tests/logs/multi_source_sink.json
@@ -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]
+]
diff --git a/tests/logs/short_loops.json b/tests/logs/short_loops.json
new file mode 100644
index 0000000..89b897e
--- /dev/null
+++ b/tests/logs/short_loops.json
@@ -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]
+]
diff --git a/tests/test_dfg.py b/tests/test_dfg.py
new file mode 100644
index 0000000..659d8ac
--- /dev/null
+++ b/tests/test_dfg.py
@@ -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 = {^3, ^2, ^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()
diff --git a/tests/test_empty_log.py b/tests/test_empty_log.py
new file mode 100644
index 0000000..431fb4b
--- /dev/null
+++ b/tests/test_empty_log.py
@@ -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()
diff --git a/tests/test_epsilon.py b/tests/test_epsilon.py
new file mode 100644
index 0000000..dff6bd2
--- /dev/null
+++ b/tests/test_epsilon.py
@@ -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()
diff --git a/tests/test_event_log.py b/tests/test_event_log.py
new file mode 100644
index 0000000..e49281d
--- /dev/null
+++ b/tests/test_event_log.py
@@ -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()
diff --git a/tests/test_fig5b_fig6.py b/tests/test_fig5b_fig6.py
new file mode 100644
index 0000000..a42854f
--- /dev/null
+++ b/tests/test_fig5b_fig6.py
@@ -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()
diff --git a/tests/test_node_splitting.py b/tests/test_node_splitting.py
new file mode 100644
index 0000000..3734363
--- /dev/null
+++ b/tests/test_node_splitting.py
@@ -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()
diff --git a/tests/test_paper_pipeline.py b/tests/test_paper_pipeline.py
new file mode 100644
index 0000000..39d06da
--- /dev/null
+++ b/tests/test_paper_pipeline.py
@@ -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()
diff --git a/tests/test_split_miner.py b/tests/test_split_miner.py
new file mode 100644
index 0000000..09810c2
--- /dev/null
+++ b/tests/test_split_miner.py
@@ -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: {^10, ^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: {^10, ^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()
diff --git a/tests/test_spqr_rpst.py b/tests/test_spqr_rpst.py
new file mode 100644
index 0000000..4317209
--- /dev/null
+++ b/tests/test_spqr_rpst.py
@@ -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()