Skip to main content

salib_core/
distribution.rs

1//! `Distribution` — closed enum of factor distributions, with
2//! inverse-CDF (`quantile`) and support boundaries as the unified
3//! extension point.
4//!
5//! # Why a closed enum, not a `dyn Distribution` trait
6//!
7//! and matching
8//!  verbatim
9//! — the distribution set is closed and `Serialize + Deserialize`.
10//! The ledger entry is a JSON dump of `Problem`, byte-comparable
11//! across runs. A trait-object distribution would break that
12//! provenance property.
13//!
14//! Custom / exotic distributions plug in *on the model side* by
15//! composing inverse CDFs over `Uniform { 0, 1 }` — the same trick
16//! `SALib` uses. `Empirical { quantiles }` and `Truncated` are
17//! `#[non_exhaustive]` extensions that land in follow-on PRs (each
18//! has its own design questions: interpolation policy for
19//! `Empirical`, truncation discipline for `Truncated`).
20//!
21//! # The `quantile` contract
22//!
23//! `quantile(u: f64) -> f64` for `u ∈ [0, 1]`. Out-of-range `u` is
24//! saturated to `[0, 1]`, giving well-defined behavior at the
25//! support boundaries. This is the only direction samplers consume —
26//! they produce uniform `[0, 1)` samples and call `quantile` per
27//! factor. The reverse direction (`cdf`) is needed for `Truncated`
28//! and for future moment-independent estimators; lands in the
29//! follow-on PR that introduces `Truncated`.
30//!
31//! # Closed-form vs `statrs`
32//!
33//! - Closed-form (this file): Uniform, Triangular, Weibull,
34//!   Exponential, Bernoulli, `DiscreteUniform`.
35//! - `statrs` Newton-converged inverse CDF: Normal, `LogNormal`, Beta,
36//!   Gamma. These have no useful closed-form quantile.
37//!
38//! # Determinism
39//!
40//! Every quantile is a pure function of `(parameters, u)`. No RNG,
41//! no clock, no env. Cross-platform-byte-exact under the no-FMA
42//! reference build (`cargo xtask reference-ci`) — `statrs`'s
43//! Newton iterations are convergence-tolerant, not bit-stable across
44//! FMA-on vs FMA-off builds. This is documented in
45//!  § "Threat
46//! model" as a known property of the inverse-CDF path.
47
48use serde::{Deserialize, Serialize};
49use statrs::distribution::{
50    Beta as BetaDist, ContinuousCDF, Gamma as GammaDist, LogNormal as LogNormalDist,
51    Normal as NormalDist,
52};
53
54/// Factor distributions saltelli supports. Closed enum,
55/// `#[non_exhaustive]`. Future variants (`Truncated`, `Empirical`,
56/// `Categorical`, …) land non-breaking via follow-on ADRs.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58#[non_exhaustive]
59#[serde(tag = "kind")]
60pub enum Distribution {
61    /// Uniform on `[lo, hi]`. Closed-form quantile.
62    Uniform { lo: f64, hi: f64 },
63
64    /// Normal `N(mu, sigma²)`. `statrs::Normal::inverse_cdf`.
65    Normal { mu: f64, sigma: f64 },
66
67    /// Log-normal — `exp(N(mu_log, sigma_log²))`.
68    /// `statrs::LogNormal::inverse_cdf`.
69    LogNormal { mu_log: f64, sigma_log: f64 },
70
71    /// Triangular on `[lo, hi]` with mode `mode`. Closed-form
72    /// quantile (piecewise sqrt).
73    Triangular { lo: f64, mode: f64, hi: f64 },
74
75    /// Beta on `[lo, hi]` with shape parameters `alpha`, `beta`.
76    /// `statrs::Beta::inverse_cdf` then affine-mapped to `[lo, hi]`.
77    Beta {
78        alpha: f64,
79        beta: f64,
80        lo: f64,
81        hi: f64,
82    },
83
84    /// Gamma with shape `shape` and **scale** `scale` (so mean =
85    /// shape × scale). `statrs::Gamma` parameterizes by rate, so
86    /// we pass `1/scale`. Closed-form for shape = 1 collapses to
87    /// `Exponential { lambda: 1/scale }`.
88    Gamma { shape: f64, scale: f64 },
89
90    /// Weibull with shape `shape`, scale `scale`. Closed-form
91    /// quantile: `scale * (-ln(1 - u))^(1/shape)`.
92    Weibull { shape: f64, scale: f64 },
93
94    /// Exponential with rate `lambda`. Closed-form quantile:
95    /// `-ln(1 - u) / lambda`.
96    Exponential { lambda: f64 },
97
98    /// Bernoulli with success probability `p`. Quantile: 0 if
99    /// `u < 1 - p`, else 1.
100    Bernoulli { p: f64 },
101
102    /// Discrete uniform on the inclusive integer range `[lo, hi]`.
103    DiscreteUniform { lo: i64, hi: i64 },
104}
105
106// `statrs`'s `Beta::new` etc. return `Result`; the `quantile` impls
107// below panic on invalid params via debug-style assertions. This is
108// safe because `ProblemBuilder::build` validates parameters at
109// `Problem` construction time (),
110// so a `Distribution` value reachable from a built `Problem` cannot
111// have bad params. A future fallible `Distribution::checked_quantile`
112// lands when a public `Distribution` constructor surface is needed
113// (none today).
114
115impl Distribution {
116    /// Inverse CDF. `u ∈ [0, 1]` (saturated; out-of-range inputs
117    /// clamp to the support boundary). Pure function of
118    /// `(parameters, u)`.
119    ///
120    /// # Panics
121    ///
122    /// On invalid distribution parameters (`sigma ≤ 0`, `alpha ≤ 0`,
123    /// `Beta` with `lo ≥ hi`, etc.). `Problem` construction validates
124    /// parameters at build time so this never fires for a Problem
125    /// produced by `ProblemBuilder::build`.
126    #[must_use]
127    pub fn quantile(&self, u: f64) -> f64 {
128        let u = u.clamp(0.0, 1.0);
129        match *self {
130            Self::Uniform { lo, hi } => uniform_quantile(lo, hi, u),
131            Self::Normal { mu, sigma } => normal_quantile(mu, sigma, u),
132            Self::LogNormal { mu_log, sigma_log } => lognormal_quantile(mu_log, sigma_log, u),
133            Self::Triangular { lo, mode, hi } => triangular_quantile(lo, mode, hi, u),
134            Self::Beta {
135                alpha,
136                beta,
137                lo,
138                hi,
139            } => beta_quantile(alpha, beta, lo, hi, u),
140            Self::Gamma { shape, scale } => gamma_quantile(shape, scale, u),
141            Self::Weibull { shape, scale } => weibull_quantile(shape, scale, u),
142            Self::Exponential { lambda } => exponential_quantile(lambda, u),
143            Self::Bernoulli { p } => bernoulli_quantile(p, u),
144            Self::DiscreteUniform { lo, hi } => discrete_uniform_quantile(lo, hi, u),
145        }
146    }
147
148    /// Support of the distribution as `(lower, upper)`. For
149    /// distributions with infinite support, returns `±f64::INFINITY`.
150    /// `quantile(0.0)` returns the lower support and `quantile(1.0)`
151    /// the upper support.
152    #[must_use]
153    pub fn support(&self) -> (f64, f64) {
154        match *self {
155            // Distributions with explicit `(lo, hi)` bounds (Uniform,
156            // Triangular, Beta) share an arm body. LogNormal /
157            // Gamma / Exponential / Weibull share `(0, +∞)`.
158            Self::Uniform { lo, hi }
159            | Self::Triangular { lo, hi, .. }
160            | Self::Beta { lo, hi, .. } => (lo, hi),
161            Self::Normal { .. } => (f64::NEG_INFINITY, f64::INFINITY),
162            Self::LogNormal { .. }
163            | Self::Gamma { .. }
164            | Self::Exponential { .. }
165            | Self::Weibull { .. } => (0.0, f64::INFINITY),
166            Self::Bernoulli { .. } => (0.0, 1.0),
167            #[allow(clippy::cast_precision_loss)]
168            Self::DiscreteUniform { lo, hi } => (lo as f64, hi as f64),
169        }
170    }
171}
172
173// ── Closed-form quantiles ────────────────────────────────────────────
174
175fn uniform_quantile(lo: f64, hi: f64, u: f64) -> f64 {
176    lo + u * (hi - lo)
177}
178
179fn triangular_quantile(lo: f64, mode: f64, hi: f64, u: f64) -> f64 {
180    assert!(lo < hi, "Triangular: lo must be < hi");
181    assert!(
182        lo <= mode && mode <= hi,
183        "Triangular: mode must be in [lo, hi]"
184    );
185    let f_mode = (mode - lo) / (hi - lo);
186    if u <= f_mode {
187        lo + (u * (hi - lo) * (mode - lo)).sqrt()
188    } else {
189        hi - ((1.0 - u) * (hi - lo) * (hi - mode)).sqrt()
190    }
191}
192
193fn weibull_quantile(shape: f64, scale: f64, u: f64) -> f64 {
194    assert!(shape > 0.0, "Weibull: shape must be > 0");
195    assert!(scale > 0.0, "Weibull: scale must be > 0");
196    if u >= 1.0 {
197        return f64::INFINITY;
198    }
199    scale * (-(1.0 - u).ln()).powf(1.0 / shape)
200}
201
202fn exponential_quantile(lambda: f64, u: f64) -> f64 {
203    assert!(lambda > 0.0, "Exponential: lambda must be > 0");
204    if u >= 1.0 {
205        return f64::INFINITY;
206    }
207    -((1.0 - u).ln()) / lambda
208}
209
210fn bernoulli_quantile(p: f64, u: f64) -> f64 {
211    assert!((0.0..=1.0).contains(&p), "Bernoulli: p must be in [0, 1]");
212    // Standard inverse CDF: F⁻¹(u) = inf{x : F(x) ≥ u}. For Bernoulli,
213    // F(0) = 1-p, F(1) = 1, so the boundary at u = 1-p maps to 0.
214    // u ∈ [0, 1-p] → 0; u ∈ (1-p, 1] → 1.
215    // Marginal probability of returning 1 is the measure of (1-p, 1],
216    // which is 1 - (1-p) = p — as required.
217    if u <= 1.0 - p {
218        0.0
219    } else {
220        1.0
221    }
222}
223
224fn discrete_uniform_quantile(lo: i64, hi: i64, u: f64) -> f64 {
225    assert!(lo <= hi, "DiscreteUniform: lo must be <= hi");
226    let n = hi - lo + 1;
227    #[allow(clippy::cast_precision_loss)]
228    let scaled = u * (n as f64);
229    // Floor and clamp the upper edge: at u = 1.0, `scaled == n`,
230    // which would index past `hi`. Clamp to `n - 1`.
231    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
232    let idx = (scaled.floor() as i64).min(n - 1);
233    #[allow(clippy::cast_precision_loss)]
234    let result = (lo + idx) as f64;
235    result
236}
237
238// ── statrs-backed quantiles ──────────────────────────────────────────
239//
240// `expect()` on the statrs constructors is sound because
241// `validate_distribution` in `problem.rs` rejects bad params at
242// `ProblemBuilder::build` time; a `Distribution` value reachable from
243// a built `Problem` cannot have parameters that fail statrs's own
244// validity check. The local `assert!`s below are belt-and-suspenders
245// for the case where these helpers are called directly (only inside
246// this crate, and only from `Distribution::quantile` which receives
247// the already-validated `Distribution`).
248
249#[allow(clippy::expect_used)]
250fn normal_quantile(mu: f64, sigma: f64, u: f64) -> f64 {
251    assert!(sigma > 0.0, "Normal: sigma must be > 0");
252    let dist = NormalDist::new(mu, sigma).expect("Normal::new param check");
253    dist.inverse_cdf(u)
254}
255
256#[allow(clippy::expect_used)]
257fn lognormal_quantile(mu_log: f64, sigma_log: f64, u: f64) -> f64 {
258    assert!(sigma_log > 0.0, "LogNormal: sigma_log must be > 0");
259    let dist = LogNormalDist::new(mu_log, sigma_log).expect("LogNormal::new param check");
260    dist.inverse_cdf(u)
261}
262
263#[allow(clippy::expect_used)]
264fn beta_quantile(alpha: f64, beta: f64, lo: f64, hi: f64, u: f64) -> f64 {
265    assert!(alpha > 0.0, "Beta: alpha must be > 0");
266    assert!(beta > 0.0, "Beta: beta must be > 0");
267    assert!(lo < hi, "Beta: lo must be < hi");
268    let dist = BetaDist::new(alpha, beta).expect("Beta::new param check");
269    let v = dist.inverse_cdf(u);
270    lo + (hi - lo) * v
271}
272
273#[allow(clippy::expect_used)]
274fn gamma_quantile(shape: f64, scale: f64, u: f64) -> f64 {
275    assert!(shape > 0.0, "Gamma: shape must be > 0");
276    assert!(scale > 0.0, "Gamma: scale must be > 0");
277    // statrs `Gamma::new(shape, rate)` parameterizes by rate = 1/scale.
278    let dist = GammaDist::new(shape, 1.0 / scale).expect("Gamma::new param check");
279    dist.inverse_cdf(u)
280}
281
282#[cfg(test)]
283#[allow(
284    clippy::float_cmp,
285    clippy::approx_constant,
286    clippy::cast_precision_loss
287)]
288mod tests {
289    use super::*;
290
291    // Numerical-tolerance helper for floating-point assertions.
292    fn assert_close(got: f64, want: f64, tol: f64, ctx: &str) {
293        assert!(
294            (got - want).abs() <= tol,
295            "{ctx}: got {got}, want {want}, |Δ|={}, tol={tol}",
296            (got - want).abs()
297        );
298    }
299
300    fn assert_monotone_non_decreasing(d: &Distribution) {
301        // 17 sample u values; check that quantile is monotone
302        // non-decreasing across the [0, 1] range.
303        let us = [
304            0.0, 0.001, 0.01, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99, 0.999,
305            1.0,
306        ];
307        let mut prev = f64::NEG_INFINITY;
308        for &u in &us {
309            let q = d.quantile(u);
310            assert!(
311                q >= prev || (q.is_nan() && prev.is_nan()),
312                "monotonicity violated for {d:?}: q({u}) = {q} < prev {prev}"
313            );
314            prev = q;
315        }
316    }
317
318    // ── Uniform ─────────────────────────────────────────────────────
319
320    #[test]
321    fn uniform_zero_one_quantile_is_u() {
322        let d = Distribution::Uniform { lo: 0.0, hi: 1.0 };
323        for u in [0.0, 0.25, 0.5, 0.75, 1.0] {
324            assert_eq!(d.quantile(u), u);
325        }
326    }
327
328    #[test]
329    fn uniform_general_quantile_linearly_maps() {
330        let d = Distribution::Uniform { lo: 10.0, hi: 30.0 };
331        assert_eq!(d.quantile(0.0), 10.0);
332        assert_eq!(d.quantile(0.5), 20.0);
333        assert_eq!(d.quantile(1.0), 30.0);
334    }
335
336    #[test]
337    fn uniform_negative_range() {
338        let d = Distribution::Uniform { lo: -5.0, hi: 5.0 };
339        assert_eq!(d.quantile(0.5), 0.0);
340        assert_eq!(d.quantile(0.0), -5.0);
341        assert_eq!(d.quantile(1.0), 5.0);
342    }
343
344    #[test]
345    fn uniform_support_matches_params() {
346        let d = Distribution::Uniform { lo: 2.5, hi: 7.5 };
347        assert_eq!(d.support(), (2.5, 7.5));
348    }
349
350    #[test]
351    fn uniform_monotone() {
352        assert_monotone_non_decreasing(&Distribution::Uniform { lo: 0.0, hi: 1.0 });
353        assert_monotone_non_decreasing(&Distribution::Uniform {
354            lo: -10.0,
355            hi: 100.0,
356        });
357    }
358
359    #[test]
360    fn uniform_saturates_out_of_range_u() {
361        let d = Distribution::Uniform { lo: 0.0, hi: 1.0 };
362        assert_eq!(d.quantile(-0.5), 0.0);
363        assert_eq!(d.quantile(1.5), 1.0);
364    }
365
366    // ── Normal ──────────────────────────────────────────────────────
367
368    #[test]
369    fn normal_quantile_at_half_is_mean() {
370        let d = Distribution::Normal {
371            mu: 5.0,
372            sigma: 2.0,
373        };
374        assert_close(d.quantile(0.5), 5.0, 1e-12, "Normal median");
375    }
376
377    #[test]
378    fn normal_quantile_one_sigma_above_mean() {
379        // Φ⁻¹(0.8413447) ≈ 1.0
380        let d = Distribution::Normal {
381            mu: 0.0,
382            sigma: 1.0,
383        };
384        assert_close(d.quantile(0.841_344_746_068_543), 1.0, 1e-9, "+1σ");
385    }
386
387    #[test]
388    fn normal_quantile_symmetric_about_mean() {
389        let d = Distribution::Normal {
390            mu: 7.0,
391            sigma: 3.0,
392        };
393        for u in [0.1, 0.2, 0.3, 0.4] {
394            let q_lo = d.quantile(u);
395            let q_hi = d.quantile(1.0 - u);
396            // Symmetric: lo + hi = 2 * mu
397            assert_close(q_lo + q_hi, 2.0 * 7.0, 1e-9, "Normal symmetry");
398        }
399    }
400
401    #[test]
402    fn normal_support_is_unbounded() {
403        let d = Distribution::Normal {
404            mu: 0.0,
405            sigma: 1.0,
406        };
407        let (lo, hi) = d.support();
408        assert_eq!(lo, f64::NEG_INFINITY);
409        assert_eq!(hi, f64::INFINITY);
410    }
411
412    #[test]
413    fn normal_monotone() {
414        assert_monotone_non_decreasing(&Distribution::Normal {
415            mu: 0.0,
416            sigma: 1.0,
417        });
418    }
419
420    #[test]
421    #[should_panic(expected = "sigma must be > 0")]
422    fn normal_zero_sigma_panics() {
423        let d = Distribution::Normal {
424            mu: 0.0,
425            sigma: 0.0,
426        };
427        let _ = d.quantile(0.5);
428    }
429
430    // ── LogNormal ───────────────────────────────────────────────────
431
432    #[test]
433    fn lognormal_quantile_at_half_is_exp_mu_log() {
434        // Median of LogNormal(μ, σ²) is exp(μ).
435        let d = Distribution::LogNormal {
436            mu_log: 1.0,
437            sigma_log: 0.5,
438        };
439        assert_close(d.quantile(0.5), 1.0_f64.exp(), 1e-9, "LogNormal median");
440    }
441
442    #[test]
443    fn lognormal_support_is_zero_to_infinity() {
444        let d = Distribution::LogNormal {
445            mu_log: 0.0,
446            sigma_log: 1.0,
447        };
448        let (lo, hi) = d.support();
449        assert_eq!(lo, 0.0);
450        assert_eq!(hi, f64::INFINITY);
451    }
452
453    #[test]
454    fn lognormal_monotone() {
455        assert_monotone_non_decreasing(&Distribution::LogNormal {
456            mu_log: 0.0,
457            sigma_log: 1.0,
458        });
459    }
460
461    // ── Triangular ──────────────────────────────────────────────────
462
463    #[test]
464    fn triangular_quantile_at_zero_is_lo() {
465        let d = Distribution::Triangular {
466            lo: 0.0,
467            mode: 0.5,
468            hi: 1.0,
469        };
470        assert_eq!(d.quantile(0.0), 0.0);
471    }
472
473    #[test]
474    fn triangular_quantile_at_one_is_hi() {
475        let d = Distribution::Triangular {
476            lo: 0.0,
477            mode: 0.5,
478            hi: 1.0,
479        };
480        assert_eq!(d.quantile(1.0), 1.0);
481    }
482
483    #[test]
484    fn triangular_at_f_mode_is_mode() {
485        // For symmetric triangular [0, 0.5, 1], F(mode) = 0.5.
486        let d = Distribution::Triangular {
487            lo: 0.0,
488            mode: 0.5,
489            hi: 1.0,
490        };
491        assert_close(d.quantile(0.5), 0.5, 1e-12, "Triangular at F(mode)");
492    }
493
494    #[test]
495    fn triangular_asymmetric_mode() {
496        // Triangular [0, 0.25, 1]: F(mode) = 0.25.
497        let d = Distribution::Triangular {
498            lo: 0.0,
499            mode: 0.25,
500            hi: 1.0,
501        };
502        assert_close(d.quantile(0.25), 0.25, 1e-12, "asymmetric mode");
503    }
504
505    #[test]
506    fn triangular_support_matches_params() {
507        let d = Distribution::Triangular {
508            lo: -2.0,
509            mode: 0.0,
510            hi: 5.0,
511        };
512        assert_eq!(d.support(), (-2.0, 5.0));
513    }
514
515    #[test]
516    fn triangular_monotone_symmetric() {
517        assert_monotone_non_decreasing(&Distribution::Triangular {
518            lo: 0.0,
519            mode: 0.5,
520            hi: 1.0,
521        });
522    }
523
524    #[test]
525    fn triangular_monotone_asymmetric() {
526        assert_monotone_non_decreasing(&Distribution::Triangular {
527            lo: -10.0,
528            mode: -3.0,
529            hi: 7.0,
530        });
531    }
532
533    // ── Beta ────────────────────────────────────────────────────────
534
535    #[test]
536    fn beta_quantile_at_half_for_alpha_eq_beta_is_midpoint() {
537        // Beta(α, α) is symmetric about 0.5 (in unit space).
538        let d = Distribution::Beta {
539            alpha: 2.0,
540            beta: 2.0,
541            lo: 0.0,
542            hi: 1.0,
543        };
544        assert_close(d.quantile(0.5), 0.5, 1e-9, "symmetric Beta median");
545    }
546
547    #[test]
548    fn beta_affine_to_general_range() {
549        // Beta(2, 2) on [10, 30] should have quantile(0.5) = 20.
550        let d = Distribution::Beta {
551            alpha: 2.0,
552            beta: 2.0,
553            lo: 10.0,
554            hi: 30.0,
555        };
556        assert_close(d.quantile(0.5), 20.0, 1e-8, "Beta affine median");
557    }
558
559    #[test]
560    fn beta_quantile_at_zero_is_lo() {
561        let d = Distribution::Beta {
562            alpha: 2.0,
563            beta: 5.0,
564            lo: 1.0,
565            hi: 7.0,
566        };
567        assert_close(d.quantile(0.0), 1.0, 1e-12, "Beta lo edge");
568    }
569
570    #[test]
571    fn beta_quantile_at_one_is_hi() {
572        let d = Distribution::Beta {
573            alpha: 2.0,
574            beta: 5.0,
575            lo: 1.0,
576            hi: 7.0,
577        };
578        assert_close(d.quantile(1.0), 7.0, 1e-12, "Beta hi edge");
579    }
580
581    #[test]
582    fn beta_uniform_special_case() {
583        // Beta(1, 1) ≡ Uniform.
584        let d = Distribution::Beta {
585            alpha: 1.0,
586            beta: 1.0,
587            lo: 0.0,
588            hi: 1.0,
589        };
590        for u in [0.1, 0.3, 0.5, 0.7, 0.9] {
591            assert_close(d.quantile(u), u, 1e-9, "Beta(1,1) ≡ Uniform");
592        }
593    }
594
595    #[test]
596    fn beta_monotone() {
597        assert_monotone_non_decreasing(&Distribution::Beta {
598            alpha: 2.0,
599            beta: 5.0,
600            lo: 0.0,
601            hi: 1.0,
602        });
603    }
604
605    // ── Gamma ───────────────────────────────────────────────────────
606
607    #[test]
608    fn gamma_shape_one_collapses_to_exponential() {
609        // Gamma(shape=1, scale) ≡ Exponential(rate = 1/scale).
610        let d_g = Distribution::Gamma {
611            shape: 1.0,
612            scale: 2.0,
613        };
614        let d_e = Distribution::Exponential { lambda: 0.5 };
615        for u in [0.1, 0.3, 0.5, 0.7, 0.9] {
616            assert_close(
617                d_g.quantile(u),
618                d_e.quantile(u),
619                1e-7,
620                "Gamma(1) ≡ Exponential",
621            );
622        }
623    }
624
625    #[test]
626    fn gamma_quantile_at_zero_is_zero() {
627        let d = Distribution::Gamma {
628            shape: 2.0,
629            scale: 3.0,
630        };
631        assert_close(d.quantile(0.0), 0.0, 1e-12, "Gamma lo edge");
632    }
633
634    #[test]
635    fn gamma_support() {
636        let d = Distribution::Gamma {
637            shape: 2.0,
638            scale: 3.0,
639        };
640        assert_eq!(d.support(), (0.0, f64::INFINITY));
641    }
642
643    #[test]
644    fn gamma_monotone() {
645        assert_monotone_non_decreasing(&Distribution::Gamma {
646            shape: 2.0,
647            scale: 3.0,
648        });
649    }
650
651    // ── Weibull ─────────────────────────────────────────────────────
652
653    #[test]
654    fn weibull_shape_one_collapses_to_exponential() {
655        // Weibull(shape=1, scale) ≡ Exponential(rate = 1/scale).
656        let d_w = Distribution::Weibull {
657            shape: 1.0,
658            scale: 4.0,
659        };
660        let d_e = Distribution::Exponential { lambda: 0.25 };
661        for u in [0.1, 0.3, 0.5, 0.7, 0.9] {
662            assert_close(
663                d_w.quantile(u),
664                d_e.quantile(u),
665                1e-12,
666                "Weibull(1) ≡ Exponential",
667            );
668        }
669    }
670
671    #[test]
672    fn weibull_quantile_at_zero_is_zero() {
673        let d = Distribution::Weibull {
674            shape: 2.0,
675            scale: 1.0,
676        };
677        assert_close(d.quantile(0.0), 0.0, 1e-12, "Weibull lo edge");
678    }
679
680    #[test]
681    fn weibull_quantile_at_one_is_infinity() {
682        let d = Distribution::Weibull {
683            shape: 2.0,
684            scale: 1.0,
685        };
686        assert_eq!(d.quantile(1.0), f64::INFINITY);
687    }
688
689    #[test]
690    fn weibull_monotone() {
691        assert_monotone_non_decreasing(&Distribution::Weibull {
692            shape: 2.0,
693            scale: 1.0,
694        });
695    }
696
697    // ── Exponential ─────────────────────────────────────────────────
698
699    #[test]
700    fn exponential_quantile_at_zero_is_zero() {
701        let d = Distribution::Exponential { lambda: 1.0 };
702        assert_eq!(d.quantile(0.0), 0.0);
703    }
704
705    #[test]
706    fn exponential_quantile_at_one_is_infinity() {
707        let d = Distribution::Exponential { lambda: 1.0 };
708        assert_eq!(d.quantile(1.0), f64::INFINITY);
709    }
710
711    #[test]
712    fn exponential_quantile_known_point() {
713        // Q(1 - e⁻¹) = 1/λ.
714        let lambda = 2.0_f64;
715        let d = Distribution::Exponential { lambda };
716        let u = 1.0 - (-1.0_f64).exp();
717        assert_close(d.quantile(u), 1.0 / lambda, 1e-12, "Exponential @1/λ");
718    }
719
720    #[test]
721    fn exponential_monotone() {
722        assert_monotone_non_decreasing(&Distribution::Exponential { lambda: 1.0 });
723    }
724
725    // ── Bernoulli ───────────────────────────────────────────────────
726
727    #[test]
728    fn bernoulli_zero_p_is_always_zero() {
729        let d = Distribution::Bernoulli { p: 0.0 };
730        for u in [0.0, 0.25, 0.5, 0.75, 1.0] {
731            assert_eq!(d.quantile(u), 0.0);
732        }
733    }
734
735    #[test]
736    fn bernoulli_one_p_returns_one_above_zero() {
737        // For p=1, threshold = 1-p = 0. u=0 saturates to lower
738        // support (0); u>0 returns 1. At Bernoulli(p=1) the marginal
739        // P(X=1) = measure of (0, 1] = 1, so all u ∈ (0, 1] should
740        // give 1.
741        let d = Distribution::Bernoulli { p: 1.0 };
742        assert_eq!(d.quantile(0.0), 0.0); // boundary; F⁻¹(0) = 0.
743        for u in [0.000_001, 0.25, 0.5, 0.75, 1.0] {
744            assert_eq!(d.quantile(u), 1.0);
745        }
746    }
747
748    #[test]
749    fn bernoulli_threshold_is_inclusive_at_one_minus_p() {
750        // P(X = 0) = 1 - p; standard inverse CDF places the boundary
751        // u = 1 - p on the X = 0 side (F⁻¹(1 - p) = 0).
752        // u ∈ [0, 1-p] → 0; u ∈ (1-p, 1] → 1.
753        let d = Distribution::Bernoulli { p: 0.3 };
754        // 1 - p = 0.7.
755        assert_eq!(d.quantile(0.0), 0.0);
756        assert_eq!(d.quantile(0.5), 0.0);
757        assert_eq!(d.quantile(0.69), 0.0);
758        assert_eq!(d.quantile(0.7), 0.0); // boundary inclusive on 0 side
759                                          // Just past threshold:
760        assert_eq!(d.quantile(0.700_000_000_001), 1.0);
761        assert_eq!(d.quantile(0.99), 1.0);
762        assert_eq!(d.quantile(1.0), 1.0);
763    }
764
765    #[test]
766    fn bernoulli_monotone() {
767        assert_monotone_non_decreasing(&Distribution::Bernoulli { p: 0.4 });
768    }
769
770    // ── DiscreteUniform ─────────────────────────────────────────────
771
772    #[test]
773    fn discrete_uniform_singleton() {
774        let d = Distribution::DiscreteUniform { lo: 5, hi: 5 };
775        for u in [0.0, 0.5, 1.0] {
776            assert_eq!(d.quantile(u), 5.0);
777        }
778    }
779
780    #[test]
781    fn discrete_uniform_two_values() {
782        let d = Distribution::DiscreteUniform { lo: 0, hi: 1 };
783        // n = 2; u in [0, 0.5) → 0; u in [0.5, 1.0] → 1.
784        assert_eq!(d.quantile(0.0), 0.0);
785        assert_eq!(d.quantile(0.49), 0.0);
786        assert_eq!(d.quantile(0.5), 1.0);
787        assert_eq!(d.quantile(0.99), 1.0);
788        assert_eq!(d.quantile(1.0), 1.0);
789    }
790
791    #[test]
792    fn discrete_uniform_six_values() {
793        // Roll a six-sided die: lo=1, hi=6, n=6.
794        let d = Distribution::DiscreteUniform { lo: 1, hi: 6 };
795        assert_eq!(d.quantile(0.0), 1.0);
796        assert_eq!(d.quantile(1.0 / 6.0 + 1e-9), 2.0); // edge of bin 1
797        assert_eq!(d.quantile(0.5), 4.0); // floor(0.5 * 6) = 3, so lo + 3 = 4
798        assert_eq!(d.quantile(1.0), 6.0); // saturated to last bin
799    }
800
801    #[test]
802    fn discrete_uniform_negative_range() {
803        let d = Distribution::DiscreteUniform { lo: -3, hi: 3 };
804        assert_eq!(d.quantile(0.0), -3.0);
805        assert_eq!(d.quantile(1.0), 3.0);
806        let mid = d.quantile(0.5);
807        // n = 7, scaled = 3.5, floor = 3, lo + 3 = 0
808        assert_eq!(mid, 0.0);
809    }
810
811    #[test]
812    fn discrete_uniform_monotone() {
813        assert_monotone_non_decreasing(&Distribution::DiscreteUniform { lo: 1, hi: 10 });
814    }
815
816    // ── Cross-cutting: serde round-trip ─────────────────────────────
817
818    #[test]
819    fn distribution_serde_round_trip_for_all_variants() {
820        let cases = vec![
821            Distribution::Uniform { lo: 1.0, hi: 5.0 },
822            Distribution::Normal {
823                mu: 0.0,
824                sigma: 2.0,
825            },
826            Distribution::LogNormal {
827                mu_log: 1.0,
828                sigma_log: 0.5,
829            },
830            Distribution::Triangular {
831                lo: 0.0,
832                mode: 0.3,
833                hi: 1.0,
834            },
835            Distribution::Beta {
836                alpha: 2.0,
837                beta: 5.0,
838                lo: 0.0,
839                hi: 1.0,
840            },
841            Distribution::Gamma {
842                shape: 2.0,
843                scale: 1.0,
844            },
845            Distribution::Weibull {
846                shape: 1.5,
847                scale: 2.0,
848            },
849            Distribution::Exponential { lambda: 0.7 },
850            Distribution::Bernoulli { p: 0.3 },
851            Distribution::DiscreteUniform { lo: 1, hi: 6 },
852        ];
853        for d in cases {
854            let json = serde_json::to_string(&d).expect("serialize");
855            let back: Distribution = serde_json::from_str(&json).expect("deserialize");
856            assert_eq!(back, d, "round-trip {d:?} → {json} → {back:?}");
857        }
858    }
859
860    #[test]
861    fn quantile_at_zero_returns_lower_support_for_finite_distributions() {
862        let cases = vec![
863            (Distribution::Uniform { lo: 2.0, hi: 5.0 }, 2.0),
864            (
865                Distribution::Triangular {
866                    lo: -1.0,
867                    mode: 0.0,
868                    hi: 1.0,
869                },
870                -1.0,
871            ),
872            (
873                Distribution::Beta {
874                    alpha: 2.0,
875                    beta: 3.0,
876                    lo: 0.5,
877                    hi: 1.5,
878                },
879                0.5,
880            ),
881            (Distribution::Bernoulli { p: 0.4 }, 0.0),
882            (Distribution::DiscreteUniform { lo: -2, hi: 2 }, -2.0),
883        ];
884        for (d, lo) in cases {
885            assert_close(d.quantile(0.0), lo, 1e-9, "lo edge");
886        }
887    }
888
889    #[test]
890    fn quantile_at_one_returns_upper_support_for_finite_distributions() {
891        let cases = vec![
892            (Distribution::Uniform { lo: 2.0, hi: 5.0 }, 5.0),
893            (
894                Distribution::Triangular {
895                    lo: -1.0,
896                    mode: 0.0,
897                    hi: 1.0,
898                },
899                1.0,
900            ),
901            (
902                Distribution::Beta {
903                    alpha: 2.0,
904                    beta: 3.0,
905                    lo: 0.5,
906                    hi: 1.5,
907                },
908                1.5,
909            ),
910            (Distribution::Bernoulli { p: 0.4 }, 1.0),
911            (Distribution::DiscreteUniform { lo: -2, hi: 2 }, 2.0),
912        ];
913        for (d, hi) in cases {
914            assert_close(d.quantile(1.0), hi, 1e-9, "hi edge");
915        }
916    }
917}