Skip to main content

antecedent_graph/
pag.rs

1//! Partial ancestral graphs (PAGs) with circle marks.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![allow(clippy::many_single_char_names)]
6
7use std::sync::Arc;
8
9use antecedent_core::VariableId;
10
11use crate::error::GraphError;
12use crate::marked_storage::{self, AdjEntry};
13use crate::types::{DenseNodeId, Endpoint, MarkedEdge, MiddleMark, NodeRef};
14use crate::workspace::GraphWorkspace;
15
16/// Static PAG over variables .
17#[derive(Clone, Debug)]
18pub struct Pag {
19    nodes: Vec<NodeRef>,
20    adj: Vec<Vec<AdjEntry>>,
21}
22
23impl Pag {
24    /// Empty PAG.
25    #[must_use]
26    pub fn empty() -> Self {
27        Self { nodes: Vec::new(), adj: Vec::new() }
28    }
29
30    /// One static node per variable `0..n`.
31    #[must_use]
32    pub fn with_variables(n: u32) -> Self {
33        let mut g = Self::empty();
34        for i in 0..n {
35            let _ = g.add_node(NodeRef::Static(VariableId::from_raw(i)));
36        }
37        g
38    }
39
40    /// Schema-aligned PAG with named directed edges (`VariableId` raw == dense id).
41    ///
42    /// # Errors
43    ///
44    /// Unknown names or invalid inserts.
45    pub fn from_named_edges(
46        schema: &antecedent_core::CausalSchema,
47        edges: &[(&str, &str)],
48    ) -> Result<Self, GraphError> {
49        let n = crate::named::schema_node_count(schema)?;
50        let mut g = Self::with_variables(n);
51        for &(from_name, to_name) in edges {
52            let (from, to) = crate::named::resolve_named_edge(schema, from_name, to_name)?;
53            g.insert_directed(from, to)?;
54        }
55        Ok(g)
56    }
57
58    /// Node count.
59    #[must_use]
60    pub fn node_count(&self) -> usize {
61        self.nodes.len()
62    }
63
64    /// Whether empty.
65    #[must_use]
66    pub fn is_empty(&self) -> bool {
67        self.nodes.is_empty()
68    }
69
70    /// Nodes in dense order.
71    #[must_use]
72    pub fn nodes(&self) -> &[NodeRef] {
73        &self.nodes
74    }
75
76    /// Add a static node.
77    ///
78    /// # Errors
79    ///
80    /// Non-static or capacity.
81    pub fn add_node(&mut self, node: NodeRef) -> Result<DenseNodeId, GraphError> {
82        if !matches!(node, NodeRef::Static(_)) {
83            return Err(GraphError::InvalidEndpoints { message: "Pag accepts only Static nodes" });
84        }
85        let id = u32::try_from(self.nodes.len()).map_err(|_| GraphError::TooManyNodes)?;
86        self.nodes.push(node);
87        self.adj.push(Vec::new());
88        Ok(DenseNodeId::from_raw(id))
89    }
90
91    fn validate_node(&self, id: DenseNodeId) -> Result<(), GraphError> {
92        if id.as_usize() >= self.node_count() {
93            return Err(GraphError::UnknownNode { id: id.raw() });
94        }
95        Ok(())
96    }
97
98    pub(crate) fn validate_node_pub(&self, id: DenseNodeId) -> Result<(), GraphError> {
99        self.validate_node(id)
100    }
101
102    /// Whether marks are legal for a PAG (any Tail/Arrow/Circle/Conflict pair on distinct nodes).
103    ///
104    /// Structural constraints (duplicates, directed cycles) are checked on insert.
105    #[must_use]
106    pub const fn is_pag_legal(edge: MarkedEdge) -> bool {
107        edge.a.raw() != edge.b.raw()
108    }
109
110    /// Insert a PAG-legal marked edge.
111    ///
112    /// # Errors
113    ///
114    /// Unknown nodes, duplicates, self-loops, or directed cycles from arrowheads.
115    pub fn insert_marked(&mut self, edge: MarkedEdge) -> Result<(), GraphError> {
116        if !Self::is_pag_legal(edge) {
117            return Err(GraphError::InvalidEndpoints { message: "Pag rejects self-loops" });
118        }
119        self.validate_node(edge.a)?;
120        self.validate_node(edge.b)?;
121        if edge.a == edge.b {
122            return Err(GraphError::InvalidEndpoints { message: "Pag rejects self-loops" });
123        }
124        marked_storage::insert_marked_finish(&mut self.adj, edge)
125    }
126
127    /// Directed `from -> to`.
128    ///
129    /// # Errors
130    ///
131    /// See [`Self::insert_marked`].
132    pub fn insert_directed(
133        &mut self,
134        from: DenseNodeId,
135        to: DenseNodeId,
136    ) -> Result<(), GraphError> {
137        self.insert_marked(MarkedEdge::directed(from, to))
138    }
139
140    /// Circle-arrow `from o→ to`.
141    ///
142    /// # Errors
143    ///
144    /// See [`Self::insert_marked`].
145    pub fn insert_circle_arrow(
146        &mut self,
147        from: DenseNodeId,
148        to: DenseNodeId,
149    ) -> Result<(), GraphError> {
150        self.insert_marked(MarkedEdge {
151            a: from,
152            b: to,
153            at_a: Endpoint::Circle,
154            at_b: Endpoint::Arrow,
155            middle: MiddleMark::Empty,
156        })
157    }
158
159    /// Circle-circle `a o–o b`.
160    ///
161    /// # Errors
162    ///
163    /// See [`Self::insert_marked`].
164    pub fn insert_circle_circle(
165        &mut self,
166        a: DenseNodeId,
167        b: DenseNodeId,
168    ) -> Result<(), GraphError> {
169        let (a, b) = if a.raw() <= b.raw() { (a, b) } else { (b, a) };
170        self.insert_marked(MarkedEdge {
171            a,
172            b,
173            at_a: Endpoint::Circle,
174            at_b: Endpoint::Circle,
175            middle: MiddleMark::Empty,
176        })
177    }
178
179    /// Bidirected `a ↔ b`.
180    ///
181    /// # Errors
182    ///
183    /// See [`Self::insert_marked`].
184    pub fn insert_bidirected(&mut self, a: DenseNodeId, b: DenseNodeId) -> Result<(), GraphError> {
185        self.insert_marked(MarkedEdge::bidirected(a, b))
186    }
187
188    /// Whether any edge exists between `a` and `b`.
189    #[must_use]
190    pub fn has_edge(&self, a: DenseNodeId, b: DenseNodeId) -> bool {
191        self.edge_between(a, b).is_some()
192    }
193
194    /// Marked edge between `a` and `b` if present.
195    #[must_use]
196    pub fn edge_between(&self, a: DenseNodeId, b: DenseNodeId) -> Option<MarkedEdge> {
197        marked_storage::edge_between(&self.adj, a, b)
198    }
199
200    /// Neighbors with marks.
201    pub fn neighbors(
202        &self,
203        id: DenseNodeId,
204    ) -> impl Iterator<Item = (DenseNodeId, Endpoint, Endpoint)> + '_ {
205        marked_storage::neighbors(&self.adj, id)
206    }
207
208    /// Set marks on an existing edge (from `a`'s perspective).
209    ///
210    /// # Errors
211    ///
212    /// Missing edge or cycle after orientation.
213    pub fn set_marks(
214        &mut self,
215        a: DenseNodeId,
216        b: DenseNodeId,
217        at_a: Endpoint,
218        at_b: Endpoint,
219    ) -> Result<(), GraphError> {
220        self.validate_node(a)?;
221        self.validate_node(b)?;
222        if !self.has_edge(a, b) {
223            return Err(GraphError::UnknownNode { id: a.raw() });
224        }
225        let previous =
226            marked_storage::edge_between(&self.adj, a, b).expect("edge present after has_edge");
227        marked_storage::set_marks_finish(&mut self.adj, a, b, at_a, at_b, previous)
228    }
229
230    /// Mark an existing edge as an `x-x` conflict ([`Endpoint::Conflict`]–[`Endpoint::Conflict`]).
231    ///
232    /// # Errors
233    ///
234    /// Missing edge or unknown nodes.
235    pub fn mark_conflict(&mut self, a: DenseNodeId, b: DenseNodeId) -> Result<(), GraphError> {
236        self.set_marks(a, b, Endpoint::Conflict, Endpoint::Conflict)
237    }
238
239    /// Remove an edge (both adjacency halves).
240    ///
241    /// # Errors
242    ///
243    /// Unknown nodes or missing edge.
244    pub fn remove_edge(&mut self, a: DenseNodeId, b: DenseNodeId) -> Result<(), GraphError> {
245        self.validate_node(a)?;
246        self.validate_node(b)?;
247        if self.edge_between(a, b).is_none() {
248            return Err(GraphError::UnknownNode { id: a.raw() });
249        }
250        marked_storage::remove_edge(&mut self.adj, a, b);
251        Ok(())
252    }
253
254    /// Directed children (definite Tail→Arrow from this node).
255    #[must_use]
256    pub fn directed_children(&self, id: DenseNodeId) -> Vec<DenseNodeId> {
257        marked_storage::directed_children(&self.adj, id).collect()
258    }
259
260    /// Borrowed directed-child iterator (reachability hot path).
261    pub fn directed_children_iter(
262        &self,
263        id: DenseNodeId,
264    ) -> impl Iterator<Item = DenseNodeId> + '_ {
265        marked_storage::directed_children(&self.adj, id)
266    }
267
268    /// Whether `from` reaches `to` via definite directed edges only.
269    #[must_use]
270    pub fn reaches_directed(&self, from: DenseNodeId, to: DenseNodeId) -> bool {
271        let mut ws = GraphWorkspace::default();
272        self.reaches_directed_with(&mut ws, from, to)
273    }
274
275    /// Directed reachability reusing a caller-owned workspace.
276    #[must_use]
277    pub fn reaches_directed_with(
278        &self,
279        ws: &mut GraphWorkspace,
280        from: DenseNodeId,
281        to: DenseNodeId,
282    ) -> bool {
283        marked_storage::reaches_directed(&self.adj, ws, from, to)
284    }
285}
286
287/// Path whose every non-endpoint has definite collider or non-collider status.
288#[derive(Clone, Debug, Eq, PartialEq)]
289pub struct DefiniteStatusPath {
290    /// Ordered nodes on the path.
291    pub nodes: Vec<DenseNodeId>,
292}
293
294/// Bounded enumeration of definite-status paths, with a truncation flag.
295#[derive(Clone, Debug, Eq, PartialEq)]
296pub struct DefiniteStatusPathSearch {
297    /// Paths found within the budget.
298    pub paths: Vec<DefiniteStatusPath>,
299    /// `true` if `max_paths` / `max_len` cut the search short (result may be incomplete).
300    pub truncated: bool,
301}
302
303impl Pag {
304    /// Enumerate definite-status paths from `x` to `y` up to `max_paths` (bounded).
305    ///
306    /// # Errors
307    ///
308    /// Unknown nodes.
309    pub fn definite_status_paths(
310        &self,
311        x: DenseNodeId,
312        y: DenseNodeId,
313        max_paths: usize,
314        max_len: usize,
315    ) -> Result<DefiniteStatusPathSearch, GraphError> {
316        self.validate_node(x)?;
317        self.validate_node(y)?;
318        let mut out = Vec::new();
319        if max_paths == 0 || max_len == 0 {
320            return Ok(DefiniteStatusPathSearch { paths: out, truncated: true });
321        }
322        let mut truncated = false;
323        let mut stack = vec![vec![x]];
324        while let Some(path) = stack.pop() {
325            if out.len() >= max_paths {
326                truncated = true;
327                break;
328            }
329            let last = *path.last().expect("nonempty");
330            if path.len() > 1 && last == y {
331                if self.path_is_definite_status(&path) {
332                    out.push(DefiniteStatusPath { nodes: path });
333                }
334                continue;
335            }
336            if path.len() >= max_len {
337                // Neighbors exist that we refuse to expand → incomplete.
338                for (nbr, _, _) in self.neighbors(last) {
339                    if path.len() >= 2 && path[path.len() - 2] == nbr {
340                        continue;
341                    }
342                    if path.contains(&nbr) {
343                        continue;
344                    }
345                    truncated = true;
346                    break;
347                }
348                continue;
349            }
350            for (nbr, _, _) in self.neighbors(last) {
351                if path.len() >= 2 && path[path.len() - 2] == nbr {
352                    continue; // no immediate backtrack
353                }
354                if path.contains(&nbr) {
355                    continue;
356                }
357                let mut next = path.clone();
358                next.push(nbr);
359                stack.push(next);
360            }
361        }
362        Ok(DefiniteStatusPathSearch { paths: out, truncated })
363    }
364
365    fn path_is_definite_status(&self, path: &[DenseNodeId]) -> bool {
366        if path.len() < 2 {
367            return true;
368        }
369        for i in 1..path.len() - 1 {
370            let pred = path[i - 1];
371            let v = path[i];
372            let succ = path[i + 1];
373            let Some(e1) = self.edge_between(pred, v) else {
374                return false;
375            };
376            let Some(e2) = self.edge_between(v, succ) else {
377                return false;
378            };
379            let mark_from_pred = if e1.a == v { e1.at_a } else { e1.at_b };
380            let mark_from_succ = if e2.a == v { e2.at_a } else { e2.at_b };
381            let definite_collider = matches!(mark_from_pred, Endpoint::Arrow)
382                && matches!(mark_from_succ, Endpoint::Arrow);
383            let definite_noncollider = matches!(mark_from_pred, Endpoint::Tail)
384                || matches!(mark_from_succ, Endpoint::Tail);
385            if !(definite_collider || definite_noncollider) {
386                return false;
387            }
388        }
389        true
390    }
391
392    /// Whether a definite-status path is active given `z` (m-connecting).
393    ///
394    /// A collider is open if it **or any definite directed descendant** is in `z`.
395    #[must_use]
396    pub fn path_active_given(&self, path: &[DenseNodeId], z: &[DenseNodeId]) -> bool {
397        if path.len() < 2 {
398            return false;
399        }
400        let in_z = |n: DenseNodeId| z.iter().any(|&v| v == n);
401        if in_z(path[0]) || in_z(path[path.len() - 1]) {
402            return true;
403        }
404        for i in 1..path.len() - 1 {
405            let pred = path[i - 1];
406            let v = path[i];
407            let succ = path[i + 1];
408            let e1 = self.edge_between(pred, v).expect("path edge");
409            let e2 = self.edge_between(v, succ).expect("path edge");
410            let mark_from_pred = if e1.a == v { e1.at_a } else { e1.at_b };
411            let mark_from_succ = if e2.a == v { e2.at_a } else { e2.at_b };
412            let collider = matches!(mark_from_pred, Endpoint::Arrow)
413                && matches!(mark_from_succ, Endpoint::Arrow);
414            if collider {
415                if !in_z(v) && !self.collider_descendant_in_z(v, z) {
416                    return false;
417                }
418            } else if in_z(v) {
419                return false;
420            }
421        }
422        true
423    }
424
425    /// True if some node in `z` is a definite directed descendant of `v`.
426    fn collider_descendant_in_z(&self, v: DenseNodeId, z: &[DenseNodeId]) -> bool {
427        z.iter().any(|&d| d != v && self.reaches_directed(v, d))
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn accepts_circle_marks() {
437        let mut g = Pag::with_variables(2);
438        g.insert_circle_arrow(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
439        assert!(g.has_edge(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)));
440    }
441
442    #[test]
443    fn remove_edge_clears_both_halves() {
444        let mut g = Pag::with_variables(2);
445        let a = DenseNodeId::from_raw(0);
446        let b = DenseNodeId::from_raw(1);
447        g.insert_directed(a, b).unwrap();
448        g.remove_edge(a, b).unwrap();
449        assert!(!g.has_edge(a, b));
450        assert!(g.remove_edge(a, b).is_err());
451    }
452
453    #[test]
454    fn definite_status_chain() {
455        let mut g = Pag::with_variables(3);
456        let a = DenseNodeId::from_raw(0);
457        let b = DenseNodeId::from_raw(1);
458        let c = DenseNodeId::from_raw(2);
459        g.insert_directed(a, b).unwrap();
460        g.insert_directed(b, c).unwrap();
461        let paths = g.definite_status_paths(a, c, 10, 8).unwrap();
462        assert!(!paths.paths.is_empty());
463        assert!(g.path_active_given(&paths.paths[0].nodes, &[]));
464        assert!(!g.path_active_given(&paths.paths[0].nodes, &[b]));
465    }
466}
467
468/// Review artifact for a discovered static PAG (pending circle marks).
469#[derive(Clone, Debug)]
470pub struct PagReview {
471    /// Proposed PAG.
472    pub graph: Pag,
473    /// Edges that still have at least one circle endpoint `(a,b)` with `a.raw() <= b.raw()`.
474    pub pending_circles: Arc<[(DenseNodeId, DenseNodeId)]>,
475    /// Algorithm id.
476    pub algorithm: Arc<str>,
477}
478
479impl PagReview {
480    /// Build review listing all circle-bearing edges.
481    #[must_use]
482    pub fn from_pag(graph: Pag, algorithm: impl Into<Arc<str>>) -> Self {
483        let mut pending = Vec::new();
484        for i in 0..graph.node_count() {
485            let a = DenseNodeId::from_raw(u32::try_from(i).expect("node fit"));
486            for (b, at_a, at_b) in graph.neighbors(a) {
487                if b.raw() < a.raw() {
488                    continue;
489                }
490                if matches!(at_a, Endpoint::Circle) || matches!(at_b, Endpoint::Circle) {
491                    pending.push((a, b));
492                }
493            }
494        }
495        Self { graph, pending_circles: Arc::from(pending), algorithm: algorithm.into() }
496    }
497
498    /// Whether no circle marks remain.
499    #[must_use]
500    pub fn is_complete(&self) -> bool {
501        self.pending_circles.is_empty()
502    }
503}
504
505#[cfg(test)]
506mod review_tests {
507    use super::*;
508
509    #[test]
510    fn review_lists_circle_edges() {
511        let mut g = Pag::with_variables(2);
512        g.insert_circle_circle(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
513        let review = PagReview::from_pag(g, "fci");
514        assert_eq!(review.pending_circles.len(), 1);
515        assert!(!review.is_complete());
516    }
517
518    #[test]
519    fn directed_only_is_complete() {
520        let mut g = Pag::with_variables(2);
521        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
522        let review = PagReview::from_pag(g, "fci");
523        assert!(review.is_complete());
524    }
525}