Spaces:
Sleeping
Sleeping
| """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 | |