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