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 value.sort_all_objects();
198 stable_json_fingerprint(&value)
199}
200
201fn remove_empty_fold_set_maps(value: &mut serde_json::Value) {
202 let Some(object) = value.as_object_mut() else {
203 return;
204 };
205 if object
206 .get("sample_groups")
207 .and_then(serde_json::Value::as_object)
208 .is_some_and(serde_json::Map::is_empty)
209 {
210 object.remove("sample_groups");
211 }
212 let Some(folds) = object
213 .get_mut("folds")
214 .and_then(serde_json::Value::as_array_mut)
215 else {
216 return;
217 };
218 for fold in folds {
219 let Some(fold_object) = fold.as_object_mut() else {
220 continue;
221 };
222 if fold_object
223 .get("metadata")
224 .and_then(serde_json::Value::as_object)
225 .is_some_and(serde_json::Map::is_empty)
226 {
227 fold_object.remove("metadata");
228 }
229 }
230}
231
232#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
233pub struct KFoldSpec {
234 pub n_splits: usize,
235 #[serde(default)]
236 pub shuffle: bool,
237 pub seed: Option<u64>,
238}
239
240impl KFoldSpec {
241 pub fn split(&self, id: impl Into<String>, samples: &[SampleId]) -> Result<FoldSet> {
242 if self.n_splits < 2 {
243 return Err(DagMlError::OofValidation(
244 "KFold requires at least two splits".to_string(),
245 ));
246 }
247 let unique = unique_samples("KFold samples", samples)?;
248 if self.n_splits > unique.len() {
249 return Err(DagMlError::OofValidation(format!(
250 "KFold n_splits={} exceeds sample count {}",
251 self.n_splits,
252 unique.len()
253 )));
254 }
255 let ordered = ordered_samples(samples, self.shuffle, self.seed.unwrap_or(0));
256 let folds = (0..self.n_splits)
257 .map(|fold_idx| {
258 let validation = ordered
259 .iter()
260 .enumerate()
261 .filter_map(|(idx, sample_id)| {
262 (idx % self.n_splits == fold_idx).then_some(sample_id.clone())
263 })
264 .collect::<Vec<_>>();
265 let validation_set = validation.iter().collect::<BTreeSet<_>>();
266 let train = ordered
267 .iter()
268 .filter(|sample_id| !validation_set.contains(sample_id))
269 .cloned()
270 .collect::<Vec<_>>();
271 Ok(FoldAssignment {
272 fold_id: FoldId::new(format!("fold{fold_idx}"))?,
273 train_sample_ids: train,
274 validation_sample_ids: validation,
275 metadata: BTreeMap::new(),
276 })
277 })
278 .collect::<Result<Vec<_>>>()?;
279 let fold_set = FoldSet {
280 id: id.into(),
281 sample_ids: ordered_samples(samples, false, 0),
282 folds,
283 sample_groups: BTreeMap::new(),
284 partition_mode: FoldPartitionMode::Partition,
285 };
286 fold_set.validate()?;
287 Ok(fold_set)
288 }
289}
290
291#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
296pub struct StratifiedKFoldSpec {
297 pub n_splits: usize,
298 #[serde(default)]
299 pub shuffle: bool,
300 pub seed: Option<u64>,
301}
302
303#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
312pub struct StratifiedNestedCvSpec {
313 #[serde(flatten)]
314 pub splitter: StratifiedKFoldSpec,
315 pub strata: BTreeMap<SampleId, String>,
316}
317
318impl StratifiedKFoldSpec {
319 pub fn split(
320 &self,
321 id: impl Into<String>,
322 samples: &[SampleId],
323 strata: &BTreeMap<SampleId, String>,
324 ) -> Result<FoldSet> {
325 if self.n_splits < 2 {
326 return Err(DagMlError::OofValidation(
327 "StratifiedKFold requires at least two splits".to_string(),
328 ));
329 }
330 let unique = unique_samples("StratifiedKFold samples", samples)?;
331 if self.n_splits > unique.len() {
332 return Err(DagMlError::OofValidation(format!(
333 "StratifiedKFold n_splits={} exceeds sample count {}",
334 self.n_splits,
335 unique.len()
336 )));
337 }
338 let ordered = ordered_samples(samples, self.shuffle, self.seed.unwrap_or(0));
345 let mut by_label: BTreeMap<String, Vec<SampleId>> = BTreeMap::new();
346 for sample_id in &ordered {
347 let label = strata.get(sample_id).ok_or_else(|| {
348 DagMlError::OofValidation(format!(
349 "StratifiedKFold: sample `{sample_id}` has no stratum label"
350 ))
351 })?;
352 by_label
353 .entry(label.clone())
354 .or_default()
355 .push(sample_id.clone());
356 }
357 let mut fold_of: BTreeMap<SampleId, usize> = BTreeMap::new();
358 let mut position = 0usize;
359 for members in by_label.values() {
360 for sample_id in members {
361 fold_of.insert(sample_id.clone(), position % self.n_splits);
362 position += 1;
363 }
364 }
365 let folds = (0..self.n_splits)
366 .map(|fold_idx| {
367 let validation = ordered
368 .iter()
369 .filter(|s| fold_of.get(*s) == Some(&fold_idx))
370 .cloned()
371 .collect::<Vec<_>>();
372 let train = ordered
373 .iter()
374 .filter(|s| fold_of.get(*s) != Some(&fold_idx))
375 .cloned()
376 .collect::<Vec<_>>();
377 Ok(FoldAssignment {
378 fold_id: FoldId::new(format!("fold{fold_idx}"))?,
379 train_sample_ids: train,
380 validation_sample_ids: validation,
381 metadata: BTreeMap::new(),
382 })
383 })
384 .collect::<Result<Vec<_>>>()?;
385 let fold_set = FoldSet {
386 id: id.into(),
387 sample_ids: ordered_samples(samples, false, 0),
388 folds,
389 sample_groups: BTreeMap::new(),
390 partition_mode: FoldPartitionMode::Partition,
391 };
392 fold_set.validate()?;
393 Ok(fold_set)
394 }
395}
396
397#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
398pub struct GroupKFoldSpec {
399 pub n_splits: usize,
400}
401
402impl GroupKFoldSpec {
403 pub fn split(
404 &self,
405 id: impl Into<String>,
406 sample_groups: &BTreeMap<SampleId, GroupId>,
407 ) -> Result<FoldSet> {
408 if self.n_splits < 2 {
409 return Err(DagMlError::OofValidation(
410 "GroupKFold requires at least two splits".to_string(),
411 ));
412 }
413 if sample_groups.is_empty() {
414 return Err(DagMlError::OofValidation(
415 "GroupKFold requires sample groups".to_string(),
416 ));
417 }
418 let mut groups = BTreeMap::<GroupId, Vec<SampleId>>::new();
419 for (sample_id, group_id) in sample_groups {
420 groups
421 .entry(group_id.clone())
422 .or_default()
423 .push(sample_id.clone());
424 }
425 if self.n_splits > groups.len() {
426 return Err(DagMlError::OofValidation(format!(
427 "GroupKFold n_splits={} exceeds group count {}",
428 self.n_splits,
429 groups.len()
430 )));
431 }
432
433 let mut grouped = groups.into_iter().collect::<Vec<_>>();
434 grouped.sort_by(|(left_group, left_samples), (right_group, right_samples)| {
435 right_samples
436 .len()
437 .cmp(&left_samples.len())
438 .then_with(|| left_group.cmp(right_group))
439 });
440
441 let mut fold_validation = vec![Vec::<SampleId>::new(); self.n_splits];
442 for (_group_id, mut samples) in grouped {
443 samples.sort();
444 let fold_idx = fold_validation
445 .iter()
446 .enumerate()
447 .min_by(|(left_idx, left), (right_idx, right)| {
448 left.len()
449 .cmp(&right.len())
450 .then_with(|| left_idx.cmp(right_idx))
451 })
452 .map(|(idx, _)| idx)
453 .expect("at least one fold");
454 fold_validation[fold_idx].extend(samples);
455 }
456
457 let mut sample_ids = sample_groups.keys().cloned().collect::<Vec<_>>();
458 sample_ids.sort();
459 let folds = fold_validation
460 .into_iter()
461 .enumerate()
462 .map(|(fold_idx, mut validation)| {
463 validation.sort();
464 let validation_set = validation.iter().collect::<BTreeSet<_>>();
465 let train = sample_ids
466 .iter()
467 .filter(|sample_id| !validation_set.contains(sample_id))
468 .cloned()
469 .collect::<Vec<_>>();
470 Ok(FoldAssignment {
471 fold_id: FoldId::new(format!("fold{fold_idx}"))?,
472 train_sample_ids: train,
473 validation_sample_ids: validation,
474 metadata: BTreeMap::new(),
475 })
476 })
477 .collect::<Result<Vec<_>>>()?;
478
479 let fold_set = FoldSet {
480 id: id.into(),
481 sample_ids,
482 folds,
483 sample_groups: sample_groups.clone(),
484 partition_mode: FoldPartitionMode::Partition,
485 };
486 fold_set.validate()?;
487 Ok(fold_set)
488 }
489}
490
491#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
500#[serde(tag = "kind")]
501pub enum NestedCvSpec {
502 #[serde(rename = "kfold")]
504 KFold(KFoldSpec),
505 #[serde(rename = "group_kfold")]
507 GroupKFold(GroupKFoldSpec),
508 #[serde(rename = "stratified_kfold")]
510 StratifiedKFold(StratifiedNestedCvSpec),
511}
512
513#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
517pub struct NestedFoldSet {
518 pub parent_outer_fold_id: FoldId,
519 pub inner_fold_set: FoldSet,
520}
521
522impl NestedFoldSet {
523 pub fn validate_for_outer(&self, outer: &FoldAssignment) -> Result<()> {
527 if self.parent_outer_fold_id != outer.fold_id {
528 return Err(DagMlError::OofValidation(format!(
529 "nested CV parent fold `{}` does not match outer fold `{}`",
530 self.parent_outer_fold_id, outer.fold_id
531 )));
532 }
533 validate_inner_fold_set_within_outer(&self.inner_fold_set, outer)
534 }
535}
536
537impl NestedCvSpec {
538 pub fn validate(&self) -> Result<()> {
542 match self {
543 Self::KFold(spec) => {
544 if spec.n_splits < 2 {
545 return Err(DagMlError::OofValidation(
546 "inner KFold requires at least two splits".to_string(),
547 ));
548 }
549 }
550 Self::GroupKFold(spec) => {
551 if spec.n_splits < 2 {
552 return Err(DagMlError::OofValidation(
553 "inner GroupKFold requires at least two splits".to_string(),
554 ));
555 }
556 }
557 Self::StratifiedKFold(spec) => {
558 if spec.splitter.n_splits < 2 {
559 return Err(DagMlError::OofValidation(
560 "inner StratifiedKFold requires at least two splits".to_string(),
561 ));
562 }
563 if spec.strata.is_empty() || spec.strata.values().any(|label| label.is_empty()) {
564 return Err(DagMlError::OofValidation(
565 "inner StratifiedKFold requires non-empty identity-keyed strata"
566 .to_string(),
567 ));
568 }
569 }
570 }
571 Ok(())
572 }
573
574 pub fn build_inner_fold_set(
579 &self,
580 outer: &FoldAssignment,
581 outer_groups: &BTreeMap<SampleId, GroupId>,
582 ) -> Result<FoldSet> {
583 Ok(self
584 .build_nested_fold_set(outer, outer_groups)?
585 .inner_fold_set)
586 }
587
588 pub fn build_nested_fold_set(
592 &self,
593 outer: &FoldAssignment,
594 outer_groups: &BTreeMap<SampleId, GroupId>,
595 ) -> Result<NestedFoldSet> {
596 let inner_id = format!("{}.inner", outer.fold_id);
597 let mut inner = match self {
598 Self::KFold(spec) => spec.split(inner_id, &outer.train_sample_ids)?,
599 Self::GroupKFold(spec) => {
600 let train = outer.train_sample_ids.iter().collect::<BTreeSet<_>>();
601 let inner_groups = outer_groups
602 .iter()
603 .filter(|(sample_id, _)| train.contains(sample_id))
604 .map(|(sample_id, group_id)| (sample_id.clone(), group_id.clone()))
605 .collect::<BTreeMap<_, _>>();
606 spec.split(inner_id, &inner_groups)?
607 }
608 Self::StratifiedKFold(spec) => {
609 let train = outer.train_sample_ids.iter().collect::<BTreeSet<_>>();
610 let inner_strata = spec
611 .strata
612 .iter()
613 .filter(|(sample_id, _)| train.contains(sample_id))
614 .map(|(sample_id, label)| (sample_id.clone(), label.clone()))
615 .collect::<BTreeMap<_, _>>();
616 spec.splitter
617 .split(inner_id, &outer.train_sample_ids, &inner_strata)?
618 }
619 };
620 for fold in &mut inner.folds {
626 fold.fold_id = FoldId::new(format!("{}.inner.{}", outer.fold_id, fold.fold_id))?;
627 }
628 let nested = NestedFoldSet {
629 parent_outer_fold_id: outer.fold_id.clone(),
630 inner_fold_set: inner,
631 };
632 nested.validate_for_outer(outer)?;
633 Ok(nested)
634 }
635}
636
637pub fn resolve_inner_cv<'a>(
640 node_inner_cv: Option<&'a NestedCvSpec>,
641 campaign_inner_cv: Option<&'a NestedCvSpec>,
642) -> Option<&'a NestedCvSpec> {
643 node_inner_cv.or(campaign_inner_cv)
644}
645
646pub fn validate_inner_fold_set_within_outer(inner: &FoldSet, outer: &FoldAssignment) -> Result<()> {
652 inner.validate()?;
656 let train = outer.train_sample_ids.iter().collect::<BTreeSet<_>>();
657 let ensure_train = |sample_id: &SampleId| -> Result<()> {
658 if !train.contains(sample_id) {
659 return Err(DagMlError::OofValidation(format!(
660 "nested CV leakage: inner-CV sample `{sample_id}` for outer fold `{}` is not an outer training sample",
661 outer.fold_id
662 )));
663 }
664 Ok(())
665 };
666 for sample_id in &inner.sample_ids {
667 ensure_train(sample_id)?;
668 }
669 for fold in &inner.folds {
672 for sample_id in fold
673 .train_sample_ids
674 .iter()
675 .chain(&fold.validation_sample_ids)
676 {
677 ensure_train(sample_id)?;
678 }
679 }
680 Ok(())
681}
682
683fn unique_samples<'a>(label: &str, samples: &'a [SampleId]) -> Result<BTreeSet<&'a SampleId>> {
684 let mut seen = BTreeSet::new();
685 for sample_id in samples {
686 if !seen.insert(sample_id) {
687 return Err(DagMlError::OofValidation(format!(
688 "{label} contains duplicate sample `{sample_id}`"
689 )));
690 }
691 }
692 Ok(seen)
693}
694
695fn ordered_samples(samples: &[SampleId], shuffle: bool, seed: u64) -> Vec<SampleId> {
696 let mut ordered = samples.to_vec();
697 ordered.sort();
698 if shuffle {
699 let context = SeedContext::root(seed).child("kfold");
700 ordered.sort_by(|left, right| {
701 context
702 .derive_u64(left.as_str())
703 .cmp(&context.derive_u64(right.as_str()))
704 .then_with(|| left.cmp(right))
705 });
706 }
707 ordered
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713
714 const SHARED_FOLD_SET_FINGERPRINT: &str =
715 "54d3185d6c628ef0df848828a8d8ae650222a283a78bbd3ab3bc2256f222c05c";
716
717 fn sid(value: &str) -> SampleId {
718 SampleId::new(value).unwrap()
719 }
720
721 fn gid(value: &str) -> GroupId {
722 GroupId::new(value).unwrap()
723 }
724
725 #[test]
726 fn kfold_is_deterministic_and_covers_samples_once() {
727 let samples = ["s1", "s2", "s3", "s4", "s5", "s6"]
728 .into_iter()
729 .map(sid)
730 .collect::<Vec<_>>();
731 let spec = KFoldSpec {
732 n_splits: 3,
733 shuffle: true,
734 seed: Some(42),
735 };
736
737 let left = spec.split("kfold", &samples).unwrap();
738 let right = spec.split("kfold", &samples).unwrap();
739
740 assert_eq!(left, right);
741 left.validate().unwrap();
742 for fold in &left.folds {
743 assert_eq!(fold.validation_sample_ids.len(), 2);
744 assert_eq!(fold.train_sample_ids.len(), 4);
745 }
746 }
747
748 #[test]
749 fn fold_validation_rejects_overlap() {
750 let fold_set = FoldSet {
751 id: "bad".to_string(),
752 sample_ids: vec![sid("s1"), sid("s2")],
753 folds: vec![FoldAssignment {
754 fold_id: FoldId::new("fold0").unwrap(),
755 train_sample_ids: vec![sid("s1")],
756 validation_sample_ids: vec![sid("s1")],
757 metadata: BTreeMap::new(),
758 }],
759 sample_groups: BTreeMap::new(),
760 partition_mode: FoldPartitionMode::Partition,
761 };
762
763 assert!(fold_set.validate().is_err());
764 }
765
766 #[test]
767 fn fold_validation_rejects_partial_group_maps() {
768 let fold_set = FoldSet {
769 id: "bad-groups".to_string(),
770 sample_ids: vec![sid("s1"), sid("s2")],
771 folds: vec![FoldAssignment {
772 fold_id: FoldId::new("fold0").unwrap(),
773 train_sample_ids: vec![sid("s2")],
774 validation_sample_ids: vec![sid("s1")],
775 metadata: BTreeMap::new(),
776 }],
777 sample_groups: BTreeMap::from([(sid("s1"), gid("g1"))]),
778 partition_mode: FoldPartitionMode::Partition,
779 };
780
781 assert!(fold_set.validate().is_err());
782 }
783
784 #[test]
785 fn fold_set_fingerprint_is_independent_of_ordering() {
786 let mut left = FoldSet {
787 id: "cv.partition".to_string(),
788 sample_ids: vec![sid("s3"), sid("s2"), sid("s1")],
789 folds: vec![
790 FoldAssignment {
791 fold_id: FoldId::new("fold1").unwrap(),
792 train_sample_ids: vec![sid("s2"), sid("s1")],
793 validation_sample_ids: vec![sid("s3")],
794 metadata: BTreeMap::new(),
795 },
796 FoldAssignment {
797 fold_id: FoldId::new("fold0").unwrap(),
798 train_sample_ids: vec![sid("s3")],
799 validation_sample_ids: vec![sid("s2"), sid("s1")],
800 metadata: BTreeMap::new(),
801 },
802 ],
803 sample_groups: BTreeMap::new(),
804 partition_mode: FoldPartitionMode::Partition,
805 };
806 let mut right = left.clone();
807 right.sample_ids.reverse();
808 right.folds.reverse();
809 for fold in &mut right.folds {
810 fold.train_sample_ids.reverse();
811 fold.validation_sample_ids.reverse();
812 }
813
814 assert_eq!(
815 fold_set_fingerprint(&left).unwrap(),
816 fold_set_fingerprint(&right).unwrap()
817 );
818
819 left.id = "cv.partition.changed".to_string();
820 assert_ne!(
821 fold_set_fingerprint(&left).unwrap(),
822 fold_set_fingerprint(&right).unwrap()
823 );
824 }
825
826 #[test]
827 fn shared_fold_set_fixture_fingerprint_is_locked() {
828 let fixture = include_str!("../tests/fixtures/package/fold_set_cv_partition.json");
829 let fold_set = serde_json::from_str::<FoldSet>(fixture).unwrap();
830
831 assert_eq!(
832 fold_set_fingerprint(&fold_set).unwrap(),
833 SHARED_FOLD_SET_FINGERPRINT
834 );
835 }
836
837 #[test]
838 fn group_kfold_keeps_groups_out_of_train_validation_overlap() {
839 let groups = BTreeMap::from([
840 (sid("s1"), gid("g1")),
841 (sid("s2"), gid("g1")),
842 (sid("s3"), gid("g2")),
843 (sid("s4"), gid("g2")),
844 (sid("s5"), gid("g3")),
845 (sid("s6"), gid("g3")),
846 ]);
847 let fold_set = GroupKFoldSpec { n_splits: 3 }
848 .split("group-kfold", &groups)
849 .unwrap();
850
851 fold_set.validate().unwrap();
852 for fold in &fold_set.folds {
853 let train_groups = fold
854 .train_sample_ids
855 .iter()
856 .map(|sample_id| groups.get(sample_id).unwrap())
857 .collect::<BTreeSet<_>>();
858 for sample_id in &fold.validation_sample_ids {
859 assert!(!train_groups.contains(groups.get(sample_id).unwrap()));
860 }
861 }
862 }
863
864 #[test]
865 fn stratified_kfold_is_oof_safe_and_balances_classes() {
866 let samples = (0..8).map(|i| sid(&format!("s{i}"))).collect::<Vec<_>>();
868 let strata = BTreeMap::from_iter(samples.iter().enumerate().map(|(i, s)| {
869 (
870 s.clone(),
871 if i % 2 == 0 {
872 "A".to_string()
873 } else {
874 "B".to_string()
875 },
876 )
877 }));
878 let fold_set = StratifiedKFoldSpec {
879 n_splits: 2,
880 shuffle: false,
881 seed: Some(0),
882 }
883 .split("strat", &samples, &strata)
884 .unwrap();
885 fold_set.validate().unwrap(); assert_eq!(fold_set.folds.len(), 2);
887 for fold in &fold_set.folds {
888 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
889 for s in &fold.validation_sample_ids {
890 *counts.entry(strata.get(s).unwrap().as_str()).or_insert(0) += 1;
891 }
892 assert_eq!(counts.get("A"), Some(&2));
893 assert_eq!(counts.get("B"), Some(&2));
894 }
895 }
896
897 #[test]
898 fn stratified_kfold_singleton_classes_leave_no_empty_fold() {
899 let samples = ["s0", "s1", "s2"].into_iter().map(sid).collect::<Vec<_>>();
902 let strata = BTreeMap::from_iter([
903 (sid("s0"), "A".to_string()),
904 (sid("s1"), "B".to_string()),
905 (sid("s2"), "C".to_string()),
906 ]);
907 let fold_set = StratifiedKFoldSpec {
908 n_splits: 3,
909 shuffle: false,
910 seed: Some(0),
911 }
912 .split("strat", &samples, &strata)
913 .expect("singleton-class stratified split must succeed");
914 fold_set.validate().unwrap();
915 for fold in &fold_set.folds {
916 assert_eq!(fold.validation_sample_ids.len(), 1);
917 }
918 }
919
920 #[test]
921 fn stratified_kfold_rejects_missing_label() {
922 let samples = (0..4).map(|i| sid(&format!("s{i}"))).collect::<Vec<_>>();
923 let strata = BTreeMap::from_iter([(sid("s0"), "A".to_string())]); let err = StratifiedKFoldSpec {
925 n_splits: 2,
926 shuffle: false,
927 seed: Some(0),
928 }
929 .split("strat", &samples, &strata);
930 assert!(err.is_err());
931 }
932
933 fn outer_kfold(samples: &[SampleId]) -> FoldSet {
934 KFoldSpec {
935 n_splits: 2,
936 shuffle: false,
937 seed: Some(0),
938 }
939 .split("outer", samples)
940 .unwrap()
941 }
942
943 #[test]
944 fn nested_kfold_inner_folds_are_subset_of_outer_train() {
945 let samples = ["s1", "s2", "s3", "s4", "s5", "s6"]
946 .into_iter()
947 .map(sid)
948 .collect::<Vec<_>>();
949 let outer = outer_kfold(&samples);
950 let spec = NestedCvSpec::KFold(KFoldSpec {
951 n_splits: 2,
952 shuffle: false,
953 seed: Some(1),
954 });
955 for outer_fold in &outer.folds {
956 let inner = spec
957 .build_inner_fold_set(outer_fold, &outer.sample_groups)
958 .expect("inner fold set");
959 let outer_train = outer_fold.train_sample_ids.iter().collect::<BTreeSet<_>>();
960 for sample_id in &inner.sample_ids {
962 assert!(outer_train.contains(sample_id));
963 }
964 inner.validate().unwrap();
966 assert_eq!(
967 inner.sample_ids.iter().collect::<BTreeSet<_>>(),
968 outer_train
969 );
970 }
971 }
972
973 #[test]
974 fn nested_fold_set_binds_inner_evidence_to_its_outer_fold() {
975 let samples = ["s1", "s2", "s3", "s4", "s5", "s6"]
976 .into_iter()
977 .map(sid)
978 .collect::<Vec<_>>();
979 let outer = outer_kfold(&samples);
980 let spec = NestedCvSpec::KFold(KFoldSpec {
981 n_splits: 2,
982 shuffle: false,
983 seed: Some(1),
984 });
985 let first = &outer.folds[0];
986 let nested = spec
987 .build_nested_fold_set(first, &outer.sample_groups)
988 .expect("nested fold set");
989 assert_eq!(nested.parent_outer_fold_id, first.fold_id);
990 nested.validate_for_outer(first).unwrap();
991 assert!(nested.inner_fold_set.folds.iter().all(|fold| fold
992 .fold_id
993 .as_str()
994 .starts_with(&format!("{}.inner.", first.fold_id))));
995
996 let second = &outer.folds[1];
997 let error = nested
998 .validate_for_outer(second)
999 .expect_err("inner evidence must not be transplantable across outer folds");
1000 assert!(error.to_string().contains("parent fold"));
1001 }
1002
1003 #[test]
1004 fn nested_cv_validation_refuses_inner_sample_from_outer_validation() {
1005 let samples = ["s1", "s2", "s3", "s4"]
1006 .into_iter()
1007 .map(sid)
1008 .collect::<Vec<_>>();
1009 let outer = outer_kfold(&samples);
1010 let outer_fold = &outer.folds[0];
1011 let leaking_sample = outer_fold.validation_sample_ids[0].clone();
1014 let train_sample = outer_fold.train_sample_ids[0].clone();
1015 let inner = FoldSet {
1016 id: "leaky.inner".to_string(),
1017 sample_ids: vec![train_sample.clone(), leaking_sample.clone()],
1018 folds: vec![
1019 FoldAssignment {
1020 fold_id: FoldId::new("if0").unwrap(),
1021 train_sample_ids: vec![leaking_sample.clone()],
1022 validation_sample_ids: vec![train_sample.clone()],
1023 metadata: BTreeMap::new(),
1024 },
1025 FoldAssignment {
1026 fold_id: FoldId::new("if1").unwrap(),
1027 train_sample_ids: vec![train_sample],
1028 validation_sample_ids: vec![leaking_sample],
1029 metadata: BTreeMap::new(),
1030 },
1031 ],
1032 sample_groups: BTreeMap::new(),
1033 partition_mode: FoldPartitionMode::Partition,
1034 };
1035 inner
1036 .validate()
1037 .expect("inner fold set is structurally valid");
1038 let err = validate_inner_fold_set_within_outer(&inner, outer_fold)
1039 .expect_err("inner fold leaking an outer-validation sample must be refused");
1040 assert!(err.to_string().contains("nested CV leakage"));
1041 }
1042
1043 #[test]
1044 fn nested_cv_validation_refuses_leak_hidden_in_fold_members() {
1045 let samples = ["s1", "s2", "s3", "s4"]
1049 .into_iter()
1050 .map(sid)
1051 .collect::<Vec<_>>();
1052 let outer = outer_kfold(&samples);
1053 let outer_fold = &outer.folds[0];
1054 let leaking_sample = outer_fold.validation_sample_ids[0].clone();
1055 let train_sample = outer_fold.train_sample_ids[0].clone();
1056 let inner = FoldSet {
1057 id: "hidden.inner".to_string(),
1058 sample_ids: vec![train_sample.clone()],
1060 folds: vec![FoldAssignment {
1061 fold_id: FoldId::new("if0").unwrap(),
1062 train_sample_ids: vec![train_sample],
1063 validation_sample_ids: vec![leaking_sample],
1064 metadata: BTreeMap::new(),
1065 }],
1066 sample_groups: BTreeMap::new(),
1067 partition_mode: FoldPartitionMode::Partition,
1068 };
1069 assert!(validate_inner_fold_set_within_outer(&inner, outer_fold).is_err());
1070 }
1071
1072 #[test]
1073 fn nested_cv_spec_json_shape_is_stable() {
1074 let spec = NestedCvSpec::KFold(KFoldSpec {
1075 n_splits: 3,
1076 shuffle: false,
1077 seed: Some(7),
1078 });
1079 let value = serde_json::to_value(&spec).unwrap();
1080 assert_eq!(value["kind"], "kfold");
1081 assert_eq!(value["n_splits"], 3);
1082 assert_eq!(value["seed"], 7);
1083 let round: NestedCvSpec = serde_json::from_value(value).unwrap();
1084 assert_eq!(round, spec);
1085
1086 let group = NestedCvSpec::GroupKFold(GroupKFoldSpec { n_splits: 2 });
1087 let gv = serde_json::to_value(&group).unwrap();
1088 assert_eq!(gv["kind"], "group_kfold");
1089 assert_eq!(gv["n_splits"], 2);
1090 assert_eq!(serde_json::from_value::<NestedCvSpec>(gv).unwrap(), group);
1091
1092 let stratified = NestedCvSpec::StratifiedKFold(StratifiedNestedCvSpec {
1093 splitter: StratifiedKFoldSpec {
1094 n_splits: 2,
1095 shuffle: true,
1096 seed: Some(11),
1097 },
1098 strata: BTreeMap::from([(sid("s0"), "A".to_string()), (sid("s1"), "B".to_string())]),
1099 });
1100 let sv = serde_json::to_value(&stratified).unwrap();
1101 assert_eq!(sv["kind"], "stratified_kfold");
1102 assert_eq!(sv["n_splits"], 2);
1103 assert_eq!(sv["strata"]["s0"], "A");
1104 assert_eq!(
1105 serde_json::from_value::<NestedCvSpec>(sv).unwrap(),
1106 stratified
1107 );
1108 }
1109
1110 #[test]
1111 fn nested_stratified_cv_uses_only_outer_training_labels_and_balances_inner_folds() {
1112 let samples = (0..12)
1113 .map(|index| sid(&format!("s{index}")))
1114 .collect::<Vec<_>>();
1115 let outer = KFoldSpec {
1116 n_splits: 3,
1117 shuffle: false,
1118 seed: Some(0),
1119 }
1120 .split("outer", &samples)
1121 .unwrap();
1122 let outer_fold = &outer.folds[0];
1123 let strata = BTreeMap::from_iter(samples.iter().enumerate().map(|(index, sample)| {
1124 (
1125 sample.clone(),
1126 if index % 2 == 0 { "A" } else { "B" }.to_string(),
1127 )
1128 }));
1129 let spec = NestedCvSpec::StratifiedKFold(StratifiedNestedCvSpec {
1130 splitter: StratifiedKFoldSpec {
1131 n_splits: 2,
1132 shuffle: false,
1133 seed: Some(0),
1134 },
1135 strata: strata.clone(),
1136 });
1137 let nested = spec
1138 .build_nested_fold_set(outer_fold, &BTreeMap::new())
1139 .unwrap();
1140 nested.validate_for_outer(outer_fold).unwrap();
1141 let mut externally_poisoned_strata = strata;
1142 for sample in &outer_fold.validation_sample_ids {
1143 externally_poisoned_strata
1144 .insert(sample.clone(), "poisoned-external-label".to_string());
1145 }
1146 let externally_poisoned = NestedCvSpec::StratifiedKFold(StratifiedNestedCvSpec {
1147 splitter: StratifiedKFoldSpec {
1148 n_splits: 2,
1149 shuffle: false,
1150 seed: Some(0),
1151 },
1152 strata: externally_poisoned_strata,
1153 })
1154 .build_nested_fold_set(outer_fold, &BTreeMap::new())
1155 .unwrap();
1156 assert_eq!(nested, externally_poisoned);
1157 for fold in &nested.inner_fold_set.folds {
1158 let training_labels = fold
1159 .train_sample_ids
1160 .iter()
1161 .map(|sample| {
1162 match sample
1163 .as_str()
1164 .trim_start_matches('s')
1165 .parse::<usize>()
1166 .unwrap()
1167 % 2
1168 {
1169 0 => "A",
1170 _ => "B",
1171 }
1172 })
1173 .collect::<BTreeSet<_>>();
1174 assert_eq!(training_labels, BTreeSet::from(["A", "B"]));
1175 assert!(fold
1176 .train_sample_ids
1177 .iter()
1178 .chain(&fold.validation_sample_ids)
1179 .all(|sample| outer_fold.train_sample_ids.contains(sample)));
1180 }
1181 }
1182
1183 #[test]
1184 fn resolve_inner_cv_prefers_node_over_campaign() {
1185 let node = NestedCvSpec::KFold(KFoldSpec {
1186 n_splits: 3,
1187 shuffle: false,
1188 seed: Some(2),
1189 });
1190 let campaign = NestedCvSpec::KFold(KFoldSpec {
1191 n_splits: 5,
1192 shuffle: false,
1193 seed: Some(3),
1194 });
1195 assert_eq!(resolve_inner_cv(Some(&node), Some(&campaign)), Some(&node));
1196 assert_eq!(resolve_inner_cv(None, Some(&campaign)), Some(&campaign));
1197 assert_eq!(resolve_inner_cv(Some(&node), None), Some(&node));
1198 assert_eq!(resolve_inner_cv(None, None), None);
1199 }
1200
1201 #[test]
1202 fn resampled_mode_allows_non_oof_validation_but_still_blocks_leakage() {
1203 let fold = |id: &str, train: &[&str], val: &[&str]| FoldAssignment {
1204 fold_id: FoldId::new(id).unwrap(),
1205 train_sample_ids: train.iter().map(|s| sid(s)).collect(),
1206 validation_sample_ids: val.iter().map(|s| sid(s)).collect(),
1207 metadata: BTreeMap::new(),
1208 };
1209 let samples = vec![sid("s1"), sid("s2"), sid("s3"), sid("s4")];
1210 let folds = vec![
1212 fold("f0", &["s3", "s4"], &["s1", "s2"]),
1213 fold("f1", &["s2", "s4"], &["s1", "s3"]),
1214 ];
1215
1216 let partition = FoldSet {
1217 id: "partition".to_string(),
1218 sample_ids: samples.clone(),
1219 folds: folds.clone(),
1220 sample_groups: BTreeMap::new(),
1221 partition_mode: FoldPartitionMode::Partition,
1222 };
1223 assert!(
1224 partition.validate().is_err(),
1225 "Partition mode must reject non-OOF validation"
1226 );
1227
1228 let resampled = FoldSet {
1229 id: "resampled".to_string(),
1230 sample_ids: samples,
1231 folds,
1232 sample_groups: BTreeMap::new(),
1233 partition_mode: FoldPartitionMode::Resampled,
1234 };
1235 resampled.validate().unwrap(); let leaky = FoldSet {
1239 id: "leaky".to_string(),
1240 sample_ids: vec![sid("s1"), sid("s2")],
1241 folds: vec![fold("f", &["s1"], &["s1"])],
1242 sample_groups: BTreeMap::new(),
1243 partition_mode: FoldPartitionMode::Resampled,
1244 };
1245 assert!(
1246 leaky.validate().is_err(),
1247 "Resampled must still reject train/validation overlap"
1248 );
1249 }
1250}