Skip to main content

antecedent_graph/
completion.rs

1//! Streamed / bounded PAG completion sampling.
2//!
3//! Completions are never retained without bound: the sampler yields at most
4//! `max_completions` **valid MAG** completions (circle-free ancestral graphs).
5//! Invalid orientations (directed cycles, almost-directed cycles, undirected
6//! Tail–Tail marks) are skipped and do not count toward the yield cap.
7//!
8//! SPDX-License-Identifier: MIT OR Apache-2.0
9
10use crate::error::GraphError;
11use crate::pag::Pag;
12use crate::types::{DenseNodeId, Endpoint};
13use crate::workspace::GraphWorkspace;
14
15/// One circle-free completion of a PAG (MAG marks only).
16#[derive(Clone, Debug)]
17pub struct PagCompletion {
18    /// Completed graph (no Circle endpoints).
19    pub graph: Pag,
20    /// Index of this completion in the stream (0-based).
21    pub index: usize,
22}
23
24/// Streams PAG completions with a hard cap (no unbounded retain).
25#[derive(Clone, Debug)]
26pub struct CompletionSampler {
27    base: Pag,
28    circle_sites: Vec<(DenseNodeId, DenseNodeId, bool)>, // (a,b, at_a_is_circle)
29    max_completions: usize,
30    next_index: usize,
31    /// Bitmask assignment for circle sites (site i uses bit i); advances as a counter.
32    assign: u64,
33}
34
35impl CompletionSampler {
36    /// Build a sampler that yields at most `max_completions` **valid** MAG completions.
37    ///
38    /// # Errors
39    ///
40    /// More than 63 circle endpoints (mask capacity).
41    pub fn new(pag: Pag, max_completions: usize) -> Result<Self, GraphError> {
42        let mut sites = Vec::new();
43        let n = pag.node_count();
44        for i in 0..n {
45            let a = DenseNodeId::try_from_usize(i)?;
46            for (b, at_a, at_b) in pag.neighbors(a) {
47                if b.raw() < a.raw() {
48                    continue;
49                }
50                if matches!(at_a, Endpoint::Circle) {
51                    sites.push((a, b, true));
52                }
53                if matches!(at_b, Endpoint::Circle) {
54                    sites.push((a, b, false));
55                }
56            }
57        }
58        if sites.len() > 63 {
59            return Err(GraphError::InvalidEndpoints {
60                message: "too many circle endpoints for CompletionSampler mask",
61            });
62        }
63        Ok(Self { base: pag, circle_sites: sites, max_completions, next_index: 0, assign: 0 })
64    }
65
66    /// Hard cap on yielded valid completions.
67    #[must_use]
68    pub fn max_completions(&self) -> usize {
69        self.max_completions
70    }
71
72    /// Number of circle endpoints being oriented.
73    #[must_use]
74    pub fn n_circle_sites(&self) -> usize {
75        self.circle_sites.len()
76    }
77
78    fn build_completion(&self, mask: u64) -> Option<Pag> {
79        let mut g = self.base.clone();
80        for (i, &(a, b, at_a_circle)) in self.circle_sites.iter().enumerate() {
81            let choose_arrow = ((mask >> i) & 1) == 1;
82            let new_mark = if choose_arrow { Endpoint::Arrow } else { Endpoint::Tail };
83            let edge = g.edge_between(a, b)?;
84            let (at_a, at_b) =
85                if at_a_circle { (new_mark, edge.at_b) } else { (edge.at_a, new_mark) };
86            // Skip illegal directed cycles at insertion time.
87            if g.set_marks(a, b, at_a, at_b).is_err() {
88                return None;
89            }
90        }
91        if is_mag_completion(&g) { Some(g) } else { None }
92    }
93}
94
95/// Whether `g` is a legal directed MAG completion: no circles, no Tail–Tail undirected
96/// edges, no directed cycles (assumed from construction), and no almost-directed cycles
97/// (bidirected edge `a ↔ b` with a directed path either way).
98#[must_use]
99pub fn is_mag_completion(g: &Pag) -> bool {
100    let n = g.node_count();
101    let mut ws = GraphWorkspace::default();
102    for i in 0..n {
103        let a = DenseNodeId::try_from_usize(i).expect("node fit");
104        for (b, at_a, at_b) in g.neighbors(a) {
105            if b.raw() < a.raw() {
106                continue;
107            }
108            if matches!(at_a, Endpoint::Circle | Endpoint::Conflict)
109                || matches!(at_b, Endpoint::Circle | Endpoint::Conflict)
110            {
111                return false;
112            }
113            // Directed MAGs (Zhang) allow → and ↔ only — not undirected —o—.
114            if matches!((at_a, at_b), (Endpoint::Tail, Endpoint::Tail)) {
115                return false;
116            }
117            if matches!((at_a, at_b), (Endpoint::Arrow, Endpoint::Arrow)) {
118                // Almost-directed cycle: bidirected + directed path either way.
119                if g.reaches_directed_with(&mut ws, a, b) || g.reaches_directed_with(&mut ws, b, a)
120                {
121                    return false;
122                }
123            }
124        }
125    }
126    true
127}
128
129impl Iterator for CompletionSampler {
130    type Item = PagCompletion;
131
132    fn next(&mut self) -> Option<Self::Item> {
133        if self.next_index >= self.max_completions {
134            return None;
135        }
136        let n_sites = self.circle_sites.len();
137        let total = if n_sites == 0 { 1u64 } else { 1u64 << n_sites };
138        while self.assign < total {
139            let mask = self.assign;
140            self.assign += 1;
141            if let Some(graph) = self.build_completion(mask) {
142                let index = self.next_index;
143                self.next_index += 1;
144                return Some(PagCompletion { graph, index });
145            }
146            // Invalid MAG — skip without counting against the yield cap.
147        }
148        None
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::pag::Pag;
156
157    #[test]
158    fn respects_max_completions_bound() {
159        let mut pag = Pag::with_variables(2);
160        pag.insert_circle_circle(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
161        let sampler = CompletionSampler::new(pag, 2).unwrap();
162        assert_eq!(sampler.n_circle_sites(), 2);
163        let collected: Vec<_> = sampler.collect();
164        assert!(collected.len() <= 2);
165        assert!(!collected.is_empty());
166        for c in &collected {
167            assert!(is_mag_completion(&c.graph));
168            let e =
169                c.graph.edge_between(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
170            assert!(!matches!(e.at_a, Endpoint::Circle));
171            assert!(!matches!(e.at_b, Endpoint::Circle));
172            // No undirected Tail–Tail in directed MAG completions.
173            assert!(!matches!((e.at_a, e.at_b), (Endpoint::Tail, Endpoint::Tail)));
174        }
175    }
176
177    #[test]
178    fn no_circle_yields_single_completion() {
179        let mut pag = Pag::with_variables(2);
180        pag.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
181        let collected: Vec<_> = CompletionSampler::new(pag, 10).unwrap().collect();
182        assert_eq!(collected.len(), 1);
183        assert!(is_mag_completion(&collected[0].graph));
184    }
185
186    #[test]
187    fn rejects_almost_directed_cycle() {
188        // a → b → c with a ↔ c: directed path a ⇝ c plus bidirected a ↔ c.
189        let mut g = Pag::with_variables(3);
190        let a = DenseNodeId::from_raw(0);
191        let b = DenseNodeId::from_raw(1);
192        let c = DenseNodeId::from_raw(2);
193        g.insert_directed(a, b).unwrap();
194        g.insert_directed(b, c).unwrap();
195        g.insert_bidirected(a, c).unwrap();
196        assert!(!is_mag_completion(&g));
197    }
198
199    #[test]
200    fn accepts_bidirected_without_directed_path() {
201        let mut g = Pag::with_variables(2);
202        g.insert_bidirected(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
203        assert!(is_mag_completion(&g));
204    }
205
206    fn random_pag_with_circles(rng: &mut antecedent_core::CausalRng, n: u32) -> Pag {
207        let mut pag = Pag::with_variables(n);
208        // Prefer a topological skeleton so directed inserts stay acyclic.
209        let mut order: Vec<u32> = (0..n).collect();
210        for i in (1..usize::try_from(n).unwrap_or(0)).rev() {
211            let bound = u64::try_from(i + 1).unwrap_or(1);
212            let j = usize::try_from(rng.next_u64() % bound).unwrap_or(0);
213            order.swap(i, j);
214        }
215        let n_usize = usize::try_from(n).unwrap_or(0);
216        for i in 0..n_usize {
217            for j in (i + 1)..n_usize {
218                if rng.next_u64() % 3 != 0 {
219                    continue;
220                }
221                let a = DenseNodeId::from_raw(order[i]);
222                let b = DenseNodeId::from_raw(order[j]);
223                let kind = rng.next_u64() % 4;
224                let _ = match kind {
225                    0 => pag.insert_directed(a, b),
226                    1 => pag.insert_circle_arrow(a, b),
227                    2 => pag.insert_circle_circle(a, b),
228                    _ => pag.insert_bidirected(a, b),
229                };
230            }
231        }
232        pag
233    }
234
235    /// Completions respect the yield cap and never retain circle marks.
236    #[test]
237    fn property_completions_respect_bound_and_no_circles() {
238        use antecedent_core::CausalRng;
239
240        let mut rng = CausalRng::from_seed(23);
241        for _ in 0..40 {
242            let n = 2 + u32::try_from(rng.next_u64() % 3).unwrap_or(0); // 2..=4
243            let pag = random_pag_with_circles(&mut rng, n);
244            let max_c = 1 + usize::try_from(rng.next_u64() % 4).unwrap_or(0); // 1..=4
245            let Ok(sampler) = CompletionSampler::new(pag, max_c) else {
246                continue; // too many circle sites
247            };
248            let collected: Vec<_> = sampler.collect();
249            assert!(collected.len() <= max_c, "exceeded max_completions");
250            for (i, c) in collected.iter().enumerate() {
251                assert_eq!(c.index, i);
252                assert!(is_mag_completion(&c.graph));
253                for i in 0..c.graph.node_count() {
254                    let a = DenseNodeId::from_raw(u32::try_from(i).unwrap());
255                    for (b, at_a, at_b) in c.graph.neighbors(a) {
256                        if b.raw() < a.raw() {
257                            continue;
258                        }
259                        assert!(!matches!(at_a, Endpoint::Circle | Endpoint::Conflict));
260                        assert!(!matches!(at_b, Endpoint::Circle | Endpoint::Conflict));
261                    }
262                }
263            }
264        }
265    }
266
267    /// Where cheap: an active definite-status path in the PAG remains m-connecting in
268    /// every MAG completion (sound direction only; PAG separation is incomplete).
269    #[test]
270    fn property_definite_msep_stable_across_completions() {
271        use antecedent_core::CausalRng;
272
273        let mut rng = CausalRng::from_seed(29);
274        for _ in 0..30 {
275            let n = 3u32;
276            let pag = random_pag_with_circles(&mut rng, n);
277            let Ok(sampler) = CompletionSampler::new(pag.clone(), 8) else {
278                continue;
279            };
280            if sampler.n_circle_sites() > 4 {
281                continue; // keep enumeration cheap
282            }
283            let completions: Vec<_> = sampler.collect();
284            if completions.is_empty() {
285                continue;
286            }
287            for x in 0..n {
288                for y in 0..n {
289                    if x == y {
290                        continue;
291                    }
292                    let xi = DenseNodeId::from_raw(x);
293                    let yi = DenseNodeId::from_raw(y);
294                    // Empty Z only — cheapest definite-status check.
295                    let Ok(pag_sep) = pag.is_m_separated(xi, yi, &[], 32, 6) else {
296                        continue; // budget exhaustion — skip
297                    };
298                    if pag_sep {
299                        continue; // incomplete: PAG sep ⇏ completion sep
300                    }
301                    for c in &completions {
302                        let Ok(comp_sep) = c.graph.is_m_separated(xi, yi, &[], 32, 6) else {
303                            continue;
304                        };
305                        assert!(
306                            !comp_sep,
307                            "PAG m-connected but completion {} separated {}–{}",
308                            c.index, x, y
309                        );
310                    }
311                }
312            }
313        }
314    }
315}