""" Tests for benchmarks/metrics/statistics.py Tests cover: - normal_quantile inverse CDF - wilson_score_interval confidence intervals - proportion_std_error - sample_size_for_difference power analysis - Integration: calculate_metrics returns accuracy_ci """ import json import pytest class TestNormalQuantile: """Tests for the normal_quantile function.""" def test_median(self): from local_deep_research.benchmarks.metrics.statistics import ( normal_quantile, ) assert abs(normal_quantile(0.5) - 0.0) < 1e-6 def test_975_gives_196(self): from local_deep_research.benchmarks.metrics.statistics import ( normal_quantile, ) assert abs(normal_quantile(0.975) - 1.96) < 0.001 def test_025_gives_negative_196(self): from local_deep_research.benchmarks.metrics.statistics import ( normal_quantile, ) assert abs(normal_quantile(0.025) - (-1.96)) < 0.001 def test_symmetry(self): from local_deep_research.benchmarks.metrics.statistics import ( normal_quantile, ) for p in [0.01, 0.05, 0.1, 0.25]: assert abs(normal_quantile(p) + normal_quantile(1 - p)) < 1e-6 def test_raises_on_zero(self): from local_deep_research.benchmarks.metrics.statistics import ( normal_quantile, ) with pytest.raises(ValueError): normal_quantile(0.0) def test_raises_on_one(self): from local_deep_research.benchmarks.metrics.statistics import ( normal_quantile, ) with pytest.raises(ValueError): normal_quantile(1.0) def test_raises_on_negative(self): from local_deep_research.benchmarks.metrics.statistics import ( normal_quantile, ) with pytest.raises(ValueError): normal_quantile(-0.5) def test_extreme_tails(self): from local_deep_research.benchmarks.metrics.statistics import ( normal_quantile, ) # p=0.001 -> z ~ -3.09 assert abs(normal_quantile(0.001) - (-3.09)) < 0.01 # p=0.999 -> z ~ 3.09 assert abs(normal_quantile(0.999) - 3.09) < 0.01 class TestWilsonScoreInterval: """Tests for the wilson_score_interval function.""" def test_91_of_100(self): """91/100 at 95% should give approximately [0.837, 0.955].""" from local_deep_research.benchmarks.metrics.statistics import ( wilson_score_interval, ) ci = wilson_score_interval(91, 100) assert abs(ci["lower"] - 0.837) < 0.005 assert abs(ci["upper"] - 0.955) < 0.005 assert ci["sample_size"] == 100 def test_perfect_score(self): """100/100 should NOT give [1.0, 1.0] (unlike normal approx).""" from local_deep_research.benchmarks.metrics.statistics import ( wilson_score_interval, ) ci = wilson_score_interval(100, 100) assert ci["upper"] == 1.0 assert ci["lower"] < 1.0 # Wilson correctly shows uncertainty assert ci["lower"] > 0.95 def test_zero_score(self): """0/100 should NOT give [0.0, 0.0] (unlike normal approx).""" from local_deep_research.benchmarks.metrics.statistics import ( wilson_score_interval, ) ci = wilson_score_interval(0, 100) assert ci["lower"] == 0.0 assert ci["upper"] > 0.0 # Wilson correctly shows uncertainty assert ci["upper"] < 0.05 def test_zero_total(self): """0/0 should return zeros without error.""" from local_deep_research.benchmarks.metrics.statistics import ( wilson_score_interval, ) ci = wilson_score_interval(0, 0) assert ci["lower"] == 0.0 assert ci["upper"] == 0.0 assert ci["margin_of_error"] == 0.0 assert ci["sample_size"] == 0 def test_raises_on_successes_greater_than_total(self): """successes > total should raise ValueError.""" from local_deep_research.benchmarks.metrics.statistics import ( wilson_score_interval, ) with pytest.raises(ValueError, match="successes must be in"): wilson_score_interval(110, 100) def test_raises_on_negative_successes(self): """Negative successes should raise ValueError.""" from local_deep_research.benchmarks.metrics.statistics import ( wilson_score_interval, ) with pytest.raises(ValueError, match="successes must be in"): wilson_score_interval(-1, 100) def test_single_observation(self): """1/1 should give a wide interval.""" from local_deep_research.benchmarks.metrics.statistics import ( wilson_score_interval, ) ci = wilson_score_interval(1, 1) assert ci["lower"] < 0.5 assert ci["upper"] == 1.0 def test_bounds_within_0_1(self): """Bounds should always be in [0, 1].""" from local_deep_research.benchmarks.metrics.statistics import ( wilson_score_interval, ) for successes, total in [ (0, 10), (10, 10), (5, 10), (1, 100), (99, 100), ]: ci = wilson_score_interval(successes, total) assert 0.0 <= ci["lower"] <= ci["upper"] <= 1.0 def test_higher_n_narrower_interval(self): """Larger sample size should give narrower CI at same proportion.""" from local_deep_research.benchmarks.metrics.statistics import ( wilson_score_interval, ) ci_small = wilson_score_interval(9, 10) ci_large = wilson_score_interval(90, 100) assert ci_large["margin_of_error"] < ci_small["margin_of_error"] def test_90_confidence_narrower_than_99(self): """90% CI should be narrower than 99% CI.""" from local_deep_research.benchmarks.metrics.statistics import ( wilson_score_interval, ) ci_90 = wilson_score_interval(50, 100, confidence=0.90) ci_99 = wilson_score_interval(50, 100, confidence=0.99) assert ci_90["margin_of_error"] < ci_99["margin_of_error"] class TestProportionStdError: """Tests for the proportion_std_error function.""" def test_basic(self): from local_deep_research.benchmarks.metrics.statistics import ( proportion_std_error, ) # p=0.5, n=100 -> sqrt(0.25/100) = 0.05 assert abs(proportion_std_error(0.5, 100) - 0.05) < 1e-10 def test_zero_n(self): from local_deep_research.benchmarks.metrics.statistics import ( proportion_std_error, ) assert proportion_std_error(0.5, 0) == 0.0 def test_extreme_proportions(self): from local_deep_research.benchmarks.metrics.statistics import ( proportion_std_error, ) assert proportion_std_error(0.0, 100) == 0.0 assert proportion_std_error(1.0, 100) == 0.0 def test_raises_on_invalid_p(self): from local_deep_research.benchmarks.metrics.statistics import ( proportion_std_error, ) with pytest.raises(ValueError, match="p must be in"): proportion_std_error(-0.1, 100) with pytest.raises(ValueError, match="p must be in"): proportion_std_error(1.5, 100) class TestSampleSizeForDifference: """Tests for the sample_size_for_difference function.""" def test_5pp_difference(self): """5pp difference (85% vs 90%) should need ~680 per group.""" from local_deep_research.benchmarks.metrics.statistics import ( sample_size_for_difference, ) n = sample_size_for_difference(0.85, 0.90) assert 600 <= n <= 760 def test_10pp_difference(self): """10pp difference (80% vs 90%) should need ~200 per group.""" from local_deep_research.benchmarks.metrics.statistics import ( sample_size_for_difference, ) n = sample_size_for_difference(0.80, 0.90) assert 170 <= n <= 230 def test_15pp_difference(self): """15pp difference (75% vs 90%) should need ~90 per group.""" from local_deep_research.benchmarks.metrics.statistics import ( sample_size_for_difference, ) n = sample_size_for_difference(0.75, 0.90) assert 75 <= n <= 105 def test_raises_on_equal_proportions(self): from local_deep_research.benchmarks.metrics.statistics import ( sample_size_for_difference, ) with pytest.raises(ValueError): sample_size_for_difference(0.9, 0.9) def test_raises_on_invalid_proportions(self): from local_deep_research.benchmarks.metrics.statistics import ( sample_size_for_difference, ) with pytest.raises(ValueError, match="p1 must be in"): sample_size_for_difference(-0.1, 0.9) with pytest.raises(ValueError, match="p2 must be in"): sample_size_for_difference(0.8, 1.5) def test_raises_on_invalid_power_or_alpha(self): from local_deep_research.benchmarks.metrics.statistics import ( sample_size_for_difference, ) with pytest.raises(ValueError, match="power must be in"): sample_size_for_difference(0.8, 0.9, power=0.0) with pytest.raises(ValueError, match="alpha must be in"): sample_size_for_difference(0.8, 0.9, alpha=1.0) def test_higher_power_needs_more_samples(self): from local_deep_research.benchmarks.metrics.statistics import ( sample_size_for_difference, ) n_80 = sample_size_for_difference(0.80, 0.90, power=0.80) n_95 = sample_size_for_difference(0.80, 0.90, power=0.95) assert n_95 > n_80 class TestCalculateMetricsIntegration: """Test that calculate_metrics() returns accuracy_ci (backward compat).""" def test_accuracy_ci_present(self, tmp_path): from local_deep_research.benchmarks.metrics.calculation import ( calculate_metrics, ) results_file = tmp_path / "results.jsonl" results = [ {"is_correct": True, "processing_time": 1.5}, {"is_correct": True, "processing_time": 2.0}, {"is_correct": False, "processing_time": 1.0}, ] with open(results_file, "w") as f: for r in results: f.write(json.dumps(r) + "\n") metrics = calculate_metrics(str(results_file)) # Existing keys still present assert metrics["accuracy"] == 2 / 3 assert metrics["total_examples"] == 3 # New CI key present assert "accuracy_ci" in metrics ci = metrics["accuracy_ci"] assert ci["sample_size"] == 3 assert 0.0 <= ci["lower"] <= ci["upper"] <= 1.0 # CI should contain the point estimate assert ci["lower"] <= metrics["accuracy"] <= ci["upper"] def test_category_ci_present(self, tmp_path): from local_deep_research.benchmarks.metrics.calculation import ( calculate_metrics, ) results_file = tmp_path / "results.jsonl" results = [ {"is_correct": True, "category": "science"}, {"is_correct": False, "category": "science"}, {"is_correct": True, "category": "history"}, ] with open(results_file, "w") as f: for r in results: f.write(json.dumps(r) + "\n") metrics = calculate_metrics(str(results_file)) assert "accuracy_ci" in metrics["categories"]["science"] assert "accuracy_ci" in metrics["categories"]["history"] def test_empty_results_no_ci_error(self, tmp_path): """Empty file should return error dict, not crash on CI.""" from local_deep_research.benchmarks.metrics.calculation import ( calculate_metrics, ) results_file = tmp_path / "empty.jsonl" results_file.write_text("") metrics = calculate_metrics(str(results_file)) assert "error" in metrics