Skip to main content

ipfrs_tensorlogic/
causal_inference.rs

1//! Causal Inference Engine — do-calculus, interventional distributions, and
2//! counterfactual reasoning over Gaussian structural equation models.
3//!
4//! # Overview
5//!
6//! This module implements a full causal inference pipeline built on directed
7//! acyclic graphs (DAGs) with Gaussian node semantics.  The design follows
8//! Pearl's do-calculus framework:
9//!
10//! 1. **Structural Causal Model (SCM)** — each node is parameterised by a mean
11//!    and a variance under a Gaussian structural equation model.
12//! 2. **Interventional inference** (`do_calculus`) — cuts incoming edges to the
13//!    intervened node and propagates the fixed value through all directed paths
14//!    to the target, accumulating linear causal effects.
15//! 3. **Counterfactual inference** (`counterfactual`) — applies an intervention
16//!    and conditions on observed evidence by adding a weighted correction from
17//!    each evidence node to the target.
18//! 4. **Average Causal Effect** (`average_causal_effect`) — contrasts
19//!    interventional distributions to quantify treatment effects.
20//! 5. **d-separation** — checks whether two nodes are conditionally independent
21//!    given a set of observed variables.
22//! 6. **Backdoor paths** — enumerates confounding paths for identifiability
23//!    analysis.
24
25use std::collections::{HashMap, HashSet, VecDeque};
26
27/// Opaque identifier for a node inside a [`CausalGraph`].
28#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
29pub struct CausalNodeId(pub String);
30
31impl CausalNodeId {
32    /// Create a new identifier from any string-like value.
33    pub fn new(s: impl Into<String>) -> Self {
34        Self(s.into())
35    }
36
37    /// Borrow the inner string slice.
38    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// ── Edge ──────────────────────────────────────────────────────────────────────
50
51/// Semantic classification for an edge in a [`CausalGraph`].
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub enum CausalEdgeType {
54    /// A direct causal pathway (X → Y).
55    Direct,
56    /// A common-cause (bi-directed) relationship.
57    Confounded,
58    /// An open backdoor path that may introduce confounding bias.
59    Backdoor,
60    /// An instrumental variable link (Z → X, Z ⊥ Y | X).
61    Instrumental,
62}
63
64/// A directed edge in the causal graph carrying a linear strength coefficient.
65#[derive(Debug, Clone)]
66pub struct CausalEdge {
67    /// Source node of the edge.
68    pub from: CausalNodeId,
69    /// Destination node of the edge.
70    pub to: CausalNodeId,
71    /// Signed linear coefficient representing causal strength (may be negative).
72    pub strength: f64,
73    /// Semantic category of the relationship.
74    pub edge_type: CausalEdgeType,
75}
76
77impl CausalEdge {
78    /// Construct a new edge with default type [`CausalEdgeType::Direct`].
79    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// ── Node ──────────────────────────────────────────────────────────────────────
90
91/// A variable in the structural causal model parameterised by a Gaussian prior.
92#[derive(Debug, Clone)]
93pub struct CausalNode {
94    /// Unique identifier of this node.
95    pub id: CausalNodeId,
96    /// Direct causal parents (variables whose values influence this node).
97    pub parents: Vec<CausalNodeId>,
98    /// Direct causal children (variables this node influences).
99    pub children: Vec<CausalNodeId>,
100    /// Prior (or marginal) mean of the Gaussian distribution over this variable.
101    pub mean: f64,
102    /// Prior (or marginal) variance of the Gaussian distribution over this variable.
103    pub variance: f64,
104}
105
106impl CausalNode {
107    /// Create a standalone node (no edges yet) with the given Gaussian parameters.
108    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// ── Graph ─────────────────────────────────────────────────────────────────────
120
121/// The underlying directed acyclic graph over causal nodes.
122#[derive(Debug, Default, Clone)]
123pub struct CausalGraph {
124    /// All nodes, keyed by their identifier.
125    pub nodes: HashMap<CausalNodeId, CausalNode>,
126    /// All directed edges in the graph.
127    pub edges: Vec<CausalEdge>,
128}
129
130// ── Do-calculus primitives ────────────────────────────────────────────────────
131
132/// A hard intervention: set node `node` to exactly `value` (written do(X = value)).
133#[derive(Debug, Clone)]
134pub struct Intervention {
135    /// The node being intervened upon.
136    pub node: CausalNodeId,
137    /// The value assigned by the intervention.
138    pub value: f64,
139}
140
141impl Intervention {
142    /// Construct a new intervention.
143    pub fn new(node: impl Into<String>, value: f64) -> Self {
144        Self {
145            node: CausalNodeId::new(node),
146            value,
147        }
148    }
149}
150
151/// A counterfactual query: what would `target` be if we had applied
152/// `intervention`, given that we observed `evidence`?
153#[derive(Debug, Clone)]
154pub struct CounterfactualQuery {
155    /// The variable whose counterfactual value we wish to estimate.
156    pub target: CausalNodeId,
157    /// The hypothetical intervention.
158    pub intervention: Intervention,
159    /// Observed values for conditioning variables.
160    pub evidence: HashMap<CausalNodeId, f64>,
161}
162
163/// The result returned by an inference query.
164#[derive(Debug, Clone)]
165pub struct InferenceResult {
166    /// The variable that was queried.
167    pub target: CausalNodeId,
168    /// Posterior mean under the intervention / evidence.
169    pub mean: f64,
170    /// Posterior variance under the intervention / evidence.
171    pub variance: f64,
172    /// Confidence in [0, 1] — fraction of target variance explained by causal paths.
173    pub confidence: f64,
174    /// All interventions that were applied to produce this result.
175    pub interventions_applied: Vec<Intervention>,
176}
177
178// ── Error type ────────────────────────────────────────────────────────────────
179
180/// Errors that can be produced by the [`CausalInferenceEngine`].
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum CausalError {
183    /// A node with the same identifier already exists in the graph.
184    NodeAlreadyExists(String),
185    /// A referenced node does not exist in the graph.
186    NodeNotFound(String),
187    /// Adding the requested edge would create a directed cycle.
188    CycleDetected,
189    /// The edge specification is otherwise invalid.
190    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// ── Statistics ────────────────────────────────────────────────────────────────
207
208/// Summary statistics about the structure of a [`CausalGraph`].
209#[derive(Debug, Clone)]
210pub struct CausalStats {
211    /// Total number of nodes in the graph.
212    pub node_count: usize,
213    /// Total number of edges in the graph.
214    pub edge_count: usize,
215    /// Mean number of children per node.
216    pub avg_children: f64,
217    /// Length of the longest path (in hops) from any root to any leaf.
218    pub max_depth: usize,
219}
220
221// ── Engine ────────────────────────────────────────────────────────────────────
222
223/// Production-grade causal inference engine.
224///
225/// # Example
226///
227/// ```
228/// use ipfrs_tensorlogic::{
229///     CausalInferenceEngine, CausalNode, CausalEdge, Intervention,
230/// };
231///
232/// let mut engine = CausalInferenceEngine::new(10);
233/// engine.add_node(CausalNode::new("X", 0.0, 1.0)).expect("example: should succeed in docs");
234/// engine.add_node(CausalNode::new("Y", 0.0, 1.0)).expect("example: should succeed in docs");
235/// engine.add_edge(CausalEdge::direct("X", "Y", 0.8)).expect("example: should succeed in docs");
236///
237/// let result = engine.do_calculus(
238///     &Intervention::new("X", 1.0),
239///     &ipfrs_tensorlogic::CausalNodeId::new("Y"),
240/// );
241/// assert!((result.mean - 0.8).abs() < 1e-9);
242/// ```
243#[derive(Debug)]
244pub struct CausalInferenceEngine {
245    /// The underlying causal graph.
246    pub graph: CausalGraph,
247    /// Maximum number of hops considered when enumerating paths.
248    pub max_path_length: usize,
249}
250
251impl CausalInferenceEngine {
252    // ── Construction ──────────────────────────────────────────────────────────
253
254    /// Create a new engine with an empty graph and the given path-length limit.
255    pub fn new(max_path_length: usize) -> Self {
256        Self {
257            graph: CausalGraph::default(),
258            max_path_length,
259        }
260    }
261
262    // ── Mutation ──────────────────────────────────────────────────────────────
263
264    /// Add a node to the graph.
265    ///
266    /// Returns [`CausalError::NodeAlreadyExists`] if the identifier is taken.
267    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    /// Add a directed edge to the graph.
276    ///
277    /// Both endpoints must already exist, and the edge must not introduce a
278    /// directed cycle.  On success the parent/child lists of both endpoints are
279    /// updated in-place.
280    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        // Cycle check: if there is already a path from `to` back to `from`,
292        // adding from→to would create a cycle.
293        if self.has_path(&edge.to, &edge.from) {
294            return Err(CausalError::CycleDetected);
295        }
296
297        // Update adjacency lists.
298        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    /// Remove a node and all edges that touch it.
317    ///
318    /// Returns `true` if the node existed and was removed, `false` otherwise.
319    pub fn remove_node(&mut self, id: &CausalNodeId) -> bool {
320        if !self.graph.nodes.contains_key(id) {
321            return false;
322        }
323
324        // Remove all edges incident to this node.
325        self.graph.edges.retain(|e| &e.from != id && &e.to != id);
326
327        // Clean up parent/child references in remaining nodes.
328        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    // ── Graph queries ─────────────────────────────────────────────────────────
338
339    /// Return `true` if there is at least one directed path from `from` to `to`
340    /// (depth-first search, respects `max_path_length`).
341    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    /// Return `true` if `ancestor` is a strict ancestor of `descendant`
367    /// (i.e. there is a directed path from `ancestor` to `descendant`).
368    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    /// Return all strict ancestors of `id` via BFS through parent pointers.
376    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(&current) {
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    /// Return all strict descendants of `id` via BFS through child pointers.
403    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(&current) {
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    // ── Path enumeration ──────────────────────────────────────────────────────
430
431    /// Enumerate all directed paths from `from` to `to` up to
432    /// `max_path_length` hops (DFS with backtracking).
433    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                // Avoid cycles in the traversal path
461                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    /// Enumerate backdoor paths from `from` to `to`.
472    ///
473    /// A backdoor path is any path that arrives at `from` via an *incoming*
474    /// edge (i.e. starts from a parent of `from` and eventually reaches `to`).
475    /// Paths are limited to `max_path_length` hops.
476    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            // Build paths from this parent to `to` using the undirected
485            // skeleton but respecting the "starts by entering `from`" semantics.
486            // We use a DFS that follows all adjacent edges (both directed
487            // directions) while forbidding the use of the direct from→to
488            // directed edge so as to enumerate only backdoor routes.
489            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, // the original `from` — used to avoid re-entering it
500        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        // Walk neighbours in the undirected skeleton (both directions).
511        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    // ── Edge-lookup helpers ───────────────────────────────────────────────────
534
535    /// Look up the strength of the direct edge from `from` to `to`, returning
536    /// `0.0` if no such edge exists.
537    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    /// Compute the product of edge strengths along a directed path.
547    ///
548    /// The path is given as a sequence of node ids.  For each consecutive pair
549    /// the direct edge strength is looked up; if any hop has no direct edge the
550    /// path effect is `0.0`.
551    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    // ── Interventional inference ──────────────────────────────────────────────
564
565    /// Compute the interventional distribution P(target | do(intervention)).
566    ///
567    /// Uses a linear causal model: the mean of `target` under the intervention
568    /// is the intervention value times the sum of all path effects from the
569    /// intervened node to `target`.  The posterior variance shrinks by the
570    /// total explained fraction (capped at 0.99 to avoid degenerate zero
571    /// variance), and the confidence is the explained fraction clamped to
572    /// [0, 1].
573    pub fn do_calculus(
574        &self,
575        intervention: &Intervention,
576        target: &CausalNodeId,
577    ) -> InferenceResult {
578        // All directed paths from the intervened node to the target.
579        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    // ── Counterfactual inference ──────────────────────────────────────────────
605
606    /// Estimate the counterfactual distribution of `query.target` had
607    /// `query.intervention` been applied, conditioned on `query.evidence`.
608    ///
609    /// The implementation uses do-calculus as a base and adds an evidence
610    /// correction: for each observed variable in `evidence` we sum the product
611    /// of its observed value and the direct edge strength to the target.
612    pub fn counterfactual(&self, query: &CounterfactualQuery) -> InferenceResult {
613        let mut base = self.do_calculus(&query.intervention, &query.target);
614
615        // Evidence correction: weighted sum of evidence → target edge strengths.
616        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        // Record all interventions that were implicitly applied via evidence.
627        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    // ── Average Causal Effect ─────────────────────────────────────────────────
637
638    /// Compute the Average Causal Effect (ACE) of `from` on `to`.
639    ///
640    /// ACE = E[to | do(from = value2)] - E[to | do(from = value1)]
641    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    // ── Confounders ───────────────────────────────────────────────────────────
660
661    /// Return the common ancestors of `x` and `y` (i.e. potential confounders).
662    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    // ── d-separation ─────────────────────────────────────────────────────────
671
672    /// Simplified d-separation check.
673    ///
674    /// Returns `true` if `x` and `y` are d-separated given the conditioning
675    /// set `given`.
676    ///
677    /// The implementation tests:
678    /// 1. No direct edge from `x` to `y` (after removing given nodes).
679    /// 2. No directed path from `x` to `y` that does not pass through a
680    ///    given node.
681    /// 3. No backdoor path from `x` to `y` that is not blocked by a given node.
682    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        // Check direct edge x → y not blocked.
691        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        // Check all directed paths from x to y; a path is blocked if it
698        // passes through a given node (chain / fork blocking).
699        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        // Check backdoor paths from x to y.
709        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    // ── Statistics ────────────────────────────────────────────────────────────
726
727    /// Compute summary statistics for the current graph.
728    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        // BFS from each root (no parents) to find the maximum depth.
744        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    /// Compute the maximum path length from any root to any leaf.
755    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// ─────────────────────────────────────────────────────────────────────────────
793// Tests
794// ─────────────────────────────────────────────────────────────────────────────
795
796#[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    // ── Helpers ────────────────────────────────────────────────────────────────
806
807    /// Build a simple X → Y graph.
808    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    /// Build X → M → Y (mediated chain).
823    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    // ── 1. Construction ────────────────────────────────────────────────────────
844
845    #[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    // ── 2. add_node ───────────────────────────────────────────────────────────
854
855    #[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    // ── 3. add_edge ───────────────────────────────────────────────────────────
874
875    #[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    // ── 4. remove_node ────────────────────────────────────────────────────────
950
951    #[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        // Y's parent list should be cleaned.
959        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    // ── 5. has_path ───────────────────────────────────────────────────────────
974
975    #[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    // ── 6. is_ancestor ────────────────────────────────────────────────────────
1000
1001    #[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    // ── 7. ancestors / descendants ───────────────────────────────────────────
1020
1021    #[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    // ── 8. do_calculus ────────────────────────────────────────────────────────
1050
1051    #[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        // Only one path X→Y with strength 0.5, so mean = 2.0 * 0.5 = 1.0
1056        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        // Path X→M→Y: strength = 0.6 * 0.8 = 0.48
1064        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        // Intervene on Y, query X — no directed path Y→X
1071        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    // ── 9. counterfactual ─────────────────────────────────────────────────────
1105
1106    #[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        // Build Z → Y (direct), X → Y (direct)
1123        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        // do-calculus mean = 1.0 * 0.5 = 0.5; correction = 2.0 * 0.3 = 0.6
1150        assert!((cf.mean - 1.1).abs() < 1e-9, "mean={}", cf.mean);
1151    }
1152
1153    // ── 10. average_causal_effect ─────────────────────────────────────────────
1154
1155    #[test]
1156    fn test_ace_linear() {
1157        let engine = simple_xy();
1158        // ACE = (2.0 - 0.0) * 0.5 = 1.0
1159        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        // Total path X→M→Y: 0.6*0.8 = 0.48; ACE = (1.0-0.0)*0.48 = 0.48
1184        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    // ── 11. confounders ───────────────────────────────────────────────────────
1194
1195    #[test]
1196    fn test_confounders_common_cause() {
1197        // Z → X, Z → Y
1198        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    // ── 12. is_d_separated ───────────────────────────────────────────────────
1227
1228    #[test]
1229    fn test_d_sep_blocked_by_given() {
1230        let engine = chain_xmy();
1231        // Conditioning on M blocks the path X → M → Y
1232        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    // ── 13. backdoor_paths ────────────────────────────────────────────────────
1249
1250    #[test]
1251    fn test_backdoor_paths_with_confounder() {
1252        // Z → X, Z → Y (fork)
1253        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        // X → M → Y has no backdoor path into X
1277        let engine = chain_xmy();
1278        let bd = engine.backdoor_paths(&CausalNodeId::new("X"), &CausalNodeId::new("Y"));
1279        assert!(bd.is_empty());
1280    }
1281
1282    // ── 14. stats ─────────────────────────────────────────────────────────────
1283
1284    #[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    // ── 15. CausalNodeId helpers ──────────────────────────────────────────────
1313
1314    #[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    // ── 16. CausalError display ───────────────────────────────────────────────
1336
1337    #[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    // ── 17. Multiple paths ────────────────────────────────────────────────────
1352
1353    #[test]
1354    fn test_do_calculus_multiple_paths() {
1355        // X → Y (0.3) and X → M → Y (0.5 * 0.4 = 0.2); total = 0.5
1356        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        // 0.3 + 0.2 = 0.5
1378        assert!((result.mean - 0.5).abs() < 1e-9, "mean={}", result.mean);
1379    }
1380
1381    // ── 18. all_directed_paths ────────────────────────────────────────────────
1382
1383    #[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); // X, M, Y
1389    }
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    // ── 19. path_length limit ─────────────────────────────────────────────────
1399
1400    #[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        // A→D requires 3 hops but max_path_length=2
1419        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    // ── 20. negative strength ─────────────────────────────────────────────────
1424
1425    #[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        // mean = 2.0 * (-0.4) = -0.8
1440        assert!((result.mean - (-0.8)).abs() < 1e-9, "mean={}", result.mean);
1441    }
1442
1443    // ── 21. CausalEdge construction helpers ───────────────────────────────────
1444
1445    #[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    // ── 22. Intervention helper ───────────────────────────────────────────────
1455
1456    #[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    // ── 23. large diamond graph ───────────────────────────────────────────────
1464
1465    #[test]
1466    fn test_diamond_graph_two_paths() {
1467        // X → A → Y and X → B → Y
1468        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        // Path X→A→Y: 0.5*0.6=0.3; path X→B→Y: 0.5*0.4=0.2; total = 0.5
1489        assert!((result.mean - 0.5).abs() < 1e-9, "mean={}", result.mean);
1490    }
1491
1492    // ── 24. counterfactual multiple evidence ─────────────────────────────────
1493
1494    #[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        // base = 1.0*0.4 = 0.4; correction = 1.0*0.2 + 1.0*0.3 = 0.5
1530        assert!((cf.mean - 0.9).abs() < 1e-9, "mean={}", cf.mean);
1531    }
1532
1533    // ── 25. descendants of middle node ───────────────────────────────────────
1534
1535    #[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    // ── 26. confounders sorted ────────────────────────────────────────────────
1544
1545    #[test]
1546    fn test_confounders_sorted() {
1547        // Build: Z1 → X, Z2 → X, Z1 → Y, Z2 → Y
1548        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        // They should be sorted
1570        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    // ── 27. remove node cleans children of parent ────────────────────────────
1577
1578    #[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    // ── 28. CausalNode::new initialises empty edges ──────────────────────────
1591
1592    #[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    // ── 29. stats avg_children ────────────────────────────────────────────────
1603
1604    #[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        // X has 2 children, A has 1, B has 1, Y has 0 → avg = (2+1+1+0)/4 = 1.0
1627        assert!(
1628            (s.avg_children - 1.0).abs() < 1e-9,
1629            "avg_children={}",
1630            s.avg_children
1631        );
1632    }
1633
1634    // ── 30. is_d_separated fork blocked at cause ─────────────────────────────
1635
1636    #[test]
1637    fn test_d_sep_fork_blocked_at_common_cause() {
1638        // Z → X, Z → Y: conditioning on Z blocks the backdoor X←Z→Y
1639        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    // ── 31. ACE scaling ──────────────────────────────────────────────────────
1661
1662    #[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        // ace2 should be twice ace1
1678        assert!((ace2 - 2.0 * ace1).abs() < 1e-9, "ace1={ace1} ace2={ace2}");
1679    }
1680
1681    // ── 32. all_directed_paths diamond ───────────────────────────────────────
1682
1683    #[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}