Skip to main content

fugue/core/
distribution.rs

1#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/docs/core/distribution.md"))]
2use rand::{Rng, RngCore};
3use rand_distr::{
4    Beta as RDBeta, Binomial as RDBinomial, Cauchy as RDCauchy, ChiSquared as RDChiSquared,
5    Distribution as RandDistr, Exp as RDExp, Gamma as RDGamma, LogNormal as RDLogNormal,
6    Normal as RDNormal, Poisson as RDPoisson, StudentT as RDStudentT, Weibull as RDWeibull,
7};
8/// Type alias for log-probabilities.
9///
10/// Log-probabilities are represented as `f64` values. Negative infinity represents
11/// zero probability, while finite values represent the natural logarithm of probabilities.
12pub type LogF64 = f64;
13
14/// Generic interface for type-safe probability distributions.
15/// All distributions implement `Distribution<T>` where `T` is the natural return type.
16/// Example:
17///
18/// ```rust
19/// # use fugue::*;
20/// # use rand::thread_rng;
21///
22/// let mut rng = thread_rng();
23///
24/// // Type-safe sampling
25/// let coin = Bernoulli::new(0.5).unwrap();
26/// let flip: bool = coin.sample(&mut rng);  // Natural boolean
27/// let prob = coin.log_prob(&flip);
28///
29/// // Safe indexing
30/// let choice = Categorical::uniform(3).unwrap();
31/// let idx: usize = choice.sample(&mut rng);  // Safe for arrays
32/// let choice_prob = choice.log_prob(&idx);
33///
34/// // Natural counting
35/// let events = Poisson::new(3.0).unwrap();
36/// let count: u64 = events.sample(&mut rng);  // Natural count type
37/// let count_prob = events.log_prob(&count);
38/// ```
39pub trait Distribution<T>: Send + Sync {
40    /// Generate a random sample (with its natural type), `T`, from the distribution, using the provided random number generator, `rng`.
41    ///
42    /// Example:
43    /// ```rust
44    /// # use fugue::*;
45    /// # use rand::thread_rng;
46    ///
47    /// let mut rng = thread_rng();
48    ///
49    /// // Sample different distribution types
50    /// let normal_sample: f64 = Normal::new(0.0, 1.0).unwrap().sample(&mut rng);
51    /// let coin_flip: bool = Bernoulli::new(0.5).unwrap().sample(&mut rng);
52    /// let event_count: u64 = Poisson::new(3.0).unwrap().sample(&mut rng);
53    /// let category_idx: usize = Categorical::uniform(5).unwrap().sample(&mut rng);
54    /// ```
55    fn sample(&self, rng: &mut dyn RngCore) -> T;
56
57    /// Compute the log-probability density (continuous) or mass (discrete) of a value, `x`, from the distribution.
58    ///
59    /// Example:
60    /// ```rust
61    /// # use fugue::*;
62    ///
63    /// // Continuous distribution (probability density)
64    /// let normal = Normal::new(0.0, 1.0).unwrap();
65    /// let density = normal.log_prob(&0.0);  // Peak of standard normal
66    ///
67    /// // Discrete distribution (probability mass)
68    /// let coin = Bernoulli::new(0.7).unwrap();
69    /// let prob_true = coin.log_prob(&true);   // ln(0.7)
70    /// let prob_false = coin.log_prob(&false); // ln(0.3)
71    ///
72    /// // Outside support returns -∞
73    /// let poisson = Poisson::new(3.0).unwrap();
74    /// let invalid = poisson.log_prob(&u64::MAX); // Very unlikely, returns -∞
75    /// ```
76    fn log_prob(&self, x: &T) -> LogF64;
77
78    /// Clone the distribution into a boxed trait object, `Box<dyn Distribution<T>>`.
79    ///
80    /// Example:
81    /// ```rust
82    /// # use fugue::*;
83    ///
84    /// // Clone a distribution into a box
85    /// let original = Normal::new(0.0, 1.0).unwrap();
86    /// let boxed: Box<dyn Distribution<f64>> = original.clone_box();
87    ///
88    /// // Useful for storing different distribution types
89    /// let mut distributions: Vec<Box<dyn Distribution<f64>>> = vec![];
90    /// distributions.push(Normal::new(0.0, 1.0).unwrap().clone_box());
91    /// distributions.push(Uniform::new(-1.0, 1.0).unwrap().clone_box());
92    /// ```
93    fn clone_box(&self) -> Box<dyn Distribution<T>>;
94}
95
96/// A continuous distribution characterized by its mean, `mu`, and standard deviation, `sigma`.
97///
98/// Mathematical Properties:
99/// - **Support**: (-∞, +∞)
100/// - **PDF**: f(x) = (1/(σ√(2π))) × exp(-0.5 × ((x-μ)/σ)²)
101/// - **Mean**: μ
102/// - **Variance**: σ²
103/// - **68-95-99.7 rule**: ~68% within 1σ, ~95% within 2σ, ~99.7% within 3σ
104///
105/// Example:
106/// ```rust
107/// # use fugue::*;
108///
109/// // Standard normal (mean=0, std=1)
110/// let standard = sample(addr!("z"), Normal::new(0.0, 1.0).unwrap());
111///
112/// // Parameter with prior
113/// let theta = sample(addr!("theta"), Normal::new(0.0, 2.0).unwrap());
114///
115/// // Likelihood with observation
116/// let likelihood = observe(addr!("y"), Normal::new(1.5, 0.5).unwrap(), 2.0);
117///
118/// // Measurement error model
119/// let true_value = sample(addr!("true_val"), Normal::new(100.0, 10.0).unwrap());
120/// let measurement = true_value.bind(|val| {
121///     observe(addr!("measured"), Normal::new(val, 2.0).unwrap(), 98.5)
122/// });
123/// ```
124#[derive(Clone, Copy, Debug)]
125pub struct Normal {
126    /// Mean of the normal distribution.
127    mu: f64,
128    /// Standard deviation of the normal distribution (must be positive).
129    sigma: f64,
130}
131impl Normal {
132    /// Create a new Normal distribution with validated parameters.
133    pub fn new(mu: f64, sigma: f64) -> crate::error::FugueResult<Self> {
134        if !mu.is_finite() {
135            return Err(crate::error::FugueError::invalid_parameters(
136                "Normal",
137                "Mean (mu) must be finite",
138                crate::error::ErrorCode::InvalidMean,
139            )
140            .with_context("mu", format!("{}", mu)));
141        }
142        if sigma <= 0.0 || !sigma.is_finite() {
143            return Err(crate::error::FugueError::invalid_parameters(
144                "Normal",
145                "Standard deviation (sigma) must be positive and finite",
146                crate::error::ErrorCode::InvalidVariance,
147            )
148            .with_context("sigma", format!("{}", sigma))
149            .with_context("expected", "> 0.0 and finite"));
150        }
151        Ok(Normal { mu, sigma })
152    }
153
154    /// Create the standard normal distribution `N(0, 1)`.
155    ///
156    /// FG-29: infallible constructor for the statically-valid `mu = 0`,
157    /// `sigma = 1` case, so common code does not need `new(...).unwrap()`.
158    ///
159    /// ```rust
160    /// # use fugue::*;
161    /// let z = Normal::standard();
162    /// assert_eq!(z.mu(), 0.0);
163    /// assert_eq!(z.sigma(), 1.0);
164    /// ```
165    pub fn standard() -> Self {
166        Normal {
167            mu: 0.0,
168            sigma: 1.0,
169        }
170    }
171
172    /// Get the mean of the distribution.
173    pub fn mu(&self) -> f64 {
174        self.mu
175    }
176
177    /// Get the standard deviation of the distribution.
178    pub fn sigma(&self) -> f64 {
179        self.sigma
180    }
181}
182impl Distribution<f64> for Normal {
183    fn sample(&self, rng: &mut dyn RngCore) -> f64 {
184        if self.sigma <= 0.0 {
185            return f64::NAN;
186        }
187        RDNormal::new(self.mu, self.sigma).unwrap().sample(rng)
188    }
189    fn log_prob(&self, x: &f64) -> LogF64 {
190        // Parameter validation
191        if self.sigma <= 0.0 || !self.sigma.is_finite() || !self.mu.is_finite() || !x.is_finite() {
192            return f64::NEG_INFINITY;
193        }
194
195        // Numerically stable computation.
196        //
197        // FG-08: the log-density is computed entirely in log-space
198        // (`-0.5·z² - ln(σ) - 0.5·ln(2π)`) and never evaluates `exp`, so it is
199        // finite for every finite `z`. The previous `|z| > 37` short-circuit
200        // returned `-inf` for perfectly finite densities (e.g. a tight-sigma
201        // likelihood with a moderate residual), silently collapsing whole
202        // models; it has been removed.
203        let z = (x - self.mu) / self.sigma;
204
205        // Use precomputed constant for better precision
206        const LN_2PI: f64 = 1.837_877_066_409_345_6; // ln(2π)
207        -0.5 * z * z - self.sigma.ln() - 0.5 * LN_2PI
208    }
209    fn clone_box(&self) -> Box<dyn Distribution<f64>> {
210        Box::new(*self)
211    }
212}
213
214/// A continuous distribution that assigns equal probability density to all values within a specified interval, from `low` to `high`.
215///
216/// Commonly used as an uninformative prior when you want to express complete uncertainty over a bounded range.
217///
218/// Mathematical Properties:
219/// - **Support**: [low, high)
220/// - **PDF**: f(x) = 1/(high-low) for low ≤ x < high, 0 otherwise
221/// - **Mean**: (low + high) / 2
222/// - **Variance**: (high - low)² / 12
223///
224/// Example:
225///
226/// ```rust
227/// # use fugue::*;
228///
229/// // Unit interval [0, 1)
230/// let unit = sample(addr!("p"), Uniform::new(0.0, 1.0).unwrap());
231///
232/// // Symmetric around zero
233/// let symmetric = sample(addr!("x"), Uniform::new(-5.0, 5.0).unwrap());
234///
235/// // Uninformative prior for weight
236/// let weight = sample(addr!("weight"), Uniform::new(0.0, 100.0).unwrap());
237///
238/// // Random angle in radians
239/// let angle = sample(addr!("angle"), Uniform::new(0.0, 2.0 * std::f64::consts::PI).unwrap());
240/// ```
241#[derive(Clone, Copy, Debug)]
242pub struct Uniform {
243    /// Lower bound of the uniform distribution (inclusive).
244    low: f64,
245    /// Upper bound of the uniform distribution (exclusive).
246    high: f64,
247}
248impl Uniform {
249    /// Create a new Uniform distribution with validated parameters.
250    pub fn new(low: f64, high: f64) -> crate::error::FugueResult<Self> {
251        if !low.is_finite() || !high.is_finite() {
252            return Err(crate::error::FugueError::invalid_parameters(
253                "Uniform",
254                "Bounds must be finite",
255                crate::error::ErrorCode::InvalidRange,
256            )
257            .with_context("low", format!("{}", low))
258            .with_context("high", format!("{}", high)));
259        }
260        if low >= high {
261            return Err(crate::error::FugueError::invalid_parameters(
262                "Uniform",
263                "Lower bound must be less than upper bound",
264                crate::error::ErrorCode::InvalidRange,
265            )
266            .with_context("low", format!("{}", low))
267            .with_context("high", format!("{}", high)));
268        }
269        Ok(Uniform { low, high })
270    }
271
272    /// Create the unit uniform distribution on `[0, 1)`.
273    ///
274    /// FG-29: infallible constructor for the statically-valid `low = 0`,
275    /// `high = 1` case (the canonical uninformative prior over a probability),
276    /// avoiding `new(0.0, 1.0).unwrap()`.
277    ///
278    /// ```rust
279    /// # use fugue::*;
280    /// let u = Uniform::unit();
281    /// assert_eq!(u.low(), 0.0);
282    /// assert_eq!(u.high(), 1.0);
283    /// ```
284    pub fn unit() -> Self {
285        Uniform {
286            low: 0.0,
287            high: 1.0,
288        }
289    }
290
291    /// Get the lower bound.
292    pub fn low(&self) -> f64 {
293        self.low
294    }
295
296    /// Get the upper bound.
297    pub fn high(&self) -> f64 {
298        self.high
299    }
300}
301impl Distribution<f64> for Uniform {
302    fn sample(&self, rng: &mut dyn RngCore) -> f64 {
303        // Parameter validation
304        if self.low >= self.high || !self.low.is_finite() || !self.high.is_finite() {
305            return f64::NAN;
306        }
307        Rng::gen_range(rng, self.low..self.high)
308    }
309    fn log_prob(&self, x: &f64) -> LogF64 {
310        // Parameter validation
311        if self.low >= self.high
312            || !self.low.is_finite()
313            || !self.high.is_finite()
314            || !x.is_finite()
315        {
316            return f64::NEG_INFINITY;
317        }
318
319        // Check support with proper boundary handling
320        if *x < self.low || *x >= self.high {
321            f64::NEG_INFINITY
322        } else {
323            let width = self.high - self.low;
324            if width <= 0.0 {
325                f64::NEG_INFINITY
326            } else {
327                -width.ln()
328            }
329        }
330    }
331    fn clone_box(&self) -> Box<dyn Distribution<f64>> {
332        Box::new(*self)
333    }
334}
335
336/// A continuous distribution where the logarithm follows a normal distribution.
337///
338/// Useful for modeling positive-valued quantities that are naturally multiplicative or skewed.
339///
340/// Mathematical Properties:
341/// - **Support**: (0, +∞)
342/// - **PDF**: f(x) = (1/(xσ√(2π))) × exp(-0.5 × ((ln(x)-μ)/σ)²)
343/// - **Mean**: exp(μ + σ²/2)
344/// - **Variance**: (exp(σ²) - 1) × exp(2μ + σ²)
345/// - **Relationship**: If X ~ LogNormal(μ, σ), then ln(X) ~ Normal(μ, σ)
346///
347/// Example:
348/// ```rust
349/// # use fugue::*;
350///
351/// // Standard log-normal (median = 1)
352/// let standard = sample(addr!("x"), LogNormal::new(0.0, 1.0).unwrap());
353///
354/// // Positive scale parameter
355/// let scale = sample(addr!("scale"), LogNormal::new(0.0, 0.5).unwrap());
356///
357/// // Income distribution
358/// let income = sample(addr!("income"), LogNormal::new(10.0, 0.8).unwrap())
359///     .map(|x| x.round() as u64); // Convert to dollars
360///
361/// // Multiplicative error model
362/// let true_value = 100.0;
363/// let measured = sample(addr!("error"), LogNormal::new(0.0, 0.1).unwrap())
364///     .map(move |error| true_value * error);
365/// ```
366#[derive(Clone, Copy, Debug)]
367pub struct LogNormal {
368    /// Mean of the underlying normal distribution.
369    mu: f64,
370    /// Standard deviation of the underlying normal distribution (must be positive).
371    sigma: f64,
372}
373impl LogNormal {
374    /// Create a new LogNormal distribution with validated parameters.
375    pub fn new(mu: f64, sigma: f64) -> crate::error::FugueResult<Self> {
376        if !mu.is_finite() {
377            return Err(crate::error::FugueError::invalid_parameters(
378                "LogNormal",
379                "Mean (mu) must be finite",
380                crate::error::ErrorCode::InvalidMean,
381            )
382            .with_context("mu", format!("{}", mu)));
383        }
384        if sigma <= 0.0 || !sigma.is_finite() {
385            return Err(crate::error::FugueError::invalid_parameters(
386                "LogNormal",
387                "Standard deviation (sigma) must be positive and finite",
388                crate::error::ErrorCode::InvalidVariance,
389            )
390            .with_context("sigma", format!("{}", sigma))
391            .with_context("expected", "> 0.0 and finite"));
392        }
393        Ok(LogNormal { mu, sigma })
394    }
395
396    /// Get the mean of the underlying normal distribution.
397    pub fn mu(&self) -> f64 {
398        self.mu
399    }
400
401    /// Get the standard deviation of the underlying normal distribution.
402    pub fn sigma(&self) -> f64 {
403        self.sigma
404    }
405}
406impl Distribution<f64> for LogNormal {
407    fn sample(&self, rng: &mut dyn RngCore) -> f64 {
408        if self.sigma <= 0.0 {
409            return f64::NAN;
410        }
411        RDLogNormal::new(self.mu, self.sigma).unwrap().sample(rng)
412    }
413    fn log_prob(&self, x: &f64) -> LogF64 {
414        // Parameter and input validation
415        if self.sigma <= 0.0 || !self.sigma.is_finite() || !self.mu.is_finite() {
416            return f64::NEG_INFINITY;
417        }
418        if *x <= 0.0 || !x.is_finite() {
419            return f64::NEG_INFINITY;
420        }
421
422        // Numerically stable computation.
423        //
424        // FG-08: like Normal, this is pure log-space and finite for any finite
425        // standardized residual `z`; the old `|z| > 37` guard wrongly returned
426        // `-inf` for finite densities (e.g. tight-sigma multiplicative error
427        // models) and has been removed.
428        let lx = x.ln();
429        let z = (lx - self.mu) / self.sigma;
430
431        // Stable computation: log_prob = -0.5*z² - ln(x) - ln(σ) - 0.5*ln(2π)
432        const LN_2PI: f64 = 1.837_877_066_409_345_6; // ln(2π)
433        -0.5 * z * z - lx - self.sigma.ln() - 0.5 * LN_2PI
434    }
435    fn clone_box(&self) -> Box<dyn Distribution<f64>> {
436        Box::new(*self)
437    }
438}
439
440/// A continuous distribution often used to model waiting times between events.
441///
442/// Characterized by the memoryless property.
443///
444/// Mathematical Properties:
445/// - **Support**: [0, +∞)
446/// - **PDF**: f(x) = λ × exp(-λx) for x ≥ 0
447/// - **Mean**: 1 / λ
448/// - **Variance**: 1 / λ²
449/// - **Memoryless**: P(X > s + t | X > s) = P(X > t)
450///
451/// Example:
452/// ```rust
453/// # use fugue::*;
454///
455/// // Average wait time of 2 minutes (rate = 0.5 per minute)
456/// let wait_time = sample(addr!("wait"), Exponential::new(0.5).unwrap());
457///
458/// // Service time model
459/// let service = sample(addr!("service_time"), Exponential::new(1.5).unwrap())
460///     .bind(|time| {
461///         if time > 5.0 {
462///             pure("slow")
463///         } else {
464///             pure("fast")
465///         }
466///     });
467///
468/// // Observe actual waiting time
469/// let observed = observe(addr!("actual_wait"), Exponential::new(0.3).unwrap(), 4.2);
470/// ```
471#[derive(Clone, Copy, Debug)]
472pub struct Exponential {
473    /// Rate parameter λ of the exponential distribution (must be positive).
474    rate: f64,
475}
476impl Exponential {
477    /// Create a new Exponential distribution with validated parameters.
478    pub fn new(rate: f64) -> crate::error::FugueResult<Self> {
479        if rate <= 0.0 || !rate.is_finite() {
480            return Err(crate::error::FugueError::invalid_parameters(
481                "Exponential",
482                "Rate parameter must be positive and finite",
483                crate::error::ErrorCode::InvalidRate,
484            )
485            .with_context("rate", format!("{}", rate))
486            .with_context("expected", "> 0.0 and finite"));
487        }
488        Ok(Exponential { rate })
489    }
490
491    /// Get the rate parameter.
492    pub fn rate(&self) -> f64 {
493        self.rate
494    }
495}
496impl Distribution<f64> for Exponential {
497    fn sample(&self, rng: &mut dyn RngCore) -> f64 {
498        if self.rate <= 0.0 {
499            return f64::NAN;
500        }
501        RDExp::new(self.rate).unwrap().sample(rng)
502    }
503    fn log_prob(&self, x: &f64) -> LogF64 {
504        // Parameter validation
505        if self.rate <= 0.0 || !self.rate.is_finite() || !x.is_finite() {
506            return f64::NEG_INFINITY;
507        }
508
509        if *x < 0.0 {
510            f64::NEG_INFINITY
511        } else {
512            // FG-30: `ln(λ) - λx` is computed entirely in log-space and is
513            // finite for every finite `x` (`-λx` is just a subtraction, no
514            // `exp`). The previous `rate*x > 700` short-circuit returned `-inf`
515            // for finite tail log-densities and has been removed.
516            self.rate.ln() - self.rate * x
517        }
518    }
519    fn clone_box(&self) -> Box<dyn Distribution<f64>> {
520        Box::new(*self)
521    }
522}
523
524/// A discrete distribution for binary outcomes (true/false, success/failure).
525///
526/// Returns `bool` directly for type-safe boolean logic.
527///
528/// Mathematical Properties:
529/// - **Support**: {false, true}
530/// - **PMF**: P(X = true) = p, P(X = false) = 1 - p
531/// - **Mean**: p
532/// - **Variance**: p(1 - p)
533///
534/// Example:
535/// ```rust
536/// # use fugue::*;
537///
538/// // Fair coin flip
539/// let coin = sample(addr!("coin"), Bernoulli::new(0.5).unwrap());
540/// let result = coin.bind(|heads| {
541///     if heads {
542///         pure("Heads!")
543///     } else {
544///         pure("Tails!")
545///     }
546/// });
547///
548/// // Biased coin with observation
549/// let biased = observe(addr!("biased_coin"), Bernoulli::new(0.7).unwrap(), true);
550/// ```
551#[derive(Clone, Copy, Debug)]
552pub struct Bernoulli {
553    /// Probability of success (must be in [0, 1]).
554    p: f64,
555}
556impl Bernoulli {
557    /// Create a new Bernoulli distribution with validated parameters.
558    pub fn new(p: f64) -> crate::error::FugueResult<Self> {
559        if !p.is_finite() || !(0.0..=1.0).contains(&p) {
560            return Err(crate::error::FugueError::invalid_parameters(
561                "Bernoulli",
562                "Probability must be in [0, 1]",
563                crate::error::ErrorCode::InvalidProbability,
564            )
565            .with_context("p", format!("{}", p))
566            .with_context("expected", "[0.0, 1.0]"));
567        }
568        Ok(Bernoulli { p })
569    }
570
571    /// Create a fair Bernoulli distribution (`p = 0.5`).
572    ///
573    /// FG-29: infallible constructor for the statically-valid fair-coin case,
574    /// avoiding `new(0.5).unwrap()`.
575    ///
576    /// ```rust
577    /// # use fugue::*;
578    /// let coin = Bernoulli::fair();
579    /// assert_eq!(coin.p(), 0.5);
580    /// ```
581    pub fn fair() -> Self {
582        Bernoulli { p: 0.5 }
583    }
584
585    /// Get the success probability.
586    pub fn p(&self) -> f64 {
587        self.p
588    }
589}
590impl Distribution<bool> for Bernoulli {
591    fn sample(&self, rng: &mut dyn RngCore) -> bool {
592        if self.p < 0.0 || self.p > 1.0 || !self.p.is_finite() {
593            return false; // Default to false for invalid parameters
594        }
595        use rand::Rng;
596        rng.gen::<f64>() < self.p
597    }
598    fn log_prob(&self, x: &bool) -> LogF64 {
599        // Parameter validation
600        if self.p < 0.0 || self.p > 1.0 || !self.p.is_finite() {
601            return f64::NEG_INFINITY;
602        }
603
604        if *x {
605            // P(X = true) = p
606            if self.p <= 0.0 {
607                f64::NEG_INFINITY
608            } else {
609                self.p.ln()
610            }
611        } else {
612            // P(X = false) = 1 - p
613            if self.p >= 1.0 {
614                f64::NEG_INFINITY
615            } else {
616                (1.0 - self.p).ln()
617            }
618        }
619    }
620    fn clone_box(&self) -> Box<dyn Distribution<bool>> {
621        Box::new(*self)
622    }
623}
624
625/// A discrete distribution for choosing among multiple categories with specified probabilities.
626///
627/// Returns `usize` for safe array indexing.
628///
629/// Mathematical Properties:
630/// - **Support**: {0, 1, ..., k-1} where k = number of categories
631/// - **PMF**: P(X = i) = probs[i]
632/// - **Mean**: Σ(i × probs[i])
633/// - **Variance**: Σ(i² × probs[i]) - mean²
634///
635/// Example:
636/// ```rust
637/// # use fugue::*;
638///
639/// // Custom probabilities
640/// let weighted = Categorical::new(vec![0.1, 0.2, 0.3, 0.4]).unwrap();
641///
642/// // Uniform distribution over k categories
643/// let uniform = Categorical::uniform(4).unwrap();
644///
645/// // Choose from three options
646/// let options = vec!["red", "green", "blue"];
647/// let choice = sample(addr!("color"), Categorical::new(vec![0.5, 0.3, 0.2]).unwrap())
648///     .map(move |idx| options[idx].to_string());
649///
650/// // Observe a specific choice
651/// let observed = observe(addr!("user_choice"),
652///     Categorical::uniform(3).unwrap(), 1usize);
653/// ```
654#[derive(Clone, Debug)]
655pub struct Categorical {
656    /// Probabilities for each category (validated to sum to 1.0 in the constructor).
657    probs: Vec<f64>,
658    /// Cached inclusive cumulative distribution: `cumulative[i] = Σ probs[0..=i]`.
659    ///
660    /// FG-53: computed once at construction so `sample` can binary-search the CDF
661    /// (O(log k)) and neither `sample` nor `log_prob` re-sums/re-validates the
662    /// full probability vector on the hot inference path.
663    cumulative: Vec<f64>,
664}
665impl Categorical {
666    /// Build the inclusive cumulative distribution from a validated probability slice.
667    fn compute_cumulative(probs: &[f64]) -> Vec<f64> {
668        let mut cumulative = Vec::with_capacity(probs.len());
669        let mut acc = 0.0;
670        for &p in probs {
671            acc += p;
672            cumulative.push(acc);
673        }
674        cumulative
675    }
676
677    /// Validate a probability vector against the Categorical invariants
678    /// (non-empty, every entry non-negative and finite, sum ≈ 1.0).
679    fn validate_probs(probs: &[f64]) -> crate::error::FugueResult<()> {
680        if probs.is_empty() {
681            return Err(crate::error::FugueError::invalid_parameters(
682                "Categorical",
683                "Probability vector cannot be empty",
684                crate::error::ErrorCode::InvalidProbability,
685            )
686            .with_context("length", "0"));
687        }
688
689        let sum: f64 = probs.iter().sum();
690        if (sum - 1.0).abs() > 1e-6 {
691            return Err(crate::error::FugueError::invalid_parameters(
692                "Categorical",
693                "Probabilities must sum to 1.0",
694                crate::error::ErrorCode::InvalidProbability,
695            )
696            .with_context("sum", format!("{:.6}", sum))
697            .with_context("expected", "1.0")
698            .with_context("tolerance", "1e-6"));
699        }
700
701        for (i, &p) in probs.iter().enumerate() {
702            if !p.is_finite() || p < 0.0 {
703                return Err(crate::error::FugueError::invalid_parameters(
704                    "Categorical",
705                    "All probabilities must be non-negative and finite",
706                    crate::error::ErrorCode::InvalidProbability,
707                )
708                .with_context("index", format!("{}", i))
709                .with_context("value", format!("{}", p))
710                .with_context("expected", ">= 0.0 and finite"));
711            }
712        }
713
714        Ok(())
715    }
716
717    /// Create a new Categorical distribution with validated parameters.
718    ///
719    /// FG-53: the probability vector is validated exactly once here and the
720    /// cumulative distribution is cached; `sample`/`log_prob` then rely on the
721    /// established invariant instead of re-validating on every call.
722    pub fn new(probs: Vec<f64>) -> crate::error::FugueResult<Self> {
723        Self::validate_probs(&probs)?;
724        let cumulative = Self::compute_cumulative(&probs);
725        Ok(Categorical { probs, cumulative })
726    }
727
728    /// Create a uniform categorical distribution over k categories.
729    pub fn uniform(k: usize) -> crate::error::FugueResult<Self> {
730        if k == 0 {
731            return Err(crate::error::FugueError::invalid_parameters(
732                "Categorical",
733                "Number of categories must be positive",
734                crate::error::ErrorCode::InvalidCount,
735            )
736            .with_context("k", "0"));
737        }
738
739        let prob = 1.0 / k as f64;
740        let probs = vec![prob; k];
741        let cumulative = Self::compute_cumulative(&probs);
742        Ok(Categorical { probs, cumulative })
743    }
744
745    /// Re-check the constructor invariants on the cached probability vector.
746    ///
747    /// The public constructors ([`Categorical::new`]/[`Categorical::uniform`])
748    /// already guarantee these invariants, so this is only needed if a
749    /// `Categorical` is obtained through some future unchecked path (e.g.
750    /// deserialization) and the caller wants to reassert validity.
751    pub fn revalidate(&self) -> crate::error::FugueResult<()> {
752        Self::validate_probs(&self.probs)
753    }
754
755    /// Get the probability vector.
756    pub fn probs(&self) -> &[f64] {
757        &self.probs
758    }
759
760    /// Get the number of categories.
761    pub fn len(&self) -> usize {
762        self.probs.len()
763    }
764
765    /// Check if the distribution has no categories.
766    pub fn is_empty(&self) -> bool {
767        self.probs.is_empty()
768    }
769}
770impl Distribution<usize> for Categorical {
771    fn sample(&self, rng: &mut dyn RngCore) -> usize {
772        // FG-53: the probability vector was validated once at construction, so
773        // no per-call re-sum/re-scan is needed. Draw u ~ Uniform[0,1) and binary
774        // search the cached CDF for the first index i with cumulative[i] >= u —
775        // the exact same mapping the previous linear scan produced, in O(log k).
776        if self.cumulative.is_empty() {
777            return 0;
778        }
779
780        use rand::Rng;
781        let u: f64 = rng.gen();
782        let idx = self.cumulative.partition_point(|&c| c < u);
783        idx.min(self.probs.len() - 1)
784    }
785    fn log_prob(&self, x: &usize) -> LogF64 {
786        // FG-53: bounds-checked index into the validated probability vector.
787        match self.probs.get(*x) {
788            Some(&p) if p > 0.0 => p.ln(),
789            _ => f64::NEG_INFINITY,
790        }
791    }
792    fn clone_box(&self) -> Box<dyn Distribution<usize>> {
793        Box::new(self.clone())
794    }
795}
796
797/// A continuous distribution on the interval (0, 1), commonly used for modeling probabilities and proportions.
798///
799/// Conjugate prior for Bernoulli/Binomial distributions.
800///
801/// Mathematical Properties:
802/// - **Support**: (0, 1); the closed endpoints 0 and 1 are handled as limits
803/// - **PDF**: f(x) = (x^(α-1) × (1-x)^(β-1)) / B(α,β)
804/// - **Mean**: α / (α + β)
805/// - **Variance**: (αβ) / ((α+β)²(α+β+1))
806///
807/// Boundary semantics (matching `scipy.stats.beta.logpdf`): at `x = 0`,
808/// `log_prob` is `-∞` when `α > 1` (density → 0), `ln(β)` when `α == 1`, and
809/// `+∞` when `α < 1` (density diverges, e.g. the Jeffreys prior Beta(0.5, 0.5)).
810/// The endpoint `x = 1` is symmetric in `β`.
811///
812/// Example:
813/// ```rust
814/// # use fugue::*;
815///
816/// // Uniform on [0,1]
817/// let uniform = sample(addr!("p"), Beta::new(1.0, 1.0).unwrap());
818///
819/// // Prior for success probability
820/// let prob_prior = sample(addr!("success_rate"), Beta::new(2.0, 5.0).unwrap());
821///
822/// // Conjugate prior-likelihood pair
823/// let model = sample(addr!("p"), Beta::new(3.0, 7.0).unwrap())
824///     .bind(|p| observe(addr!("trial"), Bernoulli::new(p).unwrap(), true));
825///
826/// // Skewed towards 0 (beta > alpha)
827/// let skewed = sample(addr!("proportion"), Beta::new(2.0, 8.0).unwrap());
828/// ```
829#[derive(Clone, Copy, Debug)]
830pub struct Beta {
831    /// First shape parameter α (must be positive).
832    alpha: f64,
833    /// Second shape parameter β (must be positive).
834    beta: f64,
835}
836impl Beta {
837    /// Create a new Beta distribution with validated parameters.
838    pub fn new(alpha: f64, beta: f64) -> crate::error::FugueResult<Self> {
839        if alpha <= 0.0 || !alpha.is_finite() {
840            return Err(crate::error::FugueError::invalid_parameters(
841                "Beta",
842                "Alpha parameter must be positive and finite",
843                crate::error::ErrorCode::InvalidShape,
844            )
845            .with_context("alpha", format!("{}", alpha))
846            .with_context("expected", "> 0.0 and finite"));
847        }
848        if beta <= 0.0 || !beta.is_finite() {
849            return Err(crate::error::FugueError::invalid_parameters(
850                "Beta",
851                "Beta parameter must be positive and finite",
852                crate::error::ErrorCode::InvalidShape,
853            )
854            .with_context("beta", format!("{}", beta))
855            .with_context("expected", "> 0.0 and finite"));
856        }
857        Ok(Beta { alpha, beta })
858    }
859
860    /// Create the uniform-prior Beta distribution `Beta(1, 1)`.
861    ///
862    /// FG-29: infallible constructor for the statically-valid `α = β = 1` case,
863    /// which is exactly the uniform distribution on `(0, 1)` and the standard
864    /// uninformative conjugate prior for a Bernoulli/Binomial probability;
865    /// avoids `new(1.0, 1.0).unwrap()`.
866    ///
867    /// ```rust
868    /// # use fugue::*;
869    /// let prior = Beta::uniform_prior();
870    /// assert_eq!(prior.alpha(), 1.0);
871    /// assert_eq!(prior.beta(), 1.0);
872    /// ```
873    pub fn uniform_prior() -> Self {
874        Beta {
875            alpha: 1.0,
876            beta: 1.0,
877        }
878    }
879
880    /// Get the alpha parameter.
881    pub fn alpha(&self) -> f64 {
882        self.alpha
883    }
884
885    /// Get the beta parameter.
886    pub fn beta(&self) -> f64 {
887        self.beta
888    }
889}
890impl Distribution<f64> for Beta {
891    fn sample(&self, rng: &mut dyn RngCore) -> f64 {
892        if self.alpha <= 0.0 || self.beta <= 0.0 {
893            return f64::NAN;
894        }
895        RDBeta::new(self.alpha, self.beta).unwrap().sample(rng)
896    }
897    fn log_prob(&self, x: &f64) -> LogF64 {
898        // Parameter validation
899        if self.alpha <= 0.0
900            || self.beta <= 0.0
901            || !self.alpha.is_finite()
902            || !self.beta.is_finite()
903            || !x.is_finite()
904        {
905            return f64::NEG_INFINITY;
906        }
907
908        let x = *x;
909
910        // Outside the closed support [0, 1] the density is 0.
911        if !(0.0..=1.0).contains(&x) {
912            return f64::NEG_INFINITY;
913        }
914
915        // log B(α, β), the (log) normalizing constant.
916        let log_beta_fn = libm::lgamma(self.alpha) + libm::lgamma(self.beta)
917            - libm::lgamma(self.alpha + self.beta);
918
919        // FG-27: boundary limits matching `scipy.stats.beta.logpdf`. The density
920        // behaves like x^(α-1) at 0 and (1-x)^(β-1) at 1, so at each endpoint:
921        //   - shape param > 1  ⇒ density → 0   ⇒ -inf
922        //   - shape param == 1 ⇒ density finite ⇒ the finite limit (-log B)
923        //   - shape param < 1  ⇒ density → ∞   ⇒ +inf
924        // The previous `1e-100`/`ln < -700` cutoffs returned -inf here even where
925        // the true log-density is a large *positive* number (e.g. Jeffreys prior
926        // Beta(0.5,0.5)), which is wrong in sign, not merely over-conservative.
927        if x == 0.0 {
928            return if self.alpha > 1.0 {
929                f64::NEG_INFINITY
930            } else if self.alpha < 1.0 {
931                f64::INFINITY
932            } else {
933                // α == 1: (α-1)·ln(x) = 0 and (β-1)·ln(1) = 0, so log_prob = -log B(1,β) = ln(β).
934                -log_beta_fn
935            };
936        }
937        if x == 1.0 {
938            return if self.beta > 1.0 {
939                f64::NEG_INFINITY
940            } else if self.beta < 1.0 {
941                f64::INFINITY
942            } else {
943                // β == 1: log_prob = -log B(α,1) = ln(α).
944                -log_beta_fn
945            };
946        }
947
948        // Interior x ∈ (0, 1): computed exactly with no ln guards. f64::ln
949        // handles subnormals fine, and for α<1 (or β<1) near a boundary the
950        // (α-1)·ln(x) term correctly diverges to +∞ rather than being clipped.
951        // log Beta(x; α, β) = (α-1)·ln(x) + (β-1)·ln(1-x) - log B(α, β)
952        let ln_x = x.ln();
953        let ln_1_minus_x = (1.0 - x).ln();
954
955        (self.alpha - 1.0) * ln_x + (self.beta - 1.0) * ln_1_minus_x - log_beta_fn
956    }
957    fn clone_box(&self) -> Box<dyn Distribution<f64>> {
958        Box::new(*self)
959    }
960}
961
962/// A continuous distribution over positive real numbers, parameterized by shape and rate.
963///
964/// Commonly used for modeling waiting times and as a conjugate prior for Poisson distributions.
965///
966/// Mathematical Properties:
967/// - **Support**: (0, +∞)
968/// - **PDF**: f(x) = (λ^k / Γ(k)) × x^(k-1) × exp(-λx)
969/// - **Mean**: k / λ
970/// - **Variance**: k / λ²
971///
972/// Example:
973/// ```rust
974/// # use fugue::*;
975///
976/// // Shape=1 gives Exponential distribution
977/// let exponential_like = sample(addr!("wait_time"), Gamma::new(1.0, 2.0).unwrap());
978///
979/// // Prior for precision parameter
980/// let precision = sample(addr!("precision"), Gamma::new(2.0, 1.0).unwrap());
981///
982/// // Conjugate prior for Poisson rate
983/// let model = sample(addr!("rate"), Gamma::new(3.0, 2.0).unwrap())
984///     .bind(|lambda| observe(addr!("count"), Poisson::new(lambda).unwrap(), 5u64));
985///
986/// // Scale parameter (rate = 1/scale)
987/// let scale_param = sample(addr!("scale"), Gamma::new(2.0, 0.5).unwrap()); // mean = 4
988/// ```
989#[derive(Clone, Copy, Debug)]
990pub struct Gamma {
991    /// Shape parameter k (must be positive).
992    shape: f64,
993    /// Rate parameter λ (must be positive).
994    rate: f64,
995}
996impl Gamma {
997    /// Create a new Gamma distribution with validated parameters.
998    pub fn new(shape: f64, rate: f64) -> crate::error::FugueResult<Self> {
999        if shape <= 0.0 || !shape.is_finite() {
1000            return Err(crate::error::FugueError::invalid_parameters(
1001                "Gamma",
1002                "Shape parameter must be positive and finite",
1003                crate::error::ErrorCode::InvalidShape,
1004            )
1005            .with_context("shape", format!("{}", shape))
1006            .with_context("expected", "> 0.0 and finite"));
1007        }
1008        if rate <= 0.0 || !rate.is_finite() {
1009            return Err(crate::error::FugueError::invalid_parameters(
1010                "Gamma",
1011                "Rate parameter must be positive and finite",
1012                crate::error::ErrorCode::InvalidRate,
1013            )
1014            .with_context("rate", format!("{}", rate))
1015            .with_context("expected", "> 0.0 and finite"));
1016        }
1017        Ok(Gamma { shape, rate })
1018    }
1019
1020    /// Get the shape parameter.
1021    pub fn shape(&self) -> f64 {
1022        self.shape
1023    }
1024
1025    /// Get the rate parameter.
1026    pub fn rate(&self) -> f64 {
1027        self.rate
1028    }
1029}
1030impl Distribution<f64> for Gamma {
1031    fn sample(&self, rng: &mut dyn RngCore) -> f64 {
1032        if self.shape <= 0.0 || self.rate <= 0.0 {
1033            return f64::NAN;
1034        }
1035        RDGamma::new(self.shape, 1.0 / self.rate)
1036            .unwrap()
1037            .sample(rng)
1038    }
1039    fn log_prob(&self, x: &f64) -> LogF64 {
1040        // Parameter validation
1041        if self.shape <= 0.0
1042            || self.rate <= 0.0
1043            || !self.shape.is_finite()
1044            || !self.rate.is_finite()
1045            || !x.is_finite()
1046        {
1047            return f64::NEG_INFINITY;
1048        }
1049
1050        if *x <= 0.0 {
1051            return f64::NEG_INFINITY;
1052        }
1053
1054        // FG-07: the formula below is pure log-space and never evaluates
1055        // `exp`, so `-λx` and `(k-1)·ln(x)` are finite for every `x > 0`. The
1056        // previous `rate*x > 700` / `ln(x)·(k-1) < -700` guards returned `-inf`
1057        // across the entire high-density region (including the mode) of any
1058        // Gamma with mean ≳ 700, silently zeroing large-shape posteriors. They
1059        // have been removed; only the genuine `x <= 0` support check remains.
1060        //
1061        // Numerically stable computation
1062        // log Gamma(x; k, λ) = k*ln(λ) + (k-1)*ln(x) - λ*x - ln Γ(k)
1063        let log_rate = self.rate.ln();
1064        let log_x = x.ln();
1065        let log_gamma_shape = libm::lgamma(self.shape);
1066
1067        self.shape * log_rate + (self.shape - 1.0) * log_x - self.rate * x - log_gamma_shape
1068    }
1069    fn clone_box(&self) -> Box<dyn Distribution<f64>> {
1070        Box::new(*self)
1071    }
1072}
1073
1074/// A discrete distribution representing the number of successes in n independent trials, with probability of success p.
1075///
1076/// Returns `u64` for natural success counting.
1077///
1078/// Mathematical Properties:
1079/// - **Support**: {0, 1, ..., n}
1080/// - **PMF**: P(X = k) = C(n,k) × p^k × (1-p)^(n-k)
1081/// - **Mean**: n × p
1082/// - **Variance**: n × p × (1-p)
1083///
1084/// Example:
1085/// ```rust
1086/// # use fugue::*;
1087///
1088/// // 10 coin flips
1089/// let successes = sample(addr!("heads"), Binomial::new(10, 0.5).unwrap())
1090///     .bind(|count| {
1091///         let rate = count as f64 / 10.0;
1092///         pure(format!("Success rate: {:.1}%", rate * 100.0))
1093///     });
1094///
1095/// // Clinical trial
1096/// let trial = sample(addr!("success_rate"), Beta::new(1.0, 1.0).unwrap())
1097///     .bind(|p| sample(addr!("successes"), Binomial::new(100, p).unwrap()));
1098///
1099/// // Observe trial results
1100/// let observed = observe(addr!("trial_successes"), Binomial::new(20, 0.3).unwrap(), 7u64);
1101/// ```
1102#[derive(Clone, Copy, Debug)]
1103pub struct Binomial {
1104    /// Number of trials.
1105    n: u64,
1106    /// Probability of success on each trial (must be in [0, 1]).
1107    p: f64,
1108}
1109impl Binomial {
1110    /// Create a new Binomial distribution with validated parameters.
1111    pub fn new(n: u64, p: f64) -> crate::error::FugueResult<Self> {
1112        if !p.is_finite() || !(0.0..=1.0).contains(&p) {
1113            return Err(crate::error::FugueError::invalid_parameters(
1114                "Binomial",
1115                "Probability must be in [0, 1]",
1116                crate::error::ErrorCode::InvalidProbability,
1117            )
1118            .with_context("p", format!("{}", p))
1119            .with_context("expected", "[0.0, 1.0]"));
1120        }
1121        Ok(Binomial { n, p })
1122    }
1123
1124    /// Get the number of trials.
1125    pub fn n(&self) -> u64 {
1126        self.n
1127    }
1128
1129    /// Get the success probability.
1130    pub fn p(&self) -> f64 {
1131        self.p
1132    }
1133}
1134impl Distribution<u64> for Binomial {
1135    fn sample(&self, rng: &mut dyn RngCore) -> u64 {
1136        RDBinomial::new(self.n, self.p).unwrap().sample(rng)
1137    }
1138    fn log_prob(&self, x: &u64) -> LogF64 {
1139        // Parameter validation (defensive; `new` already enforces p ∈ [0, 1]).
1140        if !self.p.is_finite() || !(0.0..=1.0).contains(&self.p) {
1141            return f64::NEG_INFINITY;
1142        }
1143        let k = *x;
1144        if k > self.n {
1145            return f64::NEG_INFINITY;
1146        }
1147
1148        // FG-28: `new` accepts the degenerate boundaries p = 0 and p = 1, which
1149        // are valid parameters. Evaluating the general formula there produces
1150        // `0 * ln(0) = 0 * -inf = NaN`, which is materially worse than -inf
1151        // because it poisons every downstream comparison. Handle them exactly:
1152        // p = 0 puts all mass on k = 0, p = 1 puts all mass on k = n.
1153        if self.p == 0.0 {
1154            return if k == 0 { 0.0 } else { f64::NEG_INFINITY };
1155        }
1156        if self.p == 1.0 {
1157            return if k == self.n { 0.0 } else { f64::NEG_INFINITY };
1158        }
1159
1160        // log Binomial(k; n, p) = log C(n,k) + k*ln(p) + (n-k)*ln(1-p)
1161        let log_binom_coeff = libm::lgamma(self.n as f64 + 1.0)
1162            - libm::lgamma(k as f64 + 1.0)
1163            - libm::lgamma((self.n - k) as f64 + 1.0);
1164        log_binom_coeff + (k as f64) * self.p.ln() + ((self.n - k) as f64) * (1.0 - self.p).ln()
1165    }
1166    fn clone_box(&self) -> Box<dyn Distribution<u64>> {
1167        Box::new(*self)
1168    }
1169}
1170
1171/// A discrete distribution for modeling the number of events occurring in a fixed interval.
1172///
1173/// Returns `u64` for natural counting arithmetic.
1174///
1175/// Mathematical Properties:
1176/// - **Support**: {0, 1, 2, 3, ...}
1177/// - **PMF**: P(X = k) = (λ^k × e^(-λ)) / k!
1178/// - **Mean**: λ
1179/// - **Variance**: λ
1180/// - **Memoryless**: Past events don't affect future rates
1181///
1182/// Example:
1183/// ```rust
1184/// # use fugue::*;
1185///
1186/// // Model event counts
1187/// let events = sample(addr!("events"), Poisson::new(3.0).unwrap())
1188///     .bind(|count| {
1189///         let status = match count {
1190///             0 => "No events",
1191///             1 => "Single event",
1192///             n if n > 10 => "High activity",
1193///             _ => "Normal activity"
1194///         };
1195///         pure(status.to_string())
1196///     });
1197///
1198/// // Hierarchical model with Gamma prior
1199/// let hierarchical = sample(addr!("rate"), Gamma::new(2.0, 1.0).unwrap())
1200///     .bind(|lambda| sample(addr!("count"), Poisson::new(lambda).unwrap()));
1201///
1202/// // Observe count data
1203/// let observed = observe(addr!("observed_count"), Poisson::new(4.0).unwrap(), 7u64);
1204/// ```
1205#[derive(Clone, Copy, Debug)]
1206pub struct Poisson {
1207    /// Rate parameter λ (must be positive). Mean and variance of the distribution.
1208    lambda: f64,
1209}
1210impl Poisson {
1211    /// Create a new Poisson distribution with validated parameters.
1212    pub fn new(lambda: f64) -> crate::error::FugueResult<Self> {
1213        if lambda <= 0.0 || !lambda.is_finite() {
1214            return Err(crate::error::FugueError::invalid_parameters(
1215                "Poisson",
1216                "Rate parameter lambda must be positive and finite",
1217                crate::error::ErrorCode::InvalidRate,
1218            )
1219            .with_context("lambda", format!("{}", lambda))
1220            .with_context("expected", "> 0.0 and finite"));
1221        }
1222        Ok(Poisson { lambda })
1223    }
1224
1225    /// Get the rate parameter.
1226    pub fn lambda(&self) -> f64 {
1227        self.lambda
1228    }
1229}
1230impl Distribution<u64> for Poisson {
1231    fn sample(&self, rng: &mut dyn RngCore) -> u64 {
1232        if self.lambda <= 0.0 || !self.lambda.is_finite() {
1233            return 0;
1234        }
1235        RDPoisson::new(self.lambda).unwrap().sample(rng) as u64
1236    }
1237    fn log_prob(&self, x: &u64) -> LogF64 {
1238        // Parameter validation
1239        if self.lambda <= 0.0 || !self.lambda.is_finite() {
1240            return f64::NEG_INFINITY;
1241        }
1242
1243        let k = *x;
1244
1245        // Handle extreme cases
1246        if self.lambda > 700.0 && k == 0 {
1247            return -self.lambda; // Direct computation to avoid lgamma issues
1248        }
1249
1250        // Numerically stable computation
1251        // log Poisson(k; λ) = k*ln(λ) - λ - ln(k!)
1252        let k_f64 = k as f64;
1253        let log_lambda = self.lambda.ln();
1254        let log_factorial = libm::lgamma(k_f64 + 1.0);
1255
1256        k_f64 * log_lambda - self.lambda - log_factorial
1257    }
1258    fn clone_box(&self) -> Box<dyn Distribution<u64>> {
1259        Box::new(*self)
1260    }
1261}
1262
1263// =============================================================================
1264// FG-31: seven additional univariate distributions.
1265//
1266// Each follows the established style exactly: a validating `new` constructor
1267// returning `FugueResult`, natural `f64`/`i64` return types, and a `log_prob`
1268// that carries the FULL normalizing constant (no dropped `lgamma`/`ln` terms).
1269// Samplers use `rand_distr` where a matching generator exists and an exact
1270// inverse-CDF / reciprocal-Gamma construction otherwise. The closed-form
1271// `log_prob` expressions match `scipy.stats.<dist>.logpdf` (constants in the
1272// tests were derived from those closed forms).
1273// =============================================================================
1274
1275/// Student's t-distribution with a location and scale, `StudentT(ν, μ, σ)`.
1276///
1277/// Heavy-tailed generalization of the Normal; as `ν → ∞` it converges to
1278/// `Normal(μ, σ)`. Widely used as a robust likelihood/prior because its tails
1279/// tolerate outliers. `ν` need not be an integer.
1280///
1281/// Mathematical Properties:
1282/// - **Support**: (-∞, +∞)
1283/// - **PDF**: f(x) = Γ((ν+1)/2) / (Γ(ν/2)·√(νπ)·σ) · (1 + z²/ν)^(-(ν+1)/2),
1284///   where z = (x−μ)/σ
1285/// - **Mean**: μ for ν > 1 (undefined otherwise)
1286/// - **Variance**: σ²·ν/(ν−2) for ν > 2 (infinite for 1 < ν ≤ 2)
1287///
1288/// Example:
1289/// ```rust
1290/// # use fugue::*;
1291/// // Robust prior with 3 degrees of freedom.
1292/// let robust = sample(addr!("theta"), StudentT::new(3.0, 0.0, 1.0).unwrap());
1293/// // Robust likelihood tolerant of outliers.
1294/// let obs = observe(addr!("y"), StudentT::new(4.0, 1.0, 0.5).unwrap(), 2.0);
1295/// ```
1296#[derive(Clone, Copy, Debug)]
1297pub struct StudentT {
1298    /// Degrees of freedom ν (must be positive).
1299    df: f64,
1300    /// Location parameter μ.
1301    loc: f64,
1302    /// Scale parameter σ (must be positive).
1303    scale: f64,
1304}
1305impl StudentT {
1306    /// Create a new Student's t-distribution with validated parameters.
1307    pub fn new(df: f64, loc: f64, scale: f64) -> crate::error::FugueResult<Self> {
1308        if df <= 0.0 || !df.is_finite() {
1309            return Err(crate::error::FugueError::invalid_parameters(
1310                "StudentT",
1311                "Degrees of freedom must be positive and finite",
1312                crate::error::ErrorCode::InvalidShape,
1313            )
1314            .with_context("df", format!("{}", df))
1315            .with_context("expected", "> 0.0 and finite"));
1316        }
1317        if !loc.is_finite() {
1318            return Err(crate::error::FugueError::invalid_parameters(
1319                "StudentT",
1320                "Location (loc) must be finite",
1321                crate::error::ErrorCode::InvalidMean,
1322            )
1323            .with_context("loc", format!("{}", loc)));
1324        }
1325        if scale <= 0.0 || !scale.is_finite() {
1326            return Err(crate::error::FugueError::invalid_parameters(
1327                "StudentT",
1328                "Scale must be positive and finite",
1329                crate::error::ErrorCode::InvalidVariance,
1330            )
1331            .with_context("scale", format!("{}", scale))
1332            .with_context("expected", "> 0.0 and finite"));
1333        }
1334        Ok(StudentT { df, loc, scale })
1335    }
1336
1337    /// Get the degrees of freedom ν.
1338    pub fn df(&self) -> f64 {
1339        self.df
1340    }
1341
1342    /// Get the location parameter μ.
1343    pub fn loc(&self) -> f64 {
1344        self.loc
1345    }
1346
1347    /// Get the scale parameter σ.
1348    pub fn scale(&self) -> f64 {
1349        self.scale
1350    }
1351}
1352impl Distribution<f64> for StudentT {
1353    fn sample(&self, rng: &mut dyn RngCore) -> f64 {
1354        if self.df <= 0.0 || self.scale <= 0.0 {
1355            return f64::NAN;
1356        }
1357        // rand_distr's StudentT is standardized (location 0, scale 1); apply the
1358        // affine location-scale transform.
1359        let t = RDStudentT::new(self.df).unwrap().sample(rng);
1360        self.loc + self.scale * t
1361    }
1362    fn log_prob(&self, x: &f64) -> LogF64 {
1363        if self.df <= 0.0
1364            || self.scale <= 0.0
1365            || !self.df.is_finite()
1366            || !self.scale.is_finite()
1367            || !self.loc.is_finite()
1368            || !x.is_finite()
1369        {
1370            return f64::NEG_INFINITY;
1371        }
1372        const LN_PI: f64 = 1.144_729_885_849_400_2; // ln(π)
1373        let z = (x - self.loc) / self.scale;
1374        // log f = lnΓ((ν+1)/2) − lnΓ(ν/2) − 0.5·ln(νπ) − ln(σ)
1375        //         − ((ν+1)/2)·ln(1 + z²/ν)
1376        libm::lgamma((self.df + 1.0) / 2.0)
1377            - libm::lgamma(self.df / 2.0)
1378            - 0.5 * (self.df.ln() + LN_PI)
1379            - self.scale.ln()
1380            - 0.5 * (self.df + 1.0) * (z * z / self.df).ln_1p()
1381    }
1382    fn clone_box(&self) -> Box<dyn Distribution<f64>> {
1383        Box::new(*self)
1384    }
1385}
1386
1387/// The Cauchy (Lorentz) distribution `Cauchy(x₀, γ)`.
1388///
1389/// The heavy-tailed limit `StudentT(1, x₀, γ)`. It has **no** finite mean or
1390/// variance; `x₀` is the median/mode and `γ` the half-width at half-maximum.
1391///
1392/// Mathematical Properties:
1393/// - **Support**: (-∞, +∞)
1394/// - **PDF**: f(x) = 1 / (πγ·(1 + ((x−x₀)/γ)²))
1395/// - **Mean/Variance**: undefined (heavy tails)
1396/// - **Median/Mode**: x₀
1397///
1398/// Example:
1399/// ```rust
1400/// # use fugue::*;
1401/// // Weakly-informative heavy-tailed prior.
1402/// let prior = sample(addr!("beta"), Cauchy::new(0.0, 2.5).unwrap());
1403/// ```
1404#[derive(Clone, Copy, Debug)]
1405pub struct Cauchy {
1406    /// Location (median) parameter x₀.
1407    loc: f64,
1408    /// Scale parameter γ (must be positive).
1409    scale: f64,
1410}
1411impl Cauchy {
1412    /// Create a new Cauchy distribution with validated parameters.
1413    pub fn new(loc: f64, scale: f64) -> crate::error::FugueResult<Self> {
1414        if !loc.is_finite() {
1415            return Err(crate::error::FugueError::invalid_parameters(
1416                "Cauchy",
1417                "Location (loc) must be finite",
1418                crate::error::ErrorCode::InvalidMean,
1419            )
1420            .with_context("loc", format!("{}", loc)));
1421        }
1422        if scale <= 0.0 || !scale.is_finite() {
1423            return Err(crate::error::FugueError::invalid_parameters(
1424                "Cauchy",
1425                "Scale must be positive and finite",
1426                crate::error::ErrorCode::InvalidVariance,
1427            )
1428            .with_context("scale", format!("{}", scale))
1429            .with_context("expected", "> 0.0 and finite"));
1430        }
1431        Ok(Cauchy { loc, scale })
1432    }
1433
1434    /// Get the location (median) parameter x₀.
1435    pub fn loc(&self) -> f64 {
1436        self.loc
1437    }
1438
1439    /// Get the scale parameter γ.
1440    pub fn scale(&self) -> f64 {
1441        self.scale
1442    }
1443}
1444impl Distribution<f64> for Cauchy {
1445    fn sample(&self, rng: &mut dyn RngCore) -> f64 {
1446        if self.scale <= 0.0 {
1447            return f64::NAN;
1448        }
1449        RDCauchy::new(self.loc, self.scale).unwrap().sample(rng)
1450    }
1451    fn log_prob(&self, x: &f64) -> LogF64 {
1452        if self.scale <= 0.0 || !self.scale.is_finite() || !self.loc.is_finite() || !x.is_finite() {
1453            return f64::NEG_INFINITY;
1454        }
1455        const LN_PI: f64 = 1.144_729_885_849_400_2; // ln(π)
1456        let z = (x - self.loc) / self.scale;
1457        // log f = −ln(π) − ln(γ) − ln(1 + z²)
1458        -LN_PI - self.scale.ln() - (z * z).ln_1p()
1459    }
1460    fn clone_box(&self) -> Box<dyn Distribution<f64>> {
1461        Box::new(*self)
1462    }
1463}
1464
1465/// The Laplace (double-exponential) distribution `Laplace(μ, b)`.
1466///
1467/// A symmetric distribution with a sharp peak at `μ` and exponential tails;
1468/// its log-density is `−|x−μ|/b` up to a constant, which is why it underlies
1469/// L1/LASSO-style priors.
1470///
1471/// Mathematical Properties:
1472/// - **Support**: (-∞, +∞)
1473/// - **PDF**: f(x) = (1/(2b))·exp(−|x−μ|/b)
1474/// - **Mean**: μ
1475/// - **Variance**: 2b²
1476///
1477/// Example:
1478/// ```rust
1479/// # use fugue::*;
1480/// // Sparsity-inducing prior on a coefficient.
1481/// let coef = sample(addr!("w"), Laplace::new(0.0, 1.0).unwrap());
1482/// ```
1483#[derive(Clone, Copy, Debug)]
1484pub struct Laplace {
1485    /// Location (mean) parameter μ.
1486    loc: f64,
1487    /// Scale parameter b (must be positive).
1488    scale: f64,
1489}
1490impl Laplace {
1491    /// Create a new Laplace distribution with validated parameters.
1492    pub fn new(loc: f64, scale: f64) -> crate::error::FugueResult<Self> {
1493        if !loc.is_finite() {
1494            return Err(crate::error::FugueError::invalid_parameters(
1495                "Laplace",
1496                "Location (loc) must be finite",
1497                crate::error::ErrorCode::InvalidMean,
1498            )
1499            .with_context("loc", format!("{}", loc)));
1500        }
1501        if scale <= 0.0 || !scale.is_finite() {
1502            return Err(crate::error::FugueError::invalid_parameters(
1503                "Laplace",
1504                "Scale must be positive and finite",
1505                crate::error::ErrorCode::InvalidVariance,
1506            )
1507            .with_context("scale", format!("{}", scale))
1508            .with_context("expected", "> 0.0 and finite"));
1509        }
1510        Ok(Laplace { loc, scale })
1511    }
1512
1513    /// Get the location (mean) parameter μ.
1514    pub fn loc(&self) -> f64 {
1515        self.loc
1516    }
1517
1518    /// Get the scale parameter b.
1519    pub fn scale(&self) -> f64 {
1520        self.scale
1521    }
1522}
1523impl Distribution<f64> for Laplace {
1524    fn sample(&self, rng: &mut dyn RngCore) -> f64 {
1525        if self.scale <= 0.0 {
1526            return f64::NAN;
1527        }
1528        // Exact inverse-CDF sampling (rand_distr has no Laplace generator):
1529        // draw u ∈ (−½, ½) and map through the quantile function. The sign of u
1530        // picks the tail and −b·sign(u)·ln(1 − 2|u|) is the corresponding
1531        // exponential deviate.
1532        let u: f64 = rng.gen::<f64>() - 0.5;
1533        self.loc - self.scale * u.signum() * (1.0 - 2.0 * u.abs()).ln()
1534    }
1535    fn log_prob(&self, x: &f64) -> LogF64 {
1536        if self.scale <= 0.0 || !self.scale.is_finite() || !self.loc.is_finite() || !x.is_finite() {
1537            return f64::NEG_INFINITY;
1538        }
1539        // log f = −ln(2b) − |x−μ|/b
1540        -(2.0 * self.scale).ln() - (x - self.loc).abs() / self.scale
1541    }
1542    fn clone_box(&self) -> Box<dyn Distribution<f64>> {
1543        Box::new(*self)
1544    }
1545}
1546
1547/// The Weibull distribution `Weibull(k, λ)` with shape `k` and scale `λ`.
1548///
1549/// A flexible positive distribution used for reliability/survival modeling;
1550/// `k < 1` is a decreasing hazard, `k = 1` is the Exponential, and `k > 1` is
1551/// an increasing hazard.
1552///
1553/// Mathematical Properties:
1554/// - **Support**: [0, +∞)
1555/// - **PDF**: f(x) = (k/λ)·(x/λ)^(k−1)·exp(−(x/λ)^k) for x ≥ 0
1556/// - **Mean**: λ·Γ(1 + 1/k)
1557/// - **Variance**: λ²·[Γ(1 + 2/k) − Γ(1 + 1/k)²]
1558///
1559/// Boundary semantics (matching `scipy.stats.weibull_min.logpdf`): at `x = 0`,
1560/// `log_prob` is `−∞` when `k > 1`, `−ln(λ)` when `k == 1`, and `+∞` when
1561/// `k < 1`.
1562///
1563/// Example:
1564/// ```rust
1565/// # use fugue::*;
1566/// // Time-to-failure prior with increasing hazard.
1567/// let ttf = sample(addr!("t"), Weibull::new(1.5, 2.0).unwrap());
1568/// ```
1569#[derive(Clone, Copy, Debug)]
1570pub struct Weibull {
1571    /// Shape parameter k (must be positive).
1572    shape: f64,
1573    /// Scale parameter λ (must be positive).
1574    scale: f64,
1575}
1576impl Weibull {
1577    /// Create a new Weibull distribution with validated parameters.
1578    pub fn new(shape: f64, scale: f64) -> crate::error::FugueResult<Self> {
1579        if shape <= 0.0 || !shape.is_finite() {
1580            return Err(crate::error::FugueError::invalid_parameters(
1581                "Weibull",
1582                "Shape parameter must be positive and finite",
1583                crate::error::ErrorCode::InvalidShape,
1584            )
1585            .with_context("shape", format!("{}", shape))
1586            .with_context("expected", "> 0.0 and finite"));
1587        }
1588        if scale <= 0.0 || !scale.is_finite() {
1589            return Err(crate::error::FugueError::invalid_parameters(
1590                "Weibull",
1591                "Scale parameter must be positive and finite",
1592                crate::error::ErrorCode::InvalidVariance,
1593            )
1594            .with_context("scale", format!("{}", scale))
1595            .with_context("expected", "> 0.0 and finite"));
1596        }
1597        Ok(Weibull { shape, scale })
1598    }
1599
1600    /// Get the shape parameter k.
1601    pub fn shape(&self) -> f64 {
1602        self.shape
1603    }
1604
1605    /// Get the scale parameter λ.
1606    pub fn scale(&self) -> f64 {
1607        self.scale
1608    }
1609}
1610impl Distribution<f64> for Weibull {
1611    fn sample(&self, rng: &mut dyn RngCore) -> f64 {
1612        if self.shape <= 0.0 || self.scale <= 0.0 {
1613            return f64::NAN;
1614        }
1615        // rand_distr::Weibull::new takes (scale, shape) in that order.
1616        RDWeibull::new(self.scale, self.shape).unwrap().sample(rng)
1617    }
1618    fn log_prob(&self, x: &f64) -> LogF64 {
1619        if self.shape <= 0.0
1620            || self.scale <= 0.0
1621            || !self.shape.is_finite()
1622            || !self.scale.is_finite()
1623            || !x.is_finite()
1624        {
1625            return f64::NEG_INFINITY;
1626        }
1627        let x = *x;
1628        if x < 0.0 {
1629            return f64::NEG_INFINITY;
1630        }
1631        if x == 0.0 {
1632            // Endpoint limit of (x/λ)^(k−1): k>1 ⇒ 0, k==1 ⇒ 1/λ, k<1 ⇒ ∞.
1633            return if self.shape > 1.0 {
1634                f64::NEG_INFINITY
1635            } else if self.shape < 1.0 {
1636                f64::INFINITY
1637            } else {
1638                -self.scale.ln()
1639            };
1640        }
1641        // log f = ln(k) − k·ln(λ) + (k−1)·ln(x) − (x/λ)^k
1642        self.shape.ln() - self.shape * self.scale.ln() + (self.shape - 1.0) * x.ln()
1643            - (x / self.scale).powf(self.shape)
1644    }
1645    fn clone_box(&self) -> Box<dyn Distribution<f64>> {
1646        Box::new(*self)
1647    }
1648}
1649
1650/// The chi-squared distribution `ChiSquared(k)` with `k` degrees of freedom.
1651///
1652/// The distribution of a sum of `k` squared standard normals; the special case
1653/// `Gamma(k/2, 1/2)`. `k` need not be an integer.
1654///
1655/// Mathematical Properties:
1656/// - **Support**: (0, +∞)
1657/// - **PDF**: f(x) = 1/(2^(k/2)·Γ(k/2))·x^(k/2−1)·exp(−x/2)
1658/// - **Mean**: k
1659/// - **Variance**: 2k
1660///
1661/// Example:
1662/// ```rust
1663/// # use fugue::*;
1664/// // Sampling distribution of a scaled variance statistic.
1665/// let s = sample(addr!("s"), ChiSquared::new(4.0).unwrap());
1666/// ```
1667#[derive(Clone, Copy, Debug)]
1668pub struct ChiSquared {
1669    /// Degrees of freedom k (must be positive).
1670    k: f64,
1671}
1672impl ChiSquared {
1673    /// Create a new chi-squared distribution with validated parameters.
1674    pub fn new(k: f64) -> crate::error::FugueResult<Self> {
1675        if k <= 0.0 || !k.is_finite() {
1676            return Err(crate::error::FugueError::invalid_parameters(
1677                "ChiSquared",
1678                "Degrees of freedom must be positive and finite",
1679                crate::error::ErrorCode::InvalidShape,
1680            )
1681            .with_context("k", format!("{}", k))
1682            .with_context("expected", "> 0.0 and finite"));
1683        }
1684        Ok(ChiSquared { k })
1685    }
1686
1687    /// Get the degrees of freedom k.
1688    pub fn k(&self) -> f64 {
1689        self.k
1690    }
1691}
1692impl Distribution<f64> for ChiSquared {
1693    fn sample(&self, rng: &mut dyn RngCore) -> f64 {
1694        if self.k <= 0.0 {
1695            return f64::NAN;
1696        }
1697        RDChiSquared::new(self.k).unwrap().sample(rng)
1698    }
1699    fn log_prob(&self, x: &f64) -> LogF64 {
1700        if self.k <= 0.0 || !self.k.is_finite() || !x.is_finite() {
1701            return f64::NEG_INFINITY;
1702        }
1703        if *x <= 0.0 {
1704            return f64::NEG_INFINITY;
1705        }
1706        // log f = −(k/2)·ln(2) − lnΓ(k/2) + (k/2 − 1)·ln(x) − x/2
1707        let half_k = self.k / 2.0;
1708        -half_k * std::f64::consts::LN_2 - libm::lgamma(half_k) + (half_k - 1.0) * x.ln() - x / 2.0
1709    }
1710    fn clone_box(&self) -> Box<dyn Distribution<f64>> {
1711        Box::new(*self)
1712    }
1713}
1714
1715/// The inverse-gamma distribution `InverseGamma(α, β)` with shape `α` and rate
1716/// `β`.
1717///
1718/// If `X ~ InverseGamma(α, β)` then `1/X ~ Gamma(α, rate = β)` — hence the
1719/// second parameter is named `rate` to parallel [`Gamma`]. It is the standard
1720/// conjugate prior for the variance of a Normal.
1721///
1722/// Mathematical Properties:
1723/// - **Support**: (0, +∞)
1724/// - **PDF**: f(x) = β^α/Γ(α)·x^(−α−1)·exp(−β/x)
1725/// - **Mean**: β/(α−1) for α > 1
1726/// - **Variance**: β²/((α−1)²(α−2)) for α > 2
1727///
1728/// This matches `scipy.stats.invgamma.logpdf(x, a = α, scale = β)`.
1729///
1730/// Example:
1731/// ```rust
1732/// # use fugue::*;
1733/// // Conjugate prior for an unknown variance.
1734/// let var = sample(addr!("sigma2"), InverseGamma::new(3.0, 2.0).unwrap());
1735/// ```
1736#[derive(Clone, Copy, Debug)]
1737pub struct InverseGamma {
1738    /// Shape parameter α (must be positive).
1739    shape: f64,
1740    /// Rate parameter β (must be positive).
1741    rate: f64,
1742}
1743impl InverseGamma {
1744    /// Create a new inverse-gamma distribution with validated parameters.
1745    pub fn new(shape: f64, rate: f64) -> crate::error::FugueResult<Self> {
1746        if shape <= 0.0 || !shape.is_finite() {
1747            return Err(crate::error::FugueError::invalid_parameters(
1748                "InverseGamma",
1749                "Shape parameter must be positive and finite",
1750                crate::error::ErrorCode::InvalidShape,
1751            )
1752            .with_context("shape", format!("{}", shape))
1753            .with_context("expected", "> 0.0 and finite"));
1754        }
1755        if rate <= 0.0 || !rate.is_finite() {
1756            return Err(crate::error::FugueError::invalid_parameters(
1757                "InverseGamma",
1758                "Rate parameter must be positive and finite",
1759                crate::error::ErrorCode::InvalidRate,
1760            )
1761            .with_context("rate", format!("{}", rate))
1762            .with_context("expected", "> 0.0 and finite"));
1763        }
1764        Ok(InverseGamma { shape, rate })
1765    }
1766
1767    /// Get the shape parameter α.
1768    pub fn shape(&self) -> f64 {
1769        self.shape
1770    }
1771
1772    /// Get the rate parameter β.
1773    pub fn rate(&self) -> f64 {
1774        self.rate
1775    }
1776}
1777impl Distribution<f64> for InverseGamma {
1778    fn sample(&self, rng: &mut dyn RngCore) -> f64 {
1779        if self.shape <= 0.0 || self.rate <= 0.0 {
1780            return f64::NAN;
1781        }
1782        // X = 1/Y with Y ~ Gamma(shape = α, rate = β). rand_distr::Gamma takes a
1783        // scale, so pass scale = 1/β.
1784        let y = RDGamma::new(self.shape, 1.0 / self.rate)
1785            .unwrap()
1786            .sample(rng);
1787        1.0 / y
1788    }
1789    fn log_prob(&self, x: &f64) -> LogF64 {
1790        if self.shape <= 0.0
1791            || self.rate <= 0.0
1792            || !self.shape.is_finite()
1793            || !self.rate.is_finite()
1794            || !x.is_finite()
1795        {
1796            return f64::NEG_INFINITY;
1797        }
1798        if *x <= 0.0 {
1799            return f64::NEG_INFINITY;
1800        }
1801        // log f = α·ln(β) − lnΓ(α) − (α+1)·ln(x) − β/x
1802        self.shape * self.rate.ln()
1803            - libm::lgamma(self.shape)
1804            - (self.shape + 1.0) * x.ln()
1805            - self.rate / x
1806    }
1807    fn clone_box(&self) -> Box<dyn Distribution<f64>> {
1808        Box::new(*self)
1809    }
1810}
1811
1812/// A discrete distribution assigning equal probability to every integer in an
1813/// inclusive range `[low, high]`, returning `i64`.
1814///
1815/// This is the first-class consumer of the `i64` sample path (`ChoiceValue::I64`
1816/// end-to-end through sample/observe/replay/score).
1817///
1818/// Mathematical Properties:
1819/// - **Support**: {low, low+1, ..., high}
1820/// - **PMF**: P(X = k) = 1/(high − low + 1) for low ≤ k ≤ high, 0 otherwise
1821/// - **Mean**: (low + high) / 2
1822/// - **Variance**: ((high − low + 1)² − 1) / 12
1823///
1824/// Example:
1825/// ```rust
1826/// # use fugue::*;
1827/// // A fair six-sided die labelled 1..=6.
1828/// let die = sample(addr!("die"), DiscreteUniform::new(1, 6).unwrap());
1829/// // Condition on an observed roll.
1830/// let obs = observe(addr!("roll"), DiscreteUniform::new(1, 6).unwrap(), 4i64);
1831/// ```
1832#[derive(Clone, Copy, Debug)]
1833pub struct DiscreteUniform {
1834    /// Inclusive lower bound.
1835    low: i64,
1836    /// Inclusive upper bound (must satisfy `high >= low`).
1837    high: i64,
1838}
1839impl DiscreteUniform {
1840    /// Create a new discrete-uniform distribution over the inclusive range
1841    /// `[low, high]`.
1842    pub fn new(low: i64, high: i64) -> crate::error::FugueResult<Self> {
1843        if high < low {
1844            return Err(crate::error::FugueError::invalid_parameters(
1845                "DiscreteUniform",
1846                "Upper bound must be >= lower bound",
1847                crate::error::ErrorCode::InvalidRange,
1848            )
1849            .with_context("low", format!("{}", low))
1850            .with_context("high", format!("{}", high)));
1851        }
1852        Ok(DiscreteUniform { low, high })
1853    }
1854
1855    /// Get the inclusive lower bound.
1856    pub fn low(&self) -> i64 {
1857        self.low
1858    }
1859
1860    /// Get the inclusive upper bound.
1861    pub fn high(&self) -> i64 {
1862        self.high
1863    }
1864
1865    /// Number of points in the support (`high − low + 1`).
1866    ///
1867    /// Exact for every range except the full `i64` domain, whose support has
1868    /// `2^64` points — one more than fits in `u64` — so `len()` **saturates to
1869    /// `u64::MAX`** for `DiscreteUniform::new(i64::MIN, i64::MAX)`. Sampling and
1870    /// scoring never round-trip through `len()`; they use the exact `u128`
1871    /// [`Self::count`], so the full-range case is handled correctly regardless.
1872    pub fn len(&self) -> u64 {
1873        u64::try_from(self.count()).unwrap_or(u64::MAX)
1874    }
1875
1876    /// Exact number of support points as a `u128`.
1877    ///
1878    /// `high >= low` is a constructor invariant, so `high − low` ranges over
1879    /// `[0, 2^64 − 1]` and `+ 1` over `[1, 2^64]` — always representable in
1880    /// `u128`. Only the full `i64` domain reaches `2^64`.
1881    fn count(&self) -> u128 {
1882        (self.high as i128 - self.low as i128 + 1) as u128
1883    }
1884
1885    /// Whether `[low, high]` spans the entire `i64` domain. This is the one range
1886    /// whose `2^64`-point support overflows a `u64` offset, so `sample`/`log_prob`
1887    /// special-case it.
1888    fn is_full_i64_range(&self) -> bool {
1889        self.low == i64::MIN && self.high == i64::MAX
1890    }
1891
1892    /// Whether the support is empty. Always `false` for a validly-constructed
1893    /// distribution (kept for clippy's `len`/`is_empty` pairing).
1894    pub fn is_empty(&self) -> bool {
1895        false
1896    }
1897}
1898impl Distribution<i64> for DiscreteUniform {
1899    fn sample(&self, rng: &mut dyn RngCore) -> i64 {
1900        if self.high < self.low {
1901            return self.low;
1902        }
1903        if self.is_full_i64_range() {
1904            // The support IS the whole i64 domain, so a raw uniform i64 draw is
1905            // already a uniform sample over [low, high]. The offset arithmetic
1906            // below is unusable here: the count is 2^64, which does not fit in the
1907            // u64 that `gen_range` needs.
1908            return Rng::gen::<i64>(rng);
1909        }
1910        // The range is not full, so the count fits in u64. Draw an offset in
1911        // [0, n) and shift; the shift is done in i128 to avoid any overflow at the
1912        // extremes of the i64 range.
1913        let n = self.count() as u64;
1914        let offset = Rng::gen_range(rng, 0..n) as i128;
1915        (self.low as i128 + offset) as i64
1916    }
1917    fn log_prob(&self, x: &i64) -> LogF64 {
1918        if self.high < self.low {
1919            return f64::NEG_INFINITY;
1920        }
1921        if *x < self.low || *x > self.high {
1922            return f64::NEG_INFINITY;
1923        }
1924        // log P = −ln(n). For the full i64 domain n = 2^64, whose logarithm is
1925        // exactly 64·ln 2; computing it directly is both exact and avoids the
1926        // `2^64 as f64` round-trip.
1927        if self.is_full_i64_range() {
1928            -(64.0 * std::f64::consts::LN_2)
1929        } else {
1930            -(self.count() as f64).ln()
1931        }
1932    }
1933    fn clone_box(&self) -> Box<dyn Distribution<i64>> {
1934        Box::new(*self)
1935    }
1936}
1937
1938#[cfg(test)]
1939mod tests {
1940    use super::*;
1941    use rand::rngs::StdRng;
1942    use rand::SeedableRng;
1943
1944    #[test]
1945    fn normal_constructor_and_log_prob() {
1946        assert!(Normal::new(0.0, 1.0).is_ok());
1947        assert!(Normal::new(f64::NAN, 1.0).is_err());
1948        assert!(Normal::new(0.0, 0.0).is_err());
1949
1950        let n = Normal::new(0.0, 1.0).unwrap();
1951        assert!(n.log_prob(&0.0).is_finite());
1952        assert_eq!(n.log_prob(&f64::INFINITY), f64::NEG_INFINITY);
1953    }
1954
1955    #[test]
1956    fn uniform_support_and_log_prob() {
1957        assert!(Uniform::new(0.0, 1.0).is_ok());
1958        assert!(Uniform::new(1.0, 0.0).is_err());
1959        let u = Uniform::new(-2.0, 2.0).unwrap();
1960        // Inside support
1961        let lp0 = u.log_prob(&0.0);
1962        assert!(lp0.is_finite());
1963        // Outside support
1964        assert_eq!(u.log_prob(&2.0), f64::NEG_INFINITY);
1965        assert_eq!(u.log_prob(&-2.1), f64::NEG_INFINITY);
1966    }
1967
1968    #[test]
1969    fn lognormal_validation() {
1970        assert!(LogNormal::new(0.0, 1.0).is_ok());
1971        assert!(LogNormal::new(0.0, 0.0).is_err());
1972        let ln = LogNormal::new(0.0, 1.0).unwrap();
1973        assert_eq!(ln.log_prob(&0.0), f64::NEG_INFINITY);
1974        assert!(ln.log_prob(&1.0).is_finite());
1975    }
1976
1977    #[test]
1978    fn exponential_validation() {
1979        assert!(Exponential::new(1.0).is_ok());
1980        assert!(Exponential::new(0.0).is_err());
1981        let e = Exponential::new(2.0).unwrap();
1982        assert_eq!(e.log_prob(&-1.0), f64::NEG_INFINITY);
1983        assert!((e.log_prob(&0.0) - (2.0f64).ln()).abs() < 1e-12);
1984    }
1985
1986    #[test]
1987    fn bernoulli_validation() {
1988        assert!(Bernoulli::new(0.5).is_ok());
1989        assert!(Bernoulli::new(-0.1).is_err());
1990        let b = Bernoulli::new(0.25).unwrap();
1991        assert!((b.log_prob(&true) - (0.25f64).ln()).abs() < 1e-12);
1992        assert!((b.log_prob(&false) - (0.75f64).ln()).abs() < 1e-12);
1993    }
1994
1995    #[test]
1996    fn categorical_validation_and_log_prob() {
1997        assert!(Categorical::new(vec![0.5, 0.5]).is_ok());
1998        assert!(Categorical::new(vec![]).is_err());
1999        assert!(Categorical::new(vec![0.6, 0.5]).is_err());
2000
2001        let c = Categorical::new(vec![0.2, 0.8]).unwrap();
2002        assert!((c.log_prob(&1) - (0.8f64).ln()).abs() < 1e-12);
2003        assert_eq!(c.log_prob(&2), f64::NEG_INFINITY);
2004    }
2005
2006    #[test]
2007    fn beta_validation_and_support() {
2008        assert!(Beta::new(2.0, 3.0).is_ok());
2009        assert!(Beta::new(0.0, 1.0).is_err());
2010        let b = Beta::new(2.0, 5.0).unwrap();
2011        assert_eq!(b.log_prob(&0.0), f64::NEG_INFINITY);
2012        assert_eq!(b.log_prob(&1.0), f64::NEG_INFINITY);
2013        assert!(b.log_prob(&0.5).is_finite());
2014    }
2015
2016    #[test]
2017    fn gamma_validation_and_support() {
2018        assert!(Gamma::new(1.5, 2.0).is_ok());
2019        assert!(Gamma::new(0.0, 2.0).is_err());
2020        assert!(Gamma::new(1.0, 0.0).is_err());
2021        let g = Gamma::new(2.0, 1.0).unwrap();
2022        assert_eq!(g.log_prob(&-1.0), f64::NEG_INFINITY);
2023        assert!(g.log_prob(&1.0).is_finite());
2024    }
2025
2026    #[test]
2027    fn binomial_validation_and_log_prob() {
2028        assert!(Binomial::new(10, 0.5).is_ok());
2029        assert!(Binomial::new(10, 1.5).is_err());
2030        let bi = Binomial::new(5, 0.3).unwrap();
2031        assert_eq!(bi.log_prob(&6), f64::NEG_INFINITY); // k > n
2032        assert!(bi.log_prob(&3).is_finite());
2033    }
2034
2035    #[test]
2036    fn poisson_validation_and_log_prob() {
2037        assert!(Poisson::new(1.0).is_ok());
2038        assert!(Poisson::new(0.0).is_err());
2039        let p = Poisson::new(3.0).unwrap();
2040        assert!(p.log_prob(&0).is_finite());
2041        assert!(p.log_prob(&5).is_finite());
2042    }
2043
2044    #[test]
2045    fn sampling_basic_sanity() {
2046        let mut rng = StdRng::seed_from_u64(42);
2047        let n = Normal::new(0.0, 1.0).unwrap();
2048        let x = n.sample(&mut rng);
2049        assert!(x.is_finite());
2050
2051        let u = Uniform::new(-1.0, 2.0).unwrap();
2052        let y = u.sample(&mut rng);
2053        assert!((-1.0..2.0).contains(&y));
2054
2055        let b = Bernoulli::new(0.7).unwrap();
2056        let _z = b.sample(&mut rng);
2057    }
2058
2059    #[test]
2060    fn categorical_uniform_constructor() {
2061        let cu = Categorical::uniform(4).unwrap();
2062        assert_eq!(cu.len(), 4);
2063        for &p in cu.probs() {
2064            assert!((p - 0.25).abs() < 1e-12);
2065        }
2066    }
2067
2068    // Helper: assert closeness with 1e-9 tolerance.
2069    fn close(a: f64, b: f64) {
2070        assert!((a - b).abs() < 1e-9, "expected {b}, got {a}");
2071    }
2072
2073    #[test]
2074    fn fg06_interior_point_known_answers() {
2075        // Interior-point closed-form checks (scipy-equivalent constants).
2076        close(
2077            Normal::new(0.0, 1.0).unwrap().log_prob(&0.0),
2078            -0.9189385332046727,
2079        );
2080        close(
2081            Normal::new(1.0, 2.0).unwrap().log_prob(&2.5),
2082            -1.893335713764618,
2083        );
2084        close(
2085            Uniform::new(-2.0, 2.0).unwrap().log_prob(&1.5),
2086            -1.3862943611198906,
2087        );
2088        close(
2089            LogNormal::new(0.0, 1.0).unwrap().log_prob(&2.0),
2090            -1.8523122207237186,
2091        );
2092        close(
2093            Exponential::new(2.0).unwrap().log_prob(&1.0),
2094            -1.3068528194400546,
2095        );
2096        close(
2097            Beta::new(2.0, 3.0).unwrap().log_prob(&0.5),
2098            0.4054651081081637,
2099        );
2100        close(
2101            Gamma::new(3.0, 2.0).unwrap().log_prob(&1.5),
2102            -0.8027754226637804,
2103        );
2104        close(
2105            Binomial::new(20, 0.3).unwrap().log_prob(&7),
2106            -1.8062926549204255,
2107        );
2108        close(Poisson::new(3.0).unwrap().log_prob(&2), -1.4959226032237254);
2109        close(
2110            Categorical::new(vec![0.2, 0.3, 0.5]).unwrap().log_prob(&2),
2111            -std::f64::consts::LN_2, // ln(0.5) = -ln(2)
2112        );
2113    }
2114
2115    #[test]
2116    fn fg07_fg08_fg30_removed_overflow_guards_return_finite() {
2117        // Each point was previously forced to -inf by a bogus overflow guard.
2118        close(
2119            Gamma::new(2.0, 1.0).unwrap().log_prob(&800.0),
2120            -793.315388272332,
2121        ); // FG-07
2122        close(
2123            Normal::new(0.0, 0.001).unwrap().log_prob(&0.05),
2124            -1244.0111832542225,
2125        ); // FG-08
2126        close(
2127            LogNormal::new(0.0, 0.001).unwrap().log_prob(&1.05),
2128            -1184.3000332584572,
2129        ); // FG-08
2130        close(
2131            Exponential::new(2.0).unwrap().log_prob(&400.0),
2132            -799.3068528194401,
2133        ); // FG-30
2134    }
2135
2136    #[test]
2137    fn fg27_beta_boundaries() {
2138        // Subnormal interior no longer clipped to -inf.
2139        close(
2140            Beta::new(0.5, 0.5).unwrap().log_prob(&1e-100),
2141            113.98452476385289,
2142        );
2143        // Endpoint limits.
2144        close(
2145            Beta::new(1.0, 5.0).unwrap().log_prob(&0.0),
2146            1.6094379124341003,
2147        ); // ln(5)
2148        close(
2149            Beta::new(3.0, 1.0).unwrap().log_prob(&1.0),
2150            1.0986122886681098,
2151        ); // ln(3)
2152        assert_eq!(
2153            Beta::new(2.0, 5.0).unwrap().log_prob(&0.0),
2154            f64::NEG_INFINITY
2155        );
2156        assert_eq!(Beta::new(0.5, 3.0).unwrap().log_prob(&0.0), f64::INFINITY);
2157    }
2158
2159    #[test]
2160    fn fg28_binomial_degenerate_p_not_nan() {
2161        let b0 = Binomial::new(5, 0.0).unwrap();
2162        assert!(!b0.log_prob(&0).is_nan());
2163        close(b0.log_prob(&0), 0.0);
2164        assert_eq!(b0.log_prob(&1), f64::NEG_INFINITY);
2165        let b1 = Binomial::new(5, 1.0).unwrap();
2166        assert!(!b1.log_prob(&5).is_nan());
2167        close(b1.log_prob(&5), 0.0);
2168        assert_eq!(b1.log_prob(&3), f64::NEG_INFINITY);
2169    }
2170
2171    #[test]
2172    fn fg29_infallible_constructors() {
2173        assert_eq!(
2174            (Normal::standard().mu(), Normal::standard().sigma()),
2175            (0.0, 1.0)
2176        );
2177        assert_eq!((Uniform::unit().low(), Uniform::unit().high()), (0.0, 1.0));
2178        assert_eq!(
2179            (Beta::uniform_prior().alpha(), Beta::uniform_prior().beta()),
2180            (1.0, 1.0)
2181        );
2182        assert_eq!(Bernoulli::fair().p(), 0.5);
2183    }
2184
2185    #[test]
2186    fn fg53_categorical_cached_cdf_and_revalidate() {
2187        let c = Categorical::new(vec![0.1, 0.2, 0.3, 0.4]).unwrap();
2188        close(c.log_prob(&3), (0.4f64).ln());
2189        assert_eq!(c.log_prob(&4), f64::NEG_INFINITY);
2190        assert!(c.revalidate().is_ok());
2191
2192        // Seeded binary-search sampling stays in-range and roughly matches probs.
2193        let mut rng = StdRng::seed_from_u64(7);
2194        let mut counts = [0usize; 4];
2195        let n = 40_000usize;
2196        for _ in 0..n {
2197            counts[c.sample(&mut rng)] += 1;
2198        }
2199        for (k, &p) in [0.1, 0.2, 0.3, 0.4].iter().enumerate() {
2200            // ~1.1e-2 is > 7 std for the tightest bin at N = 40_000.
2201            assert!((counts[k] as f64 / n as f64 - p).abs() < 1.1e-2);
2202        }
2203    }
2204
2205    // -------------------------------------------------------------------------
2206    // FG-31: the seven new distributions.
2207    // -------------------------------------------------------------------------
2208
2209    // FG-31: interior-point log_prob against the scipy-equivalent closed forms.
2210    // Constants were derived with the standard log-pdf expressions in python3
2211    // (math.lgamma), identical to `scipy.stats.<dist>.logpdf`.
2212    #[test]
2213    fn fg31_new_distributions_interior_point_log_prob() {
2214        // scipy: stats.t.logpdf(2.5, 3, 1, 2)
2215        close(
2216            StudentT::new(3.0, 1.0, 2.0).unwrap().log_prob(&2.5),
2217            -2.0377365440367736,
2218        );
2219        // scipy: stats.t.logpdf(0.0, 5, 0, 1)
2220        close(
2221            StudentT::new(5.0, 0.0, 1.0).unwrap().log_prob(&0.0),
2222            -0.9686195890547249,
2223        );
2224        // scipy: stats.t.logpdf(1.0, 10, 2, 0.5)
2225        close(
2226            StudentT::new(10.0, 2.0, 0.5).unwrap().log_prob(&1.0),
2227            -2.1013474730076767,
2228        );
2229        // scipy: stats.cauchy.logpdf(1.5, 0, 1)
2230        close(
2231            Cauchy::new(0.0, 1.0).unwrap().log_prob(&1.5),
2232            -2.3233848821910463,
2233        );
2234        // scipy: stats.cauchy.logpdf(5.0, 2, 3)
2235        close(
2236            Cauchy::new(2.0, 3.0).unwrap().log_prob(&5.0),
2237            -2.9364893550774553,
2238        );
2239        // scipy: stats.laplace.logpdf(1.5, 0, 1)
2240        close(
2241            Laplace::new(0.0, 1.0).unwrap().log_prob(&1.5),
2242            -2.1931471805599454,
2243        );
2244        // scipy: stats.laplace.logpdf(-0.5, 1, 2)
2245        close(
2246            Laplace::new(1.0, 2.0).unwrap().log_prob(&-0.5),
2247            -2.136294361119891,
2248        );
2249        // scipy: stats.weibull_min.logpdf(1.0, 1.5, scale=2)
2250        close(
2251            Weibull::new(1.5, 2.0).unwrap().log_prob(&1.0),
2252            -0.9878090533250272,
2253        );
2254        // scipy: stats.weibull_min.logpdf(2.0, 2.0, scale=1.5)
2255        close(
2256            Weibull::new(2.0, 1.5).unwrap().log_prob(&2.0),
2257            -1.2024136328742159,
2258        );
2259        // scipy: stats.chi2.logpdf(3.0, 4)
2260        close(
2261            ChiSquared::new(4.0).unwrap().log_prob(&3.0),
2262            -1.7876820724517808,
2263        );
2264        // scipy: stats.chi2.logpdf(0.5, 1)
2265        close(
2266            ChiSquared::new(1.0).unwrap().log_prob(&0.5),
2267            -0.8223649429247004,
2268        );
2269        // scipy: stats.chi2.logpdf(2.0, 2.5)
2270        close(
2271            ChiSquared::new(2.5).unwrap().log_prob(&2.0),
2272            -1.5948753441381327,
2273        );
2274        // scipy: stats.invgamma.logpdf(1.5, 3, scale=2)
2275        close(
2276            InverseGamma::new(3.0, 2.0).unwrap().log_prob(&1.5),
2277            -1.5688994046461,
2278        );
2279        // scipy: stats.invgamma.logpdf(0.5, 2, scale=1)
2280        close(
2281            InverseGamma::new(2.0, 1.0).unwrap().log_prob(&0.5),
2282            0.07944154167983575,
2283        );
2284        // DiscreteUniform over {-2,...,5}: 8 points, log P = -ln(8).
2285        close(
2286            DiscreteUniform::new(-2, 5).unwrap().log_prob(&0),
2287            -2.0794415416798357,
2288        );
2289    }
2290
2291    // FG-31: constructor validation and support/boundary behavior.
2292    #[test]
2293    fn fg31_new_distributions_validation_and_support() {
2294        // Constructor validation.
2295        assert!(StudentT::new(0.0, 0.0, 1.0).is_err()); // df must be > 0
2296        assert!(StudentT::new(3.0, f64::NAN, 1.0).is_err());
2297        assert!(StudentT::new(3.0, 0.0, 0.0).is_err()); // scale must be > 0
2298        assert!(Cauchy::new(0.0, -1.0).is_err());
2299        assert!(Cauchy::new(f64::INFINITY, 1.0).is_err());
2300        assert!(Laplace::new(0.0, 0.0).is_err());
2301        assert!(Weibull::new(0.0, 1.0).is_err());
2302        assert!(Weibull::new(1.0, 0.0).is_err());
2303        assert!(ChiSquared::new(0.0).is_err());
2304        assert!(ChiSquared::new(-1.0).is_err());
2305        assert!(InverseGamma::new(0.0, 1.0).is_err());
2306        assert!(InverseGamma::new(1.0, 0.0).is_err());
2307        assert!(DiscreteUniform::new(5, 4).is_err()); // high < low
2308
2309        // Support boundaries.
2310        assert_eq!(
2311            Weibull::new(2.0, 1.0).unwrap().log_prob(&-0.5),
2312            f64::NEG_INFINITY
2313        );
2314        // Weibull endpoint limits at x = 0.
2315        assert_eq!(
2316            Weibull::new(2.0, 1.0).unwrap().log_prob(&0.0),
2317            f64::NEG_INFINITY
2318        ); // k > 1
2319        close(
2320            Weibull::new(1.0, 2.0).unwrap().log_prob(&0.0),
2321            -(2.0f64).ln(),
2322        ); // k == 1
2323        assert_eq!(
2324            Weibull::new(0.5, 1.0).unwrap().log_prob(&0.0),
2325            f64::INFINITY
2326        ); // k < 1
2327        assert_eq!(
2328            ChiSquared::new(3.0).unwrap().log_prob(&0.0),
2329            f64::NEG_INFINITY
2330        );
2331        assert_eq!(
2332            ChiSquared::new(3.0).unwrap().log_prob(&-1.0),
2333            f64::NEG_INFINITY
2334        );
2335        assert_eq!(
2336            InverseGamma::new(2.0, 1.0).unwrap().log_prob(&0.0),
2337            f64::NEG_INFINITY
2338        );
2339        // StudentT/Cauchy/Laplace are full-support: finite everywhere finite.
2340        assert!(StudentT::new(2.0, 0.0, 1.0)
2341            .unwrap()
2342            .log_prob(&-100.0)
2343            .is_finite());
2344        assert!(Cauchy::new(0.0, 1.0).unwrap().log_prob(&1e6).is_finite());
2345        assert!(Laplace::new(0.0, 1.0).unwrap().log_prob(&-42.0).is_finite());
2346        // DiscreteUniform: outside the inclusive range -> -inf.
2347        let du = DiscreteUniform::new(1, 6).unwrap();
2348        assert_eq!(du.log_prob(&0), f64::NEG_INFINITY);
2349        assert_eq!(du.log_prob(&7), f64::NEG_INFINITY);
2350        assert!(du.log_prob(&1).is_finite());
2351        assert!(du.log_prob(&6).is_finite());
2352        assert_eq!(du.len(), 6);
2353    }
2354
2355    // FG-31: seeded moment sanity — sample means/variances match analytic
2356    // values within Monte-Carlo tolerance. Tolerances are set well above the
2357    // standard error at N = 60_000 so the seeded assertions are stable.
2358    #[test]
2359    fn fg31_new_distributions_moment_sanity() {
2360        let mut rng = StdRng::seed_from_u64(31);
2361        let n = 60_000usize;
2362
2363        // Helper: sample mean of a distribution.
2364        fn mean_of(d: &impl Distribution<f64>, rng: &mut StdRng, n: usize) -> f64 {
2365            (0..n).map(|_| d.sample(rng)).sum::<f64>() / n as f64
2366        }
2367
2368        // StudentT(df=6, loc=1, scale=2): mean = loc = 1 (df > 1).
2369        let t = StudentT::new(6.0, 1.0, 2.0).unwrap();
2370        assert!((mean_of(&t, &mut rng, n) - 1.0).abs() < 0.1);
2371
2372        // Laplace(0, 2): mean 0, variance 2b^2 = 8.
2373        let lap = Laplace::new(0.0, 2.0).unwrap();
2374        let lap_samples: Vec<f64> = (0..n).map(|_| lap.sample(&mut rng)).collect();
2375        let lap_mean = lap_samples.iter().sum::<f64>() / n as f64;
2376        let lap_var = lap_samples
2377            .iter()
2378            .map(|x| (x - lap_mean).powi(2))
2379            .sum::<f64>()
2380            / n as f64;
2381        assert!(lap_mean.abs() < 0.1);
2382        assert!((lap_var - 8.0).abs() < 0.6);
2383
2384        // Weibull(shape=2, scale=1.5): mean = scale*Γ(1+1/2) = 1.3293403881791368.
2385        let w = Weibull::new(2.0, 1.5).unwrap();
2386        assert!((mean_of(&w, &mut rng, n) - 1.3293403881791368).abs() < 0.05);
2387
2388        // ChiSquared(4): mean 4, variance 2k = 8.
2389        let c = ChiSquared::new(4.0).unwrap();
2390        let c_samples: Vec<f64> = (0..n).map(|_| c.sample(&mut rng)).collect();
2391        let c_mean = c_samples.iter().sum::<f64>() / n as f64;
2392        let c_var = c_samples.iter().map(|x| (x - c_mean).powi(2)).sum::<f64>() / n as f64;
2393        assert!((c_mean - 4.0).abs() < 0.1);
2394        assert!((c_var - 8.0).abs() < 0.6);
2395
2396        // InverseGamma(shape=4, rate=3): mean = β/(α-1) = 1.
2397        let ig = InverseGamma::new(4.0, 3.0).unwrap();
2398        assert!((mean_of(&ig, &mut rng, n) - 1.0).abs() < 0.05);
2399
2400        // Cauchy: no mean; check the empirical MEDIAN converges to loc instead.
2401        let cau = Cauchy::new(2.0, 1.0).unwrap();
2402        let mut cau_samples: Vec<f64> = (0..n).map(|_| cau.sample(&mut rng)).collect();
2403        cau_samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
2404        let median = cau_samples[n / 2];
2405        assert!((median - 2.0).abs() < 0.1);
2406
2407        // DiscreteUniform(1, 6): mean 3.5, in-range always.
2408        let du = DiscreteUniform::new(1, 6).unwrap();
2409        let du_samples: Vec<i64> = (0..n).map(|_| du.sample(&mut rng)).collect();
2410        assert!(du_samples.iter().all(|&k| (1..=6).contains(&k)));
2411        let du_mean = du_samples.iter().map(|&k| k as f64).sum::<f64>() / n as f64;
2412        assert!((du_mean - 3.5).abs() < 0.05);
2413    }
2414
2415    // FG-55: `Validate` is now implemented for every exported distribution. The
2416    // trait mirrors each `new()` constructor, so an *invalid* instance can only
2417    // be built here — via a struct literal with private fields, which is only
2418    // possible inside this module. One case per newly implemented distribution
2419    // (LogNormal, Binomial, Poisson, StudentT, Cauchy, Laplace, Weibull,
2420    // ChiSquared, InverseGamma, DiscreteUniform), asserting the same error code
2421    // the corresponding constructor emits. (The public, valid-instance
2422    // exhaustiveness guard lives in `tests/f_validate_coverage.rs`.)
2423    #[test]
2424    fn fg55_validate_rejects_invalid_parameters() {
2425        use crate::error::{ErrorCode, Validate};
2426
2427        // LogNormal: non-positive sigma -> InvalidVariance.
2428        assert_eq!(
2429            LogNormal {
2430                mu: 0.0,
2431                sigma: 0.0
2432            }
2433            .validate()
2434            .unwrap_err()
2435            .code(),
2436            ErrorCode::InvalidVariance
2437        );
2438        // Binomial: probability outside [0, 1] -> InvalidProbability.
2439        assert_eq!(
2440            Binomial { n: 10, p: 1.5 }.validate().unwrap_err().code(),
2441            ErrorCode::InvalidProbability
2442        );
2443        // Poisson: non-positive rate -> InvalidRate.
2444        assert_eq!(
2445            Poisson { lambda: -1.0 }.validate().unwrap_err().code(),
2446            ErrorCode::InvalidRate
2447        );
2448        // StudentT: non-positive degrees of freedom -> InvalidShape.
2449        assert_eq!(
2450            StudentT {
2451                df: 0.0,
2452                loc: 0.0,
2453                scale: 1.0
2454            }
2455            .validate()
2456            .unwrap_err()
2457            .code(),
2458            ErrorCode::InvalidShape
2459        );
2460        // Cauchy: non-positive scale -> InvalidVariance.
2461        assert_eq!(
2462            Cauchy {
2463                loc: 0.0,
2464                scale: -1.0
2465            }
2466            .validate()
2467            .unwrap_err()
2468            .code(),
2469            ErrorCode::InvalidVariance
2470        );
2471        // Laplace: non-finite location -> InvalidMean.
2472        assert_eq!(
2473            Laplace {
2474                loc: f64::NAN,
2475                scale: 1.0
2476            }
2477            .validate()
2478            .unwrap_err()
2479            .code(),
2480            ErrorCode::InvalidMean
2481        );
2482        // Weibull: non-positive shape -> InvalidShape.
2483        assert_eq!(
2484            Weibull {
2485                shape: -2.0,
2486                scale: 1.0
2487            }
2488            .validate()
2489            .unwrap_err()
2490            .code(),
2491            ErrorCode::InvalidShape
2492        );
2493        // ChiSquared: non-positive degrees of freedom -> InvalidShape.
2494        assert_eq!(
2495            ChiSquared { k: 0.0 }.validate().unwrap_err().code(),
2496            ErrorCode::InvalidShape
2497        );
2498        // InverseGamma: non-positive rate -> InvalidRate.
2499        assert_eq!(
2500            InverseGamma {
2501                shape: 2.0,
2502                rate: -1.0
2503            }
2504            .validate()
2505            .unwrap_err()
2506            .code(),
2507            ErrorCode::InvalidRate
2508        );
2509        // DiscreteUniform: high < low -> InvalidRange.
2510        assert_eq!(
2511            DiscreteUniform { low: 5, high: 1 }
2512                .validate()
2513                .unwrap_err()
2514                .code(),
2515            ErrorCode::InvalidRange
2516        );
2517    }
2518
2519    // Re-verification (low): `DiscreteUniform` over the full i64 domain has a
2520    // support of 2^64 points. The pre-fix `len()` computed the count as
2521    // `(high - low + 1) as u64`, which truncates 2^64 to 0 — so `sample()`
2522    // panicked on `gen_range(0..0)` and `log_prob()` for an in-range `x` returned
2523    // `-(0.0).ln() = +INF`. The fix keeps the count in `u128`, samples the full
2524    // domain with a raw uniform `i64`, and scores it as `-64·ln 2`.
2525    #[test]
2526    fn discrete_uniform_full_i64_range_samples_and_scores() {
2527        let du = DiscreteUniform::new(i64::MIN, i64::MAX).unwrap();
2528
2529        // `len()` saturates (2^64 doesn't fit in u64) but the distribution stays
2530        // usable.
2531        assert_eq!(du.len(), u64::MAX);
2532        assert!(!du.is_empty());
2533
2534        // sample() must not panic and must return real i64 values across the whole
2535        // domain (seeded for determinism). A truncated count would panic here.
2536        let mut rng = StdRng::seed_from_u64(0xF017_2026);
2537        let mut saw_negative = false;
2538        let mut saw_positive = false;
2539        for _ in 0..10_000 {
2540            let x = du.sample(&mut rng);
2541            // Every i64 is in support, so log_prob is finite for every draw.
2542            assert!(du.log_prob(&x).is_finite());
2543            saw_negative |= x < 0;
2544            saw_positive |= x > 0;
2545        }
2546        // A raw uniform i64 spans both signs; a broken offset path (or a fixed
2547        // low) would not.
2548        assert!(
2549            saw_negative && saw_positive,
2550            "full-range sampler is not uniform"
2551        );
2552
2553        // log_prob for any in-range x is exactly -ln(2^64) = -64·ln 2. The pre-fix
2554        // code returned +INF here.
2555        let expected = -(64.0 * std::f64::consts::LN_2);
2556        for &x in &[i64::MIN, -1_000_000_i64, -1, 0, 1, 1_000_000_i64, i64::MAX] {
2557            let lp = du.log_prob(&x);
2558            assert!(
2559                (lp - expected).abs() < 1e-12,
2560                "full-range log_prob({x}) = {lp}, expected {expected}"
2561            );
2562        }
2563    }
2564
2565    // Re-verification (low): ranges one short of the full domain (span 2^64 − 1,
2566    // the largest that fits in a u64 count) must still sample without overflow and
2567    // score as -ln(2^64 − 1).
2568    #[test]
2569    fn discrete_uniform_near_full_ranges_are_exact() {
2570        let mut rng = StdRng::seed_from_u64(0xBEEF_2026);
2571
2572        for du in [
2573            DiscreteUniform::new(i64::MIN, i64::MAX - 1).unwrap(),
2574            DiscreteUniform::new(i64::MIN + 1, i64::MAX).unwrap(),
2575        ] {
2576            // Count = 2^64 − 1 fits exactly in u64.
2577            assert_eq!(du.len(), u64::MAX);
2578            let expected = -((u64::MAX as f64).ln());
2579            for _ in 0..2_000 {
2580                let x = du.sample(&mut rng);
2581                assert!(du.log_prob(&x).is_finite());
2582            }
2583            // In-range score is -ln(2^64 − 1); the excluded endpoint is -inf.
2584            close(du.log_prob(&0), expected);
2585            let excluded = if du.high() == i64::MAX - 1 {
2586                i64::MAX
2587            } else {
2588                i64::MIN
2589            };
2590            assert_eq!(du.log_prob(&excluded), f64::NEG_INFINITY);
2591        }
2592    }
2593}