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