1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::campaign::stable_json_fingerprint;
6use crate::canonical::deserialize_external_contract;
7use crate::controller::{
8 ArtifactPolicy, ControllerCapability, ControllerFitScope, ControllerManifest,
9 ControllerRegistry, RngPolicy,
10};
11use crate::controller_adapter::representation_type_id;
12use crate::criteria::{
13 CriterionInput, ImplementationCapability, LossCapability, SemanticSpecKind,
14 TrainingLossRoleReference,
15};
16use crate::data::{
17 BranchViewMode, BranchViewPlan, DataBinding, ExternalDataPlanEnvelope, ModelInputFusionMode,
18 ModelInputPortSpec, ModelInputSpec, RepresentationPlan, SOURCE_INDEX_METADATA_KEY,
19};
20use crate::error::{DagMlError, Result};
21use crate::fold::{FoldSet, NestedCvSpec};
22use crate::generation::{
23 enumerate_variants, generation_spec_fingerprint, GenerationSpec, VariantPlan,
24};
25use crate::graph::{GraphSpec, NodeKind, NodeSpec, PortKind};
26use crate::ids::{ControllerId, FoldId, NodeId, VariantId};
27use crate::phase::Phase;
28use crate::policy::{AggregationPolicy, DataModelShapePlan, LeakageUnitPolicy};
29
30pub const CAMPAIGN_SPEC_SCHEMA_VERSION: u32 = 1;
31pub const CAMPAIGN_SPEC_SCHEMA_ID: &str =
32 "https://github.com/GBeurier/dag-ml/schemas/campaign_spec.v1.schema.json";
33pub const EXECUTION_PLAN_SCHEMA_VERSION: u32 = 1;
34pub const EXECUTION_PLAN_SCHEMA_ID: &str =
35 "https://github.com/GBeurier/dag-ml/schemas/execution_plan.v1.schema.json";
36
37#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
38pub struct SplitInvocation {
39 pub id: String,
40 #[serde(default)]
41 pub controller_id: Option<ControllerId>,
42 #[serde(default)]
43 pub leakage_policy: LeakageUnitPolicy,
44 #[serde(default)]
45 pub params: BTreeMap<String, serde_json::Value>,
46 #[serde(default)]
47 pub fold_set: Option<FoldSet>,
48}
49
50impl SplitInvocation {
51 pub fn validate(&self) -> Result<()> {
52 if self.id.trim().is_empty() {
53 return Err(DagMlError::CampaignValidation(
54 "split invocation id is empty".to_string(),
55 ));
56 }
57 self.leakage_policy.validate()?;
58 if let Some(fold_set) = &self.fold_set {
59 fold_set.validate()?;
60 }
61 Ok(())
62 }
63}
64
65#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
66pub struct CampaignSpec {
67 pub id: String,
68 pub root_seed: Option<u64>,
69 #[serde(default)]
70 pub leakage_policy: LeakageUnitPolicy,
71 #[serde(default)]
72 pub aggregation_policy: AggregationPolicy,
73 #[serde(default)]
74 pub split_invocation: Option<SplitInvocation>,
75 #[serde(default)]
76 pub generation: GenerationSpec,
77 #[serde(default)]
78 pub shape_plans: BTreeMap<NodeId, DataModelShapePlan>,
79 #[serde(default)]
80 pub data_bindings: BTreeMap<NodeId, Vec<DataBinding>>,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub branch_view_plans: Vec<BranchViewPlan>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub inner_cv: Option<NestedCvSpec>,
87 #[serde(default)]
88 pub metadata: BTreeMap<String, serde_json::Value>,
89}
90
91impl CampaignSpec {
92 pub fn from_json(json: &str) -> Result<Self> {
94 let campaign: Self =
95 deserialize_external_contract(json, "campaign", DagMlError::CampaignValidation)?;
96 campaign.validate()?;
97 Ok(campaign)
98 }
99
100 pub fn validate(&self) -> Result<()> {
101 if self.id.trim().is_empty() {
102 return Err(DagMlError::CampaignValidation(
103 "campaign id is empty".to_string(),
104 ));
105 }
106 self.leakage_policy.validate()?;
107 self.aggregation_policy.validate()?;
108 if let Some(inner_cv) = &self.inner_cv {
109 inner_cv.validate()?;
110 }
111 if let Some(split) = &self.split_invocation {
112 split.validate()?;
113 }
114 self.generation.validate()?;
115 for (node_id, shape_plan) in &self.shape_plans {
116 if node_id != &shape_plan.node_id {
117 return Err(DagMlError::CampaignValidation(format!(
118 "shape plan key `{node_id}` does not match node_id `{}`",
119 shape_plan.node_id
120 )));
121 }
122 shape_plan.validate()?;
123 }
124 for (node_id, bindings) in &self.data_bindings {
125 for binding in bindings {
126 if node_id != &binding.node_id {
127 return Err(DagMlError::CampaignValidation(format!(
128 "data binding key `{node_id}` does not match node_id `{}`",
129 binding.node_id
130 )));
131 }
132 binding.validate()?;
133 }
134 }
135 let mut branch_views = BTreeSet::new();
136 for plan in &self.branch_view_plans {
137 plan.validate()?;
138 if !branch_views.insert(plan.view_id.as_str()) {
139 return Err(DagMlError::CampaignValidation(format!(
140 "campaign `{}` contains duplicate branch view `{}`",
141 self.id, plan.view_id
142 )));
143 }
144 }
145 Ok(())
146 }
147
148 pub fn validate_data_envelope_relations(
149 &self,
150 envelope: &ExternalDataPlanEnvelope,
151 ) -> Result<()> {
152 envelope.validate()?;
153 let Some(relations) = &envelope.coordinator_relations else {
154 return Ok(());
155 };
156 let Some(split) = &self.split_invocation else {
157 return Ok(());
158 };
159 let Some(fold_set) = &split.fold_set else {
160 return Ok(());
161 };
162 relations.validate_against_fold_set(fold_set, &self.leakage_policy)?;
163 relations.validate_against_fold_set(fold_set, &split.leakage_policy)?;
164 if let Some(predict_cohort) = &envelope.predict_cohort {
165 predict_cohort.validate_against_cv_fold_set(fold_set)?;
166 predict_cohort.validate_against_cv_relations(relations)?;
167 }
168 Ok(())
169 }
170}
171
172#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
173pub struct GraphPlan {
174 pub graph: GraphSpec,
175 pub topological_order: Vec<NodeId>,
176 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 pub parallel_levels: Vec<Vec<NodeId>>,
178}
179
180impl GraphPlan {
181 pub fn from_graph(graph: GraphSpec) -> Result<Self> {
182 let topological_order = graph.topological_order()?;
183 let parallel_levels = graph.parallel_levels()?;
184 Ok(Self {
185 graph,
186 topological_order,
187 parallel_levels,
188 })
189 }
190
191 pub fn parallel_levels(&self) -> Result<Vec<Vec<NodeId>>> {
192 if self.parallel_levels.is_empty() {
193 return self.graph.parallel_levels();
194 }
195 Ok(self.parallel_levels.clone())
196 }
197}
198
199#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
200pub struct NodePlan {
201 pub node_id: NodeId,
202 pub kind: NodeKind,
203 pub controller_id: ControllerId,
204 pub controller_version: String,
205 pub supported_phases: BTreeSet<Phase>,
206 #[serde(default)]
207 pub controller_capabilities: BTreeSet<ControllerCapability>,
208 #[serde(default, skip_serializing_if = "Vec::is_empty")]
209 pub training_losses: Vec<TrainingLossRoleReference>,
210 pub fit_scope: ControllerFitScope,
211 pub rng_policy: RngPolicy,
212 pub artifact_policy: ArtifactPolicy,
213 pub input_nodes: Vec<NodeId>,
214 pub output_nodes: Vec<NodeId>,
215 pub shape_plan: Option<DataModelShapePlan>,
216 #[serde(default)]
217 pub data_bindings: Vec<DataBinding>,
218 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
219 pub params: BTreeMap<String, serde_json::Value>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub inner_cv: Option<NestedCvSpec>,
224 pub params_fingerprint: String,
225}
226
227impl NodePlan {
228 pub fn training_losses_for_phase(
229 &self,
230 phase: Phase,
231 ) -> impl Iterator<Item = &TrainingLossRoleReference> {
232 self.training_losses
233 .iter()
234 .filter(move |role| role.phases.contains(&phase))
235 }
236
237 pub fn training_loss_fingerprint(&self, phase: Phase) -> Result<Option<String>> {
238 let roles = self.training_losses_for_phase(phase).collect::<Vec<_>>();
239 if roles.is_empty() {
240 Ok(None)
241 } else {
242 stable_json_fingerprint(&roles).map(Some)
243 }
244 }
245}
246
247#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
248pub struct ExecutionPlan {
249 pub id: String,
250 pub graph_plan: GraphPlan,
251 pub campaign: CampaignSpec,
252 pub node_plans: BTreeMap<NodeId, NodePlan>,
253 pub controller_manifests: BTreeMap<ControllerId, ControllerManifest>,
254 pub variants: Vec<VariantPlan>,
255 pub fold_set: Option<FoldSet>,
256 pub graph_fingerprint: String,
257 pub campaign_fingerprint: String,
258 pub controller_fingerprint: String,
259}
260
261#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
262pub struct ExecutionScopePlan {
263 pub scope_id: String,
264 pub phase: Phase,
265 pub variant_id: Option<VariantId>,
266 pub variant_seed: Option<u64>,
267 pub fold_id: Option<FoldId>,
268 pub node_levels: Vec<Vec<NodeId>>,
269}
270
271#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
272pub struct PhaseExecutionSchedule {
273 pub plan_id: String,
274 pub phase: Phase,
275 pub scopes: Vec<ExecutionScopePlan>,
276}
277
278impl ExecutionPlan {
279 pub fn from_json(json: &str) -> Result<Self> {
289 let plan: Self =
290 deserialize_external_contract(json, "execution plan", DagMlError::Planning)?;
291 plan.validate()?;
292 Ok(plan)
293 }
294
295 pub fn with_training_losses(mut self, roles: Vec<TrainingLossRoleReference>) -> Result<Self> {
299 self.validate()?;
300 let mut roles_by_node = BTreeMap::<NodeId, Vec<TrainingLossRoleReference>>::new();
301 for role in roles {
302 role.validate()?;
303 if !self.node_plans.contains_key(&role.node_id) {
304 return Err(DagMlError::Planning(format!(
305 "training loss references unknown plan node `{}`",
306 role.node_id
307 )));
308 }
309 roles_by_node
310 .entry(role.node_id.clone())
311 .or_default()
312 .push(role);
313 }
314 for node_roles in roles_by_node.values_mut() {
315 node_roles.sort_by(|left, right| {
316 (&left.output_id, &left.phases).cmp(&(&right.output_id, &right.phases))
317 });
318 }
319 for (node_id, node_plan) in &mut self.node_plans {
320 node_plan.training_losses = roles_by_node.remove(node_id).unwrap_or_default();
321 }
322 self.validate()?;
323 Ok(self)
324 }
325
326 pub fn validate(&self) -> Result<()> {
327 self.graph_plan.graph.validate()?;
328 self.campaign.validate()?;
329 if !self.graph_plan.parallel_levels.is_empty()
332 && self.graph_plan.parallel_levels != self.graph_plan.graph.parallel_levels()?
333 {
334 return Err(DagMlError::Planning(
335 "graph plan parallel levels do not match graph".to_string(),
336 ));
337 }
338
339 for (controller_id, manifest) in &self.controller_manifests {
343 manifest.validate()?;
344 if controller_id != &manifest.controller_id {
345 return Err(DagMlError::Planning(format!(
346 "controller manifest keyed `{controller_id}` declares id `{}`",
347 manifest.controller_id
348 )));
349 }
350 }
351
352 let recomputed_graph_fingerprint = stable_json_fingerprint(&self.graph_plan.graph)?;
363 if recomputed_graph_fingerprint != self.graph_fingerprint {
364 return Err(DagMlError::Planning(
365 "execution plan graph_fingerprint does not match the embedded graph".to_string(),
366 ));
367 }
368 let recomputed_campaign_fingerprint = stable_json_fingerprint(&self.campaign)?;
369 if recomputed_campaign_fingerprint != self.campaign_fingerprint {
370 return Err(DagMlError::Planning(
371 "execution plan campaign_fingerprint does not match the embedded campaign"
372 .to_string(),
373 ));
374 }
375 let recomputed_controller_fingerprint =
376 stable_json_fingerprint(&self.controller_manifests)?;
377 if recomputed_controller_fingerprint != self.controller_fingerprint {
378 return Err(DagMlError::Planning(
379 "execution plan controller_fingerprint does not match the embedded controller manifests"
380 .to_string(),
381 ));
382 }
383
384 let graph_node_ids = self
389 .graph_plan
390 .graph
391 .nodes
392 .iter()
393 .map(|node| node.id.clone())
394 .collect::<BTreeSet<_>>();
395 for (node_id, plan) in &self.node_plans {
396 if node_id != &plan.node_id {
397 return Err(DagMlError::Planning(format!(
398 "node plan keyed `{node_id}` declares node_id `{}`",
399 plan.node_id
400 )));
401 }
402 }
403 let plan_node_ids = self.node_plans.keys().cloned().collect::<BTreeSet<_>>();
404 if plan_node_ids != graph_node_ids {
405 return Err(DagMlError::Planning(
406 "execution plan node_plans do not exactly cover the graph node-id set".to_string(),
407 ));
408 }
409
410 if self.graph_plan.topological_order != self.graph_plan.graph.topological_order()? {
415 return Err(DagMlError::Planning(
416 "execution plan topological_order does not match the graph".to_string(),
417 ));
418 }
419
420 let graph_nodes_by_id = self
421 .graph_plan
422 .graph
423 .nodes
424 .iter()
425 .map(|node| (node.id.clone(), node))
426 .collect::<BTreeMap<_, _>>();
427 for (node_id, plan) in &self.node_plans {
428 let graph_node = graph_nodes_by_id
429 .get(node_id)
430 .expect("node_plans keys equal graph node ids");
431 if plan.kind != graph_node.kind {
436 return Err(DagMlError::Planning(format!(
437 "node plan `{node_id}` kind does not match graph node kind"
438 )));
439 }
440 if plan.input_nodes != self.graph_plan.graph.upstream_nodes(node_id)
441 || plan.output_nodes != self.graph_plan.graph.downstream_nodes(node_id)
442 {
443 return Err(DagMlError::Planning(format!(
444 "node plan `{node_id}` input/output adjacency does not match the graph"
445 )));
446 }
447 let manifest = self
448 .controller_manifests
449 .get(&plan.controller_id)
450 .ok_or_else(|| {
451 DagMlError::Planning(format!(
452 "missing controller manifest `{}` for node `{node_id}`",
453 plan.controller_id
454 ))
455 })?;
456 if manifest.operator_kind != plan.kind
461 || manifest.controller_version != plan.controller_version
462 || manifest.supported_phases != plan.supported_phases
463 || manifest.capabilities != plan.controller_capabilities
464 || manifest.fit_scope != plan.fit_scope
465 || manifest.rng_policy != plan.rng_policy
466 || manifest.artifact_policy != plan.artifact_policy
467 {
468 return Err(DagMlError::Planning(format!(
469 "node `{node_id}` node plan does not match controller manifest `{}`",
470 manifest.controller_id
471 )));
472 }
473 for binding in &plan.data_bindings {
474 if binding.node_id != *node_id {
475 return Err(DagMlError::Planning(format!(
476 "node plan `{node_id}` contains data binding for `{}`",
477 binding.node_id
478 )));
479 }
480 binding.validate()?;
481 }
482 validate_data_binding_requirements(node_id, plan, manifest, graph_node)?;
483 validate_node_training_losses(plan)?;
484 let actual_params_fingerprint = stable_json_fingerprint(&plan.params)?;
485 if actual_params_fingerprint != plan.params_fingerprint {
486 return Err(DagMlError::Planning(format!(
487 "node plan `{node_id}` params fingerprint does not match params"
488 )));
489 }
490 if let Some(inner_cv) = &plan.inner_cv {
494 inner_cv.validate().map_err(|error| {
495 DagMlError::Planning(format!(
496 "node plan `{node_id}` has invalid inner_cv: {error}"
497 ))
498 })?;
499 }
500 }
501 self.validate_oof_controller_capabilities()?;
502 if let Some(fold_set) = &self.fold_set {
503 fold_set.validate()?;
504 }
505 if self.variants.is_empty() {
506 return Err(DagMlError::Planning(
507 "execution plan has no variants".to_string(),
508 ));
509 }
510 for variant in &self.variants {
511 variant.validate()?;
512 }
513 Ok(())
514 }
515
516 pub fn validate_parallel_controller_capabilities(
517 &self,
518 max_workers: usize,
519 phase: Phase,
520 ) -> Result<()> {
521 if max_workers <= 1 {
522 return Ok(());
523 }
524 let node_ids = self
525 .node_parallel_levels_for_phase(phase)?
526 .into_iter()
527 .flatten()
528 .collect::<Vec<_>>();
529 for node_id in node_ids {
530 let node_plan = self.node_plans.get(&node_id).ok_or_else(|| {
531 DagMlError::Planning(format!("missing node plan for `{node_id}`"))
532 })?;
533 let manifest = self
534 .controller_manifests
535 .get(&node_plan.controller_id)
536 .ok_or_else(|| {
537 DagMlError::Planning(format!(
538 "missing controller manifest `{}` for node `{}`",
539 node_plan.controller_id, node_plan.node_id
540 ))
541 })?;
542 if !manifest.supports_parallel_invocation() {
543 return Err(DagMlError::Planning(format!(
544 "parallel scheduler with {max_workers} workers requires controller `{}` for node `{}` to declare thread_safe or process_safe",
545 manifest.controller_id, node_plan.node_id
546 )));
547 }
548 }
549 Ok(())
550 }
551
552 fn validate_oof_controller_capabilities(&self) -> Result<()> {
553 for edge in &self.graph_plan.graph.edges {
554 if edge.contract.kind != PortKind::Prediction {
555 continue;
556 }
557 let target_plan = self.node_plans.get(&edge.target.node_id).ok_or_else(|| {
558 DagMlError::Planning(format!(
559 "prediction edge target node `{}` has no node plan",
560 edge.target.node_id
561 ))
562 })?;
563 let target_fits = matches!(
564 target_plan.fit_scope,
565 ControllerFitScope::FoldTrain | ControllerFitScope::FullTrain
566 ) && target_plan
567 .supported_phases
568 .iter()
569 .any(|phase| matches!(phase, Phase::FitCv | Phase::Refit));
570 if target_fits && !edge.contract.requires_oof {
571 return Err(DagMlError::Planning(format!(
572 "prediction edge `{}.{}` -> `{}.{}` enters fitting controller `{}` and must require OOF",
573 edge.source.node_id,
574 edge.source.port_name,
575 edge.target.node_id,
576 edge.target.port_name,
577 target_plan.controller_id
578 )));
579 }
580 if !edge.contract.requires_oof {
581 continue;
582 }
583 let source_plan = self.node_plans.get(&edge.source.node_id).ok_or_else(|| {
584 DagMlError::Planning(format!(
585 "OOF edge source node `{}` has no node plan",
586 edge.source.node_id
587 ))
588 })?;
589 if !source_plan
590 .controller_capabilities
591 .contains(&ControllerCapability::EmitsPredictions)
592 {
593 return Err(DagMlError::Planning(format!(
594 "OOF edge `{}.{}` -> `{}.{}` requires source controller `{}` to declare emits_predictions",
595 edge.source.node_id,
596 edge.source.port_name,
597 edge.target.node_id,
598 edge.target.port_name,
599 source_plan.controller_id
600 )));
601 }
602 if !target_plan
603 .controller_capabilities
604 .contains(&ControllerCapability::ConsumesOofPredictions)
605 {
606 return Err(DagMlError::Planning(format!(
607 "OOF edge `{}.{}` -> `{}.{}` requires target controller `{}` to declare consumes_oof_predictions",
608 edge.source.node_id,
609 edge.source.port_name,
610 edge.target.node_id,
611 edge.target.port_name,
612 target_plan.controller_id
613 )));
614 }
615 }
616 Ok(())
617 }
618
619 pub fn node_parallel_levels_for_phase(&self, phase: Phase) -> Result<Vec<Vec<NodeId>>> {
620 let levels = self
621 .graph_plan
622 .parallel_levels()?
623 .into_iter()
624 .map(|level| {
625 level
626 .into_iter()
627 .filter(|node_id| {
628 self.node_plans
629 .get(node_id)
630 .is_some_and(|node_plan| node_plan.supported_phases.contains(&phase))
631 })
632 .collect::<Vec<_>>()
633 })
634 .filter(|level| !level.is_empty())
635 .collect::<Vec<_>>();
636 Ok(levels)
637 }
638
639 pub fn campaign_phase_schedule(&self, phase: Phase) -> Result<PhaseExecutionSchedule> {
640 self.validate()?;
641 let node_levels = self.node_parallel_levels_for_phase(phase)?;
642 let fold_ids = if phase == Phase::FitCv {
643 self.fold_set
644 .as_ref()
645 .map(|fold_set| {
646 fold_set
647 .folds
648 .iter()
649 .map(|fold| Some(fold.fold_id.clone()))
650 .collect::<Vec<_>>()
651 })
652 .unwrap_or_else(|| vec![None])
653 } else {
654 vec![None]
655 };
656 let mut scopes = Vec::new();
657 for variant in &self.variants {
658 for fold_id in &fold_ids {
659 scopes.push(ExecutionScopePlan {
660 scope_id: execution_scope_id(
661 phase,
662 Some(&variant.variant_id),
663 fold_id.as_ref(),
664 ),
665 phase,
666 variant_id: Some(variant.variant_id.clone()),
667 variant_seed: variant.seed,
668 fold_id: fold_id.clone(),
669 node_levels: node_levels.clone(),
670 });
671 }
672 }
673 Ok(PhaseExecutionSchedule {
674 plan_id: self.id.clone(),
675 phase,
676 scopes,
677 })
678 }
679
680 pub fn branch_view_for(&self, branch_id: &str) -> Option<&BranchViewPlan> {
684 branch_view_for_in(&self.campaign.branch_view_plans, branch_id)
685 }
686
687 pub fn branch_view_for_path(&self, branch_path: &[String]) -> Option<&BranchViewPlan> {
694 branch_view_for_path_in(&self.campaign.branch_view_plans, branch_path)
695 }
696}
697
698fn validate_data_binding_requirements(
699 node_id: &NodeId,
700 plan: &NodePlan,
701 manifest: &ControllerManifest,
702 node: &NodeSpec,
703) -> Result<()> {
704 let branch_view = branch_view_plan_from_node_metadata(node)?;
705 let Some(model_input) = manifest.model_input_spec()? else {
706 for binding in &plan.data_bindings {
707 let effective_source_ids = effective_binding_source_ids(binding, branch_view.as_ref())?;
708 if effective_source_ids.len() > 1 {
709 return Err(data_requirement_refusal(
710 "dagml.data_requirement.missing_data_requirements",
711 node_id,
712 binding,
713 manifest,
714 "multisource",
715 &effective_source_ids,
716 "multi-source data binding requires controller data_requirements".to_string(),
717 ));
718 }
719 }
720 return Ok(());
721 };
722 for binding in &plan.data_bindings {
723 let Some(port) = model_input
724 .ports
725 .iter()
726 .find(|port| port.name == binding.input_name)
727 else {
728 return Err(DagMlError::Planning(format!(
729 "node `{node_id}` data binding `{}` is not declared by controller `{}` data_requirements",
730 binding.input_name, manifest.controller_id
731 )));
732 };
733 if !port
734 .accepted_representations
735 .iter()
736 .any(|representation| representation == &binding.output_representation)
737 {
738 return Err(DagMlError::Planning(format!(
739 "node `{node_id}` data binding `{}` output representation `{}` is not accepted by controller `{}` data_requirements port `{}`",
740 binding.input_name,
741 binding.output_representation,
742 manifest.controller_id,
743 port.name
744 )));
745 }
746 if let Some(type_id) = representation_type_id(&binding.output_representation) {
747 if !port
748 .accepted_types
749 .iter()
750 .any(|accepted_type| accepted_type.as_str() == type_id)
751 {
752 return Err(DagMlError::Planning(format!(
753 "node `{node_id}` data binding `{}` output representation `{}` has registered type `{type_id}` but controller `{}` data_requirements port `{}` accepts types {:?}",
754 binding.input_name,
755 binding.output_representation,
756 manifest.controller_id,
757 port.name,
758 port.accepted_types
759 )));
760 }
761 }
762 validate_data_binding_source_shape(
763 node_id,
764 binding,
765 port,
766 &model_input,
767 manifest,
768 branch_view.as_ref(),
769 )?;
770 }
771 Ok(())
772}
773
774fn validate_node_training_losses(plan: &NodePlan) -> Result<()> {
775 let mut previous_key: Option<(Option<String>, BTreeSet<Phase>)> = None;
776 let mut occupied_phases = BTreeSet::new();
777 for role in &plan.training_losses {
778 role.validate()?;
779 if role.node_id != plan.node_id {
780 return Err(DagMlError::Planning(format!(
781 "node plan `{}` contains training loss for `{}`",
782 plan.node_id, role.node_id
783 )));
784 }
785 let key = (role.output_id.clone(), role.phases.clone());
786 if previous_key
787 .as_ref()
788 .is_some_and(|previous| previous >= &key)
789 {
790 return Err(DagMlError::Planning(format!(
791 "node plan `{}` training losses must be strictly sorted by output_id and phases",
792 plan.node_id
793 )));
794 }
795 previous_key = Some(key);
796 for phase in &role.phases {
797 if !plan.supported_phases.contains(phase) {
798 return Err(DagMlError::Planning(format!(
799 "node `{}` has a training loss for unsupported phase {phase:?}",
800 plan.node_id
801 )));
802 }
803 if !occupied_phases.insert((role.output_id.clone(), *phase)) {
804 return Err(DagMlError::Planning(format!(
805 "node `{}` has overlapping training losses for output {:?} in phase {phase:?}",
806 plan.node_id, role.output_id
807 )));
808 }
809 }
810 if !plan
811 .controller_capabilities
812 .contains(&ControllerCapability::SupportsConfigurableLoss)
813 {
814 return Err(DagMlError::Planning(format!(
815 "node `{}` configures a training loss but its controller does not support configurable loss",
816 plan.node_id
817 )));
818 }
819 if role.loss.spec.kind == SemanticSpecKind::Custom
820 && !plan
821 .controller_capabilities
822 .contains(&ControllerCapability::SupportsCustomLoss)
823 {
824 return Err(DagMlError::Planning(format!(
825 "node `{}` configures a custom loss but its controller does not support custom loss",
826 plan.node_id
827 )));
828 }
829 if role
830 .loss
831 .spec
832 .capabilities
833 .contains(&LossCapability::Differentiable)
834 && !plan
835 .controller_capabilities
836 .contains(&ControllerCapability::SupportsDifferentiableLoss)
837 {
838 return Err(DagMlError::Planning(format!(
839 "node `{}` configures a differentiable loss but its controller does not support differentiable loss",
840 plan.node_id
841 )));
842 }
843 if role
844 .loss
845 .spec
846 .required_inputs
847 .contains(&CriterionInput::SampleWeight)
848 && !plan
849 .controller_capabilities
850 .contains(&ControllerCapability::SupportsSampleWeights)
851 {
852 return Err(DagMlError::Planning(format!(
853 "node `{}` loss requires sample weights but its controller does not support them",
854 plan.node_id
855 )));
856 }
857 if role
858 .loss
859 .spec
860 .required_inputs
861 .contains(&CriterionInput::MissingMask)
862 && !plan
863 .controller_capabilities
864 .contains(&ControllerCapability::SupportsMissingMasks)
865 {
866 return Err(DagMlError::Planning(format!(
867 "node `{}` loss requires missing masks but its controller does not support them",
868 plan.node_id
869 )));
870 }
871 if role
872 .loss
873 .implementation
874 .capabilities
875 .contains(&ImplementationCapability::NeedsGil)
876 && !plan
877 .controller_capabilities
878 .contains(&ControllerCapability::NeedsPythonGil)
879 {
880 return Err(DagMlError::Planning(format!(
881 "node `{}` loss implementation needs the Python GIL but its controller does not declare it",
882 plan.node_id
883 )));
884 }
885 }
886 Ok(())
887}
888
889fn branch_view_plan_from_node_metadata(node: &NodeSpec) -> Result<Option<BranchViewPlan>> {
890 let Some(value) = node.metadata.get("dsl_branch_view_plan") else {
891 return Ok(None);
892 };
893 let plan: BranchViewPlan = serde_json::from_value(value.clone()).map_err(|error| {
894 DagMlError::Planning(format!(
895 "node `{}` carries malformed `dsl_branch_view_plan` metadata: {error}",
896 node.id
897 ))
898 })?;
899 plan.validate()
900 .map_err(|error| DagMlError::Planning(error.to_string()))?;
901 Ok(Some(plan))
902}
903
904fn validate_data_binding_source_shape(
905 node_id: &NodeId,
906 binding: &DataBinding,
907 port: &ModelInputPortSpec,
908 model_input: &ModelInputSpec,
909 manifest: &ControllerManifest,
910 branch_view: Option<&BranchViewPlan>,
911) -> Result<()> {
912 let effective_source_ids = effective_binding_source_ids(binding, branch_view)?;
913 if effective_source_ids.len() < 2 {
914 return Ok(());
915 }
916 if !port.multi_source {
917 return Err(data_requirement_refusal(
918 "dagml.data_requirement.multi_source_port_not_supported",
919 node_id,
920 binding,
921 manifest,
922 "multisource",
923 &effective_source_ids,
924 format!(
925 "controller `{}` data_requirements port `{}` does not declare multi_source=true",
926 manifest.controller_id, port.name
927 ),
928 ));
929 }
930 let Some(fusion) = &model_input.default_fusion else {
931 return Err(data_requirement_refusal(
932 "dagml.data_requirement.missing_multisource_fusion",
933 node_id,
934 binding,
935 manifest,
936 "multisource",
937 &effective_source_ids,
938 "multi-source data binding requires an explicit default_fusion policy".to_string(),
939 ));
940 };
941 validate_fusion_sources_match_binding(
942 node_id,
943 binding,
944 manifest,
945 fusion.representation_plan.as_ref(),
946 &effective_source_ids,
947 )?;
948 match fusion.mode {
949 ModelInputFusionMode::ConcatenateFeatures => {
950 if binding
951 .metadata
952 .get(SOURCE_INDEX_METADATA_KEY)
953 .and_then(serde_json::Value::as_object)
954 .is_none()
955 {
956 return Err(data_requirement_refusal(
957 "dagml.data_requirement.source_concat_requires_source_index",
958 node_id,
959 binding,
960 manifest,
961 "source_concat",
962 &effective_source_ids,
963 "source-concat feature fusion requires data binding metadata.source_index so feature-axis blocks are explicit".to_string(),
964 ));
965 }
966 }
967 ModelInputFusionMode::DictBySource | ModelInputFusionMode::Custom => {}
968 ModelInputFusionMode::SingleSource | ModelInputFusionMode::StackSamples => {
969 let fusion_mode = fusion_mode_label(fusion.mode);
970 return Err(data_requirement_refusal(
971 "dagml.data_requirement.unsupported_multisource_fusion_mode",
972 node_id,
973 binding,
974 manifest,
975 fusion_mode,
976 &effective_source_ids,
977 format!(
978 "multi-source data binding cannot be planned with default_fusion.mode={fusion_mode}"
979 ),
980 ));
981 }
982 }
983 Ok(())
984}
985
986fn fusion_mode_label(mode: ModelInputFusionMode) -> &'static str {
987 match mode {
988 ModelInputFusionMode::SingleSource => "single_source",
989 ModelInputFusionMode::ConcatenateFeatures => "concatenate_features",
990 ModelInputFusionMode::StackSamples => "stack_samples",
991 ModelInputFusionMode::DictBySource => "dict_by_source",
992 ModelInputFusionMode::Custom => "custom",
993 }
994}
995
996fn effective_binding_source_ids(
997 binding: &DataBinding,
998 branch_view: Option<&BranchViewPlan>,
999) -> Result<Vec<String>> {
1000 let Some(branch_view) = branch_view else {
1001 return Ok(binding.source_ids.clone());
1002 };
1003 if branch_view.mode != BranchViewMode::BySource {
1004 return Ok(binding.source_ids.clone());
1005 }
1006 if branch_view.selector.source_ids.len() != 1 {
1007 return Err(data_requirement_refusal_for_branch(
1008 "dagml.data_requirement.unsupported_by_source_shape",
1009 binding,
1010 branch_view,
1011 "by_source branch views must select exactly one source_id for per-source X-chain fit semantics".to_string(),
1012 ));
1013 }
1014 if !binding.source_ids.is_empty() {
1015 let declared = binding.source_ids.iter().collect::<BTreeSet<_>>();
1016 for source_id in &branch_view.selector.source_ids {
1017 if !declared.contains(source_id) {
1018 return Err(data_requirement_refusal_for_branch(
1019 "dagml.data_requirement.by_source_selector_outside_binding",
1020 binding,
1021 branch_view,
1022 format!(
1023 "by_source branch selector source `{source_id}` is not declared by data binding source_ids"
1024 ),
1025 ));
1026 }
1027 }
1028 }
1029 Ok(branch_view.selector.source_ids.clone())
1030}
1031
1032fn validate_fusion_sources_match_binding(
1033 node_id: &NodeId,
1034 binding: &DataBinding,
1035 manifest: &ControllerManifest,
1036 representation_plan: Option<&RepresentationPlan>,
1037 effective_source_ids: &[String],
1038) -> Result<()> {
1039 let Some(representation_plan) = representation_plan else {
1040 return Ok(());
1041 };
1042 let component_sources = representation_plan_component_sources(representation_plan);
1043 if component_sources.is_empty() {
1044 return Ok(());
1045 }
1046 let declared = component_sources.iter().cloned().collect::<BTreeSet<_>>();
1047 let effective = effective_source_ids.iter().collect::<BTreeSet<_>>();
1048 if declared != effective {
1049 return Err(data_requirement_refusal(
1050 "dagml.data_requirement.representation_sources_mismatch",
1051 node_id,
1052 binding,
1053 manifest,
1054 "multisource",
1055 effective_source_ids,
1056 format!(
1057 "default_fusion.representation_plan component_source_ids {:?} do not match binding source_ids {:?}",
1058 component_sources, effective_source_ids
1059 ),
1060 ));
1061 }
1062 Ok(())
1063}
1064
1065fn representation_plan_component_sources(plan: &RepresentationPlan) -> Vec<&String> {
1066 match plan {
1067 RepresentationPlan::Aggregate(_) => Vec::new(),
1068 RepresentationPlan::CartesianProduct(plan) => {
1069 plan.combination_plan.component_source_ids.iter().collect()
1070 }
1071 RepresentationPlan::MonteCarloCartesian(plan) => {
1072 plan.combination_plan.component_source_ids.iter().collect()
1073 }
1074 RepresentationPlan::StackFixed(plan) => plan.component_source_ids.iter().collect(),
1075 RepresentationPlan::StackPaddedMasked(plan) => plan.component_source_ids.iter().collect(),
1076 }
1077}
1078
1079fn data_requirement_refusal(
1080 code: &'static str,
1081 node_id: &NodeId,
1082 binding: &DataBinding,
1083 manifest: &ControllerManifest,
1084 shape: &str,
1085 source_ids: &[String],
1086 message: String,
1087) -> DagMlError {
1088 DagMlError::Planning(format!(
1089 "data requirement refusal: {}",
1090 serde_json::json!({
1091 "schema_version": 1,
1092 "code": code,
1093 "node_id": node_id.to_string(),
1094 "input_name": binding.input_name.as_str(),
1095 "controller_id": manifest.controller_id.to_string(),
1096 "shape": shape,
1097 "source_ids": source_ids,
1098 "message": message
1099 })
1100 ))
1101}
1102
1103fn data_requirement_refusal_for_branch(
1104 code: &'static str,
1105 binding: &DataBinding,
1106 branch_view: &BranchViewPlan,
1107 message: String,
1108) -> DagMlError {
1109 DagMlError::Planning(format!(
1110 "data requirement refusal: {}",
1111 serde_json::json!({
1112 "schema_version": 1,
1113 "code": code,
1114 "node_id": binding.node_id.to_string(),
1115 "input_name": binding.input_name.as_str(),
1116 "branch_view_id": branch_view.view_id.as_str(),
1117 "branch_id": branch_view.branch_id.as_str(),
1118 "shape": "by_source",
1119 "source_ids": &branch_view.selector.source_ids,
1120 "message": message
1121 })
1122 ))
1123}
1124
1125fn branch_view_for_in<'a>(
1126 plans: &'a [BranchViewPlan],
1127 branch_id: &str,
1128) -> Option<&'a BranchViewPlan> {
1129 plans.iter().find(|plan| plan.branch_id == branch_id)
1130}
1131
1132fn branch_view_for_path_in<'a>(
1133 plans: &'a [BranchViewPlan],
1134 branch_path: &[String],
1135) -> Option<&'a BranchViewPlan> {
1136 for branch_id in branch_path.iter().rev() {
1137 if let Some(plan) = branch_view_for_in(plans, branch_id) {
1138 return Some(plan);
1139 }
1140 }
1141 None
1142}
1143
1144fn execution_scope_id(
1145 phase: Phase,
1146 variant_id: Option<&VariantId>,
1147 fold_id: Option<&FoldId>,
1148) -> String {
1149 format!(
1150 "scope:{}:{}:{}",
1151 phase_scope_label(phase),
1152 variant_id
1153 .map(ToString::to_string)
1154 .unwrap_or_else(|| "base".to_string()),
1155 fold_id
1156 .map(ToString::to_string)
1157 .unwrap_or_else(|| "nofold".to_string())
1158 )
1159}
1160
1161fn phase_scope_label(phase: Phase) -> &'static str {
1162 match phase {
1163 Phase::Compile => "COMPILE",
1164 Phase::Plan => "PLAN",
1165 Phase::FitCv => "FIT_CV",
1166 Phase::Select => "SELECT",
1167 Phase::Refit => "REFIT",
1168 Phase::Predict => "PREDICT",
1169 Phase::Explain => "EXPLAIN",
1170 }
1171}
1172
1173pub fn build_execution_plan(
1174 id: impl Into<String>,
1175 graph: GraphSpec,
1176 campaign: CampaignSpec,
1177 registry: &ControllerRegistry,
1178) -> Result<ExecutionPlan> {
1179 let id = id.into();
1180 if id.trim().is_empty() {
1181 return Err(DagMlError::Planning(
1182 "execution plan id is empty".to_string(),
1183 ));
1184 }
1185 campaign.validate()?;
1186 let graph_plan = GraphPlan::from_graph(graph)?;
1187 validate_campaign_node_targets(&graph_plan.graph, &campaign)?;
1188
1189 let mut node_plans = BTreeMap::new();
1190 let mut controller_manifests = BTreeMap::new();
1191 for node_id in &graph_plan.topological_order {
1192 let node = graph_plan
1193 .graph
1194 .nodes
1195 .iter()
1196 .find(|node| &node.id == node_id)
1197 .expect("topological node exists");
1198 let manifest = registry.resolve_for_node(node)?;
1199 let params = node.params.clone();
1200 let params_fingerprint = stable_json_fingerprint(¶ms)?;
1201 let inner_cv = match node.metadata.get("dsl_inner_cv") {
1205 Some(value) => {
1206 let spec =
1207 serde_json::from_value::<NestedCvSpec>(value.clone()).map_err(|error| {
1208 DagMlError::Planning(format!(
1209 "node `{}` has invalid dsl_inner_cv metadata: {error}",
1210 node.id
1211 ))
1212 })?;
1213 spec.validate().map_err(|error| {
1216 DagMlError::Planning(format!(
1217 "node `{}` has invalid dsl_inner_cv metadata: {error}",
1218 node.id
1219 ))
1220 })?;
1221 Some(spec)
1222 }
1223 None => None,
1224 };
1225 let shape_plan = campaign.shape_plans.get(&node.id).cloned();
1226 let data_bindings = campaign
1227 .data_bindings
1228 .get(&node.id)
1229 .cloned()
1230 .unwrap_or_default();
1231 node_plans.insert(
1232 node.id.clone(),
1233 NodePlan {
1234 inner_cv,
1235 node_id: node.id.clone(),
1236 kind: node.kind.clone(),
1237 controller_id: manifest.controller_id.clone(),
1238 controller_version: manifest.controller_version.clone(),
1239 supported_phases: manifest.supported_phases.clone(),
1240 controller_capabilities: manifest.capabilities.clone(),
1241 training_losses: Vec::new(),
1242 fit_scope: manifest.fit_scope,
1243 rng_policy: manifest.rng_policy,
1244 artifact_policy: manifest.artifact_policy,
1245 input_nodes: graph_plan.graph.upstream_nodes(&node.id),
1246 output_nodes: graph_plan.graph.downstream_nodes(&node.id),
1247 shape_plan,
1248 data_bindings,
1249 params,
1250 params_fingerprint,
1251 },
1252 );
1253 controller_manifests.insert(manifest.controller_id.clone(), manifest);
1254 }
1255
1256 let fold_set = campaign
1257 .split_invocation
1258 .as_ref()
1259 .and_then(|split| split.fold_set.clone());
1260 validate_search_space_fingerprint(&graph_plan.graph, &campaign)?;
1261 let variants = enumerate_variants(&campaign.generation, campaign.root_seed)?;
1262 validate_generation_override_targets(&graph_plan.graph, &variants)?;
1263 let graph_fingerprint = stable_json_fingerprint(&graph_plan.graph)?;
1264 let campaign_fingerprint = stable_json_fingerprint(&campaign)?;
1265 let controller_fingerprint = stable_json_fingerprint(&controller_manifests)?;
1266 let plan = ExecutionPlan {
1267 id,
1268 graph_plan,
1269 campaign,
1270 node_plans,
1271 controller_manifests,
1272 variants,
1273 fold_set,
1274 graph_fingerprint,
1275 campaign_fingerprint,
1276 controller_fingerprint,
1277 };
1278 plan.validate()?;
1279 Ok(plan)
1280}
1281
1282fn validate_search_space_fingerprint(graph: &GraphSpec, campaign: &CampaignSpec) -> Result<()> {
1283 let Some(expected_fingerprint) = &graph.search_space_fingerprint else {
1284 return Ok(());
1285 };
1286 if expected_fingerprint.trim().is_empty() {
1287 return Err(DagMlError::Planning(format!(
1288 "graph `{}` has empty search_space_fingerprint",
1289 graph.id
1290 )));
1291 }
1292 let actual_fingerprint = generation_spec_fingerprint(&campaign.generation)?;
1293 if expected_fingerprint != &actual_fingerprint {
1294 return Err(DagMlError::Planning(format!(
1295 "graph `{}` search_space_fingerprint does not match campaign generation spec",
1296 graph.id
1297 )));
1298 }
1299 Ok(())
1300}
1301
1302fn validate_generation_override_targets(graph: &GraphSpec, variants: &[VariantPlan]) -> Result<()> {
1303 let node_ids = graph
1304 .nodes
1305 .iter()
1306 .map(|node| node.id.clone())
1307 .collect::<BTreeSet<_>>();
1308 for variant in variants {
1309 for node_id in variant.param_override_targets()? {
1310 if !node_ids.contains(&node_id) {
1311 return Err(DagMlError::Planning(format!(
1312 "variant `{}` overrides params for unknown node `{node_id}`",
1313 variant.variant_id
1314 )));
1315 }
1316 }
1317 }
1318 Ok(())
1319}
1320
1321fn validate_campaign_node_targets(graph: &GraphSpec, campaign: &CampaignSpec) -> Result<()> {
1322 let node_ids = graph
1323 .nodes
1324 .iter()
1325 .map(|node| &node.id)
1326 .collect::<BTreeSet<_>>();
1327 for node_id in campaign.shape_plans.keys() {
1328 if !node_ids.contains(node_id) {
1329 return Err(DagMlError::Planning(format!(
1330 "shape plan references unknown node `{node_id}`"
1331 )));
1332 }
1333 }
1334 for node_id in campaign.data_bindings.keys() {
1335 if !node_ids.contains(node_id) {
1336 return Err(DagMlError::Planning(format!(
1337 "data binding references unknown node `{node_id}`"
1338 )));
1339 }
1340 }
1341 Ok(())
1342}
1343
1344pub fn prune_plan_to_active(
1376 plan: &ExecutionPlan,
1377 active_nodes: &BTreeSet<NodeId>,
1378 all_choice_nodes: &BTreeSet<NodeId>,
1379 variant: &VariantPlan,
1380) -> Result<ExecutionPlan> {
1381 plan.validate()?;
1382 variant.validate()?;
1383 for node_id in active_nodes {
1384 if !plan.node_plans.contains_key(node_id) {
1385 return Err(DagMlError::Planning(format!(
1386 "operator-SELECT prune: active node `{node_id}` is not in the union plan"
1387 )));
1388 }
1389 }
1390 if active_nodes.is_empty() {
1391 return Err(DagMlError::Planning(
1392 "operator-SELECT prune: active node set is empty".to_string(),
1393 ));
1394 }
1395
1396 let graph = &plan.graph_plan.graph;
1399 let mut ancestors = BTreeSet::<NodeId>::new();
1400 let mut stack: Vec<NodeId> = active_nodes.iter().cloned().collect();
1401 while let Some(node_id) = stack.pop() {
1402 for upstream in graph.upstream_nodes(&node_id) {
1403 if ancestors.insert(upstream.clone()) {
1404 stack.push(upstream);
1405 }
1406 }
1407 }
1408 let shared_prefix = ancestors
1409 .into_iter()
1410 .filter(|node_id| !all_choice_nodes.contains(node_id))
1411 .collect::<BTreeSet<_>>();
1412
1413 let keep = shared_prefix
1414 .iter()
1415 .chain(active_nodes.iter())
1416 .cloned()
1417 .collect::<BTreeSet<_>>();
1418
1419 let mut pruned_graph = graph.clone();
1422 pruned_graph.nodes.retain(|node| keep.contains(&node.id));
1423 pruned_graph
1424 .edges
1425 .retain(|edge| keep.contains(&edge.source.node_id) && keep.contains(&edge.target.node_id));
1426
1427 let graph_plan = GraphPlan::from_graph(pruned_graph)?;
1428
1429 let mut node_plans = BTreeMap::new();
1432 for (node_id, node_plan) in &plan.node_plans {
1433 if !keep.contains(node_id) {
1434 continue;
1435 }
1436 let mut pruned_node_plan = node_plan.clone();
1437 pruned_node_plan.input_nodes = graph_plan.graph.upstream_nodes(node_id);
1438 pruned_node_plan.output_nodes = graph_plan.graph.downstream_nodes(node_id);
1439 node_plans.insert(node_id.clone(), pruned_node_plan);
1440 }
1441
1442 let graph_fingerprint = stable_json_fingerprint(&graph_plan.graph)?;
1443 let pruned = ExecutionPlan {
1444 id: plan.id.clone(),
1445 graph_plan,
1446 campaign: plan.campaign.clone(),
1447 node_plans,
1448 controller_manifests: plan.controller_manifests.clone(),
1449 variants: vec![variant.clone()],
1450 fold_set: plan.fold_set.clone(),
1451 graph_fingerprint,
1452 campaign_fingerprint: plan.campaign_fingerprint.clone(),
1453 controller_fingerprint: plan.controller_fingerprint.clone(),
1454 };
1455 pruned.validate()?;
1456 validate_active_inputs(&pruned, graph)?;
1457 Ok(pruned)
1458}
1459
1460fn validate_active_inputs(plan: &ExecutionPlan, union_graph: &GraphSpec) -> Result<()> {
1474 let pruned_graph = &plan.graph_plan.graph;
1475 let kept: BTreeSet<&NodeId> = pruned_graph.nodes.iter().map(|node| &node.id).collect();
1476
1477 let mut union_port_sources = BTreeMap::<(NodeId, String), usize>::new();
1478 for edge in &union_graph.edges {
1479 if !kept.contains(&edge.target.node_id) {
1480 continue;
1481 }
1482 *union_port_sources
1483 .entry((edge.target.node_id.clone(), edge.target.port_name.clone()))
1484 .or_insert(0) += 1;
1485 }
1486 let mut pruned_port_sources = BTreeMap::<(NodeId, String), usize>::new();
1487 for edge in &pruned_graph.edges {
1488 *pruned_port_sources
1489 .entry((edge.target.node_id.clone(), edge.target.port_name.clone()))
1490 .or_insert(0) += 1;
1491 }
1492
1493 for (key, union_count) in &union_port_sources {
1494 let pruned_count = pruned_port_sources.get(key).copied().unwrap_or(0);
1495 let (node_id, port_name) = key;
1496 if *union_count >= 1 && pruned_count == 0 {
1497 return Err(DagMlError::Planning(format!(
1498 "operator-SELECT prune left node `{node_id}` required input port `{port_name}` with zero surviving sources (dangling): its producer was pruned away"
1499 )));
1500 }
1501 if pruned_count > 1 {
1502 return Err(DagMlError::Planning(format!(
1503 "operator-SELECT prune left node `{node_id}` input port `{port_name}` fed by {pruned_count} surviving sources (ambiguous)"
1504 )));
1505 }
1506 }
1507 Ok(())
1508}
1509
1510#[cfg(test)]
1511mod tests {
1512 use std::collections::{BTreeMap, BTreeSet};
1513 use std::time::{Duration, Instant};
1514
1515 use super::*;
1516 use crate::controller::{
1517 ArtifactPolicy, ControllerCapability, ControllerFitScope, ControllerManifest, RngPolicy,
1518 };
1519 use crate::fold::FoldPartitionMode;
1520
1521 #[test]
1522 fn params_fingerprint_pins_serde_json_binary64_spelling() {
1523 let params = BTreeMap::from([
1524 (
1525 "scope".to_string(),
1526 serde_json::Value::String("train_only".to_string()),
1527 ),
1528 ("std".to_string(), serde_json::Value::from(1e-7_f64)),
1529 ]);
1530 assert_eq!(
1531 stable_json_fingerprint(¶ms).unwrap(),
1532 "3f417903752f65005bc9b69bcd23dfcf3ede2cda010e4f1ead6090a1a407b851"
1533 );
1534
1535 for (value, expected) in [
1538 (1e-7_f64, "1e-7"),
1539 (1e-6_f64, "1e-6"),
1540 (1e-5_f64, "0.00001"),
1541 (1e20_f64, "1e+20"),
1542 (1e21_f64, "1e+21"),
1543 (-0.0_f64, "-0.0"),
1544 (0.1_f64, "0.1"),
1545 (2.0_f64, "2.0"),
1546 (f64::from_bits(1), "5e-324"),
1547 ] {
1548 assert_eq!(serde_json::to_string(&value).unwrap(), expected);
1549 }
1550 }
1551
1552 #[test]
1553 fn inner_cv_is_declarable_at_campaign_and_node_level() {
1554 let campaign_json = r#"{"id":"c","root_seed":null,"inner_cv":{"kind":"kfold","n_splits":3,"shuffle":false,"seed":5}}"#;
1556 let campaign: CampaignSpec = serde_json::from_str(campaign_json).unwrap();
1557 campaign.validate().unwrap();
1558 assert!(campaign.inner_cv.is_some());
1559
1560 let node_inner = crate::fold::NestedCvSpec::KFold(crate::fold::KFoldSpec {
1562 n_splits: 4,
1563 shuffle: false,
1564 seed: Some(6),
1565 });
1566 let resolved = crate::fold::resolve_inner_cv(Some(&node_inner), campaign.inner_cv.as_ref());
1567 assert_eq!(resolved, Some(&node_inner));
1568
1569 let bare = r#"{"id":"c","root_seed":null}"#;
1571 let bare_campaign: CampaignSpec = serde_json::from_str(bare).unwrap();
1572 assert!(bare_campaign.inner_cv.is_none());
1573 let reserialized = serde_json::to_string(&bare_campaign).unwrap();
1574 assert!(!reserialized.contains("inner_cv"));
1575
1576 let bad: CampaignSpec = serde_json::from_str(
1579 r#"{"id":"c","root_seed":null,"inner_cv":{"kind":"kfold","n_splits":1,"shuffle":false,"seed":null}}"#,
1580 )
1581 .unwrap();
1582 let error = bad.validate().unwrap_err();
1583 assert!(error.to_string().contains("at least two splits"));
1584 }
1585
1586 #[test]
1587 fn execution_plan_validate_rejects_invalid_node_local_inner_cv() {
1588 let campaign = CampaignSpec {
1592 inner_cv: None,
1593 id: "campaign:plan-validate".to_string(),
1594 root_seed: Some(7),
1595 leakage_policy: LeakageUnitPolicy::default(),
1596 aggregation_policy: AggregationPolicy::default(),
1597 split_invocation: None,
1598 generation: Default::default(),
1599 shape_plans: BTreeMap::new(),
1600 data_bindings: BTreeMap::new(),
1601 branch_view_plans: Vec::new(),
1602 metadata: BTreeMap::new(),
1603 };
1604 let mut plan =
1605 build_execution_plan("plan:validate", graph(), campaign, ®istry()).unwrap();
1606 plan.validate().unwrap();
1607 plan.node_plans
1608 .get_mut(&NodeId::new("model:pls").unwrap())
1609 .unwrap()
1610 .inner_cv = Some(crate::fold::NestedCvSpec::KFold(crate::fold::KFoldSpec {
1611 n_splits: 1,
1612 shuffle: false,
1613 seed: None,
1614 }));
1615 let error = plan.validate().unwrap_err();
1616 assert!(matches!(error, DagMlError::Planning(_)));
1617 assert!(error.to_string().contains("invalid inner_cv"));
1618 assert!(error.to_string().contains("at least two splits"));
1619 }
1620
1621 fn hardening_plan() -> ExecutionPlan {
1622 let campaign = CampaignSpec {
1623 inner_cv: None,
1624 id: "campaign:harden".to_string(),
1625 root_seed: Some(7),
1626 leakage_policy: LeakageUnitPolicy::default(),
1627 aggregation_policy: AggregationPolicy::default(),
1628 split_invocation: None,
1629 generation: Default::default(),
1630 shape_plans: BTreeMap::new(),
1631 data_bindings: BTreeMap::new(),
1632 branch_view_plans: Vec::new(),
1633 metadata: BTreeMap::new(),
1634 };
1635 build_execution_plan("plan:harden", graph(), campaign, ®istry()).unwrap()
1636 }
1637
1638 #[test]
1639 fn execution_plan_external_reader_rejects_positional_struct_sequences() {
1640 let plan = hardening_plan();
1641 let mut wire = serde_json::to_value(&plan).unwrap();
1642 wire["campaign"]["leakage_policy"] = serde_json::json!([]);
1643
1644 let permissive: ExecutionPlan = serde_json::from_value(wire.clone()).unwrap();
1647 permissive.validate().unwrap();
1648
1649 let error = ExecutionPlan::from_json(&serde_json::to_string(&wire).unwrap()).unwrap_err();
1651 assert!(error.to_string().contains("must use a JSON object"));
1652 assert!(error.to_string().contains("campaign.leakage_policy"));
1653 }
1654
1655 #[test]
1656 fn execution_plan_external_reader_preserves_typed_serde_compatibility() {
1657 let plan = hardening_plan();
1658 let mut wire = serde_json::to_value(&plan).unwrap();
1659 wire["graph_plan"]
1660 .as_object_mut()
1661 .unwrap()
1662 .remove("parallel_levels");
1663 wire["graph_plan"]["graph"]
1664 .as_object_mut()
1665 .unwrap()
1666 .insert("forward_compatible".to_string(), serde_json::json!(true));
1667
1668 let parsed = ExecutionPlan::from_json(&serde_json::to_string(&wire).unwrap()).unwrap();
1669 assert!(parsed.graph_plan.parallel_levels.is_empty());
1670 assert_eq!(parsed.graph_plan.graph, plan.graph_plan.graph);
1671 }
1672
1673 #[test]
1674 fn validate_rejects_manifest_keyed_under_foreign_controller_id() {
1675 let mut plan = hardening_plan();
1676 plan.validate().unwrap();
1677 plan.controller_manifests
1678 .get_mut(&ControllerId::new("controller:model").unwrap())
1679 .unwrap()
1680 .controller_id = ControllerId::new("controller:imposter").unwrap();
1681 let error = plan.validate().unwrap_err();
1682 assert!(matches!(error, DagMlError::Planning(_)));
1683 assert!(error.to_string().contains("declares id"));
1684 }
1685
1686 #[test]
1687 fn validate_rejects_node_plan_keyed_under_foreign_node_id() {
1688 let mut plan = hardening_plan();
1689 plan.validate().unwrap();
1690 plan.node_plans
1691 .get_mut(&NodeId::new("model:pls").unwrap())
1692 .unwrap()
1693 .node_id = NodeId::new("transform:snv").unwrap();
1694 let error = plan.validate().unwrap_err();
1695 assert!(matches!(error, DagMlError::Planning(_)));
1696 assert!(error.to_string().contains("declares node_id"));
1697 }
1698
1699 #[test]
1700 fn validate_rejects_node_plans_not_covering_graph_node_set() {
1701 let mut plan = hardening_plan();
1702 plan.validate().unwrap();
1703 let mut ghost = plan
1706 .node_plans
1707 .remove(&NodeId::new("model:pls").unwrap())
1708 .unwrap();
1709 let bogus = NodeId::new("model:ghost").unwrap();
1710 ghost.node_id = bogus.clone();
1711 plan.node_plans.insert(bogus, ghost);
1712 let error = plan.validate().unwrap_err();
1713 assert!(matches!(error, DagMlError::Planning(_)));
1714 assert!(error
1715 .to_string()
1716 .contains("do not exactly cover the graph node-id set"));
1717 }
1718
1719 #[test]
1720 fn validate_rejects_topological_order_that_omits_a_node() {
1721 let mut plan = hardening_plan();
1722 plan.validate().unwrap();
1723 plan.graph_plan.topological_order = vec![NodeId::new("transform:snv").unwrap()];
1726 let error = plan.validate().unwrap_err();
1727 assert!(matches!(error, DagMlError::Planning(_)));
1728 assert!(error
1729 .to_string()
1730 .contains("topological_order does not match"));
1731 }
1732
1733 #[test]
1734 fn validate_rejects_node_plan_kind_that_differs_from_graph_node() {
1735 let mut plan = hardening_plan();
1736 plan.validate().unwrap();
1737 plan.node_plans
1738 .get_mut(&NodeId::new("model:pls").unwrap())
1739 .unwrap()
1740 .kind = NodeKind::Transform;
1741 let error = plan.validate().unwrap_err();
1742 assert!(matches!(error, DagMlError::Planning(_)));
1743 assert!(error
1744 .to_string()
1745 .contains("kind does not match graph node kind"));
1746 }
1747
1748 #[test]
1749 fn validate_rejects_forged_input_adjacency() {
1750 let mut plan = hardening_plan();
1751 plan.validate().unwrap();
1752 plan.node_plans
1755 .get_mut(&NodeId::new("model:pls").unwrap())
1756 .unwrap()
1757 .input_nodes = vec![];
1758 let error = plan.validate().unwrap_err();
1759 assert!(matches!(error, DagMlError::Planning(_)));
1760 assert!(error
1761 .to_string()
1762 .contains("adjacency does not match the graph"));
1763 }
1764
1765 #[test]
1766 fn validate_rejects_node_plan_supported_phases_that_diverge_from_manifest() {
1767 let mut plan = hardening_plan();
1768 plan.validate().unwrap();
1769 plan.node_plans
1772 .get_mut(&NodeId::new("model:pls").unwrap())
1773 .unwrap()
1774 .supported_phases
1775 .insert(Phase::Explain);
1776 let error = plan.validate().unwrap_err();
1777 assert!(matches!(error, DagMlError::Planning(_)));
1778 assert!(error
1779 .to_string()
1780 .contains("does not match controller manifest"));
1781 }
1782
1783 #[test]
1784 fn validate_rejects_node_plan_controller_version_that_diverges_from_manifest() {
1785 let mut plan = hardening_plan();
1786 plan.validate().unwrap();
1787 plan.node_plans
1788 .get_mut(&NodeId::new("model:pls").unwrap())
1789 .unwrap()
1790 .controller_version = "9.9.9".to_string();
1791 let error = plan.validate().unwrap_err();
1792 assert!(matches!(error, DagMlError::Planning(_)));
1793 assert!(error
1794 .to_string()
1795 .contains("does not match controller manifest"));
1796 }
1797
1798 #[test]
1799 fn validate_rejects_stale_graph_fingerprint_after_graph_mutation() {
1800 let mut plan = hardening_plan();
1801 plan.validate().unwrap();
1802 plan.graph_plan
1807 .graph
1808 .metadata
1809 .insert("tampered".to_string(), serde_json::json!(true));
1810 let error = plan.validate().unwrap_err();
1811 assert!(matches!(error, DagMlError::Planning(_)));
1812 assert!(error
1813 .to_string()
1814 .contains("graph_fingerprint does not match"));
1815 }
1816
1817 #[test]
1818 fn validate_rejects_forged_graph_fingerprint_field() {
1819 let mut plan = hardening_plan();
1820 plan.validate().unwrap();
1821 plan.graph_fingerprint = "sha256:forged".to_string();
1824 let error = plan.validate().unwrap_err();
1825 assert!(matches!(error, DagMlError::Planning(_)));
1826 assert!(error
1827 .to_string()
1828 .contains("graph_fingerprint does not match"));
1829 }
1830
1831 #[test]
1832 fn validate_rejects_stale_campaign_fingerprint_after_campaign_mutation() {
1833 let mut plan = hardening_plan();
1834 plan.validate().unwrap();
1835 plan.campaign
1837 .metadata
1838 .insert("tampered".to_string(), serde_json::json!("x"));
1839 let error = plan.validate().unwrap_err();
1840 assert!(matches!(error, DagMlError::Planning(_)));
1841 assert!(error
1842 .to_string()
1843 .contains("campaign_fingerprint does not match"));
1844 }
1845
1846 #[test]
1847 fn validate_rejects_forged_campaign_fingerprint_field() {
1848 let mut plan = hardening_plan();
1849 plan.validate().unwrap();
1850 plan.campaign_fingerprint = "sha256:forged".to_string();
1851 let error = plan.validate().unwrap_err();
1852 assert!(matches!(error, DagMlError::Planning(_)));
1853 assert!(error
1854 .to_string()
1855 .contains("campaign_fingerprint does not match"));
1856 }
1857
1858 #[test]
1859 fn validate_rejects_stale_controller_fingerprint_after_manifest_mutation() {
1860 let mut plan = hardening_plan();
1861 plan.validate().unwrap();
1862 plan.controller_manifests
1868 .get_mut(&ControllerId::new("controller:model").unwrap())
1869 .unwrap()
1870 .priority = 7;
1871 let error = plan.validate().unwrap_err();
1872 assert!(matches!(error, DagMlError::Planning(_)));
1873 assert!(error
1874 .to_string()
1875 .contains("controller_fingerprint does not match"));
1876 }
1877
1878 #[test]
1879 fn validate_rejects_forged_controller_fingerprint_field() {
1880 let mut plan = hardening_plan();
1881 plan.validate().unwrap();
1882 plan.controller_fingerprint = "sha256:forged".to_string();
1883 let error = plan.validate().unwrap_err();
1884 assert!(matches!(error, DagMlError::Planning(_)));
1885 assert!(error
1886 .to_string()
1887 .contains("controller_fingerprint does not match"));
1888 }
1889
1890 #[test]
1891 fn build_execution_plan_lowers_dsl_inner_cv_metadata_into_node_plan() {
1892 let mut graph = graph();
1893 graph
1894 .nodes
1895 .iter_mut()
1896 .find(|node| node.id.as_str() == "model:pls")
1897 .unwrap()
1898 .metadata
1899 .insert(
1900 "dsl_inner_cv".to_string(),
1901 serde_json::json!({"kind": "kfold", "n_splits": 3, "shuffle": false, "seed": 9}),
1902 );
1903
1904 let campaign = CampaignSpec {
1905 inner_cv: None,
1906 id: "campaign:inner-cv".to_string(),
1907 root_seed: Some(7),
1908 leakage_policy: LeakageUnitPolicy::default(),
1909 aggregation_policy: AggregationPolicy::default(),
1910 split_invocation: None,
1911 generation: Default::default(),
1912 shape_plans: BTreeMap::new(),
1913 data_bindings: BTreeMap::new(),
1914 branch_view_plans: Vec::new(),
1915 metadata: BTreeMap::new(),
1916 };
1917
1918 let plan = build_execution_plan("plan:inner-cv", graph, campaign, ®istry()).unwrap();
1919 match &plan.node_plans[&NodeId::new("model:pls").unwrap()].inner_cv {
1920 Some(crate::fold::NestedCvSpec::KFold(k)) => {
1921 assert_eq!(k.n_splits, 3);
1922 assert_eq!(k.seed, Some(9));
1923 }
1924 other => panic!("expected lowered KFold inner_cv, got {other:?}"),
1925 }
1926 assert!(plan.node_plans[&NodeId::new("transform:snv").unwrap()]
1927 .inner_cv
1928 .is_none());
1929 }
1930
1931 #[test]
1932 fn build_execution_plan_rejects_malformed_dsl_inner_cv_metadata() {
1933 let mut graph = graph();
1934 graph
1935 .nodes
1936 .iter_mut()
1937 .find(|node| node.id.as_str() == "model:pls")
1938 .unwrap()
1939 .metadata
1940 .insert(
1941 "dsl_inner_cv".to_string(),
1942 serde_json::json!({"kind": "not_a_real_kind"}),
1943 );
1944
1945 let campaign = CampaignSpec {
1946 inner_cv: None,
1947 id: "campaign:inner-cv.bad".to_string(),
1948 root_seed: Some(7),
1949 leakage_policy: LeakageUnitPolicy::default(),
1950 aggregation_policy: AggregationPolicy::default(),
1951 split_invocation: None,
1952 generation: Default::default(),
1953 shape_plans: BTreeMap::new(),
1954 data_bindings: BTreeMap::new(),
1955 branch_view_plans: Vec::new(),
1956 metadata: BTreeMap::new(),
1957 };
1958
1959 let error =
1960 build_execution_plan("plan:inner-cv.bad", graph, campaign, ®istry()).unwrap_err();
1961 assert!(matches!(error, DagMlError::Planning(_)));
1962 assert!(error.to_string().contains("invalid dsl_inner_cv metadata"));
1963 }
1964
1965 #[test]
1966 fn build_execution_plan_rejects_semantically_invalid_dsl_inner_cv() {
1967 let mut graph = graph();
1970 graph
1971 .nodes
1972 .iter_mut()
1973 .find(|node| node.id.as_str() == "model:pls")
1974 .unwrap()
1975 .metadata
1976 .insert(
1977 "dsl_inner_cv".to_string(),
1978 serde_json::json!({"kind": "kfold", "n_splits": 1, "shuffle": false, "seed": null}),
1979 );
1980
1981 let campaign = CampaignSpec {
1982 inner_cv: None,
1983 id: "campaign:inner-cv.nsplits".to_string(),
1984 root_seed: Some(7),
1985 leakage_policy: LeakageUnitPolicy::default(),
1986 aggregation_policy: AggregationPolicy::default(),
1987 split_invocation: None,
1988 generation: Default::default(),
1989 shape_plans: BTreeMap::new(),
1990 data_bindings: BTreeMap::new(),
1991 branch_view_plans: Vec::new(),
1992 metadata: BTreeMap::new(),
1993 };
1994
1995 let error = build_execution_plan("plan:inner-cv.nsplits", graph, campaign, ®istry())
1996 .unwrap_err();
1997 assert!(matches!(error, DagMlError::Planning(_)));
1998 assert!(error.to_string().contains("at least two splits"));
1999 }
2000 use crate::data::{
2001 BranchViewMode, BranchViewPlan, DataBinding, DataViewSelector, SOURCE_INDEX_METADATA_KEY,
2002 };
2003 use crate::generation::{
2004 GenerationChoice, GenerationConstraints, GenerationDimension, GenerationParamOverride,
2005 GenerationStrategy,
2006 };
2007 use crate::graph::{
2008 EdgeContract, EdgeSpec, GraphInterface, NodeSpec, PortCardinality, PortKind, PortRef,
2009 PortSchema, PortSpec,
2010 };
2011 use crate::ids::{ControllerId, FoldId, ObservationId, SampleId, TargetId};
2012 use crate::phase::Phase;
2013 use crate::policy::{DataModelShapePlan, Granularity};
2014 use crate::relation::{SampleRelation, SampleRelationSet};
2015
2016 fn port(name: &str, kind: PortKind) -> PortSpec {
2017 PortSpec {
2018 name: name.to_string(),
2019 kind,
2020 representation: None,
2021 cardinality: PortCardinality::One,
2022 unit_level: None,
2023 alignment_key: None,
2024 target_level: None,
2025 description: String::new(),
2026 }
2027 }
2028
2029 fn node(id: &str, kind: NodeKind, inputs: Vec<PortSpec>, outputs: Vec<PortSpec>) -> NodeSpec {
2030 NodeSpec {
2031 id: NodeId::new(id).unwrap(),
2032 kind,
2033 operator: None,
2034 params: BTreeMap::new(),
2035 ports: PortSchema { inputs, outputs },
2036 metadata: BTreeMap::new(),
2037 seed_label: None,
2038 }
2039 }
2040
2041 fn graph() -> GraphSpec {
2042 GraphSpec {
2043 id: "g".to_string(),
2044 interface: GraphInterface::default(),
2045 nodes: vec![
2046 node(
2047 "transform:snv",
2048 NodeKind::Transform,
2049 vec![],
2050 vec![port("x", PortKind::Data)],
2051 ),
2052 node(
2053 "model:pls",
2054 NodeKind::Model,
2055 vec![port("x", PortKind::Data)],
2056 vec![port("pred", PortKind::Prediction)],
2057 ),
2058 ],
2059 edges: vec![EdgeSpec {
2060 source: PortRef {
2061 node_id: NodeId::new("transform:snv").unwrap(),
2062 port_name: "x".to_string(),
2063 },
2064 target: PortRef {
2065 node_id: NodeId::new("model:pls").unwrap(),
2066 port_name: "x".to_string(),
2067 },
2068 contract: EdgeContract {
2069 requires_oof: false,
2070 requires_fold_alignment: false,
2071 ..EdgeContract::new(PortKind::Data, None)
2072 },
2073 }],
2074 search_space_fingerprint: None,
2075 metadata: BTreeMap::new(),
2076 }
2077 }
2078
2079 fn manifest(id: &str, kind: NodeKind) -> ControllerManifest {
2080 let mut capabilities = BTreeSet::from([
2081 ControllerCapability::Deterministic,
2082 ControllerCapability::ThreadSafe,
2083 ControllerCapability::ProcessSafe,
2084 ]);
2085 if kind == NodeKind::Model {
2086 capabilities.insert(ControllerCapability::EmitsPredictions);
2087 capabilities.insert(ControllerCapability::ConsumesOofPredictions);
2088 }
2089 ControllerManifest {
2090 controller_id: ControllerId::new(id).unwrap(),
2091 controller_version: "0.1.0".to_string(),
2092 operator_kind: kind,
2093 priority: 0,
2094 supported_phases: BTreeSet::from([Phase::FitCv, Phase::Refit, Phase::Predict]),
2095 input_ports: Vec::new(),
2096 output_ports: Vec::new(),
2097 data_requirements: None,
2098 capabilities,
2099 operator_selectors: Vec::new(),
2100 fit_scope: ControllerFitScope::FoldTrain,
2101 rng_policy: RngPolicy::UsesCoreSeed,
2102 artifact_policy: ArtifactPolicy::Serializable,
2103 }
2104 }
2105
2106 fn registry() -> ControllerRegistry {
2107 let mut registry = ControllerRegistry::new();
2108 registry
2109 .register(manifest("controller:transform", NodeKind::Transform))
2110 .unwrap();
2111 registry
2112 .register(manifest("controller:model", NodeKind::Model))
2113 .unwrap();
2114 registry
2115 }
2116
2117 fn registry_with_model_data_requirements(
2118 accepted_representations: &[&str],
2119 accepted_types: &[&str],
2120 ) -> ControllerRegistry {
2121 let mut registry = ControllerRegistry::new();
2122 registry
2123 .register(manifest("controller:transform", NodeKind::Transform))
2124 .unwrap();
2125 let mut model = manifest("controller:model", NodeKind::Model);
2126 model.data_requirements = Some(serde_json::json!({
2127 "schema_version": 1,
2128 "ports": [
2129 {
2130 "name": "x",
2131 "accepted_representations": accepted_representations,
2132 "accepted_types": accepted_types,
2133 "rank": 2,
2134 "multi_source": true,
2135 "optional": false
2136 }
2137 ],
2138 "metadata": {
2139 "source": "plan-test"
2140 }
2141 }));
2142 registry.register(model).unwrap();
2143 registry
2144 }
2145
2146 fn registry_with_model_data_requirements_json(
2147 data_requirements: serde_json::Value,
2148 ) -> ControllerRegistry {
2149 let mut registry = ControllerRegistry::new();
2150 registry
2151 .register(manifest("controller:transform", NodeKind::Transform))
2152 .unwrap();
2153 let mut model = manifest("controller:model", NodeKind::Model);
2154 model.data_requirements = Some(data_requirements);
2155 registry.register(model).unwrap();
2156 registry
2157 }
2158
2159 fn model_data_requirements(
2160 multi_source: bool,
2161 default_fusion: Option<serde_json::Value>,
2162 ) -> serde_json::Value {
2163 let mut spec = serde_json::json!({
2164 "schema_version": 1,
2165 "ports": [
2166 {
2167 "name": "x",
2168 "accepted_representations": ["tabular_numeric"],
2169 "accepted_types": ["table"],
2170 "rank": 2,
2171 "multi_source": multi_source,
2172 "optional": false
2173 }
2174 ],
2175 "metadata": {
2176 "source": "plan-test"
2177 }
2178 });
2179 if let Some(default_fusion) = default_fusion {
2180 spec.as_object_mut()
2181 .unwrap()
2182 .insert("default_fusion".to_string(), default_fusion);
2183 }
2184 spec
2185 }
2186
2187 fn multisource_binding(node_id: &NodeId) -> DataBinding {
2188 let mut binding = data_binding(node_id);
2189 binding.request_id = "nir-chem-source-concat".to_string();
2190 binding.feature_set_id = Some("x_fused".to_string());
2191 binding.source_ids = vec!["nir".to_string(), "chem".to_string()];
2192 binding
2193 }
2194
2195 fn add_source_index(binding: &mut DataBinding) {
2196 binding.metadata.insert(
2197 SOURCE_INDEX_METADATA_KEY.to_string(),
2198 serde_json::json!({
2199 "nir": 0,
2200 "chem": 1
2201 }),
2202 );
2203 }
2204
2205 fn source_concat_fusion() -> serde_json::Value {
2206 serde_json::json!({
2207 "mode": "concatenate_features",
2208 "alignment": "sample_id",
2209 "adapter_id": null,
2210 "params": {
2211 "namespace_columns": true
2212 }
2213 })
2214 }
2215
2216 fn by_source_graph(source_ids: Vec<&str>) -> GraphSpec {
2217 let mut graph = graph();
2218 let branch_view = BranchViewPlan {
2219 view_id: "branch_view:source".to_string(),
2220 branch_id: "branch:source".to_string(),
2221 mode: BranchViewMode::BySource,
2222 selector: DataViewSelector {
2223 source_ids: source_ids.into_iter().map(str::to_string).collect(),
2224 ..Default::default()
2225 },
2226 allow_overlap: false,
2227 metadata: BTreeMap::new(),
2228 };
2229 graph
2230 .nodes
2231 .iter_mut()
2232 .find(|node| node.id.as_str() == "model:pls")
2233 .unwrap()
2234 .metadata
2235 .insert(
2236 "dsl_branch_view_plan".to_string(),
2237 serde_json::to_value(branch_view).unwrap(),
2238 );
2239 graph
2240 }
2241
2242 fn refusal_payload(error: DagMlError) -> serde_json::Value {
2243 let message = error.to_string();
2244 let payload = message
2245 .split_once("data requirement refusal: ")
2246 .unwrap_or_else(|| panic!("missing structured refusal payload in: {message}"))
2247 .1;
2248 serde_json::from_str(payload).unwrap()
2249 }
2250
2251 fn campaign(id: &str) -> CampaignSpec {
2252 CampaignSpec {
2253 id: id.to_string(),
2254 root_seed: Some(7),
2255 leakage_policy: LeakageUnitPolicy::default(),
2256 aggregation_policy: AggregationPolicy::default(),
2257 split_invocation: None,
2258 generation: Default::default(),
2259 shape_plans: BTreeMap::new(),
2260 data_bindings: BTreeMap::new(),
2261 branch_view_plans: Vec::new(),
2262 inner_cv: None,
2263 metadata: BTreeMap::new(),
2264 }
2265 }
2266
2267 #[cfg(dag_ml_workspace_contract_fixtures)]
2268 fn custom_loss_role(node_id: &str, output_id: &str) -> TrainingLossRoleReference {
2269 let fixture: serde_json::Value = serde_json::from_str(include_str!(
2270 "../../../examples/fixtures/criteria/javascript_local_implementations.v1.json"
2271 ))
2272 .unwrap();
2273 let mut role: TrainingLossRoleReference =
2274 serde_json::from_value(fixture["training_loss_role"].clone()).unwrap();
2275 role.node_id = NodeId::new(node_id).unwrap();
2276 role.output_id = Some(output_id.to_string());
2277 role
2278 }
2279
2280 #[cfg(dag_ml_workspace_contract_fixtures)]
2281 #[test]
2282 fn execution_plan_lowers_training_losses_in_canonical_order() {
2283 let mut loss_registry = ControllerRegistry::new();
2284 loss_registry
2285 .register(manifest("controller:transform", NodeKind::Transform))
2286 .unwrap();
2287 let mut model_manifest = manifest("controller:model", NodeKind::Model);
2288 model_manifest.capabilities.extend([
2289 ControllerCapability::SupportsConfigurableLoss,
2290 ControllerCapability::SupportsCustomLoss,
2291 ControllerCapability::SupportsDifferentiableLoss,
2292 ]);
2293 loss_registry.register(model_manifest).unwrap();
2294
2295 let plan = build_execution_plan(
2296 "plan:training-loss-lowering",
2297 graph(),
2298 campaign("campaign:training-loss-lowering"),
2299 &loss_registry,
2300 )
2301 .unwrap();
2302 let role_b = custom_loss_role("model:pls", "b");
2303 let role_a = custom_loss_role("model:pls", "a");
2304 let bound = plan
2305 .clone()
2306 .with_training_losses(vec![role_b, role_a.clone()])
2307 .unwrap();
2308 let model = bound
2309 .node_plans
2310 .get(&NodeId::new("model:pls").unwrap())
2311 .unwrap();
2312 assert_eq!(
2313 model
2314 .training_losses
2315 .iter()
2316 .map(|role| role.output_id.as_deref())
2317 .collect::<Vec<_>>(),
2318 vec![Some("a"), Some("b")]
2319 );
2320
2321 let cleared = bound.with_training_losses(Vec::new()).unwrap();
2322 assert!(cleared
2323 .node_plans
2324 .values()
2325 .all(|node| node.training_losses.is_empty()));
2326
2327 let mut unknown = role_a.clone();
2328 unknown.node_id = NodeId::new("model:unknown").unwrap();
2329 assert!(plan
2330 .clone()
2331 .with_training_losses(vec![unknown])
2332 .unwrap_err()
2333 .to_string()
2334 .contains("unknown plan node"));
2335
2336 let incapable = build_execution_plan(
2337 "plan:training-loss-incapable",
2338 graph(),
2339 campaign("campaign:training-loss-incapable"),
2340 ®istry(),
2341 )
2342 .unwrap();
2343 assert!(incapable
2344 .with_training_losses(vec![role_a])
2345 .unwrap_err()
2346 .to_string()
2347 .contains("does not support configurable loss"));
2348 }
2349
2350 #[test]
2351 fn build_execution_plan_consumes_controller_data_requirements_for_bindings() {
2352 let model_id = NodeId::new("model:pls").unwrap();
2353 let mut campaign = campaign("campaign:datareq.ok");
2354 campaign.data_bindings = BTreeMap::from([(
2355 model_id,
2356 vec![data_binding(&NodeId::new("model:pls").unwrap())],
2357 )]);
2358 let plan = build_execution_plan(
2359 "plan:datareq.ok",
2360 graph(),
2361 campaign,
2362 ®istry_with_model_data_requirements(&["tabular_numeric"], &["table"]),
2363 )
2364 .unwrap();
2365 assert_eq!(
2366 plan.node_plans[&NodeId::new("model:pls").unwrap()].data_bindings[0]
2367 .output_representation,
2368 "tabular_numeric"
2369 );
2370 }
2371
2372 #[test]
2373 fn build_execution_plan_rejects_binding_representation_outside_data_requirements() {
2374 let model_id = NodeId::new("model:pls").unwrap();
2375 let mut campaign = campaign("campaign:datareq.representation");
2376 campaign.data_bindings =
2377 BTreeMap::from([(model_id.clone(), vec![data_binding(&model_id)])]);
2378 let error = build_execution_plan(
2379 "plan:datareq.representation",
2380 graph(),
2381 campaign,
2382 ®istry_with_model_data_requirements(&["signal_1d"], &["dense_signal"]),
2383 )
2384 .unwrap_err();
2385 assert!(
2386 error.to_string().contains("output representation"),
2387 "unexpected error: {error}"
2388 );
2389 }
2390
2391 #[test]
2392 fn build_execution_plan_rejects_binding_registered_type_outside_data_requirements() {
2393 let model_id = NodeId::new("model:pls").unwrap();
2394 let mut campaign = campaign("campaign:datareq.type");
2395 campaign.data_bindings =
2396 BTreeMap::from([(model_id.clone(), vec![data_binding(&model_id)])]);
2397 let error = build_execution_plan(
2398 "plan:datareq.type",
2399 graph(),
2400 campaign,
2401 ®istry_with_model_data_requirements(&["tabular_numeric"], &["dense_signal"]),
2402 )
2403 .unwrap_err();
2404 assert!(
2405 error.to_string().contains("registered type `table`"),
2406 "unexpected error: {error}"
2407 );
2408 }
2409
2410 #[test]
2411 fn build_execution_plan_rejects_source_concat_without_source_index() {
2412 let model_id = NodeId::new("model:pls").unwrap();
2413 let mut campaign = campaign("campaign:datareq.source-concat.no-index");
2414 campaign.data_bindings =
2415 BTreeMap::from([(model_id.clone(), vec![multisource_binding(&model_id)])]);
2416
2417 let error = build_execution_plan(
2418 "plan:datareq.source-concat.no-index",
2419 graph(),
2420 campaign,
2421 ®istry_with_model_data_requirements_json(model_data_requirements(
2422 true,
2423 Some(source_concat_fusion()),
2424 )),
2425 )
2426 .unwrap_err();
2427 let payload = refusal_payload(error);
2428
2429 assert_eq!(
2430 payload["code"],
2431 "dagml.data_requirement.source_concat_requires_source_index"
2432 );
2433 assert_eq!(payload["shape"], "source_concat");
2434 assert_eq!(payload["source_ids"], serde_json::json!(["nir", "chem"]));
2435 }
2436
2437 #[test]
2438 fn build_execution_plan_rejects_multisource_binding_without_data_requirements() {
2439 let model_id = NodeId::new("model:pls").unwrap();
2440 let mut campaign = campaign("campaign:datareq.multisource.no-requirements");
2441 campaign.data_bindings =
2442 BTreeMap::from([(model_id.clone(), vec![multisource_binding(&model_id)])]);
2443
2444 let error = build_execution_plan(
2445 "plan:datareq.multisource.no-requirements",
2446 graph(),
2447 campaign,
2448 ®istry(),
2449 )
2450 .unwrap_err();
2451 let payload = refusal_payload(error);
2452
2453 assert_eq!(
2454 payload["code"],
2455 "dagml.data_requirement.missing_data_requirements"
2456 );
2457 assert_eq!(payload["source_ids"], serde_json::json!(["nir", "chem"]));
2458 }
2459
2460 #[test]
2461 fn build_execution_plan_accepts_source_concat_with_source_index() {
2462 let model_id = NodeId::new("model:pls").unwrap();
2463 let mut binding = multisource_binding(&model_id);
2464 add_source_index(&mut binding);
2465 let mut campaign = campaign("campaign:datareq.source-concat.index");
2466 campaign.data_bindings = BTreeMap::from([(model_id.clone(), vec![binding])]);
2467
2468 let plan = build_execution_plan(
2469 "plan:datareq.source-concat.index",
2470 graph(),
2471 campaign,
2472 ®istry_with_model_data_requirements_json(model_data_requirements(
2473 true,
2474 Some(source_concat_fusion()),
2475 )),
2476 )
2477 .unwrap();
2478
2479 assert_eq!(
2480 plan.node_plans[&model_id].data_bindings[0].metadata[SOURCE_INDEX_METADATA_KEY],
2481 serde_json::json!({"nir": 0, "chem": 1})
2482 );
2483 }
2484
2485 #[test]
2486 fn by_source_branch_allows_single_source_fit_from_multisource_binding() {
2487 let model_id = NodeId::new("model:pls").unwrap();
2488 let mut campaign = campaign("campaign:datareq.by-source.single");
2489 campaign.data_bindings =
2490 BTreeMap::from([(model_id.clone(), vec![multisource_binding(&model_id)])]);
2491
2492 let plan = build_execution_plan(
2493 "plan:datareq.by-source.single",
2494 by_source_graph(vec!["nir"]),
2495 campaign,
2496 ®istry_with_model_data_requirements_json(model_data_requirements(false, None)),
2497 )
2498 .unwrap();
2499
2500 assert_eq!(
2501 plan.node_plans[&model_id].data_bindings[0].source_ids.len(),
2502 2
2503 );
2504 }
2505
2506 #[test]
2507 fn by_source_branch_refuses_multi_source_selector_shape() {
2508 let model_id = NodeId::new("model:pls").unwrap();
2509 let mut campaign = campaign("campaign:datareq.by-source.multi");
2510 campaign.data_bindings =
2511 BTreeMap::from([(model_id.clone(), vec![multisource_binding(&model_id)])]);
2512
2513 let error = build_execution_plan(
2514 "plan:datareq.by-source.multi",
2515 by_source_graph(vec!["nir", "chem"]),
2516 campaign,
2517 ®istry_with_model_data_requirements_json(model_data_requirements(true, None)),
2518 )
2519 .unwrap_err();
2520 let payload = refusal_payload(error);
2521
2522 assert_eq!(
2523 payload["code"],
2524 "dagml.data_requirement.unsupported_by_source_shape"
2525 );
2526 assert_eq!(payload["shape"], "by_source");
2527 assert_eq!(payload["source_ids"], serde_json::json!(["nir", "chem"]));
2528 }
2529
2530 fn large_linear_graph(transform_count: usize) -> GraphSpec {
2531 let mut nodes = Vec::new();
2532 let mut edges = Vec::new();
2533 for node_idx in 0..transform_count {
2534 let node_id = format!("transform:t{node_idx:04}");
2535 nodes.push(node(
2536 &node_id,
2537 NodeKind::Transform,
2538 vec![port("x", PortKind::Data)],
2539 vec![port("x", PortKind::Data)],
2540 ));
2541 if node_idx > 0 {
2542 edges.push(EdgeSpec {
2543 source: PortRef {
2544 node_id: NodeId::new(format!("transform:t{:04}", node_idx - 1)).unwrap(),
2545 port_name: "x".to_string(),
2546 },
2547 target: PortRef {
2548 node_id: NodeId::new(&node_id).unwrap(),
2549 port_name: "x".to_string(),
2550 },
2551 contract: EdgeContract::new(PortKind::Data, None),
2552 });
2553 }
2554 }
2555 nodes.push(node(
2556 "model:final",
2557 NodeKind::Model,
2558 vec![port("x", PortKind::Data)],
2559 vec![port("pred", PortKind::Prediction)],
2560 ));
2561 edges.push(EdgeSpec {
2562 source: PortRef {
2563 node_id: NodeId::new(format!("transform:t{:04}", transform_count - 1)).unwrap(),
2564 port_name: "x".to_string(),
2565 },
2566 target: PortRef {
2567 node_id: NodeId::new("model:final").unwrap(),
2568 port_name: "x".to_string(),
2569 },
2570 contract: EdgeContract::new(PortKind::Data, None),
2571 });
2572
2573 GraphSpec {
2574 id: "g:perf.linear".to_string(),
2575 interface: GraphInterface::default(),
2576 nodes,
2577 edges,
2578 search_space_fingerprint: None,
2579 metadata: BTreeMap::new(),
2580 }
2581 }
2582
2583 fn oof_graph() -> GraphSpec {
2584 GraphSpec {
2585 id: "g:oof.capabilities".to_string(),
2586 interface: GraphInterface::default(),
2587 nodes: vec![
2588 node(
2589 "model:base",
2590 NodeKind::Model,
2591 vec![],
2592 vec![port("pred", PortKind::Prediction)],
2593 ),
2594 node(
2595 "model:meta",
2596 NodeKind::Model,
2597 vec![port("pred", PortKind::Prediction)],
2598 vec![port("pred", PortKind::Prediction)],
2599 ),
2600 ],
2601 edges: vec![EdgeSpec {
2602 source: PortRef {
2603 node_id: NodeId::new("model:base").unwrap(),
2604 port_name: "pred".to_string(),
2605 },
2606 target: PortRef {
2607 node_id: NodeId::new("model:meta").unwrap(),
2608 port_name: "pred".to_string(),
2609 },
2610 contract: EdgeContract {
2611 requires_oof: true,
2612 requires_fold_alignment: true,
2613 ..EdgeContract::new(PortKind::Prediction, None)
2614 },
2615 }],
2616 search_space_fingerprint: None,
2617 metadata: BTreeMap::new(),
2618 }
2619 }
2620
2621 fn data_binding(node_id: &NodeId) -> DataBinding {
2622 DataBinding {
2623 node_id: node_id.clone(),
2624 input_name: "x".to_string(),
2625 request_id: "nir-to-tabular".to_string(),
2626 schema_fingerprint: "f97b37872fa22134b508f98fd8e207e5b776b52594fb8f6f5c3e15bee212246b"
2627 .to_string(),
2628 plan_fingerprint: "7c5431d85574b3f337022fa5d25971d5b5cf445b90331b49938f573ff6901e4d"
2629 .to_string(),
2630 relation_fingerprint: Some(
2631 "a3a7e329df35db9f2883a17b8611b7fae6dcaa031875e3ec2c9be1b9e29cbe10".to_string(),
2632 ),
2633 output_representation: "tabular_numeric".to_string(),
2634 feature_set_id: Some("x".to_string()),
2635 source_ids: vec!["nir".to_string()],
2636 require_relations: true,
2637 view_policy: Default::default(),
2638 metadata: BTreeMap::new(),
2639 }
2640 }
2641
2642 fn levels_as_strings(levels: &[Vec<NodeId>]) -> Vec<Vec<String>> {
2643 levels
2644 .iter()
2645 .map(|level| level.iter().map(ToString::to_string).collect())
2646 .collect()
2647 }
2648
2649 #[cfg(dag_ml_workspace_contract_fixtures)]
2650 #[test]
2651 fn published_campaign_spec_schema_declares_current_contract() {
2652 let schema: serde_json::Value = serde_json::from_str(include_str!(
2653 "../../../docs/contracts/campaign_spec.schema.json"
2654 ))
2655 .unwrap();
2656
2657 assert_eq!(schema["$id"], CAMPAIGN_SPEC_SCHEMA_ID);
2658 assert!(schema["required"]
2659 .as_array()
2660 .unwrap()
2661 .iter()
2662 .any(|field| field.as_str() == Some("id")));
2663 assert!(schema["$defs"]["split_invocation"]["properties"]
2664 .as_object()
2665 .unwrap()
2666 .contains_key("fold_set"));
2667 assert!(schema["$defs"]["aggregation_policy"]["properties"]
2668 .as_object()
2669 .unwrap()
2670 .contains_key("selection_metric_level"));
2671 assert!(schema["$defs"]["aggregation_policy"]["properties"]
2672 .as_object()
2673 .unwrap()
2674 .contains_key("custom_controller"));
2675 assert!(schema["$defs"]["data_binding"]["properties"]
2676 .as_object()
2677 .unwrap()
2678 .contains_key("view_policy"));
2679 assert!(schema["properties"]
2680 .as_object()
2681 .unwrap()
2682 .contains_key("branch_view_plans"));
2683 assert!(schema["$defs"]["branch_view_plan"]["properties"]
2684 .as_object()
2685 .unwrap()
2686 .contains_key("selector"));
2687 }
2688
2689 #[cfg(dag_ml_workspace_contract_fixtures)]
2690 #[test]
2691 fn published_execution_plan_schema_declares_current_contract() {
2692 let schema: serde_json::Value = serde_json::from_str(include_str!(
2693 "../../../docs/contracts/execution_plan.schema.json"
2694 ))
2695 .unwrap();
2696
2697 assert_eq!(schema["$id"], EXECUTION_PLAN_SCHEMA_ID);
2698 assert!(schema["required"]
2699 .as_array()
2700 .unwrap()
2701 .iter()
2702 .any(|field| field.as_str() == Some("node_plans")));
2703 assert!(schema["properties"]
2704 .as_object()
2705 .unwrap()
2706 .contains_key("controller_fingerprint"));
2707 assert!(schema["$defs"]["node_plan"]["properties"]
2708 .as_object()
2709 .unwrap()
2710 .contains_key("shape_plan"));
2711 assert!(schema["$defs"]["variant_plan"]["properties"]
2712 .as_object()
2713 .unwrap()
2714 .contains_key("choices"));
2715 }
2716
2717 #[cfg(dag_ml_workspace_contract_fixtures)]
2718 #[test]
2719 fn published_execution_plan_fixture_validates_current_contract() {
2720 let plan: ExecutionPlan = serde_json::from_str(include_str!(
2721 "../../../examples/fixtures/runtime/execution_plan_branch_merge_executable.json"
2722 ))
2723 .unwrap();
2724
2725 plan.validate().unwrap();
2726 assert_eq!(plan.id, "plan:fixture.execution.branch_merge");
2727 assert_eq!(plan.variants.len(), 2);
2728 assert_eq!(plan.node_plans.len(), plan.graph_plan.graph.nodes.len());
2729 }
2730
2731 #[test]
2732 #[ignore = "perf sanity probe; run with --release --ignored --nocapture"]
2733 fn build_execution_plan_large_linear_graph_under_1500ms() {
2734 let started = Instant::now();
2735 let plan = build_execution_plan(
2736 "plan:perf.linear",
2737 large_linear_graph(400),
2738 campaign("campaign:perf.linear"),
2739 ®istry(),
2740 )
2741 .unwrap();
2742 let elapsed = started.elapsed();
2743
2744 assert_eq!(plan.graph_plan.topological_order.len(), 401);
2745 assert_eq!(plan.node_plans.len(), 401);
2746 assert!(
2747 elapsed <= Duration::from_millis(1_500),
2748 "large execution-plan build took {elapsed:?}"
2749 );
2750 }
2751
2752 #[test]
2753 fn builds_execution_plan_with_shape_and_fold_contracts() {
2754 let model_id = NodeId::new("model:pls").unwrap();
2755 let campaign = CampaignSpec {
2756 inner_cv: None,
2757 id: "campaign:oof".to_string(),
2758 root_seed: Some(7),
2759 leakage_policy: LeakageUnitPolicy::default(),
2760 aggregation_policy: AggregationPolicy::default(),
2761 split_invocation: Some(SplitInvocation {
2762 id: "split:outer".to_string(),
2763 controller_id: None,
2764 leakage_policy: LeakageUnitPolicy::default(),
2765 params: BTreeMap::new(),
2766 fold_set: Some(FoldSet {
2767 id: "outer".to_string(),
2768 sample_ids: vec![SampleId::new("s1").unwrap(), SampleId::new("s2").unwrap()],
2769 folds: vec![
2770 crate::fold::FoldAssignment {
2771 fold_id: FoldId::new("fold0").unwrap(),
2772 train_sample_ids: vec![SampleId::new("s2").unwrap()],
2773 validation_sample_ids: vec![SampleId::new("s1").unwrap()],
2774 metadata: BTreeMap::new(),
2775 },
2776 crate::fold::FoldAssignment {
2777 fold_id: FoldId::new("fold1").unwrap(),
2778 train_sample_ids: vec![SampleId::new("s1").unwrap()],
2779 validation_sample_ids: vec![SampleId::new("s2").unwrap()],
2780 metadata: BTreeMap::new(),
2781 },
2782 ],
2783 sample_groups: BTreeMap::new(),
2784 partition_mode: FoldPartitionMode::Partition,
2785 }),
2786 }),
2787 generation: Default::default(),
2788 shape_plans: BTreeMap::from([(
2789 model_id.clone(),
2790 DataModelShapePlan {
2791 node_id: model_id.clone(),
2792 input_granularity: Granularity::Observation,
2793 ..DataModelShapePlan {
2794 node_id: model_id.clone(),
2795 input_granularity: Granularity::Sample,
2796 target_granularity: Granularity::Sample,
2797 fit_rows: crate::policy::FitBoundary::FoldTrain,
2798 predict_rows: crate::policy::FitBoundary::FoldValidation,
2799 feature_namespace: None,
2800 feature_schema_fingerprint: None,
2801 target_space: "raw".to_string(),
2802 aggregation_policy: AggregationPolicy::default(),
2803 augmentation_policy: crate::policy::AugmentationPolicy::default(),
2804 selection_policy: crate::policy::FeatureSelectionPolicy::default(),
2805 }
2806 },
2807 )]),
2808 data_bindings: BTreeMap::from([(model_id.clone(), vec![data_binding(&model_id)])]),
2809 branch_view_plans: Vec::new(),
2810 metadata: BTreeMap::new(),
2811 };
2812
2813 let plan = build_execution_plan("plan:oof", graph(), campaign, ®istry()).unwrap();
2814
2815 assert_eq!(
2816 plan.graph_plan
2817 .topological_order
2818 .iter()
2819 .map(ToString::to_string)
2820 .collect::<Vec<_>>(),
2821 vec!["transform:snv", "model:pls"]
2822 );
2823 assert_eq!(
2824 levels_as_strings(&plan.graph_plan.parallel_levels),
2825 vec![vec!["transform:snv"], vec!["model:pls"]]
2826 );
2827 assert!(plan.node_plans[&model_id]
2828 .controller_capabilities
2829 .contains(&ControllerCapability::EmitsPredictions));
2830 assert!(plan.fold_set.is_some());
2831 let schedule = plan.campaign_phase_schedule(Phase::FitCv).unwrap();
2832 assert_eq!(schedule.scopes.len(), 2);
2833 assert!(schedule.scopes[0].scope_id.starts_with("scope:FIT_CV:"));
2834 assert!(schedule
2835 .scopes
2836 .iter()
2837 .all(|scope| levels_as_strings(&scope.node_levels)
2838 == vec![vec!["transform:snv"], vec!["model:pls"]]));
2839 assert_eq!(
2840 schedule
2841 .scopes
2842 .iter()
2843 .filter_map(|scope| scope.fold_id.as_ref().map(ToString::to_string))
2844 .collect::<Vec<_>>(),
2845 vec!["fold0", "fold1"]
2846 );
2847 assert_eq!(
2848 plan.node_plans
2849 .get(&model_id)
2850 .unwrap()
2851 .controller_id
2852 .as_str(),
2853 "controller:model"
2854 );
2855 assert_eq!(
2856 plan.node_plans.get(&model_id).unwrap().data_bindings.len(),
2857 1
2858 );
2859
2860 let mut bad_plan = plan.clone();
2861 bad_plan.graph_plan.parallel_levels =
2862 vec![vec![model_id], vec![NodeId::new("transform:snv").unwrap()]];
2863 assert!(bad_plan
2864 .validate()
2865 .unwrap_err()
2866 .to_string()
2867 .contains("parallel levels"));
2868
2869 let bad_envelope = ExternalDataPlanEnvelope {
2870 schema_version: crate::data::EXTERNAL_DATA_PLAN_ENVELOPE_SCHEMA_VERSION,
2871 schema_fingerprint: "f97b37872fa22134b508f98fd8e207e5b776b52594fb8f6f5c3e15bee212246b"
2872 .to_string(),
2873 plan_fingerprint: "7c5431d85574b3f337022fa5d25971d5b5cf445b90331b49938f573ff6901e4d"
2874 .to_string(),
2875 relation_fingerprint: None,
2876 data_content_fingerprint: None,
2877 target_content_fingerprint: None,
2878 coordinator_relations: Some(SampleRelationSet {
2879 records: vec![{
2880 let mut relation = SampleRelation::new(
2881 ObservationId::new("obs:outside").unwrap(),
2882 SampleId::new("sample:outside").unwrap(),
2883 );
2884 relation.target_id = Some(TargetId::new("target:outside").unwrap());
2885 relation.source_id = Some("nir".to_string());
2886 relation
2887 }],
2888 }),
2889 predict_cohort: None,
2890 };
2891 assert!(plan
2892 .campaign
2893 .validate_data_envelope_relations(&bad_envelope)
2894 .unwrap_err()
2895 .to_string()
2896 .contains("outside fold set"));
2897 }
2898
2899 #[test]
2900 fn planning_refuses_shape_plan_for_unknown_node() {
2901 let campaign = CampaignSpec {
2902 inner_cv: None,
2903 id: "campaign:oof".to_string(),
2904 root_seed: Some(7),
2905 leakage_policy: LeakageUnitPolicy::default(),
2906 aggregation_policy: AggregationPolicy::default(),
2907 split_invocation: None,
2908 generation: Default::default(),
2909 shape_plans: BTreeMap::from([(
2910 NodeId::new("model:missing").unwrap(),
2911 DataModelShapePlan {
2912 node_id: NodeId::new("model:missing").unwrap(),
2913 input_granularity: Granularity::Sample,
2914 target_granularity: Granularity::Sample,
2915 fit_rows: crate::policy::FitBoundary::FoldTrain,
2916 predict_rows: crate::policy::FitBoundary::FoldValidation,
2917 feature_namespace: None,
2918 feature_schema_fingerprint: None,
2919 target_space: "raw".to_string(),
2920 aggregation_policy: AggregationPolicy::default(),
2921 augmentation_policy: crate::policy::AugmentationPolicy::default(),
2922 selection_policy: crate::policy::FeatureSelectionPolicy::default(),
2923 },
2924 )]),
2925 data_bindings: BTreeMap::new(),
2926 branch_view_plans: Vec::new(),
2927 metadata: BTreeMap::new(),
2928 };
2929
2930 assert!(build_execution_plan("plan:oof", graph(), campaign, ®istry()).is_err());
2931 }
2932
2933 #[test]
2934 fn planning_refuses_oof_edge_without_controller_capabilities() {
2935 let mut registry = ControllerRegistry::new();
2936 let mut model_manifest = manifest("controller:model", NodeKind::Model);
2937 model_manifest
2938 .capabilities
2939 .remove(&ControllerCapability::ConsumesOofPredictions);
2940 registry.register(model_manifest).unwrap();
2941
2942 let err = build_execution_plan(
2943 "plan:oof.capability",
2944 oof_graph(),
2945 CampaignSpec {
2946 inner_cv: None,
2947 id: "campaign:oof.capability".to_string(),
2948 root_seed: Some(11),
2949 leakage_policy: Default::default(),
2950 aggregation_policy: Default::default(),
2951 split_invocation: None,
2952 generation: Default::default(),
2953 shape_plans: BTreeMap::new(),
2954 data_bindings: BTreeMap::new(),
2955 branch_view_plans: Vec::new(),
2956 metadata: BTreeMap::new(),
2957 },
2958 ®istry,
2959 )
2960 .unwrap_err();
2961
2962 assert!(err.to_string().contains("consumes_oof_predictions"));
2963 }
2964
2965 #[test]
2966 fn planning_refuses_raw_prediction_sibling_port_into_fitting_node() {
2967 let mut graph = oof_graph();
2968 graph.id = "g:oof.raw-sibling".to_string();
2969 let base = graph
2970 .nodes
2971 .iter_mut()
2972 .find(|node| node.id.as_str() == "model:base")
2973 .unwrap();
2974 base.ports.outputs.push(port("aux", PortKind::Prediction));
2975 let meta = graph
2976 .nodes
2977 .iter_mut()
2978 .find(|node| node.id.as_str() == "model:meta")
2979 .unwrap();
2980 meta.ports.inputs.push(port("aux", PortKind::Prediction));
2981 graph.edges.push(EdgeSpec {
2982 source: PortRef {
2983 node_id: NodeId::new("model:base").unwrap(),
2984 port_name: "aux".to_string(),
2985 },
2986 target: PortRef {
2987 node_id: NodeId::new("model:meta").unwrap(),
2988 port_name: "aux".to_string(),
2989 },
2990 contract: EdgeContract::new(PortKind::Prediction, None),
2991 });
2992
2993 let error = build_execution_plan(
2994 "plan:oof.raw-sibling",
2995 graph,
2996 campaign("campaign:oof.raw-sibling"),
2997 ®istry(),
2998 )
2999 .unwrap_err()
3000 .to_string();
3001
3002 assert!(error.contains("enters fitting controller"));
3003 assert!(error.contains("must require OOF"));
3004 }
3005
3006 #[test]
3007 fn parallel_controller_capability_validation_requires_safe_manifest() {
3008 let mut registry = ControllerRegistry::new();
3009 let mut transform_manifest = manifest("controller:transform", NodeKind::Transform);
3010 transform_manifest
3011 .capabilities
3012 .remove(&ControllerCapability::ThreadSafe);
3013 transform_manifest
3014 .capabilities
3015 .remove(&ControllerCapability::ProcessSafe);
3016 registry.register(transform_manifest).unwrap();
3017 registry
3018 .register(manifest("controller:model", NodeKind::Model))
3019 .unwrap();
3020 let plan = build_execution_plan(
3021 "plan:parallel.capability",
3022 graph(),
3023 CampaignSpec {
3024 inner_cv: None,
3025 id: "campaign:parallel.capability".to_string(),
3026 root_seed: Some(11),
3027 leakage_policy: Default::default(),
3028 aggregation_policy: Default::default(),
3029 split_invocation: None,
3030 generation: Default::default(),
3031 shape_plans: BTreeMap::new(),
3032 data_bindings: BTreeMap::new(),
3033 branch_view_plans: Vec::new(),
3034 metadata: BTreeMap::new(),
3035 },
3036 ®istry,
3037 )
3038 .unwrap();
3039
3040 assert!(plan
3041 .validate_parallel_controller_capabilities(1, Phase::FitCv)
3042 .is_ok());
3043 let err = plan
3044 .validate_parallel_controller_capabilities(2, Phase::FitCv)
3045 .unwrap_err();
3046 assert!(err.to_string().contains("thread_safe or process_safe"));
3047 }
3048
3049 #[test]
3050 fn planning_refuses_generation_override_for_unknown_node() {
3051 let campaign = CampaignSpec {
3052 inner_cv: None,
3053 id: "campaign:oof".to_string(),
3054 root_seed: Some(7),
3055 leakage_policy: LeakageUnitPolicy::default(),
3056 aggregation_policy: AggregationPolicy::default(),
3057 split_invocation: None,
3058 generation: GenerationSpec {
3059 strategy: GenerationStrategy::Cartesian,
3060 dimensions: vec![GenerationDimension {
3061 name: "model_family".to_string(),
3062 choices: vec![GenerationChoice {
3063 label: "pls".to_string(),
3064 value: serde_json::json!("pls"),
3065 param_overrides: vec![GenerationParamOverride {
3066 node_id: NodeId::new("model:missing").unwrap(),
3067 params: BTreeMap::from([(
3068 "n_components".to_string(),
3069 serde_json::json!(8),
3070 )]),
3071 }],
3072 active_subsequence: None,
3073 }],
3074 }],
3075 max_variants: Some(1),
3076 constraints: GenerationConstraints::default(),
3077 },
3078 shape_plans: BTreeMap::new(),
3079 data_bindings: BTreeMap::new(),
3080 branch_view_plans: Vec::new(),
3081 metadata: BTreeMap::new(),
3082 };
3083
3084 let error = build_execution_plan("plan:oof", graph(), campaign, ®istry())
3085 .unwrap_err()
3086 .to_string();
3087
3088 assert!(error.contains("overrides params for unknown node"));
3089 }
3090
3091 #[test]
3092 fn planning_validates_declared_search_space_fingerprint() {
3093 let campaign = CampaignSpec {
3094 inner_cv: None,
3095 id: "campaign:search.fingerprint".to_string(),
3096 root_seed: Some(7),
3097 leakage_policy: LeakageUnitPolicy::default(),
3098 aggregation_policy: AggregationPolicy::default(),
3099 split_invocation: None,
3100 generation: GenerationSpec {
3101 strategy: GenerationStrategy::Cartesian,
3102 dimensions: vec![GenerationDimension {
3103 name: "model_family".to_string(),
3104 choices: vec![GenerationChoice {
3105 label: "pls".to_string(),
3106 value: serde_json::json!("pls"),
3107 param_overrides: vec![GenerationParamOverride {
3108 node_id: NodeId::new("model:pls").unwrap(),
3109 params: BTreeMap::from([(
3110 "n_components".to_string(),
3111 serde_json::json!(8),
3112 )]),
3113 }],
3114 active_subsequence: None,
3115 }],
3116 }],
3117 max_variants: Some(1),
3118 constraints: GenerationConstraints::default(),
3119 },
3120 shape_plans: BTreeMap::new(),
3121 data_bindings: BTreeMap::new(),
3122 branch_view_plans: Vec::new(),
3123 metadata: BTreeMap::new(),
3124 };
3125 let mut graph = graph();
3126 graph.search_space_fingerprint =
3127 Some(generation_spec_fingerprint(&campaign.generation).unwrap());
3128
3129 let plan = build_execution_plan(
3130 "plan:search.fingerprint",
3131 graph.clone(),
3132 campaign.clone(),
3133 ®istry(),
3134 )
3135 .unwrap();
3136 assert_eq!(plan.variants.len(), 1);
3137
3138 graph.search_space_fingerprint = Some("sha256:not-the-generation-spec".to_string());
3139 let error = build_execution_plan("plan:search.fingerprint", graph, campaign, ®istry())
3140 .unwrap_err()
3141 .to_string();
3142 assert!(error.contains("search_space_fingerprint"));
3143 }
3144
3145 #[test]
3146 fn branch_view_lookup_helpers_match_by_branch_id_and_innermost_path() {
3147 use crate::data::{BranchViewMode, DataViewSelector};
3148
3149 let outer = BranchViewPlan {
3150 view_id: "branch_view:outer".to_string(),
3151 branch_id: "branch:outer".to_string(),
3152 mode: BranchViewMode::BySource,
3153 selector: DataViewSelector {
3154 source_ids: vec!["nir".to_string()],
3155 ..Default::default()
3156 },
3157 allow_overlap: false,
3158 metadata: BTreeMap::new(),
3159 };
3160 let inner = BranchViewPlan {
3161 view_id: "branch_view:inner".to_string(),
3162 branch_id: "branch:inner".to_string(),
3163 mode: BranchViewMode::Separation,
3164 selector: DataViewSelector {
3165 source_ids: vec!["chem".to_string()],
3166 ..Default::default()
3167 },
3168 allow_overlap: false,
3169 metadata: BTreeMap::new(),
3170 };
3171 let plans = vec![outer.clone(), inner.clone()];
3172
3173 assert_eq!(
3174 super::branch_view_for_in(&plans, "branch:outer"),
3175 Some(&outer)
3176 );
3177 assert_eq!(
3178 super::branch_view_for_in(&plans, "branch:inner"),
3179 Some(&inner)
3180 );
3181 assert_eq!(super::branch_view_for_in(&plans, "branch:missing"), None);
3182
3183 let path = vec!["branch:outer".to_string(), "branch:inner".to_string()];
3184 assert_eq!(super::branch_view_for_path_in(&plans, &path), Some(&inner));
3186
3187 let path_outer_only = vec!["branch:outer".to_string()];
3188 assert_eq!(
3189 super::branch_view_for_path_in(&plans, &path_outer_only),
3190 Some(&outer)
3191 );
3192
3193 let empty_path: Vec<String> = Vec::new();
3194 assert_eq!(super::branch_view_for_path_in(&plans, &empty_path), None);
3195
3196 let path_no_match = vec!["branch:other".to_string()];
3197 assert_eq!(super::branch_view_for_path_in(&plans, &path_no_match), None);
3198 }
3199}