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