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}