Files
split-miner/src/split_miner/refined_dfg.py
T
imacatandClaude Opus 5 d35d866b4b Add Split Miner 2.0 implementation
Adds the Split Miner 2.0 pipeline (Augusto, Dumas & La Rosa,
2021) alongside the existing 1.0 implementation:

- refined_dfg: refined DFG from activity lifecycle events
  (Definition 6)
- refined_concurrency: true concurrency from lifecycle
  overlap (Equation 5)
- heuristics: fix improper completion from AND-split
  loop-edges, and detect OR-splits from mutual
  exclusiveness (Section 3.3)
- miner.split_miner_2: the 2.0 entry point, reusing the 1.0
  filtering, splits, joins and OR-join minimization steps

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-03-12 12:36:37 +08:00

241 lines
7.3 KiB
Python

# Split Miner - BPMN process discovery from event logs.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/3/12
# 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.
"""Refined Directly-Follows Graph (SM 2.0, Definition 6).
Uses activity lifecycle (start/end) events to build the
directly-follows relation: activity ay directly-follows ax
iff ay starts after ax ends with no other end events in
between.
Reference:
Augusto, A., Dumas, M., & La Rosa, M. (2021).
Automated Discovery of Process Models with True
Concurrency and Inclusive Choices. Section 3.1,
Definition 6.
"""
from __future__ import annotations
from split_miner.bpmn import Node
class RefinedDirectlyFollowsGraph:
"""A refined DFG using activity lifecycle events.
Built from lifecycle-aware traces per Definition 6 in the
SM 2.0 paper. Each trace event is a (Node, lifecycle)
pair where lifecycle is ``"start"`` or ``"end"``.
:param traces: The event log as a dict mapping each
lifecycle trace to its frequency.
"""
def __init__(
self,
traces: dict[
tuple[tuple[Node, str], ...], int
],
) -> None:
"""Build a refined DFG from lifecycle traces.
:param traces: The event log as a dict mapping
each lifecycle trace to its frequency.
"""
self.__nodes: set[Node] = set()
self.__sources: set[Node] = set()
self.__sinks: set[Node] = set()
self.__df_freq: dict[
tuple[Node, Node], int
] = {}
self.__self_loops: set[Node] = set()
self.__build(traces)
def __build(
self,
traces: dict[
tuple[tuple[Node, str], ...], int
],
) -> None:
"""Build the refined DFG from lifecycle traces.
For each trace, scan for end events. After each
end event, collect all start events that occur
before the next end event. These form the
directly-follows pairs per Definition 6.
:param traces: The event log.
"""
for trace, count in traces.items():
if not trace:
continue
# Collect nodes and find sources/sinks.
activities: set[Node] = set()
for node, _ in trace:
activities.add(node)
self.__nodes |= activities
# Source: first activity to start.
for node, lifecycle in trace:
if lifecycle == "start":
self.__sources.add(node)
break
# Sink: last activity to end.
for node, lifecycle in reversed(trace):
if lifecycle == "end":
self.__sinks.add(node)
break
# Detect self-loops: activity with multiple
# complete lifecycles in a trace.
end_counts: dict[Node, int] = {}
for node, lifecycle in trace:
if lifecycle == "end":
end_counts[node] = (
end_counts.get(node, 0) + 1
)
for node, cnt in end_counts.items():
if cnt > 1:
self.__self_loops.add(node)
# Definition 6: ax ->r ay iff ay starts
# after ax ends with no other end event
# between.
self.__scan_trace(trace, count)
def __scan_trace(
self,
trace: tuple[tuple[Node, str], ...],
count: int,
) -> None:
"""Scan a single trace for refined DF relations.
Walk through events. When we see an end event
for activity ax, record ax as a "pending source".
When we see a start event for ay, create edges
from all pending sources to ay. When we see
another end event, clear all pending sources
(since the new end event is "between").
:param trace: The lifecycle trace.
:param count: The trace frequency.
"""
pending: set[Node] = set()
for node, lifecycle in trace:
if lifecycle == "end":
# A new end event clears previous
# pending sources (they now have an
# end event between them and any
# future start).
pending.clear()
pending.add(node)
elif lifecycle == "start":
# All pending sources directly-follow
# to this activity.
for src in pending:
if src != node:
pair: tuple[Node, Node] = (
src, node
)
self.__df_freq[pair] = (
self.__df_freq.get(
pair, 0
)
+ count
)
@property
def nodes(self) -> set[Node]:
"""The set of nodes.
:return: The nodes.
"""
return set(self.__nodes)
@property
def edges(self) -> set[tuple[Node, Node]]:
"""The set of edges with positive frequency.
:return: The edges.
"""
return {
(a, b) for (a, b), freq
in self.__df_freq.items()
if freq > 0
}
def df_frequency(
self, a: Node, b: Node
) -> int:
"""Return the directly-follows frequency.
:param a: The source node.
:param b: The target node.
:return: The frequency.
"""
return self.__df_freq.get((a, b), 0)
@property
def self_loops(self) -> set[Node]:
"""The set of self-loop nodes.
An activity is a self-loop if it completes
(has an end event) more than once in any trace.
:return: The self-loop nodes.
"""
return set(self.__self_loops)
@property
def sources(self) -> set[Node]:
"""The source nodes (first to start in traces).
:return: The source nodes.
"""
return set(self.__sources)
@property
def sinks(self) -> set[Node]:
"""The sink nodes (last to end in traces).
:return: The sink nodes.
"""
return set(self.__sinks)
def outgoing(
self, node: Node
) -> set[tuple[Node, Node]]:
"""Return the outgoing edges of a node.
:param node: The node.
:return: The outgoing edges.
"""
return {
(a, b) for (a, b) in self.edges
if a == node
}
def incoming(
self, node: Node
) -> set[tuple[Node, Node]]:
"""Return the incoming edges of a node.
:param node: The node.
:return: The incoming edges.
"""
return {
(a, b) for (a, b) in self.edges
if b == node
}