Skip to main content

dag_ml_core/
data.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{DagMlError, Result};
7use crate::ids::{ControllerId, FoldId, NodeId, RunId, VariantId};
8use crate::phase::Phase;
9use crate::policy::FitInfluencePolicy;
10use crate::relation::{EntityUnitLevel, SampleRelationSet};
11use crate::runtime::{
12    DataMaterializationRequest, DataProviderViewSpec, DataViewRequest, HandleKind, HandleRef,
13    RuntimeDataProvider,
14};
15
16pub const EXTERNAL_DATA_PLAN_ENVELOPE_SCHEMA_VERSION: u32 = 1;
17pub const MODEL_INPUT_SPEC_SCHEMA_VERSION: u32 = 1;
18pub const MODEL_INPUT_SPEC_SCHEMA_ID: &str =
19    "https://github.com/GBeurier/dag-ml/schemas/model_input_spec.v1.schema.json";
20pub const DATA_PLAN_SCHEMA_VERSION: u32 = 1;
21pub const DATA_PLAN_SCHEMA_ID: &str =
22    "https://github.com/GBeurier/dag-ml/schemas/data_plan.v1.schema.json";
23pub const SOURCE_INDEX_METADATA_KEY: &str = "source_index";
24
25fn default_external_data_plan_envelope_schema_version() -> u32 {
26    EXTERNAL_DATA_PLAN_ENVELOPE_SCHEMA_VERSION
27}
28
29fn default_model_input_spec_schema_version() -> u32 {
30    MODEL_INPUT_SPEC_SCHEMA_VERSION
31}
32
33fn default_data_plan_schema_version() -> u32 {
34    DATA_PLAN_SCHEMA_VERSION
35}
36
37#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum DataRequestPartition {
40    FoldTrain,
41    FoldValidation,
42    FullTrain,
43    Predict,
44}
45
46#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum ModelInputFusionMode {
49    SingleSource,
50    ConcatenateFeatures,
51    StackSamples,
52    DictBySource,
53    Custom,
54}
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum BranchViewMode {
59    Separation,
60    BySource,
61    ByMetadata,
62    ByTag,
63    ByFilter,
64}
65
66#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
67pub struct DataViewSelector {
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub source_ids: Vec<String>,
70    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
71    pub metadata: BTreeMap<String, serde_json::Value>,
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub tags: Vec<String>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub filter: Option<serde_json::Value>,
76}
77
78impl DataViewSelector {
79    pub fn validate(&self, label: &str) -> Result<()> {
80        if self.source_ids.is_empty()
81            && self.metadata.is_empty()
82            && self.tags.is_empty()
83            && self.filter.is_none()
84        {
85            return Err(DagMlError::CampaignValidation(format!(
86                "{label} selector must constrain source_ids, metadata, tags or filter"
87            )));
88        }
89        validate_string_list_entries(&format!("{label} selector source_ids"), &self.source_ids)?;
90        validate_unique_strings(&format!("{label} selector source_ids"), &self.source_ids)?;
91        validate_string_list_entries(&format!("{label} selector tags"), &self.tags)?;
92        validate_unique_strings(&format!("{label} selector tags"), &self.tags)?;
93        for key in self.metadata.keys() {
94            if key.trim().is_empty() {
95                return Err(DagMlError::CampaignValidation(format!(
96                    "{label} selector contains an empty metadata key"
97                )));
98            }
99        }
100        if matches!(self.filter, Some(serde_json::Value::Null)) {
101            return Err(DagMlError::CampaignValidation(format!(
102                "{label} selector filter must not be null"
103            )));
104        }
105        Ok(())
106    }
107}
108
109#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
110pub struct BranchViewPlan {
111    pub view_id: String,
112    pub branch_id: String,
113    pub mode: BranchViewMode,
114    pub selector: DataViewSelector,
115    #[serde(default)]
116    pub allow_overlap: bool,
117    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
118    pub metadata: BTreeMap<String, serde_json::Value>,
119}
120
121impl BranchViewPlan {
122    pub fn validate(&self) -> Result<()> {
123        validate_non_empty("branch view plan view_id", &self.view_id)?;
124        validate_non_empty("branch view plan branch_id", &self.branch_id)?;
125        self.selector
126            .validate(&format!("branch view `{}`", self.view_id))?;
127        match self.mode {
128            BranchViewMode::BySource if self.selector.source_ids.is_empty() => {
129                return Err(DagMlError::CampaignValidation(format!(
130                    "branch view `{}` mode=by_source requires source_ids",
131                    self.view_id
132                )));
133            }
134            BranchViewMode::ByMetadata if self.selector.metadata.is_empty() => {
135                return Err(DagMlError::CampaignValidation(format!(
136                    "branch view `{}` mode=by_metadata requires metadata",
137                    self.view_id
138                )));
139            }
140            BranchViewMode::ByTag if self.selector.tags.is_empty() => {
141                return Err(DagMlError::CampaignValidation(format!(
142                    "branch view `{}` mode=by_tag requires tags",
143                    self.view_id
144                )));
145            }
146            BranchViewMode::ByFilter if self.selector.filter.is_none() => {
147                return Err(DagMlError::CampaignValidation(format!(
148                    "branch view `{}` mode=by_filter requires filter",
149                    self.view_id
150                )));
151            }
152            _ => {}
153        }
154        for key in self.metadata.keys() {
155            if key.trim().is_empty() {
156                return Err(DagMlError::CampaignValidation(format!(
157                    "branch view `{}` metadata contains an empty key",
158                    self.view_id
159                )));
160            }
161        }
162        Ok(())
163    }
164}
165
166#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum CombinationMode {
169    #[default]
170    Cartesian,
171    Zip,
172    MatchBy,
173    SampleK,
174    ReferenceBroadcast,
175}
176
177#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum RepresentationMissingSourcePolicy {
180    Strict,
181    Warn,
182    DropIncomplete,
183    ImputeDeclared,
184    Mask,
185    PartialModel,
186    Pad,
187}
188
189#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum RepresentationCardinality {
192    OneToOne,
193    OneToMany,
194    ManyToOne,
195    ManyToMany,
196    BoundedMany,
197}
198
199#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
200#[serde(deny_unknown_fields)]
201pub struct CombinationPlan {
202    pub mode: CombinationMode,
203    #[serde(default, skip_serializing_if = "Vec::is_empty")]
204    pub component_source_ids: Vec<String>,
205    #[serde(default, skip_serializing_if = "Vec::is_empty")]
206    pub component_unit_ids: Vec<String>,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub match_key: Option<String>,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub reference_source_id: Option<String>,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub seed: Option<u64>,
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub cap: Option<usize>,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub budget: Option<usize>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub missing_source_policy: Option<RepresentationMissingSourcePolicy>,
219    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
220    pub metadata: BTreeMap<String, serde_json::Value>,
221}
222
223impl CombinationPlan {
224    pub fn validate(&self) -> Result<()> {
225        validate_string_list_entries(
226            "combination plan component_source_ids",
227            &self.component_source_ids,
228        )?;
229        validate_unique_strings(
230            "combination plan component_source_ids",
231            &self.component_source_ids,
232        )?;
233        validate_string_list_entries(
234            "combination plan component_unit_ids",
235            &self.component_unit_ids,
236        )?;
237        validate_unique_strings(
238            "combination plan component_unit_ids",
239            &self.component_unit_ids,
240        )?;
241        validate_optional_non_empty("combination plan match_key", &self.match_key)?;
242        validate_optional_non_empty(
243            "combination plan reference_source_id",
244            &self.reference_source_id,
245        )?;
246        if self.cap == Some(0) {
247            return Err(DagMlError::CampaignValidation(
248                "combination plan cap must be positive when present".to_string(),
249            ));
250        }
251        if self.budget == Some(0) {
252            return Err(DagMlError::CampaignValidation(
253                "combination plan budget must be positive when present".to_string(),
254            ));
255        }
256        match self.mode {
257            CombinationMode::Cartesian => {
258                if self.component_source_ids.len() < 2 {
259                    return Err(DagMlError::CampaignValidation(
260                        "cartesian combination requires at least two component_source_ids"
261                            .to_string(),
262                    ));
263                }
264            }
265            CombinationMode::Zip => {
266                if self.component_source_ids.len() < 2 {
267                    return Err(DagMlError::CampaignValidation(
268                        "zip combination requires at least two component_source_ids".to_string(),
269                    ));
270                }
271            }
272            CombinationMode::MatchBy => {
273                if self.match_key.is_none() {
274                    return Err(DagMlError::CampaignValidation(
275                        "match_by combination requires match_key".to_string(),
276                    ));
277                }
278            }
279            CombinationMode::SampleK => {
280                if self.seed.is_none() {
281                    return Err(DagMlError::CampaignValidation(
282                        "sample_k combination requires seed".to_string(),
283                    ));
284                }
285                if self.cap.is_none() {
286                    return Err(DagMlError::CampaignValidation(
287                        "sample_k combination requires cap".to_string(),
288                    ));
289                }
290            }
291            CombinationMode::ReferenceBroadcast => {
292                let Some(reference) = &self.reference_source_id else {
293                    return Err(DagMlError::CampaignValidation(
294                        "reference_broadcast combination requires reference_source_id".to_string(),
295                    ));
296                };
297                if !self.component_source_ids.is_empty()
298                    && !self
299                        .component_source_ids
300                        .iter()
301                        .any(|source| source == reference)
302                {
303                    return Err(DagMlError::CampaignValidation(format!(
304                        "reference_broadcast reference_source_id `{reference}` is not in component_source_ids"
305                    )));
306                }
307            }
308        }
309        for key in self.metadata.keys() {
310            if key.trim().is_empty() {
311                return Err(DagMlError::CampaignValidation(
312                    "combination plan metadata contains an empty key".to_string(),
313                ));
314            }
315        }
316        Ok(())
317    }
318}
319
320#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
321#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
322pub enum RepresentationPlan {
323    Aggregate(AggregateRepresentation),
324    CartesianProduct(CartesianProductRepresentation),
325    MonteCarloCartesian(MonteCarloCartesianRepresentation),
326    StackFixed(StackFixedRepresentation),
327    StackPaddedMasked(StackPaddedMaskedRepresentation),
328}
329
330impl RepresentationPlan {
331    pub fn validate(&self) -> Result<()> {
332        match self {
333            Self::Aggregate(plan) => plan.validate(),
334            Self::CartesianProduct(plan) => plan.validate(),
335            Self::MonteCarloCartesian(plan) => plan.validate(),
336            Self::StackFixed(plan) => plan.validate(),
337            Self::StackPaddedMasked(plan) => plan.validate(),
338        }
339    }
340
341    pub fn output_unit_level(&self) -> EntityUnitLevel {
342        match self {
343            Self::Aggregate(plan) => plan.output_unit_level,
344            Self::CartesianProduct(plan) => plan.output_unit_level,
345            Self::MonteCarloCartesian(plan) => plan.output_unit_level,
346            Self::StackFixed(plan) => plan.output_unit_level,
347            Self::StackPaddedMasked(plan) => plan.output_unit_level,
348        }
349    }
350}
351
352#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
353#[serde(deny_unknown_fields)]
354pub struct AggregateRepresentation {
355    pub input_unit_level: EntityUnitLevel,
356    pub output_unit_level: EntityUnitLevel,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub reducer_id: Option<String>,
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub method: Option<String>,
361    pub cardinality: RepresentationCardinality,
362}
363
364impl AggregateRepresentation {
365    pub fn validate(&self) -> Result<()> {
366        validate_optional_non_empty("aggregate representation reducer_id", &self.reducer_id)?;
367        validate_optional_non_empty("aggregate representation method", &self.method)?;
368        if self.cardinality != RepresentationCardinality::ManyToOne {
369            return Err(DagMlError::CampaignValidation(
370                "aggregate representation cardinality must be many_to_one".to_string(),
371            ));
372        }
373        Ok(())
374    }
375}
376
377#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
378#[serde(deny_unknown_fields)]
379pub struct CartesianProductRepresentation {
380    pub combination_plan: CombinationPlan,
381    pub output_unit_level: EntityUnitLevel,
382    pub cardinality: RepresentationCardinality,
383    #[serde(default = "default_true")]
384    pub preserve_provenance: bool,
385}
386
387impl CartesianProductRepresentation {
388    pub fn validate(&self) -> Result<()> {
389        self.combination_plan.validate()?;
390        if self.combination_plan.mode != CombinationMode::Cartesian {
391            return Err(DagMlError::CampaignValidation(
392                "cartesian_product representation requires combination_plan.mode=cartesian"
393                    .to_string(),
394            ));
395        }
396        validate_combo_like_output("cartesian_product", self.output_unit_level)?;
397        if self.cardinality != RepresentationCardinality::ManyToMany {
398            return Err(DagMlError::CampaignValidation(
399                "cartesian_product representation cardinality must be many_to_many".to_string(),
400            ));
401        }
402        Ok(())
403    }
404}
405
406#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
407#[serde(deny_unknown_fields)]
408pub struct MonteCarloCartesianRepresentation {
409    pub combination_plan: CombinationPlan,
410    pub output_unit_level: EntityUnitLevel,
411    pub cardinality: RepresentationCardinality,
412    #[serde(default = "default_true")]
413    pub preserve_provenance: bool,
414}
415
416impl MonteCarloCartesianRepresentation {
417    pub fn validate(&self) -> Result<()> {
418        self.combination_plan.validate()?;
419        if self.combination_plan.mode != CombinationMode::SampleK {
420            return Err(DagMlError::CampaignValidation(
421                "monte_carlo_cartesian representation requires combination_plan.mode=sample_k"
422                    .to_string(),
423            ));
424        }
425        validate_combo_like_output("monte_carlo_cartesian", self.output_unit_level)?;
426        if self.cardinality != RepresentationCardinality::BoundedMany {
427            return Err(DagMlError::CampaignValidation(
428                "monte_carlo_cartesian representation cardinality must be bounded_many".to_string(),
429            ));
430        }
431        Ok(())
432    }
433}
434
435#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
436#[serde(deny_unknown_fields)]
437pub struct StackFixedRepresentation {
438    pub output_unit_level: EntityUnitLevel,
439    pub cardinality: RepresentationCardinality,
440    pub expected_cardinality: usize,
441    #[serde(default, skip_serializing_if = "Vec::is_empty")]
442    pub component_source_ids: Vec<String>,
443}
444
445impl StackFixedRepresentation {
446    pub fn validate(&self) -> Result<()> {
447        if self.expected_cardinality == 0 {
448            return Err(DagMlError::CampaignValidation(
449                "stack_fixed representation expected_cardinality must be positive".to_string(),
450            ));
451        }
452        if self.cardinality != RepresentationCardinality::OneToMany {
453            return Err(DagMlError::CampaignValidation(
454                "stack_fixed representation cardinality must be one_to_many".to_string(),
455            ));
456        }
457        validate_string_list_entries(
458            "stack_fixed representation component_source_ids",
459            &self.component_source_ids,
460        )?;
461        validate_unique_strings(
462            "stack_fixed representation component_source_ids",
463            &self.component_source_ids,
464        )
465    }
466}
467
468#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
469#[serde(deny_unknown_fields)]
470pub struct StackPaddedMaskedRepresentation {
471    pub output_unit_level: EntityUnitLevel,
472    pub cardinality: RepresentationCardinality,
473    pub expected_cardinality: usize,
474    pub missing_source_policy: RepresentationMissingSourcePolicy,
475    #[serde(default = "default_true")]
476    pub requires_missing_masks: bool,
477    #[serde(default, skip_serializing_if = "Vec::is_empty")]
478    pub component_source_ids: Vec<String>,
479}
480
481impl StackPaddedMaskedRepresentation {
482    pub fn validate(&self) -> Result<()> {
483        if self.expected_cardinality == 0 {
484            return Err(DagMlError::CampaignValidation(
485                "stack_padded_masked representation expected_cardinality must be positive"
486                    .to_string(),
487            ));
488        }
489        if self.cardinality != RepresentationCardinality::BoundedMany {
490            return Err(DagMlError::CampaignValidation(
491                "stack_padded_masked representation cardinality must be bounded_many".to_string(),
492            ));
493        }
494        if !matches!(
495            self.missing_source_policy,
496            RepresentationMissingSourcePolicy::Mask | RepresentationMissingSourcePolicy::Pad
497        ) {
498            return Err(DagMlError::CampaignValidation(
499                "stack_padded_masked representation requires missing_source_policy=mask or pad"
500                    .to_string(),
501            ));
502        }
503        if !self.requires_missing_masks {
504            return Err(DagMlError::CampaignValidation(
505                "stack_padded_masked representation requires missing-mask controller support"
506                    .to_string(),
507            ));
508        }
509        validate_string_list_entries(
510            "stack_padded_masked representation component_source_ids",
511            &self.component_source_ids,
512        )?;
513        validate_unique_strings(
514            "stack_padded_masked representation component_source_ids",
515            &self.component_source_ids,
516        )
517    }
518}
519
520#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
521#[serde(deny_unknown_fields)]
522pub struct RepresentationSampleObservationMapping {
523    pub physical_sample_id: String,
524    pub source_id: String,
525    pub observation_ids: Vec<String>,
526}
527
528impl RepresentationSampleObservationMapping {
529    pub fn validate(&self) -> Result<()> {
530        validate_non_empty(
531            "representation sample observation mapping physical_sample_id",
532            &self.physical_sample_id,
533        )?;
534        validate_non_empty(
535            "representation sample observation mapping source_id",
536            &self.source_id,
537        )?;
538        validate_non_empty_list(
539            "representation sample observation mapping observation_ids",
540            &self.observation_ids,
541        )?;
542        validate_unique_strings(
543            "representation sample observation mapping observation_ids",
544            &self.observation_ids,
545        )
546    }
547}
548
549#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
550#[serde(deny_unknown_fields)]
551pub struct RepresentationComboSelectionRecord {
552    pub combo_unit_id: String,
553    pub physical_sample_id: String,
554    pub component_observation_ids: Vec<String>,
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub seed: Option<u64>,
557}
558
559impl RepresentationComboSelectionRecord {
560    pub fn validate(&self) -> Result<()> {
561        validate_non_empty(
562            "representation combo selection combo_unit_id",
563            &self.combo_unit_id,
564        )?;
565        validate_non_empty(
566            "representation combo selection physical_sample_id",
567            &self.physical_sample_id,
568        )?;
569        validate_non_empty_list(
570            "representation combo selection component_observation_ids",
571            &self.component_observation_ids,
572        )?;
573        validate_unique_strings(
574            "representation combo selection component_observation_ids",
575            &self.component_observation_ids,
576        )
577    }
578}
579
580#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
581#[serde(rename_all = "snake_case")]
582pub enum RepresentationCompatibilitySeverity {
583    Info,
584    Warning,
585    Error,
586}
587
588#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
589#[serde(rename_all = "snake_case")]
590pub enum RepresentationCompatibilityOutcome {
591    Compatible,
592    CompatibleWithFallback,
593    Incompatible,
594}
595
596#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
597#[serde(deny_unknown_fields)]
598pub struct RepresentationCompatibilityReport {
599    pub policy: RepresentationMissingSourcePolicy,
600    pub outcome: RepresentationCompatibilityOutcome,
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    pub fallback_used: Option<String>,
603    #[serde(default, skip_serializing_if = "Option::is_none")]
604    pub warning_severity: Option<RepresentationCompatibilitySeverity>,
605    #[serde(default)]
606    pub affected_source_count: u64,
607    #[serde(default)]
608    pub affected_repetition_count: u64,
609    #[serde(default)]
610    pub affected_sample_count: u64,
611    #[serde(default, skip_serializing_if = "Option::is_none")]
612    pub train_relation_fingerprint: Option<String>,
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub predict_relation_fingerprint: Option<String>,
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub train_unit_count: Option<u64>,
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub predict_unit_count: Option<u64>,
619    #[serde(default)]
620    pub fixed_width_required: bool,
621    #[serde(default)]
622    pub final_reducer_stabilizes_output: bool,
623    #[serde(default)]
624    pub cartesian_combo_count_changed: bool,
625    #[serde(default)]
626    pub late_fusion_branch_delta: bool,
627    #[serde(default, skip_serializing_if = "Vec::is_empty")]
628    pub messages: Vec<String>,
629    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
630    pub metadata: BTreeMap<String, serde_json::Value>,
631}
632
633impl RepresentationCompatibilityReport {
634    pub fn validate(&self) -> Result<()> {
635        validate_optional_non_empty(
636            "representation compatibility fallback_used",
637            &self.fallback_used,
638        )?;
639        if let Some(fingerprint) = &self.train_relation_fingerprint {
640            validate_fingerprint("representation compatibility train relation", fingerprint)?;
641        }
642        if let Some(fingerprint) = &self.predict_relation_fingerprint {
643            validate_fingerprint("representation compatibility predict relation", fingerprint)?;
644        }
645        validate_string_list_entries("representation compatibility messages", &self.messages)?;
646        for key in self.metadata.keys() {
647            if key.trim().is_empty() {
648                return Err(DagMlError::CampaignValidation(
649                    "representation compatibility metadata contains an empty key".to_string(),
650                ));
651            }
652        }
653
654        let affected_total = self
655            .affected_source_count
656            .saturating_add(self.affected_repetition_count)
657            .saturating_add(self.affected_sample_count);
658        let relation_fingerprint_changed = matches!(
659            (
660                self.train_relation_fingerprint.as_deref(),
661                self.predict_relation_fingerprint.as_deref()
662            ),
663            (Some(train), Some(predict)) if train != predict
664        );
665        let unit_count_changed = matches!(
666            (self.train_unit_count, self.predict_unit_count),
667            (Some(train), Some(predict)) if train != predict
668        );
669        if affected_total == 0 {
670            if relation_fingerprint_changed {
671                return Err(DagMlError::CampaignValidation(
672                    "representation compatibility relation fingerprint mismatch requires affected units"
673                        .to_string(),
674                ));
675            }
676            if unit_count_changed {
677                return Err(DagMlError::CampaignValidation(
678                    "representation compatibility unit count mismatch requires affected units"
679                        .to_string(),
680                ));
681            }
682            if self.outcome == RepresentationCompatibilityOutcome::CompatibleWithFallback {
683                return Err(DagMlError::CampaignValidation(
684                    "representation compatibility cannot use fallback when no units are affected"
685                        .to_string(),
686                ));
687            }
688            if self.warning_severity.is_some() {
689                return Err(DagMlError::CampaignValidation(
690                    "representation compatibility warning_severity requires affected units"
691                        .to_string(),
692                ));
693            }
694        } else if self.policy == RepresentationMissingSourcePolicy::Strict {
695            if self.outcome != RepresentationCompatibilityOutcome::Incompatible {
696                return Err(DagMlError::CampaignValidation(
697                    "strict representation compatibility with affected units must be incompatible"
698                        .to_string(),
699                ));
700            }
701            if self.fallback_used.is_some() {
702                return Err(DagMlError::CampaignValidation(
703                    "strict representation compatibility cannot declare fallback_used".to_string(),
704                ));
705            }
706        } else {
707            if self.warning_severity.is_none() {
708                return Err(DagMlError::CampaignValidation(
709                    "non-strict representation compatibility with affected units requires warning_severity"
710                        .to_string(),
711                ));
712            }
713            if self.outcome == RepresentationCompatibilityOutcome::Compatible {
714                return Err(DagMlError::CampaignValidation(
715                    "representation compatibility with affected units cannot be compatible"
716                        .to_string(),
717                ));
718            }
719            if self.outcome == RepresentationCompatibilityOutcome::CompatibleWithFallback
720                && self.fallback_used.is_none()
721            {
722                return Err(DagMlError::CampaignValidation(
723                    "compatible_with_fallback representation compatibility requires fallback_used"
724                        .to_string(),
725                ));
726            }
727        }
728
729        if self.outcome == RepresentationCompatibilityOutcome::Incompatible
730            && self.fallback_used.is_some()
731        {
732            return Err(DagMlError::CampaignValidation(
733                "incompatible representation compatibility cannot declare fallback_used"
734                    .to_string(),
735            ));
736        }
737
738        if self.fixed_width_required && unit_count_changed && !self.allows_fixed_width_fallback() {
739            if self.outcome == RepresentationCompatibilityOutcome::Incompatible {
740                return Ok(());
741            }
742            return Err(DagMlError::CampaignValidation(
743                "fixed-width representation compatibility mismatch requires mask or pad policy/fallback"
744                    .to_string(),
745            ));
746        }
747        if self.cartesian_combo_count_changed && !self.final_reducer_stabilizes_output {
748            if self.outcome == RepresentationCompatibilityOutcome::Incompatible {
749                return Ok(());
750            }
751            return Err(DagMlError::CampaignValidation(
752                "cartesian representation combo count may vary only when final reducer stabilizes output"
753                    .to_string(),
754            ));
755        }
756        if self.late_fusion_branch_delta && !self.allows_late_fusion_delta() {
757            if self.outcome == RepresentationCompatibilityOutcome::Incompatible {
758                return Ok(());
759            }
760            return Err(DagMlError::CampaignValidation(
761                "late-fusion source deltas require an explicit drop/impute/mask/partial-model/pad policy or fallback"
762                    .to_string(),
763            ));
764        }
765        Ok(())
766    }
767
768    fn allows_fixed_width_fallback(&self) -> bool {
769        matches!(
770            self.policy,
771            RepresentationMissingSourcePolicy::Mask | RepresentationMissingSourcePolicy::Pad
772        ) || self
773            .fallback_used
774            .as_deref()
775            .is_some_and(|fallback| matches!(fallback, "mask" | "pad"))
776    }
777
778    fn allows_late_fusion_delta(&self) -> bool {
779        matches!(
780            self.policy,
781            RepresentationMissingSourcePolicy::DropIncomplete
782                | RepresentationMissingSourcePolicy::ImputeDeclared
783                | RepresentationMissingSourcePolicy::Mask
784                | RepresentationMissingSourcePolicy::PartialModel
785                | RepresentationMissingSourcePolicy::Pad
786        ) || self.fallback_used.as_deref().is_some_and(|fallback| {
787            matches!(
788                fallback,
789                "drop_incomplete" | "impute_declared" | "mask" | "partial_model" | "pad"
790            )
791        })
792    }
793}
794
795#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
796#[serde(deny_unknown_fields)]
797pub struct RepresentationReplayManifest {
798    pub manifest_id: String,
799    pub representation_plan: RepresentationPlan,
800    #[serde(default, skip_serializing_if = "Option::is_none")]
801    pub combination_plan: Option<CombinationPlan>,
802    pub output_unit_level: EntityUnitLevel,
803    #[serde(default, skip_serializing_if = "Option::is_none")]
804    pub output_representation: Option<String>,
805    #[serde(default, skip_serializing_if = "Option::is_none")]
806    pub relation_fingerprint: Option<String>,
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    pub feature_schema_fingerprint: Option<String>,
809    #[serde(default, skip_serializing_if = "Option::is_none")]
810    pub final_reduction_id: Option<String>,
811    #[serde(default, skip_serializing_if = "Vec::is_empty")]
812    pub sample_observation_mapping: Vec<RepresentationSampleObservationMapping>,
813    #[serde(default, skip_serializing_if = "Vec::is_empty")]
814    pub combo_selection: Vec<RepresentationComboSelectionRecord>,
815    #[serde(default, skip_serializing_if = "Vec::is_empty")]
816    pub qc_policy_refs: Vec<String>,
817    #[serde(default, skip_serializing_if = "Vec::is_empty")]
818    pub outlier_policy_refs: Vec<String>,
819    #[serde(default, skip_serializing_if = "Option::is_none")]
820    pub missing_source_policy: Option<RepresentationMissingSourcePolicy>,
821    #[serde(default, skip_serializing_if = "Option::is_none")]
822    pub missing_repetition_policy: Option<RepresentationMissingSourcePolicy>,
823    #[serde(default, skip_serializing_if = "Option::is_none")]
824    pub prediction_representation: Option<String>,
825    #[serde(default, skip_serializing_if = "Option::is_none")]
826    pub final_output_unit_level: Option<EntityUnitLevel>,
827    #[serde(default, skip_serializing_if = "Option::is_none")]
828    pub train_compatibility: Option<RepresentationCompatibilityReport>,
829    #[serde(default, skip_serializing_if = "Option::is_none")]
830    pub predict_compatibility: Option<RepresentationCompatibilityReport>,
831    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
832    pub metadata: BTreeMap<String, serde_json::Value>,
833}
834
835impl RepresentationReplayManifest {
836    pub fn validate(&self) -> Result<()> {
837        validate_non_empty("representation replay manifest_id", &self.manifest_id)?;
838        self.representation_plan.validate()?;
839        if let Some(combination_plan) = &self.combination_plan {
840            combination_plan.validate()?;
841        }
842        if self.output_unit_level != self.representation_plan.output_unit_level() {
843            return Err(DagMlError::CampaignValidation(
844                "representation replay output_unit_level must match representation_plan"
845                    .to_string(),
846            ));
847        }
848        validate_optional_non_empty(
849            "representation replay output_representation",
850            &self.output_representation,
851        )?;
852        validate_optional_non_empty(
853            "representation replay final_reduction_id",
854            &self.final_reduction_id,
855        )?;
856        validate_string_list_entries("representation replay qc_policy_refs", &self.qc_policy_refs)?;
857        validate_unique_strings("representation replay qc_policy_refs", &self.qc_policy_refs)?;
858        validate_string_list_entries(
859            "representation replay outlier_policy_refs",
860            &self.outlier_policy_refs,
861        )?;
862        validate_unique_strings(
863            "representation replay outlier_policy_refs",
864            &self.outlier_policy_refs,
865        )?;
866        validate_optional_non_empty(
867            "representation replay prediction_representation",
868            &self.prediction_representation,
869        )?;
870        let mut sample_source_pairs = BTreeSet::new();
871        for mapping in &self.sample_observation_mapping {
872            mapping.validate()?;
873            if !sample_source_pairs.insert((
874                mapping.physical_sample_id.as_str(),
875                mapping.source_id.as_str(),
876            )) {
877                return Err(DagMlError::CampaignValidation(format!(
878                    "representation replay sample_observation_mapping contains duplicate physical_sample_id/source_id `{}`/`{}`",
879                    mapping.physical_sample_id, mapping.source_id
880                )));
881            }
882        }
883        let mut combo_unit_ids = BTreeSet::new();
884        for record in &self.combo_selection {
885            record.validate()?;
886            if !combo_unit_ids.insert(record.combo_unit_id.as_str()) {
887                return Err(DagMlError::CampaignValidation(format!(
888                    "representation replay combo_selection contains duplicate combo_unit_id `{}`",
889                    record.combo_unit_id
890                )));
891            }
892        }
893        if let Some(report) = &self.train_compatibility {
894            report.validate()?;
895        }
896        if let Some(report) = &self.predict_compatibility {
897            report.validate()?;
898        }
899        if let Some(fingerprint) = &self.relation_fingerprint {
900            validate_fingerprint("representation replay relation", fingerprint)?;
901        }
902        if let Some(fingerprint) = &self.feature_schema_fingerprint {
903            validate_fingerprint("representation replay feature schema", fingerprint)?;
904        }
905        for key in self.metadata.keys() {
906            if key.trim().is_empty() {
907                return Err(DagMlError::CampaignValidation(
908                    "representation replay metadata contains an empty key".to_string(),
909                ));
910            }
911        }
912        Ok(())
913    }
914}
915
916#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
917#[serde(deny_unknown_fields)]
918pub struct ModelInputFusionPolicy {
919    pub mode: ModelInputFusionMode,
920    #[serde(default)]
921    pub alignment: Option<String>,
922    #[serde(default)]
923    pub adapter_id: Option<String>,
924    #[serde(default, skip_serializing_if = "Option::is_none")]
925    pub representation_plan: Option<RepresentationPlan>,
926    #[serde(default)]
927    pub params: BTreeMap<String, serde_json::Value>,
928}
929
930impl ModelInputFusionPolicy {
931    pub fn validate(&self) -> Result<()> {
932        if self
933            .alignment
934            .as_ref()
935            .is_some_and(|alignment| alignment.trim().is_empty())
936        {
937            return Err(DagMlError::CampaignValidation(
938                "model input fusion policy has empty alignment".to_string(),
939            ));
940        }
941        if self
942            .adapter_id
943            .as_ref()
944            .is_some_and(|adapter_id| adapter_id.trim().is_empty())
945        {
946            return Err(DagMlError::CampaignValidation(
947                "model input fusion policy has empty adapter_id".to_string(),
948            ));
949        }
950        if self.mode == ModelInputFusionMode::Custom && self.adapter_id.is_none() {
951            return Err(DagMlError::CampaignValidation(
952                "custom model input fusion policy requires adapter_id".to_string(),
953            ));
954        }
955        if let Some(representation_plan) = &self.representation_plan {
956            representation_plan.validate()?;
957        }
958        for key in self.params.keys() {
959            if key.trim().is_empty() {
960                return Err(DagMlError::CampaignValidation(
961                    "model input fusion policy contains an empty param key".to_string(),
962                ));
963            }
964        }
965        Ok(())
966    }
967}
968
969#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
970#[serde(deny_unknown_fields)]
971pub struct ModelInputPortSpec {
972    pub name: String,
973    pub accepted_representations: Vec<String>,
974    pub accepted_types: Vec<String>,
975    #[serde(default)]
976    pub rank: Option<u32>,
977    #[serde(default)]
978    pub multi_source: bool,
979    #[serde(default)]
980    pub optional: bool,
981    #[serde(default)]
982    pub metadata: BTreeMap<String, serde_json::Value>,
983}
984
985impl ModelInputPortSpec {
986    pub fn validate(&self) -> Result<()> {
987        validate_non_empty("model input port name", &self.name)?;
988        validate_non_empty_list(
989            "model input port accepted_representations",
990            &self.accepted_representations,
991        )?;
992        validate_non_empty_list("model input port accepted_types", &self.accepted_types)?;
993        validate_unique_strings(
994            "model input port accepted_representations",
995            &self.accepted_representations,
996        )?;
997        validate_unique_strings("model input port accepted_types", &self.accepted_types)?;
998        if self.rank.is_some_and(|rank| rank > 16) {
999            return Err(DagMlError::CampaignValidation(format!(
1000                "model input port `{}` rank must be <= 16",
1001                self.name
1002            )));
1003        }
1004        for key in self.metadata.keys() {
1005            if key.trim().is_empty() {
1006                return Err(DagMlError::CampaignValidation(format!(
1007                    "model input port `{}` contains an empty metadata key",
1008                    self.name
1009                )));
1010            }
1011        }
1012        Ok(())
1013    }
1014}
1015
1016#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1017#[serde(deny_unknown_fields)]
1018pub struct ModelInputSpec {
1019    #[serde(default = "default_model_input_spec_schema_version")]
1020    pub schema_version: u32,
1021    pub ports: Vec<ModelInputPortSpec>,
1022    #[serde(default)]
1023    pub default_fusion: Option<ModelInputFusionPolicy>,
1024    #[serde(default, skip_serializing_if = "Option::is_none")]
1025    pub fit_influence_policy: Option<FitInfluencePolicy>,
1026    #[serde(default)]
1027    pub metadata: BTreeMap<String, serde_json::Value>,
1028}
1029
1030impl ModelInputSpec {
1031    pub fn validate(&self) -> Result<()> {
1032        if self.schema_version != MODEL_INPUT_SPEC_SCHEMA_VERSION {
1033            return Err(DagMlError::CampaignValidation(format!(
1034                "model input spec uses unsupported schema_version {}, expected {}",
1035                self.schema_version, MODEL_INPUT_SPEC_SCHEMA_VERSION
1036            )));
1037        }
1038        if self.ports.is_empty() {
1039            return Err(DagMlError::CampaignValidation(
1040                "model input spec must declare at least one port".to_string(),
1041            ));
1042        }
1043        let mut names = BTreeSet::new();
1044        for port in &self.ports {
1045            port.validate()?;
1046            if !names.insert(port.name.as_str()) {
1047                return Err(DagMlError::CampaignValidation(format!(
1048                    "model input spec contains duplicate port `{}`",
1049                    port.name
1050                )));
1051            }
1052        }
1053        if let Some(default_fusion) = &self.default_fusion {
1054            default_fusion.validate()?;
1055        }
1056        for key in self.metadata.keys() {
1057            if key.trim().is_empty() {
1058                return Err(DagMlError::CampaignValidation(
1059                    "model input spec contains an empty metadata key".to_string(),
1060                ));
1061            }
1062        }
1063        Ok(())
1064    }
1065}
1066
1067#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
1068#[serde(rename_all = "snake_case")]
1069pub enum DataPlanStepKind {
1070    Materialize,
1071    Adapt,
1072    Align,
1073    Join,
1074    Collate,
1075}
1076
1077#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1078#[serde(deny_unknown_fields)]
1079pub struct DataPlanStep {
1080    pub kind: DataPlanStepKind,
1081    #[serde(default)]
1082    pub inputs: Vec<String>,
1083    pub output: String,
1084    #[serde(default)]
1085    pub adapter_id: Option<String>,
1086    #[serde(default)]
1087    pub params: BTreeMap<String, serde_json::Value>,
1088}
1089
1090impl DataPlanStep {
1091    pub fn validate(&self, previous_outputs: &BTreeSet<String>) -> Result<()> {
1092        validate_non_empty("data plan step output", &self.output)?;
1093        if self.kind != DataPlanStepKind::Materialize && self.inputs.is_empty() {
1094            return Err(DagMlError::CampaignValidation(format!(
1095                "data plan step `{}` requires at least one input",
1096                self.output
1097            )));
1098        }
1099        for (index, input) in self.inputs.iter().enumerate() {
1100            validate_non_empty("data plan step input", input)?;
1101            if self.kind != DataPlanStepKind::Materialize && !previous_outputs.contains(input) {
1102                return Err(DagMlError::CampaignValidation(format!(
1103                    "data plan step `{}` input #{index} references `{input}` before it is produced",
1104                    self.output
1105                )));
1106            }
1107        }
1108        if self
1109            .adapter_id
1110            .as_ref()
1111            .is_some_and(|adapter_id| adapter_id.trim().is_empty())
1112        {
1113            return Err(DagMlError::CampaignValidation(format!(
1114                "data plan step `{}` has empty adapter_id",
1115                self.output
1116            )));
1117        }
1118        for key in self.params.keys() {
1119            if key.trim().is_empty() {
1120                return Err(DagMlError::CampaignValidation(format!(
1121                    "data plan step `{}` contains an empty param key",
1122                    self.output
1123                )));
1124            }
1125        }
1126        Ok(())
1127    }
1128}
1129
1130#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1131#[serde(deny_unknown_fields)]
1132pub struct DataPlan {
1133    #[serde(default = "default_data_plan_schema_version")]
1134    pub schema_version: u32,
1135    pub id: String,
1136    pub steps: Vec<DataPlanStep>,
1137    pub output_ports: BTreeMap<String, String>,
1138    #[serde(default)]
1139    pub warnings: Vec<String>,
1140    #[serde(default)]
1141    pub requires_user_choice: Vec<String>,
1142    #[serde(default)]
1143    pub metadata: BTreeMap<String, serde_json::Value>,
1144}
1145
1146impl DataPlan {
1147    pub fn validate(&self) -> Result<()> {
1148        if self.schema_version != DATA_PLAN_SCHEMA_VERSION {
1149            return Err(DagMlError::CampaignValidation(format!(
1150                "data plan uses unsupported schema_version {}, expected {}",
1151                self.schema_version, DATA_PLAN_SCHEMA_VERSION
1152            )));
1153        }
1154        validate_non_empty("data plan id", &self.id)?;
1155        if self.steps.is_empty() {
1156            return Err(DagMlError::CampaignValidation(format!(
1157                "data plan `{}` must contain at least one step",
1158                self.id
1159            )));
1160        }
1161        let mut outputs = BTreeSet::new();
1162        for step in &self.steps {
1163            step.validate(&outputs)?;
1164            if !outputs.insert(step.output.clone()) {
1165                return Err(DagMlError::CampaignValidation(format!(
1166                    "data plan `{}` contains duplicate step output `{}`",
1167                    self.id, step.output
1168                )));
1169            }
1170        }
1171        if self.output_ports.is_empty() {
1172            return Err(DagMlError::CampaignValidation(format!(
1173                "data plan `{}` must declare at least one output port",
1174                self.id
1175            )));
1176        }
1177        for (port_name, output) in &self.output_ports {
1178            validate_non_empty("data plan output port", port_name)?;
1179            validate_non_empty("data plan output reference", output)?;
1180            if !outputs.contains(output) {
1181                return Err(DagMlError::CampaignValidation(format!(
1182                    "data plan `{}` output port `{port_name}` references unknown output `{output}`",
1183                    self.id
1184                )));
1185            }
1186        }
1187        validate_string_list_entries("data plan warnings", &self.warnings)?;
1188        validate_string_list_entries("data plan requires_user_choice", &self.requires_user_choice)?;
1189        for key in self.metadata.keys() {
1190            if key.trim().is_empty() {
1191                return Err(DagMlError::CampaignValidation(format!(
1192                    "data plan `{}` contains an empty metadata key",
1193                    self.id
1194                )));
1195            }
1196        }
1197        Ok(())
1198    }
1199}
1200
1201#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1202pub struct DataViewPolicy {
1203    #[serde(default = "default_fit_partition")]
1204    pub fit_partition: DataRequestPartition,
1205    #[serde(default = "default_predict_partition")]
1206    pub predict_partition: DataRequestPartition,
1207    #[serde(default)]
1208    pub include_augmented_train: bool,
1209    #[serde(default)]
1210    pub include_augmented_validation: bool,
1211    #[serde(default)]
1212    pub include_excluded: bool,
1213    #[serde(default = "default_true")]
1214    pub require_sample_ids: bool,
1215    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1216    pub unsafe_flags: BTreeSet<String>,
1217}
1218
1219impl Default for DataViewPolicy {
1220    fn default() -> Self {
1221        Self {
1222            fit_partition: DataRequestPartition::FoldTrain,
1223            predict_partition: DataRequestPartition::FoldValidation,
1224            include_augmented_train: true,
1225            include_augmented_validation: false,
1226            include_excluded: false,
1227            require_sample_ids: true,
1228            unsafe_flags: BTreeSet::new(),
1229        }
1230    }
1231}
1232
1233impl DataViewPolicy {
1234    pub const ALLOW_FIT_CV_FULL_TRAIN_VIEW: &'static str = "allow_fit_cv_full_train_view";
1235    pub const ALLOW_FIT_CV_VALIDATION_VIEW: &'static str = "allow_fit_cv_validation_view";
1236    pub const ALLOW_AUGMENTED_VALIDATION_VIEW: &'static str = "allow_augmented_validation_view";
1237    pub const ALLOW_EXCLUDED_ROWS: &'static str = "allow_excluded_rows";
1238
1239    pub fn validate(&self) -> Result<()> {
1240        for unsafe_flag in &self.unsafe_flags {
1241            if unsafe_flag.trim().is_empty() {
1242                return Err(DagMlError::CampaignValidation(
1243                    "data view policy contains an empty unsafe flag".to_string(),
1244                ));
1245            }
1246        }
1247        match self.fit_partition {
1248            DataRequestPartition::FoldTrain => {}
1249            DataRequestPartition::FullTrain
1250                if self
1251                    .unsafe_flags
1252                    .contains(Self::ALLOW_FIT_CV_FULL_TRAIN_VIEW) => {}
1253            DataRequestPartition::FoldValidation
1254                if self
1255                    .unsafe_flags
1256                    .contains(Self::ALLOW_FIT_CV_VALIDATION_VIEW) => {}
1257            DataRequestPartition::FullTrain => {
1258                return Err(DagMlError::CampaignValidation(
1259                    "data view policy fit_partition=full_train would leak validation rows during FIT_CV; add explicit unsafe flag allow_fit_cv_full_train_view".to_string(),
1260                ));
1261            }
1262            DataRequestPartition::FoldValidation => {
1263                return Err(DagMlError::CampaignValidation(
1264                    "data view policy fit_partition=fold_validation would train on validation rows during FIT_CV; add explicit unsafe flag allow_fit_cv_validation_view".to_string(),
1265                ));
1266            }
1267            DataRequestPartition::Predict => {
1268                return Err(DagMlError::CampaignValidation(
1269                    "data view policy fit_partition=predict is not valid for FIT_CV".to_string(),
1270                ));
1271            }
1272        }
1273        match self.predict_partition {
1274            DataRequestPartition::FoldValidation | DataRequestPartition::Predict => {}
1275            DataRequestPartition::FoldTrain | DataRequestPartition::FullTrain => {
1276                return Err(DagMlError::CampaignValidation(format!(
1277                    "data view policy predict_partition={:?} is not valid for validation/predict views",
1278                    self.predict_partition
1279                )));
1280            }
1281        }
1282        if self.include_augmented_validation
1283            && !self
1284                .unsafe_flags
1285                .contains(Self::ALLOW_AUGMENTED_VALIDATION_VIEW)
1286        {
1287            return Err(DagMlError::CampaignValidation(
1288                "data view policy include_augmented_validation=true can leak augmented validation/test rows; add explicit unsafe flag allow_augmented_validation_view".to_string(),
1289            ));
1290        }
1291        if self.include_excluded && !self.unsafe_flags.contains(Self::ALLOW_EXCLUDED_ROWS) {
1292            return Err(DagMlError::CampaignValidation(
1293                "data view policy include_excluded=true requires explicit unsafe flag allow_excluded_rows".to_string(),
1294            ));
1295        }
1296        Ok(())
1297    }
1298}
1299
1300fn default_fit_partition() -> DataRequestPartition {
1301    DataRequestPartition::FoldTrain
1302}
1303
1304fn default_predict_partition() -> DataRequestPartition {
1305    DataRequestPartition::FoldValidation
1306}
1307
1308fn default_true() -> bool {
1309    true
1310}
1311
1312#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1313pub struct DataBinding {
1314    pub node_id: NodeId,
1315    pub input_name: String,
1316    pub request_id: String,
1317    pub schema_fingerprint: String,
1318    pub plan_fingerprint: String,
1319    #[serde(default)]
1320    pub relation_fingerprint: Option<String>,
1321    pub output_representation: String,
1322    #[serde(default)]
1323    pub feature_set_id: Option<String>,
1324    #[serde(default)]
1325    pub source_ids: Vec<String>,
1326    #[serde(default)]
1327    pub require_relations: bool,
1328    #[serde(default)]
1329    pub view_policy: DataViewPolicy,
1330    #[serde(default)]
1331    pub metadata: BTreeMap<String, serde_json::Value>,
1332}
1333
1334impl DataBinding {
1335    pub fn validate(&self) -> Result<()> {
1336        self.view_policy.validate()?;
1337        if self.input_name.trim().is_empty() {
1338            return Err(DagMlError::CampaignValidation(format!(
1339                "data binding for `{}` has empty input_name",
1340                self.node_id
1341            )));
1342        }
1343        if self.request_id.trim().is_empty() {
1344            return Err(DagMlError::CampaignValidation(format!(
1345                "data binding `{}` on `{}` has empty request_id",
1346                self.input_name, self.node_id
1347            )));
1348        }
1349        validate_fingerprint("schema", &self.schema_fingerprint)?;
1350        validate_fingerprint("plan", &self.plan_fingerprint)?;
1351        if let Some(relation_fingerprint) = &self.relation_fingerprint {
1352            validate_fingerprint("relation", relation_fingerprint)?;
1353        } else if self.require_relations {
1354            return Err(DagMlError::CampaignValidation(format!(
1355                "data binding `{}` on `{}` requires relations but has no relation_fingerprint",
1356                self.input_name, self.node_id
1357            )));
1358        }
1359        if self.output_representation.trim().is_empty() {
1360            return Err(DagMlError::CampaignValidation(format!(
1361                "data binding `{}` on `{}` has empty output_representation",
1362                self.input_name, self.node_id
1363            )));
1364        }
1365        if let Some(feature_set_id) = &self.feature_set_id {
1366            if feature_set_id.trim().is_empty() {
1367                return Err(DagMlError::CampaignValidation(format!(
1368                    "data binding `{}` on `{}` has empty feature_set_id",
1369                    self.input_name, self.node_id
1370                )));
1371            }
1372        }
1373        for source_id in &self.source_ids {
1374            if source_id.trim().is_empty() {
1375                return Err(DagMlError::CampaignValidation(format!(
1376                    "data binding `{}` on `{}` has empty source id",
1377                    self.input_name, self.node_id
1378                )));
1379            }
1380        }
1381        validate_unique_strings(
1382            &format!(
1383                "data binding `{}` on `{}` source_ids",
1384                self.input_name, self.node_id
1385            ),
1386            &self.source_ids,
1387        )?;
1388        validate_source_index_metadata(
1389            &format!(
1390                "data binding `{}` on `{}` metadata.source_index",
1391                self.input_name, self.node_id
1392            ),
1393            self.metadata.get(SOURCE_INDEX_METADATA_KEY),
1394            &self.source_ids,
1395        )?;
1396        Ok(())
1397    }
1398
1399    pub fn feature_set_id(&self) -> &str {
1400        self.feature_set_id.as_deref().unwrap_or(&self.input_name)
1401    }
1402
1403    pub fn validate_envelope(&self, envelope: &ExternalDataPlanEnvelope) -> Result<()> {
1404        self.validate()?;
1405        envelope.validate()?;
1406        if self.schema_fingerprint != envelope.schema_fingerprint {
1407            return Err(DagMlError::CampaignValidation(format!(
1408                "data binding `{}` on `{}` schema fingerprint mismatch",
1409                self.input_name, self.node_id
1410            )));
1411        }
1412        if self.plan_fingerprint != envelope.plan_fingerprint {
1413            return Err(DagMlError::CampaignValidation(format!(
1414                "data binding `{}` on `{}` plan fingerprint mismatch",
1415                self.input_name, self.node_id
1416            )));
1417        }
1418        if self.relation_fingerprint != envelope.relation_fingerprint {
1419            return Err(DagMlError::CampaignValidation(format!(
1420                "data binding `{}` on `{}` relation fingerprint mismatch",
1421                self.input_name, self.node_id
1422            )));
1423        }
1424        if self.require_relations && envelope.coordinator_relations.is_none() {
1425            return Err(DagMlError::CampaignValidation(format!(
1426                "data binding `{}` on `{}` requires coordinator relations",
1427                self.input_name, self.node_id
1428            )));
1429        }
1430        Ok(())
1431    }
1432}
1433
1434pub(crate) fn validate_source_index_metadata(
1435    label: &str,
1436    value: Option<&serde_json::Value>,
1437    expected_sources: &[String],
1438) -> Result<()> {
1439    let Some(value) = value else {
1440        return Ok(());
1441    };
1442    let Some(source_index) = value.as_object() else {
1443        return Err(DagMlError::CampaignValidation(format!(
1444            "{label} must be an object mapping source id to feature-axis block index"
1445        )));
1446    };
1447    if source_index.is_empty() {
1448        return Err(DagMlError::CampaignValidation(format!(
1449            "{label} must not be empty"
1450        )));
1451    }
1452    let mut seen_indices = BTreeSet::new();
1453    for (source_id, index_value) in source_index {
1454        if source_id.trim().is_empty() {
1455            return Err(DagMlError::CampaignValidation(format!(
1456                "{label} contains an empty source id"
1457            )));
1458        }
1459        let Some(index) = index_value.as_u64() else {
1460            return Err(DagMlError::CampaignValidation(format!(
1461                "{label} entry `{source_id}` must be a non-negative integer"
1462            )));
1463        };
1464        if !seen_indices.insert(index) {
1465            return Err(DagMlError::CampaignValidation(format!(
1466                "{label} contains duplicate feature-axis block index `{index}`"
1467            )));
1468        }
1469    }
1470    if !expected_sources.is_empty() {
1471        let actual = source_index.keys().cloned().collect::<BTreeSet<_>>();
1472        let expected = expected_sources.iter().cloned().collect::<BTreeSet<_>>();
1473        if actual != expected {
1474            return Err(DagMlError::CampaignValidation(format!(
1475                "{label} keys must match data binding source_ids"
1476            )));
1477        }
1478    }
1479    Ok(())
1480}
1481
1482#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1483pub struct ExternalDataPlanEnvelope {
1484    #[serde(default = "default_external_data_plan_envelope_schema_version")]
1485    pub schema_version: u32,
1486    pub schema_fingerprint: String,
1487    pub plan_fingerprint: String,
1488    #[serde(default)]
1489    pub relation_fingerprint: Option<String>,
1490    #[serde(default)]
1491    pub coordinator_relations: Option<SampleRelationSet>,
1492}
1493
1494impl ExternalDataPlanEnvelope {
1495    pub fn validate(&self) -> Result<()> {
1496        if self.schema_version != EXTERNAL_DATA_PLAN_ENVELOPE_SCHEMA_VERSION {
1497            return Err(DagMlError::CampaignValidation(format!(
1498                "external data-plan envelope uses unsupported schema_version {}, expected {}",
1499                self.schema_version, EXTERNAL_DATA_PLAN_ENVELOPE_SCHEMA_VERSION
1500            )));
1501        }
1502        validate_fingerprint("schema", &self.schema_fingerprint)?;
1503        validate_fingerprint("plan", &self.plan_fingerprint)?;
1504        if let Some(relation_fingerprint) = &self.relation_fingerprint {
1505            validate_fingerprint("relation", relation_fingerprint)?;
1506            if self.coordinator_relations.is_none() {
1507                return Err(DagMlError::CampaignValidation(
1508                    "relation_fingerprint requires coordinator_relations".to_string(),
1509                ));
1510            }
1511        }
1512        if let Some(relations) = &self.coordinator_relations {
1513            relations.validate()?;
1514        }
1515        Ok(())
1516    }
1517}
1518
1519pub fn validate_data_binding_envelope(
1520    binding: &DataBinding,
1521    envelope: &ExternalDataPlanEnvelope,
1522) -> Result<()> {
1523    binding.validate_envelope(envelope)
1524}
1525
1526#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
1527struct DataEnvelopeKey {
1528    schema_fingerprint: String,
1529    plan_fingerprint: String,
1530    relation_fingerprint: Option<String>,
1531}
1532
1533impl DataEnvelopeKey {
1534    fn from_binding(binding: &DataBinding) -> Self {
1535        Self {
1536            schema_fingerprint: binding.schema_fingerprint.clone(),
1537            plan_fingerprint: binding.plan_fingerprint.clone(),
1538            relation_fingerprint: binding.relation_fingerprint.clone(),
1539        }
1540    }
1541
1542    fn from_envelope(envelope: &ExternalDataPlanEnvelope) -> Self {
1543        Self {
1544            schema_fingerprint: envelope.schema_fingerprint.clone(),
1545            plan_fingerprint: envelope.plan_fingerprint.clone(),
1546            relation_fingerprint: envelope.relation_fingerprint.clone(),
1547        }
1548    }
1549}
1550
1551#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1552pub struct DataHandleRecord {
1553    pub handle: HandleRef,
1554    pub run_id: RunId,
1555    pub node_id: NodeId,
1556    pub input_name: String,
1557    pub phase: Phase,
1558    pub variant_id: Option<VariantId>,
1559    pub fold_id: Option<FoldId>,
1560    pub request_id: String,
1561    pub schema_fingerprint: String,
1562    pub plan_fingerprint: String,
1563    pub relation_fingerprint: Option<String>,
1564    pub output_representation: String,
1565    #[serde(default)]
1566    pub feature_set_id: Option<String>,
1567    #[serde(default)]
1568    pub source_ids: Vec<String>,
1569    pub relation_record_count: Option<usize>,
1570}
1571
1572#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1573pub struct DataViewHandleRecord {
1574    pub handle: HandleRef,
1575    pub parent_handle: HandleRef,
1576    pub run_id: RunId,
1577    pub node_id: NodeId,
1578    pub input_name: String,
1579    pub phase: Phase,
1580    pub variant_id: Option<VariantId>,
1581    pub fold_id: Option<FoldId>,
1582    pub request_id: String,
1583    pub feature_set_id: String,
1584    pub view: DataProviderViewSpec,
1585}
1586
1587#[derive(Debug)]
1588pub struct InMemoryDataProvider {
1589    owner_controller: ControllerId,
1590    envelopes: BTreeMap<DataEnvelopeKey, ExternalDataPlanEnvelope>,
1591    next_handle: RefCell<u64>,
1592    records: RefCell<BTreeMap<u64, DataHandleRecord>>,
1593    view_records: RefCell<BTreeMap<u64, DataViewHandleRecord>>,
1594}
1595
1596impl InMemoryDataProvider {
1597    pub fn new(owner_controller: ControllerId) -> Self {
1598        Self {
1599            owner_controller,
1600            envelopes: BTreeMap::new(),
1601            next_handle: RefCell::new(1),
1602            records: RefCell::new(BTreeMap::new()),
1603            view_records: RefCell::new(BTreeMap::new()),
1604        }
1605    }
1606
1607    pub fn with_envelope(
1608        owner_controller: ControllerId,
1609        envelope: ExternalDataPlanEnvelope,
1610    ) -> Result<Self> {
1611        let mut provider = Self::new(owner_controller);
1612        provider.register_envelope(envelope)?;
1613        Ok(provider)
1614    }
1615
1616    pub fn register_envelope(&mut self, envelope: ExternalDataPlanEnvelope) -> Result<()> {
1617        envelope.validate()?;
1618        let key = DataEnvelopeKey::from_envelope(&envelope);
1619        if let Some(existing) = self.envelopes.get(&key) {
1620            if existing == &envelope {
1621                return Ok(());
1622            }
1623            return Err(DagMlError::RuntimeValidation(
1624                "duplicate external data-plan envelope with different payload".to_string(),
1625            ));
1626        }
1627        self.envelopes.insert(key, envelope);
1628        Ok(())
1629    }
1630
1631    pub fn handle_record(&self, handle: u64) -> Option<DataHandleRecord> {
1632        self.records.borrow().get(&handle).cloned()
1633    }
1634
1635    pub fn handle_records(&self) -> Vec<DataHandleRecord> {
1636        self.records.borrow().values().cloned().collect()
1637    }
1638
1639    pub fn view_record(&self, handle: u64) -> Option<DataViewHandleRecord> {
1640        self.view_records.borrow().get(&handle).cloned()
1641    }
1642
1643    pub fn view_records(&self) -> Vec<DataViewHandleRecord> {
1644        self.view_records.borrow().values().cloned().collect()
1645    }
1646
1647    fn next_handle(&self) -> u64 {
1648        let mut next = self.next_handle.borrow_mut();
1649        let handle = *next;
1650        *next += 1;
1651        handle
1652    }
1653}
1654
1655impl RuntimeDataProvider for InMemoryDataProvider {
1656    fn materialize(&self, request: &DataMaterializationRequest) -> Result<HandleRef> {
1657        if request.node_id != request.binding.node_id {
1658            return Err(DagMlError::RuntimeValidation(format!(
1659                "data materialization request node `{}` does not match binding node `{}`",
1660                request.node_id, request.binding.node_id
1661            )));
1662        }
1663        if request.input_name != request.binding.input_name {
1664            return Err(DagMlError::RuntimeValidation(format!(
1665                "data materialization request input `{}` does not match binding input `{}`",
1666                request.input_name, request.binding.input_name
1667            )));
1668        }
1669        let envelope = self
1670            .envelopes
1671            .get(&DataEnvelopeKey::from_binding(&request.binding))
1672            .ok_or_else(|| {
1673                DagMlError::RuntimeValidation(format!(
1674                    "no external data-plan envelope registered for binding `{}` on `{}`",
1675                    request.binding.input_name, request.binding.node_id
1676                ))
1677            })?;
1678        request.binding.validate_envelope(envelope)?;
1679
1680        let handle = HandleRef {
1681            handle: self.next_handle(),
1682            kind: HandleKind::Data,
1683            owner_controller: self.owner_controller.clone(),
1684        };
1685        let record = DataHandleRecord {
1686            handle: handle.clone(),
1687            run_id: request.run_id.clone(),
1688            node_id: request.node_id.clone(),
1689            input_name: request.input_name.clone(),
1690            phase: request.phase,
1691            variant_id: request.variant_id.clone(),
1692            fold_id: request.fold_id.clone(),
1693            request_id: request.binding.request_id.clone(),
1694            schema_fingerprint: request.binding.schema_fingerprint.clone(),
1695            plan_fingerprint: request.binding.plan_fingerprint.clone(),
1696            relation_fingerprint: request.binding.relation_fingerprint.clone(),
1697            output_representation: request.binding.output_representation.clone(),
1698            feature_set_id: request.binding.feature_set_id.clone(),
1699            source_ids: request.binding.source_ids.clone(),
1700            relation_record_count: envelope
1701                .coordinator_relations
1702                .as_ref()
1703                .map(|relations| relations.records.len()),
1704        };
1705        self.records.borrow_mut().insert(handle.handle, record);
1706        Ok(handle)
1707    }
1708
1709    fn make_view(&self, request: &DataViewRequest) -> Result<HandleRef> {
1710        request.view.validate()?;
1711        if request.node_id != request.binding.node_id {
1712            return Err(DagMlError::RuntimeValidation(format!(
1713                "data view request node `{}` does not match binding node `{}`",
1714                request.node_id, request.binding.node_id
1715            )));
1716        }
1717        if request.input_name != request.binding.input_name {
1718            return Err(DagMlError::RuntimeValidation(format!(
1719                "data view request input `{}` does not match binding input `{}`",
1720                request.input_name, request.binding.input_name
1721            )));
1722        }
1723        if request.data_handle.kind != HandleKind::Data {
1724            return Err(DagMlError::RuntimeValidation(format!(
1725                "data view request for `{}` on `{}` received non-data parent handle",
1726                request.input_name, request.node_id
1727            )));
1728        }
1729        let parent = self
1730            .records
1731            .borrow()
1732            .get(&request.data_handle.handle)
1733            .cloned()
1734            .ok_or_else(|| {
1735                DagMlError::RuntimeValidation(format!(
1736                    "unknown data handle `{}` for view request `{}` on `{}`",
1737                    request.data_handle.handle, request.input_name, request.node_id
1738                ))
1739            })?;
1740        if parent.handle != request.data_handle {
1741            return Err(DagMlError::RuntimeValidation(format!(
1742                "data view request parent handle `{}` does not match provider record",
1743                request.data_handle.handle
1744            )));
1745        }
1746        request.binding.validate()?;
1747        let handle = HandleRef {
1748            handle: self.next_handle(),
1749            kind: HandleKind::DataView,
1750            owner_controller: self.owner_controller.clone(),
1751        };
1752        let record = DataViewHandleRecord {
1753            handle: handle.clone(),
1754            parent_handle: request.data_handle.clone(),
1755            run_id: request.run_id.clone(),
1756            node_id: request.node_id.clone(),
1757            input_name: request.input_name.clone(),
1758            phase: request.phase,
1759            variant_id: request.variant_id.clone(),
1760            fold_id: request.fold_id.clone(),
1761            request_id: request.binding.request_id.clone(),
1762            feature_set_id: request.binding.feature_set_id().to_string(),
1763            view: request.view.clone(),
1764        };
1765        self.view_records.borrow_mut().insert(handle.handle, record);
1766        Ok(handle)
1767    }
1768
1769    fn coordinator_relations(&self, binding: &DataBinding) -> Result<Option<SampleRelationSet>> {
1770        let envelope = self
1771            .envelopes
1772            .get(&DataEnvelopeKey::from_binding(binding))
1773            .ok_or_else(|| {
1774                DagMlError::RuntimeValidation(format!(
1775                    "no external data-plan envelope registered for binding `{}` on `{}`",
1776                    binding.input_name, binding.node_id
1777                ))
1778            })?;
1779        binding.validate_envelope(envelope)?;
1780        Ok(envelope.coordinator_relations.clone())
1781    }
1782}
1783
1784fn validate_fingerprint(label: &str, value: &str) -> Result<()> {
1785    if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1786        return Err(DagMlError::CampaignValidation(format!(
1787            "{label} fingerprint must be a 64-character hex digest"
1788        )));
1789    }
1790    Ok(())
1791}
1792
1793fn validate_non_empty(label: &str, value: &str) -> Result<()> {
1794    if value.trim().is_empty() {
1795        return Err(DagMlError::CampaignValidation(format!(
1796            "{label} must be a non-empty string"
1797        )));
1798    }
1799    Ok(())
1800}
1801
1802fn validate_optional_non_empty(label: &str, value: &Option<String>) -> Result<()> {
1803    if let Some(value) = value {
1804        validate_non_empty(label, value)?;
1805    }
1806    Ok(())
1807}
1808
1809fn validate_combo_like_output(label: &str, unit_level: EntityUnitLevel) -> Result<()> {
1810    if matches!(
1811        unit_level,
1812        EntityUnitLevel::Combo | EntityUnitLevel::Observation
1813    ) {
1814        return Ok(());
1815    }
1816    Err(DagMlError::CampaignValidation(format!(
1817        "{label} representation output_unit_level must be combo or observation"
1818    )))
1819}
1820
1821fn validate_non_empty_list(label: &str, values: &[String]) -> Result<()> {
1822    if values.is_empty() {
1823        return Err(DagMlError::CampaignValidation(format!(
1824            "{label} must be a non-empty list"
1825        )));
1826    }
1827    validate_string_list_entries(label, values)
1828}
1829
1830fn validate_string_list_entries(label: &str, values: &[String]) -> Result<()> {
1831    for (index, value) in values.iter().enumerate() {
1832        if value.trim().is_empty() {
1833            return Err(DagMlError::CampaignValidation(format!(
1834                "{label}[{index}] must be a non-empty string"
1835            )));
1836        }
1837    }
1838    Ok(())
1839}
1840
1841fn validate_unique_strings(label: &str, values: &[String]) -> Result<()> {
1842    let mut seen = BTreeSet::new();
1843    for value in values {
1844        if !seen.insert(value.as_str()) {
1845            return Err(DagMlError::CampaignValidation(format!(
1846                "{label} contains duplicate value `{value}`"
1847            )));
1848        }
1849    }
1850    Ok(())
1851}
1852
1853#[cfg(test)]
1854mod tests {
1855    use super::*;
1856    use crate::ids::NodeId;
1857    use crate::runtime::DataMaterializationRequest;
1858
1859    fn binding() -> DataBinding {
1860        DataBinding {
1861            node_id: NodeId::new("model:base").unwrap(),
1862            input_name: "x".to_string(),
1863            request_id: "nir-to-tabular".to_string(),
1864            schema_fingerprint: "f97b37872fa22134b508f98fd8e207e5b776b52594fb8f6f5c3e15bee212246b"
1865                .to_string(),
1866            plan_fingerprint: "7c5431d85574b3f337022fa5d25971d5b5cf445b90331b49938f573ff6901e4d"
1867                .to_string(),
1868            relation_fingerprint: Some(
1869                "a3a7e329df35db9f2883a17b8611b7fae6dcaa031875e3ec2c9be1b9e29cbe10".to_string(),
1870            ),
1871            output_representation: "tabular_numeric".to_string(),
1872            feature_set_id: Some("x".to_string()),
1873            source_ids: vec!["nir".to_string()],
1874            require_relations: true,
1875            view_policy: DataViewPolicy::default(),
1876            metadata: BTreeMap::new(),
1877        }
1878    }
1879
1880    #[test]
1881    fn validates_data_binding_contract() {
1882        let binding = binding();
1883        binding.validate().unwrap();
1884        assert_eq!(binding.feature_set_id(), "x");
1885    }
1886
1887    #[test]
1888    fn published_model_input_and_data_plan_schemas_declare_current_contract() {
1889        let model_input_schema: serde_json::Value = serde_json::from_str(include_str!(
1890            "../../../docs/contracts/model_input_spec.schema.json"
1891        ))
1892        .unwrap();
1893        assert_eq!(model_input_schema["$id"], MODEL_INPUT_SPEC_SCHEMA_ID);
1894        assert_eq!(
1895            model_input_schema["properties"]["schema_version"]["const"].as_u64(),
1896            Some(MODEL_INPUT_SPEC_SCHEMA_VERSION as u64)
1897        );
1898        assert!(model_input_schema["$defs"]["input_port"]["required"]
1899            .as_array()
1900            .unwrap()
1901            .iter()
1902            .any(|field| field.as_str() == Some("accepted_representations")));
1903        assert!(model_input_schema["$defs"]["fusion_policy"]["properties"]
1904            .as_object()
1905            .unwrap()
1906            .contains_key("representation_plan"));
1907        assert!(model_input_schema["$defs"]
1908            .as_object()
1909            .unwrap()
1910            .contains_key("combination_plan"));
1911        assert!(model_input_schema["$defs"]
1912            .as_object()
1913            .unwrap()
1914            .contains_key("representation_plan"));
1915
1916        let data_plan_schema: serde_json::Value = serde_json::from_str(include_str!(
1917            "../../../docs/contracts/data_plan.schema.json"
1918        ))
1919        .unwrap();
1920        assert_eq!(data_plan_schema["$id"], DATA_PLAN_SCHEMA_ID);
1921        assert_eq!(
1922            data_plan_schema["properties"]["schema_version"]["const"].as_u64(),
1923            Some(DATA_PLAN_SCHEMA_VERSION as u64)
1924        );
1925        assert!(data_plan_schema["$defs"]["data_plan_step_kind"]["enum"]
1926            .as_array()
1927            .unwrap()
1928            .iter()
1929            .any(|kind| kind.as_str() == Some("collate")));
1930    }
1931
1932    #[test]
1933    fn validates_model_input_and_data_plan_fixtures() {
1934        let model_input: ModelInputSpec = serde_json::from_str(include_str!(
1935            "../../../examples/fixtures/data/model_input_spec_tabular_regressor.json"
1936        ))
1937        .unwrap();
1938        model_input.validate().unwrap();
1939        assert_eq!(model_input.ports[0].rank, Some(2));
1940        assert!(model_input.ports[0].multi_source);
1941
1942        let data_plan: DataPlan = serde_json::from_str(include_str!(
1943            "../../../examples/fixtures/data/data_plan_tabular_fusion.json"
1944        ))
1945        .unwrap();
1946        data_plan.validate().unwrap();
1947        assert_eq!(data_plan.output_ports.get("x").unwrap(), "x_collated");
1948    }
1949
1950    #[test]
1951    fn data_plan_rejects_forward_step_references() {
1952        let data_plan = DataPlan {
1953            schema_version: DATA_PLAN_SCHEMA_VERSION,
1954            id: "data-plan:bad".to_string(),
1955            steps: vec![DataPlanStep {
1956                kind: DataPlanStepKind::Adapt,
1957                inputs: vec!["missing".to_string()],
1958                output: "adapted".to_string(),
1959                adapter_id: Some("adapter:adapt".to_string()),
1960                params: BTreeMap::new(),
1961            }],
1962            output_ports: BTreeMap::from([("x".to_string(), "adapted".to_string())]),
1963            warnings: Vec::new(),
1964            requires_user_choice: Vec::new(),
1965            metadata: BTreeMap::new(),
1966        };
1967
1968        let error = data_plan.validate().unwrap_err().to_string();
1969        assert!(error.contains("before it is produced"));
1970    }
1971
1972    #[test]
1973    fn data_view_policy_rejects_unsafe_fit_and_validation_augmentation_by_default() {
1974        let mut full_train_binding = binding();
1975        full_train_binding.view_policy.fit_partition = DataRequestPartition::FullTrain;
1976        let full_train_error = full_train_binding.validate().unwrap_err().to_string();
1977        assert!(
1978            full_train_error.contains("fit_partition=full_train"),
1979            "unexpected full-train error: {full_train_error}"
1980        );
1981
1982        let mut augmented_validation_binding = binding();
1983        augmented_validation_binding
1984            .view_policy
1985            .include_augmented_validation = true;
1986        let augmented_error = augmented_validation_binding
1987            .validate()
1988            .unwrap_err()
1989            .to_string();
1990        assert!(
1991            augmented_error.contains("include_augmented_validation=true"),
1992            "unexpected augmented-validation error: {augmented_error}"
1993        );
1994
1995        let mut excluded_binding = binding();
1996        excluded_binding.view_policy.include_excluded = true;
1997        let excluded_error = excluded_binding.validate().unwrap_err().to_string();
1998        assert!(
1999            excluded_error.contains("include_excluded=true"),
2000            "unexpected excluded-row error: {excluded_error}"
2001        );
2002    }
2003
2004    #[test]
2005    fn data_view_policy_requires_explicit_unsafe_flags_for_debug_views() {
2006        let mut binding = binding();
2007        binding.view_policy.fit_partition = DataRequestPartition::FullTrain;
2008        binding.view_policy.include_augmented_validation = true;
2009        binding.view_policy.include_excluded = true;
2010        binding.view_policy.unsafe_flags = BTreeSet::from([
2011            DataViewPolicy::ALLOW_FIT_CV_FULL_TRAIN_VIEW.to_string(),
2012            DataViewPolicy::ALLOW_AUGMENTED_VALIDATION_VIEW.to_string(),
2013            DataViewPolicy::ALLOW_EXCLUDED_ROWS.to_string(),
2014        ]);
2015
2016        binding.validate().unwrap();
2017    }
2018
2019    #[test]
2020    fn validates_external_data_envelope_subset() {
2021        let envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
2022            "../../../examples/fixtures/data/coordinator_data_plan_envelope_sample12.json"
2023        ))
2024        .unwrap();
2025
2026        assert_eq!(
2027            envelope.schema_version,
2028            EXTERNAL_DATA_PLAN_ENVELOPE_SCHEMA_VERSION
2029        );
2030        binding().validate_envelope(&envelope).unwrap();
2031    }
2032
2033    #[test]
2034    fn validates_multisource_repetition_envelope_fixture() {
2035        let envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
2036            "../../../examples/fixtures/data/coordinator_data_plan_envelope_multisource_repetitions.json"
2037        ))
2038        .unwrap();
2039
2040        envelope.validate().unwrap();
2041        let relations = envelope.coordinator_relations.as_ref().unwrap();
2042        assert_eq!(relations.records.len(), 8);
2043        let source_counts = relations.records.iter().fold(
2044            BTreeMap::<String, usize>::new(),
2045            |mut counts, record| {
2046                if record.unit_level == EntityUnitLevel::Observation {
2047                    *counts
2048                        .entry(record.source_id.clone().expect("source_id"))
2049                        .or_default() += 1;
2050                }
2051                counts
2052            },
2053        );
2054        assert_eq!(source_counts["A"], 2);
2055        assert_eq!(source_counts["B"], 3);
2056        assert_eq!(source_counts["C"], 2);
2057        let combo = relations
2058            .records
2059            .iter()
2060            .find(|record| record.unit_level == EntityUnitLevel::Combo)
2061            .expect("relation-backed combo row");
2062        assert_eq!(combo.sample_id.as_str(), "sample:1");
2063        assert_eq!(
2064            combo.origin_sample_id.as_ref().unwrap().as_str(),
2065            combo.sample_id.as_str()
2066        );
2067        assert_eq!(combo.component_observation_ids.len(), 3);
2068        for source_id in ["A", "B", "C"] {
2069            assert!(combo
2070                .component_observation_ids
2071                .iter()
2072                .any(|observation_id| observation_id.as_str().contains(source_id)));
2073        }
2074        assert_eq!(
2075            relations
2076                .sample_for_observation(
2077                    &crate::ids::ObservationId::new("obs.s1.combo.A0.B0.C0").unwrap()
2078                )
2079                .unwrap()
2080                .as_str(),
2081            "sample:1"
2082        );
2083    }
2084
2085    #[test]
2086    fn published_external_data_envelope_schema_declares_current_version() {
2087        let schema: serde_json::Value = serde_json::from_str(include_str!(
2088            "../../../docs/contracts/coordinator_data_plan_envelope.schema.json"
2089        ))
2090        .unwrap();
2091
2092        assert_eq!(
2093            schema["properties"]["schema_version"]["const"].as_u64(),
2094            Some(EXTERNAL_DATA_PLAN_ENVELOPE_SCHEMA_VERSION as u64)
2095        );
2096        assert!(schema["required"]
2097            .as_array()
2098            .unwrap()
2099            .iter()
2100            .any(|field| field.as_str() == Some("schema_version")));
2101    }
2102
2103    #[test]
2104    fn refuses_unsupported_external_data_envelope_schema_version() {
2105        let mut envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
2106            "../../../examples/fixtures/data/coordinator_data_plan_envelope_sample12.json"
2107        ))
2108        .unwrap();
2109        envelope.schema_version = EXTERNAL_DATA_PLAN_ENVELOPE_SCHEMA_VERSION + 1;
2110
2111        assert!(binding().validate_envelope(&envelope).is_err());
2112    }
2113
2114    #[test]
2115    fn refuses_envelope_fingerprint_mismatch() {
2116        let mut envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
2117            "../../../examples/fixtures/data/coordinator_data_plan_envelope_sample12.json"
2118        ))
2119        .unwrap();
2120        envelope.plan_fingerprint = "0".repeat(64);
2121
2122        assert!(binding().validate_envelope(&envelope).is_err());
2123    }
2124
2125    #[test]
2126    fn in_memory_provider_materializes_validated_data_handles() {
2127        let envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
2128            "../../../examples/fixtures/data/coordinator_data_plan_envelope_sample12.json"
2129        ))
2130        .unwrap();
2131        let provider = InMemoryDataProvider::with_envelope(
2132            ControllerId::new("controller:data.provider").unwrap(),
2133            envelope,
2134        )
2135        .unwrap();
2136
2137        let handle = provider
2138            .materialize(&DataMaterializationRequest {
2139                run_id: RunId::new("run:data").unwrap(),
2140                node_id: NodeId::new("model:base").unwrap(),
2141                input_name: "x".to_string(),
2142                phase: Phase::FitCv,
2143                variant_id: Some(VariantId::new("variant:base").unwrap()),
2144                fold_id: Some(FoldId::new("fold:0").unwrap()),
2145                binding: binding(),
2146            })
2147            .unwrap();
2148
2149        let record = provider.handle_record(handle.handle).unwrap();
2150        assert_eq!(record.input_name, "x");
2151        assert_eq!(record.relation_record_count, Some(4));
2152        assert_eq!(provider.handle_records().len(), 1);
2153    }
2154
2155    #[test]
2156    fn in_memory_provider_registration_is_idempotent_for_same_envelope() {
2157        let envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
2158            "../../../examples/fixtures/data/coordinator_data_plan_envelope_sample12.json"
2159        ))
2160        .unwrap();
2161        let mut provider =
2162            InMemoryDataProvider::new(ControllerId::new("controller:data.provider").unwrap());
2163
2164        provider.register_envelope(envelope.clone()).unwrap();
2165        provider.register_envelope(envelope).unwrap();
2166    }
2167
2168    #[test]
2169    fn in_memory_provider_refuses_unknown_envelope() {
2170        let provider =
2171            InMemoryDataProvider::new(ControllerId::new("controller:data.provider").unwrap());
2172
2173        assert!(provider
2174            .materialize(&DataMaterializationRequest {
2175                run_id: RunId::new("run:data").unwrap(),
2176                node_id: NodeId::new("model:base").unwrap(),
2177                input_name: "x".to_string(),
2178                phase: Phase::FitCv,
2179                variant_id: None,
2180                fold_id: None,
2181                binding: binding(),
2182            })
2183            .is_err());
2184    }
2185
2186    fn cartesian_combination() -> CombinationPlan {
2187        CombinationPlan {
2188            mode: CombinationMode::Cartesian,
2189            component_source_ids: vec!["source:a".to_string(), "source:b".to_string()],
2190            component_unit_ids: Vec::new(),
2191            match_key: None,
2192            reference_source_id: None,
2193            seed: None,
2194            cap: None,
2195            budget: Some(32),
2196            missing_source_policy: Some(RepresentationMissingSourcePolicy::Strict),
2197            metadata: BTreeMap::new(),
2198        }
2199    }
2200
2201    fn compatibility_report() -> RepresentationCompatibilityReport {
2202        RepresentationCompatibilityReport {
2203            policy: RepresentationMissingSourcePolicy::Mask,
2204            outcome: RepresentationCompatibilityOutcome::CompatibleWithFallback,
2205            fallback_used: Some("mask".to_string()),
2206            warning_severity: Some(RepresentationCompatibilitySeverity::Warning),
2207            affected_source_count: 1,
2208            affected_repetition_count: 2,
2209            affected_sample_count: 3,
2210            train_relation_fingerprint: Some("c".repeat(64)),
2211            predict_relation_fingerprint: Some("d".repeat(64)),
2212            train_unit_count: Some(6),
2213            predict_unit_count: Some(4),
2214            fixed_width_required: true,
2215            final_reducer_stabilizes_output: true,
2216            cartesian_combo_count_changed: true,
2217            late_fusion_branch_delta: true,
2218            messages: vec!["mask fallback applied for missing source".to_string()],
2219            metadata: BTreeMap::new(),
2220        }
2221    }
2222
2223    #[derive(serde::Deserialize)]
2224    #[serde(deny_unknown_fields)]
2225    struct D9GoldenFixture {
2226        schema_version: u32,
2227        golden_scenarios: Vec<D9GoldenScenario>,
2228    }
2229
2230    #[derive(serde::Deserialize)]
2231    #[serde(deny_unknown_fields)]
2232    struct D9GoldenScenario {
2233        scenario_id: String,
2234        flow: Vec<String>,
2235        mock_phase_path: Vec<String>,
2236        representation_replay_manifest: RepresentationReplayManifest,
2237        assertions: Vec<String>,
2238    }
2239
2240    #[test]
2241    fn d9_golden_multisource_repetition_manifests_validate() {
2242        let fixture: D9GoldenFixture = serde_json::from_str(include_str!(
2243            "../../../examples/fixtures/runtime/d9_golden_multisource_scenarios.json"
2244        ))
2245        .unwrap();
2246        assert_eq!(fixture.schema_version, 1);
2247        assert_eq!(fixture.golden_scenarios.len(), 7);
2248
2249        let mut scenario_ids = BTreeSet::new();
2250        let mut has_same_repetition_replay = false;
2251        let mut has_changed_repetition_replay = false;
2252        let mut has_combo_meta_fit_influence = false;
2253        for scenario in &fixture.golden_scenarios {
2254            assert!(
2255                scenario_ids.insert(scenario.scenario_id.as_str()),
2256                "duplicate D9 scenario {}",
2257                scenario.scenario_id
2258            );
2259            assert!(!scenario.flow.is_empty());
2260            assert_eq!(scenario.mock_phase_path, ["fit_cv", "refit", "predict"]);
2261            assert!(!scenario.assertions.is_empty());
2262
2263            let manifest = &scenario.representation_replay_manifest;
2264            manifest.validate().unwrap();
2265            assert_eq!(
2266                manifest.final_output_unit_level,
2267                Some(EntityUnitLevel::PhysicalSample),
2268                "{} must publish sample-level outputs",
2269                scenario.scenario_id
2270            );
2271            if manifest.output_unit_level == EntityUnitLevel::Combo {
2272                assert!(
2273                    manifest.final_reduction_id.is_some(),
2274                    "{} must declare combo-to-sample reduction",
2275                    scenario.scenario_id
2276                );
2277                assert!(
2278                    !manifest.combo_selection.is_empty(),
2279                    "{} must retain relation-backed combo identities",
2280                    scenario.scenario_id
2281                );
2282            }
2283
2284            if let (Some(train), Some(predict)) = (
2285                &manifest.train_compatibility,
2286                &manifest.predict_compatibility,
2287            ) {
2288                has_same_repetition_replay |= train.train_unit_count == predict.predict_unit_count
2289                    && train.train_relation_fingerprint == predict.predict_relation_fingerprint;
2290                has_changed_repetition_replay |= train.train_unit_count
2291                    != predict.predict_unit_count
2292                    || train.train_relation_fingerprint != predict.predict_relation_fingerprint;
2293            }
2294
2295            if scenario.scenario_id == "d9.combo_meta_post.relation_backed_adapters" {
2296                has_combo_meta_fit_influence = manifest
2297                    .metadata
2298                    .get("fit_influence_policy")
2299                    .is_some_and(|value| value == "equal_sample_influence");
2300            }
2301        }
2302
2303        assert!(scenario_ids.contains("d9.per_source_aggregate.source_models.sample_reducer"));
2304        assert!(scenario_ids.contains("d9.late_fusion_by_source.prediction_join.meta_model"));
2305        assert!(scenario_ids.contains("d9.cartesian_full.model.combo_to_sample_reducer"));
2306        assert!(scenario_ids.contains("d9.cartesian_mc.deterministic_replay"));
2307        assert!(scenario_ids.contains("d9.stack_fixed.strict_cardinality"));
2308        assert!(scenario_ids.contains("d9.stack_padded_masked.missing_repetition"));
2309        assert!(scenario_ids.contains("d9.combo_meta_post.relation_backed_adapters"));
2310        assert!(has_same_repetition_replay);
2311        assert!(has_changed_repetition_replay);
2312        assert!(has_combo_meta_fit_influence);
2313    }
2314
2315    #[test]
2316    fn representation_plan_validates_cartesian_and_monte_carlo_contracts() {
2317        let cartesian = RepresentationPlan::CartesianProduct(CartesianProductRepresentation {
2318            combination_plan: cartesian_combination(),
2319            output_unit_level: EntityUnitLevel::Combo,
2320            cardinality: RepresentationCardinality::ManyToMany,
2321            preserve_provenance: true,
2322        });
2323        cartesian.validate().unwrap();
2324
2325        let monte_carlo =
2326            RepresentationPlan::MonteCarloCartesian(MonteCarloCartesianRepresentation {
2327                combination_plan: CombinationPlan {
2328                    mode: CombinationMode::SampleK,
2329                    component_source_ids: vec!["source:a".to_string(), "source:b".to_string()],
2330                    component_unit_ids: Vec::new(),
2331                    match_key: None,
2332                    reference_source_id: None,
2333                    seed: Some(42),
2334                    cap: Some(8),
2335                    budget: None,
2336                    missing_source_policy: Some(RepresentationMissingSourcePolicy::Warn),
2337                    metadata: BTreeMap::new(),
2338                },
2339                output_unit_level: EntityUnitLevel::Observation,
2340                cardinality: RepresentationCardinality::BoundedMany,
2341                preserve_provenance: true,
2342            });
2343        monte_carlo.validate().unwrap();
2344
2345        let mut bad = cartesian_combination();
2346        bad.mode = CombinationMode::SampleK;
2347        bad.seed = Some(7);
2348        bad.cap = Some(0);
2349        assert!(bad.validate().is_err());
2350    }
2351
2352    #[test]
2353    fn stack_representations_validate_cardinality_and_mask_policy() {
2354        let fixed = RepresentationPlan::StackFixed(StackFixedRepresentation {
2355            output_unit_level: EntityUnitLevel::SourceSample,
2356            cardinality: RepresentationCardinality::OneToMany,
2357            expected_cardinality: 3,
2358            component_source_ids: vec!["source:a".to_string(), "source:b".to_string()],
2359        });
2360        fixed.validate().unwrap();
2361
2362        let padded = RepresentationPlan::StackPaddedMasked(StackPaddedMaskedRepresentation {
2363            output_unit_level: EntityUnitLevel::SourceSample,
2364            cardinality: RepresentationCardinality::BoundedMany,
2365            expected_cardinality: 4,
2366            missing_source_policy: RepresentationMissingSourcePolicy::Mask,
2367            requires_missing_masks: true,
2368            component_source_ids: vec!["source:a".to_string()],
2369        });
2370        padded.validate().unwrap();
2371
2372        let bad = RepresentationPlan::StackPaddedMasked(StackPaddedMaskedRepresentation {
2373            output_unit_level: EntityUnitLevel::SourceSample,
2374            cardinality: RepresentationCardinality::BoundedMany,
2375            expected_cardinality: 4,
2376            missing_source_policy: RepresentationMissingSourcePolicy::ImputeDeclared,
2377            requires_missing_masks: false,
2378            component_source_ids: Vec::new(),
2379        });
2380        assert!(bad.validate().is_err());
2381    }
2382
2383    #[test]
2384    fn representation_compatibility_report_enforces_missingness_policy() {
2385        compatibility_report().validate().unwrap();
2386
2387        let strict = RepresentationCompatibilityReport {
2388            policy: RepresentationMissingSourcePolicy::Strict,
2389            outcome: RepresentationCompatibilityOutcome::Incompatible,
2390            fallback_used: None,
2391            warning_severity: None,
2392            affected_source_count: 1,
2393            affected_repetition_count: 0,
2394            affected_sample_count: 1,
2395            train_relation_fingerprint: None,
2396            predict_relation_fingerprint: None,
2397            train_unit_count: None,
2398            predict_unit_count: None,
2399            fixed_width_required: false,
2400            final_reducer_stabilizes_output: false,
2401            cartesian_combo_count_changed: false,
2402            late_fusion_branch_delta: false,
2403            messages: Vec::new(),
2404            metadata: BTreeMap::new(),
2405        };
2406        strict.validate().unwrap();
2407
2408        let mut bad_non_strict = compatibility_report();
2409        bad_non_strict.fallback_used = None;
2410        assert!(bad_non_strict.validate().is_err());
2411
2412        let mut bad_fixed_width = compatibility_report();
2413        bad_fixed_width.policy = RepresentationMissingSourcePolicy::ImputeDeclared;
2414        bad_fixed_width.fallback_used = Some("impute_declared".to_string());
2415        assert!(bad_fixed_width.validate().is_err());
2416
2417        let mut bad_cartesian = compatibility_report();
2418        bad_cartesian.final_reducer_stabilizes_output = false;
2419        assert!(bad_cartesian.validate().is_err());
2420
2421        let bad_relation_drift = RepresentationCompatibilityReport {
2422            policy: RepresentationMissingSourcePolicy::Strict,
2423            outcome: RepresentationCompatibilityOutcome::Compatible,
2424            fallback_used: None,
2425            warning_severity: None,
2426            affected_source_count: 0,
2427            affected_repetition_count: 0,
2428            affected_sample_count: 0,
2429            train_relation_fingerprint: Some("a".repeat(64)),
2430            predict_relation_fingerprint: Some("b".repeat(64)),
2431            train_unit_count: Some(3),
2432            predict_unit_count: Some(3),
2433            fixed_width_required: false,
2434            final_reducer_stabilizes_output: true,
2435            cartesian_combo_count_changed: false,
2436            late_fusion_branch_delta: false,
2437            messages: Vec::new(),
2438            metadata: BTreeMap::new(),
2439        };
2440        let error = bad_relation_drift.validate().unwrap_err().to_string();
2441        assert!(
2442            error.contains("relation fingerprint mismatch requires affected units"),
2443            "unexpected D9 relation drift error: {error}"
2444        );
2445
2446        let mut bad_unit_drift = bad_relation_drift;
2447        bad_unit_drift.predict_relation_fingerprint =
2448            bad_unit_drift.train_relation_fingerprint.clone();
2449        bad_unit_drift.predict_unit_count = Some(2);
2450        let error = bad_unit_drift.validate().unwrap_err().to_string();
2451        assert!(
2452            error.contains("unit count mismatch requires affected units"),
2453            "unexpected D9 unit-count drift error: {error}"
2454        );
2455    }
2456
2457    #[test]
2458    fn representation_replay_manifest_round_trips_and_validates() {
2459        let plan = RepresentationPlan::CartesianProduct(CartesianProductRepresentation {
2460            combination_plan: cartesian_combination(),
2461            output_unit_level: EntityUnitLevel::Combo,
2462            cardinality: RepresentationCardinality::ManyToMany,
2463            preserve_provenance: true,
2464        });
2465        let manifest = RepresentationReplayManifest {
2466            manifest_id: "repr:combo.ab".to_string(),
2467            representation_plan: plan,
2468            combination_plan: Some(cartesian_combination()),
2469            output_unit_level: EntityUnitLevel::Combo,
2470            output_representation: Some("combo_observation".to_string()),
2471            relation_fingerprint: Some("a".repeat(64)),
2472            feature_schema_fingerprint: Some("b".repeat(64)),
2473            final_reduction_id: Some("reduction:combo_to_sample".to_string()),
2474            sample_observation_mapping: vec![
2475                RepresentationSampleObservationMapping {
2476                    physical_sample_id: "sample:1".to_string(),
2477                    source_id: "source:a".to_string(),
2478                    observation_ids: vec!["obs:a.1".to_string(), "obs:a.2".to_string()],
2479                },
2480                RepresentationSampleObservationMapping {
2481                    physical_sample_id: "sample:1".to_string(),
2482                    source_id: "source:b".to_string(),
2483                    observation_ids: vec!["obs:b.1".to_string()],
2484                },
2485            ],
2486            combo_selection: vec![RepresentationComboSelectionRecord {
2487                combo_unit_id: "combo:sample1:a1:b1".to_string(),
2488                physical_sample_id: "sample:1".to_string(),
2489                component_observation_ids: vec!["obs:a.1".to_string(), "obs:b.1".to_string()],
2490                seed: Some(42),
2491            }],
2492            qc_policy_refs: vec!["qc:default".to_string()],
2493            outlier_policy_refs: vec!["outlier:none".to_string()],
2494            missing_source_policy: Some(RepresentationMissingSourcePolicy::Strict),
2495            missing_repetition_policy: Some(RepresentationMissingSourcePolicy::Warn),
2496            prediction_representation: Some("sample_prediction".to_string()),
2497            final_output_unit_level: Some(EntityUnitLevel::PhysicalSample),
2498            train_compatibility: Some(RepresentationCompatibilityReport {
2499                policy: RepresentationMissingSourcePolicy::Strict,
2500                outcome: RepresentationCompatibilityOutcome::Compatible,
2501                fallback_used: None,
2502                warning_severity: None,
2503                affected_source_count: 0,
2504                affected_repetition_count: 0,
2505                affected_sample_count: 0,
2506                train_relation_fingerprint: Some("a".repeat(64)),
2507                predict_relation_fingerprint: None,
2508                train_unit_count: Some(1),
2509                predict_unit_count: Some(1),
2510                fixed_width_required: false,
2511                final_reducer_stabilizes_output: true,
2512                cartesian_combo_count_changed: false,
2513                late_fusion_branch_delta: false,
2514                messages: Vec::new(),
2515                metadata: BTreeMap::new(),
2516            }),
2517            predict_compatibility: Some(compatibility_report()),
2518            metadata: BTreeMap::new(),
2519        };
2520
2521        manifest.validate().unwrap();
2522        let encoded = serde_json::to_string(&manifest).unwrap();
2523        let decoded: RepresentationReplayManifest = serde_json::from_str(&encoded).unwrap();
2524        assert_eq!(decoded, manifest);
2525    }
2526}