use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CausalNodeId(pub String);
impl CausalNodeId {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for CausalNodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CausalEdgeType {
Direct,
Confounded,
Backdoor,
Instrumental,
}
#[derive(Debug, Clone)]
pub struct CausalEdge {
pub from: CausalNodeId,
pub to: CausalNodeId,
pub strength: f64,
pub edge_type: CausalEdgeType,
}
impl CausalEdge {
pub fn direct(from: impl Into<String>, to: impl Into<String>, strength: f64) -> Self {
Self {
from: CausalNodeId::new(from),
to: CausalNodeId::new(to),
strength,
edge_type: CausalEdgeType::Direct,
}
}
}
#[derive(Debug, Clone)]
pub struct CausalNode {
pub id: CausalNodeId,
pub parents: Vec<CausalNodeId>,
pub children: Vec<CausalNodeId>,
pub mean: f64,
pub variance: f64,
}
impl CausalNode {
pub fn new(id: impl Into<String>, mean: f64, variance: f64) -> Self {
Self {
id: CausalNodeId::new(id),
parents: Vec::new(),
children: Vec::new(),
mean,
variance,
}
}
}
#[derive(Debug, Default, Clone)]
pub struct CausalGraph {
pub nodes: HashMap<CausalNodeId, CausalNode>,
pub edges: Vec<CausalEdge>,
}
#[derive(Debug, Clone)]
pub struct Intervention {
pub node: CausalNodeId,
pub value: f64,
}
impl Intervention {
pub fn new(node: impl Into<String>, value: f64) -> Self {
Self {
node: CausalNodeId::new(node),
value,
}
}
}
#[derive(Debug, Clone)]
pub struct CounterfactualQuery {
pub target: CausalNodeId,
pub intervention: Intervention,
pub evidence: HashMap<CausalNodeId, f64>,
}
#[derive(Debug, Clone)]
pub struct InferenceResult {
pub target: CausalNodeId,
pub mean: f64,
pub variance: f64,
pub confidence: f64,
pub interventions_applied: Vec<Intervention>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CausalError {
NodeAlreadyExists(String),
NodeNotFound(String),
CycleDetected,
InvalidEdge(String),
}
impl std::fmt::Display for CausalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NodeAlreadyExists(id) => write!(f, "node already exists: {id}"),
Self::NodeNotFound(id) => write!(f, "node not found: {id}"),
Self::CycleDetected => write!(f, "adding this edge would create a cycle"),
Self::InvalidEdge(msg) => write!(f, "invalid edge: {msg}"),
}
}
}
impl std::error::Error for CausalError {}
#[derive(Debug, Clone)]
pub struct CausalStats {
pub node_count: usize,
pub edge_count: usize,
pub avg_children: f64,
pub max_depth: usize,
}
#[derive(Debug)]
pub struct CausalInferenceEngine {
pub graph: CausalGraph,
pub max_path_length: usize,
}
impl CausalInferenceEngine {
pub fn new(max_path_length: usize) -> Self {
Self {
graph: CausalGraph::default(),
max_path_length,
}
}
pub fn add_node(&mut self, node: CausalNode) -> Result<(), CausalError> {
if self.graph.nodes.contains_key(&node.id) {
return Err(CausalError::NodeAlreadyExists(node.id.0.clone()));
}
self.graph.nodes.insert(node.id.clone(), node);
Ok(())
}
pub fn add_edge(&mut self, edge: CausalEdge) -> Result<(), CausalError> {
if !self.graph.nodes.contains_key(&edge.from) {
return Err(CausalError::NodeNotFound(edge.from.0.clone()));
}
if !self.graph.nodes.contains_key(&edge.to) {
return Err(CausalError::NodeNotFound(edge.to.0.clone()));
}
if edge.from == edge.to {
return Err(CausalError::InvalidEdge("self-loop is not allowed".into()));
}
if self.has_path(&edge.to, &edge.from) {
return Err(CausalError::CycleDetected);
}
let from_id = edge.from.clone();
let to_id = edge.to.clone();
if let Some(from_node) = self.graph.nodes.get_mut(&from_id) {
if !from_node.children.contains(&to_id) {
from_node.children.push(to_id.clone());
}
}
if let Some(to_node) = self.graph.nodes.get_mut(&to_id) {
if !to_node.parents.contains(&from_id) {
to_node.parents.push(from_id);
}
}
self.graph.edges.push(edge);
Ok(())
}
pub fn remove_node(&mut self, id: &CausalNodeId) -> bool {
if !self.graph.nodes.contains_key(id) {
return false;
}
self.graph.edges.retain(|e| &e.from != id && &e.to != id);
for node in self.graph.nodes.values_mut() {
node.parents.retain(|p| p != id);
node.children.retain(|c| c != id);
}
self.graph.nodes.remove(id);
true
}
pub fn has_path(&self, from: &CausalNodeId, to: &CausalNodeId) -> bool {
if from == to {
return true;
}
let mut visited: HashSet<&CausalNodeId> = HashSet::new();
let mut stack: Vec<(&CausalNodeId, usize)> = vec![(from, 0)];
while let Some((current, depth)) = stack.pop() {
if current == to {
return true;
}
if depth >= self.max_path_length {
continue;
}
if !visited.insert(current) {
continue;
}
if let Some(node) = self.graph.nodes.get(current) {
for child in &node.children {
stack.push((child, depth + 1));
}
}
}
false
}
pub fn is_ancestor(&self, ancestor: &CausalNodeId, descendant: &CausalNodeId) -> bool {
if ancestor == descendant {
return false;
}
self.has_path(ancestor, descendant)
}
pub fn ancestors(&self, id: &CausalNodeId) -> Vec<CausalNodeId> {
let mut result: Vec<CausalNodeId> = Vec::new();
let mut visited: HashSet<CausalNodeId> = HashSet::new();
let mut queue: VecDeque<CausalNodeId> = VecDeque::new();
if let Some(node) = self.graph.nodes.get(id) {
for p in &node.parents {
queue.push_back(p.clone());
}
}
while let Some(current) = queue.pop_front() {
if !visited.insert(current.clone()) {
continue;
}
result.push(current.clone());
if let Some(node) = self.graph.nodes.get(¤t) {
for p in &node.parents {
if !visited.contains(p) {
queue.push_back(p.clone());
}
}
}
}
result
}
pub fn descendants(&self, id: &CausalNodeId) -> Vec<CausalNodeId> {
let mut result: Vec<CausalNodeId> = Vec::new();
let mut visited: HashSet<CausalNodeId> = HashSet::new();
let mut queue: VecDeque<CausalNodeId> = VecDeque::new();
if let Some(node) = self.graph.nodes.get(id) {
for c in &node.children {
queue.push_back(c.clone());
}
}
while let Some(current) = queue.pop_front() {
if !visited.insert(current.clone()) {
continue;
}
result.push(current.clone());
if let Some(node) = self.graph.nodes.get(¤t) {
for c in &node.children {
if !visited.contains(c) {
queue.push_back(c.clone());
}
}
}
}
result
}
pub fn all_directed_paths(
&self,
from: &CausalNodeId,
to: &CausalNodeId,
) -> Vec<Vec<CausalNodeId>> {
let mut paths: Vec<Vec<CausalNodeId>> = Vec::new();
let mut current_path: Vec<CausalNodeId> = vec![from.clone()];
self.dfs_paths(from, to, &mut current_path, &mut paths);
paths
}
fn dfs_paths(
&self,
current: &CausalNodeId,
target: &CausalNodeId,
path: &mut Vec<CausalNodeId>,
results: &mut Vec<Vec<CausalNodeId>>,
) {
if path.len() > self.max_path_length + 1 {
return;
}
if current == target && path.len() > 1 {
results.push(path.clone());
return;
}
if let Some(node) = self.graph.nodes.get(current) {
for child in &node.children {
if path.contains(child) {
continue;
}
path.push(child.clone());
self.dfs_paths(child, target, path, results);
path.pop();
}
}
}
pub fn backdoor_paths(&self, from: &CausalNodeId, to: &CausalNodeId) -> Vec<Vec<CausalNodeId>> {
let Some(from_node) = self.graph.nodes.get(from) else {
return Vec::new();
};
let parents: Vec<CausalNodeId> = from_node.parents.clone();
let mut all_paths: Vec<Vec<CausalNodeId>> = Vec::new();
for parent in &parents {
let mut path: Vec<CausalNodeId> = vec![from.clone(), parent.clone()];
self.backdoor_dfs(parent, to, from, &mut path, &mut all_paths);
}
all_paths
}
fn backdoor_dfs(
&self,
current: &CausalNodeId,
target: &CausalNodeId,
source: &CausalNodeId, path: &mut Vec<CausalNodeId>,
results: &mut Vec<Vec<CausalNodeId>>,
) {
if path.len() > self.max_path_length + 1 {
return;
}
if current == target && path.len() > 2 {
results.push(path.clone());
return;
}
let mut neighbours: Vec<CausalNodeId> = Vec::new();
if let Some(node) = self.graph.nodes.get(current) {
for c in &node.children {
neighbours.push(c.clone());
}
for p in &node.parents {
neighbours.push(p.clone());
}
}
for neighbour in &neighbours {
if neighbour == source {
continue;
}
if path.contains(neighbour) {
continue;
}
path.push(neighbour.clone());
self.backdoor_dfs(neighbour, target, source, path, results);
path.pop();
}
}
fn direct_edge_strength(&self, from: &CausalNodeId, to: &CausalNodeId) -> f64 {
self.graph
.edges
.iter()
.find(|e| &e.from == from && &e.to == to)
.map(|e| e.strength)
.unwrap_or(0.0)
}
fn path_effect(&self, path: &[CausalNodeId]) -> f64 {
if path.len() < 2 {
return 0.0;
}
let mut product = 1.0_f64;
for window in path.windows(2) {
let strength = self.direct_edge_strength(&window[0], &window[1]);
product *= strength;
}
product
}
pub fn do_calculus(
&self,
intervention: &Intervention,
target: &CausalNodeId,
) -> InferenceResult {
let paths = self.all_directed_paths(&intervention.node, target);
let total_path_effect: f64 = paths.iter().map(|p| self.path_effect(p)).sum();
let total_explained_variance: f64 = total_path_effect.powi(2).min(1.0);
let target_variance = self
.graph
.nodes
.get(target)
.map(|n| n.variance)
.unwrap_or(1.0);
let mean = intervention.value * total_path_effect;
let variance = target_variance * (1.0 - total_explained_variance.min(0.99));
let confidence = total_explained_variance.clamp(0.0, 1.0);
InferenceResult {
target: target.clone(),
mean,
variance,
confidence,
interventions_applied: vec![intervention.clone()],
}
}
pub fn counterfactual(&self, query: &CounterfactualQuery) -> InferenceResult {
let mut base = self.do_calculus(&query.intervention, &query.target);
let evidence_correction: f64 = query
.evidence
.iter()
.map(|(ev_node, &ev_value)| {
let strength = self.direct_edge_strength(ev_node, &query.target);
ev_value * strength
})
.sum();
base.mean += evidence_correction;
for (ev_node, &ev_value) in &query.evidence {
base.interventions_applied.push(Intervention {
node: ev_node.clone(),
value: ev_value,
});
}
base
}
pub fn average_causal_effect(
&self,
from: &CausalNodeId,
to: &CausalNodeId,
value1: f64,
value2: f64,
) -> f64 {
let int1 = Intervention {
node: from.clone(),
value: value1,
};
let int2 = Intervention {
node: from.clone(),
value: value2,
};
self.do_calculus(&int2, to).mean - self.do_calculus(&int1, to).mean
}
pub fn confounders(&self, x: &CausalNodeId, y: &CausalNodeId) -> Vec<CausalNodeId> {
let anc_x: HashSet<CausalNodeId> = self.ancestors(x).into_iter().collect();
let anc_y: HashSet<CausalNodeId> = self.ancestors(y).into_iter().collect();
let mut common: Vec<CausalNodeId> = anc_x.intersection(&anc_y).cloned().collect();
common.sort();
common
}
pub fn is_d_separated(
&self,
x: &CausalNodeId,
y: &CausalNodeId,
given: &[CausalNodeId],
) -> bool {
let given_set: HashSet<&CausalNodeId> = given.iter().collect();
for edge in &self.graph.edges {
if &edge.from == x && &edge.to == y && !given_set.contains(y) {
return false;
}
}
let directed_paths = self.all_directed_paths(x, y);
for path in &directed_paths {
let intermediate_nodes = &path[1..path.len().saturating_sub(1)];
let blocked = intermediate_nodes.iter().any(|n| given_set.contains(n));
if !blocked {
return false;
}
}
let bd_paths = self.backdoor_paths(x, y);
for path in &bd_paths {
let intermediate_nodes = if path.len() > 2 {
&path[1..path.len() - 1]
} else {
&path[1..path.len()]
};
let blocked = intermediate_nodes.iter().any(|n| given_set.contains(n));
if !blocked {
return false;
}
}
true
}
pub fn stats(&self) -> CausalStats {
let node_count = self.graph.nodes.len();
let edge_count = self.graph.edges.len();
let avg_children = if node_count == 0 {
0.0
} else {
self.graph
.nodes
.values()
.map(|n| n.children.len() as f64)
.sum::<f64>()
/ node_count as f64
};
let max_depth = self.compute_max_depth();
CausalStats {
node_count,
edge_count,
avg_children,
max_depth,
}
}
fn compute_max_depth(&self) -> usize {
let roots: Vec<&CausalNodeId> = self
.graph
.nodes
.values()
.filter(|n| n.parents.is_empty())
.map(|n| &n.id)
.collect();
let mut max_depth = 0usize;
for root in roots {
let depth = self.bfs_depth(root);
if depth > max_depth {
max_depth = depth;
}
}
max_depth
}
fn bfs_depth(&self, root: &CausalNodeId) -> usize {
let mut queue: VecDeque<(&CausalNodeId, usize)> = VecDeque::new();
queue.push_back((root, 0));
let mut max_depth = 0usize;
while let Some((current, depth)) = queue.pop_front() {
if depth > max_depth {
max_depth = depth;
}
if let Some(node) = self.graph.nodes.get(current) {
for child in &node.children {
queue.push_back((child, depth + 1));
}
}
}
max_depth
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::causal_inference::{
CausalEdge, CausalEdgeType, CausalError, CausalInferenceEngine, CausalNode, CausalNodeId,
CounterfactualQuery, Intervention,
};
fn simple_xy() -> CausalInferenceEngine {
let mut engine = CausalInferenceEngine::new(10);
engine
.add_node(CausalNode::new("X", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("Y", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_edge(CausalEdge::direct("X", "Y", 0.5))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
}
fn chain_xmy() -> CausalInferenceEngine {
let mut engine = CausalInferenceEngine::new(10);
engine
.add_node(CausalNode::new("X", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("M", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("Y", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_edge(CausalEdge::direct("X", "M", 0.6))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("M", "Y", 0.8))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
}
#[test]
fn test_new_engine_is_empty() {
let engine = CausalInferenceEngine::new(5);
assert_eq!(engine.graph.nodes.len(), 0);
assert_eq!(engine.graph.edges.len(), 0);
assert_eq!(engine.max_path_length, 5);
}
#[test]
fn test_add_node_success() {
let mut engine = CausalInferenceEngine::new(10);
let result = engine.add_node(CausalNode::new("A", 1.0, 2.0));
assert!(result.is_ok());
assert!(engine.graph.nodes.contains_key(&CausalNodeId::new("A")));
}
#[test]
fn test_add_node_duplicate_returns_error() {
let mut engine = CausalInferenceEngine::new(10);
engine
.add_node(CausalNode::new("A", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
let err = engine.add_node(CausalNode::new("A", 1.0, 2.0)).unwrap_err();
assert_eq!(err, CausalError::NodeAlreadyExists("A".into()));
}
#[test]
fn test_add_edge_success_updates_parent_children() {
let engine = simple_xy();
let x = engine
.graph
.nodes
.get(&CausalNodeId::new("X"))
.expect("test setup: node must exist in graph");
let y = engine
.graph
.nodes
.get(&CausalNodeId::new("Y"))
.expect("test setup: node must exist in graph");
assert!(x.children.contains(&CausalNodeId::new("Y")));
assert!(y.parents.contains(&CausalNodeId::new("X")));
}
#[test]
fn test_add_edge_missing_from_returns_error() {
let mut engine = CausalInferenceEngine::new(10);
engine
.add_node(CausalNode::new("Y", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
let err = engine
.add_edge(CausalEdge::direct("X", "Y", 1.0))
.unwrap_err();
assert_eq!(err, CausalError::NodeNotFound("X".into()));
}
#[test]
fn test_add_edge_missing_to_returns_error() {
let mut engine = CausalInferenceEngine::new(10);
engine
.add_node(CausalNode::new("X", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
let err = engine
.add_edge(CausalEdge::direct("X", "Y", 1.0))
.unwrap_err();
assert_eq!(err, CausalError::NodeNotFound("Y".into()));
}
#[test]
fn test_add_edge_self_loop_rejected() {
let mut engine = CausalInferenceEngine::new(10);
engine
.add_node(CausalNode::new("X", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
let err = engine
.add_edge(CausalEdge::direct("X", "X", 1.0))
.unwrap_err();
assert_eq!(
err,
CausalError::InvalidEdge("self-loop is not allowed".into())
);
}
#[test]
fn test_add_edge_cycle_rejected() {
let mut engine = CausalInferenceEngine::new(10);
engine
.add_node(CausalNode::new("A", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("B", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_edge(CausalEdge::direct("A", "B", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
let err = engine
.add_edge(CausalEdge::direct("B", "A", 1.0))
.unwrap_err();
assert_eq!(err, CausalError::CycleDetected);
}
#[test]
fn test_remove_node_removes_edges() {
let mut engine = simple_xy();
let removed = engine.remove_node(&CausalNodeId::new("X"));
assert!(removed);
assert!(!engine.graph.nodes.contains_key(&CausalNodeId::new("X")));
assert!(engine.graph.edges.is_empty());
let y = engine
.graph
.nodes
.get(&CausalNodeId::new("Y"))
.expect("test setup: node must exist in graph");
assert!(y.parents.is_empty());
}
#[test]
fn test_remove_nonexistent_node_returns_false() {
let mut engine = CausalInferenceEngine::new(10);
assert!(!engine.remove_node(&CausalNodeId::new("Ghost")));
}
#[test]
fn test_has_path_direct() {
let engine = simple_xy();
assert!(engine.has_path(&CausalNodeId::new("X"), &CausalNodeId::new("Y")));
}
#[test]
fn test_has_path_no_reverse() {
let engine = simple_xy();
assert!(!engine.has_path(&CausalNodeId::new("Y"), &CausalNodeId::new("X")));
}
#[test]
fn test_has_path_through_mediator() {
let engine = chain_xmy();
assert!(engine.has_path(&CausalNodeId::new("X"), &CausalNodeId::new("Y")));
}
#[test]
fn test_has_path_same_node() {
let engine = simple_xy();
assert!(engine.has_path(&CausalNodeId::new("X"), &CausalNodeId::new("X")));
}
#[test]
fn test_is_ancestor_direct() {
let engine = simple_xy();
assert!(engine.is_ancestor(&CausalNodeId::new("X"), &CausalNodeId::new("Y")));
}
#[test]
fn test_is_ancestor_not_self() {
let engine = simple_xy();
assert!(!engine.is_ancestor(&CausalNodeId::new("X"), &CausalNodeId::new("X")));
}
#[test]
fn test_is_ancestor_transitive() {
let engine = chain_xmy();
assert!(engine.is_ancestor(&CausalNodeId::new("X"), &CausalNodeId::new("Y")));
}
#[test]
fn test_ancestors_chain() {
let engine = chain_xmy();
let ancs = engine.ancestors(&CausalNodeId::new("Y"));
assert!(ancs.contains(&CausalNodeId::new("M")));
assert!(ancs.contains(&CausalNodeId::new("X")));
}
#[test]
fn test_ancestors_root_has_none() {
let engine = chain_xmy();
assert!(engine.ancestors(&CausalNodeId::new("X")).is_empty());
}
#[test]
fn test_descendants_chain() {
let engine = chain_xmy();
let descs = engine.descendants(&CausalNodeId::new("X"));
assert!(descs.contains(&CausalNodeId::new("M")));
assert!(descs.contains(&CausalNodeId::new("Y")));
}
#[test]
fn test_descendants_leaf_has_none() {
let engine = chain_xmy();
assert!(engine.descendants(&CausalNodeId::new("Y")).is_empty());
}
#[test]
fn test_do_calculus_direct_edge() {
let engine = simple_xy();
let result = engine.do_calculus(&Intervention::new("X", 2.0), &CausalNodeId::new("Y"));
assert!((result.mean - 1.0).abs() < 1e-9, "mean={}", result.mean);
}
#[test]
fn test_do_calculus_chain() {
let engine = chain_xmy();
let result = engine.do_calculus(&Intervention::new("X", 1.0), &CausalNodeId::new("Y"));
assert!((result.mean - 0.48).abs() < 1e-9, "mean={}", result.mean);
}
#[test]
fn test_do_calculus_no_path_gives_zero_mean() {
let engine = simple_xy();
let result = engine.do_calculus(&Intervention::new("Y", 5.0), &CausalNodeId::new("X"));
assert!((result.mean).abs() < 1e-9);
}
#[test]
fn test_do_calculus_target_variance_shrinks() {
let engine = simple_xy();
let base_var = engine
.graph
.nodes
.get(&CausalNodeId::new("Y"))
.expect("test setup: node must exist in graph")
.variance;
let result = engine.do_calculus(&Intervention::new("X", 1.0), &CausalNodeId::new("Y"));
assert!(result.variance <= base_var);
}
#[test]
fn test_do_calculus_confidence_bounded() {
let engine = simple_xy();
let result = engine.do_calculus(&Intervention::new("X", 1.0), &CausalNodeId::new("Y"));
assert!((0.0..=1.0).contains(&result.confidence));
}
#[test]
fn test_do_calculus_interventions_recorded() {
let engine = simple_xy();
let int = Intervention::new("X", 3.0);
let result = engine.do_calculus(&int, &CausalNodeId::new("Y"));
assert_eq!(result.interventions_applied.len(), 1);
assert_eq!(result.interventions_applied[0].node, CausalNodeId::new("X"));
}
#[test]
fn test_counterfactual_no_evidence_equals_do_calculus() {
let engine = simple_xy();
let int = Intervention::new("X", 1.0);
let query = CounterfactualQuery {
target: CausalNodeId::new("Y"),
intervention: int.clone(),
evidence: HashMap::new(),
};
let cf_result = engine.counterfactual(&query);
let do_result = engine.do_calculus(&int, &CausalNodeId::new("Y"));
assert!((cf_result.mean - do_result.mean).abs() < 1e-9);
}
#[test]
fn test_counterfactual_with_evidence_adjusts_mean() {
let mut engine = CausalInferenceEngine::new(10);
engine
.add_node(CausalNode::new("X", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("Z", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("Y", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_edge(CausalEdge::direct("X", "Y", 0.5))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("Z", "Y", 0.3))
.expect("test setup: add_edge should not fail for valid DAG edge");
let mut evidence = HashMap::new();
evidence.insert(CausalNodeId::new("Z"), 2.0);
let query = CounterfactualQuery {
target: CausalNodeId::new("Y"),
intervention: Intervention::new("X", 1.0),
evidence,
};
let cf = engine.counterfactual(&query);
assert!((cf.mean - 1.1).abs() < 1e-9, "mean={}", cf.mean);
}
#[test]
fn test_ace_linear() {
let engine = simple_xy();
let ace = engine.average_causal_effect(
&CausalNodeId::new("X"),
&CausalNodeId::new("Y"),
0.0,
2.0,
);
assert!((ace - 1.0).abs() < 1e-9, "ace={ace}");
}
#[test]
fn test_ace_zero_when_no_path() {
let engine = simple_xy();
let ace = engine.average_causal_effect(
&CausalNodeId::new("Y"),
&CausalNodeId::new("X"),
0.0,
1.0,
);
assert!((ace).abs() < 1e-9);
}
#[test]
fn test_ace_chain() {
let engine = chain_xmy();
let ace = engine.average_causal_effect(
&CausalNodeId::new("X"),
&CausalNodeId::new("Y"),
0.0,
1.0,
);
assert!((ace - 0.48).abs() < 1e-9, "ace={ace}");
}
#[test]
fn test_confounders_common_cause() {
let mut engine = CausalInferenceEngine::new(10);
engine
.add_node(CausalNode::new("Z", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("X", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("Y", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_edge(CausalEdge::direct("Z", "X", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("Z", "Y", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
let conf = engine.confounders(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
assert!(conf.contains(&CausalNodeId::new("Z")));
}
#[test]
fn test_confounders_no_common_cause() {
let engine = simple_xy();
let conf = engine.confounders(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
assert!(conf.is_empty());
}
#[test]
fn test_d_sep_blocked_by_given() {
let engine = chain_xmy();
let given = vec![CausalNodeId::new("M")];
assert!(engine.is_d_separated(&CausalNodeId::new("X"), &CausalNodeId::new("Y"), &given,));
}
#[test]
fn test_d_sep_not_separated_without_given() {
let engine = chain_xmy();
assert!(!engine.is_d_separated(&CausalNodeId::new("X"), &CausalNodeId::new("Y"), &[],));
}
#[test]
fn test_d_sep_direct_edge_blocks_without_given() {
let engine = simple_xy();
assert!(!engine.is_d_separated(&CausalNodeId::new("X"), &CausalNodeId::new("Y"), &[],));
}
#[test]
fn test_backdoor_paths_with_confounder() {
let mut engine = CausalInferenceEngine::new(10);
engine
.add_node(CausalNode::new("Z", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("X", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("Y", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_edge(CausalEdge::direct("Z", "X", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("Z", "Y", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
let bd = engine.backdoor_paths(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
assert!(!bd.is_empty(), "expected at least one backdoor path");
}
#[test]
fn test_backdoor_paths_empty_for_chain() {
let engine = chain_xmy();
let bd = engine.backdoor_paths(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
assert!(bd.is_empty());
}
#[test]
fn test_stats_empty_graph() {
let engine = CausalInferenceEngine::new(5);
let s = engine.stats();
assert_eq!(s.node_count, 0);
assert_eq!(s.edge_count, 0);
assert_eq!(s.max_depth, 0);
}
#[test]
fn test_stats_simple_xy() {
let engine = simple_xy();
let s = engine.stats();
assert_eq!(s.node_count, 2);
assert_eq!(s.edge_count, 1);
assert!((s.avg_children - 0.5).abs() < 1e-9);
assert_eq!(s.max_depth, 1);
}
#[test]
fn test_stats_chain() {
let engine = chain_xmy();
let s = engine.stats();
assert_eq!(s.node_count, 3);
assert_eq!(s.edge_count, 2);
assert_eq!(s.max_depth, 2);
}
#[test]
fn test_causal_node_id_display() {
let id = CausalNodeId::new("foo");
assert_eq!(format!("{id}"), "foo");
}
#[test]
fn test_causal_node_id_as_str() {
let id = CausalNodeId::new("bar");
assert_eq!(id.as_str(), "bar");
}
#[test]
fn test_causal_node_id_equality() {
let a = CausalNodeId::new("x");
let b = CausalNodeId::new("x");
let c = CausalNodeId::new("y");
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn test_error_display_messages() {
assert!(CausalError::NodeAlreadyExists("X".into())
.to_string()
.contains("X"));
assert!(CausalError::NodeNotFound("Y".into())
.to_string()
.contains("Y"));
assert!(!CausalError::CycleDetected.to_string().is_empty());
assert!(CausalError::InvalidEdge("bad".into())
.to_string()
.contains("bad"));
}
#[test]
fn test_do_calculus_multiple_paths() {
let mut engine = CausalInferenceEngine::new(10);
engine
.add_node(CausalNode::new("X", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("M", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("Y", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_edge(CausalEdge::direct("X", "Y", 0.3))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("X", "M", 0.5))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("M", "Y", 0.4))
.expect("test setup: add_edge should not fail for valid DAG edge");
let result = engine.do_calculus(&Intervention::new("X", 1.0), &CausalNodeId::new("Y"));
assert!((result.mean - 0.5).abs() < 1e-9, "mean={}", result.mean);
}
#[test]
fn test_all_directed_paths_chain() {
let engine = chain_xmy();
let paths = engine.all_directed_paths(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].len(), 3); }
#[test]
fn test_all_directed_paths_no_path() {
let engine = simple_xy();
let paths = engine.all_directed_paths(&CausalNodeId::new("Y"), &CausalNodeId::new("X"));
assert!(paths.is_empty());
}
#[test]
fn test_path_length_limit_blocks_long_paths() {
let mut engine = CausalInferenceEngine::new(2);
for name in ["A", "B", "C", "D"] {
engine
.add_node(CausalNode::new(name, 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
}
engine
.add_edge(CausalEdge::direct("A", "B", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("B", "C", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("C", "D", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
let paths = engine.all_directed_paths(&CausalNodeId::new("A"), &CausalNodeId::new("D"));
assert!(paths.is_empty(), "expected no paths with limit=2");
}
#[test]
fn test_do_calculus_negative_strength() {
let mut engine = CausalInferenceEngine::new(10);
engine
.add_node(CausalNode::new("X", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("Y", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_edge(CausalEdge::direct("X", "Y", -0.4))
.expect("test setup: add_edge should not fail for valid DAG edge");
let result = engine.do_calculus(&Intervention::new("X", 2.0), &CausalNodeId::new("Y"));
assert!((result.mean - (-0.8)).abs() < 1e-9, "mean={}", result.mean);
}
#[test]
fn test_edge_direct_helper() {
let edge = CausalEdge::direct("A", "B", 0.7);
assert_eq!(edge.from, CausalNodeId::new("A"));
assert_eq!(edge.to, CausalNodeId::new("B"));
assert_eq!(edge.edge_type, CausalEdgeType::Direct);
assert!((edge.strength - 0.7).abs() < 1e-9);
}
#[test]
fn test_intervention_new() {
let int = Intervention::new("X", std::f64::consts::PI);
assert_eq!(int.node, CausalNodeId::new("X"));
assert!((int.value - std::f64::consts::PI).abs() < 1e-9);
}
#[test]
fn test_diamond_graph_two_paths() {
let mut engine = CausalInferenceEngine::new(10);
for name in ["X", "A", "B", "Y"] {
engine
.add_node(CausalNode::new(name, 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
}
engine
.add_edge(CausalEdge::direct("X", "A", 0.5))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("X", "B", 0.5))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("A", "Y", 0.6))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("B", "Y", 0.4))
.expect("test setup: add_edge should not fail for valid DAG edge");
let result = engine.do_calculus(&Intervention::new("X", 1.0), &CausalNodeId::new("Y"));
assert!((result.mean - 0.5).abs() < 1e-9, "mean={}", result.mean);
}
#[test]
fn test_counterfactual_multiple_evidence_nodes() {
let mut engine = CausalInferenceEngine::new(10);
engine
.add_node(CausalNode::new("X", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("Z1", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("Z2", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("Y", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_edge(CausalEdge::direct("X", "Y", 0.4))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("Z1", "Y", 0.2))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("Z2", "Y", 0.3))
.expect("test setup: add_edge should not fail for valid DAG edge");
let mut evidence = HashMap::new();
evidence.insert(CausalNodeId::new("Z1"), 1.0);
evidence.insert(CausalNodeId::new("Z2"), 1.0);
let query = CounterfactualQuery {
target: CausalNodeId::new("Y"),
intervention: Intervention::new("X", 1.0),
evidence,
};
let cf = engine.counterfactual(&query);
assert!((cf.mean - 0.9).abs() < 1e-9, "mean={}", cf.mean);
}
#[test]
fn test_descendants_of_mediator() {
let engine = chain_xmy();
let descs = engine.descendants(&CausalNodeId::new("M"));
assert_eq!(descs.len(), 1);
assert!(descs.contains(&CausalNodeId::new("Y")));
}
#[test]
fn test_confounders_sorted() {
let mut engine = CausalInferenceEngine::new(10);
for name in ["Z1", "Z2", "X", "Y"] {
engine
.add_node(CausalNode::new(name, 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
}
engine
.add_edge(CausalEdge::direct("Z1", "X", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("Z2", "X", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("Z1", "Y", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("Z2", "Y", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
let conf = engine.confounders(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
assert_eq!(conf.len(), 2);
let names: Vec<&str> = conf.iter().map(|id| id.as_str()).collect();
let mut sorted_names = names.clone();
sorted_names.sort_unstable();
assert_eq!(names, sorted_names);
}
#[test]
fn test_remove_child_updates_parent_children_list() {
let mut engine = chain_xmy();
engine.remove_node(&CausalNodeId::new("Y"));
let m_node = engine
.graph
.nodes
.get(&CausalNodeId::new("M"))
.expect("test setup: node must exist in graph");
assert!(m_node.children.is_empty());
}
#[test]
fn test_causal_node_new_empty() {
let node = CausalNode::new("test", 1.5, 2.5);
assert_eq!(node.id, CausalNodeId::new("test"));
assert!((node.mean - 1.5).abs() < 1e-9);
assert!((node.variance - 2.5).abs() < 1e-9);
assert!(node.parents.is_empty());
assert!(node.children.is_empty());
}
#[test]
fn test_stats_avg_children_diamond() {
let mut engine = CausalInferenceEngine::new(10);
for name in ["X", "A", "B", "Y"] {
engine
.add_node(CausalNode::new(name, 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
}
engine
.add_edge(CausalEdge::direct("X", "A", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("X", "B", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("A", "Y", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("B", "Y", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
let s = engine.stats();
assert!(
(s.avg_children - 1.0).abs() < 1e-9,
"avg_children={}",
s.avg_children
);
}
#[test]
fn test_d_sep_fork_blocked_at_common_cause() {
let mut engine = CausalInferenceEngine::new(10);
engine
.add_node(CausalNode::new("Z", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("X", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_node(CausalNode::new("Y", 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
engine
.add_edge(CausalEdge::direct("Z", "X", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("Z", "Y", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
let given = vec![CausalNodeId::new("Z")];
assert!(engine.is_d_separated(&CausalNodeId::new("X"), &CausalNodeId::new("Y"), &given,));
}
#[test]
fn test_ace_scales_linearly_with_delta() {
let engine = simple_xy();
let ace1 = engine.average_causal_effect(
&CausalNodeId::new("X"),
&CausalNodeId::new("Y"),
0.0,
1.0,
);
let ace2 = engine.average_causal_effect(
&CausalNodeId::new("X"),
&CausalNodeId::new("Y"),
0.0,
2.0,
);
assert!((ace2 - 2.0 * ace1).abs() < 1e-9, "ace1={ace1} ace2={ace2}");
}
#[test]
fn test_all_directed_paths_diamond_returns_two() {
let mut engine = CausalInferenceEngine::new(10);
for name in ["X", "A", "B", "Y"] {
engine
.add_node(CausalNode::new(name, 0.0, 1.0))
.expect("test setup: add_node should not fail for unique node");
}
engine
.add_edge(CausalEdge::direct("X", "A", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("X", "B", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("A", "Y", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
engine
.add_edge(CausalEdge::direct("B", "Y", 1.0))
.expect("test setup: add_edge should not fail for valid DAG edge");
let paths = engine.all_directed_paths(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
assert_eq!(paths.len(), 2);
}
}