"""Unit tests for v3 LLR entropy contradiction score. Tests compute_v3_contradiction from services.aggregation.contradiction. Requirements validated: 7.1–7.7 """ from __future__ import annotations import math import pytest from services.aggregation.contradiction import compute_v3_contradiction from services.aggregation.worker import EvidenceCluster def _make_cluster(cluster_llr: float) -> EvidenceCluster: """Helper to create a minimal EvidenceCluster with a given cluster_llr.""" return EvidenceCluster( cluster_id="test", units=[], llrs=[], n_eff=1.0, cluster_llr=cluster_llr ) # --------------------------------------------------------------------------- # 1. All bullish (unidirectional positive) → contradiction = 0.0 # --------------------------------------------------------------------------- class TestUnidirectionalPositive: """When all cluster LLRs are positive, there is no contradiction.""" def test_all_bullish(self): clusters = [_make_cluster(1.0), _make_cluster(0.5), _make_cluster(0.8)] assert compute_v3_contradiction(clusters) == 0.0 def test_single_bullish(self): clusters = [_make_cluster(2.0)] assert compute_v3_contradiction(clusters) == 0.0 # --------------------------------------------------------------------------- # 2. All bearish (unidirectional negative) → contradiction = 0.0 # --------------------------------------------------------------------------- class TestUnidirectionalNegative: """When all cluster LLRs are negative, there is no contradiction.""" def test_all_bearish(self): clusters = [_make_cluster(-1.0), _make_cluster(-0.5)] assert compute_v3_contradiction(clusters) == 0.0 def test_single_bearish(self): clusters = [_make_cluster(-2.5)] assert compute_v3_contradiction(clusters) == 0.0 # --------------------------------------------------------------------------- # 3. Equal split → high contradiction near 1.0 # --------------------------------------------------------------------------- class TestEqualSplit: """Equal positive and negative evidence produces maximum entropy (H=1.0), modulated by the volume factor.""" def test_equal_split_moderate_mass(self): """LLR +2.0 and -2.0: E_pos=2, E_neg=2, E_total=4. H_conflict = 1.0 (50/50 split). volume_factor = 1 - exp(-4/3) ≈ 0.7364. contradiction ≈ 0.7364. """ clusters = [_make_cluster(2.0), _make_cluster(-2.0)] result = compute_v3_contradiction(clusters) expected_volume = 1.0 - math.exp(-4.0 / 3.0) expected = 1.0 * expected_volume # H_conflict = 1.0 for equal split assert result == pytest.approx(expected, abs=1e-4) def test_equal_split_large_mass(self): """LLR +5.0 and -5.0: E_total=10, volume_factor → ~0.964. contradiction near 1.0. """ clusters = [_make_cluster(5.0), _make_cluster(-5.0)] result = compute_v3_contradiction(clusters) expected_volume = 1.0 - math.exp(-10.0 / 3.0) expected = 1.0 * expected_volume assert result == pytest.approx(expected, abs=1e-4) assert result > 0.9 # Near 1.0 due to large evidence mass # --------------------------------------------------------------------------- # 4. E_total = 0 → contradiction = 0.0 # --------------------------------------------------------------------------- class TestZeroEvidence: """When all cluster LLRs are zero, E_total=0, contradiction is 0.""" def test_all_zero_llrs(self): clusters = [_make_cluster(0.0), _make_cluster(0.0), _make_cluster(0.0)] assert compute_v3_contradiction(clusters) == 0.0 def test_single_zero_llr(self): clusters = [_make_cluster(0.0)] assert compute_v3_contradiction(clusters) == 0.0 # --------------------------------------------------------------------------- # 5. Empty clusters → contradiction = 0.0 # --------------------------------------------------------------------------- class TestEmptyClusters: """Empty cluster list returns 0.0.""" def test_empty_list(self): assert compute_v3_contradiction([]) == 0.0 # --------------------------------------------------------------------------- # 6. Small evidence mass → suppressed score (volume_factor effect) # --------------------------------------------------------------------------- class TestSmallEvidenceMass: """Small E_total leads to a small volume_factor that suppresses the score.""" def test_tiny_equal_split(self): """LLR +0.1 and -0.1: E_total=0.2. H_conflict = 1.0 (equal split). volume_factor = 1 - exp(-0.2/3) ≈ 0.0645. contradiction ≈ 0.0645 (suppressed). """ clusters = [_make_cluster(0.1), _make_cluster(-0.1)] result = compute_v3_contradiction(clusters) expected_volume = 1.0 - math.exp(-0.2 / 3.0) expected = 1.0 * expected_volume assert result == pytest.approx(expected, abs=1e-4) # Confirm suppression: score well below 0.1 assert result < 0.1 # --------------------------------------------------------------------------- # 7. Large evidence mass → volume_factor approaches 1.0 # --------------------------------------------------------------------------- class TestLargeEvidenceMass: """Large E_total pushes volume_factor near 1.0, so score ≈ H_conflict.""" def test_large_equal_split(self): """LLR +5.0 and -5.0: E_total=10, volume_factor ≈ 0.964. contradiction ≈ 0.964 (near maximum). """ clusters = [_make_cluster(5.0), _make_cluster(-5.0)] result = compute_v3_contradiction(clusters) # Volume factor should be very close to 1.0 volume_factor = 1.0 - math.exp(-10.0 / 3.0) assert volume_factor > 0.95 assert result > 0.95 def test_asymmetric_large_mass(self): """LLR +4.0 and -1.0: E_pos=4, E_neg=1, E_total=5. f_pos=0.8, f_neg=0.2. H_conflict = -0.8×log2(0.8) - 0.2×log2(0.2) ≈ 0.7219. volume_factor = 1 - exp(-5/3) ≈ 0.8111. contradiction ≈ 0.586. """ clusters = [_make_cluster(4.0), _make_cluster(-1.0)] result = compute_v3_contradiction(clusters) f_pos = 4.0 / 5.0 f_neg = 1.0 / 5.0 h_conflict = -f_pos * math.log2(f_pos) - f_neg * math.log2(f_neg) volume_factor = 1.0 - math.exp(-5.0 / 3.0) expected = h_conflict * volume_factor assert result == pytest.approx(expected, abs=1e-4)