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