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 {
460 w.push(x);
461 }
462 let n = xs.len() as f64;
463 let mean = xs.iter().sum::<f64>() / n;
464 let m2 = xs.iter().map(|x| (x - mean).powi(2)).sum::<f64>();
465 assert_eq!(w.count(), 1000);
466 assert!((w.mean() - mean).abs() < 1e-6);
467 assert!((w.population_variance() - m2 / n).abs() < 1e-6);
468 assert!((w.sample_variance() - m2 / (n - 1.0)).abs() < 1e-6);
469 }
470
471 #[test]
472 fn welford_empty_and_single_are_defined() {
473 let mut w = Welford::new();
474 assert_eq!(w.count(), 0);
475 assert_eq!(w.mean(), 0.0);
476 assert_eq!(w.sample_variance(), 0.0);
477 assert_eq!(w.population_variance(), 0.0);
478 w.push(42.0);
479 assert_eq!(w.mean(), 42.0);
480 assert_eq!(w.sample_variance(), 0.0); // n < 2
481 assert_eq!(w.population_variance(), 0.0);
482 }
483
484 #[test]
485 fn confidence_level_accessors_are_pinned_exactly() {
486 // z(): bit-for-bit against the three literals pinned in the plan's Global Constraints
487 // (docs/superpowers/plans/2026-08-04-decision-support-plan-c.md). The 95% value is the
488 // same number as `truing_uncertainty::NORMAL_95_TWO_SIDED_Z`
489 // (src/truing_uncertainty.rs:36), but that `const` is module-private (not `pub` or
490 // `pub(crate)`), so it is not reachable from here; the digits are restated rather than
491 // imported. Without this test, z() was previously anchored only indirectly (P95 via
492 // the Newcombe reference, P99 only by an inequality, P90 by nothing at all) -- review I3.
493 assert_eq!(
494 ConfidenceLevel::P90.z().to_bits(),
495 1.644_853_626_951_472_2_f64.to_bits()
496 );
497 assert_eq!(
498 ConfidenceLevel::P95.z().to_bits(),
499 1.959_963_984_540_054_f64.to_bits()
500 );
501 assert_eq!(
502 ConfidenceLevel::P99.z().to_bits(),
503 2.575_829_303_548_900_4_f64.to_bits()
504 );
505
506 assert_eq!(ConfidenceLevel::P90.alpha(), 0.10);
507 assert_eq!(ConfidenceLevel::P95.alpha(), 0.05);
508 assert_eq!(ConfidenceLevel::P99.alpha(), 0.01);
509
510 assert_eq!(ConfidenceLevel::P90.as_percent(), 90);
511 assert_eq!(ConfidenceLevel::P95.as_percent(), 95);
512 assert_eq!(ConfidenceLevel::P99.as_percent(), 99);
513 }
514
515 #[test]
516 fn wilson_matches_the_newcombe_canonical_example() {
517 // Newcombe (1998), worked example: 81/263 at 95% → (0.2553, 0.3662) to 4 dp.
518 let (lo, hi) = wilson_interval(81, 263, ConfidenceLevel::P95);
519 assert!((lo - 0.2553).abs() < 5e-4, "lo = {lo}");
520 assert!((hi - 0.3662).abs() < 5e-4, "hi = {hi}");
521 }
522
523 #[test]
524 fn wilson_two_algebraic_forms_agree() {
525 // center ± spread form (production) vs the quadratic-root form (independent oracle,
526 // structurally different): solving (p_hat - p)^2 = z^2 * p(1-p)/n directly for p gives
527 // the quadratic p^2*(n + z^2) - p*(2*n*p_hat + z^2) + n*p_hat^2 == 0, whose roots are
528 // p = [ (2*n*p_hat + z^2) +- z * sqrt(z^2 + 4*n*p_hat*(1 - p_hat)) ] / (2*(n + z^2))
529 // A mis-transcription of either form fails this cross-check (review I2: the previous
530 // version of this test restated the production expression term-for-term and could not).
531 for &(k, n) in &[
532 (0_u64, 10_u64),
533 (10, 10),
534 (1, 10),
535 (8, 10),
536 (500, 1000),
537 (3, 7),
538 ] {
539 for level in [
540 ConfidenceLevel::P90,
541 ConfidenceLevel::P95,
542 ConfidenceLevel::P99,
543 ] {
544 let (lo, hi) = wilson_interval(k, n, level);
545 let z = level.z();
546 let (kf, nf) = (k as f64, n as f64);
547 let p_hat = kf / nf;
548 let z2 = z * z;
549 let a = nf + z2;
550 let b = 2.0 * nf * p_hat + z2;
551 let root_term = z * (z2 + 4.0 * nf * p_hat * (1.0 - p_hat)).sqrt();
552 let lo_root = (b - root_term) / (2.0 * a);
553 let hi_root = (b + root_term) / (2.0 * a);
554 assert!(
555 (lo - lo_root).abs() < 1e-12,
556 "lo mismatch at k={k} n={n}: {lo} vs {lo_root}"
557 );
558 assert!(
559 (hi - hi_root).abs() < 1e-12,
560 "hi mismatch at k={k} n={n}: {hi} vs {hi_root}"
561 );
562 }
563 }
564 }
565
566 #[test]
567 // The brief's bound checks are intentionally spelled out as explicit `>=`/`<` comparisons
568 // (matching the two-sided prose "lower bound must be 0" / "upper bound must be 1") rather
569 // than `Range::contains`, which clippy's default lint set does not recognize as equivalent
570 // here.
571 #[allow(clippy::manual_range_contains)]
572 fn wilson_edges_and_ordering_properties() {
573 assert_eq!(wilson_interval(0, 0, ConfidenceLevel::P95), (0.0, 1.0));
574 // k=0 / k=n bounds must land in [0, 1] EXACTLY (unclamped center +- spread can overshoot
575 // by a few ULP at these edges; `wilson_interval` clamps for it) and stay tight against
576 // 0 / 1 (exact in real arithmetic for every n, at every level). Swept rather than probed
577 // at a single n: n = 20 at P95 happens to round to exactly 0.0 / 1.0 even unclamped,
578 // which gave false assurance that the property held in general (review I1).
579 for n in 1_u64..=200 {
580 for level in [
581 ConfidenceLevel::P90,
582 ConfidenceLevel::P95,
583 ConfidenceLevel::P99,
584 ] {
585 let (lo0, _) = wilson_interval(0, n, level);
586 assert!(
587 lo0 >= 0.0 && lo0 < 1e-9,
588 "k=0 n={n} {level:?}: lo = {lo0:e}, want [0, 1e-9)"
589 );
590 let (_, hi_n) = wilson_interval(n, n, level);
591 assert!(
592 hi_n <= 1.0 && hi_n > 1.0 - 1e-9,
593 "k=n n={n} {level:?}: hi = {hi_n:e}, want (1-1e-9, 1]"
594 );
595 }
596 }
597 // Wider at higher confidence, narrower at larger n.
598 let w = |k, n, l| {
599 let (a, b) = wilson_interval(k, n, l);
600 b - a
601 };
602 assert!(w(40, 100, ConfidenceLevel::P99) > w(40, 100, ConfidenceLevel::P95));
603 assert!(w(40, 100, ConfidenceLevel::P95) > w(400, 1000, ConfidenceLevel::P95));
604 // Interval always contains the point estimate.
605 let (lo, hi) = wilson_interval(3, 7, ConfidenceLevel::P90);
606 let p = 3.0 / 7.0;
607 assert!(lo < p && p < hi);
608 }
609
610 #[test]
611 fn wilson_interval_saturates_when_successes_exceeds_trials() {
612 // successes > trials is not a panic and not a silent NaN: it saturates at trials
613 // (p = 1.0), reporting exactly the k=n interval, for every level (review I4).
614 for level in [
615 ConfidenceLevel::P90,
616 ConfidenceLevel::P95,
617 ConfidenceLevel::P99,
618 ] {
619 assert_eq!(
620 wilson_interval(37, 20, level),
621 wilson_interval(20, 20, level)
622 );
623 assert_eq!(
624 wilson_interval(u64::MAX, 20, level),
625 wilson_interval(20, 20, level)
626 );
627 }
628 }
629
630 #[test]
631 fn cs_starts_ignorant_and_shrinks_monotonically_in_n() {
632 let mut cs = BernoulliConfidenceSequence::new(ConfidenceLevel::P95);
633 assert_eq!(cs.bounds(), (0.0, 1.0));
634 let mut prev = 1.0_f64;
635 // Alternate hits/misses so p̂ stays near 0.5 while n grows.
636 for i in 0..2000 {
637 cs.update(i % 2 == 0);
638 if i % 100 == 99 {
639 let hw = cs.half_width();
640 assert!(
641 hw <= prev + 1e-12,
642 "half-width grew at n={}: {hw} > {prev}",
643 i + 1
644 );
645 prev = hw;
646 }
647 }
648 let (lo, hi) = cs.bounds();
649 assert!(lo > 0.0 && hi < 1.0 && lo < 0.5 && 0.5 < hi);
650 }
651
652 #[test]
653 fn cs_edges_saturate_exactly() {
654 let mut cs = BernoulliConfidenceSequence::new(ConfidenceLevel::P95);
655 cs.update_batch(0, 50);
656 let (lo, hi) = cs.bounds();
657 assert_eq!(lo, 0.0);
658 assert!(hi < 0.25 && hi > 0.0);
659 let mut cs2 = BernoulliConfidenceSequence::new(ConfidenceLevel::P95);
660 cs2.update_batch(50, 50);
661 let (lo2, hi2) = cs2.bounds();
662 assert_eq!(hi2, 1.0);
663 assert!(lo2 > 0.75 && lo2 < 1.0);
664 }
665
666 #[test]
667 fn cs_is_wider_than_wilson_at_the_same_n() {
668 // The honesty property that motivates anytime-validity: the price of
669 // optional stopping is a wider interval than the fixed-n Wilson at every n.
670 for &(k, n) in &[(10_u64, 40_u64), (81, 263), (500, 1000)] {
671 let mut cs = BernoulliConfidenceSequence::new(ConfidenceLevel::P95);
672 cs.update_batch(k, n);
673 let (clo, chi) = cs.bounds();
674 let (wlo, whi) = wilson_interval(k, n, ConfidenceLevel::P95);
675 assert!(
676 chi - clo > whi - wlo,
677 "CS not wider than Wilson at k={k}, n={n}"
678 );
679 }
680 }
681
682 #[test]
683 fn cs_bounds_are_deterministic_and_batch_order_invariant() {
684 let mut a = BernoulliConfidenceSequence::new(ConfidenceLevel::P99);
685 let mut b = BernoulliConfidenceSequence::new(ConfidenceLevel::P99);
686 a.update_batch(30, 100);
687 for i in 0..100 {
688 b.update(i < 30);
689 }
690 assert_eq!(a.bounds(), b.bounds()); // state is (S, n) only — exact equality
691 }
692
693 /// The structural preconditions `bounds()` relies on, swept rather than argued: the
694 /// endpoints bracket the MLE (so the two bisections really did straddle the root, and
695 /// `ln M_n(p_hat) <= 0 < ln(1/alpha)` really does hold at every `(S, n)` — the
696 /// method-of-types claim in the `bounds()` docs), the interval stays inside `[0, 1]`,
697 /// and lowering alpha only ever widens it. A sign error or a swapped bracket in the
698 /// endpoint search fails this even where the brief's fixtures happen to look sane.
699 #[test]
700 fn cs_brackets_the_mle_and_nests_by_confidence_level() {
701 for n in 1_u64..=60 {
702 for s in 0..=n {
703 let p_hat = s as f64 / n as f64;
704 let mut widths = Vec::new();
705 for level in [
706 ConfidenceLevel::P90,
707 ConfidenceLevel::P95,
708 ConfidenceLevel::P99,
709 ] {
710 let mut cs = BernoulliConfidenceSequence::new(level);
711 cs.update_batch(s, n);
712 let (lo, hi) = cs.bounds();
713 assert!(
714 lo.is_finite() && hi.is_finite(),
715 "non-finite bound at s={s} n={n}"
716 );
717 assert!(
718 (0.0..=1.0).contains(&lo),
719 "lo={lo} out of [0,1] at s={s} n={n}"
720 );
721 assert!(
722 (0.0..=1.0).contains(&hi),
723 "hi={hi} out of [0,1] at s={s} n={n}"
724 );
725 assert!(
726 lo <= p_hat,
727 "lo={lo} above p_hat={p_hat} at s={s} n={n} {level:?}"
728 );
729 assert!(
730 hi >= p_hat,
731 "hi={hi} below p_hat={p_hat} at s={s} n={n} {level:?}"
732 );
733 assert!((hi - lo - 2.0 * cs.half_width()).abs() < 1e-15);
734 widths.push(hi - lo);
735 }
736 assert!(widths[0] <= widths[1], "P90 wider than P95 at s={s} n={n}");
737 assert!(widths[1] <= widths[2], "P95 wider than P99 at s={s} n={n}");
738 }
739 }
740 // Malformed counts saturate instead of reaching ln_gamma's NaN domain, matching
741 // `wilson_interval`'s posture (see `update_batch`'s docs).
742 let mut bad = BernoulliConfidenceSequence::new(ConfidenceLevel::P95);
743 bad.update_batch(u64::MAX, 20);
744 assert_eq!(bad.successes(), 20);
745 assert_eq!(bad.trials(), 20);
746 let (lo, hi) = bad.bounds();
747 assert!(lo > 0.0 && !lo.is_nan(), "lo={lo}");
748 assert_eq!(hi, 1.0);
749 }
750
751 /// Exact binomial CDF `P(X <= k)` for `X ~ Bin(n, p)`, used to set the coverage floor
752 /// below. Forward pmf recursion — `pmf(0) = (1-p)^n`, `pmf(i) = pmf(i-1) * ((n-i+1)/i) *
753 /// (p/(1-p))` — so no factorial ever has to be represented, and no special function from
754 /// this crate is involved (the floor stays an independent oracle).
755 fn binomial_cdf(k: usize, n: usize, p: f64) -> f64 {
756 let ratio = p / (1.0 - p);
757 let mut term = (1.0 - p).powi(n as i32);
758 let mut total = term;
759 for i in 1..=k.min(n) {
760 term *= ratio * (n - i + 1) as f64 / i as f64;
761 total += term;
762 }
763 total.min(1.0)
764 }
765
766 /// Empirical coverage under OPTIONAL STOPPING — the spec's acceptance test,
767 /// following the exact-binomial-tail methodology of tests/truing_uncertainty.rs
768 /// (fixed committed seed; a floor that nominal coverage essentially never
769 /// violates but material undercoverage reliably does; an anti-width guard so
770 /// trivially-wide intervals cannot pass).
771 #[test]
772 fn cs_coverage_survives_optional_stopping() {
773 // `RngExt`, not the brief's `Rng`: this crate pins rand 0.10, which moved the
774 // `random::<T>()` extension method off `Rng` onto `RngExt`.
775 use rand::{rngs::StdRng, RngExt, SeedableRng};
776 const TRIALS: usize = 200;
777 const P_TRUE: f64 = 0.30;
778 let mut rng = StdRng::seed_from_u64(0x1352_C0FF_EE00_0001);
779 let mut covered = 0_usize;
780 let mut final_half_widths = 0.0_f64;
781 for _ in 0..TRIALS {
782 let mut cs = BernoulliConfidenceSequence::new(ConfidenceLevel::P95);
783 // Optional stopping: stop the moment the half-width crosses 0.08,
784 // or at 5000 draws. This is exactly the usage pattern that breaks
785 // a repeated-Wilson check.
786 for _ in 0..5000 {
787 cs.update(rng.random::<f64>() < P_TRUE);
788 if cs.trials() >= 50 && cs.half_width() <= 0.08 {
789 break;
790 }
791 }
792 let (lo, hi) = cs.bounds();
793 if lo <= P_TRUE && P_TRUE <= hi {
794 covered += 1;
795 }
796 final_half_widths += cs.half_width();
797 }
798 let mean_hw = final_half_widths / TRIALS as f64;
799 eprintln!("MBA-1352 CS coverage: {covered}/{TRIALS}, mean final half-width {mean_hw:.5}");
800
801 // Floor by exact binomial tail at the *advertised* level, p = 0.95 — the worst case a
802 // correct anytime-valid construction has to survive (its true coverage is >= 0.95, and
803 // measurably higher than that here, so this is conservative twice over). Recomputed by
804 // `binomial_cdf` above rather than quoted:
805 // P(X <= 179 | n = 200, p = 0.95) = 1.1599e-3 <- this floor's false-failure rate
806 // the same order as truing_uncertainty.rs's ">= 33 of 40" precedent (7.115e-4).
807 //
808 // NOTE, deliberate divergence from the plan: the plan sketched "P(covered <= 183) ~
809 // 8.4e-4" and a floor of 184. The exact tail at 183 is 2.3799e-2 — 28x the quoted
810 // figure, i.e. a 1-in-42 false-failure rate at nominal, well outside the cited
811 // precedent. 8.4e-4 actually lands between k=178 (4.8111e-4) and k=179 (1.1599e-3), so
812 // it is the tail belonging to a floor of 179-180, not 184. The computed value governs.
813 //
814 // What this floor does and does not catch, measured by mutating `bounds()` at this
815 // seed rather than assumed: dropping the `ln B(S+1, n-S+1)` prior term collapses
816 // coverage to 26/200 and fails loudly. But substituting a repeated Wilson interval
817 // scores 185/200 and swapping alpha for the confidence in the threshold scores
818 // 192/200 — both pass, at this floor *and* at the plan's 184. That is a property of
819 // the stopping rule, not of the floor: stopping at a fixed half-width of 0.08 fixes
820 // the final interval width, so every construction is judged at whatever `n` it needs
821 // to reach that width, which equalizes coverage. Those two failure modes are pinned
822 // elsewhere, and each has exactly ONE robust detector — they are not redundantly
823 // covered, so neither leg is safe to prune:
824 // * repeated Wilson -> `cs_is_wider_than_wilson_at_the_same_n`, definitionally: the
825 // two widths become bit-identical, so its strict `>` cannot hold. (The nesting
826 // sweep does also fail on this mutant today, but only through a 1-ULP artifact of
827 // Wilson's k=n upper bound landing on 0.9999999999999999 — an accident, not a
828 // property. Do not rely on it.)
829 // * alpha/confidence swap -> `cs_brackets_the_mle_and_nests_by_confidence_level`,
830 // structurally: the threshold ordering inverts, so P90 comes out wider than P95 in
831 // all 1890 swept cells. The width test catches this one at only 1 of its 3
832 // fixtures — (10, 40), by 1.2% — because at (81, 263) and (500, 1000) the
833 // alpha-swapped interval is still 1.19x and 1.31x wider than Wilson. Dropping the
834 // (10, 40) fixture would silently remove that leg.
835 // Read this test as "the sequence does not undercover under optional stopping", not as
836 // the sole guard on the construction.
837 const COVERAGE_FLOOR: usize = 180;
838 let false_failure_rate = binomial_cdf(COVERAGE_FLOOR - 1, TRIALS, 0.95);
839 assert!(
840 (false_failure_rate - 1.1599e-3).abs() < 1e-6,
841 "exact tail P(X <= {}) = {false_failure_rate:.6e}, expected ~1.1599e-3",
842 COVERAGE_FLOOR - 1
843 );
844 assert!(
845 covered >= COVERAGE_FLOOR,
846 "coverage {covered}/{TRIALS} below the exact-tail floor {COVERAGE_FLOOR}"
847 );
848 // Anti-width guard: mean final half-width must show real convergence.
849 assert!(
850 mean_hw < 0.12,
851 "mean final half-width {mean_hw} — intervals not converging"
852 );
853 }
854}