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:
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
)
+192 -50
View File
@@ -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"]