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 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
108pub 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 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]
194 RequireFullCoverage,
195 CvOnly,
197 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 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 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 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 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 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 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 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 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 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 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 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 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}