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
1334/// Return the stable wire key used to bind one node input to its external
1335/// data requirement.
1336///
1337/// The `node.input` spelling is part of the V1 JSON contract. Because both
1338/// coordinates may themselves contain `.`, callers that index more than one
1339/// binding must still reject distinct coordinates that render to the same
1340/// key instead of silently overwriting one of them.
1341pub fn data_binding_requirement_key(node_id: &NodeId, input_name: &str) -> String {
1342    format!("{node_id}.{input_name}")
1343}
1344
1345impl DataBinding {
1346    pub fn validate(&self) -> Result<()> {
1347        self.view_policy.validate()?;
1348        if self.input_name.trim().is_empty() {
1349            return Err(DagMlError::CampaignValidation(format!(
1350                "data binding for `{}` has empty input_name",
1351                self.node_id
1352            )));
1353        }
1354        if self.request_id.trim().is_empty() {
1355            return Err(DagMlError::CampaignValidation(format!(
1356                "data binding `{}` on `{}` has empty request_id",
1357                self.input_name, self.node_id
1358            )));
1359        }
1360        validate_fingerprint("schema", &self.schema_fingerprint)?;
1361        validate_fingerprint("plan", &self.plan_fingerprint)?;
1362        if let Some(relation_fingerprint) = &self.relation_fingerprint {
1363            validate_fingerprint("relation", relation_fingerprint)?;
1364        } else if self.require_relations {
1365            return Err(DagMlError::CampaignValidation(format!(
1366                "data binding `{}` on `{}` requires relations but has no relation_fingerprint",
1367                self.input_name, self.node_id
1368            )));
1369        }
1370        if self.output_representation.trim().is_empty() {
1371            return Err(DagMlError::CampaignValidation(format!(
1372                "data binding `{}` on `{}` has empty output_representation",
1373                self.input_name, self.node_id
1374            )));
1375        }
1376        if let Some(feature_set_id) = &self.feature_set_id {
1377            if feature_set_id.trim().is_empty() {
1378                return Err(DagMlError::CampaignValidation(format!(
1379                    "data binding `{}` on `{}` has empty feature_set_id",
1380                    self.input_name, self.node_id
1381                )));
1382            }
1383        }
1384        for source_id in &self.source_ids {
1385            if source_id.trim().is_empty() {
1386                return Err(DagMlError::CampaignValidation(format!(
1387                    "data binding `{}` on `{}` has empty source id",
1388                    self.input_name, self.node_id
1389                )));
1390            }
1391        }
1392        validate_unique_strings(
1393            &format!(
1394                "data binding `{}` on `{}` source_ids",
1395                self.input_name, self.node_id
1396            ),
1397            &self.source_ids,
1398        )?;
1399        validate_source_index_metadata(
1400            &format!(
1401                "data binding `{}` on `{}` metadata.source_index",
1402                self.input_name, self.node_id
1403            ),
1404            self.metadata.get(SOURCE_INDEX_METADATA_KEY),
1405            &self.source_ids,
1406        )?;
1407        Ok(())
1408    }
1409
1410    pub fn feature_set_id(&self) -> &str {
1411        self.feature_set_id.as_deref().unwrap_or(&self.input_name)
1412    }
1413
1414    pub fn validate_envelope(&self, envelope: &ExternalDataPlanEnvelope) -> Result<()> {
1415        self.validate()?;
1416        envelope.validate()?;
1417        if self.schema_fingerprint != envelope.schema_fingerprint {
1418            return Err(DagMlError::CampaignValidation(format!(
1419                "data binding `{}` on `{}` schema fingerprint mismatch",
1420                self.input_name, self.node_id
1421            )));
1422        }
1423        if self.plan_fingerprint != envelope.plan_fingerprint {
1424            return Err(DagMlError::CampaignValidation(format!(
1425                "data binding `{}` on `{}` plan fingerprint mismatch",
1426                self.input_name, self.node_id
1427            )));
1428        }
1429        if self.relation_fingerprint != envelope.relation_fingerprint {
1430            return Err(DagMlError::CampaignValidation(format!(
1431                "data binding `{}` on `{}` relation fingerprint mismatch",
1432                self.input_name, self.node_id
1433            )));
1434        }
1435        if self.require_relations && envelope.coordinator_relations.is_none() {
1436            return Err(DagMlError::CampaignValidation(format!(
1437                "data binding `{}` on `{}` requires coordinator relations",
1438                self.input_name, self.node_id
1439            )));
1440        }
1441        Ok(())
1442    }
1443}
1444
1445pub(crate) fn validate_source_index_metadata(
1446    label: &str,
1447    value: Option<&serde_json::Value>,
1448    expected_sources: &[String],
1449) -> Result<()> {
1450    let Some(value) = value else {
1451        return Ok(());
1452    };
1453    let Some(source_index) = value.as_object() else {
1454        return Err(DagMlError::CampaignValidation(format!(
1455            "{label} must be an object mapping source id to feature-axis block index"
1456        )));
1457    };
1458    if source_index.is_empty() {
1459        return Err(DagMlError::CampaignValidation(format!(
1460            "{label} must not be empty"
1461        )));
1462    }
1463    let mut seen_indices = BTreeSet::new();
1464    for (source_id, index_value) in source_index {
1465        if source_id.trim().is_empty() {
1466            return Err(DagMlError::CampaignValidation(format!(
1467                "{label} contains an empty source id"
1468            )));
1469        }
1470        let Some(index) = index_value.as_u64() else {
1471            return Err(DagMlError::CampaignValidation(format!(
1472                "{label} entry `{source_id}` must be a non-negative integer"
1473            )));
1474        };
1475        if !seen_indices.insert(index) {
1476            return Err(DagMlError::CampaignValidation(format!(
1477                "{label} contains duplicate feature-axis block index `{index}`"
1478            )));
1479        }
1480    }
1481    if !expected_sources.is_empty() {
1482        let actual = source_index.keys().cloned().collect::<BTreeSet<_>>();
1483        let expected = expected_sources.iter().cloned().collect::<BTreeSet<_>>();
1484        if actual != expected {
1485            return Err(DagMlError::CampaignValidation(format!(
1486                "{label} keys must match data binding source_ids"
1487            )));
1488        }
1489    }
1490    Ok(())
1491}
1492
1493#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1494pub struct ExternalDataPlanEnvelope {
1495    #[serde(default = "default_external_data_plan_envelope_schema_version")]
1496    pub schema_version: u32,
1497    pub schema_fingerprint: String,
1498    pub plan_fingerprint: String,
1499    #[serde(default)]
1500    pub relation_fingerprint: Option<String>,
1501    /// Optional additive identity of the concrete feature/input content. Legacy
1502    /// V1 envelopes omit it; W1 training requests bind it through
1503    /// `TrainingDataIdentity` before candidate caches are reusable.
1504    #[serde(default, skip_serializing_if = "Option::is_none")]
1505    pub data_content_fingerprint: Option<String>,
1506    /// Optional additive identity of the concrete target content. It may be
1507    /// absent for prediction-only envelopes but is mandatory at the W1
1508    /// training boundary.
1509    #[serde(default, skip_serializing_if = "Option::is_none")]
1510    pub target_content_fingerprint: Option<String>,
1511    #[serde(default)]
1512    pub coordinator_relations: Option<SampleRelationSet>,
1513}
1514
1515impl ExternalDataPlanEnvelope {
1516    pub fn validate(&self) -> Result<()> {
1517        if self.schema_version != EXTERNAL_DATA_PLAN_ENVELOPE_SCHEMA_VERSION {
1518            return Err(DagMlError::CampaignValidation(format!(
1519                "external data-plan envelope uses unsupported schema_version {}, expected {}",
1520                self.schema_version, EXTERNAL_DATA_PLAN_ENVELOPE_SCHEMA_VERSION
1521            )));
1522        }
1523        validate_fingerprint("schema", &self.schema_fingerprint)?;
1524        validate_fingerprint("plan", &self.plan_fingerprint)?;
1525        if let Some(relation_fingerprint) = &self.relation_fingerprint {
1526            validate_fingerprint("relation", relation_fingerprint)?;
1527            if self.coordinator_relations.is_none() {
1528                return Err(DagMlError::CampaignValidation(
1529                    "relation_fingerprint requires coordinator_relations".to_string(),
1530                ));
1531            }
1532        }
1533        if let Some(data_content_fingerprint) = &self.data_content_fingerprint {
1534            validate_fingerprint("data content", data_content_fingerprint)?;
1535        }
1536        if let Some(target_content_fingerprint) = &self.target_content_fingerprint {
1537            validate_fingerprint("target content", target_content_fingerprint)?;
1538        }
1539        if let Some(relations) = &self.coordinator_relations {
1540            relations.validate()?;
1541        }
1542        Ok(())
1543    }
1544}
1545
1546pub fn validate_data_binding_envelope(
1547    binding: &DataBinding,
1548    envelope: &ExternalDataPlanEnvelope,
1549) -> Result<()> {
1550    binding.validate_envelope(envelope)
1551}
1552
1553#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
1554struct DataEnvelopeKey {
1555    schema_fingerprint: String,
1556    plan_fingerprint: String,
1557    relation_fingerprint: Option<String>,
1558}
1559
1560impl DataEnvelopeKey {
1561    fn from_binding(binding: &DataBinding) -> Self {
1562        Self {
1563            schema_fingerprint: binding.schema_fingerprint.clone(),
1564            plan_fingerprint: binding.plan_fingerprint.clone(),
1565            relation_fingerprint: binding.relation_fingerprint.clone(),
1566        }
1567    }
1568
1569    fn from_envelope(envelope: &ExternalDataPlanEnvelope) -> Self {
1570        Self {
1571            schema_fingerprint: envelope.schema_fingerprint.clone(),
1572            plan_fingerprint: envelope.plan_fingerprint.clone(),
1573            relation_fingerprint: envelope.relation_fingerprint.clone(),
1574        }
1575    }
1576}
1577
1578#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1579pub struct DataHandleRecord {
1580    pub handle: HandleRef,
1581    pub run_id: RunId,
1582    pub node_id: NodeId,
1583    pub input_name: String,
1584    pub phase: Phase,
1585    pub variant_id: Option<VariantId>,
1586    pub fold_id: Option<FoldId>,
1587    pub request_id: String,
1588    pub schema_fingerprint: String,
1589    pub plan_fingerprint: String,
1590    pub relation_fingerprint: Option<String>,
1591    pub output_representation: String,
1592    #[serde(default)]
1593    pub feature_set_id: Option<String>,
1594    #[serde(default)]
1595    pub source_ids: Vec<String>,
1596    pub relation_record_count: Option<usize>,
1597}
1598
1599#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1600pub struct DataViewHandleRecord {
1601    pub handle: HandleRef,
1602    pub parent_handle: HandleRef,
1603    pub run_id: RunId,
1604    pub node_id: NodeId,
1605    pub input_name: String,
1606    pub phase: Phase,
1607    pub variant_id: Option<VariantId>,
1608    pub fold_id: Option<FoldId>,
1609    pub request_id: String,
1610    pub feature_set_id: String,
1611    pub view: DataProviderViewSpec,
1612}
1613
1614#[derive(Debug)]
1615pub struct InMemoryDataProvider {
1616    owner_controller: ControllerId,
1617    envelopes: BTreeMap<DataEnvelopeKey, ExternalDataPlanEnvelope>,
1618    next_handle: RefCell<u64>,
1619    records: RefCell<BTreeMap<u64, DataHandleRecord>>,
1620    view_records: RefCell<BTreeMap<u64, DataViewHandleRecord>>,
1621}
1622
1623impl InMemoryDataProvider {
1624    pub fn new(owner_controller: ControllerId) -> Self {
1625        Self {
1626            owner_controller,
1627            envelopes: BTreeMap::new(),
1628            next_handle: RefCell::new(1),
1629            records: RefCell::new(BTreeMap::new()),
1630            view_records: RefCell::new(BTreeMap::new()),
1631        }
1632    }
1633
1634    pub fn with_envelope(
1635        owner_controller: ControllerId,
1636        envelope: ExternalDataPlanEnvelope,
1637    ) -> Result<Self> {
1638        let mut provider = Self::new(owner_controller);
1639        provider.register_envelope(envelope)?;
1640        Ok(provider)
1641    }
1642
1643    pub fn register_envelope(&mut self, envelope: ExternalDataPlanEnvelope) -> Result<()> {
1644        envelope.validate()?;
1645        let key = DataEnvelopeKey::from_envelope(&envelope);
1646        if let Some(existing) = self.envelopes.get(&key) {
1647            if existing == &envelope {
1648                return Ok(());
1649            }
1650            return Err(DagMlError::RuntimeValidation(
1651                "duplicate external data-plan envelope with different payload".to_string(),
1652            ));
1653        }
1654        self.envelopes.insert(key, envelope);
1655        Ok(())
1656    }
1657
1658    pub fn handle_record(&self, handle: u64) -> Option<DataHandleRecord> {
1659        self.records.borrow().get(&handle).cloned()
1660    }
1661
1662    pub fn handle_records(&self) -> Vec<DataHandleRecord> {
1663        self.records.borrow().values().cloned().collect()
1664    }
1665
1666    pub fn view_record(&self, handle: u64) -> Option<DataViewHandleRecord> {
1667        self.view_records.borrow().get(&handle).cloned()
1668    }
1669
1670    pub fn view_records(&self) -> Vec<DataViewHandleRecord> {
1671        self.view_records.borrow().values().cloned().collect()
1672    }
1673
1674    fn next_handle(&self) -> u64 {
1675        let mut next = self.next_handle.borrow_mut();
1676        let handle = *next;
1677        *next += 1;
1678        handle
1679    }
1680}
1681
1682impl RuntimeDataProvider for InMemoryDataProvider {
1683    fn materialize(&self, request: &DataMaterializationRequest) -> Result<HandleRef> {
1684        if request.node_id != request.binding.node_id {
1685            return Err(DagMlError::RuntimeValidation(format!(
1686                "data materialization request node `{}` does not match binding node `{}`",
1687                request.node_id, request.binding.node_id
1688            )));
1689        }
1690        if request.input_name != request.binding.input_name {
1691            return Err(DagMlError::RuntimeValidation(format!(
1692                "data materialization request input `{}` does not match binding input `{}`",
1693                request.input_name, request.binding.input_name
1694            )));
1695        }
1696        let envelope = self
1697            .envelopes
1698            .get(&DataEnvelopeKey::from_binding(&request.binding))
1699            .ok_or_else(|| {
1700                DagMlError::RuntimeValidation(format!(
1701                    "no external data-plan envelope registered for binding `{}` on `{}`",
1702                    request.binding.input_name, request.binding.node_id
1703                ))
1704            })?;
1705        request.binding.validate_envelope(envelope)?;
1706
1707        let handle = HandleRef {
1708            handle: self.next_handle(),
1709            kind: HandleKind::Data,
1710            owner_controller: self.owner_controller.clone(),
1711        };
1712        let record = DataHandleRecord {
1713            handle: handle.clone(),
1714            run_id: request.run_id.clone(),
1715            node_id: request.node_id.clone(),
1716            input_name: request.input_name.clone(),
1717            phase: request.phase,
1718            variant_id: request.variant_id.clone(),
1719            fold_id: request.fold_id.clone(),
1720            request_id: request.binding.request_id.clone(),
1721            schema_fingerprint: request.binding.schema_fingerprint.clone(),
1722            plan_fingerprint: request.binding.plan_fingerprint.clone(),
1723            relation_fingerprint: request.binding.relation_fingerprint.clone(),
1724            output_representation: request.binding.output_representation.clone(),
1725            feature_set_id: request.binding.feature_set_id.clone(),
1726            source_ids: request.binding.source_ids.clone(),
1727            relation_record_count: envelope
1728                .coordinator_relations
1729                .as_ref()
1730                .map(|relations| relations.records.len()),
1731        };
1732        self.records.borrow_mut().insert(handle.handle, record);
1733        Ok(handle)
1734    }
1735
1736    fn make_view(&self, request: &DataViewRequest) -> Result<HandleRef> {
1737        request.view.validate()?;
1738        if request.node_id != request.binding.node_id {
1739            return Err(DagMlError::RuntimeValidation(format!(
1740                "data view request node `{}` does not match binding node `{}`",
1741                request.node_id, request.binding.node_id
1742            )));
1743        }
1744        if request.input_name != request.binding.input_name {
1745            return Err(DagMlError::RuntimeValidation(format!(
1746                "data view request input `{}` does not match binding input `{}`",
1747                request.input_name, request.binding.input_name
1748            )));
1749        }
1750        if request.data_handle.kind != HandleKind::Data {
1751            return Err(DagMlError::RuntimeValidation(format!(
1752                "data view request for `{}` on `{}` received non-data parent handle",
1753                request.input_name, request.node_id
1754            )));
1755        }
1756        let parent = self
1757            .records
1758            .borrow()
1759            .get(&request.data_handle.handle)
1760            .cloned()
1761            .ok_or_else(|| {
1762                DagMlError::RuntimeValidation(format!(
1763                    "unknown data handle `{}` for view request `{}` on `{}`",
1764                    request.data_handle.handle, request.input_name, request.node_id
1765                ))
1766            })?;
1767        if parent.handle != request.data_handle {
1768            return Err(DagMlError::RuntimeValidation(format!(
1769                "data view request parent handle `{}` does not match provider record",
1770                request.data_handle.handle
1771            )));
1772        }
1773        request.binding.validate()?;
1774        let handle = HandleRef {
1775            handle: self.next_handle(),
1776            kind: HandleKind::DataView,
1777            owner_controller: self.owner_controller.clone(),
1778        };
1779        let record = DataViewHandleRecord {
1780            handle: handle.clone(),
1781            parent_handle: request.data_handle.clone(),
1782            run_id: request.run_id.clone(),
1783            node_id: request.node_id.clone(),
1784            input_name: request.input_name.clone(),
1785            phase: request.phase,
1786            variant_id: request.variant_id.clone(),
1787            fold_id: request.fold_id.clone(),
1788            request_id: request.binding.request_id.clone(),
1789            feature_set_id: request.binding.feature_set_id().to_string(),
1790            view: request.view.clone(),
1791        };
1792        self.view_records.borrow_mut().insert(handle.handle, record);
1793        Ok(handle)
1794    }
1795
1796    fn training_data_identity(
1797        &self,
1798        binding: &DataBinding,
1799    ) -> Result<Option<crate::training::TrainingDataIdentity>> {
1800        let envelope = self
1801            .envelopes
1802            .get(&DataEnvelopeKey::from_binding(binding))
1803            .ok_or_else(|| {
1804                DagMlError::RuntimeValidation(format!(
1805                    "no external data-plan envelope registered for binding `{}` on `{}`",
1806                    binding.input_name, binding.node_id
1807                ))
1808            })?;
1809        binding.validate_envelope(envelope)?;
1810        if envelope.relation_fingerprint.is_none()
1811            || envelope.data_content_fingerprint.is_none()
1812            || envelope.target_content_fingerprint.is_none()
1813        {
1814            return Ok(None);
1815        }
1816        Ok(Some(
1817            crate::training::TrainingDataIdentity::from_binding_envelope(binding, envelope)?,
1818        ))
1819    }
1820
1821    fn coordinator_relations(&self, binding: &DataBinding) -> Result<Option<SampleRelationSet>> {
1822        let envelope = self
1823            .envelopes
1824            .get(&DataEnvelopeKey::from_binding(binding))
1825            .ok_or_else(|| {
1826                DagMlError::RuntimeValidation(format!(
1827                    "no external data-plan envelope registered for binding `{}` on `{}`",
1828                    binding.input_name, binding.node_id
1829                ))
1830            })?;
1831        binding.validate_envelope(envelope)?;
1832        Ok(envelope.coordinator_relations.clone())
1833    }
1834}
1835
1836fn validate_fingerprint(label: &str, value: &str) -> Result<()> {
1837    if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1838        return Err(DagMlError::CampaignValidation(format!(
1839            "{label} fingerprint must be a 64-character hex digest"
1840        )));
1841    }
1842    Ok(())
1843}
1844
1845fn validate_non_empty(label: &str, value: &str) -> Result<()> {
1846    if value.trim().is_empty() {
1847        return Err(DagMlError::CampaignValidation(format!(
1848            "{label} must be a non-empty string"
1849        )));
1850    }
1851    Ok(())
1852}
1853
1854fn validate_optional_non_empty(label: &str, value: &Option<String>) -> Result<()> {
1855    if let Some(value) = value {
1856        validate_non_empty(label, value)?;
1857    }
1858    Ok(())
1859}
1860
1861fn validate_combo_like_output(label: &str, unit_level: EntityUnitLevel) -> Result<()> {
1862    if matches!(
1863        unit_level,
1864        EntityUnitLevel::Combo | EntityUnitLevel::Observation
1865    ) {
1866        return Ok(());
1867    }
1868    Err(DagMlError::CampaignValidation(format!(
1869        "{label} representation output_unit_level must be combo or observation"
1870    )))
1871}
1872
1873fn validate_non_empty_list(label: &str, values: &[String]) -> Result<()> {
1874    if values.is_empty() {
1875        return Err(DagMlError::CampaignValidation(format!(
1876            "{label} must be a non-empty list"
1877        )));
1878    }
1879    validate_string_list_entries(label, values)
1880}
1881
1882fn validate_string_list_entries(label: &str, values: &[String]) -> Result<()> {
1883    for (index, value) in values.iter().enumerate() {
1884        if value.trim().is_empty() {
1885            return Err(DagMlError::CampaignValidation(format!(
1886                "{label}[{index}] must be a non-empty string"
1887            )));
1888        }
1889    }
1890    Ok(())
1891}
1892
1893fn validate_unique_strings(label: &str, values: &[String]) -> Result<()> {
1894    let mut seen = BTreeSet::new();
1895    for value in values {
1896        if !seen.insert(value.as_str()) {
1897            return Err(DagMlError::CampaignValidation(format!(
1898                "{label} contains duplicate value `{value}`"
1899            )));
1900        }
1901    }
1902    Ok(())
1903}
1904
1905#[cfg(test)]
1906mod tests {
1907    use super::*;
1908    use crate::ids::NodeId;
1909    use crate::runtime::DataMaterializationRequest;
1910
1911    fn binding() -> DataBinding {
1912        DataBinding {
1913            node_id: NodeId::new("model:base").unwrap(),
1914            input_name: "x".to_string(),
1915            request_id: "nir-to-tabular".to_string(),
1916            schema_fingerprint: "f97b37872fa22134b508f98fd8e207e5b776b52594fb8f6f5c3e15bee212246b"
1917                .to_string(),
1918            plan_fingerprint: "7c5431d85574b3f337022fa5d25971d5b5cf445b90331b49938f573ff6901e4d"
1919                .to_string(),
1920            relation_fingerprint: Some(
1921                "a3a7e329df35db9f2883a17b8611b7fae6dcaa031875e3ec2c9be1b9e29cbe10".to_string(),
1922            ),
1923            output_representation: "tabular_numeric".to_string(),
1924            feature_set_id: Some("x".to_string()),
1925            source_ids: vec!["nir".to_string()],
1926            require_relations: true,
1927            view_policy: DataViewPolicy::default(),
1928            metadata: BTreeMap::new(),
1929        }
1930    }
1931
1932    #[test]
1933    fn validates_data_binding_contract() {
1934        let binding = binding();
1935        binding.validate().unwrap();
1936        assert_eq!(binding.feature_set_id(), "x");
1937    }
1938
1939    #[test]
1940    fn published_model_input_and_data_plan_schemas_declare_current_contract() {
1941        let model_input_schema: serde_json::Value = serde_json::from_str(include_str!(
1942            "../../../docs/contracts/model_input_spec.schema.json"
1943        ))
1944        .unwrap();
1945        assert_eq!(model_input_schema["$id"], MODEL_INPUT_SPEC_SCHEMA_ID);
1946        assert_eq!(
1947            model_input_schema["properties"]["schema_version"]["const"].as_u64(),
1948            Some(MODEL_INPUT_SPEC_SCHEMA_VERSION as u64)
1949        );
1950        assert!(model_input_schema["$defs"]["input_port"]["required"]
1951            .as_array()
1952            .unwrap()
1953            .iter()
1954            .any(|field| field.as_str() == Some("accepted_representations")));
1955        assert!(model_input_schema["$defs"]["fusion_policy"]["properties"]
1956            .as_object()
1957            .unwrap()
1958            .contains_key("representation_plan"));
1959        assert!(model_input_schema["$defs"]
1960            .as_object()
1961            .unwrap()
1962            .contains_key("combination_plan"));
1963        assert!(model_input_schema["$defs"]
1964            .as_object()
1965            .unwrap()
1966            .contains_key("representation_plan"));
1967
1968        let data_plan_schema: serde_json::Value = serde_json::from_str(include_str!(
1969            "../../../docs/contracts/data_plan.schema.json"
1970        ))
1971        .unwrap();
1972        assert_eq!(data_plan_schema["$id"], DATA_PLAN_SCHEMA_ID);
1973        assert_eq!(
1974            data_plan_schema["properties"]["schema_version"]["const"].as_u64(),
1975            Some(DATA_PLAN_SCHEMA_VERSION as u64)
1976        );
1977        assert!(data_plan_schema["$defs"]["data_plan_step_kind"]["enum"]
1978            .as_array()
1979            .unwrap()
1980            .iter()
1981            .any(|kind| kind.as_str() == Some("collate")));
1982    }
1983
1984    #[test]
1985    fn validates_model_input_and_data_plan_fixtures() {
1986        let model_input: ModelInputSpec = serde_json::from_str(include_str!(
1987            "../../../examples/fixtures/data/model_input_spec_tabular_regressor.json"
1988        ))
1989        .unwrap();
1990        model_input.validate().unwrap();
1991        assert_eq!(model_input.ports[0].rank, Some(2));
1992        assert!(model_input.ports[0].multi_source);
1993
1994        let data_plan: DataPlan = serde_json::from_str(include_str!(
1995            "../../../examples/fixtures/data/data_plan_tabular_fusion.json"
1996        ))
1997        .unwrap();
1998        data_plan.validate().unwrap();
1999        assert_eq!(data_plan.output_ports.get("x").unwrap(), "x_collated");
2000    }
2001
2002    #[test]
2003    fn data_plan_rejects_forward_step_references() {
2004        let data_plan = DataPlan {
2005            schema_version: DATA_PLAN_SCHEMA_VERSION,
2006            id: "data-plan:bad".to_string(),
2007            steps: vec![DataPlanStep {
2008                kind: DataPlanStepKind::Adapt,
2009                inputs: vec!["missing".to_string()],
2010                output: "adapted".to_string(),
2011                adapter_id: Some("adapter:adapt".to_string()),
2012                params: BTreeMap::new(),
2013            }],
2014            output_ports: BTreeMap::from([("x".to_string(), "adapted".to_string())]),
2015            warnings: Vec::new(),
2016            requires_user_choice: Vec::new(),
2017            metadata: BTreeMap::new(),
2018        };
2019
2020        let error = data_plan.validate().unwrap_err().to_string();
2021        assert!(error.contains("before it is produced"));
2022    }
2023
2024    #[test]
2025    fn data_view_policy_rejects_unsafe_fit_and_validation_augmentation_by_default() {
2026        let mut full_train_binding = binding();
2027        full_train_binding.view_policy.fit_partition = DataRequestPartition::FullTrain;
2028        let full_train_error = full_train_binding.validate().unwrap_err().to_string();
2029        assert!(
2030            full_train_error.contains("fit_partition=full_train"),
2031            "unexpected full-train error: {full_train_error}"
2032        );
2033
2034        let mut augmented_validation_binding = binding();
2035        augmented_validation_binding
2036            .view_policy
2037            .include_augmented_validation = true;
2038        let augmented_error = augmented_validation_binding
2039            .validate()
2040            .unwrap_err()
2041            .to_string();
2042        assert!(
2043            augmented_error.contains("include_augmented_validation=true"),
2044            "unexpected augmented-validation error: {augmented_error}"
2045        );
2046
2047        let mut excluded_binding = binding();
2048        excluded_binding.view_policy.include_excluded = true;
2049        let excluded_error = excluded_binding.validate().unwrap_err().to_string();
2050        assert!(
2051            excluded_error.contains("include_excluded=true"),
2052            "unexpected excluded-row error: {excluded_error}"
2053        );
2054    }
2055
2056    #[test]
2057    fn data_view_policy_requires_explicit_unsafe_flags_for_debug_views() {
2058        let mut binding = binding();
2059        binding.view_policy.fit_partition = DataRequestPartition::FullTrain;
2060        binding.view_policy.include_augmented_validation = true;
2061        binding.view_policy.include_excluded = true;
2062        binding.view_policy.unsafe_flags = BTreeSet::from([
2063            DataViewPolicy::ALLOW_FIT_CV_FULL_TRAIN_VIEW.to_string(),
2064            DataViewPolicy::ALLOW_AUGMENTED_VALIDATION_VIEW.to_string(),
2065            DataViewPolicy::ALLOW_EXCLUDED_ROWS.to_string(),
2066        ]);
2067
2068        binding.validate().unwrap();
2069    }
2070
2071    #[test]
2072    fn validates_external_data_envelope_subset() {
2073        let envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
2074            "../../../examples/fixtures/data/coordinator_data_plan_envelope_sample12.json"
2075        ))
2076        .unwrap();
2077
2078        assert_eq!(
2079            envelope.schema_version,
2080            EXTERNAL_DATA_PLAN_ENVELOPE_SCHEMA_VERSION
2081        );
2082        binding().validate_envelope(&envelope).unwrap();
2083        assert!(envelope.data_content_fingerprint.is_none());
2084        assert!(envelope.target_content_fingerprint.is_none());
2085    }
2086
2087    #[test]
2088    fn external_data_envelope_content_identity_is_additive_and_validated() {
2089        let mut envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
2090            "../../../examples/fixtures/data/coordinator_data_plan_envelope_sample12.json"
2091        ))
2092        .unwrap();
2093        envelope.data_content_fingerprint = Some("a".repeat(64));
2094        envelope.target_content_fingerprint = Some("b".repeat(64));
2095        envelope.validate().unwrap();
2096
2097        envelope.data_content_fingerprint = Some("not-a-fingerprint".to_string());
2098        assert!(envelope
2099            .validate()
2100            .unwrap_err()
2101            .to_string()
2102            .contains("data content fingerprint"));
2103    }
2104
2105    #[test]
2106    fn in_memory_provider_attests_complete_training_identity() {
2107        let mut envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
2108            "../../../examples/fixtures/data/coordinator_data_plan_envelope_sample12.json"
2109        ))
2110        .unwrap();
2111        envelope.data_content_fingerprint = Some("a".repeat(64));
2112        envelope.target_content_fingerprint = Some("b".repeat(64));
2113        let provider = InMemoryDataProvider::with_envelope(
2114            ControllerId::new("controller:data.provider").unwrap(),
2115            envelope,
2116        )
2117        .unwrap();
2118
2119        let identity = provider
2120            .training_data_identity(&binding())
2121            .unwrap()
2122            .expect("content-aware envelope must attest training identity");
2123        identity.validate().unwrap();
2124        assert_eq!(identity.requirement_key, "model:base.x");
2125        assert_eq!(identity.data_content_fingerprint, "a".repeat(64));
2126        assert_eq!(identity.target_content_fingerprint, "b".repeat(64));
2127    }
2128
2129    #[test]
2130    fn validates_multisource_repetition_envelope_fixture() {
2131        let envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
2132            "../../../examples/fixtures/data/coordinator_data_plan_envelope_multisource_repetitions.json"
2133        ))
2134        .unwrap();
2135
2136        envelope.validate().unwrap();
2137        let relations = envelope.coordinator_relations.as_ref().unwrap();
2138        assert_eq!(relations.records.len(), 8);
2139        let source_counts = relations.records.iter().fold(
2140            BTreeMap::<String, usize>::new(),
2141            |mut counts, record| {
2142                if record.unit_level == EntityUnitLevel::Observation {
2143                    *counts
2144                        .entry(record.source_id.clone().expect("source_id"))
2145                        .or_default() += 1;
2146                }
2147                counts
2148            },
2149        );
2150        assert_eq!(source_counts["A"], 2);
2151        assert_eq!(source_counts["B"], 3);
2152        assert_eq!(source_counts["C"], 2);
2153        let combo = relations
2154            .records
2155            .iter()
2156            .find(|record| record.unit_level == EntityUnitLevel::Combo)
2157            .expect("relation-backed combo row");
2158        assert_eq!(combo.sample_id.as_str(), "sample:1");
2159        assert_eq!(
2160            combo.origin_sample_id.as_ref().unwrap().as_str(),
2161            combo.sample_id.as_str()
2162        );
2163        assert_eq!(combo.component_observation_ids.len(), 3);
2164        for source_id in ["A", "B", "C"] {
2165            assert!(combo
2166                .component_observation_ids
2167                .iter()
2168                .any(|observation_id| observation_id.as_str().contains(source_id)));
2169        }
2170        assert_eq!(
2171            relations
2172                .sample_for_observation(
2173                    &crate::ids::ObservationId::new("obs.s1.combo.A0.B0.C0").unwrap()
2174                )
2175                .unwrap()
2176                .as_str(),
2177            "sample:1"
2178        );
2179    }
2180
2181    #[test]
2182    fn published_external_data_envelope_schema_declares_current_version() {
2183        let schema: serde_json::Value = serde_json::from_str(include_str!(
2184            "../../../docs/contracts/coordinator_data_plan_envelope.schema.json"
2185        ))
2186        .unwrap();
2187
2188        assert_eq!(
2189            schema["properties"]["schema_version"]["const"].as_u64(),
2190            Some(EXTERNAL_DATA_PLAN_ENVELOPE_SCHEMA_VERSION as u64)
2191        );
2192        assert!(schema["required"]
2193            .as_array()
2194            .unwrap()
2195            .iter()
2196            .any(|field| field.as_str() == Some("schema_version")));
2197    }
2198
2199    #[test]
2200    fn refuses_unsupported_external_data_envelope_schema_version() {
2201        let mut envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
2202            "../../../examples/fixtures/data/coordinator_data_plan_envelope_sample12.json"
2203        ))
2204        .unwrap();
2205        envelope.schema_version = EXTERNAL_DATA_PLAN_ENVELOPE_SCHEMA_VERSION + 1;
2206
2207        assert!(binding().validate_envelope(&envelope).is_err());
2208    }
2209
2210    #[test]
2211    fn refuses_envelope_fingerprint_mismatch() {
2212        let mut envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
2213            "../../../examples/fixtures/data/coordinator_data_plan_envelope_sample12.json"
2214        ))
2215        .unwrap();
2216        envelope.plan_fingerprint = "0".repeat(64);
2217
2218        assert!(binding().validate_envelope(&envelope).is_err());
2219    }
2220
2221    #[test]
2222    fn in_memory_provider_materializes_validated_data_handles() {
2223        let envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
2224            "../../../examples/fixtures/data/coordinator_data_plan_envelope_sample12.json"
2225        ))
2226        .unwrap();
2227        let provider = InMemoryDataProvider::with_envelope(
2228            ControllerId::new("controller:data.provider").unwrap(),
2229            envelope,
2230        )
2231        .unwrap();
2232
2233        let handle = provider
2234            .materialize(&DataMaterializationRequest {
2235                run_id: RunId::new("run:data").unwrap(),
2236                node_id: NodeId::new("model:base").unwrap(),
2237                input_name: "x".to_string(),
2238                phase: Phase::FitCv,
2239                variant_id: Some(VariantId::new("variant:base").unwrap()),
2240                fold_id: Some(FoldId::new("fold:0").unwrap()),
2241                binding: binding(),
2242            })
2243            .unwrap();
2244
2245        let record = provider.handle_record(handle.handle).unwrap();
2246        assert_eq!(record.input_name, "x");
2247        assert_eq!(record.relation_record_count, Some(4));
2248        assert_eq!(provider.handle_records().len(), 1);
2249    }
2250
2251    #[test]
2252    fn in_memory_provider_registration_is_idempotent_for_same_envelope() {
2253        let envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
2254            "../../../examples/fixtures/data/coordinator_data_plan_envelope_sample12.json"
2255        ))
2256        .unwrap();
2257        let mut provider =
2258            InMemoryDataProvider::new(ControllerId::new("controller:data.provider").unwrap());
2259
2260        provider.register_envelope(envelope.clone()).unwrap();
2261        provider.register_envelope(envelope).unwrap();
2262    }
2263
2264    #[test]
2265    fn in_memory_provider_refuses_unknown_envelope() {
2266        let provider =
2267            InMemoryDataProvider::new(ControllerId::new("controller:data.provider").unwrap());
2268
2269        assert!(provider
2270            .materialize(&DataMaterializationRequest {
2271                run_id: RunId::new("run:data").unwrap(),
2272                node_id: NodeId::new("model:base").unwrap(),
2273                input_name: "x".to_string(),
2274                phase: Phase::FitCv,
2275                variant_id: None,
2276                fold_id: None,
2277                binding: binding(),
2278            })
2279            .is_err());
2280    }
2281
2282    fn cartesian_combination() -> CombinationPlan {
2283        CombinationPlan {
2284            mode: CombinationMode::Cartesian,
2285            component_source_ids: vec!["source:a".to_string(), "source:b".to_string()],
2286            component_unit_ids: Vec::new(),
2287            match_key: None,
2288            reference_source_id: None,
2289            seed: None,
2290            cap: None,
2291            budget: Some(32),
2292            missing_source_policy: Some(RepresentationMissingSourcePolicy::Strict),
2293            metadata: BTreeMap::new(),
2294        }
2295    }
2296
2297    fn compatibility_report() -> RepresentationCompatibilityReport {
2298        RepresentationCompatibilityReport {
2299            policy: RepresentationMissingSourcePolicy::Mask,
2300            outcome: RepresentationCompatibilityOutcome::CompatibleWithFallback,
2301            fallback_used: Some("mask".to_string()),
2302            warning_severity: Some(RepresentationCompatibilitySeverity::Warning),
2303            affected_source_count: 1,
2304            affected_repetition_count: 2,
2305            affected_sample_count: 3,
2306            train_relation_fingerprint: Some("c".repeat(64)),
2307            predict_relation_fingerprint: Some("d".repeat(64)),
2308            train_unit_count: Some(6),
2309            predict_unit_count: Some(4),
2310            fixed_width_required: true,
2311            final_reducer_stabilizes_output: true,
2312            cartesian_combo_count_changed: true,
2313            late_fusion_branch_delta: true,
2314            messages: vec!["mask fallback applied for missing source".to_string()],
2315            metadata: BTreeMap::new(),
2316        }
2317    }
2318
2319    #[derive(serde::Deserialize)]
2320    #[serde(deny_unknown_fields)]
2321    struct D9GoldenFixture {
2322        schema_version: u32,
2323        golden_scenarios: Vec<D9GoldenScenario>,
2324    }
2325
2326    #[derive(serde::Deserialize)]
2327    #[serde(deny_unknown_fields)]
2328    struct D9GoldenScenario {
2329        scenario_id: String,
2330        flow: Vec<String>,
2331        mock_phase_path: Vec<String>,
2332        representation_replay_manifest: RepresentationReplayManifest,
2333        assertions: Vec<String>,
2334    }
2335
2336    #[test]
2337    fn d9_golden_multisource_repetition_manifests_validate() {
2338        let fixture: D9GoldenFixture = serde_json::from_str(include_str!(
2339            "../../../examples/fixtures/runtime/d9_golden_multisource_scenarios.json"
2340        ))
2341        .unwrap();
2342        assert_eq!(fixture.schema_version, 1);
2343        assert_eq!(fixture.golden_scenarios.len(), 7);
2344
2345        let mut scenario_ids = BTreeSet::new();
2346        let mut has_same_repetition_replay = false;
2347        let mut has_changed_repetition_replay = false;
2348        let mut has_combo_meta_fit_influence = false;
2349        for scenario in &fixture.golden_scenarios {
2350            assert!(
2351                scenario_ids.insert(scenario.scenario_id.as_str()),
2352                "duplicate D9 scenario {}",
2353                scenario.scenario_id
2354            );
2355            assert!(!scenario.flow.is_empty());
2356            assert_eq!(scenario.mock_phase_path, ["fit_cv", "refit", "predict"]);
2357            assert!(!scenario.assertions.is_empty());
2358
2359            let manifest = &scenario.representation_replay_manifest;
2360            manifest.validate().unwrap();
2361            assert_eq!(
2362                manifest.final_output_unit_level,
2363                Some(EntityUnitLevel::PhysicalSample),
2364                "{} must publish sample-level outputs",
2365                scenario.scenario_id
2366            );
2367            if manifest.output_unit_level == EntityUnitLevel::Combo {
2368                assert!(
2369                    manifest.final_reduction_id.is_some(),
2370                    "{} must declare combo-to-sample reduction",
2371                    scenario.scenario_id
2372                );
2373                assert!(
2374                    !manifest.combo_selection.is_empty(),
2375                    "{} must retain relation-backed combo identities",
2376                    scenario.scenario_id
2377                );
2378            }
2379
2380            if let (Some(train), Some(predict)) = (
2381                &manifest.train_compatibility,
2382                &manifest.predict_compatibility,
2383            ) {
2384                has_same_repetition_replay |= train.train_unit_count == predict.predict_unit_count
2385                    && train.train_relation_fingerprint == predict.predict_relation_fingerprint;
2386                has_changed_repetition_replay |= train.train_unit_count
2387                    != predict.predict_unit_count
2388                    || train.train_relation_fingerprint != predict.predict_relation_fingerprint;
2389            }
2390
2391            if scenario.scenario_id == "d9.combo_meta_post.relation_backed_adapters" {
2392                has_combo_meta_fit_influence = manifest
2393                    .metadata
2394                    .get("fit_influence_policy")
2395                    .is_some_and(|value| value == "equal_sample_influence");
2396            }
2397        }
2398
2399        assert!(scenario_ids.contains("d9.per_source_aggregate.source_models.sample_reducer"));
2400        assert!(scenario_ids.contains("d9.late_fusion_by_source.prediction_join.meta_model"));
2401        assert!(scenario_ids.contains("d9.cartesian_full.model.combo_to_sample_reducer"));
2402        assert!(scenario_ids.contains("d9.cartesian_mc.deterministic_replay"));
2403        assert!(scenario_ids.contains("d9.stack_fixed.strict_cardinality"));
2404        assert!(scenario_ids.contains("d9.stack_padded_masked.missing_repetition"));
2405        assert!(scenario_ids.contains("d9.combo_meta_post.relation_backed_adapters"));
2406        assert!(has_same_repetition_replay);
2407        assert!(has_changed_repetition_replay);
2408        assert!(has_combo_meta_fit_influence);
2409    }
2410
2411    #[test]
2412    fn representation_plan_validates_cartesian_and_monte_carlo_contracts() {
2413        let cartesian = RepresentationPlan::CartesianProduct(CartesianProductRepresentation {
2414            combination_plan: cartesian_combination(),
2415            output_unit_level: EntityUnitLevel::Combo,
2416            cardinality: RepresentationCardinality::ManyToMany,
2417            preserve_provenance: true,
2418        });
2419        cartesian.validate().unwrap();
2420
2421        let monte_carlo =
2422            RepresentationPlan::MonteCarloCartesian(MonteCarloCartesianRepresentation {
2423                combination_plan: CombinationPlan {
2424                    mode: CombinationMode::SampleK,
2425                    component_source_ids: vec!["source:a".to_string(), "source:b".to_string()],
2426                    component_unit_ids: Vec::new(),
2427                    match_key: None,
2428                    reference_source_id: None,
2429                    seed: Some(42),
2430                    cap: Some(8),
2431                    budget: None,
2432                    missing_source_policy: Some(RepresentationMissingSourcePolicy::Warn),
2433                    metadata: BTreeMap::new(),
2434                },
2435                output_unit_level: EntityUnitLevel::Observation,
2436                cardinality: RepresentationCardinality::BoundedMany,
2437                preserve_provenance: true,
2438            });
2439        monte_carlo.validate().unwrap();
2440
2441        let mut bad = cartesian_combination();
2442        bad.mode = CombinationMode::SampleK;
2443        bad.seed = Some(7);
2444        bad.cap = Some(0);
2445        assert!(bad.validate().is_err());
2446    }
2447
2448    #[test]
2449    fn stack_representations_validate_cardinality_and_mask_policy() {
2450        let fixed = RepresentationPlan::StackFixed(StackFixedRepresentation {
2451            output_unit_level: EntityUnitLevel::SourceSample,
2452            cardinality: RepresentationCardinality::OneToMany,
2453            expected_cardinality: 3,
2454            component_source_ids: vec!["source:a".to_string(), "source:b".to_string()],
2455        });
2456        fixed.validate().unwrap();
2457
2458        let padded = RepresentationPlan::StackPaddedMasked(StackPaddedMaskedRepresentation {
2459            output_unit_level: EntityUnitLevel::SourceSample,
2460            cardinality: RepresentationCardinality::BoundedMany,
2461            expected_cardinality: 4,
2462            missing_source_policy: RepresentationMissingSourcePolicy::Mask,
2463            requires_missing_masks: true,
2464            component_source_ids: vec!["source:a".to_string()],
2465        });
2466        padded.validate().unwrap();
2467
2468        let bad = RepresentationPlan::StackPaddedMasked(StackPaddedMaskedRepresentation {
2469            output_unit_level: EntityUnitLevel::SourceSample,
2470            cardinality: RepresentationCardinality::BoundedMany,
2471            expected_cardinality: 4,
2472            missing_source_policy: RepresentationMissingSourcePolicy::ImputeDeclared,
2473            requires_missing_masks: false,
2474            component_source_ids: Vec::new(),
2475        });
2476        assert!(bad.validate().is_err());
2477    }
2478
2479    #[test]
2480    fn representation_compatibility_report_enforces_missingness_policy() {
2481        compatibility_report().validate().unwrap();
2482
2483        let strict = RepresentationCompatibilityReport {
2484            policy: RepresentationMissingSourcePolicy::Strict,
2485            outcome: RepresentationCompatibilityOutcome::Incompatible,
2486            fallback_used: None,
2487            warning_severity: None,
2488            affected_source_count: 1,
2489            affected_repetition_count: 0,
2490            affected_sample_count: 1,
2491            train_relation_fingerprint: None,
2492            predict_relation_fingerprint: None,
2493            train_unit_count: None,
2494            predict_unit_count: None,
2495            fixed_width_required: false,
2496            final_reducer_stabilizes_output: false,
2497            cartesian_combo_count_changed: false,
2498            late_fusion_branch_delta: false,
2499            messages: Vec::new(),
2500            metadata: BTreeMap::new(),
2501        };
2502        strict.validate().unwrap();
2503
2504        let mut bad_non_strict = compatibility_report();
2505        bad_non_strict.fallback_used = None;
2506        assert!(bad_non_strict.validate().is_err());
2507
2508        let mut bad_fixed_width = compatibility_report();
2509        bad_fixed_width.policy = RepresentationMissingSourcePolicy::ImputeDeclared;
2510        bad_fixed_width.fallback_used = Some("impute_declared".to_string());
2511        assert!(bad_fixed_width.validate().is_err());
2512
2513        let mut bad_cartesian = compatibility_report();
2514        bad_cartesian.final_reducer_stabilizes_output = false;
2515        assert!(bad_cartesian.validate().is_err());
2516
2517        let bad_relation_drift = RepresentationCompatibilityReport {
2518            policy: RepresentationMissingSourcePolicy::Strict,
2519            outcome: RepresentationCompatibilityOutcome::Compatible,
2520            fallback_used: None,
2521            warning_severity: None,
2522            affected_source_count: 0,
2523            affected_repetition_count: 0,
2524            affected_sample_count: 0,
2525            train_relation_fingerprint: Some("a".repeat(64)),
2526            predict_relation_fingerprint: Some("b".repeat(64)),
2527            train_unit_count: Some(3),
2528            predict_unit_count: Some(3),
2529            fixed_width_required: false,
2530            final_reducer_stabilizes_output: true,
2531            cartesian_combo_count_changed: false,
2532            late_fusion_branch_delta: false,
2533            messages: Vec::new(),
2534            metadata: BTreeMap::new(),
2535        };
2536        let error = bad_relation_drift.validate().unwrap_err().to_string();
2537        assert!(
2538            error.contains("relation fingerprint mismatch requires affected units"),
2539            "unexpected D9 relation drift error: {error}"
2540        );
2541
2542        let mut bad_unit_drift = bad_relation_drift;
2543        bad_unit_drift.predict_relation_fingerprint =
2544            bad_unit_drift.train_relation_fingerprint.clone();
2545        bad_unit_drift.predict_unit_count = Some(2);
2546        let error = bad_unit_drift.validate().unwrap_err().to_string();
2547        assert!(
2548            error.contains("unit count mismatch requires affected units"),
2549            "unexpected D9 unit-count drift error: {error}"
2550        );
2551    }
2552
2553    #[test]
2554    fn representation_replay_manifest_round_trips_and_validates() {
2555        let plan = RepresentationPlan::CartesianProduct(CartesianProductRepresentation {
2556            combination_plan: cartesian_combination(),
2557            output_unit_level: EntityUnitLevel::Combo,
2558            cardinality: RepresentationCardinality::ManyToMany,
2559            preserve_provenance: true,
2560        });
2561        let manifest = RepresentationReplayManifest {
2562            manifest_id: "repr:combo.ab".to_string(),
2563            representation_plan: plan,
2564            combination_plan: Some(cartesian_combination()),
2565            output_unit_level: EntityUnitLevel::Combo,
2566            output_representation: Some("combo_observation".to_string()),
2567            relation_fingerprint: Some("a".repeat(64)),
2568            feature_schema_fingerprint: Some("b".repeat(64)),
2569            final_reduction_id: Some("reduction:combo_to_sample".to_string()),
2570            sample_observation_mapping: vec![
2571                RepresentationSampleObservationMapping {
2572                    physical_sample_id: "sample:1".to_string(),
2573                    source_id: "source:a".to_string(),
2574                    observation_ids: vec!["obs:a.1".to_string(), "obs:a.2".to_string()],
2575                },
2576                RepresentationSampleObservationMapping {
2577                    physical_sample_id: "sample:1".to_string(),
2578                    source_id: "source:b".to_string(),
2579                    observation_ids: vec!["obs:b.1".to_string()],
2580                },
2581            ],
2582            combo_selection: vec![RepresentationComboSelectionRecord {
2583                combo_unit_id: "combo:sample1:a1:b1".to_string(),
2584                physical_sample_id: "sample:1".to_string(),
2585                component_observation_ids: vec!["obs:a.1".to_string(), "obs:b.1".to_string()],
2586                seed: Some(42),
2587            }],
2588            qc_policy_refs: vec!["qc:default".to_string()],
2589            outlier_policy_refs: vec!["outlier:none".to_string()],
2590            missing_source_policy: Some(RepresentationMissingSourcePolicy::Strict),
2591            missing_repetition_policy: Some(RepresentationMissingSourcePolicy::Warn),
2592            prediction_representation: Some("sample_prediction".to_string()),
2593            final_output_unit_level: Some(EntityUnitLevel::PhysicalSample),
2594            train_compatibility: Some(RepresentationCompatibilityReport {
2595                policy: RepresentationMissingSourcePolicy::Strict,
2596                outcome: RepresentationCompatibilityOutcome::Compatible,
2597                fallback_used: None,
2598                warning_severity: None,
2599                affected_source_count: 0,
2600                affected_repetition_count: 0,
2601                affected_sample_count: 0,
2602                train_relation_fingerprint: Some("a".repeat(64)),
2603                predict_relation_fingerprint: None,
2604                train_unit_count: Some(1),
2605                predict_unit_count: Some(1),
2606                fixed_width_required: false,
2607                final_reducer_stabilizes_output: true,
2608                cartesian_combo_count_changed: false,
2609                late_fusion_branch_delta: false,
2610                messages: Vec::new(),
2611                metadata: BTreeMap::new(),
2612            }),
2613            predict_compatibility: Some(compatibility_report()),
2614            metadata: BTreeMap::new(),
2615        };
2616
2617        manifest.validate().unwrap();
2618        let encoded = serde_json::to_string(&manifest).unwrap();
2619        let decoded: RepresentationReplayManifest = serde_json::from_str(&encoded).unwrap();
2620        assert_eq!(decoded, manifest);
2621    }
2622}