265 lines
9.3 KiB
Python
265 lines
9.3 KiB
Python
|
|
"""Tests for bex/reduce.py — Algorithm 4 (TODS 2010)."""
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from bex.reduce import (
|
||
|
|
build_soa_with_support, soa_distance, adjunct_support,
|
||
|
|
reduce_contexts, minimize_contexts, reduce_and_infer,
|
||
|
|
)
|
||
|
|
from bex.twotinf import build_soa
|
||
|
|
from bex.rwr0 import rwr0
|
||
|
|
|
||
|
|
|
||
|
|
class TestBuildSoaWithSupport:
|
||
|
|
"""Test support-annotated SOA building."""
|
||
|
|
|
||
|
|
def test_single_sequence(self):
|
||
|
|
seqs = [["a", "b", "c"]]
|
||
|
|
soa, supp = build_soa_with_support(seqs)
|
||
|
|
assert len(supp) > 0
|
||
|
|
# Should have edges: (src, a), (a, b), (b, c), (c, sink)
|
||
|
|
assert len(supp) == 4
|
||
|
|
|
||
|
|
def test_multiple_sequences_share_edges(self):
|
||
|
|
seqs = [["a", "b"], ["a", "b"]]
|
||
|
|
soa, supp = build_soa_with_support(seqs)
|
||
|
|
# Support keys are labeled tuples: (('SRC',), ('a',)), etc.
|
||
|
|
src_key = (('SRC',), ('a',))
|
||
|
|
assert src_key in supp
|
||
|
|
assert supp[src_key] == 2
|
||
|
|
|
||
|
|
def test_different_sequences(self):
|
||
|
|
seqs = [["a", "b"], ["a", "c"]]
|
||
|
|
soa, supp = build_soa_with_support(seqs)
|
||
|
|
# Both share (src, a) but diverge after
|
||
|
|
src_key = (('SRC',), ('a',))
|
||
|
|
assert src_key in supp
|
||
|
|
assert supp[src_key] == 2
|
||
|
|
|
||
|
|
def test_empty_sequence(self):
|
||
|
|
seqs = [[]]
|
||
|
|
soa, supp = build_soa_with_support(seqs)
|
||
|
|
empty_key = (('SRC',), ('SINK',))
|
||
|
|
assert empty_key in supp
|
||
|
|
|
||
|
|
|
||
|
|
class TestSoaDistance:
|
||
|
|
"""Test SOA edit distance (Definition 14)."""
|
||
|
|
|
||
|
|
def test_identical_soas(self):
|
||
|
|
seqs_a = [["a", "b"]]
|
||
|
|
seqs_b = [["a", "b"]]
|
||
|
|
_, supp_a = build_soa_with_support(seqs_a)
|
||
|
|
_, supp_b = build_soa_with_support(seqs_b)
|
||
|
|
assert soa_distance(supp_a, supp_b) == 0.0
|
||
|
|
|
||
|
|
def test_completely_disjoint(self):
|
||
|
|
seqs_a = [["a", "b"]]
|
||
|
|
seqs_b = [["x", "y"]]
|
||
|
|
_, supp_a = build_soa_with_support(seqs_a)
|
||
|
|
_, supp_b = build_soa_with_support(seqs_b)
|
||
|
|
dist = soa_distance(supp_a, supp_b)
|
||
|
|
# They share src->first and last->sink edges
|
||
|
|
# But the middle edges are completely different
|
||
|
|
assert dist > 0.5
|
||
|
|
|
||
|
|
def test_partial_overlap(self):
|
||
|
|
seqs_a = [["a", "b", "c"]]
|
||
|
|
seqs_b = [["a", "b", "d"]]
|
||
|
|
_, supp_a = build_soa_with_support(seqs_a)
|
||
|
|
_, supp_b = build_soa_with_support(seqs_b)
|
||
|
|
dist = soa_distance(supp_a, supp_b)
|
||
|
|
# Each SOA has 2 unique edges out of 4 total
|
||
|
|
# dist = 2/4 + 2/4 = 1.0 (formula sums both sides)
|
||
|
|
assert dist == 1.0
|
||
|
|
|
||
|
|
def test_asymmetric_support(self):
|
||
|
|
"""One SOA with high support shared edges, other with unique edges."""
|
||
|
|
seqs_a = [["a", "b"]] * 10 # (a,b) has support 10
|
||
|
|
seqs_b = [["a", "c"]] # (a,c) has support 1
|
||
|
|
_, supp_a = build_soa_with_support(seqs_a)
|
||
|
|
_, supp_b = build_soa_with_support(seqs_b)
|
||
|
|
dist = soa_distance(supp_a, supp_b)
|
||
|
|
# dist_a = 0 (all A edges in B? no - (a,b) not in B)
|
||
|
|
# Actually: E_A = {(SRC,a):10, (a,b):10, (b,SINK):10}
|
||
|
|
# F_B = {(SRC,a):1, (a,c):1, (c,SINK):1}
|
||
|
|
# E-F = {(a,b):10, (b,SINK):10} → only_a = 20, total_a = 30 → 2/3
|
||
|
|
# F-E = {(a,c):1, (c,SINK):1} → only_b = 2, total_b = 3 → 2/3
|
||
|
|
# dist = 2/3 + 2/3 ≈ 1.33... wait, that can't be right
|
||
|
|
# Actually dist can exceed 1.0 because it's two separate fractions summed
|
||
|
|
assert dist > 0.5
|
||
|
|
|
||
|
|
def test_symmetry(self):
|
||
|
|
seqs_a = [["a", "b", "c"]]
|
||
|
|
seqs_b = [["x", "y", "z"]]
|
||
|
|
_, supp_a = build_soa_with_support(seqs_a)
|
||
|
|
_, supp_b = build_soa_with_support(seqs_b)
|
||
|
|
assert soa_distance(supp_a, supp_b) == soa_distance(supp_b, supp_a)
|
||
|
|
|
||
|
|
def test_support_weighted(self):
|
||
|
|
# Edge with high support should contribute more to distance
|
||
|
|
seqs_a = [["a", "b"]] * 10 + [["a", "c"]]
|
||
|
|
seqs_b = [["a", "b"]] * 10 + [["a", "d"]]
|
||
|
|
_, supp_a = build_soa_with_support(seqs_a)
|
||
|
|
_, supp_b = build_soa_with_support(seqs_b)
|
||
|
|
dist = soa_distance(supp_a, supp_b)
|
||
|
|
# Distance should be small because (a,b) has high support in both
|
||
|
|
assert dist < 0.2
|
||
|
|
|
||
|
|
|
||
|
|
class TestAdjunctSupport:
|
||
|
|
"""Test SOA adjunction (line 6, Algorithm 4)."""
|
||
|
|
|
||
|
|
def test_basic_adjunction(self):
|
||
|
|
supp_a = {(0, 1): 5, (1, 2): 3}
|
||
|
|
supp_b = {(0, 1): 2, (1, 3): 4}
|
||
|
|
combined = adjunct_support(supp_a, supp_b)
|
||
|
|
assert combined[(0, 1)] == 7 # 5 + 2
|
||
|
|
assert combined[(1, 2)] == 3
|
||
|
|
assert combined[(1, 3)] == 4
|
||
|
|
|
||
|
|
def test_disjoint_edges(self):
|
||
|
|
supp_a = {(0, 1): 1}
|
||
|
|
supp_b = {(2, 3): 1}
|
||
|
|
combined = adjunct_support(supp_a, supp_b)
|
||
|
|
assert len(combined) == 2
|
||
|
|
|
||
|
|
def test_empty(self):
|
||
|
|
supp_a = {}
|
||
|
|
supp_b = {}
|
||
|
|
combined = adjunct_support(supp_a, supp_b)
|
||
|
|
assert combined == {}
|
||
|
|
|
||
|
|
|
||
|
|
class TestReduceContexts:
|
||
|
|
"""Test Reduce algorithm (Algorithm 4)."""
|
||
|
|
|
||
|
|
def test_no_merge_high_threshold(self):
|
||
|
|
"""With very high threshold, nothing should merge."""
|
||
|
|
contexts = {
|
||
|
|
"a": [["x", "y"]] * 5,
|
||
|
|
"b": [["p", "q"]] * 5,
|
||
|
|
}
|
||
|
|
merged, info = reduce_contexts(contexts, threshold=0.99)
|
||
|
|
assert info["merges"] == 0
|
||
|
|
assert len(merged) == 2
|
||
|
|
|
||
|
|
def test_no_merge_identical(self):
|
||
|
|
"""Identical contexts (dist=0) are NOT merged by Reduce.
|
||
|
|
|
||
|
|
From the paper: M := {(s, t) | 0 < distD(s, t) < ε}
|
||
|
|
dist=0 means already equivalent → unified by Minimize, not Reduce.
|
||
|
|
"""
|
||
|
|
contexts = {
|
||
|
|
"a": [["x", "y"]] * 5,
|
||
|
|
"b": [["x", "y"]] * 5,
|
||
|
|
}
|
||
|
|
merged, info = reduce_contexts(contexts, threshold=0.5)
|
||
|
|
assert info["merges"] == 0 # No merge for identical contexts
|
||
|
|
assert len(merged) == 2
|
||
|
|
|
||
|
|
def test_merge_similar(self):
|
||
|
|
"""Similar contexts (0 < dist < threshold) should merge.
|
||
|
|
|
||
|
|
Use asymmetric support: many shared sequences, few unique ones.
|
||
|
|
This makes the unique edges have low support relative to shared edges.
|
||
|
|
"""
|
||
|
|
seqs_a = [["a", "b"]] * 10 + [["a", "c"]] # 10 shared, 1 unique
|
||
|
|
seqs_b = [["a", "b"]] * 10 + [["a", "d"]] # 10 shared, 1 unique
|
||
|
|
contexts = {
|
||
|
|
"a": seqs_a,
|
||
|
|
"b": seqs_b,
|
||
|
|
}
|
||
|
|
merged, info = reduce_contexts(contexts, threshold=0.5)
|
||
|
|
# dist ≈ 0.12 (2/33 + 2/33), well below threshold 0.5
|
||
|
|
assert info["merges"] >= 1
|
||
|
|
|
||
|
|
def test_small_contexts_preserved(self):
|
||
|
|
"""Contexts with < 2 sequences should be preserved."""
|
||
|
|
contexts = {
|
||
|
|
"a": [["x", "y"]], # Only 1 sequence
|
||
|
|
"b": [["p", "q"]] * 5,
|
||
|
|
}
|
||
|
|
merged, info = reduce_contexts(contexts, threshold=0.5)
|
||
|
|
assert "a" in merged # Preserved
|
||
|
|
assert "b" in merged
|
||
|
|
|
||
|
|
def test_merge_info_contains_log(self):
|
||
|
|
"""Merge log should record each merge."""
|
||
|
|
contexts = {
|
||
|
|
"a": [["a", "b", "c"]] * 5,
|
||
|
|
"b": [["a", "b", "d"]] * 5,
|
||
|
|
}
|
||
|
|
merged, info = reduce_contexts(contexts, threshold=0.5)
|
||
|
|
if info["merges"] > 0:
|
||
|
|
assert len(info["merge_log"]) == 1
|
||
|
|
assert "merged_into" in info["merge_log"][0]
|
||
|
|
assert "removed" in info["merge_log"][0]
|
||
|
|
|
||
|
|
def test_iterative_merging(self):
|
||
|
|
"""Multiple iterations should work correctly."""
|
||
|
|
# Create contexts that form a chain: a~b, b~c, but a!~c
|
||
|
|
contexts = {
|
||
|
|
"a": [["a", "b"]] * 5,
|
||
|
|
"b": [["a", "b", "c"]] * 5,
|
||
|
|
"c": [["a", "b", "c", "d"]] * 5,
|
||
|
|
}
|
||
|
|
merged, info = reduce_contexts(contexts, threshold=0.6)
|
||
|
|
# Should merge at least some pairs
|
||
|
|
assert info["iterations"] >= 1
|
||
|
|
|
||
|
|
|
||
|
|
class TestMinimizeContexts:
|
||
|
|
"""Test Minimize (Line 15, Algorithm 4)."""
|
||
|
|
|
||
|
|
def test_merge_same_sore(self):
|
||
|
|
"""Contexts with identical SOREs should be merged."""
|
||
|
|
# Both contexts produce the same SORE
|
||
|
|
contexts = {
|
||
|
|
"a": [["mockk"]] * 5,
|
||
|
|
"b": [["mockk"]] * 5,
|
||
|
|
}
|
||
|
|
minimized = minimize_contexts(contexts)
|
||
|
|
# Should merge into one context
|
||
|
|
assert len(minimized) == 1
|
||
|
|
total_seqs = sum(len(v) for v in minimized.values())
|
||
|
|
assert total_seqs == 10
|
||
|
|
|
||
|
|
def test_keep_different_sores(self):
|
||
|
|
"""Contexts with different SOREs should stay separate."""
|
||
|
|
contexts = {
|
||
|
|
"a": [["a", "b"]] * 5,
|
||
|
|
"b": [["x", "y"]] * 5,
|
||
|
|
}
|
||
|
|
minimized = minimize_contexts(contexts)
|
||
|
|
assert len(minimized) == 2
|
||
|
|
|
||
|
|
def test_empty_contexts(self):
|
||
|
|
"""Empty contexts should be handled gracefully."""
|
||
|
|
contexts = {}
|
||
|
|
minimized = minimize_contexts(contexts)
|
||
|
|
assert len(minimized) == 0
|
||
|
|
|
||
|
|
|
||
|
|
class TestReduceAndInfer:
|
||
|
|
"""Test full pipeline: Reduce + Minimize + Infer."""
|
||
|
|
|
||
|
|
def test_basic_pipeline(self):
|
||
|
|
contexts = {
|
||
|
|
"a": [["a", "b", "c"]] * 5,
|
||
|
|
"b": [["a", "b", "d"]] * 5,
|
||
|
|
}
|
||
|
|
result = reduce_and_infer(contexts, threshold=0.5)
|
||
|
|
assert "merged" in result
|
||
|
|
assert "infer_results" in result
|
||
|
|
assert "merge_info" in result
|
||
|
|
assert "minimize_info" in result
|
||
|
|
|
||
|
|
def test_coverage_count(self):
|
||
|
|
"""Coverage count should equal total methods in successful contexts."""
|
||
|
|
contexts = {
|
||
|
|
"a": [["a", "b"]] * 5,
|
||
|
|
"b": [["x", "y"]] * 5,
|
||
|
|
}
|
||
|
|
result = reduce_and_infer(contexts, threshold=0.99) # No merging
|
||
|
|
assert result["coverage_count"] >= 0
|