Skip to main content

gam_inference/
probability.rs

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