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