Skip to main content

antecedent_data/
resample.rs

1//! Temporal bootstrap / resampling index plans.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![allow(
6    clippy::cast_possible_truncation,
7    clippy::cast_precision_loss,
8    clippy::neg_cmp_op_on_partial_ord
9)]
10
11use std::sync::Arc;
12
13use antecedent_core::{CausalRng, ExecutionContext, VariableId};
14use antecedent_kernels::unbiased_index;
15
16use crate::column::{ColumnView, Float64Column, OwnedColumn};
17use crate::dataset::TimeSeriesData;
18use crate::error::DataError;
19use crate::storage::OwnedColumnarStorage;
20use crate::table::TableView;
21
22/// Null / permutation scheme for [`ResamplingPlan::Permutation`].
23#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
24pub enum PermutationScheme {
25    /// Full Fisher–Yates shuffle of row indexes `0..n`.
26    Full,
27    /// Shuffle within each cluster (ids supplied to the fill helper).
28    WithinCluster,
29}
30
31/// Resampling plan producing row-index or weight replicates.
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub enum ResamplingPlan {
34    /// IID with-replacement row bootstrap.
35    IidBootstrap,
36    /// Bayesian bootstrap: Dirichlet(1,…,1) observation weights (Rubin).
37    BayesianBootstrap,
38    /// Moving-block bootstrap (contiguous blocks).
39    MovingBlock {
40        /// Block length in rows.
41        length: usize,
42    },
43    /// Circular block bootstrap (wraps the series).
44    CircularBlock {
45        /// Block length in rows.
46        length: usize,
47    },
48    /// Cluster bootstrap (resample whole clusters; ids via grouped fill).
49    ///
50    /// `cluster` names the variable whose labels are supplied as `cluster_ids`
51    /// to the fill helpers (labels remain caller-resolved).
52    ClusterBootstrap {
53        /// Variable that holds cluster membership labels.
54        cluster: VariableId,
55    },
56    /// Stationary block bootstrap (Politis–Romano geometric lengths).
57    StationaryBlock {
58        /// Expected block length (mean of geometric distribution).
59        expected_length: f64,
60    },
61    /// Permutation (shuffle) index plan.
62    Permutation(PermutationScheme),
63}
64
65impl ResamplingPlan {
66    /// Whether this plan yields observation weights rather than row indexes.
67    #[must_use]
68    pub const fn is_weight_plan(self) -> bool {
69        matches!(self, Self::BayesianBootstrap)
70    }
71
72    /// Whether this plan requires per-row cluster labels.
73    #[must_use]
74    pub const fn needs_clusters(self) -> bool {
75        matches!(
76            self,
77            Self::ClusterBootstrap { .. } | Self::Permutation(PermutationScheme::WithinCluster)
78        )
79    }
80
81    /// Cluster variable recorded on [`ResamplingPlan::ClusterBootstrap`], if any.
82    #[must_use]
83    pub const fn cluster_variable(self) -> Option<VariableId> {
84        match self {
85            Self::ClusterBootstrap { cluster } => Some(cluster),
86            _ => None,
87        }
88    }
89}
90
91/// Fill `out` with a length-`n` row-index plan under `plan`.
92///
93/// Plans that need cluster labels ([`ResamplingPlan::ClusterBootstrap`],
94/// [`PermutationScheme::WithinCluster`]) must use
95/// [`fill_resample_indexes_grouped`] instead.
96///
97/// # Errors
98///
99/// Zero series length, zero block length, weight-only plan, or a clustered plan.
100pub fn fill_resample_indexes(
101    plan: ResamplingPlan,
102    n: usize,
103    rng: &mut CausalRng,
104    out: &mut Vec<u32>,
105) -> Result<(), DataError> {
106    if plan.needs_clusters() {
107        return Err(DataError::InvalidArgument {
108            message: "clustered plan requires fill_resample_indexes_grouped".into(),
109        });
110    }
111    fill_resample_indexes_grouped(plan, n, None, rng, out)
112}
113
114/// Fill `out` with a length-`n` row-index plan, optionally using `cluster_ids`.
115///
116/// # Errors
117///
118/// Shape / plan mismatches as in [`fill_resample_indexes`], plus missing or
119/// wrong-length cluster ids when required.
120///
121/// # Panics
122///
123/// Propagates panic from [`unbiased_index`] if a zero-length draw is requested
124/// after the `n > 0` guard (should be unreachable).
125pub fn fill_resample_indexes_grouped(
126    plan: ResamplingPlan,
127    n: usize,
128    cluster_ids: Option<&[u32]>,
129    rng: &mut CausalRng,
130    out: &mut Vec<u32>,
131) -> Result<(), DataError> {
132    if n == 0 {
133        return Err(DataError::InvalidArgument { message: "resample needs n > 0".into() });
134    }
135    if plan.is_weight_plan() {
136        return Err(DataError::InvalidArgument {
137            message: "BayesianBootstrap yields weights; use fill_resample_weights".into(),
138        });
139    }
140    if plan.needs_clusters() {
141        let Some(ids) = cluster_ids else {
142            return Err(DataError::InvalidArgument {
143                message: "clustered resampling requires cluster_ids".into(),
144            });
145        };
146        if ids.len() != n {
147            return Err(DataError::LengthMismatch {
148                expected: n,
149                actual: ids.len(),
150                context: "cluster_ids",
151            });
152        }
153    }
154    out.clear();
155    out.reserve(n);
156    match plan {
157        ResamplingPlan::IidBootstrap => {
158            for _ in 0..n {
159                out.push(unbiased_index(rng, n) as u32);
160            }
161        }
162        ResamplingPlan::BayesianBootstrap => unreachable!("checked above"),
163        ResamplingPlan::MovingBlock { length } | ResamplingPlan::CircularBlock { length } => {
164            fill_block(n, length, matches!(plan, ResamplingPlan::CircularBlock { .. }), rng, out)?;
165        }
166        ResamplingPlan::StationaryBlock { expected_length } => {
167            fill_stationary(n, expected_length, rng, out)?;
168        }
169        ResamplingPlan::ClusterBootstrap { .. } => {
170            fill_cluster_bootstrap(n, cluster_ids.unwrap(), rng, out)?;
171        }
172        ResamplingPlan::Permutation(PermutationScheme::Full) => {
173            out.extend(0..n as u32);
174            // Fisher–Yates
175            for i in (1..n).rev() {
176                let j = unbiased_index(rng, i + 1);
177                out.swap(i, j);
178            }
179        }
180        ResamplingPlan::Permutation(PermutationScheme::WithinCluster) => {
181            fill_within_cluster_permutation(n, cluster_ids.unwrap(), rng, out);
182        }
183    }
184    Ok(())
185}
186
187fn fill_block(
188    n: usize,
189    length: usize,
190    circular: bool,
191    rng: &mut CausalRng,
192    out: &mut Vec<u32>,
193) -> Result<(), DataError> {
194    if length == 0 {
195        return Err(DataError::InvalidArgument { message: "block length must be > 0".into() });
196    }
197    if length > n {
198        return Err(DataError::InvalidArgument {
199            message: format!("block length {length} exceeds series length {n}"),
200        });
201    }
202    let n_starts = if circular { n } else { n.saturating_sub(length).saturating_add(1) };
203    while out.len() < n {
204        let start = unbiased_index(rng, n_starts);
205        for k in 0..length {
206            if out.len() >= n {
207                break;
208            }
209            let idx = if circular { (start + k) % n } else { start + k };
210            out.push(idx as u32);
211        }
212    }
213    out.truncate(n);
214    Ok(())
215}
216
217fn fill_stationary(
218    n: usize,
219    expected_length: f64,
220    rng: &mut CausalRng,
221    out: &mut Vec<u32>,
222) -> Result<(), DataError> {
223    if !(expected_length.is_finite() && expected_length >= 1.0) {
224        return Err(DataError::InvalidArgument {
225            message: "stationary expected_length must be finite and ≥ 1".into(),
226        });
227    }
228    // Geometric block length with mean L: P(L=k) = p(1-p)^{k-1}, p = 1/L.
229    let p = 1.0 / expected_length;
230    while out.len() < n {
231        let mut len = 1usize;
232        while rng.next_f64() > p {
233            len += 1;
234            if len > n {
235                break;
236            }
237        }
238        len = len.min(n);
239        let start = unbiased_index(rng, n);
240        for k in 0..len {
241            if out.len() >= n {
242                break;
243            }
244            out.push(((start + k) % n) as u32);
245        }
246    }
247    out.truncate(n);
248    Ok(())
249}
250
251fn fill_cluster_bootstrap(
252    n: usize,
253    cluster_ids: &[u32],
254    rng: &mut CausalRng,
255    out: &mut Vec<u32>,
256) -> Result<(), DataError> {
257    const MAX_RESTARTS: usize = 64;
258    // Build cluster → row list.
259    let mut order: Vec<usize> = (0..n).collect();
260    order.sort_by_key(|&i| cluster_ids[i]);
261    let mut clusters: Vec<Vec<u32>> = Vec::new();
262    let mut idx = 0usize;
263    while idx < n {
264        let g = cluster_ids[order[idx]];
265        let mut members = Vec::new();
266        while idx < n && cluster_ids[order[idx]] == g {
267            members.push(order[idx] as u32);
268            idx += 1;
269        }
270        clusters.push(members);
271    }
272    if clusters.is_empty() {
273        return Err(DataError::InvalidArgument { message: "no clusters".into() });
274    }
275    // Resample whole clusters only (never truncate mid-cluster). Reject draws
276    // that would overflow the remaining budget; restart if the residual gap
277    // cannot be filled exactly.
278    for _ in 0..MAX_RESTARTS {
279        out.clear();
280        let mut attempts = 0usize;
281        while out.len() < n {
282            attempts += 1;
283            if attempts > n.saturating_mul(64).max(64) {
284                break;
285            }
286            let c = unbiased_index(rng, clusters.len());
287            let cluster = &clusters[c];
288            if cluster.len() > n {
289                return Err(DataError::InvalidArgument {
290                    message: "cluster bootstrap: a single cluster exceeds sample size n".into(),
291                });
292            }
293            if out.len() + cluster.len() > n {
294                continue;
295            }
296            out.extend_from_slice(cluster);
297        }
298        if out.len() == n {
299            return Ok(());
300        }
301    }
302    Err(DataError::InvalidArgument {
303        message: "cluster bootstrap: could not assemble length-n sample from full clusters".into(),
304    })
305}
306
307fn fill_within_cluster_permutation(
308    n: usize,
309    cluster_ids: &[u32],
310    rng: &mut CausalRng,
311    out: &mut Vec<u32>,
312) {
313    out.extend(0..n as u32);
314    let mut order: Vec<usize> = (0..n).collect();
315    order.sort_by_key(|&i| cluster_ids[i]);
316    let mut idx = 0usize;
317    while idx < n {
318        let g = cluster_ids[order[idx]];
319        let start = idx;
320        while idx < n && cluster_ids[order[idx]] == g {
321            idx += 1;
322        }
323        // Fisher–Yates on out[order[start..idx]] positions — shuffle the member rows
324        // among the slots that belong to this cluster in the original order.
325        let members: Vec<usize> = order[start..idx].to_vec();
326        let mut vals: Vec<u32> = members.iter().map(|&i| i as u32).collect();
327        for i in (1..vals.len()).rev() {
328            let j = unbiased_index(rng, i + 1);
329            vals.swap(i, j);
330        }
331        for (k, &pos) in members.iter().enumerate() {
332            out[pos] = vals[k];
333        }
334    }
335}
336
337/// Fill `out` with length-`n` Bayesian-bootstrap weights (normalized Exp(1) draws).
338///
339/// Weights sum to `n` (mean weight 1) so weighted estimators match unweighted
340/// scale on the original sample size.
341///
342/// # Errors
343///
344/// Zero length, or a non-weight plan.
345pub fn fill_resample_weights(
346    plan: ResamplingPlan,
347    n: usize,
348    rng: &mut CausalRng,
349    out: &mut Vec<f64>,
350) -> Result<(), DataError> {
351    if n == 0 {
352        return Err(DataError::InvalidArgument { message: "resample needs n > 0".into() });
353    }
354    if !matches!(plan, ResamplingPlan::BayesianBootstrap) {
355        return Err(DataError::InvalidArgument {
356            message: "fill_resample_weights requires BayesianBootstrap".into(),
357        });
358    }
359    out.clear();
360    out.reserve(n);
361    let mut sum = 0.0;
362    for _ in 0..n {
363        // Exp(1) via -ln(U); Dirichlet(1..1) = normalized exponentials.
364        let u = rng.next_f64().max(f64::EPSILON);
365        let e = -u.ln();
366        out.push(e);
367        sum += e;
368    }
369    if !(sum > 0.0) {
370        return Err(DataError::InvalidArgument {
371            message: "BayesianBootstrap weight sum non-positive".into(),
372        });
373    }
374    let scale = n as f64 / sum;
375    for w in out.iter_mut() {
376        *w *= scale;
377    }
378    Ok(())
379}
380
381/// Fill `out` (`len = n * n_replicates`, replicate-major) with index plans under one
382/// [`ExecutionContext`].
383///
384/// Each replicate uses `ctx.rng.stream(stream_base ^ replicate_id)` so results are
385/// independent of scheduling order under [`antecedent_core::Determinism::Strict`].
386/// When `max_threads > 1` and `n_replicates ≥ 2`, fills run in a bounded
387/// `std::thread::scope` pool.
388///
389/// # Errors
390///
391/// Shape / plan mismatches as in [`fill_resample_indexes_grouped`], or `out` length mismatch.
392pub fn fill_resample_index_batch(
393    plan: ResamplingPlan,
394    n: usize,
395    n_replicates: usize,
396    cluster_ids: Option<&[u32]>,
397    ctx: &ExecutionContext,
398    stream_base: u64,
399    out: &mut [u32],
400) -> Result<(), DataError> {
401    if n == 0 || n_replicates == 0 {
402        return Err(DataError::InvalidArgument {
403            message: "batch resample needs n > 0 and n_replicates > 0".into(),
404        });
405    }
406    if out.len() != n.saturating_mul(n_replicates) {
407        return Err(DataError::LengthMismatch {
408            expected: n * n_replicates,
409            actual: out.len(),
410            context: "fill_resample_index_batch out",
411        });
412    }
413    if plan.is_weight_plan() {
414        return Err(DataError::InvalidArgument {
415            message: "BayesianBootstrap yields weights; use fill_resample_weight_batch".into(),
416        });
417    }
418    let threads = ctx.parallelism.max_threads.get().max(1) as usize;
419    if threads == 1 || n_replicates < 2 {
420        let mut scratch = Vec::with_capacity(n);
421        for r in 0..n_replicates {
422            let mut rng = ctx.rng.stream(stream_base ^ r as u64);
423            fill_resample_indexes_grouped(plan, n, cluster_ids, &mut rng, &mut scratch)?;
424            out[r * n..(r + 1) * n].copy_from_slice(&scratch);
425        }
426        return Ok(());
427    }
428    let chunk = n_replicates.div_ceil(threads);
429    let cluster_owned: Option<Vec<u32>> = cluster_ids.map(<[u32]>::to_vec);
430    let err = std::sync::Mutex::new(None::<DataError>);
431    std::thread::scope(|scope| {
432        let mut rest = out;
433        let mut start = 0usize;
434        while start < n_replicates {
435            let end = (start + chunk).min(n_replicates);
436            let (this, next) = rest.split_at_mut((end - start) * n);
437            rest = next;
438            let cluster_ref = cluster_owned.as_deref();
439            let err_slot = &err;
440            let rng_factory = &ctx.rng;
441            scope.spawn(move || {
442                let mut scratch = Vec::with_capacity(n);
443                for (local, r) in (start..end).enumerate() {
444                    let mut rng = rng_factory.stream(stream_base ^ r as u64);
445                    if let Err(e) =
446                        fill_resample_indexes_grouped(plan, n, cluster_ref, &mut rng, &mut scratch)
447                    {
448                        let mut guard =
449                            err_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
450                        if guard.is_none() {
451                            *guard = Some(e);
452                        }
453                        return;
454                    }
455                    this[local * n..(local + 1) * n].copy_from_slice(&scratch);
456                }
457            });
458            start = end;
459        }
460    });
461    if let Some(e) = err.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner) {
462        return Err(e);
463    }
464    Ok(())
465}
466
467/// Batch Bayesian-bootstrap weights under one [`ExecutionContext`] (replicate-major).
468///
469/// # Errors
470///
471/// Non-weight plan, zero sizes, or `out` length mismatch.
472pub fn fill_resample_weight_batch(
473    plan: ResamplingPlan,
474    n: usize,
475    n_replicates: usize,
476    ctx: &ExecutionContext,
477    stream_base: u64,
478    out: &mut [f64],
479) -> Result<(), DataError> {
480    if n == 0 || n_replicates == 0 {
481        return Err(DataError::InvalidArgument {
482            message: "batch resample needs n > 0 and n_replicates > 0".into(),
483        });
484    }
485    if out.len() != n.saturating_mul(n_replicates) {
486        return Err(DataError::LengthMismatch {
487            expected: n * n_replicates,
488            actual: out.len(),
489            context: "fill_resample_weight_batch out",
490        });
491    }
492    if !matches!(plan, ResamplingPlan::BayesianBootstrap) {
493        return Err(DataError::InvalidArgument {
494            message: "fill_resample_weight_batch requires BayesianBootstrap".into(),
495        });
496    }
497    let threads = ctx.parallelism.max_threads.get().max(1) as usize;
498    if threads == 1 || n_replicates < 2 {
499        let mut scratch = Vec::with_capacity(n);
500        for r in 0..n_replicates {
501            let mut rng = ctx.rng.stream(stream_base ^ r as u64);
502            fill_resample_weights(plan, n, &mut rng, &mut scratch)?;
503            out[r * n..(r + 1) * n].copy_from_slice(&scratch);
504        }
505        return Ok(());
506    }
507    let chunk = n_replicates.div_ceil(threads);
508    let err = std::sync::Mutex::new(None::<DataError>);
509    std::thread::scope(|scope| {
510        let mut rest = out;
511        let mut start = 0usize;
512        while start < n_replicates {
513            let end = (start + chunk).min(n_replicates);
514            let (this, next) = rest.split_at_mut((end - start) * n);
515            rest = next;
516            let err_slot = &err;
517            let rng_factory = &ctx.rng;
518            scope.spawn(move || {
519                let mut scratch = Vec::with_capacity(n);
520                for (local, r) in (start..end).enumerate() {
521                    let mut rng = rng_factory.stream(stream_base ^ r as u64);
522                    if let Err(e) = fill_resample_weights(plan, n, &mut rng, &mut scratch) {
523                        let mut guard =
524                            err_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
525                        if guard.is_none() {
526                            *guard = Some(e);
527                        }
528                        return;
529                    }
530                    this[local * n..(local + 1) * n].copy_from_slice(&scratch);
531                }
532            });
533            start = end;
534        }
535    });
536    if let Some(e) = err.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner) {
537        return Err(e);
538    }
539    Ok(())
540}
541
542/// Apply a resampling plan to produce a new float64 time series.
543///
544/// Index plans gather rows. [`ResamplingPlan::BayesianBootstrap`] keeps row
545/// order and replaces storage weights with a fresh weight plan (multiplied by
546/// any existing weights). Clustered plans require [`resample_timeseries_grouped`].
547///
548/// # Errors
549///
550/// Non-float columns or construction failures.
551pub fn resample_timeseries(
552    data: &TimeSeriesData,
553    plan: ResamplingPlan,
554    rng: &mut CausalRng,
555    index_scratch: &mut Vec<u32>,
556) -> Result<TimeSeriesData, DataError> {
557    if plan.needs_clusters() {
558        return Err(DataError::InvalidArgument {
559            message: "clustered plan requires resample_timeseries_grouped".into(),
560        });
561    }
562    resample_timeseries_grouped(data, plan, None, rng, index_scratch)
563}
564
565/// Like [`resample_timeseries`], with optional cluster labels for clustered plans.
566///
567/// # Errors
568///
569/// Non-float columns, clustered plan without ids, or construction failures.
570pub fn resample_timeseries_grouped(
571    data: &TimeSeriesData,
572    plan: ResamplingPlan,
573    cluster_ids: Option<&[u32]>,
574    rng: &mut CausalRng,
575    index_scratch: &mut Vec<u32>,
576) -> Result<TimeSeriesData, DataError> {
577    let n = data.row_count();
578    if plan.is_weight_plan() {
579        let mut weights = Vec::new();
580        fill_resample_weights(plan, n, rng, &mut weights)?;
581        return apply_weight_plan(data, &weights);
582    }
583    fill_resample_indexes_grouped(plan, n, cluster_ids, rng, index_scratch)?;
584    apply_row_map(data, index_scratch)
585}
586
587fn apply_weight_plan(data: &TimeSeriesData, weights: &[f64]) -> Result<TimeSeriesData, DataError> {
588    let n = data.row_count();
589    if weights.len() != n {
590        return Err(DataError::LengthMismatch {
591            expected: n,
592            actual: weights.len(),
593            context: "BayesianBootstrap weights",
594        });
595    }
596    let schema = data.schema().clone();
597    let mut cols = Vec::with_capacity(schema.len());
598    for v in schema.variables() {
599        let ColumnView::Float64(src) = data.column(v.id)? else {
600            return Err(DataError::TypeMismatch { id: v.id, expected: "float64" });
601        };
602        cols.push(OwnedColumn::Float64(Float64Column::new(
603            v.id,
604            src.values.clone(),
605            src.validity.clone(),
606        )?));
607    }
608    let analysis_mask = data.storage().analysis_mask().cloned();
609    let combined: Arc<[f64]> = match data.storage().weights() {
610        Some(existing) => {
611            Arc::from(existing.iter().zip(weights.iter()).map(|(a, b)| a * b).collect::<Vec<_>>())
612        }
613        None => Arc::from(weights.to_vec()),
614    };
615    let storage = OwnedColumnarStorage::try_new(schema, cols, analysis_mask, Some(combined))?;
616    TimeSeriesData::try_new(storage, data.time_index().clone())
617}
618
619fn apply_row_map(data: &TimeSeriesData, row_map: &[u32]) -> Result<TimeSeriesData, DataError> {
620    let n = row_map.len();
621    if n != data.row_count() {
622        return Err(DataError::LengthMismatch {
623            expected: data.row_count(),
624            actual: n,
625            context: "resample row map",
626        });
627    }
628    let schema = data.schema().clone();
629    let mut cols = Vec::with_capacity(schema.len());
630    for v in schema.variables() {
631        let ColumnView::Float64(src) = data.column(v.id)? else {
632            return Err(DataError::TypeMismatch { id: v.id, expected: "float64" });
633        };
634        let values: Vec<f64> = row_map.iter().map(|&r| src.values[r as usize]).collect();
635        // A replicate slot inherits the source row's validity: values under
636        // null slots must not resurface as valid observations.
637        let validity = src.validity.gather(row_map)?;
638        cols.push(OwnedColumn::Float64(Float64Column::new(v.id, Arc::from(values), validity)?));
639    }
640    // Analysis mask and weights follow the same row map as the values.
641    let analysis_mask = data.storage().analysis_mask().map(|m| m.gather(row_map)).transpose()?;
642    let weights = data
643        .storage()
644        .weights()
645        .map(|w| Arc::from(row_map.iter().map(|&r| w[r as usize]).collect::<Vec<f64>>()));
646    let storage = OwnedColumnarStorage::try_new(schema, cols, analysis_mask, weights)?;
647    TimeSeriesData::try_new(storage, data.time_index().clone())
648}
649
650#[cfg(test)]
651#[allow(clippy::cast_precision_loss)]
652mod tests {
653    use antecedent_core::{
654        CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, ValueType, VariableId,
655    };
656
657    use super::*;
658    use crate::column::ValidityBitmap;
659    use crate::temporal::{SamplingRegularity, TimeIndex};
660    use crate::testing::float_series;
661
662    /// One-variable series of length 4 with row 2 invalid, a mask hiding row 1,
663    /// and per-row weights.
664    fn series_with_missing() -> TimeSeriesData {
665        let mut b = CausalSchemaBuilder::new();
666        b.add_variable(
667            "v0".to_owned(),
668            ValueType::Continuous,
669            SmallRoleSet::from_hint(RoleHint::Context),
670            None,
671            None,
672            MeasurementSpec::default(),
673        )
674        .unwrap();
675        let schema = b.build().unwrap();
676        // Row 2 invalid (LSB-first bits: rows 0,1,3 set).
677        let validity = ValidityBitmap::from_bytes(vec![0b1011u8], 4).unwrap();
678        let col = OwnedColumn::Float64(
679            Float64Column::new(
680                VariableId::from_raw(0),
681                Arc::from(vec![10.0, 11.0, 0.0, 13.0]),
682                validity,
683            )
684            .unwrap(),
685        );
686        // Analysis mask hides row 1 (LSB-first bits: rows 0,2,3 set).
687        let mask = ValidityBitmap::from_bytes(vec![0b1101u8], 4).unwrap();
688        let weights: Arc<[f64]> = Arc::from(vec![1.0, 2.0, 3.0, 4.0]);
689        let storage =
690            OwnedColumnarStorage::try_new(schema, vec![col], Some(mask), Some(weights)).unwrap();
691        TimeSeriesData::try_new(
692            storage,
693            TimeIndex { regularity: SamplingRegularity::Regular { interval_ns: 1 }, length: 4 },
694        )
695        .unwrap()
696    }
697
698    #[test]
699    fn row_map_gathers_validity_mask_and_weights() {
700        let data = series_with_missing();
701        let out = apply_row_map(&data, &[2, 0, 2, 1]).unwrap();
702        let ColumnView::Float64(col) = out.column(VariableId::from_raw(0)).unwrap() else {
703            panic!("expected float64 column");
704        };
705        // Replicate slots sourced from row 2 must stay invalid.
706        assert!(!col.validity.is_valid(0));
707        assert!(col.validity.is_valid(1));
708        assert!(!col.validity.is_valid(2));
709        assert!(col.validity.is_valid(3));
710        // No invalid source value appears as a valid replicate value.
711        for i in 0..4 {
712            if col.validity.is_valid(i) {
713                assert!(
714                    (col.values[i] - 10.0).abs() < 1e-12 || (col.values[i] - 11.0).abs() < 1e-12
715                );
716            }
717        }
718        // Analysis mask follows the row map (source row 1 was hidden).
719        let mask = out.storage().analysis_mask().unwrap();
720        assert!(mask.is_valid(0));
721        assert!(mask.is_valid(1));
722        assert!(mask.is_valid(2));
723        assert!(!mask.is_valid(3));
724        // Weights follow the row map.
725        assert_eq!(out.storage().weights().unwrap(), &[3.0, 1.0, 3.0, 2.0]);
726    }
727
728    #[test]
729    fn bayesian_bootstrap_weights_sum_to_n() {
730        let mut rng = CausalRng::from_seed(99);
731        let mut w = Vec::new();
732        fill_resample_weights(ResamplingPlan::BayesianBootstrap, 50, &mut rng, &mut w).unwrap();
733        assert_eq!(w.len(), 50);
734        let sum: f64 = w.iter().sum();
735        assert!((sum - 50.0).abs() < 1e-9);
736        assert!(w.iter().all(|&x| x > 0.0));
737    }
738
739    #[test]
740    fn bayesian_bootstrap_rejects_index_fill() {
741        let mut rng = CausalRng::from_seed(1);
742        let mut idx = Vec::new();
743        assert!(
744            fill_resample_indexes(ResamplingPlan::BayesianBootstrap, 10, &mut rng, &mut idx)
745                .is_err()
746        );
747    }
748
749    #[test]
750    fn moving_block_preserves_length() {
751        let data = float_series(100, 1);
752        let mut rng = CausalRng::from_seed(1);
753        let mut idx = Vec::new();
754        let out = resample_timeseries(
755            &data,
756            ResamplingPlan::MovingBlock { length: 10 },
757            &mut rng,
758            &mut idx,
759        )
760        .unwrap();
761        assert_eq!(out.row_count(), 100);
762        assert_eq!(idx.len(), 100);
763    }
764
765    #[test]
766    fn permutation_full_is_bijection() {
767        let mut rng = CausalRng::from_seed(3);
768        let mut idx = Vec::new();
769        fill_resample_indexes(
770            ResamplingPlan::Permutation(PermutationScheme::Full),
771            20,
772            &mut rng,
773            &mut idx,
774        )
775        .unwrap();
776        let mut sorted = idx.clone();
777        sorted.sort_unstable();
778        assert_eq!(sorted, (0..20u32).collect::<Vec<_>>());
779    }
780
781    #[test]
782    fn cluster_bootstrap_keeps_members_contiguous_from_same_cluster() {
783        let n = 12usize;
784        let clusters: Vec<u32> = (0..n as u32).map(|i| i / 3).collect();
785        let mut rng = CausalRng::from_seed(5);
786        let mut idx = Vec::new();
787        fill_resample_indexes_grouped(
788            ResamplingPlan::ClusterBootstrap { cluster: VariableId::from_raw(0) },
789            n,
790            Some(&clusters),
791            &mut rng,
792            &mut idx,
793        )
794        .unwrap();
795        assert_eq!(idx.len(), n);
796        // Every index must come from some original cluster; consecutive triples
797        // from the same draw share a cluster id when length allows.
798        for &i in &idx {
799            assert!((i as usize) < n);
800        }
801    }
802
803    #[test]
804    fn stationary_block_preserves_length() {
805        let mut rng = CausalRng::from_seed(2);
806        let mut idx = Vec::new();
807        fill_resample_indexes(
808            ResamplingPlan::StationaryBlock { expected_length: 5.0 },
809            80,
810            &mut rng,
811            &mut idx,
812        )
813        .unwrap();
814        assert_eq!(idx.len(), 80);
815    }
816
817    #[test]
818    fn index_batch_shape_and_strict_determinism() {
819        use antecedent_core::ExecutionContext;
820        let ctx = ExecutionContext::for_tests(42);
821        let n = 20usize;
822        let b = 8usize;
823        let mut out_a = vec![0u32; n * b];
824        let mut out_b = vec![0u32; n * b];
825        fill_resample_index_batch(
826            ResamplingPlan::IidBootstrap,
827            n,
828            b,
829            None,
830            &ctx,
831            0xDEAD_u64,
832            &mut out_a,
833        )
834        .unwrap();
835        fill_resample_index_batch(
836            ResamplingPlan::IidBootstrap,
837            n,
838            b,
839            None,
840            &ctx,
841            0xDEAD_u64,
842            &mut out_b,
843        )
844        .unwrap();
845        assert_eq!(out_a, out_b);
846        assert!(out_a.iter().all(|&i| (i as usize) < n));
847    }
848
849    #[test]
850    fn weight_batch_rows_sum_to_n() {
851        use antecedent_core::ExecutionContext;
852        let ctx = ExecutionContext::for_tests(7);
853        let n = 15usize;
854        let b = 4usize;
855        let mut out = vec![0.0; n * b];
856        fill_resample_weight_batch(
857            ResamplingPlan::BayesianBootstrap,
858            n,
859            b,
860            &ctx,
861            0xBEEF_u64,
862            &mut out,
863        )
864        .unwrap();
865        for r in 0..b {
866            let sum: f64 = out[r * n..(r + 1) * n].iter().sum();
867            assert!((sum - n as f64).abs() < 1e-9);
868        }
869    }
870}