Skip to main content

rlevo_evolution/algorithms/eda/
univariate_gaussian.rs

1//! Univariate Gaussian model (UMDA — Univariate Marginal Distribution
2//! Algorithm) for continuous search spaces.
3//!
4//! Each dimension is modelled by an independent Gaussian. [`fit`] performs an
5//! unweighted maximum-likelihood estimate over the `k` selected rows: the
6//! per-column mean and variance are computed via `÷k` (not `÷(k-1)`), and the
7//! variance is floored at [`UnivariateGaussianParams::min_variance`] to prevent
8//! collapse to a point mass. The `fitness` tensor is accepted by the
9//! [`ProbabilityModel`] interface but ignored; the fit is unweighted.
10//! [`sample`] draws each gene from its dimension's fitted Gaussian using the
11//! supplied host RNG (never `Tensor::random` / `B::seed`).
12//!
13//! Cross-dimension dependencies are not captured — for those, see
14//! [`super::dependency_chain`].
15//!
16//! # References
17//!
18//! - Mühlenbein & Paaß (1996), *From recombination of genes to the
19//!   estimation of distributions I. Binary parameters*.
20//!
21//! [`fit`]: crate::ProbabilityModel::fit
22//! [`sample`]: crate::ProbabilityModel::sample
23
24use burn::tensor::{Tensor, TensorData, backend::Backend};
25use rand::Rng;
26use rand_distr::{Distribution as _, Normal};
27use rlevo_core::config::{self, ConfigError, ConstraintKind};
28
29use crate::probability_model::ProbabilityModel;
30
31/// Per-run configuration for the [`UnivariateGaussian`] model.
32///
33/// Held inside [`EdaParams::model`](crate::algorithms::eda::EdaParams::model)
34/// for the lifetime of a run. All fields are `pub` for struct-literal
35/// construction; use [`UnivariateGaussianParams::default_for`] for typical
36/// continuous-optimisation defaults.
37#[derive(Debug, Clone)]
38pub struct UnivariateGaussianParams {
39    /// Number of genes per genome; determines the length of
40    /// [`UnivariateGaussianState::mean`] and [`UnivariateGaussianState::variance`].
41    pub genome_dim: usize,
42    /// Prior mean for every dimension, used when `prev = None`.
43    pub init_mean: f32,
44    /// Prior standard deviation for every dimension, used when `prev = None`.
45    /// The prior variance is `init_std²`.
46    pub init_std: f32,
47    /// Minimum variance for any dimension; prevents the model from collapsing
48    /// to a point mass. The MLE estimate is floored at this value after each
49    /// [`ProbabilityModel::fit`] call.
50    pub min_variance: f32,
51}
52
53impl UnivariateGaussianParams {
54    /// Sensible defaults for a `genome_dim`-dimensional continuous problem.
55    #[must_use]
56    pub fn default_for(genome_dim: usize) -> Self {
57        Self {
58            genome_dim,
59            init_mean: 0.0,
60            init_std: 2.0,
61            min_variance: 1e-6,
62        }
63    }
64}
65
66/// Fitted statistics for the [`UnivariateGaussian`] model after one call to
67/// [`ProbabilityModel::fit`].
68///
69/// Both vectors have length `genome_dim`. On the prior path (`prev = None`)
70/// they are initialised from [`UnivariateGaussianParams::init_mean`] /
71/// `init_std²`; on subsequent calls they hold the unweighted MLE estimates
72/// computed from the truncation-selected population.
73///
74/// Fields are private so a mismatched `mean` / `variance` length (or a
75/// negative / non-finite variance) is unrepresentable from outside this
76/// module; use [`try_new`](UnivariateGaussianState::try_new) to build one and
77/// the [`mean`](UnivariateGaussianState::mean) /
78/// [`variance`](UnivariateGaussianState::variance) accessors to read it.
79#[derive(Debug, Clone)]
80pub struct UnivariateGaussianState {
81    /// Per-dimension MLE mean.
82    mean: Vec<f32>,
83    /// Per-dimension MLE variance, floored at
84    /// [`UnivariateGaussianParams::min_variance`].
85    variance: Vec<f32>,
86}
87
88impl UnivariateGaussianState {
89    /// Builds a fitted Gaussian state from per-dimension `mean` and `variance`.
90    ///
91    /// # Errors
92    ///
93    /// Returns a [`ConfigError`] if `mean` is empty, if `mean` and `variance`
94    /// differ in length, or if any variance is negative or non-finite. A
95    /// variance is a squared deviation, so it must be `>= 0` and finite.
96    pub fn try_new(mean: Vec<f32>, variance: Vec<f32>) -> Result<Self, ConfigError> {
97        config::nonzero("UnivariateGaussianState", "mean", mean.len())?;
98        if mean.len() != variance.len() {
99            return Err(ConfigError {
100                config: "UnivariateGaussianState",
101                field: "variance",
102                kind: ConstraintKind::Custom("mean and variance must have equal length"),
103            });
104        }
105        if variance.iter().any(|v| !v.is_finite() || *v < 0.0) {
106            return Err(ConfigError {
107                config: "UnivariateGaussianState",
108                field: "variance",
109                kind: ConstraintKind::Custom("every variance must be finite and non-negative"),
110            });
111        }
112        Ok(Self { mean, variance })
113    }
114
115    /// Per-dimension MLE means, length `genome_dim`.
116    #[must_use]
117    pub fn mean(&self) -> &[f32] {
118        &self.mean
119    }
120
121    /// Per-dimension MLE variances, length `genome_dim`.
122    #[must_use]
123    pub fn variance(&self) -> &[f32] {
124        &self.variance
125    }
126}
127
128/// Univariate Marginal Distribution Algorithm for continuous spaces (UMDA).
129///
130/// Implements [`ProbabilityModel`] with an unweighted per-dimension Gaussian
131/// fit (`÷k` MLE variance, [`UnivariateGaussianParams::min_variance`] floor)
132/// and independent per-dimension Gaussian sampling via the host RNG.
133/// The fitness tensor passed to [`ProbabilityModel::fit`] is accepted but
134/// ignored; the estimate is always unweighted.
135///
136/// See the [module docs](self) for the algorithm description and references.
137#[derive(Debug, Clone, Copy, Default)]
138pub struct UnivariateGaussian;
139
140impl<B: Backend> ProbabilityModel<B> for UnivariateGaussian {
141    type Params = UnivariateGaussianParams;
142    type State = UnivariateGaussianState;
143
144    /// Fit per-dimension Gaussian statistics to the selected population.
145    ///
146    /// When `prev = None` returns the prior (length-`genome_dim` vectors of
147    /// `init_mean` and `init_std²`); `population` and `fitness` are ignored
148    /// on that path. Otherwise computes the unweighted `÷k` MLE mean and
149    /// variance for every column and floors each variance at `min_variance`.
150    /// The `fitness` argument is accepted but always ignored.
151    ///
152    /// # Panics
153    ///
154    /// Panics if the `population` tensor cannot be read back as `f32`
155    /// (`.expect("population tensor must be readable as f32")`), or with an
156    /// out-of-bounds index if the host buffer is shorter than `k * d`. Callers
157    /// must therefore pass an `f32`, `(k, d)`-shaped population tensor.
158    fn fit(
159        &self,
160        params: &Self::Params,
161        prev: Option<&Self::State>,
162        population: Tensor<B, 2>,
163        fitness: Tensor<B, 1>,
164        device: &<B as burn::tensor::backend::BackendTypes>::Device,
165    ) -> Self::State {
166        let _ = device;
167        // Fitness is accepted but ignored: the MLE fit is unweighted.
168        let _ = fitness;
169        let Some(_prev) = prev else {
170            // Prior path: build purely from params, ignore population/fitness.
171            let d = params.genome_dim;
172            return UnivariateGaussianState {
173                mean: vec![params.init_mean; d],
174                variance: vec![params.init_std * params.init_std; d],
175            };
176        };
177        // `prev`'s contents are unused on this path — UMDA is a full refit.
178
179        let [k, d] = population.dims();
180        let rows = population
181            .into_data()
182            .into_vec::<f32>()
183            .expect("population tensor must be readable as f32");
184        // k is a selected-population count, far below f32's 2^24 exact-integer
185        // limit; the cast is lossless in practice.
186        #[allow(clippy::cast_precision_loss)]
187        let kf = k as f32;
188
189        let mut mean = vec![0.0_f32; d];
190        for i in 0..k {
191            for j in 0..d {
192                mean[j] += rows[i * d + j];
193            }
194        }
195        for m in &mut mean {
196            let mu = *m / kf;
197            // `Normal::new` accepts any mean, so a single non-finite genome value
198            // (common from divergent DRL rollouts) would propagate into every
199            // sample for this dimension and silently poison the search. Fall back
200            // to the prior mean. (The `tell` chokepoint now also sanitizes the
201            // population as a coarse backstop; this is the precise per-dimension
202            // guard for direct `fit` callers.)
203            *m = if mu.is_finite() { mu } else { params.init_mean };
204        }
205
206        let mut variance = vec![0.0_f32; d];
207        for i in 0..k {
208            for j in 0..d {
209                let diff = rows[i * d + j] - mean[j];
210                variance[j] += diff * diff;
211            }
212        }
213        for v in &mut variance {
214            // Floor the lower bound AND reject non-finite estimates. `f32::max`
215            // suppresses `NaN` (returns `min_variance`), but an overflowed `inf`
216            // MLE variance would flow through as `inf` → `inf` std →
217            // `Normal::new` rejects non-finite σ → `sample` panics. Making the
218            // stored variance always finite and `≥ min_variance` keeps `sample`
219            // panic-free.
220            let mle = *v / kf;
221            *v = if mle.is_finite() && mle > params.min_variance {
222                mle
223            } else {
224                params.min_variance
225            };
226        }
227
228        UnivariateGaussianState { mean, variance }
229    }
230
231    /// Draw `n` genomes from the fitted per-dimension Gaussians.
232    ///
233    /// All randomness is drawn from `rng` (host RNG only; never
234    /// `Tensor::random` / `B::seed`). The returned tensor has shape `(n, D)`.
235    ///
236    /// # Panics
237    ///
238    /// The internal `Normal::new` constructor is guarded by `min_variance`,
239    /// ensuring the standard deviation is always strictly positive and finite,
240    /// so `.expect("floored std is positive and finite")` does not fire. Any
241    /// non-finite genome values are already contained in [`fit`](Self::fit):
242    /// a non-finite mean falls back to `init_mean`, and the MLE variance is
243    /// floored to a finite, positive value at least `min_variance`. Given a
244    /// state produced by `fit`, this method does not panic under normal
245    /// operation.
246    fn sample(
247        &self,
248        state: &Self::State,
249        n: usize,
250        rng: &mut dyn Rng,
251        device: &<B as burn::tensor::backend::BackendTypes>::Device,
252    ) -> Tensor<B, 2> {
253        let d = state.mean.len();
254        // One Normal per dimension; the floored std is positive and finite.
255        let normals: Vec<Normal<f32>> = (0..d)
256            .map(|j| {
257                Normal::new(state.mean[j], state.variance[j].sqrt())
258                    .expect("floored std is positive and finite")
259            })
260            .collect();
261        // Row-major fill: outer loop individuals, inner loop dimensions.
262        let mut rows = Vec::with_capacity(n * d);
263        for _ in 0..n {
264            for normal in &normals {
265                rows.push(normal.sample(rng));
266            }
267        }
268        Tensor::<B, 2>::from_data(TensorData::new(rows, [n, d]), device)
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use burn::backend::Flex;
276    use rand::SeedableRng;
277    use rand::rngs::StdRng;
278
279    type TestBackend = Flex;
280
281    fn pop(rows: Vec<f32>, n: usize, d: usize) -> Tensor<TestBackend, 2> {
282        let device = Default::default();
283        Tensor::<TestBackend, 2>::from_data(TensorData::new(rows, [n, d]), &device)
284    }
285
286    fn fitness(values: Vec<f32>) -> Tensor<TestBackend, 1> {
287        let device = Default::default();
288        let n = values.len();
289        Tensor::<TestBackend, 1>::from_data(TensorData::new(values, [n]), &device)
290    }
291
292    #[test]
293    fn prior_from_params() {
294        let device = Default::default();
295        let model = UnivariateGaussian;
296        let p = UnivariateGaussianParams::default_for(3);
297        let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
298            &model,
299            &p,
300            None,
301            pop(vec![], 0, 0),
302            fitness(vec![]),
303            &device,
304        );
305        assert_eq!(state.mean, vec![0.0, 0.0, 0.0]);
306        for v in &state.variance {
307            approx::assert_relative_eq!(*v, 4.0, epsilon = 1e-6);
308        }
309    }
310
311    #[test]
312    fn mle_matches_hand_computed() {
313        let device = Default::default();
314        let model = UnivariateGaussian;
315        let p = UnivariateGaussianParams::default_for(2);
316        // Column 0: [0, 2, 4] → mean 2, var = (4+0+4)/3 = 8/3.
317        // Column 1: [1, 1, 4] → mean 2, var = (1+1+4)/3 = 2.
318        let population = pop(vec![0.0, 1.0, 2.0, 1.0, 4.0, 4.0], 3, 2);
319        let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
320            &model,
321            &p,
322            None,
323            pop(vec![], 0, 0),
324            fitness(vec![]),
325            &device,
326        );
327        let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
328            &model,
329            &p,
330            Some(&prior),
331            population,
332            fitness(vec![0.0, 1.0, 2.0]),
333            &device,
334        );
335        approx::assert_relative_eq!(state.mean[0], 2.0, epsilon = 1e-5);
336        approx::assert_relative_eq!(state.mean[1], 2.0, epsilon = 1e-5);
337        approx::assert_relative_eq!(state.variance[0], 8.0 / 3.0, epsilon = 1e-5);
338        approx::assert_relative_eq!(state.variance[1], 2.0, epsilon = 1e-5);
339    }
340
341    #[test]
342    fn variance_floor_engages_on_constant_column() {
343        let device = Default::default();
344        let model = UnivariateGaussian;
345        let p = UnivariateGaussianParams::default_for(1);
346        let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
347            &model,
348            &p,
349            None,
350            pop(vec![], 0, 0),
351            fitness(vec![]),
352            &device,
353        );
354        // Constant column → raw variance 0, floored to min_variance.
355        let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
356            &model,
357            &p,
358            Some(&prior),
359            pop(vec![3.0, 3.0, 3.0], 3, 1),
360            fitness(vec![0.0, 0.0, 0.0]),
361            &device,
362        );
363        approx::assert_relative_eq!(state.variance[0], p.min_variance, epsilon = 1e-9);
364    }
365
366    #[test]
367    fn fitness_is_ignored() {
368        let device = Default::default();
369        let model = UnivariateGaussian;
370        let p = UnivariateGaussianParams::default_for(2);
371        let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
372            &model,
373            &p,
374            None,
375            pop(vec![], 0, 0),
376            fitness(vec![]),
377            &device,
378        );
379        let rows = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
380        let a = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
381            &model,
382            &p,
383            Some(&prior),
384            pop(rows.clone(), 3, 2),
385            fitness(vec![0.0, 1.0, 2.0]),
386            &device,
387        );
388        let b = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
389            &model,
390            &p,
391            Some(&prior),
392            pop(rows, 3, 2),
393            fitness(vec![100.0, -7.0, 42.0]),
394            &device,
395        );
396        assert_eq!(a.mean, b.mean);
397        assert_eq!(a.variance, b.variance);
398    }
399
400    #[test]
401    fn try_new_accepts_valid_and_round_trips() {
402        let state = UnivariateGaussianState::try_new(vec![1.0, -2.0], vec![0.5, 4.0]).unwrap();
403        assert_eq!(state.mean(), &[1.0, -2.0]);
404        assert_eq!(state.variance(), &[0.5, 4.0]);
405    }
406
407    #[test]
408    fn try_new_rejects_length_mismatch_and_bad_variance() {
409        assert!(UnivariateGaussianState::try_new(vec![0.0, 0.0], vec![1.0]).is_err());
410        assert!(UnivariateGaussianState::try_new(vec![], vec![]).is_err());
411        assert!(UnivariateGaussianState::try_new(vec![0.0], vec![-1.0]).is_err());
412        assert!(UnivariateGaussianState::try_new(vec![0.0], vec![f32::NAN]).is_err());
413    }
414
415    #[test]
416    fn seeded_sampling_mean_matches_state() {
417        let device = Default::default();
418        let model = UnivariateGaussian;
419        let state = UnivariateGaussianState {
420            mean: vec![3.0, -1.0],
421            variance: vec![1.0, 0.25],
422        };
423        let mut rng = StdRng::seed_from_u64(123);
424        let samples = <UnivariateGaussian as ProbabilityModel<TestBackend>>::sample(
425            &model, &state, 10_000, &mut rng, &device,
426        );
427        let dims = samples.dims();
428        assert_eq!(dims, [10_000, 2]);
429        let data = samples
430            .into_data()
431            .into_vec::<f32>()
432            .expect("samples host-read of a tensor this test just built");
433        let mut sum0 = 0.0_f32;
434        let mut sum1 = 0.0_f32;
435        for i in 0..10_000 {
436            sum0 += data[i * 2];
437            sum1 += data[i * 2 + 1];
438        }
439        approx::assert_relative_eq!(sum0 / 10_000.0, 3.0, epsilon = 0.1);
440        approx::assert_relative_eq!(sum1 / 10_000.0, -1.0, epsilon = 0.1);
441    }
442
443    #[test]
444    fn inf_variance_floored_to_min() {
445        // Squared deviations overflow f32 → inf MLE variance. The floor (#129)
446        // must reject the non-finite estimate instead of passing inf through to
447        // sample() (where Normal::new would then panic on a non-finite σ).
448        let device = Default::default();
449        let p = UnivariateGaussianParams::default_for(1);
450        let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
451            &UnivariateGaussian,
452            &p,
453            None,
454            pop(vec![], 0, 0),
455            fitness(vec![]),
456            &device,
457        );
458        let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
459            &UnivariateGaussian,
460            &p,
461            Some(&prior),
462            pop(vec![1e38, -1e38], 2, 1),
463            fitness(vec![0.0, 1.0]),
464            &device,
465        );
466        let v = state.variance[0];
467        assert!(v.is_finite(), "variance must be finite, got {v}");
468        approx::assert_relative_eq!(v, p.min_variance, epsilon = 1e-12);
469    }
470
471    #[test]
472    fn nonfinite_gene_mean_falls_back_to_init_mean() {
473        // A NaN gene makes the column mean NaN; the guard (#129) falls back to
474        // init_mean so the fitted mean stays finite (and sampling stays sane).
475        let device = Default::default();
476        let mut p = UnivariateGaussianParams::default_for(1);
477        p.init_mean = 3.0;
478        let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
479            &UnivariateGaussian,
480            &p,
481            None,
482            pop(vec![], 0, 0),
483            fitness(vec![]),
484            &device,
485        );
486        let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
487            &UnivariateGaussian,
488            &p,
489            Some(&prior),
490            pop(vec![f32::NAN, 0.0], 2, 1),
491            fitness(vec![0.0, 1.0]),
492            &device,
493        );
494        assert!(state.mean[0].is_finite(), "mean must be finite");
495        approx::assert_relative_eq!(state.mean[0], p.init_mean, epsilon = 1e-12);
496    }
497
498    #[test]
499    fn single_row_variance_floored() {
500        // §7.1: k == 1. Each column's only deviation is from its own value, so
501        // the MLE variance is exactly 0 and must floor to min_variance, while the
502        // mean equals the single row.
503        let device = Default::default();
504        let model = UnivariateGaussian;
505        let p = UnivariateGaussianParams::default_for(2);
506        let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
507            &model,
508            &p,
509            None,
510            pop(vec![], 0, 0),
511            fitness(vec![]),
512            &device,
513        );
514        let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
515            &model,
516            &p,
517            Some(&prior),
518            pop(vec![5.0, -3.0], 1, 2),
519            fitness(vec![0.0]),
520            &device,
521        );
522        approx::assert_relative_eq!(state.mean[0], 5.0, epsilon = 1e-6);
523        approx::assert_relative_eq!(state.mean[1], -3.0, epsilon = 1e-6);
524        for v in &state.variance {
525            approx::assert_relative_eq!(*v, p.min_variance, epsilon = 1e-9);
526        }
527    }
528
529    #[test]
530    fn seeded_sampling_variance_matches_state() {
531        // §7.1: the empirical variance of a large seeded sample must match the
532        // per-dimension variance stored in the state (the mean case is covered by
533        // `seeded_sampling_mean_matches_state`).
534        let device = Default::default();
535        let model = UnivariateGaussian;
536        let state = UnivariateGaussianState {
537            mean: vec![3.0, -1.0],
538            variance: vec![1.0, 0.25],
539        };
540        let mut rng = StdRng::seed_from_u64(321);
541        let n = 20_000_usize;
542        let samples = <UnivariateGaussian as ProbabilityModel<TestBackend>>::sample(
543            &model, &state, n, &mut rng, &device,
544        );
545        let data = samples
546            .into_data()
547            .into_vec::<f32>()
548            .expect("samples host-read of a tensor this test just built");
549        // n = 20_000 fits f64 exactly; the cast is lossless.
550        #[allow(clippy::cast_precision_loss)]
551        let nf = n as f64;
552        for j in 0..2 {
553            let mut sum = 0.0_f64;
554            for i in 0..n {
555                sum += f64::from(data[i * 2 + j]);
556            }
557            let mean = sum / nf;
558            let mut var = 0.0_f64;
559            for i in 0..n {
560                let diff = f64::from(data[i * 2 + j]) - mean;
561                var += diff * diff;
562            }
563            var /= nf;
564            // Empirical variance narrowed to f32 for comparison against the state.
565            #[allow(clippy::cast_possible_truncation)]
566            let var_f32 = var as f32;
567            approx::assert_abs_diff_eq!(var_f32, state.variance()[j], epsilon = 0.05);
568        }
569    }
570
571    #[test]
572    fn refit_overwrites_prev_state() {
573        // §7.1: UMDA is a full refit — `fit` recomputes the MLE from the current
574        // population and never blends with the prior state. A wildly different
575        // `prev` must not leak into the result.
576        let device = Default::default();
577        let model = UnivariateGaussian;
578        let p = UnivariateGaussianParams::default_for(1);
579        let prev = UnivariateGaussianState {
580            mean: vec![100.0],
581            variance: vec![50.0],
582        };
583        // Column [0, 2, 4] → mean 2, var (4+0+4)/3 = 8/3.
584        let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
585            &model,
586            &p,
587            Some(&prev),
588            pop(vec![0.0, 2.0, 4.0], 3, 1),
589            fitness(vec![0.0, 1.0, 2.0]),
590            &device,
591        );
592        approx::assert_relative_eq!(state.mean[0], 2.0, epsilon = 1e-5);
593        approx::assert_relative_eq!(state.variance[0], 8.0 / 3.0, epsilon = 1e-5);
594        // No blend with the prior's mean 100 / variance 50.
595        assert!(
596            (state.mean[0] - 100.0).abs() > 50.0,
597            "refit must overwrite, not blend with prev mean"
598        );
599    }
600
601    use proptest::prelude::*;
602
603    proptest! {
604        // §7.2: `fit` output-shape and variance-floor contract over arbitrary
605        // populations. Host-side tensor ops only (no backend train) → 64 cases.
606        #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
607
608        /// For ANY selected population, the fitted state has both vectors of
609        /// length `genome_dim`, every mean entry finite, and every variance
610        /// entry finite and floored at `min_variance` (never below, never
611        /// non-finite — the §7.1 floor / #129 guards hold universally).
612        ///
613        /// RNG boundary (ADR 0029): proptest generates host config only
614        /// (`data`, `d`); `fit` is a deterministic MLE update and takes no rng.
615        /// The MLE path is only reached with `Some(&prior)` — `None` returns the
616        /// prior and ignores the population, so a prior is fitted first and then
617        /// the generated data is fitted against it.
618        #[test]
619        fn fit_produces_finite_floored_state(
620            data in prop::collection::vec(-1e6f32..1e6f32, 2usize..200),
621            d in 2usize..=8,
622        ) {
623            let device = Default::default();
624            let model = UnivariateGaussian;
625            let params = UnivariateGaussianParams::default_for(d);
626
627            // Take `k = data.len() / d` full rows; reject the rare case where the
628            // generated data is shorter than one row.
629            let k = data.len() / d;
630            prop_assume!(k >= 1);
631            let rows: Vec<f32> = data[..k * d].to_vec();
632            let population = pop(rows, k, d);
633
634            let prior = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
635                &model,
636                &params,
637                None,
638                pop(vec![], 0, 0),
639                fitness(vec![]),
640                &device,
641            );
642            let state = <UnivariateGaussian as ProbabilityModel<TestBackend>>::fit(
643                &model,
644                &params,
645                Some(&prior),
646                population,
647                fitness(vec![0.0_f32; k]),
648                &device,
649            );
650
651            prop_assert_eq!(state.mean().len(), d);
652            prop_assert_eq!(state.variance().len(), d);
653            for &m in state.mean() {
654                prop_assert!(m.is_finite(), "mean entry not finite: {}", m);
655            }
656            for &v in state.variance() {
657                prop_assert!(v.is_finite(), "variance entry not finite: {}", v);
658                prop_assert!(
659                    v >= params.min_variance,
660                    "variance {} below floor {}",
661                    v,
662                    params.min_variance
663                );
664            }
665        }
666    }
667
668    proptest! {
669        // §7.2: `sample` unbiasedness. Backend-heavy (up to 20k draws per case) →
670        // keep case count low and shrinking bounded.
671        #![proptest_config(ProptestConfig {
672            cases: 16,
673            max_shrink_iters: 64,
674            ..ProptestConfig::default()
675        })]
676
677        /// A large seeded sample from a one-dimensional fitted Gaussian has a
678        /// sample mean close to the state's mean `mu`.
679        ///
680        /// RNG boundary (ADR 0029): proptest generates host config only
681        /// (`mu`, `sigma2`, `n`, `seed`); the sampler is seeded via
682        /// `StdRng::seed_from_u64(seed)` (module idiom), never `B::seed` /
683        /// `Tensor::random`.
684        ///
685        /// Flakiness margin: the standard error of the mean is `sigma / sqrt(n)`.
686        /// With `n >= 5000`, `SE <= sigma / sqrt(5000) ≈ 0.01414 * sigma`, so the
687        /// `0.1 * sigma` bound is ≈ `7.07 * SE` at the worst case (and looser for
688        /// larger `n`). At ~7 sigma the per-case failure probability is ≈ 1e-12,
689        /// so 16 cases across arbitrary seeds do not flake.
690        #[test]
691        fn sample_mean_is_unbiased(
692            mu in -10f32..10f32,
693            sigma2 in 1e-3f32..10f32,
694            n in 5_000usize..=20_000,
695            seed in any::<u64>(),
696        ) {
697            let device = Default::default();
698            let model = UnivariateGaussian;
699            let state = UnivariateGaussianState {
700                mean: vec![mu],
701                variance: vec![sigma2],
702            };
703            let mut rng = StdRng::seed_from_u64(seed);
704            let samples = <UnivariateGaussian as ProbabilityModel<TestBackend>>::sample(
705                &model, &state, n, &mut rng, &device,
706            );
707            prop_assert_eq!(samples.dims(), [n, 1]);
708
709            let data = samples
710                .into_data()
711                .into_vec::<f32>()
712                .expect("samples host-read of a tensor this test just built");
713            let sum: f64 = data.iter().map(|&x| f64::from(x)).sum();
714            // `n <= 20_000` is exactly representable in f64; the cast is lossless.
715            #[allow(clippy::cast_precision_loss)]
716            let sample_mean = sum / n as f64;
717
718            let sigma = f64::from(sigma2).sqrt();
719            let bound = 0.1 * sigma;
720            let diff = (sample_mean - f64::from(mu)).abs();
721            prop_assert!(
722                diff < bound,
723                "sample mean {} strayed from mu {} by {} (bound {})",
724                sample_mean,
725                mu,
726                diff,
727                bound
728            );
729        }
730    }
731}