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    // This fingerprint has always hashed a recursively key-sorted JSON value,
195    // not typed struct field order. Preserve that published contract when an
196    // embedding aggregate enables serde_json/preserve_order.
197    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/// Stratified K-fold: each sample is validated exactly once (OOF-safe like
292/// plain K-fold), but folds are balanced by a per-sample class label so every
293/// fold mirrors the overall class distribution. `strata` maps each sample id to
294/// its class label (identity-keyed metadata — never feature values).
295#[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/// Nested stratified K-fold with the identity-keyed class labels required to
304/// construct folds inside each outer-training universe.
305///
306/// Keeping the labels on the fingerprinted policy lets the coordinator build
307/// every inner fold without inspecting feature buffers or asking a model
308/// controller to reinterpret target rows.  Labels for outer-validation samples
309/// may be present, but [`NestedCvSpec::build_nested_fold_set`] selects only the
310/// owning outer fold's training ids before splitting.
311#[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        // Group samples by class (deterministic label order), preserving the
339        // within-class order, then assign folds by GLOBAL round-robin over that
340        // class-grouped order. Each sample lands in exactly one fold (OOF) and
341        // every class is spread across folds; crucially no fold is left empty
342        // whenever KFold's `n_splits <= n_samples` invariant holds (the previous
343        // per-class counter could pile singleton classes all into fold 0).
344        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/// Inner (nested) cross-validation policy.
492///
493/// Declared globally on the `CampaignSpec` and/or locally on a `NodePlan`
494/// (e.g. a finetune/tuner or branch node); the local policy overrides the global
495/// default (see [`resolve_inner_cv`]). dag-ml builds the inner `FoldSet` from each
496/// outer fold's **training** samples via [`NestedCvSpec::build_inner_fold_set`],
497/// so the inner folds are a subset of outer-train *by construction* — nested CV
498/// cannot leak outer-validation rows into inner tuning.
499#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
500#[serde(tag = "kind")]
501pub enum NestedCvSpec {
502    /// Index-based inner K-fold, built in-core from outer-train samples.
503    #[serde(rename = "kfold")]
504    KFold(KFoldSpec),
505    /// Group-aware inner K-fold, built in-core from outer-train sample groups.
506    #[serde(rename = "group_kfold")]
507    GroupKFold(GroupKFoldSpec),
508    /// Target-stratified inner K-fold, with labels bound to stable sample ids.
509    #[serde(rename = "stratified_kfold")]
510    StratifiedKFold(StratifiedNestedCvSpec),
511}
512
513/// One inner fold set bound to the exact outer fold that owns its training
514/// universe.  The parent is a first-class identity: nested schedulers and OOF
515/// caches must not infer it by parsing an inner fold-set identifier.
516#[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    /// Validate both the explicit parent binding and the no-leakage subset
524    /// invariant.  A structurally valid inner set cannot be reused under a
525    /// different outer fold.
526    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    /// Validate the nested-CV policy's parameters independently of any outer fold.
539    /// Mirrors the checks the splitters enforce (`n_splits >= 2`) so a malformed
540    /// declaration is rejected at plan time rather than deferred to FIT_CV.
541    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    /// Build the inner `FoldSet` for one outer fold from its **training** samples
575    /// only. `outer_groups` is the outer `FoldSet.sample_groups` (used by
576    /// `GroupKFold`; ignored otherwise). The result is validated to lie entirely
577    /// within the outer fold's training set.
578    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    /// Build an inner fold set together with its non-optional outer-fold
589    /// ownership.  New scheduler paths should retain this wrapper throughout
590    /// execution rather than carrying a bare `FoldSet`.
591    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        // Splitters name folds locally (`fold0`, `fold1`, …).  Nested OOF is
621        // retained in one run context across every outer fold, so local names
622        // would collide in the prediction store. Namespace every assignment by
623        // the explicit parent rather than relying on the fold-set id (which is
624        // not carried by `PredictionBlock`).
625        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
637/// Resolve the effective inner-CV policy for a node: a node-local policy
638/// overrides the campaign-global default; `None` means no nested CV.
639pub 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
646/// Enforce the nested-CV invariant: every sample in `inner` — both the top-level
647/// universe and every fold's train/validation members — must be an outer-fold
648/// **training** sample (never an outer-validation sample). Holds by construction
649/// for dag-ml-built inner folds, and also validates inner folds supplied from
650/// elsewhere. Refuses with an OOF-validation error on any leaking sample.
651pub fn validate_inner_fold_set_within_outer(inner: &FoldSet, outer: &FoldAssignment) -> Result<()> {
652    // Ensure the inner fold set is structurally sound first; otherwise a malformed
653    // supplied fold set could hide a leaking sample in a fold while omitting it
654    // from `sample_ids`. After this, fold members are guaranteed ⊆ `sample_ids`.
655    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    // Defence-in-depth: check every fold member directly, independent of the
670    // sample_ids / structural invariants above.
671    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        // 8 samples, 2 classes (4 each); 2-fold stratified → each fold gets 2 of each class.
867        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(); // OOF: each sample validated exactly once
886        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        // Codex repro: 3 singleton classes with n_splits=3 must not pile all
900        // samples into fold0 (which FoldSet.validate rejects as an empty fold1).
901        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())]); // incomplete
924        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            // Every inner sample is an outer training sample.
961            for sample_id in &inner.sample_ids {
962                assert!(outer_train.contains(sample_id));
963            }
964            // The inner fold set is itself valid and covers exactly outer-train.
965            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        // A STRUCTURALLY VALID inner fold set that nonetheless includes an outer
1012        // VALIDATION sample — the nested-CV boundary check must refuse it.
1013        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        // A malformed supplied inner fold set hides an outer-validation sample in a
1046        // fold's members while omitting it from the top-level `sample_ids`. It must
1047        // still be refused (structural validation catches the inconsistency).
1048        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` omits the leaking sample, but a fold member smuggles it in.
1059            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        // s1 is validated twice and s4 never — not an OOF partition (ShuffleSplit-like).
1211        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(); // overlapping / incomplete validation is allowed
1236
1237        // The leakage guard (train ∩ validation per fold) still holds in Resampled mode.
1238        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}