Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion pm4py/algo/discovery/split_miner/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
dfg_discovery,
dtypes,
filtering,
heuristics,
joins,
or_min,
splits,
Expand Down
31 changes: 26 additions & 5 deletions pm4py/algo/discovery/split_miner/algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,29 @@
Two variants are exposed:

* :data:`CLASSIC` — the classic Split Miner pipeline.
* :data:`SM2` — Split Miner 2.0, with a lifecycle-aware refined DFG,
a lifecycle-overlap concurrency oracle, and two heuristics for
improper-completion repair and OR-split identification.
* :data:`SM2` — Split Miner 2.0: the same machinery driven by a
lifecycle-aware ``complete``-event DFG, an overlap-based concurrency
oracle for genuine lifecycle logs, a fixed frequency threshold
(``eta = 1.0``), inclusive joins left in place (``replaceIORs = false``)
plus the OR-split heuristic, and compact (marked) self-loops.

Both variants return a :class:`pm4py.objects.bpmn.obj.BPMN`.

Faithfulness and validation
---------------------------
Every stage (DFG filtering, concurrency oracle, Oracle split discovery,
RPST-based SESE join discovery, OR-join replacement, gateway collapse)
is a port of the reference Java Split Miner, intended to reproduce its
output exactly rather than the idealised figures in the papers. The
ports were validated against the original Java tools on the SM-Experiment
corpus: classic Split Miner is byte-identical (isomorphic, same gateway
labels) to ``splitminer.jar`` on the deterministic real-life logs, and
SM2 is byte-identical to ``sm2.jar`` (``MineWithSMTC``) on 9 of 10
reference logs (the tenth is non-deterministic in the Java tool itself).
Because the target is the *tool*, not the papers, some hand-computed
gateway counts from earlier tests changed: e.g. on the Augusto et al.
(2019) running example the tool yields 2 AND-splits / 4 XOR-splits, not
the 1 / 3 from the paper.
"""
from enum import Enum
from typing import Any, Dict, Optional, Tuple, Union
Expand Down Expand Up @@ -69,8 +87,11 @@ def apply(
or a precomputed DFG (only accepted by the classic variant).
parameters
Variant-specific parameters; see ``classic.Parameters`` and
``sm2.Parameters`` for the supported keys (``EPSILON``, ``ETA``,
``OR_MINIMISE``, ``ACTIVITY_KEY``, …).
``sm2.Parameters`` for the supported keys. The classic variant
honors ``EPSILON``, ``ETA``, ``OR_MINIMISE`` and
``ACTIVITY_KEY``; the SM2 variant honors ``EPSILON``,
``ACTIVITY_KEY`` and ``TIMESTAMP_KEY`` and ignores ``ETA`` and
``OR_MINIMISE`` (pinned to the reference tool's fixed values).
variant
Either :data:`CLASSIC` (default) or :data:`SM2`.
"""
Expand Down
2 changes: 1 addition & 1 deletion pm4py/algo/discovery/split_miner/bpmn_export/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@
Website: https://processintelligence.solutions
Contact: info@processintelligence.solutions
'''
from pm4py.algo.discovery.split_miner.bpmn_export import abc, classic
from pm4py.algo.discovery.split_miner.bpmn_export import abc, classic, lifecycle
68 changes: 68 additions & 0 deletions pm4py/algo/discovery/split_miner/bpmn_export/lifecycle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'''
PM4Py – A Process Mining Library for Python
Copyright (C) 2026 Process Intelligence Solutions GmbH

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see this software project's root or
visit <https://www.gnu.org/licenses/>.

Website: https://processintelligence.solutions
Contact: info@processintelligence.solutions
'''
"""BPMN export for Split Miner 2.0.

Differs from the classic exporter in how level-1 (self) loops are
rendered. Classic Split Miner expands every self-looping task into an
explicit XOR-join / XOR-split pair with a back edge; Split Miner 2.0
keeps the task compact and marks it as a looping activity (the Java side
attaches ``standardLoopCharacteristics``), adding no extra gateways.
"""
from typing import Any, Dict, Optional

from pm4py.algo.discovery.split_miner.bpmn_export.abc import BPMNExporter
from pm4py.algo.discovery.split_miner.bpmn_export.classic import _make_node
from pm4py.algo.discovery.split_miner.dtypes.log import END_LABEL, START_LABEL
from pm4py.algo.discovery.split_miner.dtypes.working_graph import WorkingGraph
from pm4py.objects.bpmn.obj import BPMN


class LifecycleBPMNExporter(BPMNExporter):
"""Materialize the BPMN, keeping self-loops as marked activities."""

@classmethod
def apply(
cls,
wg: WorkingGraph,
parameters: Optional[Dict[str, Any]] = None,
) -> BPMN:
bpmn = BPMN()
node_map: Dict[str, BPMN.BPMNNode] = {}
for nid, n in wg.nodes.items():
bnode = _make_node(n.kind, n.label, nid)
bpmn.add_node(bnode)
node_map[nid] = bnode

for src, tgt in wg.edges():
bpmn.add_flow(BPMN.SequenceFlow(node_map[src], node_map[tgt]))

# Mark self-looping tasks (no XOR expansion). The Java side
# serializes this as ``standardLoopCharacteristics``; pm4py's
# BPMN exporter has no equivalent, so the attribute is purely
# informational for downstream consumers of the BPMN object.
for task_id in wg.self_loops:
if task_id in {START_LABEL, END_LABEL}:
continue
node = node_map.get(task_id)
if node is not None:
setattr(node, "_sm_looped", True)
return bpmn
2 changes: 1 addition & 1 deletion pm4py/algo/discovery/split_miner/concurrency/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@
Website: https://processintelligence.solutions
Contact: info@processintelligence.solutions
'''
from pm4py.algo.discovery.split_miner.concurrency import abc, classic, refined
from pm4py.algo.discovery.split_miner.concurrency import abc, classic, lifecycle
125 changes: 98 additions & 27 deletions pm4py/algo/discovery/split_miner/concurrency/classic.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,22 @@
'''
"""Classic Split Miner concurrency oracle.

Two activities are flagged as concurrent when they appear as ``a -> b``
and ``b -> a`` in the DFG with roughly balanced frequencies, are not a
short-loop pair, and neither is a self-loop. Imbalanced bidirectional
pairs keep only the more frequent direction.
Two activities ``a`` and ``b`` are concurrent when they appear as both
``a -> b`` and ``b -> a`` in the DFG, are not a short-loop pair, and the
bidirectional frequencies are roughly balanced (imbalance under epsilon).

When an imbalanced bidirectional pair is detected the less frequent
direction is dropped instead and concurrency is *not* recorded.

Both pruning operations are performed greedily in ascending-frequency
order and respect a connectedness invariant inherited from the
reference Java implementation: an edge is only dropped when its source
retains another outgoing edge *and* its target retains another incoming
edge in the pruned DFG. When the favoured direction cannot be dropped
the algorithm falls back to dropping the opposite direction (if that is
itself removable) and demotes the pair from concurrent to sequential,
mirroring the behaviour of ``DirectlyFollowGraphPlus.removeEdge`` with
``ensureConnectedness=true``.
"""
from enum import Enum
from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple
Expand All @@ -46,11 +58,8 @@ class Parameters(Enum):


class ClassicConcurrencyOracle(ConcurrencyOracle):
"""Three-condition test on directly-follows frequencies.

The imbalance condition uses ``<= eps`` rather than ``< eps`` to
mirror the Java reference implementation; with strict ``<`` the
boundary case at exactly ``eps`` is missed.
"""Imbalance test on directly-follows frequencies, with a
connectedness guard that prevents pruning from isolating any task.
"""

@classmethod
Expand All @@ -65,11 +74,34 @@ def apply(
Parameters.EPSILON, parameters or {}, DEFAULT_EPSILON
)

concurrent: Set[FrozenSet[str]] = set()
drop_infrequent: Set[Tuple[str, str]] = set()
# Live in/out degree per node in the working DFG. The oracle
# mutates these counters as it drops edges so the connectedness
# guard reflects intermediate state, exactly like Java's
# incomings/outgoings maps.
out_count: Dict[str, int] = {}
in_count: Dict[str, int] = {}
for (a, b) in dfg.keys():
out_count[a] = out_count.get(a, 0) + 1
in_count[b] = in_count.get(b, 0) + 1

# Candidate drops, paired with their frequencies. For every
# bidirectional pair both directions are queued when the pair is
# balanced enough to be concurrent; when imbalanced only the
# weaker direction is queued (and concurrency is not asserted).
# ``pair_kind`` records whether each queued drop belongs to a
# concurrent pair (``"par"``) or an imbalance demotion
# (``"imb"``); this drives the bookkeeping that demotes
# concurrency back to a sequence arc when a removal is vetoed by
# the connectedness guard.
candidates: List[Tuple[Tuple[str, str], int]] = []
pair_kind: Dict[Tuple[str, str], str] = {}
concurrent_pairs: Set[FrozenSet[str]] = set()
seen: Set[FrozenSet[str]] = set()

for (a, b), f_ab in list(dfg.items()):
# Deterministic iteration: sorting up front keeps the set of
# candidate pairs identical across runs regardless of dict
# insertion order.
for (a, b), f_ab in sorted(dfg.items()):
if a == b:
continue
pair = frozenset((a, b))
Expand All @@ -84,23 +116,62 @@ def apply(
continue

denom = f_ab + f_ba
if denom == 0:
continue
imbalance = abs(f_ab - f_ba) / denom

if imbalance <= eps:
concurrent.add(pair)
# Reference uses a strict comparison (Math.abs(score) <
# parallelismsThreshold); a pair whose imbalance is exactly
# epsilon is NOT concurrent.
if imbalance < eps:
concurrent_pairs.add(pair)
candidates.append(((a, b), f_ab))
candidates.append(((b, a), f_ba))
pair_kind[(a, b)] = "par"
pair_kind[(b, a)] = "par"
else:
if f_ab < f_ba:
drop_infrequent.add((a, b))
else:
drop_infrequent.add((b, a))

pdfg: DFG = {}
for (a, b), f in dfg.items():
if frozenset((a, b)) in concurrent:
drop = (a, b) if f_ab < f_ba else (b, a)
drop_f = min(f_ab, f_ba)
candidates.append((drop, drop_f))
pair_kind[drop] = "imb"

# Process candidate drops in ascending-frequency order so the
# least informative arcs are challenged by the guard first
# (matching Collections.sort on Java's DFGEdge).
candidates.sort(key=lambda x: (x[1], x[0]))

to_drop: Set[Tuple[str, str]] = set()

def _can_drop(edge: Tuple[str, str]) -> bool:
a, b = edge
return out_count.get(a, 0) > 1 and in_count.get(b, 0) > 1

def _do_drop(edge: Tuple[str, str]) -> None:
a, b = edge
to_drop.add(edge)
out_count[a] -= 1
in_count[b] -= 1

for edge, _f in candidates:
if edge in to_drop:
continue
if (a, b) in drop_infrequent:
if _can_drop(edge):
_do_drop(edge)
continue
pdfg[(a, b)] = f
return ConcurrencyResult(pdfg=pdfg, concurrent_pairs=concurrent)
# Connectedness guard vetoed the drop. For concurrent pairs
# this demotes the pair to a sequence arc: discard the pair
# from ``concurrent_pairs`` and instead try to drop the
# reverse direction (if it has not been dropped already and
# the guard allows it).
a, b = edge
if pair_kind.get(edge) == "par":
concurrent_pairs.discard(frozenset((a, b)))
reverse = (b, a)
if reverse in dfg and reverse not in to_drop and _can_drop(reverse):
_do_drop(reverse)
# Imbalance drops vetoed by the guard simply stay in the
# DFG; Java behaves the same way (the edge survives because
# removing it would orphan a node).

pdfg: DFG = {
(a, b): f for (a, b), f in dfg.items() if (a, b) not in to_drop
}
return ConcurrencyResult(pdfg=pdfg, concurrent_pairs=concurrent_pairs)
Loading
Loading