Skip to main content

rs_stats/distributions/
fitting.rs

1//! # Distribution Fitting
2//!
3//! High-level API for automatic distribution detection and fitting.
4//!
5//! Given a dataset, this module:
6//! 1. **Detects** whether the data is discrete or continuous (`detect_data_type`)
7//! 2. **Fits** all applicable distribution candidates using MLE or MOM
8//! 3. **Ranks** them by AIC (lower = better fit, penalised for complexity)
9//! 4. **Validates** with a Kolmogorov-Smirnov goodness-of-fit test
10//!
11//! ## Key functions
12//!
13//! | Function | Description |
14//! |----------|-------------|
15//! | `auto_fit(data)` | Auto-detect type + return single best fit |
16//! | `fit_all(data)` | All 10 continuous distributions, ranked by AIC |
17//! | `fit_best(data)` | Best continuous distribution (lowest AIC) |
18//! | `fit_all_discrete(data)` | All 4 discrete distributions, ranked by AIC |
19//! | `fit_best_discrete(data)` | Best discrete distribution |
20//! | `detect_data_type(data)` | `DataKind::Discrete` or `DataKind::Continuous` |
21//! | `ks_test(data, cdf)` | Two-sided KS test for continuous distributions |
22//! | `ks_test_discrete(data, cdf)` | KS test for discrete distributions |
23//!
24//! ## Medical example — identifying the best distribution for drug half-life
25//!
26//! ```rust
27//! use rs_stats::distributions::fitting::{fit_all, auto_fit};
28//!
29//! // Drug half-life (hours) measured in 20 patients — typically log-normal in PK studies
30//! let half_lives = vec![
31//!     4.2, 6.1, 3.8, 9.5, 5.3, 7.4, 4.9, 11.2, 3.5, 6.8,
32//!     8.1, 4.4, 5.7, 7.0, 3.9, 10.3, 5.1,  6.5, 4.7,  8.6,
33//! ];
34//!
35//! // One-call: auto-detect + return single best (lowest AIC)
36//! let best = auto_fit(&half_lives).unwrap();
37//! println!("Best fit: {} (AIC={:.2}, KS p={:.3})", best.name, best.aic, best.ks_p_value);
38//!
39//! // Full ranking for model comparison and reporting
40//! println!("{:<15} {:>8} {:>8} {:>10}", "Distribution", "AIC", "BIC", "KS p-value");
41//! for r in fit_all(&half_lives).unwrap() {
42//!     println!("{:<15} {:>8.2} {:>8.2} {:>10.4}", r.name, r.aic, r.bic, r.ks_p_value);
43//! }
44//! ```
45//!
46//! ## Medical example — discrete event counts (adverse reactions)
47//!
48//! ```rust
49//! use rs_stats::distributions::fitting::fit_all_discrete;
50//!
51//! // Adverse drug reaction counts per patient over 6 months
52//! let adr_counts = vec![0.0, 1.0, 0.0, 2.0, 0.0, 1.0, 3.0, 0.0, 1.0, 0.0,
53//!                       2.0, 1.0, 0.0, 0.0, 1.0, 4.0, 0.0, 2.0, 1.0, 0.0];
54//!
55//! for r in fit_all_discrete(&adr_counts).unwrap() {
56//!     println!("{:<20} AIC={:.2}  KS p={:.3}", r.name, r.aic, r.ks_p_value);
57//! }
58//! // Poisson usually wins when variance ≈ mean; NegativeBinomial wins if overdispersed
59//! ```
60
61use crate::distributions::{
62    beta::Beta,
63    binomial_distribution::Binomial,
64    chi_squared::ChiSquared,
65    f_distribution::FDistribution,
66    gamma_distribution::Gamma,
67    geometric::Geometric,
68    lognormal::LogNormal,
69    negative_binomial::NegativeBinomial,
70    normal_distribution::Normal,
71    poisson_distribution::Poisson,
72    student_t::StudentT,
73    traits::{DiscreteDistribution, Distribution},
74    uniform_distribution::Uniform,
75    weibull::Weibull,
76};
77use crate::error::{StatsError, StatsResult};
78
79// ── Data kind detection ────────────────────────────────────────────────────────
80
81/// Whether a dataset looks discrete or continuous.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum DataKind {
84    /// All values are non-negative integers (whole numbers ≥ 0).
85    Discrete,
86    /// Contains non-integer or negative values — treated as continuous.
87    Continuous,
88}
89
90/// Infer whether `data` is discrete (all non-negative integers) or continuous.
91///
92/// # Examples
93/// ```
94/// use rs_stats::distributions::fitting::{detect_data_type, DataKind};
95///
96/// assert_eq!(detect_data_type(&[0.0, 1.0, 2.0, 3.0]), DataKind::Discrete);
97/// assert_eq!(detect_data_type(&[0.5, 1.5, 2.3]), DataKind::Continuous);
98/// ```
99pub fn detect_data_type(data: &[f64]) -> DataKind {
100    if data
101        .iter()
102        .all(|&x| x >= 0.0 && x.fract() == 0.0 && x.is_finite())
103    {
104        DataKind::Discrete
105    } else {
106        DataKind::Continuous
107    }
108}
109
110// ── Kolmogorov-Smirnov test ────────────────────────────────────────────────────
111
112/// Result of a Kolmogorov-Smirnov goodness-of-fit test.
113#[derive(Debug, Clone, Copy)]
114pub struct KsResult {
115    /// KS statistic D (maximum absolute deviation between empirical and theoretical CDF).
116    pub statistic: f64,
117    /// Approximate two-sided p-value.
118    pub p_value: f64,
119}
120
121/// Two-sided Kolmogorov-Smirnov test of `data` against `cdf`.
122///
123/// Uses the Kolmogorov distribution for the p-value approximation.
124pub fn ks_test(data: &[f64], cdf: impl Fn(f64) -> f64) -> KsResult {
125    let mut buf = Vec::with_capacity(data.len());
126    buf.extend_from_slice(data);
127    ks_test_with_scratch(&mut buf, cdf)
128}
129
130/// Zero-allocation variant of [`ks_test`] — sorts the caller-provided
131/// buffer in place and uses it as the working copy.
132///
133/// `scratch` must already contain the data to test (the function does not
134/// copy from anywhere). This lets callers reuse one buffer across many KS
135/// evaluations: see [`fit_all`] which fits 10 candidates from a single
136/// `&[f64]` input.
137pub fn ks_test_with_scratch(scratch: &mut [f64], cdf: impl Fn(f64) -> f64) -> KsResult {
138    let n = scratch.len();
139    if n == 0 {
140        return KsResult {
141            statistic: 0.0,
142            p_value: 1.0,
143        };
144    }
145    scratch.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
146
147    let nf = n as f64;
148    let mut d = 0.0_f64;
149    for (i, &x) in scratch.iter().enumerate() {
150        let f = cdf(x);
151        let upper = (i + 1) as f64 / nf;
152        let lower = i as f64 / nf;
153        d = d.max((upper - f).abs()).max((f - lower).abs());
154    }
155
156    let p_value = kolmogorov_p(((nf).sqrt() + 0.12 + 0.11 / nf.sqrt()) * d);
157
158    KsResult {
159        statistic: d,
160        p_value,
161    }
162}
163
164/// KS test for discrete distributions (uses PMF-based CDF on integer grid).
165pub fn ks_test_discrete(data: &[f64], cdf: impl Fn(u64) -> f64) -> KsResult {
166    let mut buf = Vec::with_capacity(data.len());
167    buf.extend_from_slice(data);
168    ks_test_discrete_with_scratch(&mut buf, cdf)
169}
170
171/// Zero-allocation variant of [`ks_test_discrete`].
172pub fn ks_test_discrete_with_scratch(scratch: &mut [f64], cdf: impl Fn(u64) -> f64) -> KsResult {
173    let n = scratch.len();
174    if n == 0 {
175        return KsResult {
176            statistic: 0.0,
177            p_value: 1.0,
178        };
179    }
180    scratch.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
181
182    let nf = n as f64;
183    let mut d = 0.0_f64;
184    for (i, &x) in scratch.iter().enumerate() {
185        let k = x.round() as u64;
186        let f = cdf(k);
187        let upper = (i + 1) as f64 / nf;
188        let lower = i as f64 / nf;
189        d = d.max((upper - f).abs()).max((f - lower).abs());
190    }
191
192    let p_value = kolmogorov_p(((nf).sqrt() + 0.12 + 0.11 / nf.sqrt()) * d);
193
194    KsResult {
195        statistic: d,
196        p_value,
197    }
198}
199
200/// Approximate p-value of the Kolmogorov distribution at `x`.
201fn kolmogorov_p(x: f64) -> f64 {
202    if x <= 0.0 {
203        return 1.0;
204    }
205    // P(K > x) = 2 Σ_{j=1}^∞ (−1)^{j+1} exp(−2j²x²)
206    let mut sum = 0.0_f64;
207    for j in 1_u32..=100 {
208        let term = (-(2.0 * (j as f64).powi(2) * x * x)).exp();
209        if j % 2 == 1 {
210            sum += term;
211        } else {
212            sum -= term;
213        }
214        if term < 1e-15 {
215            break;
216        }
217    }
218    (2.0 * sum).clamp(0.0, 1.0)
219}
220
221// ── Fit result ─────────────────────────────────────────────────────────────────
222
223/// Summary of a distribution fit.
224#[derive(Debug, Clone)]
225pub struct FitResult {
226    /// Distribution name (e.g. `"Normal"`, `"Gamma"`).
227    pub name: String,
228    /// Akaike Information Criterion (lower = better).
229    pub aic: f64,
230    /// Bayesian Information Criterion (lower = better).
231    pub bic: f64,
232    /// KS test statistic D.
233    pub ks_statistic: f64,
234    /// KS test p-value (higher = better fit).
235    pub ks_p_value: f64,
236}
237
238// ── Continuous fitting ─────────────────────────────────────────────────────────
239
240/// Fit all continuous distributions to `data` and return ranked results (by AIC).
241///
242/// Distributions that fail to fit (e.g. Beta when data are not in (0,1)) are silently skipped.
243pub fn fit_all(data: &[f64]) -> StatsResult<Vec<FitResult>> {
244    if data.is_empty() {
245        return Err(StatsError::InvalidInput {
246            message: "fit_all: data must not be empty".to_string(),
247        });
248    }
249
250    // Pre-allocate KS scratch buffer once; reused across all 10 candidates.
251    let mut ks_buf: Vec<f64> = Vec::with_capacity(data.len());
252    let mut results: Vec<FitResult> = Vec::with_capacity(10);
253
254    macro_rules! try_fit {
255        ($dist_type:ty, $fit_expr:expr) => {
256            if let Ok(dist) = $fit_expr {
257                if let (Ok(aic), Ok(bic)) = (dist.aic(data), dist.bic(data)) {
258                    if aic.is_finite() && bic.is_finite() {
259                        ks_buf.clear();
260                        ks_buf.extend_from_slice(data);
261                        let ks = ks_test_with_scratch(&mut ks_buf, |x| dist.cdf(x).unwrap_or(0.0));
262                        results.push(FitResult {
263                            name: dist.name().to_string(),
264                            aic,
265                            bic,
266                            ks_statistic: ks.statistic,
267                            ks_p_value: ks.p_value,
268                        });
269                    }
270                }
271            }
272        };
273    }
274
275    try_fit!(Normal, Normal::fit(data));
276    try_fit!(
277        Exponential,
278        crate::distributions::exponential_distribution::Exponential::fit(data)
279    );
280    try_fit!(Uniform, Uniform::fit(data));
281    try_fit!(Gamma, Gamma::fit(data));
282    try_fit!(LogNormal, LogNormal::fit(data));
283    try_fit!(Weibull, Weibull::fit(data));
284    try_fit!(Beta, Beta::fit(data));
285    try_fit!(StudentT, StudentT::fit(data));
286    try_fit!(FDistribution, FDistribution::fit(data));
287    try_fit!(ChiSquared, ChiSquared::fit(data));
288
289    if results.is_empty() {
290        return Err(StatsError::InvalidInput {
291            message: "fit_all: no distribution could be fitted to the data".to_string(),
292        });
293    }
294
295    results.sort_by(|a, b| {
296        a.aic
297            .partial_cmp(&b.aic)
298            .unwrap_or(std::cmp::Ordering::Equal)
299    });
300    Ok(results)
301}
302
303/// Fit all continuous distributions and return the best one (lowest AIC).
304pub fn fit_best(data: &[f64]) -> StatsResult<FitResult> {
305    let mut all = fit_all(data)?;
306    Ok(all.remove(0))
307}
308
309// ── Verbose fitting (with diagnostics) ────────────────────────────────────────
310
311/// A distribution candidate that failed to fit, with a human-readable reason.
312///
313/// Returned alongside successful fits by [`fit_all_verbose`] and [`fit_all_discrete_verbose`].
314#[derive(Debug, Clone)]
315pub struct SkippedFit {
316    /// Distribution name (e.g. `"Beta"`).
317    pub name: &'static str,
318    /// Why this distribution was not included (e.g. `"fit failed: data must be in (0,1)"`).
319    pub reason: String,
320}
321
322/// Like [`fit_all`] but also reports which distributions were skipped and why.
323///
324/// # Examples
325/// ```
326/// use rs_stats::distributions::fitting::fit_all_verbose;
327///
328/// // Data outside (0,1): Beta will be skipped
329/// let data = vec![2.1, 3.5, 1.8, 4.2, 2.9];
330/// let (fitted, skipped) = fit_all_verbose(&data).unwrap();
331/// println!("{} distributions fitted, {} skipped", fitted.len(), skipped.len());
332/// for s in &skipped {
333///     println!("  Skipped {}: {}", s.name, s.reason);
334/// }
335/// ```
336pub fn fit_all_verbose(data: &[f64]) -> StatsResult<(Vec<FitResult>, Vec<SkippedFit>)> {
337    if data.is_empty() {
338        return Err(StatsError::InvalidInput {
339            message: "fit_all_verbose: data must not be empty".to_string(),
340        });
341    }
342
343    let mut ks_buf: Vec<f64> = Vec::with_capacity(data.len());
344    let mut results: Vec<FitResult> = Vec::with_capacity(10);
345    let mut skipped: Vec<SkippedFit> = Vec::with_capacity(10);
346
347    macro_rules! try_fit_v {
348        ($name:literal, $fit_expr:expr) => {
349            match $fit_expr {
350                Err(e) => skipped.push(SkippedFit {
351                    name: $name,
352                    reason: format!("fit failed: {e}"),
353                }),
354                Ok(dist) => match (dist.aic(data), dist.bic(data)) {
355                    (Ok(aic), Ok(bic)) if aic.is_finite() && bic.is_finite() => {
356                        ks_buf.clear();
357                        ks_buf.extend_from_slice(data);
358                        let ks = ks_test_with_scratch(&mut ks_buf, |x| dist.cdf(x).unwrap_or(0.0));
359                        results.push(FitResult {
360                            name: dist.name().to_string(),
361                            aic,
362                            bic,
363                            ks_statistic: ks.statistic,
364                            ks_p_value: ks.p_value,
365                        });
366                    }
367                    _ => skipped.push(SkippedFit {
368                        name: $name,
369                        reason: "non-finite AIC/BIC (log-likelihood diverged)".to_string(),
370                    }),
371                },
372            }
373        };
374    }
375
376    try_fit_v!("Normal", Normal::fit(data));
377    try_fit_v!(
378        "Exponential",
379        crate::distributions::exponential_distribution::Exponential::fit(data)
380    );
381    try_fit_v!("Uniform", Uniform::fit(data));
382    try_fit_v!("Gamma", Gamma::fit(data));
383    try_fit_v!("LogNormal", LogNormal::fit(data));
384    try_fit_v!("Weibull", Weibull::fit(data));
385    try_fit_v!("Beta", Beta::fit(data));
386    try_fit_v!("StudentT", StudentT::fit(data));
387    try_fit_v!("FDistribution", FDistribution::fit(data));
388    try_fit_v!("ChiSquared", ChiSquared::fit(data));
389
390    if results.is_empty() {
391        return Err(StatsError::InvalidInput {
392            message: "fit_all_verbose: no distribution could be fitted to the data".to_string(),
393        });
394    }
395
396    results.sort_by(|a, b| {
397        a.aic
398            .partial_cmp(&b.aic)
399            .unwrap_or(std::cmp::Ordering::Equal)
400    });
401    Ok((results, skipped))
402}
403
404/// Like [`fit_all_discrete`] but also reports which distributions were skipped and why.
405pub fn fit_all_discrete_verbose(data: &[f64]) -> StatsResult<(Vec<FitResult>, Vec<SkippedFit>)> {
406    if data.is_empty() {
407        return Err(StatsError::InvalidInput {
408            message: "fit_all_discrete_verbose: data must not be empty".to_string(),
409        });
410    }
411
412    let mut int_data: Vec<u64> = Vec::with_capacity(data.len());
413    int_data.extend(data.iter().map(|&x| x.round() as u64));
414    let mut ks_buf: Vec<f64> = Vec::with_capacity(data.len());
415    let mut results: Vec<FitResult> = Vec::with_capacity(4);
416    let mut skipped: Vec<SkippedFit> = Vec::with_capacity(4);
417
418    macro_rules! try_fit_disc_v {
419        ($name:literal, $fit_expr:expr) => {
420            match $fit_expr {
421                Err(e) => skipped.push(SkippedFit {
422                    name: $name,
423                    reason: format!("fit failed: {e}"),
424                }),
425                Ok(dist) => match (dist.aic(&int_data), dist.bic(&int_data)) {
426                    (Ok(aic), Ok(bic)) if aic.is_finite() && bic.is_finite() => {
427                        ks_buf.clear();
428                        ks_buf.extend_from_slice(data);
429                        let ks = ks_test_discrete_with_scratch(&mut ks_buf, |k| {
430                            dist.cdf(k).unwrap_or(0.0)
431                        });
432                        results.push(FitResult {
433                            name: dist.name().to_string(),
434                            aic,
435                            bic,
436                            ks_statistic: ks.statistic,
437                            ks_p_value: ks.p_value,
438                        });
439                    }
440                    _ => skipped.push(SkippedFit {
441                        name: $name,
442                        reason: "non-finite AIC/BIC (log-likelihood diverged)".to_string(),
443                    }),
444                },
445            }
446        };
447    }
448
449    try_fit_disc_v!("Poisson", Poisson::fit(data));
450    try_fit_disc_v!("Geometric", Geometric::fit(data));
451    try_fit_disc_v!("NegativeBinomial", NegativeBinomial::fit(data));
452    try_fit_disc_v!("Binomial", Binomial::fit(data));
453
454    if results.is_empty() {
455        return Err(StatsError::InvalidInput {
456            message: "fit_all_discrete_verbose: no distribution could be fitted".to_string(),
457        });
458    }
459
460    results.sort_by(|a, b| {
461        a.aic
462            .partial_cmp(&b.aic)
463            .unwrap_or(std::cmp::Ordering::Equal)
464    });
465    Ok((results, skipped))
466}
467
468// ── Discrete fitting ───────────────────────────────────────────────────────────
469
470/// Fit all discrete distributions to integer `data` (passed as f64) and return ranked results.
471///
472/// Skips distributions that cannot be fitted.
473pub fn fit_all_discrete(data: &[f64]) -> StatsResult<Vec<FitResult>> {
474    if data.is_empty() {
475        return Err(StatsError::InvalidInput {
476            message: "fit_all_discrete: data must not be empty".to_string(),
477        });
478    }
479
480    // Pre-allocate scratch buffers; reused across all 4 candidates.
481    let mut int_data: Vec<u64> = Vec::with_capacity(data.len());
482    int_data.extend(data.iter().map(|&x| x.round() as u64));
483    let mut ks_buf: Vec<f64> = Vec::with_capacity(data.len());
484    let mut results: Vec<FitResult> = Vec::with_capacity(4);
485
486    macro_rules! try_fit_disc {
487        ($fit_expr:expr) => {
488            if let Ok(dist) = $fit_expr {
489                if let (Ok(aic), Ok(bic)) = (dist.aic(&int_data), dist.bic(&int_data)) {
490                    if aic.is_finite() && bic.is_finite() {
491                        ks_buf.clear();
492                        ks_buf.extend_from_slice(data);
493                        let ks = ks_test_discrete_with_scratch(&mut ks_buf, |k| {
494                            dist.cdf(k).unwrap_or(0.0)
495                        });
496                        results.push(FitResult {
497                            name: dist.name().to_string(),
498                            aic,
499                            bic,
500                            ks_statistic: ks.statistic,
501                            ks_p_value: ks.p_value,
502                        });
503                    }
504                }
505            }
506        };
507    }
508
509    try_fit_disc!(Poisson::fit(data));
510    try_fit_disc!(Geometric::fit(data));
511    try_fit_disc!(NegativeBinomial::fit(data));
512    try_fit_disc!(Binomial::fit(data));
513
514    if results.is_empty() {
515        return Err(StatsError::InvalidInput {
516            message: "fit_all_discrete: no distribution could be fitted to the data".to_string(),
517        });
518    }
519
520    results.sort_by(|a, b| {
521        a.aic
522            .partial_cmp(&b.aic)
523            .unwrap_or(std::cmp::Ordering::Equal)
524    });
525    Ok(results)
526}
527
528/// Fit discrete distributions and return the best (lowest AIC).
529pub fn fit_best_discrete(data: &[f64]) -> StatsResult<FitResult> {
530    let mut all = fit_all_discrete(data)?;
531    Ok(all.remove(0))
532}
533
534// ── Auto-detect and fit ────────────────────────────────────────────────────────
535
536/// Automatically detect whether data is discrete or continuous, then fit all applicable
537/// distributions and return the best match (lowest AIC).
538///
539/// # Examples
540/// ```
541/// use rs_stats::distributions::fitting::auto_fit;
542///
543/// let data = vec![1.2, 2.3, 1.8, 2.9, 1.5];
544/// let best = auto_fit(&data).unwrap();
545/// println!("Best fit: {}", best.name);
546/// ```
547pub fn auto_fit(data: &[f64]) -> StatsResult<FitResult> {
548    match detect_data_type(data) {
549        DataKind::Discrete => fit_best_discrete(data),
550        DataKind::Continuous => fit_best(data),
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn test_detect_data_type_discrete() {
560        assert_eq!(detect_data_type(&[0.0, 1.0, 2.0, 3.0]), DataKind::Discrete);
561        assert_eq!(detect_data_type(&[0.0, 0.0, 1.0]), DataKind::Discrete);
562    }
563
564    #[test]
565    fn test_detect_data_type_continuous() {
566        assert_eq!(detect_data_type(&[0.5, 1.5, 2.3]), DataKind::Continuous);
567        assert_eq!(detect_data_type(&[-1.0, 0.0, 1.0]), DataKind::Continuous);
568        assert_eq!(detect_data_type(&[1.0, 2.5, 3.0]), DataKind::Continuous);
569    }
570
571    #[test]
572    fn test_ks_test_uniform() {
573        // Data from Uniform(0,1) should give large p-value against U(0,1) CDF
574        let data: Vec<f64> = (0..20).map(|i| i as f64 / 20.0).collect();
575        let ks = ks_test(&data, |x| x.clamp(0.0, 1.0));
576        assert!(ks.statistic < 0.15);
577    }
578
579    #[test]
580    fn test_fit_all_returns_results() {
581        let data: Vec<f64> = (0..50).map(|i| (i as f64) * 0.1 + 0.5).collect();
582        let results = fit_all(&data).unwrap();
583        assert!(!results.is_empty());
584        // Results sorted by AIC (ascending)
585        for i in 1..results.len() {
586            assert!(results[i].aic >= results[i - 1].aic);
587        }
588    }
589
590    #[test]
591    fn test_fit_best_normal_data() {
592        // Data generated from N(5, 1)
593        let data = vec![
594            4.1, 5.2, 5.8, 4.7, 5.3, 4.9, 6.1, 4.5, 5.5, 5.0, 4.8, 5.1, 4.3, 5.7, 4.6, 5.4, 4.2,
595            5.9, 5.2, 4.4,
596        ];
597        let best = fit_best(&data).unwrap();
598        // Normal should win (or be competitive)
599        assert!(best.aic.is_finite());
600    }
601
602    #[test]
603    fn test_fit_all_discrete() {
604        let data = vec![0.0, 1.0, 2.0, 3.0, 1.0, 0.0, 2.0, 1.0, 0.0, 4.0];
605        let results = fit_all_discrete(&data).unwrap();
606        assert!(!results.is_empty());
607    }
608
609    #[test]
610    fn test_auto_fit_continuous() {
611        let data = vec![1.5, 2.3, 1.8, 2.1, 2.7, 1.9, 2.4, 2.0];
612        let best = auto_fit(&data).unwrap();
613        assert!(!best.name.is_empty());
614    }
615
616    #[test]
617    fn test_auto_fit_discrete() {
618        let data = vec![0.0, 1.0, 2.0, 1.0, 0.0, 3.0, 1.0, 2.0];
619        let best = auto_fit(&data).unwrap();
620        assert!(!best.name.is_empty());
621    }
622
623    #[test]
624    fn test_fit_all_empty_data() {
625        assert!(fit_all(&[]).is_err());
626    }
627}