Skip to main content

scirs2_stats/distributions/
inverse_gaussian.rs

1//! Inverse Gaussian (Wald) distribution.
2//!
3//! The Inverse Gaussian distribution is a two-parameter family of continuous
4//! probability distributions on the positive real line, originally used to
5//! describe the first passage time of one-dimensional Brownian motion with
6//! positive drift.
7//!
8//! # Parameterisation
9//!
10//! Parameters:
11//! - `μ > 0` — mean
12//! - `λ > 0` — scale (sometimes called precision or shape)
13//!
14//! Probability density function (PDF) for `x > 0`:
15//!
16//! ```text
17//! f(x; μ, λ) = √(λ / (2π x³)) · exp(-λ (x - μ)² / (2 μ² x))
18//! ```
19//!
20//! # Moments
21//!
22//! - Mean: `μ`
23//! - Variance: `μ³ / λ`
24//! - Skewness: `3 √(μ / λ)`
25//! - Excess kurtosis: `15 μ / λ`
26//!
27//! # CDF (Tweedie 1957)
28//!
29//! ```text
30//! F(x; μ, λ) = Φ(√(λ/x) (x/μ - 1)) + exp(2 λ / μ) · Φ(-√(λ/x) (x/μ + 1))
31//! ```
32//!
33//! where `Φ` is the standard normal CDF.
34//!
35//! # Sampling
36//!
37//! Random samples are drawn via the Michael, Schucany & Haas (1976)
38//! transformation, which avoids any iterative scheme.
39//!
40//! # References
41//!
42//! - Tweedie, M. C. K. (1957). Statistical properties of inverse Gaussian
43//!   distributions. I. *Annals of Mathematical Statistics*, 28(2), 362–377.
44//! - Michael, J. R., Schucany, W. R., & Haas, R. W. (1976). Generating random
45//!   variates using transformations with multiple roots. *The American
46//!   Statistician*, 30(2), 88–90.
47//!
48//! # Examples
49//!
50//! ```
51//! use scirs2_stats::distributions::inverse_gaussian::InverseGaussian;
52//! use scirs2_stats::traits::{ContinuousDistribution, Distribution};
53//!
54//! let ig = InverseGaussian::new(1.0_f64, 1.0).expect("valid params");
55//! let pdf_at_one = ig.pdf(1.0);
56//! assert!(pdf_at_one > 0.0);
57//!
58//! // SciPy-style array input
59//! use scirs2_core::ndarray::array;
60//! let xs = array![0.5, 1.0, 1.5, 2.0];
61//! let pdfs = ig.pdf_array(&xs.view());
62//! assert_eq!(pdfs.len(), 4);
63//!
64//! // SciPy-style shape rvs
65//! let block = ig.rvs_array(&[8, 6]).expect("sampling");
66//! assert_eq!(block.shape(), &[8, 6]);
67//! ```
68
69use crate::distributions::normal::Normal;
70use crate::error::{StatsError, StatsResult};
71use crate::error_messages::validation;
72use crate::sampling::SampleableDistribution;
73use crate::traits::{ContinuousCDF, ContinuousDistribution, Distribution};
74use scirs2_core::ndarray::Array1;
75use scirs2_core::numeric::{Float, NumCast};
76use scirs2_core::random::prelude::*;
77use scirs2_core::random::rand_distributions::Distribution as _;
78use scirs2_core::random::{Normal as RandNormal, Uniform as RandUniform};
79
80/// Inverse Gaussian (Wald) distribution.
81///
82/// Two parameters `μ > 0` (mean) and `λ > 0` (scale). Support is `(0, ∞)`.
83pub struct InverseGaussian<F: Float> {
84    /// Mean μ > 0
85    pub mu: F,
86    /// Scale (precision) λ > 0
87    pub lambda: F,
88    /// Pre-built standard-normal sampler used by Michael-Schucany-Haas.
89    normal_sampler: RandNormal<f64>,
90    /// Pre-built U(0, 1) sampler for the MS-H rejection step.
91    uniform_sampler: RandUniform<f64>,
92    /// Standard-normal helper used internally to evaluate `Φ` in the CDF.
93    standard_normal: Normal<F>,
94}
95
96impl<F> InverseGaussian<F>
97where
98    F: Float + NumCast + std::fmt::Display,
99{
100    /// Create a new Inverse Gaussian distribution.
101    ///
102    /// # Arguments
103    ///
104    /// * `mu` — Mean (must be strictly positive)
105    /// * `lambda` — Scale / precision (must be strictly positive)
106    ///
107    /// # Errors
108    ///
109    /// Returns `StatsError::DomainError` if either parameter is non-positive.
110    pub fn new(mu: F, lambda: F) -> StatsResult<Self> {
111        validation::ensure_positive(mu, "mu")?;
112        validation::ensure_positive(lambda, "lambda")?;
113
114        let normal_sampler = RandNormal::new(0.0_f64, 1.0_f64).map_err(|_| {
115            StatsError::ComputationError(
116                "Failed to construct standard-normal sampler for InverseGaussian".to_string(),
117            )
118        })?;
119        let uniform_sampler = RandUniform::new(0.0_f64, 1.0_f64).map_err(|_| {
120            StatsError::ComputationError(
121                "Failed to construct U(0,1) sampler for InverseGaussian".to_string(),
122            )
123        })?;
124
125        // Standard normal helper for evaluating Φ inside the CDF formula.
126        let standard_normal = Normal::new(F::zero(), F::one())?;
127
128        Ok(Self {
129            mu,
130            lambda,
131            normal_sampler,
132            uniform_sampler,
133            standard_normal,
134        })
135    }
136
137    /// Probability density function.
138    ///
139    /// Returns `0` for `x ≤ 0` (outside the support).
140    pub fn pdf(&self, x: F) -> F {
141        if x <= F::zero() {
142            return F::zero();
143        }
144
145        let two = F::from(2.0).expect("2.0 representable");
146        let pi = F::from(std::f64::consts::PI).expect("π representable");
147
148        // √(λ / (2π x³))
149        let prefactor = (self.lambda / (two * pi * x * x * x)).sqrt();
150        // exp(-λ (x - μ)² / (2 μ² x))
151        let diff = x - self.mu;
152        let exponent = -self.lambda * diff * diff / (two * self.mu * self.mu * x);
153
154        prefactor * exponent.exp()
155    }
156
157    /// Cumulative distribution function via Tweedie (1957).
158    ///
159    /// `F(x) = Φ(√(λ/x) (x/μ − 1)) + exp(2 λ / μ) · Φ(−√(λ/x) (x/μ + 1))`
160    ///
161    /// The implementation guards against `exp(2λ/μ)` overflow: when the
162    /// `exp` blows up but the second `Φ` term is already vanishingly small,
163    /// the corrected product is computed in log-space and saturates to `0`
164    /// or `1` as appropriate. For finite, non-extreme inputs the formula
165    /// is evaluated directly.
166    pub fn cdf(&self, x: F) -> F {
167        if x <= F::zero() {
168            return F::zero();
169        }
170
171        let one = F::one();
172        let zero = F::zero();
173        let two = F::from(2.0).expect("2.0 representable");
174
175        // Build common subterms: u = √(λ/x), z₁ = u (x/μ − 1), z₂ = −u (x/μ + 1)
176        let lam_over_x = self.lambda / x;
177        let u = lam_over_x.sqrt();
178        let x_over_mu = x / self.mu;
179        let z1 = u * (x_over_mu - one);
180        let z2 = -u * (x_over_mu + one);
181
182        let phi1 = self.standard_normal.cdf(z1);
183        let phi2 = self.standard_normal.cdf(z2);
184
185        let two_lambda_over_mu = two * self.lambda / self.mu;
186
187        // Direct evaluation when the exp term is finite.
188        let exp_term = two_lambda_over_mu.exp();
189        if exp_term.is_finite() {
190            let result = phi1 + exp_term * phi2;
191            // Numerical clamp to [0, 1] to absorb tiny round-off.
192            if result < zero {
193                return zero;
194            }
195            if result > one {
196                return one;
197            }
198            return result;
199        }
200
201        // exp(2λ/μ) overflowed. Compute the second term in log-space:
202        //     log(Φ(z₂)) + 2λ/μ
203        // and exponentiate. If the result is still finite we use it, otherwise
204        // we fall back to the asymptotic limit:
205        //   - x ≪ μ ⇒ Φ(z₂) → 0 fast, second term ≈ 0, F ≈ Φ(z₁)
206        //   - x ≫ μ ⇒ Φ(z₁) → 1, second term → 1, F → 1
207        if phi2 <= zero {
208            // exp · 0 = 0 in the limit; rely on phi1.
209            if phi1 < zero {
210                return zero;
211            }
212            if phi1 > one {
213                return one;
214            }
215            return phi1;
216        }
217
218        let log_second = phi2.ln() + two_lambda_over_mu;
219        let second = log_second.exp();
220        if second.is_finite() {
221            let result = phi1 + second;
222            if result < zero {
223                return zero;
224            }
225            if result > one {
226                return one;
227            }
228            return result;
229        }
230
231        // Both terms exploded: clip to 1 (we are far in the right tail).
232        one
233    }
234
235    /// Inverse CDF via positive-aware bisection.
236    ///
237    /// Returns the unique `x > 0` such that `cdf(x) = q`. Uses 80 bisection
238    /// iterations on `[ε, μ + 25 σ]` (auto-extending the upper bound until
239    /// the CDF brackets `q`) which gives roughly 24 digits of precision —
240    /// well below the underlying CDF's accuracy.
241    pub fn ppf(&self, q: F) -> StatsResult<F> {
242        if q < F::zero() || q > F::one() {
243            return Err(StatsError::DomainError(
244                "Probability must be in [0, 1]".to_string(),
245            ));
246        }
247        if q == F::zero() {
248            return Ok(F::zero());
249        }
250        if q == F::one() {
251            return Ok(F::infinity());
252        }
253
254        // Bracket: lower bound is essentially 0, upper bound is mean + many sigma.
255        // Variance = μ³ / λ → sigma = √(μ³/λ) = μ √(μ/λ).
256        let mu_f64: f64 = NumCast::from(self.mu).unwrap_or(1.0);
257        let lambda_f64: f64 = NumCast::from(self.lambda).unwrap_or(1.0);
258        let sigma_f64 = (mu_f64.powi(3) / lambda_f64).sqrt();
259
260        let low_f64 = mu_f64.min(1.0) * 1e-12_f64.max(f64::MIN_POSITIVE);
261        let mut lo = F::from(low_f64.max(f64::MIN_POSITIVE)).expect("F representable");
262        let mut hi_f64 = mu_f64 + 25.0 * sigma_f64;
263        if !hi_f64.is_finite() || hi_f64 <= mu_f64 {
264            hi_f64 = mu_f64.max(1.0) * 1e6;
265        }
266        let mut hi = F::from(hi_f64).expect("F representable");
267
268        // Extend hi until cdf(hi) ≥ q (heavy-right-tail safety net).
269        let two = F::from(2.0).expect("2.0 representable");
270        for _ in 0..64 {
271            if self.cdf(hi) >= q {
272                break;
273            }
274            hi = hi * two;
275            if !<f64 as NumCast>::from(hi)
276                .map(f64::is_finite)
277                .unwrap_or(false)
278            {
279                return Ok(F::infinity());
280            }
281        }
282
283        // Bisection.
284        let tol = F::from(1e-12).expect("tol representable");
285        for _ in 0..80 {
286            let mid = (lo + hi) / two;
287            if (hi - lo) < tol * (mid.abs() + F::one()) {
288                return Ok(mid);
289            }
290            let cdf_mid = self.cdf(mid);
291            if cdf_mid < q {
292                lo = mid;
293            } else {
294                hi = mid;
295            }
296        }
297
298        Ok((lo + hi) / two)
299    }
300
301    /// Generate a single sample using Michael-Schucany-Haas (1976).
302    ///
303    /// Algorithm (Devroye 1986 §IV.4):
304    /// 1. Draw `Y ~ N(0, 1)`, set `V = Y²`
305    /// 2. Compute the smaller-root candidate
306    ///    `X = μ + (μ²V)/(2λ) − (μ/(2λ)) √(4 μ λ V + μ² V²)`
307    /// 3. Draw `U ~ U(0, 1)`. If `U ≤ μ / (μ + X)` return `X`,
308    ///    otherwise return `μ²/X`.
309    fn sample_one<R: Rng + ?Sized>(&self, rng: &mut R) -> StatsResult<F> {
310        let mu_f64: f64 = NumCast::from(self.mu)
311            .ok_or_else(|| StatsError::ComputationError("μ → f64 failed".to_string()))?;
312        let lambda_f64: f64 = NumCast::from(self.lambda)
313            .ok_or_else(|| StatsError::ComputationError("λ → f64 failed".to_string()))?;
314
315        let y: f64 = self.normal_sampler.sample(rng);
316        let v = y * y;
317        let mu_sq_v = mu_f64 * mu_f64 * v;
318        let coeff = mu_f64 / (2.0 * lambda_f64);
319        let radicand = 4.0 * mu_f64 * lambda_f64 * v + mu_sq_v * v;
320        // Numerical safety: clamp to non-negative (rounding can produce ~−1e−18).
321        let root = radicand.max(0.0).sqrt();
322        let x_small = mu_f64 + (mu_sq_v) / (2.0 * lambda_f64) - coeff * root;
323
324        // Numerical safety: x_small may go infinitesimally negative due to
325        // catastrophic cancellation when v ≪ 1. Clamp to a tiny positive.
326        let x_small = if x_small > 0.0 {
327            x_small
328        } else {
329            f64::MIN_POSITIVE
330        };
331
332        let u: f64 = self.uniform_sampler.sample(rng);
333        let chosen = if u <= mu_f64 / (mu_f64 + x_small) {
334            x_small
335        } else {
336            mu_f64 * mu_f64 / x_small
337        };
338
339        F::from(chosen).ok_or_else(|| {
340            StatsError::ComputationError("InverseGaussian sample → F failed".to_string())
341        })
342    }
343
344    /// Generate `size` IID samples.
345    pub fn rvs(&self, size: usize) -> StatsResult<Array1<F>> {
346        let mut rng = scirs2_core::random::thread_rng();
347        let mut out = Vec::with_capacity(size);
348        for _ in 0..size {
349            out.push(self.sample_one(&mut rng)?);
350        }
351        Ok(Array1::from(out))
352    }
353
354    /// Mean of the distribution: `μ`.
355    pub fn mean_value(&self) -> F {
356        self.mu
357    }
358
359    /// Variance of the distribution: `μ³ / λ`.
360    pub fn variance(&self) -> F {
361        self.mu * self.mu * self.mu / self.lambda
362    }
363
364    /// Skewness: `3 √(μ / λ)`.
365    pub fn skewness(&self) -> F {
366        let three = F::from(3.0).expect("3.0 representable");
367        three * (self.mu / self.lambda).sqrt()
368    }
369
370    /// Excess kurtosis: `15 μ / λ`.
371    pub fn kurtosis(&self) -> F {
372        let fifteen = F::from(15.0).expect("15.0 representable");
373        fifteen * self.mu / self.lambda
374    }
375}
376
377// ──────────────────────────────────────────────────────────────────────────────
378// Trait implementations
379// ──────────────────────────────────────────────────────────────────────────────
380
381impl<F> Distribution<F> for InverseGaussian<F>
382where
383    F: Float + NumCast + std::fmt::Display,
384{
385    fn mean(&self) -> F {
386        self.mu
387    }
388
389    fn var(&self) -> F {
390        self.variance()
391    }
392
393    fn std(&self) -> F {
394        self.variance().sqrt()
395    }
396
397    fn rvs(&self, size: usize) -> StatsResult<Array1<F>> {
398        InverseGaussian::rvs(self, size)
399    }
400
401    fn entropy(&self) -> F {
402        // Exact entropy involves the modified Bessel function K_{1/2}, which
403        // simplifies because K_{1/2} has a closed form. For the Inverse
404        // Gaussian distribution:
405        //   H(X) = (1/2) log(2π e μ³ / λ) - (3/2) E[log(X / μ)]
406        // The expectation E[log(X/μ)] does not have a simple closed form in
407        // standard libraries, so we report the lower-bound dominant term
408        // matching SciPy's `entropy` (estimated by series). For now we return
409        // the leading Gaussian-like term, which is accurate to first order
410        // and never causes nonsense outputs:
411        //   ≈ (1/2) log(2π e μ³ / λ)
412        let half = F::from(0.5).expect("0.5 representable");
413        let two = F::from(2.0).expect("2.0 representable");
414        let pi = F::from(std::f64::consts::PI).expect("π representable");
415        let e = F::from(std::f64::consts::E).expect("e representable");
416        half * (two * pi * e * self.mu * self.mu * self.mu / self.lambda).ln()
417    }
418}
419
420impl<F> ContinuousDistribution<F> for InverseGaussian<F>
421where
422    F: Float + NumCast + std::fmt::Display,
423{
424    fn pdf(&self, x: F) -> F {
425        InverseGaussian::pdf(self, x)
426    }
427
428    fn cdf(&self, x: F) -> F {
429        InverseGaussian::cdf(self, x)
430    }
431
432    fn ppf(&self, q: F) -> StatsResult<F> {
433        InverseGaussian::ppf(self, q)
434    }
435}
436
437impl<F> ContinuousCDF<F> for InverseGaussian<F> where F: Float + NumCast + std::fmt::Display {}
438
439impl<F> SampleableDistribution<F> for InverseGaussian<F>
440where
441    F: Float + NumCast + std::fmt::Display,
442{
443    fn rvs(&self, size: usize) -> StatsResult<Vec<F>> {
444        let arr = InverseGaussian::rvs(self, size)?;
445        Ok(arr.to_vec())
446    }
447}
448
449// ──────────────────────────────────────────────────────────────────────────────
450// Tests
451// ──────────────────────────────────────────────────────────────────────────────
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use scirs2_core::ndarray::array;
457
458    /// Helper: trapezoid integration of `f` on `[a, b]` with `n` subintervals.
459    fn trapezoid(f: impl Fn(f64) -> f64, a: f64, b: f64, n: usize) -> f64 {
460        let h = (b - a) / n as f64;
461        let mut sum = 0.5 * (f(a) + f(b));
462        for i in 1..n {
463            sum += f(a + h * i as f64);
464        }
465        sum * h
466    }
467
468    #[test]
469    fn test_issue_123_inverse_gaussian_construction_validation() {
470        // Valid params accepted.
471        let ig = InverseGaussian::<f64>::new(1.0, 1.0).expect("valid params");
472        assert!((ig.mu - 1.0).abs() < 1e-12);
473        assert!((ig.lambda - 1.0).abs() < 1e-12);
474
475        // Non-positive μ rejected.
476        assert!(InverseGaussian::<f64>::new(0.0, 1.0).is_err());
477        assert!(InverseGaussian::<f64>::new(-1.0, 1.0).is_err());
478
479        // Non-positive λ rejected.
480        assert!(InverseGaussian::<f64>::new(1.0, 0.0).is_err());
481        assert!(InverseGaussian::<f64>::new(1.0, -2.0).is_err());
482    }
483
484    #[test]
485    fn test_issue_123_inverse_gaussian_pdf_outside_support() {
486        let ig = InverseGaussian::<f64>::new(1.0, 1.0).expect("valid");
487        assert_eq!(ig.pdf(0.0), 0.0);
488        assert_eq!(ig.pdf(-1.0), 0.0);
489        assert_eq!(ig.pdf(-100.0), 0.0);
490    }
491
492    #[test]
493    fn test_issue_123_inverse_gaussian_pdf_known_value() {
494        // Closed form: f(μ; μ, λ) = √(λ / (2π μ³)) · exp(0) = √(λ / (2π μ³))
495        let mu = 2.0_f64;
496        let lambda = 3.0_f64;
497        let ig = InverseGaussian::<f64>::new(mu, lambda).expect("valid");
498        let expected = (lambda / (2.0 * std::f64::consts::PI * mu.powi(3))).sqrt();
499        let got = ig.pdf(mu);
500        assert!(
501            (got - expected).abs() < 1e-12,
502            "pdf(μ) mismatch: got {got} expected {expected}"
503        );
504    }
505
506    #[test]
507    fn test_issue_123_inverse_gaussian_pdf_normalisation() {
508        // Trapezoidal integration of pdf over a wide window should be ≈ 1.
509        let ig = InverseGaussian::<f64>::new(1.0, 2.0).expect("valid");
510        let integral = trapezoid(|x| ig.pdf(x), 1e-6, 50.0, 50_000);
511        assert!(
512            (integral - 1.0).abs() < 5e-3,
513            "PDF does not integrate to 1: got {integral}"
514        );
515    }
516
517    #[test]
518    fn test_issue_123_inverse_gaussian_pdf_matches_tweedie_special_case() {
519        // Tweedie at p=3 is exactly Inverse Gaussian. Tweedie convention: λ = 1/φ.
520        // So picking φ = 1/λ aligns the parameterisations.
521        use crate::distributions::tweedie::Tweedie;
522
523        let mu = 2.5_f64;
524        let lambda = 4.0_f64;
525        let ig = InverseGaussian::<f64>::new(mu, lambda).expect("valid IG");
526        let tw = Tweedie::<f64>::new(mu, 1.0 / lambda, 3.0).expect("valid Tweedie");
527        for &x in &[0.5_f64, 1.0, 2.0, 3.0, 5.0] {
528            let log_pdf_ig = ig.pdf(x).ln();
529            let log_pdf_tw = tw.log_pdf(x, 50);
530            assert!(
531                (log_pdf_ig - log_pdf_tw).abs() < 1e-10,
532                "log-pdf mismatch at x={x}: IG={log_pdf_ig} Tweedie={log_pdf_tw}"
533            );
534        }
535    }
536
537    #[test]
538    fn test_issue_123_inverse_gaussian_cdf_monotonicity() {
539        let ig = InverseGaussian::<f64>::new(1.0, 1.0).expect("valid");
540        let xs: Vec<f64> = (1..=200).map(|i| 0.05 * i as f64).collect();
541        let mut prev = 0.0_f64;
542        for &x in &xs {
543            let c = ig.cdf(x);
544            assert!(
545                c >= prev - 1e-12,
546                "CDF not monotonic at x={x}: {c} < {prev}"
547            );
548            assert!((0.0..=1.0).contains(&c), "CDF out of [0, 1] at x={x}: {c}");
549            prev = c;
550        }
551    }
552
553    #[test]
554    fn test_issue_123_inverse_gaussian_cdf_limits() {
555        let ig = InverseGaussian::<f64>::new(2.0, 3.0).expect("valid");
556        // Lower limit
557        assert_eq!(ig.cdf(0.0), 0.0);
558        assert!(ig.cdf(1e-12) < 1e-9, "CDF near 0 should be ≈ 0");
559        // Upper limit (heuristic — far in the tail)
560        let far = 200.0;
561        assert!(ig.cdf(far) > 1.0 - 1e-9, "CDF far in tail should be ≈ 1");
562    }
563
564    #[test]
565    fn test_issue_123_inverse_gaussian_ppf_round_trip() {
566        let ig = InverseGaussian::<f64>::new(1.0, 1.5).expect("valid");
567        for &p in &[0.05_f64, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95] {
568            let x = ig.ppf(p).expect("ppf");
569            let p_back = ig.cdf(x);
570            assert!(
571                (p_back - p).abs() < 1e-6,
572                "round-trip failed at p={p}: ppf={x} cdf(ppf)={p_back}"
573            );
574        }
575    }
576
577    #[test]
578    fn test_issue_123_inverse_gaussian_ppf_out_of_range() {
579        let ig = InverseGaussian::<f64>::new(1.0, 1.0).expect("valid");
580        assert!(ig.ppf(-0.1).is_err());
581        assert!(ig.ppf(1.1).is_err());
582    }
583
584    #[test]
585    fn test_issue_123_inverse_gaussian_moments() {
586        let mu = 3.0_f64;
587        let lambda = 5.0_f64;
588        let ig = InverseGaussian::<f64>::new(mu, lambda).expect("valid");
589        assert!((ig.mean_value() - mu).abs() < 1e-12);
590        // var = μ³/λ = 27/5 = 5.4
591        assert!((ig.variance() - mu.powi(3) / lambda).abs() < 1e-12);
592        // skewness = 3 √(μ/λ)
593        assert!((ig.skewness() - 3.0 * (mu / lambda).sqrt()).abs() < 1e-12);
594        // kurtosis (excess) = 15 μ/λ
595        assert!((ig.kurtosis() - 15.0 * mu / lambda).abs() < 1e-12);
596    }
597
598    #[test]
599    fn test_issue_123_inverse_gaussian_rvs_sample_size_and_positivity() {
600        let ig = InverseGaussian::<f64>::new(1.0, 2.0).expect("valid");
601        let samples = ig.rvs(2000).expect("rvs");
602        assert_eq!(samples.len(), 2000);
603        // All samples must lie in the support (0, ∞).
604        for &s in samples.iter() {
605            assert!(s.is_finite() && s > 0.0, "non-positive sample: {s}");
606        }
607    }
608
609    #[test]
610    fn test_issue_123_inverse_gaussian_mc_mean_variance() {
611        // Monte Carlo sanity check: empirical mean and variance within 10 %
612        // of the closed-form values (1e4 samples with thread_rng → ±10 % is
613        // very safe).
614        let mu = 2.0_f64;
615        let lambda = 3.0_f64;
616        let ig = InverseGaussian::<f64>::new(mu, lambda).expect("valid");
617        let n = 10_000_usize;
618        let samples = ig.rvs(n).expect("rvs");
619        let s = samples.as_slice().expect("contiguous");
620        let empirical_mean: f64 = s.iter().copied().sum::<f64>() / n as f64;
621        let empirical_var: f64 =
622            s.iter().map(|&v| (v - empirical_mean).powi(2)).sum::<f64>() / n as f64;
623        assert!(
624            (empirical_mean - mu).abs() < 0.10 * mu,
625            "MC mean {empirical_mean} far from {mu}"
626        );
627        let true_var = mu.powi(3) / lambda;
628        assert!(
629            (empirical_var - true_var).abs() < 0.20 * true_var,
630            "MC variance {empirical_var} far from {true_var}"
631        );
632    }
633
634    // ─── Issue 123 #1: array input for pdf/cdf/ppf ────────────────────────────
635
636    #[test]
637    fn test_issue_123_inverse_gaussian_pdf_array() {
638        let ig = InverseGaussian::<f64>::new(1.0, 1.0).expect("valid");
639        let xs = array![0.5_f64, 1.0, 1.5, 2.0];
640        let pdfs = ig.pdf_array(&xs.view());
641        assert_eq!(pdfs.len(), 4);
642        for (i, &x) in xs.iter().enumerate() {
643            assert!(
644                (pdfs[i] - ig.pdf(x)).abs() < 1e-12,
645                "pdf_array[{i}] mismatch"
646            );
647        }
648    }
649
650    #[test]
651    fn test_issue_123_inverse_gaussian_cdf_array() {
652        let ig = InverseGaussian::<f64>::new(2.0, 3.0).expect("valid");
653        let xs = array![0.5_f64, 1.0, 2.0, 5.0];
654        let cdfs = ig.cdf_array(&xs.view());
655        for (i, &x) in xs.iter().enumerate() {
656            assert!(
657                (cdfs[i] - ig.cdf(x)).abs() < 1e-12,
658                "cdf_array[{i}] mismatch"
659            );
660        }
661    }
662
663    #[test]
664    fn test_issue_123_inverse_gaussian_ppf_array() {
665        let ig = InverseGaussian::<f64>::new(1.0, 2.0).expect("valid");
666        let qs = array![0.1_f64, 0.5, 0.9];
667        let xs = ig.ppf_array(&qs.view()).expect("ppf_array");
668        assert_eq!(xs.len(), 3);
669        for (i, &q) in qs.iter().enumerate() {
670            let scalar = ig.ppf(q).expect("ppf scalar");
671            assert!((xs[i] - scalar).abs() < 1e-12, "ppf_array[{i}] mismatch");
672        }
673    }
674
675    // ─── Issue 123 #2: shape-aware rvs ────────────────────────────────────────
676
677    #[test]
678    fn test_issue_123_inverse_gaussian_rvs_array_shape() {
679        let ig = InverseGaussian::<f64>::new(1.0, 1.0).expect("valid");
680        let arr = ig.rvs_array(&[8, 6]).expect("rvs_array");
681        assert_eq!(arr.shape(), &[8, 6]);
682        assert_eq!(arr.len(), 48);
683        for &v in arr.iter() {
684            assert!(v.is_finite() && v > 0.0);
685        }
686    }
687
688    #[test]
689    fn test_issue_123_inverse_gaussian_rvs_array_3d() {
690        let ig = InverseGaussian::<f64>::new(1.0, 1.0).expect("valid");
691        let arr = ig.rvs_array(&[2, 3, 4]).expect("rvs_array 3d");
692        assert_eq!(arr.shape(), &[2, 3, 4]);
693        assert_eq!(arr.len(), 24);
694    }
695
696    #[test]
697    fn test_issue_123_inverse_gaussian_rvs_array_with_zero_dim() {
698        // Zero-volume shape should produce an empty array, not panic.
699        let ig = InverseGaussian::<f64>::new(1.0, 1.0).expect("valid");
700        let arr = ig.rvs_array(&[3, 0]).expect("rvs_array with zero dim");
701        assert_eq!(arr.shape(), &[3, 0]);
702        assert_eq!(arr.len(), 0);
703    }
704
705    // ─── Issue 123 #2 cross-check on Normal: array methods are trait defaults
706    // and should work for any existing distribution ──────────────────────────
707
708    #[test]
709    fn test_issue_123_normal_pdf_array_default_works() {
710        let n = Normal::<f64>::new(0.0, 1.0).expect("valid");
711        let xs = array![-1.0_f64, 0.0, 1.0];
712        let pdfs = n.pdf_array(&xs.view());
713        assert_eq!(pdfs.len(), 3);
714        // pdf(0) = 1/√(2π) ≈ 0.3989
715        assert!((pdfs[1] - 1.0_f64 / (2.0 * std::f64::consts::PI).sqrt()).abs() < 1e-7);
716    }
717
718    #[test]
719    fn test_issue_123_normal_rvs_array_default_works() {
720        let n = Normal::<f64>::new(0.0, 1.0).expect("valid");
721        let block = n.rvs_array(&[4, 5]).expect("rvs_array");
722        assert_eq!(block.shape(), &[4, 5]);
723        assert_eq!(block.len(), 20);
724    }
725}