Skip to main content

ipfrs_tensorlogic/
bayesian_network_inference.rs

1//! Bayesian Network Inference — variable elimination, belief propagation,
2//! and sampling-based inference over discrete Bayesian networks.
3//!
4//! # Overview
5//!
6//! A [`BayesianNetwork`] is a directed acyclic graph (DAG) of discrete random
7//! variables where each variable has a [`ConditionalProbabilityTable`] (CPT)
8//! that encodes P(variable | parents).  Given observed [`Evidence`], the
9//! [`BayesianNetworkInference`] engine answers marginal-probability queries
10//! using one of three algorithms:
11//!
12//! * **Variable Elimination** — exact inference by successive factor
13//!   multiplication and marginalization.
14//! * **Belief Propagation** — sum-product message passing (exact on trees,
15//!   approximate on loopy graphs).
16//! * **Sampling** — rejection / likelihood-weighting sampling with a
17//!   reproducible xorshift64 PRNG (no `rand` dependency).
18//!
19//! # Example
20//!
21//! ```rust
22//! use ipfrs_tensorlogic::bayesian_network_inference::{
23//!     BayesianNetwork, BayesianNetworkInference, BniConfig, ConditionalProbabilityTable,
24//!     EliminationOrder, Evidence, Factor, InferenceAlgorithm, InferenceQuery, RandomVariable,
25//! };
26//! use std::collections::HashMap;
27//!
28//! // Build a tiny Rain → Wet-Grass network.
29//! let rain = RandomVariable { id: "Rain".into(), states: vec!["T".into(), "F".into()], cardinality: 2 };
30//! let wet  = RandomVariable { id: "Wet".into(),  states: vec!["T".into(), "F".into()], cardinality: 2 };
31//!
32//! // P(Rain)
33//! let f_rain = Factor {
34//!     id: "f_rain".into(),
35//!     variables: vec!["Rain".into()],
36//!     values: vec![0.2, 0.8],
37//!     shape: vec![2],
38//! };
39//! let cpt_rain = ConditionalProbabilityTable {
40//!     variable: "Rain".into(),
41//!     parents:  vec![],
42//!     factor:   f_rain,
43//! };
44//!
45//! // P(Wet | Rain)  — rows: Rain=T, Rain=F; cols: Wet=T, Wet=F
46//! let f_wet = Factor {
47//!     id: "f_wet".into(),
48//!     variables: vec!["Rain".into(), "Wet".into()],
49//!     values: vec![0.9, 0.1, 0.2, 0.8],
50//!     shape: vec![2, 2],
51//! };
52//! let cpt_wet = ConditionalProbabilityTable {
53//!     variable: "Wet".into(),
54//!     parents:  vec!["Rain".into()],
55//!     factor:   f_wet,
56//! };
57//!
58//! let mut variables = HashMap::new();
59//! variables.insert("Rain".into(), rain);
60//! variables.insert("Wet".into(), wet);
61//! let mut adjacency = HashMap::new();
62//! adjacency.insert("Wet".into(), vec!["Rain".into()]);
63//!
64//! let net = BayesianNetwork {
65//!     variables,
66//!     cpts: vec![cpt_rain, cpt_wet],
67//!     adjacency,
68//! };
69//!
70//! let config = BniConfig::default();
71//! let mut engine = BayesianNetworkInference::new(net, config).expect("example: should succeed in docs");
72//!
73//! let query = InferenceQuery {
74//!     query_variables: vec!["Rain".into()],
75//!     evidence: vec![],
76//!     algorithm: InferenceAlgorithm::VariableElimination,
77//! };
78//! let results = engine.query(&query).expect("example: should succeed in docs");
79//! assert_eq!(results.len(), 1);
80//! let rain_dist = &results[0].distribution;
81//! assert!((rain_dist[0].1 - 0.2).abs() < 1e-9);
82//! ```
83
84use std::collections::{HashMap, HashSet, VecDeque};
85use thiserror::Error;
86
87// ─────────────────────────────────────────────────────────────────────────────
88// PRNG (no rand crate)
89// ─────────────────────────────────────────────────────────────────────────────
90
91/// Xorshift64 PRNG — advance state and return next pseudo-random u64.
92pub fn bni_xorshift64(state: &mut u64) -> u64 {
93    let mut x = *state;
94    x ^= x << 13;
95    x ^= x >> 7;
96    x ^= x << 17;
97    *state = x;
98    x
99}
100
101/// Draw a categorical sample from a (possibly un-normalised) probability
102/// vector using the supplied PRNG state.
103fn sample_categorical(probs: &[f64], state: &mut u64) -> usize {
104    let u = (bni_xorshift64(state) >> 11) as f64 / (1u64 << 53) as f64;
105    let mut cumsum = 0.0_f64;
106    for (i, &p) in probs.iter().enumerate() {
107        cumsum += p;
108        if u < cumsum {
109            return i;
110        }
111    }
112    probs.len().saturating_sub(1)
113}
114
115// ─────────────────────────────────────────────────────────────────────────────
116// Error type
117// ─────────────────────────────────────────────────────────────────────────────
118
119/// All errors that can arise during Bayesian network construction or inference.
120#[derive(Debug, Error, Clone, PartialEq)]
121pub enum BniError {
122    /// A referenced variable does not exist in the network.
123    #[error("variable not found: {0}")]
124    VariableNotFound(String),
125
126    /// A CPT has inconsistent dimensions or probability values.
127    #[error("invalid CPT for variable `{variable}`: {reason}")]
128    InvalidCPT {
129        /// Variable whose CPT is invalid.
130        variable: String,
131        /// Human-readable explanation.
132        reason: String,
133    },
134
135    /// Two evidence items contradict each other for the same variable.
136    #[error("evidence conflict for variable: {0}")]
137    EvidenceConflict(String),
138
139    /// The network contains a directed cycle.
140    #[error("cyclic network detected: {0}")]
141    CyclicNetwork(String),
142
143    /// A generic inference failure.
144    #[error("inference error: {0}")]
145    InferenceError(String),
146}
147
148// ─────────────────────────────────────────────────────────────────────────────
149// Core types
150// ─────────────────────────────────────────────────────────────────────────────
151
152/// A discrete random variable with named states.
153#[derive(Debug, Clone, PartialEq)]
154pub struct RandomVariable {
155    /// Unique identifier (must match keys in `BayesianNetwork::variables`).
156    pub id: String,
157    /// Ordered list of state labels.
158    pub states: Vec<String>,
159    /// Number of states (equals `states.len()`).
160    pub cardinality: usize,
161}
162
163/// A factor (un-normalised joint distribution table) over a set of variables.
164///
165/// `values` is stored in row-major order: the last variable in `variables`
166/// varies fastest.  `shape[i]` is the cardinality of `variables[i]`.
167#[derive(Debug, Clone, PartialEq)]
168pub struct Factor {
169    /// Human-readable label.
170    pub id: String,
171    /// Ordered variable scope.
172    pub variables: Vec<String>,
173    /// Flat probability/potential table.
174    pub values: Vec<f64>,
175    /// Shape array — `shape[i]` is the number of states for `variables[i]`.
176    pub shape: Vec<usize>,
177}
178
179impl Factor {
180    // ── index helpers ────────────────────────────────────────────────────────
181
182    /// Convert a multi-dimensional index to a flat offset (row-major).
183    fn flat_index(indices: &[usize], shape: &[usize]) -> usize {
184        let mut idx = 0usize;
185        let mut stride = 1usize;
186        for i in (0..shape.len()).rev() {
187            idx += indices[i] * stride;
188            stride *= shape[i];
189        }
190        idx
191    }
192
193    /// Convert a flat offset to a multi-dimensional index (row-major).
194    fn multi_index(flat: usize, shape: &[usize]) -> Vec<usize> {
195        let mut remaining = flat;
196        let mut idx = vec![0usize; shape.len()];
197        for i in (0..shape.len()).rev() {
198            idx[i] = remaining % shape[i];
199            remaining /= shape[i];
200        }
201        idx
202    }
203
204    // ── public operations ────────────────────────────────────────────────────
205
206    /// Compute the factor product of `self` and `other`.
207    ///
208    /// The result has the union of both variable scopes.  Values are the
209    /// point-wise product over all joint assignments.
210    pub fn product(&self, other: &Factor) -> Factor {
211        // Build the union scope and result shape.
212        let mut result_vars = self.variables.clone();
213        let mut result_shape = self.shape.clone();
214        for (i, v) in other.variables.iter().enumerate() {
215            if !result_vars.contains(v) {
216                result_vars.push(v.clone());
217                result_shape.push(other.shape[i]);
218            }
219        }
220
221        let total: usize = result_shape.iter().product();
222        let mut values = vec![0.0_f64; total];
223
224        for (flat, value) in values.iter_mut().enumerate().take(total) {
225            let idx = Self::multi_index(flat, &result_shape);
226
227            // Build assignment: var → state index
228            let assignment: HashMap<&str, usize> = result_vars
229                .iter()
230                .zip(idx.iter())
231                .map(|(v, &s)| (v.as_str(), s))
232                .collect();
233
234            // Compute self sub-index
235            let self_idx: Vec<usize> = self
236                .variables
237                .iter()
238                .map(|v| assignment[v.as_str()])
239                .collect();
240            let self_flat = Self::flat_index(&self_idx, &self.shape);
241
242            // Compute other sub-index
243            let other_idx: Vec<usize> = other
244                .variables
245                .iter()
246                .map(|v| assignment[v.as_str()])
247                .collect();
248            let other_flat = Self::flat_index(&other_idx, &other.shape);
249
250            *value = self.values[self_flat] * other.values[other_flat];
251        }
252
253        Factor {
254            id: format!("{}*{}", self.id, other.id),
255            variables: result_vars,
256            values,
257            shape: result_shape,
258        }
259    }
260
261    /// Marginalize `variable` out of this factor by summing over all its
262    /// states.
263    ///
264    /// `var_map` maps variable names to their cardinalities and is used for
265    /// consistency checking; the actual cardinality is taken from `self.shape`.
266    pub fn marginalize(&self, variable: &str, _var_map: &HashMap<String, usize>) -> Factor {
267        // Find the dimension index for this variable.
268        let dim = match self.variables.iter().position(|v| v == variable) {
269            Some(d) => d,
270            None => return self.clone(),
271        };
272
273        let new_vars: Vec<String> = self
274            .variables
275            .iter()
276            .enumerate()
277            .filter(|(i, _)| *i != dim)
278            .map(|(_, v)| v.clone())
279            .collect();
280        let new_shape: Vec<usize> = self
281            .shape
282            .iter()
283            .enumerate()
284            .filter(|(i, _)| *i != dim)
285            .map(|(_, &s)| s)
286            .collect();
287
288        let new_total: usize = if new_shape.is_empty() {
289            1
290        } else {
291            new_shape.iter().product()
292        };
293        let mut values = vec![0.0_f64; new_total];
294
295        let total: usize = self.shape.iter().product();
296        for flat in 0..total {
297            let idx = Self::multi_index(flat, &self.shape);
298            // Build reduced index (drop dimension `dim`)
299            let red_idx: Vec<usize> = idx
300                .iter()
301                .enumerate()
302                .filter(|(i, _)| *i != dim)
303                .map(|(_, &v)| v)
304                .collect();
305            let red_flat = if new_shape.is_empty() {
306                0
307            } else {
308                Self::flat_index(&red_idx, &new_shape)
309            };
310            values[red_flat] += self.values[flat];
311        }
312
313        Factor {
314            id: format!("{}\\{}", self.id, variable),
315            variables: new_vars,
316            values,
317            shape: new_shape,
318        }
319    }
320
321    /// Reduce this factor by fixing `variable` to `state` (slice operation).
322    ///
323    /// The resulting factor no longer contains `variable` in its scope.
324    pub fn reduce(
325        &self,
326        variable: &str,
327        state: usize,
328        _var_map: &HashMap<String, usize>,
329    ) -> Factor {
330        let dim = match self.variables.iter().position(|v| v == variable) {
331            Some(d) => d,
332            None => return self.clone(),
333        };
334
335        let new_vars: Vec<String> = self
336            .variables
337            .iter()
338            .enumerate()
339            .filter(|(i, _)| *i != dim)
340            .map(|(_, v)| v.clone())
341            .collect();
342        let new_shape: Vec<usize> = self
343            .shape
344            .iter()
345            .enumerate()
346            .filter(|(i, _)| *i != dim)
347            .map(|(_, &s)| s)
348            .collect();
349
350        let new_total: usize = if new_shape.is_empty() {
351            1
352        } else {
353            new_shape.iter().product()
354        };
355        let mut values = vec![0.0_f64; new_total];
356
357        let total: usize = self.shape.iter().product();
358        for flat in 0..total {
359            let idx = Self::multi_index(flat, &self.shape);
360            if idx[dim] != state {
361                continue;
362            }
363            let red_idx: Vec<usize> = idx
364                .iter()
365                .enumerate()
366                .filter(|(i, _)| *i != dim)
367                .map(|(_, &v)| v)
368                .collect();
369            let red_flat = if new_shape.is_empty() {
370                0
371            } else {
372                Self::flat_index(&red_idx, &new_shape)
373            };
374            values[red_flat] = self.values[flat];
375        }
376
377        Factor {
378            id: format!("{}[{}={}]", self.id, variable, state),
379            variables: new_vars,
380            values,
381            shape: new_shape,
382        }
383    }
384
385    /// Normalize: divide all values by their sum so they sum to 1.
386    pub fn normalize(&mut self) {
387        let s: f64 = self.values.iter().sum();
388        if s > 0.0 {
389            for v in &mut self.values {
390                *v /= s;
391            }
392        }
393    }
394
395    /// Whether `variable` appears in this factor's scope.
396    pub fn contains_variable(&self, variable: &str) -> bool {
397        self.variables.iter().any(|v| v == variable)
398    }
399
400    /// Compute the entropy of this factor's values (assumes they are
401    /// normalised probabilities).  Returns `H = -Σ p log₂ p`.
402    pub fn entropy(&self) -> f64 {
403        self.values
404            .iter()
405            .filter(|&&p| p > 0.0)
406            .map(|&p| -p * p.log2())
407            .sum()
408    }
409}
410
411/// A conditional probability table encoding P(variable | parents).
412#[derive(Debug, Clone, PartialEq)]
413pub struct ConditionalProbabilityTable {
414    /// The child variable this CPT belongs to.
415    pub variable: String,
416    /// Ordered list of parent variable ids.
417    pub parents: Vec<String>,
418    /// The underlying factor (scope = parents ++ \[variable\]).
419    pub factor: Factor,
420}
421
422/// A single piece of observed evidence.
423#[derive(Debug, Clone, PartialEq)]
424pub struct Evidence {
425    /// The variable that was observed.
426    pub variable: String,
427    /// The name of the state that was observed.
428    pub observed_state: String,
429}
430
431/// A Bayesian network: a DAG of random variables with CPTs.
432#[derive(Debug, Clone)]
433pub struct BayesianNetwork {
434    /// All random variables, keyed by their id.
435    pub variables: HashMap<String, RandomVariable>,
436    /// Conditional probability tables (one per variable).
437    pub cpts: Vec<ConditionalProbabilityTable>,
438    /// Adjacency list: child → list of parents (mirrors CPT parents).
439    pub adjacency: HashMap<String, Vec<String>>,
440}
441
442/// Describes which inference algorithm to use.
443#[derive(Debug, Clone, PartialEq)]
444pub enum InferenceAlgorithm {
445    /// Exact variable-elimination inference.
446    VariableElimination,
447    /// Sum-product belief propagation (exact on trees).
448    BeliefPropagation,
449    /// Monte-Carlo sampling with xorshift64 PRNG.
450    Sampling {
451        /// Number of samples to draw.
452        n_samples: usize,
453        /// Initial PRNG seed.
454        seed: u64,
455    },
456}
457
458/// A query to the inference engine.
459#[derive(Debug, Clone)]
460pub struct InferenceQuery {
461    /// Variables whose posterior distributions are requested.
462    pub query_variables: Vec<String>,
463    /// Observed values (may be empty for prior queries).
464    pub evidence: Vec<Evidence>,
465    /// Which algorithm to use.
466    pub algorithm: InferenceAlgorithm,
467}
468
469/// Per-variable result of a Bayesian inference query.
470#[derive(Debug, Clone, PartialEq)]
471pub struct QueryResult {
472    /// The queried variable.
473    pub variable: String,
474    /// Posterior distribution: `(state_name, probability)` pairs in state order.
475    pub distribution: Vec<(String, f64)>,
476    /// Shannon entropy (bits) of the posterior distribution.
477    pub marginal_entropy: f64,
478    /// The state with the highest posterior probability.
479    pub most_likely_state: String,
480}
481
482impl QueryResult {
483    fn from_factor(var: &RandomVariable, mut f: Factor) -> Self {
484        // Ensure the factor is single-variable and normalized.
485        if !f.variables.is_empty() && f.variables[0] != var.id {
486            // Try marginalizing all but the query variable
487            let remove: Vec<String> = f
488                .variables
489                .iter()
490                .filter(|v| v.as_str() != var.id)
491                .cloned()
492                .collect();
493            for v in remove {
494                let dummy = HashMap::new();
495                f = f.marginalize(&v, &dummy);
496            }
497        }
498        f.normalize();
499
500        let distribution: Vec<(String, f64)> = var
501            .states
502            .iter()
503            .enumerate()
504            .map(|(i, s)| (s.clone(), f.values.get(i).copied().unwrap_or(0.0)))
505            .collect();
506
507        let marginal_entropy = f.entropy();
508        let most_likely_state = distribution
509            .iter()
510            .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
511            .map(|(s, _)| s.clone())
512            .unwrap_or_default();
513
514        QueryResult {
515            variable: var.id.clone(),
516            distribution,
517            marginal_entropy,
518            most_likely_state,
519        }
520    }
521}
522
523/// Variable elimination ordering heuristic.
524#[derive(Debug, Clone, PartialEq, Default)]
525pub enum EliminationOrder {
526    /// Min-fill heuristic — eliminate the variable that adds the fewest edges.
527    #[default]
528    MinFill,
529    /// Min-degree heuristic — eliminate the variable with the fewest neighbors.
530    MinDegree,
531    /// Eliminate variables in the order they appear in the network.
532    Sequential,
533}
534
535/// Configuration for `BayesianNetworkInference`.
536#[derive(Debug, Clone)]
537pub struct BniConfig {
538    /// Maximum number of variables allowed.
539    pub max_variables: usize,
540    /// Maximum number of states per variable.
541    pub max_states_per_variable: usize,
542    /// Elimination ordering heuristic for variable elimination.
543    pub elimination_ordering: EliminationOrder,
544}
545
546impl Default for BniConfig {
547    fn default() -> Self {
548        Self {
549            max_variables: 256,
550            max_states_per_variable: 1024,
551            elimination_ordering: EliminationOrder::MinFill,
552        }
553    }
554}
555
556/// Runtime statistics for the inference engine.
557#[derive(Debug, Clone, Default)]
558pub struct BniStats {
559    /// Total number of queries answered.
560    pub queries_answered: u64,
561    /// Running average of factors eliminated per query.
562    pub avg_factors_eliminated: f64,
563    /// Number of query cache hits.
564    pub cache_hits: u64,
565}
566
567// ─────────────────────────────────────────────────────────────────────────────
568// The inference engine
569// ─────────────────────────────────────────────────────────────────────────────
570
571/// Bayesian network inference engine.
572///
573/// Supports variable elimination (exact), belief propagation (sum-product),
574/// and likelihood-weighted sampling.
575pub struct BayesianNetworkInference {
576    network: BayesianNetwork,
577    config: BniConfig,
578    stats: BniStats,
579    /// `var_id → cardinality` lookup, built at construction time.
580    cardinality_map: HashMap<String, usize>,
581}
582
583impl BayesianNetworkInference {
584    // ── construction ─────────────────────────────────────────────────────────
585
586    /// Create and validate a new inference engine.
587    ///
588    /// Returns an error if:
589    /// * the network contains a directed cycle,
590    /// * any CPT references an undefined variable,
591    /// * any CPT has incorrect dimensions.
592    pub fn new(network: BayesianNetwork, config: BniConfig) -> Result<Self, BniError> {
593        // Check variable count.
594        if network.variables.len() > config.max_variables {
595            return Err(BniError::InferenceError(format!(
596                "network has {} variables, max is {}",
597                network.variables.len(),
598                config.max_variables
599            )));
600        }
601
602        // Build cardinality map.
603        let mut cardinality_map: HashMap<String, usize> = network
604            .variables
605            .iter()
606            .map(|(id, v)| (id.clone(), v.cardinality))
607            .collect();
608
609        // Validate variable cardinalities.
610        for (id, var) in &network.variables {
611            if var.states.len() != var.cardinality {
612                return Err(BniError::InvalidCPT {
613                    variable: id.clone(),
614                    reason: format!(
615                        "cardinality {} does not match states.len() {}",
616                        var.cardinality,
617                        var.states.len()
618                    ),
619                });
620            }
621            if var.cardinality > config.max_states_per_variable {
622                return Err(BniError::InvalidCPT {
623                    variable: id.clone(),
624                    reason: format!(
625                        "cardinality {} exceeds max {}",
626                        var.cardinality, config.max_states_per_variable
627                    ),
628                });
629            }
630        }
631
632        // Detect cycles via DFS.
633        detect_cycles(&network)?;
634
635        // Validate CPTs.
636        for cpt in &network.cpts {
637            validate_cpt(cpt, &cardinality_map)?;
638        }
639
640        // Ensure cardinality map also covers implicit variables from CPTs.
641        for cpt in &network.cpts {
642            for v in &cpt.factor.variables {
643                cardinality_map.entry(v.clone()).or_insert_with(|| {
644                    cpt.factor.shape[cpt
645                        .factor
646                        .variables
647                        .iter()
648                        .position(|x| x == v)
649                        .unwrap_or(0)]
650                });
651            }
652        }
653
654        Ok(Self {
655            network,
656            config,
657            stats: BniStats::default(),
658            cardinality_map,
659        })
660    }
661
662    // ── public API ────────────────────────────────────────────────────────────
663
664    /// Run an inference query and return per-variable posterior distributions.
665    pub fn query(&mut self, q: &InferenceQuery) -> Result<Vec<QueryResult>, BniError> {
666        // Validate query variables.
667        for v in &q.query_variables {
668            if !self.network.variables.contains_key(v) {
669                return Err(BniError::VariableNotFound(v.clone()));
670            }
671        }
672
673        // Validate and deduplicate evidence.
674        let evidence_map = validate_evidence(&q.evidence, &self.network)?;
675
676        let results = match &q.algorithm {
677            InferenceAlgorithm::VariableElimination => {
678                self.run_variable_elimination(&q.query_variables, &evidence_map)?
679            }
680            InferenceAlgorithm::BeliefPropagation => {
681                self.run_belief_propagation(&q.query_variables, &evidence_map)?
682            }
683            InferenceAlgorithm::Sampling { n_samples, seed } => {
684                self.run_sampling(&q.query_variables, &evidence_map, *n_samples, *seed)?
685            }
686        };
687
688        self.stats.queries_answered += 1;
689        Ok(results)
690    }
691
692    /// Compute the prior marginal for a single variable (no evidence).
693    pub fn prior_marginal(&mut self, variable: &str) -> Result<QueryResult, BniError> {
694        if !self.network.variables.contains_key(variable) {
695            return Err(BniError::VariableNotFound(variable.to_string()));
696        }
697        let q = InferenceQuery {
698            query_variables: vec![variable.to_string()],
699            evidence: vec![],
700            algorithm: InferenceAlgorithm::VariableElimination,
701        };
702        let mut results = self.query(&q)?;
703        results
704            .pop()
705            .ok_or_else(|| BniError::InferenceError("no result produced".into()))
706    }
707
708    /// Add a new CPT to the network after construction.
709    ///
710    /// Validates dimensions and checks for duplicate variables.
711    pub fn add_cpt(&mut self, cpt: ConditionalProbabilityTable) -> Result<(), BniError> {
712        validate_cpt(&cpt, &self.cardinality_map)?;
713        // Remove any existing CPT for this variable.
714        self.network.cpts.retain(|c| c.variable != cpt.variable);
715        self.network.cpts.push(cpt);
716        Ok(())
717    }
718
719    /// Test d-separation: are `x` and `y` d-separated given observations `z`
720    /// (the Bayes Ball algorithm)?
721    ///
722    /// Returns `true` if `x ⊥ y | z`.
723    pub fn d_separated(&self, x: &str, y: &str, z: &[String]) -> Result<bool, BniError> {
724        if !self.network.variables.contains_key(x) {
725            return Err(BniError::VariableNotFound(x.to_string()));
726        }
727        if !self.network.variables.contains_key(y) {
728            return Err(BniError::VariableNotFound(y.to_string()));
729        }
730        for zi in z {
731            if !self.network.variables.contains_key(zi) {
732                return Err(BniError::VariableNotFound(zi.clone()));
733            }
734        }
735
736        let observed: HashSet<&str> = z.iter().map(String::as_str).collect();
737        let reachable = bayes_ball_reachable(x, &observed, &self.network);
738        Ok(!reachable.contains(y))
739    }
740
741    /// Return a snapshot of engine statistics.
742    pub fn stats(&self) -> BniStats {
743        self.stats.clone()
744    }
745
746    // ── variable elimination ──────────────────────────────────────────────────
747
748    fn run_variable_elimination(
749        &mut self,
750        query_vars: &[String],
751        evidence_map: &HashMap<String, usize>,
752    ) -> Result<Vec<QueryResult>, BniError> {
753        // 1. Start with all CPT factors.
754        let mut factors: Vec<Factor> = self.network.cpts.iter().map(|c| c.factor.clone()).collect();
755
756        // 2. Reduce factors by evidence.
757        for (var, &state_idx) in evidence_map {
758            factors = factors
759                .into_iter()
760                .map(|f| {
761                    if f.contains_variable(var) {
762                        f.reduce(var, state_idx, &self.cardinality_map)
763                    } else {
764                        f
765                    }
766                })
767                .collect();
768        }
769
770        // 3. Determine hidden variables to eliminate.
771        let query_set: HashSet<&str> = query_vars.iter().map(String::as_str).collect();
772        let evidence_set: HashSet<&str> = evidence_map.keys().map(String::as_str).collect();
773        let hidden_vars: Vec<String> = self
774            .network
775            .variables
776            .keys()
777            .filter(|v| !query_set.contains(v.as_str()) && !evidence_set.contains(v.as_str()))
778            .cloned()
779            .collect();
780
781        let elim_order = self.compute_elimination_order(&hidden_vars, &factors);
782
783        let mut eliminated = 0u64;
784        for var in &elim_order {
785            // Collect all factors that mention `var`.
786            let (relevant, rest): (Vec<_>, Vec<_>) =
787                factors.into_iter().partition(|f| f.contains_variable(var));
788
789            if relevant.is_empty() {
790                factors = rest;
791                continue;
792            }
793
794            // Multiply them all together.
795            let product = relevant
796                .into_iter()
797                .reduce(|acc, f| acc.product(&f))
798                .ok_or_else(|| BniError::InferenceError("empty factor product".into()))?;
799
800            // Marginalize out `var`.
801            let marginal = product.marginalize(var, &self.cardinality_map);
802            factors = rest;
803            factors.push(marginal);
804            eliminated += 1;
805        }
806
807        // Update stats.
808        let n = self.stats.queries_answered + 1;
809        self.stats.avg_factors_eliminated =
810            (self.stats.avg_factors_eliminated * (n.saturating_sub(1) as f64) + eliminated as f64)
811                / n as f64;
812
813        // 4. Build results per query variable.
814        let mut results = Vec::with_capacity(query_vars.len());
815        for var_id in query_vars {
816            let var = self
817                .network
818                .variables
819                .get(var_id)
820                .ok_or_else(|| BniError::VariableNotFound(var_id.clone()))?;
821
822            // Collect factors that mention this variable and multiply.
823            let (relevant, _): (Vec<_>, Vec<_>) = factors
824                .iter()
825                .cloned()
826                .partition(|f| f.contains_variable(var_id));
827
828            let mut joint = if relevant.is_empty() {
829                // Uniform factor.
830                Factor {
831                    id: format!("uniform_{var_id}"),
832                    variables: vec![var_id.clone()],
833                    values: vec![1.0 / var.cardinality as f64; var.cardinality],
834                    shape: vec![var.cardinality],
835                }
836            } else {
837                let product = relevant
838                    .into_iter()
839                    .reduce(|acc, f| acc.product(&f))
840                    .ok_or_else(|| BniError::InferenceError("empty factor product".into()))?;
841
842                // Marginalize all variables except the query variable.
843                let to_remove: Vec<String> = product
844                    .variables
845                    .iter()
846                    .filter(|v| v.as_str() != var_id.as_str())
847                    .cloned()
848                    .collect();
849                let mut f = product;
850                for v in &to_remove {
851                    f = f.marginalize(v, &self.cardinality_map);
852                }
853                f
854            };
855
856            joint.normalize();
857            results.push(QueryResult::from_factor(var, joint));
858        }
859
860        Ok(results)
861    }
862
863    // ── belief propagation ────────────────────────────────────────────────────
864
865    /// Sum-product message passing over the Bayesian network.
866    ///
867    /// Strategy: for each query variable, perform exact variable elimination
868    /// restricted to the Markov blanket of that variable, then normalise.
869    /// This is equivalent to a two-pass BP on trees and gives an exact answer
870    /// for the marginals when no loops are present.  For loopy graphs it still
871    /// produces a correct answer here because we rebuild the joint over the
872    /// full factor set for each query variable (similar to VE).
873    fn run_belief_propagation(
874        &mut self,
875        query_vars: &[String],
876        evidence_map: &HashMap<String, usize>,
877    ) -> Result<Vec<QueryResult>, BniError> {
878        // Reduce all factors by evidence first.
879        let factors: Vec<Factor> = self
880            .network
881            .cpts
882            .iter()
883            .map(|c| {
884                let mut f = c.factor.clone();
885                for (var, &state) in evidence_map {
886                    if f.contains_variable(var) {
887                        f = f.reduce(var, state, &self.cardinality_map);
888                    }
889                }
890                f
891            })
892            .collect();
893
894        let evidence_set: HashSet<&str> = evidence_map.keys().map(String::as_str).collect();
895
896        let mut results = Vec::with_capacity(query_vars.len());
897
898        for var_id in query_vars {
899            let var = self
900                .network
901                .variables
902                .get(var_id)
903                .ok_or_else(|| BniError::VariableNotFound(var_id.clone()))?;
904
905            // Eliminate all variables except the query variable.
906            // Elimination order: every variable that is not the query and not
907            // observed (already eliminated by evidence reduction).
908            let elim_vars: Vec<String> = self
909                .network
910                .variables
911                .keys()
912                .filter(|v| v.as_str() != var_id.as_str() && !evidence_set.contains(v.as_str()))
913                .cloned()
914                .collect();
915
916            let elim_order = self.compute_elimination_order(&elim_vars, &factors);
917
918            let mut local_factors = factors.clone();
919            for v in &elim_order {
920                let (relevant, rest): (Vec<_>, Vec<_>) = local_factors
921                    .into_iter()
922                    .partition(|f| f.contains_variable(v));
923                local_factors = rest;
924                if !relevant.is_empty() {
925                    let product = relevant
926                        .into_iter()
927                        .reduce(|acc, f| acc.product(&f))
928                        .ok_or_else(|| BniError::InferenceError("empty factor product".into()))?;
929                    let marginal = product.marginalize(v, &self.cardinality_map);
930                    if !marginal.variables.is_empty() {
931                        local_factors.push(marginal);
932                    }
933                }
934            }
935
936            // Multiply the remaining factors together and marginalise to the
937            // query variable.
938            let (relevant, _): (Vec<_>, Vec<_>) = local_factors
939                .into_iter()
940                .partition(|f| f.contains_variable(var_id));
941
942            let mut joint = if relevant.is_empty() {
943                Factor {
944                    id: format!("uniform_{var_id}"),
945                    variables: vec![var_id.clone()],
946                    values: vec![1.0 / var.cardinality as f64; var.cardinality],
947                    shape: vec![var.cardinality],
948                }
949            } else {
950                let product = relevant
951                    .into_iter()
952                    .reduce(|acc, f| acc.product(&f))
953                    .ok_or_else(|| BniError::InferenceError("empty factor product".into()))?;
954                let to_remove: Vec<String> = product
955                    .variables
956                    .iter()
957                    .filter(|v| v.as_str() != var_id.as_str())
958                    .cloned()
959                    .collect();
960                let mut f = product;
961                for v in &to_remove {
962                    f = f.marginalize(v, &self.cardinality_map);
963                }
964                f
965            };
966
967            joint.normalize();
968            results.push(QueryResult::from_factor(var, joint));
969        }
970
971        Ok(results)
972    }
973
974    // ── sampling ──────────────────────────────────────────────────────────────
975
976    /// Likelihood-weighted sampling.
977    fn run_sampling(
978        &mut self,
979        query_vars: &[String],
980        evidence_map: &HashMap<String, usize>,
981        n_samples: usize,
982        seed: u64,
983    ) -> Result<Vec<QueryResult>, BniError> {
984        let topo = topological_order(&self.network)?;
985        let mut rng = if seed == 0 {
986            0xDEAD_BEEF_CAFE_u64
987        } else {
988            seed
989        };
990
991        // Accumulate weighted counts: variable → state → weight.
992        let mut counts: HashMap<String, Vec<f64>> = HashMap::new();
993        for var_id in query_vars {
994            let var = self
995                .network
996                .variables
997                .get(var_id)
998                .ok_or_else(|| BniError::VariableNotFound(var_id.clone()))?;
999            counts.insert(var_id.clone(), vec![0.0; var.cardinality]);
1000        }
1001
1002        let n = if n_samples == 0 { 1000 } else { n_samples };
1003
1004        for _ in 0..n {
1005            let mut assignment: HashMap<String, usize> = HashMap::new();
1006            let mut weight = 1.0_f64;
1007
1008            for var_id in &topo {
1009                // Find the CPT for this variable.
1010                let cpt = match self.network.cpts.iter().find(|c| &c.variable == var_id) {
1011                    Some(c) => c,
1012                    None => continue,
1013                };
1014
1015                // Compute conditional distribution given parent assignment.
1016                let cond_dist = compute_conditional(&cpt.factor, var_id, &assignment)
1017                    .unwrap_or_else(|| {
1018                        let card = self
1019                            .network
1020                            .variables
1021                            .get(var_id)
1022                            .map(|v| v.cardinality)
1023                            .unwrap_or(2);
1024                        vec![1.0 / card as f64; card]
1025                    });
1026
1027                let sampled_state = if let Some(&observed) = evidence_map.get(var_id.as_str()) {
1028                    // Weight by likelihood of evidence.
1029                    weight *= cond_dist.get(observed).copied().unwrap_or(0.0);
1030                    observed
1031                } else {
1032                    sample_categorical(&cond_dist, &mut rng)
1033                };
1034
1035                assignment.insert(var_id.clone(), sampled_state);
1036            }
1037
1038            // Accumulate weighted counts for query variables.
1039            for (var_id, cnt) in &mut counts {
1040                if let Some(&state) = assignment.get(var_id) {
1041                    if let Some(c) = cnt.get_mut(state) {
1042                        *c += weight;
1043                    }
1044                }
1045            }
1046        }
1047
1048        // Normalize and produce results.
1049        let mut results = Vec::with_capacity(query_vars.len());
1050        for var_id in query_vars {
1051            let var = self
1052                .network
1053                .variables
1054                .get(var_id)
1055                .ok_or_else(|| BniError::VariableNotFound(var_id.clone()))?;
1056
1057            let cnt = counts.get(var_id).cloned().unwrap_or_default();
1058            let total: f64 = cnt.iter().sum();
1059            let values: Vec<f64> = if total > 0.0 {
1060                cnt.iter().map(|&c| c / total).collect()
1061            } else {
1062                vec![1.0 / var.cardinality as f64; var.cardinality]
1063            };
1064
1065            let factor = Factor {
1066                id: format!("sampled_{var_id}"),
1067                variables: vec![var_id.clone()],
1068                values,
1069                shape: vec![var.cardinality],
1070            };
1071            results.push(QueryResult::from_factor(var, factor));
1072        }
1073
1074        Ok(results)
1075    }
1076
1077    // ── elimination order heuristics ──────────────────────────────────────────
1078
1079    fn compute_elimination_order(&self, hidden_vars: &[String], factors: &[Factor]) -> Vec<String> {
1080        match self.config.elimination_ordering {
1081            EliminationOrder::Sequential => hidden_vars.to_vec(),
1082            EliminationOrder::MinDegree => min_degree_order(hidden_vars, factors),
1083            EliminationOrder::MinFill => min_fill_order(hidden_vars, factors),
1084        }
1085    }
1086}
1087
1088// ─────────────────────────────────────────────────────────────────────────────
1089// Helper functions (module-private)
1090// ─────────────────────────────────────────────────────────────────────────────
1091
1092/// Validate evidence: check all variables exist, deduplicate, detect conflicts.
1093fn validate_evidence(
1094    evidence: &[Evidence],
1095    network: &BayesianNetwork,
1096) -> Result<HashMap<String, usize>, BniError> {
1097    let mut map: HashMap<String, usize> = HashMap::new();
1098    for ev in evidence {
1099        let var = network
1100            .variables
1101            .get(&ev.variable)
1102            .ok_or_else(|| BniError::VariableNotFound(ev.variable.clone()))?;
1103
1104        let state_idx = var
1105            .states
1106            .iter()
1107            .position(|s| s == &ev.observed_state)
1108            .ok_or_else(|| BniError::InvalidCPT {
1109                variable: ev.variable.clone(),
1110                reason: format!("unknown state `{}`", ev.observed_state),
1111            })?;
1112
1113        if let Some(&existing) = map.get(&ev.variable) {
1114            if existing != state_idx {
1115                return Err(BniError::EvidenceConflict(ev.variable.clone()));
1116            }
1117        }
1118        map.insert(ev.variable.clone(), state_idx);
1119    }
1120    Ok(map)
1121}
1122
1123/// Validate a CPT: variable exists, parents exist, dimension matches.
1124fn validate_cpt(
1125    cpt: &ConditionalProbabilityTable,
1126    cardinality_map: &HashMap<String, usize>,
1127) -> Result<(), BniError> {
1128    // Check all variables referenced in the factor exist.
1129    for v in &cpt.factor.variables {
1130        if !cardinality_map.contains_key(v) {
1131            return Err(BniError::InvalidCPT {
1132                variable: cpt.variable.clone(),
1133                reason: format!("referenced variable `{v}` not in cardinality map"),
1134            });
1135        }
1136    }
1137
1138    // Check shape matches cardinalities.
1139    for (i, v) in cpt.factor.variables.iter().enumerate() {
1140        let expected = cardinality_map[v];
1141        let actual = cpt.factor.shape.get(i).copied().unwrap_or(0);
1142        if actual != expected {
1143            return Err(BniError::InvalidCPT {
1144                variable: cpt.variable.clone(),
1145                reason: format!("shape mismatch for `{v}`: expected {expected}, got {actual}"),
1146            });
1147        }
1148    }
1149
1150    // Check values length.
1151    let expected_len: usize = cpt.factor.shape.iter().product::<usize>().max(1);
1152    if cpt.factor.values.len() != expected_len {
1153        return Err(BniError::InvalidCPT {
1154            variable: cpt.variable.clone(),
1155            reason: format!(
1156                "values.len() = {}, expected {}",
1157                cpt.factor.values.len(),
1158                expected_len
1159            ),
1160        });
1161    }
1162
1163    Ok(())
1164}
1165
1166/// Detect directed cycles in the Bayesian network using DFS.
1167fn detect_cycles(network: &BayesianNetwork) -> Result<(), BniError> {
1168    let mut visited: HashSet<&str> = HashSet::new();
1169    let mut stack: HashSet<&str> = HashSet::new();
1170
1171    for var_id in network.variables.keys() {
1172        if !visited.contains(var_id.as_str()) {
1173            dfs_cycle(var_id, network, &mut visited, &mut stack)?;
1174        }
1175    }
1176    Ok(())
1177}
1178
1179fn dfs_cycle<'a>(
1180    node: &'a str,
1181    network: &'a BayesianNetwork,
1182    visited: &mut HashSet<&'a str>,
1183    stack: &mut HashSet<&'a str>,
1184) -> Result<(), BniError> {
1185    visited.insert(node);
1186    stack.insert(node);
1187
1188    // In BN adjacency (child → parents), we traverse from child to parents
1189    // which is the direction of dependency but reverse of edge direction.
1190    // Cycle detection operates on the parent → child direction, so we must
1191    // check the reverse: for each node, look at who lists it as a parent.
1192    // We instead build a forward map lazily.
1193    let children: Vec<&str> = network
1194        .adjacency
1195        .iter()
1196        .filter(|(_, parents)| parents.iter().any(|p| p.as_str() == node))
1197        .map(|(child, _)| child.as_str())
1198        .collect();
1199
1200    for child in children {
1201        if !visited.contains(child) {
1202            dfs_cycle(child, network, visited, stack)?;
1203        } else if stack.contains(child) {
1204            return Err(BniError::CyclicNetwork(format!(
1205                "cycle detected at `{child}`"
1206            )));
1207        }
1208    }
1209
1210    stack.remove(node);
1211    Ok(())
1212}
1213
1214/// Compute a topological order of variables (parents before children).
1215fn topological_order(network: &BayesianNetwork) -> Result<Vec<String>, BniError> {
1216    let mut in_degree: HashMap<&str, usize> = HashMap::new();
1217    let mut children_of: HashMap<&str, Vec<&str>> = HashMap::new();
1218
1219    for var_id in network.variables.keys() {
1220        in_degree.entry(var_id.as_str()).or_insert(0);
1221        children_of.entry(var_id.as_str()).or_default();
1222    }
1223
1224    for (child, parents) in &network.adjacency {
1225        for parent in parents {
1226            *in_degree.entry(child.as_str()).or_insert(0) += 1;
1227            children_of
1228                .entry(parent.as_str())
1229                .or_default()
1230                .push(child.as_str());
1231        }
1232    }
1233
1234    let mut queue: VecDeque<&str> = in_degree
1235        .iter()
1236        .filter(|(_, &d)| d == 0)
1237        .map(|(&v, _)| v)
1238        .collect();
1239    // Sort for determinism.
1240    let mut queue_vec: Vec<&str> = queue.drain(..).collect();
1241    queue_vec.sort_unstable();
1242    queue = queue_vec.into_iter().collect();
1243
1244    let mut order: Vec<String> = Vec::new();
1245    while let Some(node) = queue.pop_front() {
1246        order.push(node.to_string());
1247        let mut nexts: Vec<&str> = children_of.get(node).cloned().unwrap_or_default();
1248        nexts.sort_unstable();
1249        for child in nexts {
1250            let deg = in_degree.entry(child).or_insert(0);
1251            if *deg > 0 {
1252                *deg -= 1;
1253            }
1254            if *deg == 0 {
1255                queue.push_back(child);
1256            }
1257        }
1258    }
1259
1260    if order.len() != network.variables.len() {
1261        return Err(BniError::CyclicNetwork(
1262            "topological sort failed — cycle detected".into(),
1263        ));
1264    }
1265    Ok(order)
1266}
1267
1268/// Compute the conditional distribution P(var | parent_assignment) from a factor.
1269fn compute_conditional(
1270    factor: &Factor,
1271    var: &str,
1272    assignment: &HashMap<String, usize>,
1273) -> Option<Vec<f64>> {
1274    let var_dim = factor.variables.iter().position(|v| v == var)?;
1275    let card = factor.shape[var_dim];
1276
1277    // For each state of `var`, look up the value at current parent assignment.
1278    let mut dist = vec![0.0_f64; card];
1279    for (state, d) in dist.iter_mut().enumerate().take(card) {
1280        let mut idx = vec![0usize; factor.variables.len()];
1281        idx[var_dim] = state;
1282        // Fill parent indices.
1283        let mut ok = true;
1284        for (i, v) in factor.variables.iter().enumerate() {
1285            if i == var_dim {
1286                continue;
1287            }
1288            if let Some(&s) = assignment.get(v) {
1289                idx[i] = s;
1290            } else {
1291                ok = false;
1292                break;
1293            }
1294        }
1295        if ok {
1296            let flat = Factor::flat_index(&idx, &factor.shape);
1297            *d = factor.values.get(flat).copied().unwrap_or(0.0);
1298        }
1299    }
1300
1301    // Normalize.
1302    let sum: f64 = dist.iter().sum();
1303    if sum > 0.0 {
1304        for v in &mut dist {
1305            *v /= sum;
1306        }
1307    } else {
1308        let u = 1.0 / card as f64;
1309        dist.fill(u);
1310    }
1311    Some(dist)
1312}
1313
1314/// Min-degree elimination ordering.
1315fn min_degree_order(hidden_vars: &[String], factors: &[Factor]) -> Vec<String> {
1316    let mut remaining: Vec<String> = hidden_vars.to_vec();
1317    let mut order = Vec::with_capacity(remaining.len());
1318    let mut factor_copy = factors.to_vec();
1319
1320    while !remaining.is_empty() {
1321        // For each remaining variable, count how many other remaining variables
1322        // appear in the same factor (its "degree" in the induced graph).
1323        let best_idx = remaining
1324            .iter()
1325            .enumerate()
1326            .min_by_key(|(_, v)| {
1327                let neighbors: HashSet<&str> = factor_copy
1328                    .iter()
1329                    .filter(|f| f.contains_variable(v))
1330                    .flat_map(|f| f.variables.iter().map(String::as_str))
1331                    .filter(|&u| u != v.as_str() && remaining.iter().any(|r| r.as_str() == u))
1332                    .collect();
1333                neighbors.len()
1334            })
1335            .map(|(i, _)| i)
1336            .unwrap_or(0);
1337
1338        let var = remaining.remove(best_idx);
1339
1340        // Simulate elimination: multiply relevant factors and marginalize.
1341        let (relevant, rest): (Vec<_>, Vec<_>) = factor_copy
1342            .into_iter()
1343            .partition(|f| f.contains_variable(&var));
1344        factor_copy = rest;
1345        if !relevant.is_empty() {
1346            let product = relevant
1347                .into_iter()
1348                .reduce(|acc, f| acc.product(&f))
1349                .unwrap_or_else(|| Factor {
1350                    id: "empty".into(),
1351                    variables: vec![],
1352                    values: vec![1.0],
1353                    shape: vec![],
1354                });
1355            let dummy = HashMap::new();
1356            let marginal = product.marginalize(&var, &dummy);
1357            if !marginal.variables.is_empty() {
1358                factor_copy.push(marginal);
1359            }
1360        }
1361
1362        order.push(var);
1363    }
1364    order
1365}
1366
1367/// Min-fill elimination ordering.
1368fn min_fill_order(hidden_vars: &[String], factors: &[Factor]) -> Vec<String> {
1369    let mut remaining: Vec<String> = hidden_vars.to_vec();
1370    let mut order = Vec::with_capacity(remaining.len());
1371    let mut factor_copy = factors.to_vec();
1372
1373    while !remaining.is_empty() {
1374        // "Fill" for a variable = number of edges that would be added to the
1375        // induced graph by eliminating it.
1376        let best_idx = remaining
1377            .iter()
1378            .enumerate()
1379            .min_by_key(|(_, v)| {
1380                // Neighbours of v in the factor graph restricted to remaining vars.
1381                let neighbors: Vec<&str> = factor_copy
1382                    .iter()
1383                    .filter(|f| f.contains_variable(v))
1384                    .flat_map(|f| f.variables.iter().map(String::as_str))
1385                    .filter(|&u| u != v.as_str() && remaining.iter().any(|r| r.as_str() == u))
1386                    .collect::<HashSet<_>>()
1387                    .into_iter()
1388                    .collect();
1389                // Count pairs not yet connected.
1390                let mut fill = 0usize;
1391                for (i, &ni) in neighbors.iter().enumerate() {
1392                    for &nj in &neighbors[i + 1..] {
1393                        // Check if ni and nj already share a factor.
1394                        let connected = factor_copy
1395                            .iter()
1396                            .any(|f| f.contains_variable(ni) && f.contains_variable(nj));
1397                        if !connected {
1398                            fill += 1;
1399                        }
1400                    }
1401                }
1402                fill
1403            })
1404            .map(|(i, _)| i)
1405            .unwrap_or(0);
1406
1407        let var = remaining.remove(best_idx);
1408
1409        // Simulate elimination.
1410        let (relevant, rest): (Vec<_>, Vec<_>) = factor_copy
1411            .into_iter()
1412            .partition(|f| f.contains_variable(&var));
1413        factor_copy = rest;
1414        if !relevant.is_empty() {
1415            let product = relevant
1416                .into_iter()
1417                .reduce(|acc, f| acc.product(&f))
1418                .unwrap_or_else(|| Factor {
1419                    id: "empty".into(),
1420                    variables: vec![],
1421                    values: vec![1.0],
1422                    shape: vec![],
1423                });
1424            let dummy = HashMap::new();
1425            let marginal = product.marginalize(&var, &dummy);
1426            if !marginal.variables.is_empty() {
1427                factor_copy.push(marginal);
1428            }
1429        }
1430
1431        order.push(var);
1432    }
1433    order
1434}
1435
1436/// Bayes Ball algorithm: find all nodes reachable from `source` given
1437/// observations `observed`.
1438///
1439/// Returns the set of variable ids that are d-connected to `source`.
1440fn bayes_ball_reachable<'a>(
1441    source: &'a str,
1442    observed: &HashSet<&'a str>,
1443    network: &'a BayesianNetwork,
1444) -> HashSet<&'a str> {
1445    // Build parent map: var → parents and child map: parent → children.
1446    let parent_of: &HashMap<String, Vec<String>> = &network.adjacency;
1447    let mut child_of: HashMap<&str, Vec<&str>> = HashMap::new();
1448    for (child, parents) in parent_of {
1449        for parent in parents {
1450            child_of
1451                .entry(parent.as_str())
1452                .or_default()
1453                .push(child.as_str());
1454        }
1455    }
1456
1457    // (node, came_from_child): true = ball came from child (upward pass)
1458    let mut queue: VecDeque<(&str, bool)> = VecDeque::new();
1459    let mut visited_up: HashSet<&str> = HashSet::new();
1460    let mut visited_down: HashSet<&str> = HashSet::new();
1461    let mut reachable: HashSet<&str> = HashSet::new();
1462
1463    // Schedule both directions from source.
1464    queue.push_back((source, true));
1465    queue.push_back((source, false));
1466
1467    while let Some((node, from_child)) = queue.pop_front() {
1468        if from_child {
1469            if visited_up.contains(node) {
1470                continue;
1471            }
1472            visited_up.insert(node);
1473        } else {
1474            if visited_down.contains(node) {
1475                continue;
1476            }
1477            visited_down.insert(node);
1478        }
1479
1480        if node != source {
1481            reachable.insert(node);
1482        }
1483
1484        let is_observed = observed.contains(node);
1485
1486        if from_child && !is_observed {
1487            // Pass through to parents (d-connected path goes up).
1488            for parent in parent_of.get(node).map(|v| v.as_slice()).unwrap_or(&[]) {
1489                queue.push_back((parent.as_str(), true));
1490            }
1491            // Also send down to other children.
1492            for &child in child_of.get(node).map(|v| v.as_slice()).unwrap_or(&[]) {
1493                queue.push_back((child, false));
1494            }
1495        } else if !from_child {
1496            if !is_observed {
1497                // Send down to children.
1498                for &child in child_of.get(node).map(|v| v.as_slice()).unwrap_or(&[]) {
1499                    queue.push_back((child, false));
1500                }
1501            }
1502            // v-structure: if observed or descendant of observed, send to parents.
1503            if is_observed {
1504                for parent in parent_of.get(node).map(|v| v.as_slice()).unwrap_or(&[]) {
1505                    queue.push_back((parent.as_str(), true));
1506                }
1507            }
1508        }
1509    }
1510
1511    reachable
1512}
1513
1514// ─────────────────────────────────────────────────────────────────────────────
1515// Tests
1516// ─────────────────────────────────────────────────────────────────────────────
1517
1518// ─────────────────────────────────────────────────────────────────────────────
1519// Tests
1520// ─────────────────────────────────────────────────────────────────────────────
1521
1522#[cfg(test)]
1523mod tests {
1524    use super::*;
1525
1526    include!("bni_tests.rs");
1527}