Add split miner implementation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 07:03:52 +08:00
co-authored by Claude Opus 4.6
parent 94c97629de
commit 446e6f90fe
27 changed files with 6281 additions and 0 deletions
+247
View File
@@ -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()