1use std::collections::{HashMap, HashSet, VecDeque};
85use thiserror::Error;
86
87pub 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
101fn 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#[derive(Debug, Error, Clone, PartialEq)]
121pub enum BniError {
122 #[error("variable not found: {0}")]
124 VariableNotFound(String),
125
126 #[error("invalid CPT for variable `{variable}`: {reason}")]
128 InvalidCPT {
129 variable: String,
131 reason: String,
133 },
134
135 #[error("evidence conflict for variable: {0}")]
137 EvidenceConflict(String),
138
139 #[error("cyclic network detected: {0}")]
141 CyclicNetwork(String),
142
143 #[error("inference error: {0}")]
145 InferenceError(String),
146}
147
148#[derive(Debug, Clone, PartialEq)]
154pub struct RandomVariable {
155 pub id: String,
157 pub states: Vec<String>,
159 pub cardinality: usize,
161}
162
163#[derive(Debug, Clone, PartialEq)]
168pub struct Factor {
169 pub id: String,
171 pub variables: Vec<String>,
173 pub values: Vec<f64>,
175 pub shape: Vec<usize>,
177}
178
179impl Factor {
180 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 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 pub fn product(&self, other: &Factor) -> Factor {
211 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 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 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 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 pub fn marginalize(&self, variable: &str, _var_map: &HashMap<String, usize>) -> Factor {
267 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 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 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 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 pub fn contains_variable(&self, variable: &str) -> bool {
397 self.variables.iter().any(|v| v == variable)
398 }
399
400 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#[derive(Debug, Clone, PartialEq)]
413pub struct ConditionalProbabilityTable {
414 pub variable: String,
416 pub parents: Vec<String>,
418 pub factor: Factor,
420}
421
422#[derive(Debug, Clone, PartialEq)]
424pub struct Evidence {
425 pub variable: String,
427 pub observed_state: String,
429}
430
431#[derive(Debug, Clone)]
433pub struct BayesianNetwork {
434 pub variables: HashMap<String, RandomVariable>,
436 pub cpts: Vec<ConditionalProbabilityTable>,
438 pub adjacency: HashMap<String, Vec<String>>,
440}
441
442#[derive(Debug, Clone, PartialEq)]
444pub enum InferenceAlgorithm {
445 VariableElimination,
447 BeliefPropagation,
449 Sampling {
451 n_samples: usize,
453 seed: u64,
455 },
456}
457
458#[derive(Debug, Clone)]
460pub struct InferenceQuery {
461 pub query_variables: Vec<String>,
463 pub evidence: Vec<Evidence>,
465 pub algorithm: InferenceAlgorithm,
467}
468
469#[derive(Debug, Clone, PartialEq)]
471pub struct QueryResult {
472 pub variable: String,
474 pub distribution: Vec<(String, f64)>,
476 pub marginal_entropy: f64,
478 pub most_likely_state: String,
480}
481
482impl QueryResult {
483 fn from_factor(var: &RandomVariable, mut f: Factor) -> Self {
484 if !f.variables.is_empty() && f.variables[0] != var.id {
486 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#[derive(Debug, Clone, PartialEq, Default)]
525pub enum EliminationOrder {
526 #[default]
528 MinFill,
529 MinDegree,
531 Sequential,
533}
534
535#[derive(Debug, Clone)]
537pub struct BniConfig {
538 pub max_variables: usize,
540 pub max_states_per_variable: usize,
542 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#[derive(Debug, Clone, Default)]
558pub struct BniStats {
559 pub queries_answered: u64,
561 pub avg_factors_eliminated: f64,
563 pub cache_hits: u64,
565}
566
567pub struct BayesianNetworkInference {
576 network: BayesianNetwork,
577 config: BniConfig,
578 stats: BniStats,
579 cardinality_map: HashMap<String, usize>,
581}
582
583impl BayesianNetworkInference {
584 pub fn new(network: BayesianNetwork, config: BniConfig) -> Result<Self, BniError> {
593 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 let mut cardinality_map: HashMap<String, usize> = network
604 .variables
605 .iter()
606 .map(|(id, v)| (id.clone(), v.cardinality))
607 .collect();
608
609 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(&network)?;
634
635 for cpt in &network.cpts {
637 validate_cpt(cpt, &cardinality_map)?;
638 }
639
640 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 pub fn query(&mut self, q: &InferenceQuery) -> Result<Vec<QueryResult>, BniError> {
666 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 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 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 pub fn add_cpt(&mut self, cpt: ConditionalProbabilityTable) -> Result<(), BniError> {
712 validate_cpt(&cpt, &self.cardinality_map)?;
713 self.network.cpts.retain(|c| c.variable != cpt.variable);
715 self.network.cpts.push(cpt);
716 Ok(())
717 }
718
719 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 pub fn stats(&self) -> BniStats {
743 self.stats.clone()
744 }
745
746 fn run_variable_elimination(
749 &mut self,
750 query_vars: &[String],
751 evidence_map: &HashMap<String, usize>,
752 ) -> Result<Vec<QueryResult>, BniError> {
753 let mut factors: Vec<Factor> = self.network.cpts.iter().map(|c| c.factor.clone()).collect();
755
756 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 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 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 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 let marginal = product.marginalize(var, &self.cardinality_map);
802 factors = rest;
803 factors.push(marginal);
804 eliminated += 1;
805 }
806
807 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 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 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 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 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 fn run_belief_propagation(
874 &mut self,
875 query_vars: &[String],
876 evidence_map: &HashMap<String, usize>,
877 ) -> Result<Vec<QueryResult>, BniError> {
878 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 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 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 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 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 let cpt = match self.network.cpts.iter().find(|c| &c.variable == var_id) {
1011 Some(c) => c,
1012 None => continue,
1013 };
1014
1015 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 *= 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 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 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 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
1088fn 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
1123fn validate_cpt(
1125 cpt: &ConditionalProbabilityTable,
1126 cardinality_map: &HashMap<String, usize>,
1127) -> Result<(), BniError> {
1128 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 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 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
1166fn 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 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
1214fn 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 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
1268fn 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 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 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 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
1314fn 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 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 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
1367fn 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 let best_idx = remaining
1377 .iter()
1378 .enumerate()
1379 .min_by_key(|(_, v)| {
1380 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 let mut fill = 0usize;
1391 for (i, &ni) in neighbors.iter().enumerate() {
1392 for &nj in &neighbors[i + 1..] {
1393 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 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
1436fn bayes_ball_reachable<'a>(
1441 source: &'a str,
1442 observed: &HashSet<&'a str>,
1443 network: &'a BayesianNetwork,
1444) -> HashSet<&'a str> {
1445 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 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 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 for parent in parent_of.get(node).map(|v| v.as_slice()).unwrap_or(&[]) {
1489 queue.push_back((parent.as_str(), true));
1490 }
1491 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 for &child in child_of.get(node).map(|v| v.as_slice()).unwrap_or(&[]) {
1499 queue.push_back((child, false));
1500 }
1501 }
1502 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#[cfg(test)]
1523mod tests {
1524 use super::*;
1525
1526 include!("bni_tests.rs");
1527}