Skip to main content

antecedent_data/
split.rs

1//! Split strategies for discovery / estimation.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![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/// Half-open index range `[start, end)`.
16#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
17pub struct TimeRange {
18    /// Inclusive start index.
19    pub start: usize,
20    /// Exclusive end index.
21    pub end: usize,
22}
23
24impl TimeRange {
25    /// Length of the range.
26    #[must_use]
27    pub const fn len(self) -> usize {
28        self.end.saturating_sub(self.start)
29    }
30
31    /// Whether the range is empty.
32    #[must_use]
33    pub const fn is_empty(self) -> bool {
34        self.start >= self.end
35    }
36}
37
38/// Train / test row-index partition (metadata only — no data copy).
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct RowSplit {
41    /// Training row indexes.
42    pub train: Arc<[u32]>,
43    /// Test / holdout row indexes.
44    pub test: Arc<[u32]>,
45}
46
47impl RowSplit {
48    /// Total rows covered.
49    #[must_use]
50    pub fn len(&self) -> usize {
51        self.train.len() + self.test.len()
52    }
53
54    /// Whether both sides are empty.
55    #[must_use]
56    pub fn is_empty(&self) -> bool {
57        self.train.is_empty() && self.test.is_empty()
58    }
59}
60
61/// One rolling-origin fold.
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct TemporalFold {
64    /// Fold index (0-based).
65    pub fold: usize,
66    /// Training window (half-open).
67    pub train: TimeRange,
68    /// Test / forecast window (half-open).
69    pub test: TimeRange,
70    /// Gap between train end and test start.
71    pub gap: usize,
72}
73
74/// Whether random row splits are allowed on temporal / panel / event data.
75#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default)]
76pub enum TemporalRandomPolicy {
77    /// Refuse random IID splits on temporal-like data (default, §5.6).
78    #[default]
79    Refuse,
80    /// Explicit opt-in for random row splits on temporal / panel / event data.
81    Allow,
82}
83
84/// Guard used by planners before applying [`RandomIidSplit`] to temporal data.
85///
86/// # Errors
87///
88/// When policy is [`TemporalRandomPolicy::Refuse`].
89pub 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/// Discovery / estimation split with a temporal gap.
101///
102/// Layout over `0..series_len`:
103/// `[discovery) | gap | [estimation)`.
104///
105/// This is metadata only — no data copy.
106#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
107pub struct DiscoveryEstimationSplit {
108    /// Contiguous discovery window.
109    pub discovery: TimeRange,
110    /// Contiguous estimation window (after the gap).
111    pub estimation: TimeRange,
112    /// Number of time steps skipped between discovery end and estimation start.
113    pub gap: usize,
114    /// Full series length used to validate the split.
115    pub series_len: usize,
116}
117
118impl DiscoveryEstimationSplit {
119    /// Build a split from absolute ranges.
120    ///
121    /// # Errors
122    ///
123    /// Empty windows, inverted ranges, overlapping discovery/estimation, or
124    /// ranges outside `series_len`.
125    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    /// Split `series_len` into discovery / gap / estimation by sizes.
144    ///
145    /// # Errors
146    ///
147    /// When sizes do not sum to `series_len`, or either window is empty.
148    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    /// Proportional split: discovery gets `discovery_frac` of the length after
174    /// reserving `gap` steps in the middle (remainder → estimation).
175    ///
176    /// # Errors
177    ///
178    /// Invalid fraction, insufficient length for gap + two non-empty windows.
179    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/// Environment holdout: discovery vs estimation environment index sets (no data copy).
208#[derive(Clone, Debug, PartialEq, Eq)]
209pub struct EnvHoldoutSplit {
210    /// Environment indices used for discovery / CI.
211    pub discovery_envs: Arc<[usize]>,
212    /// Environment indices held out for estimation / evaluation.
213    pub estimation_envs: Arc<[usize]>,
214}
215
216impl EnvHoldoutSplit {
217    /// Split environments: first `n_discovery` indices for discovery, rest for estimation.
218    ///
219    /// # Errors
220    ///
221    /// Empty total, or `n_discovery` not in `1..n_env`.
222    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/// Regime holdout: discovery vs estimation regime id sets (no data copy).
241#[derive(Clone, Debug, PartialEq, Eq)]
242pub struct RegimeHoldoutSplit {
243    /// Regime ids used for discovery.
244    pub discovery_regimes: Arc<[u32]>,
245    /// Regime ids held out for estimation.
246    pub estimation_regimes: Arc<[u32]>,
247}
248
249impl RegimeHoldoutSplit {
250    /// Partition a list of regime ids into discovery / estimation by prefix count.
251    ///
252    /// # Errors
253    ///
254    /// Empty list, or `n_discovery` not in `1..n_regimes`.
255    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/// Random IID train/test split (seeded; metadata only).
275#[derive(Clone, Debug, PartialEq, Eq)]
276pub struct RandomIidSplit {
277    /// Resulting row partition.
278    pub rows: RowSplit,
279    /// Seed used to shuffle.
280    pub seed: u64,
281    /// Requested test fraction.
282    pub test_frac: OrderedFrac,
283}
284
285/// Newtype so `f64` fraction can live on an `Eq` struct via bit pattern.
286#[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    /// Shuffle `0..n` and take the last `test_frac` as test.
299    ///
300    /// For temporal / panel / event data, callers must pass
301    /// [`TemporalRandomPolicy::Allow`] via [`ensure_random_allowed_on_temporal`]
302    /// before constructing this split.
303    ///
304    /// # Errors
305    ///
306    /// Empty `n`, invalid fraction, or empty train/test after rounding.
307    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/// Grouped split: entire groups assigned to train or test (no leakage).
334#[derive(Clone, Debug, PartialEq, Eq)]
335pub struct GroupedSplit {
336    /// Row partition.
337    pub rows: RowSplit,
338    /// Group ids assigned to train.
339    pub train_groups: Arc<[i64]>,
340    /// Group ids assigned to test.
341    pub test_groups: Arc<[i64]>,
342    /// Seed.
343    pub seed: u64,
344}
345
346impl GroupedSplit {
347    /// Assign whole groups by shuffling unique group ids.
348    ///
349    /// # Errors
350    ///
351    /// Length mismatch, fewer than two groups, or invalid fraction.
352    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/// Cluster split: same mechanics as grouped, distinct type for cluster ids.
363#[derive(Clone, Debug, PartialEq, Eq)]
364pub struct ClusterSplit {
365    /// Row partition.
366    pub rows: RowSplit,
367    /// Cluster ids in train.
368    pub train_clusters: Arc<[i64]>,
369    /// Cluster ids in test.
370    pub test_clusters: Arc<[i64]>,
371    /// Seed.
372    pub seed: u64,
373}
374
375impl ClusterSplit {
376    /// Assign whole clusters by shuffling unique cluster ids.
377    ///
378    /// # Errors
379    ///
380    /// Same as [`GroupedSplit::try_new`].
381    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/// Blocked temporal split: contiguous time blocks assigned to train / test.
394#[derive(Clone, Debug, PartialEq, Eq)]
395pub struct BlockedTemporalSplit {
396    /// Series length.
397    pub series_len: usize,
398    /// Block length in rows.
399    pub block_len: usize,
400    /// Training blocks as ranges.
401    pub train_blocks: Arc<[TimeRange]>,
402    /// Test blocks as ranges.
403    pub test_blocks: Arc<[TimeRange]>,
404    /// Flattened row partition.
405    pub rows: RowSplit,
406    /// Seed.
407    pub seed: u64,
408}
409
410impl BlockedTemporalSplit {
411    /// Partition `0..series_len` into blocks of `block_len` and assign by shuffle.
412    ///
413    /// The final block may be shorter. At least one train and one test block required.
414    ///
415    /// # Errors
416    ///
417    /// Invalid sizes / fraction.
418    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/// Rolling-origin (expanding window) temporal folds.
483#[derive(Clone, Debug, PartialEq, Eq)]
484pub struct RollingOriginSplit {
485    /// Series length.
486    pub series_len: usize,
487    /// Minimum initial training length.
488    pub min_train: usize,
489    /// Forecast / test horizon per fold.
490    pub horizon: usize,
491    /// Gap between train end and test start.
492    pub gap: usize,
493    /// Step between successive fold origins.
494    pub step: usize,
495    /// Ordered folds.
496    pub folds: Arc<[TemporalFold]>,
497}
498
499impl RollingOriginSplit {
500    /// Build expanding-origin folds.
501    ///
502    /// Fold `k` uses train `[0, origin)` where `origin = min_train + k * step`,
503    /// then gap, then test of length `horizon`.
504    ///
505    /// # Errors
506    ///
507    /// When no complete fold fits in `series_len`.
508    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        // min_train=40, horizon=10, gap=0, step=10, series=100
731        // origins 40..=90 → 6 folds
732        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}