Skip to main content

antecedent_graph/
overlay.rs

1//! Intervention / mutilation overlays on an immutable [`Dag`].
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5use crate::dag::Dag;
6use crate::dsep::{DSeparationWorkspace, SeparationResult};
7use crate::error::GraphError;
8use crate::types::DenseNodeId;
9use crate::workspace::{BitSet, GraphWorkspace};
10
11/// Compact edge-visibility mask for graph surgery without cloning adjacency.
12///
13/// A directed edge `from → to` is visible iff
14/// `!hide_outgoing(from) && !hide_incoming(to)`.
15#[derive(Clone, Debug)]
16pub struct GraphOverlay {
17    /// Nodes whose incoming directed edges are hidden (`do(X)` mutilation).
18    hide_incoming: BitSet,
19    /// Nodes whose outgoing directed edges are hidden (`G_{\bar T}`).
20    hide_outgoing: BitSet,
21}
22
23impl GraphOverlay {
24    /// Observational overlay (all edges visible) for `n` nodes.
25    #[must_use]
26    pub fn observational(n: usize) -> Self {
27        Self { hide_incoming: BitSet::with_len(n), hide_outgoing: BitSet::with_len(n) }
28    }
29
30    /// Do-intervention overlay: hide all edges into each intervened node.
31    #[must_use]
32    pub fn do_intervention(n: usize, intervened: &[DenseNodeId]) -> Self {
33        let mut overlay = Self::observational(n);
34        for &v in intervened {
35            if v.as_usize() < n {
36                overlay.hide_incoming.insert(v);
37            }
38        }
39        overlay
40    }
41
42    /// Hide all outgoing edges from each node (backdoor / IV mutilation style).
43    #[must_use]
44    pub fn remove_outgoing(n: usize, nodes: &[DenseNodeId]) -> Self {
45        let mut overlay = Self::observational(n);
46        for &v in nodes {
47            if v.as_usize() < n {
48                overlay.hide_outgoing.insert(v);
49            }
50        }
51        overlay
52    }
53
54    /// Whether every directed edge of the base graph remains visible.
55    #[must_use]
56    pub fn is_observational(&self) -> bool {
57        !self.hide_incoming.any() && !self.hide_outgoing.any()
58    }
59
60    /// Whether directed edge `from → to` is visible under this overlay.
61    #[must_use]
62    pub fn edge_visible(&self, from: DenseNodeId, to: DenseNodeId) -> bool {
63        !self.hide_outgoing.contains(from) && !self.hide_incoming.contains(to)
64    }
65}
66
67/// Borrowed view of a [`Dag`] under a [`GraphOverlay`] (no adjacency clone).
68#[derive(Clone, Copy, Debug)]
69pub struct DagView<'a> {
70    dag: &'a Dag,
71    overlay: &'a GraphOverlay,
72}
73
74impl<'a> DagView<'a> {
75    /// Node count (same as the base DAG).
76    #[must_use]
77    pub fn node_count(&self) -> usize {
78        self.dag.node_count()
79    }
80
81    /// Base DAG.
82    #[must_use]
83    pub fn dag(&self) -> &'a Dag {
84        self.dag
85    }
86
87    /// Overlay mask.
88    #[must_use]
89    pub fn overlay(&self) -> &'a GraphOverlay {
90        self.overlay
91    }
92
93    /// Collect visible parents of `id` into `out` (cleared first).
94    pub fn parents_into(&self, id: DenseNodeId, out: &mut Vec<DenseNodeId>) {
95        out.clear();
96        if id.as_usize() >= self.dag.node_count() {
97            return;
98        }
99        for &p in self.dag.parents(id) {
100            if self.overlay.edge_visible(p, id) {
101                out.push(p);
102            }
103        }
104    }
105
106    /// Collect visible children of `id` into `out` (cleared first).
107    pub fn children_into(&self, id: DenseNodeId, out: &mut Vec<DenseNodeId>) {
108        out.clear();
109        if id.as_usize() >= self.dag.node_count() {
110            return;
111        }
112        for &c in self.dag.children(id) {
113            if self.overlay.edge_visible(id, c) {
114                out.push(c);
115            }
116        }
117    }
118
119    /// Ancestors under the overlay (including seeds).
120    pub fn ancestors_of(&self, nodes: &[DenseNodeId], out: &mut BitSet, ws: &mut GraphWorkspace) {
121        self.dag.ancestors_of_with(nodes, out, ws, Some(self.overlay));
122    }
123
124    /// Descendants under the overlay (including seeds).
125    pub fn descendants_of(&self, nodes: &[DenseNodeId], out: &mut BitSet, ws: &mut GraphWorkspace) {
126        self.dag.descendants_of_with(nodes, out, ws, Some(self.overlay));
127    }
128
129    /// d-separation on the overlay view (boolean; no path alloc).
130    ///
131    /// # Errors
132    ///
133    /// Unknown node ids.
134    pub fn is_d_separated(
135        &self,
136        x: DenseNodeId,
137        y: DenseNodeId,
138        z: &[DenseNodeId],
139        ws: &mut DSeparationWorkspace,
140    ) -> Result<bool, GraphError> {
141        self.dag.is_d_separated_with(x, y, z, ws, Some(self.overlay))
142    }
143
144    /// d-separation with witness on the overlay view.
145    ///
146    /// # Errors
147    ///
148    /// Unknown node ids.
149    pub fn d_separation(
150        &self,
151        x: DenseNodeId,
152        y: DenseNodeId,
153        z: &[DenseNodeId],
154        ws: &mut DSeparationWorkspace,
155    ) -> Result<SeparationResult, GraphError> {
156        self.dag.d_separation_with(x, y, z, ws, Some(self.overlay))
157    }
158
159    /// Materialize a concrete [`Dag`] with only visible edges (nodes preserved).
160    ///
161    /// # Errors
162    ///
163    /// Node-count overflow (should not occur for a valid base DAG).
164    pub fn materialize(&self) -> Result<Dag, GraphError> {
165        let n = u32::try_from(self.dag.node_count()).map_err(|_| GraphError::TooManyNodes)?;
166        let mut out = Dag::with_variables(n);
167        // Base is a valid DAG; removing edges cannot create cycles.
168        for i in 0..self.dag.node_count() {
169            let from = DenseNodeId::from_raw(u32::try_from(i).expect("node fit"));
170            for &to in self.dag.children(from) {
171                if self.overlay.edge_visible(from, to) {
172                    out.insert_directed_unchecked(from, to);
173                }
174            }
175        }
176        Ok(out)
177    }
178}
179
180impl Dag {
181    /// Borrow `self` under an intervention / mutilation overlay.
182    #[must_use]
183    pub fn view<'a>(&'a self, overlay: &'a GraphOverlay) -> DagView<'a> {
184        DagView { dag: self, overlay }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::dsep::DSeparationWorkspace;
192
193    fn chain3() -> Dag {
194        let mut g = Dag::with_variables(3);
195        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
196        g.insert_directed(DenseNodeId::from_raw(1), DenseNodeId::from_raw(2)).unwrap();
197        g
198    }
199
200    #[test]
201    fn do_intervention_view_matches_materialize() {
202        let g = chain3();
203        let t = DenseNodeId::from_raw(1);
204        let overlay = GraphOverlay::do_intervention(g.node_count(), &[t]);
205        let view = g.view(&overlay);
206        let mut parents = Vec::new();
207        view.parents_into(t, &mut parents);
208        assert!(parents.is_empty());
209        let mut children = Vec::new();
210        view.children_into(DenseNodeId::from_raw(0), &mut children);
211        assert!(children.is_empty());
212        view.children_into(t, &mut children);
213        assert_eq!(children, vec![DenseNodeId::from_raw(2)]);
214
215        let m = view.materialize().unwrap();
216        assert!(m.parents(t).is_empty());
217        assert!(m.children(DenseNodeId::from_raw(0)).is_empty());
218        assert_eq!(m.children(t).len(), 1);
219    }
220
221    #[test]
222    fn remove_outgoing_hides_treatment_children() {
223        let g = chain3();
224        let t = DenseNodeId::from_raw(1);
225        let overlay = GraphOverlay::remove_outgoing(g.node_count(), &[t]);
226        let view = g.view(&overlay);
227        let mut children = Vec::new();
228        view.children_into(t, &mut children);
229        assert!(children.is_empty());
230        let mut parents = Vec::new();
231        view.parents_into(t, &mut parents);
232        assert_eq!(parents, vec![DenseNodeId::from_raw(0)]);
233    }
234
235    #[test]
236    fn view_dsep_matches_materialized_mutilate() {
237        // A → T → Y with confounder A → Y; do(T) blocks A→T.
238        let mut g = Dag::with_variables(3);
239        let a = DenseNodeId::from_raw(0);
240        let t = DenseNodeId::from_raw(1);
241        let y = DenseNodeId::from_raw(2);
242        g.insert_directed(a, t).unwrap();
243        g.insert_directed(t, y).unwrap();
244        g.insert_directed(a, y).unwrap();
245
246        let overlay = GraphOverlay::do_intervention(g.node_count(), &[t]);
247        let view = g.view(&overlay);
248        let materialized = view.materialize().unwrap();
249        let mut ws = DSeparationWorkspace::default();
250        let view_sep = view.is_d_separated(t, y, &[a], &mut ws).unwrap();
251        let mat_sep = materialized.is_d_separated(t, y, &[a], &mut ws).unwrap();
252        assert_eq!(view_sep, mat_sep);
253        // Under do(T), path T→Y remains; A does not separate T from Y.
254        assert!(!view_sep);
255    }
256
257    /// Overlay d-sep agrees with `Dag::mutilate` on random multi-treatment graphs.
258    #[test]
259    fn property_overlay_dsep_matches_mutilate_on_random_dags() {
260        use antecedent_core::CausalRng;
261
262        let mut rng = CausalRng::from_seed(17);
263        let mut ws = DSeparationWorkspace::default();
264        for _ in 0..40 {
265            let node_count = 4 + u32::try_from(rng.next_u64() % 3).unwrap_or(0); // 4..=6
266            let mut graph = Dag::with_variables(node_count);
267            let mut order: Vec<u32> = (0..node_count).collect();
268            let n_usize = usize::try_from(node_count).unwrap_or(0);
269            for i in (1..n_usize).rev() {
270                let bound = u64::try_from(i + 1).unwrap_or(1);
271                let j = usize::try_from(rng.next_u64() % bound).unwrap_or(0);
272                order.swap(i, j);
273            }
274            for i in 0..n_usize {
275                for j in (i + 1)..n_usize {
276                    if rng.next_u64() % 3 == 0 {
277                        let _ = graph.insert_directed(
278                            DenseNodeId::from_raw(order[i]),
279                            DenseNodeId::from_raw(order[j]),
280                        );
281                    }
282                }
283            }
284            let treat_cap = n_usize.clamp(1, 3);
285            let treat_bound = u64::try_from(treat_cap).unwrap_or(1);
286            let n_treated = 1 + usize::try_from(rng.next_u64() % treat_bound).unwrap_or(0);
287            let mut treated = Vec::new();
288            while treated.len() < n_treated {
289                let raw = u32::try_from(rng.next_u64() % u64::from(node_count)).unwrap_or(0);
290                let treatment = DenseNodeId::from_raw(raw);
291                if !treated.contains(&treatment) {
292                    treated.push(treatment);
293                }
294            }
295            let overlay = GraphOverlay::do_intervention(graph.node_count(), &treated);
296            let view = graph.view(&overlay);
297            let mutilated = graph.mutilate(&treated).unwrap();
298            // Structure agreement.
299            let mat = view.materialize().unwrap();
300            for i in 0..node_count {
301                let u = DenseNodeId::from_raw(i);
302                assert_eq!(mat.children(u), mutilated.children(u));
303            }
304            // d-sep agreement on random queries.
305            for _ in 0..12 {
306                let x_raw = u32::try_from(rng.next_u64() % u64::from(node_count)).unwrap_or(0);
307                let source = DenseNodeId::from_raw(x_raw);
308                let mut y_raw = u32::try_from(rng.next_u64() % u64::from(node_count)).unwrap_or(0);
309                let mut target = DenseNodeId::from_raw(y_raw);
310                while target == source {
311                    y_raw = u32::try_from(rng.next_u64() % u64::from(node_count)).unwrap_or(0);
312                    target = DenseNodeId::from_raw(y_raw);
313                }
314                let mut conditioning = Vec::new();
315                for i in 0..node_count {
316                    let node = DenseNodeId::from_raw(i);
317                    if node == source || node == target {
318                        continue;
319                    }
320                    if rng.next_u64() % 2 == 0 {
321                        conditioning.push(node);
322                    }
323                }
324                let view_sep = view.is_d_separated(source, target, &conditioning, &mut ws).unwrap();
325                let mut_sep =
326                    mutilated.is_d_separated(source, target, &conditioning, &mut ws).unwrap();
327                assert_eq!(
328                    view_sep,
329                    mut_sep,
330                    "overlay≠mutilate d-sep x={} y={} z={:?} T={:?}",
331                    source.raw(),
332                    target.raw(),
333                    conditioning.iter().map(|v| v.raw()).collect::<Vec<_>>(),
334                    treated.iter().map(|v| v.raw()).collect::<Vec<_>>()
335                );
336            }
337        }
338    }
339}