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