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
491#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
495pub struct NestedFoldSet {
496 pub parent_outer_fold_id: FoldId,
497 pub inner_fold_set: FoldSet,
498}
499
500impl NestedFoldSet {
501 pub fn validate_for_outer(&self, outer: &FoldAssignment) -> Result<()> {
505 if self.parent_outer_fold_id != outer.fold_id {
506 return Err(DagMlError::OofValidation(format!(
507 "nested CV parent fold `{}` does not match outer fold `{}`",
508 self.parent_outer_fold_id, outer.fold_id
509 )));
510 }
511 validate_inner_fold_set_within_outer(&self.inner_fold_set, outer)
512 }
513}
514
515impl NestedCvSpec {
516 pub fn validate(&self) -> Result<()> {
520 match self {
521 Self::KFold(spec) => {
522 if spec.n_splits < 2 {
523 return Err(DagMlError::OofValidation(
524 "inner KFold requires at least two splits".to_string(),
525 ));
526 }
527 }
528 Self::GroupKFold(spec) => {
529 if spec.n_splits < 2 {
530 return Err(DagMlError::OofValidation(
531 "inner GroupKFold requires at least two splits".to_string(),
532 ));
533 }
534 }
535 }
536 Ok(())
537 }
538
539 pub fn build_inner_fold_set(
544 &self,
545 outer: &FoldAssignment,
546 outer_groups: &BTreeMap<SampleId, GroupId>,
547 ) -> Result<FoldSet> {
548 Ok(self
549 .build_nested_fold_set(outer, outer_groups)?
550 .inner_fold_set)
551 }
552
553 pub fn build_nested_fold_set(
557 &self,
558 outer: &FoldAssignment,
559 outer_groups: &BTreeMap<SampleId, GroupId>,
560 ) -> Result<NestedFoldSet> {
561 let inner_id = format!("{}.inner", outer.fold_id);
562 let mut inner = match self {
563 Self::KFold(spec) => spec.split(inner_id, &outer.train_sample_ids)?,
564 Self::GroupKFold(spec) => {
565 let train = outer.train_sample_ids.iter().collect::<BTreeSet<_>>();
566 let inner_groups = outer_groups
567 .iter()
568 .filter(|(sample_id, _)| train.contains(sample_id))
569 .map(|(sample_id, group_id)| (sample_id.clone(), group_id.clone()))
570 .collect::<BTreeMap<_, _>>();
571 spec.split(inner_id, &inner_groups)?
572 }
573 };
574 for fold in &mut inner.folds {
580 fold.fold_id = FoldId::new(format!("{}.inner.{}", outer.fold_id, fold.fold_id))?;
581 }
582 let nested = NestedFoldSet {
583 parent_outer_fold_id: outer.fold_id.clone(),
584 inner_fold_set: inner,
585 };
586 nested.validate_for_outer(outer)?;
587 Ok(nested)
588 }
589}
590
591pub fn resolve_inner_cv<'a>(
594 node_inner_cv: Option<&'a NestedCvSpec>,
595 campaign_inner_cv: Option<&'a NestedCvSpec>,
596) -> Option<&'a NestedCvSpec> {
597 node_inner_cv.or(campaign_inner_cv)
598}
599
600pub fn validate_inner_fold_set_within_outer(inner: &FoldSet, outer: &FoldAssignment) -> Result<()> {
606 inner.validate()?;
610 let train = outer.train_sample_ids.iter().collect::<BTreeSet<_>>();
611 let ensure_train = |sample_id: &SampleId| -> Result<()> {
612 if !train.contains(sample_id) {
613 return Err(DagMlError::OofValidation(format!(
614 "nested CV leakage: inner-CV sample `{sample_id}` for outer fold `{}` is not an outer training sample",
615 outer.fold_id
616 )));
617 }
618 Ok(())
619 };
620 for sample_id in &inner.sample_ids {
621 ensure_train(sample_id)?;
622 }
623 for fold in &inner.folds {
626 for sample_id in fold
627 .train_sample_ids
628 .iter()
629 .chain(&fold.validation_sample_ids)
630 {
631 ensure_train(sample_id)?;
632 }
633 }
634 Ok(())
635}
636
637fn unique_samples<'a>(label: &str, samples: &'a [SampleId]) -> Result<BTreeSet<&'a SampleId>> {
638 let mut seen = BTreeSet::new();
639 for sample_id in samples {
640 if !seen.insert(sample_id) {
641 return Err(DagMlError::OofValidation(format!(
642 "{label} contains duplicate sample `{sample_id}`"
643 )));
644 }
645 }
646 Ok(seen)
647}
648
649fn ordered_samples(samples: &[SampleId], shuffle: bool, seed: u64) -> Vec<SampleId> {
650 let mut ordered = samples.to_vec();
651 ordered.sort();
652 if shuffle {
653 let context = SeedContext::root(seed).child("kfold");
654 ordered.sort_by(|left, right| {
655 context
656 .derive_u64(left.as_str())
657 .cmp(&context.derive_u64(right.as_str()))
658 .then_with(|| left.cmp(right))
659 });
660 }
661 ordered
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 const SHARED_FOLD_SET_FINGERPRINT: &str =
669 "54d3185d6c628ef0df848828a8d8ae650222a283a78bbd3ab3bc2256f222c05c";
670
671 fn sid(value: &str) -> SampleId {
672 SampleId::new(value).unwrap()
673 }
674
675 fn gid(value: &str) -> GroupId {
676 GroupId::new(value).unwrap()
677 }
678
679 #[test]
680 fn kfold_is_deterministic_and_covers_samples_once() {
681 let samples = ["s1", "s2", "s3", "s4", "s5", "s6"]
682 .into_iter()
683 .map(sid)
684 .collect::<Vec<_>>();
685 let spec = KFoldSpec {
686 n_splits: 3,
687 shuffle: true,
688 seed: Some(42),
689 };
690
691 let left = spec.split("kfold", &samples).unwrap();
692 let right = spec.split("kfold", &samples).unwrap();
693
694 assert_eq!(left, right);
695 left.validate().unwrap();
696 for fold in &left.folds {
697 assert_eq!(fold.validation_sample_ids.len(), 2);
698 assert_eq!(fold.train_sample_ids.len(), 4);
699 }
700 }
701
702 #[test]
703 fn fold_validation_rejects_overlap() {
704 let fold_set = FoldSet {
705 id: "bad".to_string(),
706 sample_ids: vec![sid("s1"), sid("s2")],
707 folds: vec![FoldAssignment {
708 fold_id: FoldId::new("fold0").unwrap(),
709 train_sample_ids: vec![sid("s1")],
710 validation_sample_ids: vec![sid("s1")],
711 metadata: BTreeMap::new(),
712 }],
713 sample_groups: BTreeMap::new(),
714 partition_mode: FoldPartitionMode::Partition,
715 };
716
717 assert!(fold_set.validate().is_err());
718 }
719
720 #[test]
721 fn fold_validation_rejects_partial_group_maps() {
722 let fold_set = FoldSet {
723 id: "bad-groups".to_string(),
724 sample_ids: vec![sid("s1"), sid("s2")],
725 folds: vec![FoldAssignment {
726 fold_id: FoldId::new("fold0").unwrap(),
727 train_sample_ids: vec![sid("s2")],
728 validation_sample_ids: vec![sid("s1")],
729 metadata: BTreeMap::new(),
730 }],
731 sample_groups: BTreeMap::from([(sid("s1"), gid("g1"))]),
732 partition_mode: FoldPartitionMode::Partition,
733 };
734
735 assert!(fold_set.validate().is_err());
736 }
737
738 #[test]
739 fn fold_set_fingerprint_is_independent_of_ordering() {
740 let mut left = FoldSet {
741 id: "cv.partition".to_string(),
742 sample_ids: vec![sid("s3"), sid("s2"), sid("s1")],
743 folds: vec![
744 FoldAssignment {
745 fold_id: FoldId::new("fold1").unwrap(),
746 train_sample_ids: vec![sid("s2"), sid("s1")],
747 validation_sample_ids: vec![sid("s3")],
748 metadata: BTreeMap::new(),
749 },
750 FoldAssignment {
751 fold_id: FoldId::new("fold0").unwrap(),
752 train_sample_ids: vec![sid("s3")],
753 validation_sample_ids: vec![sid("s2"), sid("s1")],
754 metadata: BTreeMap::new(),
755 },
756 ],
757 sample_groups: BTreeMap::new(),
758 partition_mode: FoldPartitionMode::Partition,
759 };
760 let mut right = left.clone();
761 right.sample_ids.reverse();
762 right.folds.reverse();
763 for fold in &mut right.folds {
764 fold.train_sample_ids.reverse();
765 fold.validation_sample_ids.reverse();
766 }
767
768 assert_eq!(
769 fold_set_fingerprint(&left).unwrap(),
770 fold_set_fingerprint(&right).unwrap()
771 );
772
773 left.id = "cv.partition.changed".to_string();
774 assert_ne!(
775 fold_set_fingerprint(&left).unwrap(),
776 fold_set_fingerprint(&right).unwrap()
777 );
778 }
779
780 #[test]
781 fn shared_fold_set_fixture_fingerprint_is_locked() {
782 let fixture = include_str!("../tests/fixtures/package/fold_set_cv_partition.json");
783 let fold_set = serde_json::from_str::<FoldSet>(fixture).unwrap();
784
785 assert_eq!(
786 fold_set_fingerprint(&fold_set).unwrap(),
787 SHARED_FOLD_SET_FINGERPRINT
788 );
789 }
790
791 #[test]
792 fn group_kfold_keeps_groups_out_of_train_validation_overlap() {
793 let groups = BTreeMap::from([
794 (sid("s1"), gid("g1")),
795 (sid("s2"), gid("g1")),
796 (sid("s3"), gid("g2")),
797 (sid("s4"), gid("g2")),
798 (sid("s5"), gid("g3")),
799 (sid("s6"), gid("g3")),
800 ]);
801 let fold_set = GroupKFoldSpec { n_splits: 3 }
802 .split("group-kfold", &groups)
803 .unwrap();
804
805 fold_set.validate().unwrap();
806 for fold in &fold_set.folds {
807 let train_groups = fold
808 .train_sample_ids
809 .iter()
810 .map(|sample_id| groups.get(sample_id).unwrap())
811 .collect::<BTreeSet<_>>();
812 for sample_id in &fold.validation_sample_ids {
813 assert!(!train_groups.contains(groups.get(sample_id).unwrap()));
814 }
815 }
816 }
817
818 #[test]
819 fn stratified_kfold_is_oof_safe_and_balances_classes() {
820 let samples = (0..8).map(|i| sid(&format!("s{i}"))).collect::<Vec<_>>();
822 let strata = BTreeMap::from_iter(samples.iter().enumerate().map(|(i, s)| {
823 (
824 s.clone(),
825 if i % 2 == 0 {
826 "A".to_string()
827 } else {
828 "B".to_string()
829 },
830 )
831 }));
832 let fold_set = StratifiedKFoldSpec {
833 n_splits: 2,
834 shuffle: false,
835 seed: Some(0),
836 }
837 .split("strat", &samples, &strata)
838 .unwrap();
839 fold_set.validate().unwrap(); assert_eq!(fold_set.folds.len(), 2);
841 for fold in &fold_set.folds {
842 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
843 for s in &fold.validation_sample_ids {
844 *counts.entry(strata.get(s).unwrap().as_str()).or_insert(0) += 1;
845 }
846 assert_eq!(counts.get("A"), Some(&2));
847 assert_eq!(counts.get("B"), Some(&2));
848 }
849 }
850
851 #[test]
852 fn stratified_kfold_singleton_classes_leave_no_empty_fold() {
853 let samples = ["s0", "s1", "s2"].into_iter().map(sid).collect::<Vec<_>>();
856 let strata = BTreeMap::from_iter([
857 (sid("s0"), "A".to_string()),
858 (sid("s1"), "B".to_string()),
859 (sid("s2"), "C".to_string()),
860 ]);
861 let fold_set = StratifiedKFoldSpec {
862 n_splits: 3,
863 shuffle: false,
864 seed: Some(0),
865 }
866 .split("strat", &samples, &strata)
867 .expect("singleton-class stratified split must succeed");
868 fold_set.validate().unwrap();
869 for fold in &fold_set.folds {
870 assert_eq!(fold.validation_sample_ids.len(), 1);
871 }
872 }
873
874 #[test]
875 fn stratified_kfold_rejects_missing_label() {
876 let samples = (0..4).map(|i| sid(&format!("s{i}"))).collect::<Vec<_>>();
877 let strata = BTreeMap::from_iter([(sid("s0"), "A".to_string())]); let err = StratifiedKFoldSpec {
879 n_splits: 2,
880 shuffle: false,
881 seed: Some(0),
882 }
883 .split("strat", &samples, &strata);
884 assert!(err.is_err());
885 }
886
887 fn outer_kfold(samples: &[SampleId]) -> FoldSet {
888 KFoldSpec {
889 n_splits: 2,
890 shuffle: false,
891 seed: Some(0),
892 }
893 .split("outer", samples)
894 .unwrap()
895 }
896
897 #[test]
898 fn nested_kfold_inner_folds_are_subset_of_outer_train() {
899 let samples = ["s1", "s2", "s3", "s4", "s5", "s6"]
900 .into_iter()
901 .map(sid)
902 .collect::<Vec<_>>();
903 let outer = outer_kfold(&samples);
904 let spec = NestedCvSpec::KFold(KFoldSpec {
905 n_splits: 2,
906 shuffle: false,
907 seed: Some(1),
908 });
909 for outer_fold in &outer.folds {
910 let inner = spec
911 .build_inner_fold_set(outer_fold, &outer.sample_groups)
912 .expect("inner fold set");
913 let outer_train = outer_fold.train_sample_ids.iter().collect::<BTreeSet<_>>();
914 for sample_id in &inner.sample_ids {
916 assert!(outer_train.contains(sample_id));
917 }
918 inner.validate().unwrap();
920 assert_eq!(
921 inner.sample_ids.iter().collect::<BTreeSet<_>>(),
922 outer_train
923 );
924 }
925 }
926
927 #[test]
928 fn nested_fold_set_binds_inner_evidence_to_its_outer_fold() {
929 let samples = ["s1", "s2", "s3", "s4", "s5", "s6"]
930 .into_iter()
931 .map(sid)
932 .collect::<Vec<_>>();
933 let outer = outer_kfold(&samples);
934 let spec = NestedCvSpec::KFold(KFoldSpec {
935 n_splits: 2,
936 shuffle: false,
937 seed: Some(1),
938 });
939 let first = &outer.folds[0];
940 let nested = spec
941 .build_nested_fold_set(first, &outer.sample_groups)
942 .expect("nested fold set");
943 assert_eq!(nested.parent_outer_fold_id, first.fold_id);
944 nested.validate_for_outer(first).unwrap();
945 assert!(nested.inner_fold_set.folds.iter().all(|fold| fold
946 .fold_id
947 .as_str()
948 .starts_with(&format!("{}.inner.", first.fold_id))));
949
950 let second = &outer.folds[1];
951 let error = nested
952 .validate_for_outer(second)
953 .expect_err("inner evidence must not be transplantable across outer folds");
954 assert!(error.to_string().contains("parent fold"));
955 }
956
957 #[test]
958 fn nested_cv_validation_refuses_inner_sample_from_outer_validation() {
959 let samples = ["s1", "s2", "s3", "s4"]
960 .into_iter()
961 .map(sid)
962 .collect::<Vec<_>>();
963 let outer = outer_kfold(&samples);
964 let outer_fold = &outer.folds[0];
965 let leaking_sample = outer_fold.validation_sample_ids[0].clone();
968 let train_sample = outer_fold.train_sample_ids[0].clone();
969 let inner = FoldSet {
970 id: "leaky.inner".to_string(),
971 sample_ids: vec![train_sample.clone(), leaking_sample.clone()],
972 folds: vec![
973 FoldAssignment {
974 fold_id: FoldId::new("if0").unwrap(),
975 train_sample_ids: vec![leaking_sample.clone()],
976 validation_sample_ids: vec![train_sample.clone()],
977 metadata: BTreeMap::new(),
978 },
979 FoldAssignment {
980 fold_id: FoldId::new("if1").unwrap(),
981 train_sample_ids: vec![train_sample],
982 validation_sample_ids: vec![leaking_sample],
983 metadata: BTreeMap::new(),
984 },
985 ],
986 sample_groups: BTreeMap::new(),
987 partition_mode: FoldPartitionMode::Partition,
988 };
989 inner
990 .validate()
991 .expect("inner fold set is structurally valid");
992 let err = validate_inner_fold_set_within_outer(&inner, outer_fold)
993 .expect_err("inner fold leaking an outer-validation sample must be refused");
994 assert!(err.to_string().contains("nested CV leakage"));
995 }
996
997 #[test]
998 fn nested_cv_validation_refuses_leak_hidden_in_fold_members() {
999 let samples = ["s1", "s2", "s3", "s4"]
1003 .into_iter()
1004 .map(sid)
1005 .collect::<Vec<_>>();
1006 let outer = outer_kfold(&samples);
1007 let outer_fold = &outer.folds[0];
1008 let leaking_sample = outer_fold.validation_sample_ids[0].clone();
1009 let train_sample = outer_fold.train_sample_ids[0].clone();
1010 let inner = FoldSet {
1011 id: "hidden.inner".to_string(),
1012 sample_ids: vec![train_sample.clone()],
1014 folds: vec![FoldAssignment {
1015 fold_id: FoldId::new("if0").unwrap(),
1016 train_sample_ids: vec![train_sample],
1017 validation_sample_ids: vec![leaking_sample],
1018 metadata: BTreeMap::new(),
1019 }],
1020 sample_groups: BTreeMap::new(),
1021 partition_mode: FoldPartitionMode::Partition,
1022 };
1023 assert!(validate_inner_fold_set_within_outer(&inner, outer_fold).is_err());
1024 }
1025
1026 #[test]
1027 fn nested_cv_spec_json_shape_is_stable() {
1028 let spec = NestedCvSpec::KFold(KFoldSpec {
1029 n_splits: 3,
1030 shuffle: false,
1031 seed: Some(7),
1032 });
1033 let value = serde_json::to_value(&spec).unwrap();
1034 assert_eq!(value["kind"], "kfold");
1035 assert_eq!(value["n_splits"], 3);
1036 assert_eq!(value["seed"], 7);
1037 let round: NestedCvSpec = serde_json::from_value(value).unwrap();
1038 assert_eq!(round, spec);
1039
1040 let group = NestedCvSpec::GroupKFold(GroupKFoldSpec { n_splits: 2 });
1041 let gv = serde_json::to_value(&group).unwrap();
1042 assert_eq!(gv["kind"], "group_kfold");
1043 assert_eq!(gv["n_splits"], 2);
1044 assert_eq!(serde_json::from_value::<NestedCvSpec>(gv).unwrap(), group);
1045 }
1046
1047 #[test]
1048 fn resolve_inner_cv_prefers_node_over_campaign() {
1049 let node = NestedCvSpec::KFold(KFoldSpec {
1050 n_splits: 3,
1051 shuffle: false,
1052 seed: Some(2),
1053 });
1054 let campaign = NestedCvSpec::KFold(KFoldSpec {
1055 n_splits: 5,
1056 shuffle: false,
1057 seed: Some(3),
1058 });
1059 assert_eq!(resolve_inner_cv(Some(&node), Some(&campaign)), Some(&node));
1060 assert_eq!(resolve_inner_cv(None, Some(&campaign)), Some(&campaign));
1061 assert_eq!(resolve_inner_cv(Some(&node), None), Some(&node));
1062 assert_eq!(resolve_inner_cv(None, None), None);
1063 }
1064
1065 #[test]
1066 fn resampled_mode_allows_non_oof_validation_but_still_blocks_leakage() {
1067 let fold = |id: &str, train: &[&str], val: &[&str]| FoldAssignment {
1068 fold_id: FoldId::new(id).unwrap(),
1069 train_sample_ids: train.iter().map(|s| sid(s)).collect(),
1070 validation_sample_ids: val.iter().map(|s| sid(s)).collect(),
1071 metadata: BTreeMap::new(),
1072 };
1073 let samples = vec![sid("s1"), sid("s2"), sid("s3"), sid("s4")];
1074 let folds = vec![
1076 fold("f0", &["s3", "s4"], &["s1", "s2"]),
1077 fold("f1", &["s2", "s4"], &["s1", "s3"]),
1078 ];
1079
1080 let partition = FoldSet {
1081 id: "partition".to_string(),
1082 sample_ids: samples.clone(),
1083 folds: folds.clone(),
1084 sample_groups: BTreeMap::new(),
1085 partition_mode: FoldPartitionMode::Partition,
1086 };
1087 assert!(
1088 partition.validate().is_err(),
1089 "Partition mode must reject non-OOF validation"
1090 );
1091
1092 let resampled = FoldSet {
1093 id: "resampled".to_string(),
1094 sample_ids: samples,
1095 folds,
1096 sample_groups: BTreeMap::new(),
1097 partition_mode: FoldPartitionMode::Resampled,
1098 };
1099 resampled.validate().unwrap(); let leaky = FoldSet {
1103 id: "leaky".to_string(),
1104 sample_ids: vec![sid("s1"), sid("s2")],
1105 folds: vec![fold("f", &["s1"], &["s1"])],
1106 sample_groups: BTreeMap::new(),
1107 partition_mode: FoldPartitionMode::Resampled,
1108 };
1109 assert!(
1110 leaky.validate().is_err(),
1111 "Resampled must still reject train/validation overlap"
1112 );
1113 }
1114}