Add split miner implementation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,645 @@
|
||||
# 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.
|
||||
"""Tests for the Split Miner algorithm.
|
||||
|
||||
Uses the running example from Section 3 of the SM 1.0 journal
|
||||
paper (Augusto et al., 2018).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from collections import deque
|
||||
|
||||
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 TestDFGConstruction(unittest.TestCase):
|
||||
"""Tests for DFG construction (Section 3.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_nodes(self) -> None:
|
||||
"""The DFG has the correct set of nodes."""
|
||||
self.assertEqual(
|
||||
self.__dfg.nodes,
|
||||
set(self.__t.values()),
|
||||
)
|
||||
|
||||
def test_sources_and_sinks(self) -> None:
|
||||
"""The DFG has correct sources and sinks."""
|
||||
self.assertIn(
|
||||
self.__t["a"], self.__dfg.sources
|
||||
)
|
||||
self.assertIn(
|
||||
self.__t["h"], self.__dfg.sinks
|
||||
)
|
||||
|
||||
def test_df_frequencies(self) -> None:
|
||||
"""Selected directly-follows frequencies match."""
|
||||
t: dict[str, Task] = self.__t
|
||||
# a -> b: appears in 6 trace types * 10 = 60
|
||||
self.assertEqual(
|
||||
self.__dfg.df_frequency(
|
||||
t["a"], t["b"]
|
||||
), 60
|
||||
)
|
||||
# a -> c: 2 trace types * 10 = 20
|
||||
self.assertEqual(
|
||||
self.__dfg.df_frequency(
|
||||
t["a"], t["c"]
|
||||
), 20
|
||||
)
|
||||
# a -> d: 2 trace types * 10 = 20
|
||||
self.assertEqual(
|
||||
self.__dfg.df_frequency(
|
||||
t["a"], t["d"]
|
||||
), 20
|
||||
)
|
||||
|
||||
def test_no_self_loops(self) -> None:
|
||||
"""The paper example has no self-loops."""
|
||||
self.assertEqual(self.__dfg.self_loops, set())
|
||||
|
||||
def test_no_short_loops(self) -> None:
|
||||
"""The paper example has no short-loops."""
|
||||
self.assertEqual(
|
||||
self.__dfg.short_loops, set()
|
||||
)
|
||||
|
||||
def test_self_loop_detection(self) -> None:
|
||||
"""Self-loops are correctly detected."""
|
||||
t: dict[str, Task] = _make_tasks("abc")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["a"], t["b"], t["b"], t["c"]): 10,
|
||||
}
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
self.assertIn(t["b"], dfg.self_loops)
|
||||
self.assertNotIn(t["a"], dfg.self_loops)
|
||||
|
||||
def test_self_loop_edges_excluded(self) -> None:
|
||||
"""Self-loop edges are excluded from edges set."""
|
||||
t: dict[str, Task] = _make_tasks("abc")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["a"], t["b"], t["b"], t["c"]): 10,
|
||||
}
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
self.assertNotIn(
|
||||
(t["b"], t["b"]), dfg.edges
|
||||
)
|
||||
self.assertIn(
|
||||
(t["a"], t["b"]), dfg.edges
|
||||
)
|
||||
self.assertIn(
|
||||
(t["b"], t["c"]), dfg.edges
|
||||
)
|
||||
|
||||
def test_short_loop_detection(self) -> None:
|
||||
"""Short-loops are correctly detected."""
|
||||
t: dict[str, Task] = _make_tasks("abcd")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["a"], t["b"], t["c"],
|
||||
t["b"], t["d"]): 10,
|
||||
}
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
self.assertIn(
|
||||
(t["b"], t["c"]), dfg.short_loops
|
||||
)
|
||||
self.assertIn(
|
||||
(t["c"], t["b"]), dfg.short_loops
|
||||
)
|
||||
|
||||
|
||||
class TestConcurrencyDiscovery(unittest.TestCase):
|
||||
"""Tests for concurrency discovery (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_concurrent_pairs(self) -> None:
|
||||
"""Correct concurrency relations with epsilon=0.2.
|
||||
|
||||
The paper identifies: b||c, b||d, d||e, e||g.
|
||||
"""
|
||||
t: dict[str, Task] = self.__t
|
||||
# Check expected concurrent pairs
|
||||
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"])
|
||||
)
|
||||
# Non-concurrent pairs
|
||||
self.assertFalse(
|
||||
self.__pdfg.is_concurrent(t["a"], t["b"])
|
||||
)
|
||||
self.assertFalse(
|
||||
self.__pdfg.is_concurrent(t["c"], t["d"])
|
||||
)
|
||||
|
||||
def test_self_loop_skipped_in_concurrency(
|
||||
self,
|
||||
) -> None:
|
||||
"""Self-loop nodes are never concurrent."""
|
||||
# b has a self-loop; a and b could look
|
||||
# concurrent but b should be skipped.
|
||||
t: dict[str, Task] = _make_tasks("abc")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["a"], t["b"], t["b"],
|
||||
t["a"], t["c"]): 10,
|
||||
(t["a"], t["b"],
|
||||
t["a"], t["c"]): 10,
|
||||
}
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
pdfg: PrunedDFG = PrunedDFG(
|
||||
dfg, epsilon=1.0
|
||||
)
|
||||
self.assertFalse(
|
||||
pdfg.is_concurrent(t["a"], t["b"])
|
||||
)
|
||||
|
||||
def test_short_loop_not_concurrent(self) -> None:
|
||||
"""Short-loop pairs are not concurrent.
|
||||
|
||||
Condition 4 prevents short-loop pairs from being
|
||||
declared concurrent.
|
||||
"""
|
||||
t: dict[str, Task] = _make_tasks("abcd")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["a"], t["b"], t["c"],
|
||||
t["b"], t["d"]): 10,
|
||||
(t["a"], t["c"],
|
||||
t["b"], t["d"]): 10,
|
||||
}
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
pdfg: PrunedDFG = PrunedDFG(
|
||||
dfg, epsilon=1.0
|
||||
)
|
||||
self.assertFalse(
|
||||
pdfg.is_concurrent(t["b"], t["c"])
|
||||
)
|
||||
|
||||
|
||||
class TestFiltering(unittest.TestCase):
|
||||
"""Tests for edge filtering (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=1.0
|
||||
)
|
||||
|
||||
def test_filtered_edges_retain_best(self) -> None:
|
||||
"""Filtered DFG retains best incoming/outgoing
|
||||
edges.
|
||||
|
||||
Per Table 1 in the paper, edges (e,c) and (c,f)
|
||||
should be dropped.
|
||||
"""
|
||||
t: dict[str, Task] = self.__t
|
||||
edges: set[tuple[Node, Node]] = (
|
||||
self.__fdfg.edges
|
||||
)
|
||||
# These should be retained (best edges)
|
||||
self.assertIn((t["a"], t["b"]), edges)
|
||||
self.assertIn((t["b"], t["e"]), edges)
|
||||
self.assertIn((t["f"], t["g"]), edges)
|
||||
self.assertIn((t["g"], t["h"]), edges)
|
||||
|
||||
def test_sources_and_sinks_preserved(self) -> None:
|
||||
"""Filtering preserves sources and sinks."""
|
||||
t: dict[str, Task] = self.__t
|
||||
self.assertIn(
|
||||
t["a"], self.__fdfg.sources
|
||||
)
|
||||
self.assertIn(
|
||||
t["h"], self.__fdfg.sinks
|
||||
)
|
||||
|
||||
|
||||
class TestEndToEnd(unittest.TestCase):
|
||||
"""End-to-end test for Split Miner."""
|
||||
|
||||
def test_paper_example_basic(self) -> None:
|
||||
"""Split Miner produces a valid BPMN model.
|
||||
|
||||
The discovered model should have start/end events,
|
||||
all 8 tasks, gateways, and proper connectivity.
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = (
|
||||
_make_paper_str_log()
|
||||
)
|
||||
model: BPMNModel = split_miner(
|
||||
traces, epsilon=0.2, eta=0.4
|
||||
)
|
||||
# Has start and end
|
||||
self.assertIsNotNone(model.start)
|
||||
self.assertIsNotNone(model.end)
|
||||
# Has all 8 tasks
|
||||
task_labels: set[str] = {
|
||||
t.label for t in model.tasks.values()
|
||||
if t.label is not None
|
||||
}
|
||||
self.assertEqual(
|
||||
task_labels,
|
||||
{"a", "b", "c", "d", "e", "f", "g", "h"},
|
||||
)
|
||||
# Has edges
|
||||
self.assertGreater(len(model.edges), 0)
|
||||
# Start has outgoing edge
|
||||
self.assertGreater(
|
||||
len(model.outgoing_edges(model.start)), 0
|
||||
)
|
||||
# End has incoming edge
|
||||
self.assertGreater(
|
||||
len(model.incoming_edges(model.end)), 0
|
||||
)
|
||||
|
||||
def test_paper_example_has_gateways(self) -> None:
|
||||
"""The paper example produces split and join
|
||||
gateways.
|
||||
|
||||
Per Fig. 3c, the model should have both XOR and
|
||||
AND gateways (or OR gateways that get minimized).
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = (
|
||||
_make_paper_str_log()
|
||||
)
|
||||
model: BPMNModel = split_miner(
|
||||
traces, epsilon=0.2, eta=0.4
|
||||
)
|
||||
self.assertGreater(len(model.gateways), 0)
|
||||
gw_types: set[GatewayType] = {
|
||||
gw.gateway_type
|
||||
for gw in model.gateways.values()
|
||||
}
|
||||
# Should have at least XOR or AND gateways
|
||||
self.assertTrue(
|
||||
GatewayType.XOR in gw_types
|
||||
or GatewayType.AND in gw_types,
|
||||
f"Expected XOR or AND gateways, "
|
||||
f"got {gw_types}"
|
||||
)
|
||||
|
||||
def test_paper_example_all_tasks_connected(
|
||||
self,
|
||||
) -> None:
|
||||
"""Every task is reachable from start.
|
||||
|
||||
Verifies syntactic correctness: all tasks on a
|
||||
path from start to end.
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = (
|
||||
_make_paper_str_log()
|
||||
)
|
||||
model: BPMNModel = split_miner(
|
||||
traces, epsilon=0.2, eta=0.4
|
||||
)
|
||||
# BFS from start
|
||||
reachable: set[Node] = set()
|
||||
queue: deque[Node] = deque([model.start])
|
||||
while queue:
|
||||
node: Node = queue.popleft()
|
||||
if node in reachable:
|
||||
continue
|
||||
reachable.add(node)
|
||||
for _, succ in model.outgoing_edges(node):
|
||||
queue.append(succ)
|
||||
# All tasks should be reachable
|
||||
for task in model.tasks.values():
|
||||
self.assertIn(
|
||||
task, reachable,
|
||||
f"Task {task.label!r} not reachable "
|
||||
f"from start"
|
||||
)
|
||||
# End should be reachable
|
||||
self.assertIn(model.end, reachable)
|
||||
|
||||
def test_paper_example_all_tasks_reach_end(
|
||||
self,
|
||||
) -> None:
|
||||
"""Every task can reach the end event.
|
||||
|
||||
Verifies syntactic correctness by backward BFS.
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = (
|
||||
_make_paper_str_log()
|
||||
)
|
||||
model: BPMNModel = split_miner(
|
||||
traces, epsilon=0.2, eta=0.4
|
||||
)
|
||||
# Backward BFS from end
|
||||
can_reach_end: set[Node] = set()
|
||||
queue: deque[Node] = deque([model.end])
|
||||
while queue:
|
||||
node: Node = queue.popleft()
|
||||
if node in can_reach_end:
|
||||
continue
|
||||
can_reach_end.add(node)
|
||||
for pred, _ in model.incoming_edges(node):
|
||||
queue.append(pred)
|
||||
# All tasks should reach end
|
||||
for task in model.tasks.values():
|
||||
self.assertIn(
|
||||
task, can_reach_end,
|
||||
f"Task {task.label!r} cannot reach end"
|
||||
)
|
||||
|
||||
def test_simple_sequence(self) -> None:
|
||||
"""A simple sequential log produces no gateways."""
|
||||
traces: dict[tuple[str, ...], int] = {
|
||||
("a", "b", "c"): 10,
|
||||
}
|
||||
model: BPMNModel = split_miner(traces)
|
||||
self.assertEqual(len(model.gateways), 0)
|
||||
self.assertEqual(len(model.tasks), 3)
|
||||
|
||||
def test_simple_xor_choice(self) -> None:
|
||||
"""A log with exclusive choice produces XOR
|
||||
gateways.
|
||||
|
||||
Log: {<a,b,d>^10, <a,c,d>^10}
|
||||
Expected: a -> XOR-split -> {b, c} ->
|
||||
XOR-join -> d
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = {
|
||||
("a", "b", "d"): 10,
|
||||
("a", "c", "d"): 10,
|
||||
}
|
||||
model: BPMNModel = split_miner(
|
||||
traces, epsilon=0.1, eta=0.4
|
||||
)
|
||||
# Should have tasks a, b, c, d
|
||||
task_labels: set[str] = {
|
||||
t.label for t in model.tasks.values()
|
||||
if t.label is not None
|
||||
}
|
||||
self.assertEqual(
|
||||
task_labels, {"a", "b", "c", "d"}
|
||||
)
|
||||
# Should have gateways
|
||||
self.assertGreater(len(model.gateways), 0)
|
||||
# All tasks reachable from start
|
||||
reachable: set[Node] = set()
|
||||
queue: deque[Node] = deque([model.start])
|
||||
while queue:
|
||||
node: Node = queue.popleft()
|
||||
if node in reachable:
|
||||
continue
|
||||
reachable.add(node)
|
||||
for _, s in model.outgoing_edges(node):
|
||||
queue.append(s)
|
||||
for task in model.tasks.values():
|
||||
self.assertIn(task, reachable)
|
||||
|
||||
def test_simple_concurrency(self) -> None:
|
||||
"""A log with concurrency produces AND gateways.
|
||||
|
||||
Log: {<a,b,c,d>^10, <a,c,b,d>^10}
|
||||
b and c are concurrent.
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = {
|
||||
("a", "b", "c", "d"): 10,
|
||||
("a", "c", "b", "d"): 10,
|
||||
}
|
||||
model: BPMNModel = split_miner(
|
||||
traces, epsilon=1.0, eta=0.4
|
||||
)
|
||||
task_labels: set[str] = {
|
||||
t.label for t in model.tasks.values()
|
||||
if t.label is not None
|
||||
}
|
||||
self.assertEqual(
|
||||
task_labels, {"a", "b", "c", "d"}
|
||||
)
|
||||
# Should have AND gateways for b||c
|
||||
and_gws: list[Gateway] = [
|
||||
gw for gw in model.gateways.values()
|
||||
if gw.gateway_type == GatewayType.AND
|
||||
]
|
||||
self.assertGreater(
|
||||
len(and_gws), 0,
|
||||
"Expected AND gateways for concurrent "
|
||||
"b and c"
|
||||
)
|
||||
|
||||
|
||||
class TestSelfLoopHandling(unittest.TestCase):
|
||||
"""Tests for self-loop handling."""
|
||||
|
||||
def test_self_loop_restored(self) -> None:
|
||||
"""Self-loops are restored in the final BPMN model.
|
||||
|
||||
A self-loop on task b should produce XOR-join and
|
||||
XOR-split gateways around b with a back-edge.
|
||||
"""
|
||||
traces: dict[tuple[str, ...], int] = {
|
||||
("a", "b", "c"): 10,
|
||||
("a", "b", "b", "c"): 10,
|
||||
("a", "b", "b", "b", "c"): 10,
|
||||
}
|
||||
model: BPMNModel = split_miner(traces)
|
||||
# Task b should have a gateway predecessor
|
||||
# and a gateway successor (the self-loop
|
||||
# XOR-join and XOR-split)
|
||||
b: Node = model.get_task("b")
|
||||
b_preds: set[Node] = model.predecessors(b)
|
||||
b_succs: set[Node] = model.successors(b)
|
||||
# b should have exactly 1 predecessor (XOR-join)
|
||||
# and 1 successor (XOR-split)
|
||||
self.assertEqual(len(b_preds), 1)
|
||||
self.assertEqual(len(b_succs), 1)
|
||||
join_node: Node = next(iter(b_preds))
|
||||
split_node: Node = next(iter(b_succs))
|
||||
self.assertIsInstance(join_node, Gateway)
|
||||
self.assertIsInstance(split_node, Gateway)
|
||||
assert isinstance(join_node, Gateway)
|
||||
assert isinstance(split_node, Gateway)
|
||||
self.assertEqual(
|
||||
join_node.gateway_type, GatewayType.XOR
|
||||
)
|
||||
self.assertEqual(
|
||||
split_node.gateway_type, GatewayType.XOR
|
||||
)
|
||||
# Back-edge: split -> join
|
||||
self.assertIn(
|
||||
split_node,
|
||||
model.predecessors(join_node)
|
||||
)
|
||||
|
||||
|
||||
class TestShortLoopHandling(unittest.TestCase):
|
||||
"""Tests for short-loop handling."""
|
||||
|
||||
def test_short_loop_not_concurrent(self) -> None:
|
||||
"""Short-loop pairs are excluded from concurrency.
|
||||
|
||||
If a and b form a short-loop, they must not be
|
||||
declared concurrent even if they appear in both
|
||||
orders.
|
||||
"""
|
||||
# a,b,a pattern = short-loop
|
||||
t: dict[str, Task] = _make_tasks("abxy")
|
||||
traces: dict[tuple[Node, ...], int] = {
|
||||
(t["x"], t["a"], t["b"],
|
||||
t["a"], t["y"]): 10,
|
||||
(t["x"], t["b"], t["a"],
|
||||
t["b"], t["y"]): 10,
|
||||
(t["x"], t["a"], t["y"]): 10,
|
||||
(t["x"], t["b"], t["y"]): 10,
|
||||
}
|
||||
dfg: DirectlyFollowsGraph = (
|
||||
DirectlyFollowsGraph(traces)
|
||||
)
|
||||
# Should detect short-loop
|
||||
self.assertIn(
|
||||
(t["a"], t["b"]), dfg.short_loops
|
||||
)
|
||||
# Should NOT be concurrent
|
||||
pdfg: PrunedDFG = PrunedDFG(
|
||||
dfg, epsilon=1.0
|
||||
)
|
||||
self.assertFalse(
|
||||
pdfg.is_concurrent(t["a"], t["b"])
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user