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
491/// One inner fold set bound to the exact outer fold that owns its training
492/// universe.  The parent is a first-class identity: nested schedulers and OOF
493/// caches must not infer it by parsing an inner fold-set identifier.
494#[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    /// Validate both the explicit parent binding and the no-leakage subset
502    /// invariant.  A structurally valid inner set cannot be reused under a
503    /// different outer fold.
504    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    /// Validate the nested-CV policy's parameters independently of any outer fold.
517    /// Mirrors the checks the splitters enforce (`n_splits >= 2`) so a malformed
518    /// declaration is rejected at plan time rather than deferred to FIT_CV.
519    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    /// Build the inner `FoldSet` for one outer fold from its **training** samples
540    /// only. `outer_groups` is the outer `FoldSet.sample_groups` (used by
541    /// `GroupKFold`; ignored otherwise). The result is validated to lie entirely
542    /// within the outer fold's training set.
543    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    /// Build an inner fold set together with its non-optional outer-fold
554    /// ownership.  New scheduler paths should retain this wrapper throughout
555    /// execution rather than carrying a bare `FoldSet`.
556    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        // Splitters name folds locally (`fold0`, `fold1`, …).  Nested OOF is
575        // retained in one run context across every outer fold, so local names
576        // would collide in the prediction store. Namespace every assignment by
577        // the explicit parent rather than relying on the fold-set id (which is
578        // not carried by `PredictionBlock`).
579        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
591/// Resolve the effective inner-CV policy for a node: a node-local policy
592/// overrides the campaign-global default; `None` means no nested CV.
593pub 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
600/// Enforce the nested-CV invariant: every sample in `inner` — both the top-level
601/// universe and every fold's train/validation members — must be an outer-fold
602/// **training** sample (never an outer-validation sample). Holds by construction
603/// for dag-ml-built inner folds, and also validates inner folds supplied from
604/// elsewhere. Refuses with an OOF-validation error on any leaking sample.
605pub fn validate_inner_fold_set_within_outer(inner: &FoldSet, outer: &FoldAssignment) -> Result<()> {
606    // Ensure the inner fold set is structurally sound first; otherwise a malformed
607    // supplied fold set could hide a leaking sample in a fold while omitting it
608    // from `sample_ids`. After this, fold members are guaranteed ⊆ `sample_ids`.
609    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    // Defence-in-depth: check every fold member directly, independent of the
624    // sample_ids / structural invariants above.
625    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        // 8 samples, 2 classes (4 each); 2-fold stratified → each fold gets 2 of each class.
821        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(); // OOF: each sample validated exactly once
840        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        // Codex repro: 3 singleton classes with n_splits=3 must not pile all
854        // samples into fold0 (which FoldSet.validate rejects as an empty fold1).
855        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())]); // incomplete
878        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            // Every inner sample is an outer training sample.
915            for sample_id in &inner.sample_ids {
916                assert!(outer_train.contains(sample_id));
917            }
918            // The inner fold set is itself valid and covers exactly outer-train.
919            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        // A STRUCTURALLY VALID inner fold set that nonetheless includes an outer
966        // VALIDATION sample — the nested-CV boundary check must refuse it.
967        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        // A malformed supplied inner fold set hides an outer-validation sample in a
1000        // fold's members while omitting it from the top-level `sample_ids`. It must
1001        // still be refused (structural validation catches the inconsistency).
1002        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` omits the leaking sample, but a fold member smuggles it in.
1013            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        // s1 is validated twice and s4 never — not an OOF partition (ShuffleSplit-like).
1075        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(); // overlapping / incomplete validation is allowed
1100
1101        // The leakage guard (train ∩ validation per fold) still holds in Resampled mode.
1102        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}