Add split miner implementation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Split Miner - BPMN process discovery from event logs.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
|
||||
|
||||
# Copyright (c) 2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
recursive-include docs *
|
||||
prune docs/build
|
||||
recursive-include tests *
|
||||
recursive-exclude tests *.pyc
|
||||
+137
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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."""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
]
|
||||
@@ -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]
|
||||
]
|
||||
@@ -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]
|
||||
]
|
||||
@@ -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]
|
||||
]
|
||||
@@ -0,0 +1,129 @@
|
||||
# Split Miner - BPMN process discovery from event logs.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
|
||||
# AI assistance: Claude Code (Anthropic)
|
||||
|
||||
# Copyright (c) 2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
# implied. See the License for the specific language governing
|
||||
# permissions and limitations under the License.
|
||||
"""Tests for the DFG construction with a 5-task example.
|
||||
|
||||
Uses a small event log with 5 tasks to verify DFG construction,
|
||||
edge frequencies, and source/sink detection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from split_miner.bpmn import Node, Task
|
||||
from split_miner.dfg import DirectlyFollowsGraph
|
||||
|
||||
|
||||
def _make_tasks(
|
||||
labels: str,
|
||||
) -> dict[str, Task]:
|
||||
"""Create a Task for each single-character label.
|
||||
|
||||
:param labels: The labels as a string.
|
||||
:return: A dict mapping label to Task.
|
||||
"""
|
||||
return {ch: Task(ch, ch) for ch in labels}
|
||||
|
||||
|
||||
def _make_five_task_log() -> tuple[
|
||||
dict[tuple[Node, ...], int], dict[str, Task]
|
||||
]:
|
||||
"""Build a 5-task event log.
|
||||
|
||||
L = {<a,b,c,d>^3, <a,c,b,d>^2, <a,e,d>^1}
|
||||
|
||||
:return: The event log and the task map.
|
||||
"""
|
||||
t: dict[str, Task] = _make_tasks("abcde")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["a"], t["b"], t["c"], t["d"]): 3,
|
||||
(t["a"], t["c"], t["b"], t["d"]): 2,
|
||||
(t["a"], t["e"], t["d"]): 1,
|
||||
}
|
||||
return traces, t
|
||||
|
||||
|
||||
class TestFiveTaskDFG(unittest.TestCase):
|
||||
"""Tests for DFG with 5 tasks (a, b, c, d, e)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up the test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
traces: dict[tuple[Node, ...], int]
|
||||
traces, self.__t = _make_five_task_log()
|
||||
self.__dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
|
||||
def test_nodes(self) -> None:
|
||||
"""DFG has the correct 5 nodes."""
|
||||
self.assertEqual(
|
||||
self.__dfg.nodes,
|
||||
set(self.__t.values()),
|
||||
)
|
||||
|
||||
def test_edge_frequencies(self) -> None:
|
||||
"""All 8 edge frequencies are correct."""
|
||||
t: dict[str, Task] = self.__t
|
||||
expected: dict[tuple[Node, Node], int] = {
|
||||
(t["a"], t["b"]): 3,
|
||||
(t["a"], t["c"]): 2,
|
||||
(t["a"], t["e"]): 1,
|
||||
(t["b"], t["c"]): 3,
|
||||
(t["b"], t["d"]): 2,
|
||||
(t["c"], t["b"]): 2,
|
||||
(t["c"], t["d"]): 3,
|
||||
(t["e"], t["d"]): 1,
|
||||
}
|
||||
for (src, tgt), freq in expected.items():
|
||||
self.assertEqual(
|
||||
self.__dfg.df_frequency(src, tgt),
|
||||
freq,
|
||||
f"|{src.node_id} -> {tgt.node_id}|"
|
||||
f" should be {freq}"
|
||||
)
|
||||
|
||||
def test_edges(self) -> None:
|
||||
"""DFG has the correct 8 edges."""
|
||||
t: dict[str, Task] = self.__t
|
||||
expected: set[tuple[Node, Node]] = {
|
||||
(t["a"], t["b"]), (t["a"], t["c"]),
|
||||
(t["a"], t["e"]),
|
||||
(t["b"], t["c"]), (t["b"], t["d"]),
|
||||
(t["c"], t["b"]), (t["c"], t["d"]),
|
||||
(t["e"], t["d"]),
|
||||
}
|
||||
self.assertEqual(self.__dfg.edges, expected)
|
||||
|
||||
def test_sources(self) -> None:
|
||||
"""Sources include a (first task of traces)."""
|
||||
self.assertIn(
|
||||
self.__t["a"], self.__dfg.sources
|
||||
)
|
||||
|
||||
def test_sinks(self) -> None:
|
||||
"""Sinks include d (last task of traces)."""
|
||||
self.assertIn(
|
||||
self.__t["d"], self.__dfg.sinks
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,645 @@
|
||||
# Split Miner - BPMN process discovery from event logs.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
|
||||
# AI assistance: Claude Code (Anthropic)
|
||||
|
||||
# Copyright (c) 2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
# implied. See the License for the specific language governing
|
||||
# permissions and limitations under the License.
|
||||
"""Tests for the Split Miner algorithm.
|
||||
|
||||
Uses the running example from Section 3 of the SM 1.0 journal
|
||||
paper (Augusto et al., 2018).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from collections import deque
|
||||
|
||||
from split_miner import (
|
||||
BPMNModel,
|
||||
Gateway,
|
||||
GatewayType,
|
||||
Node,
|
||||
Task,
|
||||
split_miner,
|
||||
)
|
||||
from split_miner.concurrency import PrunedDFG
|
||||
from split_miner.dfg import DirectlyFollowsGraph
|
||||
from split_miner.filtering import FilteredDFG
|
||||
|
||||
|
||||
def _make_tasks(
|
||||
labels: str,
|
||||
) -> dict[str, Task]:
|
||||
"""Create a Task for each single-character label.
|
||||
|
||||
:param labels: The labels as a string.
|
||||
:return: A dict mapping label to Task.
|
||||
"""
|
||||
return {ch: Task(ch, ch) for ch in labels}
|
||||
|
||||
|
||||
def _make_paper_node_log() -> tuple[
|
||||
dict[tuple[Node, ...], int], dict[str, Task]
|
||||
]:
|
||||
"""Build the paper example log with Node objects.
|
||||
|
||||
:return: The Node-based traces and the task map.
|
||||
"""
|
||||
t: dict[str, Task] = _make_tasks("abcdefgh")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["a"], t["b"], t["c"], t["g"],
|
||||
t["e"], t["h"]): 10,
|
||||
(t["a"], t["b"], t["c"], t["f"],
|
||||
t["g"], t["h"]): 10,
|
||||
(t["a"], t["b"], t["d"], t["g"],
|
||||
t["e"], t["h"]): 10,
|
||||
(t["a"], t["b"], t["d"], t["e"],
|
||||
t["g"], t["h"]): 10,
|
||||
(t["a"], t["b"], t["e"], t["c"],
|
||||
t["g"], t["h"]): 10,
|
||||
(t["a"], t["b"], t["e"], t["d"],
|
||||
t["g"], t["h"]): 10,
|
||||
(t["a"], t["c"], t["b"], t["e"],
|
||||
t["g"], t["h"]): 10,
|
||||
(t["a"], t["c"], t["b"], t["f"],
|
||||
t["g"], t["h"]): 10,
|
||||
(t["a"], t["d"], t["b"], t["e"],
|
||||
t["g"], t["h"]): 10,
|
||||
(t["a"], t["d"], t["b"], t["f"],
|
||||
t["g"], t["h"]): 10,
|
||||
}
|
||||
return traces, t
|
||||
|
||||
|
||||
def _make_paper_str_log() -> dict[
|
||||
tuple[str, ...], int
|
||||
]:
|
||||
"""Build the paper example log with string labels.
|
||||
|
||||
:return: The string-based traces.
|
||||
"""
|
||||
return {
|
||||
("a", "b", "c", "g", "e", "h"): 10,
|
||||
("a", "b", "c", "f", "g", "h"): 10,
|
||||
("a", "b", "d", "g", "e", "h"): 10,
|
||||
("a", "b", "d", "e", "g", "h"): 10,
|
||||
("a", "b", "e", "c", "g", "h"): 10,
|
||||
("a", "b", "e", "d", "g", "h"): 10,
|
||||
("a", "c", "b", "e", "g", "h"): 10,
|
||||
("a", "c", "b", "f", "g", "h"): 10,
|
||||
("a", "d", "b", "e", "g", "h"): 10,
|
||||
("a", "d", "b", "f", "g", "h"): 10,
|
||||
}
|
||||
|
||||
|
||||
class TestDFGConstruction(unittest.TestCase):
|
||||
"""Tests for DFG construction (Section 3.1)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up the test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
traces: dict[tuple[Node, ...], int]
|
||||
traces, self.__t = _make_paper_node_log()
|
||||
self.__dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
|
||||
def test_nodes(self) -> None:
|
||||
"""The DFG has the correct set of nodes."""
|
||||
self.assertEqual(
|
||||
self.__dfg.nodes,
|
||||
set(self.__t.values()),
|
||||
)
|
||||
|
||||
def test_sources_and_sinks(self) -> None:
|
||||
"""The DFG has correct sources and sinks."""
|
||||
self.assertIn(
|
||||
self.__t["a"], self.__dfg.sources
|
||||
)
|
||||
self.assertIn(
|
||||
self.__t["h"], self.__dfg.sinks
|
||||
)
|
||||
|
||||
def test_df_frequencies(self) -> None:
|
||||
"""Selected directly-follows frequencies match."""
|
||||
t: dict[str, Task] = self.__t
|
||||
# a -> b: appears in 6 trace types * 10 = 60
|
||||
self.assertEqual(
|
||||
self.__dfg.df_frequency(
|
||||
t["a"], t["b"]
|
||||
), 60
|
||||
)
|
||||
# a -> c: 2 trace types * 10 = 20
|
||||
self.assertEqual(
|
||||
self.__dfg.df_frequency(
|
||||
t["a"], t["c"]
|
||||
), 20
|
||||
)
|
||||
# a -> d: 2 trace types * 10 = 20
|
||||
self.assertEqual(
|
||||
self.__dfg.df_frequency(
|
||||
t["a"], t["d"]
|
||||
), 20
|
||||
)
|
||||
|
||||
def test_no_self_loops(self) -> None:
|
||||
"""The paper example has no self-loops."""
|
||||
self.assertEqual(self.__dfg.self_loops, set())
|
||||
|
||||
def test_no_short_loops(self) -> None:
|
||||
"""The paper example has no short-loops."""
|
||||
self.assertEqual(
|
||||
self.__dfg.short_loops, set()
|
||||
)
|
||||
|
||||
def test_self_loop_detection(self) -> None:
|
||||
"""Self-loops are correctly detected."""
|
||||
t: dict[str, Task] = _make_tasks("abc")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["a"], t["b"], t["b"], t["c"]): 10,
|
||||
}
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
self.assertIn(t["b"], dfg.self_loops)
|
||||
self.assertNotIn(t["a"], dfg.self_loops)
|
||||
|
||||
def test_self_loop_edges_excluded(self) -> None:
|
||||
"""Self-loop edges are excluded from edges set."""
|
||||
t: dict[str, Task] = _make_tasks("abc")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["a"], t["b"], t["b"], t["c"]): 10,
|
||||
}
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
self.assertNotIn(
|
||||
(t["b"], t["b"]), dfg.edges
|
||||
)
|
||||
self.assertIn(
|
||||
(t["a"], t["b"]), dfg.edges
|
||||
)
|
||||
self.assertIn(
|
||||
(t["b"], t["c"]), dfg.edges
|
||||
)
|
||||
|
||||
def test_short_loop_detection(self) -> None:
|
||||
"""Short-loops are correctly detected."""
|
||||
t: dict[str, Task] = _make_tasks("abcd")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["a"], t["b"], t["c"],
|
||||
t["b"], t["d"]): 10,
|
||||
}
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
self.assertIn(
|
||||
(t["b"], t["c"]), dfg.short_loops
|
||||
)
|
||||
self.assertIn(
|
||||
(t["c"], t["b"]), dfg.short_loops
|
||||
)
|
||||
|
||||
|
||||
class TestConcurrencyDiscovery(unittest.TestCase):
|
||||
"""Tests for concurrency discovery (Section 3.2)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up the test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
traces: dict[tuple[Node, ...], int]
|
||||
traces, self.__t = _make_paper_node_log()
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
self.__pdfg: PrunedDFG = PrunedDFG(
|
||||
dfg, epsilon=0.2
|
||||
)
|
||||
|
||||
def test_concurrent_pairs(self) -> None:
|
||||
"""Correct concurrency relations with epsilon=0.2.
|
||||
|
||||
The paper identifies: b||c, b||d, d||e, e||g.
|
||||
"""
|
||||
t: dict[str, Task] = self.__t
|
||||
# Check expected concurrent pairs
|
||||
self.assertTrue(
|
||||
self.__pdfg.is_concurrent(t["b"], t["c"])
|
||||
)
|
||||
self.assertTrue(
|
||||
self.__pdfg.is_concurrent(t["b"], t["d"])
|
||||
)
|
||||
self.assertTrue(
|
||||
self.__pdfg.is_concurrent(t["d"], t["e"])
|
||||
)
|
||||
self.assertTrue(
|
||||
self.__pdfg.is_concurrent(t["e"], t["g"])
|
||||
)
|
||||
# Non-concurrent pairs
|
||||
self.assertFalse(
|
||||
self.__pdfg.is_concurrent(t["a"], t["b"])
|
||||
)
|
||||
self.assertFalse(
|
||||
self.__pdfg.is_concurrent(t["c"], t["d"])
|
||||
)
|
||||
|
||||
def test_self_loop_skipped_in_concurrency(
|
||||
self,
|
||||
) -> None:
|
||||
"""Self-loop nodes are never concurrent."""
|
||||
# b has a self-loop; a and b could look
|
||||
# concurrent but b should be skipped.
|
||||
t: dict[str, Task] = _make_tasks("abc")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["a"], t["b"], t["b"],
|
||||
t["a"], t["c"]): 10,
|
||||
(t["a"], t["b"],
|
||||
t["a"], t["c"]): 10,
|
||||
}
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
pdfg: PrunedDFG = PrunedDFG(
|
||||
dfg, epsilon=1.0
|
||||
)
|
||||
self.assertFalse(
|
||||
pdfg.is_concurrent(t["a"], t["b"])
|
||||
)
|
||||
|
||||
def test_short_loop_not_concurrent(self) -> None:
|
||||
"""Short-loop pairs are not concurrent.
|
||||
|
||||
Condition 4 prevents short-loop pairs from being
|
||||
declared concurrent.
|
||||
"""
|
||||
t: dict[str, Task] = _make_tasks("abcd")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["a"], t["b"], t["c"],
|
||||
t["b"], t["d"]): 10,
|
||||
(t["a"], t["c"],
|
||||
t["b"], t["d"]): 10,
|
||||
}
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
pdfg: PrunedDFG = PrunedDFG(
|
||||
dfg, epsilon=1.0
|
||||
)
|
||||
self.assertFalse(
|
||||
pdfg.is_concurrent(t["b"], t["c"])
|
||||
)
|
||||
|
||||
|
||||
class TestFiltering(unittest.TestCase):
|
||||
"""Tests for edge filtering (Section 3.3)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up the test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
traces: dict[tuple[Node, ...], int]
|
||||
traces, self.__t = _make_paper_node_log()
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
pdfg: PrunedDFG = PrunedDFG(
|
||||
dfg, epsilon=0.2
|
||||
)
|
||||
self.__fdfg: FilteredDFG = FilteredDFG(
|
||||
pdfg, eta=1.0
|
||||
)
|
||||
|
||||
def test_filtered_edges_retain_best(self) -> None:
|
||||
"""Filtered DFG retains best incoming/outgoing
|
||||
edges.
|
||||
|
||||
Per Table 1 in the paper, edges (e,c) and (c,f)
|
||||
should be dropped.
|
||||
"""
|
||||
t: dict[str, Task] = self.__t
|
||||
edges: set[tuple[Node, Node]] = (
|
||||
self.__fdfg.edges
|
||||
)
|
||||
# These should be retained (best edges)
|
||||
self.assertIn((t["a"], t["b"]), edges)
|
||||
self.assertIn((t["b"], t["e"]), edges)
|
||||
self.assertIn((t["f"], t["g"]), edges)
|
||||
self.assertIn((t["g"], t["h"]), edges)
|
||||
|
||||
def test_sources_and_sinks_preserved(self) -> None:
|
||||
"""Filtering preserves sources and sinks."""
|
||||
t: dict[str, Task] = self.__t
|
||||
self.assertIn(
|
||||
t["a"], self.__fdfg.sources
|
||||
)
|
||||
self.assertIn(
|
||||
t["h"], self.__fdfg.sinks
|
||||
)
|
||||
|
||||
|
||||
class TestEndToEnd(unittest.TestCase):
|
||||
"""End-to-end test for Split Miner."""
|
||||
|
||||
def test_paper_example_basic(self) -> None:
|
||||
"""Split Miner produces a valid BPMN model.
|
||||
|
||||
The discovered model should have start/end events,
|
||||
all 8 tasks, gateways, and proper connectivity.
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = (
|
||||
_make_paper_str_log()
|
||||
)
|
||||
model: BPMNModel = split_miner(
|
||||
traces, epsilon=0.2, eta=0.4
|
||||
)
|
||||
# Has start and end
|
||||
self.assertIsNotNone(model.start)
|
||||
self.assertIsNotNone(model.end)
|
||||
# Has all 8 tasks
|
||||
task_labels: set[str] = {
|
||||
t.label for t in model.tasks.values()
|
||||
if t.label is not None
|
||||
}
|
||||
self.assertEqual(
|
||||
task_labels,
|
||||
{"a", "b", "c", "d", "e", "f", "g", "h"},
|
||||
)
|
||||
# Has edges
|
||||
self.assertGreater(len(model.edges), 0)
|
||||
# Start has outgoing edge
|
||||
self.assertGreater(
|
||||
len(model.outgoing_edges(model.start)), 0
|
||||
)
|
||||
# End has incoming edge
|
||||
self.assertGreater(
|
||||
len(model.incoming_edges(model.end)), 0
|
||||
)
|
||||
|
||||
def test_paper_example_has_gateways(self) -> None:
|
||||
"""The paper example produces split and join
|
||||
gateways.
|
||||
|
||||
Per Fig. 3c, the model should have both XOR and
|
||||
AND gateways (or OR gateways that get minimized).
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = (
|
||||
_make_paper_str_log()
|
||||
)
|
||||
model: BPMNModel = split_miner(
|
||||
traces, epsilon=0.2, eta=0.4
|
||||
)
|
||||
self.assertGreater(len(model.gateways), 0)
|
||||
gw_types: set[GatewayType] = {
|
||||
gw.gateway_type
|
||||
for gw in model.gateways.values()
|
||||
}
|
||||
# Should have at least XOR or AND gateways
|
||||
self.assertTrue(
|
||||
GatewayType.XOR in gw_types
|
||||
or GatewayType.AND in gw_types,
|
||||
f"Expected XOR or AND gateways, "
|
||||
f"got {gw_types}"
|
||||
)
|
||||
|
||||
def test_paper_example_all_tasks_connected(
|
||||
self,
|
||||
) -> None:
|
||||
"""Every task is reachable from start.
|
||||
|
||||
Verifies syntactic correctness: all tasks on a
|
||||
path from start to end.
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = (
|
||||
_make_paper_str_log()
|
||||
)
|
||||
model: BPMNModel = split_miner(
|
||||
traces, epsilon=0.2, eta=0.4
|
||||
)
|
||||
# BFS from start
|
||||
reachable: set[Node] = set()
|
||||
queue: deque[Node] = deque([model.start])
|
||||
while queue:
|
||||
node: Node = queue.popleft()
|
||||
if node in reachable:
|
||||
continue
|
||||
reachable.add(node)
|
||||
for _, succ in model.outgoing_edges(node):
|
||||
queue.append(succ)
|
||||
# All tasks should be reachable
|
||||
for task in model.tasks.values():
|
||||
self.assertIn(
|
||||
task, reachable,
|
||||
f"Task {task.label!r} not reachable "
|
||||
f"from start"
|
||||
)
|
||||
# End should be reachable
|
||||
self.assertIn(model.end, reachable)
|
||||
|
||||
def test_paper_example_all_tasks_reach_end(
|
||||
self,
|
||||
) -> None:
|
||||
"""Every task can reach the end event.
|
||||
|
||||
Verifies syntactic correctness by backward BFS.
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = (
|
||||
_make_paper_str_log()
|
||||
)
|
||||
model: BPMNModel = split_miner(
|
||||
traces, epsilon=0.2, eta=0.4
|
||||
)
|
||||
# Backward BFS from end
|
||||
can_reach_end: set[Node] = set()
|
||||
queue: deque[Node] = deque([model.end])
|
||||
while queue:
|
||||
node: Node = queue.popleft()
|
||||
if node in can_reach_end:
|
||||
continue
|
||||
can_reach_end.add(node)
|
||||
for pred, _ in model.incoming_edges(node):
|
||||
queue.append(pred)
|
||||
# All tasks should reach end
|
||||
for task in model.tasks.values():
|
||||
self.assertIn(
|
||||
task, can_reach_end,
|
||||
f"Task {task.label!r} cannot reach end"
|
||||
)
|
||||
|
||||
def test_simple_sequence(self) -> None:
|
||||
"""A simple sequential log produces no gateways."""
|
||||
traces: dict[tuple[str, ...], int] = {
|
||||
("a", "b", "c"): 10,
|
||||
}
|
||||
model: BPMNModel = split_miner(traces)
|
||||
self.assertEqual(len(model.gateways), 0)
|
||||
self.assertEqual(len(model.tasks), 3)
|
||||
|
||||
def test_simple_xor_choice(self) -> None:
|
||||
"""A log with exclusive choice produces XOR
|
||||
gateways.
|
||||
|
||||
Log: {<a,b,d>^10, <a,c,d>^10}
|
||||
Expected: a -> XOR-split -> {b, c} ->
|
||||
XOR-join -> d
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = {
|
||||
("a", "b", "d"): 10,
|
||||
("a", "c", "d"): 10,
|
||||
}
|
||||
model: BPMNModel = split_miner(
|
||||
traces, epsilon=0.1, eta=0.4
|
||||
)
|
||||
# Should have tasks a, b, c, d
|
||||
task_labels: set[str] = {
|
||||
t.label for t in model.tasks.values()
|
||||
if t.label is not None
|
||||
}
|
||||
self.assertEqual(
|
||||
task_labels, {"a", "b", "c", "d"}
|
||||
)
|
||||
# Should have gateways
|
||||
self.assertGreater(len(model.gateways), 0)
|
||||
# All tasks reachable from start
|
||||
reachable: set[Node] = set()
|
||||
queue: deque[Node] = deque([model.start])
|
||||
while queue:
|
||||
node: Node = queue.popleft()
|
||||
if node in reachable:
|
||||
continue
|
||||
reachable.add(node)
|
||||
for _, s in model.outgoing_edges(node):
|
||||
queue.append(s)
|
||||
for task in model.tasks.values():
|
||||
self.assertIn(task, reachable)
|
||||
|
||||
def test_simple_concurrency(self) -> None:
|
||||
"""A log with concurrency produces AND gateways.
|
||||
|
||||
Log: {<a,b,c,d>^10, <a,c,b,d>^10}
|
||||
b and c are concurrent.
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = {
|
||||
("a", "b", "c", "d"): 10,
|
||||
("a", "c", "b", "d"): 10,
|
||||
}
|
||||
model: BPMNModel = split_miner(
|
||||
traces, epsilon=1.0, eta=0.4
|
||||
)
|
||||
task_labels: set[str] = {
|
||||
t.label for t in model.tasks.values()
|
||||
if t.label is not None
|
||||
}
|
||||
self.assertEqual(
|
||||
task_labels, {"a", "b", "c", "d"}
|
||||
)
|
||||
# Should have AND gateways for b||c
|
||||
and_gws: list[Gateway] = [
|
||||
gw for gw in model.gateways.values()
|
||||
if gw.gateway_type == GatewayType.AND
|
||||
]
|
||||
self.assertGreater(
|
||||
len(and_gws), 0,
|
||||
"Expected AND gateways for concurrent "
|
||||
"b and c"
|
||||
)
|
||||
|
||||
|
||||
class TestSelfLoopHandling(unittest.TestCase):
|
||||
"""Tests for self-loop handling."""
|
||||
|
||||
def test_self_loop_restored(self) -> None:
|
||||
"""Self-loops are restored in the final BPMN model.
|
||||
|
||||
A self-loop on task b should produce XOR-join and
|
||||
XOR-split gateways around b with a back-edge.
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = {
|
||||
("a", "b", "c"): 10,
|
||||
("a", "b", "b", "c"): 10,
|
||||
("a", "b", "b", "b", "c"): 10,
|
||||
}
|
||||
model: BPMNModel = split_miner(traces)
|
||||
# Task b should have a gateway predecessor
|
||||
# and a gateway successor (the self-loop
|
||||
# XOR-join and XOR-split)
|
||||
b: Node = model.get_task("b")
|
||||
b_preds: set[Node] = model.predecessors(b)
|
||||
b_succs: set[Node] = model.successors(b)
|
||||
# b should have exactly 1 predecessor (XOR-join)
|
||||
# and 1 successor (XOR-split)
|
||||
self.assertEqual(len(b_preds), 1)
|
||||
self.assertEqual(len(b_succs), 1)
|
||||
join_node: Node = next(iter(b_preds))
|
||||
split_node: Node = next(iter(b_succs))
|
||||
self.assertIsInstance(join_node, Gateway)
|
||||
self.assertIsInstance(split_node, Gateway)
|
||||
assert isinstance(join_node, Gateway)
|
||||
assert isinstance(split_node, Gateway)
|
||||
self.assertEqual(
|
||||
join_node.gateway_type, GatewayType.XOR
|
||||
)
|
||||
self.assertEqual(
|
||||
split_node.gateway_type, GatewayType.XOR
|
||||
)
|
||||
# Back-edge: split -> join
|
||||
self.assertIn(
|
||||
split_node,
|
||||
model.predecessors(join_node)
|
||||
)
|
||||
|
||||
|
||||
class TestShortLoopHandling(unittest.TestCase):
|
||||
"""Tests for short-loop handling."""
|
||||
|
||||
def test_short_loop_not_concurrent(self) -> None:
|
||||
"""Short-loop pairs are excluded from concurrency.
|
||||
|
||||
If a and b form a short-loop, they must not be
|
||||
declared concurrent even if they appear in both
|
||||
orders.
|
||||
"""
|
||||
# a,b,a pattern = short-loop
|
||||
t: dict[str, Task] = _make_tasks("abxy")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["x"], t["a"], t["b"],
|
||||
t["a"], t["y"]): 10,
|
||||
(t["x"], t["b"], t["a"],
|
||||
t["b"], t["y"]): 10,
|
||||
(t["x"], t["a"], t["y"]): 10,
|
||||
(t["x"], t["b"], t["y"]): 10,
|
||||
}
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
# Should detect short-loop
|
||||
self.assertIn(
|
||||
(t["a"], t["b"]), dfg.short_loops
|
||||
)
|
||||
# Should NOT be concurrent
|
||||
pdfg: PrunedDFG = PrunedDFG(
|
||||
dfg, epsilon=1.0
|
||||
)
|
||||
self.assertFalse(
|
||||
pdfg.is_concurrent(t["a"], t["b"])
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user