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}