Skip to main content

ballistics_engine/
mc_stats.rs

1//! Streaming moments and Bernoulli intervals for Monte Carlo hit statistics.
2//!
3//! [`Welford`] accumulates a running mean and variance in constant memory, one trial at a
4//! time, so an adaptive Monte Carlo driver never needs to retain the full trial history just
5//! to report a standard deviation. [`wilson_interval`] is the classic fixed-`n` confidence
6//! interval for a Bernoulli hit/miss proportion, evaluated at one of three pinned confidence
7//! levels ([`ConfidenceLevel`]). [`BernoulliConfidenceSequence`] is the anytime-valid
8//! beta-binomial-mixture counterpart to [`wilson_interval`]: it lets an adaptive driver peek at
9//! partial results after every trial, and stop on a data-dependent rule, without inflating its
10//! error rate (Task 3 of Plan C, MBA-1352).
11//!
12//! No randomness lives in the production path: this module consumes trial outcomes a caller
13//! already produced (`MonteCarloTrialSampler::sample_one_trial` in `src/cli_api.rs`, the one
14//! trial body both the legacy fixed-count loop and the adaptive driver run) and is pure `std`
15//! math only -- no `rand`, no `fs`, no `clap` -- so it compiles for `wasm32-unknown-unknown`
16//! unconditionally. (One `#[cfg(test)]` test, the empirical coverage check, does draw from a
17//! seeded `rand` generator; nothing outside `#[cfg(test)]` does.)
18
19use crate::special::ln_beta;
20
21/// Online (streaming) mean and variance via Welford's algorithm.
22///
23/// Naive "sum of squares minus square of sum" variance loses precision catastrophically once
24/// the values share a large common offset (see this module's tests for a worked example);
25/// Welford's algorithm instead updates the mean and the sum of squared deviations from the
26/// *running* mean one sample at a time, and never needs to retain the samples themselves --
27/// exactly the shape an adaptive Monte Carlo driver needs: an unbounded trial count held in
28/// `O(1)` memory.
29#[derive(Debug, Clone, Default)]
30pub struct Welford {
31    n: u64,
32    mean: f64,
33    m2: f64,
34}
35
36impl Welford {
37    /// A fresh accumulator with no observations.
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Folds one more observation into the running mean and variance.
43    pub fn push(&mut self, x: f64) {
44        self.n += 1;
45        let delta = x - self.mean;
46        self.mean += delta / self.n as f64;
47        self.m2 += delta * (x - self.mean);
48    }
49
50    /// Number of observations folded in so far.
51    pub fn count(&self) -> u64 {
52        self.n
53    }
54
55    /// Running mean. `0.0` when no observations have been pushed yet.
56    pub fn mean(&self) -> f64 {
57        self.mean
58    }
59
60    /// Unbiased (Bessel-corrected) sample variance, `m2 / (n - 1)`.
61    ///
62    /// `0.0` when fewer than two observations have been pushed -- a single point has no
63    /// defined spread.
64    pub fn sample_variance(&self) -> f64 {
65        if self.n < 2 {
66            0.0
67        } else {
68            self.m2 / (self.n - 1) as f64
69        }
70    }
71
72    /// Population variance, `m2 / n`. `0.0` when no observations have been pushed yet.
73    pub fn population_variance(&self) -> f64 {
74        if self.n == 0 {
75            0.0
76        } else {
77            self.m2 / self.n as f64
78        }
79    }
80
81    /// Sample standard deviation, `sqrt(sample_variance())`.
82    pub fn sample_std(&self) -> f64 {
83        self.sample_variance().sqrt()
84    }
85}
86
87/// A confidence level pinned to one of three fixed two-sided critical values.
88///
89/// A probit implementation (inverse normal CDF) is deliberately out of scope: three levels
90/// cover every caller in this train, so the module states the constants directly rather than
91/// deriving them numerically (Plan C spec 9.2).
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum ConfidenceLevel {
94    /// 90% two-sided confidence.
95    P90,
96    /// 95% two-sided confidence.
97    P95,
98    /// 99% two-sided confidence.
99    P99,
100}
101
102impl ConfidenceLevel {
103    /// The two-sided critical value `z` such that `P(-z <= Z <= z) == 1 - alpha` for a
104    /// standard normal `Z`.
105    pub fn z(&self) -> f64 {
106        match self {
107            Self::P90 => 1.644_853_626_951_472_2,
108            Self::P95 => 1.959_963_984_540_054,
109            Self::P99 => 2.575_829_303_548_900_4,
110        }
111    }
112
113    /// The significance level `alpha = 1 - confidence`.
114    pub fn alpha(&self) -> f64 {
115        match self {
116            Self::P90 => 0.10,
117            Self::P95 => 0.05,
118            Self::P99 => 0.01,
119        }
120    }
121
122    /// The confidence level as a whole-number percentage (`90`, `95`, or `99`).
123    pub fn as_percent(&self) -> u32 {
124        match self {
125            Self::P90 => 90,
126            Self::P95 => 95,
127            Self::P99 => 99,
128        }
129    }
130}
131
132/// Wilson score interval for a Bernoulli proportion at fixed `n`.
133///
134/// `n == 0` returns `(0.0, 1.0)` -- total ignorance, not an error: with zero trials nothing is
135/// known about the proportion beyond it lying in `[0, 1]`, so the widest possible interval is
136/// the honest answer rather than a divide-by-zero or a panic.
137///
138/// `successes` above `trials` is not a domain error either: it saturates at `trials` (`p =
139/// 1.0`), so the call reports exactly the interval a fully-successful run of that size would.
140/// This is deliberate, not merely tolerated: for `p` only just above `1.0` the radicand below
141/// does not reliably go negative (`z^2/(4n^2)` can outweigh a small negative `p(1-p)/n`), so an
142/// unclamped implementation would silently return a plausible-looking wrong interval rather
143/// than a loud `NaN` -- saturating `p` at the input removes that failure mode entirely instead
144/// of documenting around it.
145///
146/// Unlike the naive Wald interval (`p +- z * sqrt(p(1-p)/n)`), the Wilson interval stays
147/// inside `[0, 1]` and has correct coverage even at small `n` or `p` near `0` or `1` --
148/// exactly the regime a hit-probability Monte Carlo run starts in before enough trials have
149/// accumulated. Computed directly in "center +- spread" form (Wilson 1927; worked example in
150/// Newcombe 1998, cross-checked by this module's tests):
151///
152/// ```text
153/// center = (p + z^2 / (2n)) / (1 + z^2 / n)
154/// spread = (z / (1 + z^2 / n)) * sqrt(p(1-p)/n + z^2/(4n^2))
155/// ```
156///
157/// where `p = min(successes / trials, 1.0)` and `z` is [`ConfidenceLevel::z`]. The result is
158/// additionally clamped to `[0, 1]`: floating-point rounding in `center +- spread` can overshoot
159/// by up to a few ULP right at the `p == 0` / `p == 1` edges, and a probability bound outside
160/// `[0, 1]` is a worse answer than one that is merely maximally uninformative.
161pub fn wilson_interval(successes: u64, trials: u64, level: ConfidenceLevel) -> (f64, f64) {
162    if trials == 0 {
163        return (0.0, 1.0);
164    }
165    let n = trials as f64;
166    let p = (successes as f64 / n).min(1.0);
167    let z = level.z();
168    let z2 = z * z;
169    let denom = 1.0 + z2 / n;
170    let center = (p + z2 / (2.0 * n)) / denom;
171    let spread = (z / denom) * (p * (1.0 - p) / n + z2 / (4.0 * n * n)).sqrt();
172    ((center - spread).max(0.0), (center + spread).min(1.0))
173}
174
175/// Bracket floor for the endpoint search: roots are hunted on `(EPS, p_hat]` and
176/// `[p_hat, 1 - EPS)` rather than on the open unit interval, because `ln M_n` diverges at both
177/// ends and a bracket endpoint has to be a finite number. `1e-15` is ~9 ULP away from `0.0` on
178/// the low side and ~9 ULP from `1.0` on the high side, so the excluded slivers are narrower
179/// than any proportion this crate could report meaningfully; a root inside a sliver is reported
180/// as the saturated `0.0` / `1.0`, which widens the interval and therefore cannot break
181/// coverage.
182const BRACKET_EPS: f64 = 1e-15;
183
184/// Bisection steps taken per endpoint by [`BernoulliConfidenceSequence::bounds`]. Fixed, not
185/// tolerance-driven -- see that method's docs for why.
186const BISECTION_ITERS: u32 = 200;
187
188/// `ln M_n(p)`, the log mixture martingale, given the precomputed prior term
189/// `ln_b = ln B(S+1, n-S+1)` (constant across an endpoint search, so it is hoisted out).
190///
191/// The two guards apply the `0 * ln(0) == 0` convention, which makes this a total function at
192/// `p == 0` and `p == 1` (where `S == 0` / `S == n` respectively kill the divergent term) rather
193/// than a `0.0 * -INFINITY == NaN`. The bisection itself never evaluates outside
194/// `[BRACKET_EPS, 1 - BRACKET_EPS]`, so the guards are a correctness margin, not a hot path.
195///
196/// `(1.0 - p).ln()` is used rather than `(-p).ln_1p()`: for `p >= 0.5` the subtraction is exact
197/// (Sterbenz), and for `p < 0.5` the operand is at least `0.5`, so its half-ULP rounding is a
198/// relative `1e-16` that contributes an absolute `~1e-16` to a logarithm -- far below the
199/// `ln_beta` error budget quantified in [`BernoulliConfidenceSequence::bounds`].
200fn ln_mixture(ln_b: f64, successes: f64, failures: f64, p: f64) -> f64 {
201    let mut ln_m = ln_b;
202    if successes > 0.0 {
203        ln_m -= successes * p.ln();
204    }
205    if failures > 0.0 {
206        ln_m -= failures * (1.0 - p).ln();
207    }
208    ln_m
209}
210
211/// Bisects for the endpoint where `ln M_n(p) == threshold`, on a bracket whose `outside` end is
212/// known to exceed the threshold and whose `inside` end is known not to.
213///
214/// Direction-agnostic on purpose: the lower endpoint passes `outside = BRACKET_EPS,
215/// inside = p_hat` and the upper passes `outside = 1 - BRACKET_EPS, inside = p_hat`, so both
216/// endpoints run the identical loop and cannot drift apart under maintenance. Midpoints stay
217/// strictly inside the initial bracket, so `p` never reaches `0.0` or `1.0`.
218fn bisect_endpoint(
219    ln_b: f64,
220    successes: f64,
221    failures: f64,
222    threshold: f64,
223    mut outside: f64,
224    mut inside: f64,
225) -> f64 {
226    for _ in 0..BISECTION_ITERS {
227        let mid = 0.5 * (outside + inside);
228        if ln_mixture(ln_b, successes, failures, mid) > threshold {
229            outside = mid;
230        } else {
231            inside = mid;
232        }
233    }
234    0.5 * (outside + inside)
235}
236
237/// Anytime-valid confidence sequence for a Bernoulli proportion: Robbins' beta-binomial mixture
238/// with a uniform `Beta(1, 1)` prior.
239///
240/// # Why not just re-run [`wilson_interval`]
241///
242/// A fixed-`n` interval is only valid if `n` was fixed *before* the data. An adaptive driver
243/// that watches the interval after every trial and stops when it looks tight enough is doing
244/// optional stopping, and a fixed-`n` interval checked that way has no coverage guarantee at
245/// all: the error rate grows with the number of peeks. A confidence sequence is instead a whole
246/// family of intervals, one per `n`, that are *simultaneously* valid:
247///
248/// ```text
249/// P( there exists n >= 1 with p_true outside CS_n )  <=  alpha
250/// ```
251///
252/// so the caller may look as often as it likes, stop on any rule it likes -- including a
253/// data-dependent one -- and the interval it stops on still covers at the advertised rate. The
254/// price is width: at every `n` this interval is strictly wider than the Wilson interval on the
255/// same counts (pinned by this module's tests). Paying it is the point.
256///
257/// # The mixture martingale
258///
259/// Robbins (1970), "Statistical methods related to the law of the iterated logarithm"; the
260/// modern treatment is Howard, Ramdas, McAuliffe & Sekhon (2021), "Time-uniform, nonasymptotic,
261/// nonparametric confidence sequences". Against a candidate value `p`, with `S` successes in
262/// `n` trials, mix the likelihood ratio over a uniform `Beta(1, 1)` prior on the alternative:
263///
264/// ```text
265/// M_n(p) = integral_0^1 q^S (1-q)^(n-S) dq / [ p^S (1-p)^(n-S) ]
266///        = B(S+1, n-S+1) / [ p^S (1-p)^(n-S) ]
267/// ```
268///
269/// The prior's own normaliser `1 / B(1, 1)` is `1` (`ln B(1,1) == 0`), so it drops out. In logs,
270/// via [`crate::special::ln_beta`]:
271///
272/// ```text
273/// ln M_n(p) = ln B(S+1, n-S+1) - S*ln(p) - (n-S)*ln(1-p)
274/// ```
275///
276/// When `p` is the true success probability, `M_n` is a nonnegative martingale with `M_0 = 1`,
277/// so Ville's inequality bounds it uniformly over all time: `P(exists n : M_n >= 1/alpha) <=
278/// alpha`. Inverting that test -- keeping every `p` the data has not yet ruled out -- gives the
279/// confidence set
280///
281/// ```text
282/// CS_n = { p in (0, 1) : ln M_n(p) <= ln(1/alpha) }
283/// ```
284///
285/// whose miss probability, over the whole infinite sequence at once, is at most `alpha`.
286///
287/// # Asymptotics
288///
289/// Substituting Stirling into `ln B(S+1, n-S+1) = -ln[(n+1) * C(n, S)]` gives
290/// `ln M_n(p) ~ n*KL(p_hat || p) - ln(n+1) + 0.5*ln(2*pi*n*p_hat*(1-p_hat))`, so the half-width
291/// shrinks like `sqrt((ln(1/alpha) + 0.5*ln n) * p_hat*(1-p_hat) * 2 / n)` -- the familiar
292/// `sqrt(log(n)/n)` rate of a confidence sequence, a `sqrt(log n)` factor wider than the
293/// `sqrt(1/n)` of a fixed-`n` interval. That extra factor *is* the optional-stopping licence.
294#[derive(Debug, Clone)]
295pub struct BernoulliConfidenceSequence {
296    successes: u64,
297    trials: u64,
298    alpha: f64,
299}
300
301impl BernoulliConfidenceSequence {
302    /// A fresh sequence with no observations, at the given confidence level.
303    pub fn new(level: ConfidenceLevel) -> Self {
304        Self {
305            successes: 0,
306            trials: 0,
307            alpha: level.alpha(),
308        }
309    }
310
311    /// Folds in one more trial.
312    pub fn update(&mut self, hit: bool) {
313        self.trials = self.trials.saturating_add(1);
314        if hit {
315            self.successes = self.successes.saturating_add(1);
316        }
317    }
318
319    /// Folds in `trials` more trials of which `hits` succeeded.
320    ///
321    /// `hits` above `trials` saturates at `trials` rather than panicking or asserting, matching
322    /// [`wilson_interval`]'s posture for the same malformed input so the two functions cannot
323    /// disagree about what a bad count means. That choice is load-bearing here rather than
324    /// merely tidy: `S > n` would make the second argument of `ln B(S+1, n-S+1)` non-positive,
325    /// [`crate::special::ln_gamma`] returns `NaN` out of domain, and `NaN > threshold` is
326    /// `false` -- so `bisect_endpoint` would take its `else` arm 200 times running and return
327    /// a perfectly plausible-looking number pinned to the bracket end instead of signalling
328    /// anything. Saturating at the input removes that silent-wrong-answer path entirely. A
329    /// `debug_assert!` was considered and rejected for the reason recorded on `wilson_interval`:
330    /// it would panic under plain `cargo test`, leaving the documented behaviour unpinnable by
331    /// an ordinary test.
332    ///
333    /// Both counters saturate at `u64::MAX` too, which preserves the `successes <= trials`
334    /// invariant (`min(S+h, MAX) <= min(n+t, MAX)` whenever `S <= n` and `h <= t`).
335    pub fn update_batch(&mut self, hits: u64, trials: u64) {
336        self.trials = self.trials.saturating_add(trials);
337        self.successes = self.successes.saturating_add(hits.min(trials));
338    }
339
340    /// Successes observed so far.
341    pub fn successes(&self) -> u64 {
342        self.successes
343    }
344
345    /// Trials observed so far.
346    pub fn trials(&self) -> u64 {
347        self.trials
348    }
349
350    /// The current interval: every `p` not yet ruled out at level `alpha`.
351    ///
352    /// `(0.0, 1.0)` when no trials have been folded in -- total ignorance, the same answer
353    /// [`wilson_interval`] gives at `n == 0`, and not an error.
354    ///
355    /// # How the endpoints are found
356    ///
357    /// `d^2/dp^2 ln M_n(p) = S/p^2 + (n-S)/(1-p)^2 > 0`, so `ln M_n` is strictly convex on
358    /// `(0, 1)` with its unique minimum at the MLE `p_hat = S/n`. The confidence set is
359    /// therefore a genuine interval, bounded by the two solutions of
360    /// `ln M_n(p) = ln(1/alpha)`, one on each side of `p_hat`.
361    ///
362    /// `p_hat` is always strictly inside the set, so both brackets are always valid: the
363    /// minimum value is `ln M_n(p_hat) = -ln[(n+1) * C(n,S) * p_hat^S * (1-p_hat)^(n-S)]`, and
364    /// the method-of-types bound (Cover & Thomas, *Elements of Information Theory*, Thm 11.1.4:
365    /// a type's probability under its own maximum-likelihood distribution is at least
366    /// `1/(n+1)^(|alphabet|-1)`, here `1/(n+1)`) makes that bracketed product at least `1`,
367    /// hence `ln M_n(p_hat) <= 0 < ln(1/alpha)` for every `n >= 1` and every level.
368    ///
369    /// Each endpoint is then bisected with exactly `BISECTION_ITERS` (200) steps -- a fixed count,
370    /// not a tolerance test. Every step halves the bracket, so after 200 the bracket is
371    /// `2^-200 ~ 6e-61` of a starting width below `1.0`: the loop has been sitting on two
372    /// adjacent doubles since roughly step 55, and the remaining steps are no-ops that keep
373    /// `bounds()` a pure function of `(S, n, alpha)` with no data-dependent trip count. Since
374    /// the search resolves `p` to the last bit, accuracy is set by `ln M_n` itself, i.e. by
375    /// [`crate::special::ln_beta`]: its measured `~7e-16` relative error at `n = 1e6` is an
376    /// absolute `~5e-10` on an `ln B` of magnitude `~7e5`, and dividing by the slope of
377    /// `ln M_n` at the endpoint (`~9e3` there) puts the endpoint error near `1e-13` -- ten
378    /// orders of magnitude below the `~2e-3` half-width at that `n`.
379    ///
380    /// # Saturation
381    ///
382    /// Each side saturates for either of two independent reasons.
383    ///
384    /// **Rule 1, the `p_hat` rule.** The lower root can never sit above `p_hat`, so
385    /// `p_hat <= BRACKET_EPS` (which includes `S == 0`, where `p_hat` is exactly `0`) means the
386    /// root is below anything the bracket can resolve: the bound is reported as exactly `0.0`.
387    /// The upper side mirrors it at `p_hat >= 1 - BRACKET_EPS` (including `S == n`, reported as
388    /// exactly `1.0`). This rule is what handles every small-`n` case, on both sides.
389    ///
390    /// **Rule 2, the endpoint-value guard.** Independently, `ln M_n` evaluated *at* the bracket
391    /// end can already sit at or below the threshold, meaning no root exists inside the bracket
392    /// at all; the bound then saturates rather than bisecting for something that is not there.
393    /// Despite the "tiny `n`" intuition this guard is **not** load-bearing at small `n` -- at
394    /// `S = 1, n = 1` and 95% confidence, `ln M_n(eps) = 33.85` against a threshold of `2.996`,
395    /// 30 nats away from firing, and the low bound there is a genuine root at `0.025`. Every
396    /// small-`n` case that does satisfy this guard is already caught by rule 1. It first becomes
397    /// non-redundant in the opposite regime -- large `n` with an extreme `p_hat`, where the true
398    /// root falls below `BRACKET_EPS` while `p_hat` itself does not. Measured first firings:
399    /// `S = 1` at `n ~ 1e7` (giving `(0.0, 2.22e-6)`), and `S = 2` at `n ~ 1e10`. Do not read it
400    /// as dead code because a small-`n` probe never reaches it.
401    ///
402    /// Both saturations widen the interval, so neither can cost coverage.
403    pub fn bounds(&self) -> (f64, f64) {
404        if self.trials == 0 {
405            return (0.0, 1.0);
406        }
407        let n = self.trials as f64;
408        // The invariant is maintained by both update paths; re-imposed here so a future
409        // constructor cannot leak an S > n state into ln_gamma's NaN domain.
410        let successes = self.successes.min(self.trials) as f64;
411        let failures = n - successes;
412        let p_hat = successes / n;
413        let threshold = -self.alpha.ln(); // ln(1/alpha)
414        let ln_b = ln_beta(successes + 1.0, failures + 1.0);
415
416        let lower = if p_hat <= BRACKET_EPS
417            || ln_mixture(ln_b, successes, failures, BRACKET_EPS) <= threshold
418        {
419            0.0
420        } else {
421            bisect_endpoint(ln_b, successes, failures, threshold, BRACKET_EPS, p_hat)
422        };
423
424        let upper = if p_hat >= 1.0 - BRACKET_EPS
425            || ln_mixture(ln_b, successes, failures, 1.0 - BRACKET_EPS) <= threshold
426        {
427            1.0
428        } else {
429            bisect_endpoint(
430                ln_b,
431                successes,
432                failures,
433                threshold,
434                1.0 - BRACKET_EPS,
435                p_hat,
436            )
437        };
438
439        (lower, upper)
440    }
441
442    /// Half the width of the current interval; `0.5` before any trials.
443    pub fn half_width(&self) -> f64 {
444        let (lo, hi) = self.bounds();
445        (hi - lo) / 2.0
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn welford_matches_two_pass_moments_on_a_hostile_fixture() {
455        // Catastrophic-cancellation fixture: large offset, small spread — the case
456        // naive sum-of-squares gets wrong and Welford exists to protect.
457        let xs: Vec<f64> = (0..1000).map(|i| 1.0e9 + (i % 7) as f64 * 0.25).collect();
458        let mut w = Welford::new();
459        for &x in &xs { w.push(x); }
460        let n = xs.len() as f64;
461        let mean = xs.iter().sum::<f64>() / n;
462        let m2 = xs.iter().map(|x| (x - mean).powi(2)).sum::<f64>();
463        assert_eq!(w.count(), 1000);
464        assert!((w.mean() - mean).abs() < 1e-6);
465        assert!((w.population_variance() - m2 / n).abs() < 1e-6);
466        assert!((w.sample_variance() - m2 / (n - 1.0)).abs() < 1e-6);
467    }
468
469    #[test]
470    fn welford_empty_and_single_are_defined() {
471        let mut w = Welford::new();
472        assert_eq!(w.count(), 0);
473        assert_eq!(w.mean(), 0.0);
474        assert_eq!(w.sample_variance(), 0.0);
475        assert_eq!(w.population_variance(), 0.0);
476        w.push(42.0);
477        assert_eq!(w.mean(), 42.0);
478        assert_eq!(w.sample_variance(), 0.0); // n < 2
479        assert_eq!(w.population_variance(), 0.0);
480    }
481
482    #[test]
483    fn confidence_level_accessors_are_pinned_exactly() {
484        // z(): bit-for-bit against the three literals pinned in the plan's Global Constraints
485        // (docs/superpowers/plans/2026-08-04-decision-support-plan-c.md). The 95% value is the
486        // same number as `truing_uncertainty::NORMAL_95_TWO_SIDED_Z`
487        // (src/truing_uncertainty.rs:36), but that `const` is module-private (not `pub` or
488        // `pub(crate)`), so it is not reachable from here; the digits are restated rather than
489        // imported. Without this test, z() was previously anchored only indirectly (P95 via
490        // the Newcombe reference, P99 only by an inequality, P90 by nothing at all) -- review I3.
491        assert_eq!(ConfidenceLevel::P90.z().to_bits(), 1.644_853_626_951_472_2_f64.to_bits());
492        assert_eq!(ConfidenceLevel::P95.z().to_bits(), 1.959_963_984_540_054_f64.to_bits());
493        assert_eq!(ConfidenceLevel::P99.z().to_bits(), 2.575_829_303_548_900_4_f64.to_bits());
494
495        assert_eq!(ConfidenceLevel::P90.alpha(), 0.10);
496        assert_eq!(ConfidenceLevel::P95.alpha(), 0.05);
497        assert_eq!(ConfidenceLevel::P99.alpha(), 0.01);
498
499        assert_eq!(ConfidenceLevel::P90.as_percent(), 90);
500        assert_eq!(ConfidenceLevel::P95.as_percent(), 95);
501        assert_eq!(ConfidenceLevel::P99.as_percent(), 99);
502    }
503
504    #[test]
505    fn wilson_matches_the_newcombe_canonical_example() {
506        // Newcombe (1998), worked example: 81/263 at 95% → (0.2553, 0.3662) to 4 dp.
507        let (lo, hi) = wilson_interval(81, 263, ConfidenceLevel::P95);
508        assert!((lo - 0.2553).abs() < 5e-4, "lo = {lo}");
509        assert!((hi - 0.3662).abs() < 5e-4, "hi = {hi}");
510    }
511
512    #[test]
513    fn wilson_two_algebraic_forms_agree() {
514        // center ± spread form (production) vs the quadratic-root form (independent oracle,
515        // structurally different): solving (p_hat - p)^2 = z^2 * p(1-p)/n directly for p gives
516        // the quadratic p^2*(n + z^2) - p*(2*n*p_hat + z^2) + n*p_hat^2 == 0, whose roots are
517        //   p = [ (2*n*p_hat + z^2) +- z * sqrt(z^2 + 4*n*p_hat*(1 - p_hat)) ] / (2*(n + z^2))
518        // A mis-transcription of either form fails this cross-check (review I2: the previous
519        // version of this test restated the production expression term-for-term and could not).
520        for &(k, n) in &[(0_u64, 10_u64), (10, 10), (1, 10), (8, 10), (500, 1000), (3, 7)] {
521            for level in [ConfidenceLevel::P90, ConfidenceLevel::P95, ConfidenceLevel::P99] {
522                let (lo, hi) = wilson_interval(k, n, level);
523                let z = level.z();
524                let (kf, nf) = (k as f64, n as f64);
525                let p_hat = kf / nf;
526                let z2 = z * z;
527                let a = nf + z2;
528                let b = 2.0 * nf * p_hat + z2;
529                let root_term = z * (z2 + 4.0 * nf * p_hat * (1.0 - p_hat)).sqrt();
530                let lo_root = (b - root_term) / (2.0 * a);
531                let hi_root = (b + root_term) / (2.0 * a);
532                assert!((lo - lo_root).abs() < 1e-12, "lo mismatch at k={k} n={n}: {lo} vs {lo_root}");
533                assert!((hi - hi_root).abs() < 1e-12, "hi mismatch at k={k} n={n}: {hi} vs {hi_root}");
534            }
535        }
536    }
537
538    #[test]
539    // The brief's bound checks are intentionally spelled out as explicit `>=`/`<` comparisons
540    // (matching the two-sided prose "lower bound must be 0" / "upper bound must be 1") rather
541    // than `Range::contains`, which clippy's default lint set does not recognize as equivalent
542    // here.
543    #[allow(clippy::manual_range_contains)]
544    fn wilson_edges_and_ordering_properties() {
545        assert_eq!(wilson_interval(0, 0, ConfidenceLevel::P95), (0.0, 1.0));
546        // k=0 / k=n bounds must land in [0, 1] EXACTLY (unclamped center +- spread can overshoot
547        // by a few ULP at these edges; `wilson_interval` clamps for it) and stay tight against
548        // 0 / 1 (exact in real arithmetic for every n, at every level). Swept rather than probed
549        // at a single n: n = 20 at P95 happens to round to exactly 0.0 / 1.0 even unclamped,
550        // which gave false assurance that the property held in general (review I1).
551        for n in 1_u64..=200 {
552            for level in [ConfidenceLevel::P90, ConfidenceLevel::P95, ConfidenceLevel::P99] {
553                let (lo0, _) = wilson_interval(0, n, level);
554                assert!(lo0 >= 0.0 && lo0 < 1e-9, "k=0 n={n} {level:?}: lo = {lo0:e}, want [0, 1e-9)");
555                let (_, hi_n) = wilson_interval(n, n, level);
556                assert!(hi_n <= 1.0 && hi_n > 1.0 - 1e-9, "k=n n={n} {level:?}: hi = {hi_n:e}, want (1-1e-9, 1]");
557            }
558        }
559        // Wider at higher confidence, narrower at larger n.
560        let w = |k, n, l| { let (a, b) = wilson_interval(k, n, l); b - a };
561        assert!(w(40, 100, ConfidenceLevel::P99) > w(40, 100, ConfidenceLevel::P95));
562        assert!(w(40, 100, ConfidenceLevel::P95) > w(400, 1000, ConfidenceLevel::P95));
563        // Interval always contains the point estimate.
564        let (lo, hi) = wilson_interval(3, 7, ConfidenceLevel::P90);
565        let p = 3.0 / 7.0;
566        assert!(lo < p && p < hi);
567    }
568
569    #[test]
570    fn wilson_interval_saturates_when_successes_exceeds_trials() {
571        // successes > trials is not a panic and not a silent NaN: it saturates at trials
572        // (p = 1.0), reporting exactly the k=n interval, for every level (review I4).
573        for level in [ConfidenceLevel::P90, ConfidenceLevel::P95, ConfidenceLevel::P99] {
574            assert_eq!(wilson_interval(37, 20, level), wilson_interval(20, 20, level));
575            assert_eq!(wilson_interval(u64::MAX, 20, level), wilson_interval(20, 20, level));
576        }
577    }
578
579    #[test]
580    fn cs_starts_ignorant_and_shrinks_monotonically_in_n() {
581        let mut cs = BernoulliConfidenceSequence::new(ConfidenceLevel::P95);
582        assert_eq!(cs.bounds(), (0.0, 1.0));
583        let mut prev = 1.0_f64;
584        // Alternate hits/misses so p̂ stays near 0.5 while n grows.
585        for i in 0..2000 {
586            cs.update(i % 2 == 0);
587            if i % 100 == 99 {
588                let hw = cs.half_width();
589                assert!(hw <= prev + 1e-12, "half-width grew at n={}: {hw} > {prev}", i + 1);
590                prev = hw;
591            }
592        }
593        let (lo, hi) = cs.bounds();
594        assert!(lo > 0.0 && hi < 1.0 && lo < 0.5 && 0.5 < hi);
595    }
596
597    #[test]
598    fn cs_edges_saturate_exactly() {
599        let mut cs = BernoulliConfidenceSequence::new(ConfidenceLevel::P95);
600        cs.update_batch(0, 50);
601        let (lo, hi) = cs.bounds();
602        assert_eq!(lo, 0.0);
603        assert!(hi < 0.25 && hi > 0.0);
604        let mut cs2 = BernoulliConfidenceSequence::new(ConfidenceLevel::P95);
605        cs2.update_batch(50, 50);
606        let (lo2, hi2) = cs2.bounds();
607        assert_eq!(hi2, 1.0);
608        assert!(lo2 > 0.75 && lo2 < 1.0);
609    }
610
611    #[test]
612    fn cs_is_wider_than_wilson_at_the_same_n() {
613        // The honesty property that motivates anytime-validity: the price of
614        // optional stopping is a wider interval than the fixed-n Wilson at every n.
615        for &(k, n) in &[(10_u64, 40_u64), (81, 263), (500, 1000)] {
616            let mut cs = BernoulliConfidenceSequence::new(ConfidenceLevel::P95);
617            cs.update_batch(k, n);
618            let (clo, chi) = cs.bounds();
619            let (wlo, whi) = wilson_interval(k, n, ConfidenceLevel::P95);
620            assert!(chi - clo > whi - wlo, "CS not wider than Wilson at k={k}, n={n}");
621        }
622    }
623
624    #[test]
625    fn cs_bounds_are_deterministic_and_batch_order_invariant() {
626        let mut a = BernoulliConfidenceSequence::new(ConfidenceLevel::P99);
627        let mut b = BernoulliConfidenceSequence::new(ConfidenceLevel::P99);
628        a.update_batch(30, 100);
629        for i in 0..100 { b.update(i < 30); }
630        assert_eq!(a.bounds(), b.bounds()); // state is (S, n) only — exact equality
631    }
632
633    /// The structural preconditions `bounds()` relies on, swept rather than argued: the
634    /// endpoints bracket the MLE (so the two bisections really did straddle the root, and
635    /// `ln M_n(p_hat) <= 0 < ln(1/alpha)` really does hold at every `(S, n)` — the
636    /// method-of-types claim in the `bounds()` docs), the interval stays inside `[0, 1]`,
637    /// and lowering alpha only ever widens it. A sign error or a swapped bracket in the
638    /// endpoint search fails this even where the brief's fixtures happen to look sane.
639    #[test]
640    fn cs_brackets_the_mle_and_nests_by_confidence_level() {
641        for n in 1_u64..=60 {
642            for s in 0..=n {
643                let p_hat = s as f64 / n as f64;
644                let mut widths = Vec::new();
645                for level in [ConfidenceLevel::P90, ConfidenceLevel::P95, ConfidenceLevel::P99] {
646                    let mut cs = BernoulliConfidenceSequence::new(level);
647                    cs.update_batch(s, n);
648                    let (lo, hi) = cs.bounds();
649                    assert!(lo.is_finite() && hi.is_finite(), "non-finite bound at s={s} n={n}");
650                    assert!((0.0..=1.0).contains(&lo), "lo={lo} out of [0,1] at s={s} n={n}");
651                    assert!((0.0..=1.0).contains(&hi), "hi={hi} out of [0,1] at s={s} n={n}");
652                    assert!(lo <= p_hat, "lo={lo} above p_hat={p_hat} at s={s} n={n} {level:?}");
653                    assert!(hi >= p_hat, "hi={hi} below p_hat={p_hat} at s={s} n={n} {level:?}");
654                    assert!((hi - lo - 2.0 * cs.half_width()).abs() < 1e-15);
655                    widths.push(hi - lo);
656                }
657                assert!(widths[0] <= widths[1], "P90 wider than P95 at s={s} n={n}");
658                assert!(widths[1] <= widths[2], "P95 wider than P99 at s={s} n={n}");
659            }
660        }
661        // Malformed counts saturate instead of reaching ln_gamma's NaN domain, matching
662        // `wilson_interval`'s posture (see `update_batch`'s docs).
663        let mut bad = BernoulliConfidenceSequence::new(ConfidenceLevel::P95);
664        bad.update_batch(u64::MAX, 20);
665        assert_eq!(bad.successes(), 20);
666        assert_eq!(bad.trials(), 20);
667        let (lo, hi) = bad.bounds();
668        assert!(lo > 0.0 && !lo.is_nan(), "lo={lo}");
669        assert_eq!(hi, 1.0);
670    }
671
672    /// Exact binomial CDF `P(X <= k)` for `X ~ Bin(n, p)`, used to set the coverage floor
673    /// below. Forward pmf recursion — `pmf(0) = (1-p)^n`, `pmf(i) = pmf(i-1) * ((n-i+1)/i) *
674    /// (p/(1-p))` — so no factorial ever has to be represented, and no special function from
675    /// this crate is involved (the floor stays an independent oracle).
676    fn binomial_cdf(k: usize, n: usize, p: f64) -> f64 {
677        let ratio = p / (1.0 - p);
678        let mut term = (1.0 - p).powi(n as i32);
679        let mut total = term;
680        for i in 1..=k.min(n) {
681            term *= ratio * (n - i + 1) as f64 / i as f64;
682            total += term;
683        }
684        total.min(1.0)
685    }
686
687    /// Empirical coverage under OPTIONAL STOPPING — the spec's acceptance test,
688    /// following the exact-binomial-tail methodology of tests/truing_uncertainty.rs
689    /// (fixed committed seed; a floor that nominal coverage essentially never
690    /// violates but material undercoverage reliably does; an anti-width guard so
691    /// trivially-wide intervals cannot pass).
692    #[test]
693    fn cs_coverage_survives_optional_stopping() {
694        // `RngExt`, not the brief's `Rng`: this crate pins rand 0.10, which moved the
695        // `random::<T>()` extension method off `Rng` onto `RngExt`.
696        use rand::{rngs::StdRng, RngExt, SeedableRng};
697        const TRIALS: usize = 200;
698        const P_TRUE: f64 = 0.30;
699        let mut rng = StdRng::seed_from_u64(0x1352_C0FF_EE00_0001);
700        let mut covered = 0_usize;
701        let mut final_half_widths = 0.0_f64;
702        for _ in 0..TRIALS {
703            let mut cs = BernoulliConfidenceSequence::new(ConfidenceLevel::P95);
704            // Optional stopping: stop the moment the half-width crosses 0.08,
705            // or at 5000 draws. This is exactly the usage pattern that breaks
706            // a repeated-Wilson check.
707            for _ in 0..5000 {
708                cs.update(rng.random::<f64>() < P_TRUE);
709                if cs.trials() >= 50 && cs.half_width() <= 0.08 { break; }
710            }
711            let (lo, hi) = cs.bounds();
712            if lo <= P_TRUE && P_TRUE <= hi { covered += 1; }
713            final_half_widths += cs.half_width();
714        }
715        let mean_hw = final_half_widths / TRIALS as f64;
716        eprintln!("MBA-1352 CS coverage: {covered}/{TRIALS}, mean final half-width {mean_hw:.5}");
717
718        // Floor by exact binomial tail at the *advertised* level, p = 0.95 — the worst case a
719        // correct anytime-valid construction has to survive (its true coverage is >= 0.95, and
720        // measurably higher than that here, so this is conservative twice over). Recomputed by
721        // `binomial_cdf` above rather than quoted:
722        //   P(X <= 179 | n = 200, p = 0.95) = 1.1599e-3   <- this floor's false-failure rate
723        // the same order as truing_uncertainty.rs's ">= 33 of 40" precedent (7.115e-4).
724        //
725        // NOTE, deliberate divergence from the plan: the plan sketched "P(covered <= 183) ~
726        // 8.4e-4" and a floor of 184. The exact tail at 183 is 2.3799e-2 — 28x the quoted
727        // figure, i.e. a 1-in-42 false-failure rate at nominal, well outside the cited
728        // precedent. 8.4e-4 actually lands between k=178 (4.8111e-4) and k=179 (1.1599e-3), so
729        // it is the tail belonging to a floor of 179-180, not 184. The computed value governs.
730        //
731        // What this floor does and does not catch, measured by mutating `bounds()` at this
732        // seed rather than assumed: dropping the `ln B(S+1, n-S+1)` prior term collapses
733        // coverage to 26/200 and fails loudly. But substituting a repeated Wilson interval
734        // scores 185/200 and swapping alpha for the confidence in the threshold scores
735        // 192/200 — both pass, at this floor *and* at the plan's 184. That is a property of
736        // the stopping rule, not of the floor: stopping at a fixed half-width of 0.08 fixes
737        // the final interval width, so every construction is judged at whatever `n` it needs
738        // to reach that width, which equalizes coverage. Those two failure modes are pinned
739        // elsewhere, and each has exactly ONE robust detector — they are not redundantly
740        // covered, so neither leg is safe to prune:
741        //   * repeated Wilson  -> `cs_is_wider_than_wilson_at_the_same_n`, definitionally: the
742        //     two widths become bit-identical, so its strict `>` cannot hold. (The nesting
743        //     sweep does also fail on this mutant today, but only through a 1-ULP artifact of
744        //     Wilson's k=n upper bound landing on 0.9999999999999999 — an accident, not a
745        //     property. Do not rely on it.)
746        //   * alpha/confidence swap -> `cs_brackets_the_mle_and_nests_by_confidence_level`,
747        //     structurally: the threshold ordering inverts, so P90 comes out wider than P95 in
748        //     all 1890 swept cells. The width test catches this one at only 1 of its 3
749        //     fixtures — (10, 40), by 1.2% — because at (81, 263) and (500, 1000) the
750        //     alpha-swapped interval is still 1.19x and 1.31x wider than Wilson. Dropping the
751        //     (10, 40) fixture would silently remove that leg.
752        // Read this test as "the sequence does not undercover under optional stopping", not as
753        // the sole guard on the construction.
754        const COVERAGE_FLOOR: usize = 180;
755        let false_failure_rate = binomial_cdf(COVERAGE_FLOOR - 1, TRIALS, 0.95);
756        assert!(
757            (false_failure_rate - 1.1599e-3).abs() < 1e-6,
758            "exact tail P(X <= {}) = {false_failure_rate:.6e}, expected ~1.1599e-3",
759            COVERAGE_FLOOR - 1
760        );
761        assert!(
762            covered >= COVERAGE_FLOOR,
763            "coverage {covered}/{TRIALS} below the exact-tail floor {COVERAGE_FLOOR}"
764        );
765        // Anti-width guard: mean final half-width must show real convergence.
766        assert!(mean_hw < 0.12, "mean final half-width {mean_hw} — intervals not converging");
767    }
768}