Skip to main content

antecedent_data/
event.rs

1//! Irregular event-indexed datasets.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
6
7use std::sync::Arc;
8
9use antecedent_core::{CausalSchema, VariableId};
10
11use crate::column::{
12    BooleanColumn, ColumnView, FixedVectorColumn, Float64Column, Int64Column, OwnedColumn,
13    TimestampColumn, ValidityBitmap,
14};
15use crate::dataset::TimeSeriesData;
16use crate::error::DataError;
17use crate::storage::OwnedColumnarStorage;
18use crate::table::TableView;
19use crate::temporal::{SamplingRegularity, TimeIndex};
20
21/// Event-indexed dataset with irregular event times and mark columns.
22///
23/// Integer lag algorithms must not treat event indices as regular time steps
24/// (§5.4). Use [`Self::align_to_grid`] (duration bins), or native event models.
25#[derive(Clone, Debug)]
26pub struct EventData {
27    storage: OwnedColumnarStorage,
28    /// Event timestamps in nanoseconds (non-decreasing, length = row count).
29    event_times_ns: Arc<[i64]>,
30}
31
32impl EventData {
33    /// Construct from mark/covariate storage and event timestamps.
34    ///
35    /// # Errors
36    ///
37    /// Length mismatch, empty data, or timestamps that are not non-decreasing.
38    pub fn try_new(
39        storage: OwnedColumnarStorage,
40        event_times_ns: impl Into<Arc<[i64]>>,
41    ) -> Result<Self, DataError> {
42        let event_times_ns = event_times_ns.into();
43        let n = storage.row_count();
44        if event_times_ns.len() != n {
45            return Err(DataError::LengthMismatch {
46                expected: n,
47                actual: event_times_ns.len(),
48                context: "event times",
49            });
50        }
51        if n == 0 {
52            return Err(DataError::InvalidArgument {
53                message: "event data requires ≥1 event".into(),
54            });
55        }
56        for w in event_times_ns.windows(2) {
57            if w[1] < w[0] {
58                return Err(DataError::InvalidArgument {
59                    message: "event times must be non-decreasing".into(),
60                });
61            }
62        }
63        Ok(Self { storage, event_times_ns })
64    }
65
66    /// Event timestamps (nanoseconds).
67    #[must_use]
68    pub fn event_times_ns(&self) -> &[i64] {
69        &self.event_times_ns
70    }
71
72    /// Borrow mark / covariate storage.
73    #[must_use]
74    pub fn storage(&self) -> &OwnedColumnarStorage {
75        &self.storage
76    }
77
78    /// Sampling regularity is always irregular for event data.
79    #[must_use]
80    pub const fn regularity(&self) -> SamplingRegularity {
81        SamplingRegularity::Irregular
82    }
83
84    /// Reject integer-lag sample planning (lags are not durations on irregular data).
85    ///
86    /// # Errors
87    ///
88    /// Always returns [`DataError::InvalidArgument`].
89    pub fn reject_integer_lag_planning(&self) -> Result<(), DataError> {
90        Err(DataError::InvalidArgument {
91            message:
92                "integer lags are not valid on EventData; use duration windows or event models"
93                    .into(),
94        })
95    }
96
97    /// Align irregular events onto a regular grid via duration bins.
98    ///
99    /// Bins are half-open intervals `[t0 + k·Δ, t0 + (k+1)·Δ)` covering
100    /// `[t0, t_last]` where `t0` / `t_last` are the first / last event times and
101    /// `Δ = interval_ns`. Within each bin the **last** event's mark values are
102    /// kept. Empty bins are marked invalid (value zeroed).
103    ///
104    /// The result is a regular [`TimeSeriesData`] suitable for integer-lag
105    /// temporal discovery / identification / estimation.
106    ///
107    /// # Errors
108    ///
109    /// Zero interval, empty events, or column construction failures.
110    pub fn align_to_grid(&self, interval_ns: u64) -> Result<TimeSeriesData, DataError> {
111        if interval_ns == 0 {
112            return Err(DataError::InvalidArgument {
113                message: "align_to_grid requires interval_ns > 0".into(),
114            });
115        }
116        let times = self.event_times_ns.as_ref();
117        let t0 = times[0];
118        let t_last = times[times.len() - 1];
119        let span = (t_last - t0) as u64;
120        let n_bins = (span / interval_ns) as usize + 1;
121
122        // Last event index per bin (None = empty).
123        let mut last_in_bin: Vec<Option<usize>> = vec![None; n_bins];
124        for (i, &t) in times.iter().enumerate() {
125            let bin = ((t - t0) as u64 / interval_ns) as usize;
126            let bin = bin.min(n_bins - 1);
127            last_in_bin[bin] = Some(i);
128        }
129
130        let mut out_cols = Vec::with_capacity(self.storage.columns().len());
131        for col in self.storage.columns() {
132            out_cols.push(align_column(col, &last_in_bin, n_bins)?);
133        }
134
135        let mut analysis_mask = None;
136        if let Some(mask) = self.storage.analysis_mask() {
137            let mut bytes = vec![0u8; n_bins.div_ceil(8)];
138            for (b, &src) in last_in_bin.iter().enumerate() {
139                if let Some(i) = src {
140                    if mask.is_valid(i) {
141                        bytes[b / 8] |= 1 << (b % 8);
142                    }
143                }
144            }
145            analysis_mask = Some(ValidityBitmap::from_bytes(bytes, n_bins)?);
146        }
147
148        let weights = self.storage.weights().map(|w| {
149            let mut out = vec![0.0_f64; n_bins];
150            for (b, &src) in last_in_bin.iter().enumerate() {
151                if let Some(i) = src {
152                    out[b] = w[i];
153                }
154            }
155            Arc::<[f64]>::from(out)
156        });
157
158        let storage = OwnedColumnarStorage::try_new(
159            self.storage.schema().clone(),
160            out_cols,
161            analysis_mask,
162            weights,
163        )?;
164        TimeSeriesData::try_new(
165            storage,
166            TimeIndex { regularity: SamplingRegularity::Regular { interval_ns }, length: n_bins },
167        )
168    }
169}
170
171fn align_column(
172    col: &OwnedColumn,
173    last_in_bin: &[Option<usize>],
174    n_bins: usize,
175) -> Result<OwnedColumn, DataError> {
176    let mut valid_bytes = vec![0u8; n_bins.div_ceil(8)];
177    match col {
178        OwnedColumn::Float64(c) => {
179            let mut values = vec![0.0_f64; n_bins];
180            for (b, &src) in last_in_bin.iter().enumerate() {
181                if let Some(i) = src {
182                    if c.validity.is_valid(i) {
183                        values[b] = c.values.as_slice()[i];
184                        valid_bytes[b / 8] |= 1 << (b % 8);
185                    }
186                }
187            }
188            Ok(OwnedColumn::Float64(Float64Column::new(
189                c.id,
190                Arc::<[f64]>::from(values),
191                ValidityBitmap::from_bytes(valid_bytes, n_bins)?,
192            )?))
193        }
194        OwnedColumn::Int64(c) => {
195            let mut values = vec![0_i64; n_bins];
196            for (b, &src) in last_in_bin.iter().enumerate() {
197                if let Some(i) = src {
198                    if c.validity.is_valid(i) {
199                        values[b] = c.values[i];
200                        valid_bytes[b / 8] |= 1 << (b % 8);
201                    }
202                }
203            }
204            Ok(OwnedColumn::Int64(Int64Column::new(
205                c.id,
206                Arc::<[i64]>::from(values),
207                ValidityBitmap::from_bytes(valid_bytes, n_bins)?,
208            )?))
209        }
210        OwnedColumn::Boolean(c) => {
211            let mut values = vec![0u8; n_bins];
212            for (b, &src) in last_in_bin.iter().enumerate() {
213                if let Some(i) = src {
214                    if c.validity.is_valid(i) {
215                        values[b] = c.values[i];
216                        valid_bytes[b / 8] |= 1 << (b % 8);
217                    }
218                }
219            }
220            Ok(OwnedColumn::Boolean(BooleanColumn::new(
221                c.id,
222                Arc::<[u8]>::from(values),
223                ValidityBitmap::from_bytes(valid_bytes, n_bins)?,
224            )?))
225        }
226        OwnedColumn::Timestamp(c) => {
227            let mut values = vec![0_i64; n_bins];
228            for (b, &src) in last_in_bin.iter().enumerate() {
229                if let Some(i) = src {
230                    if c.validity.is_valid(i) {
231                        values[b] = c.values_ns[i];
232                        valid_bytes[b / 8] |= 1 << (b % 8);
233                    }
234                }
235            }
236            Ok(OwnedColumn::Timestamp(TimestampColumn::new(
237                c.id,
238                Arc::<[i64]>::from(values),
239                ValidityBitmap::from_bytes(valid_bytes, n_bins)?,
240            )?))
241        }
242        OwnedColumn::FixedVector(c) => {
243            let dim = c.dim;
244            let mut values = vec![0.0_f64; n_bins * dim];
245            for (b, &src) in last_in_bin.iter().enumerate() {
246                if let Some(i) = src {
247                    if c.validity.is_valid(i) {
248                        let src_off = i * dim;
249                        let dst_off = b * dim;
250                        values[dst_off..dst_off + dim]
251                            .copy_from_slice(&c.values[src_off..src_off + dim]);
252                        valid_bytes[b / 8] |= 1 << (b % 8);
253                    }
254                }
255            }
256            Ok(OwnedColumn::FixedVector(FixedVectorColumn::new(
257                c.id,
258                dim,
259                Arc::<[f64]>::from(values),
260                ValidityBitmap::from_bytes(valid_bytes, n_bins)?,
261            )?))
262        }
263        OwnedColumn::Categorical(_) => Err(DataError::InvalidArgument {
264            message: "align_to_grid does not support categorical mark columns".into(),
265        }),
266    }
267}
268
269impl TableView for EventData {
270    fn schema(&self) -> &CausalSchema {
271        self.storage.schema()
272    }
273
274    fn row_count(&self) -> usize {
275        self.storage.row_count()
276    }
277
278    fn column(&self, id: VariableId) -> Result<ColumnView<'_>, DataError> {
279        self.storage.column(id)
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use std::sync::Arc;
286
287    use antecedent_core::{
288        CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, ValueType, VariableId,
289    };
290
291    use super::*;
292    use crate::column::{Float64Column, OwnedColumn, ValidityBitmap};
293    use crate::table::TableView;
294
295    fn one_col(n: usize) -> OwnedColumnarStorage {
296        let mut b = CausalSchemaBuilder::new();
297        b.add_variable(
298            "mark",
299            ValueType::Continuous,
300            SmallRoleSet::from_hint(RoleHint::Context),
301            None,
302            None,
303            MeasurementSpec::default(),
304        )
305        .unwrap();
306        let schema = b.build().unwrap();
307        let col = Float64Column::new(
308            VariableId::from_raw(0),
309            Arc::<[f64]>::from(vec![1.0; n]),
310            ValidityBitmap::all_valid(n),
311        )
312        .unwrap();
313        OwnedColumnarStorage::try_new(schema, vec![OwnedColumn::Float64(col)], None, None).unwrap()
314    }
315
316    #[test]
317    fn event_data_round_trip() {
318        let times = Arc::<[i64]>::from(vec![0, 10, 10, 25]);
319        let data = EventData::try_new(one_col(4), times).unwrap();
320        assert_eq!(data.row_count(), 4);
321        assert_eq!(data.event_times_ns(), &[0, 10, 10, 25]);
322        assert_eq!(data.regularity(), SamplingRegularity::Irregular);
323        assert!(data.reject_integer_lag_planning().is_err());
324    }
325
326    #[test]
327    fn rejects_decreasing_times() {
328        let err = EventData::try_new(one_col(3), Arc::<[i64]>::from(vec![0, 5, 4]));
329        assert!(err.is_err());
330    }
331
332    #[test]
333    fn align_to_grid_last_event_in_bin() {
334        let mut b = CausalSchemaBuilder::new();
335        b.add_variable(
336            "mark",
337            ValueType::Continuous,
338            SmallRoleSet::from_hint(RoleHint::Context),
339            None,
340            None,
341            MeasurementSpec::default(),
342        )
343        .unwrap();
344        let schema = b.build().unwrap();
345        // Events at 0, 5, 15 with marks 1, 2, 3 → bins of 10ns: [0,10), [10,20]
346        // bin0 last=5→2.0, bin1 last=15→3.0
347        let col = Float64Column::new(
348            VariableId::from_raw(0),
349            Arc::<[f64]>::from(vec![1.0, 2.0, 3.0]),
350            ValidityBitmap::all_valid(3),
351        )
352        .unwrap();
353        let storage =
354            OwnedColumnarStorage::try_new(schema, vec![OwnedColumn::Float64(col)], None, None)
355                .unwrap();
356        let data = EventData::try_new(storage, Arc::<[i64]>::from(vec![0, 5, 15])).unwrap();
357        let series = data.align_to_grid(10).unwrap();
358        assert_eq!(series.row_count(), 2);
359        assert_eq!(series.time_index().regularity, SamplingRegularity::Regular { interval_ns: 10 });
360        let ColumnView::Float64(c) = series.column(VariableId::from_raw(0)).unwrap() else {
361            panic!("expected float");
362        };
363        assert!((c.values.as_slice()[0] - 2.0).abs() < f64::EPSILON);
364        assert!((c.values.as_slice()[1] - 3.0).abs() < f64::EPSILON);
365        assert!(c.validity.is_valid(0) && c.validity.is_valid(1));
366    }
367
368    #[test]
369    fn align_to_grid_marks_empty_bins_invalid() {
370        let mut b = CausalSchemaBuilder::new();
371        b.add_variable(
372            "mark",
373            ValueType::Continuous,
374            SmallRoleSet::from_hint(RoleHint::Context),
375            None,
376            None,
377            MeasurementSpec::default(),
378        )
379        .unwrap();
380        let schema = b.build().unwrap();
381        // Events at 0 and 20 with Δ=10 → bins [0,10), [10,20), [20,30): middle empty
382        let col = Float64Column::new(
383            VariableId::from_raw(0),
384            Arc::<[f64]>::from(vec![1.0, 9.0]),
385            ValidityBitmap::all_valid(2),
386        )
387        .unwrap();
388        let storage =
389            OwnedColumnarStorage::try_new(schema, vec![OwnedColumn::Float64(col)], None, None)
390                .unwrap();
391        let data = EventData::try_new(storage, Arc::<[i64]>::from(vec![0, 20])).unwrap();
392        let series = data.align_to_grid(10).unwrap();
393        assert_eq!(series.row_count(), 3);
394        let ColumnView::Float64(c) = series.column(VariableId::from_raw(0)).unwrap() else {
395            panic!("expected float");
396        };
397        assert!(c.validity.is_valid(0));
398        assert!(!c.validity.is_valid(1));
399        assert!(c.validity.is_valid(2));
400        assert!((c.values.as_slice()[2] - 9.0).abs() < f64::EPSILON);
401    }
402
403    #[test]
404    fn align_rejects_zero_interval() {
405        let data = EventData::try_new(one_col(2), Arc::<[i64]>::from(vec![0, 1])).unwrap();
406        assert!(data.align_to_grid(0).is_err());
407    }
408}