Skip to main content

rlevo_evolution/algorithms/eda/
dependency_chain.rs

1//! Continuous-Gaussian dependency-chain model (MIMIC-style EDA) for continuous
2//! search spaces.
3//!
4//! Unlike [`super::univariate_gaussian`], this model captures *pairwise*
5//! dependencies. [`fit`] estimates per-dimension Gaussians **and** builds a
6//! dimension ordering (chain `c₀ → c₁ → … → c_{D-1}`) that maximises captured
7//! mutual information, then represents the joint as a first-order chain: each
8//! dimension is conditionally Gaussian given its predecessor. [`sample`] walks
9//! the chain, drawing each gene from the conditional Gaussian of its parent's
10//! sampled value.
11//!
12//! The chain is built greedily à la MIMIC (De Bonet et al., 1997): the root
13//! is the dimension with the smallest marginal standard deviation (lowest
14//! marginal entropy), and each subsequent link is the unvisited dimension with
15//! the highest mutual information to the last chosen one.
16//!
17//! The `fitness` tensor is accepted by the [`ProbabilityModel`] interface but
18//! always ignored; the fit is unweighted.
19//!
20//! # Estimator regularisation
21//!
22//! Sample Pearson correlations from `k` selected rows have a standard error
23//! of approximately `1/√k` under the null hypothesis of independence. Treating
24//! those spurious correlations as real dependency injects noise into every
25//! conditional mean — a penalty the univariate model never pays. To suppress
26//! this effect, any Pearson `|r| < 2/√k` is zeroed before the chain is built,
27//! causing the affected link to degenerate to independent marginal sampling
28//! exactly where no statistically detectable dependency exists. Correlations
29//! that survive this threshold are clamped to `[−0.9999, 0.9999]` to keep
30//! conditional variances positive.
31//!
32//! # Complexity
33//!
34//! [`fit`] is `O(k · D²)`: it forms the full `D × D` mutual-information matrix
35//! from the `k` selected rows and greedily orders the `D` dimensions.
36//! [`sample`] is `O(D)` per individual: one conditional Gaussian draw per
37//! chain link.
38//!
39//! # References
40//!
41//! - De Bonet, Isbell & Viola (1997), *MIMIC: Finding optima by estimating
42//!   probability densities*.
43//!
44//! [`fit`]: crate::ProbabilityModel::fit
45//! [`sample`]: crate::ProbabilityModel::sample
46
47use burn::tensor::{Tensor, TensorData, backend::Backend};
48use rand::Rng;
49use rand_distr::{Distribution as _, Normal};
50
51use crate::probability_model::ProbabilityModel;
52
53/// Per-run configuration for the [`DependencyChain`] model.
54///
55/// Held inside [`EdaParams::model`](crate::algorithms::eda::EdaParams::model)
56/// for the lifetime of a run. Use [`DependencyChainParams::default_for`] for
57/// typical continuous-optimisation defaults.
58#[derive(Debug, Clone)]
59pub struct DependencyChainParams {
60    /// Number of genes per genome; determines the length of all vectors in
61    /// [`DependencyChainState`] and the chain dimension `D`.
62    pub genome_dim: usize,
63    /// Prior mean for every dimension, used when `prev = None`.
64    pub init_mean: f32,
65    /// Prior standard deviation for every dimension, used when `prev = None`.
66    pub init_std: f32,
67    /// Minimum per-dimension variance; prevents the model from collapsing to a
68    /// point mass and keeps conditional standard deviations positive.
69    pub min_variance: f32,
70}
71
72impl DependencyChainParams {
73    /// Sensible defaults for a `genome_dim`-dimensional continuous problem.
74    #[must_use]
75    pub fn default_for(genome_dim: usize) -> Self {
76        Self {
77            genome_dim,
78            init_mean: 0.0,
79            init_std: 2.0,
80            min_variance: 1e-6,
81        }
82    }
83}
84
85/// Fitted state for the [`DependencyChain`] model after one call to
86/// [`ProbabilityModel::fit`].
87///
88/// All four vectors have length `genome_dim` and are indexed by **dimension**
89/// (not chain position), except `chain` which is indexed by chain position.
90/// On the prior path (`prev = None`) the chain is the natural order
91/// `[0, 1, …, D-1]`, all means are `init_mean`, all standard deviations are
92/// `init_std`, and all `link_corr` entries are `0.0`.
93#[derive(Debug, Clone)]
94pub struct DependencyChainState {
95    /// Dimension permutation: `chain[t]` is the dimension index sampled at
96    /// chain position `t`. `chain[0]` is the root (marginal Gaussian).
97    pub chain: Vec<usize>,
98    /// Per-dimension MLE mean (indexed by dimension, not chain position).
99    pub mean: Vec<f32>,
100    /// Per-dimension standard deviation, floored at
101    /// [`DependencyChainParams::min_variance`]`.sqrt()` (indexed by
102    /// dimension).
103    pub std: Vec<f32>,
104    /// Pearson correlation of dimension `d` with its chain parent, after the
105    /// `|r| < 2/√k` significance filter and `[-0.9999, 0.9999]` clamp.
106    /// The root dimension's entry is `0.0` (unused in sampling).
107    pub link_corr: Vec<f32>,
108}
109
110/// MIMIC-style dependency-chain model for continuous spaces.
111///
112/// Implements [`ProbabilityModel`] by fitting a greedy first-order dependency
113/// chain over dimensions (see [module docs](self) for the algorithm, estimator
114/// regularisation, and references). Fitness is accepted but ignored; the fit
115/// is always unweighted.
116///
117/// [`fit`](ProbabilityModel::fit) is `O(k · D²)`; [`sample`](ProbabilityModel::sample)
118/// is `O(D)` per individual.
119#[derive(Debug, Clone, Copy, Default)]
120pub struct DependencyChain;
121
122impl<B: Backend> ProbabilityModel<B> for DependencyChain {
123    type Params = DependencyChainParams;
124    type State = DependencyChainState;
125
126    /// Fit the dependency-chain model to the selected population.
127    ///
128    /// When `prev = None` returns the prior (natural-order chain, uniform
129    /// `init_mean` / `init_std`, zero correlations). Otherwise:
130    ///
131    /// 1. Computes per-dimension MLE means and standard deviations
132    ///    (`÷k` variance, floored at `min_variance`).
133    /// 2. Builds the full `D × D` Pearson correlation matrix.
134    /// 3. Applies the `|r| < 2/√k` significance filter (see [module docs](self)
135    ///    for the estimator regularisation rationale) and clamps surviving
136    ///    correlations to `[−0.9999, 0.9999]`.
137    /// 4. Converts to mutual information `MI = −0.5 · ln(1 − r²)`.
138    /// 5. Builds the chain greedily: root = minimum-σ dimension, each
139    ///    subsequent link = unvisited dimension with the highest MI to the
140    ///    last chosen one.
141    ///
142    /// The `fitness` argument is accepted but always ignored.
143    ///
144    /// # Panics
145    ///
146    /// Panics if the `population` tensor cannot be read back as `f32`
147    /// (`.expect("population tensor must be readable as f32")`), or with an
148    /// out-of-bounds index if the host buffer is shorter than `k * d`. Callers
149    /// must therefore pass an `f32`, `(k, d)`-shaped population tensor. The
150    /// `unwrap()` on the last greedy-chain iteration does not fire: at least one
151    /// unvisited dimension remains when `d > 1`.
152    // The MI matrix, greedy chain ordering, and per-link conditional
153    // extraction form one coherent algorithmic unit; splitting it would
154    // scatter the shared intermediate buffers without aiding readability.
155    #[allow(clippy::too_many_lines)]
156    fn fit(
157        &self,
158        params: &Self::Params,
159        prev: Option<&Self::State>,
160        population: Tensor<B, 2>,
161        fitness: Tensor<B, 1>,
162        device: &<B as burn::tensor::backend::BackendTypes>::Device,
163    ) -> Self::State {
164        let _ = device;
165        // Fitness is accepted but ignored: the fit is unweighted.
166        let _ = fitness;
167        let Some(_prev) = prev else {
168            // Prior path: independent dimensions in natural order; population
169            // and fitness ignored.
170            let d = params.genome_dim;
171            return DependencyChainState {
172                chain: (0..d).collect(),
173                mean: vec![params.init_mean; d],
174                std: vec![params.init_std; d],
175                link_corr: vec![0.0; d],
176            };
177        };
178
179        let [k, d] = population.dims();
180        if k < 2 {
181            // Correlation is unidentifiable from fewer than two rows: `kf` would
182            // be `0`/`1`, driving `/= kf` to `NaN`/degenerate stats that then
183            // poison `std`/`link_corr` and later panic in `sample`. Return the
184            // prior-shaped state (independent dimensions) to keep the run alive.
185            // `EdaStrategy::tell` clamps `k ≥ 2`, but `fit` is a public trait
186            // method reachable directly with a `0×D`/`1×D` population.
187            return DependencyChainState {
188                chain: (0..d).collect(),
189                mean: vec![params.init_mean; d],
190                std: vec![params.init_std; d],
191                link_corr: vec![0.0; d],
192            };
193        }
194        let rows = population
195            .into_data()
196            .into_vec::<f32>()
197            .expect("population tensor must be readable as f32");
198        // k is a selected-population count, far below f32's 2^24 exact-integer
199        // limit; the cast is lossless in practice.
200        #[allow(clippy::cast_precision_loss)]
201        let kf = k as f32;
202
203        // Column means.
204        let mut mean = vec![0.0_f32; d];
205        for i in 0..k {
206            for j in 0..d {
207                mean[j] += rows[i * d + j];
208            }
209        }
210        for m in &mut mean {
211            *m /= kf;
212        }
213
214        // Raw MLE variances (unfloored) for the correlation guard, plus the
215        // floored std stored in state.
216        let mut raw_var = vec![0.0_f32; d];
217        for i in 0..k {
218            for j in 0..d {
219                let diff = rows[i * d + j] - mean[j];
220                raw_var[j] += diff * diff;
221            }
222        }
223        for v in &mut raw_var {
224            *v /= kf;
225        }
226        let std: Vec<f32> = raw_var
227            .iter()
228            .map(|&v| v.max(params.min_variance).sqrt())
229            .collect();
230
231        // Pairwise covariances → Pearson correlations.
232        // cov[a][b] = Σ (x_a - μ_a)(x_b - μ_b) / k.
233        let mut cov = vec![0.0_f32; d * d];
234        for i in 0..k {
235            for a in 0..d {
236                let da = rows[i * d + a] - mean[a];
237                for b in 0..d {
238                    let db = rows[i * d + b] - mean[b];
239                    cov[a * d + b] += da * db;
240                }
241            }
242        }
243        for c in &mut cov {
244            *c /= kf;
245        }
246
247        // r[a][b] = cov / (raw_σ_a · raw_σ_b); guarded and clamped.
248        //
249        // Sample correlations from k rows are noisy with std ≈ 1/√k under
250        // independence; conditioning the chain on spurious correlations
251        // injects that noise into every conditional mean — a penalty a
252        // univariate model never pays. Estimates below the ~2σ significance
253        // threshold are therefore zeroed, so the chain degenerates to
254        // independent sampling exactly where no dependency is detectable.
255        let significance = 2.0 / kf.sqrt();
256        let mut corr = vec![0.0_f32; d * d];
257        // Mutual information MI[a][b] = -0.5 ln(1 - r²); computed explicitly
258        // for fidelity though it is monotone in r².
259        let mut mi = vec![0.0_f32; d * d];
260        for a in 0..d {
261            for b in 0..d {
262                let r = if raw_var[a] < params.min_variance || raw_var[b] < params.min_variance {
263                    0.0
264                } else {
265                    let raw = cov[a * d + b] / (raw_var[a].sqrt() * raw_var[b].sqrt());
266                    if raw.abs() < significance {
267                        0.0
268                    } else {
269                        raw.clamp(-0.9999, 0.9999)
270                    }
271                };
272                corr[a * d + b] = r;
273                mi[a * d + b] = -0.5 * (1.0 - r * r).ln();
274            }
275        }
276
277        // NOTE: the sentinels in this structure-learning routine are about
278        // marginal entropy (σ) and mutual information, NOT objective fitness.
279        // They are independent of the crate's maximise convention — do not
280        // "fix" them to match it.
281        //
282        // Root: smallest floored std (Gaussian entropy is monotone in σ, so the
283        // lowest-σ dimension has the smallest marginal entropy); tie → lowest
284        // index.
285        let mut root = 0_usize;
286        let mut root_std = f32::INFINITY;
287        for (j, &sj) in std.iter().enumerate() {
288            if sj < root_std {
289                root_std = sj;
290                root = j;
291            }
292        }
293
294        // Greedy chain: append the unvisited dimension with maximal MI to the
295        // last chosen one; tie → lowest index.
296        let mut visited = vec![false; d];
297        let mut chain = Vec::with_capacity(d);
298        chain.push(root);
299        visited[root] = true;
300        for _ in 1..d {
301            let last = *chain.last().unwrap();
302            let mut best_j = usize::MAX;
303            let mut best_mi = f32::NEG_INFINITY;
304            for j in 0..d {
305                if visited[j] {
306                    continue;
307                }
308                if mi[last * d + j] > best_mi {
309                    best_mi = mi[last * d + j];
310                    best_j = j;
311                }
312            }
313            chain.push(best_j);
314            visited[best_j] = true;
315        }
316
317        // link_corr[chain[t]] = r[chain[t-1]][chain[t]]; root entry stays 0.
318        let mut link_corr = vec![0.0_f32; d];
319        for t in 1..chain.len() {
320            let parent = chain[t - 1];
321            let cur = chain[t];
322            link_corr[cur] = corr[parent * d + cur];
323        }
324
325        DependencyChainState {
326            chain,
327            mean,
328            std,
329            link_corr,
330        }
331    }
332
333    /// Draw `n` genomes by ancestral sampling along the fitted chain.
334    ///
335    /// The root dimension is sampled from its marginal Gaussian; each
336    /// subsequent dimension is sampled from the conditional Gaussian given its
337    /// parent's sampled value:
338    ///
339    /// ```text
340    /// μ_cond = μ_c + r · (σ_c / σ_p) · (x_parent − μ_p)
341    /// σ_cond = σ_c · √(1 − r²)
342    /// ```
343    ///
344    /// All randomness is drawn from `rng` (host RNG only; never
345    /// `Tensor::random` / `B::seed`). The returned tensor has shape `(n, D)`.
346    /// This is `O(D)` per individual drawn.
347    ///
348    /// # Panics
349    ///
350    /// Does not panic under normal operation. The `Normal::new` calls are
351    /// guarded: `σ_c` is floored at `min_variance.sqrt()` during `fit`, and
352    /// `r` is clamped to `[-0.9999, 0.9999]` so `1 − r² ≥ 0.0002 > 0`.
353    fn sample(
354        &self,
355        state: &Self::State,
356        n: usize,
357        rng: &mut dyn Rng,
358        device: &<B as burn::tensor::backend::BackendTypes>::Device,
359    ) -> Tensor<B, 2> {
360        let d = state.mean.len();
361        let mut rows = vec![0.0_f32; n * d];
362        for i in 0..n {
363            let base = i * d;
364            // Root: marginal Gaussian.
365            let root = state.chain[0];
366            let root_normal = Normal::new(state.mean[root], state.std[root])
367                .expect("floored std is positive and finite");
368            rows[base + root] = root_normal.sample(rng);
369            // Subsequent links: conditional Gaussian given the chain parent.
370            for t in 1..state.chain.len() {
371                let parent = state.chain[t - 1];
372                let cur = state.chain[t];
373                let r = state.link_corr[cur];
374                let mu_c = state.mean[cur];
375                let mu_p = state.mean[parent];
376                let sigma_c = state.std[cur];
377                let sigma_p = state.std[parent]; // > 0 by floor.
378                let cond_mean = mu_c + r * (sigma_c / sigma_p) * (rows[base + parent] - mu_p);
379                // 1 - r² ≥ 1 - 0.9999² > 0.
380                let cond_std = (sigma_c * sigma_c * (1.0 - r * r)).sqrt();
381                // `Normal::new` rejects a non-finite std but accepts any mean, so
382                // an overflowed `cond_mean` (large parent value × near-1 `r`)
383                // would silently emit `NaN` samples and poison the next
384                // generation. If either parameter is non-finite, fall back to the
385                // marginal Gaussian of `cur` — the distribution the link
386                // degenerates to at `r = 0`.
387                rows[base + cur] =
388                    if cond_mean.is_finite() && cond_std.is_finite() && cond_std > 0.0 {
389                        Normal::new(cond_mean, cond_std)
390                            .expect("guarded: conditional std positive and finite")
391                            .sample(rng)
392                    } else {
393                        Normal::new(mu_c, sigma_c)
394                            .expect("floored marginal std is positive and finite")
395                            .sample(rng)
396                    };
397            }
398        }
399        Tensor::<B, 2>::from_data(TensorData::new(rows, [n, d]), device)
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use burn::backend::Flex;
407    use rand::SeedableRng;
408    use rand::rngs::StdRng;
409
410    type TestBackend = Flex;
411
412    fn pop(rows: Vec<f32>, n: usize, d: usize) -> Tensor<TestBackend, 2> {
413        let device = Default::default();
414        Tensor::<TestBackend, 2>::from_data(TensorData::new(rows, [n, d]), &device)
415    }
416
417    fn fitness(values: Vec<f32>) -> Tensor<TestBackend, 1> {
418        let device = Default::default();
419        let n = values.len();
420        Tensor::<TestBackend, 1>::from_data(TensorData::new(values, [n]), &device)
421    }
422
423    fn fit_prior(p: &DependencyChainParams) -> DependencyChainState {
424        let device = Default::default();
425        <DependencyChain as ProbabilityModel<TestBackend>>::fit(
426            &DependencyChain,
427            p,
428            None,
429            pop(vec![], 0, 0),
430            fitness(vec![]),
431            &device,
432        )
433    }
434
435    fn refit(
436        p: &DependencyChainParams,
437        rows: Vec<f32>,
438        n: usize,
439        d: usize,
440    ) -> DependencyChainState {
441        let device = Default::default();
442        let prior = fit_prior(p);
443        // Test row counts are tiny; the cast is lossless.
444        #[allow(clippy::cast_precision_loss)]
445        let fit_values: Vec<f32> = (0..n).map(|i| i as f32).collect();
446        <DependencyChain as ProbabilityModel<TestBackend>>::fit(
447            &DependencyChain,
448            p,
449            Some(&prior),
450            pop(rows, n, d),
451            fitness(fit_values),
452            &device,
453        )
454    }
455
456    #[test]
457    fn prior_is_natural_order_independent() {
458        let p = DependencyChainParams::default_for(3);
459        let state = fit_prior(&p);
460        assert_eq!(state.chain, vec![0, 1, 2]);
461        assert_eq!(state.mean, vec![0.0, 0.0, 0.0]);
462        assert_eq!(state.std, vec![2.0, 2.0, 2.0]);
463        assert_eq!(state.link_corr, vec![0.0, 0.0, 0.0]);
464    }
465
466    #[test]
467    fn chain_links_correlated_dimensions_adjacently() {
468        // x0 spread; x1 = x0 + tiny noise (strongly correlated with x0);
469        // x2 independent. The chain should place 0 and 1 adjacently.
470        let p = DependencyChainParams::default_for(3);
471        let rows = vec![
472            -2.0, -2.01, 5.0, //
473            -1.0, -0.99, -3.0, //
474            0.0, 0.01, 1.0, //
475            1.0, 1.02, -4.0, //
476            2.0, 1.98, 0.5, //
477        ];
478        let state = refit(&p, rows, 5, 3);
479        // Find positions of dims 0 and 1 in the chain; they must be adjacent.
480        let pos0 = state.chain.iter().position(|&x| x == 0).unwrap();
481        let pos1 = state.chain.iter().position(|&x| x == 1).unwrap();
482        assert_eq!(
483            pos0.abs_diff(pos1),
484            1,
485            "dims 0 and 1 should be adjacent in chain {:?}",
486            state.chain
487        );
488        // Whichever of 0/1 is the child (later in the chain) carries a high
489        // link correlation.
490        let child = usize::from(pos0 <= pos1);
491        assert!(
492            state.link_corr[child].abs() > 0.99,
493            "expected strong link corr, got {}",
494            state.link_corr[child]
495        );
496    }
497
498    #[test]
499    fn zero_variance_column_yields_zero_correlation() {
500        let p = DependencyChainParams::default_for(2);
501        // Column 1 is constant → raw variance 0 → r guarded to 0.
502        let rows = vec![0.0, 5.0, 1.0, 5.0, 2.0, 5.0, 3.0, 5.0];
503        let state = refit(&p, rows, 4, 2);
504        for &r in &state.link_corr {
505            approx::assert_relative_eq!(r, 0.0, epsilon = 1e-6);
506        }
507    }
508
509    #[test]
510    fn perfect_correlation_is_clamped() {
511        let p = DependencyChainParams::default_for(2);
512        // Column 1 is an exact copy of column 0 → r would be 1, clamped to
513        // 0.9999.
514        let rows = vec![0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 3.0, 3.0];
515        let state = refit(&p, rows, 4, 2);
516        let child = *state.chain.last().unwrap();
517        assert!(state.link_corr[child].abs() <= 0.9999 + 1e-6);
518        assert!(state.link_corr[child].abs() > 0.99);
519    }
520
521    #[test]
522    fn link_corr_matches_expected_pearson() {
523        let p = DependencyChainParams::default_for(2);
524        // Column 1 = 2 * column 0 → Pearson r = 1, clamped to 0.9999.
525        let rows = vec![-2.0, -4.0, -1.0, -2.0, 0.0, 0.0, 1.0, 2.0, 2.0, 4.0];
526        let state = refit(&p, rows, 5, 2);
527        let child = *state.chain.last().unwrap();
528        approx::assert_relative_eq!(state.link_corr[child], 0.9999, epsilon = 1e-3);
529    }
530
531    #[test]
532    fn sampling_respects_chain_correlation() {
533        // Two strongly-correlated dimensions → sampled values must track.
534        let p = DependencyChainParams::default_for(2);
535        let rows = vec![-2.0, -2.0, -1.0, -1.0, 0.0, 0.0, 1.0, 1.0, 2.0, 2.0];
536        let state = refit(&p, rows, 5, 2);
537        let device = Default::default();
538        let mut rng = StdRng::seed_from_u64(99);
539        let n = 5_000;
540        let samples = <DependencyChain as ProbabilityModel<TestBackend>>::sample(
541            &DependencyChain,
542            &state,
543            n,
544            &mut rng,
545            &device,
546        );
547        let data = samples
548            .into_data()
549            .into_vec::<f32>()
550            .expect("samples host-read of a tensor this test just built");
551        // Pearson correlation of sampled columns 0 and 1.
552        let mut s0 = 0.0_f64;
553        let mut s1 = 0.0_f64;
554        for i in 0..n {
555            s0 += f64::from(data[i * 2]);
556            s1 += f64::from(data[i * 2 + 1]);
557        }
558        // n = 5_000 fits f64 exactly; the cast is lossless here.
559        #[allow(clippy::cast_precision_loss)]
560        let nf = n as f64;
561        let m0 = s0 / nf;
562        let m1 = s1 / nf;
563        let (mut cov, mut v0, mut v1) = (0.0_f64, 0.0_f64, 0.0_f64);
564        for i in 0..n {
565            let a = f64::from(data[i * 2]) - m0;
566            let b = f64::from(data[i * 2 + 1]) - m1;
567            cov += a * b;
568            v0 += a * a;
569            v1 += b * b;
570        }
571        let corr = cov / (v0.sqrt() * v1.sqrt());
572        assert!(corr > 0.9, "sampled correlation too low: {corr}");
573    }
574
575    #[test]
576    fn two_fits_same_data_identical_state() {
577        let p = DependencyChainParams::default_for(3);
578        let rows = vec![
579            -2.0, 1.0, 0.5, //
580            -1.0, 2.0, -0.5, //
581            0.0, 0.0, 1.0, //
582            1.0, -1.0, -1.0, //
583        ];
584        let a = refit(&p, rows.clone(), 4, 3);
585        let b = refit(&p, rows, 4, 3);
586        assert_eq!(a.chain, b.chain);
587        assert_eq!(a.mean, b.mean);
588        assert_eq!(a.std, b.std);
589        assert_eq!(a.link_corr, b.link_corr);
590    }
591
592    #[test]
593    fn fit_k_less_than_two_returns_prior() {
594        // n = 1 (k = 1): correlation is unidentifiable and `/= kf` would poison
595        // the state with NaN. The guard (#129) returns the prior-shaped state.
596        let p = DependencyChainParams::default_for(2);
597        let state = refit(&p, vec![1.0, 2.0], 1, 2);
598        assert_eq!(state.chain, vec![0, 1]);
599        assert_eq!(state.mean, vec![p.init_mean, p.init_mean]);
600        assert_eq!(state.std, vec![p.init_std, p.init_std]);
601        assert_eq!(state.link_corr, vec![0.0, 0.0]);
602    }
603
604    #[test]
605    fn sample_with_degenerate_link_stays_finite() {
606        // A pathological state whose conditional link overflows (σ_c/σ_p → inf):
607        // the sample() guard (#129) must fall back to the marginal Gaussian
608        // rather than emit NaN/inf into the population.
609        let device = Default::default();
610        let state = DependencyChainState {
611            chain: vec![0, 1],
612            mean: vec![0.0, 0.0],
613            std: vec![1e-30, 1e30],
614            link_corr: vec![0.0, 0.9999],
615        };
616        let mut rng = StdRng::seed_from_u64(7);
617        let samples = <DependencyChain as ProbabilityModel<TestBackend>>::sample(
618            &DependencyChain,
619            &state,
620            16,
621            &mut rng,
622            &device,
623        );
624        for v in samples
625            .into_data()
626            .into_vec::<f32>()
627            .expect("samples host-read of a tensor this test just built")
628        {
629            assert!(
630                v.is_finite(),
631                "degenerate link must yield finite samples, got {v}"
632            );
633        }
634    }
635
636    #[test]
637    fn single_dimension_sample_stays_finite() {
638        // §7.1: genome_dim == 1. The chain is a single root node (no links);
639        // sampling must draw finite marginal Gaussian values.
640        let p = DependencyChainParams::default_for(1);
641        let state = refit(&p, vec![1.0, 2.0, 3.0, 4.0], 4, 1);
642        assert_eq!(state.chain, vec![0], "single-dim chain is the root only");
643        let device = Default::default();
644        let mut rng = StdRng::seed_from_u64(3);
645        let samples = <DependencyChain as ProbabilityModel<TestBackend>>::sample(
646            &DependencyChain,
647            &state,
648            256,
649            &mut rng,
650            &device,
651        );
652        for v in samples
653            .into_data()
654            .into_vec::<f32>()
655            .expect("samples host-read of a tensor this test just built")
656        {
657            assert!(v.is_finite(), "single-dim sample must be finite, got {v}");
658        }
659    }
660
661    #[test]
662    fn sample_is_deterministic_for_seed_and_state() {
663        // §7.1: same seed + same fitted state ⇒ byte-identical sample tensor.
664        let p = DependencyChainParams::default_for(3);
665        let rows = vec![
666            -2.0, 1.0, 0.5, //
667            -1.0, 2.0, -0.5, //
668            0.0, 0.0, 1.0, //
669            1.0, -1.0, -1.0, //
670        ];
671        let state = refit(&p, rows, 4, 3);
672        let device = Default::default();
673        let mut rng_a = StdRng::seed_from_u64(555);
674        let mut rng_b = StdRng::seed_from_u64(555);
675        let a = <DependencyChain as ProbabilityModel<TestBackend>>::sample(
676            &DependencyChain,
677            &state,
678            300,
679            &mut rng_a,
680            &device,
681        );
682        let b = <DependencyChain as ProbabilityModel<TestBackend>>::sample(
683            &DependencyChain,
684            &state,
685            300,
686            &mut rng_b,
687            &device,
688        );
689        let data_a = a
690            .into_data()
691            .into_vec::<f32>()
692            .expect("samples host-read of a tensor this test just built");
693        let data_b = b
694            .into_data()
695            .into_vec::<f32>()
696            .expect("samples host-read of a tensor this test just built");
697        assert_eq!(
698            data_a, data_b,
699            "same seed + state must produce identical output"
700        );
701    }
702}