Skip to main content

dag_ml_core/
plan.rs

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    /// Campaign-wide default nested (inner) CV policy. A node-level
84    /// `NodePlan.inner_cv` overrides it; see [`crate::fold::resolve_inner_cv`].
85    #[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    /// Parse the published object-only campaign JSON representation and validate it.
93    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    /// Node-local nested (inner) CV policy (e.g. for a finetune/tuner or branch
221    /// node); overrides the campaign-wide default.
222    #[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    /// Parse and validate an external execution-plan JSON document.
280    ///
281    /// Serde's derived struct visitors also accept positional JSON arrays. That
282    /// representation is an implementation detail, is not part of the published
283    /// object-only JSON Schema, and would otherwise make standalone Rust readers
284    /// more permissive than the C, Python and validation-oracle boundaries. The
285    /// container-shape comparison keeps legitimate serde defaults and BTree
286    /// ordering normalization, while refusing a sequence wherever the typed wire
287    /// representation is an object (and vice versa).
288    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    /// Replace the plan's training-loss roles with a validated canonical set.
296    /// Roles are grouped by node and sorted so every binding receives the same
297    /// `NodeTask` requirement ordering.
298    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        // Retain the historical parallel-levels compatibility: an empty cached
330        // level list is allowed (recomputed on demand), a present one must match.
331        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        // Every controller manifest must be self-valid and keyed by its own id,
340        // so a forged registry entry cannot masquerade under another id or ship
341        // an internally inconsistent contract that later checks trust.
342        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        // Fail-closed embedded-fingerprint verification. Recompute each embedded
353        // fingerprint from the canonical content — exactly as `build_execution_plan`
354        // does at construction — and require exact equality with the serialized
355        // top-level field. Without this a caller could mutate embedded
356        // graph/campaign/manifest content, retain the stale fingerprint strings,
357        // and re-sign the outer plan/outcome/package: the bundle layer only compares
358        // fingerprint STRINGS, so the embedded content is the sole source of truth
359        // here and its serialized fingerprint field must never be trusted on its own.
360        // Structural validation above runs first so recomputation is over
361        // well-formed content.
362        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        // The node-plan map must key each plan by its own node id and cover
385        // exactly the graph node-id set — no missing, extra or mis-keyed plan.
386        // A bare length check is insufficient: it would accept a duplicated key
387        // masking a missing node.
388        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        // The cached topological order must be exactly the graph's canonical
411        // order, so a forged or stale order cannot omit a node from phase
412        // scheduling. Per-node validation below iterates `node_plans` directly
413        // and therefore no longer depends on this order for completeness.
414        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            // The plan's own node kind must match the graph node, and its
432            // adjacency must be exactly the graph's upstream/downstream sets so a
433            // forged plan cannot manufacture or trim the predictor closure that
434            // replay derivation walks through `input_nodes`.
435            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            // Every capability-bearing field the plan copies from its manifest
457            // must match exactly. `supported_phases` and `controller_version` are
458            // load-bearing for replay-phase truthfulness, so they are enforced
459            // alongside kind, capabilities and the policy triple.
460            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            // Validate every node-local inner_cv while iterating ALL node plans
491            // (not the cached order), so a stale/tampered order cannot defer a
492            // malformed inner_cv to FIT_CV fold building.
493            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    /// Returns the `BranchViewPlan` whose `branch_id` matches `branch_id`,
681    /// if any. The match is exact; callers that need fuzzy or prefix matching
682    /// must iterate `self.campaign.branch_view_plans` themselves.
683    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    /// Returns the `BranchViewPlan` for the deepest branch in `branch_path`
688    /// that has a matching plan, if any. The path is walked tip-first so the
689    /// closest enclosing branch wins; an empty path returns `None`. The
690    /// returned reference borrows the plan from the campaign; the caller can
691    /// `.clone()` it into a `DataProviderViewSpec.branch_view` field when
692    /// constructing a provider view for an in-branch node.
693    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(&params)?;
1201        // Lower a node-local nested-CV policy carried by the DSL compiler in the
1202        // graph node metadata into the typed NodePlan field. Malformed metadata
1203        // fails the plan rather than silently dropping nested CV.
1204        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                // Reject semantically malformed specs (e.g. n_splits < 2) here, at
1214                // the plan boundary, rather than deferring to FIT_CV fold building.
1215                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
1344/// Prune `plan` (a Mechanism-B operator-generator UNION plan, compiled as a STACKING graph:
1345/// every choice's terminal model fans into `merge:generator_predictions -> model:meta`) down to a
1346/// single operator-SELECT candidate: the one operator choice in `active_nodes` plus the prefix it
1347/// shares with the other choices, with the generator merge + meta-model + every inactive choice
1348/// physically removed (C Phase 4, #23).
1349///
1350/// `active_nodes` is the chosen choice's active set (`OperatorVariantModel::active_nodes[choice]`);
1351/// `all_choice_nodes` is the union of EVERY choice's active set. The kept set is computed by
1352/// structure, not by id-prefix matching:
1353///
1354/// 1. `shared_prefix` = the transitive ANCESTORS of `active_nodes` in the compiled graph (walked via
1355///    [`GraphSpec::upstream_nodes`], graph.rs), MINUS `all_choice_nodes`. The subtraction is the
1356///    crux: ancestors that are themselves choice nodes (this choice's own upstream operators) stay
1357///    in via `active_nodes`, but a sibling choice's nodes are never pulled in, and — because the
1358///    merge + meta sit DOWNSTREAM of the choice models, never upstream — they are never ancestors,
1359///    so they are elided.
1360/// 2. `keep` = `shared_prefix ∪ active_nodes`.
1361/// 3. graph nodes/edges are filtered to `keep` (an edge survives only when BOTH endpoints are kept,
1362///    which drops the now-dangling stacking edges into the elided merge).
1363/// 4. a fresh [`GraphPlan::from_graph`] recomputes the topo order + parallel levels for the pruned
1364///    graph, `node_plans` are filtered to `keep`, and EACH surviving node plan's
1365///    `input_nodes`/`output_nodes` are REBUILT from the pruned graph (the scheduler reads
1366///    `input_nodes` to decide handle forwarding — a stale entry would silently reintroduce an
1367///    inactive edge).
1368/// 5. `graph_fingerprint` is recomputed from the pruned graph; `variants` is set to exactly the
1369///    SELECT candidate's variant; the result is `validate`d and then run through
1370///    `validate_active_inputs` (Invariant P4-1).
1371///
1372/// The campaign is carried unchanged (its `shape_plans`/`data_bindings`/`generation` are validated
1373/// per-object, not re-checked against the pruned node set), so the pruned candidate replays exactly
1374/// the chosen operator sub-sequence with no stacking residue.
1375pub 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    // shared_prefix = transitive ancestors of the active nodes, MINUS every choice's active nodes
1397    // (so sibling choices, the stacking merge, and the meta-model are all excluded).
1398    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    // Filter the graph to `keep`; an edge survives only when BOTH endpoints survive, which drops the
1420    // dangling edges into the elided merge/meta-model.
1421    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    // Filter node plans to `keep` and rebuild every surviving plan's input/output nodes from the
1430    // pruned graph (the scheduler reads `input_nodes`; stale entries reintroduce inactive edges).
1431    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
1460/// Invariant P4-1: after an operator-SELECT prune, every kept node's edge-fed input port is still
1461/// fed by exactly one surviving source.
1462///
1463/// The edge-driven scheduler / OOF traversals only ever see the surviving nodes+edges — the inactive
1464/// choices, the merge, and the meta-model are physically gone — so the active-edge gate is otherwise
1465/// IMPLICIT; this is the only residual check. It is strictly additive: it weakens no OOF/leakage
1466/// validator.
1467///
1468/// For each input port of each kept node it compares the union graph's per-port edge count
1469/// (`union_graph`) with the pruned graph's. A port that was edge-fed in the union but now has NO
1470/// surviving source is DANGLING — its sole producer was pruned away, which is a malformed prune. A
1471/// port with MORE THAN ONE surviving source is AMBIGUOUS. Ports that were never edge-fed in the union
1472/// (graph-interface / data-binding inputs) carry no edge by design and are left alone.
1473fn 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(&params).unwrap(),
1532            "3f417903752f65005bc9b69bcd23dfcf3ede2cda010e4f1ead6090a1a407b851"
1533        );
1534
1535        // Pin both fixed/scientific cutovers and special finite spellings used
1536        // by the independent Python serde encoder.
1537        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        // Campaign-level (global) declaration round-trips through JSON.
1555        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        // A node-local declaration overrides the campaign default.
1561        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        // Absent on both campaign and node serializes away (skip_serializing_if).
1570        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        // A semantically-malformed campaign-global inner_cv (n_splits < 2) is
1577        // rejected by CampaignSpec::validate (the plan boundary), not deferred.
1578        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        // A canonical ExecutionPlan loaded from JSON (bypassing DSL lowering) can
1589        // carry a malformed node-local inner_cv; ExecutionPlan::validate must
1590        // refuse it rather than deferring to FIT_CV fold building.
1591        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, &registry()).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, &registry()).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        // Serde's derived struct visitor accepts this internal positional form,
1645        // and the resulting typed plan is otherwise semantically valid.
1646        let permissive: ExecutionPlan = serde_json::from_value(wire.clone()).unwrap();
1647        permissive.validate().unwrap();
1648
1649        // The published standalone JSON boundary is object-only and refuses it.
1650        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        // Re-key an existing plan under a node id absent from the graph. The
1704        // count is unchanged, so only an exact set check catches the mismatch.
1705        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        // A forged order that drops `model:pls` must not let it skip per-node
1724        // validation or phase scheduling.
1725        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        // Trimming `model:pls`'s real upstream would shrink the predictor closure
1753        // replay derivation walks through `input_nodes`.
1754        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        // Injecting EXPLAIN into the node plan without the manifest backing it is
1770        // exactly the forgery that could otherwise manufacture an EXPLAIN replay.
1771        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        // Mutate embedded graph content but RETAIN the stale graph_fingerprint —
1803        // exactly the tamper a caller would attempt before re-signing the outer
1804        // plan (whose bundle layer only compares fingerprint strings). Validation
1805        // must recompute from content and refuse.
1806        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        // Direct fingerprint-field forgery with unchanged content is refused: the
1822        // recomputation from canonical content is the source of truth.
1823        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        // Mutate embedded campaign content, keep the stale campaign_fingerprint.
1836        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        // `priority` is embedded in the controller fingerprint but is NOT copied
1863        // into any NodePlan, so only the recomputed controller_fingerprint — never
1864        // a node-plan cross-copy check — can catch this manifest mutation. Keeping
1865        // the stale fingerprint string models an outer re-sign that leaves the
1866        // embedded string untouched.
1867        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, &registry()).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, &registry()).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        // Right discriminator, invalid value: a single split is rejected at the
1968        // plan boundary rather than deferred to FIT_CV fold building.
1969        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, &registry())
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            &registry(),
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            &registry_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            &registry_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            &registry_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            &registry_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            &registry(),
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            &registry_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            &registry_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            &registry_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            &registry(),
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, &registry()).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, &registry()).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            &registry,
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            &registry(),
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            &registry,
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, &registry())
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            &registry(),
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, &registry())
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        // tip-first: innermost matching branch wins
3185        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}