1#![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#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
24pub enum PermutationScheme {
25 Full,
27 WithinCluster,
29}
30
31#[derive(Clone, Copy, Debug, PartialEq)]
33pub enum ResamplingPlan {
34 IidBootstrap,
36 BayesianBootstrap,
38 MovingBlock {
40 length: usize,
42 },
43 CircularBlock {
45 length: usize,
47 },
48 ClusterBootstrap {
53 cluster: VariableId,
55 },
56 StationaryBlock {
58 expected_length: f64,
60 },
61 Permutation(PermutationScheme),
63}
64
65impl ResamplingPlan {
66 #[must_use]
68 pub const fn is_weight_plan(self) -> bool {
69 matches!(self, Self::BayesianBootstrap)
70 }
71
72 #[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 #[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
91pub 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
114pub 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 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 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 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 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 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
337pub 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 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
381pub 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
467pub 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
542pub 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
565pub 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 let validity = src.validity.gather(row_map)?;
638 cols.push(OwnedColumn::Float64(Float64Column::new(v.id, Arc::from(values), validity)?));
639 }
640 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 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 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 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 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 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 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 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 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}