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()