1use std::collections::{HashMap, HashSet, VecDeque};
26
27#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
29pub struct CausalNodeId(pub String);
30
31impl CausalNodeId {
32 pub fn new(s: impl Into<String>) -> Self {
34 Self(s.into())
35 }
36
37 pub fn as_str(&self) -> &str {
39 &self.0
40 }
41}
42
43impl std::fmt::Display for CausalNodeId {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.write_str(&self.0)
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub enum CausalEdgeType {
54 Direct,
56 Confounded,
58 Backdoor,
60 Instrumental,
62}
63
64#[derive(Debug, Clone)]
66pub struct CausalEdge {
67 pub from: CausalNodeId,
69 pub to: CausalNodeId,
71 pub strength: f64,
73 pub edge_type: CausalEdgeType,
75}
76
77impl CausalEdge {
78 pub fn direct(from: impl Into<String>, to: impl Into<String>, strength: f64) -> Self {
80 Self {
81 from: CausalNodeId::new(from),
82 to: CausalNodeId::new(to),
83 strength,
84 edge_type: CausalEdgeType::Direct,
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
93pub struct CausalNode {
94 pub id: CausalNodeId,
96 pub parents: Vec<CausalNodeId>,
98 pub children: Vec<CausalNodeId>,
100 pub mean: f64,
102 pub variance: f64,
104}
105
106impl CausalNode {
107 pub fn new(id: impl Into<String>, mean: f64, variance: f64) -> Self {
109 Self {
110 id: CausalNodeId::new(id),
111 parents: Vec::new(),
112 children: Vec::new(),
113 mean,
114 variance,
115 }
116 }
117}
118
119#[derive(Debug, Default, Clone)]
123pub struct CausalGraph {
124 pub nodes: HashMap<CausalNodeId, CausalNode>,
126 pub edges: Vec<CausalEdge>,
128}
129
130#[derive(Debug, Clone)]
134pub struct Intervention {
135 pub node: CausalNodeId,
137 pub value: f64,
139}
140
141impl Intervention {
142 pub fn new(node: impl Into<String>, value: f64) -> Self {
144 Self {
145 node: CausalNodeId::new(node),
146 value,
147 }
148 }
149}
150
151#[derive(Debug, Clone)]
154pub struct CounterfactualQuery {
155 pub target: CausalNodeId,
157 pub intervention: Intervention,
159 pub evidence: HashMap<CausalNodeId, f64>,
161}
162
163#[derive(Debug, Clone)]
165pub struct InferenceResult {
166 pub target: CausalNodeId,
168 pub mean: f64,
170 pub variance: f64,
172 pub confidence: f64,
174 pub interventions_applied: Vec<Intervention>,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum CausalError {
183 NodeAlreadyExists(String),
185 NodeNotFound(String),
187 CycleDetected,
189 InvalidEdge(String),
191}
192
193impl std::fmt::Display for CausalError {
194 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195 match self {
196 Self::NodeAlreadyExists(id) => write!(f, "node already exists: {id}"),
197 Self::NodeNotFound(id) => write!(f, "node not found: {id}"),
198 Self::CycleDetected => write!(f, "adding this edge would create a cycle"),
199 Self::InvalidEdge(msg) => write!(f, "invalid edge: {msg}"),
200 }
201 }
202}
203
204impl std::error::Error for CausalError {}
205
206#[derive(Debug, Clone)]
210pub struct CausalStats {
211 pub node_count: usize,
213 pub edge_count: usize,
215 pub avg_children: f64,
217 pub max_depth: usize,
219}
220
221#[derive(Debug)]
244pub struct CausalInferenceEngine {
245 pub graph: CausalGraph,
247 pub max_path_length: usize,
249}
250
251impl CausalInferenceEngine {
252 pub fn new(max_path_length: usize) -> Self {
256 Self {
257 graph: CausalGraph::default(),
258 max_path_length,
259 }
260 }
261
262 pub fn add_node(&mut self, node: CausalNode) -> Result<(), CausalError> {
268 if self.graph.nodes.contains_key(&node.id) {
269 return Err(CausalError::NodeAlreadyExists(node.id.0.clone()));
270 }
271 self.graph.nodes.insert(node.id.clone(), node);
272 Ok(())
273 }
274
275 pub fn add_edge(&mut self, edge: CausalEdge) -> Result<(), CausalError> {
281 if !self.graph.nodes.contains_key(&edge.from) {
282 return Err(CausalError::NodeNotFound(edge.from.0.clone()));
283 }
284 if !self.graph.nodes.contains_key(&edge.to) {
285 return Err(CausalError::NodeNotFound(edge.to.0.clone()));
286 }
287 if edge.from == edge.to {
288 return Err(CausalError::InvalidEdge("self-loop is not allowed".into()));
289 }
290
291 if self.has_path(&edge.to, &edge.from) {
294 return Err(CausalError::CycleDetected);
295 }
296
297 let from_id = edge.from.clone();
299 let to_id = edge.to.clone();
300
301 if let Some(from_node) = self.graph.nodes.get_mut(&from_id) {
302 if !from_node.children.contains(&to_id) {
303 from_node.children.push(to_id.clone());
304 }
305 }
306 if let Some(to_node) = self.graph.nodes.get_mut(&to_id) {
307 if !to_node.parents.contains(&from_id) {
308 to_node.parents.push(from_id);
309 }
310 }
311
312 self.graph.edges.push(edge);
313 Ok(())
314 }
315
316 pub fn remove_node(&mut self, id: &CausalNodeId) -> bool {
320 if !self.graph.nodes.contains_key(id) {
321 return false;
322 }
323
324 self.graph.edges.retain(|e| &e.from != id && &e.to != id);
326
327 for node in self.graph.nodes.values_mut() {
329 node.parents.retain(|p| p != id);
330 node.children.retain(|c| c != id);
331 }
332
333 self.graph.nodes.remove(id);
334 true
335 }
336
337 pub fn has_path(&self, from: &CausalNodeId, to: &CausalNodeId) -> bool {
342 if from == to {
343 return true;
344 }
345 let mut visited: HashSet<&CausalNodeId> = HashSet::new();
346 let mut stack: Vec<(&CausalNodeId, usize)> = vec![(from, 0)];
347 while let Some((current, depth)) = stack.pop() {
348 if current == to {
349 return true;
350 }
351 if depth >= self.max_path_length {
352 continue;
353 }
354 if !visited.insert(current) {
355 continue;
356 }
357 if let Some(node) = self.graph.nodes.get(current) {
358 for child in &node.children {
359 stack.push((child, depth + 1));
360 }
361 }
362 }
363 false
364 }
365
366 pub fn is_ancestor(&self, ancestor: &CausalNodeId, descendant: &CausalNodeId) -> bool {
369 if ancestor == descendant {
370 return false;
371 }
372 self.has_path(ancestor, descendant)
373 }
374
375 pub fn ancestors(&self, id: &CausalNodeId) -> Vec<CausalNodeId> {
377 let mut result: Vec<CausalNodeId> = Vec::new();
378 let mut visited: HashSet<CausalNodeId> = HashSet::new();
379 let mut queue: VecDeque<CausalNodeId> = VecDeque::new();
380
381 if let Some(node) = self.graph.nodes.get(id) {
382 for p in &node.parents {
383 queue.push_back(p.clone());
384 }
385 }
386 while let Some(current) = queue.pop_front() {
387 if !visited.insert(current.clone()) {
388 continue;
389 }
390 result.push(current.clone());
391 if let Some(node) = self.graph.nodes.get(¤t) {
392 for p in &node.parents {
393 if !visited.contains(p) {
394 queue.push_back(p.clone());
395 }
396 }
397 }
398 }
399 result
400 }
401
402 pub fn descendants(&self, id: &CausalNodeId) -> Vec<CausalNodeId> {
404 let mut result: Vec<CausalNodeId> = Vec::new();
405 let mut visited: HashSet<CausalNodeId> = HashSet::new();
406 let mut queue: VecDeque<CausalNodeId> = VecDeque::new();
407
408 if let Some(node) = self.graph.nodes.get(id) {
409 for c in &node.children {
410 queue.push_back(c.clone());
411 }
412 }
413 while let Some(current) = queue.pop_front() {
414 if !visited.insert(current.clone()) {
415 continue;
416 }
417 result.push(current.clone());
418 if let Some(node) = self.graph.nodes.get(¤t) {
419 for c in &node.children {
420 if !visited.contains(c) {
421 queue.push_back(c.clone());
422 }
423 }
424 }
425 }
426 result
427 }
428
429 pub fn all_directed_paths(
434 &self,
435 from: &CausalNodeId,
436 to: &CausalNodeId,
437 ) -> Vec<Vec<CausalNodeId>> {
438 let mut paths: Vec<Vec<CausalNodeId>> = Vec::new();
439 let mut current_path: Vec<CausalNodeId> = vec![from.clone()];
440 self.dfs_paths(from, to, &mut current_path, &mut paths);
441 paths
442 }
443
444 fn dfs_paths(
445 &self,
446 current: &CausalNodeId,
447 target: &CausalNodeId,
448 path: &mut Vec<CausalNodeId>,
449 results: &mut Vec<Vec<CausalNodeId>>,
450 ) {
451 if path.len() > self.max_path_length + 1 {
452 return;
453 }
454 if current == target && path.len() > 1 {
455 results.push(path.clone());
456 return;
457 }
458 if let Some(node) = self.graph.nodes.get(current) {
459 for child in &node.children {
460 if path.contains(child) {
462 continue;
463 }
464 path.push(child.clone());
465 self.dfs_paths(child, target, path, results);
466 path.pop();
467 }
468 }
469 }
470
471 pub fn backdoor_paths(&self, from: &CausalNodeId, to: &CausalNodeId) -> Vec<Vec<CausalNodeId>> {
477 let Some(from_node) = self.graph.nodes.get(from) else {
478 return Vec::new();
479 };
480 let parents: Vec<CausalNodeId> = from_node.parents.clone();
481 let mut all_paths: Vec<Vec<CausalNodeId>> = Vec::new();
482
483 for parent in &parents {
484 let mut path: Vec<CausalNodeId> = vec![from.clone(), parent.clone()];
490 self.backdoor_dfs(parent, to, from, &mut path, &mut all_paths);
491 }
492 all_paths
493 }
494
495 fn backdoor_dfs(
496 &self,
497 current: &CausalNodeId,
498 target: &CausalNodeId,
499 source: &CausalNodeId, path: &mut Vec<CausalNodeId>,
501 results: &mut Vec<Vec<CausalNodeId>>,
502 ) {
503 if path.len() > self.max_path_length + 1 {
504 return;
505 }
506 if current == target && path.len() > 2 {
507 results.push(path.clone());
508 return;
509 }
510 let mut neighbours: Vec<CausalNodeId> = Vec::new();
512 if let Some(node) = self.graph.nodes.get(current) {
513 for c in &node.children {
514 neighbours.push(c.clone());
515 }
516 for p in &node.parents {
517 neighbours.push(p.clone());
518 }
519 }
520 for neighbour in &neighbours {
521 if neighbour == source {
522 continue;
523 }
524 if path.contains(neighbour) {
525 continue;
526 }
527 path.push(neighbour.clone());
528 self.backdoor_dfs(neighbour, target, source, path, results);
529 path.pop();
530 }
531 }
532
533 fn direct_edge_strength(&self, from: &CausalNodeId, to: &CausalNodeId) -> f64 {
538 self.graph
539 .edges
540 .iter()
541 .find(|e| &e.from == from && &e.to == to)
542 .map(|e| e.strength)
543 .unwrap_or(0.0)
544 }
545
546 fn path_effect(&self, path: &[CausalNodeId]) -> f64 {
552 if path.len() < 2 {
553 return 0.0;
554 }
555 let mut product = 1.0_f64;
556 for window in path.windows(2) {
557 let strength = self.direct_edge_strength(&window[0], &window[1]);
558 product *= strength;
559 }
560 product
561 }
562
563 pub fn do_calculus(
574 &self,
575 intervention: &Intervention,
576 target: &CausalNodeId,
577 ) -> InferenceResult {
578 let paths = self.all_directed_paths(&intervention.node, target);
580
581 let total_path_effect: f64 = paths.iter().map(|p| self.path_effect(p)).sum();
582 let total_explained_variance: f64 = total_path_effect.powi(2).min(1.0);
583
584 let target_variance = self
585 .graph
586 .nodes
587 .get(target)
588 .map(|n| n.variance)
589 .unwrap_or(1.0);
590
591 let mean = intervention.value * total_path_effect;
592 let variance = target_variance * (1.0 - total_explained_variance.min(0.99));
593 let confidence = total_explained_variance.clamp(0.0, 1.0);
594
595 InferenceResult {
596 target: target.clone(),
597 mean,
598 variance,
599 confidence,
600 interventions_applied: vec![intervention.clone()],
601 }
602 }
603
604 pub fn counterfactual(&self, query: &CounterfactualQuery) -> InferenceResult {
613 let mut base = self.do_calculus(&query.intervention, &query.target);
614
615 let evidence_correction: f64 = query
617 .evidence
618 .iter()
619 .map(|(ev_node, &ev_value)| {
620 let strength = self.direct_edge_strength(ev_node, &query.target);
621 ev_value * strength
622 })
623 .sum();
624
625 base.mean += evidence_correction;
626 for (ev_node, &ev_value) in &query.evidence {
628 base.interventions_applied.push(Intervention {
629 node: ev_node.clone(),
630 value: ev_value,
631 });
632 }
633 base
634 }
635
636 pub fn average_causal_effect(
642 &self,
643 from: &CausalNodeId,
644 to: &CausalNodeId,
645 value1: f64,
646 value2: f64,
647 ) -> f64 {
648 let int1 = Intervention {
649 node: from.clone(),
650 value: value1,
651 };
652 let int2 = Intervention {
653 node: from.clone(),
654 value: value2,
655 };
656 self.do_calculus(&int2, to).mean - self.do_calculus(&int1, to).mean
657 }
658
659 pub fn confounders(&self, x: &CausalNodeId, y: &CausalNodeId) -> Vec<CausalNodeId> {
663 let anc_x: HashSet<CausalNodeId> = self.ancestors(x).into_iter().collect();
664 let anc_y: HashSet<CausalNodeId> = self.ancestors(y).into_iter().collect();
665 let mut common: Vec<CausalNodeId> = anc_x.intersection(&anc_y).cloned().collect();
666 common.sort();
667 common
668 }
669
670 pub fn is_d_separated(
683 &self,
684 x: &CausalNodeId,
685 y: &CausalNodeId,
686 given: &[CausalNodeId],
687 ) -> bool {
688 let given_set: HashSet<&CausalNodeId> = given.iter().collect();
689
690 for edge in &self.graph.edges {
692 if &edge.from == x && &edge.to == y && !given_set.contains(y) {
693 return false;
694 }
695 }
696
697 let directed_paths = self.all_directed_paths(x, y);
700 for path in &directed_paths {
701 let intermediate_nodes = &path[1..path.len().saturating_sub(1)];
702 let blocked = intermediate_nodes.iter().any(|n| given_set.contains(n));
703 if !blocked {
704 return false;
705 }
706 }
707
708 let bd_paths = self.backdoor_paths(x, y);
710 for path in &bd_paths {
711 let intermediate_nodes = if path.len() > 2 {
712 &path[1..path.len() - 1]
713 } else {
714 &path[1..path.len()]
715 };
716 let blocked = intermediate_nodes.iter().any(|n| given_set.contains(n));
717 if !blocked {
718 return false;
719 }
720 }
721
722 true
723 }
724
725 pub fn stats(&self) -> CausalStats {
729 let node_count = self.graph.nodes.len();
730 let edge_count = self.graph.edges.len();
731
732 let avg_children = if node_count == 0 {
733 0.0
734 } else {
735 self.graph
736 .nodes
737 .values()
738 .map(|n| n.children.len() as f64)
739 .sum::<f64>()
740 / node_count as f64
741 };
742
743 let max_depth = self.compute_max_depth();
745
746 CausalStats {
747 node_count,
748 edge_count,
749 avg_children,
750 max_depth,
751 }
752 }
753
754 fn compute_max_depth(&self) -> usize {
756 let roots: Vec<&CausalNodeId> = self
757 .graph
758 .nodes
759 .values()
760 .filter(|n| n.parents.is_empty())
761 .map(|n| &n.id)
762 .collect();
763
764 let mut max_depth = 0usize;
765 for root in roots {
766 let depth = self.bfs_depth(root);
767 if depth > max_depth {
768 max_depth = depth;
769 }
770 }
771 max_depth
772 }
773
774 fn bfs_depth(&self, root: &CausalNodeId) -> usize {
775 let mut queue: VecDeque<(&CausalNodeId, usize)> = VecDeque::new();
776 queue.push_back((root, 0));
777 let mut max_depth = 0usize;
778 while let Some((current, depth)) = queue.pop_front() {
779 if depth > max_depth {
780 max_depth = depth;
781 }
782 if let Some(node) = self.graph.nodes.get(current) {
783 for child in &node.children {
784 queue.push_back((child, depth + 1));
785 }
786 }
787 }
788 max_depth
789 }
790}
791
792#[cfg(test)]
797mod tests {
798 use std::collections::HashMap;
799
800 use crate::causal_inference::{
801 CausalEdge, CausalEdgeType, CausalError, CausalInferenceEngine, CausalNode, CausalNodeId,
802 CounterfactualQuery, Intervention,
803 };
804
805 fn simple_xy() -> CausalInferenceEngine {
809 let mut engine = CausalInferenceEngine::new(10);
810 engine
811 .add_node(CausalNode::new("X", 0.0, 1.0))
812 .expect("test setup: add_node should not fail for unique node");
813 engine
814 .add_node(CausalNode::new("Y", 0.0, 1.0))
815 .expect("test setup: add_node should not fail for unique node");
816 engine
817 .add_edge(CausalEdge::direct("X", "Y", 0.5))
818 .expect("test setup: add_edge should not fail for valid DAG edge");
819 engine
820 }
821
822 fn chain_xmy() -> CausalInferenceEngine {
824 let mut engine = CausalInferenceEngine::new(10);
825 engine
826 .add_node(CausalNode::new("X", 0.0, 1.0))
827 .expect("test setup: add_node should not fail for unique node");
828 engine
829 .add_node(CausalNode::new("M", 0.0, 1.0))
830 .expect("test setup: add_node should not fail for unique node");
831 engine
832 .add_node(CausalNode::new("Y", 0.0, 1.0))
833 .expect("test setup: add_node should not fail for unique node");
834 engine
835 .add_edge(CausalEdge::direct("X", "M", 0.6))
836 .expect("test setup: add_edge should not fail for valid DAG edge");
837 engine
838 .add_edge(CausalEdge::direct("M", "Y", 0.8))
839 .expect("test setup: add_edge should not fail for valid DAG edge");
840 engine
841 }
842
843 #[test]
846 fn test_new_engine_is_empty() {
847 let engine = CausalInferenceEngine::new(5);
848 assert_eq!(engine.graph.nodes.len(), 0);
849 assert_eq!(engine.graph.edges.len(), 0);
850 assert_eq!(engine.max_path_length, 5);
851 }
852
853 #[test]
856 fn test_add_node_success() {
857 let mut engine = CausalInferenceEngine::new(10);
858 let result = engine.add_node(CausalNode::new("A", 1.0, 2.0));
859 assert!(result.is_ok());
860 assert!(engine.graph.nodes.contains_key(&CausalNodeId::new("A")));
861 }
862
863 #[test]
864 fn test_add_node_duplicate_returns_error() {
865 let mut engine = CausalInferenceEngine::new(10);
866 engine
867 .add_node(CausalNode::new("A", 0.0, 1.0))
868 .expect("test setup: add_node should not fail for unique node");
869 let err = engine.add_node(CausalNode::new("A", 1.0, 2.0)).unwrap_err();
870 assert_eq!(err, CausalError::NodeAlreadyExists("A".into()));
871 }
872
873 #[test]
876 fn test_add_edge_success_updates_parent_children() {
877 let engine = simple_xy();
878 let x = engine
879 .graph
880 .nodes
881 .get(&CausalNodeId::new("X"))
882 .expect("test setup: node must exist in graph");
883 let y = engine
884 .graph
885 .nodes
886 .get(&CausalNodeId::new("Y"))
887 .expect("test setup: node must exist in graph");
888 assert!(x.children.contains(&CausalNodeId::new("Y")));
889 assert!(y.parents.contains(&CausalNodeId::new("X")));
890 }
891
892 #[test]
893 fn test_add_edge_missing_from_returns_error() {
894 let mut engine = CausalInferenceEngine::new(10);
895 engine
896 .add_node(CausalNode::new("Y", 0.0, 1.0))
897 .expect("test setup: add_node should not fail for unique node");
898 let err = engine
899 .add_edge(CausalEdge::direct("X", "Y", 1.0))
900 .unwrap_err();
901 assert_eq!(err, CausalError::NodeNotFound("X".into()));
902 }
903
904 #[test]
905 fn test_add_edge_missing_to_returns_error() {
906 let mut engine = CausalInferenceEngine::new(10);
907 engine
908 .add_node(CausalNode::new("X", 0.0, 1.0))
909 .expect("test setup: add_node should not fail for unique node");
910 let err = engine
911 .add_edge(CausalEdge::direct("X", "Y", 1.0))
912 .unwrap_err();
913 assert_eq!(err, CausalError::NodeNotFound("Y".into()));
914 }
915
916 #[test]
917 fn test_add_edge_self_loop_rejected() {
918 let mut engine = CausalInferenceEngine::new(10);
919 engine
920 .add_node(CausalNode::new("X", 0.0, 1.0))
921 .expect("test setup: add_node should not fail for unique node");
922 let err = engine
923 .add_edge(CausalEdge::direct("X", "X", 1.0))
924 .unwrap_err();
925 assert_eq!(
926 err,
927 CausalError::InvalidEdge("self-loop is not allowed".into())
928 );
929 }
930
931 #[test]
932 fn test_add_edge_cycle_rejected() {
933 let mut engine = CausalInferenceEngine::new(10);
934 engine
935 .add_node(CausalNode::new("A", 0.0, 1.0))
936 .expect("test setup: add_node should not fail for unique node");
937 engine
938 .add_node(CausalNode::new("B", 0.0, 1.0))
939 .expect("test setup: add_node should not fail for unique node");
940 engine
941 .add_edge(CausalEdge::direct("A", "B", 1.0))
942 .expect("test setup: add_edge should not fail for valid DAG edge");
943 let err = engine
944 .add_edge(CausalEdge::direct("B", "A", 1.0))
945 .unwrap_err();
946 assert_eq!(err, CausalError::CycleDetected);
947 }
948
949 #[test]
952 fn test_remove_node_removes_edges() {
953 let mut engine = simple_xy();
954 let removed = engine.remove_node(&CausalNodeId::new("X"));
955 assert!(removed);
956 assert!(!engine.graph.nodes.contains_key(&CausalNodeId::new("X")));
957 assert!(engine.graph.edges.is_empty());
958 let y = engine
960 .graph
961 .nodes
962 .get(&CausalNodeId::new("Y"))
963 .expect("test setup: node must exist in graph");
964 assert!(y.parents.is_empty());
965 }
966
967 #[test]
968 fn test_remove_nonexistent_node_returns_false() {
969 let mut engine = CausalInferenceEngine::new(10);
970 assert!(!engine.remove_node(&CausalNodeId::new("Ghost")));
971 }
972
973 #[test]
976 fn test_has_path_direct() {
977 let engine = simple_xy();
978 assert!(engine.has_path(&CausalNodeId::new("X"), &CausalNodeId::new("Y")));
979 }
980
981 #[test]
982 fn test_has_path_no_reverse() {
983 let engine = simple_xy();
984 assert!(!engine.has_path(&CausalNodeId::new("Y"), &CausalNodeId::new("X")));
985 }
986
987 #[test]
988 fn test_has_path_through_mediator() {
989 let engine = chain_xmy();
990 assert!(engine.has_path(&CausalNodeId::new("X"), &CausalNodeId::new("Y")));
991 }
992
993 #[test]
994 fn test_has_path_same_node() {
995 let engine = simple_xy();
996 assert!(engine.has_path(&CausalNodeId::new("X"), &CausalNodeId::new("X")));
997 }
998
999 #[test]
1002 fn test_is_ancestor_direct() {
1003 let engine = simple_xy();
1004 assert!(engine.is_ancestor(&CausalNodeId::new("X"), &CausalNodeId::new("Y")));
1005 }
1006
1007 #[test]
1008 fn test_is_ancestor_not_self() {
1009 let engine = simple_xy();
1010 assert!(!engine.is_ancestor(&CausalNodeId::new("X"), &CausalNodeId::new("X")));
1011 }
1012
1013 #[test]
1014 fn test_is_ancestor_transitive() {
1015 let engine = chain_xmy();
1016 assert!(engine.is_ancestor(&CausalNodeId::new("X"), &CausalNodeId::new("Y")));
1017 }
1018
1019 #[test]
1022 fn test_ancestors_chain() {
1023 let engine = chain_xmy();
1024 let ancs = engine.ancestors(&CausalNodeId::new("Y"));
1025 assert!(ancs.contains(&CausalNodeId::new("M")));
1026 assert!(ancs.contains(&CausalNodeId::new("X")));
1027 }
1028
1029 #[test]
1030 fn test_ancestors_root_has_none() {
1031 let engine = chain_xmy();
1032 assert!(engine.ancestors(&CausalNodeId::new("X")).is_empty());
1033 }
1034
1035 #[test]
1036 fn test_descendants_chain() {
1037 let engine = chain_xmy();
1038 let descs = engine.descendants(&CausalNodeId::new("X"));
1039 assert!(descs.contains(&CausalNodeId::new("M")));
1040 assert!(descs.contains(&CausalNodeId::new("Y")));
1041 }
1042
1043 #[test]
1044 fn test_descendants_leaf_has_none() {
1045 let engine = chain_xmy();
1046 assert!(engine.descendants(&CausalNodeId::new("Y")).is_empty());
1047 }
1048
1049 #[test]
1052 fn test_do_calculus_direct_edge() {
1053 let engine = simple_xy();
1054 let result = engine.do_calculus(&Intervention::new("X", 2.0), &CausalNodeId::new("Y"));
1055 assert!((result.mean - 1.0).abs() < 1e-9, "mean={}", result.mean);
1057 }
1058
1059 #[test]
1060 fn test_do_calculus_chain() {
1061 let engine = chain_xmy();
1062 let result = engine.do_calculus(&Intervention::new("X", 1.0), &CausalNodeId::new("Y"));
1063 assert!((result.mean - 0.48).abs() < 1e-9, "mean={}", result.mean);
1065 }
1066
1067 #[test]
1068 fn test_do_calculus_no_path_gives_zero_mean() {
1069 let engine = simple_xy();
1070 let result = engine.do_calculus(&Intervention::new("Y", 5.0), &CausalNodeId::new("X"));
1072 assert!((result.mean).abs() < 1e-9);
1073 }
1074
1075 #[test]
1076 fn test_do_calculus_target_variance_shrinks() {
1077 let engine = simple_xy();
1078 let base_var = engine
1079 .graph
1080 .nodes
1081 .get(&CausalNodeId::new("Y"))
1082 .expect("test setup: node must exist in graph")
1083 .variance;
1084 let result = engine.do_calculus(&Intervention::new("X", 1.0), &CausalNodeId::new("Y"));
1085 assert!(result.variance <= base_var);
1086 }
1087
1088 #[test]
1089 fn test_do_calculus_confidence_bounded() {
1090 let engine = simple_xy();
1091 let result = engine.do_calculus(&Intervention::new("X", 1.0), &CausalNodeId::new("Y"));
1092 assert!((0.0..=1.0).contains(&result.confidence));
1093 }
1094
1095 #[test]
1096 fn test_do_calculus_interventions_recorded() {
1097 let engine = simple_xy();
1098 let int = Intervention::new("X", 3.0);
1099 let result = engine.do_calculus(&int, &CausalNodeId::new("Y"));
1100 assert_eq!(result.interventions_applied.len(), 1);
1101 assert_eq!(result.interventions_applied[0].node, CausalNodeId::new("X"));
1102 }
1103
1104 #[test]
1107 fn test_counterfactual_no_evidence_equals_do_calculus() {
1108 let engine = simple_xy();
1109 let int = Intervention::new("X", 1.0);
1110 let query = CounterfactualQuery {
1111 target: CausalNodeId::new("Y"),
1112 intervention: int.clone(),
1113 evidence: HashMap::new(),
1114 };
1115 let cf_result = engine.counterfactual(&query);
1116 let do_result = engine.do_calculus(&int, &CausalNodeId::new("Y"));
1117 assert!((cf_result.mean - do_result.mean).abs() < 1e-9);
1118 }
1119
1120 #[test]
1121 fn test_counterfactual_with_evidence_adjusts_mean() {
1122 let mut engine = CausalInferenceEngine::new(10);
1124 engine
1125 .add_node(CausalNode::new("X", 0.0, 1.0))
1126 .expect("test setup: add_node should not fail for unique node");
1127 engine
1128 .add_node(CausalNode::new("Z", 0.0, 1.0))
1129 .expect("test setup: add_node should not fail for unique node");
1130 engine
1131 .add_node(CausalNode::new("Y", 0.0, 1.0))
1132 .expect("test setup: add_node should not fail for unique node");
1133 engine
1134 .add_edge(CausalEdge::direct("X", "Y", 0.5))
1135 .expect("test setup: add_edge should not fail for valid DAG edge");
1136 engine
1137 .add_edge(CausalEdge::direct("Z", "Y", 0.3))
1138 .expect("test setup: add_edge should not fail for valid DAG edge");
1139
1140 let mut evidence = HashMap::new();
1141 evidence.insert(CausalNodeId::new("Z"), 2.0);
1142
1143 let query = CounterfactualQuery {
1144 target: CausalNodeId::new("Y"),
1145 intervention: Intervention::new("X", 1.0),
1146 evidence,
1147 };
1148 let cf = engine.counterfactual(&query);
1149 assert!((cf.mean - 1.1).abs() < 1e-9, "mean={}", cf.mean);
1151 }
1152
1153 #[test]
1156 fn test_ace_linear() {
1157 let engine = simple_xy();
1158 let ace = engine.average_causal_effect(
1160 &CausalNodeId::new("X"),
1161 &CausalNodeId::new("Y"),
1162 0.0,
1163 2.0,
1164 );
1165 assert!((ace - 1.0).abs() < 1e-9, "ace={ace}");
1166 }
1167
1168 #[test]
1169 fn test_ace_zero_when_no_path() {
1170 let engine = simple_xy();
1171 let ace = engine.average_causal_effect(
1172 &CausalNodeId::new("Y"),
1173 &CausalNodeId::new("X"),
1174 0.0,
1175 1.0,
1176 );
1177 assert!((ace).abs() < 1e-9);
1178 }
1179
1180 #[test]
1181 fn test_ace_chain() {
1182 let engine = chain_xmy();
1183 let ace = engine.average_causal_effect(
1185 &CausalNodeId::new("X"),
1186 &CausalNodeId::new("Y"),
1187 0.0,
1188 1.0,
1189 );
1190 assert!((ace - 0.48).abs() < 1e-9, "ace={ace}");
1191 }
1192
1193 #[test]
1196 fn test_confounders_common_cause() {
1197 let mut engine = CausalInferenceEngine::new(10);
1199 engine
1200 .add_node(CausalNode::new("Z", 0.0, 1.0))
1201 .expect("test setup: add_node should not fail for unique node");
1202 engine
1203 .add_node(CausalNode::new("X", 0.0, 1.0))
1204 .expect("test setup: add_node should not fail for unique node");
1205 engine
1206 .add_node(CausalNode::new("Y", 0.0, 1.0))
1207 .expect("test setup: add_node should not fail for unique node");
1208 engine
1209 .add_edge(CausalEdge::direct("Z", "X", 1.0))
1210 .expect("test setup: add_edge should not fail for valid DAG edge");
1211 engine
1212 .add_edge(CausalEdge::direct("Z", "Y", 1.0))
1213 .expect("test setup: add_edge should not fail for valid DAG edge");
1214
1215 let conf = engine.confounders(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
1216 assert!(conf.contains(&CausalNodeId::new("Z")));
1217 }
1218
1219 #[test]
1220 fn test_confounders_no_common_cause() {
1221 let engine = simple_xy();
1222 let conf = engine.confounders(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
1223 assert!(conf.is_empty());
1224 }
1225
1226 #[test]
1229 fn test_d_sep_blocked_by_given() {
1230 let engine = chain_xmy();
1231 let given = vec![CausalNodeId::new("M")];
1233 assert!(engine.is_d_separated(&CausalNodeId::new("X"), &CausalNodeId::new("Y"), &given,));
1234 }
1235
1236 #[test]
1237 fn test_d_sep_not_separated_without_given() {
1238 let engine = chain_xmy();
1239 assert!(!engine.is_d_separated(&CausalNodeId::new("X"), &CausalNodeId::new("Y"), &[],));
1240 }
1241
1242 #[test]
1243 fn test_d_sep_direct_edge_blocks_without_given() {
1244 let engine = simple_xy();
1245 assert!(!engine.is_d_separated(&CausalNodeId::new("X"), &CausalNodeId::new("Y"), &[],));
1246 }
1247
1248 #[test]
1251 fn test_backdoor_paths_with_confounder() {
1252 let mut engine = CausalInferenceEngine::new(10);
1254 engine
1255 .add_node(CausalNode::new("Z", 0.0, 1.0))
1256 .expect("test setup: add_node should not fail for unique node");
1257 engine
1258 .add_node(CausalNode::new("X", 0.0, 1.0))
1259 .expect("test setup: add_node should not fail for unique node");
1260 engine
1261 .add_node(CausalNode::new("Y", 0.0, 1.0))
1262 .expect("test setup: add_node should not fail for unique node");
1263 engine
1264 .add_edge(CausalEdge::direct("Z", "X", 1.0))
1265 .expect("test setup: add_edge should not fail for valid DAG edge");
1266 engine
1267 .add_edge(CausalEdge::direct("Z", "Y", 1.0))
1268 .expect("test setup: add_edge should not fail for valid DAG edge");
1269
1270 let bd = engine.backdoor_paths(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
1271 assert!(!bd.is_empty(), "expected at least one backdoor path");
1272 }
1273
1274 #[test]
1275 fn test_backdoor_paths_empty_for_chain() {
1276 let engine = chain_xmy();
1278 let bd = engine.backdoor_paths(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
1279 assert!(bd.is_empty());
1280 }
1281
1282 #[test]
1285 fn test_stats_empty_graph() {
1286 let engine = CausalInferenceEngine::new(5);
1287 let s = engine.stats();
1288 assert_eq!(s.node_count, 0);
1289 assert_eq!(s.edge_count, 0);
1290 assert_eq!(s.max_depth, 0);
1291 }
1292
1293 #[test]
1294 fn test_stats_simple_xy() {
1295 let engine = simple_xy();
1296 let s = engine.stats();
1297 assert_eq!(s.node_count, 2);
1298 assert_eq!(s.edge_count, 1);
1299 assert!((s.avg_children - 0.5).abs() < 1e-9);
1300 assert_eq!(s.max_depth, 1);
1301 }
1302
1303 #[test]
1304 fn test_stats_chain() {
1305 let engine = chain_xmy();
1306 let s = engine.stats();
1307 assert_eq!(s.node_count, 3);
1308 assert_eq!(s.edge_count, 2);
1309 assert_eq!(s.max_depth, 2);
1310 }
1311
1312 #[test]
1315 fn test_causal_node_id_display() {
1316 let id = CausalNodeId::new("foo");
1317 assert_eq!(format!("{id}"), "foo");
1318 }
1319
1320 #[test]
1321 fn test_causal_node_id_as_str() {
1322 let id = CausalNodeId::new("bar");
1323 assert_eq!(id.as_str(), "bar");
1324 }
1325
1326 #[test]
1327 fn test_causal_node_id_equality() {
1328 let a = CausalNodeId::new("x");
1329 let b = CausalNodeId::new("x");
1330 let c = CausalNodeId::new("y");
1331 assert_eq!(a, b);
1332 assert_ne!(a, c);
1333 }
1334
1335 #[test]
1338 fn test_error_display_messages() {
1339 assert!(CausalError::NodeAlreadyExists("X".into())
1340 .to_string()
1341 .contains("X"));
1342 assert!(CausalError::NodeNotFound("Y".into())
1343 .to_string()
1344 .contains("Y"));
1345 assert!(!CausalError::CycleDetected.to_string().is_empty());
1346 assert!(CausalError::InvalidEdge("bad".into())
1347 .to_string()
1348 .contains("bad"));
1349 }
1350
1351 #[test]
1354 fn test_do_calculus_multiple_paths() {
1355 let mut engine = CausalInferenceEngine::new(10);
1357 engine
1358 .add_node(CausalNode::new("X", 0.0, 1.0))
1359 .expect("test setup: add_node should not fail for unique node");
1360 engine
1361 .add_node(CausalNode::new("M", 0.0, 1.0))
1362 .expect("test setup: add_node should not fail for unique node");
1363 engine
1364 .add_node(CausalNode::new("Y", 0.0, 1.0))
1365 .expect("test setup: add_node should not fail for unique node");
1366 engine
1367 .add_edge(CausalEdge::direct("X", "Y", 0.3))
1368 .expect("test setup: add_edge should not fail for valid DAG edge");
1369 engine
1370 .add_edge(CausalEdge::direct("X", "M", 0.5))
1371 .expect("test setup: add_edge should not fail for valid DAG edge");
1372 engine
1373 .add_edge(CausalEdge::direct("M", "Y", 0.4))
1374 .expect("test setup: add_edge should not fail for valid DAG edge");
1375
1376 let result = engine.do_calculus(&Intervention::new("X", 1.0), &CausalNodeId::new("Y"));
1377 assert!((result.mean - 0.5).abs() < 1e-9, "mean={}", result.mean);
1379 }
1380
1381 #[test]
1384 fn test_all_directed_paths_chain() {
1385 let engine = chain_xmy();
1386 let paths = engine.all_directed_paths(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
1387 assert_eq!(paths.len(), 1);
1388 assert_eq!(paths[0].len(), 3); }
1390
1391 #[test]
1392 fn test_all_directed_paths_no_path() {
1393 let engine = simple_xy();
1394 let paths = engine.all_directed_paths(&CausalNodeId::new("Y"), &CausalNodeId::new("X"));
1395 assert!(paths.is_empty());
1396 }
1397
1398 #[test]
1401 fn test_path_length_limit_blocks_long_paths() {
1402 let mut engine = CausalInferenceEngine::new(2);
1403 for name in ["A", "B", "C", "D"] {
1404 engine
1405 .add_node(CausalNode::new(name, 0.0, 1.0))
1406 .expect("test setup: add_node should not fail for unique node");
1407 }
1408 engine
1409 .add_edge(CausalEdge::direct("A", "B", 1.0))
1410 .expect("test setup: add_edge should not fail for valid DAG edge");
1411 engine
1412 .add_edge(CausalEdge::direct("B", "C", 1.0))
1413 .expect("test setup: add_edge should not fail for valid DAG edge");
1414 engine
1415 .add_edge(CausalEdge::direct("C", "D", 1.0))
1416 .expect("test setup: add_edge should not fail for valid DAG edge");
1417
1418 let paths = engine.all_directed_paths(&CausalNodeId::new("A"), &CausalNodeId::new("D"));
1420 assert!(paths.is_empty(), "expected no paths with limit=2");
1421 }
1422
1423 #[test]
1426 fn test_do_calculus_negative_strength() {
1427 let mut engine = CausalInferenceEngine::new(10);
1428 engine
1429 .add_node(CausalNode::new("X", 0.0, 1.0))
1430 .expect("test setup: add_node should not fail for unique node");
1431 engine
1432 .add_node(CausalNode::new("Y", 0.0, 1.0))
1433 .expect("test setup: add_node should not fail for unique node");
1434 engine
1435 .add_edge(CausalEdge::direct("X", "Y", -0.4))
1436 .expect("test setup: add_edge should not fail for valid DAG edge");
1437
1438 let result = engine.do_calculus(&Intervention::new("X", 2.0), &CausalNodeId::new("Y"));
1439 assert!((result.mean - (-0.8)).abs() < 1e-9, "mean={}", result.mean);
1441 }
1442
1443 #[test]
1446 fn test_edge_direct_helper() {
1447 let edge = CausalEdge::direct("A", "B", 0.7);
1448 assert_eq!(edge.from, CausalNodeId::new("A"));
1449 assert_eq!(edge.to, CausalNodeId::new("B"));
1450 assert_eq!(edge.edge_type, CausalEdgeType::Direct);
1451 assert!((edge.strength - 0.7).abs() < 1e-9);
1452 }
1453
1454 #[test]
1457 fn test_intervention_new() {
1458 let int = Intervention::new("X", std::f64::consts::PI);
1459 assert_eq!(int.node, CausalNodeId::new("X"));
1460 assert!((int.value - std::f64::consts::PI).abs() < 1e-9);
1461 }
1462
1463 #[test]
1466 fn test_diamond_graph_two_paths() {
1467 let mut engine = CausalInferenceEngine::new(10);
1469 for name in ["X", "A", "B", "Y"] {
1470 engine
1471 .add_node(CausalNode::new(name, 0.0, 1.0))
1472 .expect("test setup: add_node should not fail for unique node");
1473 }
1474 engine
1475 .add_edge(CausalEdge::direct("X", "A", 0.5))
1476 .expect("test setup: add_edge should not fail for valid DAG edge");
1477 engine
1478 .add_edge(CausalEdge::direct("X", "B", 0.5))
1479 .expect("test setup: add_edge should not fail for valid DAG edge");
1480 engine
1481 .add_edge(CausalEdge::direct("A", "Y", 0.6))
1482 .expect("test setup: add_edge should not fail for valid DAG edge");
1483 engine
1484 .add_edge(CausalEdge::direct("B", "Y", 0.4))
1485 .expect("test setup: add_edge should not fail for valid DAG edge");
1486
1487 let result = engine.do_calculus(&Intervention::new("X", 1.0), &CausalNodeId::new("Y"));
1488 assert!((result.mean - 0.5).abs() < 1e-9, "mean={}", result.mean);
1490 }
1491
1492 #[test]
1495 fn test_counterfactual_multiple_evidence_nodes() {
1496 let mut engine = CausalInferenceEngine::new(10);
1497 engine
1498 .add_node(CausalNode::new("X", 0.0, 1.0))
1499 .expect("test setup: add_node should not fail for unique node");
1500 engine
1501 .add_node(CausalNode::new("Z1", 0.0, 1.0))
1502 .expect("test setup: add_node should not fail for unique node");
1503 engine
1504 .add_node(CausalNode::new("Z2", 0.0, 1.0))
1505 .expect("test setup: add_node should not fail for unique node");
1506 engine
1507 .add_node(CausalNode::new("Y", 0.0, 1.0))
1508 .expect("test setup: add_node should not fail for unique node");
1509 engine
1510 .add_edge(CausalEdge::direct("X", "Y", 0.4))
1511 .expect("test setup: add_edge should not fail for valid DAG edge");
1512 engine
1513 .add_edge(CausalEdge::direct("Z1", "Y", 0.2))
1514 .expect("test setup: add_edge should not fail for valid DAG edge");
1515 engine
1516 .add_edge(CausalEdge::direct("Z2", "Y", 0.3))
1517 .expect("test setup: add_edge should not fail for valid DAG edge");
1518
1519 let mut evidence = HashMap::new();
1520 evidence.insert(CausalNodeId::new("Z1"), 1.0);
1521 evidence.insert(CausalNodeId::new("Z2"), 1.0);
1522
1523 let query = CounterfactualQuery {
1524 target: CausalNodeId::new("Y"),
1525 intervention: Intervention::new("X", 1.0),
1526 evidence,
1527 };
1528 let cf = engine.counterfactual(&query);
1529 assert!((cf.mean - 0.9).abs() < 1e-9, "mean={}", cf.mean);
1531 }
1532
1533 #[test]
1536 fn test_descendants_of_mediator() {
1537 let engine = chain_xmy();
1538 let descs = engine.descendants(&CausalNodeId::new("M"));
1539 assert_eq!(descs.len(), 1);
1540 assert!(descs.contains(&CausalNodeId::new("Y")));
1541 }
1542
1543 #[test]
1546 fn test_confounders_sorted() {
1547 let mut engine = CausalInferenceEngine::new(10);
1549 for name in ["Z1", "Z2", "X", "Y"] {
1550 engine
1551 .add_node(CausalNode::new(name, 0.0, 1.0))
1552 .expect("test setup: add_node should not fail for unique node");
1553 }
1554 engine
1555 .add_edge(CausalEdge::direct("Z1", "X", 1.0))
1556 .expect("test setup: add_edge should not fail for valid DAG edge");
1557 engine
1558 .add_edge(CausalEdge::direct("Z2", "X", 1.0))
1559 .expect("test setup: add_edge should not fail for valid DAG edge");
1560 engine
1561 .add_edge(CausalEdge::direct("Z1", "Y", 1.0))
1562 .expect("test setup: add_edge should not fail for valid DAG edge");
1563 engine
1564 .add_edge(CausalEdge::direct("Z2", "Y", 1.0))
1565 .expect("test setup: add_edge should not fail for valid DAG edge");
1566
1567 let conf = engine.confounders(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
1568 assert_eq!(conf.len(), 2);
1569 let names: Vec<&str> = conf.iter().map(|id| id.as_str()).collect();
1571 let mut sorted_names = names.clone();
1572 sorted_names.sort_unstable();
1573 assert_eq!(names, sorted_names);
1574 }
1575
1576 #[test]
1579 fn test_remove_child_updates_parent_children_list() {
1580 let mut engine = chain_xmy();
1581 engine.remove_node(&CausalNodeId::new("Y"));
1582 let m_node = engine
1583 .graph
1584 .nodes
1585 .get(&CausalNodeId::new("M"))
1586 .expect("test setup: node must exist in graph");
1587 assert!(m_node.children.is_empty());
1588 }
1589
1590 #[test]
1593 fn test_causal_node_new_empty() {
1594 let node = CausalNode::new("test", 1.5, 2.5);
1595 assert_eq!(node.id, CausalNodeId::new("test"));
1596 assert!((node.mean - 1.5).abs() < 1e-9);
1597 assert!((node.variance - 2.5).abs() < 1e-9);
1598 assert!(node.parents.is_empty());
1599 assert!(node.children.is_empty());
1600 }
1601
1602 #[test]
1605 fn test_stats_avg_children_diamond() {
1606 let mut engine = CausalInferenceEngine::new(10);
1607 for name in ["X", "A", "B", "Y"] {
1608 engine
1609 .add_node(CausalNode::new(name, 0.0, 1.0))
1610 .expect("test setup: add_node should not fail for unique node");
1611 }
1612 engine
1613 .add_edge(CausalEdge::direct("X", "A", 1.0))
1614 .expect("test setup: add_edge should not fail for valid DAG edge");
1615 engine
1616 .add_edge(CausalEdge::direct("X", "B", 1.0))
1617 .expect("test setup: add_edge should not fail for valid DAG edge");
1618 engine
1619 .add_edge(CausalEdge::direct("A", "Y", 1.0))
1620 .expect("test setup: add_edge should not fail for valid DAG edge");
1621 engine
1622 .add_edge(CausalEdge::direct("B", "Y", 1.0))
1623 .expect("test setup: add_edge should not fail for valid DAG edge");
1624
1625 let s = engine.stats();
1626 assert!(
1628 (s.avg_children - 1.0).abs() < 1e-9,
1629 "avg_children={}",
1630 s.avg_children
1631 );
1632 }
1633
1634 #[test]
1637 fn test_d_sep_fork_blocked_at_common_cause() {
1638 let mut engine = CausalInferenceEngine::new(10);
1640 engine
1641 .add_node(CausalNode::new("Z", 0.0, 1.0))
1642 .expect("test setup: add_node should not fail for unique node");
1643 engine
1644 .add_node(CausalNode::new("X", 0.0, 1.0))
1645 .expect("test setup: add_node should not fail for unique node");
1646 engine
1647 .add_node(CausalNode::new("Y", 0.0, 1.0))
1648 .expect("test setup: add_node should not fail for unique node");
1649 engine
1650 .add_edge(CausalEdge::direct("Z", "X", 1.0))
1651 .expect("test setup: add_edge should not fail for valid DAG edge");
1652 engine
1653 .add_edge(CausalEdge::direct("Z", "Y", 1.0))
1654 .expect("test setup: add_edge should not fail for valid DAG edge");
1655
1656 let given = vec![CausalNodeId::new("Z")];
1657 assert!(engine.is_d_separated(&CausalNodeId::new("X"), &CausalNodeId::new("Y"), &given,));
1658 }
1659
1660 #[test]
1663 fn test_ace_scales_linearly_with_delta() {
1664 let engine = simple_xy();
1665 let ace1 = engine.average_causal_effect(
1666 &CausalNodeId::new("X"),
1667 &CausalNodeId::new("Y"),
1668 0.0,
1669 1.0,
1670 );
1671 let ace2 = engine.average_causal_effect(
1672 &CausalNodeId::new("X"),
1673 &CausalNodeId::new("Y"),
1674 0.0,
1675 2.0,
1676 );
1677 assert!((ace2 - 2.0 * ace1).abs() < 1e-9, "ace1={ace1} ace2={ace2}");
1679 }
1680
1681 #[test]
1684 fn test_all_directed_paths_diamond_returns_two() {
1685 let mut engine = CausalInferenceEngine::new(10);
1686 for name in ["X", "A", "B", "Y"] {
1687 engine
1688 .add_node(CausalNode::new(name, 0.0, 1.0))
1689 .expect("test setup: add_node should not fail for unique node");
1690 }
1691 engine
1692 .add_edge(CausalEdge::direct("X", "A", 1.0))
1693 .expect("test setup: add_edge should not fail for valid DAG edge");
1694 engine
1695 .add_edge(CausalEdge::direct("X", "B", 1.0))
1696 .expect("test setup: add_edge should not fail for valid DAG edge");
1697 engine
1698 .add_edge(CausalEdge::direct("A", "Y", 1.0))
1699 .expect("test setup: add_edge should not fail for valid DAG edge");
1700 engine
1701 .add_edge(CausalEdge::direct("B", "Y", 1.0))
1702 .expect("test setup: add_edge should not fail for valid DAG edge");
1703
1704 let paths = engine.all_directed_paths(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
1705 assert_eq!(paths.len(), 2);
1706 }
1707}