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        if self.has_edge(edge.a, edge.b) {
125            return Err(GraphError::DuplicateEdge { from: edge.a.raw(), to: edge.b.raw() });
126        }
127        if let Some((from, to)) = edge.parent_child() {
128            if self.reaches_directed(to, from) {
129                return Err(GraphError::Cycle { from: from.raw(), to: to.raw() });
130            }
131        }
132        marked_storage::push_marked_pair(&mut self.adj, edge);
133        Ok(())
134    }
135
136    /// Directed `from -> to`.
137    ///
138    /// # Errors
139    ///
140    /// See [`Self::insert_marked`].
141    pub fn insert_directed(
142        &mut self,
143        from: DenseNodeId,
144        to: DenseNodeId,
145    ) -> Result<(), GraphError> {
146        self.insert_marked(MarkedEdge::directed(from, to))
147    }
148
149    /// Circle-arrow `from o→ to`.
150    ///
151    /// # Errors
152    ///
153    /// See [`Self::insert_marked`].
154    pub fn insert_circle_arrow(
155        &mut self,
156        from: DenseNodeId,
157        to: DenseNodeId,
158    ) -> Result<(), GraphError> {
159        self.insert_marked(MarkedEdge {
160            a: from,
161            b: to,
162            at_a: Endpoint::Circle,
163            at_b: Endpoint::Arrow,
164            middle: MiddleMark::Empty,
165        })
166    }
167
168    /// Circle-circle `a o–o b`.
169    ///
170    /// # Errors
171    ///
172    /// See [`Self::insert_marked`].
173    pub fn insert_circle_circle(
174        &mut self,
175        a: DenseNodeId,
176        b: DenseNodeId,
177    ) -> Result<(), GraphError> {
178        let (a, b) = if a.raw() <= b.raw() { (a, b) } else { (b, a) };
179        self.insert_marked(MarkedEdge {
180            a,
181            b,
182            at_a: Endpoint::Circle,
183            at_b: Endpoint::Circle,
184            middle: MiddleMark::Empty,
185        })
186    }
187
188    /// Bidirected `a ↔ b`.
189    ///
190    /// # Errors
191    ///
192    /// See [`Self::insert_marked`].
193    pub fn insert_bidirected(&mut self, a: DenseNodeId, b: DenseNodeId) -> Result<(), GraphError> {
194        self.insert_marked(MarkedEdge::bidirected(a, b))
195    }
196
197    /// Whether any edge exists between `a` and `b`.
198    #[must_use]
199    pub fn has_edge(&self, a: DenseNodeId, b: DenseNodeId) -> bool {
200        self.edge_between(a, b).is_some()
201    }
202
203    /// Marked edge between `a` and `b` if present.
204    #[must_use]
205    pub fn edge_between(&self, a: DenseNodeId, b: DenseNodeId) -> Option<MarkedEdge> {
206        marked_storage::edge_between(&self.adj, a, b)
207    }
208
209    /// Neighbors with marks.
210    pub fn neighbors(
211        &self,
212        id: DenseNodeId,
213    ) -> impl Iterator<Item = (DenseNodeId, Endpoint, Endpoint)> + '_ {
214        self.adj[id.as_usize()].iter().map(|e| (e.neighbor, e.at_self, e.at_neighbor))
215    }
216
217    /// Set marks on an existing edge (from `a`'s perspective).
218    ///
219    /// # Errors
220    ///
221    /// Missing edge or cycle after orientation.
222    pub fn set_marks(
223        &mut self,
224        a: DenseNodeId,
225        b: DenseNodeId,
226        at_a: Endpoint,
227        at_b: Endpoint,
228    ) -> Result<(), GraphError> {
229        self.validate_node(a)?;
230        self.validate_node(b)?;
231        if !self.has_edge(a, b) {
232            return Err(GraphError::UnknownNode { id: a.raw() });
233        }
234        let previous =
235            marked_storage::edge_between(&self.adj, a, b).expect("edge present after has_edge");
236        let edge = MarkedEdge { a, b, at_a, at_b, middle: previous.middle };
237        if let Some((from, to)) = edge.parent_child() {
238            marked_storage::remove_edge(&mut self.adj, a, b);
239            let cycle = self.reaches_directed(to, from);
240            if cycle {
241                marked_storage::push_marked_pair(&mut self.adj, previous);
242                return Err(GraphError::Cycle { from: from.raw(), to: to.raw() });
243            }
244            marked_storage::push_marked_pair(&mut self.adj, edge);
245            return Ok(());
246        }
247        marked_storage::set_marks(&mut self.adj, a, b, at_a, at_b)
248    }
249
250    /// Mark an existing edge as a pinned baseline `x-x` conflict ([`Endpoint::Conflict`]–[`Endpoint::Conflict`]).
251    ///
252    /// # Errors
253    ///
254    /// Missing edge or unknown nodes.
255    pub fn mark_conflict(&mut self, a: DenseNodeId, b: DenseNodeId) -> Result<(), GraphError> {
256        self.set_marks(a, b, Endpoint::Conflict, Endpoint::Conflict)
257    }
258
259    /// Remove an edge (both adjacency halves).
260    ///
261    /// # Errors
262    ///
263    /// Unknown nodes or missing edge.
264    pub fn remove_edge(&mut self, a: DenseNodeId, b: DenseNodeId) -> Result<(), GraphError> {
265        self.validate_node(a)?;
266        self.validate_node(b)?;
267        if self.edge_between(a, b).is_none() {
268            return Err(GraphError::UnknownNode { id: a.raw() });
269        }
270        marked_storage::remove_edge(&mut self.adj, a, b);
271        Ok(())
272    }
273
274    /// Directed children (definite Tail→Arrow from this node).
275    #[must_use]
276    pub fn directed_children(&self, id: DenseNodeId) -> Vec<DenseNodeId> {
277        marked_storage::directed_children(&self.adj, id).collect()
278    }
279
280    /// Borrowed directed-child iterator (reachability hot path).
281    pub fn directed_children_iter(
282        &self,
283        id: DenseNodeId,
284    ) -> impl Iterator<Item = DenseNodeId> + '_ {
285        marked_storage::directed_children(&self.adj, id)
286    }
287
288    /// Whether `from` reaches `to` via definite directed edges only.
289    #[must_use]
290    pub fn reaches_directed(&self, from: DenseNodeId, to: DenseNodeId) -> bool {
291        let mut ws = GraphWorkspace::default();
292        self.reaches_directed_with(&mut ws, from, to)
293    }
294
295    /// Directed reachability reusing a caller-owned workspace.
296    #[must_use]
297    pub fn reaches_directed_with(
298        &self,
299        ws: &mut GraphWorkspace,
300        from: DenseNodeId,
301        to: DenseNodeId,
302    ) -> bool {
303        marked_storage::reaches_directed(&self.adj, ws, from, to)
304    }
305}
306
307/// Path whose every non-endpoint has definite collider or non-collider status.
308#[derive(Clone, Debug, Eq, PartialEq)]
309pub struct DefiniteStatusPath {
310    /// Ordered nodes on the path.
311    pub nodes: Vec<DenseNodeId>,
312}
313
314/// Bounded enumeration of definite-status paths, with a truncation flag.
315#[derive(Clone, Debug, Eq, PartialEq)]
316pub struct DefiniteStatusPathSearch {
317    /// Paths found within the budget.
318    pub paths: Vec<DefiniteStatusPath>,
319    /// `true` if `max_paths` / `max_len` cut the search short (result may be incomplete).
320    pub truncated: bool,
321}
322
323impl Pag {
324    /// Enumerate definite-status paths from `x` to `y` up to `max_paths` (bounded).
325    ///
326    /// # Errors
327    ///
328    /// Unknown nodes.
329    pub fn definite_status_paths(
330        &self,
331        x: DenseNodeId,
332        y: DenseNodeId,
333        max_paths: usize,
334        max_len: usize,
335    ) -> Result<DefiniteStatusPathSearch, GraphError> {
336        self.validate_node(x)?;
337        self.validate_node(y)?;
338        let mut out = Vec::new();
339        if max_paths == 0 || max_len == 0 {
340            return Ok(DefiniteStatusPathSearch { paths: out, truncated: true });
341        }
342        let mut truncated = false;
343        let mut stack = vec![vec![x]];
344        while let Some(path) = stack.pop() {
345            if out.len() >= max_paths {
346                truncated = true;
347                break;
348            }
349            let last = *path.last().expect("nonempty");
350            if path.len() > 1 && last == y {
351                if self.path_is_definite_status(&path) {
352                    out.push(DefiniteStatusPath { nodes: path });
353                }
354                continue;
355            }
356            if path.len() >= max_len {
357                // Neighbors exist that we refuse to expand → incomplete.
358                for (nbr, _, _) in self.neighbors(last) {
359                    if path.len() >= 2 && path[path.len() - 2] == nbr {
360                        continue;
361                    }
362                    if path.contains(&nbr) {
363                        continue;
364                    }
365                    truncated = true;
366                    break;
367                }
368                continue;
369            }
370            for (nbr, _, _) in self.neighbors(last) {
371                if path.len() >= 2 && path[path.len() - 2] == nbr {
372                    continue; // no immediate backtrack
373                }
374                if path.contains(&nbr) {
375                    continue;
376                }
377                let mut next = path.clone();
378                next.push(nbr);
379                stack.push(next);
380            }
381        }
382        Ok(DefiniteStatusPathSearch { paths: out, truncated })
383    }
384
385    fn path_is_definite_status(&self, path: &[DenseNodeId]) -> bool {
386        if path.len() < 2 {
387            return true;
388        }
389        for i in 1..path.len() - 1 {
390            let pred = path[i - 1];
391            let v = path[i];
392            let succ = path[i + 1];
393            let Some(e1) = self.edge_between(pred, v) else {
394                return false;
395            };
396            let Some(e2) = self.edge_between(v, succ) else {
397                return false;
398            };
399            let mark_from_pred = if e1.a == v { e1.at_a } else { e1.at_b };
400            let mark_from_succ = if e2.a == v { e2.at_a } else { e2.at_b };
401            let definite_collider = matches!(mark_from_pred, Endpoint::Arrow)
402                && matches!(mark_from_succ, Endpoint::Arrow);
403            let definite_noncollider = matches!(mark_from_pred, Endpoint::Tail)
404                || matches!(mark_from_succ, Endpoint::Tail);
405            if !(definite_collider || definite_noncollider) {
406                return false;
407            }
408        }
409        true
410    }
411
412    /// Whether a definite-status path is active given `z` (m-connecting).
413    ///
414    /// A collider is open if it **or any definite directed descendant** is in `z`.
415    #[must_use]
416    pub fn path_active_given(&self, path: &[DenseNodeId], z: &[DenseNodeId]) -> bool {
417        if path.len() < 2 {
418            return false;
419        }
420        let in_z = |n: DenseNodeId| z.iter().any(|&v| v == n);
421        for i in 1..path.len() - 1 {
422            let pred = path[i - 1];
423            let v = path[i];
424            let succ = path[i + 1];
425            let e1 = self.edge_between(pred, v).expect("path edge");
426            let e2 = self.edge_between(v, succ).expect("path edge");
427            let mark_from_pred = if e1.a == v { e1.at_a } else { e1.at_b };
428            let mark_from_succ = if e2.a == v { e2.at_a } else { e2.at_b };
429            let collider = matches!(mark_from_pred, Endpoint::Arrow)
430                && matches!(mark_from_succ, Endpoint::Arrow);
431            if collider {
432                if !in_z(v) && !self.collider_descendant_in_z(v, z) {
433                    return false;
434                }
435            } else if in_z(v) {
436                return false;
437            }
438        }
439        true
440    }
441
442    /// True if some node in `z` is a definite directed descendant of `v`.
443    fn collider_descendant_in_z(&self, v: DenseNodeId, z: &[DenseNodeId]) -> bool {
444        z.iter().any(|&d| d != v && self.reaches_directed(v, d))
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn accepts_circle_marks() {
454        let mut g = Pag::with_variables(2);
455        g.insert_circle_arrow(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
456        assert!(g.has_edge(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)));
457    }
458
459    #[test]
460    fn remove_edge_clears_both_halves() {
461        let mut g = Pag::with_variables(2);
462        let a = DenseNodeId::from_raw(0);
463        let b = DenseNodeId::from_raw(1);
464        g.insert_directed(a, b).unwrap();
465        g.remove_edge(a, b).unwrap();
466        assert!(!g.has_edge(a, b));
467        assert!(g.remove_edge(a, b).is_err());
468    }
469
470    #[test]
471    fn definite_status_chain() {
472        let mut g = Pag::with_variables(3);
473        let a = DenseNodeId::from_raw(0);
474        let b = DenseNodeId::from_raw(1);
475        let c = DenseNodeId::from_raw(2);
476        g.insert_directed(a, b).unwrap();
477        g.insert_directed(b, c).unwrap();
478        let paths = g.definite_status_paths(a, c, 10, 8).unwrap();
479        assert!(!paths.paths.is_empty());
480        assert!(g.path_active_given(&paths.paths[0].nodes, &[]));
481        assert!(!g.path_active_given(&paths.paths[0].nodes, &[b]));
482    }
483}
484
485/// Review artifact for a discovered static PAG (pending circle marks).
486#[derive(Clone, Debug)]
487pub struct PagReview {
488    /// Proposed PAG.
489    pub graph: Pag,
490    /// Edges that still have at least one circle endpoint `(a,b)` with `a.raw() <= b.raw()`.
491    pub pending_circles: Arc<[(DenseNodeId, DenseNodeId)]>,
492    /// Algorithm id.
493    pub algorithm: Arc<str>,
494}
495
496impl PagReview {
497    /// Build review listing all circle-bearing edges.
498    #[must_use]
499    pub fn from_pag(graph: Pag, algorithm: impl Into<Arc<str>>) -> Self {
500        let mut pending = Vec::new();
501        for i in 0..graph.node_count() {
502            let a = DenseNodeId::from_raw(u32::try_from(i).expect("node fit"));
503            for (b, at_a, at_b) in graph.neighbors(a) {
504                if b.raw() < a.raw() {
505                    continue;
506                }
507                if matches!(at_a, Endpoint::Circle) || matches!(at_b, Endpoint::Circle) {
508                    pending.push((a, b));
509                }
510            }
511        }
512        Self { graph, pending_circles: Arc::from(pending), algorithm: algorithm.into() }
513    }
514
515    /// Whether no circle marks remain.
516    #[must_use]
517    pub fn is_complete(&self) -> bool {
518        self.pending_circles.is_empty()
519    }
520}
521
522#[cfg(test)]
523mod review_tests {
524    use super::*;
525
526    #[test]
527    fn review_lists_circle_edges() {
528        let mut g = Pag::with_variables(2);
529        g.insert_circle_circle(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
530        let review = PagReview::from_pag(g, "fci");
531        assert_eq!(review.pending_circles.len(), 1);
532        assert!(!review.is_complete());
533    }
534
535    #[test]
536    fn directed_only_is_complete() {
537        let mut g = Pag::with_variables(2);
538        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
539        let review = PagReview::from_pag(g, "fci");
540        assert!(review.is_complete());
541    }
542}