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