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:
|
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
|
|
||||||
)
|
)
|
||||||
|
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":
|
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
|
|
||||||
# containing A).
|
|
||||||
for node in seen:
|
|
||||||
activity_count[node] = (
|
|
||||||
activity_count.get(node, 0) + count
|
|
||||||
)
|
)
|
||||||
# Add overlap counts (canonical pairs).
|
if not pending:
|
||||||
for pair in overlapped:
|
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[pair] = (
|
||||||
overlap_count.get(pair, 0) + count
|
overlap_count.get(pair, 0) + count
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user