1#![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
25pub type MatrixRef<'a> = F64MatrixView<'a>;
27
28#[derive(Clone, Copy, Debug)]
30pub struct RowSelectionRef<'a> {
31 pub indexes: &'a [u32],
33}
34
35#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
37pub struct SamplePartitions {
38 pub n_x: usize,
40 pub n_y: usize,
42 pub n_z: usize,
44}
45
46impl SamplePartitions {
47 #[must_use]
49 pub const fn ncols(self) -> usize {
50 self.n_x + self.n_y + self.n_z
51 }
52}
53
54#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default)]
56pub enum SampleLayout {
57 #[default]
59 ColumnMajor,
60}
61
62#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
64pub struct PreparedColumn {
65 pub variable: VariableId,
67 pub lag: Lag,
69 pub role: u8,
71}
72
73#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct PreparedRowSelector {
76 pub candidate_rows: Arc<[u32]>,
78 pub missing: MissingPolicy,
80 pub mask: MaskPolicy,
82}
83
84#[derive(Clone, Debug, Eq, PartialEq, Hash)]
86pub struct SampleCacheKey {
87 pub nodes: Arc<[(u32, u32, u8)]>,
89 pub reference: (u8, u64),
91 pub policies: (u8, u8, u8),
93 pub data_len: usize,
95 pub time_index_version: u64,
97}
98
99#[derive(Clone, Copy, Debug)]
101pub struct SampleRequest<'a> {
102 pub x: &'a [NodeRef],
104 pub y: &'a [NodeRef],
106 pub z: &'a [NodeRef],
108 pub reference: ReferencePointPolicy,
110 pub missing: MissingPolicy,
112 pub mask: MaskPolicy,
114 pub weights: WeightPolicy,
116}
117
118impl<'a> SampleRequest<'a> {
119 #[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#[derive(Clone, Debug)]
137pub struct SamplePlan {
138 pub columns: Vec<PreparedColumn>,
140 pub row_selector: PreparedRowSelector,
142 pub output_layout: SampleLayout,
144 pub cache_key: SampleCacheKey,
146 pub partitions: SamplePartitions,
148 lag_map: Option<Arc<LagMap>>,
150 weight_policy: WeightPolicy,
152}
153
154#[derive(Clone, Debug, Default)]
156pub struct SampleWorkspace {
157 pub row_indexes: Vec<u32>,
159 pub values: AlignedBuffer<f64>,
161 pub validity_words: Vec<u64>,
163 pub scratch: AlignedBuffer<f64>,
165 gather_indexes: Vec<usize>,
167 weights: Vec<f64>,
169}
170
171impl SampleWorkspace {
172 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#[derive(Clone, Copy, Debug)]
191pub struct PreparedSample<'a> {
192 pub matrix: MatrixRef<'a>,
194 pub partitions: SamplePartitions,
196 pub selected_rows: RowSelectionRef<'a>,
198 pub effective_n: usize,
200 pub dropped: DropSummary,
202 pub weights: Option<&'a [f64]>,
204}
205
206impl SamplePlan {
207 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 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 #[must_use]
244 pub fn columns(&self) -> &[PreparedColumn] {
245 &self.columns
246 }
247
248 #[must_use]
250 pub fn cache_key(&self) -> &SampleCacheKey {
251 &self.cache_key
252 }
253
254 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 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 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 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 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 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}