Skip to main content

dag_ml_core/
oof.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::campaign::stable_json_fingerprint;
7use crate::error::{DagMlError, OofLeakageReport, OofLeakageViolation, Result};
8use crate::fold::{FoldAssignment, FoldPartitionMode, FoldSet};
9use crate::ids::{FoldId, NodeId, SampleId};
10
11pub const STACKING_OOF_REFIT_CONTRACT_METADATA_KEY: &str = "stacking_oof_refit_contract";
12
13#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum PredictionPartition {
16    Train,
17    Validation,
18    Test,
19    Final,
20}
21
22#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum PredictionJoinKey {
25    SampleId,
26}
27
28fn default_prediction_join_key() -> PredictionJoinKey {
29    PredictionJoinKey::SampleId
30}
31
32#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct PredictionBlock {
35    #[serde(default)]
36    pub prediction_id: Option<String>,
37    pub producer_node: NodeId,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub producer_port: Option<String>,
40    pub partition: PredictionPartition,
41    pub fold_id: Option<FoldId>,
42    pub sample_ids: Vec<SampleId>,
43    pub values: Vec<Vec<f64>>,
44    #[serde(default)]
45    pub target_names: Vec<String>,
46}
47
48impl PredictionBlock {
49    pub fn validate_shape(&self) -> Result<usize> {
50        if self.sample_ids.len() != self.values.len() {
51            return Err(DagMlError::OofValidation(format!(
52                "producer `{}` has {} sample ids but {} prediction rows",
53                self.producer_node,
54                self.sample_ids.len(),
55                self.values.len()
56            )));
57        }
58        let width = self.values.first().map_or(0, Vec::len);
59        if width == 0 {
60            return Err(DagMlError::OofValidation(format!(
61                "producer `{}` emitted empty prediction rows",
62                self.producer_node
63            )));
64        }
65        if self.values.iter().any(|row| row.len() != width) {
66            return Err(DagMlError::OofValidation(format!(
67                "producer `{}` emitted ragged prediction rows",
68                self.producer_node
69            )));
70        }
71        if !self.target_names.is_empty() && self.target_names.len() != width {
72            return Err(DagMlError::OofValidation(format!(
73                "producer `{}` has {} target names for width {}",
74                self.producer_node,
75                self.target_names.len(),
76                width
77            )));
78        }
79        Ok(width)
80    }
81
82    /// Mandatory, central content invariant for a prediction block — the single gate every
83    /// path that *stores* or *scores* a `PredictionBlock` must pass through. It is a strict
84    /// superset of [`validate_shape`](Self::validate_shape): it first checks dimensions/width,
85    /// then enforces the two content invariants `validate_shape` does not — every prediction
86    /// value must be finite (no `NaN`/`Inf`) and no `sample_id` may repeat within the block
87    /// (a within-block duplicate double-counts in every identity-keyed reducer). A block that
88    /// is already valid passes unchanged and returns the same `width`; only malformed or
89    /// adversarial blocks are rejected.
90    pub fn validate_content(&self) -> Result<usize> {
91        let width = self.validate_shape()?;
92        if self.values.iter().flatten().any(|value| !value.is_finite()) {
93            return Err(DagMlError::OofValidation(format!(
94                "producer `{}` emitted non-finite prediction values",
95                self.producer_node
96            )));
97        }
98        let mut seen = BTreeSet::new();
99        for sample_id in &self.sample_ids {
100            if !seen.insert(sample_id) {
101                return Err(DagMlError::OofValidation(format!(
102                    "producer `{}` emitted duplicate prediction for sample `{sample_id}`",
103                    self.producer_node
104                )));
105            }
106        }
107        Ok(width)
108    }
109}
110
111/// Mandatory, central OOF *coverage* invariant — the single gate every path that *concatenates a
112/// producer's per-fold validation predictions into one out-of-fold set* must pass through. Spec
113/// `COORDINATOR_SPEC.md` §"OOF And Leakage Rules" rule 3: every producer must provide **exactly one
114/// validation prediction per requested sample** unless an explicit aggregation policy says otherwise.
115///
116/// The gate is [`FoldPartitionMode`]-aware, matching the same Partition/Resampled split that
117/// [`FoldSet::validate`](crate::fold::FoldSet::validate) already enforces on the fold layout:
118///
119/// - **Partition** (KFold-style, the default): a clean out-of-fold partition. Every block passes
120///   [`PredictionBlock::validate_content`] (shape, finite, no within-block dup) AND **uniqueness** —
121///   no `sample_id` appears in more than one block (a cross-fold duplicate; the signature of either a
122///   duplicated fold or, since [`PredictionBlock`] carries no variant tag, a run context that mixed
123///   several variants — see audit R-P0-1). This is the central analogue of the runtime merge handler's
124///   "the run context mixes several variants" guard, on the *scoring* path.
125/// - **Resampled** (ShuffleSplit / repeated KFold / bootstrap): a sample may legitimately be validated
126///   in several folds, so its OOF predictions are *aggregated* (averaged by
127///   [`reduce_predictions_across_folds`](crate::aggregation::reduce_predictions_across_folds)). The
128///   across-fold uniqueness check is therefore relaxed: a sample appearing in multiple blocks is
129///   allowed. The per-block content gate (including within-block uniqueness via `validate_content`)
130///   still runs, so a duplicate *within one fold's block* is still refused — only the cross-fold
131///   multiplicity is permitted.
132///
133/// The "unless an explicit aggregation policy says otherwise" carve-out (the branch-merge concat
134/// partition case, where each branch legitimately covers only its partition of samples) is handled by
135/// the dedicated separation-merge runtime handler and partition-aware bundle group validators, which
136/// never route a producer's raw cross-fold blocks through this gate — so this validator does not
137/// over-reject those legitimate partial-partition merges.
138///
139/// `requested_samples`, when `Some`, additionally pins **completeness**: every requested sample must
140/// have at least one validation prediction and no unexpected sample may be present. Under `Partition`,
141/// combined with the uniqueness check above, this is exactly-once coverage; under `Resampled`, it is
142/// at-least-once coverage (each requested sample covered, possibly several times). When `None`, only
143/// the mode-appropriate uniqueness is enforced over whatever OOF the producer emitted (the cross-fold
144/// scoring path, which scores over the producer's own OOF union and has no externally-fixed universe).
145pub fn validate_producer_oof_coverage(
146    producer_node: &NodeId,
147    blocks: &[&PredictionBlock],
148    partition_mode: FoldPartitionMode,
149    requested_samples: Option<&BTreeSet<SampleId>>,
150) -> Result<()> {
151    let mut covered: BTreeSet<SampleId> = BTreeSet::new();
152    for block in blocks {
153        if block.partition != PredictionPartition::Validation {
154            continue;
155        }
156        block.validate_content()?;
157        for sample_id in &block.sample_ids {
158            let first_time = covered.insert(sample_id.clone());
159            // Partition is a clean OOF set: a sample seen twice across blocks is a duplicated fold or a
160            // mixed-variant context, and is refused. Resampled aggregates a multiply-validated sample,
161            // so a repeat across blocks is expected and allowed (within-block dups are still caught by
162            // `validate_content` above).
163            if !first_time && partition_mode == FoldPartitionMode::Partition {
164                return Err(DagMlError::OofValidation(format!(
165                    "producer `{producer_node}` emitted more than one validation prediction for sample `{sample_id}` — the OOF set is not unique (a duplicated fold, or a run context that mixed several variants); concatenate exactly one validation prediction per sample"
166                )));
167            }
168        }
169    }
170    if let Some(requested) = requested_samples {
171        if &covered != requested {
172            let missing = requested.difference(&covered).count();
173            let extra = covered.difference(requested).count();
174            let expectation = match partition_mode {
175                FoldPartitionMode::Partition => {
176                    "exactly one validation prediction per requested sample is required"
177                }
178                FoldPartitionMode::Resampled => {
179                    "every requested sample needs at least one validation prediction and no extra sample may appear"
180                }
181            };
182            return Err(DagMlError::OofValidation(format!(
183                "producer `{producer_node}` OOF coverage is not exact: {missing} requested sample(s) missing, {extra} unexpected sample(s) present — {expectation}"
184            )));
185        }
186    }
187    Ok(())
188}
189
190#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum StackingOofRefitPolicy {
193    /// Default: a stacking meta-model may enter REFIT only when every upstream
194    /// producer has validation OOF coverage for the complete refit sample
195    /// universe.
196    #[default]
197    RequireFullCoverage,
198    /// Explicit CV-only behavior: skip the REFIT task for this stacking node.
199    CvOnly,
200    /// Allow REFIT to be skipped when coverage is incomplete, while still
201    /// rejecting malformed OOF blocks.
202    SkipRefitOnIncompleteOof,
203}
204
205#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
206pub struct StackingOofRefitContract {
207    #[serde(default)]
208    pub policy: StackingOofRefitPolicy,
209}
210
211impl Default for StackingOofRefitContract {
212    fn default() -> Self {
213        Self {
214            policy: StackingOofRefitPolicy::RequireFullCoverage,
215        }
216    }
217}
218
219impl StackingOofRefitContract {
220    pub fn from_metadata(metadata: &BTreeMap<String, Value>) -> Result<Self> {
221        let Some(value) = metadata.get(STACKING_OOF_REFIT_CONTRACT_METADATA_KEY) else {
222            return Ok(Self::default());
223        };
224        let contract = serde_json::from_value::<Self>(value.clone()).map_err(|error| {
225            DagMlError::OofValidation(format!(
226                "`{STACKING_OOF_REFIT_CONTRACT_METADATA_KEY}` must be an object with policy \
227                 `require_full_coverage`, `cv_only` or `skip_refit_on_incomplete_oof`: {error}"
228            ))
229        })?;
230        Ok(contract)
231    }
232}
233
234#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
235#[serde(rename_all = "snake_case")]
236pub enum StackingOofRefitDecision {
237    RefitAllowed(StackingOofRefitCoverageDiagnostic),
238    SkipRefit(StackingOofRefitCoverageDiagnostic),
239}
240
241#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
242#[serde(rename_all = "snake_case")]
243pub enum StackingOofRefitCause {
244    FullCoverage,
245    CvOnly,
246    IncompleteOofCoverage,
247    PartialOofWithoutPolicy,
248    MissingFoldId,
249    UnknownFold,
250    FoldCoverageMismatch,
251    DuplicateValidationSample,
252    NonValidationPartition,
253}
254
255impl StackingOofRefitCause {
256    pub fn as_str(self) -> &'static str {
257        match self {
258            Self::FullCoverage => "full_coverage",
259            Self::CvOnly => "cv_only",
260            Self::IncompleteOofCoverage => "incomplete_oof_coverage",
261            Self::PartialOofWithoutPolicy => "partial_oof_without_policy",
262            Self::MissingFoldId => "missing_fold_id",
263            Self::UnknownFold => "unknown_fold",
264            Self::FoldCoverageMismatch => "fold_coverage_mismatch",
265            Self::DuplicateValidationSample => "duplicate_validation_sample",
266            Self::NonValidationPartition => "non_validation_partition",
267        }
268    }
269}
270
271#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
272pub struct StackingOofRefitCoverageDiagnostic {
273    pub policy: StackingOofRefitPolicy,
274    pub cause: StackingOofRefitCause,
275    pub requested_sample_count: usize,
276    pub covered_sample_count: usize,
277    #[serde(default, skip_serializing_if = "Vec::is_empty")]
278    pub missing_sample_ids: Vec<SampleId>,
279    #[serde(default, skip_serializing_if = "Vec::is_empty")]
280    pub extra_sample_ids: Vec<SampleId>,
281}
282
283impl StackingOofRefitDecision {
284    pub fn diagnostic(&self) -> &StackingOofRefitCoverageDiagnostic {
285        match self {
286            Self::RefitAllowed(diagnostic) | Self::SkipRefit(diagnostic) => diagnostic,
287        }
288    }
289
290    pub fn should_skip_refit(&self) -> bool {
291        matches!(self, Self::SkipRefit(_))
292    }
293}
294
295pub fn validate_stacking_oof_refit_contract(
296    producer_node: &NodeId,
297    blocks: &[&PredictionBlock],
298    fold_set: &FoldSet,
299    contract: &StackingOofRefitContract,
300) -> Result<StackingOofRefitDecision> {
301    fold_set.validate()?;
302    if contract.policy == StackingOofRefitPolicy::CvOnly {
303        return Ok(StackingOofRefitDecision::SkipRefit(
304            StackingOofRefitCoverageDiagnostic {
305                policy: contract.policy,
306                cause: StackingOofRefitCause::CvOnly,
307                requested_sample_count: fold_set.sample_ids.len(),
308                covered_sample_count: 0,
309                missing_sample_ids: fold_set.sample_ids.clone(),
310                extra_sample_ids: Vec::new(),
311            },
312        ));
313    }
314
315    let folds = fold_set
316        .folds
317        .iter()
318        .map(|fold| (&fold.fold_id, fold))
319        .collect::<BTreeMap<_, _>>();
320    let mut covered = BTreeSet::new();
321    for block in blocks {
322        if block.partition != PredictionPartition::Validation {
323            return Err(stacking_refit_contract_error(
324                producer_node,
325                StackingOofRefitCause::NonValidationPartition,
326                format!(
327                    "selected {:?} predictions for REFIT stacking; only validation OOF may train a meta-model",
328                    block.partition
329                ),
330            ));
331        }
332        block.validate_content()?;
333        let fold_id = block.fold_id.as_ref().ok_or_else(|| {
334            stacking_refit_contract_error(
335                producer_node,
336                StackingOofRefitCause::MissingFoldId,
337                "validation OOF block is missing fold_id".to_string(),
338            )
339        })?;
340        let fold = folds.get(fold_id).ok_or_else(|| {
341            stacking_refit_contract_error(
342                producer_node,
343                StackingOofRefitCause::UnknownFold,
344                format!("validation OOF block references unknown fold `{fold_id}`"),
345            )
346        })?;
347        validate_stacking_block_matches_fold(producer_node, block, fold)?;
348        for sample_id in &block.sample_ids {
349            if !covered.insert(sample_id.clone())
350                && fold_set.partition_mode == FoldPartitionMode::Partition
351            {
352                return Err(stacking_refit_contract_error(
353                    producer_node,
354                    StackingOofRefitCause::DuplicateValidationSample,
355                    format!(
356                        "sample `{sample_id}` appears in validation OOF for more than one fold"
357                    ),
358                ));
359            }
360        }
361    }
362
363    let requested = fold_set.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
364    if covered == requested {
365        return Ok(StackingOofRefitDecision::RefitAllowed(
366            StackingOofRefitCoverageDiagnostic {
367                policy: contract.policy,
368                cause: StackingOofRefitCause::FullCoverage,
369                requested_sample_count: requested.len(),
370                covered_sample_count: covered.len(),
371                missing_sample_ids: Vec::new(),
372                extra_sample_ids: Vec::new(),
373            },
374        ));
375    }
376
377    let diagnostic = StackingOofRefitCoverageDiagnostic {
378        policy: contract.policy,
379        cause: match contract.policy {
380            StackingOofRefitPolicy::SkipRefitOnIncompleteOof => {
381                StackingOofRefitCause::IncompleteOofCoverage
382            }
383            StackingOofRefitPolicy::RequireFullCoverage => {
384                StackingOofRefitCause::PartialOofWithoutPolicy
385            }
386            StackingOofRefitPolicy::CvOnly => StackingOofRefitCause::CvOnly,
387        },
388        requested_sample_count: requested.len(),
389        covered_sample_count: covered.len(),
390        missing_sample_ids: requested.difference(&covered).cloned().collect(),
391        extra_sample_ids: covered.difference(&requested).cloned().collect(),
392    };
393    if contract.policy == StackingOofRefitPolicy::SkipRefitOnIncompleteOof {
394        return Ok(StackingOofRefitDecision::SkipRefit(diagnostic));
395    }
396    Err(stacking_refit_contract_error(
397        producer_node,
398        diagnostic.cause,
399        format!(
400            "OOF predictions do not cover the refit sample universe: {} requested sample(s), {} covered, {} missing, {} extra",
401            diagnostic.requested_sample_count,
402            diagnostic.covered_sample_count,
403            diagnostic.missing_sample_ids.len(),
404            diagnostic.extra_sample_ids.len()
405        ),
406    ))
407}
408
409fn validate_stacking_block_matches_fold(
410    producer_node: &NodeId,
411    block: &PredictionBlock,
412    fold: &FoldAssignment,
413) -> Result<()> {
414    let actual = block.sample_ids.iter().collect::<BTreeSet<_>>();
415    let expected = fold.validation_sample_ids.iter().collect::<BTreeSet<_>>();
416    if actual != expected {
417        return Err(stacking_refit_contract_error(
418            producer_node,
419            StackingOofRefitCause::FoldCoverageMismatch,
420            format!(
421                "fold `{}` OOF samples do not match the fold validation samples",
422                fold.fold_id
423            ),
424        ));
425    }
426    Ok(())
427}
428
429fn stacking_refit_contract_error(
430    producer_node: &NodeId,
431    cause: StackingOofRefitCause,
432    detail: String,
433) -> DagMlError {
434    DagMlError::OofValidation(format!(
435        "stacking OOF refit contract violation for producer `{producer_node}`: cause={}; {detail}",
436        cause.as_str()
437    ))
438}
439
440#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
441pub struct OofMatrix {
442    pub sample_ids: Vec<SampleId>,
443    pub columns: Vec<String>,
444    pub values: Vec<Vec<f64>>,
445}
446
447#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
448pub struct OofCampaign {
449    pub fold_set: FoldSet,
450    pub join_policy: PredictionJoinPolicy,
451    pub requested_sample_order: Vec<SampleId>,
452    pub prediction_blocks: Vec<PredictionBlock>,
453}
454
455#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
456pub struct PredictionJoinPolicy {
457    pub node_id: NodeId,
458    #[serde(default = "default_prediction_join_key")]
459    pub join_on: PredictionJoinKey,
460    #[serde(default)]
461    pub allow_train_predictions_as_features: bool,
462    #[serde(default)]
463    pub include_partitions: Vec<PredictionPartition>,
464}
465
466#[derive(Clone, Debug)]
467struct ProducerPredictions {
468    width: usize,
469    target_names: Vec<String>,
470    by_sample: BTreeMap<SampleId, Vec<f64>>,
471}
472
473pub fn join_oof_features(
474    blocks: &[PredictionBlock],
475    required_samples: &[SampleId],
476) -> Result<OofMatrix> {
477    validate_prediction_blocks_are_oof(
478        &PredictionJoinPolicy {
479            node_id: NodeId::new("prediction_join")?,
480            join_on: PredictionJoinKey::SampleId,
481            allow_train_predictions_as_features: false,
482            include_partitions: vec![PredictionPartition::Validation],
483        },
484        blocks,
485    )?;
486    if required_samples.is_empty() {
487        return Err(DagMlError::OofValidation(
488            "required sample set is empty".to_string(),
489        ));
490    }
491
492    let required = required_samples.iter().collect::<BTreeSet<_>>();
493    if required.len() != required_samples.len() {
494        return Err(DagMlError::OofValidation(
495            "required sample set contains duplicates".to_string(),
496        ));
497    }
498
499    let mut rows = required_samples
500        .iter()
501        .cloned()
502        .map(|sample_id| (sample_id, Vec::<f64>::new()))
503        .collect::<BTreeMap<_, _>>();
504    let mut columns = Vec::new();
505
506    for block in blocks {
507        let width = block.validate_shape()?;
508        let mut seen = BTreeSet::new();
509        let mut by_sample = BTreeMap::new();
510        for (sample_id, values) in block.sample_ids.iter().zip(block.values.iter()) {
511            if !seen.insert(sample_id) {
512                return Err(DagMlError::OofValidation(format!(
513                    "producer `{}` emitted duplicate prediction for sample `{}`",
514                    block.producer_node, sample_id
515                )));
516            }
517            by_sample.insert(sample_id, values);
518        }
519
520        for sample_id in required_samples {
521            let values = by_sample.get(sample_id).ok_or_else(|| {
522                DagMlError::OofValidation(format!(
523                    "producer `{}` is missing required sample `{}`",
524                    block.producer_node, sample_id
525                ))
526            })?;
527            rows.get_mut(sample_id)
528                .expect("required sample row exists")
529                .extend(values.iter().copied());
530        }
531
532        for column_idx in 0..width {
533            let target = block
534                .target_names
535                .get(column_idx)
536                .cloned()
537                .unwrap_or_else(|| format!("p{column_idx}"));
538            columns.push(format!("{}__{target}", block.producer_node));
539        }
540    }
541
542    Ok(OofMatrix {
543        sample_ids: required_samples.to_vec(),
544        columns,
545        values: required_samples
546            .iter()
547            .map(|sample_id| rows.remove(sample_id).expect("row exists"))
548            .collect(),
549    })
550}
551
552pub fn join_oof_campaign_features(
553    policy: &PredictionJoinPolicy,
554    blocks: &[PredictionBlock],
555    required_samples: &[SampleId],
556) -> Result<OofMatrix> {
557    validate_prediction_blocks_are_oof(policy, blocks)?;
558    ensure_required_samples(required_samples)?;
559
560    let required = required_samples.iter().collect::<BTreeSet<_>>();
561    let included_partitions = effective_partitions(policy);
562    let mut producers = BTreeMap::<NodeId, ProducerPredictions>::new();
563
564    for block in blocks {
565        if !included_partitions.contains(&block.partition) {
566            continue;
567        }
568        let width = block.validate_shape()?;
569        let target_names = normalized_targets(block, width);
570        let producer = producers
571            .entry(block.producer_node.clone())
572            .or_insert_with(|| ProducerPredictions {
573                width,
574                target_names: target_names.clone(),
575                by_sample: BTreeMap::new(),
576            });
577        if producer.width != width {
578            return Err(DagMlError::OofValidation(format!(
579                "producer `{}` changed prediction width from {} to {}",
580                block.producer_node, producer.width, width
581            )));
582        }
583        if producer.target_names != target_names {
584            return Err(DagMlError::OofValidation(format!(
585                "producer `{}` changed target names across folds",
586                block.producer_node
587            )));
588        }
589
590        for (sample_id, values) in block.sample_ids.iter().zip(block.values.iter()) {
591            if !required.contains(sample_id) {
592                return Err(DagMlError::OofValidation(format!(
593                    "producer `{}` emitted unexpected sample `{}`",
594                    block.producer_node, sample_id
595                )));
596            }
597            if producer
598                .by_sample
599                .insert(sample_id.clone(), values.clone())
600                .is_some()
601            {
602                return Err(DagMlError::OofValidation(format!(
603                    "producer `{}` emitted duplicate OOF prediction for sample `{}`",
604                    block.producer_node, sample_id
605                )));
606            }
607        }
608    }
609
610    if producers.is_empty() {
611        return Err(DagMlError::OofValidation(
612            "no prediction blocks were selected for OOF join".to_string(),
613        ));
614    }
615
616    for (producer_node, producer) in &producers {
617        for sample_id in required_samples {
618            if !producer.by_sample.contains_key(sample_id) {
619                return Err(DagMlError::OofValidation(format!(
620                    "producer `{producer_node}` is missing required sample `{sample_id}`"
621                )));
622            }
623        }
624    }
625
626    let producer_predictions = producers.into_iter().collect::<Vec<_>>();
627    let columns = producer_predictions
628        .iter()
629        .flat_map(|(producer_node, producer)| {
630            producer
631                .target_names
632                .iter()
633                .map(move |target| format!("{producer_node}__{target}"))
634        })
635        .collect::<Vec<_>>();
636    let values = required_samples
637        .iter()
638        .map(|sample_id| {
639            let mut row = Vec::new();
640            for (_producer_node, producer) in &producer_predictions {
641                row.extend(
642                    producer
643                        .by_sample
644                        .get(sample_id)
645                        .expect("required sample was checked")
646                        .iter()
647                        .copied(),
648                );
649            }
650            row
651        })
652        .collect::<Vec<_>>();
653
654    Ok(OofMatrix {
655        sample_ids: required_samples.to_vec(),
656        columns,
657        values,
658    })
659}
660
661pub fn validate_oof_campaign(campaign: &OofCampaign) -> Result<OofMatrix> {
662    campaign.fold_set.validate()?;
663    validate_requested_samples_match_fold_set(
664        &campaign.requested_sample_order,
665        &campaign.fold_set,
666    )?;
667    validate_prediction_blocks_against_folds(&campaign.fold_set, &campaign.prediction_blocks)?;
668    join_oof_campaign_features(
669        &campaign.join_policy,
670        &campaign.prediction_blocks,
671        &campaign.requested_sample_order,
672    )
673}
674
675pub fn oof_campaign_fingerprint(campaign: &OofCampaign) -> Result<String> {
676    campaign.fold_set.validate()?;
677    validate_requested_samples_match_fold_set(
678        &campaign.requested_sample_order,
679        &campaign.fold_set,
680    )?;
681    validate_prediction_blocks_against_folds(&campaign.fold_set, &campaign.prediction_blocks)?;
682    stable_json_fingerprint(campaign)
683}
684
685pub fn validate_prediction_blocks_against_folds(
686    fold_set: &FoldSet,
687    blocks: &[PredictionBlock],
688) -> Result<()> {
689    fold_set.validate()?;
690    let folds = fold_set
691        .folds
692        .iter()
693        .map(|fold| (&fold.fold_id, fold))
694        .collect::<BTreeMap<_, _>>();
695    for block in blocks {
696        block.validate_shape()?;
697        let Some(fold_id) = &block.fold_id else {
698            if matches!(
699                block.partition,
700                PredictionPartition::Train | PredictionPartition::Validation
701            ) {
702                return Err(DagMlError::OofValidation(format!(
703                    "producer `{}` emitted {:?} predictions without fold_id",
704                    block.producer_node, block.partition
705                )));
706            }
707            continue;
708        };
709        let fold = folds.get(fold_id).ok_or_else(|| {
710            DagMlError::OofValidation(format!(
711                "producer `{}` references unknown fold `{fold_id}`",
712                block.producer_node
713            ))
714        })?;
715        match block.partition {
716            PredictionPartition::Train => {
717                assert_exact_partition_samples(block, &fold.train_sample_ids, "train")?
718            }
719            PredictionPartition::Validation => {
720                assert_exact_partition_samples(block, &fold.validation_sample_ids, "validation")?
721            }
722            PredictionPartition::Test | PredictionPartition::Final => {}
723        }
724    }
725    Ok(())
726}
727
728pub fn validate_prediction_blocks_are_oof(
729    policy: &PredictionJoinPolicy,
730    blocks: &[PredictionBlock],
731) -> Result<()> {
732    if policy.allow_train_predictions_as_features {
733        return Ok(());
734    }
735    let violators = blocks
736        .iter()
737        .filter(|block| block.partition != PredictionPartition::Validation)
738        .map(|block| OofLeakageViolation {
739            producer_node: block.producer_node.to_string(),
740            partition: format!("{:?}", block.partition).to_lowercase(),
741            fold_id: block.fold_id.as_ref().map(ToString::to_string),
742        })
743        .collect::<Vec<_>>();
744    if violators.is_empty() {
745        Ok(())
746    } else {
747        crate::observability::emit_oof_refusal(policy.node_id.as_str(), violators.len());
748        Err(DagMlError::OofLeakage(Box::new(OofLeakageReport {
749            node_id: policy.node_id.to_string(),
750            violators,
751            allow_train_predictions_as_features: policy.allow_train_predictions_as_features,
752            remediation: "Use only OOF validation predictions as training features, or explicitly set allow_train_predictions_as_features=true for an unsafe run.".to_string(),
753        })))
754    }
755}
756
757fn validate_requested_samples_match_fold_set(
758    requested_sample_order: &[SampleId],
759    fold_set: &FoldSet,
760) -> Result<()> {
761    ensure_required_samples(requested_sample_order)?;
762    let requested = requested_sample_order.iter().collect::<BTreeSet<_>>();
763    let expected = fold_set.sample_ids.iter().collect::<BTreeSet<_>>();
764    if requested != expected {
765        return Err(DagMlError::OofValidation(
766            "requested sample order does not match fold-set sample universe".to_string(),
767        ));
768    }
769    Ok(())
770}
771
772fn assert_exact_partition_samples(
773    block: &PredictionBlock,
774    expected_samples: &[SampleId],
775    partition_name: &str,
776) -> Result<()> {
777    let actual = unique_block_samples(block)?;
778    let expected = expected_samples.iter().collect::<BTreeSet<_>>();
779    if actual != expected {
780        return Err(DagMlError::OofValidation(format!(
781            "producer `{}` fold `{}` {} predictions do not match fold {} samples",
782            block.producer_node,
783            block.fold_id.as_ref().expect("fold id exists"),
784            partition_name,
785            partition_name
786        )));
787    }
788    Ok(())
789}
790
791fn unique_block_samples(block: &PredictionBlock) -> Result<BTreeSet<&SampleId>> {
792    let mut seen = BTreeSet::new();
793    for sample_id in &block.sample_ids {
794        if !seen.insert(sample_id) {
795            return Err(DagMlError::OofValidation(format!(
796                "producer `{}` emitted duplicate prediction for sample `{sample_id}`",
797                block.producer_node
798            )));
799        }
800    }
801    Ok(seen)
802}
803
804fn ensure_required_samples(required_samples: &[SampleId]) -> Result<()> {
805    if required_samples.is_empty() {
806        return Err(DagMlError::OofValidation(
807            "required sample set is empty".to_string(),
808        ));
809    }
810    let required = required_samples.iter().collect::<BTreeSet<_>>();
811    if required.len() != required_samples.len() {
812        return Err(DagMlError::OofValidation(
813            "required sample set contains duplicates".to_string(),
814        ));
815    }
816    Ok(())
817}
818
819fn effective_partitions(policy: &PredictionJoinPolicy) -> BTreeSet<PredictionPartition> {
820    if policy.include_partitions.is_empty() {
821        BTreeSet::from([PredictionPartition::Validation])
822    } else {
823        policy.include_partitions.iter().cloned().collect()
824    }
825}
826
827fn normalized_targets(block: &PredictionBlock, width: usize) -> Vec<String> {
828    if block.target_names.is_empty() {
829        (0..width)
830            .map(|column_idx| format!("p{column_idx}"))
831            .collect()
832    } else {
833        block.target_names.clone()
834    }
835}
836
837#[cfg(test)]
838mod tests {
839    use std::time::{Duration, Instant};
840
841    use super::*;
842
843    fn sid(value: &str) -> SampleId {
844        SampleId::new(value).unwrap()
845    }
846
847    fn producer() -> NodeId {
848        NodeId::new("model:base").unwrap()
849    }
850
851    fn block(partition: PredictionPartition) -> PredictionBlock {
852        PredictionBlock {
853            prediction_id: None,
854            producer_node: producer(),
855            producer_port: None,
856            partition,
857            fold_id: Some(FoldId::new("fold0").unwrap()),
858            sample_ids: vec![sid("s2"), sid("s1")],
859            values: vec![vec![20.0], vec![10.0]],
860            target_names: vec!["y".to_string()],
861        }
862    }
863
864    fn campaign_block(producer_node: &str, fold_id: &str, samples: &[&str]) -> PredictionBlock {
865        PredictionBlock {
866            prediction_id: None,
867            producer_node: NodeId::new(producer_node).unwrap(),
868            producer_port: None,
869            partition: PredictionPartition::Validation,
870            fold_id: Some(FoldId::new(fold_id).unwrap()),
871            sample_ids: samples.iter().copied().map(sid).collect(),
872            values: samples
873                .iter()
874                .map(|sample_id| {
875                    let suffix = sample_id.trim_start_matches('s').parse::<f64>().unwrap();
876                    vec![suffix]
877                })
878                .collect(),
879            target_names: vec!["y".to_string()],
880        }
881    }
882
883    fn contract_fold_set() -> FoldSet {
884        FoldSet {
885            id: "folds:stacking.contract".to_string(),
886            sample_ids: ["s1", "s2", "s3", "s4"].iter().map(|s| sid(s)).collect(),
887            folds: vec![
888                FoldAssignment {
889                    fold_id: FoldId::new("fold0").unwrap(),
890                    train_sample_ids: ["s3", "s4"].iter().map(|s| sid(s)).collect(),
891                    validation_sample_ids: ["s1", "s2"].iter().map(|s| sid(s)).collect(),
892                    metadata: BTreeMap::new(),
893                },
894                FoldAssignment {
895                    fold_id: FoldId::new("fold1").unwrap(),
896                    train_sample_ids: ["s1", "s2"].iter().map(|s| sid(s)).collect(),
897                    validation_sample_ids: ["s3", "s4"].iter().map(|s| sid(s)).collect(),
898                    metadata: BTreeMap::new(),
899                },
900            ],
901            sample_groups: BTreeMap::new(),
902            partition_mode: FoldPartitionMode::Partition,
903        }
904    }
905
906    fn load_fixture(source: &str) -> OofCampaign {
907        serde_json::from_str(source).unwrap()
908    }
909
910    #[test]
911    fn aligns_oof_by_sample_id_not_position() {
912        let joined = join_oof_features(
913            &[block(PredictionPartition::Validation)],
914            &[sid("s1"), sid("s2")],
915        )
916        .unwrap();
917
918        assert_eq!(joined.values, vec![vec![10.0], vec![20.0]]);
919        assert_eq!(joined.columns, vec!["model:base__y"]);
920    }
921
922    #[test]
923    fn rejects_train_predictions_as_training_features() {
924        let err = join_oof_features(
925            &[block(PredictionPartition::Train)],
926            &[sid("s1"), sid("s2")],
927        )
928        .unwrap_err();
929
930        match err {
931            DagMlError::OofLeakage(report) => {
932                assert_eq!(report.violators[0].producer_node, "model:base");
933                assert_eq!(report.violators[0].partition, "train");
934            }
935            other => panic!("expected OOF leakage error, got {other:?}"),
936        }
937    }
938
939    #[test]
940    fn rejects_duplicate_samples() {
941        let mut duplicate = block(PredictionPartition::Validation);
942        duplicate.sample_ids = vec![sid("s1"), sid("s1")];
943
944        assert!(join_oof_features(&[duplicate], &[sid("s1")]).is_err());
945    }
946
947    #[test]
948    fn validate_content_passes_valid_block_unchanged() {
949        let valid = block(PredictionPartition::Validation);
950        // A valid block passes both gates with the same width — behavior is unchanged.
951        assert_eq!(
952            valid.validate_content().unwrap(),
953            valid.validate_shape().unwrap()
954        );
955    }
956
957    #[test]
958    fn validate_content_rejects_non_finite_values() {
959        for poison in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
960            let mut tainted = block(PredictionPartition::Validation);
961            tainted.values = vec![vec![poison], vec![10.0]];
962            // validate_shape still accepts it (dimensions only); validate_content must reject it.
963            assert!(tainted.validate_shape().is_ok());
964            let err = tainted.validate_content().unwrap_err();
965            assert!(err.to_string().contains("non-finite"), "got: {err}");
966        }
967    }
968
969    #[test]
970    fn validate_content_rejects_duplicate_sample_id() {
971        let mut dup = block(PredictionPartition::Validation);
972        dup.sample_ids = vec![sid("s1"), sid("s1")];
973        // Dimensions match, so validate_shape accepts; validate_content must reject the duplicate.
974        assert!(dup.validate_shape().is_ok());
975        let err = dup.validate_content().unwrap_err();
976        assert!(
977            err.to_string().contains("duplicate prediction"),
978            "got: {err}"
979        );
980    }
981
982    #[test]
983    fn producer_oof_coverage_accepts_disjoint_folds() {
984        // Two folds of one producer, disjoint OOF samples — exactly one validation prediction per
985        // sample. This is the well-formed single-variant case and must pass unchanged.
986        let f0 = campaign_block("model:pls", "fold0", &["s1", "s2"]);
987        let f1 = campaign_block("model:pls", "fold1", &["s3", "s4"]);
988        let producer = NodeId::new("model:pls").unwrap();
989        validate_producer_oof_coverage(&producer, &[&f0, &f1], FoldPartitionMode::Partition, None)
990            .unwrap();
991        // With the requested universe pinned, exact coverage also passes.
992        let requested = ["s1", "s2", "s3", "s4"].iter().map(|s| sid(s)).collect();
993        validate_producer_oof_coverage(
994            &producer,
995            &[&f0, &f1],
996            FoldPartitionMode::Partition,
997            Some(&requested),
998        )
999        .unwrap();
1000    }
1001
1002    #[test]
1003    fn producer_oof_coverage_resampled_allows_multiply_validated_sample() {
1004        // ShuffleSplit / repeated CV (Resampled): the SAME sample `s1` is legitimately validated in
1005        // two folds — its OOF predictions are aggregated (averaged) downstream. The Partition uniqueness
1006        // gate would reject this; the Resampled mode must accept it. Coverage stays exact over the
1007        // requested universe (at-least-once for every requested sample, no extras).
1008        let f0 = campaign_block("model:pls", "fold0", &["s1", "s2"]);
1009        let f1 = campaign_block("model:pls", "fold1", &["s1", "s3"]);
1010        let producer = NodeId::new("model:pls").unwrap();
1011        validate_producer_oof_coverage(&producer, &[&f0, &f1], FoldPartitionMode::Resampled, None)
1012            .unwrap();
1013        let requested = ["s1", "s2", "s3"].iter().map(|s| sid(s)).collect();
1014        validate_producer_oof_coverage(
1015            &producer,
1016            &[&f0, &f1],
1017            FoldPartitionMode::Resampled,
1018            Some(&requested),
1019        )
1020        .unwrap();
1021    }
1022
1023    #[test]
1024    fn producer_oof_coverage_resampled_still_rejects_within_block_duplicate() {
1025        // Even in Resampled mode, a duplicate WITHIN one fold's block is a double-count and stays
1026        // refused by the per-block content gate (only ACROSS-fold multiplicity is relaxed).
1027        let mut f0 = campaign_block("model:pls", "fold0", &["s1", "s2"]);
1028        f0.sample_ids = vec![sid("s1"), sid("s1")];
1029        let producer = NodeId::new("model:pls").unwrap();
1030        let err =
1031            validate_producer_oof_coverage(&producer, &[&f0], FoldPartitionMode::Resampled, None)
1032                .unwrap_err();
1033        assert!(
1034            err.to_string().contains("duplicate prediction"),
1035            "got: {err}"
1036        );
1037    }
1038
1039    #[test]
1040    fn producer_oof_coverage_resampled_requires_at_least_once_coverage() {
1041        // Resampled relaxes uniqueness but still demands completeness: a requested sample with NO
1042        // validation prediction is refused.
1043        let f0 = campaign_block("model:pls", "fold0", &["s1", "s2"]);
1044        let producer = NodeId::new("model:pls").unwrap();
1045        let missing: BTreeSet<SampleId> = ["s1", "s2", "s3"].iter().map(|s| sid(s)).collect();
1046        let err = validate_producer_oof_coverage(
1047            &producer,
1048            &[&f0],
1049            FoldPartitionMode::Resampled,
1050            Some(&missing),
1051        )
1052        .unwrap_err();
1053        assert!(err.to_string().contains("not exact"), "got: {err}");
1054    }
1055
1056    #[test]
1057    fn producer_oof_coverage_rejects_cross_fold_duplicate_sample() {
1058        // The SAME sample `s1` appears in two of this producer's blocks — the signature of a
1059        // duplicated fold or a context that mixed several variants (PredictionBlock carries no variant
1060        // tag). The central gate must refuse this rather than let it be silently double-counted.
1061        let f0 = campaign_block("model:pls", "fold0", &["s1", "s2"]);
1062        let f1 = campaign_block("model:pls", "fold1", &["s1", "s3"]);
1063        let producer = NodeId::new("model:pls").unwrap();
1064        let err = validate_producer_oof_coverage(
1065            &producer,
1066            &[&f0, &f1],
1067            FoldPartitionMode::Partition,
1068            None,
1069        )
1070        .unwrap_err();
1071        assert!(
1072            err.to_string().contains("not unique")
1073                && err.to_string().contains("mixed several variants"),
1074            "got: {err}"
1075        );
1076    }
1077
1078    #[test]
1079    fn producer_oof_coverage_requested_universe_is_exact() {
1080        // Coverage must equal the requested universe exactly: a missing or extra sample is refused.
1081        let f0 = campaign_block("model:pls", "fold0", &["s1", "s2"]);
1082        let producer = NodeId::new("model:pls").unwrap();
1083        let missing: BTreeSet<SampleId> = ["s1", "s2", "s3"].iter().map(|s| sid(s)).collect();
1084        let err = validate_producer_oof_coverage(
1085            &producer,
1086            &[&f0],
1087            FoldPartitionMode::Partition,
1088            Some(&missing),
1089        )
1090        .unwrap_err();
1091        assert!(err.to_string().contains("not exact"), "got: {err}");
1092    }
1093
1094    #[test]
1095    fn producer_oof_coverage_ignores_non_validation_blocks() {
1096        // Train/Test/Final blocks are not OOF validation predictions and are skipped by the gate.
1097        let mut train = campaign_block("model:pls", "fold0", &["s1"]);
1098        train.partition = PredictionPartition::Train;
1099        let val = campaign_block("model:pls", "fold1", &["s1"]);
1100        let producer = NodeId::new("model:pls").unwrap();
1101        // s1 appears in a train block AND a validation block — only the validation one counts, so this
1102        // is unique and accepted.
1103        validate_producer_oof_coverage(
1104            &producer,
1105            &[&train, &val],
1106            FoldPartitionMode::Partition,
1107            None,
1108        )
1109        .unwrap();
1110    }
1111
1112    #[test]
1113    fn stacking_oof_refit_contract_allows_full_coverage() {
1114        let fold_set = contract_fold_set();
1115        let f0 = campaign_block("model:pls", "fold0", &["s1", "s2"]);
1116        let f1 = campaign_block("model:pls", "fold1", &["s3", "s4"]);
1117        let producer = NodeId::new("model:pls").unwrap();
1118
1119        let decision = validate_stacking_oof_refit_contract(
1120            &producer,
1121            &[&f0, &f1],
1122            &fold_set,
1123            &StackingOofRefitContract::default(),
1124        )
1125        .unwrap();
1126
1127        match decision {
1128            StackingOofRefitDecision::RefitAllowed(diagnostic) => {
1129                assert_eq!(diagnostic.cause, StackingOofRefitCause::FullCoverage);
1130                assert_eq!(diagnostic.requested_sample_count, 4);
1131                assert_eq!(diagnostic.covered_sample_count, 4);
1132            }
1133            other => panic!("full OOF coverage must allow refit, got {other:?}"),
1134        }
1135    }
1136
1137    #[test]
1138    fn stacking_oof_refit_contract_rejects_partial_without_policy() {
1139        let fold_set = contract_fold_set();
1140        let f0 = campaign_block("model:pls", "fold0", &["s1", "s2"]);
1141        let producer = NodeId::new("model:pls").unwrap();
1142
1143        let error = validate_stacking_oof_refit_contract(
1144            &producer,
1145            &[&f0],
1146            &fold_set,
1147            &StackingOofRefitContract::default(),
1148        )
1149        .unwrap_err()
1150        .to_string();
1151
1152        assert!(error.contains("cause=partial_oof_without_policy"));
1153        assert!(error.contains("do not cover the refit sample universe"));
1154    }
1155
1156    #[test]
1157    fn stacking_oof_refit_contract_skips_incomplete_when_explicit() {
1158        let fold_set = contract_fold_set();
1159        let f0 = campaign_block("model:pls", "fold0", &["s1", "s2"]);
1160        let producer = NodeId::new("model:pls").unwrap();
1161        let contract = StackingOofRefitContract {
1162            policy: StackingOofRefitPolicy::SkipRefitOnIncompleteOof,
1163        };
1164
1165        let decision =
1166            validate_stacking_oof_refit_contract(&producer, &[&f0], &fold_set, &contract).unwrap();
1167
1168        match decision {
1169            StackingOofRefitDecision::SkipRefit(diagnostic) => {
1170                assert_eq!(
1171                    diagnostic.cause,
1172                    StackingOofRefitCause::IncompleteOofCoverage
1173                );
1174                assert_eq!(diagnostic.covered_sample_count, 2);
1175                assert_eq!(diagnostic.missing_sample_ids, vec![sid("s3"), sid("s4")]);
1176            }
1177            other => panic!("partial OOF with explicit skip policy must skip refit, got {other:?}"),
1178        }
1179    }
1180
1181    #[test]
1182    fn stacking_oof_refit_contract_cv_only_skips_without_oof() {
1183        let fold_set = contract_fold_set();
1184        let producer = NodeId::new("model:pls").unwrap();
1185        let contract = StackingOofRefitContract {
1186            policy: StackingOofRefitPolicy::CvOnly,
1187        };
1188
1189        let decision =
1190            validate_stacking_oof_refit_contract(&producer, &[], &fold_set, &contract).unwrap();
1191
1192        match decision {
1193            StackingOofRefitDecision::SkipRefit(diagnostic) => {
1194                assert_eq!(diagnostic.cause, StackingOofRefitCause::CvOnly);
1195                assert_eq!(diagnostic.missing_sample_ids, fold_set.sample_ids);
1196            }
1197            other => panic!("cv_only stacking policy must skip refit, got {other:?}"),
1198        }
1199    }
1200
1201    #[test]
1202    fn stacking_oof_refit_contract_rejects_invalid_oof_even_with_skip_policy() {
1203        let fold_set = contract_fold_set();
1204        let mut f0 = campaign_block("model:pls", "fold0", &["s1", "s2"]);
1205        f0.partition = PredictionPartition::Train;
1206        let producer = NodeId::new("model:pls").unwrap();
1207        let contract = StackingOofRefitContract {
1208            policy: StackingOofRefitPolicy::SkipRefitOnIncompleteOof,
1209        };
1210
1211        let error = validate_stacking_oof_refit_contract(&producer, &[&f0], &fold_set, &contract)
1212            .unwrap_err()
1213            .to_string();
1214
1215        assert!(error.contains("cause=non_validation_partition"));
1216    }
1217
1218    #[test]
1219    fn joins_fold_blocks_by_producer_for_campaigns() {
1220        let mut b1_fold0 = campaign_block("branch:b1.model:rf", "fold0", &["s4", "s1"]);
1221        b1_fold0.values = vec![vec![40.0], vec![10.0]];
1222        let mut b1_fold1 = campaign_block("branch:b1.model:rf", "fold1", &["s2", "s3"]);
1223        b1_fold1.values = vec![vec![20.0], vec![30.0]];
1224        let mut b0_fold0 = campaign_block("branch:b0.model:pls", "fold0", &["s4", "s1"]);
1225        b0_fold0.values = vec![vec![4.0], vec![1.0]];
1226        let mut b0_fold1 = campaign_block("branch:b0.model:pls", "fold1", &["s2", "s3"]);
1227        b0_fold1.values = vec![vec![2.0], vec![3.0]];
1228
1229        let joined = join_oof_campaign_features(
1230            &PredictionJoinPolicy {
1231                node_id: NodeId::new("merge:pred").unwrap(),
1232                join_on: PredictionJoinKey::SampleId,
1233                allow_train_predictions_as_features: false,
1234                include_partitions: vec![PredictionPartition::Validation],
1235            },
1236            &[b1_fold0, b1_fold1, b0_fold0, b0_fold1],
1237            &[sid("s1"), sid("s2"), sid("s3"), sid("s4")],
1238        )
1239        .unwrap();
1240
1241        assert_eq!(
1242            joined.columns,
1243            vec!["branch:b0.model:pls__y", "branch:b1.model:rf__y"]
1244        );
1245        assert_eq!(
1246            joined.values,
1247            vec![
1248                vec![1.0, 10.0],
1249                vec![2.0, 20.0],
1250                vec![3.0, 30.0],
1251                vec![4.0, 40.0]
1252            ]
1253        );
1254    }
1255
1256    #[test]
1257    fn uc6_fixture_joins_successfully() {
1258        let fixture = load_fixture(include_str!(
1259            "../../../examples/fixtures/oof_campaign/uc6_oof_success_predictions.json"
1260        ));
1261
1262        let joined = validate_oof_campaign(&fixture).unwrap();
1263        assert_eq!(
1264            oof_campaign_fingerprint(&fixture).unwrap(),
1265            oof_campaign_fingerprint(&fixture).unwrap()
1266        );
1267
1268        assert_eq!(joined.columns.len(), 3);
1269        assert_eq!(joined.values[0], vec![1.0, 10.0, 100.0]);
1270        assert_eq!(joined.values[5], vec![6.0, 60.0, 600.0]);
1271    }
1272
1273    #[test]
1274    fn uc11_fixture_refuses_train_predictions() {
1275        let fixture = load_fixture(include_str!(
1276            "../../../examples/fixtures/oof_campaign/uc11_train_prediction_refusal.json"
1277        ));
1278
1279        let err = validate_oof_campaign(&fixture).unwrap_err();
1280
1281        match err {
1282            DagMlError::OofLeakage(report) => {
1283                assert_eq!(report.node_id, "merge:pred");
1284                assert!(!report.allow_train_predictions_as_features);
1285                assert_eq!(report.violators.len(), 1);
1286                assert_eq!(report.violators[0].partition, "train");
1287            }
1288            other => panic!("expected OOF leakage error, got {other:?}"),
1289        }
1290    }
1291
1292    #[test]
1293    fn fold_validation_rejects_wrong_validation_partition_samples() {
1294        let mut fixture = load_fixture(include_str!(
1295            "../../../examples/fixtures/oof_campaign/uc6_oof_success_predictions.json"
1296        ));
1297        fixture.prediction_blocks[0].sample_ids = vec![sid("S001"), sid("S002")];
1298
1299        let err = validate_oof_campaign(&fixture).unwrap_err();
1300
1301        assert!(err
1302            .to_string()
1303            .contains("do not match fold validation samples"));
1304    }
1305
1306    #[test]
1307    #[ignore = "perf sanity probe; run with --release --ignored --nocapture"]
1308    fn oof_join_large_campaign_under_1500ms() {
1309        let sample_count = 12_000usize;
1310        let producer_count = 4usize;
1311        let fold_count = 6usize;
1312        let required_samples = (0..sample_count)
1313            .map(|sample_idx| sid(&format!("s{sample_idx:05}")))
1314            .collect::<Vec<_>>();
1315        let mut blocks = Vec::new();
1316
1317        for producer_idx in 0..producer_count {
1318            for fold_idx in 0..fold_count {
1319                let sample_ids = (fold_idx..sample_count)
1320                    .step_by(fold_count)
1321                    .map(|sample_idx| sid(&format!("s{sample_idx:05}")))
1322                    .collect::<Vec<_>>();
1323                let values = (fold_idx..sample_count)
1324                    .step_by(fold_count)
1325                    .map(|sample_idx| vec![producer_idx as f64, sample_idx as f64])
1326                    .collect::<Vec<_>>();
1327                blocks.push(PredictionBlock {
1328                    prediction_id: None,
1329                    producer_node: NodeId::new(format!("model:p{producer_idx}")).unwrap(),
1330                    producer_port: None,
1331                    partition: PredictionPartition::Validation,
1332                    fold_id: Some(FoldId::new(format!("fold:{fold_idx}")).unwrap()),
1333                    sample_ids,
1334                    values,
1335                    target_names: vec!["score".to_string(), "rank".to_string()],
1336                });
1337            }
1338        }
1339
1340        let started = Instant::now();
1341        let joined = join_oof_campaign_features(
1342            &PredictionJoinPolicy {
1343                node_id: NodeId::new("merge:perf").unwrap(),
1344                join_on: PredictionJoinKey::SampleId,
1345                allow_train_predictions_as_features: false,
1346                include_partitions: vec![PredictionPartition::Validation],
1347            },
1348            &blocks,
1349            &required_samples,
1350        )
1351        .unwrap();
1352        let elapsed = started.elapsed();
1353
1354        assert_eq!(joined.sample_ids.len(), sample_count);
1355        assert_eq!(joined.columns.len(), producer_count * 2);
1356        assert!(
1357            elapsed <= Duration::from_millis(1_500),
1358            "large OOF join took {elapsed:?}"
1359        );
1360    }
1361}