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. 1990 / Pearl 1988).
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    /// If `Z ∩ {x,y} ≠ ∅` the query is ill-posed and this returns `false`
91    /// (not separated), matching PAG m-separation / definite-status activity.
92    ///
93    /// # Errors
94    ///
95    /// Unknown node ids.
96    pub fn is_d_separated(
97        &self,
98        x: DenseNodeId,
99        y: DenseNodeId,
100        z: &[DenseNodeId],
101        ws: &mut DSeparationWorkspace,
102    ) -> Result<bool, GraphError> {
103        self.is_d_separated_with(x, y, z, ws, None)
104    }
105
106    /// d-separation under an optional [`GraphOverlay`].
107    pub(crate) fn is_d_separated_with(
108        &self,
109        x: DenseNodeId,
110        y: DenseNodeId,
111        z: &[DenseNodeId],
112        ws: &mut DSeparationWorkspace,
113        overlay: Option<&GraphOverlay>,
114    ) -> Result<bool, GraphError> {
115        self.validate_node_pub(x)?;
116        self.validate_node_pub(y)?;
117        for &v in z {
118            self.validate_node_pub(v)?;
119        }
120        if x == y {
121            return Ok(false);
122        }
123        if z.iter().any(|&v| v == x || v == y) {
124            return Ok(false);
125        }
126        Ok(self.d_sep_active_path(x, y, z, ws, overlay).is_none())
127    }
128
129    /// Batch boolean d-separation. `out[i]` corresponds to `queries[i] = (x,y,z)`.
130    ///
131    /// # Errors
132    ///
133    /// Unknown nodes; or `out.len() != queries.len()`.
134    pub fn is_d_separated_batch(
135        &self,
136        queries: &[(DenseNodeId, DenseNodeId, &[DenseNodeId])],
137        out: &mut [bool],
138        ws: &mut DSeparationWorkspace,
139    ) -> Result<(), GraphError> {
140        if out.len() != queries.len() {
141            return Err(GraphError::InvalidEndpoints { message: "batch output length mismatch" });
142        }
143        for (i, &(x, y, z)) in queries.iter().enumerate() {
144            out[i] = self.is_d_separated(x, y, z, ws)?;
145        }
146        Ok(())
147    }
148
149    /// d-separation with witness (active path or separation certificate).
150    ///
151    /// # Errors
152    ///
153    /// Unknown nodes.
154    pub fn d_separation(
155        &self,
156        x: DenseNodeId,
157        y: DenseNodeId,
158        z: &[DenseNodeId],
159        ws: &mut DSeparationWorkspace,
160    ) -> Result<SeparationResult, GraphError> {
161        self.d_separation_with(x, y, z, ws, None)
162    }
163
164    /// Witness d-separation under an optional [`GraphOverlay`].
165    pub(crate) fn d_separation_with(
166        &self,
167        x: DenseNodeId,
168        y: DenseNodeId,
169        z: &[DenseNodeId],
170        ws: &mut DSeparationWorkspace,
171        overlay: Option<&GraphOverlay>,
172    ) -> Result<SeparationResult, GraphError> {
173        self.validate_node_pub(x)?;
174        self.validate_node_pub(y)?;
175        for &v in z {
176            self.validate_node_pub(v)?;
177        }
178        if x == y {
179            return Ok(SeparationResult::Connected { active_path: vec![PathStep { node: x }] });
180        }
181        if z.iter().any(|&v| v == x || v == y) {
182            return Ok(SeparationResult::Connected {
183                active_path: vec![PathStep { node: x }, PathStep { node: y }],
184            });
185        }
186        if let Some(path) = self.d_sep_active_path(x, y, z, ws, overlay) {
187            Ok(SeparationResult::Connected {
188                active_path: path.into_iter().map(|node| PathStep { node }).collect(),
189            })
190        } else {
191            Ok(SeparationResult::Separated {
192                conditioning: z.to_vec(),
193                certificate: SeparationCertificate { conditioning: z.to_vec() },
194            })
195        }
196    }
197
198    /// Returns an active undirected path if d-connected; `None` if separated.
199    fn d_sep_active_path(
200        &self,
201        x: DenseNodeId,
202        y: DenseNodeId,
203        z: &[DenseNodeId],
204        ws: &mut DSeparationWorkspace,
205        overlay: Option<&GraphOverlay>,
206    ) -> Option<Vec<DenseNodeId>> {
207        let n = self.node_count();
208        ws.prepare(n);
209
210        // Ancestral set of {x,y} ∪ z
211        let mut seeds = Vec::with_capacity(2 + z.len());
212        seeds.push(x);
213        seeds.push(y);
214        seeds.extend_from_slice(z);
215        self.ancestors_of_with(&seeds, &mut ws.ancestral, &mut ws.graph_ws, overlay);
216
217        ws.conditioning.clear();
218        for &v in z {
219            ws.conditioning.insert(v);
220        }
221
222        // Build moral undirected graph on ancestral nodes.
223        for i in 0..n {
224            let u = DenseNodeId::from_raw(u32::try_from(i).expect("fit"));
225            if !ws.ancestral.contains(u) {
226                continue;
227            }
228            // Directed edges become undirected (within ancestral set).
229            for &c in self.children(u) {
230                if overlay.is_some_and(|ov| !ov.edge_visible(u, c)) {
231                    continue;
232                }
233                if ws.ancestral.contains(c) {
234                    add_undirected(&mut ws.undirected, u, c);
235                }
236            }
237            // Moral edges: marry parents connected by visible edges into u.
238            let parents = self.parents(u);
239            for (a_idx, &a) in parents.iter().enumerate() {
240                if overlay.is_some_and(|ov| !ov.edge_visible(a, u)) {
241                    continue;
242                }
243                if !ws.ancestral.contains(a) {
244                    continue;
245                }
246                for &b in &parents[a_idx + 1..] {
247                    if overlay.is_some_and(|ov| !ov.edge_visible(b, u)) {
248                        continue;
249                    }
250                    if ws.ancestral.contains(b) {
251                        add_undirected(&mut ws.undirected, a, b);
252                    }
253                }
254            }
255        }
256
257        // BFS from x to y avoiding conditioning set.
258        ws.visited.clear();
259        for p in &mut ws.pred {
260            *p = None;
261        }
262        ws.frontier.clear();
263        ws.frontier.push(x);
264        ws.visited.insert(x);
265        while let Some(u) = ws.frontier.pop() {
266            if u == y {
267                return Some(reconstruct_path(&ws.pred, x, y));
268            }
269            for &v in &ws.undirected[u.as_usize()] {
270                if ws.conditioning.contains(v) || ws.visited.contains(v) {
271                    continue;
272                }
273                if !ws.ancestral.contains(v) {
274                    continue;
275                }
276                ws.visited.insert(v);
277                ws.pred[v.as_usize()] = Some(u);
278                ws.frontier.push(v);
279            }
280        }
281        None
282    }
283}
284
285fn add_undirected(adj: &mut [Vec<DenseNodeId>], a: DenseNodeId, b: DenseNodeId) {
286    if a == b {
287        return;
288    }
289    let ai = a.as_usize();
290    let bi = b.as_usize();
291    if !adj[ai].contains(&b) {
292        adj[ai].push(b);
293    }
294    if !adj[bi].contains(&a) {
295        adj[bi].push(a);
296    }
297}
298
299fn reconstruct_path(
300    pred: &[Option<DenseNodeId>],
301    start: DenseNodeId,
302    end: DenseNodeId,
303) -> Vec<DenseNodeId> {
304    let mut path = vec![end];
305    let mut cur = end;
306    while cur != start {
307        cur = pred[cur.as_usize()].expect("path predecessor");
308        path.push(cur);
309    }
310    path.reverse();
311    path
312}
313
314#[cfg(test)]
315#[path = "dsep_tests.rs"]
316mod tests;