Skip to main content

wm_simulation/
monte_carlo.rs

1//! Monte Carlo simulation — sampling-based estimation.
2//!
3//! Supports Bayesian MC (random sampling), Quasi-MC (low-discrepancy
4//! sequences), and high-dimensional integration via sampling.
5
6#![forbid(unsafe_code)]
7
8use serde::{Deserialize, Serialize};
9
10// ── Distribution ──────────────────────────────────────────────────────
11
12/// Probability distributions for MC sampling.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub enum Distribution {
15    /// Uniform distribution on [min, max].
16    Uniform { min: f64, max: f64 },
17    /// Normal distribution with mean and std_dev.
18    Normal { mean: f64, std_dev: f64 },
19    /// Exponential distribution with rate lambda.
20    Exponential { lambda: f64 },
21    /// Triangular distribution (min, mode, max).
22    Triangular { min: f64, mode: f64, max: f64 },
23    /// Constant value (degenerate distribution).
24    Constant(f64),
25}
26
27impl Distribution {
28    /// Sample from this distribution using a uniform random number in [0, 1).
29    #[must_use]
30    pub fn sample(&self, u: f64) -> f64 {
31        match *self {
32            Self::Uniform { min, max } => u.mul_add(max - min, min),
33            Self::Normal { mean, std_dev } => {
34                // Box-Muller transform
35                let u1 = u.max(1e-10);
36                let u2 = (u * 1.618033988749895).fract();
37                let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
38                std_dev.mul_add(z, mean)
39            }
40            Self::Exponential { lambda } => {
41                let u = u.max(1e-10);
42                -u.ln() / lambda
43            }
44            Self::Triangular { min, mode, max } => {
45                let fc = (mode - min) / (max - min);
46                if u < fc {
47                    min + (u * (max - min) * (mode - min)).sqrt()
48                } else {
49                    max - ((1.0 - u) * (max - min) * (max - mode)).sqrt()
50                }
51            }
52            Self::Constant(v) => v,
53        }
54    }
55
56    /// Mean of the distribution.
57    #[must_use]
58    pub const fn mean(&self) -> f64 {
59        match *self {
60            Self::Uniform { min, max } => f64::midpoint(min, max),
61            Self::Normal { mean, .. } => mean,
62            Self::Exponential { lambda } => 1.0 / lambda,
63            Self::Triangular { min, mode, max } => (min + mode + max) / 3.0,
64            Self::Constant(v) => v,
65        }
66    }
67
68    /// Human-readable name.
69    #[must_use]
70    pub const fn name(&self) -> &'static str {
71        match self {
72            Self::Uniform { .. } => "uniform",
73            Self::Normal { .. } => "normal",
74            Self::Exponential { .. } => "exponential",
75            Self::Triangular { .. } => "triangular",
76            Self::Constant(_) => "constant",
77        }
78    }
79}
80
81// ── MC Config ─────────────────────────────────────────────────────────
82
83/// Configuration for Monte Carlo simulation.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct McConfig {
86    /// Number of samples.
87    pub n_samples: usize,
88    /// Random seed (0 = use time-based seed).
89    pub seed: u64,
90    /// Whether to use Quasi-MC (Sobol-like low-discrepancy sequence).
91    pub quasi_mc: bool,
92}
93
94impl Default for McConfig {
95    fn default() -> Self {
96        Self {
97            n_samples: 10_000,
98            seed: 42,
99            quasi_mc: false,
100        }
101    }
102}
103
104// ── MC Result ─────────────────────────────────────────────────────────
105
106/// Result of a Monte Carlo simulation.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct McResult {
109    /// Mean of the output.
110    pub mean: f64,
111    /// Standard deviation.
112    pub std_dev: f64,
113    /// Minimum value observed.
114    pub min: f64,
115    /// Maximum value observed.
116    pub max: f64,
117    /// 5th percentile.
118    pub p5: f64,
119    /// 25th percentile.
120    pub p25: f64,
121    /// 50th percentile (median).
122    pub p50: f64,
123    /// 75th percentile.
124    pub p75: f64,
125    /// 95th percentile.
126    pub p95: f64,
127    /// Number of samples.
128    pub n_samples: usize,
129}
130
131impl McResult {
132    /// 95% confidence interval half-width.
133    #[must_use]
134    pub fn ci95_half_width(&self) -> f64 {
135        1.96 * self.std_dev / (self.n_samples as f64).sqrt()
136    }
137
138    /// Lower bound of 95% CI.
139    #[must_use]
140    pub fn ci95_lower(&self) -> f64 {
141        self.mean - self.ci95_half_width()
142    }
143
144    /// Upper bound of 95% CI.
145    #[must_use]
146    pub fn ci95_upper(&self) -> f64 {
147        self.mean + self.ci95_half_width()
148    }
149
150    /// Convert to JSON.
151    #[must_use]
152    pub fn to_json(&self) -> serde_json::Value {
153        serde_json::json!({
154            "mean": self.mean,
155            "std_dev": self.std_dev,
156            "min": self.min,
157            "max": self.max,
158            "p5": self.p5,
159            "p25": self.p25,
160            "p50": self.p50,
161            "p75": self.p75,
162            "p95": self.p95,
163            "n_samples": self.n_samples,
164            "ci95_lower": self.ci95_lower(),
165            "ci95_upper": self.ci95_upper(),
166        })
167    }
168}
169
170// ── Monte Carlo Simulator ─────────────────────────────────────────────
171
172/// Simple PRNG (xorshift64).
173const fn xorshift64(state: &mut u64) -> u64 {
174    let mut x = *state;
175    if x == 0 {
176        x = 0x9E37_79B9_7F4A_7C15;
177    }
178    x ^= x << 13;
179    x ^= x >> 7;
180    x ^= x << 17;
181    *state = x;
182    x
183}
184
185/// Generate a uniform random number in [0, 1) from a PRNG state.
186fn rand_u01(state: &mut u64) -> f64 {
187    let r = xorshift64(state);
188    (r >> 11) as f64 / (1u64 << 53) as f64
189}
190
191/// Generate a low-discrepancy Sobol-like sequence point.
192fn sobol_point(index: usize, dim: usize) -> f64 {
193    // Simple Van der Corput sequence in base 2 for 1D
194    let _ = dim;
195    let mut result = 0.0;
196    let mut f = 0.5;
197    let mut i = index + 1;
198    while i > 0 {
199        if i & 1 != 0 {
200            result += f;
201        }
202        i >>= 1;
203        f *= 0.5;
204    }
205    result
206}
207
208/// Monte Carlo simulator — runs sampling-based simulations.
209pub struct MonteCarloSimulator {
210    config: McConfig,
211    rng_state: u64,
212}
213
214impl Default for MonteCarloSimulator {
215    fn default() -> Self {
216        Self::new(McConfig::default())
217    }
218}
219
220impl std::fmt::Debug for MonteCarloSimulator {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        f.debug_struct("MonteCarloSimulator")
223            .field("config", &self.config)
224            .finish_non_exhaustive()
225    }
226}
227
228impl MonteCarloSimulator {
229    /// Create a new simulator.
230    #[must_use]
231    pub fn new(config: McConfig) -> Self {
232        Self {
233            rng_state: if config.seed == 0 {
234                chrono::Utc::now().timestamp_nanos_opt().unwrap_or(42) as u64
235            } else {
236                config.seed
237            },
238            config,
239        }
240    }
241
242    /// Run a simulation with a model function.
243    /// The model takes a vector of sampled inputs and returns an output.
244    pub fn simulate<F>(&mut self, distributions: &[Distribution], model: F) -> McResult
245    where
246        F: Fn(&[f64]) -> f64,
247    {
248        let n = self.config.n_samples;
249        let mut outputs = Vec::with_capacity(n);
250
251        for i in 0..n {
252            let inputs: Vec<f64> = distributions
253                .iter()
254                .enumerate()
255                .map(|(d, dist)| {
256                    if self.config.quasi_mc {
257                        let u = sobol_point(i, d);
258                        dist.sample(u)
259                    } else {
260                        let u = rand_u01(&mut self.rng_state);
261                        dist.sample(u)
262                    }
263                })
264                .collect();
265
266            outputs.push(model(&inputs));
267        }
268
269        Self::compute_stats(&outputs)
270    }
271
272    /// Compute statistics from a vector of samples.
273    #[must_use]
274    pub fn compute_stats(samples: &[f64]) -> McResult {
275        let n = samples.len();
276        if n == 0 {
277            return McResult {
278                mean: 0.0,
279                std_dev: 0.0,
280                min: 0.0,
281                max: 0.0,
282                p5: 0.0,
283                p25: 0.0,
284                p50: 0.0,
285                p75: 0.0,
286                p95: 0.0,
287                n_samples: 0,
288            };
289        }
290
291        let mut sorted = samples.to_vec();
292        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
293
294        let mean = sorted.iter().sum::<f64>() / n as f64;
295        let variance = sorted
296            .iter()
297            .map(|x| {
298                let d = x - mean;
299                d * d
300            })
301            .sum::<f64>()
302            / n as f64;
303        let std_dev = variance.sqrt();
304
305        let percentile = |p: f64| -> f64 {
306            let idx = ((p / 100.0) * (n - 1) as f64).round() as usize;
307            sorted[idx.min(n - 1)]
308        };
309
310        McResult {
311            mean,
312            std_dev,
313            min: sorted[0],
314            max: sorted[n - 1],
315            p5: percentile(5.0),
316            p25: percentile(25.0),
317            p50: percentile(50.0),
318            p75: percentile(75.0),
319            p95: percentile(95.0),
320            n_samples: n,
321        }
322    }
323
324    /// Estimate the integral of a function over a hyper-rectangle.
325    pub fn integrate<F>(&mut self, bounds: &[(f64, f64)], f: F) -> McResult
326    where
327        F: Fn(&[f64]) -> f64,
328    {
329        let distributions: Vec<Distribution> = bounds
330            .iter()
331            .map(|(min, max)| Distribution::Uniform {
332                min: *min,
333                max: *max,
334            })
335            .collect();
336
337        let volume: f64 = bounds.iter().map(|(min, max)| max - min).product();
338
339        self.simulate(&distributions, |inputs| f(inputs) * volume)
340    }
341
342    /// Get the config.
343    #[must_use]
344    pub const fn config(&self) -> &McConfig {
345        &self.config
346    }
347}
348
349// ── Tests ─────────────────────────────────────────────────────────────
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn distribution_uniform_sample() {
357        let d = Distribution::Uniform {
358            min: 0.0,
359            max: 10.0,
360        };
361        let s = d.sample(0.5);
362        assert!((s - 5.0).abs() < 0.001);
363    }
364
365    #[test]
366    fn distribution_normal_sample() {
367        let d = Distribution::Normal {
368            mean: 0.0,
369            std_dev: 1.0,
370        };
371        let s = d.sample(0.5);
372        // Just check it's a finite number
373        assert!(s.is_finite());
374    }
375
376    #[test]
377    fn distribution_exponential_sample() {
378        let d = Distribution::Exponential { lambda: 1.0 };
379        let s = d.sample(0.5);
380        assert!(s > 0.0);
381    }
382
383    #[test]
384    fn distribution_triangular_sample() {
385        let d = Distribution::Triangular {
386            min: 0.0,
387            mode: 0.5,
388            max: 1.0,
389        };
390        let s = d.sample(0.5);
391        assert!((0.0..=1.0).contains(&s));
392    }
393
394    #[test]
395    fn distribution_constant_sample() {
396        let d = Distribution::Constant(42.0);
397        assert!((d.sample(0.5) - 42.0).abs() < 0.001);
398    }
399
400    #[test]
401    fn distribution_mean() {
402        assert!(
403            (Distribution::Uniform {
404                min: 0.0,
405                max: 10.0
406            }
407            .mean()
408                - 5.0)
409                .abs()
410                < 0.001
411        );
412        assert!(
413            (Distribution::Normal {
414                mean: 3.0,
415                std_dev: 1.0
416            }
417            .mean()
418                - 3.0)
419                .abs()
420                < 0.001
421        );
422        assert!((Distribution::Constant(42.0).mean() - 42.0).abs() < 0.001);
423    }
424
425    #[test]
426    fn distribution_name() {
427        assert_eq!(
428            Distribution::Uniform { min: 0.0, max: 1.0 }.name(),
429            "uniform"
430        );
431        assert_eq!(
432            Distribution::Normal {
433                mean: 0.0,
434                std_dev: 1.0
435            }
436            .name(),
437            "normal"
438        );
439        assert_eq!(Distribution::Constant(0.0).name(), "constant");
440    }
441
442    #[test]
443    fn mc_simulate_constant_model() {
444        let mut sim = MonteCarloSimulator::new(McConfig {
445            n_samples: 1000,
446            seed: 42,
447            quasi_mc: false,
448        });
449        let dists = vec![Distribution::Uniform { min: 0.0, max: 1.0 }];
450        let result = sim.simulate(&dists, |_| 42.0);
451        assert!((result.mean - 42.0).abs() < 0.001);
452        assert!((result.std_dev - 0.0).abs() < 0.001);
453    }
454
455    #[test]
456    fn mc_simulate_identity_model() {
457        let mut sim = MonteCarloSimulator::new(McConfig {
458            n_samples: 10_000,
459            seed: 42,
460            quasi_mc: false,
461        });
462        let dists = vec![Distribution::Uniform {
463            min: 0.0,
464            max: 10.0,
465        }];
466        let result = sim.simulate(&dists, |inputs| inputs[0]);
467        // Mean of Uniform[0,10] = 5
468        assert!((result.mean - 5.0).abs() < 0.5);
469    }
470
471    #[test]
472    fn mc_simulate_sum_model() {
473        let mut sim = MonteCarloSimulator::new(McConfig {
474            n_samples: 10_000,
475            seed: 42,
476            quasi_mc: false,
477        });
478        let dists = vec![
479            Distribution::Uniform { min: 0.0, max: 1.0 },
480            Distribution::Uniform { min: 0.0, max: 1.0 },
481        ];
482        let result = sim.simulate(&dists, |inputs| inputs[0] + inputs[1]);
483        // Mean of sum of two Uniform[0,1] = 1.0
484        assert!((result.mean - 1.0).abs() < 0.2);
485    }
486
487    #[test]
488    fn mc_quasi_mc_mode() {
489        let mut sim = MonteCarloSimulator::new(McConfig {
490            n_samples: 1000,
491            seed: 42,
492            quasi_mc: true,
493        });
494        let dists = vec![Distribution::Uniform { min: 0.0, max: 1.0 }];
495        let result = sim.simulate(&dists, |inputs| inputs[0]);
496        // Quasi-MC should converge faster
497        assert!((result.mean - 0.5).abs() < 0.1);
498    }
499
500    #[test]
501    fn mc_result_percentiles() {
502        let samples: Vec<f64> = (0..100).map(f64::from).collect();
503        let result = MonteCarloSimulator::compute_stats(&samples);
504        assert!((result.mean - 49.5).abs() < 0.001);
505        assert!((result.min - 0.0).abs() < 0.001);
506        assert!((result.max - 99.0).abs() < 0.001);
507        assert!((result.p50 - 49.0).abs() <= 2.0);
508    }
509
510    #[test]
511    fn mc_result_ci95() {
512        let samples: Vec<f64> = vec![1.0; 100];
513        let result = MonteCarloSimulator::compute_stats(&samples);
514        assert!((result.ci95_half_width() - 0.0).abs() < 0.001);
515    }
516
517    #[test]
518    fn mc_result_to_json() {
519        let result = McResult {
520            mean: 5.0,
521            std_dev: 1.0,
522            min: 0.0,
523            max: 10.0,
524            p5: 1.0,
525            p25: 3.0,
526            p50: 5.0,
527            p75: 7.0,
528            p95: 9.0,
529            n_samples: 100,
530        };
531        let json = result.to_json();
532        assert_eq!(json["mean"], 5.0);
533        assert_eq!(json["n_samples"], 100);
534    }
535
536    #[test]
537    fn mc_integrate_constant() {
538        let mut sim = MonteCarloSimulator::new(McConfig {
539            n_samples: 10_000,
540            seed: 42,
541            quasi_mc: false,
542        });
543        // Integral of 1.0 over [0, 2] = 2.0
544        let result = sim.integrate(&[(0.0, 2.0)], |_| 1.0);
545        assert!((result.mean - 2.0).abs() < 0.2);
546    }
547
548    #[test]
549    fn mc_integrate_identity() {
550        let mut sim = MonteCarloSimulator::new(McConfig {
551            n_samples: 10_000,
552            seed: 42,
553            quasi_mc: false,
554        });
555        // Integral of x over [0, 1] = 0.5
556        let result = sim.integrate(&[(0.0, 1.0)], |inputs| inputs[0]);
557        assert!((result.mean - 0.5).abs() < 0.1);
558    }
559
560    #[test]
561    fn mc_compute_stats_empty() {
562        let result = MonteCarloSimulator::compute_stats(&[]);
563        assert_eq!(result.n_samples, 0);
564        assert!((result.mean - 0.0).abs() < 0.001);
565    }
566
567    #[test]
568    fn mc_simulate_with_normal() {
569        let mut sim = MonteCarloSimulator::new(McConfig {
570            n_samples: 50_000,
571            seed: 42,
572            quasi_mc: false,
573        });
574        let dists = vec![Distribution::Normal {
575            mean: 10.0,
576            std_dev: 2.0,
577        }];
578        let result = sim.simulate(&dists, |inputs| inputs[0]);
579        // Mean should be close to 10
580        assert!((result.mean - 10.0).abs() < 1.0);
581        // Std dev should be close to 2
582        assert!((result.std_dev - 2.0).abs() < 1.0);
583    }
584
585    #[test]
586    fn mc_config_default() {
587        let config = McConfig::default();
588        assert_eq!(config.n_samples, 10_000);
589        assert_eq!(config.seed, 42);
590        assert!(!config.quasi_mc);
591    }
592}