1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::campaign::stable_json_fingerprint;
6use crate::error::{DagMlError, Result};
7use crate::ids::{FoldId, GroupId, SampleId};
8use crate::rng::SeedContext;
9
10#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
11pub struct FoldAssignment {
12 pub fold_id: FoldId,
13 pub train_sample_ids: Vec<SampleId>,
14 pub validation_sample_ids: Vec<SampleId>,
15 #[serde(default)]
16 pub metadata: BTreeMap<String, serde_json::Value>,
17}
18
19#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum FoldPartitionMode {
23 #[default]
25 Partition,
26 Resampled,
30}
31
32fn is_partition_mode_default(mode: &FoldPartitionMode) -> bool {
33 *mode == FoldPartitionMode::Partition
34}
35
36#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
37pub struct FoldSet {
38 pub id: String,
39 pub sample_ids: Vec<SampleId>,
40 pub folds: Vec<FoldAssignment>,
41 #[serde(default)]
42 pub sample_groups: BTreeMap<SampleId, GroupId>,
43 #[serde(default, skip_serializing_if = "is_partition_mode_default")]
46 pub partition_mode: FoldPartitionMode,
47}
48
49impl FoldSet {
50 pub fn validate(&self) -> Result<()> {
51 if self.id.trim().is_empty() {
52 return Err(DagMlError::OofValidation(
53 "fold set id is empty".to_string(),
54 ));
55 }
56 if self.sample_ids.is_empty() {
57 return Err(DagMlError::OofValidation(
58 "fold set contains no samples".to_string(),
59 ));
60 }
61 if self.folds.is_empty() {
62 return Err(DagMlError::OofValidation(
63 "fold set contains no folds".to_string(),
64 ));
65 }
66 let universe = unique_samples("fold set sample_ids", &self.sample_ids)?;
67 if !self.sample_groups.is_empty() {
68 for sample_id in self.sample_groups.keys() {
69 if !universe.contains(sample_id) {
70 return Err(DagMlError::OofValidation(format!(
71 "sample group map references unknown sample `{sample_id}`"
72 )));
73 }
74 }
75 for sample_id in &self.sample_ids {
76 if !self.sample_groups.contains_key(sample_id) {
77 return Err(DagMlError::OofValidation(format!(
78 "sample `{sample_id}` is missing from non-empty group map"
79 )));
80 }
81 }
82 }
83 let mut fold_ids = BTreeSet::new();
84 let mut validation_counts = self
85 .sample_ids
86 .iter()
87 .cloned()
88 .map(|sample_id| (sample_id, 0usize))
89 .collect::<BTreeMap<_, _>>();
90
91 for fold in &self.folds {
92 if !fold_ids.insert(&fold.fold_id) {
93 return Err(DagMlError::OofValidation(format!(
94 "duplicate fold id `{}`",
95 fold.fold_id
96 )));
97 }
98 let train = unique_samples(
99 &format!("fold `{}` train_sample_ids", fold.fold_id),
100 &fold.train_sample_ids,
101 )?;
102 let validation = unique_samples(
103 &format!("fold `{}` validation_sample_ids", fold.fold_id),
104 &fold.validation_sample_ids,
105 )?;
106 if validation.is_empty() {
107 return Err(DagMlError::OofValidation(format!(
108 "fold `{}` has no validation samples",
109 fold.fold_id
110 )));
111 }
112 for sample_id in train.union(&validation) {
113 if !universe.contains(sample_id) {
114 return Err(DagMlError::OofValidation(format!(
115 "fold `{}` references unknown sample `{}`",
116 fold.fold_id, sample_id
117 )));
118 }
119 }
120 let overlap = train.intersection(&validation).collect::<Vec<_>>();
121 if !overlap.is_empty() {
122 return Err(DagMlError::OofValidation(format!(
123 "fold `{}` has train/validation overlap at sample `{}`",
124 fold.fold_id, overlap[0]
125 )));
126 }
127 for sample_id in validation {
128 *validation_counts
129 .get_mut(sample_id)
130 .expect("validation sample is in universe") += 1;
131 }
132 self.validate_group_boundary(fold, &train)?;
133 }
134
135 if self.partition_mode == FoldPartitionMode::Partition {
140 for (sample_id, count) in validation_counts {
141 if count != 1 {
142 return Err(DagMlError::OofValidation(format!(
143 "sample `{}` appears in validation {} time(s), expected exactly once",
144 sample_id, count
145 )));
146 }
147 }
148 }
149
150 Ok(())
151 }
152
153 fn validate_group_boundary(
154 &self,
155 fold: &FoldAssignment,
156 train: &BTreeSet<&SampleId>,
157 ) -> Result<()> {
158 if self.sample_groups.is_empty() {
159 return Ok(());
160 }
161 let train_groups = train
162 .iter()
163 .filter_map(|sample_id| self.sample_groups.get(*sample_id))
164 .collect::<BTreeSet<_>>();
165 for sample_id in &fold.validation_sample_ids {
166 let Some(group_id) = self.sample_groups.get(sample_id) else {
167 continue;
168 };
169 if train_groups.contains(group_id) {
170 return Err(DagMlError::OofValidation(format!(
171 "fold `{}` leaks group `{}` across train/validation",
172 fold.fold_id, group_id
173 )));
174 }
175 }
176 Ok(())
177 }
178}
179
180pub fn fold_set_fingerprint(fold_set: &FoldSet) -> Result<String> {
181 let mut canonical = fold_set.clone();
182 canonical.validate()?;
183 canonical.sample_ids.sort();
184 canonical
185 .folds
186 .sort_by(|left, right| left.fold_id.cmp(&right.fold_id));
187 for fold in &mut canonical.folds {
188 fold.train_sample_ids.sort();
189 fold.validation_sample_ids.sort();
190 }
191
192 let mut value = serde_json::to_value(&canonical)?;
193 remove_empty_fold_set_maps(&mut value);
194 stable_json_fingerprint(&value)
195}
196
197fn remove_empty_fold_set_maps(value: &mut serde_json::Value) {
198 let Some(object) = value.as_object_mut() else {
199 return;
200 };
201 if object
202 .get("sample_groups")
203 .and_then(serde_json::Value::as_object)
204 .is_some_and(serde_json::Map::is_empty)
205 {
206 object.remove("sample_groups");
207 }
208 let Some(folds) = object
209 .get_mut("folds")
210 .and_then(serde_json::Value::as_array_mut)
211 else {
212 return;
213 };
214 for fold in folds {
215 let Some(fold_object) = fold.as_object_mut() else {
216 continue;
217 };
218 if fold_object
219 .get("metadata")
220 .and_then(serde_json::Value::as_object)
221 .is_some_and(serde_json::Map::is_empty)
222 {
223 fold_object.remove("metadata");
224 }
225 }
226}
227
228#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
229pub struct KFoldSpec {
230 pub n_splits: usize,
231 #[serde(default)]
232 pub shuffle: bool,
233 pub seed: Option<u64>,
234}
235
236impl KFoldSpec {
237 pub fn split(&self, id: impl Into<String>, samples: &[SampleId]) -> Result<FoldSet> {
238 if self.n_splits < 2 {
239 return Err(DagMlError::OofValidation(
240 "KFold requires at least two splits".to_string(),
241 ));
242 }
243 let unique = unique_samples("KFold samples", samples)?;
244 if self.n_splits > unique.len() {
245 return Err(DagMlError::OofValidation(format!(
246 "KFold n_splits={} exceeds sample count {}",
247 self.n_splits,
248 unique.len()
249 )));
250 }
251 let ordered = ordered_samples(samples, self.shuffle, self.seed.unwrap_or(0));
252 let folds = (0..self.n_splits)
253 .map(|fold_idx| {
254 let validation = ordered
255 .iter()
256 .enumerate()
257 .filter_map(|(idx, sample_id)| {
258 (idx % self.n_splits == fold_idx).then_some(sample_id.clone())
259 })
260 .collect::<Vec<_>>();
261 let validation_set = validation.iter().collect::<BTreeSet<_>>();
262 let train = ordered
263 .iter()
264 .filter(|sample_id| !validation_set.contains(sample_id))
265 .cloned()
266 .collect::<Vec<_>>();
267 Ok(FoldAssignment {
268 fold_id: FoldId::new(format!("fold{fold_idx}"))?,
269 train_sample_ids: train,
270 validation_sample_ids: validation,
271 metadata: BTreeMap::new(),
272 })
273 })
274 .collect::<Result<Vec<_>>>()?;
275 let fold_set = FoldSet {
276 id: id.into(),
277 sample_ids: ordered_samples(samples, false, 0),
278 folds,
279 sample_groups: BTreeMap::new(),
280 partition_mode: FoldPartitionMode::Partition,
281 };
282 fold_set.validate()?;
283 Ok(fold_set)
284 }
285}
286
287#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
292pub struct StratifiedKFoldSpec {
293 pub n_splits: usize,
294 #[serde(default)]
295 pub shuffle: bool,
296 pub seed: Option<u64>,
297}
298
299impl StratifiedKFoldSpec {
300 pub fn split(
301 &self,
302 id: impl Into<String>,
303 samples: &[SampleId],
304 strata: &BTreeMap<SampleId, String>,
305 ) -> Result<FoldSet> {
306 if self.n_splits < 2 {
307 return Err(DagMlError::OofValidation(
308 "StratifiedKFold requires at least two splits".to_string(),
309 ));
310 }
311 let unique = unique_samples("StratifiedKFold samples", samples)?;
312 if self.n_splits > unique.len() {
313 return Err(DagMlError::OofValidation(format!(
314 "StratifiedKFold n_splits={} exceeds sample count {}",
315 self.n_splits,
316 unique.len()
317 )));
318 }
319 let ordered = ordered_samples(samples, self.shuffle, self.seed.unwrap_or(0));
326 let mut by_label: BTreeMap<String, Vec<SampleId>> = BTreeMap::new();
327 for sample_id in &ordered {
328 let label = strata.get(sample_id).ok_or_else(|| {
329 DagMlError::OofValidation(format!(
330 "StratifiedKFold: sample `{sample_id}` has no stratum label"
331 ))
332 })?;
333 by_label
334 .entry(label.clone())
335 .or_default()
336 .push(sample_id.clone());
337 }
338 let mut fold_of: BTreeMap<SampleId, usize> = BTreeMap::new();
339 let mut position = 0usize;
340 for members in by_label.values() {
341 for sample_id in members {
342 fold_of.insert(sample_id.clone(), position % self.n_splits);
343 position += 1;
344 }
345 }
346 let folds = (0..self.n_splits)
347 .map(|fold_idx| {
348 let validation = ordered
349 .iter()
350 .filter(|s| fold_of.get(*s) == Some(&fold_idx))
351 .cloned()
352 .collect::<Vec<_>>();
353 let train = ordered
354 .iter()
355 .filter(|s| fold_of.get(*s) != Some(&fold_idx))
356 .cloned()
357 .collect::<Vec<_>>();
358 Ok(FoldAssignment {
359 fold_id: FoldId::new(format!("fold{fold_idx}"))?,
360 train_sample_ids: train,
361 validation_sample_ids: validation,
362 metadata: BTreeMap::new(),
363 })
364 })
365 .collect::<Result<Vec<_>>>()?;
366 let fold_set = FoldSet {
367 id: id.into(),
368 sample_ids: ordered_samples(samples, false, 0),
369 folds,
370 sample_groups: BTreeMap::new(),
371 partition_mode: FoldPartitionMode::Partition,
372 };
373 fold_set.validate()?;
374 Ok(fold_set)
375 }
376}
377
378#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
379pub struct GroupKFoldSpec {
380 pub n_splits: usize,
381}
382
383impl GroupKFoldSpec {
384 pub fn split(
385 &self,
386 id: impl Into<String>,
387 sample_groups: &BTreeMap<SampleId, GroupId>,
388 ) -> Result<FoldSet> {
389 if self.n_splits < 2 {
390 return Err(DagMlError::OofValidation(
391 "GroupKFold requires at least two splits".to_string(),
392 ));
393 }
394 if sample_groups.is_empty() {
395 return Err(DagMlError::OofValidation(
396 "GroupKFold requires sample groups".to_string(),
397 ));
398 }
399 let mut groups = BTreeMap::<GroupId, Vec<SampleId>>::new();
400 for (sample_id, group_id) in sample_groups {
401 groups
402 .entry(group_id.clone())
403 .or_default()
404 .push(sample_id.clone());
405 }
406 if self.n_splits > groups.len() {
407 return Err(DagMlError::OofValidation(format!(
408 "GroupKFold n_splits={} exceeds group count {}",
409 self.n_splits,
410 groups.len()
411 )));
412 }
413
414 let mut grouped = groups.into_iter().collect::<Vec<_>>();
415 grouped.sort_by(|(left_group, left_samples), (right_group, right_samples)| {
416 right_samples
417 .len()
418 .cmp(&left_samples.len())
419 .then_with(|| left_group.cmp(right_group))
420 });
421
422 let mut fold_validation = vec![Vec::<SampleId>::new(); self.n_splits];
423 for (_group_id, mut samples) in grouped {
424 samples.sort();
425 let fold_idx = fold_validation
426 .iter()
427 .enumerate()
428 .min_by(|(left_idx, left), (right_idx, right)| {
429 left.len()
430 .cmp(&right.len())
431 .then_with(|| left_idx.cmp(right_idx))
432 })
433 .map(|(idx, _)| idx)
434 .expect("at least one fold");
435 fold_validation[fold_idx].extend(samples);
436 }
437
438 let mut sample_ids = sample_groups.keys().cloned().collect::<Vec<_>>();
439 sample_ids.sort();
440 let folds = fold_validation
441 .into_iter()
442 .enumerate()
443 .map(|(fold_idx, mut validation)| {
444 validation.sort();
445 let validation_set = validation.iter().collect::<BTreeSet<_>>();
446 let train = sample_ids
447 .iter()
448 .filter(|sample_id| !validation_set.contains(sample_id))
449 .cloned()
450 .collect::<Vec<_>>();
451 Ok(FoldAssignment {
452 fold_id: FoldId::new(format!("fold{fold_idx}"))?,
453 train_sample_ids: train,
454 validation_sample_ids: validation,
455 metadata: BTreeMap::new(),
456 })
457 })
458 .collect::<Result<Vec<_>>>()?;
459
460 let fold_set = FoldSet {
461 id: id.into(),
462 sample_ids,
463 folds,
464 sample_groups: sample_groups.clone(),
465 partition_mode: FoldPartitionMode::Partition,
466 };
467 fold_set.validate()?;
468 Ok(fold_set)
469 }
470}
471
472#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
481#[serde(tag = "kind")]
482pub enum NestedCvSpec {
483 #[serde(rename = "kfold")]
485 KFold(KFoldSpec),
486 #[serde(rename = "group_kfold")]
488 GroupKFold(GroupKFoldSpec),
489}
490
491impl NestedCvSpec {
492 pub fn validate(&self) -> Result<()> {
496 match self {
497 Self::KFold(spec) => {
498 if spec.n_splits < 2 {
499 return Err(DagMlError::OofValidation(
500 "inner KFold requires at least two splits".to_string(),
501 ));
502 }
503 }
504 Self::GroupKFold(spec) => {
505 if spec.n_splits < 2 {
506 return Err(DagMlError::OofValidation(
507 "inner GroupKFold requires at least two splits".to_string(),
508 ));
509 }
510 }
511 }
512 Ok(())
513 }
514
515 pub fn build_inner_fold_set(
520 &self,
521 outer: &FoldAssignment,
522 outer_groups: &BTreeMap<SampleId, GroupId>,
523 ) -> Result<FoldSet> {
524 let inner_id = format!("{}.inner", outer.fold_id);
525 let inner = match self {
526 Self::KFold(spec) => spec.split(inner_id, &outer.train_sample_ids)?,
527 Self::GroupKFold(spec) => {
528 let train = outer.train_sample_ids.iter().collect::<BTreeSet<_>>();
529 let inner_groups = outer_groups
530 .iter()
531 .filter(|(sample_id, _)| train.contains(sample_id))
532 .map(|(sample_id, group_id)| (sample_id.clone(), group_id.clone()))
533 .collect::<BTreeMap<_, _>>();
534 spec.split(inner_id, &inner_groups)?
535 }
536 };
537 validate_inner_fold_set_within_outer(&inner, outer)?;
538 Ok(inner)
539 }
540}
541
542pub fn resolve_inner_cv<'a>(
545 node_inner_cv: Option<&'a NestedCvSpec>,
546 campaign_inner_cv: Option<&'a NestedCvSpec>,
547) -> Option<&'a NestedCvSpec> {
548 node_inner_cv.or(campaign_inner_cv)
549}
550
551pub fn validate_inner_fold_set_within_outer(inner: &FoldSet, outer: &FoldAssignment) -> Result<()> {
557 inner.validate()?;
561 let train = outer.train_sample_ids.iter().collect::<BTreeSet<_>>();
562 let ensure_train = |sample_id: &SampleId| -> Result<()> {
563 if !train.contains(sample_id) {
564 return Err(DagMlError::OofValidation(format!(
565 "nested CV leakage: inner-CV sample `{sample_id}` for outer fold `{}` is not an outer training sample",
566 outer.fold_id
567 )));
568 }
569 Ok(())
570 };
571 for sample_id in &inner.sample_ids {
572 ensure_train(sample_id)?;
573 }
574 for fold in &inner.folds {
577 for sample_id in fold
578 .train_sample_ids
579 .iter()
580 .chain(&fold.validation_sample_ids)
581 {
582 ensure_train(sample_id)?;
583 }
584 }
585 Ok(())
586}
587
588fn unique_samples<'a>(label: &str, samples: &'a [SampleId]) -> Result<BTreeSet<&'a SampleId>> {
589 let mut seen = BTreeSet::new();
590 for sample_id in samples {
591 if !seen.insert(sample_id) {
592 return Err(DagMlError::OofValidation(format!(
593 "{label} contains duplicate sample `{sample_id}`"
594 )));
595 }
596 }
597 Ok(seen)
598}
599
600fn ordered_samples(samples: &[SampleId], shuffle: bool, seed: u64) -> Vec<SampleId> {
601 let mut ordered = samples.to_vec();
602 ordered.sort();
603 if shuffle {
604 let context = SeedContext::root(seed).child("kfold");
605 ordered.sort_by(|left, right| {
606 context
607 .derive_u64(left.as_str())
608 .cmp(&context.derive_u64(right.as_str()))
609 .then_with(|| left.cmp(right))
610 });
611 }
612 ordered
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618
619 const SHARED_FOLD_SET_FINGERPRINT: &str =
620 "54d3185d6c628ef0df848828a8d8ae650222a283a78bbd3ab3bc2256f222c05c";
621
622 fn sid(value: &str) -> SampleId {
623 SampleId::new(value).unwrap()
624 }
625
626 fn gid(value: &str) -> GroupId {
627 GroupId::new(value).unwrap()
628 }
629
630 #[test]
631 fn kfold_is_deterministic_and_covers_samples_once() {
632 let samples = ["s1", "s2", "s3", "s4", "s5", "s6"]
633 .into_iter()
634 .map(sid)
635 .collect::<Vec<_>>();
636 let spec = KFoldSpec {
637 n_splits: 3,
638 shuffle: true,
639 seed: Some(42),
640 };
641
642 let left = spec.split("kfold", &samples).unwrap();
643 let right = spec.split("kfold", &samples).unwrap();
644
645 assert_eq!(left, right);
646 left.validate().unwrap();
647 for fold in &left.folds {
648 assert_eq!(fold.validation_sample_ids.len(), 2);
649 assert_eq!(fold.train_sample_ids.len(), 4);
650 }
651 }
652
653 #[test]
654 fn fold_validation_rejects_overlap() {
655 let fold_set = FoldSet {
656 id: "bad".to_string(),
657 sample_ids: vec![sid("s1"), sid("s2")],
658 folds: vec![FoldAssignment {
659 fold_id: FoldId::new("fold0").unwrap(),
660 train_sample_ids: vec![sid("s1")],
661 validation_sample_ids: vec![sid("s1")],
662 metadata: BTreeMap::new(),
663 }],
664 sample_groups: BTreeMap::new(),
665 partition_mode: FoldPartitionMode::Partition,
666 };
667
668 assert!(fold_set.validate().is_err());
669 }
670
671 #[test]
672 fn fold_validation_rejects_partial_group_maps() {
673 let fold_set = FoldSet {
674 id: "bad-groups".to_string(),
675 sample_ids: vec![sid("s1"), sid("s2")],
676 folds: vec![FoldAssignment {
677 fold_id: FoldId::new("fold0").unwrap(),
678 train_sample_ids: vec![sid("s2")],
679 validation_sample_ids: vec![sid("s1")],
680 metadata: BTreeMap::new(),
681 }],
682 sample_groups: BTreeMap::from([(sid("s1"), gid("g1"))]),
683 partition_mode: FoldPartitionMode::Partition,
684 };
685
686 assert!(fold_set.validate().is_err());
687 }
688
689 #[test]
690 fn fold_set_fingerprint_is_independent_of_ordering() {
691 let mut left = FoldSet {
692 id: "cv.partition".to_string(),
693 sample_ids: vec![sid("s3"), sid("s2"), sid("s1")],
694 folds: vec![
695 FoldAssignment {
696 fold_id: FoldId::new("fold1").unwrap(),
697 train_sample_ids: vec![sid("s2"), sid("s1")],
698 validation_sample_ids: vec![sid("s3")],
699 metadata: BTreeMap::new(),
700 },
701 FoldAssignment {
702 fold_id: FoldId::new("fold0").unwrap(),
703 train_sample_ids: vec![sid("s3")],
704 validation_sample_ids: vec![sid("s2"), sid("s1")],
705 metadata: BTreeMap::new(),
706 },
707 ],
708 sample_groups: BTreeMap::new(),
709 partition_mode: FoldPartitionMode::Partition,
710 };
711 let mut right = left.clone();
712 right.sample_ids.reverse();
713 right.folds.reverse();
714 for fold in &mut right.folds {
715 fold.train_sample_ids.reverse();
716 fold.validation_sample_ids.reverse();
717 }
718
719 assert_eq!(
720 fold_set_fingerprint(&left).unwrap(),
721 fold_set_fingerprint(&right).unwrap()
722 );
723
724 left.id = "cv.partition.changed".to_string();
725 assert_ne!(
726 fold_set_fingerprint(&left).unwrap(),
727 fold_set_fingerprint(&right).unwrap()
728 );
729 }
730
731 #[test]
732 fn shared_fold_set_fixture_fingerprint_is_locked() {
733 let fixture = include_str!("../../../examples/fixtures/shared/fold_set_cv_partition.json");
734 let fold_set = serde_json::from_str::<FoldSet>(fixture).unwrap();
735
736 assert_eq!(
737 fold_set_fingerprint(&fold_set).unwrap(),
738 SHARED_FOLD_SET_FINGERPRINT
739 );
740 }
741
742 #[test]
743 fn group_kfold_keeps_groups_out_of_train_validation_overlap() {
744 let groups = BTreeMap::from([
745 (sid("s1"), gid("g1")),
746 (sid("s2"), gid("g1")),
747 (sid("s3"), gid("g2")),
748 (sid("s4"), gid("g2")),
749 (sid("s5"), gid("g3")),
750 (sid("s6"), gid("g3")),
751 ]);
752 let fold_set = GroupKFoldSpec { n_splits: 3 }
753 .split("group-kfold", &groups)
754 .unwrap();
755
756 fold_set.validate().unwrap();
757 for fold in &fold_set.folds {
758 let train_groups = fold
759 .train_sample_ids
760 .iter()
761 .map(|sample_id| groups.get(sample_id).unwrap())
762 .collect::<BTreeSet<_>>();
763 for sample_id in &fold.validation_sample_ids {
764 assert!(!train_groups.contains(groups.get(sample_id).unwrap()));
765 }
766 }
767 }
768
769 #[test]
770 fn stratified_kfold_is_oof_safe_and_balances_classes() {
771 let samples = (0..8).map(|i| sid(&format!("s{i}"))).collect::<Vec<_>>();
773 let strata = BTreeMap::from_iter(samples.iter().enumerate().map(|(i, s)| {
774 (
775 s.clone(),
776 if i % 2 == 0 {
777 "A".to_string()
778 } else {
779 "B".to_string()
780 },
781 )
782 }));
783 let fold_set = StratifiedKFoldSpec {
784 n_splits: 2,
785 shuffle: false,
786 seed: Some(0),
787 }
788 .split("strat", &samples, &strata)
789 .unwrap();
790 fold_set.validate().unwrap(); assert_eq!(fold_set.folds.len(), 2);
792 for fold in &fold_set.folds {
793 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
794 for s in &fold.validation_sample_ids {
795 *counts.entry(strata.get(s).unwrap().as_str()).or_insert(0) += 1;
796 }
797 assert_eq!(counts.get("A"), Some(&2));
798 assert_eq!(counts.get("B"), Some(&2));
799 }
800 }
801
802 #[test]
803 fn stratified_kfold_singleton_classes_leave_no_empty_fold() {
804 let samples = ["s0", "s1", "s2"].into_iter().map(sid).collect::<Vec<_>>();
807 let strata = BTreeMap::from_iter([
808 (sid("s0"), "A".to_string()),
809 (sid("s1"), "B".to_string()),
810 (sid("s2"), "C".to_string()),
811 ]);
812 let fold_set = StratifiedKFoldSpec {
813 n_splits: 3,
814 shuffle: false,
815 seed: Some(0),
816 }
817 .split("strat", &samples, &strata)
818 .expect("singleton-class stratified split must succeed");
819 fold_set.validate().unwrap();
820 for fold in &fold_set.folds {
821 assert_eq!(fold.validation_sample_ids.len(), 1);
822 }
823 }
824
825 #[test]
826 fn stratified_kfold_rejects_missing_label() {
827 let samples = (0..4).map(|i| sid(&format!("s{i}"))).collect::<Vec<_>>();
828 let strata = BTreeMap::from_iter([(sid("s0"), "A".to_string())]); let err = StratifiedKFoldSpec {
830 n_splits: 2,
831 shuffle: false,
832 seed: Some(0),
833 }
834 .split("strat", &samples, &strata);
835 assert!(err.is_err());
836 }
837
838 fn outer_kfold(samples: &[SampleId]) -> FoldSet {
839 KFoldSpec {
840 n_splits: 2,
841 shuffle: false,
842 seed: Some(0),
843 }
844 .split("outer", samples)
845 .unwrap()
846 }
847
848 #[test]
849 fn nested_kfold_inner_folds_are_subset_of_outer_train() {
850 let samples = ["s1", "s2", "s3", "s4", "s5", "s6"]
851 .into_iter()
852 .map(sid)
853 .collect::<Vec<_>>();
854 let outer = outer_kfold(&samples);
855 let spec = NestedCvSpec::KFold(KFoldSpec {
856 n_splits: 2,
857 shuffle: false,
858 seed: Some(1),
859 });
860 for outer_fold in &outer.folds {
861 let inner = spec
862 .build_inner_fold_set(outer_fold, &outer.sample_groups)
863 .expect("inner fold set");
864 let outer_train = outer_fold.train_sample_ids.iter().collect::<BTreeSet<_>>();
865 for sample_id in &inner.sample_ids {
867 assert!(outer_train.contains(sample_id));
868 }
869 inner.validate().unwrap();
871 assert_eq!(
872 inner.sample_ids.iter().collect::<BTreeSet<_>>(),
873 outer_train
874 );
875 }
876 }
877
878 #[test]
879 fn nested_cv_validation_refuses_inner_sample_from_outer_validation() {
880 let samples = ["s1", "s2", "s3", "s4"]
881 .into_iter()
882 .map(sid)
883 .collect::<Vec<_>>();
884 let outer = outer_kfold(&samples);
885 let outer_fold = &outer.folds[0];
886 let leaking_sample = outer_fold.validation_sample_ids[0].clone();
889 let train_sample = outer_fold.train_sample_ids[0].clone();
890 let inner = FoldSet {
891 id: "leaky.inner".to_string(),
892 sample_ids: vec![train_sample.clone(), leaking_sample.clone()],
893 folds: vec![
894 FoldAssignment {
895 fold_id: FoldId::new("if0").unwrap(),
896 train_sample_ids: vec![leaking_sample.clone()],
897 validation_sample_ids: vec![train_sample.clone()],
898 metadata: BTreeMap::new(),
899 },
900 FoldAssignment {
901 fold_id: FoldId::new("if1").unwrap(),
902 train_sample_ids: vec![train_sample],
903 validation_sample_ids: vec![leaking_sample],
904 metadata: BTreeMap::new(),
905 },
906 ],
907 sample_groups: BTreeMap::new(),
908 partition_mode: FoldPartitionMode::Partition,
909 };
910 inner
911 .validate()
912 .expect("inner fold set is structurally valid");
913 let err = validate_inner_fold_set_within_outer(&inner, outer_fold)
914 .expect_err("inner fold leaking an outer-validation sample must be refused");
915 assert!(err.to_string().contains("nested CV leakage"));
916 }
917
918 #[test]
919 fn nested_cv_validation_refuses_leak_hidden_in_fold_members() {
920 let samples = ["s1", "s2", "s3", "s4"]
924 .into_iter()
925 .map(sid)
926 .collect::<Vec<_>>();
927 let outer = outer_kfold(&samples);
928 let outer_fold = &outer.folds[0];
929 let leaking_sample = outer_fold.validation_sample_ids[0].clone();
930 let train_sample = outer_fold.train_sample_ids[0].clone();
931 let inner = FoldSet {
932 id: "hidden.inner".to_string(),
933 sample_ids: vec![train_sample.clone()],
935 folds: vec![FoldAssignment {
936 fold_id: FoldId::new("if0").unwrap(),
937 train_sample_ids: vec![train_sample],
938 validation_sample_ids: vec![leaking_sample],
939 metadata: BTreeMap::new(),
940 }],
941 sample_groups: BTreeMap::new(),
942 partition_mode: FoldPartitionMode::Partition,
943 };
944 assert!(validate_inner_fold_set_within_outer(&inner, outer_fold).is_err());
945 }
946
947 #[test]
948 fn nested_cv_spec_json_shape_is_stable() {
949 let spec = NestedCvSpec::KFold(KFoldSpec {
950 n_splits: 3,
951 shuffle: false,
952 seed: Some(7),
953 });
954 let value = serde_json::to_value(&spec).unwrap();
955 assert_eq!(value["kind"], "kfold");
956 assert_eq!(value["n_splits"], 3);
957 assert_eq!(value["seed"], 7);
958 let round: NestedCvSpec = serde_json::from_value(value).unwrap();
959 assert_eq!(round, spec);
960
961 let group = NestedCvSpec::GroupKFold(GroupKFoldSpec { n_splits: 2 });
962 let gv = serde_json::to_value(&group).unwrap();
963 assert_eq!(gv["kind"], "group_kfold");
964 assert_eq!(gv["n_splits"], 2);
965 assert_eq!(serde_json::from_value::<NestedCvSpec>(gv).unwrap(), group);
966 }
967
968 #[test]
969 fn resolve_inner_cv_prefers_node_over_campaign() {
970 let node = NestedCvSpec::KFold(KFoldSpec {
971 n_splits: 3,
972 shuffle: false,
973 seed: Some(2),
974 });
975 let campaign = NestedCvSpec::KFold(KFoldSpec {
976 n_splits: 5,
977 shuffle: false,
978 seed: Some(3),
979 });
980 assert_eq!(resolve_inner_cv(Some(&node), Some(&campaign)), Some(&node));
981 assert_eq!(resolve_inner_cv(None, Some(&campaign)), Some(&campaign));
982 assert_eq!(resolve_inner_cv(Some(&node), None), Some(&node));
983 assert_eq!(resolve_inner_cv(None, None), None);
984 }
985
986 #[test]
987 fn resampled_mode_allows_non_oof_validation_but_still_blocks_leakage() {
988 let fold = |id: &str, train: &[&str], val: &[&str]| FoldAssignment {
989 fold_id: FoldId::new(id).unwrap(),
990 train_sample_ids: train.iter().map(|s| sid(s)).collect(),
991 validation_sample_ids: val.iter().map(|s| sid(s)).collect(),
992 metadata: BTreeMap::new(),
993 };
994 let samples = vec![sid("s1"), sid("s2"), sid("s3"), sid("s4")];
995 let folds = vec![
997 fold("f0", &["s3", "s4"], &["s1", "s2"]),
998 fold("f1", &["s2", "s4"], &["s1", "s3"]),
999 ];
1000
1001 let partition = FoldSet {
1002 id: "partition".to_string(),
1003 sample_ids: samples.clone(),
1004 folds: folds.clone(),
1005 sample_groups: BTreeMap::new(),
1006 partition_mode: FoldPartitionMode::Partition,
1007 };
1008 assert!(
1009 partition.validate().is_err(),
1010 "Partition mode must reject non-OOF validation"
1011 );
1012
1013 let resampled = FoldSet {
1014 id: "resampled".to_string(),
1015 sample_ids: samples,
1016 folds,
1017 sample_groups: BTreeMap::new(),
1018 partition_mode: FoldPartitionMode::Resampled,
1019 };
1020 resampled.validate().unwrap(); let leaky = FoldSet {
1024 id: "leaky".to_string(),
1025 sample_ids: vec![sid("s1"), sid("s2")],
1026 folds: vec![fold("f", &["s1"], &["s1"])],
1027 sample_groups: BTreeMap::new(),
1028 partition_mode: FoldPartitionMode::Resampled,
1029 };
1030 assert!(
1031 leaky.validate().is_err(),
1032 "Resampled must still reject train/validation overlap"
1033 );
1034 }
1035}