Skip to main content

rlevo_evolution/algorithms/eda/
bayesian_network.rs

1//! Bayesian-network model (BOA — Bayesian Optimization Algorithm) for binary
2//! search spaces.
3//!
4//! Unlike the univariate binary models ([`super::univariate_bernoulli`],
5//! [`super::compact_genetic`]) and the first-order chain of
6//! [`super::dependency_chain`], this model learns an arbitrary-topology directed
7//! acyclic graph (DAG) over the binary genes, bounded to at most
8//! [`BayesianNetworkParams::max_parents`] parents per node. [`fit`] greedily
9//! constructs the network by adding the single edge with the highest score gain
10//! each round; [`sample`] performs ancestral sampling along a topological order,
11//! drawing each gene from its conditional probability table (CPT) given the
12//! already-sampled parent configuration.
13//!
14//! The chain is built à la BOA (Pelikan, Goldberg & Cantú-Paz, 1999): starting
15//! from an edgeless network, the algorithm repeatedly scores every candidate
16//! edge `u → v` and commits the one with the largest strictly-positive gain,
17//! subject to the `max_parents` cap and an acyclicity check, until no profitable
18//! edge remains.
19//!
20//! The `fitness` tensor is accepted by the [`ProbabilityModel`] interface but
21//! always ignored; the fit is unweighted (the MIMIC precedent).
22//!
23//! # Non-incremental fit
24//!
25//! `prev` is consumed only as the *not-bootstrap* signal: when `prev = Some(_)`
26//! the whole network — structure and CPTs — is relearned from scratch from the
27//! current generation's selected rows. Canonical BOA carries no cross-generation
28//! state, so the previous [`BayesianNetworkState`] is never read. The `Some`
29//! arm exists purely to distinguish the learning path from the
30//! [`params`](BayesianNetworkParams)-only prior path.
31//!
32//! # Structure score: BIC
33//!
34//! Edges are scored with the Bayesian Information Criterion (BIC). For a node
35//! `v` with sorted parent set `Pa` (`q = |Pa|`), let `N(c, x)` be the number of
36//! selected rows in which the parents take packed configuration `c` and gene `v`
37//! takes bit `x ∈ {0, 1}`, and `N(c) = N(c, 0) + N(c, 1)`:
38//!
39//! ```text
40//! score(v, Pa) = Σ_c Σ_x  N(c, x) · ln( N(c, x) / N(c) )  −  (ln(n) / 2) · 2^q
41//! ```
42//!
43//! The log-likelihood term rewards parents that make `v` more predictable; the
44//! `−½·ln(n)·2^q` complexity term penalises CPT size (`2^q` cells), which grows
45//! exponentially in the parent count. This penalty is the structural analogue
46//! of [`super::dependency_chain`]'s `|r| < 2/√k` significance filter: both
47//! suppress spurious dependencies that a univariate model would never pay for,
48//! here by requiring an edge's likelihood improvement to outweigh the cost of
49//! doubling the child's CPT. The score is computed on **raw maximum-likelihood
50//! counts** — never the Laplace-smoothed counts used for CPT estimation — so the
51//! penalty is the sole overfitting guard. Terms with `N(c, x) = 0` contribute
52//! exactly `0` (the `p·ln p → 0` limit), and configurations with `N(c) = 0`
53//! contribute `0` likelihood while still counting toward the `2^q` penalty. All
54//! scoring arithmetic is performed in `f64`.
55//!
56//! # Parent-configuration bit-packing
57//!
58//! For a node `v` with `parents[v]` sorted ascending, a row's parent
59//! configuration is packed as `config = Σ_j bit(gene[parents[v][j]]) << j`:
60//! parent `j` (in sorted order) contributes bit `j`. [`fit`] and [`sample`] use
61//! the identical packing, so the CPT index computed at sampling time matches the
62//! one used during estimation. See the [`cpt`](BayesianNetworkState::cpt) field.
63//!
64//! # Complexity
65//!
66//! [`fit`] is `O(D² · N · κ)` per generation with gain caching: the first sweep
67//! scores all `D²` candidate edges (each an `O(N·κ)` counting pass), and after
68//! each accepted edge only the `D` entries sharing the affected child are
69//! rescored. It is fully host-side and sequential. [`sample`] is `O(D)` per
70//! drawn individual: one conditional Bernoulli draw per gene.
71//!
72//! # Binary gene convention
73//!
74//! Genes are emitted as raw `{0, 1}` `f32` values, so
75//! [`EdaParams::bounds`](crate::algorithms::eda::EdaParams::bounds) clamps are a
76//! documented no-op (the PBIL / cGA precedent).
77//!
78//! # References
79//!
80//! - Pelikan, Goldberg & Cantú-Paz (1999), *BOA: The Bayesian optimization
81//!   algorithm*.
82//!
83//! [`fit`]: crate::ProbabilityModel::fit
84//! [`sample`]: crate::ProbabilityModel::sample
85
86use burn::tensor::{Tensor, TensorData, backend::Backend};
87use rand::{Rng, RngExt};
88
89use crate::probability_model::ProbabilityModel;
90
91/// Per-run configuration for the [`BayesianNetwork`] model.
92///
93/// Held inside [`EdaParams::model`](crate::algorithms::eda::EdaParams::model)
94/// for the lifetime of a run. Use [`BayesianNetworkParams::default_for`] for
95/// typical binary-optimisation defaults.
96#[derive(Debug, Clone)]
97pub struct BayesianNetworkParams {
98    /// Number of bits per genome; the number of nodes `D` in the network and
99    /// the length of [`BayesianNetworkState::order`] and
100    /// [`BayesianNetworkState::parents`].
101    pub genome_dim: usize,
102    /// Maximum number of parents per node (`κ`); bounds each node's CPT to
103    /// `2^κ` cells and caps the greedy edge-addition search.
104    pub max_parents: usize,
105    /// Prior marginal probability of a `1` gene, used to seed every edgeless
106    /// CPT on the prior path (`prev = None`).
107    pub init_prob: f32,
108    /// Laplace pseudo-count `s` added per CPT cell during estimation; `s ≥ 1`
109    /// keeps every probability strictly inside `(0, 1)`. Applies only to CPT
110    /// estimation for sampling, never to the BIC structure score. The value is
111    /// floored to `1` inside [`fit`](ProbabilityModel::fit), so a supplied `0`
112    /// is treated as `1` to uphold the strictly-interior guarantee.
113    pub smoothing_count: usize,
114}
115
116impl BayesianNetworkParams {
117    /// Sensible BOA defaults for a `genome_dim`-bit problem.
118    #[must_use]
119    pub fn default_for(genome_dim: usize) -> Self {
120        Self {
121            genome_dim,
122            max_parents: 3,
123            init_prob: 0.5,
124            smoothing_count: 1,
125        }
126    }
127}
128
129/// Fitted state for the [`BayesianNetwork`] model after one call to
130/// [`ProbabilityModel::fit`].
131///
132/// On the prior path (`prev = None`) the network is edgeless: `order` is the
133/// natural order `[0, 1, …, D-1]`, every `parents[v]` is empty, and every
134/// `cpt[v]` is the single-entry vector `[init_prob]`.
135#[derive(Debug, Clone)]
136pub struct BayesianNetworkState {
137    /// Topological sampling order: a permutation of `0..D` such that every
138    /// node appears after all of its parents. Ancestral sampling walks this
139    /// order so each gene's parents are already drawn.
140    pub order: Vec<usize>,
141    /// `parents[node]` is the node's parent index set, kept sorted ascending.
142    /// The sort defines the bit positions used by the CPT packing (see the
143    /// [module docs](self)).
144    pub parents: Vec<Vec<usize>>,
145    /// Conditional probability tables: `cpt[node][config]` is
146    /// `P(node = 1 | parents = config)`, where `config` is the bit-packed
147    /// parent configuration `Σ_j bit(parent_j) << j` over `parents[node]` in
148    /// sorted order. Each inner vector has length `2^|parents[node]|`.
149    pub cpt: Vec<Vec<f32>>,
150}
151
152/// Bayesian-network model for binary spaces (BOA).
153///
154/// Implements [`ProbabilityModel`] by greedily learning a bounded-in-degree DAG
155/// over the binary genes with a BIC structure score, then ancestral-sampling
156/// from the fitted CPTs (see the [module docs](self) for the algorithm, the BIC
157/// rationale, bit-packing, and references). Fitness is accepted but ignored; the
158/// fit is always unweighted and non-incremental.
159///
160/// [`fit`](ProbabilityModel::fit) is `O(D² · N · κ)`;
161/// [`sample`](ProbabilityModel::sample) is `O(D)` per individual.
162#[derive(Debug, Clone, Copy, Default)]
163pub struct BayesianNetwork;
164
165/// Build the edgeless prior state: natural order, no parents, single-cell CPTs
166/// initialised to `init_prob`.
167///
168/// `init_prob` is clamped into the open interior `(0, 1)` before it seeds the
169/// CPTs. This is the single chokepoint for every prior return, so a
170/// misconfigured or non-finite `init_prob` (e.g. `NaN`, `1.5`, `-0.3`) cannot
171/// silently produce a degenerate population during sampling. `NaN` maps to the
172/// neutral `0.5` (`f32::clamp` would *propagate* `NaN`); `±inf` clamp to the
173/// interior bounds.
174fn prior_state(d: usize, init_prob: f32) -> BayesianNetworkState {
175    let p = if init_prob.is_nan() {
176        0.5
177    } else {
178        init_prob.clamp(1e-6, 1.0 - 1e-6)
179    };
180    BayesianNetworkState {
181        order: (0..d).collect(),
182        parents: vec![Vec::new(); d],
183        cpt: vec![vec![p]; d],
184    }
185}
186
187/// Pack the parent configuration for node `v` from a single row's bits.
188///
189/// `parents` is `parents[v]` (sorted ascending); parent `j` contributes bit `j`.
190fn pack_config(bits: &[u8], row_base: usize, parents: &[usize]) -> usize {
191    let mut config = 0usize;
192    for (j, &p) in parents.iter().enumerate() {
193        if bits[row_base + p] == 1 {
194            config |= 1 << j;
195        }
196    }
197    config
198}
199
200/// BIC score of node `v` given the (sorted) candidate parent set `parents`.
201///
202/// Single pass over the `n` rows: pack each row's parent config and increment
203/// `counts[config * 2 + bit_v]`. The likelihood term sums
204/// `N(c, x) · ln(N(c, x) / N(c))` over occupied cells (zero counts skipped), and
205/// the `½·ln(n)·2^q` complexity penalty applies regardless of which configs were
206/// observed. All arithmetic is `f64`; scores use raw MLE counts.
207//
208// Single-char math names (n, d, v, q, c, x) mirror the BIC formula and the
209// sibling EDA models; spelling them out would obscure the algebra.
210#[allow(clippy::many_single_char_names)]
211fn bic_score(bits: &[u8], n: usize, d: usize, v: usize, parents: &[usize]) -> f64 {
212    let q = parents.len();
213    let num_configs = 1usize << q;
214    // counts[config * 2 + x] = N(config, x).
215    let mut counts = vec![0u32; num_configs * 2];
216    for i in 0..n {
217        let base = i * d;
218        let x = usize::from(bits[base + v]);
219        let config = pack_config(bits, base, parents);
220        counts[config * 2 + x] += 1;
221    }
222
223    let mut log_likelihood = 0.0_f64;
224    for c in 0..num_configs {
225        let count_0 = counts[c * 2];
226        let count_1 = counts[c * 2 + 1];
227        let count_total = count_0 + count_1;
228        if count_total == 0 {
229            // N(c) == 0: zero likelihood, but the config still counts toward the
230            // 2^q penalty below (that is the pressure keeping q small).
231            continue;
232        }
233        // f64::from(u32) is a lossless widening, not a lossy cast.
234        let total_f = f64::from(count_total);
235        for &count_x in &[count_0, count_1] {
236            if count_x == 0 {
237                // N(c, x) == 0 contributes exactly 0 (the p·ln p → 0 limit); no
238                // ln(0) path is ever reached.
239                continue;
240            }
241            let count_x_f = f64::from(count_x);
242            log_likelihood += count_x_f * (count_x_f / total_f).ln();
243        }
244    }
245
246    // Complexity penalty: ½·ln(n)·2^q over all 2^q configs.
247    #[allow(clippy::cast_precision_loss)]
248    let nf = n as f64;
249    #[allow(clippy::cast_precision_loss)]
250    let penalty = 0.5 * nf.ln() * (num_configs as f64);
251    log_likelihood - penalty
252}
253
254/// Insert `value` into the ascending-sorted vector `parents`, keeping it sorted.
255fn insert_sorted(parents: &mut Vec<usize>, value: usize) {
256    let pos = parents.partition_point(|&p| p < value);
257    parents.insert(pos, value);
258}
259
260/// Does adding edge `u → v` create a cycle?
261///
262/// A cycle appears iff `v` is already an ancestor of `u`. Iterative DFS upward
263/// from `u` through `parents[]`, looking for `v`. `O(D·κ)` per check.
264fn creates_cycle(parents: &[Vec<usize>], u: usize, v: usize) -> bool {
265    let d = parents.len();
266    let mut visited = vec![false; d];
267    let mut stack = vec![u];
268    while let Some(node) = stack.pop() {
269        if node == v {
270            return true;
271        }
272        if visited[node] {
273            continue;
274        }
275        visited[node] = true;
276        for &p in &parents[node] {
277            if !visited[p] {
278                stack.push(p);
279            }
280        }
281    }
282    false
283}
284
285/// Kahn's algorithm with deterministic minimum-index selection.
286///
287/// Repeatedly emits the smallest-index node whose remaining (unemitted) parent
288/// count is zero. The greedy loop guarantees the graph is a DAG.
289fn topological_order(parents: &[Vec<usize>]) -> Vec<usize> {
290    let d = parents.len();
291    let mut indegree: Vec<usize> = parents.iter().map(Vec::len).collect();
292    let mut emitted = vec![false; d];
293    let mut order = Vec::with_capacity(d);
294    while order.len() < d {
295        // Smallest-index node with indegree 0 not yet emitted.
296        let mut next = None;
297        for v in 0..d {
298            if !emitted[v] && indegree[v] == 0 {
299                next = Some(v);
300                break;
301            }
302        }
303        let Some(node) = next else { break };
304        emitted[node] = true;
305        order.push(node);
306        // Decrement indegree of every node that has `node` as a parent.
307        for (child, ps) in parents.iter().enumerate() {
308            if !emitted[child] && ps.contains(&node) {
309                indegree[child] -= 1;
310            }
311        }
312    }
313    order
314}
315
316impl<B: Backend> ProbabilityModel<B> for BayesianNetwork {
317    type Params = BayesianNetworkParams;
318    type State = BayesianNetworkState;
319
320    /// Fit the Bayesian network to the selected population.
321    ///
322    /// When `prev = None` returns the edgeless prior (natural order, empty
323    /// parent lists, single-cell CPTs at `init_prob`); `population` and
324    /// `fitness` are ignored. Otherwise the whole network is relearned from
325    /// scratch (the fit is non-incremental — `prev` is the not-bootstrap signal
326    /// only):
327    ///
328    /// 1. Bitizes the selected rows to `{0, 1}` via `>= 0.5`.
329    /// 2. Greedily adds the highest-BIC-gain edge each round (subject to the
330    ///    `max_parents` cap and acyclicity) until no strictly-positive gain
331    ///    remains, using a `D × D` gain cache.
332    /// 3. Estimates Laplace-smoothed CPTs from the final structure.
333    /// 4. Computes a deterministic topological order (Kahn, min-index).
334    ///
335    /// The `fitness` argument is accepted but always ignored.
336    ///
337    /// # Panics
338    ///
339    /// In release builds, panics if the `population` tensor cannot be read back
340    /// as `f32` (`.expect("population tensor must be readable as f32")`).
341    ///
342    /// In debug builds, additionally panics on the following `debug_assert`
343    /// checks (all disabled in release):
344    ///
345    /// - the population column count disagrees with `params.genome_dim`;
346    /// - `params.max_parents >= usize::BITS` (which would overflow the
347    ///   `1usize << q` CPT table sizing);
348    /// - the closing topological order fails to cover all `D` nodes (guaranteed
349    ///   by the DAG invariant).
350    // The counting passes, gain-cached greedy search, CPT estimation, and
351    // topological ordering form one coherent fit; splitting them would scatter
352    // the shared `bits`/`parents` buffers without aiding readability.
353    // Single-char math names (n, d, v, q, s, u) mirror the BIC/CPT formulae and
354    // the sibling EDA models; spelling them out would obscure the algebra.
355    #[allow(clippy::too_many_lines, clippy::many_single_char_names)]
356    fn fit(
357        &self,
358        params: &Self::Params,
359        prev: Option<&Self::State>,
360        population: Tensor<B, 2>,
361        fitness: Tensor<B, 1>,
362        device: &<B as burn::tensor::backend::BackendTypes>::Device,
363    ) -> Self::State {
364        let _ = device;
365        // Fitness is accepted but ignored: the fit is unweighted.
366        let _ = fitness;
367        let Some(_prev) = prev else {
368            // Prior path: edgeless network in natural order; population and
369            // fitness ignored. `prev` is consumed only as the bootstrap signal.
370            return prior_state(params.genome_dim, params.init_prob);
371        };
372
373        // Host extraction and bitization. The population's column count is the
374        // row stride for every counting pass below, so it — not
375        // `params.genome_dim` — is the authoritative `d` (mirrors
376        // `DependencyChain::fit`); the two must agree.
377        let [n, d] = population.dims();
378        debug_assert_eq!(
379            d, params.genome_dim,
380            "population column count must match params.genome_dim"
381        );
382        // CPT sizes are 2^q with q <= max_parents; a cap at or above the
383        // word width would overflow the `1usize << q` table sizing.
384        debug_assert!(
385            params.max_parents < usize::BITS as usize,
386            "max_parents must be below usize::BITS"
387        );
388        let rows = population
389            .into_data()
390            .into_vec::<f32>()
391            .expect("population tensor must be readable as f32");
392        if n == 0 {
393            // Degenerate input: nothing to learn, return the prior-shaped
394            // state (params-shaped, since a 0×0 tensor carries no width).
395            return prior_state(params.genome_dim, params.init_prob);
396        }
397        let bits: Vec<u8> = rows.iter().map(|&v| u8::from(v >= 0.5)).collect();
398
399        // Greedy structure learning with a D×D gain cache.
400        let mut parents: Vec<Vec<usize>> = vec![Vec::new(); d];
401        let mut base_score: Vec<f64> = (0..d).map(|v| bic_score(&bits, n, d, v, &[])).collect();
402
403        // gain_cache[u * d + v] = score(v, parents[v] ∪ {u}) − base_score[v],
404        // an exact recomputation. Entries are recomputed for child v* after each
405        // accepted edge; eligibility (cycle / cap / already-present) is checked
406        // live at selection time, so the cache holds only the score gain.
407        // NOTE: this `NEG_INFINITY` is a structure-score (BDeu/likelihood)
408        // gain sentinel for greedy edge maximisation — NOT objective fitness.
409        // It is independent of the crate's maximise convention; do not flip it.
410        let mut gain_cache = vec![f64::NEG_INFINITY; d * d];
411        // Helper closure would need to borrow `bits`/`parents`/`base_score`
412        // mutably and immutably; an inline recompute keeps borrows simple.
413        // Initial full sweep.
414        for u in 0..d {
415            for v in 0..d {
416                if u == v {
417                    continue;
418                }
419                let mut cand = parents[v].clone();
420                insert_sorted(&mut cand, u);
421                gain_cache[u * d + v] = bic_score(&bits, n, d, v, &cand) - base_score[v];
422            }
423        }
424
425        loop {
426            // Select the eligible (u, v) with the maximal cached gain.
427            // Lexicographic (u, v) scan with strict '>' ⇒ first pair wins ties.
428            let mut best: Option<(f64, usize, usize)> = None;
429            for u in 0..d {
430                for v in 0..d {
431                    if u == v
432                        || parents[v].len() >= params.max_parents
433                        || parents[v].contains(&u)
434                        || creates_cycle(&parents, u, v)
435                    {
436                        continue;
437                    }
438                    let g = gain_cache[u * d + v];
439                    if best.is_none_or(|(bg, _, _)| g > bg) {
440                        best = Some((g, u, v));
441                    }
442                }
443            }
444
445            let Some((gain, u, v)) = best else { break };
446            if gain <= 0.0 {
447                // Strictly-positive gain required to add an edge.
448                break;
449            }
450            insert_sorted(&mut parents[v], u);
451            base_score[v] += gain;
452
453            // Only entries with child == v are now stale; recompute just those.
454            for uu in 0..d {
455                if uu == v {
456                    continue;
457                }
458                let mut cand = parents[v].clone();
459                if !cand.contains(&uu) {
460                    insert_sorted(&mut cand, uu);
461                }
462                gain_cache[uu * d + v] = bic_score(&bits, n, d, v, &cand) - base_score[v];
463            }
464        }
465
466        // CPT estimation from the final structure: one counting pass per node.
467        // Floor the smoothing at 1 so every probability stays strictly inside
468        // `(0, 1)` (the field-doc guarantee): with `s ≥ 1`, `den = N(c) + 2s > 0`
469        // always, so the `0/0` case is unreachable and `count_1/count_total`
470        // cannot pin a cell to an absorbing `0.0`/`1.0`.
471        let s = params.smoothing_count.max(1);
472        let mut cpt: Vec<Vec<f32>> = Vec::with_capacity(d);
473        // Laplace pseudo-count as f64; `s` is a tiny smoothing constant, far
474        // below f64's exact-integer range, so the cast is lossless.
475        #[allow(clippy::cast_precision_loss)]
476        let s_f = s as f64;
477        for v in 0..d {
478            let q = parents[v].len();
479            let num_configs = 1usize << q;
480            let mut counts = vec![0u32; num_configs * 2];
481            for i in 0..n {
482                let base = i * d;
483                let x = usize::from(bits[base + v]);
484                let config = pack_config(&bits, base, &parents[v]);
485                counts[config * 2 + x] += 1;
486            }
487            let mut table = Vec::with_capacity(num_configs);
488            for c in 0..num_configs {
489                let count_1 = counts[c * 2 + 1];
490                let count_total = counts[c * 2] + count_1;
491                // (N(c,1) + s) / (N(c) + 2s); f64::from(u32) is lossless. With
492                // `s ≥ 1` the denominator is always positive, so no `0/0` guard
493                // is needed.
494                let num = f64::from(count_1) + s_f;
495                let den = f64::from(count_total) + 2.0 * s_f;
496                // Probability in (0, 1) for s ≥ 1; the f64→f32 narrowing of a
497                // value in [0, 1] cannot truncate meaningfully.
498                #[allow(clippy::cast_possible_truncation)]
499                let prob = (num / den) as f32;
500                table.push(prob);
501            }
502            cpt.push(table);
503        }
504
505        let order = topological_order(&parents);
506        debug_assert_eq!(order.len(), d, "topological order must cover all nodes");
507
508        BayesianNetworkState {
509            order,
510            parents,
511            cpt,
512        }
513    }
514
515    /// Draw `n` binary genomes by ancestral sampling along the topological order.
516    ///
517    /// Each gene `v` is sampled from `P(v = 1 | parents)` read out of its CPT at
518    /// the bit-packed parent configuration (parents already sampled, since the
519    /// traversal follows the topological order). Exactly one `rng.random::<f32>()`
520    /// call is consumed per gene regardless of structure, keeping RNG
521    /// consumption stable. Host RNG only (never `Tensor::random` / `B::seed`).
522    /// The returned tensor has shape `(n, D)` and contains only `0.0` and `1.0`.
523    fn sample(
524        &self,
525        state: &Self::State,
526        n: usize,
527        rng: &mut dyn Rng,
528        device: &<B as burn::tensor::backend::BackendTypes>::Device,
529    ) -> Tensor<B, 2> {
530        let d = state.parents.len();
531        let mut rows = vec![0.0_f32; n * d];
532        for i in 0..n {
533            let base = i * d;
534            for &v in &state.order {
535                let mut config = 0usize;
536                for (j, &p) in state.parents[v].iter().enumerate() {
537                    if rows[base + p] >= 0.5 {
538                        config |= 1 << j;
539                    }
540                }
541                let p1 = state.cpt[v][config];
542                rows[base + v] = if rng.random::<f32>() < p1 { 1.0 } else { 0.0 };
543            }
544        }
545        Tensor::<B, 2>::from_data(TensorData::new(rows, [n, d]), device)
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use burn::backend::Flex;
553    use rand::SeedableRng;
554    use rand::rngs::StdRng;
555
556    type TestBackend = Flex;
557
558    fn pop(rows: Vec<f32>, n: usize, d: usize) -> Tensor<TestBackend, 2> {
559        let device = Default::default();
560        Tensor::<TestBackend, 2>::from_data(TensorData::new(rows, [n, d]), &device)
561    }
562
563    fn fitness(values: Vec<f32>) -> Tensor<TestBackend, 1> {
564        let device = Default::default();
565        let n = values.len();
566        Tensor::<TestBackend, 1>::from_data(TensorData::new(values, [n]), &device)
567    }
568
569    fn fit_prior(p: &BayesianNetworkParams) -> BayesianNetworkState {
570        let device = Default::default();
571        <BayesianNetwork as ProbabilityModel<TestBackend>>::fit(
572            &BayesianNetwork,
573            p,
574            None,
575            pop(vec![], 0, 0),
576            fitness(vec![]),
577            &device,
578        )
579    }
580
581    fn refit(
582        p: &BayesianNetworkParams,
583        rows: Vec<f32>,
584        n: usize,
585        d: usize,
586    ) -> BayesianNetworkState {
587        let device = Default::default();
588        let prior = fit_prior(p);
589        // Test row counts are tiny; the cast is lossless.
590        #[allow(clippy::cast_precision_loss)]
591        let fit_values: Vec<f32> = (0..n).map(|i| i as f32).collect();
592        <BayesianNetwork as ProbabilityModel<TestBackend>>::fit(
593            &BayesianNetwork,
594            p,
595            Some(&prior),
596            pop(rows, n, d),
597            fitness(fit_values),
598            &device,
599        )
600    }
601
602    #[test]
603    fn prior_is_edgeless_with_init_prob() {
604        let p = BayesianNetworkParams::default_for(3);
605        let state = fit_prior(&p);
606        assert_eq!(state.order, vec![0, 1, 2], "prior order is natural");
607        for ps in &state.parents {
608            assert!(ps.is_empty(), "prior parent lists must be empty");
609        }
610        for table in &state.cpt {
611            assert_eq!(table, &vec![0.5], "prior CPT is single-cell init_prob");
612        }
613    }
614
615    #[test]
616    fn two_fits_same_data_identical_state() {
617        let p = BayesianNetworkParams::default_for(3);
618        // gene1 = copy of gene0; gene2 a balanced, decorrelated pattern.
619        let rows = vec![
620            0.0, 0.0, 0.0, //
621            0.0, 0.0, 1.0, //
622            0.0, 0.0, 0.0, //
623            0.0, 0.0, 1.0, //
624            0.0, 0.0, 0.0, //
625            1.0, 1.0, 1.0, //
626            1.0, 1.0, 0.0, //
627            1.0, 1.0, 1.0, //
628            1.0, 1.0, 0.0, //
629            1.0, 1.0, 1.0, //
630        ];
631        let a = refit(&p, rows.clone(), 10, 3);
632        let b = refit(&p, rows, 10, 3);
633        assert_eq!(a.order, b.order, "order must be bit-deterministic");
634        assert_eq!(a.parents, b.parents, "parents must be bit-deterministic");
635        assert_eq!(a.cpt, b.cpt, "CPTs must be bit-deterministic");
636    }
637
638    #[test]
639    fn cpt_probabilities_strictly_interior() {
640        let p = BayesianNetworkParams::default_for(3);
641        // gene1 is constant 1 (constant column); Laplace smoothing must keep
642        // every CPT entry strictly inside (0, 1).
643        let rows = vec![
644            0.0, 1.0, 0.0, //
645            0.0, 1.0, 1.0, //
646            1.0, 1.0, 0.0, //
647            1.0, 1.0, 1.0, //
648            0.0, 1.0, 1.0, //
649            1.0, 1.0, 0.0, //
650        ];
651        let state = refit(&p, rows, 6, 3);
652        for (v, table) in state.cpt.iter().enumerate() {
653            for (c, &prob) in table.iter().enumerate() {
654                assert!(
655                    prob > 0.0 && prob < 1.0,
656                    "cpt[{v}][{c}] = {prob} not strictly interior"
657                );
658            }
659        }
660    }
661
662    #[test]
663    fn samples_are_binary_and_finite() {
664        let p = BayesianNetworkParams::default_for(4);
665        let rows = vec![
666            0.0, 0.0, 1.0, 1.0, //
667            1.0, 1.0, 0.0, 0.0, //
668            0.0, 1.0, 0.0, 1.0, //
669            1.0, 0.0, 1.0, 0.0, //
670        ];
671        let state = refit(&p, rows, 4, 4);
672        let device = Default::default();
673        let mut rng = StdRng::seed_from_u64(7);
674        let samples = <BayesianNetwork as ProbabilityModel<TestBackend>>::sample(
675            &BayesianNetwork,
676            &state,
677            1000,
678            &mut rng,
679            &device,
680        );
681        let data = samples
682            .into_data()
683            .into_vec::<f32>()
684            .expect("samples host-read of a tensor this test just built");
685        for v in data {
686            assert!(v.is_finite(), "sampled gene must be finite, got {v}");
687            // Exact float compare is correct: sample() writes literal 0.0/1.0.
688            #[allow(clippy::float_cmp)]
689            let is_binary = v == 0.0 || v == 1.0;
690            assert!(is_binary, "non-binary gene {v}");
691        }
692    }
693
694    #[test]
695    fn recovers_pairwise_dependency() {
696        // d=3, n=20: gene0 balanced (10 zeros then 10 ones), gene1 = copy of
697        // gene0, gene2 alternating within each half (zero correlation to gene0).
698        // BIC: dependence gain ≈ n·ln2 ≈ 13.9 vs penalty increment ½·ln20 ≈ 1.5
699        // ⇒ exactly one edge between 0 and 1; gene2 isolated.
700        let p = BayesianNetworkParams::default_for(3);
701        let mut rows = Vec::with_capacity(20 * 3);
702        for i in 0..20 {
703            let g0 = if i < 10 { 0.0 } else { 1.0 };
704            let g1 = g0; // exact copy
705            let g2 = if i % 2 == 0 { 0.0 } else { 1.0 }; // alternating, decorrelated
706            rows.push(g0);
707            rows.push(g1);
708            rows.push(g2);
709        }
710        let state = refit(&p, rows, 20, 3);
711        // Direction-agnostic single edge between 0 and 1.
712        let edge_0_to_1 = state.parents[1] == vec![0];
713        let edge_1_to_0 = state.parents[0] == vec![1];
714        assert!(
715            edge_0_to_1 ^ edge_1_to_0,
716            "expected exactly one 0↔1 edge, parents = {:?}",
717            state.parents
718        );
719        // gene2 has no parents and appears in nobody's parent list.
720        assert!(state.parents[2].is_empty(), "gene2 must have no parents");
721        for ps in &state.parents {
722            assert!(!ps.contains(&2), "gene2 must not be a parent: {ps:?}");
723        }
724        // The child's 2-entry CPT is ≈ [<0.2, >0.8] after smoothing.
725        let child = usize::from(edge_0_to_1);
726        assert_eq!(state.cpt[child].len(), 2, "child CPT has 2 cells");
727        assert!(
728            state.cpt[child][0] < 0.2,
729            "P(child=1 | parent=0) too high: {}",
730            state.cpt[child][0]
731        );
732        assert!(
733            state.cpt[child][1] > 0.8,
734            "P(child=1 | parent=1) too low: {}",
735            state.cpt[child][1]
736        );
737    }
738
739    #[test]
740    fn recovers_two_parent_dependency() {
741        // gene2 = gene0 AND gene1 over the four balanced combos repeated 8×.
742        let p = BayesianNetworkParams::default_for(3);
743        let mut rows = Vec::with_capacity(32 * 3);
744        for _ in 0..8 {
745            for &(a, b) in &[(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)] {
746                let c = if a >= 0.5 && b >= 0.5 { 1.0 } else { 0.0 };
747                rows.push(a);
748                rows.push(b);
749                rows.push(c);
750            }
751        }
752        let state = refit(&p, rows, 32, 3);
753        // Assert specifically on gene2's parents (a 0↔1 edge, if added, is fine).
754        assert_eq!(
755            state.parents[2],
756            vec![0, 1],
757            "gene2 must depend on both 0 and 1, got {:?}",
758            state.parents[2]
759        );
760    }
761
762    #[test]
763    fn independent_data_yields_no_edges() {
764        // d=2, all four combinations equally → no detectable dependency.
765        let p = BayesianNetworkParams::default_for(2);
766        let rows = vec![
767            0.0, 0.0, //
768            0.0, 1.0, //
769            1.0, 0.0, //
770            1.0, 1.0, //
771        ];
772        let state = refit(&p, rows, 4, 2);
773        for ps in &state.parents {
774            assert!(
775                ps.is_empty(),
776                "independent data must yield no edges: {ps:?}"
777            );
778        }
779    }
780
781    #[test]
782    fn max_parents_cap_respected() {
783        // AND dataset with max_parents = 1: must complete and respect the cap.
784        let mut p = BayesianNetworkParams::default_for(3);
785        p.max_parents = 1;
786        let mut rows = Vec::with_capacity(32 * 3);
787        for _ in 0..8 {
788            for &(a, b) in &[(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)] {
789                let c = if a >= 0.5 && b >= 0.5 { 1.0 } else { 0.0 };
790                rows.push(a);
791                rows.push(b);
792                rows.push(c);
793            }
794        }
795        let state = refit(&p, rows, 32, 3);
796        for ps in &state.parents {
797            assert!(ps.len() <= 1, "max_parents=1 violated: {ps:?}");
798        }
799    }
800
801    #[test]
802    fn order_is_topological() {
803        // After a structure-learning fit, order is a permutation of 0..d with
804        // every parent preceding its child.
805        let p = BayesianNetworkParams::default_for(3);
806        let mut rows = Vec::with_capacity(32 * 3);
807        for _ in 0..8 {
808            for &(a, b) in &[(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)] {
809                let c = if a >= 0.5 && b >= 0.5 { 1.0 } else { 0.0 };
810                rows.push(a);
811                rows.push(b);
812                rows.push(c);
813            }
814        }
815        let state = refit(&p, rows, 32, 3);
816        // Permutation check.
817        let mut seen = state.order.clone();
818        seen.sort_unstable();
819        assert_eq!(seen, vec![0, 1, 2], "order must be a permutation of 0..d");
820        // Parent precedes child.
821        let position: Vec<usize> = {
822            let mut pos = vec![0usize; state.order.len()];
823            for (idx, &node) in state.order.iter().enumerate() {
824                pos[node] = idx;
825            }
826            pos
827        };
828        for (child, ps) in state.parents.iter().enumerate() {
829            for &parent in ps {
830                assert!(
831                    position[parent] < position[child],
832                    "parent {parent} must precede child {child} in {:?}",
833                    state.order
834                );
835            }
836        }
837    }
838
839    #[test]
840    fn sampling_respects_learned_dependency() {
841        // Fit the copy dataset, sample 5000, columns 0 and 1 agree on > 90%.
842        let p = BayesianNetworkParams::default_for(2);
843        let mut rows = Vec::with_capacity(20 * 2);
844        for i in 0..20 {
845            let g0 = if i < 10 { 0.0 } else { 1.0 };
846            rows.push(g0);
847            rows.push(g0); // gene1 = copy of gene0
848        }
849        let state = refit(&p, rows, 20, 2);
850        let device = Default::default();
851        let mut rng = StdRng::seed_from_u64(123);
852        let n = 5000;
853        let samples = <BayesianNetwork as ProbabilityModel<TestBackend>>::sample(
854            &BayesianNetwork,
855            &state,
856            n,
857            &mut rng,
858            &device,
859        );
860        let data = samples
861            .into_data()
862            .into_vec::<f32>()
863            .expect("samples host-read of a tensor this test just built");
864        let mut agree = 0usize;
865        for i in 0..n {
866            if (data[i * 2] - data[i * 2 + 1]).abs() < 0.5 {
867                agree += 1;
868            }
869        }
870        // Tiny counts vs f64 exact range; lossless.
871        #[allow(clippy::cast_precision_loss)]
872        let frac = agree as f64 / n as f64;
873        assert!(
874            frac > 0.9,
875            "sampled columns 0 and 1 should agree on > 90% of rows, got {frac}"
876        );
877    }
878
879    #[test]
880    fn nan_init_prob_clamped_on_prior() {
881        // A non-finite init_prob must not propagate into the CPTs (#129): the
882        // prior clamps it into the open interior (0, 1).
883        let mut p = BayesianNetworkParams::default_for(3);
884        p.init_prob = f32::NAN;
885        let state = fit_prior(&p);
886        for table in &state.cpt {
887            let v = table[0];
888            assert!(v.is_finite(), "clamped init_prob must be finite, got {v}");
889            assert!(
890                v > 0.0 && v < 1.0,
891                "clamped init_prob must be interior, got {v}"
892            );
893        }
894    }
895
896    #[test]
897    fn out_of_range_init_prob_clamped_on_prior() {
898        for bad in [1.5_f32, -0.3, f32::INFINITY] {
899            let mut p = BayesianNetworkParams::default_for(2);
900            p.init_prob = bad;
901            let state = fit_prior(&p);
902            for table in &state.cpt {
903                let v = table[0];
904                assert!(
905                    v > 0.0 && v < 1.0,
906                    "init_prob {bad} must clamp interior, got {v}"
907                );
908            }
909        }
910    }
911
912    #[test]
913    fn smoothing_count_zero_keeps_cpt_interior() {
914        // s = 0 with a constant-1 column would give count_1/count_total = 1.0
915        // (an absorbing gene) without the floor. Flooring s at 1 keeps it in
916        // (0, 1). Single gene, all ones ⇒ one CPT cell.
917        let mut p = BayesianNetworkParams::default_for(1);
918        p.smoothing_count = 0;
919        let state = refit(&p, vec![1.0, 1.0, 1.0, 1.0], 4, 1);
920        let v = state.cpt[0][0];
921        assert!(
922            v > 0.0 && v < 1.0,
923            "s=0 must be floored to keep CPT interior, got {v}"
924        );
925    }
926
927    #[test]
928    fn cpt_sizes_and_parents_sorted_unique_over_random_fits() {
929        // §7.3: over many seeded random fits, every node's CPT must have exactly
930        // 2^|parents| cells and its parent list must be strictly ascending (hence
931        // sorted and duplicate-free). These are structural invariants of `fit`.
932        let p = BayesianNetworkParams::default_for(4);
933        let d = 4;
934        let n = 16;
935        let mut rng = StdRng::seed_from_u64(2024);
936        for _ in 0..30 {
937            let rows: Vec<f32> = (0..n * d)
938                .map(|_| if rng.random::<f32>() < 0.5 { 0.0 } else { 1.0 })
939                .collect();
940            let state = refit(&p, rows, n, d);
941            for v in 0..d {
942                assert_eq!(
943                    state.cpt[v].len(),
944                    1 << state.parents[v].len(),
945                    "cpt[{v}] must have 2^|parents| cells, parents = {:?}",
946                    state.parents[v]
947                );
948                for w in state.parents[v].windows(2) {
949                    assert!(
950                        w[0] < w[1],
951                        "parents[{v}] must be strictly ascending (sorted & unique), got {:?}",
952                        state.parents[v]
953                    );
954                }
955            }
956        }
957    }
958
959    #[test]
960    fn single_gene_is_edgeless() {
961        // §7.2: genome_dim == 1 has no candidate edges; the single gene must be
962        // parentless with a one-cell CPT, regardless of the data.
963        let p = BayesianNetworkParams::default_for(1);
964        let state = refit(&p, vec![0.0, 1.0, 1.0, 0.0], 4, 1);
965        assert_eq!(state.order, vec![0], "single-gene order is natural");
966        assert!(state.parents[0].is_empty(), "single gene must be edgeless");
967        assert_eq!(state.cpt[0].len(), 1, "single-gene CPT is one cell");
968    }
969
970    #[test]
971    fn single_individual_population_yields_prior_shape() {
972        // §7.2: n == 1. With one row the BIC complexity penalty ½·ln(1)·2^q is 0
973        // and every config's likelihood term is 0, so no edge yields positive
974        // gain: the learned structure is edgeless and prior-shaped (natural
975        // order, empty parent lists, single-cell CPTs).
976        let p = BayesianNetworkParams::default_for(3);
977        let state = refit(&p, vec![1.0, 0.0, 1.0], 1, 3);
978        assert_eq!(state.order, vec![0, 1, 2], "order must be natural");
979        for v in 0..3 {
980            assert!(state.parents[v].is_empty(), "node {v} must be edgeless");
981            assert_eq!(state.cpt[v].len(), 1, "node {v} CPT must be one cell");
982        }
983    }
984}