Skip to main content

antecedent_graph/
dsep.rs

1//! d-separation for DAGs.
2//!
3//! Boolean batch path allocates no path objects. Witness mode returns an active
4//! path certificate when nodes are d-connected given the conditioning set.
5//!
6//! Algorithm: ancestral subgraph → moralize → remove conditioning → undirected
7//! reachability (Lauritzen et al. / Pearl).
8//!
9//! SPDX-License-Identifier: MIT OR Apache-2.0
10
11#![allow(clippy::many_single_char_names)] // x, y, z are standard d-separation names
12
13use crate::dag::Dag;
14use crate::error::GraphError;
15use crate::overlay::GraphOverlay;
16use crate::types::DenseNodeId;
17use crate::workspace::{BitSet, GraphWorkspace};
18
19/// Step on an undirected active path (witness mode).
20#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
21pub struct PathStep {
22    /// Node at this step.
23    pub node: DenseNodeId,
24}
25
26/// Certificate that a conditioning set d-separates two nodes (boolean path).
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct SeparationCertificate {
29    /// Conditioning set used.
30    pub conditioning: Vec<DenseNodeId>,
31}
32
33/// Result of a d-separation query with optional witness.
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub enum SeparationResult {
36    /// d-separated given `conditioning`.
37    Separated {
38        /// Conditioning set.
39        conditioning: Vec<DenseNodeId>,
40        /// Certificate .
41        certificate: SeparationCertificate,
42    },
43    /// d-connected; `active_path` is an undirected path in the moral graph
44    /// after removing the conditioning set.
45    Connected {
46        /// Active path nodes from x to y.
47        active_path: Vec<PathStep>,
48    },
49}
50
51/// Scratch buffers for repeated d-separation queries.
52#[derive(Clone, Debug, Default)]
53pub struct DSeparationWorkspace {
54    /// Ancestral closure.
55    pub ancestral: BitSet,
56    /// Conditioning set membership.
57    pub conditioning: BitSet,
58    /// Undirected adjacency for the moral graph (only ancestral nodes).
59    pub undirected: Vec<Vec<DenseNodeId>>,
60    /// BFS visited.
61    pub visited: BitSet,
62    /// BFS frontier / predecessor scratch.
63    pub frontier: Vec<DenseNodeId>,
64    /// Predecessor for path reconstruction.
65    pub pred: Vec<Option<DenseNodeId>>,
66    /// Graph traversal workspace.
67    pub graph_ws: GraphWorkspace,
68}
69
70impl DSeparationWorkspace {
71    /// Prepare for a DAG with `n` nodes.
72    pub fn prepare(&mut self, n: usize) {
73        self.ancestral.resize(n);
74        self.conditioning.resize(n);
75        self.visited.resize(n);
76        self.undirected.resize(n, Vec::new());
77        for adj in &mut self.undirected {
78            adj.clear();
79        }
80        self.frontier.clear();
81        self.pred.clear();
82        self.pred.resize(n, None);
83        self.graph_ws.prepare(n);
84    }
85}
86
87impl Dag {
88    /// Whether `x` is d-separated from `y` given `z` (boolean; no path alloc).
89    ///
90    /// # Errors
91    ///
92    /// Unknown node ids.
93    pub fn is_d_separated(
94        &self,
95        x: DenseNodeId,
96        y: DenseNodeId,
97        z: &[DenseNodeId],
98        ws: &mut DSeparationWorkspace,
99    ) -> Result<bool, GraphError> {
100        self.is_d_separated_with(x, y, z, ws, None)
101    }
102
103    /// d-separation under an optional [`GraphOverlay`].
104    pub(crate) fn is_d_separated_with(
105        &self,
106        x: DenseNodeId,
107        y: DenseNodeId,
108        z: &[DenseNodeId],
109        ws: &mut DSeparationWorkspace,
110        overlay: Option<&GraphOverlay>,
111    ) -> Result<bool, GraphError> {
112        self.validate_node_pub(x)?;
113        self.validate_node_pub(y)?;
114        for &v in z {
115            self.validate_node_pub(v)?;
116        }
117        if x == y {
118            return Ok(false);
119        }
120        Ok(self.d_sep_active_path(x, y, z, ws, overlay).is_none())
121    }
122
123    /// Batch boolean d-separation. `out[i]` corresponds to `queries[i] = (x,y,z)`.
124    ///
125    /// # Errors
126    ///
127    /// Unknown nodes; or `out.len() != queries.len()`.
128    pub fn is_d_separated_batch(
129        &self,
130        queries: &[(DenseNodeId, DenseNodeId, &[DenseNodeId])],
131        out: &mut [bool],
132        ws: &mut DSeparationWorkspace,
133    ) -> Result<(), GraphError> {
134        if out.len() != queries.len() {
135            return Err(GraphError::InvalidEndpoints { message: "batch output length mismatch" });
136        }
137        for (i, &(x, y, z)) in queries.iter().enumerate() {
138            out[i] = self.is_d_separated(x, y, z, ws)?;
139        }
140        Ok(())
141    }
142
143    /// d-separation with witness (active path or separation certificate).
144    ///
145    /// # Errors
146    ///
147    /// Unknown nodes.
148    pub fn d_separation(
149        &self,
150        x: DenseNodeId,
151        y: DenseNodeId,
152        z: &[DenseNodeId],
153        ws: &mut DSeparationWorkspace,
154    ) -> Result<SeparationResult, GraphError> {
155        self.d_separation_with(x, y, z, ws, None)
156    }
157
158    /// Witness d-separation under an optional [`GraphOverlay`].
159    pub(crate) fn d_separation_with(
160        &self,
161        x: DenseNodeId,
162        y: DenseNodeId,
163        z: &[DenseNodeId],
164        ws: &mut DSeparationWorkspace,
165        overlay: Option<&GraphOverlay>,
166    ) -> Result<SeparationResult, GraphError> {
167        self.validate_node_pub(x)?;
168        self.validate_node_pub(y)?;
169        for &v in z {
170            self.validate_node_pub(v)?;
171        }
172        if x == y {
173            return Ok(SeparationResult::Connected { active_path: vec![PathStep { node: x }] });
174        }
175        if let Some(path) = self.d_sep_active_path(x, y, z, ws, overlay) {
176            Ok(SeparationResult::Connected {
177                active_path: path.into_iter().map(|node| PathStep { node }).collect(),
178            })
179        } else {
180            Ok(SeparationResult::Separated {
181                conditioning: z.to_vec(),
182                certificate: SeparationCertificate { conditioning: z.to_vec() },
183            })
184        }
185    }
186
187    /// Returns an active undirected path if d-connected; `None` if separated.
188    fn d_sep_active_path(
189        &self,
190        x: DenseNodeId,
191        y: DenseNodeId,
192        z: &[DenseNodeId],
193        ws: &mut DSeparationWorkspace,
194        overlay: Option<&GraphOverlay>,
195    ) -> Option<Vec<DenseNodeId>> {
196        let n = self.node_count();
197        ws.prepare(n);
198
199        // Ancestral set of {x,y} ∪ z
200        let mut seeds = Vec::with_capacity(2 + z.len());
201        seeds.push(x);
202        seeds.push(y);
203        seeds.extend_from_slice(z);
204        self.ancestors_of_with(&seeds, &mut ws.ancestral, &mut ws.graph_ws, overlay);
205
206        ws.conditioning.clear();
207        for &v in z {
208            ws.conditioning.insert(v);
209        }
210
211        // Build moral undirected graph on ancestral nodes.
212        for i in 0..n {
213            let u = DenseNodeId::from_raw(u32::try_from(i).expect("fit"));
214            if !ws.ancestral.contains(u) {
215                continue;
216            }
217            // Directed edges become undirected (within ancestral set).
218            for &c in self.children(u) {
219                if overlay.is_some_and(|ov| !ov.edge_visible(u, c)) {
220                    continue;
221                }
222                if ws.ancestral.contains(c) {
223                    add_undirected(&mut ws.undirected, u, c);
224                }
225            }
226            // Moral edges: marry parents connected by visible edges into u.
227            let parents = self.parents(u);
228            for (a_idx, &a) in parents.iter().enumerate() {
229                if overlay.is_some_and(|ov| !ov.edge_visible(a, u)) {
230                    continue;
231                }
232                if !ws.ancestral.contains(a) {
233                    continue;
234                }
235                for &b in &parents[a_idx + 1..] {
236                    if overlay.is_some_and(|ov| !ov.edge_visible(b, u)) {
237                        continue;
238                    }
239                    if ws.ancestral.contains(b) {
240                        add_undirected(&mut ws.undirected, a, b);
241                    }
242                }
243            }
244        }
245
246        // BFS from x to y avoiding conditioning set.
247        ws.visited.clear();
248        for p in &mut ws.pred {
249            *p = None;
250        }
251        if ws.conditioning.contains(x) || ws.conditioning.contains(y) {
252            // If x or y is in Z, they are not d-connected as open endpoints
253            // for the classical X⊥Y|Z query (conditioning includes the node).
254            // Treat as separated when either endpoint is conditioned.
255            return None;
256        }
257        ws.frontier.clear();
258        ws.frontier.push(x);
259        ws.visited.insert(x);
260        while let Some(u) = ws.frontier.pop() {
261            if u == y {
262                return Some(reconstruct_path(&ws.pred, x, y));
263            }
264            for &v in &ws.undirected[u.as_usize()] {
265                if ws.conditioning.contains(v) || ws.visited.contains(v) {
266                    continue;
267                }
268                if !ws.ancestral.contains(v) {
269                    continue;
270                }
271                ws.visited.insert(v);
272                ws.pred[v.as_usize()] = Some(u);
273                ws.frontier.push(v);
274            }
275        }
276        None
277    }
278}
279
280fn add_undirected(adj: &mut [Vec<DenseNodeId>], a: DenseNodeId, b: DenseNodeId) {
281    if a == b {
282        return;
283    }
284    let ai = a.as_usize();
285    let bi = b.as_usize();
286    if !adj[ai].contains(&b) {
287        adj[ai].push(b);
288    }
289    if !adj[bi].contains(&a) {
290        adj[bi].push(a);
291    }
292}
293
294fn reconstruct_path(
295    pred: &[Option<DenseNodeId>],
296    start: DenseNodeId,
297    end: DenseNodeId,
298) -> Vec<DenseNodeId> {
299    let mut path = vec![end];
300    let mut cur = end;
301    while cur != start {
302        cur = pred[cur.as_usize()].expect("path predecessor");
303        path.push(cur);
304    }
305    path.reverse();
306    path
307}
308
309#[cfg(test)]
310#[path = "dsep_tests.rs"]
311mod tests;