Skip to main content

dag_ml_core/
fold.rs

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/// How validation membership is distributed across a fold set.
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum FoldPartitionMode {
23    /// Each sample is validated EXACTLY once — a clean out-of-fold partition (KFold-style). Default.
24    #[default]
25    Partition,
26    /// Validation sets may overlap or omit samples (resampling CV: ShuffleSplit, repeated KFold,
27    /// bootstrap). The per-fold `train ∩ validation = ∅` leakage guard still holds; only OOF
28    /// *completeness* is relaxed. Predictions for a multiply-validated sample are aggregated.
29    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    /// Validation-distribution mode. Skipped when `Partition` (the default), so existing OOF fold
44    /// sets serialize byte-identically — no fingerprint or fixture churn.
45    #[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        // OOF completeness: a clean Partition requires every sample validated exactly once. The
136        // Resampled mode (ShuffleSplit/repeated CV) drops this — a sample may be validated any
137        // number of times — while the per-fold train/validation disjointness (the leakage guard,
138        // enforced above) still holds for every fold.
139        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/// Stratified K-fold: each sample is validated exactly once (OOF-safe like
288/// plain K-fold), but folds are balanced by a per-sample class label so every
289/// fold mirrors the overall class distribution. `strata` maps each sample id to
290/// its class label (identity-keyed metadata — never feature values).
291#[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        // Group samples by class (deterministic label order), preserving the
320        // within-class order, then assign folds by GLOBAL round-robin over that
321        // class-grouped order. Each sample lands in exactly one fold (OOF) and
322        // every class is spread across folds; crucially no fold is left empty
323        // whenever KFold's `n_splits <= n_samples` invariant holds (the previous
324        // per-class counter could pile singleton classes all into fold 0).
325        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/// Inner (nested) cross-validation policy.
473///
474/// Declared globally on the `CampaignSpec` and/or locally on a `NodePlan`
475/// (e.g. a finetune/tuner or branch node); the local policy overrides the global
476/// default (see [`resolve_inner_cv`]). dag-ml builds the inner `FoldSet` from each
477/// outer fold's **training** samples via [`NestedCvSpec::build_inner_fold_set`],
478/// so the inner folds are a subset of outer-train *by construction* — nested CV
479/// cannot leak outer-validation rows into inner tuning.
480#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
481#[serde(tag = "kind")]
482pub enum NestedCvSpec {
483    /// Index-based inner K-fold, built in-core from outer-train samples.
484    #[serde(rename = "kfold")]
485    KFold(KFoldSpec),
486    /// Group-aware inner K-fold, built in-core from outer-train sample groups.
487    #[serde(rename = "group_kfold")]
488    GroupKFold(GroupKFoldSpec),
489}
490
491impl NestedCvSpec {
492    /// Validate the nested-CV policy's parameters independently of any outer fold.
493    /// Mirrors the checks the splitters enforce (`n_splits >= 2`) so a malformed
494    /// declaration is rejected at plan time rather than deferred to FIT_CV.
495    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    /// Build the inner `FoldSet` for one outer fold from its **training** samples
516    /// only. `outer_groups` is the outer `FoldSet.sample_groups` (used by
517    /// `GroupKFold`; ignored otherwise). The result is validated to lie entirely
518    /// within the outer fold's training set.
519    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
542/// Resolve the effective inner-CV policy for a node: a node-local policy
543/// overrides the campaign-global default; `None` means no nested CV.
544pub 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
551/// Enforce the nested-CV invariant: every sample in `inner` — both the top-level
552/// universe and every fold's train/validation members — must be an outer-fold
553/// **training** sample (never an outer-validation sample). Holds by construction
554/// for dag-ml-built inner folds, and also validates inner folds supplied from
555/// elsewhere. Refuses with an OOF-validation error on any leaking sample.
556pub fn validate_inner_fold_set_within_outer(inner: &FoldSet, outer: &FoldAssignment) -> Result<()> {
557    // Ensure the inner fold set is structurally sound first; otherwise a malformed
558    // supplied fold set could hide a leaking sample in a fold while omitting it
559    // from `sample_ids`. After this, fold members are guaranteed ⊆ `sample_ids`.
560    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    // Defence-in-depth: check every fold member directly, independent of the
575    // sample_ids / structural invariants above.
576    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        // 8 samples, 2 classes (4 each); 2-fold stratified → each fold gets 2 of each class.
772        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(); // OOF: each sample validated exactly once
791        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        // Codex repro: 3 singleton classes with n_splits=3 must not pile all
805        // samples into fold0 (which FoldSet.validate rejects as an empty fold1).
806        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())]); // incomplete
829        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            // Every inner sample is an outer training sample.
866            for sample_id in &inner.sample_ids {
867                assert!(outer_train.contains(sample_id));
868            }
869            // The inner fold set is itself valid and covers exactly outer-train.
870            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        // A STRUCTURALLY VALID inner fold set that nonetheless includes an outer
887        // VALIDATION sample — the nested-CV boundary check must refuse it.
888        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        // A malformed supplied inner fold set hides an outer-validation sample in a
921        // fold's members while omitting it from the top-level `sample_ids`. It must
922        // still be refused (structural validation catches the inconsistency).
923        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` omits the leaking sample, but a fold member smuggles it in.
934            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        // s1 is validated twice and s4 never — not an OOF partition (ShuffleSplit-like).
996        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(); // overlapping / incomplete validation is allowed
1021
1022        // The leakage guard (train ∩ validation per fold) still holds in Resampled mode.
1023        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}