Add split miner implementation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user