"""
Test coverage expansion batch 6.

Targets:
- conformal_chart.py: _conformal_p_value, _conformal_threshold, NCS helpers,
  ConformalChartResult properties/methods, MultivariateConformalMonitor
- sequential.py: _gaussian_msprt, _poisson_msprt, _lognormal_msprt,
  _confidence_sequence, _bayesian_prob, _safe_lambda, _build_summary,
  SequentialTest loss_ratio metric, severity metric, reset, history,
  futility stopping, max_duration, sequential_test_from_df
- drift_attribution.py: DriftAttributor validation, fit_reference, test,
  update alias, run_stream, psi_comparison, interaction_pairs,
  DriftAttributionResult dict-style access, _compute_loss, auto_retrain

Total: approximately 220 tests.
"""

from __future__ import annotations

import datetime
import math
import warnings

import numpy as np
import polars as pl
import pytest

sklearn = pytest.importorskip("sklearn", reason="scikit-learn not installed")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _simple_model(coef=None):
    """Return a sklearn-duck-typed linear model for DriftAttributor tests."""
    from sklearn.linear_model import LinearRegression
    return LinearRegression()


def _make_linear_data(n=200, d=3, seed=42):
    rng = np.random.default_rng(seed)
    X = rng.normal(0, 1, (n, d))
    y = X @ np.ones(d) + rng.normal(0, 0.1, n)
    return X, y


# ---------------------------------------------------------------------------
# conformal_chart._conformal_p_value
# ---------------------------------------------------------------------------

class TestConformalPValue:
    def test_import(self):
        from insurance_monitoring.conformal_chart import _conformal_p_value
        assert callable(_conformal_p_value)

    def test_all_below_new(self):
        from insurance_monitoring.conformal_chart import _conformal_p_value
        cal = np.array([1.0, 2.0, 3.0])
        # s_new=0: all cal >= 0, so count=3 -> (3+1)/(3+1) = 1.0
        p = _conformal_p_value(0.0, cal)
        assert p == pytest.approx(1.0)

    def test_all_above_new(self):
        from insurance_monitoring.conformal_chart import _conformal_p_value
        cal = np.array([1.0, 2.0, 3.0])
        # s_new=10: no cal >= 10, count=0 -> (0+1)/(3+1) = 0.25
        p = _conformal_p_value(10.0, cal)
        assert p == pytest.approx(0.25)

    def test_p_value_positive(self):
        from insurance_monitoring.conformal_chart import _conformal_p_value
        cal = np.arange(100, dtype=float)
        p = _conformal_p_value(200.0, cal)
        assert p > 0.0

    def test_p_value_at_most_one(self):
        from insurance_monitoring.conformal_chart import _conformal_p_value
        cal = np.arange(100, dtype=float)
        p = _conformal_p_value(-1.0, cal)
        assert p <= 1.0

    def test_single_cal_score(self):
        from insurance_monitoring.conformal_chart import _conformal_p_value
        cal = np.array([5.0])
        p = _conformal_p_value(5.0, cal)
        # count=1, n=1 -> (1+1)/(1+1)=1.0
        assert p == pytest.approx(1.0)

    def test_mid_range(self):
        from insurance_monitoring.conformal_chart import _conformal_p_value
        cal = np.array([1.0, 2.0, 3.0, 4.0])
        # s_new=2.5: cal >= 2.5 -> {3.0, 4.0}, count=2 -> (2+1)/(4+1)=0.6
        p = _conformal_p_value(2.5, cal)
        assert p == pytest.approx(0.6)


# ---------------------------------------------------------------------------
# conformal_chart._conformal_threshold
# ---------------------------------------------------------------------------

class TestConformalThreshold:
    def test_import(self):
        from insurance_monitoring.conformal_chart import _conformal_threshold
        assert callable(_conformal_threshold)

    def test_small_alpha_saturates(self):
        from insurance_monitoring.conformal_chart import _conformal_threshold
        # Small n, very small alpha -> level >= 1.0 -> threshold = max(cal)
        cal = np.arange(10, dtype=float)
        t = _conformal_threshold(cal, 0.001)
        assert t == pytest.approx(float(np.max(cal)))

    def test_large_alpha_low_threshold(self):
        from insurance_monitoring.conformal_chart import _conformal_threshold
        cal = np.arange(1000, dtype=float)
        t50 = _conformal_threshold(cal, 0.5)
        t05 = _conformal_threshold(cal, 0.05)
        assert t50 < t05

    def test_threshold_is_in_range(self):
        from insurance_monitoring.conformal_chart import _conformal_threshold
        cal = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
        t = _conformal_threshold(cal, 0.05)
        assert cal.min() <= t <= cal.max()

    def test_level_capped_at_one(self):
        from insurance_monitoring.conformal_chart import _conformal_threshold
        # Very small calibration set with alpha=0.05: level formula may hit 1.0
        cal = np.array([1.0, 2.0])
        t = _conformal_threshold(cal, 0.05)
        # Should return max (or close) without crashing
        assert np.isfinite(t)


# ---------------------------------------------------------------------------
# conformal_chart._conformal_p_values (vectorised)
# ---------------------------------------------------------------------------

class TestConformalPValuesVectorised:
    def test_import(self):
        from insurance_monitoring.conformal_chart import _conformal_p_values
        assert callable(_conformal_p_values)

    def test_shape_preserved(self):
        from insurance_monitoring.conformal_chart import _conformal_p_values
        cal = np.arange(50, dtype=float)
        scores = np.array([10.0, 20.0, 30.0, 60.0])
        p = _conformal_p_values(scores, cal)
        assert p.shape == (4,)

    def test_monotone_in_score(self):
        from insurance_monitoring.conformal_chart import _conformal_p_values
        cal = np.arange(100, dtype=float)
        scores = np.array([0.0, 50.0, 200.0])
        p = _conformal_p_values(scores, cal)
        # Higher score => fewer cal >= score => lower p-value
        assert p[0] > p[1] > p[2]

    def test_all_positive(self):
        from insurance_monitoring.conformal_chart import _conformal_p_values
        cal = np.arange(100, dtype=float)
        scores = np.linspace(0, 200, 50)
        p = _conformal_p_values(scores, cal)
        assert np.all(p > 0.0)


# ---------------------------------------------------------------------------
# conformal_chart NCS static helpers
# ---------------------------------------------------------------------------

class TestNCSHelpers:
    def test_absolute_residual_shape(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        a = np.array([1.0, 2.0, 3.0])
        p = np.array([1.1, 1.9, 3.2])
        ncs = ConformalControlChart.ncs_absolute_residual(a, p)
        assert ncs.shape == (3,)
        np.testing.assert_allclose(ncs, np.abs(a - p))

    def test_absolute_residual_zeros(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        a = np.array([1.0, 2.0])
        ncs = ConformalControlChart.ncs_absolute_residual(a, a)
        np.testing.assert_allclose(ncs, 0.0)

    def test_relative_residual_shape(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        a = np.array([1.0, 2.0, 3.0])
        p = np.array([1.0, 2.0, 3.0])
        ncs = ConformalControlChart.ncs_relative_residual(a, p)
        assert ncs.shape == (3,)
        np.testing.assert_allclose(ncs, 0.0, atol=1e-12)

    def test_relative_residual_near_zero_denom(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        # predicted=0 -> denom floored at 1e-9 -> no division by zero
        a = np.array([1.0])
        p = np.array([0.0])
        ncs = ConformalControlChart.ncs_relative_residual(a, p)
        assert np.isfinite(ncs[0])
        assert ncs[0] > 1e6  # 1/1e-9

    def test_median_deviation_with_explicit_median(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        v = np.array([1.0, 2.0, 3.0, 4.0])
        ncs = ConformalControlChart.ncs_median_deviation(v, median=2.0)
        expected = np.abs(v - 2.0)
        np.testing.assert_allclose(ncs, expected)

    def test_median_deviation_computed_from_data(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        v = np.array([1.0, 2.0, 3.0])
        ncs = ConformalControlChart.ncs_median_deviation(v)
        # median(v)=2.0 -> |v - 2.0| = [1, 0, 1]
        np.testing.assert_allclose(ncs, [1.0, 0.0, 1.0])

    def test_studentized_shape(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        a = np.array([1.0, 2.0, 3.0])
        p = np.array([1.1, 1.9, 3.2])
        vol = np.array([0.5, 0.5, 0.5])
        ncs = ConformalControlChart.ncs_studentized(a, p, vol)
        assert ncs.shape == (3,)

    def test_studentized_zero_vol_floor(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        a = np.array([1.0])
        p = np.array([0.5])
        vol = np.array([0.0])
        ncs = ConformalControlChart.ncs_studentized(a, p, vol)
        assert np.isfinite(ncs[0])
        # |1.0 - 0.5| / 1e-9 = 5e8
        assert ncs[0] > 1e7

    def test_ncs_helpers_callable_without_instance(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        # All NCS helpers are @staticmethod, callable on class directly
        a = np.array([1.0, 2.0])
        p = np.array([1.0, 2.0])
        ConformalControlChart.ncs_absolute_residual(a, p)
        ConformalControlChart.ncs_relative_residual(a, p)


# ---------------------------------------------------------------------------
# ConformalChartResult properties and methods
# ---------------------------------------------------------------------------

class TestConformalChartResultProperties:
    def _make_result(self):
        from insurance_monitoring.conformal_chart import ConformalChartResult
        scores = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
        p_values = np.array([0.8, 0.6, 0.1, 0.03, 0.01])
        is_alarm = p_values < 0.05
        return ConformalChartResult(
            scores=scores,
            p_values=p_values,
            threshold=3.5,
            is_alarm=is_alarm,
            alpha=0.05,
            n_cal=100,
        )

    def test_n_alarms(self):
        r = self._make_result()
        assert r.n_alarms == 2  # p < 0.05 at indices 3 and 4

    def test_alarm_rate(self):
        r = self._make_result()
        assert r.alarm_rate == pytest.approx(2 / 5)

    def test_alarm_rate_empty(self):
        from insurance_monitoring.conformal_chart import ConformalChartResult
        r = ConformalChartResult(
            scores=np.array([]),
            p_values=np.array([]),
            threshold=1.0,
            is_alarm=np.array([], dtype=bool),
            alpha=0.05,
            n_cal=10,
        )
        assert r.alarm_rate == 0.0

    def test_to_polars_columns(self):
        r = self._make_result()
        df = r.to_polars()
        assert isinstance(df, pl.DataFrame)
        assert "obs_index" in df.columns
        assert "score" in df.columns
        assert "p_value" in df.columns
        assert "threshold" in df.columns
        assert "is_alarm" in df.columns

    def test_to_polars_row_count(self):
        r = self._make_result()
        df = r.to_polars()
        assert len(df) == 5

    def test_to_polars_obs_index_sequential(self):
        r = self._make_result()
        df = r.to_polars()
        assert df["obs_index"].to_list() == list(range(5))

    def test_to_polars_threshold_constant(self):
        r = self._make_result()
        df = r.to_polars()
        assert all(v == 3.5 for v in df["threshold"].to_list())

    def test_summary_contains_status(self):
        r = self._make_result()
        s = r.summary()
        assert "OUT OF CONTROL" in s or "IN CONTROL" in s

    def test_summary_contains_alpha(self):
        r = self._make_result()
        s = r.summary()
        assert "0.05" in s or "0.0500" in s

    def test_summary_contains_n_cal(self):
        r = self._make_result()
        s = r.summary()
        assert "100" in s

    def test_summary_no_alarms_in_control(self):
        from insurance_monitoring.conformal_chart import ConformalChartResult
        r = ConformalChartResult(
            scores=np.array([1.0, 2.0]),
            p_values=np.array([0.8, 0.9]),
            threshold=5.0,
            is_alarm=np.array([False, False]),
            alpha=0.05,
            n_cal=50,
        )
        s = r.summary()
        assert "IN CONTROL" in s

    def test_n_alarms_zero(self):
        from insurance_monitoring.conformal_chart import ConformalChartResult
        r = ConformalChartResult(
            scores=np.array([1.0]),
            p_values=np.array([0.9]),
            threshold=5.0,
            is_alarm=np.array([False]),
            alpha=0.05,
            n_cal=50,
        )
        assert r.n_alarms == 0


# ---------------------------------------------------------------------------
# ConformalControlChart fit and monitor
# ---------------------------------------------------------------------------

class TestConformalControlChartFitMonitor:
    def test_alpha_validation(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        with pytest.raises(ValueError):
            ConformalControlChart(alpha=0.0)
        with pytest.raises(ValueError):
            ConformalControlChart(alpha=1.0)
        with pytest.raises(ValueError):
            ConformalControlChart(alpha=-0.1)

    def test_fit_requires_min_2_scores(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        chart = ConformalControlChart()
        with pytest.raises(ValueError):
            chart.fit([1.0])
        with pytest.raises(ValueError):
            chart.fit([])

    def test_fit_returns_self(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        chart = ConformalControlChart()
        result = chart.fit(np.arange(50, dtype=float))
        assert result is chart

    def test_monitor_before_fit_raises(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        chart = ConformalControlChart()
        with pytest.raises(RuntimeError):
            chart.monitor([1.0, 2.0])

    def test_small_cal_warns_at_alpha_05(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        chart = ConformalControlChart(alpha=0.05)
        with warnings.catch_warnings(record=True) as w:
            warnings.simplefilter("always")
            chart.fit(np.arange(10, dtype=float))
            assert any("calibration" in str(x.message).lower() or "Calibration" in str(x.message) for x in w)

    def test_small_cal_warns_at_3sigma(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        chart = ConformalControlChart(alpha=0.003)
        with warnings.catch_warnings(record=True) as w:
            warnings.simplefilter("always")
            chart.fit(np.arange(50, dtype=float))
            assert any("calibration" in str(x.message).lower() or "370" in str(x.message) for x in w)

    def test_monitor_returns_result(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart, ConformalChartResult
        chart = ConformalControlChart(alpha=0.05).fit(np.arange(100, dtype=float))
        result = chart.monitor(np.array([5.0, 200.0]))
        assert isinstance(result, ConformalChartResult)

    def test_monitor_length_correct(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        chart = ConformalControlChart(alpha=0.05).fit(np.arange(100, dtype=float))
        result = chart.monitor(np.arange(10, dtype=float))
        assert len(result.scores) == 10

    def test_extreme_score_causes_alarm(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        rng = np.random.default_rng(42)
        cal = rng.normal(0, 1, 200)
        ncs_cal = np.abs(cal)
        chart = ConformalControlChart(alpha=0.05).fit(ncs_cal)
        result = chart.monitor(np.array([1000.0]))
        assert result.is_alarm[0]

    def test_in_control_score_no_alarm(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        cal = np.ones(100) * 5.0  # all same
        chart = ConformalControlChart(alpha=0.05).fit(cal)
        result = chart.monitor(np.array([5.0]))
        assert not result.is_alarm[0]

    def test_p_values_in_range(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        chart = ConformalControlChart(alpha=0.05).fit(np.arange(100, dtype=float))
        result = chart.monitor(np.linspace(0, 200, 50))
        assert np.all(result.p_values > 0.0)
        assert np.all(result.p_values <= 1.0)

    def test_threshold_stored(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        cal = np.arange(100, dtype=float)
        chart = ConformalControlChart(alpha=0.05).fit(cal)
        assert chart.threshold_ is not None
        assert np.isfinite(chart.threshold_)

    def test_n_cal_stored(self):
        from insurance_monitoring.conformal_chart import ConformalControlChart
        cal = np.arange(100, dtype=float)
        chart = ConformalControlChart(alpha=0.05).fit(cal)
        assert chart.n_cal_ == 100


# ---------------------------------------------------------------------------
# MultivariateConformalMonitor
# ---------------------------------------------------------------------------

class TestMultivariateConformalMonitor:
    def test_alpha_validation(self):
        from insurance_monitoring.conformal_chart import MultivariateConformalMonitor
        with pytest.raises(ValueError):
            MultivariateConformalMonitor(alpha=0.0)
        with pytest.raises(ValueError):
            MultivariateConformalMonitor(alpha=1.5)

    def test_fit_requires_min_2_cal(self):
        from insurance_monitoring.conformal_chart import MultivariateConformalMonitor

        class FakeModel:
            def fit(self, X): return self
            def decision_function(self, X): return np.zeros(len(X))

        mon = MultivariateConformalMonitor(model=FakeModel(), alpha=0.05)
        X_train = np.random.randn(50, 3)
        X_cal_too_small = np.random.randn(1, 3)
        with pytest.raises(ValueError):
            mon.fit(X_train, X_cal_too_small)

    def test_monitor_before_fit_raises(self):
        from insurance_monitoring.conformal_chart import MultivariateConformalMonitor

        class FakeModel:
            def fit(self, X): return self
            def decision_function(self, X): return np.zeros(len(X))

        mon = MultivariateConformalMonitor(model=FakeModel(), alpha=0.05)
        with pytest.raises(RuntimeError):
            mon.monitor(np.random.randn(5, 3))

    def test_custom_model_decision_function(self):
        from insurance_monitoring.conformal_chart import MultivariateConformalMonitor, ConformalChartResult

        class ConstantModel:
            def fit(self, X): return self
            def decision_function(self, X): return np.ones(len(X))

        X = np.random.randn(50, 3)
        mon = MultivariateConformalMonitor(model=ConstantModel(), alpha=0.05)
        mon.fit(X, X[:20])
        result = mon.monitor(X[:10])
        assert isinstance(result, ConformalChartResult)

    def test_custom_model_score_samples(self):
        from insurance_monitoring.conformal_chart import MultivariateConformalMonitor

        class SampleModel:
            def fit(self, X): return self
            def score_samples(self, X): return -np.ones(len(X))

        X = np.random.randn(50, 3)
        mon = MultivariateConformalMonitor(model=SampleModel(), alpha=0.05)
        mon.fit(X, X[:20])
        result = mon.monitor(X[:5])
        assert len(result.scores) == 5

    def test_model_no_method_raises(self):
        from insurance_monitoring.conformal_chart import MultivariateConformalMonitor

        class BadModel:
            def fit(self, X): return self
            # No decision_function or score_samples

        X = np.random.randn(50, 3)
        mon = MultivariateConformalMonitor(model=BadModel(), alpha=0.05)
        # AttributeError is raised at fit() time when computing cal NCS scores
        with pytest.raises(AttributeError):
            mon.fit(X, X[:20])

    def test_1d_input_reshaped(self):
        from insurance_monitoring.conformal_chart import MultivariateConformalMonitor

        class IdentModel:
            def fit(self, X): return self
            def decision_function(self, X): return np.zeros(len(X))

        X_1d = np.random.randn(50)
        mon = MultivariateConformalMonitor(model=IdentModel(), alpha=0.05)
        mon.fit(X_1d, X_1d[:20])  # should not raise; 1D reshaped to (n, 1)
        # monitor() treats 1D input as a single row (1, d), so 1 score
        result = mon.monitor(X_1d[:5])
        assert len(result.scores) == 1  # 1D -> 1 row, not 5 rows

    def test_single_row_monitoring(self):
        from insurance_monitoring.conformal_chart import MultivariateConformalMonitor

        class IdentModel:
            def fit(self, X): return self
            def decision_function(self, X): return np.zeros(len(X))

        X = np.random.randn(50, 3)
        mon = MultivariateConformalMonitor(model=IdentModel(), alpha=0.05)
        mon.fit(X, X[:20])
        result = mon.monitor(X[0])  # 1d -> 1 row
        assert len(result.scores) == 1


# ---------------------------------------------------------------------------
# sequential._gaussian_msprt
# ---------------------------------------------------------------------------

class TestGaussianMSPRT:
    def test_import(self):
        from insurance_monitoring.sequential import _gaussian_msprt
        assert callable(_gaussian_msprt)

    def test_zero_sigma_sq(self):
        from insurance_monitoring.sequential import _gaussian_msprt
        result = _gaussian_msprt(0.5, 0.0, 0.1, "two_sided")
        assert result == 0.0

    def test_two_sided_nonneg(self):
        from insurance_monitoring.sequential import _gaussian_msprt
        # With real signal, log_lambda should be > 0
        result = _gaussian_msprt(5.0, 0.01, 0.1, "two_sided")
        assert result > 0.0

    def test_greater_positive_effect(self):
        from insurance_monitoring.sequential import _gaussian_msprt
        result = _gaussian_msprt(2.0, 0.01, 0.1, "greater")
        assert result > 0.0  # theta > 0 and alternative='greater' -> valid

    def test_greater_negative_effect(self):
        from insurance_monitoring.sequential import _gaussian_msprt
        result = _gaussian_msprt(-2.0, 0.01, 0.1, "greater")
        assert result == -math.inf  # wrong direction

    def test_less_negative_effect(self):
        from insurance_monitoring.sequential import _gaussian_msprt
        result = _gaussian_msprt(-2.0, 0.01, 0.1, "less")
        assert result > 0.0

    def test_less_positive_effect(self):
        from insurance_monitoring.sequential import _gaussian_msprt
        result = _gaussian_msprt(2.0, 0.01, 0.1, "less")
        assert result == -math.inf


# ---------------------------------------------------------------------------
# sequential._poisson_msprt
# ---------------------------------------------------------------------------

class TestPoissonMSPRT:
    def test_import(self):
        from insurance_monitoring.sequential import _poisson_msprt
        assert callable(_poisson_msprt)

    def test_insufficient_claims_returns_zero(self):
        from insurance_monitoring.sequential import _poisson_msprt
        # C_A < 5 -> returns 0.0
        result = _poisson_msprt(4, 100, 4, 100, 0.03, "two_sided")
        assert result == 0.0

    def test_sufficient_claims_nonzero(self):
        from insurance_monitoring.sequential import _poisson_msprt
        # Same rates -> log_lambda close to 0 (null)
        result = _poisson_msprt(50, 100, 50, 100, 0.03, "two_sided")
        # Lambda should be close to 1 (log close to 0), but positive if any signal
        assert isinstance(result, float)

    def test_large_rate_difference_warns(self):
        from insurance_monitoring.sequential import _poisson_msprt
        with warnings.catch_warnings(record=True) as w:
            warnings.simplefilter("always")
            _poisson_msprt(10, 1, 10000, 1, 0.03, "two_sided")
            assert any("100x" in str(x.message) or "Rate ratio" in str(x.message) for x in w)


# ---------------------------------------------------------------------------
# sequential._confidence_sequence
# ---------------------------------------------------------------------------

class TestConfidenceSequence:
    def test_import(self):
        from insurance_monitoring.sequential import _confidence_sequence
        assert callable(_confidence_sequence)

    def test_insufficient_claims_returns_wide(self):
        from insurance_monitoring.sequential import _confidence_sequence
        lo, hi = _confidence_sequence(4, 100, 4, 100, 0.05)
        assert lo == 0.0
        assert hi == float("inf")

    def test_sufficient_claims_finite_bounds(self):
        from insurance_monitoring.sequential import _confidence_sequence
        lo, hi = _confidence_sequence(100, 200, 100, 200, 0.05)
        assert lo > 0.0
        assert hi < float("inf")
        assert lo < 1.0
        assert hi > 1.0  # straddles 1 under null

    def test_rate_ratio_in_ci(self):
        from insurance_monitoring.sequential import _confidence_sequence
        # Same rates -> CI should contain 1.0
        lo, hi = _confidence_sequence(200, 1000, 200, 1000, 0.05)
        assert lo < 1.0 < hi


# ---------------------------------------------------------------------------
# sequential._safe_lambda
# ---------------------------------------------------------------------------

class TestSafeLambda:
    def test_import(self):
        from insurance_monitoring.sequential import _safe_lambda
        assert callable(_safe_lambda)

    def test_overflow_returns_inf(self):
        from insurance_monitoring.sequential import _safe_lambda
        result = _safe_lambda(600.0)
        assert result == float("inf")

    def test_neg_inf_returns_zero(self):
        from insurance_monitoring.sequential import _safe_lambda
        result = _safe_lambda(-math.inf)
        assert result == 0.0

    def test_normal_value(self):
        from insurance_monitoring.sequential import _safe_lambda
        result = _safe_lambda(0.0)
        assert result == pytest.approx(1.0)

    def test_positive_value(self):
        from insurance_monitoring.sequential import _safe_lambda
        result = _safe_lambda(math.log(10.0))
        assert result == pytest.approx(10.0)


# ---------------------------------------------------------------------------
# SequentialTest: loss_ratio and severity metrics
# ---------------------------------------------------------------------------

class TestSequentialTestLossRatioAndSeverity:
    def _make_freq_update(self, test, n=10, c_a=10, c_b=10, e=100):
        """Make n updates to accumulate enough claims."""
        result = None
        for _ in range(n):
            result = test.update(
                champion_claims=c_a, champion_exposure=e,
                challenger_claims=c_b, challenger_exposure=e,
            )
        return result

    def test_severity_metric_basic(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest(metric="severity", alpha=0.05)
        result = test.update(
            champion_claims=20, champion_exposure=200,
            challenger_claims=20, challenger_exposure=200,
            champion_severity_sum=20 * math.log(5000),
            champion_severity_ss=20 * (math.log(5000)) ** 2,
            challenger_severity_sum=20 * math.log(5000),
            challenger_severity_ss=20 * (math.log(5000)) ** 2,
        )
        assert result.lambda_value >= 0.0

    def test_severity_metric_insufficient_returns_one(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest(metric="severity", alpha=0.05)
        # n_A < 10 -> returns 0.0 log_lambda
        result = test.update(
            champion_claims=5, champion_exposure=100,
            challenger_claims=5, challenger_exposure=100,
            champion_severity_sum=5 * math.log(5000),
            champion_severity_ss=5 * (math.log(5000)) ** 2,
            challenger_severity_sum=5 * math.log(5000),
            challenger_severity_ss=5 * (math.log(5000)) ** 2,
        )
        # log_lambda=0 -> lambda=1.0
        assert result.lambda_value == pytest.approx(1.0, abs=1e-6)

    def test_loss_ratio_metric_basic(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest(metric="loss_ratio", alpha=0.05)
        for _ in range(5):
            result = test.update(
                champion_claims=20, champion_exposure=200,
                challenger_claims=20, challenger_exposure=200,
                champion_severity_sum=20 * math.log(5000),
                champion_severity_ss=20 * (math.log(5000)) ** 2,
                challenger_severity_sum=20 * math.log(5000),
                challenger_severity_ss=20 * (math.log(5000)) ** 2,
            )
        assert result is not None
        assert isinstance(result.lambda_value, float)

    def test_loss_ratio_summary_shows_lr(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest(metric="loss_ratio", alpha=0.05)
        result = test.update(
            champion_claims=5, champion_exposure=100,
            challenger_claims=5, challenger_exposure=100,
        )
        assert "LR" in result.summary

    def test_invalid_metric_raises(self):
        from insurance_monitoring.sequential import SequentialTest
        with pytest.raises(ValueError):
            SequentialTest(metric="nonsense")

    def test_invalid_alternative_raises(self):
        from insurance_monitoring.sequential import SequentialTest
        with pytest.raises(ValueError):
            SequentialTest(alternative="both")

    def test_invalid_alpha_raises(self):
        from insurance_monitoring.sequential import SequentialTest
        with pytest.raises(ValueError):
            SequentialTest(alpha=0.0)
        with pytest.raises(ValueError):
            SequentialTest(alpha=1.0)

    def test_invalid_tau_raises(self):
        from insurance_monitoring.sequential import SequentialTest
        with pytest.raises(ValueError):
            SequentialTest(tau=0.0)
        with pytest.raises(ValueError):
            SequentialTest(tau=-1.0)

    def test_negative_claims_raises(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest()
        with pytest.raises(ValueError):
            test.update(
                champion_claims=-1, champion_exposure=100,
                challenger_claims=5, challenger_exposure=100,
            )

    def test_negative_exposure_raises(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest()
        with pytest.raises(ValueError):
            test.update(
                champion_claims=5, champion_exposure=-10,
                challenger_claims=5, challenger_exposure=100,
            )


# ---------------------------------------------------------------------------
# SequentialTest: reset and history
# ---------------------------------------------------------------------------

class TestSequentialTestResetAndHistory:
    def test_reset_clears_state(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest()
        for _ in range(3):
            test.update(
                champion_claims=10, champion_exposure=100,
                challenger_claims=10, challenger_exposure=100,
            )
        assert test._n_updates == 3
        test.reset()
        assert test._n_updates == 0
        assert test._freq.C_A == 0.0
        assert test._freq.E_A == 0.0

    def test_history_empty_before_updates(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest()
        df = test.history()
        assert isinstance(df, pl.DataFrame)
        assert len(df) == 0

    def test_history_row_count(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest()
        for _ in range(5):
            test.update(
                champion_claims=10, champion_exposure=100,
                challenger_claims=10, challenger_exposure=100,
            )
        df = test.history()
        assert len(df) == 5

    def test_history_columns(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest()
        test.update(
            champion_claims=10, champion_exposure=100,
            challenger_claims=10, challenger_exposure=100,
        )
        df = test.history()
        assert "lambda_value" in df.columns
        assert "decision" in df.columns
        assert "rate_ratio" in df.columns

    def test_history_has_date_column_when_provided(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest()
        test.update(
            champion_claims=10, champion_exposure=100,
            challenger_claims=10, challenger_exposure=100,
            calendar_date=datetime.date(2024, 1, 31),
        )
        df = test.history()
        assert "calendar_date" in df.columns

    def test_n_updates_increments(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest()
        for i in range(4):
            result = test.update(
                champion_claims=5, champion_exposure=50,
                challenger_claims=5, challenger_exposure=50,
            )
        assert result.n_updates == 4

    def test_reset_and_reuse(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest()
        test.update(champion_claims=10, champion_exposure=100, challenger_claims=10, challenger_exposure=100)
        test.reset()
        result = test.update(champion_claims=5, champion_exposure=50, challenger_claims=5, challenger_exposure=50)
        assert result.n_updates == 1


# ---------------------------------------------------------------------------
# SequentialTest: futility and max_duration
# ---------------------------------------------------------------------------

class TestSequentialTestStopping:
    def test_futility_stopping(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest(
            metric="frequency",
            alpha=0.05,
            futility_threshold=0.5,
            min_exposure_per_arm=0.0,  # no minimum
        )
        # With matching rates and very small tau, lambda stays near 1.0
        result = test.update(
            champion_claims=5, champion_exposure=100,
            challenger_claims=5, challenger_exposure=100,
        )
        # lambda=1.0 is not < 0.5, so not futility
        # But let's just check the property works
        assert result.decision in ("inconclusive", "futility", "reject_H0", "max_duration_reached")

    def test_max_duration_stopping(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest(
            metric="frequency",
            alpha=0.05,
            max_duration_years=0.0,
        )
        d1 = datetime.date(2022, 1, 1)
        d2 = datetime.date(2024, 1, 1)  # 2 years -> exceeds max_duration_years=0.0
        test.update(
            champion_claims=5, champion_exposure=100,
            challenger_claims=5, challenger_exposure=100,
            calendar_date=d1,
        )
        result = test.update(
            champion_claims=5, champion_exposure=100,
            challenger_claims=5, challenger_exposure=100,
            calendar_date=d2,
        )
        assert result.decision == "max_duration_reached"

    def test_calendar_time_tracked(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest()
        d1 = datetime.date(2024, 1, 1)
        d2 = datetime.date(2024, 7, 1)
        test.update(champion_claims=5, champion_exposure=100, challenger_claims=5, challenger_exposure=100, calendar_date=d1)
        result = test.update(champion_claims=5, champion_exposure=100, challenger_claims=5, challenger_exposure=100, calendar_date=d2)
        assert result.total_calendar_time_days == (d2 - d1).days


# ---------------------------------------------------------------------------
# sequential_test_from_df
# ---------------------------------------------------------------------------

class TestSequentialTestFromDF:
    def _make_df(self, n=6):
        return pl.DataFrame({
            "champ_c": [10.0] * n,
            "champ_e": [100.0] * n,
            "chall_c": [10.0] * n,
            "chall_e": [100.0] * n,
        })

    def test_basic_frequency(self):
        from insurance_monitoring.sequential import sequential_test_from_df
        df = self._make_df()
        result = sequential_test_from_df(
            df,
            champion_claims_col="champ_c",
            champion_exposure_col="champ_e",
            challenger_claims_col="chall_c",
            challenger_exposure_col="chall_e",
            metric="frequency",
        )
        assert result.n_updates == 6

    def test_empty_df_raises(self):
        from insurance_monitoring.sequential import sequential_test_from_df
        df = pl.DataFrame({
            "champ_c": pl.Series([], dtype=pl.Float64),
            "champ_e": pl.Series([], dtype=pl.Float64),
            "chall_c": pl.Series([], dtype=pl.Float64),
            "chall_e": pl.Series([], dtype=pl.Float64),
        })
        with pytest.raises(ValueError):
            sequential_test_from_df(
                df,
                champion_claims_col="champ_c",
                champion_exposure_col="champ_e",
                challenger_claims_col="chall_c",
                challenger_exposure_col="chall_e",
            )

    def test_with_date_column(self):
        from insurance_monitoring.sequential import sequential_test_from_df
        df = pl.DataFrame({
            "champ_c": [10.0, 10.0],
            "champ_e": [100.0, 100.0],
            "chall_c": [10.0, 10.0],
            "chall_e": [100.0, 100.0],
            "date": [datetime.date(2024, 1, 1), datetime.date(2024, 2, 1)],
        })
        result = sequential_test_from_df(
            df,
            champion_claims_col="champ_c",
            champion_exposure_col="champ_e",
            challenger_claims_col="chall_c",
            challenger_exposure_col="chall_e",
            date_col="date",
        )
        assert result.total_calendar_time_days > 0

    def test_result_fields_present(self):
        from insurance_monitoring.sequential import sequential_test_from_df
        df = self._make_df()
        result = sequential_test_from_df(
            df,
            champion_claims_col="champ_c",
            champion_exposure_col="champ_e",
            challenger_claims_col="chall_c",
            challenger_exposure_col="chall_e",
        )
        assert hasattr(result, "lambda_value")
        assert hasattr(result, "rate_ratio")
        assert hasattr(result, "decision")


# ---------------------------------------------------------------------------
# DriftAttributor: validation and fit_reference
# ---------------------------------------------------------------------------

class TestDriftAttributorValidation:
    def test_d_greater_than_12_no_permutations_raises(self):
        from insurance_monitoring.drift_attribution import DriftAttributor
        model = _simple_model()
        features = [f"f{i}" for i in range(13)]
        with pytest.raises(ValueError, match="n_permutations"):
            DriftAttributor(model=model, features=features)

    def test_alpha_zero_raises(self):
        from insurance_monitoring.drift_attribution import DriftAttributor
        model = _simple_model()
        with pytest.raises(ValueError):
            DriftAttributor(model=model, features=["a", "b"], alpha=0.0)

    def test_alpha_one_raises(self):
        from insurance_monitoring.drift_attribution import DriftAttributor
        model = _simple_model()
        with pytest.raises(ValueError):
            DriftAttributor(model=model, features=["a", "b"], alpha=1.0)

    def test_n_bootstrap_too_small_raises(self):
        from insurance_monitoring.drift_attribution import DriftAttributor
        model = _simple_model()
        with pytest.raises(ValueError):
            DriftAttributor(model=model, features=["a", "b"], n_bootstrap=5)

    def test_test_before_fit_raises(self):
        from insurance_monitoring.drift_attribution import DriftAttributor
        X, y = _make_linear_data()
        model = _simple_model()
        attr = DriftAttributor(model=model, features=["a", "b", "c"], n_bootstrap=10)
        with pytest.raises(RuntimeError):
            attr.test(X, y)

    def test_fit_reference_wrong_d_raises(self):
        from insurance_monitoring.drift_attribution import DriftAttributor
        model = _simple_model()
        attr = DriftAttributor(
            model=model,
            features=["a", "b"],
            n_bootstrap=10,
        )
        X, y = _make_linear_data(d=3)  # 3 cols but 2 features specified
        model.fit(X[:, :2], y)  # pre-train
        with pytest.raises(ValueError):
            attr.fit_reference(X, y, train_on_ref=False)


# ---------------------------------------------------------------------------
# DriftAttributor: test() and basic result
# ---------------------------------------------------------------------------

class TestDriftAttributorTest:
    def _get_fitted_attributor(self, features=None, n_bootstrap=20, d=3, n=200):
        from insurance_monitoring.drift_attribution import DriftAttributor
        if features is None:
            features = [f"f{i}" for i in range(d)]
        X, y = _make_linear_data(n=n, d=d, seed=42)
        model = _simple_model()
        model.fit(X, y)
        attr = DriftAttributor(
            model=model,
            features=features,
            n_bootstrap=n_bootstrap,
            auto_retrain=False,
        )
        attr.fit_reference(X, y, train_on_ref=False)
        return attr, X, y

    def test_result_drift_detected_bool(self):
        attr, X, y = self._get_fitted_attributor()
        result = attr.test(X, y)
        assert isinstance(result.drift_detected, bool)

    def test_result_attributed_features_list(self):
        attr, X, y = self._get_fitted_attributor()
        result = attr.test(X, y)
        assert isinstance(result.attributed_features, list)

    def test_feature_ranking_is_polars_df(self):
        attr, X, y = self._get_fitted_attributor()
        result = attr.test(X, y)
        assert isinstance(result.feature_ranking, pl.DataFrame)

    def test_feature_ranking_columns(self):
        attr, X, y = self._get_fitted_attributor()
        result = attr.test(X, y)
        assert "feature" in result.feature_ranking.columns
        assert "ratio" in result.feature_ranking.columns

    def test_test_statistics_keys_match_features(self):
        features = ["age", "ncb", "mileage"]
        attr, X, y = self._get_fitted_attributor(features=features)
        result = attr.test(X, y)
        assert set(result.test_statistics.keys()) == set(features)

    def test_thresholds_keys_match_features(self):
        features = ["age", "ncb", "mileage"]
        attr, X, y = self._get_fitted_attributor(features=features)
        result = attr.test(X, y)
        assert set(result.thresholds.keys()) == set(features)

    def test_p_values_in_range(self):
        attr, X, y = self._get_fitted_attributor()
        result = attr.test(X, y)
        for pv in result.p_values.values():
            assert 0.0 <= pv <= 1.0

    def test_window_sizes_correct(self):
        attr, X, y = self._get_fitted_attributor(n=200)
        X_new, y_new = _make_linear_data(n=100, d=3, seed=99)
        result = attr.test(X_new, y_new)
        assert result.window_ref_size == 200
        assert result.window_new_size == 100

    def test_dict_style_access(self):
        attr, X, y = self._get_fitted_attributor()
        result = attr.test(X, y)
        # DriftAttributionResult supports __getitem__
        assert result["drift_detected"] == result.drift_detected

    def test_update_alias_same_as_test(self):
        attr, X, y = self._get_fitted_attributor()
        r1 = attr.test(X, y)
        # reset reference
        attr.fit_reference(X, y, train_on_ref=False)
        r2 = attr.update(X, y)
        assert r1.drift_detected == r2.drift_detected

    def test_interaction_pairs_disabled_by_default(self):
        attr, X, y = self._get_fitted_attributor()
        result = attr.test(X, y)
        assert result.interaction_pairs is None

    def test_interaction_pairs_enabled(self):
        from insurance_monitoring.drift_attribution import DriftAttributor
        X, y = _make_linear_data(n=200, d=3, seed=42)
        model = _simple_model()
        model.fit(X, y)
        attr = DriftAttributor(
            model=model,
            features=["a", "b", "c"],
            n_bootstrap=10,
            auto_retrain=False,
            feature_pairs=True,
        )
        attr.fit_reference(X, y, train_on_ref=False)
        result = attr.test(X, y)
        assert result.interaction_pairs is not None
        assert isinstance(result.interaction_pairs, pl.DataFrame)


# ---------------------------------------------------------------------------
# DriftAttributor: auto_retrain
# ---------------------------------------------------------------------------

class TestDriftAttributorAutoRetrain:
    def test_auto_retrain_when_drift_detected(self):
        from insurance_monitoring.drift_attribution import DriftAttributor
        rng = np.random.default_rng(42)
        d = 3
        X_ref = rng.normal(0, 1, (200, d))
        y_ref = X_ref @ np.ones(d) + rng.normal(0, 0.01, 200)

        model = _simple_model()
        model.fit(X_ref, y_ref)

        attr = DriftAttributor(
            model=model,
            features=[f"f{i}" for i in range(d)],
            n_bootstrap=10,
            auto_retrain=True,
        )
        attr.fit_reference(X_ref, y_ref, train_on_ref=False)

        # Massive shift -> should detect drift and retrain
        X_new = rng.normal(100, 1, (200, d))
        y_new = X_new @ np.ones(d) + rng.normal(0, 0.01, 200)
        result = attr.test(X_new, y_new)
        # model_retrained is True only if drift was detected
        if result.drift_detected:
            assert result.model_retrained

    def test_auto_retrain_false_no_retrain(self):
        from insurance_monitoring.drift_attribution import DriftAttributor
        rng = np.random.default_rng(42)
        X_ref = rng.normal(0, 1, (200, 3))
        y_ref = X_ref @ np.ones(3) + rng.normal(0, 0.01, 200)
        model = _simple_model()
        model.fit(X_ref, y_ref)
        attr = DriftAttributor(
            model=model, features=["a", "b", "c"], n_bootstrap=10, auto_retrain=False
        )
        attr.fit_reference(X_ref, y_ref, train_on_ref=False)
        X_new = rng.normal(0, 1, (200, 3))
        y_new = X_new @ np.ones(3) + rng.normal(0, 0.01, 200)
        result = attr.test(X_new, y_new)
        assert not result.model_retrained


# ---------------------------------------------------------------------------
# DriftAttributor: run_stream
# ---------------------------------------------------------------------------

class TestDriftAttributorRunStream:
    def test_run_stream_returns_df(self):
        from insurance_monitoring.drift_attribution import DriftAttributor
        from sklearn.linear_model import LinearRegression
        rng = np.random.default_rng(42)
        N, d = 500, 3
        X = rng.normal(0, 1, (N, d))
        y = X @ np.ones(d) + rng.normal(0, 0.1, N)
        # Model must be pre-fitted; run_stream calls fit_reference(train_on_ref=False)
        model = LinearRegression().fit(X, y)
        attr = DriftAttributor(
            model=model, features=["a", "b", "c"],
            n_bootstrap=10, window_size=100, step_size=100, auto_retrain=False,
        )
        result_df = attr.run_stream(X, y)
        assert isinstance(result_df, pl.DataFrame)
        assert "drift_detected" in result_df.columns

    def test_run_stream_too_short_raises(self):
        from insurance_monitoring.drift_attribution import DriftAttributor
        rng = np.random.default_rng(42)
        X = rng.normal(0, 1, (100, 3))
        y = np.ones(100)
        model = _simple_model()
        attr = DriftAttributor(
            model=model, features=["a", "b", "c"],
            n_bootstrap=10, window_size=100,
        )
        with pytest.raises(ValueError, match="window_size"):
            attr.run_stream(X, y)  # N=100 < 2 * window_size=200


# ---------------------------------------------------------------------------
# _compute_loss
# ---------------------------------------------------------------------------

class TestComputeLoss:
    def test_mse(self):
        from insurance_monitoring.drift_attribution import _compute_loss
        y_true = np.array([1.0, 2.0, 3.0])
        y_pred = np.array([1.0, 2.0, 3.0])
        assert _compute_loss(y_true, y_pred, "mse") == pytest.approx(0.0)

    def test_mae(self):
        from insurance_monitoring.drift_attribution import _compute_loss
        y_true = np.array([1.0, 2.0, 3.0])
        y_pred = np.array([2.0, 2.0, 2.0])
        assert _compute_loss(y_true, y_pred, "mae") == pytest.approx(2.0 / 3.0)

    def test_log_loss_perfect(self):
        from insurance_monitoring.drift_attribution import _compute_loss
        y_true = np.array([1.0, 0.0])
        y_pred = np.array([1.0 - 1e-7, 1e-7])  # clip to near-perfect
        loss = _compute_loss(y_true, y_pred, "log_loss")
        assert loss < 0.01

    def test_invalid_loss_raises(self):
        from insurance_monitoring.drift_attribution import _compute_loss
        with pytest.raises(ValueError):
            _compute_loss(np.array([1.0]), np.array([1.0]), "cross_entropy")


# ---------------------------------------------------------------------------
# DriftAttributionResult dict-style access
# ---------------------------------------------------------------------------

class TestDriftAttributionResultDictAccess:
    def _make_result(self):
        from insurance_monitoring.drift_attribution import DriftAttributionResult
        return DriftAttributionResult(
            drift_detected=True,
            attributed_features=["f0"],
            test_statistics={"f0": 1.5, "f1": 0.3},
            thresholds={"f0": 1.0, "f1": 0.5},
            p_values={"f0": 0.02, "f1": 0.4},
            window_ref_size=200,
            window_new_size=100,
            alpha=0.05,
            feature_ranking=pl.DataFrame({"feature": ["f0", "f1"], "ratio": [1.5, 0.6]}),
            interaction_pairs=None,
            subset_risks_ref={"f0": 0.1, "f1": 0.2},
            subset_risks_new={"f0": 0.15, "f1": 0.18},
            model_retrained=False,
        )

    def test_getitem_drift_detected(self):
        r = self._make_result()
        assert r["drift_detected"] is True

    def test_getitem_attributed_features(self):
        r = self._make_result()
        assert r["attributed_features"] == ["f0"]

    def test_getitem_alpha(self):
        r = self._make_result()
        assert r["alpha"] == 0.05

    def test_getitem_window_sizes(self):
        r = self._make_result()
        assert r["window_ref_size"] == 200
        assert r["window_new_size"] == 100


# ---------------------------------------------------------------------------
# SequentialTestResult: fields and summary
# ---------------------------------------------------------------------------

class TestSequentialTestResultFields:
    def _make_result(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest(metric="frequency", alpha=0.05, min_exposure_per_arm=0.0)
        for _ in range(3):
            result = test.update(
                champion_claims=10, champion_exposure=100,
                challenger_claims=10, challenger_exposure=100,
            )
        return result

    def test_champion_rate(self):
        r = self._make_result()
        assert r.champion_rate == pytest.approx(30 / 300)

    def test_challenger_rate(self):
        r = self._make_result()
        assert r.challenger_rate == pytest.approx(30 / 300)

    def test_rate_ratio_near_one_under_null(self):
        r = self._make_result()
        assert r.rate_ratio == pytest.approx(1.0, rel=0.01)

    def test_prob_challenger_better_in_range(self):
        r = self._make_result()
        assert 0.0 <= r.prob_challenger_better <= 1.0

    def test_summary_is_string(self):
        r = self._make_result()
        assert isinstance(r.summary, str)

    def test_summary_nonempty(self):
        r = self._make_result()
        assert len(r.summary) > 10

    def test_threshold_is_reciprocal_alpha(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest(alpha=0.05)
        result = test.update(champion_claims=5, champion_exposure=100, challenger_claims=5, challenger_exposure=100)
        assert result.threshold == pytest.approx(1.0 / 0.05)

    def test_log_lambda_consistent(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest(alpha=0.05)
        result = test.update(champion_claims=5, champion_exposure=100, challenger_claims=5, challenger_exposure=100)
        if result.lambda_value > 0 and not math.isinf(result.lambda_value):
            assert result.log_lambda_value == pytest.approx(math.log(result.lambda_value), rel=0.01)

    def test_total_claims_accumulated(self):
        from insurance_monitoring.sequential import SequentialTest
        test = SequentialTest(min_exposure_per_arm=0.0)
        for _ in range(4):
            test.update(champion_claims=5, champion_exposure=50, challenger_claims=3, challenger_exposure=50)
        result = test.update(champion_claims=5, champion_exposure=50, challenger_claims=3, challenger_exposure=50)
        assert result.total_champion_claims == pytest.approx(25.0)
        assert result.total_challenger_claims == pytest.approx(15.0)


# ---------------------------------------------------------------------------
# Additional ConformalControlChart parametric tests
# ---------------------------------------------------------------------------

@pytest.mark.parametrize("alpha", [0.01, 0.05, 0.1, 0.2])
def test_conformal_chart_false_alarm_rate(alpha):
    from insurance_monitoring.conformal_chart import ConformalControlChart
    rng = np.random.default_rng(0)
    cal = rng.exponential(1, 500)
    chart = ConformalControlChart(alpha=alpha).fit(cal)
    # Under null (same distribution), alarm rate should be <= alpha
    test_data = rng.exponential(1, 10000)
    result = chart.monitor(test_data)
    # Allow some slack since this is a statistical guarantee, not exact at n=10000
    assert result.alarm_rate <= alpha + 0.02


@pytest.mark.parametrize("n_cal", [20, 50, 100, 500])
def test_conformal_threshold_stability(n_cal):
    from insurance_monitoring.conformal_chart import ConformalControlChart
    rng = np.random.default_rng(42)
    cal = rng.normal(0, 1, n_cal)
    ncs = np.abs(cal)
    chart = ConformalControlChart(alpha=0.05)
    chart.fit(ncs)
    assert chart.threshold_ is not None
    assert np.isfinite(chart.threshold_)
    assert chart.threshold_ >= 0.0


@pytest.mark.parametrize("alternative", ["two_sided", "greater", "less"])
def test_sequential_test_alternatives_run(alternative):
    from insurance_monitoring.sequential import SequentialTest
    test = SequentialTest(metric="frequency", alternative=alternative, alpha=0.05)
    result = test.update(
        champion_claims=10, champion_exposure=100,
        challenger_claims=10, challenger_exposure=100,
    )
    assert isinstance(result.lambda_value, float)
    assert result.lambda_value >= 0.0


@pytest.mark.parametrize("tau", [0.01, 0.03, 0.1, 0.5])
def test_sequential_test_tau_values(tau):
    from insurance_monitoring.sequential import SequentialTest
    test = SequentialTest(metric="frequency", tau=tau)
    result = test.update(
        champion_claims=10, champion_exposure=100,
        challenger_claims=10, challenger_exposure=100,
    )
    assert isinstance(result.lambda_value, float)


@pytest.mark.parametrize("loss", ["mse", "mae", "log_loss"])
def test_compute_loss_with_all_types(loss):
    from insurance_monitoring.drift_attribution import _compute_loss
    rng = np.random.default_rng(1)
    if loss == "log_loss":
        y_true = rng.choice([0.0, 1.0], size=50)
        y_pred = rng.uniform(0.1, 0.9, 50)
    else:
        y_true = rng.normal(0, 1, 50)
        y_pred = rng.normal(0, 1, 50)
    loss_val = _compute_loss(y_true, y_pred, loss)
    assert isinstance(loss_val, float)
    assert loss_val >= 0.0


@pytest.mark.parametrize("n_features", [2, 3, 4])
def test_drift_attributor_feature_count(n_features):
    from insurance_monitoring.drift_attribution import DriftAttributor
    rng = np.random.default_rng(42)
    X = rng.normal(0, 1, (150, n_features))
    y = X @ np.ones(n_features) + rng.normal(0, 0.1, 150)
    model = _simple_model()
    model.fit(X, y)
    features = [f"f{i}" for i in range(n_features)]
    attr = DriftAttributor(model=model, features=features, n_bootstrap=10, auto_retrain=False)
    attr.fit_reference(X, y, train_on_ref=False)
    result = attr.test(X, y)
    assert len(result.test_statistics) == n_features
    assert len(result.thresholds) == n_features


@pytest.mark.parametrize("ncs_type", ["absolute_residual", "relative_residual", "median_deviation", "studentized"])
def test_ncs_helpers_return_correct_shape(ncs_type):
    from insurance_monitoring.conformal_chart import ConformalControlChart
    n = 50
    a = np.random.randn(n)
    p = np.abs(np.random.randn(n)) + 0.1
    if ncs_type == "absolute_residual":
        ncs = ConformalControlChart.ncs_absolute_residual(a, p)
    elif ncs_type == "relative_residual":
        ncs = ConformalControlChart.ncs_relative_residual(a, p)
    elif ncs_type == "median_deviation":
        ncs = ConformalControlChart.ncs_median_deviation(a)
    elif ncs_type == "studentized":
        vol = np.abs(np.random.randn(n)) + 0.1
        ncs = ConformalControlChart.ncs_studentized(a, p, vol)
    assert ncs.shape == (n,)
    assert np.all(ncs >= 0.0)


@pytest.mark.parametrize("n_obs", [1, 5, 20, 100])
def test_conformal_chart_monitor_various_sizes(n_obs):
    from insurance_monitoring.conformal_chart import ConformalControlChart
    rng = np.random.default_rng(0)
    cal = rng.exponential(1, 200)
    chart = ConformalControlChart(alpha=0.05).fit(cal)
    new_scores = rng.exponential(1, n_obs)
    result = chart.monitor(new_scores)
    assert len(result.scores) == n_obs
    assert len(result.p_values) == n_obs
    assert len(result.is_alarm) == n_obs


@pytest.mark.parametrize("alpha", [0.01, 0.05, 0.1])
def test_sequential_test_threshold_reciprocal(alpha):
    from insurance_monitoring.sequential import SequentialTest
    test = SequentialTest(alpha=alpha)
    result = test.update(champion_claims=5, champion_exposure=100, challenger_claims=5, challenger_exposure=100)
    assert result.threshold == pytest.approx(1.0 / alpha)
