Skip to main content

u_analytics/
distribution.rs

1//! Distribution analysis.
2//!
3//! Empirical distribution functions, histogram binning, QQ-plot data,
4//! distribution fitting/testing, and kernel density estimation.
5//!
6//! # Examples
7//!
8//! ```
9//! use u_analytics::distribution::{ecdf, histogram_bins, BinMethod};
10//!
11//! let data = [1.0, 2.0, 3.0, 4.0, 5.0];
12//! let (values, probs) = ecdf(&data).unwrap();
13//! assert_eq!(values.len(), 5);
14//! assert!((probs[4] - 1.0).abs() < 1e-10);
15//!
16//! let bins = histogram_bins(&data, BinMethod::Sturges).unwrap();
17//! assert!(bins.n_bins >= 2);
18//! ```
19
20use u_numflow::distributions::{
21    BetaDistribution, Exponential, GammaDistribution, LogNormal, Normal, Sample, Weibull,
22};
23use u_numflow::special;
24use u_numflow::stats;
25
26/// A point on the empirical CDF.
27///
28/// Computes the ECDF: F_n(x) = (number of observations ≤ x) / n.
29///
30/// # Returns
31///
32/// Tuple of (sorted unique values, cumulative probabilities). Returns
33/// `None` if the data is empty or contains non-finite values.
34///
35/// # Examples
36///
37/// ```
38/// use u_analytics::distribution::ecdf;
39///
40/// let data = [3.0, 1.0, 2.0, 1.0, 4.0];
41/// let (vals, probs) = ecdf(&data).unwrap();
42/// assert_eq!(vals, vec![1.0, 2.0, 3.0, 4.0]);
43/// assert!((probs[0] - 0.4).abs() < 1e-10); // 2 values ≤ 1.0
44/// assert!((probs[3] - 1.0).abs() < 1e-10);
45/// ```
46pub fn ecdf(data: &[f64]) -> Option<(Vec<f64>, Vec<f64>)> {
47    if data.is_empty() || data.iter().any(|v| !v.is_finite()) {
48        return None;
49    }
50
51    let n = data.len() as f64;
52    let mut sorted: Vec<f64> = data.to_vec();
53    sorted.sort_by(|a, b| a.partial_cmp(b).expect("finite values"));
54
55    let mut values = Vec::new();
56    let mut probs = Vec::new();
57
58    let mut i = 0;
59    while i < sorted.len() {
60        let val = sorted[i];
61        // Count all occurrences of this value
62        let mut j = i;
63        while j < sorted.len() && (sorted[j] - val).abs() < 1e-300 {
64            j += 1;
65        }
66        values.push(val);
67        probs.push(j as f64 / n);
68        i = j;
69    }
70
71    Some((values, probs))
72}
73
74// ---------------------------------------------------------------------------
75// Histogram binning
76// ---------------------------------------------------------------------------
77
78/// Method for computing optimal number of histogram bins.
79#[derive(Debug, Clone, Copy)]
80pub enum BinMethod {
81    /// Sturges' rule: k = ⌈log₂(n)⌉ + 1. Best for near-normal data.
82    Sturges,
83    /// Scott's rule: h = 3.49·σ·n^(-1/3). Width-based.
84    Scott,
85    /// Freedman-Diaconis rule: h = 2·IQR·n^(-1/3). Robust to outliers.
86    FreedmanDiaconis,
87    /// Explicit bin count chosen by the caller. Must be ≥ 1.
88    Fixed(usize),
89}
90
91/// Result of histogram bin computation.
92#[derive(Debug, Clone)]
93pub struct HistogramBins {
94    /// Number of bins.
95    pub n_bins: usize,
96    /// Bin width.
97    pub bin_width: f64,
98    /// Bin edges (length = n_bins + 1).
99    pub edges: Vec<f64>,
100    /// Bin counts.
101    pub counts: Vec<usize>,
102}
103
104/// Computes optimal histogram bins using the specified method.
105///
106/// # Returns
107///
108/// `None` if fewer than 2 data points, non-finite values, or zero range.
109///
110/// # Examples
111///
112/// ```
113/// use u_analytics::distribution::{histogram_bins, BinMethod};
114///
115/// let data = [1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0];
116/// let result = histogram_bins(&data, BinMethod::Sturges).unwrap();
117/// assert!(result.n_bins >= 3);
118/// assert_eq!(result.edges.len(), result.n_bins + 1);
119/// assert_eq!(result.counts.len(), result.n_bins);
120/// ```
121pub fn histogram_bins(data: &[f64], method: BinMethod) -> Option<HistogramBins> {
122    let n = data.len();
123    if n < 2 || data.iter().any(|v| !v.is_finite()) {
124        return None;
125    }
126
127    let min_val = data.iter().cloned().reduce(f64::min)?;
128    let max_val = data.iter().cloned().reduce(f64::max)?;
129    let range = max_val - min_val;
130
131    if range < 1e-300 {
132        return None; // all same value
133    }
134
135    let nf = n as f64;
136
137    let n_bins = match method {
138        BinMethod::Sturges => {
139            let k = (nf.log2()).ceil() as usize + 1;
140            k.max(2)
141        }
142        BinMethod::Scott => {
143            let sd = stats::std_dev(data)?;
144            if sd < 1e-300 {
145                return None;
146            }
147            let h = 3.49 * sd * nf.powf(-1.0 / 3.0);
148            ((range / h).ceil() as usize).max(2)
149        }
150        BinMethod::FreedmanDiaconis => {
151            let q1 = stats::quantile(data, 0.25)?;
152            let q3 = stats::quantile(data, 0.75)?;
153            let iqr = q3 - q1;
154            if iqr < 1e-300 {
155                // Fall back to Sturges if IQR is zero
156                let k = (nf.log2()).ceil() as usize + 1;
157                k.max(2)
158            } else {
159                let h = 2.0 * iqr * nf.powf(-1.0 / 3.0);
160                ((range / h).ceil() as usize).max(2)
161            }
162        }
163        // Caller-specified count is honored exactly (no automatic floor);
164        // zero bins is invalid input.
165        BinMethod::Fixed(0) => return None,
166        BinMethod::Fixed(k) => k,
167    };
168
169    let bin_width = range / n_bins as f64;
170
171    // Compute edges
172    let mut edges = Vec::with_capacity(n_bins + 1);
173    for i in 0..=n_bins {
174        edges.push(min_val + i as f64 * bin_width);
175    }
176
177    // Count data in each bin
178    let mut counts = vec![0_usize; n_bins];
179    for &x in data {
180        let bin = ((x - min_val) / bin_width).floor() as usize;
181        let bin = bin.min(n_bins - 1); // last point goes in last bin
182        counts[bin] += 1;
183    }
184
185    Some(HistogramBins {
186        n_bins,
187        bin_width,
188        edges,
189        counts,
190    })
191}
192
193// ---------------------------------------------------------------------------
194// QQ-plot
195// ---------------------------------------------------------------------------
196
197/// Generates QQ-plot data (theoretical quantiles vs sample quantiles).
198///
199/// Uses the standard normal distribution as the theoretical distribution.
200/// Sample quantiles are the sorted data. Theoretical quantiles are computed
201/// using the plotting position (i - 0.5) / n.
202///
203/// # Returns
204///
205/// Tuple of (theoretical_quantiles, sample_quantiles). Returns `None` if
206/// fewer than 3 data points.
207///
208/// # Examples
209///
210/// ```
211/// use u_analytics::distribution::qq_plot_normal;
212///
213/// let data = [-1.5, -0.5, 0.0, 0.5, 1.5];
214/// let (theoretical, sample) = qq_plot_normal(&data).unwrap();
215/// assert_eq!(theoretical.len(), 5);
216/// assert_eq!(sample.len(), 5);
217/// // Sample and theoretical should be roughly aligned for normal data
218/// ```
219pub fn qq_plot_normal(data: &[f64]) -> Option<(Vec<f64>, Vec<f64>)> {
220    let n = data.len();
221    if n < 3 || data.iter().any(|v| !v.is_finite()) {
222        return None;
223    }
224
225    let mut sample: Vec<f64> = data.to_vec();
226    sample.sort_by(|a, b| a.partial_cmp(b).expect("finite values"));
227
228    let nf = n as f64;
229    let theoretical: Vec<f64> = (0..n)
230        .map(|i| {
231            let p = (i as f64 + 0.5) / nf;
232            special::inverse_normal_cdf(p)
233        })
234        .collect();
235
236    Some((theoretical, sample))
237}
238
239// ---------------------------------------------------------------------------
240// Kolmogorov-Smirnov test
241// ---------------------------------------------------------------------------
242
243/// Kolmogorov-Smirnov one-sample test against the standard normal distribution.
244///
245/// # Algorithm
246///
247/// D = max|F_n(x) - Φ(x)| where F_n is the ECDF and Φ is the CDF of N(0,1).
248/// P-value approximation: Kolmogorov distribution (Marsaglia et al., 2003).
249///
250/// # Returns
251///
252/// `None` if fewer than 5 observations or non-finite values.
253///
254/// # Examples
255///
256/// ```
257/// use u_analytics::distribution::ks_test_normal;
258///
259/// // Data from approximately N(0,1)
260/// let data = [-1.2, -0.8, -0.3, 0.1, 0.5, 0.7, 1.1, 1.4];
261/// let (d_stat, p_value) = ks_test_normal(&data).unwrap();
262/// assert!(p_value > 0.05); // cannot reject normality
263/// ```
264pub fn ks_test_normal(data: &[f64]) -> Option<(f64, f64)> {
265    let n = data.len();
266    if n < 5 || data.iter().any(|v| !v.is_finite()) {
267        return None;
268    }
269
270    let mean = stats::mean(data)?;
271    let sd = stats::std_dev(data)?;
272    if sd < 1e-300 {
273        return None;
274    }
275
276    let mut sorted: Vec<f64> = data.to_vec();
277    sorted.sort_by(|a, b| a.partial_cmp(b).expect("finite values"));
278
279    let nf = n as f64;
280    let mut d_stat = 0.0_f64;
281
282    for (i, &x) in sorted.iter().enumerate() {
283        let z = (x - mean) / sd;
284        let cdf = special::standard_normal_cdf(z);
285        let ecdf_above = (i + 1) as f64 / nf;
286        let ecdf_below = i as f64 / nf;
287        d_stat = d_stat.max((ecdf_above - cdf).abs());
288        d_stat = d_stat.max((ecdf_below - cdf).abs());
289    }
290
291    // P-value approximation (Kolmogorov distribution, simplified)
292    // For large n: P(D > x) ≈ 2·Σ (-1)^(k-1) exp(-2k²n·x²)
293    let lambda = (nf.sqrt() + 0.12 + 0.11 / nf.sqrt()) * d_stat;
294    let mut p_value = 0.0;
295    for k in 1..=100 {
296        let kf = k as f64;
297        let sign = if k % 2 == 1 { 1.0 } else { -1.0 };
298        let term = sign * (-2.0 * kf * kf * lambda * lambda).exp();
299        p_value += term;
300        if term.abs() < 1e-15 {
301            break;
302        }
303    }
304    p_value = (2.0 * p_value).clamp(0.0, 1.0);
305
306    Some((d_stat, p_value))
307}
308
309// ---------------------------------------------------------------------------
310// Kernel Density Estimation
311// ---------------------------------------------------------------------------
312
313/// Bandwidth selection method for kernel density estimation.
314#[derive(Debug, Clone, Copy)]
315pub enum BandwidthMethod {
316    /// Silverman's rule of thumb: h = 0.9 * min(σ, IQR/1.34) * n^(-1/5).
317    /// Robust to outliers and multimodal distributions.
318    ///
319    /// Reference: Silverman (1986), "Density Estimation for Statistics and
320    /// Data Analysis"
321    Silverman,
322    /// Scott's rule: h = 1.06 * σ * n^(-1/5).
323    /// Slightly smoother, assumes approximately normal data.
324    ///
325    /// Reference: Scott (1992), "Multivariate Density Estimation"
326    Scott,
327    /// Manual bandwidth specification.
328    Manual(f64),
329}
330
331/// Result of kernel density estimation.
332#[derive(Debug, Clone)]
333pub struct KdeResult {
334    /// Evaluation points (x-axis).
335    pub x: Vec<f64>,
336    /// Density estimates at each evaluation point (y-axis).
337    pub density: Vec<f64>,
338    /// Bandwidth used.
339    pub bandwidth: f64,
340}
341
342/// Gaussian kernel density estimation.
343///
344/// # Algorithm
345///
346/// f̂(x) = (1/nh) Σᵢ K((x - xᵢ)/h)
347///
348/// where K(u) = (1/√(2π)) exp(-u²/2) is the Gaussian kernel and
349/// h is the bandwidth (smoothing parameter).
350///
351/// The evaluation grid extends 3h beyond the data range to capture tail
352/// contributions from boundary points (3σ covers 99.7% of a Gaussian).
353///
354/// Reference: Silverman (1986), "Density Estimation for Statistics and
355/// Data Analysis"
356///
357/// # Parameters
358///
359/// - `data`: Sample observations (must have ≥ 2 finite values)
360/// - `method`: Bandwidth selection method
361/// - `n_points`: Number of evaluation grid points (typical: 256–1024)
362///
363/// # Returns
364///
365/// `None` if fewer than 2 data points, fewer than 2 grid points,
366/// non-finite values, or zero variance (for automatic bandwidth methods).
367///
368/// # Examples
369///
370/// ```
371/// use u_analytics::distribution::{kde, BandwidthMethod};
372///
373/// let data = [1.0, 1.1, 1.2, 2.0, 2.1, 2.2, 5.0];
374/// let result = kde(&data, BandwidthMethod::Silverman, 512).unwrap();
375/// assert_eq!(result.x.len(), 512);
376/// assert_eq!(result.density.len(), 512);
377/// assert!(result.bandwidth > 0.0);
378/// // Density should integrate approximately to 1
379/// let dx = result.x[1] - result.x[0];
380/// let integral: f64 = result.density.iter().sum::<f64>() * dx;
381/// assert!((integral - 1.0).abs() < 0.05);
382/// ```
383pub fn kde(data: &[f64], method: BandwidthMethod, n_points: usize) -> Option<KdeResult> {
384    let n = data.len();
385    if n < 2 || n_points < 2 || data.iter().any(|v| !v.is_finite()) {
386        return None;
387    }
388
389    let bandwidth = kde_bandwidth(data, method)?;
390
391    // Evaluation grid: extend 3*bandwidth beyond data range
392    let min_val = data.iter().cloned().reduce(f64::min)?;
393    let max_val = data.iter().cloned().reduce(f64::max)?;
394    let x_min = min_val - 3.0 * bandwidth;
395    let x_max = max_val + 3.0 * bandwidth;
396    let step = (x_max - x_min) / (n_points - 1) as f64;
397
398    let x: Vec<f64> = (0..n_points).map(|i| x_min + i as f64 * step).collect();
399
400    // Evaluate density at each grid point
401    let inv_h = 1.0 / bandwidth;
402    let inv_nh = inv_h / n as f64;
403    let inv_sqrt_2pi = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
404
405    let density: Vec<f64> = x
406        .iter()
407        .map(|&xi| {
408            let sum: f64 = data
409                .iter()
410                .map(|&xj| {
411                    let u = (xi - xj) * inv_h;
412                    inv_sqrt_2pi * (-0.5 * u * u).exp()
413                })
414                .sum();
415            sum * inv_nh
416        })
417        .collect();
418
419    Some(KdeResult {
420        x,
421        density,
422        bandwidth,
423    })
424}
425
426/// Evaluates the kernel density estimate at a single point.
427///
428/// Useful for point-wise evaluation without generating a full grid.
429///
430/// # Returns
431///
432/// `None` if data is empty, contains non-finite values, or bandwidth
433/// is invalid.
434pub fn kde_evaluate(data: &[f64], bandwidth: f64, x: f64) -> Option<f64> {
435    let n = data.len();
436    if n == 0 || bandwidth <= 0.0 || !bandwidth.is_finite() || !x.is_finite() {
437        return None;
438    }
439    if data.iter().any(|v| !v.is_finite()) {
440        return None;
441    }
442
443    let inv_h = 1.0 / bandwidth;
444    let inv_nh = inv_h / n as f64;
445    let inv_sqrt_2pi = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
446
447    let sum: f64 = data
448        .iter()
449        .map(|&xj| {
450            let u = (x - xj) * inv_h;
451            inv_sqrt_2pi * (-0.5 * u * u).exp()
452        })
453        .sum();
454
455    Some(sum * inv_nh)
456}
457
458/// Computes the bandwidth for KDE using the specified method.
459///
460/// Useful when you want to inspect the bandwidth before running full KDE.
461///
462/// # Returns
463///
464/// `None` if fewer than 2 data points, non-finite values, or zero
465/// variance (for automatic methods).
466pub fn kde_bandwidth(data: &[f64], method: BandwidthMethod) -> Option<f64> {
467    let n = data.len();
468    if n < 2 || data.iter().any(|v| !v.is_finite()) {
469        return None;
470    }
471
472    match method {
473        BandwidthMethod::Silverman => {
474            let sd = stats::std_dev(data)?;
475            if sd < 1e-300 {
476                return None;
477            }
478            let q1 = stats::quantile(data, 0.25)?;
479            let q3 = stats::quantile(data, 0.75)?;
480            let iqr = q3 - q1;
481            let spread = if iqr > 1e-300 { sd.min(iqr / 1.34) } else { sd };
482            Some(0.9 * spread * (n as f64).powf(-0.2))
483        }
484        BandwidthMethod::Scott => {
485            let sd = stats::std_dev(data)?;
486            if sd < 1e-300 {
487                return None;
488            }
489            Some(1.06 * sd * (n as f64).powf(-0.2))
490        }
491        BandwidthMethod::Manual(h) => {
492            if h <= 0.0 || !h.is_finite() {
493                None
494            } else {
495                Some(h)
496            }
497        }
498    }
499}
500
501// ---------------------------------------------------------------------------
502// MLE Distribution Fitting
503// ---------------------------------------------------------------------------
504
505/// Result of a distribution fit via maximum likelihood estimation.
506#[derive(Debug, Clone)]
507pub struct FitResult {
508    /// Name of the fitted distribution (e.g., "Normal", "Exponential").
509    pub distribution: String,
510    /// Estimated parameters (name, value) pairs.
511    pub parameters: Vec<(String, f64)>,
512    /// Log-likelihood at MLE parameters.
513    pub log_likelihood: f64,
514    /// Akaike Information Criterion: -2·ℓ + 2k.
515    pub aic: f64,
516    /// Bayesian Information Criterion: -2·ℓ + k·ln(n).
517    pub bic: f64,
518    /// Number of estimated parameters (k).
519    pub n_params: usize,
520}
521
522/// Fits a Normal distribution N(μ, σ²) via MLE.
523///
524/// # Estimators
525///
526/// - μ̂ = x̄ (sample mean)
527/// - σ̂ = √((1/n) Σ(xᵢ - x̄)²) (biased MLE, not sample std dev)
528///
529/// # Returns
530///
531/// `None` if fewer than 2 data points, non-finite values, or zero variance.
532///
533/// # Examples
534///
535/// ```
536/// use u_analytics::distribution::fit_normal;
537///
538/// let data = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
539/// let fit = fit_normal(&data).unwrap();
540/// assert_eq!(fit.distribution, "Normal");
541/// assert!((fit.parameters[0].1 - 5.0).abs() < 1e-10); // μ̂ = 5.0
542/// assert!(fit.parameters[1].1 > 0.0); // σ̂ > 0
543/// ```
544pub fn fit_normal(data: &[f64]) -> Option<FitResult> {
545    let n = data.len();
546    if n < 2 || data.iter().any(|v| !v.is_finite()) {
547        return None;
548    }
549
550    let nf = n as f64;
551    let mu = stats::mean(data)?;
552
553    // Biased MLE variance (denominator n, not n-1)
554    let sum_sq: f64 = data.iter().map(|&x| (x - mu).powi(2)).sum();
555    let sigma_sq = sum_sq / nf;
556    if sigma_sq < 1e-300 {
557        return None;
558    }
559    let sigma = sigma_sq.sqrt();
560
561    // Log-likelihood: -n/2·ln(2π) - n·ln(σ) - n/2
562    let log_lik = -nf / 2.0 * (2.0 * std::f64::consts::PI).ln() - nf * sigma.ln() - nf / 2.0;
563
564    let k = 2;
565    let aic = -2.0 * log_lik + 2.0 * k as f64;
566    let bic = -2.0 * log_lik + k as f64 * nf.ln();
567
568    Some(FitResult {
569        distribution: "Normal".to_string(),
570        parameters: vec![("mu".to_string(), mu), ("sigma".to_string(), sigma)],
571        log_likelihood: log_lik,
572        aic,
573        bic,
574        n_params: k,
575    })
576}
577
578/// Fits an Exponential distribution Exp(λ) via MLE.
579///
580/// # Estimators
581///
582/// - λ̂ = 1/x̄ (rate parameter)
583///
584/// # Returns
585///
586/// `None` if fewer than 2 data points, non-positive values, or non-finite values.
587///
588/// # Examples
589///
590/// ```
591/// use u_analytics::distribution::fit_exponential;
592///
593/// let data = [0.5, 1.2, 0.8, 2.1, 0.3, 1.5, 0.9, 1.8];
594/// let fit = fit_exponential(&data).unwrap();
595/// assert_eq!(fit.distribution, "Exponential");
596/// assert!(fit.parameters[0].1 > 0.0); // λ̂ > 0
597/// ```
598pub fn fit_exponential(data: &[f64]) -> Option<FitResult> {
599    let n = data.len();
600    if n < 2 || data.iter().any(|v| !v.is_finite()) {
601        return None;
602    }
603    // Exponential requires x > 0
604    if data.iter().any(|&v| v <= 0.0) {
605        return None;
606    }
607
608    let nf = n as f64;
609    let mean = stats::mean(data)?;
610    if mean < 1e-300 {
611        return None;
612    }
613
614    let lambda = 1.0 / mean;
615
616    // Log-likelihood: n·ln(λ) - λ·n·x̄ = n·ln(λ) - n
617    let log_lik = nf * lambda.ln() - nf;
618
619    let k = 1;
620    let aic = -2.0 * log_lik + 2.0 * k as f64;
621    let bic = -2.0 * log_lik + k as f64 * nf.ln();
622
623    Some(FitResult {
624        distribution: "Exponential".to_string(),
625        parameters: vec![("lambda".to_string(), lambda)],
626        log_likelihood: log_lik,
627        aic,
628        bic,
629        n_params: k,
630    })
631}
632
633/// Digamma function ψ(x) = d/dx ln(Γ(x)).
634///
635/// Uses the asymptotic expansion for x ≥ 8, with recurrence for smaller x.
636/// Reference: Abramowitz & Stegun (1972), formula 6.3.18.
637fn digamma(x: f64) -> f64 {
638    if x <= 0.0 {
639        return f64::NAN;
640    }
641
642    // Recurrence: ψ(x) = ψ(x+1) - 1/x
643    let mut val = 0.0;
644    let mut x = x;
645    while x < 8.0 {
646        val -= 1.0 / x;
647        x += 1.0;
648    }
649
650    // Asymptotic expansion for x ≥ 8
651    // ψ(x) ≈ ln(x) - 1/(2x) - 1/(12x²) + 1/(120x⁴) - 1/(252x⁶) + ...
652    let inv_x = 1.0 / x;
653    let inv_x2 = inv_x * inv_x;
654    val += x.ln()
655        - 0.5 * inv_x
656        - inv_x2 * (1.0 / 12.0 - inv_x2 * (1.0 / 120.0 - inv_x2 * (1.0 / 252.0)));
657
658    val
659}
660
661/// Trigamma function ψ'(x) = d²/dx² ln(Γ(x)).
662///
663/// Asymptotic expansion for x ≥ 8, with recurrence for smaller x.
664fn trigamma(x: f64) -> f64 {
665    if x <= 0.0 {
666        return f64::NAN;
667    }
668
669    // Recurrence: ψ'(x) = ψ'(x+1) + 1/x²
670    let mut val = 0.0;
671    let mut x = x;
672    while x < 8.0 {
673        val += 1.0 / (x * x);
674        x += 1.0;
675    }
676
677    // Asymptotic expansion for x ≥ 8
678    // ψ'(x) ≈ 1/x + 1/(2x²) + 1/(6x³) - 1/(30x⁵) + 1/(42x⁷) - ...
679    let inv_x = 1.0 / x;
680    let inv_x2 = inv_x * inv_x;
681    val += inv_x
682        + 0.5 * inv_x2
683        + inv_x2 * inv_x * (1.0 / 6.0 - inv_x2 * (1.0 / 30.0 - inv_x2 * (1.0 / 42.0)));
684
685    val
686}
687
688/// Fits a Gamma distribution Gamma(α, β) via MLE.
689///
690/// # Algorithm
691///
692/// Rate parameter β̂ = α̂/x̄ (closed-form once α is known).
693/// Shape parameter α̂ solved via Newton-Raphson on:
694/// ln(α) - ψ(α) = ln(x̄) - (1/n)Σln(xᵢ)
695///
696/// Initial guess from method of moments: α₀ = x̄²/s².
697///
698/// Reference: Minka (2002), "Estimating a Gamma distribution"
699///
700/// # Returns
701///
702/// `None` if fewer than 2 data points, non-positive values, non-finite values,
703/// or Newton-Raphson fails to converge.
704///
705/// # Examples
706///
707/// ```
708/// use u_analytics::distribution::fit_gamma;
709///
710/// let data = [2.1, 3.5, 1.8, 4.2, 2.9, 3.1, 2.5, 3.8, 1.5, 2.7];
711/// let fit = fit_gamma(&data).unwrap();
712/// assert_eq!(fit.distribution, "Gamma");
713/// assert!(fit.parameters[0].1 > 0.0); // α̂ > 0
714/// assert!(fit.parameters[1].1 > 0.0); // β̂ > 0
715/// ```
716pub fn fit_gamma(data: &[f64]) -> Option<FitResult> {
717    let n = data.len();
718    if n < 2 || data.iter().any(|v| !v.is_finite()) {
719        return None;
720    }
721    // Gamma requires x > 0
722    if data.iter().any(|&v| v <= 0.0) {
723        return None;
724    }
725
726    let nf = n as f64;
727    let mean = stats::mean(data)?;
728    if mean < 1e-300 {
729        return None;
730    }
731
732    // Biased variance (denominator n)
733    let var: f64 = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / nf;
734    if var < 1e-300 {
735        return None;
736    }
737
738    let sum_log: f64 = data.iter().map(|&x| x.ln()).sum();
739    let mean_log = sum_log / nf;
740    let target = mean.ln() - mean_log; // ln(x̄) - (1/n)Σln(xᵢ)
741
742    // Method of moments initial guess
743    let mut alpha = mean * mean / var;
744    if alpha < 1e-10 {
745        alpha = 0.5;
746    }
747
748    // Newton-Raphson: solve ln(α) - ψ(α) = target
749    for _ in 0..200 {
750        let f = alpha.ln() - digamma(alpha) - target;
751        let f_prime = 1.0 / alpha - trigamma(alpha);
752        if f_prime.abs() < 1e-30 {
753            break;
754        }
755
756        let delta = f / f_prime;
757        alpha -= delta;
758
759        // Keep alpha positive
760        if alpha <= 0.0 {
761            alpha = 1e-10;
762        }
763
764        if delta.abs() < 1e-10 {
765            break;
766        }
767    }
768
769    if !alpha.is_finite() || alpha <= 0.0 {
770        return None;
771    }
772
773    let beta = alpha / mean; // rate parameter
774
775    // Log-likelihood: n·α·ln(β) - n·ln(Γ(α)) + (α-1)·Σln(xᵢ) - β·Σxᵢ
776    let sum_x: f64 = data.iter().sum();
777    let log_lik = nf * alpha * beta.ln() - nf * special::ln_gamma(alpha) + (alpha - 1.0) * sum_log
778        - beta * sum_x;
779
780    let k = 2;
781    let aic = -2.0 * log_lik + 2.0 * k as f64;
782    let bic = -2.0 * log_lik + k as f64 * nf.ln();
783
784    Some(FitResult {
785        distribution: "Gamma".to_string(),
786        parameters: vec![("alpha".to_string(), alpha), ("beta".to_string(), beta)],
787        log_likelihood: log_lik,
788        aic,
789        bic,
790        n_params: k,
791    })
792}
793
794/// Fits a LogNormal distribution via MLE.
795///
796/// # Estimators
797///
798/// If Y = ln(X), then Y ~ N(μ, σ²):
799/// - μ̂ = (1/n) Σ ln(xᵢ)
800/// - σ̂ = √((1/n) Σ (ln(xᵢ) - μ̂)²)
801///
802/// # Returns
803///
804/// `None` if fewer than 2 data points, non-positive values, or non-finite values.
805///
806/// # Examples
807///
808/// ```
809/// use u_analytics::distribution::fit_lognormal;
810///
811/// let data = [1.2, 2.5, 1.8, 3.1, 0.9, 2.0, 1.5, 4.0];
812/// let fit = fit_lognormal(&data).unwrap();
813/// assert_eq!(fit.distribution, "LogNormal");
814/// assert!(fit.parameters[0].1.is_finite()); // μ̂
815/// assert!(fit.parameters[1].1 > 0.0); // σ̂ > 0
816/// ```
817pub fn fit_lognormal(data: &[f64]) -> Option<FitResult> {
818    let n = data.len();
819    if n < 2 || data.iter().any(|v| !v.is_finite()) {
820        return None;
821    }
822    if data.iter().any(|&v| v <= 0.0) {
823        return None;
824    }
825
826    let nf = n as f64;
827    let log_data: Vec<f64> = data.iter().map(|&x| x.ln()).collect();
828
829    let mu = log_data.iter().sum::<f64>() / nf;
830    let sigma_sq = log_data.iter().map(|&y| (y - mu).powi(2)).sum::<f64>() / nf;
831    if sigma_sq < 1e-300 {
832        return None;
833    }
834    let sigma = sigma_sq.sqrt();
835
836    // Log-likelihood: -n/2·ln(2π) - n·ln(σ) - (1/2σ²)Σ(ln(xᵢ)-μ)² - Σln(xᵢ)
837    let sum_log: f64 = log_data.iter().sum();
838    let log_lik =
839        -nf / 2.0 * (2.0 * std::f64::consts::PI).ln() - nf * sigma.ln() - nf / 2.0 - sum_log;
840
841    let k = 2;
842    let aic = -2.0 * log_lik + 2.0 * k as f64;
843    let bic = -2.0 * log_lik + k as f64 * nf.ln();
844
845    Some(FitResult {
846        distribution: "LogNormal".to_string(),
847        parameters: vec![("mu".to_string(), mu), ("sigma".to_string(), sigma)],
848        log_likelihood: log_lik,
849        aic,
850        bic,
851        n_params: k,
852    })
853}
854
855/// Fits a Poisson distribution via MLE.
856///
857/// # Estimators
858///
859/// λ̂ = x̄ (sample mean). Data are counts: non-negative integers
860/// (represented as f64 for API consistency).
861///
862/// # Returns
863///
864/// `None` if fewer than 2 data points, a value that is not a non-negative
865/// integer, all zeros, or non-finite values. (A non-integer has no Poisson
866/// probability; `ln Γ(x + 1)` would extend the formula past where it means one.)
867///
868/// # Examples
869///
870/// ```
871/// use u_analytics::distribution::fit_poisson;
872///
873/// let data = [2.0, 3.0, 1.0, 4.0, 2.0, 3.0, 1.0, 2.0];
874/// let fit = fit_poisson(&data).unwrap();
875/// assert_eq!(fit.distribution, "Poisson");
876/// assert!((fit.parameters[0].1 - 2.25).abs() < 1e-10); // λ̂ = mean
877/// ```
878pub fn fit_poisson(data: &[f64]) -> Option<FitResult> {
879    let n = data.len();
880    if n < 2 || data.iter().any(|v| !v.is_finite()) {
881        return None;
882    }
883    if data.iter().any(|&v| v < 0.0 || v.fract() != 0.0) {
884        return None;
885    }
886
887    let nf = n as f64;
888    let lambda = stats::mean(data)?;
889    if lambda < 1e-300 {
890        return None; // All zeros → degenerate
891    }
892
893    // Log-likelihood: Σ(xᵢ·ln(λ) - λ - ln(xᵢ!))
894    // = n·x̄·ln(λ) - n·λ - Σln(xᵢ!)
895    let sum_log_fact: f64 = data.iter().map(|&x| special::ln_gamma(x + 1.0)).sum();
896    let log_lik = nf * lambda * lambda.ln() - nf * lambda - sum_log_fact;
897
898    let k = 1;
899    let aic = -2.0 * log_lik + 2.0 * k as f64;
900    let bic = -2.0 * log_lik + k as f64 * nf.ln();
901
902    Some(FitResult {
903        distribution: "Poisson".to_string(),
904        parameters: vec![("lambda".to_string(), lambda)],
905        log_likelihood: log_lik,
906        aic,
907        bic,
908        n_params: k,
909    })
910}
911
912/// Fits a Beta distribution Beta(α, β) via MLE.
913///
914/// # Algorithm
915///
916/// Newton-Raphson on the system:
917/// - ψ(α) - ψ(α + β) = (1/n) Σ ln(xᵢ)
918/// - ψ(β) - ψ(α + β) = (1/n) Σ ln(1 - xᵢ)
919///
920/// Initial guess from method of moments:
921/// - α₀ = x̄ · ((x̄(1-x̄)/s²) - 1)
922/// - β₀ = (1-x̄) · ((x̄(1-x̄)/s²) - 1)
923///
924/// Data must be in (0, 1).
925///
926/// # Returns
927///
928/// `None` if fewer than 2 data points, values outside (0,1), or convergence failure.
929///
930/// # Examples
931///
932/// ```
933/// use u_analytics::distribution::fit_beta;
934///
935/// let data = [0.2, 0.35, 0.5, 0.15, 0.45, 0.3, 0.6, 0.25, 0.4, 0.55];
936/// let fit = fit_beta(&data).unwrap();
937/// assert_eq!(fit.distribution, "Beta");
938/// assert!(fit.parameters[0].1 > 0.0); // α̂ > 0
939/// assert!(fit.parameters[1].1 > 0.0); // β̂ > 0
940/// ```
941pub fn fit_beta(data: &[f64]) -> Option<FitResult> {
942    let n = data.len();
943    if n < 2 || data.iter().any(|v| !v.is_finite()) {
944        return None;
945    }
946    // Beta requires 0 < x < 1
947    if data.iter().any(|&v| v <= 0.0 || v >= 1.0) {
948        return None;
949    }
950
951    let nf = n as f64;
952    let mean = stats::mean(data)?;
953    let var = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / nf;
954    if var < 1e-300 {
955        return None;
956    }
957
958    let sum_log_x: f64 = data.iter().map(|&x| x.ln()).sum();
959    let sum_log_1mx: f64 = data.iter().map(|&x| (1.0 - x).ln()).sum();
960    let mean_log_x = sum_log_x / nf;
961    let mean_log_1mx = sum_log_1mx / nf;
962
963    // Method of moments initial guess
964    let factor = mean * (1.0 - mean) / var - 1.0;
965    if factor <= 0.0 {
966        return None;
967    }
968    let mut alpha = mean * factor;
969    let mut beta = (1.0 - mean) * factor;
970
971    if alpha < 0.01 {
972        alpha = 0.5;
973    }
974    if beta < 0.01 {
975        beta = 0.5;
976    }
977
978    // Newton-Raphson (joint update)
979    for _ in 0..200 {
980        let psi_ab = digamma(alpha + beta);
981        let tri_ab = trigamma(alpha + beta);
982
983        let f1 = digamma(alpha) - psi_ab - mean_log_x;
984        let f2 = digamma(beta) - psi_ab - mean_log_1mx;
985
986        let j11 = trigamma(alpha) - tri_ab;
987        let j12 = -tri_ab;
988        let j21 = -tri_ab;
989        let j22 = trigamma(beta) - tri_ab;
990
991        let det = j11 * j22 - j12 * j21;
992        if det.abs() < 1e-30 {
993            break;
994        }
995
996        let da = (j22 * f1 - j12 * f2) / det;
997        let db = (j11 * f2 - j21 * f1) / det;
998
999        alpha -= da;
1000        beta -= db;
1001
1002        if alpha <= 0.0 {
1003            alpha = 1e-10;
1004        }
1005        if beta <= 0.0 {
1006            beta = 1e-10;
1007        }
1008
1009        if da.abs() < 1e-10 && db.abs() < 1e-10 {
1010            break;
1011        }
1012    }
1013
1014    if !alpha.is_finite() || !beta.is_finite() || alpha <= 0.0 || beta <= 0.0 {
1015        return None;
1016    }
1017
1018    // Log-likelihood: n·[ln(Γ(α+β)) - ln(Γ(α)) - ln(Γ(β))]
1019    //                + (α-1)·Σln(xᵢ) + (β-1)·Σln(1-xᵢ)
1020    let log_lik = nf
1021        * (special::ln_gamma(alpha + beta) - special::ln_gamma(alpha) - special::ln_gamma(beta))
1022        + (alpha - 1.0) * sum_log_x
1023        + (beta - 1.0) * sum_log_1mx;
1024
1025    let k = 2;
1026    let aic = -2.0 * log_lik + 2.0 * k as f64;
1027    let bic = -2.0 * log_lik + k as f64 * nf.ln();
1028
1029    Some(FitResult {
1030        distribution: "Beta".to_string(),
1031        parameters: vec![("alpha".to_string(), alpha), ("beta".to_string(), beta)],
1032        log_likelihood: log_lik,
1033        aic,
1034        bic,
1035        n_params: k,
1036    })
1037}
1038
1039/// Fits a two-parameter Weibull distribution by maximum likelihood
1040/// ([`crate::weibull::weibull_mle`]), as a [`FitResult`] for comparison with
1041/// the other families.
1042///
1043/// # Returns
1044///
1045/// `None` if fewer than 2 data points, a value that is not strictly positive
1046/// and finite, or the fit does not converge.
1047///
1048/// # Examples
1049///
1050/// ```
1051/// use u_analytics::distribution::fit_weibull;
1052///
1053/// let data = [12.0, 25.0, 31.0, 44.0, 52.0, 60.0, 71.0, 85.0];
1054/// let fit = fit_weibull(&data).unwrap();
1055/// assert_eq!(fit.distribution, "Weibull");
1056/// assert_eq!(fit.parameters[0].0, "shape");
1057/// ```
1058pub fn fit_weibull(data: &[f64]) -> Option<FitResult> {
1059    if data.len() < 2 || data.iter().any(|&v| !(v.is_finite() && v > 0.0)) {
1060        return None;
1061    }
1062    let r = crate::weibull::weibull_mle(data)?;
1063    let nf = data.len() as f64;
1064    let k = 2;
1065    Some(FitResult {
1066        distribution: "Weibull".to_string(),
1067        parameters: vec![
1068            ("shape".to_string(), r.shape),
1069            ("scale".to_string(), r.scale),
1070        ],
1071        log_likelihood: r.log_likelihood,
1072        aic: -2.0 * r.log_likelihood + 2.0 * k as f64,
1073        bic: -2.0 * r.log_likelihood + k as f64 * nf.ln(),
1074        n_params: k,
1075    })
1076}
1077
1078/// Fits the continuous families that apply to the data and returns them sorted
1079/// by AIC (best first).
1080///
1081/// Tries Normal, Exponential, Gamma, LogNormal, Weibull and Beta; each is kept
1082/// only if it fits (the positive families need data > 0, Beta needs data in
1083/// (0, 1)).
1084///
1085/// Poisson is not a candidate: its likelihood is a probability mass, the
1086/// others' a density, and AIC compares likelihoods only on the same footing --
1087/// a density's value changes with the unit of measurement, a mass does not.
1088/// Fit count data with [`fit_poisson`] directly.
1089///
1090/// # Examples
1091///
1092/// ```
1093/// use u_analytics::distribution::fit_best;
1094///
1095/// let data = [2.1, 3.5, 1.8, 4.2, 2.9, 3.1, 2.5, 3.8, 1.5, 2.7];
1096/// let fits = fit_best(&data);
1097/// assert!(!fits.is_empty());
1098/// // Results sorted by AIC (lowest = best fit)
1099/// if fits.len() >= 2 {
1100///     assert!(fits[0].aic <= fits[1].aic);
1101/// }
1102/// ```
1103pub fn fit_best(data: &[f64]) -> Vec<FitResult> {
1104    let mut results = Vec::new();
1105
1106    if let Some(r) = fit_normal(data) {
1107        results.push(r);
1108    }
1109    if let Some(r) = fit_exponential(data) {
1110        results.push(r);
1111    }
1112    if let Some(r) = fit_gamma(data) {
1113        results.push(r);
1114    }
1115    if let Some(r) = fit_lognormal(data) {
1116        results.push(r);
1117    }
1118    if let Some(r) = fit_weibull(data) {
1119        results.push(r);
1120    }
1121    if let Some(r) = fit_beta(data) {
1122        results.push(r);
1123    }
1124
1125    results.sort_by(|a, b| {
1126        a.aic
1127            .partial_cmp(&b.aic)
1128            .unwrap_or(std::cmp::Ordering::Equal)
1129    });
1130    results
1131}
1132
1133// ---------------------------------------------------------------------------
1134// Goodness of fit of a fitted family
1135// ---------------------------------------------------------------------------
1136
1137/// A family [`fit_best`] reports, with its fitted parameters.
1138#[derive(Debug, Clone)]
1139enum Fitted {
1140    Normal(Normal),
1141    LogNormal(LogNormal),
1142    Weibull(Weibull),
1143    Gamma(GammaDistribution),
1144    Exponential(Exponential),
1145    Beta(BetaDistribution),
1146}
1147
1148impl Fitted {
1149    /// The distribution a [`FitResult`] names, or `None` for a family outside
1150    /// [`fit_best`]'s (Poisson) or parameters it cannot be built from.
1151    fn of(fit: &FitResult) -> Option<Self> {
1152        let param = |name: &str| {
1153            fit.parameters
1154                .iter()
1155                .find(|(n, _)| n == name)
1156                .map(|&(_, v)| v)
1157        };
1158        Some(match fit.distribution.as_str() {
1159            "Normal" => Self::Normal(Normal::new(param("mu")?, param("sigma")?).ok()?),
1160            "LogNormal" => Self::LogNormal(LogNormal::new(param("mu")?, param("sigma")?).ok()?),
1161            "Weibull" => Self::Weibull(Weibull::new(param("shape")?, param("scale")?).ok()?),
1162            // `fit_gamma` reports the rate as `beta`.
1163            "Gamma" => Self::Gamma(GammaDistribution::new(param("alpha")?, param("beta")?).ok()?),
1164            "Exponential" => Self::Exponential(Exponential::new(param("lambda")?).ok()?),
1165            "Beta" => Self::Beta(BetaDistribution::new(param("alpha")?, param("beta")?).ok()?),
1166            _ => return None,
1167        })
1168    }
1169
1170    /// Refits the same family to `data`.
1171    fn refit(&self, data: &[f64]) -> Option<FitResult> {
1172        match self {
1173            Self::Normal(_) => fit_normal(data),
1174            Self::LogNormal(_) => fit_lognormal(data),
1175            Self::Weibull(_) => fit_weibull(data),
1176            Self::Gamma(_) => fit_gamma(data),
1177            Self::Exponential(_) => fit_exponential(data),
1178            Self::Beta(_) => fit_beta(data),
1179        }
1180    }
1181
1182    fn quantile(&self, p: f64) -> Option<f64> {
1183        match self {
1184            Self::Normal(d) => d.quantile(p),
1185            Self::LogNormal(d) => d.quantile(p),
1186            Self::Weibull(d) => d.quantile(p),
1187            Self::Gamma(d) => d.quantile(p),
1188            Self::Exponential(d) => d.quantile(p),
1189            Self::Beta(d) => d.quantile(p),
1190        }
1191    }
1192
1193    /// `(ln F(x), ln (1 − F(x)))`, each tail evaluated directly so that a value
1194    /// far out contributes its true log-probability rather than `ln 0`.
1195    fn ln_tails(&self, x: f64) -> (f64, f64) {
1196        let (cdf, sf) = match self {
1197            Self::Normal(d) => {
1198                let z = (x - d.mu()) / d.sigma();
1199                (
1200                    special::standard_normal_cdf(z),
1201                    special::standard_normal_sf(z),
1202                )
1203            }
1204            Self::LogNormal(d) => {
1205                if x <= 0.0 {
1206                    (0.0, 1.0)
1207                } else {
1208                    let z = (x.ln() - d.mu()) / d.sigma();
1209                    (
1210                        special::standard_normal_cdf(z),
1211                        special::standard_normal_sf(z),
1212                    )
1213                }
1214            }
1215            Self::Weibull(d) => {
1216                if x <= 0.0 {
1217                    (0.0, 1.0)
1218                } else {
1219                    let h = (x / d.scale()).powf(d.shape());
1220                    (-(-h).exp_m1(), (-h).exp())
1221                }
1222            }
1223            Self::Gamma(d) => {
1224                if x <= 0.0 {
1225                    (0.0, 1.0)
1226                } else {
1227                    let t = d.rate() * x;
1228                    (
1229                        special::regularized_lower_gamma(d.shape(), t),
1230                        special::regularized_upper_gamma(d.shape(), t),
1231                    )
1232                }
1233            }
1234            Self::Exponential(d) => (d.cdf(x), d.survival(x)),
1235            Self::Beta(d) => {
1236                if x <= 0.0 {
1237                    (0.0, 1.0)
1238                } else if x >= 1.0 {
1239                    (1.0, 0.0)
1240                } else {
1241                    (
1242                        special::regularized_incomplete_beta(x, d.alpha, d.beta),
1243                        special::regularized_incomplete_beta(1.0 - x, d.beta, d.alpha),
1244                    )
1245                }
1246            }
1247        };
1248        (
1249            cdf.max(f64::MIN_POSITIVE).ln(),
1250            sf.max(f64::MIN_POSITIVE).ln(),
1251        )
1252    }
1253
1254    fn sample_n(&self, n: usize, seed: u64) -> Vec<f64> {
1255        match self {
1256            Self::Normal(d) => d.sample_n(n, seed),
1257            Self::LogNormal(d) => d.sample_n(n, seed),
1258            Self::Weibull(d) => d.sample_n(n, seed),
1259            Self::Gamma(d) => d.sample_n(n, seed),
1260            Self::Exponential(d) => d.sample_n(n, seed),
1261            Self::Beta(d) => d.sample_n(n, seed),
1262        }
1263    }
1264
1265    /// A² of `data` against this distribution.
1266    fn anderson_darling(&self, data: &[f64]) -> f64 {
1267        let mut x = data.to_vec();
1268        x.sort_by(|a, b| a.partial_cmp(b).expect("finite values"));
1269        let n = x.len();
1270        let tails: Vec<(f64, f64)> = x.iter().map(|&v| self.ln_tails(v)).collect();
1271        let s: f64 = (0..n)
1272            .map(|i| (2 * i + 1) as f64 * (tails[i].0 + tails[n - 1 - i].1))
1273            .sum();
1274        -(n as f64) - s / n as f64
1275    }
1276}
1277
1278/// The 0.135 %, 50 % and 99.865 % quantiles (or any others) of the family a
1279/// [`FitResult`] names — for the capability methods that read a fitted model.
1280pub(crate) fn fitted_quantile(fit: &FitResult, p: f64) -> Option<f64> {
1281    Fitted::of(fit)?.quantile(p)
1282}
1283
1284/// Anderson–Darling statistic A² of `data` against the distribution `fit`
1285/// describes (its fitted parameters, not re-estimated).
1286///
1287/// A² = −n − (1/n) Σᵢ (2i − 1)·[ln F(x₍ᵢ₎) + ln(1 − F(x₍ₙ₊₁₋ᵢ₎))]. It weights
1288/// the tails, where a wrong family misplaces quantiles most. Smaller is closer;
1289/// among families fitted to the same data it ranks them by fit rather than by
1290/// likelihood. To judge whether the family fits at all, use
1291/// [`goodness_of_fit`] — the null distribution of A² depends on the family and
1292/// on the parameters having been estimated from the same data.
1293///
1294/// # Returns
1295///
1296/// `None` if `data` is empty or has a non-finite value, or `fit` is not one of
1297/// the families [`fit_best`] reports.
1298///
1299/// # References
1300///
1301/// Anderson, T.W. & Darling, D.A. (1954). "A test of goodness of fit."
1302/// *JASA*, 49(268), 765–769.
1303///
1304/// # Examples
1305///
1306/// ```
1307/// use u_analytics::distribution::{anderson_darling_fit, fit_normal};
1308///
1309/// let data = [4.1, 5.3, 4.8, 5.9, 5.0, 4.4, 5.6, 4.9, 5.2, 4.7];
1310/// let fit = fit_normal(&data).unwrap();
1311/// let a2 = anderson_darling_fit(&data, &fit).unwrap();
1312/// assert!(a2 > 0.0 && a2 < 1.0);
1313/// ```
1314pub fn anderson_darling_fit(data: &[f64], fit: &FitResult) -> Option<f64> {
1315    if data.is_empty() || data.iter().any(|v| !v.is_finite()) {
1316        return None;
1317    }
1318    Some(Fitted::of(fit)?.anderson_darling(data))
1319}
1320
1321/// Whether a fitted family describes the data: the Anderson–Darling statistic
1322/// against the fitted distribution and its p-value.
1323#[derive(Debug, Clone, Copy, PartialEq)]
1324pub struct GoodnessOfFit {
1325    /// A² of the data against the fitted distribution ([`anderson_darling_fit`]).
1326    pub statistic: f64,
1327    /// Probability, if the data came from the family, of an A² at least this
1328    /// large after fitting the family to a sample of the same size — the
1329    /// parametric-bootstrap estimate `(1 + #{A²_b ≥ A²}) / (B + 1)`. Small
1330    /// values say the family does not describe the data.
1331    pub p_value: f64,
1332    /// Bootstrap replicates `B` behind `p_value`; the smallest p it can report
1333    /// is `1 / (B + 1)`.
1334    pub replicates: usize,
1335}
1336
1337/// Replicates [`goodness_of_fit`] uses when a caller has no reason to choose:
1338/// with 199 the p-value is resolved to 0.005, and a 0.05 or 0.01 cut falls on
1339/// a whole number of replicates ((B + 1)·α integer — Davison & Hinkley §4.2).
1340pub const DEFAULT_GOF_REPLICATES: usize = 199;
1341
1342/// Tests whether the family `fit` describes `data`, with the family's
1343/// parameters estimated from that same data.
1344///
1345/// The statistic is A² against the fitted distribution
1346/// ([`anderson_darling_fit`]). Its null distribution is not the textbook one
1347/// when parameters are estimated — fitting pulls the model toward the sample —
1348/// and it depends on the family (and, for Gamma and Beta, on the shape). The
1349/// p-value is therefore the **parametric bootstrap**: draw `replicates` samples
1350/// of size n from the fitted distribution, refit the same family to each, and
1351/// count how often their A² reaches the observed one. This applies the same
1352/// way to every family, where tables exist only for some.
1353///
1354/// Deterministic: the same `(data, fit, replicates, seed)` gives the same
1355/// p-value.
1356///
1357/// # Complexity
1358///
1359/// O(B · (n log n + fit)), where `fit` is the family's maximum-likelihood
1360/// iteration (closed form for Normal, LogNormal and Exponential).
1361///
1362/// # Returns
1363///
1364/// `None` if `replicates` is 0, `data` has fewer than 2 values or a non-finite
1365/// one, or `fit` is not one of the families [`fit_best`] reports.
1366///
1367/// # References
1368///
1369/// - Stute, W., González Manteiga, W. & Presedo Quindimil, M. (1993).
1370///   "Bootstrap based goodness-of-fit-tests." *Metrika*, 40, 243–256.
1371/// - Davison, A.C. & Hinkley, D.V. (1997). *Bootstrap Methods and their
1372///   Application*, §4.2 (the `(1 + count) / (B + 1)` estimate).
1373/// - Stephens, M.A. (1974). "EDF statistics for goodness of fit and some
1374///   comparisons." *JASA*, 69(347), 730–737 (A² with estimated parameters).
1375///
1376/// # Examples
1377///
1378/// ```
1379/// use u_analytics::distribution::{fit_best, goodness_of_fit, DEFAULT_GOF_REPLICATES};
1380///
1381/// // Uniform data: no listed family describes it.
1382/// let data: Vec<f64> = (1..=500).map(f64::from).collect();
1383/// let best = &fit_best(&data)[0];
1384/// let gof = goodness_of_fit(&data, best, DEFAULT_GOF_REPLICATES, 0).unwrap();
1385/// assert!(gof.p_value < 0.01);
1386/// ```
1387pub fn goodness_of_fit(
1388    data: &[f64],
1389    fit: &FitResult,
1390    replicates: usize,
1391    seed: u64,
1392) -> Option<GoodnessOfFit> {
1393    if replicates == 0 || data.len() < 2 || data.iter().any(|v| !v.is_finite()) {
1394        return None;
1395    }
1396    let model = Fitted::of(fit)?;
1397    let statistic = model.anderson_darling(data);
1398    let n = data.len();
1399    let at_least = (0..replicates as u64)
1400        .filter(|&b| {
1401            let sample = model.sample_n(n, seed.wrapping_add(b));
1402            // A replicate the family cannot refit (a draw that degenerates)
1403            // counts as at least as extreme: the estimate stays conservative.
1404            match model.refit(&sample).and_then(|f| Fitted::of(&f)) {
1405                Some(refitted) => refitted.anderson_darling(&sample) >= statistic,
1406                None => true,
1407            }
1408        })
1409        .count();
1410    Some(GoodnessOfFit {
1411        statistic,
1412        p_value: (1 + at_least) as f64 / (replicates + 1) as f64,
1413        replicates,
1414    })
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419    use super::*;
1420
1421    // -----------------------------------------------------------------------
1422    // ECDF
1423    // -----------------------------------------------------------------------
1424
1425    #[test]
1426    fn ecdf_basic() {
1427        let (vals, probs) = ecdf(&[3.0, 1.0, 2.0]).expect("should compute");
1428        assert_eq!(vals, vec![1.0, 2.0, 3.0]);
1429        assert!((probs[0] - 1.0 / 3.0).abs() < 1e-10);
1430        assert!((probs[1] - 2.0 / 3.0).abs() < 1e-10);
1431        assert!((probs[2] - 1.0).abs() < 1e-10);
1432    }
1433
1434    #[test]
1435    fn ecdf_with_ties() {
1436        let (vals, probs) = ecdf(&[1.0, 2.0, 2.0, 3.0]).expect("should compute");
1437        assert_eq!(vals, vec![1.0, 2.0, 3.0]);
1438        assert!((probs[0] - 0.25).abs() < 1e-10);
1439        assert!((probs[1] - 0.75).abs() < 1e-10); // 3 out of 4 ≤ 2.0
1440        assert!((probs[2] - 1.0).abs() < 1e-10);
1441    }
1442
1443    #[test]
1444    fn ecdf_single() {
1445        let (vals, probs) = ecdf(&[5.0]).expect("should compute");
1446        assert_eq!(vals, vec![5.0]);
1447        assert!((probs[0] - 1.0).abs() < 1e-10);
1448    }
1449
1450    #[test]
1451    fn ecdf_empty() {
1452        assert!(ecdf(&[]).is_none());
1453    }
1454
1455    #[test]
1456    fn ecdf_nan() {
1457        assert!(ecdf(&[1.0, f64::NAN, 3.0]).is_none());
1458    }
1459
1460    // -----------------------------------------------------------------------
1461    // Histogram bins
1462    // -----------------------------------------------------------------------
1463
1464    #[test]
1465    fn hist_sturges_basic() {
1466        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
1467        let r = histogram_bins(&data, BinMethod::Sturges).expect("should compute");
1468        assert!(r.n_bins >= 5);
1469        assert_eq!(r.edges.len(), r.n_bins + 1);
1470        assert_eq!(r.counts.len(), r.n_bins);
1471        let total: usize = r.counts.iter().sum();
1472        assert_eq!(total, 100);
1473    }
1474
1475    #[test]
1476    fn hist_scott() {
1477        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
1478        let r = histogram_bins(&data, BinMethod::Scott).expect("should compute");
1479        assert!(r.n_bins >= 3);
1480        let total: usize = r.counts.iter().sum();
1481        assert_eq!(total, 100);
1482    }
1483
1484    #[test]
1485    fn hist_fd() {
1486        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
1487        let r = histogram_bins(&data, BinMethod::FreedmanDiaconis).expect("should compute");
1488        assert!(r.n_bins >= 3);
1489        let total: usize = r.counts.iter().sum();
1490        assert_eq!(total, 100);
1491    }
1492
1493    #[test]
1494    fn hist_edge_cases() {
1495        assert!(histogram_bins(&[1.0], BinMethod::Sturges).is_none()); // < 2
1496        assert!(histogram_bins(&[5.0, 5.0, 5.0], BinMethod::Sturges).is_none());
1497        // zero range
1498    }
1499
1500    #[test]
1501    fn hist_fixed_exact_count() {
1502        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
1503        let r = histogram_bins(&data, BinMethod::Fixed(5)).expect("should compute");
1504        assert_eq!(r.n_bins, 5);
1505        assert_eq!(r.edges.len(), 6);
1506        assert_eq!(r.counts.len(), 5);
1507        let total: usize = r.counts.iter().sum();
1508        assert_eq!(total, 100);
1509    }
1510
1511    #[test]
1512    fn hist_fixed_single_bin() {
1513        let data = [1.0, 2.0, 3.0];
1514        let r = histogram_bins(&data, BinMethod::Fixed(1)).expect("should compute");
1515        assert_eq!(r.n_bins, 1);
1516        assert_eq!(r.counts, vec![3]);
1517    }
1518
1519    #[test]
1520    fn hist_fixed_zero_is_invalid() {
1521        let data = [1.0, 2.0, 3.0];
1522        assert!(histogram_bins(&data, BinMethod::Fixed(0)).is_none());
1523    }
1524
1525    #[test]
1526    fn hist_fixed_shares_input_guards() {
1527        assert!(histogram_bins(&[1.0], BinMethod::Fixed(5)).is_none()); // < 2 points
1528        assert!(histogram_bins(&[5.0, 5.0, 5.0], BinMethod::Fixed(5)).is_none());
1529        // zero range
1530    }
1531
1532    // -----------------------------------------------------------------------
1533    // QQ-plot
1534    // -----------------------------------------------------------------------
1535
1536    #[test]
1537    fn qq_basic() {
1538        let data = [-1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5];
1539        let (theo, samp) = qq_plot_normal(&data).expect("should compute");
1540        assert_eq!(theo.len(), 7);
1541        assert_eq!(samp.len(), 7);
1542        // Sorted sample
1543        assert!((samp[0] + 1.5).abs() < 1e-10);
1544        assert!((samp[6] - 1.5).abs() < 1e-10);
1545        // Theoretical: median should be near 0
1546        assert!(theo[3].abs() < 0.1);
1547    }
1548
1549    #[test]
1550    fn qq_edge_cases() {
1551        assert!(qq_plot_normal(&[1.0, 2.0]).is_none()); // < 3
1552    }
1553
1554    // -----------------------------------------------------------------------
1555    // KS test
1556    // -----------------------------------------------------------------------
1557
1558    #[test]
1559    fn ks_normal_data() {
1560        // Approximately normal data
1561        let data = [-2.0, -1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 2.5];
1562        let (d, p) = ks_test_normal(&data).expect("should compute");
1563        assert!(d > 0.0 && d < 1.0, "D = {d}");
1564        assert!(p > 0.05, "p = {p}");
1565    }
1566
1567    #[test]
1568    fn ks_non_normal_data() {
1569        // Clearly non-normal (uniform 0-100)
1570        let data: Vec<f64> = (0..50).map(|i| i as f64 * 2.0).collect();
1571        let (d, _p) = ks_test_normal(&data).expect("should compute");
1572        assert!(d > 0.0);
1573    }
1574
1575    #[test]
1576    fn ks_edge_cases() {
1577        assert!(ks_test_normal(&[1.0, 2.0, 3.0, 4.0]).is_none()); // < 5
1578        assert!(ks_test_normal(&[5.0, 5.0, 5.0, 5.0, 5.0]).is_none()); // zero var
1579    }
1580
1581    // -----------------------------------------------------------------------
1582    // KDE
1583    // -----------------------------------------------------------------------
1584
1585    #[test]
1586    fn kde_silverman_basic() {
1587        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
1588        let r = kde(&data, BandwidthMethod::Silverman, 256).expect("should compute");
1589        assert_eq!(r.x.len(), 256);
1590        assert_eq!(r.density.len(), 256);
1591        assert!(r.bandwidth > 0.0);
1592        // All densities non-negative
1593        assert!(r.density.iter().all(|&d| d >= 0.0));
1594        // Integral ≈ 1
1595        let dx = r.x[1] - r.x[0];
1596        let integral: f64 = r.density.iter().sum::<f64>() * dx;
1597        assert!(
1598            (integral - 1.0).abs() < 0.05,
1599            "integral = {integral}, expected ≈ 1.0"
1600        );
1601    }
1602
1603    #[test]
1604    fn kde_scott_basic() {
1605        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
1606        let r = kde(&data, BandwidthMethod::Scott, 256).expect("should compute");
1607        assert!(r.bandwidth > 0.0);
1608        let dx = r.x[1] - r.x[0];
1609        let integral: f64 = r.density.iter().sum::<f64>() * dx;
1610        assert!(
1611            (integral - 1.0).abs() < 0.05,
1612            "integral = {integral}, expected ≈ 1.0"
1613        );
1614    }
1615
1616    #[test]
1617    fn kde_manual_bandwidth() {
1618        let data = [1.0, 2.0, 3.0, 4.0, 5.0];
1619        let r = kde(&data, BandwidthMethod::Manual(0.5), 128).expect("should compute");
1620        assert!((r.bandwidth - 0.5).abs() < 1e-10);
1621        assert_eq!(r.x.len(), 128);
1622    }
1623
1624    #[test]
1625    fn kde_edge_cases() {
1626        // Too few points
1627        assert!(kde(&[1.0], BandwidthMethod::Silverman, 256).is_none());
1628        // Empty
1629        assert!(kde(&[], BandwidthMethod::Silverman, 256).is_none());
1630        // NaN
1631        assert!(kde(&[1.0, f64::NAN], BandwidthMethod::Silverman, 256).is_none());
1632        // Constant data (zero variance)
1633        assert!(kde(&[5.0, 5.0, 5.0], BandwidthMethod::Silverman, 256).is_none());
1634        // Too few grid points
1635        assert!(kde(&[1.0, 2.0, 3.0], BandwidthMethod::Manual(1.0), 1).is_none());
1636        // Invalid manual bandwidth
1637        assert!(kde(&[1.0, 2.0, 3.0], BandwidthMethod::Manual(0.0), 256).is_none());
1638        assert!(kde(&[1.0, 2.0, 3.0], BandwidthMethod::Manual(-1.0), 256).is_none());
1639    }
1640
1641    #[test]
1642    fn kde_bimodal() {
1643        // Two clusters: around 0 and around 10
1644        let mut data = Vec::new();
1645        for i in 0..50 {
1646            data.push(i as f64 * 0.1); // 0.0 to 4.9
1647        }
1648        for i in 0..50 {
1649            data.push(10.0 + i as f64 * 0.1); // 10.0 to 14.9
1650        }
1651        let r = kde(&data, BandwidthMethod::Silverman, 512).expect("should compute");
1652        // Should have higher density near the two clusters
1653        // Find density at x ≈ 2.5 (in cluster 1) and x ≈ 7.5 (between clusters)
1654        let idx_cluster = r.x.iter().position(|&x| x >= 2.5).expect("grid point");
1655        let idx_valley = r.x.iter().position(|&x| x >= 7.5).expect("grid point");
1656        assert!(
1657            r.density[idx_cluster] > r.density[idx_valley],
1658            "cluster density ({}) should exceed valley density ({})",
1659            r.density[idx_cluster],
1660            r.density[idx_valley]
1661        );
1662    }
1663
1664    #[test]
1665    fn kde_evaluate_single() {
1666        let data = [0.0, 1.0, 2.0, 3.0, 4.0];
1667        let h = 1.0;
1668        let d = kde_evaluate(&data, h, 2.0).expect("should compute");
1669        assert!(d > 0.0);
1670        // At the center of the data, density should be highest
1671        let d_edge = kde_evaluate(&data, h, -5.0).expect("should compute");
1672        assert!(d > d_edge, "center density ({d}) > edge density ({d_edge})");
1673    }
1674
1675    #[test]
1676    fn kde_evaluate_edge_cases() {
1677        assert!(kde_evaluate(&[], 1.0, 0.0).is_none());
1678        assert!(kde_evaluate(&[1.0], 0.0, 0.0).is_none());
1679        assert!(kde_evaluate(&[1.0], -1.0, 0.0).is_none());
1680        assert!(kde_evaluate(&[1.0], 1.0, f64::NAN).is_none());
1681        assert!(kde_evaluate(&[f64::NAN], 1.0, 0.0).is_none());
1682    }
1683
1684    #[test]
1685    fn kde_bandwidth_methods() {
1686        let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
1687        let h_silv = kde_bandwidth(&data, BandwidthMethod::Silverman).expect("silverman");
1688        let h_scott = kde_bandwidth(&data, BandwidthMethod::Scott).expect("scott");
1689        let h_manual = kde_bandwidth(&data, BandwidthMethod::Manual(2.5)).expect("manual");
1690        assert!(h_silv > 0.0);
1691        assert!(h_scott > 0.0);
1692        assert!((h_manual - 2.5).abs() < 1e-10);
1693        // Scott produces slightly larger bandwidth than Silverman for uniform data
1694        // (1.06σ vs 0.9*min(σ, IQR/1.34))
1695        assert!(h_scott > h_silv);
1696    }
1697
1698    #[test]
1699    fn kde_bandwidth_edge_cases() {
1700        assert!(kde_bandwidth(&[1.0], BandwidthMethod::Silverman).is_none());
1701        assert!(kde_bandwidth(&[5.0, 5.0], BandwidthMethod::Silverman).is_none());
1702        assert!(kde_bandwidth(&[1.0, 2.0], BandwidthMethod::Manual(0.0)).is_none());
1703    }
1704
1705    #[test]
1706    fn kde_grid_extends_beyond_data() {
1707        let data = [10.0, 20.0, 30.0];
1708        let r = kde(&data, BandwidthMethod::Manual(2.0), 64).expect("should compute");
1709        // Grid should extend 3*h = 6 beyond data range [10, 30]
1710        assert!(r.x[0] < 10.0 - 2.0, "grid starts at {}", r.x[0]);
1711        assert!(
1712            *r.x.last().expect("non-empty") > 30.0 + 2.0,
1713            "grid ends at {}",
1714            r.x.last().expect("non-empty")
1715        );
1716    }
1717
1718    // -----------------------------------------------------------------------
1719    // Distribution Fitting
1720    // -----------------------------------------------------------------------
1721
1722    #[test]
1723    fn fit_normal_basic() {
1724        let data = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
1725        let r = fit_normal(&data).expect("should compute");
1726        assert_eq!(r.distribution, "Normal");
1727        assert_eq!(r.n_params, 2);
1728        assert!((r.parameters[0].1 - 5.0).abs() < 1e-10, "μ̂ = 5.0");
1729        // Biased MLE σ̂ = √(Σ(xᵢ-x̄)²/n) = √(32/8) = 2.0
1730        // (xᵢ-5)² = 9,1,1,1,0,0,4,16 → sum = 32
1731        let expected_sigma = (32.0_f64 / 8.0).sqrt();
1732        assert!(
1733            (r.parameters[1].1 - expected_sigma).abs() < 1e-10,
1734            "σ̂ = {}, expected {}",
1735            r.parameters[1].1,
1736            expected_sigma
1737        );
1738        assert!(r.log_likelihood.is_finite());
1739        assert!(r.aic.is_finite());
1740        assert!(r.bic.is_finite());
1741    }
1742
1743    #[test]
1744    fn fit_normal_edge_cases() {
1745        assert!(fit_normal(&[1.0]).is_none()); // < 2
1746        assert!(fit_normal(&[]).is_none());
1747        assert!(fit_normal(&[1.0, f64::NAN]).is_none());
1748        assert!(fit_normal(&[5.0, 5.0, 5.0]).is_none()); // zero variance
1749    }
1750
1751    #[test]
1752    fn fit_exponential_basic() {
1753        let data = [0.5, 1.2, 0.8, 2.1, 0.3, 1.5, 0.9, 1.8];
1754        let r = fit_exponential(&data).expect("should compute");
1755        assert_eq!(r.distribution, "Exponential");
1756        assert_eq!(r.n_params, 1);
1757        let mean: f64 = data.iter().sum::<f64>() / data.len() as f64;
1758        let expected_lambda = 1.0 / mean;
1759        assert!(
1760            (r.parameters[0].1 - expected_lambda).abs() < 1e-10,
1761            "λ̂ = {}, expected {}",
1762            r.parameters[0].1,
1763            expected_lambda
1764        );
1765    }
1766
1767    #[test]
1768    fn fit_exponential_edge_cases() {
1769        assert!(fit_exponential(&[1.0]).is_none()); // < 2
1770        assert!(fit_exponential(&[1.0, -1.0]).is_none()); // negative
1771        assert!(fit_exponential(&[0.0, 1.0]).is_none()); // zero value
1772        assert!(fit_exponential(&[1.0, f64::NAN]).is_none());
1773    }
1774
1775    #[test]
1776    fn fit_gamma_basic() {
1777        // Generate Gamma-like data (known α ≈ 4, β ≈ 2, mean = α/β = 2)
1778        let data = [
1779            1.5, 2.1, 1.8, 2.5, 1.9, 2.3, 2.0, 1.7, 2.4, 2.2, 1.6, 2.6, 1.4, 2.8, 2.1, 1.9, 2.3,
1780            2.0, 1.8, 2.5,
1781        ];
1782        let r = fit_gamma(&data).expect("should compute");
1783        assert_eq!(r.distribution, "Gamma");
1784        assert_eq!(r.n_params, 2);
1785        assert!(r.parameters[0].1 > 0.0, "α̂ > 0");
1786        assert!(r.parameters[1].1 > 0.0, "β̂ > 0");
1787        assert!(r.log_likelihood.is_finite());
1788    }
1789
1790    #[test]
1791    fn fit_gamma_edge_cases() {
1792        assert!(fit_gamma(&[1.0]).is_none()); // < 2
1793        assert!(fit_gamma(&[1.0, -1.0]).is_none()); // negative
1794        assert!(fit_gamma(&[0.0, 1.0]).is_none()); // zero
1795        assert!(fit_gamma(&[1.0, f64::NAN]).is_none());
1796        assert!(fit_gamma(&[5.0, 5.0, 5.0]).is_none()); // zero variance
1797    }
1798
1799    #[test]
1800    fn fit_gamma_mean_rate_relationship() {
1801        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
1802        let r = fit_gamma(&data).expect("should compute");
1803        let alpha = r.parameters[0].1;
1804        let beta = r.parameters[1].1;
1805        let mean: f64 = data.iter().sum::<f64>() / data.len() as f64;
1806        // β̂ = α̂/x̄, so α̂/β̂ ≈ x̄
1807        assert!(
1808            (alpha / beta - mean).abs() < 1e-6,
1809            "α/β = {}, mean = {}",
1810            alpha / beta,
1811            mean
1812        );
1813    }
1814
1815    #[test]
1816    fn fit_best_sorted_by_aic() {
1817        let data = [2.1, 3.5, 1.8, 4.2, 2.9, 3.1, 2.5, 3.8, 1.5, 2.7];
1818        let fits = fit_best(&data);
1819        assert!(!fits.is_empty());
1820        // Should include Normal and Gamma (and Exponential since all > 0)
1821        assert!(fits.len() >= 2, "got {} fits", fits.len());
1822        // Verify AIC ordering
1823        for w in fits.windows(2) {
1824            assert!(
1825                w[0].aic <= w[1].aic,
1826                "AIC not sorted: {} > {}",
1827                w[0].aic,
1828                w[1].aic
1829            );
1830        }
1831    }
1832
1833    #[test]
1834    fn fit_best_mixed_sign_data() {
1835        // Negative values → only Normal should succeed
1836        let data = [-2.0, -1.0, 0.5, 1.0, 2.0, 3.0, 1.5, -0.5];
1837        let fits = fit_best(&data);
1838        assert_eq!(fits.len(), 1, "only Normal should fit");
1839        assert_eq!(fits[0].distribution, "Normal");
1840    }
1841
1842    #[test]
1843    fn digamma_known_values() {
1844        // ψ(1) = -γ ≈ -0.5772156649
1845        assert!((digamma(1.0) + 0.5772156649).abs() < 1e-8);
1846        // ψ(2) = 1 - γ ≈ 0.4227843351
1847        assert!((digamma(2.0) - 0.4227843351).abs() < 1e-8);
1848        // ψ(0.5) = -γ - 2·ln(2) ≈ -1.9635100260
1849        assert!((digamma(0.5) + 1.9635100260).abs() < 1e-7);
1850    }
1851
1852    #[test]
1853    fn trigamma_known_values() {
1854        // ψ'(1) = π²/6 ≈ 1.6449340668
1855        assert!((trigamma(1.0) - std::f64::consts::PI.powi(2) / 6.0).abs() < 1e-7);
1856        // ψ'(2) = π²/6 - 1 ≈ 0.6449340668
1857        assert!((trigamma(2.0) - (std::f64::consts::PI.powi(2) / 6.0 - 1.0)).abs() < 1e-7);
1858    }
1859
1860    // -----------------------------------------------------------------------
1861    // LogNormal fitting
1862    // -----------------------------------------------------------------------
1863
1864    #[test]
1865    fn fit_lognormal_basic() {
1866        let data = [1.2, 2.5, 1.8, 3.1, 0.9, 2.0, 1.5, 4.0];
1867        let r = fit_lognormal(&data).expect("should compute");
1868        assert_eq!(r.distribution, "LogNormal");
1869        assert_eq!(r.n_params, 2);
1870        assert!(r.parameters[1].1 > 0.0); // σ̂ > 0
1871        assert!(r.log_likelihood.is_finite());
1872    }
1873
1874    #[test]
1875    fn fit_lognormal_params_match_log_transform() {
1876        let data = [1.0, 2.0, 3.0, 4.0, 5.0];
1877        let r = fit_lognormal(&data).expect("should compute");
1878        let mu = r.parameters[0].1;
1879        let sigma = r.parameters[1].1;
1880        // μ̂ should be mean of ln(data)
1881        let log_data: Vec<f64> = data.iter().map(|&x| x.ln()).collect();
1882        let expected_mu: f64 = log_data.iter().sum::<f64>() / 5.0;
1883        assert!(
1884            (mu - expected_mu).abs() < 1e-10,
1885            "μ = {}, expected {}",
1886            mu,
1887            expected_mu
1888        );
1889        assert!(sigma > 0.0);
1890    }
1891
1892    #[test]
1893    fn fit_lognormal_edge_cases() {
1894        assert!(fit_lognormal(&[1.0]).is_none()); // < 2
1895        assert!(fit_lognormal(&[1.0, -1.0]).is_none()); // negative
1896        assert!(fit_lognormal(&[0.0, 1.0]).is_none()); // zero
1897    }
1898
1899    // -----------------------------------------------------------------------
1900    // Poisson fitting
1901    // -----------------------------------------------------------------------
1902
1903    #[test]
1904    fn fit_poisson_basic() {
1905        let data = [2.0, 3.0, 1.0, 4.0, 2.0, 3.0, 1.0, 2.0];
1906        let r = fit_poisson(&data).expect("should compute");
1907        assert_eq!(r.distribution, "Poisson");
1908        assert_eq!(r.n_params, 1);
1909        let mean: f64 = data.iter().sum::<f64>() / data.len() as f64;
1910        assert!(
1911            (r.parameters[0].1 - mean).abs() < 1e-10,
1912            "λ̂ = {}, expected {}",
1913            r.parameters[0].1,
1914            mean
1915        );
1916    }
1917
1918    #[test]
1919    fn fit_poisson_edge_cases() {
1920        assert!(fit_poisson(&[1.0]).is_none()); // < 2
1921        assert!(fit_poisson(&[1.0, -1.0]).is_none()); // negative
1922        assert!(fit_poisson(&[0.0, 0.0]).is_none()); // all zeros
1923        assert!(fit_poisson(&[1.0, 2.5]).is_none()); // not a count
1924    }
1925
1926    // -----------------------------------------------------------------------
1927    // Beta fitting
1928    // -----------------------------------------------------------------------
1929
1930    #[test]
1931    fn fit_beta_basic() {
1932        let data = [0.2, 0.35, 0.5, 0.15, 0.45, 0.3, 0.6, 0.25, 0.4, 0.55];
1933        let r = fit_beta(&data).expect("should compute");
1934        assert_eq!(r.distribution, "Beta");
1935        assert_eq!(r.n_params, 2);
1936        assert!(r.parameters[0].1 > 0.0, "α̂ > 0");
1937        assert!(r.parameters[1].1 > 0.0, "β̂ > 0");
1938        assert!(r.log_likelihood.is_finite());
1939    }
1940
1941    #[test]
1942    fn fit_beta_symmetric() {
1943        // Symmetric data around 0.5 → α ≈ β
1944        let data = [0.3, 0.4, 0.5, 0.6, 0.7, 0.35, 0.45, 0.55, 0.65, 0.5];
1945        let r = fit_beta(&data).expect("should compute");
1946        let alpha = r.parameters[0].1;
1947        let beta = r.parameters[1].1;
1948        // α and β should be roughly equal for symmetric data
1949        assert!(
1950            (alpha - beta).abs() / alpha < 0.3,
1951            "α = {alpha}, β = {beta}, should be roughly equal"
1952        );
1953    }
1954
1955    #[test]
1956    fn fit_beta_edge_cases() {
1957        assert!(fit_beta(&[0.5]).is_none()); // < 2
1958        assert!(fit_beta(&[0.5, 1.0]).is_none()); // value = 1.0 (boundary)
1959        assert!(fit_beta(&[0.0, 0.5]).is_none()); // value = 0.0 (boundary)
1960        assert!(fit_beta(&[0.5, 1.5]).is_none()); // value > 1
1961        assert!(fit_beta(&[0.5, -0.5]).is_none()); // value < 0
1962    }
1963
1964    // -----------------------------------------------------------------------
1965    // fit_best (updated)
1966    // -----------------------------------------------------------------------
1967
1968    #[test]
1969    fn fit_best_compares_the_continuous_families_that_apply() {
1970        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
1971        let fits = fit_best(&data);
1972        let names: Vec<&str> = fits.iter().map(|f| f.distribution.as_str()).collect();
1973        // All > 0 and above 1: every positive family, not Beta.
1974        for family in ["Normal", "Exponential", "Gamma", "LogNormal", "Weibull"] {
1975            assert!(
1976                names.contains(&family),
1977                "should include {family}: {names:?}"
1978            );
1979        }
1980        assert!(!names.contains(&"Beta"), "{names:?}");
1981        // Counts are still data a density is fitted to; a mass is not ranked
1982        // against densities.
1983        assert!(!names.contains(&"Poisson"), "{names:?}");
1984
1985        let proportions = [0.2, 0.35, 0.5, 0.15, 0.45, 0.3, 0.6, 0.25, 0.4, 0.55];
1986        let names: Vec<String> = fit_best(&proportions)
1987            .into_iter()
1988            .map(|f| f.distribution)
1989            .collect();
1990        assert!(names.iter().any(|n| n == "Beta"), "{names:?}");
1991    }
1992
1993    /// The ranking a mass and a density cannot share: rescaling continuous
1994    /// data moves every density's log-likelihood by n·ln(scale) and leaves the
1995    /// order among densities alone -- so the winner must not depend on units.
1996    #[test]
1997    fn fit_best_ranking_does_not_depend_on_the_unit() {
1998        let data = [2.0, 3.0, 1.0, 4.0, 2.0, 3.0, 5.0, 2.0, 3.0, 4.0];
1999        let in_thousandths: Vec<f64> = data.iter().map(|v| v * 1000.0).collect();
2000        let order = |d: &[f64]| -> Vec<String> {
2001            fit_best(d).into_iter().map(|f| f.distribution).collect()
2002        };
2003        assert_eq!(order(&data), order(&in_thousandths));
2004    }
2005
2006    #[test]
2007    fn fit_weibull_matches_the_weibull_mle() {
2008        let data = [12.0, 25.0, 31.0, 44.0, 52.0, 60.0, 71.0, 85.0];
2009        let fit = fit_weibull(&data).expect("positive data");
2010        let mle = crate::weibull::weibull_mle(&data).expect("same data");
2011        assert_eq!(fit.parameters[0], ("shape".to_string(), mle.shape));
2012        assert_eq!(fit.parameters[1], ("scale".to_string(), mle.scale));
2013        assert!((fit.aic - (-2.0 * mle.log_likelihood + 4.0)).abs() < 1e-12);
2014        assert!(fit_weibull(&[1.0, 0.0, 2.0]).is_none());
2015    }
2016}
2017
2018#[cfg(test)]
2019mod proptests {
2020    use super::*;
2021    use proptest::prelude::*;
2022
2023    proptest! {
2024        #[test]
2025        fn ecdf_last_is_one(
2026            data in proptest::collection::vec(-1e3_f64..1e3, 1..=50)
2027        ) {
2028            if let Some((_, probs)) = ecdf(&data) {
2029                let last = *probs.last().expect("non-empty");
2030                prop_assert!((last - 1.0).abs() < 1e-10, "last prob = {last}");
2031            }
2032        }
2033
2034        #[test]
2035        fn ecdf_monotonic(
2036            data in proptest::collection::vec(-1e3_f64..1e3, 2..=50)
2037        ) {
2038            if let Some((_, probs)) = ecdf(&data) {
2039                for w in probs.windows(2) {
2040                    prop_assert!(w[1] >= w[0] - 1e-15, "{} < {}", w[1], w[0]);
2041                }
2042            }
2043        }
2044
2045        #[test]
2046        fn hist_counts_sum_to_n(
2047            data in proptest::collection::vec(-1e3_f64..1e3, 5..=100)
2048        ) {
2049            if let Some(r) = histogram_bins(&data, BinMethod::Sturges) {
2050                let total: usize = r.counts.iter().sum();
2051                prop_assert_eq!(total, data.len());
2052            }
2053        }
2054
2055        #[test]
2056        fn kde_density_non_negative(
2057            data in proptest::collection::vec(-100.0_f64..100.0, 5..=50)
2058        ) {
2059            if let Some(r) = kde(&data, BandwidthMethod::Silverman, 128) {
2060                for &d in &r.density {
2061                    prop_assert!(d >= 0.0, "negative density: {d}");
2062                }
2063            }
2064        }
2065
2066        #[test]
2067        fn kde_integral_approx_one(
2068            data in proptest::collection::vec(-100.0_f64..100.0, 10..=80)
2069        ) {
2070            if let Some(r) = kde(&data, BandwidthMethod::Silverman, 512) {
2071                let dx = r.x[1] - r.x[0];
2072                let integral: f64 = r.density.iter().sum::<f64>() * dx;
2073                prop_assert!(
2074                    (integral - 1.0).abs() < 0.1,
2075                    "integral = {integral}, expected ≈ 1.0"
2076                );
2077            }
2078        }
2079
2080        #[test]
2081        fn fit_normal_aic_finite(
2082            data in proptest::collection::vec(-100.0_f64..100.0, 5..=50)
2083        ) {
2084            if let Some(r) = fit_normal(&data) {
2085                prop_assert!(r.aic.is_finite(), "AIC = {}", r.aic);
2086                prop_assert!(r.bic.is_finite(), "BIC = {}", r.bic);
2087                prop_assert!(r.log_likelihood.is_finite(), "LL = {}", r.log_likelihood);
2088            }
2089        }
2090
2091        #[test]
2092        fn fit_gamma_alpha_positive(
2093            data in proptest::collection::vec(0.1_f64..100.0, 5..=50)
2094        ) {
2095            if let Some(r) = fit_gamma(&data) {
2096                let alpha = r.parameters[0].1;
2097                let beta = r.parameters[1].1;
2098                prop_assert!(alpha > 0.0, "α = {alpha}");
2099                prop_assert!(beta > 0.0, "β = {beta}");
2100                prop_assert!(r.aic.is_finite(), "AIC = {}", r.aic);
2101            }
2102        }
2103    }
2104}
2105
2106/// `fit_best` against the maximum-likelihood fits of an independent
2107/// computation: closed forms for Normal, Exponential and LogNormal, and the
2108/// score equations solved to 1e-15 (SciPy `brentq` / `root`, digamma from
2109/// SciPy) for Gamma, Weibull and Beta, with the log-likelihood summed from
2110/// SciPy's own `logpdf`. `scipy.stats.<family>.fit` is not the reference — it
2111/// stops short of the optimum (Weibull, Gamma).
2112#[cfg(test)]
2113mod scipy_reference {
2114    use super::*;
2115
2116    type Want = (&'static str, &'static [f64], f64, f64, f64);
2117
2118    fn close(got: f64, want: f64) -> bool {
2119        (got - want).abs() <= 1e-9 * want.abs().max(1.0)
2120    }
2121
2122    fn check(data: &[f64], want: &[Want], what: &str) {
2123        let got = fit_best(data);
2124        let names: Vec<&str> = got.iter().map(|f| f.distribution.as_str()).collect();
2125        let wanted: Vec<&str> = want.iter().map(|w| w.0).collect();
2126        assert_eq!(names, wanted, "{what}: families in AIC order");
2127        for (f, w) in got.iter().zip(want) {
2128            let name = &f.distribution;
2129            assert_eq!(f.parameters.len(), w.1.len(), "{what} {name}: parameters");
2130            for ((pname, p), q) in f.parameters.iter().zip(w.1) {
2131                assert!(
2132                    close(*p, *q),
2133                    "{what} {name} {pname}: got {p}, reference {q}"
2134                );
2135            }
2136            assert!(
2137                close(f.log_likelihood, w.2),
2138                "{what} {name} log-likelihood: got {}, reference {}",
2139                f.log_likelihood,
2140                w.2
2141            );
2142            assert!(
2143                close(f.aic, w.3),
2144                "{what} {name} AIC: got {}, reference {}",
2145                f.aic,
2146                w.3
2147            );
2148            assert!(
2149                close(f.bic, w.4),
2150                "{what} {name} BIC: got {}, reference {}",
2151                f.bic,
2152                w.4
2153            );
2154        }
2155    }
2156
2157    #[test]
2158    fn data_inside_the_unit_interval_is_fitted_by_all_six_families() {
2159        check(
2160            &[
2161                0.12, 0.23, 0.31, 0.38, 0.44, 0.52, 0.57, 0.63, 0.71, 0.78, 0.86, 0.93,
2162            ],
2163            &[
2164                (
2165                    "Beta",
2166                    &[1.8438565181620945, 1.5583703583443078],
2167                    1.035200520768992,
2168                    1.929598958462016,
2169                    2.8994122580380166,
2170                ),
2171                (
2172                    "Weibull",
2173                    &[2.391258961247938, 0.6091890535880672],
2174                    0.10397825424951779,
2175                    3.7920434915009644,
2176                    4.761856791076966,
2177                ),
2178                (
2179                    "Normal",
2180                    &[0.54, 0.24348168993444524],
2181                    -0.0746999829213777,
2182                    4.149399965842756,
2183                    5.119213265418756,
2184                ),
2185                (
2186                    "Gamma",
2187                    &[3.7996792156263166, 7.036442991900586],
2188                    -0.4996467398561213,
2189                    4.999293479712243,
2190                    5.969106779288243,
2191                ),
2192                (
2193                    "LogNormal",
2194                    &[-0.7535094301720809, 0.5764955804748335],
2195                    -1.3756979711767767,
2196                    6.751395942353554,
2197                    7.721209241929554,
2198                ),
2199                (
2200                    "Exponential",
2201                    &[1.8518518518518516],
2202                    -4.605766326914196,
2203                    11.211532653828392,
2204                    11.696439303616392,
2205                ),
2206            ],
2207            "unit interval",
2208        );
2209    }
2210
2211    #[test]
2212    fn positive_data_is_fitted_by_the_five_positive_families() {
2213        check(
2214            &[2.1, 3.5, 1.8, 4.2, 2.9, 3.1, 2.5, 3.8, 1.5, 2.7, 6.4, 9.8],
2215            &[
2216                (
2217                    "LogNormal",
2218                    &[1.1668644506389008, 0.5022892551220499],
2219                    -22.766686368395682,
2220                    49.533372736791364,
2221                    50.50318603636737,
2222                ),
2223                (
2224                    "Gamma",
2225                    &[3.7501260541767762, 1.0158355000027386],
2226                    -23.629758495985087,
2227                    51.25951699197017,
2228                    52.22933029154618,
2229                ),
2230                (
2231                    "Weibull",
2232                    &[1.8208938249614839, 4.190960652455631],
2233                    -24.62416845020696,
2234                    53.24833690041392,
2235                    54.21815019998992,
2236                ),
2237                (
2238                    "Normal",
2239                    &[3.6916666666666664, 2.2220329780531065],
2240                    -26.60833279104041,
2241                    57.21666558208082,
2242                    58.186478881656825,
2243                ),
2244                (
2245                    "Exponential",
2246                    &[0.2708803611738149],
2247                    -27.672936327157075,
2248                    57.34587265431415,
2249                    57.83077930410215,
2250                ),
2251            ],
2252            "positive",
2253        );
2254    }
2255
2256    #[test]
2257    fn data_of_mixed_sign_is_fitted_by_the_normal_alone() {
2258        check(
2259            &[-1.2, 0.4, 2.3, -0.7, 1.9, 0.8, -2.1, 3.1],
2260            &[(
2261                "Normal",
2262                &[0.5625, 1.6999540434964704],
2263                -15.596318005312547,
2264                35.192636010625094,
2265                35.35151909398476,
2266            )],
2267            "mixed sign",
2268        );
2269    }
2270}
2271
2272/// Goodness of fit against SciPy 1.17.1 (`ad_ref.py` / `p_ref.py` in the
2273/// cycle log): A² from `logcdf` / `logsf` of the same distribution, and the
2274/// parametric bootstrap of `scipy.stats.goodness_of_fit(statistic="ad")`.
2275#[cfg(test)]
2276mod goodness_of_fit_reference {
2277    use super::*;
2278
2279    const POS: &[f64] = &[
2280        3.2, 4.8, 5.1, 6.0, 6.7, 7.3, 8.1, 9.4, 10.2, 12.5, 15.8, 41.0,
2281    ];
2282    const UNIT: &[f64] = &[
2283        0.04, 0.11, 0.19, 0.23, 0.31, 0.38, 0.42, 0.55, 0.61, 0.74, 0.88, 0.97,
2284    ];
2285
2286    fn fixed(distribution: &str, parameters: &[(&str, f64)]) -> FitResult {
2287        FitResult {
2288            distribution: distribution.to_string(),
2289            parameters: parameters
2290                .iter()
2291                .map(|&(n, v)| (n.to_string(), v))
2292                .collect(),
2293            log_likelihood: 0.0,
2294            aic: 0.0,
2295            bic: 0.0,
2296            n_params: parameters.len(),
2297        }
2298    }
2299
2300    /// Every family's A² against fixed parameters — including a value (41) in
2301    /// the far upper tail of the Normal and Exponential, where `1 − F` taken by
2302    /// subtraction would lose its digits.
2303    #[test]
2304    fn anderson_darling_statistic_reproduces_scipy_for_every_family() {
2305        type Case = (
2306            &'static str,
2307            &'static [(&'static str, f64)],
2308            &'static [f64],
2309            f64,
2310        );
2311        let cases: [Case; 6] = [
2312            (
2313                "Normal",
2314                &[("mu", 9.0), ("sigma", 4.0)],
2315                POS,
2316                3.0398709332150187,
2317            ),
2318            (
2319                "LogNormal",
2320                &[("mu", 2.0), ("sigma", 0.6)],
2321                POS,
2322                0.4154008172250965,
2323            ),
2324            (
2325                "Weibull",
2326                &[("shape", 1.7), ("scale", 11.0)],
2327                POS,
2328                0.8259972063661056,
2329            ),
2330            (
2331                "Gamma",
2332                &[("alpha", 2.5), ("beta", 0.25)],
2333                POS,
2334                0.5575965468003456,
2335            ),
2336            ("Exponential", &[("lambda", 0.1)], POS, 1.235635893786549),
2337            (
2338                "Beta",
2339                &[("alpha", 1.3), ("beta", 1.6)],
2340                UNIT,
2341                0.34159812117638744,
2342            ),
2343        ];
2344        for (name, params, data, want) in cases {
2345            let got = anderson_darling_fit(data, &fixed(name, params)).unwrap();
2346            assert!(
2347                ((got - want) / want).abs() < 1e-9,
2348                "{name}: A² {got}, SciPy {want}"
2349            );
2350        }
2351    }
2352
2353    /// The bootstrap p-value agrees with SciPy's Monte Carlo of the same test
2354    /// (99 999 samples there, 9 999 here: standard errors 0.001 and 0.004).
2355    /// Exponential only: SciPy's Normal fit takes the n − 1 standard deviation
2356    /// where `fit_normal` takes the maximum-likelihood one, so its A² (0.4988)
2357    /// is a different test's statistic (A² at the MLE fit: 0.5086, as here).
2358    #[test]
2359    fn bootstrap_p_value_agrees_with_scipy_goodness_of_fit() {
2360        let data = [
2361            3.2, 4.8, 5.1, 6.0, 6.7, 7.3, 8.1, 9.4, 10.2, 12.5, 15.8, 21.0,
2362        ];
2363        let fit = fit_exponential(&data).unwrap();
2364        let (statistic, p) = (1.4502868637902981, 0.03158);
2365        let g = goodness_of_fit(&data, &fit, 9999, 3).unwrap();
2366        assert!(
2367            ((g.statistic - statistic) / statistic).abs() < 1e-9,
2368            "A² {} vs {statistic}",
2369            g.statistic
2370        );
2371        assert!(
2372            (g.p_value - p).abs() < 0.015,
2373            "p {} vs SciPy {p}",
2374            g.p_value
2375        );
2376    }
2377
2378    /// The case that motivated the test: uniform data, whose best-AIC family
2379    /// places the median at 4400 of 1..10 000 — no listed family fits it, and
2380    /// the test says so; a sample drawn from a family is not rejected.
2381    #[test]
2382    fn rejects_the_best_family_for_uniform_data_and_keeps_a_true_one() {
2383        use u_numflow::distributions::Sample;
2384        let uniform: Vec<f64> = (1..=2000).map(f64::from).collect();
2385        let best = &fit_best(&uniform)[0];
2386        let g = goodness_of_fit(&uniform, best, DEFAULT_GOF_REPLICATES, 0).unwrap();
2387        assert_eq!(g.p_value, 1.0 / 200.0, "{}: {g:?}", best.distribution);
2388
2389        let weibull = Weibull::new(2.0, 10.0).unwrap().sample_n(300, 11);
2390        let fit = fit_weibull(&weibull).unwrap();
2391        let g = goodness_of_fit(&weibull, &fit, DEFAULT_GOF_REPLICATES, 0).unwrap();
2392        assert!(g.p_value > 0.05, "{g:?}");
2393    }
2394
2395    #[test]
2396    fn same_seed_same_p_value_and_refusals() {
2397        let data = [2.1, 3.5, 1.8, 4.2, 2.9, 3.1, 2.5, 3.8, 1.5, 2.7];
2398        let fit = fit_gamma(&data).unwrap();
2399        let a = goodness_of_fit(&data, &fit, 99, 7).unwrap();
2400        assert_eq!(a, goodness_of_fit(&data, &fit, 99, 7).unwrap());
2401        assert_eq!(a.replicates, 99);
2402        assert!(goodness_of_fit(&data, &fit, 0, 7).is_none());
2403        assert!(goodness_of_fit(&[1.0], &fit, 9, 7).is_none());
2404        assert!(goodness_of_fit(&[1.0, f64::NAN, 2.0], &fit, 9, 7).is_none());
2405        let poisson = fit_poisson(&[1.0, 2.0, 2.0, 3.0]).unwrap();
2406        assert!(anderson_darling_fit(&data, &poisson).is_none());
2407    }
2408
2409    /// The statistic and the p-value do not depend on the unit of the data.
2410    #[test]
2411    fn unit_invariant() {
2412        let data = [2.1, 3.5, 1.8, 4.2, 2.9, 3.1, 2.5, 3.8, 1.5, 2.7, 6.3, 2.2];
2413        for scale in [1e-9, 1.0, 1e9] {
2414            let x: Vec<f64> = data.iter().map(|v| v * scale).collect();
2415            for fit in fit_best(&x) {
2416                let base: Vec<f64> = data.to_vec();
2417                let reference = fit_best(&base)
2418                    .into_iter()
2419                    .find(|f| f.distribution == fit.distribution);
2420                let Some(reference) = reference else { continue };
2421                let a = goodness_of_fit(&x, &fit, 49, 1).unwrap();
2422                let b = goodness_of_fit(&base, &reference, 49, 1).unwrap();
2423                assert!(
2424                    (a.statistic - b.statistic).abs() < 1e-6 * b.statistic.max(1.0),
2425                    "{} at ×{scale}: {} vs {}",
2426                    fit.distribution,
2427                    a.statistic,
2428                    b.statistic
2429                );
2430            }
2431        }
2432    }
2433}
2434
2435/// The density against SciPy's `gaussian_kde` with the same bandwidth, and the
2436/// two rules against their formulas.
2437#[cfg(test)]
2438mod scipy_kde_reference {
2439    use super::*;
2440
2441    // SciPy 1.17.1, NumPy 2.4.6: scipy.stats.gaussian_kde(data, bw_method=h / std(data, ddof=1))
2442    // evaluated on the grid kde builds; Silverman / Scott by their formulas (np.percentile, linear).
2443    const DATA: [f64; 12] = [2.1, 3.4, 1.9, 5.6, 4.4, 3.3, 7.8, 2.6, 3.9, 4.1, 6.2, 2.8];
2444    const SILVERMAN: f64 = 0.7967756865840121;
2445    const SCOTT: f64 = 1.1385490329223509;
2446    const SILVERMAN_DENSITY: [f64; 9] = [
2447        0.0007066889808412874,
2448        0.03580684164023267,
2449        0.18353112427620594,
2450        0.23798379981197426,
2451        0.13633500262791384,
2452        0.08452484667619467,
2453        0.052115888610237714,
2454        0.017530164985399863,
2455        0.00046367095048729804,
2456    ];
2457    const SCOTT_DENSITY: [f64; 9] = [
2458        0.0005845362991529891,
2459        0.019392325594481553,
2460        0.12301502047560456,
2461        0.2108829109504221,
2462        0.14715473950228602,
2463        0.07867154443668554,
2464        0.0398740041713175,
2465        0.008464240871237205,
2466        0.00032631483638323043,
2467    ];
2468    const MANUAL_DENSITY: [f64; 9] = [
2469        0.0007323711273510353,
2470        0.04017958036368482,
2471        0.193937781081597,
2472        0.2410153369290394,
2473        0.13316172496294076,
2474        0.08659812892373103,
2475        0.05293645833627802,
2476        0.02001662229267105,
2477        0.0004925126676146541,
2478    ];
2479
2480    fn close(a: &[f64], b: &[f64]) {
2481        assert_eq!(a.len(), b.len());
2482        for (i, (x, y)) in a.iter().zip(b).enumerate() {
2483            assert!(
2484                (x - y).abs() <= 1e-12 * y.abs().max(1e-3),
2485                "point {i}: {x} vs {y}"
2486            );
2487        }
2488    }
2489
2490    #[test]
2491    fn the_density_and_both_rules_match_scipy() {
2492        let s = kde(&DATA, BandwidthMethod::Silverman, 9).expect("a density");
2493        assert!((s.bandwidth - SILVERMAN).abs() <= 1e-12 * SILVERMAN);
2494        close(&s.density, &SILVERMAN_DENSITY);
2495        let s = kde(&DATA, BandwidthMethod::Scott, 9).expect("a density");
2496        assert!((s.bandwidth - SCOTT).abs() <= 1e-12 * SCOTT);
2497        close(&s.density, &SCOTT_DENSITY);
2498        let m = kde(&DATA, BandwidthMethod::Manual(0.75), 9).expect("a density");
2499        close(&m.density, &MANUAL_DENSITY);
2500    }
2501}