Fix Equation 5 to count lifecycle observations instead of traces
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
)
|
||||
for other_id in ids:
|
||||
overlapped.append(
|
||||
(pair, other_id, started)
|
||||
)
|
||||
active.setdefault(node, []).append(
|
||||
started
|
||||
)
|
||||
if pair not in overlapped:
|
||||
overlapped.add(pair)
|
||||
active.add(node)
|
||||
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
|
||||
pending: list[int] = active.get(
|
||||
node, []
|
||||
)
|
||||
# Add overlap counts (canonical pairs).
|
||||
for pair in overlapped:
|
||||
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
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user