Skip to main content

antecedent_graph/
dag.rs

1//! Indexed DAG storage with acyclicity validation.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5use std::sync::Arc;
6
7use antecedent_core::VariableId;
8
9use crate::algo::{bfs_reaches, kahn_order};
10use crate::error::GraphError;
11use crate::types::{DenseNodeId, MarkedEdge, NodeRef};
12use crate::workspace::GraphWorkspace;
13
14/// Static directed acyclic graph over variables.
15#[derive(Clone, Debug)]
16pub struct Dag {
17    nodes: Vec<NodeRef>,
18    /// Outgoing children per node.
19    children: Vec<Vec<DenseNodeId>>,
20    /// Incoming parents per node.
21    parents: Vec<Vec<DenseNodeId>>,
22    /// Reused by insertion-time acyclicity checks to avoid per-insert allocation.
23    insert_ws: GraphWorkspace,
24}
25
26impl Dag {
27    /// Empty DAG.
28    #[must_use]
29    pub fn empty() -> Self {
30        Self {
31            nodes: Vec::new(),
32            children: Vec::new(),
33            parents: Vec::new(),
34            insert_ws: GraphWorkspace::default(),
35        }
36    }
37
38    /// Build a DAG with one static node per variable `0..n`.
39    #[must_use]
40    pub fn with_variables(n: u32) -> Self {
41        let mut g = Self::empty();
42        for i in 0..n {
43            let _ = g.add_node(NodeRef::Static(VariableId::from_raw(i)));
44        }
45        g
46    }
47
48    /// Build a DAG with one static node per schema variable (`VariableId` raw == dense id),
49    /// then insert directed edges named by schema variable names.
50    ///
51    /// # Errors
52    ///
53    /// Unknown names, duplicate edges, or cycles.
54    pub fn from_named_edges(
55        schema: &antecedent_core::CausalSchema,
56        edges: &[(&str, &str)],
57    ) -> Result<Self, GraphError> {
58        let n = crate::named::schema_node_count(schema)?;
59        let mut g = Self::with_variables(n);
60        for &(from_name, to_name) in edges {
61            let (from, to) = crate::named::resolve_named_edge(schema, from_name, to_name)?;
62            g.insert_directed(from, to)?;
63        }
64        Ok(g)
65    }
66
67    /// Number of nodes.
68    #[must_use]
69    pub fn node_count(&self) -> usize {
70        self.nodes.len()
71    }
72
73    /// Whether empty.
74    #[must_use]
75    pub fn is_empty(&self) -> bool {
76        self.nodes.is_empty()
77    }
78
79    /// Node refs in dense order.
80    #[must_use]
81    pub fn nodes(&self) -> &[NodeRef] {
82        &self.nodes
83    }
84
85    /// Add a static node; returns its dense id.
86    ///
87    /// # Errors
88    ///
89    /// [`GraphError::TooManyNodes`] on overflow.
90    pub fn add_node(&mut self, node: NodeRef) -> Result<DenseNodeId, GraphError> {
91        if !matches!(node, NodeRef::Static(_)) {
92            return Err(GraphError::InvalidEndpoints { message: "Dag accepts only Static nodes" });
93        }
94        let id = u32::try_from(self.nodes.len()).map_err(|_| GraphError::TooManyNodes)?;
95        self.nodes.push(node);
96        self.children.push(Vec::new());
97        self.parents.push(Vec::new());
98        Ok(DenseNodeId::from_raw(id))
99    }
100
101    /// Insert a directed edge `from -> to` if it preserves acyclicity.
102    ///
103    /// # Errors
104    ///
105    /// Unknown nodes, duplicates, or cycles.
106    pub fn insert_directed(
107        &mut self,
108        from: DenseNodeId,
109        to: DenseNodeId,
110    ) -> Result<(), GraphError> {
111        self.validate_node(from)?;
112        self.validate_node(to)?;
113        if self.children[from.as_usize()].contains(&to) {
114            return Err(GraphError::DuplicateEdge { from: from.raw(), to: to.raw() });
115        }
116        let mut ws = core::mem::take(&mut self.insert_ws);
117        let cycle = self.reaches_with(to, from, &mut ws);
118        self.insert_ws = ws;
119        if cycle {
120            return Err(GraphError::Cycle { from: from.raw(), to: to.raw() });
121        }
122        self.children[from.as_usize()].push(to);
123        self.parents[to.as_usize()].push(from);
124        Ok(())
125    }
126
127    /// Push an edge without duplicate/acyclicity checks; the caller guarantees
128    /// both nodes exist and the edge preserves invariants.
129    pub(crate) fn insert_directed_unchecked(&mut self, from: DenseNodeId, to: DenseNodeId) {
130        self.children[from.as_usize()].push(to);
131        self.parents[to.as_usize()].push(from);
132    }
133
134    /// Remove a directed edge if present.
135    pub fn remove_directed(&mut self, from: DenseNodeId, to: DenseNodeId) {
136        if from.as_usize() >= self.node_count() || to.as_usize() >= self.node_count() {
137            return;
138        }
139        self.children[from.as_usize()].retain(|c| *c != to);
140        self.parents[to.as_usize()].retain(|p| *p != from);
141    }
142
143    /// Children of `id`.
144    #[must_use]
145    pub fn children(&self, id: DenseNodeId) -> &[DenseNodeId] {
146        &self.children[id.as_usize()]
147    }
148
149    /// Parents of `id`.
150    #[must_use]
151    pub fn parents(&self, id: DenseNodeId) -> &[DenseNodeId] {
152        &self.parents[id.as_usize()]
153    }
154
155    /// Whether `from` can reach `to` via directed edges.
156    #[must_use]
157    pub fn reaches(&self, from: DenseNodeId, to: DenseNodeId) -> bool {
158        if from == to {
159            return true;
160        }
161        let mut ws = GraphWorkspace::default();
162        self.reaches_with(from, to, &mut ws)
163    }
164
165    /// Reachability using a reusable workspace.
166    pub fn reaches_with(
167        &self,
168        from: DenseNodeId,
169        to: DenseNodeId,
170        ws: &mut GraphWorkspace,
171    ) -> bool {
172        bfs_reaches(&self.children, from, to, ws)
173    }
174
175    /// Topological order (Kahn). Returns `None` if a cycle slipped in.
176    #[must_use]
177    pub fn topological_order(&self) -> Option<Vec<DenseNodeId>> {
178        kahn_order(&self.parents, &self.children)
179    }
180
181    /// Validate graph invariants.
182    ///
183    /// # Errors
184    ///
185    /// Cycle detected.
186    pub fn validate(&self) -> Result<(), GraphError> {
187        if self.topological_order().is_none() {
188            return Err(GraphError::Cycle { from: 0, to: 0 });
189        }
190        Ok(())
191    }
192
193    fn validate_node(&self, id: DenseNodeId) -> Result<(), GraphError> {
194        if id.as_usize() >= self.node_count() {
195            Err(GraphError::UnknownNode { id: id.raw() })
196        } else {
197            Ok(())
198        }
199    }
200
201    /// Iterate directed edges as marked edges.
202    pub fn edges(&self) -> impl Iterator<Item = MarkedEdge> + '_ {
203        self.children.iter().enumerate().flat_map(|(i, kids)| {
204            let from = DenseNodeId::from_raw(u32::try_from(i).expect("node fit"));
205            kids.iter().map(move |&to| MarkedEdge::directed(from, to))
206        })
207    }
208
209    /// Enumerate simple directed paths from `from` to `to` (inclusive endpoints).
210    ///
211    /// Bounded by `max_paths` and `max_len` (number of nodes on the path).
212    ///
213    /// The returned set is silently truncated when either bound binds. Callers whose
214    /// correctness depends on seeing *every* path — e.g. a recanting-witness test, which
215    /// concludes "no witness exists" from the absence of one — must use
216    /// [`Self::directed_paths_with_budget`] and fail closed on truncation instead.
217    ///
218    /// # Errors
219    ///
220    /// Unknown nodes.
221    pub fn directed_paths(
222        &self,
223        from: DenseNodeId,
224        to: DenseNodeId,
225        max_paths: usize,
226        max_len: usize,
227    ) -> Result<Vec<Vec<DenseNodeId>>, GraphError> {
228        self.directed_paths_with_budget(from, to, max_paths, max_len).map(|(paths, _)| paths)
229    }
230
231    /// [`Self::directed_paths`] plus a flag reporting whether enumeration was cut short.
232    ///
233    /// The flag is `true` when `max_paths` stopped the search with candidates still
234    /// pending, or when `max_len` pruned a partial path that had not yet reached `to`.
235    /// It is deliberately conservative: `true` means "the path set may be incomplete",
236    /// never "it is definitely incomplete".
237    ///
238    /// # Errors
239    ///
240    /// Unknown nodes.
241    pub fn directed_paths_with_budget(
242        &self,
243        from: DenseNodeId,
244        to: DenseNodeId,
245        max_paths: usize,
246        max_len: usize,
247    ) -> Result<(Vec<Vec<DenseNodeId>>, bool), GraphError> {
248        self.validate_node(from)?;
249        self.validate_node(to)?;
250        let mut out = Vec::new();
251        if max_paths == 0 || max_len == 0 {
252            // A zero budget cannot certify that no path exists.
253            return Ok((out, true));
254        }
255        let mut truncated = false;
256        let mut stack = vec![vec![from]];
257        while let Some(path) = stack.pop() {
258            if out.len() >= max_paths {
259                truncated = true;
260                break;
261            }
262            let last = *path.last().expect("nonempty");
263            if path.len() > 1 && last == to {
264                out.push(path);
265                continue;
266            }
267            if last == to && path.len() == 1 {
268                out.push(path);
269                continue;
270            }
271            if path.len() >= max_len {
272                // Pruned before reaching `to`; a longer completion may exist.
273                truncated = true;
274                continue;
275            }
276            for &c in self.children(last) {
277                if path.contains(&c) {
278                    continue;
279                }
280                let mut next = path.clone();
281                next.push(c);
282                stack.push(next);
283            }
284        }
285        Ok((out, truncated))
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn directed_paths_reports_max_paths_truncation() {
295        // t→c→y, t→w→b→y, t→w→a→y : three directed paths.
296        let mut g = Dag::with_variables(6);
297        for (u, v) in [(0, 4), (4, 5), (0, 1), (1, 3), (3, 5), (1, 2), (2, 5)] {
298            g.insert_directed(DenseNodeId::from_raw(u), DenseNodeId::from_raw(v)).unwrap();
299        }
300        let (t, y) = (DenseNodeId::from_raw(0), DenseNodeId::from_raw(5));
301
302        let (all, truncated) = g.directed_paths_with_budget(t, y, 64, 16).unwrap();
303        assert_eq!(all.len(), 3);
304        assert!(!truncated, "a budget that comfortably fits every path must not report truncation");
305
306        for cap in 1..=2 {
307            let (paths, truncated) = g.directed_paths_with_budget(t, y, cap, 16).unwrap();
308            assert_eq!(paths.len(), cap);
309            assert!(truncated, "max_paths={cap} dropped paths but reported none");
310        }
311    }
312
313    #[test]
314    fn directed_paths_reports_max_len_truncation() {
315        // Single path 0→1→2→3 needs 4 nodes; max_len=3 prunes it before it reaches the target.
316        let mut g = Dag::with_variables(4);
317        for (u, v) in [(0, 1), (1, 2), (2, 3)] {
318            g.insert_directed(DenseNodeId::from_raw(u), DenseNodeId::from_raw(v)).unwrap();
319        }
320        let (t, y) = (DenseNodeId::from_raw(0), DenseNodeId::from_raw(3));
321        let (paths, truncated) = g.directed_paths_with_budget(t, y, 64, 3).unwrap();
322        assert!(paths.is_empty());
323        assert!(truncated, "max_len pruned the only path but reported no truncation");
324
325        let (paths, truncated) = g.directed_paths_with_budget(t, y, 64, 4).unwrap();
326        assert_eq!(paths.len(), 1);
327        assert!(!truncated);
328    }
329
330    #[test]
331    fn rejects_cycles() {
332        let mut g = Dag::with_variables(3);
333        let a = DenseNodeId::from_raw(0);
334        let b = DenseNodeId::from_raw(1);
335        let c = DenseNodeId::from_raw(2);
336        g.insert_directed(a, b).unwrap();
337        g.insert_directed(b, c).unwrap();
338        assert!(matches!(g.insert_directed(c, a), Err(GraphError::Cycle { .. })));
339    }
340
341    #[test]
342    fn topological_order_respects_edges() {
343        let mut g = Dag::with_variables(3);
344        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
345        g.insert_directed(DenseNodeId::from_raw(1), DenseNodeId::from_raw(2)).unwrap();
346        let order = g.topological_order().unwrap();
347        let pos = |id: u32| order.iter().position(|n| n.raw() == id).unwrap();
348        assert!(pos(0) < pos(1) && pos(1) < pos(2));
349    }
350
351    #[test]
352    fn traversal_workspace_reuses_frontier_capacity() {
353        let mut dag = Dag::with_variables(1_000);
354        for i in 0..999 {
355            dag.insert_directed(DenseNodeId::from_raw(i), DenseNodeId::from_raw(i + 1)).unwrap();
356        }
357        let mut ws = GraphWorkspace::default();
358        assert!(dag.reaches_with(DenseNodeId::from_raw(0), DenseNodeId::from_raw(999), &mut ws));
359        let ptr = ws.frontier.as_ptr();
360        let cap = ws.frontier.capacity();
361        for _ in 0..50 {
362            assert!(dag.reaches_with(
363                DenseNodeId::from_raw(0),
364                DenseNodeId::from_raw(999),
365                &mut ws
366            ));
367            assert_eq!(ws.frontier.as_ptr(), ptr);
368            assert_eq!(ws.frontier.capacity(), cap);
369        }
370    }
371}
372
373/// Review-required static DAG artifact (`DirectLiNGAM` and other full-DAG discovery).
374#[derive(Clone, Debug)]
375pub struct DagReview {
376    /// Proposed discovery DAG.
377    pub graph: Dag,
378    /// Directed edges awaiting explicit acceptance `(from, to)` as [`VariableId`]s.
379    pub pending_edges: Arc<[(VariableId, VariableId)]>,
380    /// Algorithm id that produced the proposal.
381    pub algorithm: Arc<str>,
382}
383
384impl DagReview {
385    /// Construct a review listing all current edges as pending.
386    #[must_use]
387    pub fn from_dag(graph: Dag, algorithm: impl Into<Arc<str>>) -> Self {
388        let mut pending = Vec::new();
389        for e in graph.edges() {
390            if let Some((from, to)) = e.parent_child() {
391                if let (Some(fv), Some(tv)) =
392                    (variable_id_of(&graph, from), variable_id_of(&graph, to))
393                {
394                    pending.push((fv, tv));
395                }
396            }
397        }
398        Self { graph, pending_edges: Arc::from(pending), algorithm: algorithm.into() }
399    }
400
401    /// Accept a pending directed edge (no-op if absent).
402    #[must_use]
403    pub fn accept_edge(mut self, from: VariableId, to: VariableId) -> Self {
404        let pending: Vec<_> =
405            self.pending_edges.iter().copied().filter(|e| *e != (from, to)).collect();
406        self.pending_edges = Arc::from(pending);
407        self
408    }
409
410    /// Accept all remaining pending edges.
411    #[must_use]
412    pub fn accept_all(mut self) -> Self {
413        self.pending_edges = Arc::from([]);
414        self
415    }
416
417    /// Whether all pending edges have been accepted.
418    #[must_use]
419    pub fn is_complete(&self) -> bool {
420        self.pending_edges.is_empty()
421    }
422
423    /// Borrow the accepted DAG when review is complete.
424    ///
425    /// # Errors
426    ///
427    /// Incomplete review.
428    pub fn try_into_dag(self) -> Result<Dag, GraphError> {
429        if !self.is_complete() {
430            return Err(GraphError::InvalidEndpoints {
431                message: "cannot finish DagReview while pending edges remain",
432            });
433        }
434        Ok(self.graph)
435    }
436}
437
438fn variable_id_of(dag: &Dag, id: DenseNodeId) -> Option<VariableId> {
439    match dag.nodes().get(id.as_usize()) {
440        Some(NodeRef::Static(v)) => Some(*v),
441        _ => None,
442    }
443}