"""Unit tests for v3 macro and competitive layers. Tests the noisy-OR normalized macro exposure, resilience dampener, macro LLR computation, shrunk correlation convergence, competitive LLR clamping, and graph-distance attenuation. Requirements validated: 9.1–9.5, 10.1–10.5 """ from __future__ import annotations import math import pytest from services.aggregation.interpolation import ( compute_macro_llr, compute_normalized_macro_exposure, ) from services.aggregation.signal_propagation import ( compute_competitive_llr, compute_shrunk_correlation, ) # --------------------------------------------------------------------------- # Noisy-OR: compute_normalized_macro_exposure # --------------------------------------------------------------------------- class TestNoisyORExposure: """Tests for noisy-OR normalized macro exposure (Req 9.1, 9.2, 9.3).""" def test_all_overlaps_max_regional(self): """All O_k = 1.0 with regional tier → E_macro = 1.0.""" overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0} result = compute_normalized_macro_exposure(overlaps, tier="regional") assert result == pytest.approx(1.0, abs=1e-9) def test_all_overlaps_zero(self): """All O_k = 0 → E_macro = 0.0.""" overlaps = {"geo": 0.0, "supply": 0.0, "commodity": 0.0, "sector": 0.0} result = compute_normalized_macro_exposure(overlaps, tier="regional") assert result == pytest.approx(0.0, abs=1e-9) def test_empty_overlaps(self): """Empty overlaps dict → E_macro = 0.0.""" result = compute_normalized_macro_exposure({}, tier="regional") assert result == pytest.approx(0.0, abs=1e-9) def test_single_dimension_geo(self): """Only geo overlap → partial exposure.""" overlaps = {"geo": 1.0} result = compute_normalized_macro_exposure(overlaps, tier="regional") # E_raw = 1 - (1-0.35*1)(1-0.25*0)(1-0.25*0)(1-0.15*0) = 1 - 0.65 = 0.35 # E_max = 1 - (0.65)(0.75)(0.75)(0.85) = 1 - 0.311484375 ≈ 0.688515625 # E_macro = 0.35 / 0.688515625 ≈ 0.508 expected_e_raw = 0.35 e_max = 1.0 - (0.65 * 0.75 * 0.75 * 0.85) expected = expected_e_raw / e_max assert result == pytest.approx(expected, rel=1e-6) def test_partial_overlaps(self): """Partial overlaps produce intermediate exposure.""" overlaps = {"geo": 0.5, "supply": 0.3, "commodity": 0.0, "sector": 0.8} result = compute_normalized_macro_exposure(overlaps, tier="regional") # Should be between 0 and 1 assert 0.0 < result < 1.0 # --------------------------------------------------------------------------- # Resilience dampener per tier # --------------------------------------------------------------------------- class TestResilienceDampener: """Tests for resilience dampener application (Req 9.3).""" def test_global_leader_dampener(self): """Global leader tier dampens exposure by 0.70.""" overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0} result = compute_normalized_macro_exposure(overlaps, tier="global_leader") # E_macro = 1.0 * 0.70 = 0.70 assert result == pytest.approx(0.70, abs=1e-9) def test_multinational_dampener(self): """Multinational tier dampens exposure by 0.85.""" overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0} result = compute_normalized_macro_exposure(overlaps, tier="multinational") assert result == pytest.approx(0.85, abs=1e-9) def test_regional_dampener(self): """Regional tier has no dampening (1.00).""" overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0} result = compute_normalized_macro_exposure(overlaps, tier="regional") assert result == pytest.approx(1.00, abs=1e-9) def test_domestic_amplifier(self): """Domestic tier amplifies exposure by 1.20.""" overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0} result = compute_normalized_macro_exposure(overlaps, tier="domestic") assert result == pytest.approx(1.20, abs=1e-9) def test_unknown_tier_no_dampening(self): """Unknown tier defaults to 1.0 dampener.""" overlaps = {"geo": 1.0, "supply": 1.0, "commodity": 1.0, "sector": 1.0} result = compute_normalized_macro_exposure(overlaps, tier="unknown_tier") assert result == pytest.approx(1.00, abs=1e-9) # --------------------------------------------------------------------------- # Macro LLR at boundary values # --------------------------------------------------------------------------- class TestMacroLLR: """Tests for macro LLR computation (Req 9.4, 9.5).""" def test_max_positive_inputs(self): """macro_impact=1, event_conf=1, q_recency=1, direction=+1 → p_macro=0.80.""" llr = compute_macro_llr( macro_impact=1.0, event_confidence=1.0, q_recency=1.0, macro_direction=1, ) # p_macro = 0.50 + 0.30*1*1*1 = 0.80 expected = math.log(0.80 / 0.20) # ≈ 1.386 assert llr == pytest.approx(expected, rel=1e-6) def test_max_negative_inputs(self): """All max with direction=-1 → negative LLR.""" llr = compute_macro_llr( macro_impact=1.0, event_confidence=1.0, q_recency=1.0, macro_direction=-1, ) expected = -math.log(0.80 / 0.20) assert llr == pytest.approx(expected, rel=1e-6) def test_neutral_direction_zero_llr(self): """direction=0 → LLR=0.0 regardless of other inputs.""" llr = compute_macro_llr( macro_impact=1.0, event_confidence=1.0, q_recency=1.0, macro_direction=0, ) assert llr == 0.0 def test_minimum_p_macro_clamp(self): """All impact factors zero → p_macro clamped to 0.501.""" llr = compute_macro_llr( macro_impact=0.0, event_confidence=0.0, q_recency=0.0, macro_direction=1, ) # p_macro = 0.50 + 0 = 0.50, clamped up to 0.501 expected = math.log(0.501 / (1.0 - 0.501)) assert llr == pytest.approx(expected, rel=1e-6) def test_mid_range_inputs(self): """Intermediate inputs produce reasonable LLR.""" llr = compute_macro_llr( macro_impact=0.5, event_confidence=0.7, q_recency=0.8, macro_direction=1, ) # p_macro = 0.50 + 0.30 * 0.5 * 0.7 * 0.8 = 0.50 + 0.084 = 0.584 p_macro = 0.584 expected = math.log(p_macro / (1.0 - p_macro)) assert llr == pytest.approx(expected, rel=1e-6) # --------------------------------------------------------------------------- # Shrunk correlation convergence # --------------------------------------------------------------------------- class TestShrunkCorrelation: """Tests for shrinkage-adjusted correlation (Req 10.1, 10.2).""" def test_large_n_approaches_rho_rolling(self): """n=1000 with same_sector → result ≈ rho_rolling.""" rho_rolling = 0.65 result = compute_shrunk_correlation( rho_rolling=rho_rolling, n_observations=1000, same_sector=True, ) # (1000/1030) × 0.65 + (30/1030) × 0.30 ≈ 0.6311 + 0.00874 ≈ 0.6398 weight_data = 1000 / 1030 weight_prior = 30 / 1030 expected = weight_data * rho_rolling + weight_prior * 0.30 assert result == pytest.approx(expected, rel=1e-6) # Should be close to rho_rolling assert abs(result - rho_rolling) < 0.02 def test_zero_observations_returns_prior(self): """n=0 → result = prior (same_sector: 0.30, cross_sector: 0.10).""" # same sector result_same = compute_shrunk_correlation( rho_rolling=0.9, n_observations=0, same_sector=True, ) # (0/30) × 0.9 + (30/30) × 0.30 = 0.30 assert result_same == pytest.approx(0.30, abs=1e-9) # cross sector result_cross = compute_shrunk_correlation( rho_rolling=0.9, n_observations=0, same_sector=False, ) # (0/30) × 0.9 + (30/30) × 0.10 = 0.10 assert result_cross == pytest.approx(0.10, abs=1e-9) def test_cross_sector_prior(self): """Cross-sector uses prior = 0.10.""" result = compute_shrunk_correlation( rho_rolling=0.50, n_observations=30, same_sector=False, ) # (30/60) × 0.50 + (30/60) × 0.10 = 0.25 + 0.05 = 0.30 assert result == pytest.approx(0.30, abs=1e-9) def test_negative_rolling_floored_at_zero(self): """Negative rolling correlation → rho_effective floored at 0.""" result = compute_shrunk_correlation( rho_rolling=-0.50, n_observations=100, same_sector=False, ) # (100/130)×(-0.50) + (30/130)×0.10 = -0.3846 + 0.0231 ≈ -0.3615 # Floored at 0 assert result == 0.0 def test_n_30_equal_weight(self): """n=30 → data and prior have equal weight.""" rho_rolling = 0.80 result = compute_shrunk_correlation( rho_rolling=rho_rolling, n_observations=30, same_sector=True, ) # (30/60)×0.80 + (30/60)×0.30 = 0.40 + 0.15 = 0.55 assert result == pytest.approx(0.55, abs=1e-9) # --------------------------------------------------------------------------- # Competitive LLR clamp at ±1.25 # --------------------------------------------------------------------------- class TestCompetitiveLLRClamp: """Tests for competitive LLR clamping (Req 10.3, 10.4, 10.5).""" def test_large_positive_clamped(self): """Large positive inputs → clamped to +1.25.""" result = compute_competitive_llr( llr_source=10.0, rho_effective=0.9, d_network=1, pattern_confidence=1.0, ) assert result == pytest.approx(1.25, abs=1e-9) def test_large_negative_clamped(self): """Large negative inputs → clamped to -1.25.""" result = compute_competitive_llr( llr_source=-10.0, rho_effective=0.9, d_network=1, pattern_confidence=1.0, ) assert result == pytest.approx(-1.25, abs=1e-9) def test_within_bounds_not_clamped(self): """Small inputs produce unclamped result.""" # attenuation = 0.5 × exp(-0.85 × 1) ≈ 0.5 × 0.4274 ≈ 0.2137 # LLR_competitive = 1.0 × 0.2137 × 0.8 ≈ 0.1710 result = compute_competitive_llr( llr_source=1.0, rho_effective=0.5, d_network=1, pattern_confidence=0.8, ) expected = 1.0 * 0.5 * math.exp(-0.85 * 1) * 0.8 assert result == pytest.approx(expected, rel=1e-6) assert abs(result) < 1.25 def test_zero_rho_gives_zero(self): """Zero correlation → zero competitive LLR.""" result = compute_competitive_llr( llr_source=5.0, rho_effective=0.0, d_network=1, pattern_confidence=1.0, ) assert result == pytest.approx(0.0, abs=1e-9) # --------------------------------------------------------------------------- # Distance > 3 → zero attenuation # --------------------------------------------------------------------------- class TestDistanceAttenuation: """Tests for graph distance cutoff (Req 10.5).""" def test_distance_4_returns_zero(self): """d_network=4 → LLR_competitive = 0.0.""" result = compute_competitive_llr( llr_source=5.0, rho_effective=0.9, d_network=4, pattern_confidence=1.0, ) assert result == 0.0 def test_distance_10_returns_zero(self): """Very large distance → LLR_competitive = 0.0.""" result = compute_competitive_llr( llr_source=5.0, rho_effective=0.9, d_network=10, pattern_confidence=1.0, ) assert result == 0.0 def test_distance_3_still_active(self): """d_network=3 (max allowed) → non-zero result.""" result = compute_competitive_llr( llr_source=2.0, rho_effective=0.8, d_network=3, pattern_confidence=0.9, ) # attenuation = 0.8 × exp(-0.85 × 3) ≈ 0.8 × 0.0776 ≈ 0.0621 # LLR_competitive = 2.0 × 0.0621 × 0.9 ≈ 0.1118 expected = 2.0 * 0.8 * math.exp(-0.85 * 3) * 0.9 assert result == pytest.approx(expected, rel=1e-6) assert result != 0.0 def test_distance_1_strongest(self): """d_network=1 gives strongest attenuation (least decay).""" result_d1 = compute_competitive_llr( llr_source=2.0, rho_effective=0.8, d_network=1, pattern_confidence=0.9, ) result_d2 = compute_competitive_llr( llr_source=2.0, rho_effective=0.8, d_network=2, pattern_confidence=0.9, ) result_d3 = compute_competitive_llr( llr_source=2.0, rho_effective=0.8, d_network=3, pattern_confidence=0.9, ) assert result_d1 > result_d2 > result_d3 > 0.0