"""Sanity tests for the Phase 2 domain modules. These don't try to match the JS implementation byte-for-byte because we deliberately swapped some approximations for scipy's exact versions (Jarque-Bera p-value, KS test, chi-squared p-value, etc.). Instead they check mathematical properties (monotonicity, symmetry, ranges) and a few golden hand-calculated values. """ from __future__ import annotations import json from pathlib import Path import numpy as np import pytest from app.domain.multi_expert import ( JUDGMENT_PRESETS, Expert, fuse_dempster_shafer, fuse_weighted_arithmetic_mean, fuse_weighted_geometric_mean, kendall_w, ) from app.domain.multi_param import ( Parameter, batch_compute, compare_scenarios, correlation_matrix, js_divergence, kl_divergence, pearson_correlation, spearman_correlation, wasserstein_distance, ) from app.domain.preprocessing import ( boxcox_transform, detect_outliers_iqr, detect_outliers_zscore, jarque_bera, ks_normal, log_transform, optimal_bin_count, shapiro_wilk, winsorize, ) SAMPLES_DIR = Path(__file__).resolve().parents[1] / "app" / "data_sources" / "samples" def _gdp(): return json.loads((SAMPLES_DIR / "sample-gdp.json").read_text())["values"] # --------------------------------------------------------------------------- # # Multi-expert # --------------------------------------------------------------------------- # def test_expert_validates_judgments(): with pytest.raises(ValueError): Expert("A", judgments=[0, 1, 2, 3]) # only 4 with pytest.raises(ValueError): Expert("B", judgments=[0, 1, 2, 3, 5]) # 5 out of range def test_geometric_fusion_equals_single_expert(): """With one expert, geometric mean == that expert's likelihood.""" e = Expert("solo", weight=2.0, judgments=[0, 1, 2, 3, 4]) fused = fuse_weighted_geometric_mean([e], R=10) expected = [10 ** k for k in (-2, -1, 0, 1, 2)] for a, b in zip(fused, expected, strict=True): assert a == pytest.approx(b, rel=1e-9) def test_geometric_vs_arithmetic_two_experts_uniform_weights(): """For identical experts, both fusion methods agree.""" e1 = Expert("A", judgments=[0, 1, 2, 3, 4]) e2 = Expert("B", judgments=[0, 1, 2, 3, 4]) g = fuse_weighted_geometric_mean([e1, e2], R=10) a = fuse_weighted_arithmetic_mean([e1, e2], R=10) for gi, ai in zip(g, a, strict=True): assert gi == pytest.approx(ai, rel=1e-9) def test_dempster_shafer_asymmetric_conflict(): """Two non-symmetric experts: the one with extreme certainty should pull the combined distribution toward its peak.""" e1 = Expert("A", judgments=[0, 1, 4, 4, 4]) # strong "high" + "very high" e2 = Expert("B", judgments=[0, 1, 2, 3, 4]) # gradual increase fused = fuse_dempster_shafer([e1, e2], R=5) assert sum(fused) == pytest.approx(1.0, abs=1e-9) # Combined mass should peak at "very high" (index 4) assert fused.index(max(fused)) == 4 def test_dempster_shafer_symmetric_opposite_yields_uniform(): """Mathematically correct: symmetric opposing likelihoods give uniform DS.""" e1 = Expert("A", judgments=[4, 3, 2, 1, 0]) e2 = Expert("B", judgments=[0, 1, 2, 3, 4]) fused = fuse_dempster_shafer([e1, e2], R=5) assert sum(fused) == pytest.approx(1.0, abs=1e-9) for w in fused: assert w == pytest.approx(0.2, abs=1e-6) def test_kendall_w_perfect_agreement(): e1 = Expert("A", judgments=[0, 1, 2, 3, 4]) e2 = Expert("B", judgments=[0, 1, 2, 3, 4]) e3 = Expert("C", judgments=[0, 1, 2, 3, 4]) r = kendall_w([e1, e2, e3]) assert r.W == pytest.approx(1.0, abs=1e-9) assert r.p_value < 0.05 def test_kendall_w_perfect_disagreement(): # Three experts that disagree pairwise as much as possible e1 = Expert("A", judgments=[0, 1, 2, 3, 4]) e2 = Expert("B", judgments=[4, 3, 2, 1, 0]) e3 = Expert("C", judgments=[2, 0, 4, 1, 3]) r = kendall_w([e1, e2, e3]) assert 0.0 <= r.W <= 1.0 # Likely no significant agreement assert r.p_value > 0.05 or r.W < 0.5 def test_presets_well_formed(): for _k, p in JUDGMENT_PRESETS.items(): assert isinstance(p["label"], str) assert len(p["judgments"]) == 5 # --------------------------------------------------------------------------- # # Multi-parameter # --------------------------------------------------------------------------- # def test_pearson_perfect_positive(): x = list(range(10)) y = [2 * v + 1 for v in x] assert pearson_correlation(x, y) == pytest.approx(1.0, abs=1e-9) def test_pearson_perfect_negative(): x = list(range(10)) y = [-3 * v for v in x] assert pearson_correlation(x, y) == pytest.approx(-1.0, abs=1e-9) def test_spearman_monotonic_nonlinear(): x = list(range(1, 11)) y = [v**2 for v in x] assert spearman_correlation(x, y) == pytest.approx(1.0, abs=1e-9) def test_correlation_matrix_symmetry(): p1 = Parameter("a", data=list(range(10))) p2 = Parameter("b", data=[v * 0.5 for v in range(10)]) p3 = Parameter("c", data=[10 - v for v in range(10)]) m = correlation_matrix([p1, p2, p3]) for i in range(3): assert m[i][i] == pytest.approx(1.0) for j in range(3): assert m[i][j] == pytest.approx(m[j][i]) def test_batch_compute_runs(): gdp = _gdp() p = Parameter("gdp", data=gdp, judgments=[0, 1, 2, 3, 4], R=10) out = batch_compute([p]) assert out[0].result is not None assert sum(out[0].result.posterior.weights) == pytest.approx(1.0, abs=1e-12) def test_kl_zero_for_identical_distributions(): p = [0.1, 0.2, 0.4, 0.2, 0.1] assert kl_divergence(p, p) == pytest.approx(0.0, abs=1e-9) def test_js_symmetric(): a = [0.1, 0.2, 0.4, 0.2, 0.1] b = [0.4, 0.3, 0.15, 0.1, 0.05] assert js_divergence(a, b) == pytest.approx(js_divergence(b, a), abs=1e-12) def test_wasserstein_known_value(): """Two delta distributions one unit apart -> W1 = 1.""" values = [0.0, 1.0] p = [1.0, 0.0] q = [0.0, 1.0] assert wasserstein_distance(p, q, values) == pytest.approx(1.0, abs=1e-9) def test_compare_scenarios_runs(): from app.domain.bayesian import compute gdp = _gdp() r1 = compute(gdp, [0, 1, 2, 3, 4], 10.0) r2 = compute(gdp, [4, 3, 2, 1, 0], 10.0) d = compare_scenarios(r1, r2) assert d.delta_mean < 0 # r2 (negative trend) should be lower assert d.kl > 0 assert d.js > 0 # --------------------------------------------------------------------------- # # Preprocessing # --------------------------------------------------------------------------- # def test_outliers_iqr_picks_extremes(): data = [0.0] * 100 + [100.0, -100.0] r = detect_outliers_iqr(data) assert 100.0 in r.values assert -100.0 in r.values assert r.count == 2 def test_outliers_zscore_picks_extremes(): np.random.seed(0) data = [*np.random.normal(0, 1, 1000).tolist(), 50.0, -50.0] r = detect_outliers_zscore(data, threshold=3.0) assert 50.0 in r.values assert -50.0 in r.values def test_winsorize_clips_extremes(): data = list(range(100)) out = winsorize(data, 0.05, 0.95) assert min(out) >= 4 assert max(out) <= 95 def test_jarque_bera_detects_normal(): np.random.seed(0) data = np.random.normal(0, 1, 2000) r = jarque_bera(data) assert r.is_normal def test_jarque_bera_detects_skewed(): np.random.seed(0) data = np.random.exponential(1.0, 2000) r = jarque_bera(data) assert not r.is_normal def test_shapiro_wilk_basic(): np.random.seed(0) r = shapiro_wilk(np.random.normal(0, 1, 200)) assert r.is_normal def test_ks_normal_basic(): np.random.seed(0) r = ks_normal(np.random.normal(0, 1, 500)) assert r.is_normal def test_log_transform_handles_non_positive(): data = [-2.0, -1.0, 0.0, 1.0, 2.0] r = log_transform(data) assert r.shifted_by == pytest.approx(3.0) assert len(r.values) == 5 assert all(v == v for v in r.values) # no NaN def test_boxcox_transform_falls_back_for_non_positive(): data = [-1.0, 0.0, 1.0, 2.0, 3.0] r = boxcox_transform(data) assert r.transform == "yeo-johnson" def test_optimal_bin_count(): np.random.seed(0) data = np.random.normal(0, 1, 500).tolist() bins_fd = optimal_bin_count(data, "fd") bins_sturges = optimal_bin_count(data, "sturges") bins_scott = optimal_bin_count(data, "scott") for b in (bins_fd, bins_sturges, bins_scott): assert 1 <= b <= 200