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}