Add split miner implementation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
# Split Miner - BPMN process discovery from event logs.
|
||||
# Authors:
|
||||
# imacat@mail.imacat.idv.tw (imacat), 2026/3/11
|
||||
# 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.
|
||||
"""Tests for real-world event logs.
|
||||
|
||||
Ported from the bpmn project's test_event_log.py.
|
||||
Task labels are anonymized per log-anonymization.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from split_miner import (
|
||||
BPMNModel,
|
||||
Gateway,
|
||||
GatewayType,
|
||||
Task,
|
||||
split_miner,
|
||||
)
|
||||
|
||||
|
||||
def _load_traces(
|
||||
log_file: str,
|
||||
) -> dict[tuple[str, ...], int]:
|
||||
"""Load traces from a JSON log file.
|
||||
|
||||
:param log_file: The path to the log file.
|
||||
:return: The traces as a dict mapping trace tuples
|
||||
to frequencies.
|
||||
"""
|
||||
with open(log_file) as f:
|
||||
raw: list[list] = json.loads(f.read())
|
||||
return {tuple(x[0]): x[1] for x in raw}
|
||||
|
||||
|
||||
def _run_split_miner(
|
||||
log_file: str,
|
||||
epsilon: float = 0.8,
|
||||
eta: float = 0.8,
|
||||
) -> BPMNModel:
|
||||
"""Run split miner on a log file.
|
||||
|
||||
:param log_file: The path to the log file.
|
||||
:param epsilon: The concurrency threshold.
|
||||
:param eta: The filtering percentile.
|
||||
:return: The discovered BPMN model.
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = (
|
||||
_load_traces(log_file)
|
||||
)
|
||||
return split_miner(
|
||||
traces, epsilon=epsilon, eta=eta
|
||||
)
|
||||
|
||||
|
||||
def _count_gateways(
|
||||
model: BPMNModel,
|
||||
gw_type: GatewayType,
|
||||
is_split: bool,
|
||||
) -> int:
|
||||
"""Count gateways of a given type and role.
|
||||
|
||||
:param model: The BPMN model.
|
||||
:param gw_type: The gateway type.
|
||||
:param is_split: True for splits, False for joins.
|
||||
:return: The count.
|
||||
"""
|
||||
result: int = 0
|
||||
for node in model.all_nodes:
|
||||
if not isinstance(node, Gateway):
|
||||
continue
|
||||
if node.gateway_type != gw_type:
|
||||
continue
|
||||
if is_split:
|
||||
if len(model.outgoing_edges(node)) > 1:
|
||||
result += 1
|
||||
else:
|
||||
if len(model.incoming_edges(node)) > 1:
|
||||
result += 1
|
||||
return result
|
||||
|
||||
|
||||
_LOGS_DIR: str = str(
|
||||
Path(__file__).parent / "logs"
|
||||
)
|
||||
|
||||
|
||||
class TestMultiSourceSinkEventLog(unittest.TestCase):
|
||||
"""Tests for multi_source_sink.json (kmu.json)."""
|
||||
|
||||
__LOG_FILE: str = str(
|
||||
Path(_LOGS_DIR) / "multi_source_sink.json"
|
||||
)
|
||||
__TASKS: set[str] = {
|
||||
"A", "B", "C", "D", "E",
|
||||
"F", "G", "H", "I", "J", "K",
|
||||
}
|
||||
|
||||
def test_event_log(self) -> None:
|
||||
"""Tests the event log with default parameters.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
model: BPMNModel = _run_split_miner(
|
||||
self.__LOG_FILE
|
||||
)
|
||||
self.assertEqual(len(model.all_nodes), 21)
|
||||
task_labels: set[str] = {
|
||||
t.label for t in model.tasks.values()
|
||||
if t.label is not None
|
||||
}
|
||||
self.assertEqual(task_labels, self.__TASKS)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.XOR, True
|
||||
), 4
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.AND, True
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.OR, True
|
||||
), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.XOR, False
|
||||
), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.AND, False
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.OR, False
|
||||
), 2
|
||||
)
|
||||
self.assertEqual(len(model.edges), 30)
|
||||
|
||||
def test_event_log_eta_0(self) -> None:
|
||||
"""Tests the event log with eta=0.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
model: BPMNModel = _run_split_miner(
|
||||
self.__LOG_FILE, eta=0
|
||||
)
|
||||
task_labels: set[str] = {
|
||||
t.label for t in model.tasks.values()
|
||||
if t.label is not None
|
||||
}
|
||||
self.assertEqual(task_labels, self.__TASKS)
|
||||
self.assertEqual(len(model.all_nodes), 42)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.XOR, True
|
||||
), 16
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.AND, True
|
||||
), 2
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.OR, True
|
||||
), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.XOR, False
|
||||
), 9
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.AND, False
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.OR, False
|
||||
), 1
|
||||
)
|
||||
self.assertEqual(len(model.edges), 78)
|
||||
|
||||
def test_event_log_eta_1(self) -> None:
|
||||
"""Tests the event log with eta=1.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
model: BPMNModel = _run_split_miner(
|
||||
self.__LOG_FILE, eta=1
|
||||
)
|
||||
task_labels: set[str] = {
|
||||
t.label for t in model.tasks.values()
|
||||
if t.label is not None
|
||||
}
|
||||
self.assertEqual(task_labels, self.__TASKS)
|
||||
self.assertEqual(len(model.all_nodes), 21)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.XOR, True
|
||||
), 4
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.AND, True
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.OR, True
|
||||
), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.XOR, False
|
||||
), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.AND, False
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.OR, False
|
||||
), 2
|
||||
)
|
||||
self.assertEqual(len(model.edges), 30)
|
||||
|
||||
|
||||
class TestCyclicTraceEventLog(unittest.TestCase):
|
||||
"""Tests for cyclic_trace.json (ntphrf.json)."""
|
||||
|
||||
__LOG_FILE: str = str(
|
||||
Path(_LOGS_DIR) / "cyclic_trace.json"
|
||||
)
|
||||
__TASKS: set[str] = {
|
||||
"A", "B", "C", "D", "E",
|
||||
"F", "G", "H", "I", "J",
|
||||
}
|
||||
|
||||
def test_event_log(self) -> None:
|
||||
"""Tests the event log with default parameters.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
model: BPMNModel = _run_split_miner(
|
||||
self.__LOG_FILE
|
||||
)
|
||||
task_labels: set[str] = {
|
||||
t.label for t in model.tasks.values()
|
||||
if t.label is not None
|
||||
}
|
||||
self.assertEqual(task_labels, self.__TASKS)
|
||||
self.assertEqual(len(model.all_nodes), 14)
|
||||
self.assertEqual(len(model.edges), 14)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.XOR, True
|
||||
), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.AND, True
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.OR, True
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.XOR, False
|
||||
), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.AND, False
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.OR, False
|
||||
), 0
|
||||
)
|
||||
|
||||
|
||||
class TestMultiSinkEventLog(unittest.TestCase):
|
||||
"""Tests for multi_sink.json (ntp_job403.json)."""
|
||||
|
||||
__LOG_FILE: str = str(
|
||||
Path(_LOGS_DIR) / "multi_sink.json"
|
||||
)
|
||||
__TASKS: set[str] = {
|
||||
"A", "B", "C", "D", "E",
|
||||
"F", "G", "H", "I", "J", "K",
|
||||
}
|
||||
|
||||
def test_event_log(self) -> None:
|
||||
"""Tests the event log with default parameters.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
model: BPMNModel = _run_split_miner(
|
||||
self.__LOG_FILE
|
||||
)
|
||||
task_labels: set[str] = {
|
||||
t.label for t in model.tasks.values()
|
||||
if t.label is not None
|
||||
}
|
||||
self.assertEqual(task_labels, self.__TASKS)
|
||||
self.assertEqual(len(model.all_nodes), 15)
|
||||
self.assertEqual(len(model.edges), 16)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.XOR, True
|
||||
), 2
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.AND, True
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.OR, True
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.XOR, False
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.AND, False
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.OR, False
|
||||
), 0
|
||||
)
|
||||
|
||||
|
||||
class TestShortLoopsEventLog(unittest.TestCase):
|
||||
"""Tests for short_loops.json (lottery.json)."""
|
||||
|
||||
__LOG_FILE: str = str(
|
||||
Path(_LOGS_DIR) / "short_loops.json"
|
||||
)
|
||||
__TASKS: set[str] = {
|
||||
"A", "B", "C", "D", "E", "F",
|
||||
"G", "H", "I", "J", "K", "L",
|
||||
}
|
||||
|
||||
def test_event_log(self) -> None:
|
||||
"""Tests the event log with default parameters.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
model: BPMNModel = _run_split_miner(
|
||||
self.__LOG_FILE
|
||||
)
|
||||
task_labels: set[str] = {
|
||||
t.label for t in model.tasks.values()
|
||||
if t.label is not None
|
||||
}
|
||||
self.assertEqual(task_labels, self.__TASKS)
|
||||
self.assertEqual(len(model.all_nodes), 21)
|
||||
self.assertEqual(len(model.edges), 24)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.XOR, True
|
||||
), 4
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.AND, True
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.OR, True
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.XOR, False
|
||||
), 3
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.AND, False
|
||||
), 0
|
||||
)
|
||||
self.assertEqual(
|
||||
_count_gateways(
|
||||
model, GatewayType.OR, False
|
||||
), 0
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user