Skip to main content

antecedent_graph/
temporal.rs

1//! Temporal DAG over lagged variable nodes.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5use antecedent_core::TemporalNodeKey;
6use antecedent_core::{Lag, VariableId};
7
8use crate::algo::bfs_reaches;
9use crate::error::GraphError;
10use crate::types::{DenseNodeId, MarkedEdge, NodeRef};
11use crate::workspace::GraphWorkspace;
12
13/// Directed acyclic graph over lagged (`VariableId`, `Lag`) nodes.
14#[derive(Clone, Debug)]
15pub struct TemporalDag {
16    nodes: Vec<NodeRef>,
17    children: Vec<Vec<DenseNodeId>>,
18    parents: Vec<Vec<DenseNodeId>>,
19    insert_ws: GraphWorkspace,
20}
21
22impl TemporalDag {
23    /// Empty temporal DAG.
24    #[must_use]
25    pub fn empty() -> Self {
26        Self {
27            nodes: Vec::new(),
28            children: Vec::new(),
29            parents: Vec::new(),
30            insert_ws: GraphWorkspace::default(),
31        }
32    }
33
34    /// Node count.
35    #[must_use]
36    pub fn node_count(&self) -> usize {
37        self.nodes.len()
38    }
39
40    /// Whether empty.
41    #[must_use]
42    pub fn is_empty(&self) -> bool {
43        self.nodes.is_empty()
44    }
45
46    /// Nodes in dense order.
47    #[must_use]
48    pub fn nodes(&self) -> &[NodeRef] {
49        &self.nodes
50    }
51
52    /// Add a lagged node.
53    ///
54    /// # Errors
55    ///
56    /// Non-lagged node refs or capacity overflow.
57    pub fn add_node(&mut self, node: NodeRef) -> Result<DenseNodeId, GraphError> {
58        match node {
59            NodeRef::Lagged { .. } => {}
60            _ => {
61                return Err(GraphError::InvalidEndpoints {
62                    message: "TemporalDag accepts only Lagged nodes",
63                });
64            }
65        }
66        let id = u32::try_from(self.nodes.len()).map_err(|_| GraphError::TooManyNodes)?;
67        self.nodes.push(node);
68        self.children.push(Vec::new());
69        self.parents.push(Vec::new());
70        Ok(DenseNodeId::from_raw(id))
71    }
72
73    /// Convenience: add `variable` at `lag`.
74    ///
75    /// # Errors
76    ///
77    /// Capacity overflow.
78    pub fn add_lagged(
79        &mut self,
80        variable: VariableId,
81        lag: Lag,
82    ) -> Result<DenseNodeId, GraphError> {
83        self.add_node(NodeRef::Lagged { variable, lag })
84    }
85
86    /// Insert directed edge with temporal rules.
87    ///
88    /// Contemporaneous self-edges are rejected. A self-loop on a single dense
89    /// node is always a [`GraphError::Cycle`]; lagged self-influence is modeled
90    /// as an edge between two distinct nodes (e.g. `X@t-1 -> X@t`).
91    ///
92    /// `from`'s lag must be greater than or equal to `to`'s lag: larger `Lag`
93    /// values sit further in the past (`Lag::CONTEMPORANEOUS` is the present),
94    /// so an edge is only valid running from the past (or same time) toward the
95    /// present. An edge whose source is nearer the present than its target
96    /// would point from the future into the past and is rejected.
97    ///
98    /// # Errors
99    ///
100    /// Unknown nodes, duplicates, cycles, contemporaneous self-edges, or edges
101    /// that point from the future into the past.
102    pub fn insert_directed(
103        &mut self,
104        from: DenseNodeId,
105        to: DenseNodeId,
106    ) -> Result<(), GraphError> {
107        self.validate_node(from)?;
108        self.validate_node(to)?;
109        if let (
110            NodeRef::Lagged { variable: v1, lag: l1 },
111            NodeRef::Lagged { variable: v2, lag: l2 },
112        ) = (self.nodes[from.as_usize()], self.nodes[to.as_usize()])
113        {
114            if v1 == v2 && l1 == l2 && l1.is_contemporaneous() {
115                return Err(GraphError::ContemporaneousSelfEdge { variable: v1 });
116            }
117            crate::types::reject_future_to_past(&self.nodes, from, to)?;
118        }
119        if self.children[from.as_usize()].contains(&to) {
120            return Err(GraphError::DuplicateEdge { from: from.raw(), to: to.raw() });
121        }
122        let mut ws = core::mem::take(&mut self.insert_ws);
123        let cycle = bfs_reaches(&self.children, to, from, &mut ws);
124        self.insert_ws = ws;
125        if cycle {
126            return Err(GraphError::Cycle { from: from.raw(), to: to.raw() });
127        }
128        self.children[from.as_usize()].push(to);
129        self.parents[to.as_usize()].push(from);
130        Ok(())
131    }
132
133    /// Children.
134    #[must_use]
135    pub fn children(&self, id: DenseNodeId) -> &[DenseNodeId] {
136        &self.children[id.as_usize()]
137    }
138
139    /// Iterate directed edges as marked edges.
140    pub fn edges(&self) -> impl Iterator<Item = MarkedEdge> + '_ {
141        self.children.iter().enumerate().flat_map(|(i, kids)| {
142            let from = DenseNodeId::from_raw(u32::try_from(i).expect("node fit"));
143            kids.iter().map(move |&to| MarkedEdge::directed(from, to))
144        })
145    }
146
147    /// Reachability.
148    #[must_use]
149    pub fn reaches(&self, from: DenseNodeId, to: DenseNodeId) -> bool {
150        let mut ws = GraphWorkspace::default();
151        bfs_reaches(&self.children, from, to, &mut ws)
152    }
153
154    /// Reachability with a reusable workspace.
155    pub fn reaches_with(
156        &self,
157        from: DenseNodeId,
158        to: DenseNodeId,
159        ws: &mut GraphWorkspace,
160    ) -> bool {
161        bfs_reaches(&self.children, from, to, ws)
162    }
163
164    /// Map dense id to a serializable [`TemporalNodeKey`].
165    #[must_use]
166    pub fn temporal_key(&self, id: DenseNodeId) -> Option<TemporalNodeKey> {
167        match self.nodes.get(id.as_usize())? {
168            NodeRef::Lagged { variable, lag } => {
169                let offset = -i32::try_from(lag.raw()).ok()?;
170                Some(TemporalNodeKey { variable: *variable, offset })
171            }
172            _ => None,
173        }
174    }
175
176    fn validate_node(&self, id: DenseNodeId) -> Result<(), GraphError> {
177        if id.as_usize() >= self.node_count() {
178            Err(GraphError::UnknownNode { id: id.raw() })
179        } else {
180            Ok(())
181        }
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn rejects_contemporaneous_self_edge() {
191        let mut g = TemporalDag::empty();
192        let n = g.add_lagged(VariableId::from_raw(0), Lag::CONTEMPORANEOUS).unwrap();
193        assert!(matches!(g.insert_directed(n, n), Err(GraphError::ContemporaneousSelfEdge { .. })));
194    }
195
196    #[test]
197    fn allows_lagged_self_edge() {
198        let mut g = TemporalDag::empty();
199        let past = g.add_lagged(VariableId::from_raw(0), Lag::from_raw(1)).unwrap();
200        let now = g.add_lagged(VariableId::from_raw(0), Lag::CONTEMPORANEOUS).unwrap();
201        g.insert_directed(past, now).unwrap();
202        assert!(g.reaches(past, now));
203    }
204
205    #[test]
206    fn rejects_future_to_past_edge() {
207        let mut g = TemporalDag::empty();
208        let past = g.add_lagged(VariableId::from_raw(0), Lag::from_raw(1)).unwrap();
209        let now = g.add_lagged(VariableId::from_raw(1), Lag::CONTEMPORANEOUS).unwrap();
210        // `now` (lag 0) -> `past` (lag 1) points from the present into the past.
211        assert!(matches!(g.insert_directed(now, past), Err(GraphError::FutureToPast { .. })));
212    }
213}