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}