1#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)]
6
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10use antecedent_core::CausalRng;
11use antecedent_kernels::shuffle;
12
13use crate::error::DataError;
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
17pub struct TimeRange {
18 pub start: usize,
20 pub end: usize,
22}
23
24impl TimeRange {
25 #[must_use]
27 pub const fn len(self) -> usize {
28 self.end.saturating_sub(self.start)
29 }
30
31 #[must_use]
33 pub const fn is_empty(self) -> bool {
34 self.start >= self.end
35 }
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct RowSplit {
41 pub train: Arc<[u32]>,
43 pub test: Arc<[u32]>,
45}
46
47impl RowSplit {
48 #[must_use]
50 pub fn len(&self) -> usize {
51 self.train.len() + self.test.len()
52 }
53
54 #[must_use]
56 pub fn is_empty(&self) -> bool {
57 self.train.is_empty() && self.test.is_empty()
58 }
59}
60
61#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct TemporalFold {
64 pub fold: usize,
66 pub train: TimeRange,
68 pub test: TimeRange,
70 pub gap: usize,
72}
73
74#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default)]
76pub enum TemporalRandomPolicy {
77 #[default]
79 Refuse,
80 Allow,
82}
83
84pub fn ensure_random_allowed_on_temporal(policy: TemporalRandomPolicy) -> Result<(), DataError> {
90 match policy {
91 TemporalRandomPolicy::Allow => Ok(()),
92 TemporalRandomPolicy::Refuse => Err(DataError::InvalidArgument {
93 message:
94 "random IID split refused on temporal/panel/event data without explicit opt-in"
95 .into(),
96 }),
97 }
98}
99
100#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
107pub struct DiscoveryEstimationSplit {
108 pub discovery: TimeRange,
110 pub estimation: TimeRange,
112 pub gap: usize,
114 pub series_len: usize,
116}
117
118impl DiscoveryEstimationSplit {
119 pub fn try_new(
126 series_len: usize,
127 discovery: TimeRange,
128 estimation: TimeRange,
129 ) -> Result<Self, DataError> {
130 validate_range(series_len, discovery, "discovery")?;
131 validate_range(series_len, estimation, "estimation")?;
132 if discovery.end > estimation.start {
133 return Err(DataError::InvalidArgument {
134 message:
135 "discovery window must end at or before estimation start (with optional gap)"
136 .into(),
137 });
138 }
139 let gap = estimation.start - discovery.end;
140 Ok(Self { discovery, estimation, gap, series_len })
141 }
142
143 pub fn from_sizes(
149 series_len: usize,
150 discovery_len: usize,
151 gap: usize,
152 estimation_len: usize,
153 ) -> Result<Self, DataError> {
154 if discovery_len == 0 || estimation_len == 0 {
155 return Err(DataError::InvalidArgument {
156 message: "discovery and estimation windows must be non-empty".into(),
157 });
158 }
159 let need = discovery_len
160 .checked_add(gap)
161 .and_then(|v| v.checked_add(estimation_len))
162 .ok_or(DataError::InvalidArgument { message: "split sizes overflow".into() })?;
163 if need != series_len {
164 return Err(DataError::InvalidArgument {
165 message: "discovery + gap + estimation must equal series_len".into(),
166 });
167 }
168 let discovery = TimeRange { start: 0, end: discovery_len };
169 let estimation = TimeRange { start: discovery_len + gap, end: series_len };
170 Self::try_new(series_len, discovery, estimation)
171 }
172
173 pub fn from_fraction(
180 series_len: usize,
181 discovery_frac: f64,
182 gap: usize,
183 ) -> Result<Self, DataError> {
184 if !(discovery_frac > 0.0 && discovery_frac < 1.0) {
185 return Err(DataError::InvalidArgument {
186 message: "discovery_frac must be in (0, 1)".into(),
187 });
188 }
189 if series_len <= gap + 1 {
190 return Err(DataError::InvalidArgument {
191 message: "series too short for gap and two non-empty windows".into(),
192 });
193 }
194 let usable = series_len - gap;
195 let mut discovery_len = ((usable as f64) * discovery_frac).floor() as usize;
196 if discovery_len == 0 {
197 discovery_len = 1;
198 }
199 if discovery_len >= usable {
200 discovery_len = usable - 1;
201 }
202 let estimation_len = usable - discovery_len;
203 Self::from_sizes(series_len, discovery_len, gap, estimation_len)
204 }
205}
206
207#[derive(Clone, Debug, PartialEq, Eq)]
209pub struct EnvHoldoutSplit {
210 pub discovery_envs: Arc<[usize]>,
212 pub estimation_envs: Arc<[usize]>,
214}
215
216impl EnvHoldoutSplit {
217 pub fn try_prefix(n_env: usize, n_discovery: usize) -> Result<Self, DataError> {
223 if n_env == 0 {
224 return Err(DataError::InvalidArgument {
225 message: "env holdout needs ≥1 environment".into(),
226 });
227 }
228 if n_discovery == 0 || n_discovery >= n_env {
229 return Err(DataError::InvalidArgument {
230 message: "n_discovery must be in 1..n_env".into(),
231 });
232 }
233 Ok(Self {
234 discovery_envs: Arc::from((0..n_discovery).collect::<Vec<_>>()),
235 estimation_envs: Arc::from((n_discovery..n_env).collect::<Vec<_>>()),
236 })
237 }
238}
239
240#[derive(Clone, Debug, PartialEq, Eq)]
242pub struct RegimeHoldoutSplit {
243 pub discovery_regimes: Arc<[u32]>,
245 pub estimation_regimes: Arc<[u32]>,
247}
248
249impl RegimeHoldoutSplit {
250 pub fn try_prefix(regime_ids: &[u32], n_discovery: usize) -> Result<Self, DataError> {
256 let n = regime_ids.len();
257 if n == 0 {
258 return Err(DataError::InvalidArgument {
259 message: "regime holdout needs ≥1 regime".into(),
260 });
261 }
262 if n_discovery == 0 || n_discovery >= n {
263 return Err(DataError::InvalidArgument {
264 message: "n_discovery must be in 1..n_regimes".into(),
265 });
266 }
267 Ok(Self {
268 discovery_regimes: Arc::from(regime_ids[..n_discovery].to_vec()),
269 estimation_regimes: Arc::from(regime_ids[n_discovery..].to_vec()),
270 })
271 }
272}
273
274#[derive(Clone, Debug, PartialEq, Eq)]
276pub struct RandomIidSplit {
277 pub rows: RowSplit,
279 pub seed: u64,
281 pub test_frac: OrderedFrac,
283}
284
285#[derive(Clone, Copy, Debug, PartialEq)]
287pub struct OrderedFrac(pub f64);
288
289impl Eq for OrderedFrac {}
290
291impl std::hash::Hash for OrderedFrac {
292 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
293 self.0.to_bits().hash(state);
294 }
295}
296
297impl RandomIidSplit {
298 pub fn try_new(n: usize, test_frac: f64, seed: u64) -> Result<Self, DataError> {
308 if n == 0 {
309 return Err(DataError::InvalidArgument {
310 message: "random IID split needs n ≥ 1".into(),
311 });
312 }
313 if !(test_frac > 0.0 && test_frac < 1.0) {
314 return Err(DataError::InvalidArgument {
315 message: "test_frac must be in (0, 1)".into(),
316 });
317 }
318 let mut test_len = ((n as f64) * test_frac).round() as usize;
319 if test_len == 0 {
320 test_len = 1;
321 }
322 if test_len >= n {
323 test_len = n - 1;
324 }
325 let mut idx: Vec<u32> = (0..n as u32).collect();
326 fisher_yates(&mut idx, seed);
327 let train = Arc::from(idx[..n - test_len].to_vec());
328 let test = Arc::from(idx[n - test_len..].to_vec());
329 Ok(Self { rows: RowSplit { train, test }, seed, test_frac: OrderedFrac(test_frac) })
330 }
331}
332
333#[derive(Clone, Debug, PartialEq, Eq)]
335pub struct GroupedSplit {
336 pub rows: RowSplit,
338 pub train_groups: Arc<[i64]>,
340 pub test_groups: Arc<[i64]>,
342 pub seed: u64,
344}
345
346impl GroupedSplit {
347 pub fn try_new(group_ids: &[i64], test_frac: f64, seed: u64) -> Result<Self, DataError> {
353 split_by_labels(group_ids, test_frac, seed).map(|(rows, train_groups, test_groups)| Self {
354 rows,
355 train_groups,
356 test_groups,
357 seed,
358 })
359 }
360}
361
362#[derive(Clone, Debug, PartialEq, Eq)]
364pub struct ClusterSplit {
365 pub rows: RowSplit,
367 pub train_clusters: Arc<[i64]>,
369 pub test_clusters: Arc<[i64]>,
371 pub seed: u64,
373}
374
375impl ClusterSplit {
376 pub fn try_new(cluster_ids: &[i64], test_frac: f64, seed: u64) -> Result<Self, DataError> {
382 split_by_labels(cluster_ids, test_frac, seed).map(
383 |(rows, train_clusters, test_clusters)| Self {
384 rows,
385 train_clusters,
386 test_clusters,
387 seed,
388 },
389 )
390 }
391}
392
393#[derive(Clone, Debug, PartialEq, Eq)]
395pub struct BlockedTemporalSplit {
396 pub series_len: usize,
398 pub block_len: usize,
400 pub train_blocks: Arc<[TimeRange]>,
402 pub test_blocks: Arc<[TimeRange]>,
404 pub rows: RowSplit,
406 pub seed: u64,
408}
409
410impl BlockedTemporalSplit {
411 pub fn try_new(
419 series_len: usize,
420 block_len: usize,
421 test_frac: f64,
422 seed: u64,
423 ) -> Result<Self, DataError> {
424 if series_len == 0 || block_len == 0 {
425 return Err(DataError::InvalidArgument {
426 message: "blocked temporal split needs series_len, block_len ≥ 1".into(),
427 });
428 }
429 if !(test_frac > 0.0 && test_frac < 1.0) {
430 return Err(DataError::InvalidArgument {
431 message: "test_frac must be in (0, 1)".into(),
432 });
433 }
434 let mut blocks = Vec::new();
435 let mut start = 0usize;
436 while start < series_len {
437 let end = (start + block_len).min(series_len);
438 blocks.push(TimeRange { start, end });
439 start = end;
440 }
441 let n_blocks = blocks.len();
442 if n_blocks < 2 {
443 return Err(DataError::InvalidArgument {
444 message: "blocked temporal split needs ≥2 blocks".into(),
445 });
446 }
447 let mut test_blocks_n = ((n_blocks as f64) * test_frac).round() as usize;
448 if test_blocks_n == 0 {
449 test_blocks_n = 1;
450 }
451 if test_blocks_n >= n_blocks {
452 test_blocks_n = n_blocks - 1;
453 }
454 let mut order: Vec<usize> = (0..n_blocks).collect();
455 fisher_yates_usize(&mut order, seed);
456 let mut train_blocks = Vec::new();
457 let mut test_blocks = Vec::new();
458 let mut train_rows = Vec::new();
459 let mut test_rows = Vec::new();
460 for (rank, &bi) in order.iter().enumerate() {
461 let block = blocks[bi];
462 let rows = (block.start as u32)..(block.end as u32);
463 if rank < n_blocks - test_blocks_n {
464 train_blocks.push(block);
465 train_rows.extend(rows);
466 } else {
467 test_blocks.push(block);
468 test_rows.extend(rows);
469 }
470 }
471 Ok(Self {
472 series_len,
473 block_len,
474 train_blocks: Arc::from(train_blocks),
475 test_blocks: Arc::from(test_blocks),
476 rows: RowSplit { train: Arc::from(train_rows), test: Arc::from(test_rows) },
477 seed,
478 })
479 }
480}
481
482#[derive(Clone, Debug, PartialEq, Eq)]
484pub struct RollingOriginSplit {
485 pub series_len: usize,
487 pub min_train: usize,
489 pub horizon: usize,
491 pub gap: usize,
493 pub step: usize,
495 pub folds: Arc<[TemporalFold]>,
497}
498
499impl RollingOriginSplit {
500 pub fn try_new(
509 series_len: usize,
510 min_train: usize,
511 horizon: usize,
512 gap: usize,
513 step: usize,
514 ) -> Result<Self, DataError> {
515 if min_train == 0 || horizon == 0 || step == 0 {
516 return Err(DataError::InvalidArgument {
517 message: "rolling-origin requires min_train, horizon, step ≥ 1".into(),
518 });
519 }
520 let mut folds = Vec::new();
521 let mut origin = min_train;
522 let mut fold = 0usize;
523 while origin + gap + horizon <= series_len {
524 folds.push(TemporalFold {
525 fold,
526 train: TimeRange { start: 0, end: origin },
527 test: TimeRange { start: origin + gap, end: origin + gap + horizon },
528 gap,
529 });
530 fold += 1;
531 origin = origin.saturating_add(step);
532 }
533 if folds.is_empty() {
534 return Err(DataError::InvalidArgument {
535 message: "series too short for any rolling-origin fold".into(),
536 });
537 }
538 Ok(Self { series_len, min_train, horizon, gap, step, folds: Arc::from(folds) })
539 }
540}
541
542fn validate_range(series_len: usize, range: TimeRange, label: &str) -> Result<(), DataError> {
543 if range.is_empty() {
544 return Err(DataError::InvalidArgument {
545 message: format!("{label} window must be non-empty"),
546 });
547 }
548 if range.end > series_len {
549 return Err(DataError::InvalidArgument {
550 message: format!("{label} window exceeds series_len"),
551 });
552 }
553 Ok(())
554}
555
556fn fisher_yates(idx: &mut [u32], seed: u64) {
557 let mut rng = CausalRng::from_seed(seed);
558 shuffle(&mut rng, idx);
559}
560
561fn fisher_yates_usize(idx: &mut [usize], seed: u64) {
562 let mut rng = CausalRng::from_seed(seed);
563 shuffle(&mut rng, idx);
564}
565
566type LabelSplitResult = (RowSplit, Arc<[i64]>, Arc<[i64]>);
567
568fn split_by_labels(
569 labels: &[i64],
570 test_frac: f64,
571 seed: u64,
572) -> Result<LabelSplitResult, DataError> {
573 if labels.is_empty() {
574 return Err(DataError::InvalidArgument {
575 message: "grouped/cluster split needs ≥1 row".into(),
576 });
577 }
578 if !(test_frac > 0.0 && test_frac < 1.0) {
579 return Err(DataError::InvalidArgument { message: "test_frac must be in (0, 1)".into() });
580 }
581 let mut members: BTreeMap<i64, Vec<u32>> = BTreeMap::new();
582 for (i, &g) in labels.iter().enumerate() {
583 members.entry(g).or_default().push(i as u32);
584 }
585 if members.len() < 2 {
586 return Err(DataError::InvalidArgument {
587 message: "grouped/cluster split needs ≥2 distinct labels".into(),
588 });
589 }
590 let mut unique: Vec<i64> = members.keys().copied().collect();
591 fisher_yates_i64(&mut unique, seed);
592 let n_g = unique.len();
593 let mut test_n = ((n_g as f64) * test_frac).round() as usize;
594 if test_n == 0 {
595 test_n = 1;
596 }
597 if test_n >= n_g {
598 test_n = n_g - 1;
599 }
600 let test_groups = Arc::<[i64]>::from(unique[n_g - test_n..].to_vec());
601 let train_groups = Arc::<[i64]>::from(unique[..n_g - test_n].to_vec());
602 let mut train_rows = Vec::new();
603 let mut test_rows = Vec::new();
604 for &g in train_groups.iter() {
605 train_rows.extend_from_slice(&members[&g]);
606 }
607 for &g in test_groups.iter() {
608 test_rows.extend_from_slice(&members[&g]);
609 }
610 Ok((
611 RowSplit { train: Arc::from(train_rows), test: Arc::from(test_rows) },
612 train_groups,
613 test_groups,
614 ))
615}
616
617fn fisher_yates_i64(idx: &mut [i64], seed: u64) {
618 let mut rng = CausalRng::from_seed(seed);
619 for i in (1..idx.len()).rev() {
620 let j = (rng.next_u64() as usize) % (i + 1);
621 idx.swap(i, j);
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 #[test]
630 fn from_sizes_layout() {
631 let s = DiscoveryEstimationSplit::from_sizes(100, 60, 10, 30).unwrap();
632 assert_eq!(s.discovery, TimeRange { start: 0, end: 60 });
633 assert_eq!(s.gap, 10);
634 assert_eq!(s.estimation, TimeRange { start: 70, end: 100 });
635 }
636
637 #[test]
638 fn from_fraction_respects_gap() {
639 let s = DiscoveryEstimationSplit::from_fraction(100, 0.5, 10).unwrap();
640 assert_eq!(s.gap, 10);
641 assert_eq!(s.discovery.len() + s.gap + s.estimation.len(), 100);
642 assert!(!s.discovery.is_empty());
643 assert!(!s.estimation.is_empty());
644 }
645
646 #[test]
647 fn from_fraction_rejects_boundary_fractions() {
648 assert!(DiscoveryEstimationSplit::from_fraction(100, 0.0, 10).is_err());
649 assert!(DiscoveryEstimationSplit::from_fraction(100, 1.0, 10).is_err());
650 assert!(DiscoveryEstimationSplit::from_fraction(100, f64::NAN, 10).is_err());
651 }
652
653 #[test]
654 fn rejects_overlap() {
655 let err = DiscoveryEstimationSplit::try_new(
656 50,
657 TimeRange { start: 0, end: 30 },
658 TimeRange { start: 20, end: 50 },
659 );
660 assert!(err.is_err());
661 }
662
663 #[test]
664 fn rejects_bad_sum() {
665 assert!(DiscoveryEstimationSplit::from_sizes(100, 40, 10, 40).is_err());
666 }
667
668 #[test]
669 fn env_holdout_prefix() {
670 let s = EnvHoldoutSplit::try_prefix(4, 2).unwrap();
671 assert_eq!(s.discovery_envs.as_ref(), &[0, 1]);
672 assert_eq!(s.estimation_envs.as_ref(), &[2, 3]);
673 }
674
675 #[test]
676 fn regime_holdout_prefix() {
677 let s = RegimeHoldoutSplit::try_prefix(&[10, 20, 30], 1).unwrap();
678 assert_eq!(s.discovery_regimes.as_ref(), &[10]);
679 assert_eq!(s.estimation_regimes.as_ref(), &[20, 30]);
680 }
681
682 #[test]
683 fn random_iid_seed_stable() {
684 let a = RandomIidSplit::try_new(100, 0.2, 7).unwrap();
685 let b = RandomIidSplit::try_new(100, 0.2, 7).unwrap();
686 assert_eq!(a.rows, b.rows);
687 assert_eq!(a.rows.test.len(), 20);
688 assert_eq!(a.rows.train.len(), 80);
689 }
690
691 #[test]
692 fn temporal_random_refused_by_default() {
693 assert!(ensure_random_allowed_on_temporal(TemporalRandomPolicy::Refuse).is_err());
694 assert!(ensure_random_allowed_on_temporal(TemporalRandomPolicy::Allow).is_ok());
695 }
696
697 #[test]
698 fn grouped_keeps_groups_intact() {
699 let ids = [0i64, 0, 1, 1, 2, 2, 3, 3];
700 let s = GroupedSplit::try_new(&ids, 0.25, 11).unwrap();
701 assert_eq!(s.test_groups.len(), 1);
702 assert_eq!(s.train_groups.len(), 3);
703 for &g in s.test_groups.iter() {
704 for &row in s.rows.test.iter() {
705 if ids[row as usize] == g {
706 assert!(!s.rows.train.contains(&row));
707 }
708 }
709 }
710 }
711
712 #[test]
713 fn cluster_matches_grouped_mechanics() {
714 let ids = [10i64, 10, 20, 20, 30, 30];
715 let g = GroupedSplit::try_new(&ids, 0.5, 3).unwrap();
716 let c = ClusterSplit::try_new(&ids, 0.5, 3).unwrap();
717 assert_eq!(g.rows, c.rows);
718 }
719
720 #[test]
721 fn blocked_temporal_covers_series() {
722 let s = BlockedTemporalSplit::try_new(100, 10, 0.2, 5).unwrap();
723 assert_eq!(s.rows.len(), 100);
724 assert!(!s.train_blocks.is_empty());
725 assert!(!s.test_blocks.is_empty());
726 }
727
728 #[test]
729 fn rolling_origin_fold_count() {
730 let s = RollingOriginSplit::try_new(100, 40, 10, 0, 10).unwrap();
733 assert_eq!(s.folds.len(), 6);
734 assert_eq!(s.folds[0].train, TimeRange { start: 0, end: 40 });
735 assert_eq!(s.folds[0].test, TimeRange { start: 40, end: 50 });
736 assert_eq!(s.folds.last().unwrap().train.end, 90);
737 }
738}