Skip to main content

antecedent_data/
lagged_frame.rs

1//! Pre-materialized lagged columns for temporal discovery.
2//!
3//! Built once per discovery run so candidate CI tests index columns without
4//! rebuilding lag alignment or re-gathering series. Optional analysis masks and
5//! column missingness are recorded per lagged column so masked MCI can keep
6//! complete-case windows without requiring a fully contiguous unmasked series.
7//!
8//! SPDX-License-Identifier: MIT OR Apache-2.0
9
10#![allow(clippy::cast_possible_truncation)]
11
12use std::sync::Arc;
13
14use antecedent_core::{KernelPolicy, Lag, VariableId};
15use antecedent_kernels::{F64VectorView, gather};
16
17use crate::column::{ColumnView, ValidityBitmap};
18use crate::dataset::TimeSeriesData;
19use crate::error::DataError;
20use crate::reference::ReferencePointPolicy;
21use crate::sample::LagMap;
22use crate::sample_policy::{MaskPolicy, MissingPolicy};
23use crate::table::TableView;
24
25/// Options for lag-aligned frame materialization.
26#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
27pub struct LaggedFrameOptions {
28    /// How the optional analysis mask is applied when recording column validity.
29    pub mask: MaskPolicy,
30    /// How column missingness is recorded (`CompleteCase` marks invalid; `ErrorOnMissing` fails).
31    pub missing: MissingPolicy,
32}
33
34impl Default for LaggedFrameOptions {
35    fn default() -> Self {
36        Self { mask: MaskPolicy::Honor, missing: MissingPolicy::CompleteCase }
37    }
38}
39
40/// Pre-materialized lagged frame: one contiguous column per `(variable, lag)`.
41///
42/// Layout is column-major over slots `variable_slot * (max_lag + 1) + lag`.
43/// Per-column [`ValidityBitmap`]s record whether each effective row's source
44/// sample passed analysis-mask ∩ column-validity (under the build options).
45#[derive(Clone, Debug)]
46pub struct LaggedFrame {
47    variables: Arc<[VariableId]>,
48    max_lag: u32,
49    n_effective: usize,
50    n_lags: usize,
51    /// Column-major values: `n_cols * n_effective`.
52    values: Vec<f64>,
53    /// Per lagged-column validity (`ncols` bitmaps, each length `n_effective`).
54    validity: Vec<ValidityBitmap>,
55}
56
57impl LaggedFrame {
58    /// Materialize all lags `0..=max_lag` for `variables` from `data`.
59    ///
60    /// Uses [`LaggedFrameOptions::default`] (Honor analysis mask + `CompleteCase`).
61    ///
62    /// # Errors
63    ///
64    /// Empty variable list, invalid lag map, missing/non-float64 columns, or
65    /// `ErrorOnMissing` when a source column has nulls.
66    pub fn from_series(
67        data: &TimeSeriesData,
68        variables: &[VariableId],
69        max_lag: u32,
70        policy: &KernelPolicy,
71    ) -> Result<Self, DataError> {
72        Self::from_series_with_options(
73            data,
74            variables,
75            max_lag,
76            ReferencePointPolicy::SeriesOrigin,
77            LaggedFrameOptions::default(),
78            policy,
79        )
80    }
81
82    /// Materialize lagged columns under an explicit reference-point policy.
83    ///
84    /// # Errors
85    ///
86    /// Empty variable list, invalid lag map, missing/non-float64 columns, or
87    /// `ErrorOnMissing` when a source column has nulls.
88    pub fn from_series_with_reference(
89        data: &TimeSeriesData,
90        variables: &[VariableId],
91        max_lag: u32,
92        reference: ReferencePointPolicy,
93        policy: &KernelPolicy,
94    ) -> Result<Self, DataError> {
95        Self::from_series_with_options(
96            data,
97            variables,
98            max_lag,
99            reference,
100            LaggedFrameOptions::default(),
101            policy,
102        )
103    }
104
105    /// Materialize lagged columns with explicit mask / missingness policies.
106    ///
107    /// # Errors
108    ///
109    /// Empty variable list, invalid lag map, missing/non-float64 columns, or
110    /// `ErrorOnMissing` when a source column has nulls.
111    pub fn from_series_with_options(
112        data: &TimeSeriesData,
113        variables: &[VariableId],
114        max_lag: u32,
115        reference: ReferencePointPolicy,
116        options: LaggedFrameOptions,
117        policy: &KernelPolicy,
118    ) -> Result<Self, DataError> {
119        if variables.is_empty() {
120            return Err(DataError::InvalidArgument {
121                message: "lagged frame needs ≥1 variable".into(),
122            });
123        }
124        let lag_map = LagMap::with_reference(data.row_count(), max_lag, reference)?;
125        let n_effective = lag_map.n_effective();
126        let n_lags = max_lag as usize + 1;
127        let n_cols = variables.len().saturating_mul(n_lags);
128        let mut values = vec![0.0; n_cols.saturating_mul(n_effective)];
129        let mut validity = Vec::with_capacity(n_cols);
130
131        // Row indexes depend only on the lag: compute each lag's gather once.
132        let mut lag_rows = vec![vec![0usize; n_effective]; n_lags];
133        for (lag, rows) in lag_rows.iter_mut().enumerate() {
134            lag_map.fill_row_indexes(Lag::from_raw(lag as u32), rows)?;
135        }
136
137        let analysis = data.storage().analysis_mask();
138        for (slot, &var) in variables.iter().enumerate() {
139            let ColumnView::Float64(src) = data.column(var)? else {
140                return Err(DataError::TypeMismatch { id: var, expected: "float64" });
141            };
142            if options.missing == MissingPolicy::ErrorOnMissing && !src.validity.is_all_valid() {
143                return Err(DataError::IncompleteSeries {
144                    id: Some(src.id),
145                    message: "missing values under ErrorOnMissing policy",
146                });
147            }
148            if options.mask == MaskPolicy::Honor && options.missing == MissingPolicy::ErrorOnMissing
149            {
150                if let Some(mask) = analysis {
151                    if !mask.is_all_valid() {
152                        return Err(DataError::IncompleteSeries {
153                            id: None,
154                            message: "analysis mask hides rows under ErrorOnMissing policy",
155                        });
156                    }
157                }
158            }
159            let src_view = F64VectorView::contiguous(src.values.as_slice());
160            for (lag, rows) in lag_rows.iter().enumerate() {
161                let col = slot * n_lags + lag;
162                let dst = &mut values[col * n_effective..(col + 1) * n_effective];
163                gather(policy, src_view, rows, dst);
164                let col_valid = gather_column_validity(src, analysis, options.mask, rows)?;
165                validity.push(col_valid);
166            }
167        }
168
169        Ok(Self { variables: Arc::from(variables), max_lag, n_effective, n_lags, values, validity })
170    }
171
172    /// Variables in slot order.
173    #[must_use]
174    pub fn variables(&self) -> &[VariableId] {
175        &self.variables
176    }
177
178    /// Maximum lag materialised (inclusive).
179    #[must_use]
180    pub const fn max_lag(&self) -> u32 {
181        self.max_lag
182    }
183
184    /// Effective aligned sample count.
185    #[must_use]
186    pub const fn n_effective(&self) -> usize {
187        self.n_effective
188    }
189
190    /// Number of columns (`n_vars * (max_lag + 1)`).
191    #[must_use]
192    pub fn ncols(&self) -> usize {
193        self.variables.len().saturating_mul(self.n_lags)
194    }
195
196    /// Whether every lagged column is fully valid (fast path for unmasked series).
197    #[must_use]
198    pub fn is_fully_valid(&self) -> bool {
199        self.validity.iter().all(ValidityBitmap::is_all_valid)
200    }
201
202    /// Byte size of the gathered value buffer.
203    #[must_use]
204    pub fn values_bytes(&self) -> u64 {
205        (self.values.len() * core::mem::size_of::<f64>()) as u64
206    }
207
208    /// Dense column index for `(variable, lag)`, or `None` if unknown / out of range.
209    #[must_use]
210    pub fn column_index(&self, variable: VariableId, lag: Lag) -> Option<usize> {
211        let slot = self.variables.iter().position(|&v| v == variable)?;
212        let l = lag.raw() as usize;
213        if l >= self.n_lags {
214            return None;
215        }
216        Some(slot * self.n_lags + l)
217    }
218
219    /// Borrow column at dense index.
220    ///
221    /// # Panics
222    ///
223    /// Panics if `idx >= ncols`.
224    #[must_use]
225    pub fn column(&self, idx: usize) -> &[f64] {
226        let n = self.n_effective;
227        &self.values[idx * n..(idx + 1) * n]
228    }
229
230    /// Borrow per-row validity for lagged column `idx`.
231    ///
232    /// # Panics
233    ///
234    /// Panics if `idx >= ncols`.
235    #[must_use]
236    pub fn column_valid(&self, idx: usize) -> &ValidityBitmap {
237        &self.validity[idx]
238    }
239
240    /// Keep-mask for a set of lagged column indexes: AND of their validity bitmaps.
241    ///
242    /// # Errors
243    ///
244    /// Empty column list or out-of-range index.
245    pub fn keep_mask_for_columns(&self, cols: &[usize]) -> Result<Vec<bool>, DataError> {
246        if cols.is_empty() {
247            return Err(DataError::InvalidArgument {
248                message: "keep_mask_for_columns needs ≥1 column".into(),
249            });
250        }
251        let mut keep = vec![true; self.n_effective];
252        for &c in cols {
253            if c >= self.ncols() {
254                return Err(DataError::InvalidArgument {
255                    message: format!("keep_mask_for_columns: column {c} out of range"),
256                });
257            }
258            let v = &self.validity[c];
259            for (i, slot) in keep.iter_mut().enumerate() {
260                if *slot && !v.is_valid(i) {
261                    *slot = false;
262                }
263            }
264        }
265        Ok(keep)
266    }
267
268    /// Compact to effective rows where `keep[i]` is true.
269    ///
270    /// Used for regime-masked CI: the full series is materialized first so lag
271    /// alignment is correct, then only windows wholly inside a regime are retained.
272    ///
273    /// # Errors
274    ///
275    /// Length mismatch or empty keep set.
276    pub fn retain_effective(&self, keep: &[bool]) -> Result<Self, DataError> {
277        if keep.len() != self.n_effective {
278            return Err(DataError::InvalidArgument {
279                message: format!(
280                    "retain_effective keep length {} != n_effective {}",
281                    keep.len(),
282                    self.n_effective
283                ),
284            });
285        }
286        let n_new = keep.iter().filter(|&&k| k).count();
287        if n_new == 0 {
288            return Err(DataError::InvalidArgument {
289                message: "retain_effective: no effective rows retained".into(),
290            });
291        }
292        let n_cols = self.ncols();
293        let mut values = vec![0.0; n_cols.saturating_mul(n_new)];
294        let mut validity = Vec::with_capacity(n_cols);
295        for c in 0..n_cols {
296            let src = self.column(c);
297            let dst = &mut values[c * n_new..(c + 1) * n_new];
298            let mut j = 0;
299            for (i, &k) in keep.iter().enumerate() {
300                if k {
301                    dst[j] = src[i];
302                    j += 1;
303                }
304            }
305            validity.push(self.validity[c].compact(keep)?);
306        }
307        Ok(Self {
308            variables: Arc::clone(&self.variables),
309            max_lag: self.max_lag,
310            n_effective: n_new,
311            n_lags: self.n_lags,
312            values,
313            validity,
314        })
315    }
316
317    /// Vertically stack frames that share the same variable list and max lag.
318    ///
319    /// Used for multi-environment pooling without lag windows crossing env boundaries.
320    ///
321    /// # Errors
322    ///
323    /// Empty input, or mismatched variables / `max_lag` across frames.
324    pub fn stack(frames: &[Self]) -> Result<Self, DataError> {
325        let Some(first) = frames.first() else {
326            return Err(DataError::InvalidArgument {
327                message: "LaggedFrame::stack needs ≥1 frame".into(),
328            });
329        };
330        for (i, f) in frames.iter().enumerate().skip(1) {
331            if f.variables.as_ref() != first.variables.as_ref() {
332                return Err(DataError::InvalidArgument {
333                    message: format!("LaggedFrame::stack: variables mismatch at frame {i}"),
334                });
335            }
336            if f.max_lag != first.max_lag || f.n_lags != first.n_lags {
337                return Err(DataError::InvalidArgument {
338                    message: format!("LaggedFrame::stack: max_lag mismatch at frame {i}"),
339                });
340            }
341        }
342        let n_eff: usize = frames.iter().map(Self::n_effective).sum();
343        if n_eff == 0 {
344            return Err(DataError::InvalidArgument {
345                message: "LaggedFrame::stack: zero effective rows".into(),
346            });
347        }
348        let n_cols = first.ncols();
349        let mut values = vec![0.0; n_cols.saturating_mul(n_eff)];
350        let mut validity = Vec::with_capacity(n_cols);
351        for c in 0..n_cols {
352            let mut offset = 0usize;
353            for f in frames {
354                let src = f.column(c);
355                let dst = &mut values[c * n_eff + offset..c * n_eff + offset + f.n_effective];
356                dst.copy_from_slice(src);
357                offset += f.n_effective;
358            }
359            let parts: Vec<&ValidityBitmap> = frames.iter().map(|f| &f.validity[c]).collect();
360            validity.push(ValidityBitmap::concat(&parts)?);
361        }
362        Ok(Self {
363            variables: Arc::clone(&first.variables),
364            max_lag: first.max_lag,
365            n_effective: n_eff,
366            n_lags: first.n_lags,
367            values,
368            validity,
369        })
370    }
371
372    /// Append variables whose values are constant across lags (space/time dummies).
373    ///
374    /// Each entry is `(variable_id, contemporaneous column)` of length `n_effective`.
375    /// The same values are copied into every lag slot so MCI can index any lag;
376    /// link assumptions should still forbid lagged parents of space/time dummies.
377    /// Appended columns are marked all-valid.
378    ///
379    /// # Errors
380    ///
381    /// Length mismatch, duplicate variable id, or empty column list.
382    pub fn append_constant_lag_columns(
383        &self,
384        columns: &[(VariableId, Vec<f64>)],
385    ) -> Result<Self, DataError> {
386        if columns.is_empty() {
387            return Ok(self.clone());
388        }
389        let mut vars = self.variables.to_vec();
390        for (id, col) in columns {
391            if col.len() != self.n_effective {
392                return Err(DataError::InvalidArgument {
393                    message: format!(
394                        "append_constant_lag_columns: column len {} != n_effective {}",
395                        col.len(),
396                        self.n_effective
397                    ),
398                });
399            }
400            if vars.contains(id) {
401                return Err(DataError::InvalidArgument {
402                    message: format!("append_constant_lag_columns: duplicate variable {id}"),
403                });
404            }
405            vars.push(*id);
406        }
407        let n_eff = self.n_effective;
408        let n_lags = self.n_lags;
409        let old_cols = self.ncols();
410        let new_slots = columns.len();
411        let n_cols = old_cols + new_slots * n_lags;
412        let mut values = vec![0.0; n_cols.saturating_mul(n_eff)];
413        values[..old_cols * n_eff].copy_from_slice(&self.values);
414        let mut validity = self.validity.clone();
415        for (s, (_id, col)) in columns.iter().enumerate() {
416            for lag in 0..n_lags {
417                let c = old_cols + s * n_lags + lag;
418                values[c * n_eff..(c + 1) * n_eff].copy_from_slice(col);
419                validity.push(ValidityBitmap::all_valid(n_eff));
420            }
421        }
422        Ok(Self {
423            variables: Arc::from(vars),
424            max_lag: self.max_lag,
425            n_effective: n_eff,
426            n_lags,
427            values,
428            validity,
429        })
430    }
431}
432
433fn gather_column_validity(
434    src: &crate::column::Float64Column,
435    analysis: Option<&ValidityBitmap>,
436    mask_policy: MaskPolicy,
437    rows: &[usize],
438) -> Result<ValidityBitmap, DataError> {
439    let col_valid = src.validity.gather_rows(rows)?;
440    match (mask_policy, analysis) {
441        (MaskPolicy::Ignore, _) | (MaskPolicy::Honor, None) => Ok(col_valid),
442        (MaskPolicy::Honor, Some(mask)) => {
443            let mask_valid = mask.gather_rows(rows)?;
444            let n = rows.len();
445            let mut bytes = vec![0u8; n.div_ceil(8)];
446            for i in 0..n {
447                if col_valid.is_valid(i) && mask_valid.is_valid(i) {
448                    bytes[i / 8] |= 1 << (i % 8);
449                }
450            }
451            ValidityBitmap::from_bytes(bytes, n)
452        }
453    }
454}
455
456#[cfg(test)]
457#[allow(clippy::cast_precision_loss, clippy::many_single_char_names)]
458mod tests {
459    use antecedent_core::{Lag, VariableId};
460
461    use super::*;
462    use crate::sample_policy::{MaskPolicy, MissingPolicy};
463    use crate::testing::{float_series, float_series_with_gap, float_series_with_mask};
464
465    #[test]
466    fn builds_with_missing_values_marking_invalid() {
467        let data = float_series_with_gap(20, 2, 5);
468        let vars = [VariableId::from_raw(0), VariableId::from_raw(1)];
469        let frame = LaggedFrame::from_series(
470            &data,
471            &vars,
472            2,
473            &antecedent_core::KernelPolicy::default_policy(),
474        )
475        .unwrap();
476        assert_eq!(frame.n_effective(), 18);
477        // Row index 5 in the series is invalid for v0; lag-0 effective index = 5 - 2 = 3.
478        let i0 = frame.column_index(vars[0], Lag::CONTEMPORANEOUS).unwrap();
479        assert!(!frame.column_valid(i0).is_valid(3));
480        assert!(frame.column_valid(i0).is_valid(0));
481        let i1 = frame.column_index(vars[1], Lag::CONTEMPORANEOUS).unwrap();
482        assert!(frame.column_valid(i1).is_all_valid());
483    }
484
485    #[test]
486    fn builds_with_analysis_mask_marking_invalid() {
487        let data = float_series_with_mask(20, 2, 5);
488        let vars = [VariableId::from_raw(0), VariableId::from_raw(1)];
489        let frame = LaggedFrame::from_series(
490            &data,
491            &vars,
492            2,
493            &antecedent_core::KernelPolicy::default_policy(),
494        )
495        .unwrap();
496        let i0 = frame.column_index(vars[0], Lag::CONTEMPORANEOUS).unwrap();
497        assert!(!frame.column_valid(i0).is_valid(3));
498        let keep = frame.keep_mask_for_columns(&[i0]).unwrap();
499        assert!(!keep[3]);
500        assert!(keep[0]);
501    }
502
503    #[test]
504    fn ignore_mask_keeps_analysis_hidden_rows_valid() {
505        let data = float_series_with_mask(20, 2, 5);
506        let vars = [VariableId::from_raw(0), VariableId::from_raw(1)];
507        let frame = LaggedFrame::from_series_with_options(
508            &data,
509            &vars,
510            2,
511            ReferencePointPolicy::SeriesOrigin,
512            LaggedFrameOptions { mask: MaskPolicy::Ignore, missing: MissingPolicy::CompleteCase },
513            &KernelPolicy::default_policy(),
514        )
515        .unwrap();
516        assert!(frame.is_fully_valid());
517    }
518
519    #[test]
520    fn error_on_missing_rejects_gaps() {
521        let data = float_series_with_gap(20, 2, 5);
522        let vars = [VariableId::from_raw(0), VariableId::from_raw(1)];
523        let err = LaggedFrame::from_series_with_options(
524            &data,
525            &vars,
526            2,
527            ReferencePointPolicy::SeriesOrigin,
528            LaggedFrameOptions { mask: MaskPolicy::Honor, missing: MissingPolicy::ErrorOnMissing },
529            &KernelPolicy::default_policy(),
530        )
531        .unwrap_err();
532        assert!(matches!(
533            err,
534            DataError::IncompleteSeries { id: Some(v), .. } if v == VariableId::from_raw(0)
535        ));
536    }
537
538    #[test]
539    fn frame_matches_lag_map_gather() {
540        let data = float_series(20, 2);
541        let vars = [VariableId::from_raw(0), VariableId::from_raw(1)];
542        let frame = LaggedFrame::from_series(
543            &data,
544            &vars,
545            2,
546            &antecedent_core::KernelPolicy::default_policy(),
547        )
548        .unwrap();
549        assert_eq!(frame.n_effective(), 18);
550        assert_eq!(frame.ncols(), 6);
551        assert!(frame.is_fully_valid());
552        let i = frame.column_index(vars[0], Lag::CONTEMPORANEOUS).unwrap();
553        assert!((frame.column(i)[0] - 2.0).abs() < 1e-12);
554        let j = frame.column_index(vars[1], Lag::from_raw(1)).unwrap();
555        assert!((frame.column(j)[0] - 101.0).abs() < 1e-12);
556    }
557
558    #[test]
559    fn retain_effective_compacts_validity() {
560        let data = float_series_with_mask(20, 2, 5);
561        let vars = [VariableId::from_raw(0), VariableId::from_raw(1)];
562        let frame = LaggedFrame::from_series(
563            &data,
564            &vars,
565            2,
566            &antecedent_core::KernelPolicy::default_policy(),
567        )
568        .unwrap();
569        let cols: Vec<usize> = (0..frame.ncols()).collect();
570        let keep = frame.keep_mask_for_columns(&cols).unwrap();
571        let compacted = frame.retain_effective(&keep).unwrap();
572        assert!(compacted.is_fully_valid());
573        assert_eq!(compacted.n_effective(), keep.iter().filter(|&&k| k).count());
574    }
575}