| 1 | from __future__ import annotations
|
| 2 |
|
| 3 | import contextlib
|
| 4 | import io
|
| 5 | import json
|
| 6 | import sys
|
| 7 | import tempfile
|
| 8 | import unittest
|
| 9 | from pathlib import Path
|
| 10 | from unittest.mock import patch
|
| 11 |
|
| 12 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
| 13 |
|
| 14 | import benchmark_stats
|
| 15 | from oakbench.stats import (
|
| 16 | benjamini_hochberg,
|
| 17 | bootstrap_ci,
|
| 18 | named_statistic,
|
| 19 | percentile_nearest,
|
| 20 | ratio_ci,
|
| 21 | required_n_for_relative_delta,
|
| 22 | )
|
| 23 |
|
| 24 |
|
| 25 | class StatsTest(unittest.TestCase):
|
| 26 | def test_percentile_nearest(self) -> None:
|
| 27 | self.assertEqual(percentile_nearest([1, 2, 3, 4], 50), 2)
|
| 28 | self.assertEqual(percentile_nearest([1, 2, 3, 4], 95), 4)
|
| 29 | self.assertIsNone(percentile_nearest([], 50))
|
| 30 |
|
| 31 | def test_bootstrap_ci_singleton(self) -> None:
|
| 32 | self.assertEqual(bootstrap_ci([7.0]), (7.0, 7.0))
|
| 33 |
|
| 34 | def test_zero_percentile_is_observed_zero_not_nan(self) -> None:
|
| 35 | self.assertEqual(named_statistic("p90")([0.0, 0.0, 0.0]), 0.0)
|
| 36 | self.assertEqual(bootstrap_ci([0.0] * 10, statistic="p90", samples=20), (0.0, 0.0))
|
| 37 |
|
| 38 | def test_ratio_ci_orders_bounds(self) -> None:
|
| 39 | low, high = ratio_ci([1, 2, 3], [2, 4, 6], samples=100, seed=1)
|
| 40 | self.assertLessEqual(low, high)
|
| 41 | self.assertGreater(low, 0)
|
| 42 |
|
| 43 | def test_bh_fdr(self) -> None:
|
| 44 | self.assertEqual(benjamini_hochberg([0.001, 0.02, 0.2], alpha=0.05), [True, True, False])
|
| 45 |
|
| 46 | def test_required_n_scales_with_square_of_noise_ratio(self) -> None:
|
| 47 | self.assertEqual(required_n_for_relative_delta(10, 5, 4), 16)
|
| 48 | self.assertEqual(required_n_for_relative_delta(3, 5, 4), 4)
|
| 49 | self.assertIsNone(required_n_for_relative_delta(3, 0, 4))
|
| 50 |
|
| 51 |
|
| 52 | class BenchmarkStatsCliTest(unittest.TestCase):
|
| 53 | def test_null_returncode_row_is_excluded_not_a_crash(self) -> None:
|
| 54 | rows = [
|
| 55 | {"subject": "git", "scenario": "tiny", "operation": "status.clean", "returncode": 0, "elapsed_ms": 10.0},
|
| 56 | {"subject": "git", "scenario": "tiny", "operation": "status.clean", "returncode": 0, "elapsed_ms": 12.0},
|
| 57 | # Emitters may record returncode: null (unmeasured); it must be
|
| 58 | # treated as a failure and excluded, never raise TypeError.
|
| 59 | {"subject": "git", "scenario": "tiny", "operation": "status.clean", "returncode": None, "elapsed_ms": 999.0},
|
| 60 | ]
|
| 61 | with tempfile.TemporaryDirectory() as tmp:
|
| 62 | path = Path(tmp) / "rows.jsonl"
|
| 63 | path.write_text("\n".join(json.dumps(row) for row in rows) + "\n")
|
| 64 | stdout = io.StringIO()
|
| 65 | argv = ["benchmark_stats.py", str(path), "--bootstrap-samples", "10"]
|
| 66 | with patch.object(sys, "argv", argv):
|
| 67 | with contextlib.redirect_stdout(stdout):
|
| 68 | code = benchmark_stats.main()
|
| 69 | self.assertEqual(code, 0)
|
| 70 | output = stdout.getvalue()
|
| 71 | self.assertIn("| `git` | `tiny/status.clean` | 2 |", output)
|
| 72 | self.assertNotIn("999", output)
|
| 73 |
|
| 74 |
|
| 75 | if __name__ == "__main__":
|
| 76 | unittest.main()
|