//! Wald's Sequential Probability Ratio Test for bounded repeated-run verdicts (WOR-1400).
//!
//! Why this exists: `mcptest run --repeat N` runs a non-deterministic test up
//! to N times to decide whether it *reliably* passes. Running the full N every
//! time wastes trials; AgentAssay (arXiv:2603.02601) shows a sequential test
//! reaches a confident verdict in far fewer. After each trial we update the
//! log-likelihood ratio of two hypotheses about the true pass probability and
//! stop the moment it crosses a boundary, so a clearly-good or clearly-bad test
//! ends early and only a genuinely borderline one spends the whole budget (and
//! returns inconclusive).
//!
//! The math is dependency-free. For `s` passes over `n` trials, with a low
//! hypothesis `p0` (unreliable floor) and a high hypothesis `p1` (reliable
//! target, `p1 > p0`):
//!
//! ```text
//! llr = s*ln(p1/p0) + (n - s)*ln((1 - p1)/(1 - p0))
//! A   = ln((1 - beta)/alpha)     cross up   => accept reliable
//! B   = ln(beta/(1 - alpha))     cross down => accept unreliable
//! ```
//!
//! `alpha` is the false-accept-reliable rate, `beta` the false-accept-unreliable
//! rate. Between `B` and `A` the test keeps sampling. Every decision is a pure
//! function of the counts, so a fixed trial sequence always yields the same
//! verdict.

use super::verdict::Verdict;

/// One SPRT configuration: the two hypotheses and their error rates.
///
/// `p0` is the pass-probability floor below which the test is judged
/// unreliable; `p1` is the target at or above which it is judged reliable.
/// `alpha` and `beta` are the two error rates that set how much evidence a
/// verdict needs.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SprtConfig {
    /// Unreliable-hypothesis pass probability (the floor). `0 < p0 < p1`.
    pub p0: f64,
    /// Reliable-hypothesis pass probability (the target). `p0 < p1 < 1`.
    pub p1: f64,
    /// Probability of wrongly accepting "reliable" when the floor holds.
    pub alpha: f64,
    /// Probability of wrongly accepting "unreliable" when the target holds.
    pub beta: f64,
}

impl Default for SprtConfig {
    /// A coin-flip floor (`p0 = 0.5`) against a high-reliability target
    /// (`p1 = 0.9`) at 95 percent confidence (`alpha = beta = 0.05`). These
    /// defaults let an all-pass test cross the reliable boundary in a handful
    /// of trials and an all-fail test cross the unreliable boundary even
    /// faster, which is what makes `--repeat` cheap on a clear signal.
    fn default() -> Self {
        Self {
            p0: 0.5,
            p1: 0.9,
            alpha: 0.05,
            beta: 0.05,
        }
    }
}

impl SprtConfig {
    /// Build a config from an integer confidence percent, setting both error
    /// rates to `1 - confidence`. `confidence_pct` is clamped to `[50, 99]` so
    /// the rates stay in `(0, 1)`; `p0` and `p1` carry the hypotheses.
    pub fn from_confidence_pct(confidence_pct: u8, p0: f64, p1: f64) -> Self {
        let pct = confidence_pct.clamp(50, 99) as f64;
        let err = (100.0 - pct) / 100.0;
        Self {
            p0,
            p1,
            alpha: err,
            beta: err,
        }
    }

    /// Validate the hypotheses and error rates, returning a human-readable
    /// reason when they are out of range. The loop calls this before any trial
    /// so a bad knob fails fast instead of producing a nonsense boundary.
    pub fn validate(&self) -> Result<(), String> {
        if !(self.p0 > 0.0 && self.p0 < 1.0) {
            return Err(format!("p0 must be in (0, 1), got {}", self.p0));
        }
        if !(self.p1 > 0.0 && self.p1 < 1.0) {
            return Err(format!("p1 must be in (0, 1), got {}", self.p1));
        }
        if self.p0 >= self.p1 {
            return Err(format!(
                "p0 ({}) must be strictly below p1 ({})",
                self.p0, self.p1
            ));
        }
        if !(self.alpha > 0.0 && self.alpha < 1.0) {
            return Err(format!("alpha must be in (0, 1), got {}", self.alpha));
        }
        if !(self.beta > 0.0 && self.beta < 1.0) {
            return Err(format!("beta must be in (0, 1), got {}", self.beta));
        }
        Ok(())
    }

    /// The `(upper, lower)` log-likelihood-ratio boundaries `(A, B)`. Crossing
    /// `A` accepts reliable; crossing `B` accepts unreliable.
    pub fn boundaries(&self) -> (f64, f64) {
        let upper = ((1.0 - self.beta) / self.alpha).ln();
        let lower = (self.beta / (1.0 - self.alpha)).ln();
        (upper, lower)
    }

    /// The log-likelihood ratio after `passes` of `trials`.
    pub fn log_likelihood_ratio(&self, passes: usize, trials: usize) -> f64 {
        let s = passes as f64;
        let n = trials as f64;
        s * (self.p1 / self.p0).ln() + (n - s) * ((1.0 - self.p1) / (1.0 - self.p0)).ln()
    }

    /// Decide whether the evidence so far crosses a boundary. With zero trials
    /// the answer is always [`SprtDecision::Continue`].
    pub fn decide(&self, passes: usize, trials: usize) -> SprtDecision {
        if trials == 0 {
            return SprtDecision::Continue;
        }
        let llr = self.log_likelihood_ratio(passes, trials);
        let (upper, lower) = self.boundaries();
        if llr >= upper {
            SprtDecision::AcceptReliable
        } else if llr <= lower {
            SprtDecision::AcceptUnreliable
        } else {
            SprtDecision::Continue
        }
    }
}

/// The running decision after observing a trial sequence.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SprtDecision {
    /// The ratio crossed the upper boundary: accept the reliable hypothesis.
    AcceptReliable,
    /// The ratio crossed the lower boundary: accept the unreliable hypothesis.
    AcceptUnreliable,
    /// Neither boundary crossed yet: keep sampling.
    Continue,
}

impl SprtDecision {
    /// Map the decision onto the shared three-valued [`Verdict`]. A still-open
    /// decision (the budget ran out) is [`Verdict::Inconclusive`].
    pub fn verdict(self) -> Verdict {
        match self {
            SprtDecision::AcceptReliable => Verdict::Pass,
            SprtDecision::AcceptUnreliable => Verdict::Fail,
            SprtDecision::Continue => Verdict::Inconclusive,
        }
    }
}

/// The result of a bounded sequential run: the counts spent, the boundary
/// decision, and a confidence interval around the observed pass rate.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SprtOutcome {
    /// Passing trials observed.
    pub passes: usize,
    /// Trials actually spent (at most `max_trials`).
    pub trials: usize,
    /// The trial budget the run was allowed.
    pub max_trials: usize,
    /// The boundary decision at the stopping point.
    pub decision: SprtDecision,
    /// True when a boundary crossed before the budget was exhausted.
    pub early_stopped: bool,
    /// Observed pass rate `passes / trials` (0.0 when no trials ran).
    pub pass_rate: f64,
    /// `(lower, upper)` Wald interval around [`Self::pass_rate`], clamped to
    /// `[0, 1]`.
    pub interval: (f64, f64),
    /// The three-valued verdict the decision maps to.
    pub verdict: Verdict,
}

impl SprtOutcome {
    /// Assemble an outcome from the counts the run accumulated. `confidence_pct`
    /// sets the reported interval width; it is the same percent the config was
    /// built from.
    pub fn build(
        config: &SprtConfig,
        passes: usize,
        trials: usize,
        max_trials: usize,
        confidence_pct: u8,
    ) -> Self {
        let decision = config.decide(passes, trials);
        let early_stopped = decision != SprtDecision::Continue && trials < max_trials;
        let pass_rate = if trials == 0 {
            0.0
        } else {
            passes as f64 / trials as f64
        };
        Self {
            passes,
            trials,
            max_trials,
            decision,
            early_stopped,
            pass_rate,
            interval: wald_interval(passes, trials, confidence_pct),
            verdict: decision.verdict(),
        }
    }
}

/// Drive a config over a full boolean trial sequence under a `max_trials`
/// budget, stopping at the first boundary crossing. Reads at most `max_trials`
/// of `outcomes`; a `true` entry is a passing trial. Pure and deterministic, so
/// it doubles as the test oracle for the streaming loop in the runner.
pub fn evaluate(config: &SprtConfig, outcomes: &[bool], max_trials: usize) -> SprtOutcome {
    let confidence_pct = ((1.0 - config.alpha) * 100.0).round() as u8;
    let mut passes = 0usize;
    let mut trials = 0usize;
    for &passed in outcomes.iter().take(max_trials) {
        if passed {
            passes += 1;
        }
        trials += 1;
        if config.decide(passes, trials) != SprtDecision::Continue {
            break;
        }
    }
    SprtOutcome::build(config, passes, trials, max_trials, confidence_pct)
}

/// Wald (normal-approximation) interval around the observed pass rate, clamped
/// to `[0, 1]`. Mirrors the interval the binary verdict path reports so the two
/// surfaces read the same; an empty run returns `(0, 0)`.
fn wald_interval(passes: usize, trials: usize, confidence_pct: u8) -> (f64, f64) {
    if trials == 0 {
        return (0.0, 0.0);
    }
    let n = trials as f64;
    let p = passes as f64 / n;
    let z = z_for_pct(confidence_pct);
    let half_width = z * (p * (1.0 - p) / n).sqrt();
    ((p - half_width).max(0.0), (p + half_width).min(1.0))
}

/// Standard-normal critical value for an integer two-sided confidence percent,
/// picking the smallest tabulated level at or above the request so a wider
/// interval errs toward a cautious report.
fn z_for_pct(confidence_pct: u8) -> f64 {
    const TABLE: &[(u8, f64)] = &[
        (80, 1.2816),
        (90, 1.6449),
        (95, 1.9600),
        (98, 2.3263),
        (99, 2.5758),
    ];
    for (pct, z) in TABLE {
        if confidence_pct <= *pct {
            return *z;
        }
    }
    2.5758
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_config_is_valid_and_well_ordered() {
        let cfg = SprtConfig::default();
        assert!(cfg.validate().is_ok());
        assert!(cfg.p0 < cfg.p1);
        let (upper, lower) = cfg.boundaries();
        assert!(upper > 0.0, "upper boundary accepts reliable");
        assert!(lower < 0.0, "lower boundary accepts unreliable");
    }

    #[test]
    fn from_confidence_pct_sets_both_error_rates() {
        let cfg = SprtConfig::from_confidence_pct(95, 0.5, 0.9);
        assert!((cfg.alpha - 0.05).abs() < 1e-9);
        assert!((cfg.beta - 0.05).abs() < 1e-9);
    }

    #[test]
    fn from_confidence_pct_clamps_out_of_range_input() {
        // 100 would make alpha == 0 (an infinite boundary); the clamp keeps it
        // finite at the 99 percent level.
        let cfg = SprtConfig::from_confidence_pct(100, 0.5, 0.9);
        assert!(cfg.alpha > 0.0 && cfg.alpha < 1.0);
        assert!(cfg.validate().is_ok());
    }

    #[test]
    fn validate_rejects_inverted_hypotheses() {
        let cfg = SprtConfig {
            p0: 0.9,
            p1: 0.5,
            alpha: 0.05,
            beta: 0.05,
        };
        assert!(cfg.validate().is_err());
    }

    #[test]
    fn validate_rejects_degenerate_probabilities() {
        let cfg = SprtConfig {
            p0: 0.0,
            p1: 0.9,
            alpha: 0.05,
            beta: 0.05,
        };
        assert!(cfg.validate().is_err());
    }

    #[test]
    fn an_all_pass_run_accepts_reliable_well_before_the_budget() {
        let cfg = SprtConfig::default();
        let outcomes = vec![true; 50];
        let out = evaluate(&cfg, &outcomes, 50);
        assert_eq!(out.decision, SprtDecision::AcceptReliable);
        assert_eq!(out.verdict, Verdict::Pass);
        assert!(out.early_stopped);
        assert!(
            out.trials < 15,
            "a clear pass should stop early, spent {}",
            out.trials
        );
    }

    #[test]
    fn an_all_fail_run_accepts_unreliable_well_before_the_budget() {
        let cfg = SprtConfig::default();
        let outcomes = vec![false; 50];
        let out = evaluate(&cfg, &outcomes, 50);
        assert_eq!(out.decision, SprtDecision::AcceptUnreliable);
        assert_eq!(out.verdict, Verdict::Fail);
        assert!(out.early_stopped);
        assert!(
            out.trials < 10,
            "a clear fail should stop early, spent {}",
            out.trials
        );
    }

    #[test]
    fn a_borderline_run_exhausts_the_budget_and_is_inconclusive() {
        let cfg = SprtConfig::default();
        // An even pass/fail split sits on the unreliable floor, so the ratio
        // drifts down slowly; a tight four-trial budget runs out before it
        // reaches the lower boundary.
        let outcomes = vec![true, false, true, false];
        let out = evaluate(&cfg, &outcomes, 4);
        assert_eq!(out.decision, SprtDecision::Continue);
        assert_eq!(out.verdict, Verdict::Inconclusive);
        assert!(!out.early_stopped);
        assert_eq!(out.trials, 4);
    }

    #[test]
    fn the_reported_interval_brackets_the_observed_rate() {
        let cfg = SprtConfig::default();
        let outcomes = vec![true; 50];
        let out = evaluate(&cfg, &outcomes, 50);
        let (lower, upper) = out.interval;
        assert!(lower <= out.pass_rate && out.pass_rate <= upper);
        assert!(lower >= 0.0 && upper <= 1.0);
    }

    #[test]
    fn evaluate_respects_a_smaller_budget_than_the_sequence() {
        let cfg = SprtConfig::default();
        let outcomes = vec![true; 50];
        let out = evaluate(&cfg, &outcomes, 3);
        // Three passes is not enough evidence at the default config, so the
        // capped run stays open and reports inconclusive.
        assert_eq!(out.trials, 3);
        assert_eq!(out.verdict, Verdict::Inconclusive);
    }
}
