Add split miner implementation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
# 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.
|
||||
"""Detailed pipeline stage tests for the paper example.
|
||||
|
||||
Verifies each stage of the Split Miner pipeline using the
|
||||
running example from Section 3 of the SM 1.0 paper.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from split_miner import (
|
||||
BPMNModel,
|
||||
Gateway,
|
||||
GatewayType,
|
||||
Node,
|
||||
Task,
|
||||
split_miner,
|
||||
)
|
||||
from split_miner.concurrency import PrunedDFG
|
||||
from split_miner.dfg import DirectlyFollowsGraph
|
||||
from split_miner.filtering import FilteredDFG
|
||||
|
||||
|
||||
def _make_tasks(
|
||||
labels: str,
|
||||
) -> dict[str, Task]:
|
||||
"""Create a Task for each single-character label.
|
||||
|
||||
:param labels: The labels as a string.
|
||||
:return: A dict mapping label to Task.
|
||||
"""
|
||||
return {ch: Task(ch, ch) for ch in labels}
|
||||
|
||||
|
||||
def _make_paper_node_log() -> tuple[
|
||||
dict[tuple[Node, ...], int], dict[str, Task]
|
||||
]:
|
||||
"""Build the paper example log with Node objects.
|
||||
|
||||
:return: The Node-based traces and the task map.
|
||||
"""
|
||||
t: dict[str, Task] = _make_tasks("abcdefgh")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["a"], t["b"], t["c"], t["g"],
|
||||
t["e"], t["h"]): 10,
|
||||
(t["a"], t["b"], t["c"], t["f"],
|
||||
t["g"], t["h"]): 10,
|
||||
(t["a"], t["b"], t["d"], t["g"],
|
||||
t["e"], t["h"]): 10,
|
||||
(t["a"], t["b"], t["d"], t["e"],
|
||||
t["g"], t["h"]): 10,
|
||||
(t["a"], t["b"], t["e"], t["c"],
|
||||
t["g"], t["h"]): 10,
|
||||
(t["a"], t["b"], t["e"], t["d"],
|
||||
t["g"], t["h"]): 10,
|
||||
(t["a"], t["c"], t["b"], t["e"],
|
||||
t["g"], t["h"]): 10,
|
||||
(t["a"], t["c"], t["b"], t["f"],
|
||||
t["g"], t["h"]): 10,
|
||||
(t["a"], t["d"], t["b"], t["e"],
|
||||
t["g"], t["h"]): 10,
|
||||
(t["a"], t["d"], t["b"], t["f"],
|
||||
t["g"], t["h"]): 10,
|
||||
}
|
||||
return traces, t
|
||||
|
||||
|
||||
def _make_paper_str_log() -> dict[
|
||||
tuple[str, ...], int
|
||||
]:
|
||||
"""Build the paper example log with string labels.
|
||||
|
||||
:return: The string-based traces.
|
||||
"""
|
||||
return {
|
||||
("a", "b", "c", "g", "e", "h"): 10,
|
||||
("a", "b", "c", "f", "g", "h"): 10,
|
||||
("a", "b", "d", "g", "e", "h"): 10,
|
||||
("a", "b", "d", "e", "g", "h"): 10,
|
||||
("a", "b", "e", "c", "g", "h"): 10,
|
||||
("a", "b", "e", "d", "g", "h"): 10,
|
||||
("a", "c", "b", "e", "g", "h"): 10,
|
||||
("a", "c", "b", "f", "g", "h"): 10,
|
||||
("a", "d", "b", "e", "g", "h"): 10,
|
||||
("a", "d", "b", "f", "g", "h"): 10,
|
||||
}
|
||||
|
||||
|
||||
class TestDFGAllEdges(unittest.TestCase):
|
||||
"""Tests for all DFG edge frequencies (Table 1)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up the test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
traces: dict[tuple[Node, ...], int]
|
||||
traces, self.__t = _make_paper_node_log()
|
||||
self.__dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
|
||||
def test_all_edge_frequencies(self) -> None:
|
||||
"""All 20 DFG edge frequencies match Table 1.
|
||||
|
||||
Verifies every directly-follows frequency from the
|
||||
paper's example event log.
|
||||
"""
|
||||
t: dict[str, Task] = self.__t
|
||||
expected: dict[tuple[Node, Node], int] = {
|
||||
(t["a"], t["b"]): 60,
|
||||
(t["a"], t["c"]): 20,
|
||||
(t["a"], t["d"]): 20,
|
||||
(t["b"], t["c"]): 20,
|
||||
(t["b"], t["d"]): 20,
|
||||
(t["b"], t["e"]): 40,
|
||||
(t["b"], t["f"]): 20,
|
||||
(t["c"], t["b"]): 20,
|
||||
(t["c"], t["f"]): 10,
|
||||
(t["c"], t["g"]): 20,
|
||||
(t["d"], t["b"]): 20,
|
||||
(t["d"], t["e"]): 10,
|
||||
(t["d"], t["g"]): 20,
|
||||
(t["e"], t["c"]): 10,
|
||||
(t["e"], t["d"]): 10,
|
||||
(t["e"], t["g"]): 30,
|
||||
(t["e"], t["h"]): 20,
|
||||
(t["f"], t["g"]): 30,
|
||||
(t["g"], t["e"]): 20,
|
||||
(t["g"], t["h"]): 80,
|
||||
}
|
||||
for (src, tgt), freq in expected.items():
|
||||
self.assertEqual(
|
||||
self.__dfg.df_frequency(src, tgt),
|
||||
freq,
|
||||
f"|{src.node_id} -> {tgt.node_id}|"
|
||||
f" should be {freq}"
|
||||
)
|
||||
|
||||
def test_all_edges(self) -> None:
|
||||
"""The DFG has exactly 20 edges."""
|
||||
t: dict[str, Task] = self.__t
|
||||
expected: set[tuple[Node, Node]] = {
|
||||
(t["a"], t["b"]), (t["a"], t["c"]),
|
||||
(t["a"], t["d"]),
|
||||
(t["b"], t["c"]), (t["b"], t["d"]),
|
||||
(t["b"], t["e"]), (t["b"], t["f"]),
|
||||
(t["c"], t["b"]), (t["c"], t["f"]),
|
||||
(t["c"], t["g"]),
|
||||
(t["d"], t["b"]), (t["d"], t["e"]),
|
||||
(t["d"], t["g"]),
|
||||
(t["e"], t["c"]), (t["e"], t["d"]),
|
||||
(t["e"], t["g"]), (t["e"], t["h"]),
|
||||
(t["f"], t["g"]),
|
||||
(t["g"], t["e"]), (t["g"], t["h"]),
|
||||
}
|
||||
self.assertEqual(self.__dfg.edges, expected)
|
||||
|
||||
def test_edge_count(self) -> None:
|
||||
"""The DFG has 20 edges."""
|
||||
self.assertEqual(len(self.__dfg.edges), 20)
|
||||
|
||||
|
||||
class TestPrunedDFGEdges(unittest.TestCase):
|
||||
"""Tests for PDFG edge set (Section 3.2)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up the test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
traces: dict[tuple[Node, ...], int]
|
||||
traces, self.__t = _make_paper_node_log()
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
self.__pdfg: PrunedDFG = PrunedDFG(
|
||||
dfg, epsilon=0.2
|
||||
)
|
||||
|
||||
def test_pdfg_edges(self) -> None:
|
||||
"""PDFG has 12 edges after concurrent pruning.
|
||||
|
||||
Concurrent pairs b||c, b||d, d||e, e||g are
|
||||
removed along with their reverse edges.
|
||||
"""
|
||||
t: dict[str, Task] = self.__t
|
||||
expected: set[tuple[Node, Node]] = {
|
||||
(t["a"], t["b"]), (t["a"], t["c"]),
|
||||
(t["a"], t["d"]),
|
||||
(t["b"], t["e"]), (t["b"], t["f"]),
|
||||
(t["c"], t["f"]), (t["c"], t["g"]),
|
||||
(t["d"], t["g"]),
|
||||
(t["e"], t["c"]), (t["e"], t["h"]),
|
||||
(t["f"], t["g"]),
|
||||
(t["g"], t["h"]),
|
||||
}
|
||||
self.assertEqual(self.__pdfg.edges, expected)
|
||||
|
||||
def test_pdfg_edge_count(self) -> None:
|
||||
"""PDFG has 12 edges."""
|
||||
self.assertEqual(len(self.__pdfg.edges), 12)
|
||||
|
||||
def test_concurrent_pairs(self) -> None:
|
||||
"""All four concurrent pairs are detected."""
|
||||
t: dict[str, Task] = self.__t
|
||||
self.assertTrue(
|
||||
self.__pdfg.is_concurrent(t["b"], t["c"])
|
||||
)
|
||||
self.assertTrue(
|
||||
self.__pdfg.is_concurrent(t["b"], t["d"])
|
||||
)
|
||||
self.assertTrue(
|
||||
self.__pdfg.is_concurrent(t["d"], t["e"])
|
||||
)
|
||||
self.assertTrue(
|
||||
self.__pdfg.is_concurrent(t["e"], t["g"])
|
||||
)
|
||||
|
||||
def test_not_concurrent(self) -> None:
|
||||
"""Non-concurrent pairs."""
|
||||
t: dict[str, Task] = self.__t
|
||||
self.assertFalse(
|
||||
self.__pdfg.is_concurrent(t["a"], t["b"])
|
||||
)
|
||||
self.assertFalse(
|
||||
self.__pdfg.is_concurrent(t["c"], t["d"])
|
||||
)
|
||||
self.assertFalse(
|
||||
self.__pdfg.is_concurrent(t["c"], t["f"])
|
||||
)
|
||||
|
||||
|
||||
class TestFilteredDFGEdges(unittest.TestCase):
|
||||
"""Tests for filtered PDFG edge set (Section 3.3)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up the test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
traces: dict[tuple[Node, ...], int]
|
||||
traces, self.__t = _make_paper_node_log()
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
pdfg: PrunedDFG = PrunedDFG(dfg, epsilon=0.2)
|
||||
self.__fdfg: FilteredDFG = FilteredDFG(
|
||||
pdfg, eta=0.4
|
||||
)
|
||||
|
||||
def test_filtered_edges(self) -> None:
|
||||
"""Filtered PDFG has 10 edges.
|
||||
|
||||
Edges c->f and e->c are filtered out.
|
||||
"""
|
||||
t: dict[str, Task] = self.__t
|
||||
expected: set[tuple[Node, Node]] = {
|
||||
(t["a"], t["b"]), (t["a"], t["c"]),
|
||||
(t["a"], t["d"]),
|
||||
(t["b"], t["e"]), (t["b"], t["f"]),
|
||||
(t["c"], t["g"]),
|
||||
(t["d"], t["g"]),
|
||||
(t["e"], t["h"]),
|
||||
(t["f"], t["g"]),
|
||||
(t["g"], t["h"]),
|
||||
}
|
||||
self.assertEqual(self.__fdfg.edges, expected)
|
||||
|
||||
def test_filtered_edge_count(self) -> None:
|
||||
"""Filtered PDFG has 10 edges."""
|
||||
self.assertEqual(len(self.__fdfg.edges), 10)
|
||||
|
||||
def test_removed_edges(self) -> None:
|
||||
"""Edges c->f and e->c are not in filtered PDFG."""
|
||||
t: dict[str, Task] = self.__t
|
||||
self.assertNotIn(
|
||||
(t["c"], t["f"]), self.__fdfg.edges
|
||||
)
|
||||
self.assertNotIn(
|
||||
(t["e"], t["c"]), self.__fdfg.edges
|
||||
)
|
||||
|
||||
|
||||
class TestPaperExampleStructure(unittest.TestCase):
|
||||
"""Tests for the final paper example structure."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up the test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = (
|
||||
_make_paper_str_log()
|
||||
)
|
||||
self.__model: BPMNModel = split_miner(
|
||||
traces, epsilon=0.2, eta=0.4
|
||||
)
|
||||
|
||||
def test_node_count(self) -> None:
|
||||
"""The final model has 16 nodes."""
|
||||
self.assertEqual(
|
||||
len(self.__model.all_nodes), 16
|
||||
)
|
||||
|
||||
def test_edge_count(self) -> None:
|
||||
"""The final model has 18 edges."""
|
||||
self.assertEqual(len(self.__model.edges), 18)
|
||||
|
||||
def test_gateway_counts(self) -> None:
|
||||
"""6 gateways: AND=1, XOR=4, OR=1."""
|
||||
gw_types: list[GatewayType] = [
|
||||
gw.gateway_type
|
||||
for gw in self.__model.gateways.values()
|
||||
]
|
||||
self.assertEqual(len(gw_types), 6)
|
||||
self.assertEqual(
|
||||
gw_types.count(GatewayType.AND), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
gw_types.count(GatewayType.XOR), 4
|
||||
)
|
||||
self.assertEqual(
|
||||
gw_types.count(GatewayType.OR), 1
|
||||
)
|
||||
|
||||
def test_and_split_after_a(self) -> None:
|
||||
"""Task a leads to an AND split gateway."""
|
||||
a: Task = self.__model.get_task("a")
|
||||
a_succs: set[Node] = (
|
||||
self.__model.successors(a)
|
||||
)
|
||||
self.assertEqual(len(a_succs), 1)
|
||||
and_gw: Node = next(iter(a_succs))
|
||||
self.assertIsInstance(and_gw, Gateway)
|
||||
assert isinstance(and_gw, Gateway)
|
||||
self.assertEqual(
|
||||
and_gw.gateway_type, GatewayType.AND
|
||||
)
|
||||
|
||||
def test_and_split_successors(self) -> None:
|
||||
"""AND split has successors b and XOR split."""
|
||||
a: Task = self.__model.get_task("a")
|
||||
and_gw: Node = next(
|
||||
iter(self.__model.successors(a))
|
||||
)
|
||||
and_succs: set[Node] = (
|
||||
self.__model.successors(and_gw)
|
||||
)
|
||||
self.assertEqual(len(and_succs), 2)
|
||||
b: Task = self.__model.get_task("b")
|
||||
self.assertIn(b, and_succs)
|
||||
|
||||
def test_xor_split_cd(self) -> None:
|
||||
"""XOR split for c and d (successor of AND)."""
|
||||
a: Task = self.__model.get_task("a")
|
||||
and_gw: Node = next(
|
||||
iter(self.__model.successors(a))
|
||||
)
|
||||
and_succs: set[Node] = (
|
||||
self.__model.successors(and_gw)
|
||||
)
|
||||
b: Task = self.__model.get_task("b")
|
||||
xor1: Node = (and_succs - {b}).pop()
|
||||
self.assertIsInstance(xor1, Gateway)
|
||||
assert isinstance(xor1, Gateway)
|
||||
self.assertEqual(
|
||||
xor1.gateway_type, GatewayType.XOR
|
||||
)
|
||||
xor1_succs: set[Node] = (
|
||||
self.__model.successors(xor1)
|
||||
)
|
||||
c: Task = self.__model.get_task("c")
|
||||
d: Task = self.__model.get_task("d")
|
||||
self.assertEqual(xor1_succs, {c, d})
|
||||
|
||||
def test_xor_split_ef(self) -> None:
|
||||
"""XOR split for e and f (after b)."""
|
||||
b: Task = self.__model.get_task("b")
|
||||
b_succs: set[Node] = (
|
||||
self.__model.successors(b)
|
||||
)
|
||||
self.assertEqual(len(b_succs), 1)
|
||||
xor2: Node = next(iter(b_succs))
|
||||
self.assertIsInstance(xor2, Gateway)
|
||||
assert isinstance(xor2, Gateway)
|
||||
self.assertEqual(
|
||||
xor2.gateway_type, GatewayType.XOR
|
||||
)
|
||||
xor2_succs: set[Node] = (
|
||||
self.__model.successors(xor2)
|
||||
)
|
||||
e: Task = self.__model.get_task("e")
|
||||
f: Task = self.__model.get_task("f")
|
||||
self.assertEqual(xor2_succs, {e, f})
|
||||
|
||||
def test_xor_join_cd(self) -> None:
|
||||
"""XOR join for c and d."""
|
||||
c: Task = self.__model.get_task("c")
|
||||
d: Task = self.__model.get_task("d")
|
||||
c_succs: set[Node] = (
|
||||
self.__model.successors(c)
|
||||
)
|
||||
d_succs: set[Node] = (
|
||||
self.__model.successors(d)
|
||||
)
|
||||
self.assertEqual(len(c_succs), 1)
|
||||
self.assertEqual(len(d_succs), 1)
|
||||
self.assertEqual(c_succs, d_succs)
|
||||
join: Node = next(iter(c_succs))
|
||||
self.assertIsInstance(join, Gateway)
|
||||
assert isinstance(join, Gateway)
|
||||
self.assertEqual(
|
||||
join.gateway_type, GatewayType.XOR
|
||||
)
|
||||
|
||||
def test_or_join_to_g(self) -> None:
|
||||
"""OR join for {XOR-join, f} leading to g."""
|
||||
g: Task = self.__model.get_task("g")
|
||||
g_preds: set[Node] = (
|
||||
self.__model.predecessors(g)
|
||||
)
|
||||
self.assertEqual(len(g_preds), 1)
|
||||
or_gw: Node = next(iter(g_preds))
|
||||
self.assertIsInstance(or_gw, Gateway)
|
||||
assert isinstance(or_gw, Gateway)
|
||||
self.assertEqual(
|
||||
or_gw.gateway_type, GatewayType.OR
|
||||
)
|
||||
or_preds: set[Node] = (
|
||||
self.__model.predecessors(or_gw)
|
||||
)
|
||||
self.assertEqual(len(or_preds), 2)
|
||||
f: Task = self.__model.get_task("f")
|
||||
self.assertIn(f, or_preds)
|
||||
|
||||
def test_xor_join_to_h(self) -> None:
|
||||
"""XOR join for {e, g} leading to h.
|
||||
|
||||
This was an OR-join that became XOR after
|
||||
OR-joins minimization (Algorithm 9).
|
||||
"""
|
||||
h: Task = self.__model.get_task("h")
|
||||
h_preds: set[Node] = (
|
||||
self.__model.predecessors(h)
|
||||
)
|
||||
self.assertEqual(len(h_preds), 1)
|
||||
join: Node = next(iter(h_preds))
|
||||
self.assertIsInstance(join, Gateway)
|
||||
assert isinstance(join, Gateway)
|
||||
self.assertEqual(
|
||||
join.gateway_type, GatewayType.XOR
|
||||
)
|
||||
join_preds: set[Node] = (
|
||||
self.__model.predecessors(join)
|
||||
)
|
||||
self.assertEqual(len(join_preds), 2)
|
||||
e: Task = self.__model.get_task("e")
|
||||
g: Task = self.__model.get_task("g")
|
||||
self.assertIn(e, join_preds)
|
||||
self.assertIn(g, join_preds)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user