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