Skip to main content

antecedent_core/
temporal.rs

1//! Temporal node identity and dense unfolding indexes (ADR 0005 / ).
2//!
3//! Dense indexes are process-local and **must not** be serialized.
4//!
5//! SPDX-License-Identifier: MIT OR Apache-2.0
6
7use crate::ids::{Lag, VariableId};
8
9/// Errors from temporal indexing.
10#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
11#[non_exhaustive]
12pub enum TemporalIndexError {
13    /// Invalid constructor arguments.
14    #[error("{message}")]
15    Invalid {
16        /// Message.
17        message: &'static str,
18    },
19    /// Variable id outside the indexer.
20    #[error("unknown variable id {}", id.raw())]
21    UnknownVariable {
22        /// Variable.
23        id: VariableId,
24    },
25}
26
27/// Stable unfolded temporal node identity (serializable).
28#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
29pub struct TemporalNodeKey {
30    /// Variable.
31    pub variable: VariableId,
32    /// Offset relative to the analysis origin (may be negative for history).
33    pub offset: i32,
34}
35
36impl TemporalNodeKey {
37    /// Contemporaneous node at the origin.
38    #[must_use]
39    pub const fn contemporaneous(variable: VariableId) -> Self {
40        Self { variable, offset: 0 }
41    }
42
43    /// Node at `t - lag` when lag is non-negative and fits in `i32`.
44    #[must_use]
45    pub fn lagged(variable: VariableId, lag: Lag) -> Option<Self> {
46        let lag_i = i32::try_from(lag.raw()).ok()?;
47        Some(Self { variable, offset: -lag_i })
48    }
49}
50
51/// Finite unfolding indexer: time-major dense layout.
52///
53/// `dense_index = time_slice_index * variable_count + variable_index`
54/// where `time_slice_index = offset + history`.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct TemporalIndexer {
57    variable_count: u32,
58    /// Number of historical slices before offset 0.
59    history: u32,
60    /// Number of forward slices including offset 0.
61    horizon: u32,
62    /// Cached `variable_count * (history + horizon)`.
63    dense_len: usize,
64}
65
66impl TemporalIndexer {
67    /// Create an indexer.
68    ///
69    /// # Errors
70    ///
71    /// When counts are zero or products overflow.
72    pub fn new(
73        variable_count: u32,
74        history: u32,
75        horizon: u32,
76    ) -> Result<Self, TemporalIndexError> {
77        if variable_count == 0 || horizon == 0 {
78            return Err(TemporalIndexError::Invalid {
79                message: "variable_count and horizon must be non-zero",
80            });
81        }
82        let slices = history
83            .checked_add(horizon)
84            .ok_or(TemporalIndexError::Invalid { message: "history+horizon overflow" })?;
85        let dense_u32 = variable_count
86            .checked_mul(slices)
87            .ok_or(TemporalIndexError::Invalid { message: "dense index space overflow" })?;
88        let dense_len = usize::try_from(dense_u32)
89            .map_err(|_| TemporalIndexError::Invalid { message: "dense index space overflow" })?;
90        Ok(Self { variable_count, history, horizon, dense_len })
91    }
92
93    /// Variable count.
94    #[must_use]
95    pub const fn variable_count(&self) -> u32 {
96        self.variable_count
97    }
98
99    /// History depth.
100    #[must_use]
101    pub const fn history(&self) -> u32 {
102        self.history
103    }
104
105    /// Horizon (including t=0).
106    #[must_use]
107    pub const fn horizon(&self) -> u32 {
108        self.horizon
109    }
110
111    /// Total dense nodes.
112    #[must_use]
113    pub const fn dense_len(&self) -> usize {
114        self.dense_len
115    }
116
117    /// Convert a stable key to a dense index.
118    ///
119    /// # Errors
120    ///
121    /// Out of range variable or offset.
122    pub fn dense_id(&self, key: TemporalNodeKey) -> Result<u32, TemporalIndexError> {
123        let v = key.variable.raw();
124        if v >= self.variable_count {
125            return Err(TemporalIndexError::UnknownVariable { id: key.variable });
126        }
127        let slice = i64::from(key.offset) + i64::from(self.history);
128        if slice < 0 || slice >= i64::from(self.history) + i64::from(self.horizon) {
129            return Err(TemporalIndexError::Invalid {
130                message: "temporal offset outside unfolding window",
131            });
132        }
133        let slice_u = u64::try_from(slice).map_err(|_| TemporalIndexError::Invalid {
134            message: "temporal offset outside unfolding window",
135        })?;
136        let dense = slice_u * u64::from(self.variable_count) + u64::from(v);
137        u32::try_from(dense)
138            .map_err(|_| TemporalIndexError::Invalid { message: "dense id exceeds u32" })
139    }
140
141    /// Invert a dense index to a stable key.
142    ///
143    /// # Errors
144    ///
145    /// Out of range dense id.
146    pub fn key_of(&self, dense: u32) -> Result<TemporalNodeKey, TemporalIndexError> {
147        let dense_usize = usize::try_from(dense)
148            .map_err(|_| TemporalIndexError::Invalid { message: "dense id out of range" })?;
149        if dense_usize >= self.dense_len() {
150            return Err(TemporalIndexError::Invalid { message: "dense id out of range" });
151        }
152        let vc = self.variable_count;
153        let slice = dense / vc;
154        let var = dense % vc;
155        let offset = i32::try_from(i64::from(slice) - i64::from(self.history))
156            .map_err(|_| TemporalIndexError::Invalid { message: "offset overflow" })?;
157        Ok(TemporalNodeKey { variable: VariableId::from_raw(var), offset })
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn time_major_dense_round_trip() {
167        let idx = TemporalIndexer::new(3, 2, 4).unwrap();
168        assert_eq!(idx.dense_len(), 18);
169        let key = TemporalNodeKey { variable: VariableId::from_raw(1), offset: -1 };
170        let dense = idx.dense_id(key).unwrap();
171        assert_eq!(dense, 4);
172        assert_eq!(idx.key_of(dense).unwrap(), key);
173    }
174}