Skip to main content

dag_ml_core/
bundle.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::aggregation::{AggregatedPredictionBlock, PredictionUnitId};
6use crate::campaign::stable_json_fingerprint;
7use crate::canonical::deserialize_external_contract;
8use crate::data::{
9    data_binding_requirement_key, ExternalDataPlanEnvelope, RepresentationCompatibilityReport,
10    RepresentationReplayManifest,
11};
12use crate::error::{DagMlError, Result};
13use crate::ids::{BundleId, ControllerId, FoldId, NodeId, SampleId, VariantId};
14use crate::metrics::ScoreSet;
15use crate::oof::{PredictionBlock, PredictionPartition};
16use crate::phase::Phase;
17use crate::plan::ExecutionPlan;
18use crate::policy::PredictionLevel;
19use crate::runtime::ArtifactRef;
20use crate::selection::SelectionDecision;
21
22pub const EXECUTION_BUNDLE_SCHEMA_VERSION: u32 = 2;
23pub const PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION: u32 = 2;
24pub const LEGACY_EXECUTION_BUNDLE_SCHEMA_VERSION: u32 = 1;
25pub const LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION: u32 = 1;
26pub const LEGACY_BUNDLE_PREDICTION_CACHE_FORMAT: &str = "dag-ml-json-prediction-blocks-v1";
27pub const BUNDLE_PREDICTION_CACHE_FORMAT: &str = "dag-ml-json-prediction-blocks-v2";
28
29pub const MIN_READABLE_EXECUTION_BUNDLE_SCHEMA_VERSION: u32 = 1;
30pub const MIN_WRITABLE_EXECUTION_BUNDLE_SCHEMA_VERSION: u32 = 2;
31pub const MIN_READABLE_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION: u32 = 1;
32pub const MIN_WRITABLE_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION: u32 = 2;
33
34fn default_execution_bundle_schema_version() -> u32 {
35    LEGACY_EXECUTION_BUNDLE_SCHEMA_VERSION
36}
37
38fn default_prediction_cache_payload_schema_version() -> u32 {
39    LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION
40}
41
42fn default_prediction_level() -> PredictionLevel {
43    PredictionLevel::Sample
44}
45
46fn supported_prediction_cache_format(format: &str) -> bool {
47    matches!(
48        format,
49        LEGACY_BUNDLE_PREDICTION_CACHE_FORMAT | BUNDLE_PREDICTION_CACHE_FORMAT
50    )
51}
52
53fn prediction_cache_schema_version_for_format(format: &str, owner: &str) -> Result<u32> {
54    match format {
55        LEGACY_BUNDLE_PREDICTION_CACHE_FORMAT => Ok(LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION),
56        BUNDLE_PREDICTION_CACHE_FORMAT => Ok(PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION),
57        _ => Err(DagMlError::RuntimeValidation(format!(
58            "{owner} uses unsupported cache format `{format}`"
59        ))),
60    }
61}
62
63fn expected_prediction_cache_format_for_schema_version(
64    schema_version: u32,
65    owner: &str,
66) -> Result<&'static str> {
67    match schema_version {
68        LEGACY_EXECUTION_BUNDLE_SCHEMA_VERSION => Ok(LEGACY_BUNDLE_PREDICTION_CACHE_FORMAT),
69        EXECUTION_BUNDLE_SCHEMA_VERSION => Ok(BUNDLE_PREDICTION_CACHE_FORMAT),
70        _ => Err(DagMlError::RuntimeValidation(format!(
71            "{owner} uses unsupported cache family schema_version {schema_version}"
72        ))),
73    }
74}
75
76fn validate_prediction_cache_format_for_schema_version(
77    format: &str,
78    schema_version: u32,
79    owner: &str,
80) -> Result<()> {
81    let expected = expected_prediction_cache_format_for_schema_version(schema_version, owner)?;
82    if format != expected {
83        return Err(DagMlError::RuntimeValidation(format!(
84            "{owner} uses cache format `{format}` but schema_version {schema_version} requires `{expected}`"
85        )));
86    }
87    Ok(())
88}
89
90fn validate_prediction_block_port_family(
91    producer_port: &Option<String>,
92    schema_version: u32,
93    owner: &str,
94) -> Result<()> {
95    match (schema_version, producer_port.as_deref()) {
96        (LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION, Some(_)) => Err(
97            DagMlError::RuntimeValidation(format!("{owner} is V1 but carries producer_port")),
98        ),
99        (PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION, Some(port)) if port.trim().is_empty() => {
100            Err(DagMlError::RuntimeValidation(format!(
101                "{owner} is V2 but carries an empty producer_port"
102            )))
103        }
104        (PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION, None) => Err(DagMlError::RuntimeValidation(
105            format!("{owner} is V2 and requires producer_port"),
106        )),
107        _ => Ok(()),
108    }
109}
110
111fn validate_prediction_cache_payload_block_family(
112    payload: &BundlePredictionCachePayload,
113    schema_version: u32,
114) -> Result<()> {
115    for block in &payload.blocks {
116        validate_prediction_block_port_family(
117            &block.producer_port,
118            schema_version,
119            &format!(
120                "prediction cache payload `{}` block for node `{}`",
121                payload.cache_id, block.producer_node
122            ),
123        )?;
124    }
125    for block in &payload.aggregated_blocks {
126        validate_prediction_block_port_family(
127            &block.producer_port,
128            schema_version,
129            &format!(
130                "prediction cache payload `{}` aggregated block for node `{}`",
131                payload.cache_id, block.producer_node
132            ),
133        )?;
134    }
135    Ok(())
136}
137
138#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
139pub struct SchemaMigrationPolicy {
140    pub artifact: String,
141    pub current_version: u32,
142    pub min_readable_version: u32,
143    pub min_writable_version: u32,
144    #[serde(default)]
145    pub automatic_migrations: BTreeMap<u32, u32>,
146}
147
148impl SchemaMigrationPolicy {
149    pub fn validate(&self) -> Result<()> {
150        validate_non_empty("schema migration artifact", &self.artifact)?;
151        if self.current_version == 0
152            || self.min_readable_version == 0
153            || self.min_writable_version == 0
154        {
155            return Err(DagMlError::RuntimeValidation(format!(
156                "schema migration policy `{}` has zero version boundary",
157                self.artifact
158            )));
159        }
160        if self.min_readable_version > self.current_version {
161            return Err(DagMlError::RuntimeValidation(format!(
162                "schema migration policy `{}` min_readable_version exceeds current_version",
163                self.artifact
164            )));
165        }
166        if self.min_writable_version > self.current_version {
167            return Err(DagMlError::RuntimeValidation(format!(
168                "schema migration policy `{}` min_writable_version exceeds current_version",
169                self.artifact
170            )));
171        }
172        for (from, to) in &self.automatic_migrations {
173            if *from == 0 || *to == 0 {
174                return Err(DagMlError::RuntimeValidation(format!(
175                    "schema migration policy `{}` contains a zero migration version",
176                    self.artifact
177                )));
178            }
179            if from == to {
180                return Err(DagMlError::RuntimeValidation(format!(
181                    "schema migration policy `{}` contains a no-op migration {from}->{to}",
182                    self.artifact
183                )));
184            }
185            if *to > self.current_version {
186                return Err(DagMlError::RuntimeValidation(format!(
187                    "schema migration policy `{}` migrates to unsupported future version {to}",
188                    self.artifact
189                )));
190            }
191        }
192        Ok(())
193    }
194
195    pub fn validate_read_version(&self, version: u32, owner: &str) -> Result<()> {
196        self.validate()?;
197        if version < self.min_readable_version {
198            return Err(DagMlError::RuntimeValidation(format!(
199                "{owner} uses schema_version {version}, below minimum readable {} for {}",
200                self.min_readable_version, self.artifact
201            )));
202        }
203        if version > self.current_version {
204            return Err(DagMlError::RuntimeValidation(format!(
205                "{owner} uses future schema_version {version}, current readable {} for {}",
206                self.current_version, self.artifact
207            )));
208        }
209        if version != self.current_version && !self.automatic_migrations.contains_key(&version) {
210            return Err(DagMlError::RuntimeValidation(format!(
211                "{owner} uses schema_version {version}, but {} declares no automatic migration to current version {}",
212                self.artifact, self.current_version
213            )));
214        }
215        Ok(())
216    }
217}
218
219pub fn execution_bundle_schema_migration_policy() -> SchemaMigrationPolicy {
220    SchemaMigrationPolicy {
221        artifact: "execution_bundle".to_string(),
222        current_version: EXECUTION_BUNDLE_SCHEMA_VERSION,
223        min_readable_version: MIN_READABLE_EXECUTION_BUNDLE_SCHEMA_VERSION,
224        min_writable_version: MIN_WRITABLE_EXECUTION_BUNDLE_SCHEMA_VERSION,
225        automatic_migrations: BTreeMap::from([(
226            LEGACY_EXECUTION_BUNDLE_SCHEMA_VERSION,
227            EXECUTION_BUNDLE_SCHEMA_VERSION,
228        )]),
229    }
230}
231
232pub fn prediction_cache_payload_schema_migration_policy() -> SchemaMigrationPolicy {
233    SchemaMigrationPolicy {
234        artifact: "prediction_cache_payload".to_string(),
235        current_version: PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
236        min_readable_version: MIN_READABLE_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
237        min_writable_version: MIN_WRITABLE_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
238        automatic_migrations: BTreeMap::from([(
239            LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
240            PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
241        )]),
242    }
243}
244
245#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
246pub struct BundleDataRequirement {
247    pub node_id: NodeId,
248    pub input_name: String,
249    pub schema_fingerprint: String,
250    pub plan_fingerprint: String,
251    #[serde(default)]
252    pub relation_fingerprint: Option<String>,
253    pub output_representation: String,
254    #[serde(default)]
255    pub feature_set_id: Option<String>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub representation_replay_manifest: Option<RepresentationReplayManifest>,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub representation_compatibility: Option<RepresentationCompatibilityReport>,
260}
261
262impl BundleDataRequirement {
263    pub fn key(&self) -> String {
264        data_binding_requirement_key(&self.node_id, &self.input_name)
265    }
266
267    fn matches_plan_requirement(&self, expected: &Self) -> bool {
268        self.node_id == expected.node_id
269            && self.input_name == expected.input_name
270            && self.schema_fingerprint == expected.schema_fingerprint
271            && self.plan_fingerprint == expected.plan_fingerprint
272            && self.relation_fingerprint == expected.relation_fingerprint
273            && self.output_representation == expected.output_representation
274            && self.feature_set_id == expected.feature_set_id
275    }
276
277    pub fn validate(&self) -> Result<()> {
278        if self.input_name.trim().is_empty() {
279            return Err(DagMlError::CampaignValidation(format!(
280                "bundle data requirement for `{}` has empty input_name",
281                self.node_id
282            )));
283        }
284        validate_fingerprint("schema", &self.schema_fingerprint)?;
285        validate_fingerprint("plan", &self.plan_fingerprint)?;
286        if let Some(relation_fingerprint) = &self.relation_fingerprint {
287            validate_fingerprint("relation", relation_fingerprint)?;
288        }
289        if let Some(replay_manifest) = &self.representation_replay_manifest {
290            replay_manifest.validate()?;
291            if let (Some(requirement), Some(manifest)) = (
292                self.relation_fingerprint.as_deref(),
293                replay_manifest.relation_fingerprint.as_deref(),
294            ) {
295                if requirement != manifest {
296                    return Err(DagMlError::CampaignValidation(format!(
297                        "bundle data requirement `{}` relation_fingerprint does not match representation replay manifest",
298                        self.key()
299                    )));
300                }
301            }
302        }
303        if let Some(report) = &self.representation_compatibility {
304            report.validate()?;
305        }
306        if self.output_representation.trim().is_empty() {
307            return Err(DagMlError::CampaignValidation(format!(
308                "bundle data requirement `{}` has empty output representation",
309                self.key()
310            )));
311        }
312        if let Some(feature_set_id) = &self.feature_set_id {
313            if feature_set_id.trim().is_empty() {
314                return Err(DagMlError::CampaignValidation(format!(
315                    "bundle data requirement `{}` has empty feature_set_id",
316                    self.key()
317                )));
318            }
319        }
320        Ok(())
321    }
322}
323
324#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
325pub struct BundlePredictionRequirement {
326    pub producer_node: NodeId,
327    pub source_port: String,
328    pub consumer_node: NodeId,
329    pub target_port: String,
330    pub partition: PredictionPartition,
331    #[serde(default = "default_prediction_level")]
332    pub prediction_level: PredictionLevel,
333    #[serde(default)]
334    pub fold_ids: Vec<FoldId>,
335    #[serde(default, skip_serializing_if = "Vec::is_empty")]
336    pub unit_ids: Vec<PredictionUnitId>,
337    #[serde(default)]
338    pub sample_ids: Vec<SampleId>,
339    pub prediction_width: usize,
340    pub target_names: Vec<String>,
341}
342
343impl BundlePredictionRequirement {
344    pub fn key(&self) -> String {
345        bundle_prediction_requirement_key(
346            &self.producer_node,
347            &self.source_port,
348            &self.consumer_node,
349            &self.target_port,
350        )
351    }
352
353    pub fn validate(&self) -> Result<()> {
354        validate_non_empty("source_port", &self.source_port)?;
355        validate_non_empty("target_port", &self.target_port)?;
356        if self.partition != PredictionPartition::Validation {
357            return Err(DagMlError::RuntimeValidation(format!(
358                "bundle prediction requirement `{}` must use validation OOF predictions",
359                self.key()
360            )));
361        }
362        validate_unique_ids("fold id", &self.fold_ids)?;
363        validate_prediction_requirement_units(self)?;
364        if self.prediction_width == 0 {
365            return Err(DagMlError::RuntimeValidation(format!(
366                "bundle prediction requirement `{}` has zero prediction width",
367                self.key()
368            )));
369        }
370        if self.target_names.len() != self.prediction_width {
371            return Err(DagMlError::RuntimeValidation(format!(
372                "bundle prediction requirement `{}` target name count does not match prediction width",
373                self.key()
374            )));
375        }
376        for target_name in &self.target_names {
377            validate_non_empty("target_name", target_name)?;
378        }
379        Ok(())
380    }
381}
382
383pub fn bundle_prediction_requirement_key(
384    producer_node: &NodeId,
385    source_port: &str,
386    consumer_node: &NodeId,
387    target_port: &str,
388) -> String {
389    format!("{producer_node}.{source_port}->{consumer_node}.{target_port}")
390}
391
392#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
393pub struct BundlePredictionBlockCacheRecord {
394    #[serde(default)]
395    pub prediction_id: Option<String>,
396    #[serde(default)]
397    pub fold_id: Option<FoldId>,
398    #[serde(default = "default_prediction_level")]
399    pub prediction_level: PredictionLevel,
400    pub row_count: usize,
401    #[serde(default, skip_serializing_if = "Vec::is_empty")]
402    pub unit_ids: Vec<PredictionUnitId>,
403    #[serde(default)]
404    pub sample_ids: Vec<SampleId>,
405    pub content_fingerprint: String,
406}
407
408impl BundlePredictionBlockCacheRecord {
409    pub fn validate(&self) -> Result<()> {
410        if let Some(prediction_id) = &self.prediction_id {
411            validate_non_empty("prediction_id", prediction_id)?;
412        }
413        if self.row_count == 0 {
414            return Err(DagMlError::RuntimeValidation(
415                "prediction block cache record has zero rows".to_string(),
416            ));
417        }
418        validate_prediction_cache_block_record_units(self)?;
419        validate_fingerprint("prediction block cache content", &self.content_fingerprint)
420    }
421}
422
423#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
424pub struct BundlePredictionCacheRecord {
425    pub requirement_key: String,
426    pub cache_id: String,
427    #[serde(default, skip_serializing_if = "Vec::is_empty")]
428    pub cache_namespace_fingerprints: Vec<String>,
429    pub format: String,
430    pub partition: PredictionPartition,
431    #[serde(default = "default_prediction_level")]
432    pub prediction_level: PredictionLevel,
433    #[serde(default)]
434    pub fold_ids: Vec<FoldId>,
435    #[serde(default, skip_serializing_if = "Vec::is_empty")]
436    pub unit_ids: Vec<PredictionUnitId>,
437    #[serde(default)]
438    pub sample_ids: Vec<SampleId>,
439    pub prediction_width: usize,
440    pub target_names: Vec<String>,
441    pub block_count: usize,
442    pub row_count: usize,
443    pub content_fingerprint: String,
444    #[serde(default)]
445    pub blocks: Vec<BundlePredictionBlockCacheRecord>,
446}
447
448impl BundlePredictionCacheRecord {
449    pub fn validate(&self) -> Result<()> {
450        validate_non_empty("requirement_key", &self.requirement_key)?;
451        validate_non_empty("cache_id", &self.cache_id)?;
452        validate_prediction_cache_namespace_fingerprints(
453            &self.cache_id,
454            &self.cache_namespace_fingerprints,
455        )?;
456        validate_non_empty("format", &self.format)?;
457        if !supported_prediction_cache_format(&self.format) {
458            return Err(DagMlError::RuntimeValidation(format!(
459                "prediction cache `{}` uses unsupported format `{}`",
460                self.cache_id, self.format
461            )));
462        }
463        if self.partition != PredictionPartition::Validation {
464            return Err(DagMlError::RuntimeValidation(format!(
465                "prediction cache `{}` must cache validation OOF predictions",
466                self.cache_id
467            )));
468        }
469        validate_unique_ids("fold id", &self.fold_ids)?;
470        validate_prediction_cache_record_units(self)?;
471        if self.prediction_width == 0 {
472            return Err(DagMlError::RuntimeValidation(format!(
473                "prediction cache `{}` has zero prediction width",
474                self.cache_id
475            )));
476        }
477        if self.target_names.len() != self.prediction_width {
478            return Err(DagMlError::RuntimeValidation(format!(
479                "prediction cache `{}` target name count does not match prediction width",
480                self.cache_id
481            )));
482        }
483        for target_name in &self.target_names {
484            validate_non_empty("target_name", target_name)?;
485        }
486        if self.block_count == 0 || self.block_count != self.blocks.len() {
487            return Err(DagMlError::RuntimeValidation(format!(
488                "prediction cache `{}` block_count does not match block records",
489                self.cache_id
490            )));
491        }
492        if !self.cache_namespace_fingerprints.is_empty()
493            && self.cache_namespace_fingerprints.len() != self.block_count
494        {
495            return Err(DagMlError::RuntimeValidation(format!(
496                "prediction cache `{}` namespace fingerprint count does not match block_count",
497                self.cache_id
498            )));
499        }
500        validate_prediction_cache_record_blocks(self)?;
501        validate_fingerprint("prediction cache content", &self.content_fingerprint)?;
502        Ok(())
503    }
504}
505
506fn validate_prediction_requirement_units(requirement: &BundlePredictionRequirement) -> Result<()> {
507    match requirement.prediction_level {
508        PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
509            "bundle prediction requirement `{}` cannot replay observation-level caches; aggregate to sample first",
510            requirement.key()
511        ))),
512        PredictionLevel::Sample => {
513            validate_unique_ids("sample id", &requirement.sample_ids)?;
514            if requirement.sample_ids.is_empty() {
515                return Err(DagMlError::RuntimeValidation(format!(
516                    "bundle prediction requirement `{}` has no sample ids",
517                    requirement.key()
518                )));
519            }
520            if !requirement.unit_ids.is_empty()
521                && requirement.unit_ids != sample_prediction_units(&requirement.sample_ids)
522            {
523                return Err(DagMlError::RuntimeValidation(format!(
524                    "bundle prediction requirement `{}` sample ids do not match unit ids",
525                    requirement.key()
526                )));
527            }
528            Ok(())
529        }
530        PredictionLevel::Target | PredictionLevel::Group => {
531            if !requirement.sample_ids.is_empty() {
532                return Err(DagMlError::RuntimeValidation(format!(
533                    "bundle prediction requirement `{}` uses {:?} unit ids but also carries sample ids",
534                    requirement.key(),
535                    requirement.prediction_level
536                )));
537            }
538            validate_prediction_units(
539                "bundle prediction requirement unit",
540                requirement.prediction_level,
541                &requirement.unit_ids,
542            )?;
543            if requirement.unit_ids.is_empty() {
544                return Err(DagMlError::RuntimeValidation(format!(
545                    "bundle prediction requirement `{}` has no unit ids",
546                    requirement.key()
547                )));
548            }
549            Ok(())
550        }
551    }
552}
553
554fn validate_prediction_cache_block_record_units(
555    block: &BundlePredictionBlockCacheRecord,
556) -> Result<()> {
557    match block.prediction_level {
558        PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(
559            "prediction block cache record cannot use observation-level predictions".to_string(),
560        )),
561        PredictionLevel::Sample => {
562            validate_unique_ids("sample id", &block.sample_ids)?;
563            if block.row_count != block.sample_ids.len() {
564                return Err(DagMlError::RuntimeValidation(format!(
565                    "prediction block cache record row_count {} does not match {} sample ids",
566                    block.row_count,
567                    block.sample_ids.len()
568                )));
569            }
570            if !block.unit_ids.is_empty()
571                && block.unit_ids != sample_prediction_units(&block.sample_ids)
572            {
573                return Err(DagMlError::RuntimeValidation(
574                    "prediction block cache record sample ids do not match unit ids".to_string(),
575                ));
576            }
577            Ok(())
578        }
579        PredictionLevel::Target | PredictionLevel::Group => {
580            if !block.sample_ids.is_empty() {
581                return Err(DagMlError::RuntimeValidation(format!(
582                    "prediction block cache record uses {:?} unit ids but also carries sample ids",
583                    block.prediction_level
584                )));
585            }
586            validate_prediction_units(
587                "prediction block cache record unit",
588                block.prediction_level,
589                &block.unit_ids,
590            )?;
591            if block.row_count != block.unit_ids.len() {
592                return Err(DagMlError::RuntimeValidation(format!(
593                    "prediction block cache record row_count {} does not match {} unit ids",
594                    block.row_count,
595                    block.unit_ids.len()
596                )));
597            }
598            Ok(())
599        }
600    }
601}
602
603fn validate_prediction_cache_record_units(cache: &BundlePredictionCacheRecord) -> Result<()> {
604    match cache.prediction_level {
605        PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
606            "prediction cache `{}` cannot use observation-level predictions",
607            cache.cache_id
608        ))),
609        PredictionLevel::Sample => {
610            validate_unique_ids("sample id", &cache.sample_ids)?;
611            if cache.row_count != cache.sample_ids.len() {
612                return Err(DagMlError::RuntimeValidation(format!(
613                    "prediction cache `{}` row_count does not match unique sample ids",
614                    cache.cache_id
615                )));
616            }
617            if !cache.unit_ids.is_empty()
618                && cache.unit_ids != sample_prediction_units(&cache.sample_ids)
619            {
620                return Err(DagMlError::RuntimeValidation(format!(
621                    "prediction cache `{}` sample ids do not match unit ids",
622                    cache.cache_id
623                )));
624            }
625            Ok(())
626        }
627        PredictionLevel::Target | PredictionLevel::Group => {
628            if !cache.sample_ids.is_empty() {
629                return Err(DagMlError::RuntimeValidation(format!(
630                    "prediction cache `{}` uses {:?} unit ids but also carries sample ids",
631                    cache.cache_id, cache.prediction_level
632                )));
633            }
634            validate_prediction_units(
635                "prediction cache unit",
636                cache.prediction_level,
637                &cache.unit_ids,
638            )?;
639            if cache.row_count != cache.unit_ids.len() {
640                return Err(DagMlError::RuntimeValidation(format!(
641                    "prediction cache `{}` row_count does not match unique unit ids",
642                    cache.cache_id
643                )));
644            }
645            Ok(())
646        }
647    }
648}
649
650fn validate_prediction_cache_record_blocks(cache: &BundlePredictionCacheRecord) -> Result<()> {
651    let mut row_count = 0usize;
652    let mut samples = BTreeSet::new();
653    let mut units = BTreeSet::new();
654    for block in &cache.blocks {
655        block.validate()?;
656        if block.prediction_level != cache.prediction_level {
657            return Err(DagMlError::RuntimeValidation(format!(
658                "prediction cache `{}` mixes block prediction levels",
659                cache.cache_id
660            )));
661        }
662        row_count += block.row_count;
663        match cache.prediction_level {
664            PredictionLevel::Sample => {
665                for sample_id in &block.sample_ids {
666                    if !samples.insert(sample_id.clone()) {
667                        return Err(DagMlError::RuntimeValidation(format!(
668                            "prediction cache `{}` contains duplicate sample `{sample_id}`",
669                            cache.cache_id
670                        )));
671                    }
672                }
673            }
674            PredictionLevel::Target | PredictionLevel::Group => {
675                for unit_id in &block.unit_ids {
676                    if !units.insert(unit_id.clone()) {
677                        return Err(DagMlError::RuntimeValidation(format!(
678                            "prediction cache `{}` contains duplicate unit `{unit_id}`",
679                            cache.cache_id
680                        )));
681                    }
682                }
683            }
684            PredictionLevel::Observation => {
685                unreachable!("record unit validation rejects observation")
686            }
687        }
688    }
689    if cache.row_count == 0 || cache.row_count != row_count {
690        return Err(DagMlError::RuntimeValidation(format!(
691            "prediction cache `{}` row_count does not match block records",
692            cache.cache_id
693        )));
694    }
695    if cache.prediction_level == PredictionLevel::Sample {
696        let expected = cache.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
697        if samples != expected {
698            return Err(DagMlError::RuntimeValidation(format!(
699                "prediction cache `{}` block samples do not match cache sample ids",
700                cache.cache_id
701            )));
702        }
703    } else {
704        let expected = cache.unit_ids.iter().cloned().collect::<BTreeSet<_>>();
705        if units != expected {
706            return Err(DagMlError::RuntimeValidation(format!(
707                "prediction cache `{}` block units do not match cache unit ids",
708                cache.cache_id
709            )));
710        }
711    }
712    Ok(())
713}
714
715fn validate_prediction_cache_payload_blocks(
716    payload: &BundlePredictionCachePayload,
717) -> Result<usize> {
718    match payload.prediction_level {
719        PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
720            "prediction cache payload `{}` cannot use observation-level predictions",
721            payload.cache_id
722        ))),
723        PredictionLevel::Sample => validate_sample_prediction_cache_payload_blocks(payload),
724        PredictionLevel::Target | PredictionLevel::Group => {
725            validate_aggregated_prediction_cache_payload_blocks(payload)
726        }
727    }
728}
729
730fn validate_sample_prediction_cache_payload_blocks(
731    payload: &BundlePredictionCachePayload,
732) -> Result<usize> {
733    let mut row_count = 0usize;
734    let mut sample_ids = BTreeSet::new();
735    for block in &payload.blocks {
736        block.validate_shape()?;
737        if block.partition != payload.partition {
738            return Err(DagMlError::RuntimeValidation(format!(
739                "prediction cache payload `{}` contains a block from partition {:?}",
740                payload.cache_id, block.partition
741            )));
742        }
743        for sample_id in &block.sample_ids {
744            if !sample_ids.insert(sample_id) {
745                return Err(DagMlError::RuntimeValidation(format!(
746                    "prediction cache payload `{}` contains duplicate sample `{}`",
747                    payload.cache_id, sample_id
748                )));
749            }
750        }
751        row_count += block.sample_ids.len();
752    }
753    Ok(row_count)
754}
755
756fn validate_aggregated_prediction_cache_payload_blocks(
757    payload: &BundlePredictionCachePayload,
758) -> Result<usize> {
759    let mut row_count = 0usize;
760    let mut unit_ids = BTreeSet::new();
761    for block in &payload.aggregated_blocks {
762        block.validate_shape()?;
763        if block.partition != payload.partition {
764            return Err(DagMlError::RuntimeValidation(format!(
765                "prediction cache payload `{}` contains an aggregated block from partition {:?}",
766                payload.cache_id, block.partition
767            )));
768        }
769        if block.level != payload.prediction_level {
770            return Err(DagMlError::RuntimeValidation(format!(
771                "prediction cache payload `{}` contains {:?} block inside {:?} payload",
772                payload.cache_id, block.level, payload.prediction_level
773            )));
774        }
775        for unit_id in &block.unit_ids {
776            if !unit_ids.insert(unit_id) {
777                return Err(DagMlError::RuntimeValidation(format!(
778                    "prediction cache payload `{}` contains duplicate unit `{unit_id}`",
779                    payload.cache_id
780                )));
781            }
782        }
783        row_count += block.unit_ids.len();
784    }
785    Ok(row_count)
786}
787
788#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
789pub struct BundlePredictionCachePayload {
790    pub requirement_key: String,
791    pub cache_id: String,
792    #[serde(default, skip_serializing_if = "Vec::is_empty")]
793    pub cache_namespace_fingerprints: Vec<String>,
794    pub format: String,
795    pub partition: PredictionPartition,
796    #[serde(default = "default_prediction_level")]
797    pub prediction_level: PredictionLevel,
798    pub block_count: usize,
799    pub row_count: usize,
800    pub content_fingerprint: String,
801    #[serde(default)]
802    pub blocks: Vec<PredictionBlock>,
803    #[serde(default, skip_serializing_if = "Vec::is_empty")]
804    pub aggregated_blocks: Vec<AggregatedPredictionBlock>,
805}
806
807impl BundlePredictionCachePayload {
808    pub fn validate(&self) -> Result<()> {
809        validate_non_empty("requirement_key", &self.requirement_key)?;
810        validate_non_empty("cache_id", &self.cache_id)?;
811        validate_prediction_cache_namespace_fingerprints(
812            &self.cache_id,
813            &self.cache_namespace_fingerprints,
814        )?;
815        validate_non_empty("format", &self.format)?;
816        if !supported_prediction_cache_format(&self.format) {
817            return Err(DagMlError::RuntimeValidation(format!(
818                "prediction cache payload `{}` uses unsupported format `{}`",
819                self.cache_id, self.format
820            )));
821        }
822        let payload_schema_version = prediction_cache_schema_version_for_format(
823            &self.format,
824            &format!("prediction cache payload `{}`", self.cache_id),
825        )?;
826        if self.partition != PredictionPartition::Validation {
827            return Err(DagMlError::RuntimeValidation(format!(
828                "prediction cache payload `{}` must cache validation OOF predictions",
829                self.cache_id
830            )));
831        }
832        let expected_block_count = if self.prediction_level == PredictionLevel::Sample {
833            if !self.aggregated_blocks.is_empty() {
834                return Err(DagMlError::RuntimeValidation(format!(
835                    "prediction cache payload `{}` mixes sample and aggregated blocks",
836                    self.cache_id
837                )));
838            }
839            self.blocks.len()
840        } else {
841            if !self.blocks.is_empty() {
842                return Err(DagMlError::RuntimeValidation(format!(
843                    "prediction cache payload `{}` mixes aggregated and sample blocks",
844                    self.cache_id
845                )));
846            }
847            self.aggregated_blocks.len()
848        };
849        if self.block_count == 0 || self.block_count != expected_block_count {
850            return Err(DagMlError::RuntimeValidation(format!(
851                "prediction cache payload `{}` block_count does not match blocks",
852                self.cache_id
853            )));
854        }
855        if !self.cache_namespace_fingerprints.is_empty()
856            && self.cache_namespace_fingerprints.len() != self.block_count
857        {
858            return Err(DagMlError::RuntimeValidation(format!(
859                "prediction cache payload `{}` namespace fingerprint count does not match block_count",
860                self.cache_id
861            )));
862        }
863        let row_count = validate_prediction_cache_payload_blocks(self)?;
864        if self.row_count == 0 || self.row_count != row_count {
865            return Err(DagMlError::RuntimeValidation(format!(
866                "prediction cache payload `{}` row_count does not match blocks",
867                self.cache_id
868            )));
869        }
870        validate_prediction_cache_payload_block_family(self, payload_schema_version)?;
871        validate_fingerprint(
872            "prediction cache payload content",
873            &self.content_fingerprint,
874        )?;
875        let actual_fingerprint = if self.prediction_level == PredictionLevel::Sample {
876            stable_json_fingerprint(&self.blocks)?
877        } else {
878            stable_json_fingerprint(&self.aggregated_blocks)?
879        };
880        if actual_fingerprint != self.content_fingerprint {
881            return Err(DagMlError::RuntimeValidation(format!(
882                "prediction cache payload `{}` content fingerprint does not match blocks",
883                self.cache_id
884            )));
885        }
886        Ok(())
887    }
888}
889
890#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
891pub struct BundlePredictionCachePayloadSet {
892    pub bundle_id: BundleId,
893    #[serde(default = "default_prediction_cache_payload_schema_version")]
894    pub schema_version: u32,
895    #[serde(default)]
896    pub caches: Vec<BundlePredictionCachePayload>,
897}
898
899impl BundlePredictionCachePayloadSet {
900    pub fn validate(&self) -> Result<()> {
901        prediction_cache_payload_schema_migration_policy().validate_read_version(
902            self.schema_version,
903            &format!(
904                "prediction cache payload set for bundle `{}`",
905                self.bundle_id
906            ),
907        )?;
908        let mut requirement_keys = BTreeSet::new();
909        let mut cache_ids = BTreeSet::new();
910        for payload in &self.caches {
911            payload.validate()?;
912            validate_prediction_cache_format_for_schema_version(
913                &payload.format,
914                self.schema_version,
915                &format!(
916                    "prediction cache payload `{}` in set for bundle `{}`",
917                    payload.cache_id, self.bundle_id
918                ),
919            )?;
920            validate_prediction_cache_payload_block_family(payload, self.schema_version)?;
921            if !requirement_keys.insert(payload.requirement_key.as_str()) {
922                return Err(DagMlError::RuntimeValidation(format!(
923                    "prediction cache payload set for bundle `{}` has duplicate requirement `{}`",
924                    self.bundle_id, payload.requirement_key
925                )));
926            }
927            if !cache_ids.insert(payload.cache_id.as_str()) {
928                return Err(DagMlError::RuntimeValidation(format!(
929                    "prediction cache payload set for bundle `{}` has duplicate cache id `{}`",
930                    self.bundle_id, payload.cache_id
931                )));
932            }
933        }
934        Ok(())
935    }
936
937    pub fn validate_against_bundle(&self, bundle: &ExecutionBundle) -> Result<()> {
938        self.validate()?;
939        bundle.validate()?;
940        if self.bundle_id != bundle.bundle_id {
941            return Err(DagMlError::RuntimeValidation(format!(
942                "prediction cache payload set bundle `{}` does not match bundle `{}`",
943                self.bundle_id, bundle.bundle_id
944            )));
945        }
946        if self.schema_version != bundle.schema_version {
947            return Err(DagMlError::RuntimeValidation(format!(
948                "prediction cache payload set for bundle `{}` uses schema_version {} but bundle uses schema_version {}",
949                self.bundle_id, self.schema_version, bundle.schema_version
950            )));
951        }
952        if self.caches.len() != bundle.prediction_caches.len() {
953            return Err(DagMlError::RuntimeValidation(format!(
954                "prediction cache payload set for bundle `{}` has {} payload(s) for {} cache record(s)",
955                self.bundle_id,
956                self.caches.len(),
957                bundle.prediction_caches.len()
958            )));
959        }
960        let records_by_requirement = bundle
961            .prediction_caches
962            .iter()
963            .map(|record| (record.requirement_key.as_str(), record))
964            .collect::<BTreeMap<_, _>>();
965        let payloads_by_requirement = self
966            .caches
967            .iter()
968            .map(|payload| (payload.requirement_key.as_str(), payload))
969            .collect::<BTreeMap<_, _>>();
970        for (requirement_key, record) in records_by_requirement {
971            let payload = payloads_by_requirement
972                .get(requirement_key)
973                .ok_or_else(|| {
974                    DagMlError::RuntimeValidation(format!(
975                        "prediction cache payload set for bundle `{}` is missing requirement `{}`",
976                        self.bundle_id, requirement_key
977                    ))
978                })?;
979            validate_prediction_cache_payload_matches_record(payload, record)?;
980        }
981        for requirement_key in payloads_by_requirement.keys() {
982            if !bundle
983                .prediction_caches
984                .iter()
985                .any(|record| record.requirement_key.as_str() == *requirement_key)
986            {
987                return Err(DagMlError::RuntimeValidation(format!(
988                    "prediction cache payload set for bundle `{}` contains unknown requirement `{}`",
989                    self.bundle_id, requirement_key
990                )));
991            }
992        }
993        Ok(())
994    }
995}
996
997#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
998pub struct RefitArtifactRecord {
999    pub node_id: NodeId,
1000    pub controller_id: ControllerId,
1001    pub artifact: ArtifactRef,
1002    pub params_fingerprint: String,
1003    #[serde(default, skip_serializing_if = "Option::is_none")]
1004    pub training_loss_fingerprint: Option<String>,
1005    #[serde(default)]
1006    pub data_requirement_keys: Vec<String>,
1007    #[serde(default)]
1008    pub prediction_requirement_keys: Vec<String>,
1009}
1010
1011impl RefitArtifactRecord {
1012    pub fn validate(&self) -> Result<()> {
1013        self.artifact.validate()?;
1014        if self.artifact.id.as_str().is_empty() {
1015            return Err(DagMlError::RuntimeValidation(format!(
1016                "refit artifact for `{}` has empty artifact id",
1017                self.node_id
1018            )));
1019        }
1020        if self.artifact.kind.trim().is_empty() {
1021            return Err(DagMlError::RuntimeValidation(format!(
1022                "refit artifact `{}` has empty artifact kind",
1023                self.artifact.id
1024            )));
1025        }
1026        if self.artifact.controller_id != self.controller_id {
1027            return Err(DagMlError::RuntimeValidation(format!(
1028                "refit artifact `{}` controller `{}` does not match record controller `{}`",
1029                self.artifact.id, self.artifact.controller_id, self.controller_id
1030            )));
1031        }
1032        validate_fingerprint("params", &self.params_fingerprint)?;
1033        if let Some(fingerprint) = &self.training_loss_fingerprint {
1034            validate_fingerprint("training loss", fingerprint)?;
1035        }
1036        let mut seen_keys = BTreeSet::new();
1037        for key in &self.data_requirement_keys {
1038            if key.trim().is_empty() {
1039                return Err(DagMlError::RuntimeValidation(format!(
1040                    "refit artifact `{}` has empty data requirement key",
1041                    self.artifact.id
1042                )));
1043            }
1044            if !seen_keys.insert(key.as_str()) {
1045                return Err(DagMlError::RuntimeValidation(format!(
1046                    "refit artifact `{}` has duplicate data requirement key `{key}`",
1047                    self.artifact.id
1048                )));
1049            }
1050        }
1051        let mut seen_prediction_keys = BTreeSet::new();
1052        for key in &self.prediction_requirement_keys {
1053            if key.trim().is_empty() {
1054                return Err(DagMlError::RuntimeValidation(format!(
1055                    "refit artifact `{}` has empty prediction requirement key",
1056                    self.artifact.id
1057                )));
1058            }
1059            if !seen_prediction_keys.insert(key.as_str()) {
1060                return Err(DagMlError::RuntimeValidation(format!(
1061                    "refit artifact `{}` has duplicate prediction requirement key `{key}`",
1062                    self.artifact.id
1063                )));
1064            }
1065        }
1066        Ok(())
1067    }
1068}
1069
1070#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1071pub struct ExecutionBundle {
1072    pub bundle_id: BundleId,
1073    #[serde(default = "default_execution_bundle_schema_version")]
1074    pub schema_version: u32,
1075    pub plan_id: String,
1076    pub graph_fingerprint: String,
1077    pub campaign_fingerprint: String,
1078    pub controller_fingerprint: String,
1079    #[serde(default)]
1080    pub selected_variant_id: Option<VariantId>,
1081    #[serde(default)]
1082    pub selections: BTreeMap<String, SelectionDecision>,
1083    #[serde(default)]
1084    pub refit_artifacts: Vec<RefitArtifactRecord>,
1085    #[serde(default)]
1086    pub prediction_requirements: Vec<BundlePredictionRequirement>,
1087    #[serde(default)]
1088    pub prediction_caches: Vec<BundlePredictionCacheRecord>,
1089    /// Native, cross-language score record for this run (per (node, partition, fold, level)).
1090    /// Scores are scalars derived from `y_true`, safe for every partition — distinct from the
1091    /// Validation-only `prediction_caches`. Optional + additive (legacy bundles have `None`).
1092    #[serde(default, skip_serializing_if = "Option::is_none")]
1093    pub scores: Option<ScoreSet>,
1094    #[serde(default)]
1095    pub data_requirements: Vec<BundleDataRequirement>,
1096    #[serde(default)]
1097    pub unsafe_flags: BTreeSet<String>,
1098    #[serde(default)]
1099    pub metadata: BTreeMap<String, serde_json::Value>,
1100}
1101
1102impl ExecutionBundle {
1103    /// Parse the published object-only bundle JSON representation and validate it.
1104    pub fn from_json(json: &str) -> Result<Self> {
1105        let bundle: Self =
1106            deserialize_external_contract(json, "execution bundle", DagMlError::RuntimeValidation)?;
1107        bundle.validate()?;
1108        Ok(bundle)
1109    }
1110
1111    pub fn validate(&self) -> Result<()> {
1112        execution_bundle_schema_migration_policy()
1113            .validate_read_version(self.schema_version, &format!("bundle `{}`", self.bundle_id))?;
1114        if self.plan_id.trim().is_empty() {
1115            return Err(DagMlError::RuntimeValidation(format!(
1116                "bundle `{}` has empty plan_id",
1117                self.bundle_id
1118            )));
1119        }
1120        validate_fingerprint("graph", &self.graph_fingerprint)?;
1121        validate_fingerprint("campaign", &self.campaign_fingerprint)?;
1122        validate_fingerprint("controller", &self.controller_fingerprint)?;
1123        if let Some(scores) = &self.scores {
1124            scores.validate()?;
1125            if scores.schema_version != self.schema_version {
1126                return Err(DagMlError::RuntimeValidation(format!(
1127                    "bundle `{}` uses schema_version {} but embedded scores use schema_version {}",
1128                    self.bundle_id, self.schema_version, scores.schema_version
1129                )));
1130            }
1131            if scores.plan_id != self.plan_id {
1132                return Err(DagMlError::RuntimeValidation(format!(
1133                    "bundle `{}` plan_id `{}` does not match its embedded scores plan_id `{}`",
1134                    self.bundle_id, self.plan_id, scores.plan_id
1135                )));
1136            }
1137        }
1138        for (key, decision) in &self.selections {
1139            if key.trim().is_empty() {
1140                return Err(DagMlError::RuntimeValidation(format!(
1141                    "bundle `{}` contains empty selection key",
1142                    self.bundle_id
1143                )));
1144            }
1145            decision.validate()?;
1146        }
1147        let mut data_keys = BTreeMap::new();
1148        for requirement in &self.data_requirements {
1149            requirement.validate()?;
1150            let key = requirement.key();
1151            if data_keys.insert(key.clone(), requirement).is_some() {
1152                return Err(DagMlError::RuntimeValidation(format!(
1153                    "bundle `{}` has duplicate data requirement `{}`",
1154                    self.bundle_id, key
1155                )));
1156            }
1157        }
1158        let mut prediction_keys = BTreeMap::new();
1159        for requirement in &self.prediction_requirements {
1160            requirement.validate()?;
1161            let key = requirement.key();
1162            if prediction_keys.insert(key.clone(), requirement).is_some() {
1163                return Err(DagMlError::RuntimeValidation(format!(
1164                    "bundle `{}` has duplicate prediction requirement `{}`",
1165                    self.bundle_id, key
1166                )));
1167            }
1168        }
1169        let mut prediction_cache_keys = BTreeMap::new();
1170        for cache in &self.prediction_caches {
1171            cache.validate()?;
1172            if !cache.cache_namespace_fingerprints.is_empty() && self.selected_variant_id.is_none()
1173            {
1174                return Err(DagMlError::RuntimeValidation(format!(
1175                    "bundle `{}` prediction cache `{}` is D10-enriched and requires selected_variant_id",
1176                    self.bundle_id, cache.cache_id
1177                )));
1178            }
1179            validate_prediction_cache_format_for_schema_version(
1180                &cache.format,
1181                self.schema_version,
1182                &format!(
1183                    "prediction cache `{}` in bundle `{}`",
1184                    cache.cache_id, self.bundle_id
1185                ),
1186            )?;
1187            let requirement = prediction_keys.get(&cache.requirement_key).ok_or_else(|| {
1188                DagMlError::RuntimeValidation(format!(
1189                    "prediction cache `{}` references unknown prediction requirement `{}`",
1190                    cache.cache_id, cache.requirement_key
1191                ))
1192            })?;
1193            validate_prediction_cache_matches_requirement(cache, requirement)?;
1194            if prediction_cache_keys
1195                .insert(cache.requirement_key.clone(), cache)
1196                .is_some()
1197            {
1198                return Err(DagMlError::RuntimeValidation(format!(
1199                    "bundle `{}` has duplicate prediction cache for requirement `{}`",
1200                    self.bundle_id, cache.requirement_key
1201                )));
1202            }
1203        }
1204        for artifact in &self.refit_artifacts {
1205            artifact.validate()?;
1206            for key in &artifact.data_requirement_keys {
1207                match data_keys.get(key) {
1208                    Some(requirement) if requirement.node_id == artifact.node_id => {}
1209                    Some(requirement) => {
1210                        return Err(DagMlError::RuntimeValidation(format!(
1211                            "refit artifact `{}` for `{}` references data requirement `{key}` owned by `{}`",
1212                            artifact.artifact.id, artifact.node_id, requirement.node_id
1213                        )));
1214                    }
1215                    None => {
1216                        return Err(DagMlError::RuntimeValidation(format!(
1217                            "refit artifact `{}` references unknown data requirement `{key}`",
1218                            artifact.artifact.id
1219                        )));
1220                    }
1221                }
1222            }
1223            for key in &artifact.prediction_requirement_keys {
1224                match prediction_keys.get(key) {
1225                    Some(requirement) if requirement.consumer_node == artifact.node_id => {}
1226                    Some(requirement) => {
1227                        return Err(DagMlError::RuntimeValidation(format!(
1228                            "refit artifact `{}` for `{}` references prediction requirement `{key}` consumed by `{}`",
1229                            artifact.artifact.id, artifact.node_id, requirement.consumer_node
1230                        )));
1231                    }
1232                    None => {
1233                        return Err(DagMlError::RuntimeValidation(format!(
1234                            "refit artifact `{}` references unknown prediction requirement `{key}`",
1235                            artifact.artifact.id
1236                        )));
1237                    }
1238                }
1239                if !prediction_cache_keys.contains_key(key) {
1240                    return Err(DagMlError::RuntimeValidation(format!(
1241                        "refit artifact `{}` references prediction requirement `{key}` without a prediction cache record",
1242                        artifact.artifact.id
1243                    )));
1244                }
1245            }
1246        }
1247        for unsafe_flag in &self.unsafe_flags {
1248            if unsafe_flag.trim().is_empty() {
1249                return Err(DagMlError::RuntimeValidation(format!(
1250                    "bundle `{}` contains an empty unsafe flag",
1251                    self.bundle_id
1252                )));
1253            }
1254        }
1255        Ok(())
1256    }
1257
1258    pub fn validate_against_plan(&self, plan: &ExecutionPlan) -> Result<()> {
1259        self.validate()?;
1260        plan.validate()?;
1261        if self.plan_id != plan.id {
1262            return Err(DagMlError::RuntimeValidation(format!(
1263                "bundle `{}` plan_id `{}` does not match plan `{}`",
1264                self.bundle_id, self.plan_id, plan.id
1265            )));
1266        }
1267        if self.graph_fingerprint != plan.graph_fingerprint
1268            || self.campaign_fingerprint != plan.campaign_fingerprint
1269            || self.controller_fingerprint != plan.controller_fingerprint
1270        {
1271            return Err(DagMlError::RuntimeValidation(format!(
1272                "bundle `{}` fingerprints do not match execution plan",
1273                self.bundle_id
1274            )));
1275        }
1276        let selected_variant = match &self.selected_variant_id {
1277            Some(selected_variant_id) => Some(
1278                plan.variants
1279                    .iter()
1280                    .find(|variant| &variant.variant_id == selected_variant_id)
1281                    .ok_or_else(|| {
1282                        DagMlError::RuntimeValidation(format!(
1283                            "bundle `{}` selected unknown variant `{selected_variant_id}`",
1284                            self.bundle_id
1285                        ))
1286                    })?,
1287            ),
1288            None => None,
1289        };
1290        self.validate_selections_against_plan(plan)?;
1291        let expected_requirements = collect_data_requirements(plan)?;
1292        let expected_by_key = expected_requirements
1293            .iter()
1294            .map(|requirement| (requirement.key(), requirement))
1295            .collect::<BTreeMap<_, _>>();
1296        if self.data_requirements.len() != expected_by_key.len() {
1297            return Err(DagMlError::RuntimeValidation(format!(
1298                "bundle `{}` data requirement count does not match execution plan",
1299                self.bundle_id
1300            )));
1301        }
1302        for requirement in &self.data_requirements {
1303            let key = requirement.key();
1304            let expected = expected_by_key.get(&key).ok_or_else(|| {
1305                DagMlError::RuntimeValidation(format!(
1306                    "bundle `{}` data requirement `{key}` does not exist in execution plan",
1307                    self.bundle_id
1308                ))
1309            })?;
1310            if !requirement.matches_plan_requirement(expected) {
1311                return Err(DagMlError::RuntimeValidation(format!(
1312                    "bundle `{}` data requirement `{key}` does not match execution plan",
1313                    self.bundle_id
1314                )));
1315            }
1316        }
1317        for artifact in &self.refit_artifacts {
1318            let node_plan = plan.node_plans.get(&artifact.node_id).ok_or_else(|| {
1319                DagMlError::RuntimeValidation(format!(
1320                    "bundle `{}` artifact references unknown node `{}`",
1321                    self.bundle_id, artifact.node_id
1322                ))
1323            })?;
1324            if artifact.controller_id != node_plan.controller_id {
1325                return Err(DagMlError::RuntimeValidation(format!(
1326                    "bundle `{}` artifact controller for `{}` does not match plan",
1327                    self.bundle_id, artifact.node_id
1328                )));
1329            }
1330            let expected_params_fingerprint =
1331                expected_refit_artifact_params_fingerprint(node_plan, selected_variant)?;
1332            if artifact.params_fingerprint != expected_params_fingerprint {
1333                return Err(DagMlError::RuntimeValidation(format!(
1334                    "bundle `{}` artifact params for `{}` do not match plan",
1335                    self.bundle_id, artifact.node_id
1336                )));
1337            }
1338            if artifact.training_loss_fingerprint
1339                != node_plan.training_loss_fingerprint(Phase::Refit)?
1340            {
1341                return Err(DagMlError::RuntimeValidation(format!(
1342                    "bundle `{}` artifact training loss for `{}` does not match plan",
1343                    self.bundle_id, artifact.node_id
1344                )));
1345            }
1346        }
1347        for requirement in &self.prediction_requirements {
1348            let edge = plan
1349                .graph_plan
1350                .graph
1351                .edges
1352                .iter()
1353                .find(|edge| {
1354                    edge.source.node_id == requirement.producer_node
1355                    && edge.source.port_name == requirement.source_port
1356                    && edge.target.node_id == requirement.consumer_node
1357                    && edge.target.port_name == requirement.target_port
1358                    && edge.contract.requires_oof
1359                })
1360                .ok_or_else(|| {
1361                    DagMlError::RuntimeValidation(format!(
1362                        "bundle `{}` prediction requirement `{}` does not match an OOF edge in the plan",
1363                        self.bundle_id,
1364                        requirement.key()
1365                    ))
1366                })?;
1367            let cache = self
1368                .prediction_caches
1369                .iter()
1370                .find(|cache| cache.requirement_key == requirement.key());
1371            validate_prediction_requirement_against_plan(self, plan, edge, requirement, cache)?;
1372        }
1373        // GROUP check for separation-branch concat-merge nodes: the per-input
1374        // validation above relaxes the strict full-fold OOF check for each branch
1375        // input (a partition covers only a subset); the completeness/leakage
1376        // guarantee is restored here by requiring each concat-merge node's branch
1377        // inputs to be disjoint and cover the full fold universe exactly once —
1378        // the same invariant the runtime merge handler enforces.
1379        let cache_by_key = self
1380            .prediction_caches
1381            .iter()
1382            .map(|cache| (cache.requirement_key.clone(), cache))
1383            .collect::<BTreeMap<_, _>>();
1384        let mut concat_merge_groups: BTreeMap<NodeId, Vec<&BundlePredictionRequirement>> =
1385            BTreeMap::new();
1386        for requirement in &self.prediction_requirements {
1387            if is_concat_merge_consumer(plan, &requirement.consumer_node) {
1388                concat_merge_groups
1389                    .entry(requirement.consumer_node.clone())
1390                    .or_default()
1391                    .push(requirement);
1392            }
1393        }
1394        for (consumer_node, requirements) in &concat_merge_groups {
1395            validate_concat_merge_requirement_group(
1396                self,
1397                plan,
1398                consumer_node,
1399                requirements,
1400                &cache_by_key,
1401            )?;
1402        }
1403        Ok(())
1404    }
1405
1406    fn validate_selections_against_plan(&self, plan: &ExecutionPlan) -> Result<()> {
1407        if self.selections.is_empty() {
1408            return Ok(());
1409        }
1410        let artifact_node_ids = self
1411            .refit_artifacts
1412            .iter()
1413            .map(|artifact| artifact.node_id.clone())
1414            .collect::<BTreeSet<_>>();
1415        let required_metric_level = plan.campaign.aggregation_policy.selection_metric_level;
1416        for (selection_key, decision) in &self.selections {
1417            match decision.metric_level {
1418                Some(metric_level) if metric_level == required_metric_level => {}
1419                Some(metric_level) => {
1420                    return Err(DagMlError::RuntimeValidation(format!(
1421                        "bundle `{}` selection `{selection_key}` metric_level {:?} does not match campaign selection_metric_level {:?}",
1422                        self.bundle_id, metric_level, required_metric_level
1423                    )));
1424                }
1425                None => {
1426                    return Err(DagMlError::RuntimeValidation(format!(
1427                        "bundle `{}` selection `{selection_key}` is missing metric_level for campaign selection_metric_level {:?}",
1428                        self.bundle_id, required_metric_level
1429                    )));
1430                }
1431            }
1432            let selected_candidate_id = decision.selected_candidate_id.as_str();
1433            if let Ok(selected_node_id) = NodeId::new(selected_candidate_id) {
1434                if let Some(node_plan) = plan.node_plans.get(&selected_node_id) {
1435                    if node_plan.supported_phases.contains(&Phase::Refit)
1436                        && !artifact_node_ids.contains(&node_plan.node_id)
1437                    {
1438                        return Err(DagMlError::RuntimeValidation(format!(
1439                            "bundle `{}` selection `{selection_key}` chose refittable node `{}` without a matching refit artifact",
1440                            self.bundle_id, node_plan.node_id
1441                        )));
1442                    }
1443                    continue;
1444                }
1445            }
1446            if VariantId::new(selected_candidate_id).is_ok()
1447                && plan
1448                    .variants
1449                    .iter()
1450                    .any(|variant| variant.variant_id.as_str() == selected_candidate_id)
1451            {
1452                continue;
1453            }
1454            return Err(DagMlError::RuntimeValidation(format!(
1455                "bundle `{}` selection `{selection_key}` chose unknown candidate `{selected_candidate_id}` for plan `{}`",
1456                self.bundle_id, plan.id
1457            )));
1458        }
1459        Ok(())
1460    }
1461
1462    pub fn validate_replay_envelopes(
1463        &self,
1464        envelopes: &BTreeMap<String, ExternalDataPlanEnvelope>,
1465    ) -> Result<()> {
1466        self.validate()?;
1467        for requirement in &self.data_requirements {
1468            let key = requirement.key();
1469            let envelope = envelopes.get(&key).ok_or_else(|| {
1470                DagMlError::RuntimeValidation(format!(
1471                    "replay is missing external data envelope for `{key}`"
1472                ))
1473            })?;
1474            envelope.validate()?;
1475            if requirement.schema_fingerprint != envelope.schema_fingerprint
1476                || requirement.plan_fingerprint != envelope.plan_fingerprint
1477                || requirement.relation_fingerprint != envelope.relation_fingerprint
1478            {
1479                return Err(DagMlError::RuntimeValidation(format!(
1480                    "replay envelope for `{key}` does not match bundle data requirement"
1481                )));
1482            }
1483        }
1484        Ok(())
1485    }
1486}
1487
1488fn expected_refit_artifact_params_fingerprint(
1489    node_plan: &crate::plan::NodePlan,
1490    selected_variant: Option<&crate::generation::VariantPlan>,
1491) -> Result<String> {
1492    let Some(variant) = selected_variant else {
1493        return Ok(node_plan.params_fingerprint.clone());
1494    };
1495    let effective_params =
1496        variant.effective_params_for_node(&node_plan.node_id, &node_plan.params)?;
1497    stable_json_fingerprint(&effective_params)
1498}
1499
1500/// Whether `consumer_node` is a separation-branch *concat reassembly* merge node:
1501/// a `PredictionJoin` graph node whose DSL `merge_mode` metadata is `"concat"`.
1502///
1503/// This is the exact same marker the runtime uses (`runtime::is_concat_merge_node`)
1504/// to intercept the node before the controller path and reassemble the disjoint
1505/// per-partition OOF blocks. The bundle validation mirrors that marker so the
1506/// requirement validation stays consistent with the runtime: the partition-aware
1507/// relaxation applies *only* to input edges whose consumer is such a node.
1508fn is_concat_merge_consumer(plan: &ExecutionPlan, consumer_node: &NodeId) -> bool {
1509    let Some(node_plan) = plan.node_plans.get(consumer_node) else {
1510        return false;
1511    };
1512    if node_plan.kind != crate::graph::NodeKind::PredictionJoin {
1513        return false;
1514    }
1515    plan.graph_plan
1516        .graph
1517        .nodes
1518        .iter()
1519        .find(|node| &node.id == consumer_node)
1520        .and_then(|node| node.metadata.get("merge_mode"))
1521        .and_then(serde_json::Value::as_str)
1522        == Some("concat")
1523}
1524
1525fn validate_prediction_requirement_against_plan(
1526    bundle: &ExecutionBundle,
1527    plan: &ExecutionPlan,
1528    edge: &crate::graph::EdgeSpec,
1529    requirement: &BundlePredictionRequirement,
1530    cache: Option<&BundlePredictionCacheRecord>,
1531) -> Result<()> {
1532    if !edge.contract.requires_fold_alignment {
1533        return Ok(());
1534    }
1535    // A separation-branch concat-merge input edge covers only ITS partition of the
1536    // fold universe, never the full universe. Validate it as a partition-covering
1537    // input here (subset of universe, well-formed per-fold cache blocks); the
1538    // GROUP of all such sibling inputs is validated together (disjoint + their
1539    // union == the full fold set, exactly the runtime merge handler's invariant)
1540    // by `validate_concat_merge_requirement_group` after the per-requirement loop.
1541    // Every other `requires_fold_alignment` edge keeps the strict per-input
1542    // full-fold OOF completeness check below — the general leakage guard is intact.
1543    if is_concat_merge_consumer(plan, &requirement.consumer_node) {
1544        return validate_concat_merge_branch_input_requirement(bundle, plan, requirement, cache);
1545    }
1546    let fold_set = plan.fold_set.as_ref().ok_or_else(|| {
1547        DagMlError::RuntimeValidation(format!(
1548            "bundle `{}` prediction requirement `{}` needs fold alignment but plan `{}` has no fold set",
1549            bundle.bundle_id,
1550            requirement.key(),
1551            plan.id
1552        ))
1553    })?;
1554    let expected_fold_ids = fold_set
1555        .folds
1556        .iter()
1557        .map(|fold| fold.fold_id.clone())
1558        .collect::<BTreeSet<_>>();
1559    let requirement_fold_ids = requirement
1560        .fold_ids
1561        .iter()
1562        .cloned()
1563        .collect::<BTreeSet<_>>();
1564    if requirement_fold_ids != expected_fold_ids {
1565        return Err(DagMlError::RuntimeValidation(format!(
1566            "bundle `{}` prediction requirement `{}` fold ids do not match plan fold set",
1567            bundle.bundle_id,
1568            requirement.key()
1569        )));
1570    }
1571    if requirement.prediction_level != PredictionLevel::Sample {
1572        if let Some(cache) = cache {
1573            validate_aggregated_prediction_cache_blocks_match_requirement(
1574                bundle,
1575                requirement,
1576                cache,
1577                fold_set.partition_mode,
1578            )?;
1579        }
1580        return Ok(());
1581    }
1582    let expected_sample_ids = fold_set.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
1583    let requirement_sample_ids = requirement
1584        .sample_ids
1585        .iter()
1586        .cloned()
1587        .collect::<BTreeSet<_>>();
1588    if requirement_sample_ids != expected_sample_ids {
1589        return Err(DagMlError::RuntimeValidation(format!(
1590            "bundle `{}` prediction requirement `{}` sample ids do not match plan fold set",
1591            bundle.bundle_id,
1592            requirement.key()
1593        )));
1594    }
1595    if let Some(cache) = cache {
1596        validate_prediction_cache_blocks_match_fold_set(bundle, requirement, cache, fold_set)?;
1597    }
1598    Ok(())
1599}
1600
1601/// Validate a SINGLE separation-branch input edge into a concat-merge node.
1602///
1603/// A branch's OOF covers only its partition ∩ fold (a strict subset of the fold
1604/// universe), so the strict per-input `sample_ids == full fold_set` check does
1605/// NOT apply. Here we validate only that the input is well-formed *within* the
1606/// universe: its sample ids are a subset of the fold set, its fold ids are a
1607/// subset of the plan fold set, and (when present) each per-fold cache block's
1608/// samples are a subset of that fold's validation set with no intra-block
1609/// duplicate. The cross-input completeness (disjoint + union == full fold set)
1610/// — the actual OOF/leakage guarantee — is enforced by
1611/// `validate_concat_merge_requirement_group`, mirroring the runtime merge handler.
1612fn validate_concat_merge_branch_input_requirement(
1613    bundle: &ExecutionBundle,
1614    plan: &ExecutionPlan,
1615    requirement: &BundlePredictionRequirement,
1616    cache: Option<&BundlePredictionCacheRecord>,
1617) -> Result<()> {
1618    let fold_set = plan.fold_set.as_ref().ok_or_else(|| {
1619        DagMlError::RuntimeValidation(format!(
1620            "bundle `{}` prediction requirement `{}` needs fold alignment but plan `{}` has no fold set",
1621            bundle.bundle_id,
1622            requirement.key(),
1623            plan.id
1624        ))
1625    })?;
1626    let universe_fold_ids = fold_set
1627        .folds
1628        .iter()
1629        .map(|fold| fold.fold_id.clone())
1630        .collect::<BTreeSet<_>>();
1631    let requirement_fold_ids = requirement
1632        .fold_ids
1633        .iter()
1634        .cloned()
1635        .collect::<BTreeSet<_>>();
1636    if !requirement_fold_ids.is_subset(&universe_fold_ids) {
1637        return Err(DagMlError::RuntimeValidation(format!(
1638            "bundle `{}` concat-merge prediction requirement `{}` has fold ids outside the plan fold set",
1639            bundle.bundle_id,
1640            requirement.key()
1641        )));
1642    }
1643    // Concat reassembly is a sample-level OOF operation; aggregated (target/group)
1644    // levels never feed a separation concat-merge.
1645    if requirement.prediction_level != PredictionLevel::Sample {
1646        return Err(DagMlError::RuntimeValidation(format!(
1647            "bundle `{}` concat-merge prediction requirement `{}` must be sample-level (got {:?})",
1648            bundle.bundle_id,
1649            requirement.key(),
1650            requirement.prediction_level
1651        )));
1652    }
1653    let universe_sample_ids = fold_set.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
1654    let requirement_sample_ids = requirement
1655        .sample_ids
1656        .iter()
1657        .cloned()
1658        .collect::<BTreeSet<_>>();
1659    if !requirement_sample_ids.is_subset(&universe_sample_ids) {
1660        return Err(DagMlError::RuntimeValidation(format!(
1661            "bundle `{}` concat-merge prediction requirement `{}` covers samples outside the plan fold set",
1662            bundle.bundle_id,
1663            requirement.key()
1664        )));
1665    }
1666    if let Some(cache) = cache {
1667        let folds = fold_set
1668            .folds
1669            .iter()
1670            .map(|fold| (&fold.fold_id, fold))
1671            .collect::<BTreeMap<_, _>>();
1672        for block in &cache.blocks {
1673            let fold_id = block.fold_id.as_ref().ok_or_else(|| {
1674                DagMlError::RuntimeValidation(format!(
1675                    "bundle `{}` prediction cache `{}` has an OOF block without a fold id",
1676                    bundle.bundle_id, cache.cache_id
1677                ))
1678            })?;
1679            let fold = folds.get(fold_id).ok_or_else(|| {
1680                DagMlError::RuntimeValidation(format!(
1681                    "bundle `{}` prediction cache `{}` references unknown fold `{fold_id}`",
1682                    bundle.bundle_id, cache.cache_id
1683                ))
1684            })?;
1685            let block_samples = block.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
1686            if block_samples.len() != block.sample_ids.len() {
1687                return Err(DagMlError::RuntimeValidation(format!(
1688                    "bundle `{}` prediction cache `{}` block for fold `{fold_id}` has a duplicate sample for requirement `{}`",
1689                    bundle.bundle_id,
1690                    cache.cache_id,
1691                    requirement.key()
1692                )));
1693            }
1694            let validation_samples = fold
1695                .validation_sample_ids
1696                .iter()
1697                .cloned()
1698                .collect::<BTreeSet<_>>();
1699            if !block_samples.is_subset(&validation_samples) {
1700                return Err(DagMlError::RuntimeValidation(format!(
1701                    "bundle `{}` prediction cache `{}` block for fold `{fold_id}` covers samples outside the fold validation set for requirement `{}`",
1702                    bundle.bundle_id,
1703                    cache.cache_id,
1704                    requirement.key()
1705                )));
1706            }
1707        }
1708    }
1709    Ok(())
1710}
1711
1712/// Validate the GROUP of separation-branch input requirements feeding ONE
1713/// concat-merge node, mirroring the runtime merge handler's invariant
1714/// (`runtime::reassemble_separation_merge`): the branch inputs must be pairwise
1715/// DISJOINT and their UNION must equal the full fold set sample universe exactly
1716/// — each sample covered once. This is the OOF completeness the strict per-input
1717/// check guarantees for an ordinary model; for a separation branch the
1718/// completeness is a property of the partition-covering inputs *as a group*, not
1719/// of any single input.
1720///
1721/// When per-fold caches are present, the same disjoint+complete property is also
1722/// enforced per fold against each fold's validation set, so a partition that is
1723/// missing samples in some fold (a real OOF gap) still errors clearly.
1724fn validate_concat_merge_requirement_group(
1725    bundle: &ExecutionBundle,
1726    plan: &ExecutionPlan,
1727    consumer_node: &NodeId,
1728    requirements: &[&BundlePredictionRequirement],
1729    caches: &BTreeMap<String, &BundlePredictionCacheRecord>,
1730) -> Result<()> {
1731    let fold_set = plan.fold_set.as_ref().ok_or_else(|| {
1732        DagMlError::RuntimeValidation(format!(
1733            "bundle `{}` concat-merge node `{consumer_node}` needs fold alignment but plan `{}` has no fold set",
1734            bundle.bundle_id, plan.id
1735        ))
1736    })?;
1737
1738    // EXPECTED-vs-SUPPLIED: the group's completeness must be judged against the
1739    // graph's incoming OOF/fold-aligned edges to the concat-merge node, NOT just
1740    // the requirements the bundle happened to supply. Otherwise a bundle could
1741    // OMIT one branch->merge edge entirely and let the remaining branches' union
1742    // still equal the full fold universe — a missing branch masked by the others.
1743    // Derive the expected branch-input requirement keys from the plan graph and
1744    // require an EXACT match (no missing, no extra) before the disjoint+union
1745    // check; a dropped (or stray) branch edge then surfaces as a clear error.
1746    let expected_keys = plan
1747        .graph_plan
1748        .graph
1749        .edges
1750        .iter()
1751        .filter(|edge| {
1752            &edge.target.node_id == consumer_node
1753                && edge.contract.requires_oof
1754                && edge.contract.requires_fold_alignment
1755        })
1756        .map(|edge| {
1757            bundle_prediction_requirement_key(
1758                &edge.source.node_id,
1759                &edge.source.port_name,
1760                &edge.target.node_id,
1761                &edge.target.port_name,
1762            )
1763        })
1764        .collect::<BTreeSet<_>>();
1765    let supplied_keys = requirements
1766        .iter()
1767        .map(|req| req.key())
1768        .collect::<BTreeSet<_>>();
1769    if supplied_keys != expected_keys {
1770        let missing: Vec<&str> = expected_keys
1771            .difference(&supplied_keys)
1772            .map(String::as_str)
1773            .collect();
1774        let extra: Vec<&str> = supplied_keys
1775            .difference(&expected_keys)
1776            .map(String::as_str)
1777            .collect();
1778        return Err(DagMlError::RuntimeValidation(format!(
1779            "bundle `{}` concat-merge node `{consumer_node}` branch inputs do not match the plan's incoming OOF edges (missing: [{}]; extra: [{}])",
1780            bundle.bundle_id,
1781            missing.join(", "),
1782            extra.join(", ")
1783        )));
1784    }
1785
1786    // Union over all branch inputs must equal the full fold universe, disjointly.
1787    let expected_universe = fold_set.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
1788    let mut covered_universe = BTreeSet::new();
1789    for requirement in requirements {
1790        for sample_id in &requirement.sample_ids {
1791            if !covered_universe.insert(sample_id.clone()) {
1792                return Err(DagMlError::RuntimeValidation(format!(
1793                    "bundle `{}` concat-merge node `{consumer_node}` received overlapping branch predictions: sample `{sample_id}` is covered by more than one partition",
1794                    bundle.bundle_id
1795                )));
1796            }
1797        }
1798    }
1799    if covered_universe != expected_universe {
1800        return Err(DagMlError::RuntimeValidation(format!(
1801            "bundle `{}` concat-merge node `{consumer_node}` branch inputs do not cover the full fold set sample universe (each sample exactly once)",
1802            bundle.bundle_id
1803        )));
1804    }
1805
1806    // Per-fold disjoint+complete coverage against each fold's validation set, using
1807    // the per-branch caches when present (the CLI cv-refit path always attaches
1808    // them). A partition that drops samples in a fold surfaces as a missing-sample
1809    // error rather than a silently incomplete OOF.
1810    //
1811    // All-or-nothing caches: persisted per-fold OOF is validated complete or not
1812    // relied on at all. If ANY branch input carries a cache, ALL must — otherwise
1813    // a no-cache branch would satisfy global coverage via its self-declared
1814    // `requirement.sample_ids` while its actual persisted per-fold OOF is missing,
1815    // hiding an incomplete-coverage gap. A partial-cache concat group is rejected.
1816    let cached_count = requirements
1817        .iter()
1818        .filter(|req| caches.contains_key(&req.key()))
1819        .count();
1820    if cached_count != 0 && cached_count != requirements.len() {
1821        return Err(DagMlError::RuntimeValidation(format!(
1822            "bundle `{}` concat-merge node `{consumer_node}` has partial prediction-cache coverage ({cached_count} of {} branch inputs cached): all branch inputs must carry a per-fold OOF cache or none",
1823            bundle.bundle_id,
1824            requirements.len()
1825        )));
1826    }
1827    if cached_count == requirements.len() {
1828        let mut covered_by_fold: BTreeMap<FoldId, BTreeSet<SampleId>> = BTreeMap::new();
1829        for requirement in requirements {
1830            let cache = caches.get(&requirement.key()).expect("checked above");
1831            for block in &cache.blocks {
1832                let Some(fold_id) = block.fold_id.as_ref() else {
1833                    continue;
1834                };
1835                let covered = covered_by_fold.entry(fold_id.clone()).or_default();
1836                for sample_id in &block.sample_ids {
1837                    if !covered.insert(sample_id.clone()) {
1838                        return Err(DagMlError::RuntimeValidation(format!(
1839                            "bundle `{}` concat-merge node `{consumer_node}` has overlapping branch predictions in fold `{fold_id}`: sample `{sample_id}` is covered by more than one partition",
1840                            bundle.bundle_id
1841                        )));
1842                    }
1843                }
1844            }
1845        }
1846        for fold in &fold_set.folds {
1847            let expected = fold
1848                .validation_sample_ids
1849                .iter()
1850                .cloned()
1851                .collect::<BTreeSet<_>>();
1852            let covered = covered_by_fold.remove(&fold.fold_id).unwrap_or_default();
1853            if covered != expected {
1854                return Err(DagMlError::RuntimeValidation(format!(
1855                    "bundle `{}` concat-merge node `{consumer_node}` branch inputs do not cover fold `{}` validation set (each sample exactly once)",
1856                    bundle.bundle_id, fold.fold_id
1857                )));
1858            }
1859        }
1860    }
1861    Ok(())
1862}
1863
1864fn validate_prediction_cache_blocks_match_fold_set(
1865    bundle: &ExecutionBundle,
1866    requirement: &BundlePredictionRequirement,
1867    cache: &BundlePredictionCacheRecord,
1868    fold_set: &crate::fold::FoldSet,
1869) -> Result<()> {
1870    let folds = fold_set
1871        .folds
1872        .iter()
1873        .map(|fold| (&fold.fold_id, fold))
1874        .collect::<BTreeMap<_, _>>();
1875    let expected_fold_ids = fold_set
1876        .folds
1877        .iter()
1878        .map(|fold| fold.fold_id.clone())
1879        .collect::<BTreeSet<_>>();
1880    let mut covered_fold_ids = BTreeSet::new();
1881    let mut covered_sample_ids = BTreeSet::new();
1882    for block in &cache.blocks {
1883        let fold_id = block.fold_id.as_ref().ok_or_else(|| {
1884            DagMlError::RuntimeValidation(format!(
1885                "bundle `{}` prediction cache `{}` has an OOF block without a fold id",
1886                bundle.bundle_id, cache.cache_id
1887            ))
1888        })?;
1889        covered_fold_ids.insert(fold_id.clone());
1890        let fold = folds.get(fold_id).ok_or_else(|| {
1891            DagMlError::RuntimeValidation(format!(
1892                "bundle `{}` prediction cache `{}` references unknown fold `{fold_id}`",
1893                bundle.bundle_id, cache.cache_id
1894            ))
1895        })?;
1896        let block_samples = block.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
1897        let expected_samples = fold
1898            .validation_sample_ids
1899            .iter()
1900            .cloned()
1901            .collect::<BTreeSet<_>>();
1902        if block_samples != expected_samples {
1903            return Err(DagMlError::RuntimeValidation(format!(
1904                "bundle `{}` prediction cache `{}` block for fold `{fold_id}` does not match validation samples for requirement `{}`",
1905                bundle.bundle_id,
1906                cache.cache_id,
1907                requirement.key()
1908            )));
1909        }
1910        for sample_id in block_samples {
1911            // Partition is a clean OOF set: a sample cached for two folds is a duplicated fold or a
1912            // mixed-variant context. Resampled (ShuffleSplit / repeated CV) validates a sample in
1913            // several folds and averages it, so the across-fold duplicate is allowed; the per-fold
1914            // match above and the universe-coverage check below still hold.
1915            if !covered_sample_ids.insert(sample_id.clone())
1916                && fold_set.partition_mode == crate::fold::FoldPartitionMode::Partition
1917            {
1918                return Err(DagMlError::RuntimeValidation(format!(
1919                    "bundle `{}` prediction cache `{}` has duplicate OOF sample `{sample_id}`",
1920                    bundle.bundle_id, cache.cache_id
1921                )));
1922            }
1923        }
1924    }
1925    if covered_fold_ids != expected_fold_ids {
1926        return Err(DagMlError::RuntimeValidation(format!(
1927            "bundle `{}` prediction cache `{}` does not cover all folds for requirement `{}`",
1928            bundle.bundle_id,
1929            cache.cache_id,
1930            requirement.key()
1931        )));
1932    }
1933    let expected_sample_ids = fold_set.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
1934    if covered_sample_ids != expected_sample_ids {
1935        return Err(DagMlError::RuntimeValidation(format!(
1936            "bundle `{}` prediction cache `{}` does not cover the full OOF sample universe for requirement `{}`",
1937            bundle.bundle_id,
1938            cache.cache_id,
1939            requirement.key()
1940        )));
1941    }
1942    Ok(())
1943}
1944
1945fn validate_aggregated_prediction_cache_blocks_match_requirement(
1946    bundle: &ExecutionBundle,
1947    requirement: &BundlePredictionRequirement,
1948    cache: &BundlePredictionCacheRecord,
1949    partition_mode: crate::fold::FoldPartitionMode,
1950) -> Result<()> {
1951    let mut covered_fold_ids = BTreeSet::new();
1952    let mut covered_unit_ids = BTreeSet::new();
1953    for block in &cache.blocks {
1954        if block.prediction_level != requirement.prediction_level {
1955            return Err(DagMlError::RuntimeValidation(format!(
1956                "bundle `{}` prediction cache `{}` block level does not match requirement `{}`",
1957                bundle.bundle_id,
1958                cache.cache_id,
1959                requirement.key()
1960            )));
1961        }
1962        if let Some(fold_id) = &block.fold_id {
1963            covered_fold_ids.insert(fold_id.clone());
1964        }
1965        for unit_id in &block.unit_ids {
1966            // Partition forbids a unit cached for two folds; Resampled (ShuffleSplit / repeated CV)
1967            // validates a unit in several folds and averages it, so the across-fold duplicate is
1968            // allowed (the unit-universe coverage check below still requires every unit at least once).
1969            if !covered_unit_ids.insert(unit_id.clone())
1970                && partition_mode == crate::fold::FoldPartitionMode::Partition
1971            {
1972                return Err(DagMlError::RuntimeValidation(format!(
1973                    "bundle `{}` prediction cache `{}` has duplicate aggregated unit `{unit_id}`",
1974                    bundle.bundle_id, cache.cache_id
1975                )));
1976            }
1977        }
1978    }
1979    let expected_fold_ids = requirement
1980        .fold_ids
1981        .iter()
1982        .cloned()
1983        .collect::<BTreeSet<_>>();
1984    if covered_fold_ids != expected_fold_ids {
1985        return Err(DagMlError::RuntimeValidation(format!(
1986            "bundle `{}` prediction cache `{}` does not cover all folds for aggregated requirement `{}`",
1987            bundle.bundle_id,
1988            cache.cache_id,
1989            requirement.key()
1990        )));
1991    }
1992    let expected_unit_ids = requirement
1993        .unit_ids
1994        .iter()
1995        .cloned()
1996        .collect::<BTreeSet<_>>();
1997    if covered_unit_ids != expected_unit_ids {
1998        return Err(DagMlError::RuntimeValidation(format!(
1999            "bundle `{}` prediction cache `{}` does not cover all units for aggregated requirement `{}`",
2000            bundle.bundle_id,
2001            cache.cache_id,
2002            requirement.key()
2003        )));
2004    }
2005    Ok(())
2006}
2007
2008pub fn build_execution_bundle(
2009    bundle_id: BundleId,
2010    plan: &ExecutionPlan,
2011    selected_variant_id: Option<VariantId>,
2012    selections: BTreeMap<String, SelectionDecision>,
2013    refit_artifacts: Vec<RefitArtifactRecord>,
2014) -> Result<ExecutionBundle> {
2015    build_execution_bundle_with_prediction_requirements(
2016        bundle_id,
2017        plan,
2018        selected_variant_id,
2019        selections,
2020        refit_artifacts,
2021        Vec::new(),
2022    )
2023}
2024
2025pub fn build_execution_bundle_with_prediction_requirements(
2026    bundle_id: BundleId,
2027    plan: &ExecutionPlan,
2028    selected_variant_id: Option<VariantId>,
2029    selections: BTreeMap<String, SelectionDecision>,
2030    refit_artifacts: Vec<RefitArtifactRecord>,
2031    prediction_requirements: Vec<BundlePredictionRequirement>,
2032) -> Result<ExecutionBundle> {
2033    build_execution_bundle_with_prediction_contracts(
2034        bundle_id,
2035        plan,
2036        selected_variant_id,
2037        selections,
2038        refit_artifacts,
2039        prediction_requirements,
2040        Vec::new(),
2041    )
2042}
2043
2044pub fn build_execution_bundle_with_prediction_contracts(
2045    bundle_id: BundleId,
2046    plan: &ExecutionPlan,
2047    selected_variant_id: Option<VariantId>,
2048    selections: BTreeMap<String, SelectionDecision>,
2049    refit_artifacts: Vec<RefitArtifactRecord>,
2050    prediction_requirements: Vec<BundlePredictionRequirement>,
2051    prediction_caches: Vec<BundlePredictionCacheRecord>,
2052) -> Result<ExecutionBundle> {
2053    plan.validate()?;
2054    let bundle = ExecutionBundle {
2055        bundle_id,
2056        schema_version: EXECUTION_BUNDLE_SCHEMA_VERSION,
2057        plan_id: plan.id.clone(),
2058        graph_fingerprint: plan.graph_fingerprint.clone(),
2059        campaign_fingerprint: plan.campaign_fingerprint.clone(),
2060        controller_fingerprint: plan.controller_fingerprint.clone(),
2061        selected_variant_id,
2062        selections,
2063        refit_artifacts,
2064        prediction_requirements,
2065        prediction_caches,
2066        scores: None,
2067        data_requirements: collect_data_requirements(plan)?,
2068        unsafe_flags: BTreeSet::new(),
2069        metadata: BTreeMap::new(),
2070    };
2071    bundle.validate_against_plan(plan)?;
2072    Ok(bundle)
2073}
2074
2075fn collect_data_requirements(plan: &ExecutionPlan) -> Result<Vec<BundleDataRequirement>> {
2076    let mut requirements = Vec::new();
2077    for node_plan in plan.node_plans.values() {
2078        for binding in &node_plan.data_bindings {
2079            requirements.push(BundleDataRequirement {
2080                node_id: node_plan.node_id.clone(),
2081                input_name: binding.input_name.clone(),
2082                schema_fingerprint: binding.schema_fingerprint.clone(),
2083                plan_fingerprint: binding.plan_fingerprint.clone(),
2084                relation_fingerprint: binding.relation_fingerprint.clone(),
2085                output_representation: binding.output_representation.clone(),
2086                feature_set_id: binding.feature_set_id.clone(),
2087                representation_replay_manifest: None,
2088                representation_compatibility: None,
2089            });
2090        }
2091    }
2092    requirements.sort_by_key(BundleDataRequirement::key);
2093    for requirement in &requirements {
2094        requirement.validate()?;
2095    }
2096    Ok(requirements)
2097}
2098
2099pub fn build_prediction_cache_record(
2100    requirement: &BundlePredictionRequirement,
2101    blocks: &[PredictionBlock],
2102) -> Result<BundlePredictionCacheRecord> {
2103    let selected = select_prediction_cache_blocks(requirement, blocks)?;
2104    build_prediction_cache_record_from_selected(requirement, &selected)
2105}
2106
2107pub fn build_prediction_cache_payload(
2108    requirement: &BundlePredictionRequirement,
2109    blocks: &[PredictionBlock],
2110) -> Result<BundlePredictionCachePayload> {
2111    let selected = select_prediction_cache_blocks(requirement, blocks)?;
2112    let payload = BundlePredictionCachePayload {
2113        requirement_key: requirement.key(),
2114        cache_id: format!("prediction-cache:{}", requirement.key()),
2115        cache_namespace_fingerprints: Vec::new(),
2116        format: BUNDLE_PREDICTION_CACHE_FORMAT.to_string(),
2117        partition: requirement.partition.clone(),
2118        prediction_level: requirement.prediction_level,
2119        block_count: selected.len(),
2120        row_count: selected.iter().map(|block| block.sample_ids.len()).sum(),
2121        content_fingerprint: stable_json_fingerprint(&selected)?,
2122        blocks: selected,
2123        aggregated_blocks: Vec::new(),
2124    };
2125    payload.validate()?;
2126    let record = build_prediction_cache_record(requirement, &payload.blocks)?;
2127    validate_prediction_cache_payload_matches_record(&payload, &record)?;
2128    Ok(payload)
2129}
2130
2131pub fn build_aggregated_prediction_cache_record(
2132    requirement: &BundlePredictionRequirement,
2133    blocks: &[AggregatedPredictionBlock],
2134) -> Result<BundlePredictionCacheRecord> {
2135    let selected = select_aggregated_prediction_cache_blocks(requirement, blocks)?;
2136    build_aggregated_prediction_cache_record_from_selected(requirement, &selected)
2137}
2138
2139pub fn build_aggregated_prediction_cache_payload(
2140    requirement: &BundlePredictionRequirement,
2141    blocks: &[AggregatedPredictionBlock],
2142) -> Result<BundlePredictionCachePayload> {
2143    let selected = select_aggregated_prediction_cache_blocks(requirement, blocks)?;
2144    let payload = BundlePredictionCachePayload {
2145        requirement_key: requirement.key(),
2146        cache_id: format!("prediction-cache:{}", requirement.key()),
2147        cache_namespace_fingerprints: Vec::new(),
2148        format: BUNDLE_PREDICTION_CACHE_FORMAT.to_string(),
2149        partition: requirement.partition.clone(),
2150        prediction_level: requirement.prediction_level,
2151        block_count: selected.len(),
2152        row_count: selected.iter().map(|block| block.unit_ids.len()).sum(),
2153        content_fingerprint: stable_json_fingerprint(&selected)?,
2154        blocks: Vec::new(),
2155        aggregated_blocks: selected,
2156    };
2157    payload.validate()?;
2158    let record = build_aggregated_prediction_cache_record(requirement, &payload.aggregated_blocks)?;
2159    validate_prediction_cache_payload_matches_record(&payload, &record)?;
2160    Ok(payload)
2161}
2162
2163pub fn validate_prediction_cache_payload_matches_record(
2164    payload: &BundlePredictionCachePayload,
2165    record: &BundlePredictionCacheRecord,
2166) -> Result<()> {
2167    payload.validate()?;
2168    record.validate()?;
2169    if payload.requirement_key != record.requirement_key
2170        || payload.cache_id != record.cache_id
2171        || payload.cache_namespace_fingerprints != record.cache_namespace_fingerprints
2172        || payload.format != record.format
2173        || payload.partition != record.partition
2174        || payload.prediction_level != record.prediction_level
2175        || payload.block_count != record.block_count
2176        || payload.row_count != record.row_count
2177        || payload.content_fingerprint != record.content_fingerprint
2178    {
2179        return Err(DagMlError::RuntimeValidation(format!(
2180            "prediction cache payload `{}` does not match cache record `{}`",
2181            payload.cache_id, record.cache_id
2182        )));
2183    }
2184    let block_records = if payload.prediction_level == PredictionLevel::Sample {
2185        payload
2186            .blocks
2187            .iter()
2188            .map(|block| {
2189                Ok(BundlePredictionBlockCacheRecord {
2190                    prediction_id: block.prediction_id.clone(),
2191                    fold_id: block.fold_id.clone(),
2192                    prediction_level: PredictionLevel::Sample,
2193                    row_count: block.sample_ids.len(),
2194                    unit_ids: Vec::new(),
2195                    sample_ids: block.sample_ids.clone(),
2196                    content_fingerprint: stable_json_fingerprint(block)?,
2197                })
2198            })
2199            .collect::<Result<Vec<_>>>()?
2200    } else {
2201        payload
2202            .aggregated_blocks
2203            .iter()
2204            .map(|block| {
2205                Ok(BundlePredictionBlockCacheRecord {
2206                    prediction_id: block.prediction_id.clone(),
2207                    fold_id: block.fold_id.clone(),
2208                    prediction_level: block.level,
2209                    row_count: block.unit_ids.len(),
2210                    unit_ids: block.unit_ids.clone(),
2211                    sample_ids: Vec::new(),
2212                    content_fingerprint: stable_json_fingerprint(block)?,
2213                })
2214            })
2215            .collect::<Result<Vec<_>>>()?
2216    };
2217    if block_records != record.blocks {
2218        return Err(DagMlError::RuntimeValidation(format!(
2219            "prediction cache payload `{}` block fingerprints do not match cache record",
2220            payload.cache_id
2221        )));
2222    }
2223    Ok(())
2224}
2225
2226fn validate_prediction_cache_namespace_fingerprints(
2227    cache_id: &str,
2228    fingerprints: &[String],
2229) -> Result<()> {
2230    let mut seen = BTreeSet::new();
2231    for fingerprint in fingerprints {
2232        validate_fingerprint("prediction cache namespace", fingerprint)?;
2233        if !seen.insert(fingerprint.as_str()) {
2234            return Err(DagMlError::RuntimeValidation(format!(
2235                "prediction cache `{cache_id}` has duplicate cache namespace fingerprint `{fingerprint}`"
2236            )));
2237        }
2238    }
2239    Ok(())
2240}
2241
2242fn select_prediction_cache_blocks(
2243    requirement: &BundlePredictionRequirement,
2244    blocks: &[PredictionBlock],
2245) -> Result<Vec<PredictionBlock>> {
2246    requirement.validate()?;
2247    let mut selected = blocks
2248        .iter()
2249        .filter(|block| {
2250            block.producer_node == requirement.producer_node
2251                && block.partition == requirement.partition
2252        })
2253        .cloned()
2254        .collect::<Vec<_>>();
2255    if selected.is_empty() {
2256        return Err(DagMlError::RuntimeValidation(format!(
2257            "prediction cache requirement `{}` has no matching prediction blocks",
2258            requirement.key()
2259        )));
2260    }
2261    selected.sort_by(|left, right| {
2262        (
2263            left.fold_id.as_ref().map(ToString::to_string),
2264            left.prediction_id.clone(),
2265        )
2266            .cmp(&(
2267                right.fold_id.as_ref().map(ToString::to_string),
2268                right.prediction_id.clone(),
2269            ))
2270    });
2271    Ok(selected)
2272}
2273
2274fn select_aggregated_prediction_cache_blocks(
2275    requirement: &BundlePredictionRequirement,
2276    blocks: &[AggregatedPredictionBlock],
2277) -> Result<Vec<AggregatedPredictionBlock>> {
2278    requirement.validate()?;
2279    if requirement.prediction_level == PredictionLevel::Sample {
2280        return Err(DagMlError::RuntimeValidation(format!(
2281            "aggregated prediction cache requirement `{}` must use target or group level",
2282            requirement.key()
2283        )));
2284    }
2285    let mut selected = blocks
2286        .iter()
2287        .filter(|block| {
2288            block.producer_node == requirement.producer_node
2289                && block.partition == requirement.partition
2290                && block.level == requirement.prediction_level
2291        })
2292        .cloned()
2293        .collect::<Vec<_>>();
2294    if selected.is_empty() {
2295        return Err(DagMlError::RuntimeValidation(format!(
2296            "aggregated prediction cache requirement `{}` has no matching prediction blocks",
2297            requirement.key()
2298        )));
2299    }
2300    selected.sort_by(|left, right| {
2301        (
2302            left.fold_id.as_ref().map(ToString::to_string),
2303            left.prediction_id.clone(),
2304        )
2305            .cmp(&(
2306                right.fold_id.as_ref().map(ToString::to_string),
2307                right.prediction_id.clone(),
2308            ))
2309    });
2310    Ok(selected)
2311}
2312
2313fn build_prediction_cache_record_from_selected(
2314    requirement: &BundlePredictionRequirement,
2315    selected: &[PredictionBlock],
2316) -> Result<BundlePredictionCacheRecord> {
2317    requirement.validate()?;
2318    if selected.is_empty() {
2319        return Err(DagMlError::RuntimeValidation(format!(
2320            "prediction cache requirement `{}` has no matching prediction blocks",
2321            requirement.key()
2322        )));
2323    }
2324    let mut fold_ids = BTreeSet::new();
2325    let mut sample_ids = BTreeSet::new();
2326    let mut target_names: Option<Vec<String>> = None;
2327    let mut prediction_width: Option<usize> = None;
2328    let mut row_count = 0usize;
2329    let mut block_records = Vec::new();
2330    for block in selected {
2331        if block.producer_node != requirement.producer_node
2332            || block.partition != requirement.partition
2333        {
2334            return Err(DagMlError::RuntimeValidation(format!(
2335                "prediction cache `{}` contains a block outside the requirement scope",
2336                requirement.key()
2337            )));
2338        }
2339        let width = block.validate_shape()?;
2340        if prediction_width.is_some_and(|expected| expected != width) {
2341            return Err(DagMlError::RuntimeValidation(format!(
2342                "prediction cache `{}` has inconsistent prediction width",
2343                requirement.key()
2344            )));
2345        }
2346        prediction_width = Some(width);
2347        let block_target_names = normalized_prediction_targets(block, width);
2348        if target_names
2349            .as_ref()
2350            .is_some_and(|expected| expected != &block_target_names)
2351        {
2352            return Err(DagMlError::RuntimeValidation(format!(
2353                "prediction cache `{}` has inconsistent target names",
2354                requirement.key()
2355            )));
2356        }
2357        target_names = Some(block_target_names);
2358        if let Some(fold_id) = &block.fold_id {
2359            fold_ids.insert(fold_id.clone());
2360        }
2361        sample_ids.extend(block.sample_ids.iter().cloned());
2362        row_count += block.sample_ids.len();
2363        block_records.push(BundlePredictionBlockCacheRecord {
2364            prediction_id: block.prediction_id.clone(),
2365            fold_id: block.fold_id.clone(),
2366            prediction_level: PredictionLevel::Sample,
2367            row_count: block.sample_ids.len(),
2368            unit_ids: Vec::new(),
2369            sample_ids: block.sample_ids.clone(),
2370            content_fingerprint: stable_json_fingerprint(block)?,
2371        });
2372    }
2373
2374    let record = BundlePredictionCacheRecord {
2375        requirement_key: requirement.key(),
2376        cache_id: format!("prediction-cache:{}", requirement.key()),
2377        cache_namespace_fingerprints: Vec::new(),
2378        format: BUNDLE_PREDICTION_CACHE_FORMAT.to_string(),
2379        partition: requirement.partition.clone(),
2380        prediction_level: requirement.prediction_level,
2381        fold_ids: fold_ids.into_iter().collect(),
2382        unit_ids: requirement.unit_ids.clone(),
2383        sample_ids: sample_ids.into_iter().collect(),
2384        prediction_width: prediction_width.unwrap_or_default(),
2385        target_names: target_names.unwrap_or_default(),
2386        block_count: block_records.len(),
2387        row_count,
2388        content_fingerprint: stable_json_fingerprint(selected)?,
2389        blocks: block_records,
2390    };
2391    validate_prediction_cache_matches_requirement(&record, requirement)?;
2392    record.validate()?;
2393    Ok(record)
2394}
2395
2396fn build_aggregated_prediction_cache_record_from_selected(
2397    requirement: &BundlePredictionRequirement,
2398    selected: &[AggregatedPredictionBlock],
2399) -> Result<BundlePredictionCacheRecord> {
2400    requirement.validate()?;
2401    if requirement.prediction_level == PredictionLevel::Sample {
2402        return Err(DagMlError::RuntimeValidation(format!(
2403            "aggregated prediction cache requirement `{}` must use target or group level",
2404            requirement.key()
2405        )));
2406    }
2407    if selected.is_empty() {
2408        return Err(DagMlError::RuntimeValidation(format!(
2409            "aggregated prediction cache requirement `{}` has no matching prediction blocks",
2410            requirement.key()
2411        )));
2412    }
2413    let mut fold_ids = BTreeSet::new();
2414    let mut unit_ids = BTreeSet::new();
2415    let mut target_names: Option<Vec<String>> = None;
2416    let mut prediction_width: Option<usize> = None;
2417    let mut row_count = 0usize;
2418    let mut block_records = Vec::new();
2419    for block in selected {
2420        if block.producer_node != requirement.producer_node
2421            || block.partition != requirement.partition
2422            || block.level != requirement.prediction_level
2423        {
2424            return Err(DagMlError::RuntimeValidation(format!(
2425                "aggregated prediction cache `{}` contains a block outside the requirement scope",
2426                requirement.key()
2427            )));
2428        }
2429        let width = block.validate_shape()?;
2430        if prediction_width.is_some_and(|expected| expected != width) {
2431            return Err(DagMlError::RuntimeValidation(format!(
2432                "aggregated prediction cache `{}` has inconsistent prediction width",
2433                requirement.key()
2434            )));
2435        }
2436        prediction_width = Some(width);
2437        let block_target_names = normalized_aggregated_prediction_targets(block, width);
2438        if target_names
2439            .as_ref()
2440            .is_some_and(|expected| expected != &block_target_names)
2441        {
2442            return Err(DagMlError::RuntimeValidation(format!(
2443                "aggregated prediction cache `{}` has inconsistent target names",
2444                requirement.key()
2445            )));
2446        }
2447        target_names = Some(block_target_names);
2448        if let Some(fold_id) = &block.fold_id {
2449            fold_ids.insert(fold_id.clone());
2450        }
2451        unit_ids.extend(block.unit_ids.iter().cloned());
2452        row_count += block.unit_ids.len();
2453        block_records.push(BundlePredictionBlockCacheRecord {
2454            prediction_id: block.prediction_id.clone(),
2455            fold_id: block.fold_id.clone(),
2456            prediction_level: block.level,
2457            row_count: block.unit_ids.len(),
2458            unit_ids: block.unit_ids.clone(),
2459            sample_ids: Vec::new(),
2460            content_fingerprint: stable_json_fingerprint(block)?,
2461        });
2462    }
2463
2464    let record = BundlePredictionCacheRecord {
2465        requirement_key: requirement.key(),
2466        cache_id: format!("prediction-cache:{}", requirement.key()),
2467        cache_namespace_fingerprints: Vec::new(),
2468        format: BUNDLE_PREDICTION_CACHE_FORMAT.to_string(),
2469        partition: requirement.partition.clone(),
2470        prediction_level: requirement.prediction_level,
2471        fold_ids: fold_ids.into_iter().collect(),
2472        unit_ids: unit_ids.into_iter().collect(),
2473        sample_ids: Vec::new(),
2474        prediction_width: prediction_width.unwrap_or_default(),
2475        target_names: target_names.unwrap_or_default(),
2476        block_count: block_records.len(),
2477        row_count,
2478        content_fingerprint: stable_json_fingerprint(selected)?,
2479        blocks: block_records,
2480    };
2481    validate_prediction_cache_matches_requirement(&record, requirement)?;
2482    record.validate()?;
2483    Ok(record)
2484}
2485
2486fn validate_prediction_cache_matches_requirement(
2487    cache: &BundlePredictionCacheRecord,
2488    requirement: &BundlePredictionRequirement,
2489) -> Result<()> {
2490    if cache.requirement_key != requirement.key()
2491        || cache.partition != requirement.partition
2492        || cache.prediction_level != requirement.prediction_level
2493        || cache.fold_ids != requirement.fold_ids
2494        || cache.unit_ids != requirement.unit_ids
2495        || cache.sample_ids != requirement.sample_ids
2496        || cache.prediction_width != requirement.prediction_width
2497        || cache.target_names != requirement.target_names
2498    {
2499        return Err(DagMlError::RuntimeValidation(format!(
2500            "prediction cache `{}` does not match requirement `{}`",
2501            cache.cache_id,
2502            requirement.key()
2503        )));
2504    }
2505    Ok(())
2506}
2507
2508fn normalized_prediction_targets(block: &PredictionBlock, width: usize) -> Vec<String> {
2509    if block.target_names.is_empty() {
2510        (0..width).map(|index| format!("p{index}")).collect()
2511    } else {
2512        block.target_names.clone()
2513    }
2514}
2515
2516fn normalized_aggregated_prediction_targets(
2517    block: &AggregatedPredictionBlock,
2518    width: usize,
2519) -> Vec<String> {
2520    if block.target_names.is_empty() {
2521        (0..width).map(|index| format!("p{index}")).collect()
2522    } else {
2523        block.target_names.clone()
2524    }
2525}
2526
2527fn sample_prediction_units(sample_ids: &[SampleId]) -> Vec<PredictionUnitId> {
2528    sample_ids
2529        .iter()
2530        .cloned()
2531        .map(PredictionUnitId::Sample)
2532        .collect()
2533}
2534
2535fn validate_prediction_units(
2536    label: &str,
2537    expected_level: PredictionLevel,
2538    unit_ids: &[PredictionUnitId],
2539) -> Result<()> {
2540    validate_unique_ids(label, unit_ids)?;
2541    for unit_id in unit_ids {
2542        if unit_id.level() != expected_level {
2543            return Err(DagMlError::RuntimeValidation(format!(
2544                "{label} `{unit_id}` does not match prediction level {:?}",
2545                expected_level
2546            )));
2547        }
2548    }
2549    Ok(())
2550}
2551
2552fn validate_fingerprint(label: &str, value: &str) -> Result<()> {
2553    if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
2554        return Err(DagMlError::RuntimeValidation(format!(
2555            "{label} fingerprint must be a 64-character hex digest"
2556        )));
2557    }
2558    Ok(())
2559}
2560
2561fn validate_non_empty(label: &str, value: &str) -> Result<()> {
2562    if value.trim().is_empty() {
2563        return Err(DagMlError::RuntimeValidation(format!("{label} is empty")));
2564    }
2565    Ok(())
2566}
2567
2568fn validate_unique_ids<T>(label: &str, values: &[T]) -> Result<()>
2569where
2570    T: Ord + ToString,
2571{
2572    let mut seen = BTreeSet::new();
2573    for value in values {
2574        if !seen.insert(value) {
2575            return Err(DagMlError::RuntimeValidation(format!(
2576                "duplicate {label} `{}`",
2577                value.to_string()
2578            )));
2579        }
2580    }
2581    Ok(())
2582}
2583
2584#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2585pub struct ReplayPhaseRequest {
2586    pub bundle_id: BundleId,
2587    pub phase: Phase,
2588    #[serde(default)]
2589    pub data_envelope_keys: Vec<String>,
2590}
2591
2592impl ReplayPhaseRequest {
2593    pub fn validate_for_bundle(&self, bundle: &ExecutionBundle) -> Result<()> {
2594        self.validate_for_bundle_with_prediction_cache_store(bundle, false)
2595    }
2596
2597    pub fn validate_for_bundle_with_prediction_cache_store(
2598        &self,
2599        bundle: &ExecutionBundle,
2600        prediction_cache_available: bool,
2601    ) -> Result<()> {
2602        self.validate_for_bundle_internal(bundle, prediction_cache_available)
2603    }
2604
2605    pub fn validate_for_bundle_with_prediction_cache_payloads(
2606        &self,
2607        bundle: &ExecutionBundle,
2608        prediction_cache_payloads: Option<&BundlePredictionCachePayloadSet>,
2609    ) -> Result<()> {
2610        if let Some(payloads) = prediction_cache_payloads {
2611            payloads.validate_against_bundle(bundle)?;
2612        }
2613        self.validate_for_bundle_internal(bundle, prediction_cache_payloads.is_some())
2614    }
2615
2616    fn validate_for_bundle_internal(
2617        &self,
2618        bundle: &ExecutionBundle,
2619        prediction_cache_available: bool,
2620    ) -> Result<()> {
2621        bundle.validate()?;
2622        if self.bundle_id != bundle.bundle_id {
2623            return Err(DagMlError::RuntimeValidation(format!(
2624                "replay request bundle `{}` does not match bundle `{}`",
2625                self.bundle_id, bundle.bundle_id
2626            )));
2627        }
2628        if !matches!(self.phase, Phase::Predict | Phase::Explain | Phase::Refit) {
2629            return Err(DagMlError::RuntimeValidation(format!(
2630                "bundle replay phase {:?} is not supported",
2631                self.phase
2632            )));
2633        }
2634        if self.phase == Phase::Refit && !bundle.prediction_requirements.is_empty() {
2635            if prediction_cache_available {
2636                return self.validate_data_envelope_keys(bundle);
2637            }
2638            return Err(DagMlError::RuntimeValidation(format!(
2639                "bundle `{}` cannot replay REFIT because it depends on {} OOF prediction requirement(s) but stores only prediction cache manifests",
2640                bundle.bundle_id,
2641                bundle.prediction_requirements.len()
2642            )));
2643        }
2644        self.validate_data_envelope_keys(bundle)
2645    }
2646
2647    fn validate_data_envelope_keys(&self, bundle: &ExecutionBundle) -> Result<()> {
2648        let expected = bundle
2649            .data_requirements
2650            .iter()
2651            .map(BundleDataRequirement::key)
2652            .collect::<BTreeSet<_>>();
2653        let mut requested = BTreeSet::new();
2654        for key in &self.data_envelope_keys {
2655            if key.trim().is_empty() {
2656                return Err(DagMlError::RuntimeValidation(
2657                    "replay request contains an empty data envelope key".to_string(),
2658                ));
2659            }
2660            if !requested.insert(key.as_str()) {
2661                return Err(DagMlError::RuntimeValidation(format!(
2662                    "replay request contains duplicate data envelope key `{key}`"
2663                )));
2664            }
2665            if !expected.contains(key.as_str()) {
2666                return Err(DagMlError::RuntimeValidation(format!(
2667                    "replay request references unknown data envelope key `{key}`"
2668                )));
2669            }
2670        }
2671        for requirement in &bundle.data_requirements {
2672            let key = requirement.key();
2673            if !requested.contains(key.as_str()) {
2674                return Err(DagMlError::RuntimeValidation(format!(
2675                    "replay request is missing data envelope key `{key}`"
2676                )));
2677            }
2678        }
2679        Ok(())
2680    }
2681}
2682
2683#[cfg(test)]
2684mod tests {
2685    use super::*;
2686    use crate::controller::{ControllerManifest, ControllerRegistry};
2687    use crate::data::{
2688        AggregateRepresentation, RepresentationCardinality, RepresentationCompatibilityOutcome,
2689        RepresentationCompatibilityReport, RepresentationMissingSourcePolicy, RepresentationPlan,
2690        RepresentationReplayManifest,
2691    };
2692    use crate::dsl::{compile_pipeline_dsl_with_generation, PipelineDslSpec};
2693    use crate::graph::GraphSpec;
2694    use crate::ids::{ArtifactId, FoldId, SampleId, TargetId};
2695    use crate::plan::{build_execution_plan, CampaignSpec};
2696    use crate::relation::EntityUnitLevel;
2697    use crate::selection::{
2698        select_candidate, CandidateScore, MetricObjective, SelectionMetric, SelectionPolicy,
2699    };
2700
2701    fn plan() -> ExecutionPlan {
2702        let graph: GraphSpec =
2703            serde_json::from_str(include_str!("../../../examples/minimal_graph.json")).unwrap();
2704        let campaign: CampaignSpec = serde_json::from_str(include_str!(
2705            "../../../examples/campaign_oof_generation.json"
2706        ))
2707        .unwrap();
2708        let manifests: Vec<ControllerManifest> =
2709            serde_json::from_str(include_str!("../../../examples/controller_manifests.json"))
2710                .unwrap();
2711        let mut registry = ControllerRegistry::new();
2712        for manifest in manifests {
2713            registry.register(manifest).unwrap();
2714        }
2715        build_execution_plan("plan:bundle", graph, campaign, &registry).unwrap()
2716    }
2717
2718    fn branch_merge_plan() -> ExecutionPlan {
2719        let graph: GraphSpec = serde_json::from_str(include_str!(
2720            "../../../examples/branch_merge_oof_graph.json"
2721        ))
2722        .unwrap();
2723        let campaign: CampaignSpec = serde_json::from_str(include_str!(
2724            "../../../examples/campaign_branch_merge_oof.json"
2725        ))
2726        .unwrap();
2727        let manifests: Vec<ControllerManifest> =
2728            serde_json::from_str(include_str!("../../../examples/controller_manifests.json"))
2729                .unwrap();
2730        let mut registry = ControllerRegistry::new();
2731        for manifest in manifests {
2732            registry.register(manifest).unwrap();
2733        }
2734        build_execution_plan("plan:branch.merge.bundle", graph, campaign, &registry).unwrap()
2735    }
2736
2737    /// A separation-branch + concat-merge plan: two model branches, each scoped to
2738    /// a disjoint partition of the sample universe, feeding one `prediction_join`
2739    /// concat-merge node. The branch OOF edges are `requires_oof +
2740    /// requires_fold_alignment`, but each branch's OOF covers only its partition
2741    /// (a strict subset of the fold universe). This is the Slice 3.5 bundle-assembly
2742    /// shape the partition-aware requirement validation must accept.
2743    fn separation_concat_merge_plan() -> ExecutionPlan {
2744        let graph: GraphSpec = serde_json::from_str(include_str!(
2745            "../../../examples/separation_branch_concat_merge_oof_graph.json"
2746        ))
2747        .unwrap();
2748        let campaign: CampaignSpec = serde_json::from_str(include_str!(
2749            "../../../examples/campaign_separation_branch_concat_merge_oof.json"
2750        ))
2751        .unwrap();
2752        let manifests: Vec<ControllerManifest> =
2753            serde_json::from_str(include_str!("../../../examples/controller_manifests.json"))
2754                .unwrap();
2755        let mut registry = ControllerRegistry::new();
2756        for manifest in manifests {
2757            registry.register(manifest).unwrap();
2758        }
2759        build_execution_plan(
2760            "plan:separation.concat.merge.bundle",
2761            graph,
2762            campaign,
2763            &registry,
2764        )
2765        .unwrap()
2766    }
2767
2768    /// Per-partition branch OOF requirement into the concat-merge node. Each branch
2769    /// covers ONLY its partition's two samples (one per fold), never the full
2770    /// 4-sample universe — the partition-covering input shape.
2771    fn separation_branch_requirement(
2772        producer_node: &str,
2773        partition_samples: &[&str],
2774        partition_folds: &[&str],
2775    ) -> BundlePredictionRequirement {
2776        BundlePredictionRequirement {
2777            producer_node: NodeId::new(producer_node).unwrap(),
2778            source_port: "oof".to_string(),
2779            consumer_node: NodeId::new("merge:sites").unwrap(),
2780            target_port: format!("oof_{producer_node}"),
2781            partition: PredictionPartition::Validation,
2782            prediction_level: PredictionLevel::Sample,
2783            fold_ids: partition_folds
2784                .iter()
2785                .map(|f| FoldId::new(*f).unwrap())
2786                .collect(),
2787            unit_ids: Vec::new(),
2788            sample_ids: partition_samples
2789                .iter()
2790                .map(|s| SampleId::new(*s).unwrap())
2791                .collect(),
2792            prediction_width: 1,
2793            target_names: vec!["y".to_string()],
2794        }
2795    }
2796
2797    /// Per-fold validation OOF blocks for a separation branch: one block per fold,
2798    /// each carrying only the branch's partition ∩ fold validation sample.
2799    fn separation_branch_blocks(
2800        producer_node: &str,
2801        fold0_sample: &str,
2802        fold1_sample: &str,
2803        offset: f64,
2804    ) -> Vec<PredictionBlock> {
2805        let producer_node = NodeId::new(producer_node).unwrap();
2806        vec![
2807            PredictionBlock {
2808                prediction_id: Some(format!("prediction:{producer_node}:fold0")),
2809                producer_node: producer_node.clone(),
2810                producer_port: Some("pred".to_string()),
2811                partition: PredictionPartition::Validation,
2812                fold_id: Some(FoldId::new("fold:0").unwrap()),
2813                sample_ids: vec![SampleId::new(fold0_sample).unwrap()],
2814                values: vec![vec![offset + 0.1]],
2815                target_names: vec!["y".to_string()],
2816            },
2817            PredictionBlock {
2818                prediction_id: Some(format!("prediction:{producer_node}:fold1")),
2819                producer_node,
2820                producer_port: Some("pred".to_string()),
2821                partition: PredictionPartition::Validation,
2822                fold_id: Some(FoldId::new("fold:1").unwrap()),
2823                sample_ids: vec![SampleId::new(fold1_sample).unwrap()],
2824                values: vec![vec![offset + 0.2]],
2825                target_names: vec!["y".to_string()],
2826            },
2827        ]
2828    }
2829
2830    fn executable_dsl_plan() -> ExecutionPlan {
2831        let spec: PipelineDslSpec = serde_json::from_str(include_str!(
2832            "../../../examples/pipeline_dsl_branch_merge_executable.json"
2833        ))
2834        .unwrap();
2835        let compiled = compile_pipeline_dsl_with_generation(&spec).unwrap();
2836        let manifests: Vec<ControllerManifest> =
2837            serde_json::from_str(include_str!("../../../examples/controller_manifests.json"))
2838                .unwrap();
2839        let mut registry = ControllerRegistry::new();
2840        for manifest in manifests {
2841            registry.register(manifest).unwrap();
2842        }
2843        build_execution_plan(
2844            "plan:dsl.branch.merge.bundle",
2845            compiled.graph,
2846            compiled.campaign_template,
2847            &registry,
2848        )
2849        .unwrap()
2850    }
2851
2852    fn branch_merge_selection_decisions() -> BTreeMap<String, SelectionDecision> {
2853        serde_json::from_str(include_str!(
2854            "../../../examples/fixtures/bundle/selection_decisions_branch_merge.json"
2855        ))
2856        .unwrap()
2857    }
2858
2859    fn refit_artifact(
2860        plan: &ExecutionPlan,
2861        node_id: &str,
2862        data_requirement_keys: Vec<String>,
2863        prediction_requirement_keys: Vec<String>,
2864    ) -> RefitArtifactRecord {
2865        let node_id = NodeId::new(node_id).unwrap();
2866        let node_plan = plan.node_plans.get(&node_id).unwrap();
2867        RefitArtifactRecord {
2868            node_id: node_plan.node_id.clone(),
2869            controller_id: node_plan.controller_id.clone(),
2870            artifact: ArtifactRef {
2871                id: ArtifactId::new(format!("artifact:{}:refit", node_plan.node_id)).unwrap(),
2872                kind: "mock_model".to_string(),
2873                controller_id: node_plan.controller_id.clone(),
2874                backend: None,
2875                uri: None,
2876                content_fingerprint: None,
2877                size_bytes: Some(128),
2878                plugin: None,
2879                plugin_version: None,
2880            },
2881            params_fingerprint: node_plan.params_fingerprint.clone(),
2882            training_loss_fingerprint: node_plan.training_loss_fingerprint(Phase::Refit).unwrap(),
2883            data_requirement_keys,
2884            prediction_requirement_keys,
2885        }
2886    }
2887
2888    fn branch_merge_samples() -> Vec<SampleId> {
2889        vec![
2890            SampleId::new("sample:1").unwrap(),
2891            SampleId::new("sample:2").unwrap(),
2892            SampleId::new("sample:3").unwrap(),
2893            SampleId::new("sample:4").unwrap(),
2894        ]
2895    }
2896
2897    fn branch_merge_requirement(
2898        producer_node: &str,
2899        target_port: &str,
2900    ) -> BundlePredictionRequirement {
2901        BundlePredictionRequirement {
2902            producer_node: NodeId::new(producer_node).unwrap(),
2903            source_port: "oof".to_string(),
2904            consumer_node: NodeId::new("merge:stack.pred_plus_original.meta:ridge").unwrap(),
2905            target_port: target_port.to_string(),
2906            partition: PredictionPartition::Validation,
2907            prediction_level: PredictionLevel::Sample,
2908            fold_ids: vec![
2909                FoldId::new("fold:0").unwrap(),
2910                FoldId::new("fold:1").unwrap(),
2911            ],
2912            unit_ids: Vec::new(),
2913            sample_ids: branch_merge_samples(),
2914            prediction_width: 1,
2915            target_names: vec!["y".to_string()],
2916        }
2917    }
2918
2919    fn branch_merge_prediction_blocks(producer_node: &str, offset: f64) -> Vec<PredictionBlock> {
2920        let producer_node = NodeId::new(producer_node).unwrap();
2921        let samples = branch_merge_samples();
2922        vec![
2923            PredictionBlock {
2924                prediction_id: Some(format!("prediction:{producer_node}:fold0")),
2925                producer_node: producer_node.clone(),
2926                producer_port: Some("pred".to_string()),
2927                partition: PredictionPartition::Validation,
2928                fold_id: Some(FoldId::new("fold:0").unwrap()),
2929                sample_ids: samples[0..2].to_vec(),
2930                values: vec![vec![offset + 0.1], vec![offset + 0.2]],
2931                target_names: vec!["y".to_string()],
2932            },
2933            PredictionBlock {
2934                prediction_id: Some(format!("prediction:{producer_node}:fold1")),
2935                producer_node,
2936                producer_port: Some("pred".to_string()),
2937                partition: PredictionPartition::Validation,
2938                fold_id: Some(FoldId::new("fold:1").unwrap()),
2939                sample_ids: samples[2..4].to_vec(),
2940                values: vec![vec![offset + 0.3], vec![offset + 0.4]],
2941                target_names: vec!["y".to_string()],
2942            },
2943        ]
2944    }
2945
2946    fn decision() -> SelectionDecision {
2947        select_candidate(
2948            &SelectionPolicy {
2949                id: "select:merge".to_string(),
2950                metric: SelectionMetric {
2951                    name: "rmse".to_string(),
2952                    objective: MetricObjective::Minimize,
2953                },
2954                required_metric_level: Some(crate::policy::PredictionLevel::Sample),
2955                require_finite: true,
2956                evaluation_scope: None,
2957                refit_slot_plan: None,
2958                stacking_fit_contract: None,
2959                reduction_id: None,
2960            },
2961            &[
2962                CandidateScore {
2963                    candidate_id: "model:base".to_string(),
2964                    metrics: BTreeMap::from([("rmse".to_string(), 1.0)]),
2965                    metadata: BTreeMap::from([(
2966                        "metric_level".to_string(),
2967                        serde_json::Value::String("sample".to_string()),
2968                    )]),
2969                },
2970                CandidateScore {
2971                    candidate_id: "model:other".to_string(),
2972                    metrics: BTreeMap::from([("rmse".to_string(), 2.0)]),
2973                    metadata: BTreeMap::from([(
2974                        "metric_level".to_string(),
2975                        serde_json::Value::String("sample".to_string()),
2976                    )]),
2977                },
2978            ],
2979        )
2980        .unwrap()
2981    }
2982
2983    fn selected_model_base_decision() -> SelectionDecision {
2984        decision()
2985    }
2986
2987    fn model_base_refit_artifact(plan: &ExecutionPlan) -> RefitArtifactRecord {
2988        let model_plan = plan
2989            .node_plans
2990            .get(&NodeId::new("model:base").unwrap())
2991            .unwrap();
2992        RefitArtifactRecord {
2993            node_id: model_plan.node_id.clone(),
2994            controller_id: model_plan.controller_id.clone(),
2995            artifact: ArtifactRef {
2996                id: ArtifactId::new("artifact:model:base:refit").unwrap(),
2997                kind: "sklearn_pickle".to_string(),
2998                controller_id: model_plan.controller_id.clone(),
2999                backend: None,
3000                uri: None,
3001                content_fingerprint: None,
3002                size_bytes: Some(128),
3003                plugin: None,
3004                plugin_version: None,
3005            },
3006            params_fingerprint: model_plan.params_fingerprint.clone(),
3007            training_loss_fingerprint: model_plan.training_loss_fingerprint(Phase::Refit).unwrap(),
3008            data_requirement_keys: vec!["model:base.x".to_string()],
3009            prediction_requirement_keys: Vec::new(),
3010        }
3011    }
3012
3013    #[test]
3014    fn builds_bundle_from_execution_plan() {
3015        let plan = plan();
3016        let artifact = model_base_refit_artifact(&plan);
3017
3018        let bundle = build_execution_bundle(
3019            BundleId::new("bundle:demo").unwrap(),
3020            &plan,
3021            Some(plan.variants[0].variant_id.clone()),
3022            BTreeMap::from([("merge".to_string(), decision())]),
3023            vec![artifact],
3024        )
3025        .unwrap();
3026
3027        bundle.validate_against_plan(&plan).unwrap();
3028        assert_eq!(bundle.data_requirements.len(), 1);
3029    }
3030
3031    #[test]
3032    fn bundle_data_requirements_accept_d7_replay_contracts() {
3033        let plan = plan();
3034        let artifact = model_base_refit_artifact(&plan);
3035        let mut bundle = build_execution_bundle(
3036            BundleId::new("bundle:d7.replay").unwrap(),
3037            &plan,
3038            Some(plan.variants[0].variant_id.clone()),
3039            BTreeMap::from([("merge".to_string(), decision())]),
3040            vec![artifact],
3041        )
3042        .unwrap();
3043        let relation_fingerprint = bundle.data_requirements[0]
3044            .relation_fingerprint
3045            .clone()
3046            .unwrap_or_else(|| "a".repeat(64));
3047        bundle.data_requirements[0].representation_replay_manifest =
3048            Some(RepresentationReplayManifest {
3049                manifest_id: "repr:d7.bundle".to_string(),
3050                representation_plan: RepresentationPlan::Aggregate(AggregateRepresentation {
3051                    input_unit_level: EntityUnitLevel::Observation,
3052                    output_unit_level: EntityUnitLevel::PhysicalSample,
3053                    reducer_id: None,
3054                    method: Some("mean".to_string()),
3055                    cardinality: RepresentationCardinality::ManyToOne,
3056                }),
3057                combination_plan: None,
3058                output_unit_level: EntityUnitLevel::PhysicalSample,
3059                output_representation: Some("tabular_numeric".to_string()),
3060                relation_fingerprint: Some(relation_fingerprint.clone()),
3061                feature_schema_fingerprint: Some("b".repeat(64)),
3062                final_reduction_id: None,
3063                sample_observation_mapping: Vec::new(),
3064                combo_selection: Vec::new(),
3065                qc_policy_refs: Vec::new(),
3066                outlier_policy_refs: Vec::new(),
3067                missing_source_policy: None,
3068                missing_repetition_policy: None,
3069                prediction_representation: None,
3070                final_output_unit_level: Some(EntityUnitLevel::PhysicalSample),
3071                train_compatibility: None,
3072                predict_compatibility: None,
3073                metadata: BTreeMap::new(),
3074            });
3075        bundle.data_requirements[0].representation_compatibility =
3076            Some(RepresentationCompatibilityReport {
3077                policy: RepresentationMissingSourcePolicy::Strict,
3078                outcome: RepresentationCompatibilityOutcome::Compatible,
3079                fallback_used: None,
3080                warning_severity: None,
3081                affected_source_count: 0,
3082                affected_repetition_count: 0,
3083                affected_sample_count: 0,
3084                train_relation_fingerprint: Some(relation_fingerprint),
3085                predict_relation_fingerprint: None,
3086                train_unit_count: Some(2),
3087                predict_unit_count: Some(2),
3088                fixed_width_required: false,
3089                final_reducer_stabilizes_output: true,
3090                cartesian_combo_count_changed: false,
3091                late_fusion_branch_delta: false,
3092                messages: Vec::new(),
3093                metadata: BTreeMap::new(),
3094            });
3095        bundle.validate_against_plan(&plan).unwrap();
3096
3097        bundle.data_requirements[0]
3098            .representation_replay_manifest
3099            .as_mut()
3100            .unwrap()
3101            .relation_fingerprint = Some("c".repeat(64));
3102        if bundle.data_requirements[0].relation_fingerprint.is_some() {
3103            assert!(bundle.validate().is_err());
3104        }
3105    }
3106
3107    #[test]
3108    fn d9_negative_prediction_cache_refuses_missing_aggregated_unit_ids() {
3109        let cache = BundlePredictionCacheRecord {
3110            requirement_key: "model:base.oof->model:meta.pred".to_string(),
3111            cache_id: "prediction-cache:d9.missing-units".to_string(),
3112            cache_namespace_fingerprints: Vec::new(),
3113            format: BUNDLE_PREDICTION_CACHE_FORMAT.to_string(),
3114            partition: PredictionPartition::Validation,
3115            prediction_level: PredictionLevel::Target,
3116            fold_ids: vec![FoldId::new("fold:0").unwrap()],
3117            unit_ids: Vec::new(),
3118            sample_ids: Vec::new(),
3119            prediction_width: 1,
3120            target_names: vec!["y".to_string()],
3121            block_count: 1,
3122            row_count: 1,
3123            content_fingerprint: "d".repeat(64),
3124            blocks: vec![BundlePredictionBlockCacheRecord {
3125                prediction_id: Some("prediction:d9.target.fold0".to_string()),
3126                fold_id: Some(FoldId::new("fold:0").unwrap()),
3127                prediction_level: PredictionLevel::Target,
3128                row_count: 1,
3129                unit_ids: vec![PredictionUnitId::Target(TargetId::new("target:a").unwrap())],
3130                sample_ids: Vec::new(),
3131                content_fingerprint: "e".repeat(64),
3132            }],
3133        };
3134
3135        let error = cache.validate().unwrap_err().to_string();
3136        assert!(
3137            error.contains("row_count does not match unique unit ids"),
3138            "unexpected D9 missing-unit-id cache error: {error}"
3139        );
3140    }
3141
3142    #[test]
3143    fn refit_artifact_validation_checks_portable_artifact_metadata() {
3144        let plan = plan();
3145        let mut artifact = model_base_refit_artifact(&plan);
3146        artifact.artifact.backend = Some(crate::runtime::ArtifactBackend::Joblib);
3147        artifact.artifact.uri = Some("artifacts/model.joblib".to_string());
3148        artifact.artifact.content_fingerprint = Some("c".repeat(64));
3149        artifact.artifact.plugin = Some("dagml.sklearn".to_string());
3150        artifact.artifact.plugin_version = Some("1.0.0".to_string());
3151        artifact.validate().unwrap();
3152
3153        artifact.artifact.content_fingerprint = Some("short".to_string());
3154        assert!(artifact
3155            .validate()
3156            .unwrap_err()
3157            .to_string()
3158            .contains("artifact content fingerprint"));
3159    }
3160
3161    #[test]
3162    fn bundle_selections_must_match_plan_and_refit_artifacts() {
3163        let plan = plan();
3164        let artifact = model_base_refit_artifact(&plan);
3165        let valid = build_execution_bundle(
3166            BundleId::new("bundle:selected.model").unwrap(),
3167            &plan,
3168            Some(plan.variants[0].variant_id.clone()),
3169            BTreeMap::from([("model".to_string(), selected_model_base_decision())]),
3170            vec![artifact.clone()],
3171        )
3172        .unwrap();
3173        valid.validate_against_plan(&plan).unwrap();
3174
3175        assert!(build_execution_bundle(
3176            BundleId::new("bundle:selected.model.missing.artifact").unwrap(),
3177            &plan,
3178            Some(plan.variants[0].variant_id.clone()),
3179            BTreeMap::from([("model".to_string(), selected_model_base_decision())]),
3180            Vec::new(),
3181        )
3182        .is_err());
3183
3184        let mut missing_level = selected_model_base_decision();
3185        missing_level.metric_level = None;
3186        assert!(build_execution_bundle(
3187            BundleId::new("bundle:selected.missing.level").unwrap(),
3188            &plan,
3189            Some(plan.variants[0].variant_id.clone()),
3190            BTreeMap::from([("model".to_string(), missing_level)]),
3191            vec![artifact.clone()],
3192        )
3193        .is_err());
3194
3195        let mut wrong_level = selected_model_base_decision();
3196        wrong_level.metric_level = Some(crate::policy::PredictionLevel::Target);
3197        assert!(build_execution_bundle(
3198            BundleId::new("bundle:selected.wrong.level").unwrap(),
3199            &plan,
3200            Some(plan.variants[0].variant_id.clone()),
3201            BTreeMap::from([("model".to_string(), wrong_level)]),
3202            vec![artifact.clone()],
3203        )
3204        .is_err());
3205
3206        let mut unknown = selected_model_base_decision();
3207        unknown.selected_candidate_id = "model:missing".to_string();
3208        unknown.ranked_candidates[0].candidate_id = "model:missing".to_string();
3209        assert!(build_execution_bundle(
3210            BundleId::new("bundle:selected.unknown").unwrap(),
3211            &plan,
3212            Some(plan.variants[0].variant_id.clone()),
3213            BTreeMap::from([("model".to_string(), unknown)]),
3214            vec![artifact],
3215        )
3216        .is_err());
3217    }
3218
3219    #[test]
3220    fn bundle_artifact_params_follow_selected_generation_variant() {
3221        let plan = executable_dsl_plan();
3222        let selected_variant = &plan.variants[0];
3223        let node_plan = plan
3224            .node_plans
3225            .get(&NodeId::new("branch:b0.model:ridge").unwrap())
3226            .unwrap();
3227        let effective_params = selected_variant
3228            .effective_params_for_node(&node_plan.node_id, &node_plan.params)
3229            .unwrap();
3230        let effective_fingerprint = stable_json_fingerprint(&effective_params).unwrap();
3231        assert_ne!(effective_fingerprint, node_plan.params_fingerprint);
3232
3233        let artifact = RefitArtifactRecord {
3234            node_id: node_plan.node_id.clone(),
3235            controller_id: node_plan.controller_id.clone(),
3236            artifact: ArtifactRef {
3237                id: ArtifactId::new("artifact:branch:b0.model:ridge:refit").unwrap(),
3238                kind: "mock_model".to_string(),
3239                controller_id: node_plan.controller_id.clone(),
3240                backend: None,
3241                uri: None,
3242                content_fingerprint: None,
3243                size_bytes: Some(128),
3244                plugin: None,
3245                plugin_version: None,
3246            },
3247            params_fingerprint: effective_fingerprint,
3248            training_loss_fingerprint: node_plan.training_loss_fingerprint(Phase::Refit).unwrap(),
3249            data_requirement_keys: vec!["branch:b0.model:ridge.x".to_string()],
3250            prediction_requirement_keys: Vec::new(),
3251        };
3252
3253        build_execution_bundle(
3254            BundleId::new("bundle:dsl.variant.params").unwrap(),
3255            &plan,
3256            Some(selected_variant.variant_id.clone()),
3257            BTreeMap::new(),
3258            vec![artifact.clone()],
3259        )
3260        .unwrap();
3261
3262        let mut stale_artifact = artifact;
3263        stale_artifact.params_fingerprint = node_plan.params_fingerprint.clone();
3264        let error = build_execution_bundle(
3265            BundleId::new("bundle:dsl.variant.params.stale").unwrap(),
3266            &plan,
3267            Some(selected_variant.variant_id.clone()),
3268            BTreeMap::new(),
3269            vec![stale_artifact],
3270        )
3271        .unwrap_err();
3272        assert!(format!("{error}").contains("artifact params"));
3273    }
3274
3275    #[test]
3276    fn branch_merge_bundle_links_selected_refits_and_fold_aligned_oof_caches() {
3277        let plan = branch_merge_plan();
3278        let b0_requirement = branch_merge_requirement("branch:b0.model:ridge", "b0_oof");
3279        let b1_requirement = branch_merge_requirement("branch:b1.model:rf", "b1_oof");
3280        let b0_cache = build_prediction_cache_record(
3281            &b0_requirement,
3282            &branch_merge_prediction_blocks("branch:b0.model:ridge", 0.0),
3283        )
3284        .unwrap();
3285        let b1_cache = build_prediction_cache_record(
3286            &b1_requirement,
3287            &branch_merge_prediction_blocks("branch:b1.model:rf", 1.0),
3288        )
3289        .unwrap();
3290        let b0_artifact = refit_artifact(
3291            &plan,
3292            "branch:b0.model:ridge",
3293            vec!["branch:b0.model:ridge.x".to_string()],
3294            Vec::new(),
3295        );
3296        let b1_artifact = refit_artifact(
3297            &plan,
3298            "branch:b1.model:rf",
3299            vec!["branch:b1.model:rf.x".to_string()],
3300            Vec::new(),
3301        );
3302        let merge_artifact = refit_artifact(
3303            &plan,
3304            "merge:stack.pred_plus_original.meta:ridge",
3305            vec!["merge:stack.pred_plus_original.meta:ridge.x_original".to_string()],
3306            vec![b0_requirement.key(), b1_requirement.key()],
3307        );
3308
3309        let bundle = build_execution_bundle_with_prediction_contracts(
3310            BundleId::new("bundle:branch.merge.selected.refit").unwrap(),
3311            &plan,
3312            Some(plan.variants[0].variant_id.clone()),
3313            branch_merge_selection_decisions(),
3314            vec![
3315                b0_artifact.clone(),
3316                b1_artifact.clone(),
3317                merge_artifact.clone(),
3318            ],
3319            vec![b0_requirement.clone(), b1_requirement.clone()],
3320            vec![b0_cache.clone(), b1_cache.clone()],
3321        )
3322        .unwrap();
3323        bundle.validate_against_plan(&plan).unwrap();
3324        assert_eq!(bundle.selections.len(), 3);
3325        assert_eq!(bundle.prediction_requirements.len(), 2);
3326        assert_eq!(
3327            bundle.refit_artifacts[2].data_requirement_keys,
3328            vec!["merge:stack.pred_plus_original.meta:ridge.x_original"]
3329        );
3330        assert_eq!(
3331            bundle.refit_artifacts[2].prediction_requirement_keys,
3332            vec![
3333                "branch:b0.model:ridge.oof->merge:stack.pred_plus_original.meta:ridge.b0_oof",
3334                "branch:b1.model:rf.oof->merge:stack.pred_plus_original.meta:ridge.b1_oof",
3335            ]
3336        );
3337
3338        assert!(build_execution_bundle_with_prediction_contracts(
3339            BundleId::new("bundle:branch.merge.missing.branch.refit").unwrap(),
3340            &plan,
3341            Some(plan.variants[0].variant_id.clone()),
3342            branch_merge_selection_decisions(),
3343            vec![b0_artifact.clone(), merge_artifact.clone()],
3344            vec![b0_requirement.clone(), b1_requirement.clone()],
3345            vec![b0_cache.clone(), b1_cache.clone()],
3346        )
3347        .is_err());
3348
3349        let mut misaligned_cache = b0_cache;
3350        misaligned_cache.blocks[0].sample_ids = vec![
3351            SampleId::new("sample:1").unwrap(),
3352            SampleId::new("sample:3").unwrap(),
3353        ];
3354        misaligned_cache.blocks[1].sample_ids = vec![
3355            SampleId::new("sample:2").unwrap(),
3356            SampleId::new("sample:4").unwrap(),
3357        ];
3358        let error = build_execution_bundle_with_prediction_contracts(
3359            BundleId::new("bundle:branch.merge.misaligned.oof.cache").unwrap(),
3360            &plan,
3361            Some(plan.variants[0].variant_id.clone()),
3362            branch_merge_selection_decisions(),
3363            vec![b0_artifact, b1_artifact, merge_artifact],
3364            vec![b0_requirement, b1_requirement],
3365            vec![misaligned_cache, b1_cache],
3366        )
3367        .unwrap_err()
3368        .to_string();
3369        assert!(
3370            error.contains("does not match validation samples"),
3371            "unexpected fold-alignment error: {error}"
3372        );
3373    }
3374
3375    /// Slice 3.5: a separation-branch + concat-merge plan whose branch OOF inputs
3376    /// cover disjoint partitions of the fold universe must ASSEMBLE (the strict
3377    /// `sample ids do not match plan fold set` check no longer aborts) AND the
3378    /// bundle scores carry a cv_best_score for the merge producer.
3379    #[test]
3380    fn separation_concat_merge_bundle_assembles_and_is_scored() {
3381        let plan = separation_concat_merge_plan();
3382        // Branch A owns partition {sample:1, sample:3}; branch B owns
3383        // {sample:2, sample:4}. Each is a strict subset of the 4-sample universe.
3384        let a_requirement = separation_branch_requirement(
3385            "branch:site__A.model:pls",
3386            &["sample:1", "sample:3"],
3387            &["fold:0", "fold:1"],
3388        );
3389        let b_requirement = separation_branch_requirement(
3390            "branch:site__B.model:pls",
3391            &["sample:2", "sample:4"],
3392            &["fold:0", "fold:1"],
3393        );
3394        let a_cache = build_prediction_cache_record(
3395            &a_requirement,
3396            &separation_branch_blocks("branch:site__A.model:pls", "sample:1", "sample:3", 0.0),
3397        )
3398        .unwrap();
3399        let b_cache = build_prediction_cache_record(
3400            &b_requirement,
3401            &separation_branch_blocks("branch:site__B.model:pls", "sample:2", "sample:4", 1.0),
3402        )
3403        .unwrap();
3404        let a_artifact = refit_artifact(
3405            &plan,
3406            "branch:site__A.model:pls",
3407            vec!["branch:site__A.model:pls.x".to_string()],
3408            Vec::new(),
3409        );
3410        let b_artifact = refit_artifact(
3411            &plan,
3412            "branch:site__B.model:pls",
3413            vec!["branch:site__B.model:pls.x".to_string()],
3414            Vec::new(),
3415        );
3416
3417        let mut bundle = build_execution_bundle_with_prediction_contracts(
3418            BundleId::new("bundle:separation.concat.merge").unwrap(),
3419            &plan,
3420            Some(plan.variants[0].variant_id.clone()),
3421            BTreeMap::new(),
3422            vec![a_artifact, b_artifact],
3423            vec![a_requirement, b_requirement],
3424            vec![a_cache, b_cache],
3425        )
3426        .expect("separation-branch concat-merge bundle must assemble");
3427
3428        // The bundle now validates against the plan: the partition-covering branch
3429        // inputs (each a strict subset) are accepted because their union covers the
3430        // full fold set disjointly — no "sample ids do not match plan fold set".
3431        bundle
3432            .validate_against_plan(&plan)
3433            .expect("partition-covering branch inputs must validate as a group");
3434        assert_eq!(bundle.prediction_requirements.len(), 2);
3435
3436        // The merge producer is scored: a cv_best_score (cross-fold OOF average)
3437        // for the concat-merge node lands in bundle.scores, proving a separation
3438        // branch produces a SCORED bundle.
3439        let scores = ScoreSet {
3440            schema_version: crate::metrics::SCORE_SET_SCHEMA_VERSION,
3441            plan_id: plan.id.clone(),
3442            selection_metric: Some("rmse".to_string()),
3443            reports: vec![crate::metrics::RegressionMetricReport {
3444                prediction_id: Some("prediction:merge:sites:avg".to_string()),
3445                producer_node: NodeId::new("merge:sites").unwrap(),
3446                producer_port: Some("pred".to_string()),
3447                variant_id: Some(plan.variants[0].variant_id.clone()),
3448                variant_label: None,
3449                partition: PredictionPartition::Validation,
3450                fold_id: Some(FoldId::new("avg").unwrap()),
3451                level: PredictionLevel::Sample,
3452                row_count: 4,
3453                target_width: 1,
3454                target_names: vec!["y".to_string()],
3455                metrics: BTreeMap::from([("rmse".to_string(), 1.5)]),
3456            }],
3457        };
3458        bundle.scores = Some(scores);
3459        bundle
3460            .validate_against_plan(&plan)
3461            .expect("bundle with merge-producer scores must validate");
3462        let cv_best = bundle
3463            .scores
3464            .as_ref()
3465            .unwrap()
3466            .reports
3467            .iter()
3468            .find(|report| {
3469                report.producer_node.as_str() == "merge:sites"
3470                    && report.fold_id.as_ref().map(FoldId::as_str) == Some("avg")
3471            })
3472            .expect("merge producer must have a cross-fold (avg) score");
3473        assert_eq!(cv_best.metrics.get("rmse"), Some(&1.5));
3474    }
3475
3476    /// Slice 3.5 negative: the partition-aware relaxation must NOT blind the OOF
3477    /// completeness check. If the branch inputs are NOT disjoint (a sample covered
3478    /// by two partitions), bundle assembly still errors clearly.
3479    #[test]
3480    fn separation_concat_merge_rejects_overlapping_partitions() {
3481        let plan = separation_concat_merge_plan();
3482        // Branch A correctly owns {sample:1, sample:3}; branch B WRONGLY also claims
3483        // sample:3 (overlap) and drops sample:4.
3484        let a_requirement = separation_branch_requirement(
3485            "branch:site__A.model:pls",
3486            &["sample:1", "sample:3"],
3487            &["fold:0", "fold:1"],
3488        );
3489        let b_requirement = separation_branch_requirement(
3490            "branch:site__B.model:pls",
3491            &["sample:2", "sample:3"],
3492            &["fold:0", "fold:1"],
3493        );
3494        let a_cache = build_prediction_cache_record(
3495            &a_requirement,
3496            &separation_branch_blocks("branch:site__A.model:pls", "sample:1", "sample:3", 0.0),
3497        )
3498        .unwrap();
3499        let b_cache = build_prediction_cache_record(
3500            &b_requirement,
3501            &separation_branch_blocks("branch:site__B.model:pls", "sample:2", "sample:3", 1.0),
3502        )
3503        .unwrap();
3504        let a_artifact = refit_artifact(
3505            &plan,
3506            "branch:site__A.model:pls",
3507            vec!["branch:site__A.model:pls.x".to_string()],
3508            Vec::new(),
3509        );
3510        let b_artifact = refit_artifact(
3511            &plan,
3512            "branch:site__B.model:pls",
3513            vec!["branch:site__B.model:pls.x".to_string()],
3514            Vec::new(),
3515        );
3516
3517        let error = build_execution_bundle_with_prediction_contracts(
3518            BundleId::new("bundle:separation.concat.merge.overlap").unwrap(),
3519            &plan,
3520            Some(plan.variants[0].variant_id.clone()),
3521            BTreeMap::new(),
3522            vec![a_artifact, b_artifact],
3523            vec![a_requirement, b_requirement],
3524            vec![a_cache, b_cache],
3525        )
3526        .unwrap_err()
3527        .to_string();
3528        assert!(
3529            error.contains("overlapping branch predictions"),
3530            "overlap must be rejected, got: {error}"
3531        );
3532    }
3533
3534    /// Slice 3.5 negative: a real OOF gap (the union of branch partitions does NOT
3535    /// cover the full fold universe) must still error — the relaxation validates
3536    /// completeness as a group, it does not skip it.
3537    #[test]
3538    fn separation_concat_merge_rejects_incomplete_coverage() {
3539        let plan = separation_concat_merge_plan();
3540        // Branch A owns {sample:1}; branch B owns {sample:2, sample:4}. sample:3 is
3541        // covered by NO branch — a genuine OOF gap.
3542        let a_requirement =
3543            separation_branch_requirement("branch:site__A.model:pls", &["sample:1"], &["fold:0"]);
3544        let b_requirement = separation_branch_requirement(
3545            "branch:site__B.model:pls",
3546            &["sample:2", "sample:4"],
3547            &["fold:0", "fold:1"],
3548        );
3549        let a_cache = build_prediction_cache_record(
3550            &a_requirement,
3551            &[PredictionBlock {
3552                prediction_id: Some("prediction:a:fold0".to_string()),
3553                producer_node: NodeId::new("branch:site__A.model:pls").unwrap(),
3554                producer_port: None,
3555                partition: PredictionPartition::Validation,
3556                fold_id: Some(FoldId::new("fold:0").unwrap()),
3557                sample_ids: vec![SampleId::new("sample:1").unwrap()],
3558                values: vec![vec![0.1]],
3559                target_names: vec!["y".to_string()],
3560            }],
3561        )
3562        .unwrap();
3563        let b_cache = build_prediction_cache_record(
3564            &b_requirement,
3565            &separation_branch_blocks("branch:site__B.model:pls", "sample:2", "sample:4", 1.0),
3566        )
3567        .unwrap();
3568        let a_artifact = refit_artifact(
3569            &plan,
3570            "branch:site__A.model:pls",
3571            vec!["branch:site__A.model:pls.x".to_string()],
3572            Vec::new(),
3573        );
3574        let b_artifact = refit_artifact(
3575            &plan,
3576            "branch:site__B.model:pls",
3577            vec!["branch:site__B.model:pls.x".to_string()],
3578            Vec::new(),
3579        );
3580
3581        let error = build_execution_bundle_with_prediction_contracts(
3582            BundleId::new("bundle:separation.concat.merge.gap").unwrap(),
3583            &plan,
3584            Some(plan.variants[0].variant_id.clone()),
3585            BTreeMap::new(),
3586            vec![a_artifact, b_artifact],
3587            vec![a_requirement, b_requirement],
3588            vec![a_cache, b_cache],
3589        )
3590        .unwrap_err()
3591        .to_string();
3592        assert!(
3593            error.contains("do not cover"),
3594            "an OOF gap must be rejected, got: {error}"
3595        );
3596    }
3597
3598    /// Slice 3.5 negative (Fix 1): the group is validated against the graph's
3599    /// EXPECTED incoming OOF edges, not just the supplied requirements. A bundle
3600    /// that OMITS one branch->merge edge cannot be masked even if the remaining
3601    /// branch's self-declared sample_ids alone cover the full fold universe.
3602    #[test]
3603    fn separation_concat_merge_rejects_missing_branch_edge() {
3604        let plan = separation_concat_merge_plan();
3605        // Branch B's edge exists in the graph, but the bundle supplies ONLY branch
3606        // A — and branch A here wrongly claims the FULL universe, so the union check
3607        // alone would pass. The expected-vs-supplied edge check must still reject.
3608        let a_requirement = separation_branch_requirement(
3609            "branch:site__A.model:pls",
3610            &["sample:1", "sample:2", "sample:3", "sample:4"],
3611            &["fold:0", "fold:1"],
3612        );
3613        let a_artifact = refit_artifact(
3614            &plan,
3615            "branch:site__A.model:pls",
3616            vec!["branch:site__A.model:pls.x".to_string()],
3617            Vec::new(),
3618        );
3619        let b_artifact = refit_artifact(
3620            &plan,
3621            "branch:site__B.model:pls",
3622            vec!["branch:site__B.model:pls.x".to_string()],
3623            Vec::new(),
3624        );
3625
3626        let error = build_execution_bundle_with_prediction_contracts(
3627            BundleId::new("bundle:separation.concat.merge.missing.branch").unwrap(),
3628            &plan,
3629            Some(plan.variants[0].variant_id.clone()),
3630            BTreeMap::new(),
3631            vec![a_artifact, b_artifact],
3632            vec![a_requirement],
3633            Vec::new(),
3634        )
3635        .unwrap_err()
3636        .to_string();
3637        assert!(
3638            error.contains("do not match the plan's incoming OOF edges"),
3639            "a missing branch edge must be rejected, got: {error}"
3640        );
3641    }
3642
3643    /// Slice 3.5 negative (Fix 2): all-or-nothing caches. If ANY branch input of a
3644    /// concat group carries a per-fold cache, ALL must — a mixed (partial) cache
3645    /// group is rejected, so a no-cache branch cannot satisfy global coverage via
3646    /// its self-declared sample_ids while its persisted per-fold OOF is incomplete.
3647    #[test]
3648    fn separation_concat_merge_rejects_partial_cache_coverage() {
3649        let plan = separation_concat_merge_plan();
3650        let a_requirement = separation_branch_requirement(
3651            "branch:site__A.model:pls",
3652            &["sample:1", "sample:3"],
3653            &["fold:0", "fold:1"],
3654        );
3655        let b_requirement = separation_branch_requirement(
3656            "branch:site__B.model:pls",
3657            &["sample:2", "sample:4"],
3658            &["fold:0", "fold:1"],
3659        );
3660        // Only branch A is persisted; branch B carries NO cache.
3661        let a_cache = build_prediction_cache_record(
3662            &a_requirement,
3663            &separation_branch_blocks("branch:site__A.model:pls", "sample:1", "sample:3", 0.0),
3664        )
3665        .unwrap();
3666        let a_artifact = refit_artifact(
3667            &plan,
3668            "branch:site__A.model:pls",
3669            vec!["branch:site__A.model:pls.x".to_string()],
3670            Vec::new(),
3671        );
3672        let b_artifact = refit_artifact(
3673            &plan,
3674            "branch:site__B.model:pls",
3675            vec!["branch:site__B.model:pls.x".to_string()],
3676            Vec::new(),
3677        );
3678
3679        let error = build_execution_bundle_with_prediction_contracts(
3680            BundleId::new("bundle:separation.concat.merge.partial.cache").unwrap(),
3681            &plan,
3682            Some(plan.variants[0].variant_id.clone()),
3683            BTreeMap::new(),
3684            vec![a_artifact, b_artifact],
3685            vec![a_requirement, b_requirement],
3686            vec![a_cache],
3687        )
3688        .unwrap_err()
3689        .to_string();
3690        assert!(
3691            error.contains("partial prediction-cache coverage"),
3692            "a partial-cache concat group must be rejected, got: {error}"
3693        );
3694    }
3695
3696    #[test]
3697    fn prediction_requirements_are_typed_and_validate_against_oof_edges() {
3698        let plan = branch_merge_plan();
3699        let meta_plan = plan
3700            .node_plans
3701            .get(&NodeId::new("merge:stack.pred_plus_original.meta:ridge").unwrap())
3702            .unwrap();
3703        let producer_node = NodeId::new("branch:b0.model:ridge").unwrap();
3704        let fold0 = FoldId::new("fold:0").unwrap();
3705        let fold1 = FoldId::new("fold:1").unwrap();
3706        let samples = [
3707            SampleId::new("sample:1").unwrap(),
3708            SampleId::new("sample:2").unwrap(),
3709            SampleId::new("sample:3").unwrap(),
3710            SampleId::new("sample:4").unwrap(),
3711        ];
3712        let requirement = BundlePredictionRequirement {
3713            producer_node: producer_node.clone(),
3714            source_port: "oof".to_string(),
3715            consumer_node: meta_plan.node_id.clone(),
3716            target_port: "b0_oof".to_string(),
3717            partition: PredictionPartition::Validation,
3718            prediction_level: PredictionLevel::Sample,
3719            fold_ids: vec![fold0.clone(), fold1.clone()],
3720            unit_ids: Vec::new(),
3721            sample_ids: samples.to_vec(),
3722            prediction_width: 1,
3723            target_names: vec!["y".to_string()],
3724        };
3725        let prediction_blocks = vec![
3726            PredictionBlock {
3727                prediction_id: Some("prediction:branch:b0.fold0".to_string()),
3728                producer_node: producer_node.clone(),
3729                producer_port: Some("oof".to_string()),
3730                partition: PredictionPartition::Validation,
3731                fold_id: Some(fold0),
3732                sample_ids: samples[0..2].to_vec(),
3733                values: vec![vec![0.1], vec![0.2]],
3734                target_names: vec!["y".to_string()],
3735            },
3736            PredictionBlock {
3737                prediction_id: Some("prediction:branch:b0.fold1".to_string()),
3738                producer_node: producer_node.clone(),
3739                producer_port: Some("oof".to_string()),
3740                partition: PredictionPartition::Validation,
3741                fold_id: Some(fold1),
3742                sample_ids: samples[2..4].to_vec(),
3743                values: vec![vec![0.3], vec![0.4]],
3744                target_names: vec!["y".to_string()],
3745            },
3746        ];
3747        let cache = build_prediction_cache_record(&requirement, &prediction_blocks).unwrap();
3748        let payload = build_prediction_cache_payload(&requirement, &prediction_blocks).unwrap();
3749        assert_eq!(cache.prediction_level, PredictionLevel::Sample);
3750        assert_eq!(payload.prediction_level, PredictionLevel::Sample);
3751        assert!(cache
3752            .blocks
3753            .iter()
3754            .all(|block| block.prediction_level == PredictionLevel::Sample));
3755        validate_prediction_cache_payload_matches_record(&payload, &cache).unwrap();
3756        let cache_namespace_fingerprints = vec!["a".repeat(64), "b".repeat(64)];
3757        let mut d10_cache = cache.clone();
3758        d10_cache.cache_namespace_fingerprints = cache_namespace_fingerprints.clone();
3759        d10_cache.validate().unwrap();
3760        let mut d10_payload = payload.clone();
3761        d10_payload.cache_namespace_fingerprints = cache_namespace_fingerprints;
3762        d10_payload.validate().unwrap();
3763        validate_prediction_cache_payload_matches_record(&d10_payload, &d10_cache).unwrap();
3764        for forbidden_partition in [
3765            PredictionPartition::Train,
3766            PredictionPartition::Test,
3767            PredictionPartition::Final,
3768        ] {
3769            let mut non_oof_requirement = requirement.clone();
3770            non_oof_requirement.partition = forbidden_partition.clone();
3771            let requirement_error = non_oof_requirement.validate().unwrap_err().to_string();
3772            assert!(
3773                requirement_error.contains("must use validation OOF predictions"),
3774                "D10 cache namespace must not broaden bundle prediction requirements to {forbidden_partition:?}: {requirement_error}"
3775            );
3776
3777            let mut non_oof_cache = d10_cache.clone();
3778            non_oof_cache.partition = forbidden_partition.clone();
3779            let cache_error = non_oof_cache.validate().unwrap_err().to_string();
3780            assert!(
3781                cache_error.contains("must cache validation OOF predictions"),
3782                "D10 cache namespace must not allow non-OOF cache records for {forbidden_partition:?}: {cache_error}"
3783            );
3784
3785            let mut non_oof_payload = d10_payload.clone();
3786            non_oof_payload.partition = forbidden_partition;
3787            let payload_error = non_oof_payload.validate().unwrap_err().to_string();
3788            assert!(
3789                payload_error.contains("must cache validation OOF predictions"),
3790                "D10 cache namespace must not allow non-OOF cache payloads: {payload_error}"
3791            );
3792        }
3793        let mut short_namespace_cache = d10_cache.clone();
3794        short_namespace_cache.cache_namespace_fingerprints.pop();
3795        assert!(short_namespace_cache
3796            .validate()
3797            .unwrap_err()
3798            .to_string()
3799            .contains("namespace fingerprint count"));
3800        let mut short_namespace_payload = d10_payload;
3801        short_namespace_payload.cache_namespace_fingerprints.pop();
3802        assert!(short_namespace_payload
3803            .validate()
3804            .unwrap_err()
3805            .to_string()
3806            .contains("namespace fingerprint count"));
3807        let mut wrong_level_requirement = requirement.clone();
3808        wrong_level_requirement.prediction_level = PredictionLevel::Target;
3809        assert!(wrong_level_requirement.validate().is_err());
3810        let mut wrong_level_cache = cache.clone();
3811        wrong_level_cache.prediction_level = PredictionLevel::Target;
3812        assert!(wrong_level_cache.validate().is_err());
3813        let mut wrong_level_payload = payload.clone();
3814        wrong_level_payload.prediction_level = PredictionLevel::Target;
3815        assert!(wrong_level_payload.validate().is_err());
3816        let prediction_key = requirement.key();
3817        let artifact = RefitArtifactRecord {
3818            node_id: meta_plan.node_id.clone(),
3819            controller_id: meta_plan.controller_id.clone(),
3820            artifact: ArtifactRef {
3821                id: ArtifactId::new("artifact:merge:stack.pred_plus_original.meta:ridge:refit")
3822                    .unwrap(),
3823                kind: "mock_model".to_string(),
3824                controller_id: meta_plan.controller_id.clone(),
3825                backend: None,
3826                uri: None,
3827                content_fingerprint: None,
3828                size_bytes: Some(128),
3829                plugin: None,
3830                plugin_version: None,
3831            },
3832            params_fingerprint: meta_plan.params_fingerprint.clone(),
3833            training_loss_fingerprint: meta_plan.training_loss_fingerprint(Phase::Refit).unwrap(),
3834            data_requirement_keys: vec![
3835                "merge:stack.pred_plus_original.meta:ridge.x_original".to_string()
3836            ],
3837            prediction_requirement_keys: vec![prediction_key],
3838        };
3839
3840        assert!(build_execution_bundle_with_prediction_contracts(
3841            BundleId::new("bundle:d10.cache.without.selected.variant").unwrap(),
3842            &plan,
3843            None,
3844            BTreeMap::new(),
3845            vec![artifact.clone()],
3846            vec![requirement.clone()],
3847            vec![d10_cache],
3848        )
3849        .unwrap_err()
3850        .to_string()
3851        .contains("requires selected_variant_id"));
3852
3853        assert!(build_execution_bundle(
3854            BundleId::new("bundle:missing.prediction.requirement").unwrap(),
3855            &plan,
3856            Some(plan.variants[0].variant_id.clone()),
3857            BTreeMap::new(),
3858            vec![artifact.clone()],
3859        )
3860        .is_err());
3861
3862        assert!(build_execution_bundle_with_prediction_requirements(
3863            BundleId::new("bundle:typed.prediction.requirement.without.cache").unwrap(),
3864            &plan,
3865            Some(plan.variants[0].variant_id.clone()),
3866            BTreeMap::new(),
3867            vec![artifact.clone()],
3868            vec![requirement.clone()],
3869        )
3870        .is_err());
3871
3872        let bundle = build_execution_bundle_with_prediction_contracts(
3873            BundleId::new("bundle:typed.prediction.requirement").unwrap(),
3874            &plan,
3875            Some(plan.variants[0].variant_id.clone()),
3876            BTreeMap::new(),
3877            vec![artifact],
3878            vec![requirement],
3879            vec![cache],
3880        )
3881        .unwrap();
3882        bundle.validate_against_plan(&plan).unwrap();
3883        assert_eq!(bundle.prediction_requirements.len(), 1);
3884        assert_eq!(bundle.prediction_caches.len(), 1);
3885        assert_eq!(
3886            bundle.refit_artifacts[0].prediction_requirement_keys,
3887            vec!["branch:b0.model:ridge.oof->merge:stack.pred_plus_original.meta:ridge.b0_oof"]
3888        );
3889        let payload_set = BundlePredictionCachePayloadSet {
3890            bundle_id: bundle.bundle_id.clone(),
3891            schema_version: PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
3892            caches: vec![payload],
3893        };
3894        payload_set.validate_against_bundle(&bundle).unwrap();
3895        let refit_replay_request = ReplayPhaseRequest {
3896            bundle_id: bundle.bundle_id.clone(),
3897            phase: Phase::Refit,
3898            data_envelope_keys: bundle
3899                .data_requirements
3900                .iter()
3901                .map(BundleDataRequirement::key)
3902                .collect(),
3903        };
3904        refit_replay_request
3905            .validate_for_bundle_with_prediction_cache_payloads(&bundle, Some(&payload_set))
3906            .unwrap();
3907        let mut tampered_payload_set = payload_set.clone();
3908        tampered_payload_set.caches[0].blocks[0].values[0][0] = 99.0;
3909        assert!(tampered_payload_set
3910            .validate_against_bundle(&bundle)
3911            .is_err());
3912        let mut missing_payload_set = payload_set.clone();
3913        missing_payload_set.caches.clear();
3914        assert!(missing_payload_set
3915            .validate_against_bundle(&bundle)
3916            .is_err());
3917        assert!(refit_replay_request.validate_for_bundle(&bundle).is_err());
3918
3919        let mut wrong_data_owner = bundle.clone();
3920        wrong_data_owner.refit_artifacts[0].data_requirement_keys =
3921            vec!["branch:b0.model:ridge.x".to_string()];
3922        assert!(wrong_data_owner.validate().is_err());
3923
3924        let mut wrong_prediction_consumer = bundle;
3925        wrong_prediction_consumer.refit_artifacts[0].node_id =
3926            NodeId::new("branch:b0.model:ridge").unwrap();
3927        wrong_prediction_consumer.refit_artifacts[0]
3928            .data_requirement_keys
3929            .clear();
3930        assert!(wrong_prediction_consumer.validate().is_err());
3931    }
3932
3933    #[test]
3934    fn aggregated_prediction_cache_contracts_preserve_unit_ids() {
3935        let plan = branch_merge_plan();
3936        let producer_node = NodeId::new("branch:b0.model:ridge").unwrap();
3937        let consumer_node = NodeId::new("merge:stack.pred_plus_original.meta:ridge").unwrap();
3938        let fold0 = FoldId::new("fold:0").unwrap();
3939        let fold1 = FoldId::new("fold:1").unwrap();
3940        let target_a = PredictionUnitId::Target(TargetId::new("target:a").unwrap());
3941        let target_b = PredictionUnitId::Target(TargetId::new("target:b").unwrap());
3942        let requirement = BundlePredictionRequirement {
3943            producer_node: producer_node.clone(),
3944            source_port: "oof".to_string(),
3945            consumer_node: consumer_node.clone(),
3946            target_port: "b0_oof".to_string(),
3947            partition: PredictionPartition::Validation,
3948            prediction_level: PredictionLevel::Target,
3949            fold_ids: vec![fold0.clone(), fold1.clone()],
3950            unit_ids: vec![target_a.clone(), target_b.clone()],
3951            sample_ids: Vec::new(),
3952            prediction_width: 1,
3953            target_names: vec!["y".to_string()],
3954        };
3955        let aggregated_blocks = vec![
3956            AggregatedPredictionBlock {
3957                prediction_id: Some("prediction:branch:b0.target.fold0".to_string()),
3958                producer_node: producer_node.clone(),
3959                producer_port: Some("pred".to_string()),
3960                partition: PredictionPartition::Validation,
3961                fold_id: Some(fold0),
3962                level: PredictionLevel::Target,
3963                unit_ids: vec![target_a],
3964                values: vec![vec![0.15]],
3965                target_names: vec!["y".to_string()],
3966            },
3967            AggregatedPredictionBlock {
3968                prediction_id: Some("prediction:branch:b0.target.fold1".to_string()),
3969                producer_node,
3970                producer_port: Some("pred".to_string()),
3971                partition: PredictionPartition::Validation,
3972                fold_id: Some(fold1),
3973                level: PredictionLevel::Target,
3974                unit_ids: vec![target_b],
3975                values: vec![vec![0.35]],
3976                target_names: vec!["y".to_string()],
3977            },
3978        ];
3979
3980        let cache =
3981            build_aggregated_prediction_cache_record(&requirement, &aggregated_blocks).unwrap();
3982        let payload =
3983            build_aggregated_prediction_cache_payload(&requirement, &aggregated_blocks).unwrap();
3984        assert_eq!(cache.prediction_level, PredictionLevel::Target);
3985        assert_eq!(cache.unit_ids, requirement.unit_ids);
3986        assert!(cache.sample_ids.is_empty());
3987        assert!(payload.blocks.is_empty());
3988        assert_eq!(payload.aggregated_blocks.len(), 2);
3989        validate_prediction_cache_payload_matches_record(&payload, &cache).unwrap();
3990
3991        let artifact = refit_artifact(
3992            &plan,
3993            "merge:stack.pred_plus_original.meta:ridge",
3994            vec!["merge:stack.pred_plus_original.meta:ridge.x_original".to_string()],
3995            vec![requirement.key()],
3996        );
3997        let bundle = build_execution_bundle_with_prediction_contracts(
3998            BundleId::new("bundle:target.prediction.requirement").unwrap(),
3999            &plan,
4000            Some(plan.variants[0].variant_id.clone()),
4001            BTreeMap::new(),
4002            vec![artifact],
4003            vec![requirement],
4004            vec![cache],
4005        )
4006        .unwrap();
4007        bundle.validate_against_plan(&plan).unwrap();
4008
4009        let mut tampered_payload = payload;
4010        tampered_payload.aggregated_blocks[0].unit_ids =
4011            vec![PredictionUnitId::Target(TargetId::new("target:z").unwrap())];
4012        assert!(validate_prediction_cache_payload_matches_record(
4013            &tampered_payload,
4014            &bundle.prediction_caches[0]
4015        )
4016        .is_err());
4017    }
4018
4019    #[test]
4020    fn replay_envelopes_must_match_bundle_requirements() {
4021        let plan = plan();
4022        let bundle = build_execution_bundle(
4023            BundleId::new("bundle:demo").unwrap(),
4024            &plan,
4025            None,
4026            BTreeMap::new(),
4027            Vec::new(),
4028        )
4029        .unwrap();
4030        let envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
4031            "../../../examples/fixtures/data/coordinator_data_plan_envelope_sample12.json"
4032        ))
4033        .unwrap();
4034
4035        bundle
4036            .validate_replay_envelopes(&BTreeMap::from([(
4037                "model:base.x".to_string(),
4038                envelope.clone(),
4039            )]))
4040            .unwrap();
4041
4042        let mut mismatched = envelope;
4043        mismatched.schema_fingerprint = "0".repeat(64);
4044        assert!(bundle
4045            .validate_replay_envelopes(&BTreeMap::from([("model:base.x".to_string(), mismatched,)]))
4046            .is_err());
4047    }
4048
4049    #[test]
4050    fn rejects_unsupported_bundle_schema_version() {
4051        let mut bundle = build_execution_bundle(
4052            BundleId::new("bundle:demo").unwrap(),
4053            &plan(),
4054            None,
4055            BTreeMap::new(),
4056            Vec::new(),
4057        )
4058        .unwrap();
4059        bundle.schema_version = EXECUTION_BUNDLE_SCHEMA_VERSION + 1;
4060
4061        assert!(bundle.validate().is_err());
4062
4063        bundle.schema_version = 0;
4064        assert!(bundle.validate().is_err());
4065    }
4066
4067    #[test]
4068    fn rejects_bundle_with_scores_plan_id_mismatch() {
4069        let plan = plan();
4070        let mut bundle = build_execution_bundle(
4071            BundleId::new("bundle:demo").unwrap(),
4072            &plan,
4073            None,
4074            BTreeMap::new(),
4075            Vec::new(),
4076        )
4077        .unwrap();
4078        bundle.scores = Some(ScoreSet {
4079            schema_version: crate::metrics::SCORE_SET_SCHEMA_VERSION,
4080            plan_id: bundle.plan_id.clone(),
4081            selection_metric: Some("rmse".to_string()),
4082            reports: vec![crate::metrics::RegressionMetricReport {
4083                prediction_id: None,
4084                producer_node: NodeId::new("model:compat.0").unwrap(),
4085                producer_port: Some("pred".to_string()),
4086                variant_id: None,
4087                variant_label: None,
4088                partition: PredictionPartition::Test,
4089                fold_id: Some(FoldId::new("final").unwrap()),
4090                level: PredictionLevel::Sample,
4091                row_count: 4,
4092                target_width: 1,
4093                target_names: vec!["y".to_string()],
4094                metrics: BTreeMap::from([("rmse".to_string(), 1.0)]),
4095            }],
4096        });
4097        // Matching plan_ids: the bundle (with embedded scores) validates.
4098        bundle.validate().unwrap();
4099        // A bundle whose embedded scores.plan_id disagrees with the bundle plan_id is rejected.
4100        bundle.scores.as_mut().unwrap().plan_id = "plan:wrong".to_string();
4101        let err = bundle.validate().unwrap_err().to_string();
4102        assert!(
4103            err.contains("does not match its embedded scores plan_id"),
4104            "{err}"
4105        );
4106    }
4107
4108    #[test]
4109    fn schema_migration_policy_is_explicit_and_refuses_implicit_migrations() {
4110        let bundle_policy = execution_bundle_schema_migration_policy();
4111        assert_eq!(
4112            bundle_policy.current_version,
4113            EXECUTION_BUNDLE_SCHEMA_VERSION
4114        );
4115        assert_eq!(
4116            bundle_policy.min_readable_version,
4117            MIN_READABLE_EXECUTION_BUNDLE_SCHEMA_VERSION
4118        );
4119        assert_eq!(
4120            bundle_policy.min_writable_version,
4121            MIN_WRITABLE_EXECUTION_BUNDLE_SCHEMA_VERSION
4122        );
4123        assert_eq!(
4124            bundle_policy
4125                .automatic_migrations
4126                .get(&LEGACY_EXECUTION_BUNDLE_SCHEMA_VERSION),
4127            Some(&EXECUTION_BUNDLE_SCHEMA_VERSION)
4128        );
4129        bundle_policy
4130            .validate_read_version(LEGACY_EXECUTION_BUNDLE_SCHEMA_VERSION, "bundle `legacy`")
4131            .unwrap();
4132        bundle_policy
4133            .validate_read_version(EXECUTION_BUNDLE_SCHEMA_VERSION, "bundle `current`")
4134            .unwrap();
4135        assert!(bundle_policy
4136            .validate_read_version(EXECUTION_BUNDLE_SCHEMA_VERSION + 1, "bundle `future`")
4137            .is_err());
4138        assert!(bundle_policy
4139            .validate_read_version(0, "bundle `zero`")
4140            .is_err());
4141
4142        let mut future_policy = SchemaMigrationPolicy {
4143            artifact: "execution_bundle".to_string(),
4144            current_version: 2,
4145            min_readable_version: 1,
4146            min_writable_version: 2,
4147            automatic_migrations: BTreeMap::new(),
4148        };
4149        assert!(future_policy
4150            .validate_read_version(1, "bundle `old-without-migration`")
4151            .is_err());
4152        future_policy.automatic_migrations.insert(1, 2);
4153        future_policy
4154            .validate_read_version(1, "bundle `old-with-migration`")
4155            .unwrap();
4156    }
4157
4158    #[test]
4159    fn prediction_cache_payload_schema_policy_rejects_unsupported_versions() {
4160        let policy = prediction_cache_payload_schema_migration_policy();
4161        assert_eq!(
4162            policy.current_version,
4163            PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION
4164        );
4165        assert_eq!(
4166            policy
4167                .automatic_migrations
4168                .get(&LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION),
4169            Some(&PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION)
4170        );
4171        policy
4172            .validate_read_version(
4173                LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
4174                "payload `legacy`",
4175            )
4176            .unwrap();
4177
4178        let mut payload_set = BundlePredictionCachePayloadSet {
4179            bundle_id: BundleId::new("bundle:payload.schema").unwrap(),
4180            schema_version: PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
4181            caches: Vec::new(),
4182        };
4183        payload_set.validate().unwrap();
4184
4185        payload_set.schema_version = PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION + 1;
4186        assert!(payload_set.validate().is_err());
4187
4188        payload_set.schema_version = 0;
4189        assert!(payload_set.validate().is_err());
4190    }
4191
4192    #[test]
4193    fn prediction_cache_payload_format_enforces_port_family() {
4194        fn payload_for_block(
4195            format: &str,
4196            producer_port: Option<String>,
4197        ) -> BundlePredictionCachePayload {
4198            let block = PredictionBlock {
4199                prediction_id: Some("prediction:model:base.fold0".to_string()),
4200                producer_node: NodeId::new("model:base").unwrap(),
4201                producer_port,
4202                partition: PredictionPartition::Validation,
4203                fold_id: Some(FoldId::new("fold:0").unwrap()),
4204                sample_ids: vec![SampleId::new("sample:1").unwrap()],
4205                values: vec![vec![1.0]],
4206                target_names: vec!["y".to_string()],
4207            };
4208            let blocks = vec![block];
4209            BundlePredictionCachePayload {
4210                requirement_key: "model:base.oof->model:meta.pred".to_string(),
4211                cache_id: "prediction-cache:model:base.oof->model:meta.pred".to_string(),
4212                cache_namespace_fingerprints: Vec::new(),
4213                format: format.to_string(),
4214                partition: PredictionPartition::Validation,
4215                prediction_level: PredictionLevel::Sample,
4216                block_count: blocks.len(),
4217                row_count: blocks.iter().map(|block| block.sample_ids.len()).sum(),
4218                content_fingerprint: stable_json_fingerprint(&blocks).unwrap(),
4219                blocks,
4220                aggregated_blocks: Vec::new(),
4221            }
4222        }
4223
4224        payload_for_block(LEGACY_BUNDLE_PREDICTION_CACHE_FORMAT, None)
4225            .validate()
4226            .unwrap();
4227        payload_for_block(BUNDLE_PREDICTION_CACHE_FORMAT, Some("oof".to_string()))
4228            .validate()
4229            .unwrap();
4230
4231        let v1_with_port = payload_for_block(
4232            LEGACY_BUNDLE_PREDICTION_CACHE_FORMAT,
4233            Some("oof".to_string()),
4234        )
4235        .validate()
4236        .unwrap_err()
4237        .to_string();
4238        assert!(
4239            v1_with_port.contains("V1") && v1_with_port.contains("producer_port"),
4240            "unexpected V1 port-family error: {v1_with_port}"
4241        );
4242
4243        let v2_without_port = payload_for_block(BUNDLE_PREDICTION_CACHE_FORMAT, None)
4244            .validate()
4245            .unwrap_err()
4246            .to_string();
4247        assert!(
4248            v2_without_port.contains("V2") && v2_without_port.contains("requires producer_port"),
4249            "unexpected V2 port-family error: {v2_without_port}"
4250        );
4251    }
4252
4253    #[test]
4254    fn replay_request_requires_predict_explain_or_refit_phase() {
4255        let bundle = build_execution_bundle(
4256            BundleId::new("bundle:demo").unwrap(),
4257            &plan(),
4258            None,
4259            BTreeMap::new(),
4260            Vec::new(),
4261        )
4262        .unwrap();
4263
4264        ReplayPhaseRequest {
4265            bundle_id: bundle.bundle_id.clone(),
4266            phase: Phase::Predict,
4267            data_envelope_keys: vec!["model:base.x".to_string()],
4268        }
4269        .validate_for_bundle(&bundle)
4270        .unwrap();
4271        ReplayPhaseRequest {
4272            bundle_id: bundle.bundle_id.clone(),
4273            phase: Phase::Refit,
4274            data_envelope_keys: vec!["model:base.x".to_string()],
4275        }
4276        .validate_for_bundle(&bundle)
4277        .unwrap();
4278        assert!(ReplayPhaseRequest {
4279            bundle_id: bundle.bundle_id.clone(),
4280            phase: Phase::FitCv,
4281            data_envelope_keys: vec!["model:base.x".to_string()],
4282        }
4283        .validate_for_bundle(&bundle)
4284        .is_err());
4285        assert!(ReplayPhaseRequest {
4286            bundle_id: bundle.bundle_id.clone(),
4287            phase: Phase::Predict,
4288            data_envelope_keys: vec!["model:base.x".to_string(), "model:base.x".to_string()],
4289        }
4290        .validate_for_bundle(&bundle)
4291        .is_err());
4292        assert!(ReplayPhaseRequest {
4293            bundle_id: bundle.bundle_id.clone(),
4294            phase: Phase::Predict,
4295            data_envelope_keys: vec!["model:base.y".to_string()],
4296        }
4297        .validate_for_bundle(&bundle)
4298        .is_err());
4299    }
4300
4301    #[test]
4302    fn prediction_level_wire_absent_parses_as_sample_but_serialization_stays_explicit() {
4303        let mut requirement = BundlePredictionRequirement {
4304            producer_node: NodeId::new("model:base").unwrap(),
4305            source_port: "oof".to_string(),
4306            consumer_node: NodeId::new("model:meta").unwrap(),
4307            target_port: "x".to_string(),
4308            partition: PredictionPartition::Validation,
4309            prediction_level: PredictionLevel::Sample,
4310            fold_ids: vec![FoldId::new("fold:0").unwrap()],
4311            unit_ids: Vec::new(),
4312            sample_ids: vec![SampleId::new("sample:1").unwrap()],
4313            prediction_width: 1,
4314            target_names: vec!["y".to_string()],
4315        };
4316
4317        let mut sample = serde_json::to_value(&requirement).unwrap();
4318        assert_eq!(sample["prediction_level"], serde_json::json!("sample"));
4319
4320        sample.as_object_mut().unwrap().remove("prediction_level");
4321        let parsed: BundlePredictionRequirement = serde_json::from_value(sample).unwrap();
4322        assert_eq!(parsed.prediction_level, PredictionLevel::Sample);
4323
4324        requirement.prediction_level = PredictionLevel::Group;
4325        let group = serde_json::to_value(&requirement).unwrap();
4326        assert_eq!(group["prediction_level"], serde_json::json!("group"));
4327    }
4328}