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