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