Fix Equation 5 to count lifecycle observations instead of traces

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 09:57:38 +08:00
co-authored by Claude Fable 5
parent 46d6565a56
commit 11045c57d3
2 changed files with 253 additions and 92 deletions
+61 -42
View File
@@ -20,9 +20,12 @@
Uses activity lifecycle overlap to discover true concurrency: Uses activity lifecycle overlap to discover true concurrency:
two activities A and B are concurrent iff two activities A and B are concurrent iff
2·|A⊓B| / (|A|+|B|) >= epsilon, where |A⊓B| is the number 2·|A⊓B| / (|A|+|B|) >= epsilon, where |A⊓B| is the total
of traces where A and B have overlapping lifecycles, number of observations of overlapping lifecycles of A and B,
and |A| is the number of traces containing A. 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: Reference:
Augusto, A., Dumas, M., & La Rosa, M. (2021). 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 Uses Equation 5 from the SM 2.0 paper to detect
true concurrency via overlapping activity lifecycles, 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 dfg: The refined DFG.
:param traces: The lifecycle traces. :param traces: The lifecycle traces.
@@ -107,10 +111,12 @@ class RefinedPrunedDFG:
) -> None: ) -> None:
"""Discover concurrency via lifecycle overlap. """Discover concurrency via lifecycle overlap.
For each pair of activities, count the number Applies Equation 5: two activities are concurrent
of trace instances where their lifecycles overlap iff 2·|A⊓B| / (|A|+|B|) >= epsilon, where |A⊓B|
(one starts before the other ends). Apply is the total number of observations of overlapping
Equation 5: 2·|A⊓B| / (|A|+|B|) >= epsilon. 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 traces: The lifecycle traces.
:param epsilon: The concurrency threshold. :param epsilon: The concurrency threshold.
@@ -167,9 +173,11 @@ class RefinedPrunedDFG:
) -> None: ) -> None:
"""Count lifecycle overlaps in a single trace. """Count lifecycle overlaps in a single trace.
An overlap between A and B occurs when one starts Every complete lifecycle, i.e. a start event
before the other ends. Track active activities matched by its end event, counts as one activity
(started but not yet ended) to detect overlaps. 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 trace: The lifecycle trace.
:param count: The trace frequency. :param count: The trace frequency.
@@ -178,42 +186,53 @@ class RefinedPrunedDFG:
:param activity_count: Accumulated activity :param activity_count: Accumulated activity
counts (mutated). counts (mutated).
""" """
# Track which activities are currently active # Lifecycles started but not yet ended, per
# (started but not ended). # activity, in start order.
active: set[Node] = set() active: dict[Node, list[int]] = {}
# Track pairs already counted as overlapping # Lifecycles matched by their end event.
# in this trace (canonical order to avoid complete: set[int] = set()
# double-counting when self-loops cause # Overlapping lifecycle pairs, to be counted
# overlap in both directions). # once both lifecycles are complete.
overlapped: set[tuple[Node, Node]] = set() overlapped: list[
# Track activities seen in this trace (for tuple[tuple[Node, Node], int, int]
# per-trace counting per Equation 5). ] = []
seen: set[Node] = set() next_id: int = 0
for node, lifecycle in trace: for node, lifecycle in trace:
if lifecycle == "start": if lifecycle == "start":
# This activity overlaps with all started: int = next_id
# currently active activities. next_id += 1
for other in active: # This lifecycle overlaps with all
# currently active lifecycles.
for other, ids in active.items():
if other == node:
continue
pair: tuple[Node, Node] = ( pair: tuple[Node, Node] = (
_canonical_pair( _canonical_pair(other, node)
other, node
)
) )
if pair not in overlapped: for other_id in ids:
overlapped.add(pair) overlapped.append(
active.add(node) (pair, other_id, started)
)
active.setdefault(node, []).append(
started
)
elif lifecycle == "end": elif lifecycle == "end":
active.discard(node) pending: list[int] = active.get(
seen.add(node) node, []
# Count each activity once per trace )
# (Equation 5: |A| = number of traces if not pending:
# containing A). continue
for node in seen: complete.add(pending.pop(0))
activity_count[node] = ( activity_count[node] = (
activity_count.get(node, 0) + count activity_count.get(node, 0)
) + count
# Add overlap counts (canonical pairs). )
for pair in overlapped: for pair, first, second in overlapped:
if (
first not in complete
or second not in complete
):
continue
overlap_count[pair] = ( overlap_count[pair] = (
overlap_count.get(pair, 0) + count overlap_count.get(pair, 0) + count
) )
+192 -50
View File
@@ -484,13 +484,12 @@ class TestTraceFrequency(unittest.TestCase):
class TestSelfLoopActivityCount(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 Per Equation 5, |A| is the total number of complete
containing activity A. An activity that completes lifecycle observations of activity A. An activity
multiple times in a single trace (self-loop) should that completes multiple times in a single trace
still count as 1 for that trace, not as the number (self-loop) counts once per completion.
of completions.
""" """
def setUp(self) -> None: def setUp(self) -> None:
@@ -500,13 +499,10 @@ class TestSelfLoopActivityCount(unittest.TestCase):
with C during its first execution. with C during its first execution.
As Ae Bs Cs Be Ce Bs Be As Ae Bs Cs Be Ce Bs Be
|B⊓C| = 1 (1 trace with overlap). |B⊓C| = 1 (1 overlapping lifecycle pair).
|B| = 1 (1 trace containing B, NOT 2). |B| = 2 (2 complete lifecycles of B).
|C| = 1 (1 trace containing C). |C| = 1 (1 complete lifecycle of C).
Ratio = 2·1/(1+1) = 1.0. Ratio = 2·1/(2+1) = 0.667.
With the bug (counting completions):
|B| = 2, ratio = 2·1/(2+1) = 0.67.
:return: None. :return: None.
""" """
@@ -528,11 +524,7 @@ class TestSelfLoopActivityCount(unittest.TestCase):
def test_concurrent_with_self_loop( def test_concurrent_with_self_loop(
self, self,
) -> None: ) -> None:
"""B||C even though B has a self-loop. """B||C with epsilon=0.6 (ratio=0.667 >= 0.6).
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.
:return: None. :return: None.
""" """
@@ -543,7 +535,7 @@ class TestSelfLoopActivityCount(unittest.TestCase):
) )
pruned: RefinedPrunedDFG = ( pruned: RefinedPrunedDFG = (
RefinedPrunedDFG( RefinedPrunedDFG(
dfg, self.__traces, 0.9 dfg, self.__traces, 0.6
) )
) )
b: Task = self.__tasks["B"] b: Task = self.__tasks["B"]
@@ -552,15 +544,14 @@ class TestSelfLoopActivityCount(unittest.TestCase):
pruned.is_concurrent(b, c) pruned.is_concurrent(b, c)
) )
def test_not_concurrent_bug_threshold( def test_not_concurrent_high_epsilon(
self, self,
) -> None: ) -> 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), The second execution of B counts towards |B|,
epsilon=0.9 would fail since 0.67 < 0.9. so the ratio is 2·1/(2+1) = 0.667, not
This test passes because |B|=1 (per-trace), 2·1/(1+1) = 1.0.
giving ratio=1.0 >= 0.9.
:return: None. :return: None.
""" """
@@ -569,28 +560,25 @@ class TestSelfLoopActivityCount(unittest.TestCase):
self.__traces self.__traces
) )
) )
# Even at very high epsilon, should be
# concurrent since ratio is 1.0.
pruned: RefinedPrunedDFG = ( pruned: RefinedPrunedDFG = (
RefinedPrunedDFG( RefinedPrunedDFG(
dfg, self.__traces, 1.0 dfg, self.__traces, 0.7
) )
) )
b: Task = self.__tasks["B"] b: Task = self.__tasks["B"]
c: Task = self.__tasks["C"] c: Task = self.__tasks["C"]
self.assertTrue( self.assertFalse(
pruned.is_concurrent(b, c) pruned.is_concurrent(b, c)
) )
class TestSelfLoopOverlapCount(unittest.TestCase): 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 Per Equation 5, |A⊓B| is the total number of
where A and B have overlapping lifecycles. When observations of overlapping lifecycles of A and B.
a self-loop activity overlaps with another activity When a self-loop activity overlaps another activity
in both "directions" within one trace, it should twice within one trace, both observations count.
still count as 1 overlap, not 2.
""" """
def setUp(self) -> None: def setUp(self) -> None:
@@ -608,17 +596,15 @@ class TestSelfLoopOverlapCount(unittest.TestCase):
- (B,e): active={A} - (B,e): active={A}
- (A,e): active={} - (A,e): active={}
Correct: |A⊓B| = 1 (one trace with overlap). |A⊓B| = 2 in this trace: the first lifecycle of
Bug: overlap counted as 2 (both directions). A overlaps B, and B overlaps the second
lifecycle of A.
Trace 2 (freq 1): A and B sequential. Trace 2 (freq 1): A and B sequential.
As Ae Bs Be As Ae Bs Be
Correct totals: |A⊓B|=1, |A|=2, |B|=2. Totals: |A⊓B|=2, |A|=3, |B|=2.
Ratio = 2·1/(2+2) = 0.5. Ratio = 2·2/(3+2) = 0.8.
Bug totals: |A⊓B|=2 (double-counted).
Bug ratio = 2·2/(2+2) = 1.0.
:return: None. :return: None.
""" """
@@ -641,11 +627,85 @@ class TestSelfLoopOverlapCount(unittest.TestCase):
def test_not_concurrent_at_high_epsilon( def test_not_concurrent_at_high_epsilon(
self, self,
) -> None: ) -> 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. :return: None.
With the bug (double-counted overlap), """
ratio would be 2·2/(2+2) = 1.0 >= 0.6. 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. :return: None.
""" """
@@ -661,14 +721,18 @@ class TestSelfLoopOverlapCount(unittest.TestCase):
) )
a: Task = self.__tasks["A"] a: Task = self.__tasks["A"]
b: Task = self.__tasks["B"] b: Task = self.__tasks["B"]
self.assertFalse( self.assertTrue(
pruned.is_concurrent(a, b) pruned.is_concurrent(a, b)
) )
def test_concurrent_at_low_epsilon( def test_not_concurrent_above_ratio(
self, self,
) -> None: ) -> 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. :return: None.
""" """
@@ -679,7 +743,85 @@ class TestSelfLoopOverlapCount(unittest.TestCase):
) )
pruned: RefinedPrunedDFG = ( pruned: RefinedPrunedDFG = (
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"] a: Task = self.__tasks["A"]