Skip to main content

antecedent_data/
sample.rs

1//! Lag-aligned sample planning for temporal discovery.
2//!
3//! General x/y/z [`SampleRequest`](crate::SampleRequest) planning lives in
4//! [`sample_request`](crate::sample_request). This module owns the lag-gather
5//! hot path ([`LaggedSamplePlan`]).
6//!
7//! SPDX-License-Identifier: MIT OR Apache-2.0
8
9#![allow(clippy::cast_possible_truncation)]
10
11use std::sync::Arc;
12
13use antecedent_core::{KernelPolicy, Lag, VariableId};
14use antecedent_kernels::{F64VectorView, gather};
15
16use crate::column::{ColumnView, Float64Column};
17use crate::dataset::TimeSeriesData;
18use crate::error::DataError;
19use crate::reference::ReferencePointPolicy;
20use crate::table::TableView;
21
22/// Lag-alignment cache for a regular series of length `series_len`.
23///
24/// For `max_lag = τ_max` under [`ReferencePointPolicy::SeriesOrigin`], effective
25/// samples are times `t = τ_max .. series_len-1` (`n = series_len - τ_max`).
26/// Sample `i` at lag `τ` reads raw row `base_t + i - τ`.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct LagMap {
29    series_len: usize,
30    max_lag: u32,
31    n_effective: usize,
32    base_t: usize,
33    reference: ReferencePointPolicy,
34}
35
36impl LagMap {
37    /// Build a lag map with the default series-origin reference policy.
38    ///
39    /// # Errors
40    ///
41    /// Empty series, or `max_lag >= series_len`.
42    pub fn new(series_len: usize, max_lag: u32) -> Result<Self, DataError> {
43        Self::with_reference(series_len, max_lag, ReferencePointPolicy::SeriesOrigin)
44    }
45
46    /// Build a lag map under an explicit reference-point policy.
47    ///
48    /// # Errors
49    ///
50    /// Empty series, invalid lag, or origin out of range.
51    pub fn with_reference(
52        series_len: usize,
53        max_lag: u32,
54        reference: ReferencePointPolicy,
55    ) -> Result<Self, DataError> {
56        let (base_t, n_effective) = reference.base_and_n(series_len, max_lag)?;
57        Ok(Self { series_len, max_lag, n_effective, base_t, reference })
58    }
59
60    /// Series length.
61    #[must_use]
62    pub const fn series_len(&self) -> usize {
63        self.series_len
64    }
65
66    /// Configured maximum lag.
67    #[must_use]
68    pub const fn max_lag(&self) -> u32 {
69        self.max_lag
70    }
71
72    /// Number of aligned samples.
73    #[must_use]
74    pub const fn n_effective(&self) -> usize {
75        self.n_effective
76    }
77
78    /// Reference-point policy used to build this map.
79    #[must_use]
80    pub const fn reference(&self) -> ReferencePointPolicy {
81        self.reference
82    }
83
84    /// Raw row index for sample `i` at the given lag.
85    ///
86    /// # Panics
87    ///
88    /// Panics if `i >= n_effective` or `lag.raw() > max_lag`.
89    #[must_use]
90    pub fn row_index(&self, lag: Lag, sample_i: usize) -> usize {
91        debug_assert!(sample_i < self.n_effective);
92        debug_assert!(lag.raw() <= self.max_lag);
93        self.base_t + sample_i - lag.raw() as usize
94    }
95
96    /// Fill `out` with raw row indexes for `lag` (`out.len()` must equal `n_effective`).
97    ///
98    /// # Errors
99    ///
100    /// Length mismatch or lag exceeding `max_lag`.
101    pub fn fill_row_indexes(&self, lag: Lag, out: &mut [usize]) -> Result<(), DataError> {
102        if out.len() != self.n_effective {
103            return Err(DataError::LengthMismatch {
104                expected: self.n_effective,
105                actual: out.len(),
106                context: "lag-map row index buffer",
107            });
108        }
109        if lag.raw() > self.max_lag {
110            return Err(DataError::InvalidArgument { message: "lag exceeds max_lag".into() });
111        }
112        for (i, slot) in out.iter_mut().enumerate() {
113            *slot = self.row_index(lag, i);
114        }
115        Ok(())
116    }
117}
118
119/// Reject datasets whose analysis mask hides rows: lag gathers index raw rows,
120/// so temporal discovery requires the full contiguous series.
121pub(crate) fn ensure_unmasked(data: &TimeSeriesData) -> Result<(), DataError> {
122    if let Some(mask) = data.storage().analysis_mask() {
123        if !mask.is_all_valid() {
124            return Err(DataError::IncompleteSeries {
125                id: None,
126                message: "analysis mask hides rows; temporal discovery requires complete series",
127            });
128        }
129    }
130    Ok(())
131}
132
133/// Reject float columns with missing values before a lag gather (values under
134/// null slots are sentinels and must never be consumed).
135pub(crate) fn ensure_complete_float(src: &Float64Column) -> Result<(), DataError> {
136    if !src.validity.is_all_valid() {
137        return Err(DataError::IncompleteSeries {
138            id: Some(src.id),
139            message: "missing values in series; temporal discovery requires complete series",
140        });
141    }
142    Ok(())
143}
144
145/// One planned lagged column.
146#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
147pub struct LaggedColumn {
148    /// Variable.
149    pub variable: VariableId,
150    /// Lag relative to the contemporaneous sample time.
151    pub lag: Lag,
152}
153
154/// Reusable plan for gathering a fixed set of lagged columns.
155#[derive(Clone, Debug)]
156pub struct LaggedSamplePlan {
157    columns: Arc<[LaggedColumn]>,
158    lag_map: Arc<LagMap>,
159}
160
161impl LaggedSamplePlan {
162    /// Plan lagged columns for a series length / max lag (series-origin reference).
163    ///
164    /// # Errors
165    ///
166    /// Invalid lag map, empty column list, or a column lag exceeding `max_lag`.
167    pub fn new(
168        series_len: usize,
169        max_lag: u32,
170        columns: impl Into<Arc<[LaggedColumn]>>,
171    ) -> Result<Self, DataError> {
172        Self::with_reference(series_len, max_lag, ReferencePointPolicy::SeriesOrigin, columns)
173    }
174
175    /// Plan lagged columns under an explicit reference-point policy.
176    ///
177    /// # Errors
178    ///
179    /// Invalid lag map, empty column list, or a column lag exceeding `max_lag`.
180    pub fn with_reference(
181        series_len: usize,
182        max_lag: u32,
183        reference: ReferencePointPolicy,
184        columns: impl Into<Arc<[LaggedColumn]>>,
185    ) -> Result<Self, DataError> {
186        let columns = columns.into();
187        if columns.is_empty() {
188            return Err(DataError::InvalidArgument {
189                message: "sample plan needs ≥1 column".into(),
190            });
191        }
192        let lag_map = Arc::new(LagMap::with_reference(series_len, max_lag, reference)?);
193        Self::validate_columns(lag_map.max_lag(), &columns)?;
194        Ok(Self { columns, lag_map })
195    }
196
197    /// Build a plan that reuses a shared lag map and column list (multi-env / panel).
198    ///
199    /// # Errors
200    ///
201    /// Empty column list, or a column lag exceeding the shared map's `max_lag`.
202    pub fn with_shared(
203        lag_map: Arc<LagMap>,
204        columns: Arc<[LaggedColumn]>,
205    ) -> Result<Self, DataError> {
206        if columns.is_empty() {
207            return Err(DataError::InvalidArgument {
208                message: "sample plan needs ≥1 column".into(),
209            });
210        }
211        Self::validate_columns(lag_map.max_lag(), &columns)?;
212        Ok(Self { columns, lag_map })
213    }
214
215    fn validate_columns(max_lag: u32, columns: &[LaggedColumn]) -> Result<(), DataError> {
216        for c in columns {
217            if c.lag.raw() > max_lag {
218                return Err(DataError::InvalidArgument {
219                    message: "planned column lag exceeds max_lag".into(),
220                });
221            }
222        }
223        Ok(())
224    }
225
226    /// Planned columns.
227    #[must_use]
228    pub fn columns(&self) -> &[LaggedColumn] {
229        &self.columns
230    }
231
232    /// Shared column Arc (for multi-env plan reuse).
233    #[must_use]
234    pub fn columns_arc(&self) -> &Arc<[LaggedColumn]> {
235        &self.columns
236    }
237
238    /// Lag map.
239    #[must_use]
240    pub fn lag_map(&self) -> &LagMap {
241        &self.lag_map
242    }
243
244    /// Shared lag-map Arc (identical lengths can share one map).
245    #[must_use]
246    pub fn lag_map_arc(&self) -> &Arc<LagMap> {
247        &self.lag_map
248    }
249
250    /// Effective sample size.
251    #[must_use]
252    pub fn n_effective(&self) -> usize {
253        self.lag_map.n_effective
254    }
255
256    /// Gather planned columns into `workspace` (grows once, then reuses capacity).
257    ///
258    /// # Errors
259    ///
260    /// Missing / non-float64 columns, series length mismatch, or incomplete
261    /// series (missing values or a row-hiding analysis mask).
262    pub fn prepare<'a>(
263        &'a self,
264        data: &TimeSeriesData,
265        workspace: &'a mut LaggedSampleWorkspace,
266        policy: &KernelPolicy,
267    ) -> Result<LaggedPreparedSample<'a>, DataError> {
268        if data.row_count() != self.lag_map.series_len {
269            return Err(DataError::LengthMismatch {
270                expected: self.lag_map.series_len,
271                actual: data.row_count(),
272                context: "time series length vs sample plan",
273            });
274        }
275        ensure_unmasked(data)?;
276        let n = self.lag_map.n_effective;
277        let ncols = self.columns.len();
278        workspace.prepare(n, ncols);
279
280        for (c, col) in self.columns.iter().enumerate() {
281            let ColumnView::Float64(src) = data.column(col.variable)? else {
282                return Err(DataError::TypeMismatch { id: col.variable, expected: "float64" });
283            };
284            ensure_complete_float(src)?;
285            self.lag_map.fill_row_indexes(col.lag, &mut workspace.row_indexes[..n])?;
286            let dst = &mut workspace.values[c * n..(c + 1) * n];
287            gather(
288                policy,
289                F64VectorView::contiguous(src.values.as_slice()),
290                &workspace.row_indexes[..n],
291                dst,
292            );
293        }
294
295        Ok(LaggedPreparedSample {
296            n,
297            ncols,
298            values: &workspace.values[..n * ncols],
299            columns: &self.columns,
300            dropped: DropSummary { requested: self.lag_map.series_len, retained: n },
301        })
302    }
303}
304
305/// Caller-owned scratch for repeated [`LaggedSamplePlan::prepare`] calls.
306#[derive(Clone, Debug, Default)]
307pub struct LaggedSampleWorkspace {
308    /// Reused row-index buffer (length = `n_effective`).
309    pub row_indexes: Vec<usize>,
310    /// Column-major gathered values (`ncols * n`).
311    pub values: Vec<f64>,
312    capacity_n: usize,
313    capacity_cols: usize,
314}
315
316impl LaggedSampleWorkspace {
317    /// Ensure capacity for `n` rows and `ncols` columns (grows, never shrinks).
318    pub fn prepare(&mut self, n: usize, ncols: usize) {
319        if self.row_indexes.len() < n {
320            self.row_indexes.resize(n, 0);
321        }
322        let need = n.saturating_mul(ncols);
323        if self.values.len() < need {
324            self.values.resize(need, 0.0);
325        }
326        self.capacity_n = self.capacity_n.max(n);
327        self.capacity_cols = self.capacity_cols.max(ncols);
328    }
329
330    /// Peak row capacity retained.
331    #[must_use]
332    pub const fn capacity_n(&self) -> usize {
333        self.capacity_n
334    }
335
336    /// Peak column capacity retained.
337    #[must_use]
338    pub const fn capacity_cols(&self) -> usize {
339        self.capacity_cols
340    }
341}
342
343/// Rows dropped vs retained when aligning lags.
344#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
345pub struct DropSummary {
346    /// Raw series length.
347    pub requested: usize,
348    /// Effective aligned samples.
349    pub retained: usize,
350}
351
352/// Borrowed prepared sample (views into a [`LaggedSampleWorkspace`]).
353#[derive(Clone, Copy, Debug)]
354pub struct LaggedPreparedSample<'a> {
355    /// Effective sample size.
356    pub n: usize,
357    /// Number of lagged columns.
358    pub ncols: usize,
359    /// Column-major values (`ncols` blocks of length `n`).
360    pub values: &'a [f64],
361    /// Column descriptors aligned with `values` blocks.
362    pub columns: &'a [LaggedColumn],
363    /// Drop summary.
364    pub dropped: DropSummary,
365}
366
367impl LaggedPreparedSample<'_> {
368    /// Borrow column `c` as a contiguous slice of length `n`.
369    #[must_use]
370    pub fn column(&self, c: usize) -> &[f64] {
371        &self.values[c * self.n..(c + 1) * self.n]
372    }
373}
374
375impl TimeSeriesData {
376    /// Plan a lagged sample for discovery / CI queries.
377    ///
378    /// # Errors
379    ///
380    /// Propagates [`LaggedSamplePlan::new`] errors.
381    pub fn plan_lagged_sample(
382        &self,
383        max_lag: u32,
384        columns: impl Into<Arc<[LaggedColumn]>>,
385    ) -> Result<LaggedSamplePlan, DataError> {
386        LaggedSamplePlan::new(self.row_count(), max_lag, columns)
387    }
388}
389
390#[cfg(test)]
391#[allow(clippy::cast_precision_loss, clippy::many_single_char_names)]
392mod tests {
393    use std::sync::Arc;
394
395    use antecedent_core::{Lag, VariableId};
396
397    use super::*;
398    use crate::testing::{float_series, float_series_with_gap, float_series_with_mask};
399
400    #[test]
401    fn prepare_rejects_missing_values() {
402        let data = float_series_with_gap(20, 1, 3);
403        let cols = Arc::from([LaggedColumn {
404            variable: VariableId::from_raw(0),
405            lag: Lag::CONTEMPORANEOUS,
406        }]);
407        let plan = data.plan_lagged_sample(2, cols).unwrap();
408        let mut ws = LaggedSampleWorkspace::default();
409        let err = plan.prepare(&data, &mut ws, &KernelPolicy::default_policy()).unwrap_err();
410        assert!(matches!(
411            err,
412            DataError::IncompleteSeries { id: Some(v), .. } if v == VariableId::from_raw(0)
413        ));
414    }
415
416    #[test]
417    fn prepare_rejects_row_hiding_analysis_mask() {
418        let data = float_series_with_mask(20, 1, 3);
419        let cols = Arc::from([LaggedColumn {
420            variable: VariableId::from_raw(0),
421            lag: Lag::CONTEMPORANEOUS,
422        }]);
423        let plan = data.plan_lagged_sample(2, cols).unwrap();
424        let mut ws = LaggedSampleWorkspace::default();
425        let err = plan.prepare(&data, &mut ws, &KernelPolicy::default_policy()).unwrap_err();
426        assert!(matches!(err, DataError::IncompleteSeries { id: None, .. }));
427    }
428
429    #[test]
430    fn lag_map_row_indexes() {
431        let map = LagMap::new(10, 2).unwrap();
432        assert_eq!(map.n_effective(), 8);
433        assert_eq!(map.row_index(Lag::CONTEMPORANEOUS, 0), 2);
434        assert_eq!(map.row_index(Lag::from_raw(2), 0), 0);
435        assert_eq!(map.row_index(Lag::from_raw(1), 3), 2 + 3 - 1);
436    }
437
438    #[test]
439    fn prepare_gathers_lagged_values() {
440        let data = float_series(20, 2);
441        let cols = Arc::from([
442            LaggedColumn { variable: VariableId::from_raw(0), lag: Lag::CONTEMPORANEOUS },
443            LaggedColumn { variable: VariableId::from_raw(0), lag: Lag::from_raw(2) },
444            LaggedColumn { variable: VariableId::from_raw(1), lag: Lag::from_raw(1) },
445        ]);
446        let plan = data.plan_lagged_sample(2, cols).unwrap();
447        let mut ws = LaggedSampleWorkspace::default();
448        let prep = plan.prepare(&data, &mut ws, &KernelPolicy::default_policy()).unwrap();
449        assert_eq!(prep.n, 18);
450        assert!((prep.column(0)[0] - 2.0).abs() < 1e-12);
451        assert!((prep.column(1)[0] - 0.0).abs() < 1e-12);
452        assert!((prep.column(2)[0] - 101.0).abs() < 1e-12);
453    }
454
455    #[test]
456    fn repeated_prepare_reuses_workspace_capacity() {
457        let data = float_series(100, 3);
458        let cols = Arc::from([
459            LaggedColumn { variable: VariableId::from_raw(0), lag: Lag::CONTEMPORANEOUS },
460            LaggedColumn { variable: VariableId::from_raw(1), lag: Lag::from_raw(3) },
461            LaggedColumn { variable: VariableId::from_raw(2), lag: Lag::from_raw(1) },
462        ]);
463        let plan = data.plan_lagged_sample(3, cols).unwrap();
464        let mut ws = LaggedSampleWorkspace::default();
465        let _ = plan.prepare(&data, &mut ws, &KernelPolicy::default_policy()).unwrap();
466        let cap_n = ws.capacity_n();
467        let cap_c = ws.capacity_cols();
468        let values_cap = ws.values.capacity();
469        let idx_cap = ws.row_indexes.capacity();
470        for _ in 0..50 {
471            let _ = plan.prepare(&data, &mut ws, &KernelPolicy::default_policy()).unwrap();
472            assert_eq!(ws.capacity_n(), cap_n);
473            assert_eq!(ws.capacity_cols(), cap_c);
474            assert_eq!(ws.values.capacity(), values_cap);
475            assert_eq!(ws.row_indexes.capacity(), idx_cap);
476        }
477    }
478}