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
+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"]