Skip to main content

gam_inference/
probability.rs

1use gam_math::probability::beta_quantile;
2use gam_problem::types::LikelihoodSpec;
3use gam_solve::estimate::EstimationError;
4use gam_solve::mixture_link::inverse_link_jet_for_family_public;
5use ndarray::{Array1, ArrayView1};
6use statrs::function::beta::beta_reg;
7
8/// Standard normal PDF φ(x).  Implementation lives in `gam-math`; re-exported
9/// to keep `crate::probability::normal_pdf` resolving for all existing callers.
10pub use gam_math::probability::normal_pdf;
11
12/// Standard normal CDF Φ(x) via the exact identity `Φ(x) = 0.5·erfc(−x/√2)`.
13/// Implementation lives in `gam-math`; re-exported to keep
14/// `crate::probability::normal_cdf` resolving for all existing callers.
15pub use gam_math::probability::normal_cdf;
16
17/// Scaled complementary error function `erfcx(x) = exp(x²) · erfc(x)`,
18/// specialized to `x ≥ 0`.  The implementation now lives in the lowest crate
19/// (`gam-math`) so the survival/probit cluster can consume it without reaching
20/// up into `inference`; re-exported here to keep
21/// `crate::probability::erfcx_nonnegative` resolving for all existing callers.
22pub use gam_math::probability::erfcx_nonnegative;
23
24/// Computes `log(1 - exp(-a))` for `a >= 0` without cancellation.  Implementation
25/// lives in `gam-math`; re-exported to keep `crate::probability::log1mexp_positive`
26/// resolving for all existing callers.
27pub use gam_math::probability::log1mexp_positive;
28
29/// Numerically stable signed log-sum-exp.  Implementation lives in `gam-math`;
30/// re-exported to keep `crate::probability::signed_log_sum_exp` resolving for all
31/// existing callers.
32pub use gam_math::probability::signed_log_sum_exp;
33
34/// Numerically stable `C(n,k) = n! / (k!·(n−k)!)` as `f64`.  The
35/// implementation now lives in the lowest crate (`gam-math`) so the
36/// terms/basis cluster can consume it without reaching up into `inference`;
37/// re-exported here to keep `crate::probability::binomial_coefficient_f64`
38/// resolving for all existing callers.
39pub use gam_math::special::binomial_coefficient_f64;
40
41/// Evaluate `(Σ_k coeffs[k]·x^k) · exp(−x)` without overflow. The
42/// implementation lives in `gam-math` so terms/basis code can consume it without
43/// reaching up into `inference`.
44pub use gam_math::special::stable_polynomial_times_exp_neg;
45
46/// Numerically stable `ln Φ(x)` for the standard normal CDF.  Implementation lives
47/// in `gam-math`; re-exported to keep `crate::probability::normal_logcdf` resolving
48/// for all existing callers.
49pub use gam_math::probability::normal_logcdf;
50
51/// Numerically stable `ln(1 − Φ(x)) = ln Φ(−x)` for the standard normal survival
52/// function.  Implementation lives in `gam-math`; re-exported to keep
53/// `crate::probability::normal_logsf` resolving for all existing callers.
54pub use gam_math::probability::normal_logsf;
55
56/// Joint evaluation of `ln Φ(x)` and the Mills-ratio analogue `φ(x) / Φ(x)`.
57/// Implementation lives in `gam-math`; re-exported to keep
58/// `crate::probability::signed_probit_logcdf_and_mills_ratio` resolving for all
59/// existing callers.
60pub use gam_math::probability::signed_probit_logcdf_and_mills_ratio;
61
62/// Standard normal quantile Φ⁻¹(p) using Acklam's rational approximation.
63/// Implementation lives in `gam-math`; re-exported to keep
64/// `crate::probability::standard_normal_quantile` resolving for all existing callers.
65pub use gam_math::probability::standard_normal_quantile;
66
67/// Quantile (inverse CDF) of a Gamma distribution parameterized by shape
68/// `k > 0` and scale `θ > 0` at probability `p ∈ (0, 1)`: the value `x` with
69/// `P(X ≤ x) = p` for `X ~ Gamma(shape = k, scale = θ)` (mean `kθ`, variance
70/// `kθ²`).
71///
72/// Equals `θ · Q(p; k)`, where `Q(p; k)` inverts the regularized lower
73/// incomplete gamma `P(k, x)` (the unit-scale Gamma CDF). `p ≤ 0` maps to the
74/// `0` support floor and `p ≥ 1` to `+∞`; a non-finite or non-positive shape or
75/// scale yields `NaN`.
76///
77/// This is the building block for *skew-aware* response-scale predictive
78/// (observation) intervals: a Gamma response is strongly right-skewed, so the
79/// symmetric `μ ± z·σ` band mis-covers each tail even when its width (variance)
80/// is correct. Equal-tailed Gamma quantiles place the right mass in each tail.
81pub fn gamma_quantile(p: f64, shape: f64, scale: f64) -> f64 {
82    if !(shape.is_finite() && shape > 0.0 && scale.is_finite() && scale > 0.0) {
83        return f64::NAN;
84    }
85    scale * inverse_regularized_lower_gamma(p, shape)
86}
87
88/// Equal-tailed predictive interval for a strictly-positive, right-skewed
89/// response modelled as a Gamma whose first two moments match a point
90/// prediction: mean `mu` and total predictive variance `total_var`
91/// (estimation + observation noise). Returns the pair of Gamma quantiles at
92/// lower-tail probabilities `p_lo < p_hi` — the skew-correct replacement for a
93/// symmetric `mu ± z·σ` band, which for a Gamma pins the lower edge near the
94/// support floor and mis-covers each tail (#817).
95///
96/// Moment matching fixes `shape k = mu²/V` and `scale θ = V/mu`, so the
97/// predictive carries exactly the requested mean and variance. When estimation
98/// uncertainty vanishes (`total_var → φμ²`) this is *exact*: `k → 1/φ`,
99/// `θ → φμ`, recovering the conditional Gamma `Gamma(shape = 1/φ, scale = φμ)`.
100/// With nonzero estimation variance it is the moment-matched Gamma predictive —
101/// the minimal skew-correct widening.
102///
103/// Returns `None` when the inputs are degenerate (non-positive mean or
104/// variance, non-finite), or when the incomplete-gamma inverse yields a
105/// non-finite / mis-ordered pair — which happens for an enormous shape, where
106/// the Gamma is essentially Gaussian and the caller should fall back to the
107/// then-accurate symmetric edges.
108pub fn gamma_moment_matched_interval(
109    mu: f64,
110    total_var: f64,
111    p_lo: f64,
112    p_hi: f64,
113) -> Option<(f64, f64)> {
114    if !(mu.is_finite() && mu > 0.0 && total_var.is_finite() && total_var > 0.0) {
115        return None;
116    }
117    let shape = mu * mu / total_var;
118    let scale = total_var / mu;
119    let q_lo = gamma_quantile(p_lo, shape, scale);
120    let q_hi = gamma_quantile(p_hi, shape, scale);
121    if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
122        Some((q_lo, q_hi))
123    } else {
124        None
125    }
126}
127
128/// Equal-tailed predictive interval for a `(0, 1)`-bounded response modelled as a
129/// Beta whose first two moments match a point prediction: mean `mu ∈ (0, 1)` and
130/// total predictive variance `total_var` (estimation + observation noise).
131/// Returns the pair of Beta quantiles at lower-tail probabilities `p_lo < p_hi` —
132/// the skew-correct replacement for a symmetric `mu ± z·σ` band, which for a
133/// skewed Beta lands *both* edges below the corresponding true quantile and so
134/// mis-covers each tail (#1194).
135///
136/// Moment matching fixes the precision `φ = a + b = μ(1−μ)/V − 1`, then
137/// `a = μφ`, `b = (1−μ)φ`, so the predictive carries exactly the requested mean
138/// and variance. When estimation uncertainty vanishes
139/// (`total_var → μ(1−μ)/(1+φ₀)`) this is *exact*: `φ → φ₀`, recovering the
140/// conditional `Beta(μφ₀, (1−μ)φ₀)`. With nonzero estimation variance it is the
141/// moment-matched Beta predictive — the minimal skew-correct widening.
142///
143/// Returns `None` when the inputs are degenerate (mean outside `(0, 1)`,
144/// non-positive variance, non-finite), or when the requested variance reaches
145/// the Bernoulli ceiling `μ(1−μ)` (no Beta has that much spread for the given
146/// mean) — in which case the caller falls back to the symmetric edges.
147pub fn beta_moment_matched_interval(
148    mu: f64,
149    total_var: f64,
150    p_lo: f64,
151    p_hi: f64,
152) -> Option<(f64, f64)> {
153    if !(mu.is_finite() && mu > 0.0 && mu < 1.0 && total_var.is_finite() && total_var > 0.0) {
154        return None;
155    }
156    // A Beta on (0,1) with mean μ can carry variance only up to the Bernoulli
157    // limit μ(1−μ); at or beyond it no Beta exists, so the moment match fails.
158    let max_var = mu * (1.0 - mu);
159    if total_var >= max_var {
160        return None;
161    }
162    let precision = max_var / total_var - 1.0; // = a + b > 0
163    let a = mu * precision;
164    let b = (1.0 - mu) * precision;
165    let q_lo = beta_quantile(p_lo, a, b);
166    let q_hi = beta_quantile(p_hi, a, b);
167    if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
168        Some((q_lo, q_hi))
169    } else {
170        None
171    }
172}
173
174/// CDF of a Negative-Binomial with mean `μ ≥ 0` and dispersion `θ > 0`
175/// (`Var = μ + μ²/θ`) at the integer count `k ≥ 0`:
176/// `P(Y ≤ k) = I_{θ/(θ+μ)}(θ, k+1)`, the regularized incomplete beta. Increasing
177/// in `k`; `P(Y ≤ 0) = (θ/(θ+μ))^θ` is the zero mass.
178#[inline]
179fn negative_binomial_cdf_at(k: f64, theta: f64, prob: f64) -> f64 {
180    // `prob ∈ (0, 1)`; `beta_reg` requires its last argument in [0, 1].
181    beta_reg(theta, k + 1.0, prob.clamp(0.0, 1.0))
182}
183
184/// Smallest integer `k` with `cdf(k) ≥ p`, found by a geometric bracket grown
185/// from `seed` followed by an integer bisection (invariant `cdf(lo) < p ≤
186/// cdf(hi)`). `cdf` must be a monotone non-decreasing lower-tail CDF on the
187/// non-negative integers. Returns `+∞` if the upper bracket grows past the
188/// `1e18` finite-arithmetic backstop without reaching `p`.
189///
190/// Shared root finder for the discrete count quantiles (Negative-Binomial,
191/// Poisson): both seed a normal approximation on their own moments and then run
192/// this identical bracket-and-bisect. Callers must already have handled the
193/// `cdf(0) ≥ p` zero-atom short-circuit.
194fn count_quantile_bracket_bisect(cdf: impl Fn(f64) -> f64, seed: f64, p: f64) -> f64 {
195    let mut lo: f64;
196    let mut hi: f64;
197    if cdf(seed) >= p {
198        hi = seed;
199        lo = 0.0;
200        // Tighten `lo` upward toward `hi` so the bisection starts narrow.
201        let mut step = 1.0;
202        let mut cand = seed - 1.0;
203        while cand > 0.0 && cdf(cand) >= p {
204            hi = cand;
205            step *= 2.0;
206            cand = seed - step;
207        }
208        if cand > 0.0 {
209            lo = cand; // CDF(cand) < p
210        }
211    } else {
212        lo = seed; // CDF(seed) < p
213        let mut step = 1.0;
214        let mut cand = seed + 1.0;
215        // CDF → 1 as k → ∞ and p < 1, so this terminates; the cap is a
216        // finite-arithmetic backstop (returns an effectively infinite edge).
217        while cdf(cand) < p {
218            lo = cand;
219            step *= 2.0;
220            cand = seed + step;
221            if cand > 1.0e18 {
222                return f64::INFINITY;
223            }
224        }
225        hi = cand;
226    }
227
228    // Bisection for the smallest integer k with CDF(k) ≥ p, maintaining the
229    // invariant CDF(lo) < p ≤ CDF(hi).
230    while hi - lo > 1.0 {
231        let mid = (lo + (hi - lo) / 2.0).floor();
232        if cdf(mid) >= p {
233            hi = mid;
234        } else {
235            lo = mid;
236        }
237    }
238    hi
239}
240
241/// Quantile (inverse CDF) of a Negative-Binomial with mean `μ ≥ 0` and
242/// dispersion `θ > 0` at probability `p ∈ (0, 1)`: the smallest integer count
243/// `k ≥ 0` with `P(Y ≤ k) ≥ p`, returned as an `f64`.
244///
245/// `p ≤ 0` maps to the `0` support floor and `p ≥ 1` to `+∞`; a non-finite or
246/// non-positive dispersion, or a non-finite / negative mean, yields `NaN`; a
247/// zero mean is the degenerate point mass at `0`.
248///
249/// Unlike the continuous Gamma/Beta quantiles, the NB is *discrete* with a real
250/// atom at zero, so its skew-correct predictive band must come from the genuine
251/// integer quantiles — a moment-matched *continuous* surrogate (e.g. a Gamma)
252/// has no zero atom and grossly over-covers the lower tail on low-mean counts
253/// (#1193). A normal-approximation seed brackets the root, then an exact
254/// bisection on the incomplete-beta CDF finds the smallest qualifying integer.
255pub fn negative_binomial_quantile(p: f64, mu: f64, theta: f64) -> f64 {
256    if !(mu.is_finite() && mu >= 0.0 && theta.is_finite() && theta > 0.0) {
257        return f64::NAN;
258    }
259    if !p.is_finite() || p <= 0.0 {
260        return 0.0;
261    }
262    if p >= 1.0 {
263        return f64::INFINITY;
264    }
265    if mu == 0.0 {
266        return 0.0;
267    }
268    let prob = theta / (theta + mu); // P(success) ∈ (0, 1); mean = θ(1−prob)/prob = μ
269    let cdf = |k: f64| negative_binomial_cdf_at(k, theta, prob);
270
271    // The zero atom already covers the requested lower-tail mass on low-mean
272    // counts (the common right-skewed case), so short-circuit before bracketing.
273    if cdf(0.0) >= p {
274        return 0.0;
275    }
276
277    // Normal-approximation seed on the NB moments, floored into the support.
278    let var = mu + mu * mu / theta;
279    let z = standard_normal_quantile(p).unwrap_or(0.0);
280    let seed = (mu + z * var.sqrt()).floor().max(1.0);
281
282    // Bracket the smallest integer with CDF ≥ p: `lo` always satisfies
283    // CDF(lo) < p (starts at 0, which failed the short-circuit) and `hi`
284    // satisfies CDF(hi) ≥ p. Grow geometrically from the seed in whichever
285    // direction is needed.
286    count_quantile_bracket_bisect(&cdf, seed, p)
287}
288
289/// Equal-tailed predictive interval for a Negative-Binomial count response whose
290/// conditional law has mean `mu > 0` and dispersion `theta > 0`, widened for
291/// estimation uncertainty to a total predictive variance `total_var`
292/// (estimation + observation noise). Returns the pair of integer NB quantiles at
293/// lower-tail probabilities `p_lo < p_hi` — the skew-correct, zero-atom-aware
294/// replacement for a symmetric `mu ± z·σ` band, which on right-skewed counts
295/// sits below the true upper quantile and under-covers the upper tail (#1193).
296///
297/// Estimation uncertainty is folded in through an *effective dispersion*: an NB
298/// with mean `μ` has variance `μ + μ²/θ`, so the `θ_eff` matching the inflated
299/// total variance solves `μ + μ²/θ_eff = total_var`, i.e.
300/// `θ_eff = μ² / (total_var − μ)`. When estimation uncertainty vanishes
301/// (`total_var → μ + μ²/θ`) this is *exact*: `θ_eff → θ`, recovering the
302/// conditional `NB(μ, θ)`. With nonzero estimation variance `θ_eff < θ` widens
303/// the band — the minimal skew-correct widening that stays inside the NB family.
304///
305/// Returns `None` for degenerate inputs (non-positive mean / variance,
306/// non-finite), or a numerically mis-ordered pair, in which case the caller
307/// falls back to the symmetric edges.
308pub fn negative_binomial_moment_matched_interval(
309    mu: f64,
310    theta: f64,
311    total_var: f64,
312    p_lo: f64,
313    p_hi: f64,
314) -> Option<(f64, f64)> {
315    if !(mu.is_finite()
316        && mu > 0.0
317        && theta.is_finite()
318        && theta > 0.0
319        && total_var.is_finite()
320        && total_var > 0.0)
321    {
322        return None;
323    }
324    // `total_var = SE(μ̂)² + (μ + μ²/θ) > μ` always, so the excess is positive;
325    // fall back to the nominal dispersion only if a degenerate caller breaks it.
326    let excess = total_var - mu;
327    let theta_eff = if excess > 0.0 {
328        mu * mu / excess
329    } else {
330        theta
331    };
332    let q_lo = negative_binomial_quantile(p_lo, mu, theta_eff);
333    let q_hi = negative_binomial_quantile(p_hi, mu, theta_eff);
334    if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
335        Some((q_lo, q_hi))
336    } else {
337        None
338    }
339}
340
341/// CDF of a Poisson with mean `mu ≥ 0` at the integer count `k ≥ 0`:
342/// `P(Y ≤ k) = Q(k+1, μ)`, the regularized *upper* incomplete gamma (the standard
343/// Poisson↔gamma identity). Increasing in `k`; `P(Y ≤ 0) = e^{−μ}` is the zero mass.
344#[inline]
345fn poisson_cdf_at(k: f64, mu: f64) -> f64 {
346    // P(Y ≤ k) = Q(k+1, μ) = 1 − P(k+1, μ); `regularized_lower_gamma` is `P`.
347    (1.0 - regularized_lower_gamma(k + 1.0, mu)).clamp(0.0, 1.0)
348}
349
350/// Quantile (inverse CDF) of a Poisson with mean `mu ≥ 0` at probability
351/// `p ∈ (0, 1)`: the smallest integer count `k ≥ 0` with `P(Y ≤ k) ≥ p`,
352/// returned as an `f64`.
353///
354/// `p ≤ 0` maps to the `0` support floor and `p ≥ 1` to `+∞`; a non-finite or
355/// negative mean yields `NaN`; a zero mean is the degenerate point mass at `0`.
356///
357/// Like the Negative-Binomial, the Poisson is *discrete* with a real atom at
358/// zero, so its skew-correct predictive band must come from the genuine integer
359/// quantiles — a symmetric `μ ± z·σ` band sits below the true upper quantile on
360/// low-rate counts and under-covers the upper tail (the #817 defect, Poisson
361/// sibling of #1193). A normal-approximation seed brackets the root, then an
362/// exact bisection on the gamma-tail CDF finds the smallest qualifying integer.
363pub fn poisson_quantile(p: f64, mu: f64) -> f64 {
364    if !(mu.is_finite() && mu >= 0.0) {
365        return f64::NAN;
366    }
367    if !p.is_finite() || p <= 0.0 {
368        return 0.0;
369    }
370    if p >= 1.0 {
371        return f64::INFINITY;
372    }
373    if mu == 0.0 {
374        return 0.0;
375    }
376    let cdf = |k: f64| poisson_cdf_at(k, mu);
377
378    // The zero atom already covers the requested lower-tail mass on low-rate
379    // counts (the common right-skewed case), so short-circuit before bracketing.
380    if cdf(0.0) >= p {
381        return 0.0;
382    }
383
384    // Normal-approximation seed on the Poisson moments (Var = μ), floored into
385    // the support.
386    let z = standard_normal_quantile(p).unwrap_or(0.0);
387    let seed = (mu + z * mu.sqrt()).floor().max(1.0);
388
389    // Bracket the smallest integer with CDF ≥ p: `lo` always satisfies
390    // CDF(lo) < p (starts at 0, which failed the short-circuit) and `hi`
391    // satisfies CDF(hi) ≥ p. Grow geometrically from the seed in whichever
392    // direction is needed.
393    count_quantile_bracket_bisect(&cdf, seed, p)
394}
395
396/// Equal-tailed predictive interval for a Poisson count response whose
397/// conditional law has mean `mu > 0` (so `Var(Y|μ) = μ`), widened for estimation
398/// uncertainty to a total predictive variance `total_var ≥ μ` (estimation +
399/// observation noise). Returns the pair of integer quantiles at lower-tail
400/// probabilities `p_lo < p_hi` — the skew-correct, zero-atom-aware replacement
401/// for a symmetric `mu ± z·σ` band, which on low-rate counts sits below the true
402/// upper quantile and under-covers the upper tail (the #817 defect, Poisson
403/// sibling of #1193).
404///
405/// A pure Poisson has no free dispersion parameter to absorb estimation
406/// uncertainty, so the widening is carried by the *conjugate over-dispersed count
407/// law*: if the point estimate `μ̂` carries (approximately) a Gamma sampling
408/// uncertainty with mean `μ` and variance `SE(μ̂)² = total_var − μ`, the posterior
409/// predictive for a *new* Poisson draw is exactly a Negative-Binomial — the
410/// Gamma–Poisson mixture — with mean `μ` and dispersion `θ_eff = μ² / (total_var − μ)`
411/// (matching the inflated variance `μ + μ²/θ_eff = total_var`). As estimation
412/// uncertainty vanishes (`total_var → μ`, `θ_eff → ∞`) the NB collapses to the
413/// *exact* conditional Poisson, which is then used directly — both because it is
414/// the correct limit and because an NB with `θ → ∞` is numerically degenerate.
415/// The two regimes agree (both are integer quantiles that coincide once `θ_eff`
416/// is large), so the switch introduces no discontinuity in the emitted edge.
417///
418/// Returns `None` for degenerate inputs (non-positive mean, non-finite, or a
419/// total variance below the Poisson floor `μ`), or a numerically mis-ordered
420/// pair, in which case the caller falls back to the symmetric edges.
421pub fn poisson_moment_matched_interval(
422    mu: f64,
423    total_var: f64,
424    p_lo: f64,
425    p_hi: f64,
426) -> Option<(f64, f64)> {
427    if !(mu.is_finite() && mu > 0.0 && total_var.is_finite() && total_var > 0.0) {
428        return None;
429    }
430    // Estimation uncertainty inflates the count variance beyond the Poisson
431    // floor `Var(Y|μ) = μ`; the excess is the (approximate) sampling variance of
432    // `μ̂`. A `total_var` below `μ` is degenerate (a caller broke the contract).
433    let excess = total_var - mu;
434    if excess < 0.0 {
435        return None;
436    }
437    // Above this effective dispersion the NB surrogate and the conditional
438    // Poisson agree to far more than the integer resolution of the quantile, and
439    // `negative_binomial_quantile`'s `I_{θ/(θ+μ)}(θ, k+1)` is better conditioned
440    // as the exact Poisson; below it the NB widening is genuine.
441    const THETA_EFF_MAX: f64 = 1.0e9;
442    let theta_eff = if excess > 0.0 {
443        mu * mu / excess
444    } else {
445        f64::INFINITY
446    };
447    let (q_lo, q_hi) = if theta_eff > THETA_EFF_MAX {
448        (poisson_quantile(p_lo, mu), poisson_quantile(p_hi, mu))
449    } else {
450        (
451            negative_binomial_quantile(p_lo, mu, theta_eff),
452            negative_binomial_quantile(p_hi, mu, theta_eff),
453        )
454    };
455    if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
456        Some((q_lo, q_hi))
457    } else {
458        None
459    }
460}
461
462/// CDF of a Tweedie compound Poisson–Gamma response (power `1 < p < 2`) with
463/// mean `mu > 0` and dispersion `phi > 0` at `y ≥ 0`:
464/// `P(Y ≤ y) = e^{−λ} + Σ_{k≥1} Poisson(k; λ)·GammaCDF(y; kα, γ)`, the mixture of
465/// a point mass at zero (no jumps) and `k` i.i.d. Gamma jumps. The Tweedie
466/// parameters map to `λ = μ^{2−p} / (φ(2−p))` (Poisson mean number of jumps),
467/// Gamma jump shape `α = (2−p)/(p−1)` and scale `γ = φ(p−1)μ^{p−1}`, which
468/// reproduce `E[Y] = μ` and `Var(Y) = φμ^p`.
469///
470/// The zero atom `e^{−λ}` is returned directly at `y = 0`. For `y > 0` the
471/// Poisson weights are accumulated in log-space and the series is truncated once
472/// the remaining Poisson mass beyond the current term is negligible — the Gamma
473/// CDF factor is ≤ 1, so the unsummed tail is bounded by the Poisson survival.
474#[inline]
475fn tweedie_cdf_at(y: f64, mu: f64, phi: f64, power: f64) -> f64 {
476    if !(y.is_finite() && y >= 0.0) {
477        return f64::NAN;
478    }
479    let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
480    let alpha = (2.0 - power) / (power - 1.0);
481    let scale = phi * (power - 1.0) * mu.powf(power - 1.0);
482    let zero_mass = (-lambda).exp();
483    if y <= 0.0 {
484        return zero_mass;
485    }
486    let x = y / scale; // unit-scale Gamma argument
487    // Poisson(k; λ) weights via a log-space recurrence: w_k = w_{k-1}·λ/k.
488    // Sum k ≥ 1 only; the k = 0 term contributes the zero atom (GammaCDF = 1 at
489    // any y > 0 for shape 0 is the degenerate point mass already in `zero_mass`).
490    let mut acc = zero_mass; // P(Y ≤ y) includes the no-jump mass (Y = 0 ≤ y)
491    let mut ln_w = -lambda; // ln Poisson(0; λ)
492    // Centre the truncation window on the Poisson mode so very large λ stays cheap.
493    let k_max = (lambda + 10.0 * lambda.sqrt()).ceil() as usize + 50;
494    let mut remaining = 1.0 - zero_mass; // Poisson mass still unaccounted for (k ≥ 1)
495    for k in 1..=k_max {
496        ln_w += lambda.ln() - (k as f64).ln();
497        let w = ln_w.exp();
498        remaining -= w;
499        // GammaCDF(y; kα, γ) = P(kα, y/γ) on the unit scale.
500        acc += w * regularized_lower_gamma(alpha * k as f64, x);
501        if remaining <= 1e-15 && k as f64 > lambda {
502            break;
503        }
504    }
505    acc.clamp(0.0, 1.0)
506}
507
508/// Quantile (inverse CDF) of a Tweedie compound Poisson–Gamma response
509/// (power `1 < p < 2`) with mean `mu > 0` and dispersion `phi > 0` at
510/// probability `q ∈ (0, 1)`: the value `y ≥ 0` with `P(Y ≤ y) = q`.
511///
512/// `q ≤ 0` maps to the `0` support floor and `q ≥ 1` to `+∞`. If the requested
513/// lower-tail probability is at or below the zero atom `e^{−λ}` the quantile is
514/// exactly `0` (the common right-skewed lower-tail case). Otherwise a normal seed
515/// on the Tweedie moments brackets the root, which is then refined by bisection
516/// on [`tweedie_cdf_at`] — the continuous part above the atom is strictly
517/// increasing, so the bracket converges.
518pub fn tweedie_quantile(q: f64, mu: f64, phi: f64, power: f64) -> f64 {
519    if !(mu.is_finite()
520        && mu > 0.0
521        && phi.is_finite()
522        && phi > 0.0
523        && power.is_finite()
524        && power > 1.0
525        && power < 2.0)
526    {
527        return f64::NAN;
528    }
529    if !q.is_finite() || q <= 0.0 {
530        return 0.0;
531    }
532    if q >= 1.0 {
533        return f64::INFINITY;
534    }
535    let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
536    let zero_mass = (-lambda).exp();
537    // The zero atom carries the lower-tail mass: q at or below it ⇒ quantile 0.
538    if q <= zero_mass {
539        return 0.0;
540    }
541
542    // Normal-approximation seed on the Tweedie moments, then geometric bracketing.
543    let var = phi * mu.powf(power);
544    let z = standard_normal_quantile(q).unwrap_or(0.0);
545    let mut hi = (mu + z * var.sqrt()).max(scale_floor(mu));
546    let cdf = |y: f64| tweedie_cdf_at(y, mu, phi, power);
547
548    // Grow `hi` until it covers `q`; `lo` stays below it. CDF → 1 as y → ∞.
549    let mut lo = 0.0_f64;
550    let mut guard = 0;
551    while cdf(hi) < q {
552        lo = hi;
553        hi *= 2.0;
554        guard += 1;
555        if guard > 200 || hi > 1.0e18 {
556            return f64::INFINITY;
557        }
558    }
559
560    // Bisection on the strictly-increasing continuous part above the atom.
561    for _ in 0..200 {
562        let mid = 0.5 * (lo + hi);
563        if cdf(mid) < q {
564            lo = mid;
565        } else {
566            hi = mid;
567        }
568        if hi - lo <= (hi.abs() + 1.0) * 1e-12 {
569            break;
570        }
571    }
572    0.5 * (lo + hi)
573}
574
575/// A strictly-positive starting scale for the Tweedie bracket: a small fraction
576/// of the mean keeps the initial `hi` inside the support when the normal seed
577/// underflows to or below zero on a heavily right-skewed row.
578#[inline]
579fn scale_floor(mu: f64) -> f64 {
580    (mu * 1e-3).max(f64::MIN_POSITIVE)
581}
582
583/// Equal-tailed predictive interval for a Tweedie compound Poisson–Gamma
584/// response (power `1 < p < 2`) whose conditional law has mean `mu > 0` and
585/// dispersion `phi > 0`, widened for estimation uncertainty to a total
586/// predictive variance `total_var` (estimation + observation noise). Returns the
587/// pair of Tweedie quantiles at lower-tail probabilities `p_lo < p_hi` — the
588/// skew-correct, zero-atom-aware replacement for a symmetric `mu ± z·σ` band,
589/// which on a right-skewed Tweedie sits below the true upper quantile and
590/// under-covers the upper tail (the #817 defect, Tweedie sibling of #1193).
591///
592/// Estimation uncertainty is folded in through an *effective dispersion*: a
593/// Tweedie with mean `μ` has variance `φμ^p`, so the `φ_eff` matching the
594/// inflated total variance solves `φ_eff·μ^p = total_var`, i.e.
595/// `φ_eff = total_var / μ^p`. When estimation uncertainty vanishes
596/// (`total_var → φμ^p`) this is *exact*: `φ_eff → φ`, recovering the conditional
597/// Tweedie. With nonzero estimation variance `φ_eff > φ` widens the band inside
598/// the Tweedie family — the minimal skew-correct widening. Unlike a moment-
599/// matched Gamma surrogate, this keeps the genuine zero atom, so it does not
600/// over-cover the lower tail on low-mean rows (#1193).
601///
602/// Returns `None` for degenerate inputs (non-positive mean / variance,
603/// non-finite, power outside `(1, 2)`) or a mis-ordered pair, in which case the
604/// caller falls back to the symmetric edges.
605pub fn tweedie_moment_matched_interval(
606    mu: f64,
607    phi: f64,
608    power: f64,
609    total_var: f64,
610    p_lo: f64,
611    p_hi: f64,
612) -> Option<(f64, f64)> {
613    if !(mu.is_finite()
614        && mu > 0.0
615        && phi.is_finite()
616        && phi > 0.0
617        && power.is_finite()
618        && power > 1.0
619        && power < 2.0
620        && total_var.is_finite()
621        && total_var > 0.0)
622    {
623        return None;
624    }
625    let phi_eff = total_var / mu.powf(power);
626    if !(phi_eff.is_finite() && phi_eff > 0.0) {
627        return None;
628    }
629    let q_lo = tweedie_quantile(p_lo, mu, phi_eff, power);
630    let q_hi = tweedie_quantile(p_hi, mu, phi_eff, power);
631    if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
632        Some((q_lo, q_hi))
633    } else {
634        None
635    }
636}
637
638/// Regularized lower incomplete gamma `P(a, x) = γ(a, x) / Γ(a)` — the CDF of a
639/// unit-scale `Gamma(shape = a)` variate — accurate down to the smallest
640/// representable `x`.
641///
642/// This is the exact function [`inverse_regularized_lower_gamma`] inverts, so we
643/// own it rather than borrowing `statrs::gamma_lr`. That routine hard-clamps to
644/// `0.0` for every `x ≤ 1.11e-15` (its `almost_eq(x, 0)` guard, with accuracy
645/// `DEFAULT_F64_ACC`), which silently zeroes the residual `P(a, x) − p` in the
646/// small-shape lower tail: the Halley iterate is then driven *up* — away from a
647/// good sub-`1e-15` seed — until `x` crosses that clamp around `~1.6e-15`, where
648/// the returned point carries far more mass than `p` (#1018). The Numerical
649/// Recipes split — a power series for `x < a + 1`, the modified-Lentz continued
650/// fraction for the complement `Q = 1 − P` otherwise — keeps the leading
651/// `exp(a·ln x − x − ln Γ(a))` factor in logs, so the value stays finite and
652/// nonzero for arguments far below that clamp, and always evaluates the *smaller*
653/// tail directly (no catastrophic cancellation near either edge).
654fn regularized_lower_gamma(a: f64, x: f64) -> f64 {
655    use statrs::function::gamma::ln_gamma;
656    // Callers (`inverse_regularized_lower_gamma`) validate `a > 0` upstream; a
657    // non-positive `a` would only mis-feed `ln_gamma`, never UB.
658    if x <= 0.0 {
659        return 0.0;
660    }
661    let gln = ln_gamma(a);
662    if x < a + 1.0 {
663        // Power series: P(a,x) = exp(a·ln x − x − ln Γ(a)) · Σ_{n≥0} xⁿ / Π_{k=0}^{n}(a+k).
664        // The running term `del` is the ratio form, so no factorial overflows.
665        let mut ap = a;
666        let mut del = 1.0 / a;
667        let mut sum = del;
668        for _ in 0..1000 {
669            ap += 1.0;
670            del *= x / ap;
671            sum += del;
672            if del.abs() <= sum.abs() * f64::EPSILON {
673                break;
674            }
675        }
676        (sum.ln() + a * x.ln() - x - gln).exp()
677    } else {
678        // Modified-Lentz continued fraction for Q(a,x) = 1 − P(a,x); P = 1 − Q.
679        // Evaluating the *upper* tail here keeps the directly-computed quantity
680        // small wherever P is near 1, so `1 − Q` loses no significant digits.
681        const FPMIN: f64 = 1e-300;
682        let mut b = x + 1.0 - a;
683        let mut c = 1.0 / FPMIN;
684        let mut d = 1.0 / b;
685        let mut h = d;
686        for i in 1..1000 {
687            let an = -(i as f64) * (i as f64 - a);
688            b += 2.0;
689            d = an * d + b;
690            if d.abs() < FPMIN {
691                d = FPMIN;
692            }
693            c = b + an / c;
694            if c.abs() < FPMIN {
695                c = FPMIN;
696            }
697            d = 1.0 / d;
698            let del = d * c;
699            h *= del;
700            if (del - 1.0).abs() <= f64::EPSILON {
701                break;
702            }
703        }
704        let q = (a * x.ln() - x - gln + h.ln()).exp();
705        1.0 - q
706    }
707}
708
709/// Inverse of the regularized lower incomplete gamma function: the `x ≥ 0` with
710/// `P(a, x) = p`, where `P(a, x) = γ(a, x) / Γ(a)` is the CDF of a unit-scale
711/// `Gamma(shape = a)` variate, `a > 0`, `p ∈ (0, 1)`.
712///
713/// Uses the standard rational/Wilson–Hilferty initial estimate, except in the
714/// extreme lower tail where the exact small-`x` seed
715/// `exp((ln p + ln Γ(a + 1)) / a)` follows from `P(a, x) ~ x^a / Γ(a + 1)`.
716/// For `a ≤ 1` it keeps the Numerical Recipes series/log initial estimate. The
717/// seed is refined by Halley's method on `P(a, x) − p` — third order, a Newton
718/// step scaled by the local curvature of `P`. The ratio `P(a, x)` is the crate's
719/// own [`regularized_lower_gamma`] (NOT `statrs::gamma_lr`, which clamps the
720/// residual to `−p` for tiny `x`; see that fn's note); the density
721/// `f(x) = x^{a−1} e^{−x} / Γ(a)` is evaluated through the same overflow-safe
722/// log factorization Numerical Recipes uses (`invgammp`), so the iteration stays
723/// finite across a wide range of `a`. A positivity step-halving guard keeps the
724/// iterate inside the support.
725fn inverse_regularized_lower_gamma(p: f64, a: f64) -> f64 {
726    use statrs::function::gamma::ln_gamma;
727
728    if !(a.is_finite() && a > 0.0) {
729        return f64::NAN;
730    }
731    if !p.is_finite() || p <= 0.0 {
732        return 0.0;
733    }
734    if p >= 1.0 {
735        return f64::INFINITY;
736    }
737
738    let gln = ln_gamma(a);
739    let a1 = a - 1.0;
740
741    // Initial estimate. For `a > 1` a Wilson–Hilferty transform of a normal
742    // quantile works away from the extreme lower tail; there, the small-`x`
743    // analytic seed is essentially exact. Both seeds feed the same Halley polish,
744    // so the crossover is continuous at the converged quantile.
745    let mut x = if a > 1.0 {
746        let pp = if p < 0.5 { p } else { 1.0 - p };
747        let t = (-2.0 * pp.ln()).sqrt();
748        let mut z = (2.30753 + t * 0.27061) / (1.0 + t * (0.99229 + t * 0.04481)) - t;
749        if p < 0.5 {
750            z = -z;
751        }
752        let wh_inner = 1.0 - 1.0 / (9.0 * a) - z / (3.0 * a.sqrt());
753        let wh_seed = if wh_inner > 0.0 {
754            a * wh_inner.powi(3)
755        } else {
756            f64::NAN
757        };
758        let analytic_seed = ((p.ln() + ln_gamma(a + 1.0)) / a).exp();
759        if analytic_seed == 0.0 {
760            return 0.0;
761        }
762        if !wh_seed.is_finite() || wh_seed <= 0.0 || wh_seed < 1.0e-2 || analytic_seed < 1.0e-2 {
763            analytic_seed
764        } else {
765            wh_seed
766        }
767    } else {
768        let t = 1.0 - a * (0.253 + a * 0.12);
769        if p < t {
770            (p / t).powf(1.0 / a)
771        } else {
772            1.0 - (1.0 - (p - t) / (1.0 - t)).ln()
773        }
774    };
775
776    // Density factorization constants for `a > 1` (kept overflow-safe in logs).
777    let (lna1, afac) = if a > 1.0 {
778        let lna1 = a1.ln();
779        (lna1, (a1 * (lna1 - 1.0) - gln).exp())
780    } else {
781        (0.0, 0.0)
782    };
783
784    // Halley refinement of the seeded quantile. Halley's cubic convergence
785    // reaches `f64` accuracy from the standard Wilson-Hilferty / asymptotic seed
786    // in only a few steps; this cap is a generous safety bound, not the expected
787    // iteration count, and the loop also exits early via the in-loop tolerance.
788    const MAX_HALLEY_STEPS: usize = 16;
789    for _ in 0..MAX_HALLEY_STEPS {
790        if x <= 0.0 {
791            return 0.0;
792        }
793        let err = regularized_lower_gamma(a, x) - p;
794        let dens = if a > 1.0 {
795            afac * (-(x - a1) + a1 * (x.ln() - lna1)).exp()
796        } else {
797            (-x + a1 * x.ln() - gln).exp()
798        };
799        if !(dens.is_finite() && dens > 0.0) {
800            break;
801        }
802        // Newton step `u = (P(a,x) − p) / f(x)`, then the Halley scaling by the
803        // local curvature `f'/f = (a−1)/x − 1`, capped (per NR) so the
804        // denominator never collapses below ½.
805        let u = err / dens;
806        let step = u / (1.0 - 0.5 * (u * (a1 / x - 1.0)).min(1.0));
807        x -= step;
808        if x <= 0.0 {
809            // Overshot the support floor: step back to half the prior iterate.
810            x = 0.5 * (x + step);
811        }
812        if step.abs() < 1.0e-12 * x.max(1.0e-300) {
813            break;
814        }
815    }
816    x
817}
818
819/// Inverse-link transform per likelihood specification (response scale).
820///
821/// Uses the exact public inverse-link jet, so the log link reports `exp(eta)`
822/// wherever IEEE-754 can represent it, including inputs outside the shared
823/// solver derivative domain (issue #963).
824#[inline]
825pub fn try_inverse_link_array(
826    likelihood: &LikelihoodSpec,
827    eta: ArrayView1<'_, f64>,
828) -> Result<Array1<f64>, EstimationError> {
829    let mut out = Array1::<f64>::zeros(eta.len());
830    for i in 0..eta.len() {
831        out[i] = inverse_link_jet_for_family_public(likelihood, eta[i])?.mu;
832    }
833    Ok(out)
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use gam_problem::types::{
840        InverseLink, LinkComponent, MixtureLinkSpec, ResponseFamily, SasLinkSpec, StandardLink,
841    };
842    use gam_solve::mixture_link::{
843        mixture_inverse_link_jet, sas_inverse_link_jet, state_from_sasspec, state_fromspec,
844    };
845    use ndarray::array;
846
847    #[test]
848    fn signed_log_sum_exp_propagates_positive_infinities() {
849        // A single +∞ positive-sign term dominates ⇒ S = +∞ ⇒ (+∞, +1).
850        let (lm, s) = signed_log_sum_exp(&[f64::INFINITY], &[1.0]);
851        assert_eq!(lm, f64::INFINITY);
852        assert_eq!(s, 1.0);
853
854        // A single +∞ negative-sign term ⇒ S = −∞, encoded as (+∞, −1).
855        let (lm, s) = signed_log_sum_exp(&[f64::INFINITY], &[-1.0]);
856        assert_eq!(lm, f64::INFINITY);
857        assert_eq!(s, -1.0);
858
859        // +∞ on both signs ⇒ indeterminate +∞ − ∞ ⇒ (NaN, 0).
860        let (lm, s) = signed_log_sum_exp(&[f64::INFINITY, f64::INFINITY], &[1.0, -1.0]);
861        assert!(lm.is_nan());
862        assert_eq!(s, 0.0);
863
864        // A finite positive term alongside a +∞ positive term still gives +∞.
865        let (lm, s) = signed_log_sum_exp(&[0.0, f64::INFINITY], &[1.0, 1.0]);
866        assert_eq!(lm, f64::INFINITY);
867        assert_eq!(s, 1.0);
868
869        // −∞ log-magnitudes are exp(−∞)=0 and must be dropped: mixing a finite
870        // term with a −∞ term reproduces the lone finite term unchanged.
871        let (lm, s) = signed_log_sum_exp(&[2.0, f64::NEG_INFINITY], &[1.0, -1.0]);
872        assert!((lm - 2.0).abs() < 1e-12);
873        assert_eq!(s, 1.0);
874
875        // Finite sanity check: exp(ln 3) − exp(ln 1) = 2 ⇒ (ln 2, +1).
876        let (lm, s) = signed_log_sum_exp(&[3.0_f64.ln(), 1.0_f64.ln()], &[1.0, -1.0]);
877        assert!((lm - 2.0_f64.ln()).abs() < 1e-12);
878        assert_eq!(s, 1.0);
879    }
880
881    #[test]
882    fn standard_inverse_link_specs_evaluate() {
883        let eta = array![0.1, -0.2, 0.3];
884        let likelihood = LikelihoodSpec::new(
885            ResponseFamily::Binomial,
886            InverseLink::Standard(StandardLink::Logit),
887        );
888        let mu = try_inverse_link_array(&likelihood, eta.view()).expect("standard logit spec");
889        assert_eq!(mu.len(), eta.len());
890        for (&got, &eta_i) in mu.iter().zip(eta.iter()) {
891            assert_eq!(
892                got.to_bits(),
893                gam_linalg::utils::stable_logistic(eta_i).to_bits()
894            );
895        }
896    }
897
898    #[test]
899    fn sas_and_mixture_stateful_inverse_link_evaluates() {
900        let eta = array![0.1, -0.2, 0.3];
901        let sas_state = state_from_sasspec(SasLinkSpec {
902            initial_epsilon: 0.2,
903            initial_log_delta: -0.1,
904        })
905        .expect("sas state");
906        let sas_expected = eta.mapv(|eta_i| {
907            sas_inverse_link_jet(eta_i, sas_state.epsilon, sas_state.log_delta)
908                .expect("direct SAS jet")
909                .mu
910        });
911        let sas_likelihood =
912            LikelihoodSpec::new(ResponseFamily::Binomial, InverseLink::Sas(sas_state));
913        let sas = try_inverse_link_array(&sas_likelihood, eta.view()).expect("SAS with params");
914        for (&got, &expected) in sas.iter().zip(sas_expected.iter()) {
915            assert_eq!(got.to_bits(), expected.to_bits());
916        }
917
918        let spec = MixtureLinkSpec {
919            components: vec![LinkComponent::Probit, LinkComponent::CLogLog],
920            initial_rho: array![0.3],
921        };
922        let state = state_fromspec(&spec).expect("mixture state");
923        let mix_expected = eta.mapv(|eta_i| mixture_inverse_link_jet(&state, eta_i).mu);
924        let mix_likelihood =
925            LikelihoodSpec::new(ResponseFamily::Binomial, InverseLink::Mixture(state));
926        let mix = try_inverse_link_array(&mix_likelihood, eta.view()).expect("mixture with state");
927        for (&got, &expected) in mix.iter().zip(mix_expected.iter()) {
928            assert_eq!(got.to_bits(), expected.to_bits());
929        }
930    }
931
932    #[test]
933    fn gamma_quantile_matches_known_reference_values() {
934        // Reference quantiles for unit-scale Gamma(shape=a) from the regularized
935        // lower incomplete gamma inverse (cross-checked against scipy
936        // `gamma.ppf(p, a)` to ~1e-6). Spanning a < 1, a = 1 (exponential), and
937        // a ≫ 1 exercises every initial-estimate / density branch.
938        let cases: [(f64, f64, f64); 9] = [
939            // (p, shape a, expected unit-scale quantile)
940            (0.025, 4.0, 1.089_865_4),
941            (0.5, 4.0, 3.672_060_4),
942            (0.975, 4.0, 8.767_273_4),
943            (0.025, 1.0, 0.025_317_8), // Exp(1): -ln(1-p)
944            (0.975, 1.0, 3.688_879_4),
945            (0.5, 0.5, 0.227_468_2),
946            (0.99, 0.5, 3.317_448_3),
947            (0.025, 50.0, 37.110_963_7),
948            (0.975, 50.0, 64.780_598_6),
949        ];
950        for (p, a, expected) in cases {
951            let got = gamma_quantile(p, a, 1.0);
952            let rel = (got - expected).abs() / expected.max(1e-12);
953            assert!(
954                rel < 1e-4,
955                "gamma_quantile(p={p}, a={a}) = {got}, expected ≈ {expected} (rel err {rel})"
956            );
957        }
958    }
959
960    #[test]
961    fn gamma_quantile_handles_extreme_lower_tail_for_shape_two() {
962        let got = gamma_quantile(1.0e-300, 2.0, 1.0);
963        let expected = 1.414_213_562_373_095_1e-150;
964        let rel = (got - expected).abs() / expected;
965        assert!(
966            rel < 1.0e-6,
967            "gamma_quantile(1e-300, 2, 1) = {got}, expected {expected} (rel err {rel})"
968        );
969    }
970
971    #[test]
972    fn gamma_quantile_round_trips_extreme_lower_tail_for_shape_above_one() {
973        for &a in &[1.5_f64, 2.0, 5.0, 20.0] {
974            for &p in &[1.0e-300_f64, 1.0e-100, 1.0e-12, 1.0e-3, 0.5, 0.999] {
975                let x = gamma_quantile(p, a, 1.0);
976                assert!(
977                    x.is_finite() && x >= 0.0,
978                    "non-finite quantile a={a} p={p}: {x}"
979                );
980                let recovered = regularized_lower_gamma(a, x);
981                let rel = (recovered - p).abs() / p;
982                assert!(
983                    rel < 1.0e-6,
984                    "round-trip failed a={a} p={p}: q={x}, P(a,q)={recovered}, rel err {rel}"
985                );
986            }
987        }
988    }
989
990    #[test]
991    fn gamma_quantile_is_consistent_with_the_cdf_round_trip() {
992        // The inverse must invert the CDF: P(a, Q(p; a)) = p. Verify across a
993        // grid of shapes and probabilities using statrs `gamma_lr` as the CDF.
994        use statrs::function::gamma::gamma_lr;
995        for &a in &[0.3_f64, 0.75, 1.0, 2.5, 10.0, 80.0] {
996            for &p in &[0.001_f64, 0.01, 0.025, 0.25, 0.5, 0.75, 0.975, 0.99, 0.999] {
997                let x = gamma_quantile(p, a, 1.0);
998                assert!(
999                    x.is_finite() && x > 0.0,
1000                    "non-finite quantile a={a} p={p}: {x}"
1001                );
1002                let recovered = gamma_lr(a, x);
1003                assert!(
1004                    (recovered - p).abs() < 1e-6,
1005                    "CDF round-trip failed a={a} p={p}: P(a, {x}) = {recovered}"
1006                );
1007            }
1008        }
1009    }
1010
1011    #[test]
1012    fn regularized_lower_gamma_is_accurate_and_unclamped_below_statrs_floor() {
1013        use statrs::function::gamma::{gamma_lr, ln_gamma};
1014
1015        // (1) Agrees with statrs `gamma_lr` everywhere statrs is itself valid
1016        // (arguments well above its `x ≤ 1.11e-15` clamp), across both the
1017        // series (x < a+1) and continued-fraction (x ≥ a+1) branches.
1018        for &a in &[0.05_f64, 0.3, 1.0, 2.5, 50.0] {
1019            for &x in &[1e-6_f64, 0.01, 0.5, 1.0, 3.0, 25.0, 120.0] {
1020                let ours = regularized_lower_gamma(a, x);
1021                let theirs = gamma_lr(a, x);
1022                assert!(
1023                    (ours - theirs).abs() < 1e-12,
1024                    "P({a},{x}): ours={ours} statrs={theirs}"
1025                );
1026                assert!(
1027                    (0.0..=1.0).contains(&ours),
1028                    "P({a},{x})={ours} out of [0,1]"
1029                );
1030            }
1031        }
1032
1033        // (2) Exp(1) closed form P(1, x) = 1 − e^{−x}.
1034        for &x in &[1e-3_f64, 0.25, 2.0, 9.0] {
1035            assert!((regularized_lower_gamma(1.0, x) - (1.0 - (-x).exp())).abs() < 1e-13);
1036        }
1037
1038        // (3) The regression heart of #1018: for x far below the naive
1039        // small-argument clamp the CDF must remain a faithful, nonzero value,
1040        // not snap to 0. Compare to the small-x leading order
1041        // P(a, x) ≈ x^a / Γ(a+1).
1042        //
1043        // This used to open with `assert_eq!(gamma_lr(a, x), 0.0)` as a
1044        // "precondition: statrs clamps P(a,x) to 0" — i.e. it asserted a
1045        // THIRD-PARTY DEFECT as a premise, so statrs fixing its clamp broke a
1046        // test of our own unchanged, already-correct function. statrs 0.19
1047        // fixed it: `gamma_lr(0.05, 1e-20)` now returns 0.1027216865271675,
1048        // which is `x^a/Γ(a+1)` to four figures.
1049        //
1050        // What the fixture actually needs is that `x` sits far below the
1051        // threshold where a naive series/clamp implementation gives up — that
1052        // is a property of the FIXTURE, checkable without reference to anyone
1053        // else's behaviour — and that OUR value is faithful there. Both are
1054        // asserted; nothing about statrs is.
1055        const NAIVE_SMALL_ARG_CLAMP: f64 = 1.11e-15;
1056        for &(a, x) in &[(0.05_f64, 1e-20_f64), (0.1, 1e-25), (0.02, 1e-40)] {
1057            assert!(
1058                x < NAIVE_SMALL_ARG_CLAMP,
1059                "fixture must probe below the naive small-argument clamp: x={x}"
1060            );
1061            let ours = regularized_lower_gamma(a, x);
1062            let leading = (a * x.ln() - ln_gamma(a + 1.0)).exp();
1063            assert!(ours > 0.0, "P({a},{x})={ours} collapsed to zero");
1064            assert!(
1065                (ours - leading).abs() < 1e-9 * leading,
1066                "P({a},{x})={ours}, leading order {leading}"
1067            );
1068        }
1069    }
1070
1071    #[test]
1072    fn gamma_quantile_scale_and_monotonicity() {
1073        // Scale is a pure multiplier, and the quantile is strictly increasing
1074        // in p (an equal-tailed interval must order correctly).
1075        let q_unit = gamma_quantile(0.9, 3.0, 1.0);
1076        let q_scaled = gamma_quantile(0.9, 3.0, 7.5);
1077        assert!((q_scaled - 7.5 * q_unit).abs() < 1e-9 * q_scaled.max(1.0));
1078
1079        let mut prev = 0.0;
1080        for i in 1..100 {
1081            let p = i as f64 / 100.0;
1082            let q = gamma_quantile(p, 2.0, 1.0);
1083            assert!(q > prev, "quantile not increasing at p={p}: {q} <= {prev}");
1084            prev = q;
1085        }
1086    }
1087
1088    #[test]
1089    fn gamma_quantile_rejects_degenerate_parameters() {
1090        assert!(gamma_quantile(0.5, -1.0, 1.0).is_nan());
1091        assert!(gamma_quantile(0.5, 1.0, 0.0).is_nan());
1092        assert!(gamma_quantile(0.5, f64::NAN, 1.0).is_nan());
1093        assert_eq!(gamma_quantile(0.0, 2.0, 1.0), 0.0);
1094        assert_eq!(gamma_quantile(-0.1, 2.0, 1.0), 0.0);
1095        assert!(gamma_quantile(1.0, 2.0, 1.0).is_infinite());
1096    }
1097
1098    #[test]
1099    fn gamma_moment_matched_interval_is_the_exact_conditional_gamma_when_se_vanishes() {
1100        // With no estimation uncertainty the total predictive variance is the
1101        // pure observation noise `Var(Y|μ) = φμ²`, and the moment-matched Gamma
1102        // must coincide *exactly* with the conditional `Gamma(shape = 1/φ,
1103        // scale = φμ)` (#817). Check against the analytic Gamma quantiles for a
1104        // shape-4 (φ = 0.25) Gamma at the equal-tailed 2.5%/97.5% levels.
1105        let phi = 0.25_f64; // shape k = 1/φ = 4
1106        let mu = 7.5_f64;
1107        let total_var = phi * mu * mu; // SE(μ̂) = 0
1108        let (lo, hi) = gamma_moment_matched_interval(mu, total_var, 0.025, 0.975)
1109            .expect("non-degenerate moment-matched Gamma interval");
1110
1111        let analytic_lo = gamma_quantile(0.025, 1.0 / phi, phi * mu);
1112        let analytic_hi = gamma_quantile(0.975, 1.0 / phi, phi * mu);
1113        assert!(
1114            (lo - analytic_lo).abs() < 1e-9 * analytic_lo.max(1.0)
1115                && (hi - analytic_hi).abs() < 1e-9 * analytic_hi.max(1.0),
1116            "moment-matched interval [{lo}, {hi}] != conditional Gamma \
1117             [{analytic_lo}, {analytic_hi}]"
1118        );
1119    }
1120
1121    #[test]
1122    fn gamma_moment_matched_interval_is_right_skewed_not_symmetric() {
1123        // The whole point of #817: for a right-skewed Gamma the equal-tailed
1124        // band is *asymmetric* about the mean — the upper gap exceeds the lower
1125        // gap — and the lower edge sits FAR above the symmetric-band edge
1126        // `μ·(1 − z/√k)`, which for shape 4 hugs the support floor at ≈ 0.02·μ.
1127        let phi = 0.25_f64; // shape 4, CV = 0.5
1128        let mu = 10.0_f64;
1129        let total_var = phi * mu * mu;
1130        let z = 1.959_963_984_540_054_f64; // 97.5% standard-normal quantile
1131        let (lo, hi) =
1132            gamma_moment_matched_interval(mu, total_var, normal_cdf(-z), normal_cdf(z)).unwrap();
1133
1134        // Ordered, strictly positive, brackets the mean.
1135        assert!(
1136            0.0 < lo && lo < mu && mu < hi,
1137            "interval [{lo}, {hi}] ∌ μ={mu}"
1138        );
1139        // Right skew: the upper gap is the larger one.
1140        let lower_gap = mu - lo;
1141        let upper_gap = hi - mu;
1142        assert!(
1143            upper_gap > 1.3 * lower_gap,
1144            "expected a right-skewed band (upper gap ≫ lower gap), got \
1145             lower_gap={lower_gap}, upper_gap={upper_gap}"
1146        );
1147        // The symmetric lower edge would be μ·(1 − z·√φ) = 10·(1 − 1.96·0.5) ≈
1148        // 0.20 — essentially the support floor. The skew-correct lower edge sits
1149        // well above it (true Gamma 2.5% quantile ≈ 0.27·μ for shape 4).
1150        let symmetric_lower = mu * (1.0 - z * phi.sqrt());
1151        assert!(
1152            lo > 2.0 * symmetric_lower.max(0.0) + 1.0,
1153            "skew-correct lower edge {lo} should sit well above the symmetric \
1154             edge {symmetric_lower}"
1155        );
1156    }
1157
1158    #[test]
1159    fn gamma_moment_matched_interval_widens_with_estimation_uncertainty() {
1160        // Adding estimation variance SE(μ̂)² to the observation noise must widen
1161        // the predictive band (lower edge down, upper edge up) — it is the
1162        // moment-matched predictive, not just the conditional law.
1163        let phi = 0.25_f64;
1164        let mu = 5.0_f64;
1165        let obs_var = phi * mu * mu;
1166        let (lo0, hi0) = gamma_moment_matched_interval(mu, obs_var, 0.025, 0.975).unwrap();
1167        let (lo1, hi1) = gamma_moment_matched_interval(mu, obs_var + 4.0, 0.025, 0.975).unwrap();
1168        assert!(
1169            lo1 < lo0 && hi1 > hi0,
1170            "estimation uncertainty must widen the band: [{lo0},{hi0}] -> [{lo1},{hi1}]"
1171        );
1172    }
1173
1174    #[test]
1175    fn gamma_moment_matched_interval_rejects_degenerate_and_near_gaussian_inputs() {
1176        // Non-positive mean / variance, or non-finite inputs => None (caller
1177        // falls back to the symmetric Gaussian edges).
1178        assert!(gamma_moment_matched_interval(0.0, 1.0, 0.025, 0.975).is_none());
1179        assert!(gamma_moment_matched_interval(-1.0, 1.0, 0.025, 0.975).is_none());
1180        assert!(gamma_moment_matched_interval(1.0, 0.0, 0.025, 0.975).is_none());
1181        assert!(gamma_moment_matched_interval(1.0, -1.0, 0.025, 0.975).is_none());
1182        assert!(gamma_moment_matched_interval(f64::NAN, 1.0, 0.025, 0.975).is_none());
1183        assert!(gamma_moment_matched_interval(1.0, f64::INFINITY, 0.025, 0.975).is_none());
1184        // A finite, well-conditioned case still returns Some.
1185        assert!(gamma_moment_matched_interval(3.0, 2.0, 0.025, 0.975).is_some());
1186    }
1187
1188    #[test]
1189    fn beta_moment_matched_interval_is_the_exact_conditional_beta_when_se_vanishes() {
1190        // With no estimation uncertainty the total predictive variance is the
1191        // pure observation noise `μ(1−μ)/(1+φ)`, and the moment-matched Beta must
1192        // coincide *exactly* with the conditional `Beta(μφ, (1−μ)φ)` (#1194).
1193        let phi = 8.0_f64;
1194        let mu = 0.2_f64;
1195        let total_var = mu * (1.0 - mu) / (1.0 + phi); // SE(μ̂) = 0
1196        let (lo, hi) = beta_moment_matched_interval(mu, total_var, 0.025, 0.975)
1197            .expect("non-degenerate moment-matched Beta interval");
1198        let analytic_lo = beta_quantile(0.025, mu * phi, (1.0 - mu) * phi);
1199        let analytic_hi = beta_quantile(0.975, mu * phi, (1.0 - mu) * phi);
1200        assert!(
1201            (lo - analytic_lo).abs() < 1e-9 && (hi - analytic_hi).abs() < 1e-9,
1202            "moment-matched interval [{lo}, {hi}] != conditional Beta [{analytic_lo}, {analytic_hi}]"
1203        );
1204    }
1205
1206    #[test]
1207    fn beta_moment_matched_interval_is_skewed_not_symmetric() {
1208        // For a small-mean Beta the equal-tailed band is asymmetric about μ (the
1209        // upper gap exceeds the lower gap) and the lower edge sits well above the
1210        // symmetric edge `μ − z·σ`, which on this data dives below 0.
1211        let phi = 8.0_f64;
1212        let mu = 0.15_f64;
1213        let total_var = mu * (1.0 - mu) / (1.0 + phi);
1214        let z = 1.959_963_984_540_054_f64;
1215        let (lo, hi) =
1216            beta_moment_matched_interval(mu, total_var, normal_cdf(-z), normal_cdf(z)).unwrap();
1217        assert!(
1218            0.0 < lo && lo < mu && mu < hi && hi < 1.0,
1219            "interval [{lo},{hi}] ∌ μ={mu}"
1220        );
1221        let lower_gap = mu - lo;
1222        let upper_gap = hi - mu;
1223        assert!(
1224            upper_gap > 1.2 * lower_gap,
1225            "expected a right-skewed band (upper gap > lower gap): lower={lower_gap}, upper={upper_gap}"
1226        );
1227        let symmetric_lower = mu - z * total_var.sqrt();
1228        assert!(
1229            symmetric_lower < 0.0 && lo > 0.0,
1230            "skew-correct lower edge {lo} should stay positive where the symmetric edge {symmetric_lower} goes negative"
1231        );
1232    }
1233
1234    #[test]
1235    fn beta_moment_matched_interval_rejects_degenerate_and_over_dispersed_inputs() {
1236        // Mean outside (0,1), non-positive variance, non-finite => None.
1237        assert!(beta_moment_matched_interval(0.0, 0.01, 0.025, 0.975).is_none());
1238        assert!(beta_moment_matched_interval(1.0, 0.01, 0.025, 0.975).is_none());
1239        assert!(beta_moment_matched_interval(-0.1, 0.01, 0.025, 0.975).is_none());
1240        assert!(beta_moment_matched_interval(0.3, 0.0, 0.025, 0.975).is_none());
1241        assert!(beta_moment_matched_interval(f64::NAN, 0.01, 0.025, 0.975).is_none());
1242        // Variance at/over the Bernoulli ceiling μ(1−μ): no Beta matches => None.
1243        assert!(beta_moment_matched_interval(0.5, 0.25, 0.025, 0.975).is_none());
1244        assert!(beta_moment_matched_interval(0.5, 0.30, 0.025, 0.975).is_none());
1245        // A well-conditioned case still returns Some.
1246        assert!(beta_moment_matched_interval(0.4, 0.02, 0.025, 0.975).is_some());
1247    }
1248
1249    #[test]
1250    fn beta_moment_matched_interval_widens_with_estimation_uncertainty() {
1251        let phi = 8.0_f64;
1252        let mu = 0.3_f64;
1253        let obs_var = mu * (1.0 - mu) / (1.0 + phi);
1254        let (lo0, hi0) = beta_moment_matched_interval(mu, obs_var, 0.025, 0.975).unwrap();
1255        let (lo1, hi1) = beta_moment_matched_interval(mu, obs_var + 0.01, 0.025, 0.975).unwrap();
1256        assert!(
1257            lo1 < lo0 && hi1 > hi0,
1258            "estimation uncertainty must widen the band: [{lo0},{hi0}] -> [{lo1},{hi1}]"
1259        );
1260    }
1261
1262    #[test]
1263    fn negative_binomial_quantile_matches_known_reference_values() {
1264        // Reference NB quantiles cross-checked against scipy
1265        // `nbinom.ppf(p, n=θ, prob=θ/(θ+μ))` — the integer count k with the
1266        // smallest CDF ≥ p. Spans the zero-atom lower tail, the right-skewed
1267        // upper tail, and a larger-mean near-Gaussian case.
1268        let cases: [(f64, f64, f64, f64); 8] = [
1269            // (p, μ, θ, expected integer quantile)
1270            (0.025, 1.6, 1.5, 0.0), // zero mass ≈ 0.34 > 0.025 ⇒ lower edge 0
1271            (0.5, 1.6, 1.5, 1.0),
1272            (0.975, 1.6, 1.5, 6.0),
1273            (0.99, 1.6, 1.5, 8.0),
1274            (0.025, 20.0, 5.0, 5.0),
1275            (0.975, 20.0, 5.0, 43.0),
1276            (0.5, 20.0, 5.0, 19.0),
1277            (0.975, 0.5, 2.0, 3.0),
1278        ];
1279        for (p, mu, theta, expected) in cases {
1280            let got = negative_binomial_quantile(p, mu, theta);
1281            assert_eq!(
1282                got, expected,
1283                "negative_binomial_quantile(p={p}, μ={mu}, θ={theta}) = {got}, expected {expected}"
1284            );
1285        }
1286    }
1287
1288    #[test]
1289    fn negative_binomial_quantile_is_a_valid_cdf_inverse() {
1290        // The returned integer k must be the *smallest* with CDF(k) ≥ p:
1291        // CDF(k) ≥ p and (for k ≥ 1) CDF(k−1) < p, across a grid of (μ, θ, p).
1292        use statrs::function::beta::beta_reg;
1293        for &mu in &[0.3_f64, 1.6, 5.0, 25.0, 120.0] {
1294            for &theta in &[0.5_f64, 1.5, 5.0, 40.0] {
1295                let prob = theta / (theta + mu);
1296                for &p in &[0.01_f64, 0.025, 0.1, 0.5, 0.9, 0.975, 0.99] {
1297                    let k = negative_binomial_quantile(p, mu, theta);
1298                    assert!(
1299                        k.is_finite() && k >= 0.0 && k.fract() == 0.0,
1300                        "non-integer k={k}"
1301                    );
1302                    let cdf_k = beta_reg(theta, k + 1.0, prob);
1303                    assert!(
1304                        cdf_k + 1e-12 >= p,
1305                        "CDF({k}) = {cdf_k} < p = {p} (μ={mu}, θ={theta})"
1306                    );
1307                    if k >= 1.0 {
1308                        let cdf_below = beta_reg(theta, k, prob);
1309                        assert!(
1310                            cdf_below < p,
1311                            "k={k} not minimal: CDF({}) = {cdf_below} ≥ p = {p} (μ={mu}, θ={theta})",
1312                            k - 1.0
1313                        );
1314                    }
1315                }
1316            }
1317        }
1318    }
1319
1320    #[test]
1321    fn negative_binomial_quantile_boundaries_and_degeneracy() {
1322        assert_eq!(negative_binomial_quantile(0.0, 2.0, 1.5), 0.0);
1323        assert_eq!(negative_binomial_quantile(-0.1, 2.0, 1.5), 0.0);
1324        assert!(negative_binomial_quantile(1.0, 2.0, 1.5).is_infinite());
1325        assert_eq!(negative_binomial_quantile(0.5, 0.0, 1.5), 0.0); // point mass at 0
1326        assert!(negative_binomial_quantile(0.5, -1.0, 1.5).is_nan());
1327        assert!(negative_binomial_quantile(0.5, 2.0, 0.0).is_nan());
1328        assert!(negative_binomial_quantile(0.5, 2.0, f64::NAN).is_nan());
1329        // Monotone non-decreasing in p (discrete ⇒ plateaus allowed).
1330        let mut prev = 0.0;
1331        for i in 1..100 {
1332            let p = i as f64 / 100.0;
1333            let q = negative_binomial_quantile(p, 4.0, 2.0);
1334            assert!(q >= prev, "NB quantile decreased at p={p}: {q} < {prev}");
1335            prev = q;
1336        }
1337    }
1338
1339    #[test]
1340    fn negative_binomial_moment_matched_interval_is_exact_conditional_when_se_vanishes() {
1341        // SE(μ̂) = 0 ⇒ total_var = μ + μ²/θ ⇒ θ_eff = θ, recovering the exact
1342        // conditional NB quantiles.
1343        let mu = 1.6_f64;
1344        let theta = 1.5_f64;
1345        let total_var = mu + mu * mu / theta;
1346        let (lo, hi) =
1347            negative_binomial_moment_matched_interval(mu, theta, total_var, 0.025, 0.975).unwrap();
1348        assert_eq!(lo, negative_binomial_quantile(0.025, mu, theta));
1349        assert_eq!(hi, negative_binomial_quantile(0.975, mu, theta));
1350    }
1351
1352    #[test]
1353    fn negative_binomial_moment_matched_interval_widens_with_estimation_uncertainty() {
1354        // Adding estimation variance lowers θ_eff (more overdispersion) and must
1355        // not shrink the band; with enough added variance the upper edge grows.
1356        let mu = 8.0_f64;
1357        let theta = 4.0_f64;
1358        let obs_var = mu + mu * mu / theta;
1359        let (lo0, hi0) =
1360            negative_binomial_moment_matched_interval(mu, theta, obs_var, 0.025, 0.975).unwrap();
1361        let (lo1, hi1) =
1362            negative_binomial_moment_matched_interval(mu, theta, obs_var + 40.0, 0.025, 0.975)
1363                .unwrap();
1364        assert!(
1365            lo1 <= lo0 && hi1 > hi0,
1366            "band did not widen: [{lo0},{hi0}] -> [{lo1},{hi1}]"
1367        );
1368    }
1369
1370    #[test]
1371    fn negative_binomial_moment_matched_interval_rejects_degenerate_inputs() {
1372        assert!(negative_binomial_moment_matched_interval(0.0, 1.5, 1.0, 0.025, 0.975).is_none());
1373        assert!(negative_binomial_moment_matched_interval(-1.0, 1.5, 1.0, 0.025, 0.975).is_none());
1374        assert!(negative_binomial_moment_matched_interval(2.0, 0.0, 1.0, 0.025, 0.975).is_none());
1375        assert!(negative_binomial_moment_matched_interval(2.0, 1.5, 0.0, 0.025, 0.975).is_none());
1376        assert!(
1377            negative_binomial_moment_matched_interval(f64::NAN, 1.5, 1.0, 0.025, 0.975).is_none()
1378        );
1379        assert!(negative_binomial_moment_matched_interval(2.0, 1.5, 6.0, 0.025, 0.975).is_some());
1380    }
1381
1382    #[test]
1383    fn poisson_quantile_matches_known_reference_values() {
1384        // Reference integer quantiles from scipy.stats.poisson.ppf.
1385        let cases: [(f64, f64, f64); 9] = [
1386            // (p, μ, expected integer quantile)
1387            (0.025, 1.6, 0.0), // zero mass e^{−1.6} ≈ 0.20 < 0.025? no: 0.20 > 0.025 ⇒ 0
1388            (0.5, 1.6, 1.0),
1389            (0.975, 1.6, 4.0),
1390            (0.99, 1.6, 5.0),
1391            (0.025, 20.0, 12.0),
1392            (0.975, 20.0, 29.0),
1393            (0.5, 20.0, 20.0),
1394            (0.975, 0.5, 2.0),
1395            (0.025, 0.5, 0.0),
1396        ];
1397        for (p, mu, expected) in cases {
1398            let got = poisson_quantile(p, mu);
1399            assert_eq!(
1400                got, expected,
1401                "poisson_quantile(p={p}, μ={mu}) = {got}, expected {expected}"
1402            );
1403        }
1404    }
1405
1406    #[test]
1407    fn poisson_quantile_is_a_valid_cdf_inverse() {
1408        // The returned integer k must be the *smallest* with CDF(k) ≥ p:
1409        // CDF(k) ≥ p and (for k ≥ 1) CDF(k−1) < p, across a grid of (μ, p).
1410        for &mu in &[0.3_f64, 1.6, 5.0, 25.0, 120.0] {
1411            for &p in &[0.01_f64, 0.025, 0.1, 0.5, 0.9, 0.975, 0.99] {
1412                let k = poisson_quantile(p, mu);
1413                assert!(
1414                    k.is_finite() && k >= 0.0 && k.fract() == 0.0,
1415                    "non-integer k={k}"
1416                );
1417                let cdf_k = poisson_cdf_at(k, mu);
1418                assert!(cdf_k + 1e-12 >= p, "CDF({k}) = {cdf_k} < p = {p} (μ={mu})");
1419                if k >= 1.0 {
1420                    let cdf_below = poisson_cdf_at(k - 1.0, mu);
1421                    assert!(
1422                        cdf_below < p,
1423                        "k={k} not minimal: CDF({}) = {cdf_below} ≥ p = {p} (μ={mu})",
1424                        k - 1.0
1425                    );
1426                }
1427            }
1428        }
1429    }
1430
1431    #[test]
1432    fn poisson_quantile_boundaries_and_degeneracy() {
1433        assert_eq!(poisson_quantile(0.0, 2.0), 0.0);
1434        assert_eq!(poisson_quantile(-0.1, 2.0), 0.0);
1435        assert!(poisson_quantile(1.0, 2.0).is_infinite());
1436        assert_eq!(poisson_quantile(0.5, 0.0), 0.0); // point mass at 0
1437        assert!(poisson_quantile(0.5, -1.0).is_nan());
1438        assert!(poisson_quantile(0.5, f64::NAN).is_nan());
1439        // Monotone non-decreasing in p (discrete ⇒ plateaus allowed).
1440        let mut prev = 0.0;
1441        for i in 1..100 {
1442            let p = i as f64 / 100.0;
1443            let q = poisson_quantile(p, 4.0);
1444            assert!(
1445                q >= prev,
1446                "Poisson quantile decreased at p={p}: {q} < {prev}"
1447            );
1448            prev = q;
1449        }
1450    }
1451
1452    #[test]
1453    fn poisson_moment_matched_interval_is_exact_conditional_when_se_vanishes() {
1454        // SE(μ̂) = 0 ⇒ total_var = μ ⇒ θ_eff = ∞, recovering the exact conditional
1455        // Poisson quantiles directly (no NB widening).
1456        for &mu in &[0.5_f64, 1.6, 20.0] {
1457            let (lo, hi) = poisson_moment_matched_interval(mu, mu, 0.025, 0.975).unwrap();
1458            assert_eq!(lo, poisson_quantile(0.025, mu));
1459            assert_eq!(hi, poisson_quantile(0.975, mu));
1460        }
1461    }
1462
1463    #[test]
1464    fn poisson_moment_matched_interval_widens_with_estimation_uncertainty() {
1465        // Adding estimation variance lowers θ_eff (genuine overdispersion) and
1466        // must not shrink the band; with enough added variance the upper edge
1467        // grows beyond the conditional Poisson quantile.
1468        let mu = 20.0_f64;
1469        let (lo0, hi0) = poisson_moment_matched_interval(mu, mu, 0.025, 0.975).unwrap();
1470        let (lo1, hi1) = poisson_moment_matched_interval(mu, mu + 40.0, 0.025, 0.975).unwrap();
1471        assert!(
1472            lo1 <= lo0 && hi1 > hi0,
1473            "band did not widen: [{lo0},{hi0}] -> [{lo1},{hi1}]"
1474        );
1475        // A negligible excess (θ_eff above the switch threshold) must coincide
1476        // with the exact conditional Poisson — no discontinuity at the boundary.
1477        let (lo2, hi2) =
1478            poisson_moment_matched_interval(mu, mu + mu * mu * 1.0e-12, 0.025, 0.975).unwrap();
1479        assert_eq!((lo2, hi2), (lo0, hi0));
1480    }
1481
1482    #[test]
1483    fn poisson_moment_matched_interval_is_skewed_not_symmetric() {
1484        // The whole point of #1193/#817: on a low-rate count the equal-tailed
1485        // upper edge sits ABOVE the symmetric `μ + z·√μ` band that under-covers
1486        // the upper tail, and the band is asymmetric about μ.
1487        let mu = 2.0_f64;
1488        let z = standard_normal_quantile(0.975).unwrap();
1489        let (lo, hi) = poisson_moment_matched_interval(mu, mu, 0.025, 0.975).unwrap();
1490        let sym_hi = mu + z * mu.sqrt();
1491        assert!(
1492            hi > sym_hi,
1493            "equal-tailed upper {hi} should exceed symmetric upper {sym_hi}"
1494        );
1495        // Upper tail reaches further from μ than the lower tail (right skew).
1496        assert!(
1497            (hi - mu) > (mu - lo),
1498            "band not right-skewed: lo={lo}, hi={hi}, μ={mu}"
1499        );
1500    }
1501
1502    #[test]
1503    fn poisson_moment_matched_interval_rejects_degenerate_inputs() {
1504        assert!(poisson_moment_matched_interval(0.0, 1.0, 0.025, 0.975).is_none());
1505        assert!(poisson_moment_matched_interval(-1.0, 1.0, 0.025, 0.975).is_none());
1506        assert!(poisson_moment_matched_interval(2.0, 0.0, 0.025, 0.975).is_none());
1507        assert!(poisson_moment_matched_interval(2.0, 1.0, 0.025, 0.975).is_none()); // total_var < μ
1508        assert!(poisson_moment_matched_interval(f64::NAN, 5.0, 0.025, 0.975).is_none());
1509        assert!(poisson_moment_matched_interval(2.0, 5.0, 0.025, 0.975).is_some());
1510    }
1511
1512    #[test]
1513    fn tweedie_quantile_is_a_valid_cdf_inverse() {
1514        // For a probability strictly above the zero atom the quantile `y` must
1515        // satisfy `CDF(y) ≈ q`: the bisection inverts `tweedie_cdf_at` exactly.
1516        let mu = 3.0_f64;
1517        let phi = 1.2_f64;
1518        let power = 1.5_f64;
1519        let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
1520        let zero_mass = (-lambda).exp();
1521        for &q in &[0.30_f64, 0.5, 0.75, 0.9, 0.975, 0.99] {
1522            assert!(
1523                q > zero_mass,
1524                "test q must exceed the zero atom {zero_mass}"
1525            );
1526            let y = tweedie_quantile(q, mu, phi, power);
1527            assert!(y.is_finite() && y > 0.0, "quantile out of support: {y}");
1528            let cdf = tweedie_cdf_at(y, mu, phi, power);
1529            assert!((cdf - q).abs() < 1e-6, "CDF(Q(q)) != q: q={q}, cdf={cdf}");
1530        }
1531    }
1532
1533    #[test]
1534    fn tweedie_quantile_returns_zero_atom_for_low_tail() {
1535        // When the requested lower-tail probability is at or below the point
1536        // mass at zero `e^{−λ}`, the quantile is exactly 0 (right-skewed low
1537        // means) — the zero-atom behaviour a continuous surrogate cannot mimic.
1538        let mu = 0.4_f64; // small mean ⇒ large zero atom
1539        let phi = 1.0_f64;
1540        let power = 1.5_f64;
1541        let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
1542        let zero_mass = (-lambda).exp();
1543        assert!(
1544            zero_mass > 0.025,
1545            "fixture must have a fat zero atom: {zero_mass}"
1546        );
1547        assert_eq!(tweedie_quantile(0.025, mu, phi, power), 0.0);
1548        assert_eq!(tweedie_quantile(0.5 * zero_mass, mu, phi, power), 0.0);
1549    }
1550
1551    #[test]
1552    fn tweedie_quantile_boundaries_and_degeneracy() {
1553        let (mu, phi, power) = (2.0_f64, 1.0_f64, 1.6_f64);
1554        assert_eq!(tweedie_quantile(0.0, mu, phi, power), 0.0);
1555        assert_eq!(tweedie_quantile(-0.1, mu, phi, power), 0.0);
1556        assert_eq!(tweedie_quantile(1.0, mu, phi, power), f64::INFINITY);
1557        // Power outside (1, 2) or non-positive params are NaN.
1558        assert!(tweedie_quantile(0.5, mu, phi, 2.0).is_nan());
1559        assert!(tweedie_quantile(0.5, mu, phi, 1.0).is_nan());
1560        assert!(tweedie_quantile(0.5, 0.0, phi, power).is_nan());
1561        assert!(tweedie_quantile(0.5, mu, 0.0, power).is_nan());
1562    }
1563
1564    #[test]
1565    fn tweedie_moment_matched_interval_is_exact_conditional_when_se_vanishes() {
1566        // total_var = φμ^p ⇒ φ_eff = φ, recovering the exact conditional Tweedie
1567        // quantiles.
1568        let mu = 3.0_f64;
1569        let phi = 1.2_f64;
1570        let power = 1.5_f64;
1571        let total_var = phi * mu.powf(power);
1572        let (lo, hi) =
1573            tweedie_moment_matched_interval(mu, phi, power, total_var, 0.025, 0.975).unwrap();
1574        assert_eq!(lo, tweedie_quantile(0.025, mu, phi, power));
1575        assert_eq!(hi, tweedie_quantile(0.975, mu, phi, power));
1576    }
1577
1578    #[test]
1579    fn tweedie_moment_matched_interval_is_skewed_not_symmetric() {
1580        // A right-skewed Tweedie has the upper edge farther from the mean than
1581        // the lower edge — the symmetric `mu ± z·σ` band cannot reproduce this.
1582        let mu = 2.0_f64;
1583        let phi = 1.5_f64;
1584        let power = 1.5_f64;
1585        let total_var = phi * mu.powf(power);
1586        let (lo, hi) =
1587            tweedie_moment_matched_interval(mu, phi, power, total_var, 0.025, 0.975).unwrap();
1588        assert!(lo >= 0.0 && hi > mu && lo < mu);
1589        assert!(
1590            hi - mu > mu - lo,
1591            "interval is not right-skewed: lo={lo}, hi={hi}"
1592        );
1593    }
1594
1595    #[test]
1596    fn tweedie_moment_matched_interval_widens_with_estimation_uncertainty() {
1597        // Adding estimation variance raises φ_eff and must not shrink the band;
1598        // the upper edge grows.
1599        let mu = 4.0_f64;
1600        let phi = 1.0_f64;
1601        let power = 1.5_f64;
1602        let obs_var = phi * mu.powf(power);
1603        let (lo0, hi0) =
1604            tweedie_moment_matched_interval(mu, phi, power, obs_var, 0.025, 0.975).unwrap();
1605        let (lo1, hi1) =
1606            tweedie_moment_matched_interval(mu, phi, power, obs_var + 30.0, 0.025, 0.975).unwrap();
1607        assert!(
1608            lo1 <= lo0 && hi1 > hi0,
1609            "band did not widen: [{lo0},{hi0}] -> [{lo1},{hi1}]"
1610        );
1611    }
1612
1613    #[test]
1614    fn tweedie_moment_matched_interval_rejects_degenerate_inputs() {
1615        assert!(tweedie_moment_matched_interval(0.0, 1.0, 1.5, 1.0, 0.025, 0.975).is_none());
1616        assert!(tweedie_moment_matched_interval(-1.0, 1.0, 1.5, 1.0, 0.025, 0.975).is_none());
1617        assert!(tweedie_moment_matched_interval(2.0, 0.0, 1.5, 1.0, 0.025, 0.975).is_none());
1618        assert!(tweedie_moment_matched_interval(2.0, 1.0, 2.0, 1.0, 0.025, 0.975).is_none());
1619        assert!(tweedie_moment_matched_interval(2.0, 1.0, 1.5, 0.0, 0.025, 0.975).is_none());
1620        assert!(tweedie_moment_matched_interval(f64::NAN, 1.0, 1.5, 1.0, 0.025, 0.975).is_none());
1621        assert!(tweedie_moment_matched_interval(2.0, 1.0, 1.5, 6.0, 0.025, 0.975).is_some());
1622    }
1623}