# 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()