Fix Equation 5 to count lifecycle observations instead of traces
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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