use crate::estimate::EstimationError;
use crate::mixture_link::inverse_link_jet_for_family_public;
use crate::types::LikelihoodSpec;
use ndarray::{Array1, ArrayView1};
use statrs::function::beta::{beta_reg, inv_beta_reg};
use statrs::function::erf::erfc;
#[inline]
pub fn normal_pdf(x: f64) -> f64 {
const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
INV_SQRT_2PI * (-0.5 * x * x).exp()
}
#[inline]
pub fn normal_cdf(x: f64) -> f64 {
0.5 * erfc(-x / std::f64::consts::SQRT_2)
}
#[inline]
pub fn erfcx_nonnegative(x: f64) -> f64 {
if !x.is_finite() {
return if x.is_sign_positive() {
0.0
} else {
f64::INFINITY
};
}
if x <= 0.0 {
return 1.0;
}
if x < 26.0 {
((x * x).min(700.0)).exp() * erfc(x)
} else {
let inv = 1.0 / x;
let inv2 = inv * inv;
let poly = 1.0 - 0.5 * inv2 + 0.75 * inv2 * inv2 - 1.875 * inv2 * inv2 * inv2
+ 6.5625 * inv2 * inv2 * inv2 * inv2;
inv * poly / std::f64::consts::PI.sqrt()
}
}
#[inline]
pub fn log1mexp_positive(a: f64) -> f64 {
assert!(a >= 0.0, "log1mexp_positive requires a >= 0: a={a}");
if a > core::f64::consts::LN_2 {
(-(-a).exp()).ln_1p()
} else if a > 0.0 {
(-(-a).exp_m1()).ln()
} else {
f64::NEG_INFINITY
}
}
pub fn signed_log_sum_exp(log_mags: &[f64], signs: &[f64]) -> (f64, f64) {
let mut has_pos_inf = false;
let mut has_neg_inf = false;
for (idx, &lm) in log_mags.iter().enumerate() {
if lm == f64::INFINITY {
if signs[idx] > 0.0 {
has_pos_inf = true;
} else if signs[idx] < 0.0 {
has_neg_inf = true;
}
}
}
match (has_pos_inf, has_neg_inf) {
(true, true) => return (f64::NAN, 0.0),
(true, false) => return (f64::INFINITY, 1.0),
(false, true) => return (f64::INFINITY, -1.0),
(false, false) => {}
}
let mut pos_max = f64::NEG_INFINITY;
let mut neg_max = f64::NEG_INFINITY;
for (idx, &lm) in log_mags.iter().enumerate() {
if signs[idx] > 0.0 {
pos_max = pos_max.max(lm);
} else if signs[idx] < 0.0 {
neg_max = neg_max.max(lm);
}
}
let mut pos_sum = 0.0_f64;
let mut neg_sum = 0.0_f64;
for (idx, &lm) in log_mags.iter().enumerate() {
if !lm.is_finite() {
continue;
}
if signs[idx] > 0.0 {
pos_sum += (lm - pos_max).exp();
} else if signs[idx] < 0.0 {
neg_sum += (lm - neg_max).exp();
}
}
let log_pos = if pos_sum > 0.0 {
pos_max + pos_sum.ln()
} else {
f64::NEG_INFINITY
};
let log_neg = if neg_sum > 0.0 {
neg_max + neg_sum.ln()
} else {
f64::NEG_INFINITY
};
if log_neg == f64::NEG_INFINITY {
return (log_pos, 1.0);
}
if log_pos == f64::NEG_INFINITY {
return (log_neg, -1.0);
}
if log_pos > log_neg {
let gap = log_pos - log_neg;
(log_pos + log1mexp_positive(gap), 1.0)
} else if log_neg > log_pos {
let gap = log_neg - log_pos;
(log_neg + log1mexp_positive(gap), -1.0)
} else {
(f64::NEG_INFINITY, 0.0)
}
}
#[inline]
fn horner_polynomial(x: f64, coeffs: &[f64]) -> f64 {
coeffs.iter().rev().fold(0.0, |acc, &c| acc * x + c)
}
#[inline]
pub fn stable_polynomial_times_exp_neg(x: f64, coeffs: &[f64]) -> f64 {
if coeffs.is_empty() || !x.is_finite() {
return 0.0;
}
const DIRECT_EXP_SWITCH: f64 = 600.0;
if x <= DIRECT_EXP_SWITCH {
return horner_polynomial(x, coeffs) * (-x).exp();
}
let inv_x = x.recip();
let mut tail = 0.0;
for &c in coeffs {
tail = tail * inv_x + c;
}
let degree = (coeffs.len() - 1) as f64;
let scale = (degree * x.ln() - x).exp();
scale * tail
}
#[inline]
pub fn binomial_coefficient_f64(n: usize, k: usize) -> f64 {
if k > n {
return 0.0;
}
if k == 0 || k == n {
return 1.0;
}
let k_eff = k.min(n - k);
let mut out = 1.0;
for j in 0..k_eff {
out *= (n - j) as f64 / (j + 1) as f64;
}
out
}
#[inline]
pub fn normal_logcdf(x: f64) -> f64 {
if x == f64::INFINITY {
return 0.0;
}
if x == f64::NEG_INFINITY {
return f64::NEG_INFINITY;
}
if x.is_nan() {
return f64::NAN;
}
if x < 0.0 {
let u = -x / std::f64::consts::SQRT_2;
-u * u + (0.5 * erfcx_nonnegative(u).max(1e-300)).ln()
} else {
normal_cdf(x).clamp(1e-300, 1.0).ln()
}
}
#[inline]
pub fn normal_logsf(x: f64) -> f64 {
normal_logcdf(-x)
}
#[inline]
pub fn signed_probit_logcdf_and_mills_ratio(x: f64) -> (f64, f64) {
if x == f64::INFINITY {
return (0.0, 0.0);
}
if x == f64::NEG_INFINITY {
return (f64::NEG_INFINITY, f64::INFINITY);
}
if x.is_nan() {
return (f64::NAN, f64::NAN);
}
if x < 0.0 {
let u = -x / std::f64::consts::SQRT_2;
let ex = erfcx_nonnegative(u).max(1e-300);
let log_cdf = -u * u + (0.5 * ex).ln();
let lambda = (2.0 / std::f64::consts::PI).sqrt() / ex;
(log_cdf, lambda)
} else {
let cdf = normal_cdf(x).clamp(1e-300, 1.0);
let lambda = normal_pdf(x) / cdf;
(cdf.ln(), lambda)
}
}
#[inline]
pub fn standard_normal_quantile(p: f64) -> Result<f64, String> {
if !(p.is_finite() && p > 0.0 && p < 1.0) {
return Err(format!("normal quantile requires p in (0,1), got {p}"));
}
const A: [f64; 6] = [
-3.969_683_028_665_376e1,
2.209_460_984_245_205e2,
-2.759_285_104_469_687e2,
1.383_577_518_672_69e2,
-3.066_479_806_614_716e1,
2.506_628_277_459_239,
];
const B: [f64; 5] = [
-5.447_609_879_822_406e1,
1.615_858_368_580_409e2,
-1.556_989_798_598_866e2,
6.680_131_188_771_972e1,
-1.328_068_155_288_572e1,
];
const C: [f64; 6] = [
-7.784_894_002_430_293e-3,
-3.223_964_580_411_365e-1,
-2.400_758_277_161_838,
-2.549_732_539_343_734,
4.374_664_141_464_968,
2.938_163_982_698_783,
];
const D: [f64; 4] = [
7.784_695_709_041_462e-3,
3.224_671_290_700_398e-1,
2.445_134_137_142_996,
3.754_408_661_907_416,
];
const P_LOW: f64 = 0.02425;
const P_HIGH: f64 = 1.0 - P_LOW;
let mut x = if p < P_LOW {
let q = (-2.0 * p.ln()).sqrt();
(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
/ ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
} else if p <= P_HIGH {
let q = p - 0.5;
let r = q * q;
(((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q
/ (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0)
} else {
let q = (-2.0 * (1.0 - p).ln()).sqrt();
-(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
/ ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
};
for _ in 0..2 {
let density = normal_pdf(x);
if !(density.is_finite() && density > 0.0) {
break;
}
let residual = if x > 0.0 {
(1.0 - p) - 0.5 * erfc(x / std::f64::consts::SQRT_2)
} else {
normal_cdf(x) - p
};
let correction = residual / density;
let denominator = 1.0 + 0.5 * x * correction;
if !(correction.is_finite() && denominator.is_finite() && denominator != 0.0) {
break;
}
let step = correction / denominator;
if !step.is_finite() {
break;
}
x -= step;
if step.abs() <= 2.0 * f64::EPSILON * x.abs().max(1.0) {
break;
}
}
Ok(x)
}
pub fn gamma_quantile(p: f64, shape: f64, scale: f64) -> f64 {
if !(shape.is_finite() && shape > 0.0 && scale.is_finite() && scale > 0.0) {
return f64::NAN;
}
scale * inverse_regularized_lower_gamma(p, shape)
}
pub fn gamma_moment_matched_interval(
mu: f64,
total_var: f64,
p_lo: f64,
p_hi: f64,
) -> Option<(f64, f64)> {
if !(mu.is_finite() && mu > 0.0 && total_var.is_finite() && total_var > 0.0) {
return None;
}
let shape = mu * mu / total_var;
let scale = total_var / mu;
let q_lo = gamma_quantile(p_lo, shape, scale);
let q_hi = gamma_quantile(p_hi, shape, scale);
if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
Some((q_lo, q_hi))
} else {
None
}
}
pub fn beta_quantile(p: f64, a: f64, b: f64) -> f64 {
if !(a.is_finite() && a > 0.0 && b.is_finite() && b > 0.0) {
return f64::NAN;
}
if !p.is_finite() || p <= 0.0 {
return 0.0;
}
if p >= 1.0 {
return 1.0;
}
inv_beta_reg(a, b, p)
}
pub fn beta_moment_matched_interval(
mu: f64,
total_var: f64,
p_lo: f64,
p_hi: f64,
) -> Option<(f64, f64)> {
if !(mu.is_finite() && mu > 0.0 && mu < 1.0 && total_var.is_finite() && total_var > 0.0) {
return None;
}
let max_var = mu * (1.0 - mu);
if total_var >= max_var {
return None;
}
let precision = max_var / total_var - 1.0; let a = mu * precision;
let b = (1.0 - mu) * precision;
let q_lo = beta_quantile(p_lo, a, b);
let q_hi = beta_quantile(p_hi, a, b);
if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
Some((q_lo, q_hi))
} else {
None
}
}
#[inline]
fn negative_binomial_cdf_at(k: f64, theta: f64, prob: f64) -> f64 {
beta_reg(theta, k + 1.0, prob.clamp(0.0, 1.0))
}
fn count_quantile_bracket_bisect(cdf: impl Fn(f64) -> f64, seed: f64, p: f64) -> f64 {
let mut lo: f64;
let mut hi: f64;
if cdf(seed) >= p {
hi = seed;
lo = 0.0;
let mut step = 1.0;
let mut cand = seed - 1.0;
while cand > 0.0 && cdf(cand) >= p {
hi = cand;
step *= 2.0;
cand = seed - step;
}
if cand > 0.0 {
lo = cand; }
} else {
lo = seed; let mut step = 1.0;
let mut cand = seed + 1.0;
while cdf(cand) < p {
lo = cand;
step *= 2.0;
cand = seed + step;
if cand > 1.0e18 {
return f64::INFINITY;
}
}
hi = cand;
}
while hi - lo > 1.0 {
let mid = (lo + (hi - lo) / 2.0).floor();
if cdf(mid) >= p {
hi = mid;
} else {
lo = mid;
}
}
hi
}
pub fn negative_binomial_quantile(p: f64, mu: f64, theta: f64) -> f64 {
if !(mu.is_finite() && mu >= 0.0 && theta.is_finite() && theta > 0.0) {
return f64::NAN;
}
if !p.is_finite() || p <= 0.0 {
return 0.0;
}
if p >= 1.0 {
return f64::INFINITY;
}
if mu == 0.0 {
return 0.0;
}
let prob = theta / (theta + mu); let cdf = |k: f64| negative_binomial_cdf_at(k, theta, prob);
if cdf(0.0) >= p {
return 0.0;
}
let var = mu + mu * mu / theta;
let z = standard_normal_quantile(p).unwrap_or(0.0);
let seed = (mu + z * var.sqrt()).floor().max(1.0);
count_quantile_bracket_bisect(&cdf, seed, p)
}
pub fn negative_binomial_moment_matched_interval(
mu: f64,
theta: f64,
total_var: f64,
p_lo: f64,
p_hi: f64,
) -> Option<(f64, f64)> {
if !(mu.is_finite()
&& mu > 0.0
&& theta.is_finite()
&& theta > 0.0
&& total_var.is_finite()
&& total_var > 0.0)
{
return None;
}
let excess = total_var - mu;
let theta_eff = if excess > 0.0 {
mu * mu / excess
} else {
theta
};
let q_lo = negative_binomial_quantile(p_lo, mu, theta_eff);
let q_hi = negative_binomial_quantile(p_hi, mu, theta_eff);
if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
Some((q_lo, q_hi))
} else {
None
}
}
#[inline]
fn poisson_cdf_at(k: f64, mu: f64) -> f64 {
(1.0 - regularized_lower_gamma(k + 1.0, mu)).clamp(0.0, 1.0)
}
pub fn poisson_quantile(p: f64, mu: f64) -> f64 {
if !(mu.is_finite() && mu >= 0.0) {
return f64::NAN;
}
if !p.is_finite() || p <= 0.0 {
return 0.0;
}
if p >= 1.0 {
return f64::INFINITY;
}
if mu == 0.0 {
return 0.0;
}
let cdf = |k: f64| poisson_cdf_at(k, mu);
if cdf(0.0) >= p {
return 0.0;
}
let z = standard_normal_quantile(p).unwrap_or(0.0);
let seed = (mu + z * mu.sqrt()).floor().max(1.0);
count_quantile_bracket_bisect(&cdf, seed, p)
}
pub fn poisson_moment_matched_interval(
mu: f64,
total_var: f64,
p_lo: f64,
p_hi: f64,
) -> Option<(f64, f64)> {
if !(mu.is_finite() && mu > 0.0 && total_var.is_finite() && total_var > 0.0) {
return None;
}
let excess = total_var - mu;
if excess < 0.0 {
return None;
}
const THETA_EFF_MAX: f64 = 1.0e9;
let theta_eff = if excess > 0.0 {
mu * mu / excess
} else {
f64::INFINITY
};
let (q_lo, q_hi) = if theta_eff > THETA_EFF_MAX {
(poisson_quantile(p_lo, mu), poisson_quantile(p_hi, mu))
} else {
(
negative_binomial_quantile(p_lo, mu, theta_eff),
negative_binomial_quantile(p_hi, mu, theta_eff),
)
};
if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
Some((q_lo, q_hi))
} else {
None
}
}
#[inline]
fn tweedie_cdf_at(y: f64, mu: f64, phi: f64, power: f64) -> f64 {
if !(y.is_finite() && y >= 0.0) {
return f64::NAN;
}
let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
let alpha = (2.0 - power) / (power - 1.0);
let scale = phi * (power - 1.0) * mu.powf(power - 1.0);
let zero_mass = (-lambda).exp();
if y <= 0.0 {
return zero_mass;
}
let x = y / scale; let mut acc = zero_mass; let mut ln_w = -lambda; let k_max = (lambda + 10.0 * lambda.sqrt()).ceil() as usize + 50;
let mut remaining = 1.0 - zero_mass; for k in 1..=k_max {
ln_w += lambda.ln() - (k as f64).ln();
let w = ln_w.exp();
remaining -= w;
acc += w * regularized_lower_gamma(alpha * k as f64, x);
if remaining <= 1e-15 && k as f64 > lambda {
break;
}
}
acc.clamp(0.0, 1.0)
}
pub fn tweedie_quantile(q: f64, mu: f64, phi: f64, power: f64) -> f64 {
if !(mu.is_finite()
&& mu > 0.0
&& phi.is_finite()
&& phi > 0.0
&& power.is_finite()
&& power > 1.0
&& power < 2.0)
{
return f64::NAN;
}
if !q.is_finite() || q <= 0.0 {
return 0.0;
}
if q >= 1.0 {
return f64::INFINITY;
}
let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
let zero_mass = (-lambda).exp();
if q <= zero_mass {
return 0.0;
}
let var = phi * mu.powf(power);
let z = standard_normal_quantile(q).unwrap_or(0.0);
let mut hi = (mu + z * var.sqrt()).max(scale_floor(mu));
let cdf = |y: f64| tweedie_cdf_at(y, mu, phi, power);
let mut lo = 0.0_f64;
let mut guard = 0;
while cdf(hi) < q {
lo = hi;
hi *= 2.0;
guard += 1;
if guard > 200 || hi > 1.0e18 {
return f64::INFINITY;
}
}
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if cdf(mid) < q {
lo = mid;
} else {
hi = mid;
}
if hi - lo <= (hi.abs() + 1.0) * 1e-12 {
break;
}
}
0.5 * (lo + hi)
}
#[inline]
fn scale_floor(mu: f64) -> f64 {
(mu * 1e-3).max(f64::MIN_POSITIVE)
}
pub fn tweedie_moment_matched_interval(
mu: f64,
phi: f64,
power: f64,
total_var: f64,
p_lo: f64,
p_hi: f64,
) -> Option<(f64, f64)> {
if !(mu.is_finite()
&& mu > 0.0
&& phi.is_finite()
&& phi > 0.0
&& power.is_finite()
&& power > 1.0
&& power < 2.0
&& total_var.is_finite()
&& total_var > 0.0)
{
return None;
}
let phi_eff = total_var / mu.powf(power);
if !(phi_eff.is_finite() && phi_eff > 0.0) {
return None;
}
let q_lo = tweedie_quantile(p_lo, mu, phi_eff, power);
let q_hi = tweedie_quantile(p_hi, mu, phi_eff, power);
if q_lo.is_finite() && q_hi.is_finite() && q_hi >= q_lo {
Some((q_lo, q_hi))
} else {
None
}
}
fn regularized_lower_gamma(a: f64, x: f64) -> f64 {
use statrs::function::gamma::ln_gamma;
if x <= 0.0 {
return 0.0;
}
let gln = ln_gamma(a);
if x < a + 1.0 {
let mut ap = a;
let mut del = 1.0 / a;
let mut sum = del;
for _ in 0..1000 {
ap += 1.0;
del *= x / ap;
sum += del;
if del.abs() <= sum.abs() * f64::EPSILON {
break;
}
}
(sum.ln() + a * x.ln() - x - gln).exp()
} else {
const FPMIN: f64 = 1e-300;
let mut b = x + 1.0 - a;
let mut c = 1.0 / FPMIN;
let mut d = 1.0 / b;
let mut h = d;
for i in 1..1000 {
let an = -(i as f64) * (i as f64 - a);
b += 2.0;
d = an * d + b;
if d.abs() < FPMIN {
d = FPMIN;
}
c = b + an / c;
if c.abs() < FPMIN {
c = FPMIN;
}
d = 1.0 / d;
let del = d * c;
h *= del;
if (del - 1.0).abs() <= f64::EPSILON {
break;
}
}
let q = (a * x.ln() - x - gln + h.ln()).exp();
1.0 - q
}
}
fn inverse_regularized_lower_gamma(p: f64, a: f64) -> f64 {
use statrs::function::gamma::ln_gamma;
if !(a.is_finite() && a > 0.0) {
return f64::NAN;
}
if !p.is_finite() || p <= 0.0 {
return 0.0;
}
if p >= 1.0 {
return f64::INFINITY;
}
let gln = ln_gamma(a);
let a1 = a - 1.0;
let mut x = if a > 1.0 {
let pp = if p < 0.5 { p } else { 1.0 - p };
let t = (-2.0 * pp.ln()).sqrt();
let mut z = (2.30753 + t * 0.27061) / (1.0 + t * (0.99229 + t * 0.04481)) - t;
if p < 0.5 {
z = -z;
}
let wh_inner = 1.0 - 1.0 / (9.0 * a) - z / (3.0 * a.sqrt());
let wh_seed = if wh_inner > 0.0 {
a * wh_inner.powi(3)
} else {
f64::NAN
};
let analytic_seed = ((p.ln() + ln_gamma(a + 1.0)) / a).exp();
if analytic_seed == 0.0 {
return 0.0;
}
if !wh_seed.is_finite() || wh_seed <= 0.0 || wh_seed < 1.0e-2 || analytic_seed < 1.0e-2 {
analytic_seed
} else {
wh_seed
}
} else {
let t = 1.0 - a * (0.253 + a * 0.12);
if p < t {
(p / t).powf(1.0 / a)
} else {
1.0 - (1.0 - (p - t) / (1.0 - t)).ln()
}
};
let (lna1, afac) = if a > 1.0 {
let lna1 = a1.ln();
(lna1, (a1 * (lna1 - 1.0) - gln).exp())
} else {
(0.0, 0.0)
};
const MAX_HALLEY_STEPS: usize = 16;
for _ in 0..MAX_HALLEY_STEPS {
if x <= 0.0 {
return 0.0;
}
let err = regularized_lower_gamma(a, x) - p;
let dens = if a > 1.0 {
afac * (-(x - a1) + a1 * (x.ln() - lna1)).exp()
} else {
(-x + a1 * x.ln() - gln).exp()
};
if !(dens.is_finite() && dens > 0.0) {
break;
}
let u = err / dens;
let step = u / (1.0 - 0.5 * (u * (a1 / x - 1.0)).min(1.0));
x -= step;
if x <= 0.0 {
x = 0.5 * (x + step);
}
if step.abs() < 1.0e-12 * x.max(1.0e-300) {
break;
}
}
x
}
#[inline]
pub fn try_inverse_link_array(
likelihood: &LikelihoodSpec,
eta: ArrayView1<'_, f64>,
) -> Result<Array1<f64>, EstimationError> {
let mut out = Array1::<f64>::zeros(eta.len());
for i in 0..eta.len() {
out[i] = inverse_link_jet_for_family_public(likelihood, eta[i])?.mu;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mixture_link::{state_from_sasspec, state_fromspec};
use crate::types::{
InverseLink, LinkComponent, MixtureLinkSpec, ResponseFamily, SasLinkSpec, StandardLink,
};
use ndarray::array;
#[test]
fn signed_log_sum_exp_propagates_positive_infinities() {
let (lm, s) = signed_log_sum_exp(&[f64::INFINITY], &[1.0]);
assert_eq!(lm, f64::INFINITY);
assert_eq!(s, 1.0);
let (lm, s) = signed_log_sum_exp(&[f64::INFINITY], &[-1.0]);
assert_eq!(lm, f64::INFINITY);
assert_eq!(s, -1.0);
let (lm, s) = signed_log_sum_exp(&[f64::INFINITY, f64::INFINITY], &[1.0, -1.0]);
assert!(lm.is_nan());
assert_eq!(s, 0.0);
let (lm, s) = signed_log_sum_exp(&[0.0, f64::INFINITY], &[1.0, 1.0]);
assert_eq!(lm, f64::INFINITY);
assert_eq!(s, 1.0);
let (lm, s) = signed_log_sum_exp(&[2.0, f64::NEG_INFINITY], &[1.0, -1.0]);
assert!((lm - 2.0).abs() < 1e-12);
assert_eq!(s, 1.0);
let (lm, s) = signed_log_sum_exp(&[3.0_f64.ln(), 1.0_f64.ln()], &[1.0, -1.0]);
assert!((lm - 2.0_f64.ln()).abs() < 1e-12);
assert_eq!(s, 1.0);
}
#[test]
fn standard_inverse_link_specs_evaluate() {
let eta = array![0.1, -0.2, 0.3];
let likelihood = LikelihoodSpec::new(
ResponseFamily::Binomial,
InverseLink::Standard(StandardLink::Logit),
);
let mu = try_inverse_link_array(&likelihood, eta.view()).expect("standard logit spec");
assert_eq!(mu.len(), eta.len());
assert!(mu.iter().all(|p| p.is_finite() && *p > 0.0 && *p < 1.0));
}
#[test]
fn sas_and_mixture_stateful_inverse_link_evaluates() {
let eta = array![0.1, -0.2, 0.3];
let sas_likelihood = LikelihoodSpec::new(
ResponseFamily::Binomial,
InverseLink::Sas(
state_from_sasspec(SasLinkSpec {
initial_epsilon: 0.2,
initial_log_delta: -0.1,
})
.expect("sas state"),
),
);
let sas = try_inverse_link_array(&sas_likelihood, eta.view()).expect("SAS with params");
assert_eq!(sas.len(), eta.len());
assert!(sas.iter().all(|p| p.is_finite() && *p > 0.0 && *p < 1.0));
let spec = MixtureLinkSpec {
components: vec![LinkComponent::Probit, LinkComponent::CLogLog],
initial_rho: array![0.3],
};
let state = state_fromspec(&spec).expect("mixture state");
let mix_likelihood =
LikelihoodSpec::new(ResponseFamily::Binomial, InverseLink::Mixture(state));
let mix = try_inverse_link_array(&mix_likelihood, eta.view()).expect("mixture with state");
assert_eq!(mix.len(), eta.len());
assert!(mix.iter().all(|p| p.is_finite() && *p > 0.0 && *p < 1.0));
}
#[test]
fn gamma_quantile_matches_known_reference_values() {
let cases: [(f64, f64, f64); 9] = [
(0.025, 4.0, 1.089_865_4),
(0.5, 4.0, 3.672_060_4),
(0.975, 4.0, 8.767_273_4),
(0.025, 1.0, 0.025_317_8), (0.975, 1.0, 3.688_879_4),
(0.5, 0.5, 0.227_468_2),
(0.99, 0.5, 3.317_448_3),
(0.025, 50.0, 37.110_963_7),
(0.975, 50.0, 64.780_598_6),
];
for (p, a, expected) in cases {
let got = gamma_quantile(p, a, 1.0);
let rel = (got - expected).abs() / expected.max(1e-12);
assert!(
rel < 1e-4,
"gamma_quantile(p={p}, a={a}) = {got}, expected ≈ {expected} (rel err {rel})"
);
}
}
#[test]
fn gamma_quantile_handles_extreme_lower_tail_for_shape_two() {
let got = gamma_quantile(1.0e-300, 2.0, 1.0);
let expected = 1.414_213_562_373_095_1e-150;
let rel = (got - expected).abs() / expected;
assert!(
rel < 1.0e-6,
"gamma_quantile(1e-300, 2, 1) = {got}, expected {expected} (rel err {rel})"
);
}
#[test]
fn gamma_quantile_round_trips_extreme_lower_tail_for_shape_above_one() {
for &a in &[1.5_f64, 2.0, 5.0, 20.0] {
for &p in &[1.0e-300_f64, 1.0e-100, 1.0e-12, 1.0e-3, 0.5, 0.999] {
let x = gamma_quantile(p, a, 1.0);
assert!(
x.is_finite() && x >= 0.0,
"non-finite quantile a={a} p={p}: {x}"
);
let recovered = regularized_lower_gamma(a, x);
let rel = (recovered - p).abs() / p;
assert!(
rel < 1.0e-6,
"round-trip failed a={a} p={p}: q={x}, P(a,q)={recovered}, rel err {rel}"
);
}
}
}
#[test]
fn gamma_quantile_is_consistent_with_the_cdf_round_trip() {
use statrs::function::gamma::gamma_lr;
for &a in &[0.3_f64, 0.75, 1.0, 2.5, 10.0, 80.0] {
for &p in &[0.001_f64, 0.01, 0.025, 0.25, 0.5, 0.75, 0.975, 0.99, 0.999] {
let x = gamma_quantile(p, a, 1.0);
assert!(
x.is_finite() && x > 0.0,
"non-finite quantile a={a} p={p}: {x}"
);
let recovered = gamma_lr(a, x);
assert!(
(recovered - p).abs() < 1e-6,
"CDF round-trip failed a={a} p={p}: P(a, {x}) = {recovered}"
);
}
}
}
#[test]
fn regularized_lower_gamma_is_accurate_and_unclamped_below_statrs_floor() {
use statrs::function::gamma::{gamma_lr, ln_gamma};
for &a in &[0.05_f64, 0.3, 1.0, 2.5, 50.0] {
for &x in &[1e-6_f64, 0.01, 0.5, 1.0, 3.0, 25.0, 120.0] {
let ours = regularized_lower_gamma(a, x);
let theirs = gamma_lr(a, x);
assert!(
(ours - theirs).abs() < 1e-12,
"P({a},{x}): ours={ours} statrs={theirs}"
);
assert!(
(0.0..=1.0).contains(&ours),
"P({a},{x})={ours} out of [0,1]"
);
}
}
for &x in &[1e-3_f64, 0.25, 2.0, 9.0] {
assert!((regularized_lower_gamma(1.0, x) - (1.0 - (-x).exp())).abs() < 1e-13);
}
for &(a, x) in &[(0.05_f64, 1e-20_f64), (0.1, 1e-25), (0.02, 1e-40)] {
assert_eq!(
gamma_lr(a, x),
0.0,
"precondition: statrs clamps P({a},{x}) to 0"
);
let ours = regularized_lower_gamma(a, x);
let leading = (a * x.ln() - ln_gamma(a + 1.0)).exp();
assert!(ours > 0.0, "P({a},{x})={ours} clamped to 0 like statrs");
assert!(
(ours - leading).abs() < 1e-9 * leading,
"P({a},{x})={ours}, leading order {leading}"
);
}
}
#[test]
fn gamma_quantile_scale_and_monotonicity() {
let q_unit = gamma_quantile(0.9, 3.0, 1.0);
let q_scaled = gamma_quantile(0.9, 3.0, 7.5);
assert!((q_scaled - 7.5 * q_unit).abs() < 1e-9 * q_scaled.max(1.0));
let mut prev = 0.0;
for i in 1..100 {
let p = i as f64 / 100.0;
let q = gamma_quantile(p, 2.0, 1.0);
assert!(q > prev, "quantile not increasing at p={p}: {q} <= {prev}");
prev = q;
}
}
#[test]
fn gamma_quantile_rejects_degenerate_parameters() {
assert!(gamma_quantile(0.5, -1.0, 1.0).is_nan());
assert!(gamma_quantile(0.5, 1.0, 0.0).is_nan());
assert!(gamma_quantile(0.5, f64::NAN, 1.0).is_nan());
assert_eq!(gamma_quantile(0.0, 2.0, 1.0), 0.0);
assert_eq!(gamma_quantile(-0.1, 2.0, 1.0), 0.0);
assert!(gamma_quantile(1.0, 2.0, 1.0).is_infinite());
}
#[test]
fn gamma_moment_matched_interval_is_the_exact_conditional_gamma_when_se_vanishes() {
let phi = 0.25_f64; let mu = 7.5_f64;
let total_var = phi * mu * mu; let (lo, hi) = gamma_moment_matched_interval(mu, total_var, 0.025, 0.975)
.expect("non-degenerate moment-matched Gamma interval");
let analytic_lo = gamma_quantile(0.025, 1.0 / phi, phi * mu);
let analytic_hi = gamma_quantile(0.975, 1.0 / phi, phi * mu);
assert!(
(lo - analytic_lo).abs() < 1e-9 * analytic_lo.max(1.0)
&& (hi - analytic_hi).abs() < 1e-9 * analytic_hi.max(1.0),
"moment-matched interval [{lo}, {hi}] != conditional Gamma \
[{analytic_lo}, {analytic_hi}]"
);
}
#[test]
fn gamma_moment_matched_interval_is_right_skewed_not_symmetric() {
let phi = 0.25_f64; let mu = 10.0_f64;
let total_var = phi * mu * mu;
let z = 1.959_963_984_540_054_f64; let (lo, hi) =
gamma_moment_matched_interval(mu, total_var, normal_cdf(-z), normal_cdf(z)).unwrap();
assert!(
0.0 < lo && lo < mu && mu < hi,
"interval [{lo}, {hi}] ∌ μ={mu}"
);
let lower_gap = mu - lo;
let upper_gap = hi - mu;
assert!(
upper_gap > 1.3 * lower_gap,
"expected a right-skewed band (upper gap ≫ lower gap), got \
lower_gap={lower_gap}, upper_gap={upper_gap}"
);
let symmetric_lower = mu * (1.0 - z * phi.sqrt());
assert!(
lo > 2.0 * symmetric_lower.max(0.0) + 1.0,
"skew-correct lower edge {lo} should sit well above the symmetric \
edge {symmetric_lower}"
);
}
#[test]
fn gamma_moment_matched_interval_widens_with_estimation_uncertainty() {
let phi = 0.25_f64;
let mu = 5.0_f64;
let obs_var = phi * mu * mu;
let (lo0, hi0) = gamma_moment_matched_interval(mu, obs_var, 0.025, 0.975).unwrap();
let (lo1, hi1) = gamma_moment_matched_interval(mu, obs_var + 4.0, 0.025, 0.975).unwrap();
assert!(
lo1 < lo0 && hi1 > hi0,
"estimation uncertainty must widen the band: [{lo0},{hi0}] -> [{lo1},{hi1}]"
);
}
#[test]
fn gamma_moment_matched_interval_rejects_degenerate_and_near_gaussian_inputs() {
assert!(gamma_moment_matched_interval(0.0, 1.0, 0.025, 0.975).is_none());
assert!(gamma_moment_matched_interval(-1.0, 1.0, 0.025, 0.975).is_none());
assert!(gamma_moment_matched_interval(1.0, 0.0, 0.025, 0.975).is_none());
assert!(gamma_moment_matched_interval(1.0, -1.0, 0.025, 0.975).is_none());
assert!(gamma_moment_matched_interval(f64::NAN, 1.0, 0.025, 0.975).is_none());
assert!(gamma_moment_matched_interval(1.0, f64::INFINITY, 0.025, 0.975).is_none());
assert!(gamma_moment_matched_interval(3.0, 2.0, 0.025, 0.975).is_some());
}
#[test]
fn beta_quantile_matches_known_reference_values() {
let cases: [(f64, f64, f64, f64); 8] = [
(0.025, 2.0, 2.0, 0.094_299_3),
(0.975, 2.0, 2.0, 0.905_700_7),
(0.5, 2.0, 2.0, 0.5),
(0.025, 0.8, 4.0, 0.002_339_1),
(0.975, 0.8, 4.0, 0.564_717_3),
(0.025, 5.0, 1.5, 0.408_549_1),
(0.5, 20.0, 80.0, 0.197_994_8),
(0.975, 20.0, 80.0, 0.283_367_6),
];
for (p, a, b, expected) in cases {
let got = beta_quantile(p, a, b);
let abs = (got - expected).abs();
assert!(
abs < 1e-5,
"beta_quantile(p={p}, a={a}, b={b}) = {got}, expected ≈ {expected} (abs err {abs})"
);
}
}
#[test]
fn beta_quantile_boundaries_and_degeneracy() {
assert_eq!(beta_quantile(0.0, 2.0, 3.0), 0.0);
assert_eq!(beta_quantile(-0.5, 2.0, 3.0), 0.0);
assert_eq!(beta_quantile(1.0, 2.0, 3.0), 1.0);
assert_eq!(beta_quantile(1.5, 2.0, 3.0), 1.0);
assert!(beta_quantile(0.5, -1.0, 3.0).is_nan());
assert!(beta_quantile(0.5, 2.0, 0.0).is_nan());
assert!(beta_quantile(0.5, f64::NAN, 3.0).is_nan());
let mut prev = 0.0;
for i in 1..100 {
let p = i as f64 / 100.0;
let q = beta_quantile(p, 3.0, 5.0);
assert!(
q > prev,
"beta quantile not increasing at p={p}: {q} <= {prev}"
);
prev = q;
}
}
#[test]
fn beta_moment_matched_interval_is_the_exact_conditional_beta_when_se_vanishes() {
let phi = 8.0_f64;
let mu = 0.2_f64;
let total_var = mu * (1.0 - mu) / (1.0 + phi); let (lo, hi) = beta_moment_matched_interval(mu, total_var, 0.025, 0.975)
.expect("non-degenerate moment-matched Beta interval");
let analytic_lo = beta_quantile(0.025, mu * phi, (1.0 - mu) * phi);
let analytic_hi = beta_quantile(0.975, mu * phi, (1.0 - mu) * phi);
assert!(
(lo - analytic_lo).abs() < 1e-9 && (hi - analytic_hi).abs() < 1e-9,
"moment-matched interval [{lo}, {hi}] != conditional Beta [{analytic_lo}, {analytic_hi}]"
);
}
#[test]
fn beta_moment_matched_interval_is_skewed_not_symmetric() {
let phi = 8.0_f64;
let mu = 0.15_f64;
let total_var = mu * (1.0 - mu) / (1.0 + phi);
let z = 1.959_963_984_540_054_f64;
let (lo, hi) =
beta_moment_matched_interval(mu, total_var, normal_cdf(-z), normal_cdf(z)).unwrap();
assert!(
0.0 < lo && lo < mu && mu < hi && hi < 1.0,
"interval [{lo},{hi}] ∌ μ={mu}"
);
let lower_gap = mu - lo;
let upper_gap = hi - mu;
assert!(
upper_gap > 1.2 * lower_gap,
"expected a right-skewed band (upper gap > lower gap): lower={lower_gap}, upper={upper_gap}"
);
let symmetric_lower = mu - z * total_var.sqrt();
assert!(
symmetric_lower < 0.0 && lo > 0.0,
"skew-correct lower edge {lo} should stay positive where the symmetric edge {symmetric_lower} goes negative"
);
}
#[test]
fn beta_moment_matched_interval_rejects_degenerate_and_over_dispersed_inputs() {
assert!(beta_moment_matched_interval(0.0, 0.01, 0.025, 0.975).is_none());
assert!(beta_moment_matched_interval(1.0, 0.01, 0.025, 0.975).is_none());
assert!(beta_moment_matched_interval(-0.1, 0.01, 0.025, 0.975).is_none());
assert!(beta_moment_matched_interval(0.3, 0.0, 0.025, 0.975).is_none());
assert!(beta_moment_matched_interval(f64::NAN, 0.01, 0.025, 0.975).is_none());
assert!(beta_moment_matched_interval(0.5, 0.25, 0.025, 0.975).is_none());
assert!(beta_moment_matched_interval(0.5, 0.30, 0.025, 0.975).is_none());
assert!(beta_moment_matched_interval(0.4, 0.02, 0.025, 0.975).is_some());
}
#[test]
fn beta_moment_matched_interval_widens_with_estimation_uncertainty() {
let phi = 8.0_f64;
let mu = 0.3_f64;
let obs_var = mu * (1.0 - mu) / (1.0 + phi);
let (lo0, hi0) = beta_moment_matched_interval(mu, obs_var, 0.025, 0.975).unwrap();
let (lo1, hi1) = beta_moment_matched_interval(mu, obs_var + 0.01, 0.025, 0.975).unwrap();
assert!(
lo1 < lo0 && hi1 > hi0,
"estimation uncertainty must widen the band: [{lo0},{hi0}] -> [{lo1},{hi1}]"
);
}
#[test]
fn negative_binomial_quantile_matches_known_reference_values() {
let cases: [(f64, f64, f64, f64); 8] = [
(0.025, 1.6, 1.5, 0.0), (0.5, 1.6, 1.5, 1.0),
(0.975, 1.6, 1.5, 6.0),
(0.99, 1.6, 1.5, 8.0),
(0.025, 20.0, 5.0, 5.0),
(0.975, 20.0, 5.0, 43.0),
(0.5, 20.0, 5.0, 19.0),
(0.975, 0.5, 2.0, 3.0),
];
for (p, mu, theta, expected) in cases {
let got = negative_binomial_quantile(p, mu, theta);
assert_eq!(
got, expected,
"negative_binomial_quantile(p={p}, μ={mu}, θ={theta}) = {got}, expected {expected}"
);
}
}
#[test]
fn negative_binomial_quantile_is_a_valid_cdf_inverse() {
use statrs::function::beta::beta_reg;
for &mu in &[0.3_f64, 1.6, 5.0, 25.0, 120.0] {
for &theta in &[0.5_f64, 1.5, 5.0, 40.0] {
let prob = theta / (theta + mu);
for &p in &[0.01_f64, 0.025, 0.1, 0.5, 0.9, 0.975, 0.99] {
let k = negative_binomial_quantile(p, mu, theta);
assert!(
k.is_finite() && k >= 0.0 && k.fract() == 0.0,
"non-integer k={k}"
);
let cdf_k = beta_reg(theta, k + 1.0, prob);
assert!(
cdf_k + 1e-12 >= p,
"CDF({k}) = {cdf_k} < p = {p} (μ={mu}, θ={theta})"
);
if k >= 1.0 {
let cdf_below = beta_reg(theta, k, prob);
assert!(
cdf_below < p,
"k={k} not minimal: CDF({}) = {cdf_below} ≥ p = {p} (μ={mu}, θ={theta})",
k - 1.0
);
}
}
}
}
}
#[test]
fn negative_binomial_quantile_boundaries_and_degeneracy() {
assert_eq!(negative_binomial_quantile(0.0, 2.0, 1.5), 0.0);
assert_eq!(negative_binomial_quantile(-0.1, 2.0, 1.5), 0.0);
assert!(negative_binomial_quantile(1.0, 2.0, 1.5).is_infinite());
assert_eq!(negative_binomial_quantile(0.5, 0.0, 1.5), 0.0); assert!(negative_binomial_quantile(0.5, -1.0, 1.5).is_nan());
assert!(negative_binomial_quantile(0.5, 2.0, 0.0).is_nan());
assert!(negative_binomial_quantile(0.5, 2.0, f64::NAN).is_nan());
let mut prev = 0.0;
for i in 1..100 {
let p = i as f64 / 100.0;
let q = negative_binomial_quantile(p, 4.0, 2.0);
assert!(q >= prev, "NB quantile decreased at p={p}: {q} < {prev}");
prev = q;
}
}
#[test]
fn negative_binomial_moment_matched_interval_is_exact_conditional_when_se_vanishes() {
let mu = 1.6_f64;
let theta = 1.5_f64;
let total_var = mu + mu * mu / theta;
let (lo, hi) =
negative_binomial_moment_matched_interval(mu, theta, total_var, 0.025, 0.975).unwrap();
assert_eq!(lo, negative_binomial_quantile(0.025, mu, theta));
assert_eq!(hi, negative_binomial_quantile(0.975, mu, theta));
}
#[test]
fn negative_binomial_moment_matched_interval_widens_with_estimation_uncertainty() {
let mu = 8.0_f64;
let theta = 4.0_f64;
let obs_var = mu + mu * mu / theta;
let (lo0, hi0) =
negative_binomial_moment_matched_interval(mu, theta, obs_var, 0.025, 0.975).unwrap();
let (lo1, hi1) =
negative_binomial_moment_matched_interval(mu, theta, obs_var + 40.0, 0.025, 0.975)
.unwrap();
assert!(
lo1 <= lo0 && hi1 > hi0,
"band did not widen: [{lo0},{hi0}] -> [{lo1},{hi1}]"
);
}
#[test]
fn negative_binomial_moment_matched_interval_rejects_degenerate_inputs() {
assert!(negative_binomial_moment_matched_interval(0.0, 1.5, 1.0, 0.025, 0.975).is_none());
assert!(negative_binomial_moment_matched_interval(-1.0, 1.5, 1.0, 0.025, 0.975).is_none());
assert!(negative_binomial_moment_matched_interval(2.0, 0.0, 1.0, 0.025, 0.975).is_none());
assert!(negative_binomial_moment_matched_interval(2.0, 1.5, 0.0, 0.025, 0.975).is_none());
assert!(
negative_binomial_moment_matched_interval(f64::NAN, 1.5, 1.0, 0.025, 0.975).is_none()
);
assert!(negative_binomial_moment_matched_interval(2.0, 1.5, 6.0, 0.025, 0.975).is_some());
}
#[test]
fn poisson_quantile_matches_known_reference_values() {
let cases: [(f64, f64, f64); 9] = [
(0.025, 1.6, 0.0), (0.5, 1.6, 1.0),
(0.975, 1.6, 4.0),
(0.99, 1.6, 5.0),
(0.025, 20.0, 12.0),
(0.975, 20.0, 29.0),
(0.5, 20.0, 20.0),
(0.975, 0.5, 2.0),
(0.025, 0.5, 0.0),
];
for (p, mu, expected) in cases {
let got = poisson_quantile(p, mu);
assert_eq!(
got, expected,
"poisson_quantile(p={p}, μ={mu}) = {got}, expected {expected}"
);
}
}
#[test]
fn poisson_quantile_is_a_valid_cdf_inverse() {
for &mu in &[0.3_f64, 1.6, 5.0, 25.0, 120.0] {
for &p in &[0.01_f64, 0.025, 0.1, 0.5, 0.9, 0.975, 0.99] {
let k = poisson_quantile(p, mu);
assert!(
k.is_finite() && k >= 0.0 && k.fract() == 0.0,
"non-integer k={k}"
);
let cdf_k = poisson_cdf_at(k, mu);
assert!(cdf_k + 1e-12 >= p, "CDF({k}) = {cdf_k} < p = {p} (μ={mu})");
if k >= 1.0 {
let cdf_below = poisson_cdf_at(k - 1.0, mu);
assert!(
cdf_below < p,
"k={k} not minimal: CDF({}) = {cdf_below} ≥ p = {p} (μ={mu})",
k - 1.0
);
}
}
}
}
#[test]
fn poisson_quantile_boundaries_and_degeneracy() {
assert_eq!(poisson_quantile(0.0, 2.0), 0.0);
assert_eq!(poisson_quantile(-0.1, 2.0), 0.0);
assert!(poisson_quantile(1.0, 2.0).is_infinite());
assert_eq!(poisson_quantile(0.5, 0.0), 0.0); assert!(poisson_quantile(0.5, -1.0).is_nan());
assert!(poisson_quantile(0.5, f64::NAN).is_nan());
let mut prev = 0.0;
for i in 1..100 {
let p = i as f64 / 100.0;
let q = poisson_quantile(p, 4.0);
assert!(
q >= prev,
"Poisson quantile decreased at p={p}: {q} < {prev}"
);
prev = q;
}
}
#[test]
fn poisson_moment_matched_interval_is_exact_conditional_when_se_vanishes() {
for &mu in &[0.5_f64, 1.6, 20.0] {
let (lo, hi) = poisson_moment_matched_interval(mu, mu, 0.025, 0.975).unwrap();
assert_eq!(lo, poisson_quantile(0.025, mu));
assert_eq!(hi, poisson_quantile(0.975, mu));
}
}
#[test]
fn poisson_moment_matched_interval_widens_with_estimation_uncertainty() {
let mu = 20.0_f64;
let (lo0, hi0) = poisson_moment_matched_interval(mu, mu, 0.025, 0.975).unwrap();
let (lo1, hi1) = poisson_moment_matched_interval(mu, mu + 40.0, 0.025, 0.975).unwrap();
assert!(
lo1 <= lo0 && hi1 > hi0,
"band did not widen: [{lo0},{hi0}] -> [{lo1},{hi1}]"
);
let (lo2, hi2) =
poisson_moment_matched_interval(mu, mu + mu * mu * 1.0e-12, 0.025, 0.975).unwrap();
assert_eq!((lo2, hi2), (lo0, hi0));
}
#[test]
fn poisson_moment_matched_interval_is_skewed_not_symmetric() {
let mu = 2.0_f64;
let z = standard_normal_quantile(0.975).unwrap();
let (lo, hi) = poisson_moment_matched_interval(mu, mu, 0.025, 0.975).unwrap();
let sym_hi = mu + z * mu.sqrt();
assert!(
hi > sym_hi,
"equal-tailed upper {hi} should exceed symmetric upper {sym_hi}"
);
assert!(
(hi - mu) > (mu - lo),
"band not right-skewed: lo={lo}, hi={hi}, μ={mu}"
);
}
#[test]
fn poisson_moment_matched_interval_rejects_degenerate_inputs() {
assert!(poisson_moment_matched_interval(0.0, 1.0, 0.025, 0.975).is_none());
assert!(poisson_moment_matched_interval(-1.0, 1.0, 0.025, 0.975).is_none());
assert!(poisson_moment_matched_interval(2.0, 0.0, 0.025, 0.975).is_none());
assert!(poisson_moment_matched_interval(2.0, 1.0, 0.025, 0.975).is_none()); assert!(poisson_moment_matched_interval(f64::NAN, 5.0, 0.025, 0.975).is_none());
assert!(poisson_moment_matched_interval(2.0, 5.0, 0.025, 0.975).is_some());
}
#[test]
fn tweedie_quantile_is_a_valid_cdf_inverse() {
let mu = 3.0_f64;
let phi = 1.2_f64;
let power = 1.5_f64;
let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
let zero_mass = (-lambda).exp();
for &q in &[0.30_f64, 0.5, 0.75, 0.9, 0.975, 0.99] {
assert!(
q > zero_mass,
"test q must exceed the zero atom {zero_mass}"
);
let y = tweedie_quantile(q, mu, phi, power);
assert!(y.is_finite() && y > 0.0, "quantile out of support: {y}");
let cdf = tweedie_cdf_at(y, mu, phi, power);
assert!((cdf - q).abs() < 1e-6, "CDF(Q(q)) != q: q={q}, cdf={cdf}");
}
}
#[test]
fn tweedie_quantile_returns_zero_atom_for_low_tail() {
let mu = 0.4_f64; let phi = 1.0_f64;
let power = 1.5_f64;
let lambda = mu.powf(2.0 - power) / (phi * (2.0 - power));
let zero_mass = (-lambda).exp();
assert!(
zero_mass > 0.025,
"fixture must have a fat zero atom: {zero_mass}"
);
assert_eq!(tweedie_quantile(0.025, mu, phi, power), 0.0);
assert_eq!(tweedie_quantile(0.5 * zero_mass, mu, phi, power), 0.0);
}
#[test]
fn tweedie_quantile_boundaries_and_degeneracy() {
let (mu, phi, power) = (2.0_f64, 1.0_f64, 1.6_f64);
assert_eq!(tweedie_quantile(0.0, mu, phi, power), 0.0);
assert_eq!(tweedie_quantile(-0.1, mu, phi, power), 0.0);
assert_eq!(tweedie_quantile(1.0, mu, phi, power), f64::INFINITY);
assert!(tweedie_quantile(0.5, mu, phi, 2.0).is_nan());
assert!(tweedie_quantile(0.5, mu, phi, 1.0).is_nan());
assert!(tweedie_quantile(0.5, 0.0, phi, power).is_nan());
assert!(tweedie_quantile(0.5, mu, 0.0, power).is_nan());
}
#[test]
fn tweedie_moment_matched_interval_is_exact_conditional_when_se_vanishes() {
let mu = 3.0_f64;
let phi = 1.2_f64;
let power = 1.5_f64;
let total_var = phi * mu.powf(power);
let (lo, hi) =
tweedie_moment_matched_interval(mu, phi, power, total_var, 0.025, 0.975).unwrap();
assert_eq!(lo, tweedie_quantile(0.025, mu, phi, power));
assert_eq!(hi, tweedie_quantile(0.975, mu, phi, power));
}
#[test]
fn tweedie_moment_matched_interval_is_skewed_not_symmetric() {
let mu = 2.0_f64;
let phi = 1.5_f64;
let power = 1.5_f64;
let total_var = phi * mu.powf(power);
let (lo, hi) =
tweedie_moment_matched_interval(mu, phi, power, total_var, 0.025, 0.975).unwrap();
assert!(lo >= 0.0 && hi > mu && lo < mu);
assert!(
hi - mu > mu - lo,
"interval is not right-skewed: lo={lo}, hi={hi}"
);
}
#[test]
fn tweedie_moment_matched_interval_widens_with_estimation_uncertainty() {
let mu = 4.0_f64;
let phi = 1.0_f64;
let power = 1.5_f64;
let obs_var = phi * mu.powf(power);
let (lo0, hi0) =
tweedie_moment_matched_interval(mu, phi, power, obs_var, 0.025, 0.975).unwrap();
let (lo1, hi1) =
tweedie_moment_matched_interval(mu, phi, power, obs_var + 30.0, 0.025, 0.975).unwrap();
assert!(
lo1 <= lo0 && hi1 > hi0,
"band did not widen: [{lo0},{hi0}] -> [{lo1},{hi1}]"
);
}
#[test]
fn tweedie_moment_matched_interval_rejects_degenerate_inputs() {
assert!(tweedie_moment_matched_interval(0.0, 1.0, 1.5, 1.0, 0.025, 0.975).is_none());
assert!(tweedie_moment_matched_interval(-1.0, 1.0, 1.5, 1.0, 0.025, 0.975).is_none());
assert!(tweedie_moment_matched_interval(2.0, 0.0, 1.5, 1.0, 0.025, 0.975).is_none());
assert!(tweedie_moment_matched_interval(2.0, 1.0, 2.0, 1.0, 0.025, 0.975).is_none());
assert!(tweedie_moment_matched_interval(2.0, 1.0, 1.5, 0.0, 0.025, 0.975).is_none());
assert!(tweedie_moment_matched_interval(f64::NAN, 1.0, 1.5, 1.0, 0.025, 0.975).is_none());
assert!(tweedie_moment_matched_interval(2.0, 1.0, 1.5, 6.0, 0.025, 0.975).is_some());
}
}