From 11045c57d39aa8340c1c72689b23e82359fb5642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BE=9D=E7=91=AA=E8=B2=93?= Date: Thu, 20 Aug 2026 09:57:38 +0800 Subject: [PATCH] Fix Equation 5 to count lifecycle observations instead of traces Co-Authored-By: Claude Fable 5 --- src/split_miner/refined_concurrency.py | 103 ++++++----- tests/test_refined_concurrency.py | 242 ++++++++++++++++++++----- 2 files changed, 253 insertions(+), 92 deletions(-) diff --git a/src/split_miner/refined_concurrency.py b/src/split_miner/refined_concurrency.py index 693abbe..78d2d2e 100644 --- a/src/split_miner/refined_concurrency.py +++ b/src/split_miner/refined_concurrency.py @@ -20,9 +20,12 @@ Uses activity lifecycle overlap to discover true concurrency: two activities A and B are concurrent iff -2·|A⊓B| / (|A|+|B|) >= epsilon, where |A⊓B| is the number -of traces where A and B have overlapping lifecycles, -and |A| is the number of traces containing A. +2·|A⊓B| / (|A|+|B|) >= epsilon, where |A⊓B| is the total +number of observations of overlapping lifecycles of A and B, +and |A| is the total number of complete lifecycle +observations of A, i.e. a start event matched by its end +event. Every execution counts, including repeated +executions within a single trace. Reference: Augusto, A., Dumas, M., & La Rosa, M. (2021). @@ -66,7 +69,8 @@ class RefinedPrunedDFG: Uses Equation 5 from the SM 2.0 paper to detect true concurrency via overlapping activity lifecycles, - then prunes edges between concurrent activities. + counting every complete lifecycle observation, then + prunes edges between concurrent activities. :param dfg: The refined DFG. :param traces: The lifecycle traces. @@ -107,10 +111,12 @@ class RefinedPrunedDFG: ) -> None: """Discover concurrency via lifecycle overlap. - For each pair of activities, count the number - of trace instances where their lifecycles overlap - (one starts before the other ends). Apply - Equation 5: 2·|A⊓B| / (|A|+|B|) >= epsilon. + Applies Equation 5: two activities are concurrent + iff 2·|A⊓B| / (|A|+|B|) >= epsilon, where |A⊓B| + is the total number of observations of overlapping + lifecycles of A and B, and |A| and |B| are the + total numbers of complete lifecycle observations + of A and of B. :param traces: The lifecycle traces. :param epsilon: The concurrency threshold. @@ -167,9 +173,11 @@ class RefinedPrunedDFG: ) -> None: """Count lifecycle overlaps in a single trace. - An overlap between A and B occurs when one starts - before the other ends. Track active activities - (started but not yet ended) to detect overlaps. + Every complete lifecycle, i.e. a start event + matched by its end event, counts as one activity + observation. Every pair of complete lifecycles of + two distinct activities that overlap in time + counts as one overlap observation. :param trace: The lifecycle trace. :param count: The trace frequency. @@ -178,42 +186,53 @@ class RefinedPrunedDFG: :param activity_count: Accumulated activity counts (mutated). """ - # Track which activities are currently active - # (started but not ended). - active: set[Node] = set() - # Track pairs already counted as overlapping - # in this trace (canonical order to avoid - # double-counting when self-loops cause - # overlap in both directions). - overlapped: set[tuple[Node, Node]] = set() - # Track activities seen in this trace (for - # per-trace counting per Equation 5). - seen: set[Node] = set() + # Lifecycles started but not yet ended, per + # activity, in start order. + active: dict[Node, list[int]] = {} + # Lifecycles matched by their end event. + complete: set[int] = set() + # Overlapping lifecycle pairs, to be counted + # once both lifecycles are complete. + overlapped: list[ + tuple[tuple[Node, Node], int, int] + ] = [] + next_id: int = 0 for node, lifecycle in trace: if lifecycle == "start": - # This activity overlaps with all - # currently active activities. - for other in active: + started: int = next_id + next_id += 1 + # This lifecycle overlaps with all + # currently active lifecycles. + for other, ids in active.items(): + if other == node: + continue pair: tuple[Node, Node] = ( - _canonical_pair( - other, node - ) + _canonical_pair(other, node) ) - if pair not in overlapped: - overlapped.add(pair) - active.add(node) + for other_id in ids: + overlapped.append( + (pair, other_id, started) + ) + active.setdefault(node, []).append( + started + ) elif lifecycle == "end": - active.discard(node) - seen.add(node) - # Count each activity once per trace - # (Equation 5: |A| = number of traces - # containing A). - for node in seen: - activity_count[node] = ( - activity_count.get(node, 0) + count - ) - # Add overlap counts (canonical pairs). - for pair in overlapped: + pending: list[int] = active.get( + node, [] + ) + if not pending: + continue + complete.add(pending.pop(0)) + activity_count[node] = ( + activity_count.get(node, 0) + + count + ) + for pair, first, second in overlapped: + if ( + first not in complete + or second not in complete + ): + continue overlap_count[pair] = ( overlap_count.get(pair, 0) + count ) diff --git a/tests/test_refined_concurrency.py b/tests/test_refined_concurrency.py index e940edc..088c50f 100644 --- a/tests/test_refined_concurrency.py +++ b/tests/test_refined_concurrency.py @@ -484,13 +484,12 @@ class TestTraceFrequency(unittest.TestCase): class TestSelfLoopActivityCount(unittest.TestCase): - """Tests that |A| counts traces, not completions. + """Tests that |A| counts every completed lifecycle. - Per Equation 5, |A| is the number of traces - containing activity A. An activity that completes - multiple times in a single trace (self-loop) should - still count as 1 for that trace, not as the number - of completions. + Per Equation 5, |A| is the total number of complete + lifecycle observations of activity A. An activity + that completes multiple times in a single trace + (self-loop) counts once per completion. """ def setUp(self) -> None: @@ -500,13 +499,10 @@ class TestSelfLoopActivityCount(unittest.TestCase): with C during its first execution. As Ae Bs Cs Be Ce Bs Be - |B⊓C| = 1 (1 trace with overlap). - |B| = 1 (1 trace containing B, NOT 2). - |C| = 1 (1 trace containing C). - Ratio = 2·1/(1+1) = 1.0. - - With the bug (counting completions): - |B| = 2, ratio = 2·1/(2+1) = 0.67. + |B⊓C| = 1 (1 overlapping lifecycle pair). + |B| = 2 (2 complete lifecycles of B). + |C| = 1 (1 complete lifecycle of C). + Ratio = 2·1/(2+1) = 0.667. :return: None. """ @@ -528,11 +524,7 @@ class TestSelfLoopActivityCount(unittest.TestCase): def test_concurrent_with_self_loop( self, ) -> None: - """B||C even though B has a self-loop. - - Ratio is 2·1/(1+1) = 1.0, not 2·1/(2+1) - = 0.67. With per-trace counting, B||C at - epsilon=0.9. + """B||C with epsilon=0.6 (ratio=0.667 >= 0.6). :return: None. """ @@ -543,7 +535,7 @@ class TestSelfLoopActivityCount(unittest.TestCase): ) pruned: RefinedPrunedDFG = ( RefinedPrunedDFG( - dfg, self.__traces, 0.9 + dfg, self.__traces, 0.6 ) ) b: Task = self.__tasks["B"] @@ -552,15 +544,14 @@ class TestSelfLoopActivityCount(unittest.TestCase): pruned.is_concurrent(b, c) ) - def test_not_concurrent_bug_threshold( + def test_not_concurrent_high_epsilon( self, ) -> None: - """Verify the ratio is 1.0, not 0.67. + """B not ||C with epsilon=0.7 (ratio=0.667). - If the bug existed (counting completions), - epsilon=0.9 would fail since 0.67 < 0.9. - This test passes because |B|=1 (per-trace), - giving ratio=1.0 >= 0.9. + The second execution of B counts towards |B|, + so the ratio is 2·1/(2+1) = 0.667, not + 2·1/(1+1) = 1.0. :return: None. """ @@ -569,28 +560,25 @@ class TestSelfLoopActivityCount(unittest.TestCase): self.__traces ) ) - # Even at very high epsilon, should be - # concurrent since ratio is 1.0. pruned: RefinedPrunedDFG = ( RefinedPrunedDFG( - dfg, self.__traces, 1.0 + dfg, self.__traces, 0.7 ) ) b: Task = self.__tasks["B"] c: Task = self.__tasks["C"] - self.assertTrue( + self.assertFalse( pruned.is_concurrent(b, c) ) class TestSelfLoopOverlapCount(unittest.TestCase): - """Tests that |A⊓B| counts at most once per trace. + """Tests that |A⊓B| counts every overlapping pair. - Per Equation 5, |A⊓B| is the number of traces - where A and B have overlapping lifecycles. When - a self-loop activity overlaps with another activity - in both "directions" within one trace, it should - still count as 1 overlap, not 2. + Per Equation 5, |A⊓B| is the total number of + observations of overlapping lifecycles of A and B. + When a self-loop activity overlaps another activity + twice within one trace, both observations count. """ def setUp(self) -> None: @@ -608,17 +596,15 @@ class TestSelfLoopOverlapCount(unittest.TestCase): - (B,e): active={A} - (A,e): active={} - Correct: |A⊓B| = 1 (one trace with overlap). - Bug: overlap counted as 2 (both directions). + |A⊓B| = 2 in this trace: the first lifecycle of + A overlaps B, and B overlaps the second + lifecycle of A. Trace 2 (freq 1): A and B sequential. As Ae Bs Be - Correct totals: |A⊓B|=1, |A|=2, |B|=2. - Ratio = 2·1/(2+2) = 0.5. - - Bug totals: |A⊓B|=2 (double-counted). - Bug ratio = 2·2/(2+2) = 1.0. + Totals: |A⊓B|=2, |A|=3, |B|=2. + Ratio = 2·2/(3+2) = 0.8. :return: None. """ @@ -641,11 +627,85 @@ class TestSelfLoopOverlapCount(unittest.TestCase): def test_not_concurrent_at_high_epsilon( self, ) -> None: - """A not ||B with epsilon=0.6 (ratio=0.5). + """A not ||B with epsilon=0.9 (ratio=0.8). - Correct ratio is 2·1/(2+2) = 0.5 < 0.6. - With the bug (double-counted overlap), - ratio would be 2·2/(2+2) = 1.0 >= 0.6. + :return: None. + """ + dfg: RefinedDirectlyFollowsGraph = ( + RefinedDirectlyFollowsGraph( + self.__traces + ) + ) + pruned: RefinedPrunedDFG = ( + RefinedPrunedDFG( + dfg, self.__traces, 0.9 + ) + ) + a: Task = self.__tasks["A"] + b: Task = self.__tasks["B"] + self.assertFalse( + pruned.is_concurrent(a, b) + ) + + def test_concurrent_at_low_epsilon( + self, + ) -> None: + """A||B with epsilon=0.8 (ratio=0.8 >= 0.8). + + :return: None. + """ + dfg: RefinedDirectlyFollowsGraph = ( + RefinedDirectlyFollowsGraph( + self.__traces + ) + ) + pruned: RefinedPrunedDFG = ( + RefinedPrunedDFG( + dfg, self.__traces, 0.8 + ) + ) + a: Task = self.__tasks["A"] + b: Task = self.__tasks["B"] + self.assertTrue( + pruned.is_concurrent(a, b) + ) + + +class TestRepeatedExecutionCounting( + unittest.TestCase +): + """Tests that every complete lifecycle is counted. + + Per Equation 5 and footnote 3, |A| is the total + number of complete lifecycle observations of A, + so repeated executions of A within a single trace + each count towards |A|. + """ + + def setUp(self) -> None: + """Set up a trace where A executes twice. + + Trace (freq 1): As Bs Be Ae As Ae + + |A⊓B| = 1, |A| = 2, |B| = 1. + Ratio = 2·1/(2+1) = 0.667. + + :return: None. + """ + self.__tasks: dict[str, Task] = ( + _make_tasks("A", "B") + ) + a: Task = self.__tasks["A"] + b: Task = self.__tasks["B"] + self.__traces: dict[ + tuple[tuple[Task, str], ...], int + ] = { + ((a, S), (b, S), (b, E), (a, E), + (a, S), (a, E)): 1, + } + + def test_concurrent_below_ratio(self) -> None: + """A||B with epsilon=0.6 (ratio=0.667 >= 0.6). :return: None. """ @@ -661,14 +721,18 @@ class TestSelfLoopOverlapCount(unittest.TestCase): ) a: Task = self.__tasks["A"] b: Task = self.__tasks["B"] - self.assertFalse( + self.assertTrue( pruned.is_concurrent(a, b) ) - def test_concurrent_at_low_epsilon( + def test_not_concurrent_above_ratio( self, ) -> None: - """A||B with epsilon=0.5 (ratio=0.5 >= 0.5). + """A not ||B with epsilon=0.7 (ratio=0.667). + + The second execution of A counts towards |A|, + so the ratio is 2·1/(2+1) = 0.667, not + 2·1/(1+1) = 1.0. :return: None. """ @@ -679,7 +743,85 @@ class TestSelfLoopOverlapCount(unittest.TestCase): ) pruned: RefinedPrunedDFG = ( RefinedPrunedDFG( - dfg, self.__traces, 0.5 + dfg, self.__traces, 0.7 + ) + ) + a: Task = self.__tasks["A"] + b: Task = self.__tasks["B"] + self.assertFalse( + pruned.is_concurrent(a, b) + ) + + +class TestIncompleteLifecycle(unittest.TestCase): + """Tests that incomplete lifecycles are not counted. + + Per footnote 3, only complete lifecycle + observations, i.e. a start event matched by its end + event, count towards |A| and |B|. + """ + + def setUp(self) -> None: + """Set up a trace where A never ends. + + Trace 1 (freq 1): As Bs Be + Trace 2 (freq 1): As Bs Be Ae + + A has no matching end in trace 1, so that + lifecycle and its overlap with B are not + observed: |A| = 1, |B| = 2, |A⊓B| = 1. + Ratio = 2·1/(1+2) = 0.667. + + :return: None. + """ + self.__tasks: dict[str, Task] = ( + _make_tasks("A", "B") + ) + a: Task = self.__tasks["A"] + b: Task = self.__tasks["B"] + self.__traces: dict[ + tuple[tuple[Task, str], ...], int + ] = { + ((a, S), (b, S), (b, E)): 1, + ((a, S), (b, S), (b, E), (a, E)): 1, + } + + def test_not_concurrent_above_ratio( + self, + ) -> None: + """A not ||B with epsilon=0.7 (ratio=0.667). + + :return: None. + """ + dfg: RefinedDirectlyFollowsGraph = ( + RefinedDirectlyFollowsGraph( + self.__traces + ) + ) + pruned: RefinedPrunedDFG = ( + RefinedPrunedDFG( + dfg, self.__traces, 0.7 + ) + ) + a: Task = self.__tasks["A"] + b: Task = self.__tasks["B"] + self.assertFalse( + pruned.is_concurrent(a, b) + ) + + def test_concurrent_below_ratio(self) -> None: + """A||B with epsilon=0.6 (ratio=0.667 >= 0.6). + + :return: None. + """ + dfg: RefinedDirectlyFollowsGraph = ( + RefinedDirectlyFollowsGraph( + self.__traces + ) + ) + pruned: RefinedPrunedDFG = ( + RefinedPrunedDFG( + dfg, self.__traces, 0.6 ) ) a: Task = self.__tasks["A"]