264 lines
7.7 KiB
Python
264 lines
7.7 KiB
Python
# 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()
|