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