Skip to main content

antecedent_data/
sample_request.rs

1//! General sample request → plan → prepare path.
2//!
3//! Temporal lag-only gathers remain in [`crate::sample`] as
4//! [`LaggedSamplePlan`](crate::LaggedSamplePlan).
5//!
6//! SPDX-License-Identifier: MIT OR Apache-2.0
7
8#![allow(clippy::cast_possible_truncation)]
9
10use std::sync::Arc;
11
12use antecedent_core::{KernelPolicy, Lag, NodeRef, VariableId};
13use antecedent_kernels::{F64MatrixView, F64VectorView, gather};
14
15use crate::aligned_buffer::AlignedBuffer;
16use crate::column::ColumnView;
17use crate::dataset::{TabularData, TimeSeriesData};
18use crate::error::DataError;
19use crate::reference::ReferencePointPolicy;
20use crate::sample::{DropSummary, LagMap};
21use crate::sample_policy::{MaskPolicy, MissingPolicy, WeightPolicy};
22use crate::table::TableView;
23use crate::temporal::SamplingRegularity;
24
25/// Alias for the design-matrix view returned by [`PreparedSample`].
26pub type MatrixRef<'a> = F64MatrixView<'a>;
27
28/// Borrowed row-index selection.
29#[derive(Clone, Copy, Debug)]
30pub struct RowSelectionRef<'a> {
31    /// Selected raw row indexes (`u32`).
32    pub indexes: &'a [u32],
33}
34
35/// Column-role partitions within a prepared sample matrix.
36#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
37pub struct SamplePartitions {
38    /// Number of X columns (treatment / predictors of interest).
39    pub n_x: usize,
40    /// Number of Y columns (outcomes).
41    pub n_y: usize,
42    /// Number of Z columns (conditioning / covariates).
43    pub n_z: usize,
44}
45
46impl SamplePartitions {
47    /// Total planned columns.
48    #[must_use]
49    pub const fn ncols(self) -> usize {
50        self.n_x + self.n_y + self.n_z
51    }
52}
53
54/// How prepared values are laid out in the workspace buffer.
55#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default)]
56pub enum SampleLayout {
57    /// Column-major blocks of length `effective_n` (default).
58    #[default]
59    ColumnMajor,
60}
61
62/// One resolved column in a [`SamplePlan`].
63#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
64pub struct PreparedColumn {
65    /// Source variable.
66    pub variable: VariableId,
67    /// Lag to apply (`0` / contemporaneous for static tabular).
68    pub lag: Lag,
69    /// Role partition index: 0 = X, 1 = Y, 2 = Z.
70    pub role: u8,
71}
72
73/// Row-selection recipe baked into a plan (applied at prepare time).
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct PreparedRowSelector {
76    /// Candidate base rows before missingness filtering (empty = all in-range).
77    pub candidate_rows: Arc<[u32]>,
78    /// Missingness policy.
79    pub missing: MissingPolicy,
80    /// Analysis-mask policy.
81    pub mask: MaskPolicy,
82}
83
84/// Cache key for reusable plans (same request + data/policy version → same rows).
85#[derive(Clone, Debug, Eq, PartialEq, Hash)]
86pub struct SampleCacheKey {
87    /// Ordered node fingerprints: (variable raw, lag raw, role).
88    pub nodes: Arc<[(u32, u32, u8)]>,
89    /// Reference policy discriminant + origin.
90    pub reference: (u8, u64),
91    /// Missing / mask / weight policy discriminants.
92    pub policies: (u8, u8, u8),
93    /// Row count / series length.
94    pub data_len: usize,
95    /// Time-index version proxy (0 for tabular; series length for temporal).
96    pub time_index_version: u64,
97}
98
99/// Request describing which nodes and policies to materialize.
100#[derive(Clone, Copy, Debug)]
101pub struct SampleRequest<'a> {
102    /// Predictors / treatments.
103    pub x: &'a [NodeRef],
104    /// Outcomes.
105    pub y: &'a [NodeRef],
106    /// Conditioning set.
107    pub z: &'a [NodeRef],
108    /// Temporal reference-point policy.
109    pub reference: ReferencePointPolicy,
110    /// Missing-value policy.
111    pub missing: MissingPolicy,
112    /// Analysis-mask policy.
113    pub mask: MaskPolicy,
114    /// Weight policy.
115    pub weights: WeightPolicy,
116}
117
118impl<'a> SampleRequest<'a> {
119    /// Construct a request with default policies (complete-case, honor mask/weights,
120    /// series-origin reference).
121    #[must_use]
122    pub const fn new(x: &'a [NodeRef], y: &'a [NodeRef], z: &'a [NodeRef]) -> Self {
123        Self {
124            x,
125            y,
126            z,
127            reference: ReferencePointPolicy::SeriesOrigin,
128            missing: MissingPolicy::CompleteCase,
129            mask: MaskPolicy::Honor,
130            weights: WeightPolicy::Honor,
131        }
132    }
133}
134
135/// Reusable compiled sample plan.
136#[derive(Clone, Debug)]
137pub struct SamplePlan {
138    /// Prepared column descriptors in matrix order (X then Y then Z).
139    pub columns: Vec<PreparedColumn>,
140    /// Row selector recipe.
141    pub row_selector: PreparedRowSelector,
142    /// Output layout.
143    pub output_layout: SampleLayout,
144    /// Cache key.
145    pub cache_key: SampleCacheKey,
146    /// Partitions.
147    pub partitions: SamplePartitions,
148    /// Shared lag map when any column is lagged; `None` for pure tabular.
149    lag_map: Option<Arc<LagMap>>,
150    /// Weight policy (applied at prepare).
151    weight_policy: WeightPolicy,
152}
153
154/// Caller-owned scratch for [`SamplePlan::prepare`].
155#[derive(Clone, Debug, Default)]
156pub struct SampleWorkspace {
157    /// Selected raw row indexes (`u32`).
158    pub row_indexes: Vec<u32>,
159    /// Column-major values.
160    pub values: AlignedBuffer<f64>,
161    /// Packed validity words for selected rows (optional scratch).
162    pub validity_words: Vec<u64>,
163    /// Extra scratch.
164    pub scratch: AlignedBuffer<f64>,
165    /// Gather index scratch (`usize` for kernel gather).
166    gather_indexes: Vec<usize>,
167    /// Optional weights for selected rows.
168    weights: Vec<f64>,
169}
170
171impl SampleWorkspace {
172    /// Ensure capacity for `n` selected rows and `ncols` columns.
173    pub fn prepare(&mut self, n: usize, ncols: usize) {
174        if self.row_indexes.len() < n {
175            self.row_indexes.resize(n, 0);
176        }
177        self.values.resize(n.saturating_mul(ncols));
178        self.gather_indexes.resize(n, 0);
179        let words = n.div_ceil(64);
180        if self.validity_words.len() < words {
181            self.validity_words.resize(words, 0);
182        }
183        if self.weights.len() < n {
184            self.weights.resize(n, 1.0);
185        }
186    }
187}
188
189/// Borrowed prepared sample.
190#[derive(Clone, Copy, Debug)]
191pub struct PreparedSample<'a> {
192    /// Column-major design matrix.
193    pub matrix: MatrixRef<'a>,
194    /// X/Y/Z partitions.
195    pub partitions: SamplePartitions,
196    /// Selected raw rows.
197    pub selected_rows: RowSelectionRef<'a>,
198    /// Effective sample size.
199    pub effective_n: usize,
200    /// Drop summary.
201    pub dropped: DropSummary,
202    /// Per-row weights when policy requests them.
203    pub weights: Option<&'a [f64]>,
204}
205
206impl SamplePlan {
207    /// Compile a plan against tabular (IID) data. Lagged nodes are rejected.
208    ///
209    /// # Errors
210    ///
211    /// Empty request, lagged nodes, unknown variables, or empty selection.
212    pub fn compile_tabular(
213        data: &TabularData,
214        request: &SampleRequest<'_>,
215    ) -> Result<Self, DataError> {
216        compile_inner(data, None, request, data.row_count(), 0)
217    }
218
219    /// Compile a plan against a regular time series (supports lagged nodes).
220    ///
221    /// # Errors
222    ///
223    /// Irregular series, empty request, unknown variables, or invalid lags.
224    pub fn compile_timeseries(
225        data: &TimeSeriesData,
226        request: &SampleRequest<'_>,
227    ) -> Result<Self, DataError> {
228        if matches!(data.time_index().regularity, SamplingRegularity::Irregular) {
229            return Err(DataError::InvalidArgument {
230                message: "integer-lag SampleRequest requires regular time series".into(),
231            });
232        }
233        let max_lag = max_lag_in_request(request);
234        let lag_map = if max_lag > 0 || has_lagged(request) {
235            Some(Arc::new(LagMap::with_reference(data.row_count(), max_lag, request.reference)?))
236        } else {
237            None
238        };
239        compile_inner(data, lag_map, request, data.row_count(), data.row_count() as u64)
240    }
241
242    /// Planned columns.
243    #[must_use]
244    pub fn columns(&self) -> &[PreparedColumn] {
245        &self.columns
246    }
247
248    /// Cache key.
249    #[must_use]
250    pub fn cache_key(&self) -> &SampleCacheKey {
251        &self.cache_key
252    }
253
254    /// Materialize into `workspace`.
255    ///
256    /// # Errors
257    ///
258    /// Missing columns, empty selection after policies, or type mismatches.
259    pub fn prepare<'a>(
260        &'a self,
261        data: &impl TableView,
262        storage_mask: Option<&crate::column::ValidityBitmap>,
263        storage_weights: Option<&[f64]>,
264        workspace: &'a mut SampleWorkspace,
265        policy: &KernelPolicy,
266    ) -> Result<PreparedSample<'a>, DataError> {
267        let ncols = self.partitions.ncols();
268        if ncols == 0 {
269            return Err(DataError::InvalidArgument {
270                message: "sample plan has no columns".into(),
271            });
272        }
273
274        let candidates = resolve_candidates(self)?;
275        let mut selected = Vec::with_capacity(candidates.len());
276        for &row in &candidates {
277            let row_usize = row as usize;
278            if !row_passes_mask(storage_mask, self.row_selector.mask, row_usize) {
279                continue;
280            }
281            match row_missing_status(data, &self.columns, self.lag_map.as_deref(), row)? {
282                RowStatus::Ok => selected.push(row),
283                RowStatus::Missing => match self.row_selector.missing {
284                    MissingPolicy::CompleteCase => {}
285                    MissingPolicy::ErrorOnMissing => {
286                        return Err(DataError::IncompleteSeries {
287                            id: None,
288                            message: "missing values under ErrorOnMissing policy",
289                        });
290                    }
291                },
292            }
293        }
294        if selected.is_empty() {
295            return Err(DataError::EmptySelection {
296                context: "sample prepare after mask/missingness",
297            });
298        }
299
300        let n = selected.len();
301        let requested = candidates.len();
302        workspace.prepare(n, ncols);
303        workspace.row_indexes[..n].copy_from_slice(&selected);
304
305        for (c, col) in self.columns.iter().enumerate() {
306            let ColumnView::Float64(src) = data.column(col.variable)? else {
307                return Err(DataError::TypeMismatch { id: col.variable, expected: "float64" });
308            };
309            // Complete-case / ErrorOnMissing already filtered `selected` via
310            // `row_missing_status` on resolved raw rows (including lagged).
311            for (i, &row) in selected.iter().enumerate() {
312                let raw = resolve_raw_row(self.lag_map.as_deref(), col.lag, row)?;
313                workspace.gather_indexes[i] = raw;
314            }
315            let dst = workspace.values.prepare_mut(n * ncols);
316            let col_dst = &mut dst[c * n..(c + 1) * n];
317            gather(
318                policy,
319                F64VectorView::contiguous(src.values.as_ref()),
320                &workspace.gather_indexes[..n],
321                col_dst,
322            );
323        }
324
325        let weights = match self.weight_policy {
326            WeightPolicy::Ignore => None,
327            WeightPolicy::Unit => {
328                workspace.weights[..n].fill(1.0);
329                Some(&workspace.weights[..n])
330            }
331            WeightPolicy::Honor => {
332                if let Some(w) = storage_weights {
333                    for (i, &row) in selected.iter().enumerate() {
334                        workspace.weights[i] = w[row as usize];
335                    }
336                } else {
337                    workspace.weights[..n].fill(1.0);
338                }
339                Some(&workspace.weights[..n])
340            }
341        };
342
343        let values = workspace.values.as_slice(n * ncols);
344        let matrix = F64MatrixView::column_major(values, n, ncols).map_err(|_| {
345            DataError::InvalidArgument { message: "prepared matrix shape rejected".into() }
346        })?;
347
348        Ok(PreparedSample {
349            matrix,
350            partitions: self.partitions,
351            selected_rows: RowSelectionRef { indexes: &workspace.row_indexes[..n] },
352            effective_n: n,
353            dropped: DropSummary { requested, retained: n },
354            weights,
355        })
356    }
357
358    /// Prepare from [`TabularData`].
359    ///
360    /// # Errors
361    ///
362    /// Propagates [`Self::prepare`] errors.
363    pub fn prepare_tabular<'a>(
364        &'a self,
365        data: &TabularData,
366        workspace: &'a mut SampleWorkspace,
367        policy: &KernelPolicy,
368    ) -> Result<PreparedSample<'a>, DataError> {
369        self.prepare(
370            data,
371            data.storage().analysis_mask(),
372            data.storage().weights(),
373            workspace,
374            policy,
375        )
376    }
377
378    /// Prepare from [`TimeSeriesData`].
379    ///
380    /// # Errors
381    ///
382    /// Propagates [`Self::prepare`] errors.
383    pub fn prepare_timeseries<'a>(
384        &'a self,
385        data: &TimeSeriesData,
386        workspace: &'a mut SampleWorkspace,
387        policy: &KernelPolicy,
388    ) -> Result<PreparedSample<'a>, DataError> {
389        self.prepare(
390            data,
391            data.storage().analysis_mask(),
392            data.storage().weights(),
393            workspace,
394            policy,
395        )
396    }
397}
398
399#[derive(Clone, Copy)]
400enum RowStatus {
401    Ok,
402    Missing,
403}
404
405fn has_lagged(request: &SampleRequest<'_>) -> bool {
406    request
407        .x
408        .iter()
409        .chain(request.y.iter())
410        .chain(request.z.iter())
411        .any(|n| matches!(n, NodeRef::Lagged { .. }))
412}
413
414fn max_lag_in_request(request: &SampleRequest<'_>) -> u32 {
415    request
416        .x
417        .iter()
418        .chain(request.y.iter())
419        .chain(request.z.iter())
420        .map(|n| match n {
421            NodeRef::Lagged { lag, .. } => lag.raw(),
422            NodeRef::Static(_) | NodeRef::Context { .. } => 0,
423        })
424        .max()
425        .unwrap_or(0)
426}
427
428fn node_to_prepared(node: NodeRef, role: u8) -> Result<PreparedColumn, DataError> {
429    match node {
430        NodeRef::Static(variable) => {
431            Ok(PreparedColumn { variable, lag: Lag::CONTEMPORANEOUS, role })
432        }
433        NodeRef::Lagged { variable, lag } => Ok(PreparedColumn { variable, lag, role }),
434        NodeRef::Context { variable, environment } => {
435            if environment.is_some() {
436                return Err(DataError::InvalidArgument {
437                    message: "SampleRequest Context nodes with environment are not supported yet"
438                        .into(),
439                });
440            }
441            Ok(PreparedColumn { variable, lag: Lag::CONTEMPORANEOUS, role })
442        }
443    }
444}
445
446fn compile_inner(
447    data: &impl TableView,
448    lag_map: Option<Arc<LagMap>>,
449    request: &SampleRequest<'_>,
450    data_len: usize,
451    time_index_version: u64,
452) -> Result<SamplePlan, DataError> {
453    let mut columns = Vec::new();
454    let mut nodes_key = Vec::new();
455    for (role, slice) in [(0u8, request.x), (1, request.y), (2, request.z)] {
456        for &node in slice {
457            if lag_map.is_none() {
458                if let NodeRef::Lagged { .. } = node {
459                    return Err(DataError::InvalidArgument {
460                        message: "lagged nodes require TimeSeriesData".into(),
461                    });
462                }
463            }
464            let prep = node_to_prepared(node, role)?;
465            // Ensure column exists and is float64 at compile time.
466            let ColumnView::Float64(_) = data.column(prep.variable)? else {
467                return Err(DataError::TypeMismatch { id: prep.variable, expected: "float64" });
468            };
469            nodes_key.push((prep.variable.raw(), prep.lag.raw(), role));
470            columns.push(prep);
471        }
472    }
473    if columns.is_empty() {
474        return Err(DataError::InvalidArgument {
475            message: "SampleRequest needs ≥1 node in x, y, or z".into(),
476        });
477    }
478
479    let partitions =
480        SamplePartitions { n_x: request.x.len(), n_y: request.y.len(), n_z: request.z.len() };
481
482    let candidate_rows: Arc<[u32]> = if let Some(map) = lag_map.as_ref() {
483        let n = map.n_effective();
484        let base = map.row_index(Lag::CONTEMPORANEOUS, 0) as u32;
485        Arc::from((0..n).map(|i| base + i as u32).collect::<Vec<_>>())
486    } else {
487        Arc::from((0..data_len as u32).collect::<Vec<_>>())
488    };
489
490    let reference_key = match request.reference {
491        ReferencePointPolicy::SeriesOrigin => (0u8, 0u64),
492        ReferencePointPolicy::AbsoluteOrigin { origin_row } => (1u8, origin_row as u64),
493    };
494    let policies = (
495        match request.missing {
496            MissingPolicy::CompleteCase => 0u8,
497            MissingPolicy::ErrorOnMissing => 1u8,
498        },
499        match request.mask {
500            MaskPolicy::Honor => 0u8,
501            MaskPolicy::Ignore => 1u8,
502        },
503        match request.weights {
504            WeightPolicy::Honor => 0u8,
505            WeightPolicy::Unit => 1u8,
506            WeightPolicy::Ignore => 2u8,
507        },
508    );
509
510    Ok(SamplePlan {
511        columns,
512        row_selector: PreparedRowSelector {
513            candidate_rows,
514            missing: request.missing,
515            mask: request.mask,
516        },
517        output_layout: SampleLayout::ColumnMajor,
518        cache_key: SampleCacheKey {
519            nodes: Arc::from(nodes_key),
520            reference: reference_key,
521            policies,
522            data_len,
523            time_index_version,
524        },
525        partitions,
526        lag_map,
527        weight_policy: request.weights,
528    })
529}
530
531fn resolve_candidates(plan: &SamplePlan) -> Result<Vec<u32>, DataError> {
532    if plan.row_selector.candidate_rows.is_empty() {
533        return Err(DataError::EmptySelection { context: "sample plan candidates" });
534    }
535    Ok(plan.row_selector.candidate_rows.to_vec())
536}
537
538fn row_passes_mask(
539    mask: Option<&crate::column::ValidityBitmap>,
540    policy: MaskPolicy,
541    row: usize,
542) -> bool {
543    match policy {
544        MaskPolicy::Ignore => true,
545        MaskPolicy::Honor => mask.is_none_or(|m| m.is_valid(row)),
546    }
547}
548
549fn resolve_raw_row(lag_map: Option<&LagMap>, lag: Lag, base_row: u32) -> Result<usize, DataError> {
550    let Some(map) = lag_map else {
551        return Ok(base_row as usize);
552    };
553    // base_row is a contemporaneous raw row in the effective window.
554    let base_t = map.row_index(Lag::CONTEMPORANEOUS, 0);
555    if (base_row as usize) < base_t {
556        return Err(DataError::InvalidArgument { message: "sample row before lag base".into() });
557    }
558    let sample_i = (base_row as usize) - base_t;
559    if sample_i >= map.n_effective() {
560        return Err(DataError::InvalidArgument { message: "sample row past effective n".into() });
561    }
562    Ok(map.row_index(lag, sample_i))
563}
564
565fn row_missing_status(
566    data: &impl TableView,
567    columns: &[PreparedColumn],
568    lag_map: Option<&LagMap>,
569    base_row: u32,
570) -> Result<RowStatus, DataError> {
571    for col in columns {
572        let raw = resolve_raw_row(lag_map, col.lag, base_row)?;
573        let view = data.column(col.variable)?;
574        let validity = view.validity();
575        if !validity.is_valid(raw) {
576            return Ok(RowStatus::Missing);
577        }
578    }
579    Ok(RowStatus::Ok)
580}
581
582#[cfg(test)]
583#[allow(clippy::cast_precision_loss)]
584mod tests {
585    use antecedent_core::{Lag, NodeRef, VariableId};
586
587    use super::*;
588    use crate::testing::float_series;
589
590    #[test]
591    fn tabular_complete_case_prepare() {
592        let series = float_series(20, 2);
593        let tabular = TabularData::new(series.storage().clone());
594        let x = [NodeRef::Static(VariableId::from_raw(0))];
595        let y = [NodeRef::Static(VariableId::from_raw(1))];
596        let req = SampleRequest::new(&x, &y, &[]);
597        let plan = SamplePlan::compile_tabular(&tabular, &req).unwrap();
598        let mut ws = SampleWorkspace::default();
599        let prep =
600            plan.prepare_tabular(&tabular, &mut ws, &KernelPolicy::default_policy()).unwrap();
601        assert_eq!(prep.effective_n, 20);
602        assert_eq!(prep.partitions.ncols(), 2);
603        assert_eq!(prep.matrix.nrows(), 20);
604        assert_eq!(prep.matrix.ncols(), 2);
605    }
606
607    #[test]
608    fn timeseries_lagged_request() {
609        let data = float_series(30, 2);
610        let x = [NodeRef::Lagged { variable: VariableId::from_raw(0), lag: Lag::from_raw(2) }];
611        let y = [NodeRef::Lagged { variable: VariableId::from_raw(1), lag: Lag::CONTEMPORANEOUS }];
612        let req = SampleRequest::new(&x, &y, &[]);
613        let plan = SamplePlan::compile_timeseries(&data, &req).unwrap();
614        let mut ws = SampleWorkspace::default();
615        let prep =
616            plan.prepare_timeseries(&data, &mut ws, &KernelPolicy::default_policy()).unwrap();
617        assert_eq!(prep.effective_n, 28);
618        assert!((prep.matrix.get(0, 0).unwrap() - 0.0).abs() < 1e-12);
619        assert!((prep.matrix.get(0, 1).unwrap() - 102.0).abs() < 1e-12);
620    }
621
622    #[test]
623    fn same_request_same_cache_key() {
624        let data = float_series(10, 1);
625        let x = [NodeRef::Static(VariableId::from_raw(0))];
626        let req = SampleRequest::new(&x, &[], &[]);
627        let a = SamplePlan::compile_timeseries(&data, &req).unwrap();
628        let b = SamplePlan::compile_timeseries(&data, &req).unwrap();
629        assert_eq!(a.cache_key, b.cache_key);
630    }
631
632    #[test]
633    fn lagged_rejected_on_tabular() {
634        let series = float_series(10, 1);
635        let tabular = TabularData::new(series.storage().clone());
636        let x = [NodeRef::Lagged { variable: VariableId::from_raw(0), lag: Lag::from_raw(1) }];
637        let req = SampleRequest::new(&x, &[], &[]);
638        assert!(SamplePlan::compile_tabular(&tabular, &req).is_err());
639    }
640}