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
1334pub 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 #[serde(default, skip_serializing_if = "Option::is_none")]
1505 pub data_content_fingerprint: Option<String>,
1506 #[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}