Skip to main content

dag_ml_core/runtime/
scheduler.rs

1// Auto-split from the former monolithic `runtime.rs` (pure refactor).
2use super::*;
3
4#[derive(Clone, Debug, Default)]
5pub struct SequentialScheduler;
6
7#[derive(Clone, Debug)]
8pub struct ParallelScheduler {
9    max_workers: usize,
10}
11
12impl ParallelScheduler {
13    pub fn new(max_workers: usize) -> Result<Self> {
14        if max_workers == 0 {
15            return Err(DagMlError::RuntimeValidation(
16                "parallel scheduler max_workers must be at least 1".to_string(),
17            ));
18        }
19        Ok(Self { max_workers })
20    }
21
22    pub fn max_workers(&self) -> usize {
23        self.max_workers
24    }
25}
26
27#[derive(Clone, Debug)]
28pub(crate) struct PhaseScope {
29    pub(crate) phase: Phase,
30    pub(crate) variant_id: Option<VariantId>,
31    pub(crate) variant: Option<VariantExecutionSpec>,
32    pub(crate) fold_id: Option<FoldId>,
33    pub(crate) seed_root: Option<u64>,
34}
35
36#[derive(Clone, Debug)]
37pub(crate) struct ReplayPredictionCacheContract {
38    pub(crate) requirement: BundlePredictionRequirement,
39    pub(crate) cache: BundlePredictionCacheRecord,
40}
41
42pub(crate) struct MaterializedReplayArtifacts {
43    pub(crate) handles: BTreeMap<NodeId, BTreeMap<String, HandleRef>>,
44    pub(crate) inputs: BTreeMap<NodeId, BTreeMap<String, ArtifactInputSpec>>,
45}
46
47fn prediction_output_ports_for_node(plan: &ExecutionPlan, node_id: &NodeId) -> Result<Vec<String>> {
48    let node = plan
49        .graph_plan
50        .graph
51        .nodes
52        .iter()
53        .find(|node| node.id == *node_id)
54        .ok_or_else(|| {
55            DagMlError::RuntimeValidation(format!(
56                "node `{node_id}` is absent from the execution graph"
57            ))
58        })?;
59    let mut ports = node
60        .ports
61        .outputs
62        .iter()
63        .filter(|port| port.kind == PortKind::Prediction)
64        .map(|port| port.name.clone())
65        .collect::<Vec<_>>();
66    ports.sort();
67    Ok(ports)
68}
69
70fn normalize_prediction_result_port(
71    node_id: &NodeId,
72    block_kind: &str,
73    producer_port: &mut Option<String>,
74    prediction_ports: &[String],
75) -> Result<()> {
76    if let Some(port) = producer_port.as_ref() {
77        if port.trim().is_empty() {
78            return Err(DagMlError::RuntimeValidation(format!(
79                "node `{node_id}` emitted {block_kind} with blank producer_port"
80            )));
81        }
82        if !prediction_ports.iter().any(|candidate| candidate == port) {
83            return Err(DagMlError::RuntimeValidation(format!(
84                "node `{node_id}` emitted {block_kind} for undeclared or non-prediction output port `{port}`; declared prediction ports are {:?}",
85                prediction_ports
86            )));
87        }
88        return Ok(());
89    }
90    match prediction_ports {
91        [only] => {
92            *producer_port = Some(only.clone());
93            Ok(())
94        }
95        [] => Err(DagMlError::RuntimeValidation(format!(
96            "node `{node_id}` emitted {block_kind} without producer_port but declares no prediction output port"
97        ))),
98        _ => Err(DagMlError::RuntimeValidation(format!(
99            "node `{node_id}` emitted {block_kind} without producer_port but declares {} prediction output ports {:?}; multi-output controllers must emit producer_port explicitly",
100            prediction_ports.len(),
101            prediction_ports
102        ))),
103    }
104}
105
106pub(crate) fn normalize_result_prediction_ports(
107    plan: &ExecutionPlan,
108    task: &NodeTask,
109    result: &mut NodeResult,
110) -> Result<()> {
111    if result.predictions.is_empty()
112        && result.observation_predictions.is_empty()
113        && result.aggregated_predictions.is_empty()
114        && result.explanations.is_empty()
115    {
116        return Ok(());
117    }
118    let prediction_ports = prediction_output_ports_for_node(plan, &task.node_plan.node_id)?;
119    for block in &mut result.predictions {
120        normalize_prediction_result_port(
121            &task.node_plan.node_id,
122            "prediction block",
123            &mut block.producer_port,
124            &prediction_ports,
125        )?;
126    }
127    for block in &mut result.observation_predictions {
128        normalize_prediction_result_port(
129            &task.node_plan.node_id,
130            "observation prediction block",
131            &mut block.producer_port,
132            &prediction_ports,
133        )?;
134    }
135    for block in &mut result.aggregated_predictions {
136        normalize_prediction_result_port(
137            &task.node_plan.node_id,
138            "aggregated prediction block",
139            &mut block.producer_port,
140            &prediction_ports,
141        )?;
142    }
143    for block in &mut result.explanations {
144        normalize_prediction_result_port(
145            &task.node_plan.node_id,
146            "explanation block",
147            &mut block.producer_port,
148            &prediction_ports,
149        )?;
150    }
151    Ok(())
152}
153
154/// Reject non-direct prediction outputs before the scheduler can aggregate
155/// them.  A terminal external cohort must never fall back to coordinator
156/// relations or a custom aggregation controller simply because a controller
157/// returned observation-level output.
158fn validate_direct_sample_prediction_result(task: &NodeTask, result: &NodeResult) -> Result<()> {
159    if !result.observation_predictions.is_empty() {
160        return Err(DagMlError::RuntimeValidation(format!(
161            "direct terminal PREDICT node `{}` emitted observation-level predictions; relation aggregation is not permitted",
162            task.node_plan.node_id
163        )));
164    }
165    if !result.aggregated_predictions.is_empty() {
166        return Err(DagMlError::RuntimeValidation(format!(
167            "direct terminal PREDICT node `{}` emitted aggregated predictions; relation aggregation is not permitted",
168            task.node_plan.node_id
169        )));
170    }
171    Ok(())
172}
173
174#[derive(Default)]
175pub(crate) struct PhaseScopeResources<'a> {
176    pub(crate) data_provider: Option<&'a dyn RuntimeDataProvider>,
177    /// Scheduler-owned fold universe for a nested execution scope.  It is
178    /// never inferred from a fold-id string: callers retain the parent-bound
179    /// `NestedFoldSet` and pass only its validated inner set here.
180    pub(crate) fold_set_override: Option<&'a FoldSet>,
181    /// Restrict execution to one dependency-closed subgraph.  Nested stacking
182    /// uses this for its base branches before invoking the meta node; ordinary
183    /// phases leave it empty and keep the full plan topology.
184    pub(crate) node_filter: Option<&'a BTreeSet<NodeId>>,
185    /// An inner base pass must not recursively apply the plan's ordinary
186    /// `inner_cv` policy.  Nested stacking owns that one level explicitly.
187    pub(crate) suppress_inner_cv: bool,
188    /// Explicit inner-OOF/outer-evaluation split for the one declared nested
189    /// stacking meta node.  This is scheduler-private evidence, never a graph
190    /// edge or a controller-selected policy.
191    pub(crate) nested_stacking: Option<NestedStackingInput<'a>>,
192    pub(crate) replay_artifact_handles: Option<&'a BTreeMap<NodeId, BTreeMap<String, HandleRef>>>,
193    pub(crate) replay_artifact_inputs:
194        Option<&'a BTreeMap<NodeId, BTreeMap<String, ArtifactInputSpec>>>,
195    pub(crate) replay_bundle_id: Option<&'a BundleId>,
196    pub(crate) data_envelopes: Option<&'a BTreeMap<String, ExternalDataPlanEnvelope>>,
197    pub(crate) prediction_cache_store: Option<&'a dyn RuntimePredictionCacheStore>,
198    pub(crate) prediction_cache_contracts:
199        Option<&'a BTreeMap<String, ReplayPredictionCacheContract>>,
200    /// Terminal replay is deliberately narrower than generic PREDICT: its
201    /// selected result must be emitted as a direct sample-level block.  This
202    /// prevents the scheduler from reading coordinator relations or invoking
203    /// a custom aggregation controller for an external V2 cohort.
204    pub(crate) direct_sample_prediction_only: bool,
205    pub(crate) artifact_store: Option<&'a mut InMemoryArtifactStore>,
206}
207
208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209enum HpoCandidateFitCvOutcome {
210    Completed,
211    Pruned,
212}
213
214struct HpoFoldFeedback<'a> {
215    trial_id: i64,
216    selection: &'a RuntimeHpoSelectionTarget,
217    session: &'a mut dyn RuntimeTunerSession,
218}
219
220fn validate_hpo_progressive_fold_topology(plan: &ExecutionPlan) -> Result<()> {
221    if nested_stacking_campaign_plan(plan)?.is_some() {
222        return Err(DagMlError::RuntimeValidation(
223            "runtime HPO progressive pruning does not support nested-stacking FIT_CV; the scheduler cannot attest one report-grade intermediate per outer fold"
224                .to_string(),
225        ));
226    }
227    let fold_set = plan.fold_set.as_ref().ok_or_else(|| {
228        DagMlError::RuntimeValidation(
229            "runtime HPO progressive pruning requires an explicit validated fold set".to_string(),
230        )
231    })?;
232    if fold_set.partition_mode != FoldPartitionMode::Partition {
233        return Err(DagMlError::RuntimeValidation(
234            "runtime HPO progressive pruning requires FoldPartitionMode::Partition; resampled folds cannot attest one stable validation resource step per sample"
235                .to_string(),
236        ));
237    }
238    i32::try_from(fold_set.folds.len()).map_err(|_| {
239        DagMlError::RuntimeValidation(
240            "runtime HPO fold count exceeds the native intermediate step range".to_string(),
241        )
242    })?;
243    Ok(())
244}
245
246impl SequentialScheduler {
247    /// Run one local tuner session and evaluate every proposal through the
248    /// ordinary FIT_CV scheduler.  The session remains on this thread; only a
249    /// portable [`RuntimeHpoProposal`] and OOF-derived scalar feedback cross
250    /// the controller boundary. SELECT and REFIT deliberately do not occur
251    /// here, so callers can make exactly one selection and one refit after the
252    /// returned report-grade candidate evidence has been audited.
253    pub fn execute_hpo_campaign(
254        &self,
255        plan: &ExecutionPlan,
256        controllers: &RuntimeControllerRegistry,
257        data_provider: &dyn RuntimeDataProvider,
258        ctx: &RunContext,
259        hpo: &RuntimeHpoExecutionContext,
260    ) -> Result<RuntimeHpoCampaignResult> {
261        plan.validate()?;
262        hpo.validate_for_plan(plan)?;
263        validate_hpo_progressive_fold_topology(plan)?;
264        let controller = controllers.get(&hpo.controller_id).ok_or_else(|| {
265            DagMlError::RuntimeValidation(format!(
266                "runtime HPO campaign controller `{}` is not registered",
267                hpo.controller_id
268            ))
269        })?;
270        let task = RuntimeHpoCampaignTask {
271            run_id: ctx.run_id.clone(),
272            operation_id: hpo.operation_id.clone(),
273            controller_id: hpo.controller_id.clone(),
274            target_node_id: hpo.target_node_id.clone(),
275            seed: ctx.root_seed,
276        };
277        let mut session = controller.create_tuner_session(&task, hpo)?;
278        let history_at_start = session.trial_history_len()?;
279        if history_at_start > hpo.trial_budget_total {
280            return Err(DagMlError::RuntimeValidation(format!(
281                "runtime HPO restored native history ({history_at_start}) exceeds total trial budget ({})",
282                hpo.trial_budget_total
283            )));
284        }
285        let remaining_trials = hpo.trial_budget_total - history_at_start;
286        let mut candidates = Vec::new();
287        let mut proposed_variant_ids = BTreeSet::new();
288        // Fresh proposals are checkpointed by this call.  The native study can
289        // nevertheless retain an incumbent from a restored terminal trial, so
290        // keep its persisted trial->variant binding separate from the new
291        // checkpoint evidence and extend it as we ask new trials.
292        let mut trial_variants = BTreeMap::new();
293        let mut incumbent_variants = hpo.resume_variants.clone();
294        let mut terminal_trials = BTreeMap::new();
295        let mut completed_proposals = Vec::new();
296        let mut completed_reports = Vec::new();
297
298        for _ in 0..remaining_trials {
299            let Some(proposal) = session.ask()? else {
300                break;
301            };
302            if trial_variants
303                .insert(proposal.trial_id, proposal.variant.variant_id.clone())
304                .is_some()
305            {
306                return Err(DagMlError::RuntimeValidation(format!(
307                    "runtime HPO session proposed duplicate trial `{}`",
308                    proposal.trial_id
309                )));
310            }
311            if incumbent_variants
312                .insert(proposal.trial_id, proposal.variant.variant_id.clone())
313                .is_some()
314            {
315                return Err(DagMlError::RuntimeValidation(format!(
316                    "runtime HPO session reused restored trial `{}`",
317                    proposal.trial_id
318                )));
319            }
320            if !proposed_variant_ids.insert(proposal.variant.variant_id.clone()) {
321                return Err(DagMlError::RuntimeValidation(format!(
322                    "runtime HPO session proposed duplicate variant `{}`",
323                    proposal.variant.variant_id
324                )));
325            }
326            let mut candidate_plan = plan.clone();
327            candidate_plan.variants = vec![proposal.variant.clone()];
328            candidate_plan.validate()?;
329            let mut candidate_ctx =
330                RunContext::new(ctx.run_id.clone(), proposal.variant.seed.or(ctx.root_seed));
331            candidate_ctx.variant_id = Some(proposal.variant.variant_id.clone());
332
333            let evaluation = {
334                let mut feedback = HpoFoldFeedback {
335                    trial_id: proposal.trial_id,
336                    selection: &hpo.selection,
337                    session: session.as_mut(),
338                };
339                self.execute_hpo_candidate_fit_cv(
340                    &candidate_plan,
341                    controllers,
342                    data_provider,
343                    &mut candidate_ctx,
344                    &mut feedback,
345                )
346            };
347            let evaluation = match evaluation {
348                Ok(evaluation) => evaluation,
349                Err(error) => {
350                    session.tell(
351                        proposal.trial_id,
352                        RuntimeHpoTerminal::Failed {
353                            failure: RuntimeHpoFailure {
354                                code: "DAGML_CV_ERROR".to_string(),
355                                message: error.to_string(),
356                                retryable: false,
357                            },
358                        },
359                    )?;
360                    terminal_trials.insert(proposal.trial_id, HpoTrialTerminalState::Failed);
361                    continue;
362                }
363            };
364            if evaluation == HpoCandidateFitCvOutcome::Pruned {
365                terminal_trials.insert(proposal.trial_id, HpoTrialTerminalState::Pruned);
366                continue;
367            }
368            if let Err(error) = candidate_ctx
369                .collect_cross_fold_validation_scores(plan_oof_partition_mode(&candidate_plan))
370            {
371                session.tell(
372                    proposal.trial_id,
373                    RuntimeHpoTerminal::Failed {
374                        failure: RuntimeHpoFailure {
375                            code: "DAGML_SCORE_ERROR".to_string(),
376                            message: error.to_string(),
377                            retryable: false,
378                        },
379                    },
380                )?;
381                terminal_trials.insert(proposal.trial_id, HpoTrialTerminalState::Failed);
382                continue;
383            }
384            let report = candidate_ctx
385                .score_collector
386                .iter()
387                .find(|report| {
388                    report.producer_node == hpo.selection.producer_node
389                        && report.producer_port.as_deref()
390                            == Some(hpo.selection.producer_port.as_str())
391                        && report.partition == PredictionPartition::Validation
392                        && report
393                            .fold_id
394                            .as_ref()
395                            .is_some_and(|fold| fold.as_str() == "avg")
396                })
397                .cloned();
398            let Some(mut report) = report else {
399                session.tell(
400                    proposal.trial_id,
401                    RuntimeHpoTerminal::Failed {
402                        failure: RuntimeHpoFailure {
403                            code: "DAGML_SCORE_MISSING".to_string(),
404                            message: format!(
405                                "runtime HPO trial `{}` emitted no target OOF average",
406                                proposal.trial_id
407                            ),
408                            retryable: false,
409                        },
410                    },
411                )?;
412                terminal_trials.insert(proposal.trial_id, HpoTrialTerminalState::Failed);
413                continue;
414            };
415            report.variant_id = Some(proposal.variant.variant_id.clone());
416            let score = report
417                .metrics
418                .get(hpo.selection.metric.name())
419                .copied()
420                .filter(|score| score.is_finite());
421            let Some(score) = score else {
422                session.tell(
423                    proposal.trial_id,
424                    RuntimeHpoTerminal::Failed {
425                        failure: RuntimeHpoFailure {
426                            code: "DAGML_SCORE_NONFINITE".to_string(),
427                            message: format!(
428                                "runtime HPO trial `{}` emitted no finite `{}` score",
429                                proposal.trial_id,
430                                hpo.selection.metric.name()
431                            ),
432                            retryable: false,
433                        },
434                    },
435                )?;
436                terminal_trials.insert(proposal.trial_id, HpoTrialTerminalState::Failed);
437                continue;
438            };
439            session.tell(proposal.trial_id, RuntimeHpoTerminal::Completed { score })?;
440            terminal_trials.insert(proposal.trial_id, HpoTrialTerminalState::Completed);
441            completed_proposals.push(proposal.clone());
442            completed_reports.push(RuntimeHpoCompletedReport {
443                trial_id: proposal.trial_id,
444                variant_id: proposal.variant.variant_id.clone(),
445                report: report.clone(),
446            });
447
448            let mut validation_reports = candidate_ctx
449                .score_collector
450                .iter()
451                .filter(|item| item.partition == PredictionPartition::Validation)
452                .cloned()
453                .collect::<Vec<_>>();
454            for item in &mut validation_reports {
455                item.variant_id = Some(proposal.variant.variant_id.clone());
456            }
457            candidates.push(RuntimeHpoCandidateEvaluation {
458                validation_predictions: capture_variant_validation_predictions(
459                    &proposal.variant.variant_id,
460                    None,
461                    &candidate_ctx,
462                ),
463                lineage: candidate_ctx.lineage.records().cloned().collect(),
464                proposal,
465                score,
466                validation_reports,
467            });
468        }
469
470        let history_at_checkpoint = session.trial_history_len()?;
471        if history_at_checkpoint != hpo.trial_budget_total {
472            return Err(DagMlError::RuntimeValidation(format!(
473                "runtime HPO native history ended at {history_at_checkpoint}, expected total trial budget {}",
474                hpo.trial_budget_total
475            )));
476        }
477
478        let checkpoint = RuntimeHpoCheckpointResult {
479            artifact: session.checkpoint()?,
480            provenance: hpo.provenance.clone(),
481            operation_id: hpo.operation_id.clone(),
482            controller_id: hpo.controller_id.clone(),
483            target_node_id: hpo.target_node_id.clone(),
484            completed_proposals,
485            completed_reports,
486            trial_history_len: history_at_checkpoint,
487        };
488        validate_hpo_checkpoint_result(
489            &checkpoint,
490            hpo,
491            &trial_variants,
492            &terminal_trials,
493            history_at_start,
494        )?;
495        let incumbent = session.incumbent(&incumbent_variants)?.ok_or_else(|| {
496            DagMlError::RuntimeValidation(
497                "native HPO campaign has no completed native incumbent after terminalization"
498                    .to_string(),
499            )
500        })?;
501        if incumbent.metric != hpo.selection.metric.name()
502            || incumbent.direction != hpo.selection.direction
503            || incumbent_variants.get(&incumbent.trial_id) != Some(&incumbent.variant_id)
504            || !incumbent.score.is_finite()
505        {
506            return Err(DagMlError::RuntimeValidation(
507                "native HPO incumbent is not bound to this scheduler campaign's metric, direction, trial, and variant"
508                    .to_string(),
509            ));
510        }
511        let terminal_trials = session.terminal_trial_snapshots(&incumbent_variants)?;
512        if terminal_trials.len() != history_at_checkpoint as usize
513            || terminal_trials
514                .windows(2)
515                .any(|pair| pair[0].trial.id >= pair[1].trial.id)
516        {
517            return Err(DagMlError::RuntimeValidation(
518                "native HPO terminal ledger is not a complete strictly ordered history".to_string(),
519            ));
520        }
521        Ok(RuntimeHpoCampaignResult {
522            operation_id: hpo.operation_id.clone(),
523            controller_id: hpo.controller_id.clone(),
524            target_node_id: hpo.target_node_id.clone(),
525            candidates,
526            checkpoint,
527            incumbent,
528            terminal_trials,
529        })
530    }
531
532    fn execute_hpo_candidate_fit_cv(
533        &self,
534        plan: &ExecutionPlan,
535        controllers: &RuntimeControllerRegistry,
536        data_provider: &dyn RuntimeDataProvider,
537        ctx: &mut RunContext,
538        feedback: &mut HpoFoldFeedback<'_>,
539    ) -> Result<HpoCandidateFitCvOutcome> {
540        let candidate_plan = plan;
541        ctx.configure_global_oof_aggregation(candidate_plan, data_provider)?;
542        let fold_ids = candidate_plan
543            .fold_set
544            .as_ref()
545            .expect("progressive HPO topology was preflighted")
546            .folds
547            .iter()
548            .map(|fold| Some(fold.fold_id.clone()))
549            .collect::<Vec<_>>();
550        let variant = candidate_plan
551            .variants
552            .first()
553            .expect("candidate plan has exactly one variant");
554        for (step, fold_id) in fold_ids.into_iter().enumerate() {
555            let score_start = ctx.score_collector.len();
556            self.execute_phase_scope(
557                candidate_plan,
558                controllers,
559                ctx,
560                PhaseScope {
561                    phase: Phase::FitCv,
562                    variant_id: Some(variant.variant_id.clone()),
563                    variant: Some(VariantExecutionSpec::from_plan(variant)),
564                    fold_id: fold_id.clone(),
565                    seed_root: variant.seed.or(ctx.root_seed),
566                },
567                PhaseScopeResources {
568                    data_provider: Some(data_provider),
569                    ..Default::default()
570                },
571            )?;
572            let fold_reports = ctx.score_collector[score_start..]
573                .iter()
574                .filter(|report| {
575                    report.producer_node == feedback.selection.producer_node
576                        && report.producer_port.as_deref()
577                            == Some(feedback.selection.producer_port.as_str())
578                        && report.partition == PredictionPartition::Validation
579                        && report.fold_id == fold_id
580                })
581                .collect::<Vec<_>>();
582            let [fold_report] = fold_reports.as_slice() else {
583                return Err(DagMlError::RuntimeValidation(format!(
584                    "runtime HPO trial `{}` must emit exactly one target validation report per fold; fold {fold_id:?} emitted {}",
585                    feedback.trial_id,
586                    fold_reports.len()
587                )));
588            };
589            let score = fold_report
590                .metrics
591                .get(feedback.selection.metric.name())
592                .copied()
593                .filter(|score| score.is_finite())
594                .ok_or_else(|| {
595                    DagMlError::RuntimeValidation(format!(
596                        "runtime HPO trial `{}` fold {fold_id:?} emitted no finite `{}` intermediate score",
597                        feedback.trial_id,
598                        feedback.selection.metric.name()
599                    ))
600                })?;
601            let step = i32::try_from(step).map_err(|_| {
602                DagMlError::RuntimeValidation(
603                    "runtime HPO fold intermediate count exceeds i32".to_string(),
604                )
605            })?;
606            if feedback
607                .session
608                .report_intermediate(RuntimeHpoIntermediate {
609                    trial_id: feedback.trial_id,
610                    step,
611                    score,
612                })?
613                == RuntimeHpoIntermediateOutcome::Pruned
614            {
615                return Ok(HpoCandidateFitCvOutcome::Pruned);
616            }
617        }
618        Ok(HpoCandidateFitCvOutcome::Completed)
619    }
620
621    pub fn execute_phase(
622        &self,
623        plan: &ExecutionPlan,
624        controllers: &RuntimeControllerRegistry,
625        ctx: &mut RunContext,
626        phase: Phase,
627    ) -> Result<Vec<NodeResult>> {
628        plan.validate()?;
629        let variant_id = ctx.variant_id.clone();
630        let seed_root = ctx.root_seed;
631        self.execute_phase_scope(
632            plan,
633            controllers,
634            ctx,
635            PhaseScope {
636                phase,
637                variant_id,
638                variant: None,
639                fold_id: None,
640                seed_root,
641            },
642            PhaseScopeResources::default(),
643        )
644    }
645
646    pub fn execute_phase_with_data_provider(
647        &self,
648        plan: &ExecutionPlan,
649        controllers: &RuntimeControllerRegistry,
650        data_provider: &dyn RuntimeDataProvider,
651        ctx: &mut RunContext,
652        phase: Phase,
653    ) -> Result<Vec<NodeResult>> {
654        plan.validate()?;
655        let variant_id = ctx.variant_id.clone();
656        let seed_root = ctx.root_seed;
657        self.execute_phase_scope(
658            plan,
659            controllers,
660            ctx,
661            PhaseScope {
662                phase,
663                variant_id,
664                variant: None,
665                fold_id: None,
666                seed_root,
667            },
668            PhaseScopeResources {
669                data_provider: Some(data_provider),
670                ..Default::default()
671            },
672        )
673    }
674
675    pub fn execute_campaign_phase(
676        &self,
677        plan: &ExecutionPlan,
678        controllers: &RuntimeControllerRegistry,
679        ctx: &mut RunContext,
680        phase: Phase,
681    ) -> Result<Vec<NodeResult>> {
682        plan.validate()?;
683        if phase == Phase::FitCv && nested_stacking_campaign_plan(plan)?.is_some() {
684            return Err(DagMlError::RuntimeValidation(
685                "nested stacking FIT_CV requires execute_campaign_phase_with_data_provider so the scheduler can materialize parent-bound inner folds"
686                    .to_string(),
687            ));
688        }
689        let mut results = Vec::new();
690        let fold_ids = if phase == Phase::FitCv {
691            plan.fold_set
692                .as_ref()
693                .map(|fold_set| {
694                    fold_set
695                        .folds
696                        .iter()
697                        .map(|fold| Some(fold.fold_id.clone()))
698                        .collect::<Vec<_>>()
699                })
700                .unwrap_or_else(|| vec![None])
701        } else {
702            vec![None]
703        };
704        for variant in &plan.variants {
705            if ctx
706                .variant_id
707                .as_ref()
708                .is_some_and(|requested| requested != &variant.variant_id)
709            {
710                continue;
711            }
712            for fold_id in &fold_ids {
713                let seed_root = variant.seed.or(ctx.root_seed);
714                results.extend(self.execute_phase_scope(
715                    plan,
716                    controllers,
717                    ctx,
718                    PhaseScope {
719                        phase,
720                        variant_id: Some(variant.variant_id.clone()),
721                        variant: Some(VariantExecutionSpec::from_plan(variant)),
722                        fold_id: fold_id.clone(),
723                        seed_root,
724                    },
725                    PhaseScopeResources::default(),
726                )?);
727            }
728        }
729        Ok(results)
730    }
731
732    pub fn execute_campaign_phase_with_data_provider(
733        &self,
734        plan: &ExecutionPlan,
735        controllers: &RuntimeControllerRegistry,
736        data_provider: &dyn RuntimeDataProvider,
737        ctx: &mut RunContext,
738        phase: Phase,
739    ) -> Result<Vec<NodeResult>> {
740        plan.validate()?;
741        if phase == Phase::FitCv {
742            ctx.configure_global_oof_aggregation(plan, data_provider)?;
743            if let Some(nested) = nested_stacking_campaign_plan(plan)? {
744                return self.execute_nested_stacking_fit_cv(
745                    plan,
746                    controllers,
747                    data_provider,
748                    ctx,
749                    &nested,
750                );
751            }
752        }
753        let mut results = Vec::new();
754        if phase == Phase::Refit {
755            results.extend(self.execute_stacking_refit_oof(
756                plan,
757                controllers,
758                data_provider,
759                ctx,
760            )?);
761        }
762        let fold_ids = if phase == Phase::FitCv {
763            plan.fold_set
764                .as_ref()
765                .map(|fold_set| {
766                    fold_set
767                        .folds
768                        .iter()
769                        .map(|fold| Some(fold.fold_id.clone()))
770                        .collect::<Vec<_>>()
771                })
772                .unwrap_or_else(|| vec![None])
773        } else {
774            vec![None]
775        };
776        for variant in &plan.variants {
777            if ctx
778                .variant_id
779                .as_ref()
780                .is_some_and(|requested| requested != &variant.variant_id)
781            {
782                continue;
783            }
784            for fold_id in &fold_ids {
785                let seed_root = variant.seed.or(ctx.root_seed);
786                results.extend(self.execute_phase_scope(
787                    plan,
788                    controllers,
789                    ctx,
790                    PhaseScope {
791                        phase,
792                        variant_id: Some(variant.variant_id.clone()),
793                        variant: Some(VariantExecutionSpec::from_plan(variant)),
794                        fold_id: fold_id.clone(),
795                        seed_root,
796                    },
797                    PhaseScopeResources {
798                        data_provider: Some(data_provider),
799                        ..Default::default()
800                    },
801                )?);
802            }
803        }
804        Ok(results)
805    }
806
807    pub fn execute_campaign_phase_with_data_provider_and_artifact_store(
808        &self,
809        plan: &ExecutionPlan,
810        controllers: &RuntimeControllerRegistry,
811        data_provider: &dyn RuntimeDataProvider,
812        artifact_store: &mut InMemoryArtifactStore,
813        ctx: &mut RunContext,
814        phase: Phase,
815    ) -> Result<Vec<NodeResult>> {
816        plan.validate()?;
817        if phase == Phase::FitCv {
818            ctx.configure_global_oof_aggregation(plan, data_provider)?;
819            if let Some(nested) = nested_stacking_campaign_plan(plan)? {
820                // FIT_CV produces no refit artifacts. Keep the data-provider
821                // route canonical rather than silently using an artifact store
822                // that cannot participate in the inner-OOF proof.
823                return self.execute_nested_stacking_fit_cv(
824                    plan,
825                    controllers,
826                    data_provider,
827                    ctx,
828                    &nested,
829                );
830            }
831        }
832        let mut results = Vec::new();
833        if phase == Phase::Refit {
834            results.extend(self.execute_stacking_refit_oof(
835                plan,
836                controllers,
837                data_provider,
838                ctx,
839            )?);
840        }
841        let fold_ids = if phase == Phase::FitCv {
842            plan.fold_set
843                .as_ref()
844                .map(|fold_set| {
845                    fold_set
846                        .folds
847                        .iter()
848                        .map(|fold| Some(fold.fold_id.clone()))
849                        .collect::<Vec<_>>()
850                })
851                .unwrap_or_else(|| vec![None])
852        } else {
853            vec![None]
854        };
855        for variant in &plan.variants {
856            if ctx
857                .variant_id
858                .as_ref()
859                .is_some_and(|requested| requested != &variant.variant_id)
860            {
861                continue;
862            }
863            for fold_id in &fold_ids {
864                let seed_root = variant.seed.or(ctx.root_seed);
865                results.extend(self.execute_phase_scope(
866                    plan,
867                    controllers,
868                    ctx,
869                    PhaseScope {
870                        phase,
871                        variant_id: Some(variant.variant_id.clone()),
872                        variant: Some(VariantExecutionSpec::from_plan(variant)),
873                        fold_id: fold_id.clone(),
874                        seed_root,
875                    },
876                    PhaseScopeResources {
877                        data_provider: Some(data_provider),
878                        artifact_store: Some(&mut *artifact_store),
879                        ..Default::default()
880                    },
881                )?);
882            }
883        }
884        Ok(results)
885    }
886
887    /// Prepare explicit full-training OOF for the meta REFIT, never for CV selection.
888    ///
889    /// Logical model calls remain FIT_CV because each call fits a strict subset
890    /// and predicts its held-out complement. The owning public phase is REFIT,
891    /// and its fingerprinted policy/namespaced fold IDs distinguish preparation
892    /// from outer evaluation. No raw train predictions are reused or imputed.
893    fn execute_stacking_refit_oof(
894        &self,
895        plan: &ExecutionPlan,
896        controllers: &RuntimeControllerRegistry,
897        data_provider: &dyn RuntimeDataProvider,
898        ctx: &mut RunContext,
899    ) -> Result<Vec<NodeResult>> {
900        let Some(nested) = nested_stacking_campaign_plan(plan)? else {
901            return Ok(Vec::new());
902        };
903        let Some(folds) = &nested.refit_fold_set else {
904            return Ok(Vec::new());
905        };
906        let outer_fold_ids = nested
907            .outer_scopes
908            .iter()
909            .map(|scope| scope.outer_fold_id.clone())
910            .collect::<BTreeSet<_>>();
911        if let Some(existing) = &ctx.validation_scoring_fold_ids {
912            if existing != &outer_fold_ids {
913                return Err(DagMlError::RuntimeValidation(
914                    "stacking REFIT OOF cannot reuse a different outer evaluation scope"
915                        .to_string(),
916                ));
917            }
918        } else {
919            ctx.validation_scoring_fold_ids = Some(outer_fold_ids);
920        }
921        let mut results = Vec::new();
922        for variant in &plan.variants {
923            if ctx
924                .variant_id
925                .as_ref()
926                .is_some_and(|id| id != &variant.variant_id)
927            {
928                continue;
929            }
930            for fold in &folds.folds {
931                results.extend(self.execute_phase_scope(
932                    plan,
933                    controllers,
934                    ctx,
935                    PhaseScope {
936                        phase: Phase::FitCv,
937                        variant_id: Some(variant.variant_id.clone()),
938                        variant: Some(VariantExecutionSpec::from_plan(variant)),
939                        fold_id: Some(fold.fold_id.clone()),
940                        seed_root: variant.seed.or(ctx.root_seed),
941                    },
942                    PhaseScopeResources {
943                        data_provider: Some(data_provider),
944                        fold_set_override: Some(folds),
945                        node_filter: Some(&nested.base_node_ids),
946                        suppress_inner_cv: true,
947                        ..Default::default()
948                    },
949                )?);
950            }
951        }
952        Ok(results)
953    }
954
955    /// Execute one explicitly declared nested-stacking FIT_CV campaign.
956    ///
957    /// For every outer fold, base nodes first produce their OOF predictions on
958    /// the parent-bound inner folds (the only rows used to fit the meta-model),
959    /// then independently produce outer-validation predictions (the only rows
960    /// scored by the meta-model).  The meta invocation receives both evidence
961    /// classes under separate keys; it cannot accidentally train on the outer
962    /// validation rows through the generic OOF collector.
963    fn execute_nested_stacking_fit_cv(
964        &self,
965        plan: &ExecutionPlan,
966        controllers: &RuntimeControllerRegistry,
967        data_provider: &dyn RuntimeDataProvider,
968        ctx: &mut RunContext,
969        nested: &NestedStackingCampaignPlan,
970    ) -> Result<Vec<NodeResult>> {
971        let parent_fold_ids = nested
972            .outer_scopes
973            .iter()
974            .map(|outer| outer.outer_fold_id.clone())
975            .collect::<BTreeSet<_>>();
976        if let Some(existing) = &ctx.validation_scoring_fold_ids {
977            if existing != &parent_fold_ids {
978                return Err(DagMlError::RuntimeValidation(
979                    "nested stacking cannot reuse a run context with a different report-grade outer fold set"
980                        .to_string(),
981                ));
982            }
983        } else {
984            ctx.validation_scoring_fold_ids = Some(parent_fold_ids);
985        }
986        let mut results = Vec::new();
987        for variant in &plan.variants {
988            if ctx
989                .variant_id
990                .as_ref()
991                .is_some_and(|requested| requested != &variant.variant_id)
992            {
993                continue;
994            }
995            let seed_root = variant.seed.or(ctx.root_seed);
996            let variant_id = Some(variant.variant_id.clone());
997            let variant_spec = Some(VariantExecutionSpec::from_plan(variant));
998            for outer in &nested.outer_scopes {
999                for inner_fold in &outer.inner.inner_fold_set.folds {
1000                    results.extend(self.execute_phase_scope(
1001                        plan,
1002                        controllers,
1003                        ctx,
1004                        PhaseScope {
1005                            phase: Phase::FitCv,
1006                            variant_id: variant_id.clone(),
1007                            variant: variant_spec.clone(),
1008                            fold_id: Some(inner_fold.fold_id.clone()),
1009                            seed_root,
1010                        },
1011                        PhaseScopeResources {
1012                            data_provider: Some(data_provider),
1013                            fold_set_override: Some(&outer.inner.inner_fold_set),
1014                            node_filter: Some(&nested.base_node_ids),
1015                            suppress_inner_cv: true,
1016                            ..Default::default()
1017                        },
1018                    )?);
1019                }
1020
1021                // Materialize outer-validation base features in a distinct
1022                // scope.  They stay out of the unsuffixed meta inputs.
1023                results.extend(self.execute_phase_scope(
1024                    plan,
1025                    controllers,
1026                    ctx,
1027                    PhaseScope {
1028                        phase: Phase::FitCv,
1029                        variant_id: variant_id.clone(),
1030                        variant: variant_spec.clone(),
1031                        fold_id: Some(outer.outer_fold_id.clone()),
1032                        seed_root,
1033                    },
1034                    PhaseScopeResources {
1035                        data_provider: Some(data_provider),
1036                        node_filter: Some(&nested.base_node_ids),
1037                        suppress_inner_cv: true,
1038                        ..Default::default()
1039                    },
1040                )?);
1041
1042                let meta_only = BTreeSet::from([nested.meta_node_id.clone()]);
1043                results.extend(self.execute_phase_scope(
1044                    plan,
1045                    controllers,
1046                    ctx,
1047                    PhaseScope {
1048                        phase: Phase::FitCv,
1049                        variant_id: variant_id.clone(),
1050                        variant: variant_spec.clone(),
1051                        fold_id: Some(outer.outer_fold_id.clone()),
1052                        seed_root,
1053                    },
1054                    PhaseScopeResources {
1055                        data_provider: Some(data_provider),
1056                        node_filter: Some(&meta_only),
1057                        suppress_inner_cv: true,
1058                        nested_stacking: Some(NestedStackingInput {
1059                            meta_node_id: &nested.meta_node_id,
1060                            inner: &outer.inner,
1061                        }),
1062                        ..Default::default()
1063                    },
1064                )?);
1065            }
1066        }
1067        Ok(results)
1068    }
1069
1070    pub fn execute_bundle_replay(
1071        &self,
1072        replay: BundleReplayExecution<'_>,
1073        ctx: &mut RunContext,
1074    ) -> Result<Vec<NodeResult>> {
1075        self.execute_bundle_replay_with_prediction_mode(replay, ctx, false)
1076    }
1077
1078    /// Execute a PREDICT replay whose controller output is required to be a
1079    /// direct sample-level block.  This is an internal terminal boundary, not
1080    /// a change to the generic replay contract.
1081    pub(crate) fn execute_direct_sample_bundle_replay(
1082        &self,
1083        replay: BundleReplayExecution<'_>,
1084        ctx: &mut RunContext,
1085    ) -> Result<Vec<NodeResult>> {
1086        if replay.replay_request.phase != Phase::Predict {
1087            return Err(DagMlError::RuntimeValidation(
1088                "direct sample bundle replay is valid only for PREDICT".to_string(),
1089            ));
1090        }
1091        self.execute_bundle_replay_with_prediction_mode(replay, ctx, true)
1092    }
1093
1094    fn execute_bundle_replay_with_prediction_mode(
1095        &self,
1096        replay: BundleReplayExecution<'_>,
1097        ctx: &mut RunContext,
1098        direct_sample_prediction_only: bool,
1099    ) -> Result<Vec<NodeResult>> {
1100        replay.bundle.validate_against_plan(replay.plan)?;
1101        replay
1102            .replay_request
1103            .validate_for_bundle_with_prediction_cache_store(
1104                replay.bundle,
1105                replay.prediction_cache_store.is_some(),
1106            )?;
1107        replay
1108            .bundle
1109            .validate_replay_envelopes(replay.data_envelopes)?;
1110        let prediction_cache_contracts = if replay.replay_request.phase == Phase::Refit {
1111            Some(replay_prediction_cache_contracts(replay.bundle)?)
1112        } else {
1113            None
1114        };
1115        if replay.replay_request.phase == Phase::Refit {
1116            preload_replay_prediction_cache_store(
1117                replay.bundle,
1118                replay.prediction_cache_store,
1119                ctx,
1120            )?;
1121        }
1122        let replay_artifacts = materialize_replay_artifact_handles(
1123            replay.plan,
1124            replay.bundle,
1125            replay.replay_request,
1126            replay.artifact_store,
1127            ctx,
1128        )?;
1129        let selected_variant = replay
1130            .bundle
1131            .selected_variant_id
1132            .as_ref()
1133            .map(|selected| {
1134                replay
1135                    .plan
1136                    .variants
1137                    .iter()
1138                    .find(|variant| &variant.variant_id == selected)
1139                    .map(VariantExecutionSpec::from_plan)
1140                    .ok_or_else(|| {
1141                        DagMlError::RuntimeValidation(format!(
1142                            "bundle `{}` selected unknown variant `{selected}`",
1143                            replay.bundle.bundle_id
1144                        ))
1145                    })
1146            })
1147            .transpose()?;
1148        let seed_root = selected_variant
1149            .as_ref()
1150            .and_then(|variant| variant.seed)
1151            .or(ctx.root_seed);
1152
1153        self.execute_phase_scope(
1154            replay.plan,
1155            replay.controllers,
1156            ctx,
1157            PhaseScope {
1158                phase: replay.replay_request.phase,
1159                variant_id: replay.bundle.selected_variant_id.clone(),
1160                variant: selected_variant,
1161                fold_id: None,
1162                seed_root,
1163            },
1164            PhaseScopeResources {
1165                data_provider: Some(replay.data_provider),
1166                replay_artifact_handles: Some(&replay_artifacts.handles),
1167                replay_artifact_inputs: Some(&replay_artifacts.inputs),
1168                replay_bundle_id: Some(&replay.bundle.bundle_id),
1169                data_envelopes: Some(replay.data_envelopes),
1170                prediction_cache_store: replay.prediction_cache_store,
1171                prediction_cache_contracts: prediction_cache_contracts.as_ref(),
1172                direct_sample_prediction_only,
1173                ..Default::default()
1174            },
1175        )
1176    }
1177
1178    fn execute_phase_scope(
1179        &self,
1180        plan: &ExecutionPlan,
1181        controllers: &RuntimeControllerRegistry,
1182        ctx: &mut RunContext,
1183        scope: PhaseScope,
1184        mut resources: PhaseScopeResources<'_>,
1185    ) -> Result<Vec<NodeResult>> {
1186        let _phase_span = crate::observability::phase_span(
1187            ctx.run_id.as_str(),
1188            plan.id.as_str(),
1189            scope.phase.as_str(),
1190            scope.variant_id.as_ref().map(VariantId::as_str),
1191            scope.fold_id.as_ref().map(FoldId::as_str),
1192        )
1193        .entered();
1194        let mut results = Vec::new();
1195        let mut output_handles = BTreeMap::<NodeId, BTreeMap<String, HandleRef>>::new();
1196        let mut output_data_views =
1197            BTreeMap::<NodeId, BTreeMap<String, DataProviderViewSpec>>::new();
1198        let mut input_lineage = BTreeMap::<NodeId, LineageId>::new();
1199
1200        for level in plan.node_parallel_levels_for_phase(scope.phase)? {
1201            for node_id in &level {
1202                if resources
1203                    .node_filter
1204                    .is_some_and(|allowed| !allowed.contains(node_id))
1205                {
1206                    continue;
1207                }
1208                let node_plan = plan
1209                    .node_plans
1210                    .get(node_id)
1211                    .expect("execution plan was validated");
1212                // Cross-branch merge reassembly (concat or late-fusion) is a
1213                // scheduler/runtime handler, not a controller call: it reads the
1214                // upstream branch OOF blocks from the prediction store and emits
1215                // one merged per-sample OOF block. Intercept it before the
1216                // controller path (and before the `requires_oof` edge collection,
1217                // which is a stacking contract the branch inputs do not satisfy).
1218                if let Some(reduction) = merge_reduction_mode(plan, node_plan) {
1219                    if let Some(mut result) =
1220                        reassemble_branch_merge(plan, node_plan, ctx, &scope, reduction)?
1221                    {
1222                        let task_node_plan = effective_node_plan_for_scope(node_plan, &scope)?;
1223                        let task = NodeTask {
1224                            inner_fold_set: None,
1225                            run_id: ctx.run_id.clone(),
1226                            node_plan: task_node_plan.clone(),
1227                            phase: scope.phase,
1228                            variant_id: scope.variant_id.clone(),
1229                            variant: scope.variant.clone(),
1230                            fold_id: scope.fold_id.clone(),
1231                            branch_path: Vec::new(),
1232                            input_handles: BTreeMap::new(),
1233                            data_views: BTreeMap::new(),
1234                            prediction_inputs: BTreeMap::new(),
1235                            artifact_inputs: BTreeMap::new(),
1236                            required_loss_attestations: NodeTask::required_loss_attestations_for(
1237                                &task_node_plan,
1238                                scope.phase,
1239                            )?,
1240                            fit_influence: FitInfluenceTask::default(),
1241                            seed: None,
1242                        };
1243                        normalize_result_prediction_ports(plan, &task, &mut result)?;
1244                        result.validate_for_task(&task)?;
1245                        for prediction in &result.predictions {
1246                            ctx.prediction_store.append(prediction.clone())?;
1247                        }
1248                        apply_result_scoring(
1249                            &result,
1250                            &mut ctx.score_collector,
1251                            &mut ctx.regression_target_records,
1252                        )?;
1253                        ctx.lineage.record(result.lineage.clone())?;
1254                        output_handles.insert(node_id.clone(), result.outputs.clone());
1255                        input_lineage.insert(node_id.clone(), result.lineage.record_id.clone());
1256                        results.push(result);
1257                    }
1258                    continue;
1259                }
1260                let controller = controllers.get(&node_plan.controller_id).ok_or_else(|| {
1261                    DagMlError::RuntimeValidation(format!(
1262                        "runtime controller `{}` is not registered",
1263                        node_plan.controller_id
1264                    ))
1265                })?;
1266                let collected_inputs = collect_input_handles(
1267                    plan,
1268                    node_plan,
1269                    &output_handles,
1270                    &output_data_views,
1271                    &resources,
1272                    ctx,
1273                    &scope,
1274                )?;
1275                if collected_inputs.skip_node {
1276                    continue;
1277                }
1278                let mut input_handles = collected_inputs.handles;
1279                let mut prediction_inputs = collected_inputs.prediction_inputs;
1280                if let Some(nested) = resources.nested_stacking.as_ref() {
1281                    replace_nested_stacking_fit_cv_inputs(
1282                        plan,
1283                        node_plan,
1284                        ctx,
1285                        &scope,
1286                        nested,
1287                        &mut input_handles,
1288                        &mut prediction_inputs,
1289                    )?;
1290                }
1291                let mut artifact_inputs = BTreeMap::new();
1292                if let Some(node_artifact_handles) = resources
1293                    .replay_artifact_handles
1294                    .and_then(|handles| handles.get(node_id))
1295                {
1296                    for (key, handle) in node_artifact_handles {
1297                        if input_handles.insert(key.clone(), handle.clone()).is_some() {
1298                            return Err(DagMlError::RuntimeValidation(format!(
1299                                "node `{node_id}` received duplicate replay artifact input `{key}`"
1300                            )));
1301                        }
1302                    }
1303                }
1304                if let Some(node_artifact_inputs) = resources
1305                    .replay_artifact_inputs
1306                    .and_then(|inputs| inputs.get(node_id))
1307                {
1308                    for (key, spec) in node_artifact_inputs {
1309                        if artifact_inputs.insert(key.clone(), spec.clone()).is_some() {
1310                            return Err(DagMlError::RuntimeValidation(format!(
1311                                "node `{node_id}` received duplicate replay artifact metadata `{key}`"
1312                            )));
1313                        }
1314                    }
1315                }
1316                let task_node_plan = effective_node_plan_for_scope(node_plan, &scope)?;
1317                let inner_fold_set = (!resources.suppress_inner_cv)
1318                    .then(|| {
1319                        inner_fold_set_for_scope(
1320                            &plan.campaign,
1321                            plan.fold_set.as_ref(),
1322                            node_plan,
1323                            &scope,
1324                        )
1325                    })
1326                    .transpose()?
1327                    .flatten();
1328                let fit_influence = fit_influence_task_for_node(
1329                    plan,
1330                    &task_node_plan,
1331                    &collected_inputs.data_views,
1332                )?;
1333                let task = NodeTask {
1334                    inner_fold_set,
1335                    run_id: ctx.run_id.clone(),
1336                    node_plan: task_node_plan.clone(),
1337                    phase: scope.phase,
1338                    variant_id: scope.variant_id.clone(),
1339                    variant: scope.variant.clone(),
1340                    fold_id: scope.fold_id.clone(),
1341                    branch_path: Vec::new(),
1342                    input_handles,
1343                    data_views: collected_inputs.data_views,
1344                    prediction_inputs,
1345                    artifact_inputs,
1346                    required_loss_attestations: NodeTask::required_loss_attestations_for(
1347                        &task_node_plan,
1348                        scope.phase,
1349                    )?,
1350                    fit_influence,
1351                    seed: derive_task_seed(
1352                        scope.seed_root,
1353                        scope.variant_id.as_ref(),
1354                        scope.fold_id.as_ref(),
1355                        &task_node_plan,
1356                        scope.phase,
1357                    ),
1358                };
1359                let _node_span = crate::observability::node_span(
1360                    task.run_id.as_str(),
1361                    plan.id.as_str(),
1362                    task.phase.as_str(),
1363                    task.node_plan.node_id.as_str(),
1364                    task.node_plan.controller_id.as_str(),
1365                )
1366                .entered();
1367                let mut result = if task.node_plan.kind == NodeKind::Tuner {
1368                    return Err(DagMlError::RuntimeValidation(format!(
1369                        "tuner node `{}` requires execute_hpo_campaign with an explicit RuntimeHpoExecutionContext",
1370                        task.node_plan.node_id
1371                    )));
1372                } else {
1373                    match resources.data_provider {
1374                        Some(data_provider) => {
1375                            controller.invoke_with_data_provider(&task, data_provider)?
1376                        }
1377                        None => controller.invoke(&task)?,
1378                    }
1379                };
1380                record_fit_influence_diagnostic(&task, &mut result);
1381                normalize_result_prediction_ports(plan, &task, &mut result)?;
1382                result.validate_for_task(&task)?;
1383                if resources.direct_sample_prediction_only {
1384                    validate_direct_sample_prediction_result(&task, &result)?;
1385                } else {
1386                    apply_result_prediction_aggregation(
1387                        plan,
1388                        controllers,
1389                        &task,
1390                        &mut result,
1391                        &resources,
1392                    )?;
1393                }
1394                if let Some(nested) = resources.nested_stacking.as_ref() {
1395                    attach_nested_stacking_input_lineage(&mut result, plan, &task, ctx, nested)?;
1396                } else {
1397                    attach_coordinator_input_lineage(
1398                        &mut result,
1399                        plan,
1400                        &task.node_plan.node_id,
1401                        &input_lineage,
1402                    )?;
1403                }
1404                if let Some(store) = resources.artifact_store.as_deref_mut() {
1405                    if scope.phase == Phase::Refit {
1406                        store.capture_refit_artifacts(&task, &result)?;
1407                    }
1408                }
1409                for prediction in &result.predictions {
1410                    ctx.prediction_store.append(prediction.clone())?;
1411                }
1412                for prediction in &result.aggregated_predictions {
1413                    ctx.aggregated_prediction_store.append(prediction.clone())?;
1414                }
1415                apply_result_scoring(
1416                    &result,
1417                    &mut ctx.score_collector,
1418                    &mut ctx.regression_target_records,
1419                )?;
1420                ctx.lineage.record(result.lineage.clone())?;
1421                let data_views = derive_output_data_views(plan, &task, &result)?;
1422                output_handles.insert(node_id.clone(), result.outputs.clone());
1423                output_data_views.insert(node_id.clone(), data_views);
1424                input_lineage.insert(node_id.clone(), result.lineage.record_id.clone());
1425                results.push(result);
1426            }
1427        }
1428
1429        Ok(results)
1430    }
1431}
1432
1433impl ParallelScheduler {
1434    pub fn execute_phase(
1435        &self,
1436        plan: &ExecutionPlan,
1437        controllers: &RuntimeControllerRegistry,
1438        ctx: &mut RunContext,
1439        phase: Phase,
1440    ) -> Result<Vec<NodeResult>> {
1441        plan.validate()?;
1442        let variant_id = ctx.variant_id.clone();
1443        let seed_root = ctx.root_seed;
1444        self.execute_phase_scope(
1445            plan,
1446            controllers,
1447            ctx,
1448            PhaseScope {
1449                phase,
1450                variant_id,
1451                variant: None,
1452                fold_id: None,
1453                seed_root,
1454            },
1455            PhaseScopeResources::default(),
1456        )
1457    }
1458
1459    pub fn execute_phase_with_data_provider(
1460        &self,
1461        plan: &ExecutionPlan,
1462        controllers: &RuntimeControllerRegistry,
1463        data_provider: &dyn RuntimeDataProvider,
1464        ctx: &mut RunContext,
1465        phase: Phase,
1466    ) -> Result<Vec<NodeResult>> {
1467        plan.validate()?;
1468        let variant_id = ctx.variant_id.clone();
1469        let seed_root = ctx.root_seed;
1470        self.execute_phase_scope(
1471            plan,
1472            controllers,
1473            ctx,
1474            PhaseScope {
1475                phase,
1476                variant_id,
1477                variant: None,
1478                fold_id: None,
1479                seed_root,
1480            },
1481            PhaseScopeResources {
1482                data_provider: Some(data_provider),
1483                ..Default::default()
1484            },
1485        )
1486    }
1487
1488    pub fn execute_campaign_phase(
1489        &self,
1490        plan: &ExecutionPlan,
1491        controllers: &RuntimeControllerRegistry,
1492        ctx: &mut RunContext,
1493        phase: Phase,
1494    ) -> Result<Vec<NodeResult>> {
1495        plan.validate()?;
1496        if phase == Phase::FitCv && nested_stacking_campaign_plan(plan)?.is_some() {
1497            return Err(DagMlError::RuntimeValidation(
1498                "nested stacking FIT_CV is scheduler-serial by construction; use SequentialScheduler so inner OOF evidence is retained before outer evaluation"
1499                    .to_string(),
1500            ));
1501        }
1502        let mut results = Vec::new();
1503        let fold_ids = if phase == Phase::FitCv {
1504            plan.fold_set
1505                .as_ref()
1506                .map(|fold_set| {
1507                    fold_set
1508                        .folds
1509                        .iter()
1510                        .map(|fold| Some(fold.fold_id.clone()))
1511                        .collect::<Vec<_>>()
1512                })
1513                .unwrap_or_else(|| vec![None])
1514        } else {
1515            vec![None]
1516        };
1517        for variant in &plan.variants {
1518            if ctx
1519                .variant_id
1520                .as_ref()
1521                .is_some_and(|requested| requested != &variant.variant_id)
1522            {
1523                continue;
1524            }
1525            for fold_id in &fold_ids {
1526                let seed_root = variant.seed.or(ctx.root_seed);
1527                results.extend(self.execute_phase_scope(
1528                    plan,
1529                    controllers,
1530                    ctx,
1531                    PhaseScope {
1532                        phase,
1533                        variant_id: Some(variant.variant_id.clone()),
1534                        variant: Some(VariantExecutionSpec::from_plan(variant)),
1535                        fold_id: fold_id.clone(),
1536                        seed_root,
1537                    },
1538                    PhaseScopeResources::default(),
1539                )?);
1540            }
1541        }
1542        Ok(results)
1543    }
1544
1545    pub fn execute_campaign_phase_with_data_provider(
1546        &self,
1547        plan: &ExecutionPlan,
1548        controllers: &RuntimeControllerRegistry,
1549        data_provider: &dyn RuntimeDataProvider,
1550        ctx: &mut RunContext,
1551        phase: Phase,
1552    ) -> Result<Vec<NodeResult>> {
1553        plan.validate()?;
1554        if phase == Phase::FitCv {
1555            ctx.configure_global_oof_aggregation(plan, data_provider)?;
1556        }
1557        if phase == Phase::FitCv && nested_stacking_campaign_plan(plan)?.is_some() {
1558            return Err(DagMlError::RuntimeValidation(
1559                "nested stacking FIT_CV is scheduler-serial by construction; use SequentialScheduler so inner OOF evidence is retained before outer evaluation"
1560                    .to_string(),
1561            ));
1562        }
1563        let mut results = Vec::new();
1564        let fold_ids = if phase == Phase::FitCv {
1565            plan.fold_set
1566                .as_ref()
1567                .map(|fold_set| {
1568                    fold_set
1569                        .folds
1570                        .iter()
1571                        .map(|fold| Some(fold.fold_id.clone()))
1572                        .collect::<Vec<_>>()
1573                })
1574                .unwrap_or_else(|| vec![None])
1575        } else {
1576            vec![None]
1577        };
1578        for variant in &plan.variants {
1579            if ctx
1580                .variant_id
1581                .as_ref()
1582                .is_some_and(|requested| requested != &variant.variant_id)
1583            {
1584                continue;
1585            }
1586            for fold_id in &fold_ids {
1587                let seed_root = variant.seed.or(ctx.root_seed);
1588                results.extend(self.execute_phase_scope(
1589                    plan,
1590                    controllers,
1591                    ctx,
1592                    PhaseScope {
1593                        phase,
1594                        variant_id: Some(variant.variant_id.clone()),
1595                        variant: Some(VariantExecutionSpec::from_plan(variant)),
1596                        fold_id: fold_id.clone(),
1597                        seed_root,
1598                    },
1599                    PhaseScopeResources {
1600                        data_provider: Some(data_provider),
1601                        ..Default::default()
1602                    },
1603                )?);
1604            }
1605        }
1606        Ok(results)
1607    }
1608
1609    pub fn execute_campaign_phase_with_data_provider_and_artifact_store(
1610        &self,
1611        plan: &ExecutionPlan,
1612        controllers: &RuntimeControllerRegistry,
1613        data_provider: &dyn RuntimeDataProvider,
1614        artifact_store: &mut InMemoryArtifactStore,
1615        ctx: &mut RunContext,
1616        phase: Phase,
1617    ) -> Result<Vec<NodeResult>> {
1618        plan.validate()?;
1619        if phase == Phase::FitCv && nested_stacking_campaign_plan(plan)?.is_some() {
1620            return Err(DagMlError::RuntimeValidation(
1621                "nested stacking FIT_CV is scheduler-serial by construction; use SequentialScheduler so inner OOF evidence is retained before outer evaluation"
1622                    .to_string(),
1623            ));
1624        }
1625        let mut results = Vec::new();
1626        let fold_ids = if phase == Phase::FitCv {
1627            plan.fold_set
1628                .as_ref()
1629                .map(|fold_set| {
1630                    fold_set
1631                        .folds
1632                        .iter()
1633                        .map(|fold| Some(fold.fold_id.clone()))
1634                        .collect::<Vec<_>>()
1635                })
1636                .unwrap_or_else(|| vec![None])
1637        } else {
1638            vec![None]
1639        };
1640        for variant in &plan.variants {
1641            if ctx
1642                .variant_id
1643                .as_ref()
1644                .is_some_and(|requested| requested != &variant.variant_id)
1645            {
1646                continue;
1647            }
1648            for fold_id in &fold_ids {
1649                let seed_root = variant.seed.or(ctx.root_seed);
1650                results.extend(self.execute_phase_scope(
1651                    plan,
1652                    controllers,
1653                    ctx,
1654                    PhaseScope {
1655                        phase,
1656                        variant_id: Some(variant.variant_id.clone()),
1657                        variant: Some(VariantExecutionSpec::from_plan(variant)),
1658                        fold_id: fold_id.clone(),
1659                        seed_root,
1660                    },
1661                    PhaseScopeResources {
1662                        data_provider: Some(data_provider),
1663                        artifact_store: Some(&mut *artifact_store),
1664                        ..Default::default()
1665                    },
1666                )?);
1667            }
1668        }
1669        Ok(results)
1670    }
1671
1672    pub fn execute_bundle_replay(
1673        &self,
1674        replay: BundleReplayExecution<'_>,
1675        ctx: &mut RunContext,
1676    ) -> Result<Vec<NodeResult>> {
1677        replay.bundle.validate_against_plan(replay.plan)?;
1678        replay
1679            .replay_request
1680            .validate_for_bundle_with_prediction_cache_store(
1681                replay.bundle,
1682                replay.prediction_cache_store.is_some(),
1683            )?;
1684        replay
1685            .bundle
1686            .validate_replay_envelopes(replay.data_envelopes)?;
1687        let prediction_cache_contracts = if replay.replay_request.phase == Phase::Refit {
1688            Some(replay_prediction_cache_contracts(replay.bundle)?)
1689        } else {
1690            None
1691        };
1692        if replay.replay_request.phase == Phase::Refit {
1693            preload_replay_prediction_cache_store(
1694                replay.bundle,
1695                replay.prediction_cache_store,
1696                ctx,
1697            )?;
1698        }
1699        let replay_artifacts = materialize_replay_artifact_handles(
1700            replay.plan,
1701            replay.bundle,
1702            replay.replay_request,
1703            replay.artifact_store,
1704            ctx,
1705        )?;
1706        let selected_variant = replay
1707            .bundle
1708            .selected_variant_id
1709            .as_ref()
1710            .map(|selected| {
1711                replay
1712                    .plan
1713                    .variants
1714                    .iter()
1715                    .find(|variant| &variant.variant_id == selected)
1716                    .map(VariantExecutionSpec::from_plan)
1717                    .ok_or_else(|| {
1718                        DagMlError::RuntimeValidation(format!(
1719                            "bundle `{}` selected unknown variant `{selected}`",
1720                            replay.bundle.bundle_id
1721                        ))
1722                    })
1723            })
1724            .transpose()?;
1725        let seed_root = selected_variant
1726            .as_ref()
1727            .and_then(|variant| variant.seed)
1728            .or(ctx.root_seed);
1729
1730        self.execute_phase_scope(
1731            replay.plan,
1732            replay.controllers,
1733            ctx,
1734            PhaseScope {
1735                phase: replay.replay_request.phase,
1736                variant_id: replay.bundle.selected_variant_id.clone(),
1737                variant: selected_variant,
1738                fold_id: None,
1739                seed_root,
1740            },
1741            PhaseScopeResources {
1742                data_provider: Some(replay.data_provider),
1743                replay_artifact_handles: Some(&replay_artifacts.handles),
1744                replay_artifact_inputs: Some(&replay_artifacts.inputs),
1745                replay_bundle_id: Some(&replay.bundle.bundle_id),
1746                data_envelopes: Some(replay.data_envelopes),
1747                prediction_cache_store: replay.prediction_cache_store,
1748                prediction_cache_contracts: prediction_cache_contracts.as_ref(),
1749                ..Default::default()
1750            },
1751        )
1752    }
1753
1754    fn execute_phase_scope(
1755        &self,
1756        plan: &ExecutionPlan,
1757        controllers: &RuntimeControllerRegistry,
1758        ctx: &mut RunContext,
1759        scope: PhaseScope,
1760        mut resources: PhaseScopeResources<'_>,
1761    ) -> Result<Vec<NodeResult>> {
1762        // Hold the phase span on the scheduler thread, and clone it into each
1763        // worker so worker-thread telemetry nests under the phase (tracing spans
1764        // are thread-local and do not auto-propagate across `thread::scope`).
1765        let phase_span = crate::observability::phase_span(
1766            ctx.run_id.as_str(),
1767            plan.id.as_str(),
1768            scope.phase.as_str(),
1769            scope.variant_id.as_ref().map(VariantId::as_str),
1770            scope.fold_id.as_ref().map(FoldId::as_str),
1771        );
1772        let _phase_entered = phase_span.clone().entered();
1773        // Borrowed for the `thread::scope` below; workers join before it ends.
1774        let plan_id = plan.id.as_str();
1775        plan.validate_parallel_controller_capabilities(self.max_workers, scope.phase)?;
1776        let mut results = Vec::new();
1777        let mut output_handles = BTreeMap::<NodeId, BTreeMap<String, HandleRef>>::new();
1778        let mut output_data_views =
1779            BTreeMap::<NodeId, BTreeMap<String, DataProviderViewSpec>>::new();
1780        let mut input_lineage = BTreeMap::<NodeId, LineageId>::new();
1781
1782        for level in plan.node_parallel_levels_for_phase(scope.phase)? {
1783            let mut prepared = Vec::<PreparedNodeTask>::new();
1784            // Cross-branch merge nodes (concat or late-fusion) are not controller
1785            // tasks: they read the upstream branch OOF blocks from the prediction
1786            // store and reassemble them on the scheduler thread (no worker), AFTER
1787            // this level's worker tasks have populated the store. They are in a
1788            // later level than their branches, so the store already holds the
1789            // branch OOF by the time we reassemble — see `reassemble_branch_merge`.
1790            let mut merge_nodes = Vec::<(NodeId, MergeReduction)>::new();
1791            for node_id in &level {
1792                let node_plan = plan
1793                    .node_plans
1794                    .get(node_id)
1795                    .expect("execution plan was validated");
1796                if let Some(reduction) = merge_reduction_mode(plan, node_plan) {
1797                    merge_nodes.push((node_id.clone(), reduction));
1798                    continue;
1799                }
1800                let collected_inputs = collect_input_handles(
1801                    plan,
1802                    node_plan,
1803                    &output_handles,
1804                    &output_data_views,
1805                    &resources,
1806                    ctx,
1807                    &scope,
1808                )?;
1809                if collected_inputs.skip_node {
1810                    continue;
1811                }
1812                let mut input_handles = collected_inputs.handles;
1813                let mut artifact_inputs = BTreeMap::new();
1814                if let Some(node_artifact_handles) = resources
1815                    .replay_artifact_handles
1816                    .and_then(|handles| handles.get(node_id))
1817                {
1818                    for (key, handle) in node_artifact_handles {
1819                        if input_handles.insert(key.clone(), handle.clone()).is_some() {
1820                            return Err(DagMlError::RuntimeValidation(format!(
1821                                "node `{node_id}` received duplicate replay artifact input `{key}`"
1822                            )));
1823                        }
1824                    }
1825                }
1826                if let Some(node_artifact_inputs) = resources
1827                    .replay_artifact_inputs
1828                    .and_then(|inputs| inputs.get(node_id))
1829                {
1830                    for (key, spec) in node_artifact_inputs {
1831                        if artifact_inputs.insert(key.clone(), spec.clone()).is_some() {
1832                            return Err(DagMlError::RuntimeValidation(format!(
1833                                "node `{node_id}` received duplicate replay artifact metadata `{key}`"
1834                            )));
1835                        }
1836                    }
1837                }
1838                let task_node_plan = effective_node_plan_for_scope(node_plan, &scope)?;
1839                let inner_fold_set = inner_fold_set_for_scope(
1840                    &plan.campaign,
1841                    plan.fold_set.as_ref(),
1842                    node_plan,
1843                    &scope,
1844                )?;
1845                let fit_influence = fit_influence_task_for_node(
1846                    plan,
1847                    &task_node_plan,
1848                    &collected_inputs.data_views,
1849                )?;
1850                prepared.push(PreparedNodeTask {
1851                    node_id: node_id.clone(),
1852                    task: NodeTask {
1853                        inner_fold_set,
1854                        run_id: ctx.run_id.clone(),
1855                        node_plan: task_node_plan.clone(),
1856                        phase: scope.phase,
1857                        variant_id: scope.variant_id.clone(),
1858                        variant: scope.variant.clone(),
1859                        fold_id: scope.fold_id.clone(),
1860                        branch_path: Vec::new(),
1861                        input_handles,
1862                        data_views: collected_inputs.data_views,
1863                        prediction_inputs: collected_inputs.prediction_inputs,
1864                        artifact_inputs,
1865                        required_loss_attestations: NodeTask::required_loss_attestations_for(
1866                            &task_node_plan,
1867                            scope.phase,
1868                        )?,
1869                        fit_influence,
1870                        seed: derive_task_seed(
1871                            scope.seed_root,
1872                            scope.variant_id.as_ref(),
1873                            scope.fold_id.as_ref(),
1874                            &task_node_plan,
1875                            scope.phase,
1876                        ),
1877                    },
1878                });
1879            }
1880
1881            for chunk in prepared.chunks(self.max_workers) {
1882                let chunk_results = std::thread::scope(
1883                    |thread_scope| -> Result<Vec<NodeResult>> {
1884                        let mut handles = Vec::with_capacity(chunk.len());
1885                        for prepared_task in chunk {
1886                            let controller = controllers
1887                                .get(&prepared_task.task.node_plan.controller_id)
1888                                .ok_or_else(|| {
1889                                    DagMlError::RuntimeValidation(format!(
1890                                        "runtime controller `{}` is not registered",
1891                                        prepared_task.task.node_plan.controller_id
1892                                    ))
1893                                })?;
1894                            let worker_span = phase_span.clone();
1895                            handles.push(thread_scope.spawn(move || {
1896                                let _worker_span = worker_span.entered();
1897                                let _node_span = crate::observability::node_span(
1898                                    prepared_task.task.run_id.as_str(),
1899                                    plan_id,
1900                                    prepared_task.task.phase.as_str(),
1901                                    prepared_task.task.node_plan.node_id.as_str(),
1902                                    prepared_task.task.node_plan.controller_id.as_str(),
1903                                )
1904                                .entered();
1905                                let mut result =
1906                                    if prepared_task.task.node_plan.kind == NodeKind::Tuner {
1907                                        return Err(DagMlError::RuntimeValidation(format!(
1908                                            "tuner node `{}` requires execute_hpo_campaign with an explicit RuntimeHpoExecutionContext",
1909                                            prepared_task.task.node_plan.node_id
1910                                        )));
1911                                    } else {
1912                                        // A provider-aware controller may require a
1913                                        // non-Sync host provider.  Parallel native
1914                                        // Methods PLS is deliberately refused by its
1915                                        // HPO preflight; ordinary controllers keep
1916                                        // their opaque-handle invocation here.
1917                                        controller.invoke(&prepared_task.task)?
1918                                    };
1919                                record_fit_influence_diagnostic(&prepared_task.task, &mut result);
1920                                normalize_result_prediction_ports(
1921                                    plan,
1922                                    &prepared_task.task,
1923                                    &mut result,
1924                                )?;
1925                                result.validate_for_task(&prepared_task.task)?;
1926                                Ok(result)
1927                            }));
1928                        }
1929                        handles
1930                            .into_iter()
1931                            .map(|handle| {
1932                                handle.join().map_err(|_| {
1933                                    DagMlError::RuntimeValidation(
1934                                        "parallel scheduler worker panicked".to_string(),
1935                                    )
1936                                })?
1937                            })
1938                            .collect()
1939                    },
1940                )?;
1941
1942                for (prepared_task, mut result) in chunk.iter().zip(chunk_results) {
1943                    apply_result_prediction_aggregation(
1944                        plan,
1945                        controllers,
1946                        &prepared_task.task,
1947                        &mut result,
1948                        &resources,
1949                    )?;
1950                    if let Some(nested) = resources.nested_stacking.as_ref() {
1951                        attach_nested_stacking_input_lineage(
1952                            &mut result,
1953                            plan,
1954                            &prepared_task.task,
1955                            ctx,
1956                            nested,
1957                        )?;
1958                    } else {
1959                        attach_coordinator_input_lineage(
1960                            &mut result,
1961                            plan,
1962                            &prepared_task.task.node_plan.node_id,
1963                            &input_lineage,
1964                        )?;
1965                    }
1966                    if let Some(store) = resources.artifact_store.as_deref_mut() {
1967                        if scope.phase == Phase::Refit {
1968                            store.capture_refit_artifacts(&prepared_task.task, &result)?;
1969                        }
1970                    }
1971                    for prediction in &result.predictions {
1972                        ctx.prediction_store.append(prediction.clone())?;
1973                    }
1974                    for prediction in &result.aggregated_predictions {
1975                        ctx.aggregated_prediction_store.append(prediction.clone())?;
1976                    }
1977                    apply_result_scoring(
1978                        &result,
1979                        &mut ctx.score_collector,
1980                        &mut ctx.regression_target_records,
1981                    )?;
1982                    ctx.lineage.record(result.lineage.clone())?;
1983                    let data_views = derive_output_data_views(plan, &prepared_task.task, &result)?;
1984                    output_handles.insert(prepared_task.node_id.clone(), result.outputs.clone());
1985                    output_data_views.insert(prepared_task.node_id.clone(), data_views);
1986                    input_lineage.insert(
1987                        prepared_task.node_id.clone(),
1988                        result.lineage.record_id.clone(),
1989                    );
1990                    results.push(result);
1991                }
1992            }
1993
1994            // Reassemble any cross-branch merge nodes in this level now that the
1995            // level's worker tasks have populated the prediction store. Merge nodes
1996            // sit in a later level than the branches they consume, so the upstream
1997            // branch OOF is already present.
1998            for (node_id, reduction) in &merge_nodes {
1999                let node_plan = plan
2000                    .node_plans
2001                    .get(node_id)
2002                    .expect("execution plan was validated");
2003                if let Some(mut result) =
2004                    reassemble_branch_merge(plan, node_plan, ctx, &scope, *reduction)?
2005                {
2006                    let task_node_plan = effective_node_plan_for_scope(node_plan, &scope)?;
2007                    let task = NodeTask {
2008                        inner_fold_set: None,
2009                        run_id: ctx.run_id.clone(),
2010                        node_plan: task_node_plan.clone(),
2011                        phase: scope.phase,
2012                        variant_id: scope.variant_id.clone(),
2013                        variant: scope.variant.clone(),
2014                        fold_id: scope.fold_id.clone(),
2015                        branch_path: Vec::new(),
2016                        input_handles: BTreeMap::new(),
2017                        data_views: BTreeMap::new(),
2018                        prediction_inputs: BTreeMap::new(),
2019                        artifact_inputs: BTreeMap::new(),
2020                        required_loss_attestations: NodeTask::required_loss_attestations_for(
2021                            &task_node_plan,
2022                            scope.phase,
2023                        )?,
2024                        fit_influence: FitInfluenceTask::default(),
2025                        seed: None,
2026                    };
2027                    normalize_result_prediction_ports(plan, &task, &mut result)?;
2028                    result.validate_for_task(&task)?;
2029                    for prediction in &result.predictions {
2030                        ctx.prediction_store.append(prediction.clone())?;
2031                    }
2032                    apply_result_scoring(
2033                        &result,
2034                        &mut ctx.score_collector,
2035                        &mut ctx.regression_target_records,
2036                    )?;
2037                    ctx.lineage.record(result.lineage.clone())?;
2038                    output_handles.insert(node_id.clone(), result.outputs.clone());
2039                    input_lineage.insert(node_id.clone(), result.lineage.record_id.clone());
2040                    results.push(result);
2041                }
2042            }
2043        }
2044
2045        Ok(results)
2046    }
2047}
2048
2049#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2050enum HpoTrialTerminalState {
2051    Completed,
2052    Pruned,
2053    Failed,
2054}
2055
2056fn validate_hpo_checkpoint_result(
2057    checkpoint: &RuntimeHpoCheckpointResult,
2058    hpo: &RuntimeHpoExecutionContext,
2059    trial_variants: &BTreeMap<i64, VariantId>,
2060    terminal_trials: &BTreeMap<i64, HpoTrialTerminalState>,
2061    history_at_start: u32,
2062) -> Result<()> {
2063    checkpoint.artifact.validate().map_err(|error| {
2064        DagMlError::RuntimeValidation(format!(
2065            "runtime HPO checkpoint artifact is invalid: {error}"
2066        ))
2067    })?;
2068    if checkpoint.operation_id != hpo.operation_id
2069        || checkpoint.controller_id != hpo.controller_id
2070        || checkpoint.target_node_id != hpo.target_node_id
2071        || checkpoint.provenance != hpo.provenance
2072    {
2073        return Err(DagMlError::RuntimeValidation(
2074            "runtime HPO checkpoint provenance does not exactly match its execution context"
2075                .to_string(),
2076        ));
2077    }
2078    let proposed_count = u32::try_from(trial_variants.len()).map_err(|_| {
2079        DagMlError::RuntimeValidation(
2080            "runtime HPO scheduler proposal count does not fit u32".to_string(),
2081        )
2082    })?;
2083    if checkpoint.trial_history_len != hpo.trial_budget_total
2084        || checkpoint.trial_history_len < history_at_start
2085        || checkpoint.trial_history_len - history_at_start != proposed_count
2086    {
2087        return Err(DagMlError::RuntimeValidation(
2088            "runtime HPO checkpoint native history is inconsistent with scheduler-observed trials"
2089                .to_string(),
2090        ));
2091    }
2092    if checkpoint.artifact.binding.controller_id != hpo.controller_id.as_str()
2093        || checkpoint.artifact.binding.controller_id != hpo.study.controller_id
2094        || checkpoint.artifact.binding.study_id != hpo.study.study_id
2095        || checkpoint.artifact.methods_abi != hpo.study.methods_abi
2096    {
2097        return Err(DagMlError::RuntimeValidation(
2098            "runtime HPO checkpoint binding/controller/study does not match the active tuner"
2099                .to_string(),
2100        ));
2101    }
2102    let expected_search_space = hpo.study.search_space.fingerprint().map_err(|error| {
2103        DagMlError::RuntimeValidation(format!(
2104            "runtime HPO cannot fingerprint the configured search space: {error}"
2105        ))
2106    })?;
2107    if checkpoint.artifact.binding.search_space_fingerprint != expected_search_space {
2108        return Err(DagMlError::RuntimeValidation(
2109            "runtime HPO checkpoint search-space binding does not match the active study"
2110                .to_string(),
2111        ));
2112    }
2113
2114    let completed_trial_ids = terminal_trials
2115        .iter()
2116        .filter_map(|(trial_id, state)| {
2117            (*state == HpoTrialTerminalState::Completed).then_some(*trial_id)
2118        })
2119        .collect::<BTreeSet<_>>();
2120    let mut proposal_trial_ids = BTreeSet::new();
2121    for proposal in &checkpoint.completed_proposals {
2122        if !proposal_trial_ids.insert(proposal.trial_id) {
2123            return Err(DagMlError::RuntimeValidation(format!(
2124                "runtime HPO checkpoint has duplicate completed proposal for trial `{}`",
2125                proposal.trial_id
2126            )));
2127        }
2128        if trial_variants.get(&proposal.trial_id) != Some(&proposal.variant.variant_id) {
2129            return Err(DagMlError::RuntimeValidation(format!(
2130                "runtime HPO checkpoint proposal for trial `{}` does not exactly match its scheduler proposal",
2131                proposal.trial_id
2132            )));
2133        }
2134    }
2135    if proposal_trial_ids != completed_trial_ids {
2136        return Err(DagMlError::RuntimeValidation(
2137            "runtime HPO checkpoint proposals must cover exactly the completed trials".to_string(),
2138        ));
2139    }
2140
2141    let mut report_trial_ids = BTreeSet::new();
2142    for completed in &checkpoint.completed_reports {
2143        if !report_trial_ids.insert(completed.trial_id) {
2144            return Err(DagMlError::RuntimeValidation(format!(
2145                "runtime HPO checkpoint has duplicate completed report for trial `{}`",
2146                completed.trial_id
2147            )));
2148        }
2149        if trial_variants.get(&completed.trial_id) != Some(&completed.variant_id)
2150            || !proposal_trial_ids.contains(&completed.trial_id)
2151        {
2152            return Err(DagMlError::RuntimeValidation(format!(
2153                "runtime HPO checkpoint report for trial `{}` does not match a completed proposal",
2154                completed.trial_id
2155            )));
2156        }
2157        let report = &completed.report;
2158        if report.producer_node != hpo.selection.producer_node
2159            || report.producer_port.as_deref() != Some(hpo.selection.producer_port.as_str())
2160            || report.partition != PredictionPartition::Validation
2161            || report
2162                .fold_id
2163                .as_ref()
2164                .is_none_or(|fold| fold.as_str() != "avg")
2165            || report.variant_id.as_ref() != Some(&completed.variant_id)
2166            || !report
2167                .metrics
2168                .get(hpo.selection.metric.name())
2169                .is_some_and(|score| score.is_finite())
2170        {
2171            return Err(DagMlError::RuntimeValidation(format!(
2172                "runtime HPO checkpoint report for trial `{}` is not its one finite target OOF average",
2173                completed.trial_id
2174            )));
2175        }
2176    }
2177    if report_trial_ids != completed_trial_ids {
2178        return Err(DagMlError::RuntimeValidation(
2179            "runtime HPO checkpoint reports must cover exactly one OOF average per completed trial"
2180                .to_string(),
2181        ));
2182    }
2183    Ok(())
2184}
2185
2186pub(crate) struct PreparedNodeTask {
2187    pub(crate) node_id: NodeId,
2188    pub(crate) task: NodeTask,
2189}
2190
2191// This module stays adjacent to the scheduler-owned task preparation it
2192// exercises; the remaining helpers below are shared by both schedulers.
2193#[cfg(test)]
2194#[allow(clippy::items_after_test_module)]
2195mod hpo_scheduler_tests {
2196    use std::collections::{BTreeMap, BTreeSet};
2197    use std::sync::{Arc, Mutex};
2198
2199    use sha2::{Digest, Sha256};
2200
2201    use super::*;
2202    use crate::controller::{
2203        ArtifactPolicy, ControllerCapability, ControllerFitScope, ControllerManifest,
2204        ControllerRegistry, RngPolicy,
2205    };
2206    use crate::data::InMemoryDataProvider;
2207    use crate::fold::{FoldAssignment, FoldPartitionMode, KFoldSpec, NestedCvSpec};
2208    use crate::graph::{
2209        EdgeContract, EdgeSpec, GraphInterface, GraphSpec, NodeSpec, PortRef, PortSchema, PortSpec,
2210    };
2211    use crate::hpo::{
2212        HpoDirection, HpoMetric, HpoOptimizerConfig, HpoParameter, HpoPruner, HpoSampler,
2213        HpoSearchSpace, HpoStudyBinding, MethodsHpoStudyConfig, N4moptCheckpointArtifact,
2214        N4MOPT_ARTIFACT_KIND, N4MOPT_CHECKPOINT_SCHEMA_VERSION, N4MOPT_FORMAT,
2215    };
2216    #[cfg(feature = "methods-optimizer-local")]
2217    use crate::hpo::{MethodsHpoController, MethodsRuntime};
2218    use crate::metrics::RegressionTargetBlock;
2219    use crate::oof::PredictionBlock;
2220    use crate::plan::{build_execution_plan, SplitInvocation};
2221
2222    struct HpoTestModel {
2223        id: ControllerId,
2224        trace: Arc<Mutex<Vec<String>>>,
2225        fail_variant: Option<VariantId>,
2226        score_by_trial: bool,
2227    }
2228
2229    impl RuntimeController for HpoTestModel {
2230        fn controller_id(&self) -> &ControllerId {
2231            &self.id
2232        }
2233
2234        fn invoke(&self, task: &NodeTask) -> Result<NodeResult> {
2235            self.trace.lock().unwrap().push("model_cv".to_string());
2236            if task.variant_id.as_ref() == self.fail_variant.as_ref() {
2237                return Err(DagMlError::RuntimeValidation(
2238                    "controlled HPO fold failure".to_string(),
2239                ));
2240            }
2241            let sample_id = match task.fold_id.as_ref().map(FoldId::as_str) {
2242                Some("fold:0") => SampleId::new("sample:one").unwrap(),
2243                Some("fold:1") => SampleId::new("sample:two").unwrap(),
2244                other => {
2245                    return Err(DagMlError::RuntimeValidation(format!(
2246                        "HPO test model received unexpected fold {other:?}"
2247                    )));
2248                }
2249            };
2250            let prediction = if self.score_by_trial {
2251                let trial_id = task
2252                    .variant_id
2253                    .as_ref()
2254                    .and_then(|variant| variant.as_str().strip_prefix("hpo:trial:"))
2255                    .and_then(|value| value.parse::<i64>().ok())
2256                    .ok_or_else(|| {
2257                        DagMlError::RuntimeValidation(
2258                            "native HPO test model received no trial variant".to_string(),
2259                        )
2260                    })?;
2261                let magnitude = (trial_id + 1) as f64;
2262                1.0 + magnitude * magnitude
2263            } else {
2264                1.0
2265            };
2266            Ok(NodeResult {
2267                schema_version: None,
2268                node_id: task.node_plan.node_id.clone(),
2269                outputs: BTreeMap::from([(
2270                    "prediction".to_string(),
2271                    HandleRef {
2272                        handle: 2,
2273                        kind: HandleKind::Prediction,
2274                        owner_controller: self.id.clone(),
2275                    },
2276                )]),
2277                predictions: vec![PredictionBlock {
2278                    prediction_id: Some(format!("prediction:{}", task.fold_id.as_ref().unwrap())),
2279                    producer_node: task.node_plan.node_id.clone(),
2280                    producer_port: None,
2281                    partition: PredictionPartition::Validation,
2282                    fold_id: task.fold_id.clone(),
2283                    sample_ids: vec![sample_id.clone()],
2284                    values: vec![vec![prediction]],
2285                    target_names: vec!["target".to_string()],
2286                }],
2287                observation_predictions: Vec::new(),
2288                aggregated_predictions: Vec::new(),
2289                explanations: Vec::new(),
2290                shape_deltas: Vec::new(),
2291                artifacts: Vec::new(),
2292                artifact_handles: BTreeMap::new(),
2293                fit_influence_diagnostics: Vec::new(),
2294                regression_targets: vec![RegressionTargetBlock {
2295                    level: PredictionLevel::Sample,
2296                    unit_ids: vec![PredictionUnitId::Sample(sample_id)],
2297                    values: vec![vec![1.0]],
2298                    target_names: vec!["target".to_string()],
2299                }],
2300                lineage: LineageRecord {
2301                    record_id: LineageId::new(format!(
2302                        "lineage:hpo-model:{}",
2303                        task.fold_id.as_ref().unwrap()
2304                    ))
2305                    .unwrap(),
2306                    run_id: task.run_id.clone(),
2307                    node_id: task.node_plan.node_id.clone(),
2308                    phase: task.phase,
2309                    controller_id: self.id.clone(),
2310                    controller_version: task.node_plan.controller_version.clone(),
2311                    variant_id: task.variant_id.clone(),
2312                    fold_id: task.fold_id.clone(),
2313                    branch_path: Vec::new(),
2314                    input_lineage: Vec::new(),
2315                    artifact_refs: Vec::new(),
2316                    params_fingerprint: task.node_plan.params_fingerprint.clone(),
2317                    data_model_shape_fingerprint: None,
2318                    aggregation_policy_fingerprint: None,
2319                    seed: task.seed,
2320                    unsafe_flags: BTreeSet::new(),
2321                    metrics: BTreeMap::new(),
2322                    loss_attestations: Vec::new(),
2323                    early_stopping_records: Vec::new(),
2324                },
2325            })
2326        }
2327    }
2328
2329    struct HpoTestTuner {
2330        id: ControllerId,
2331        trace: Arc<Mutex<Vec<String>>>,
2332        history_len: u32,
2333        proposal_count: u32,
2334        prune_at: Option<(i64, i32)>,
2335    }
2336
2337    struct HpoTestSession {
2338        proposals: Vec<RuntimeHpoProposal>,
2339        trace: Arc<Mutex<Vec<String>>>,
2340        checkpoint: N4moptCheckpointArtifact,
2341        history_len: u32,
2342        completed: BTreeMap<i64, f64>,
2343        failed: BTreeMap<i64, RuntimeHpoFailure>,
2344        prune_at: Option<(i64, i32)>,
2345        pruned: BTreeSet<i64>,
2346        intermediates: BTreeMap<i64, Vec<crate::hpo::HpoIntermediate>>,
2347    }
2348
2349    impl RuntimeController for HpoTestTuner {
2350        fn controller_id(&self) -> &ControllerId {
2351            &self.id
2352        }
2353
2354        fn invoke(&self, task: &NodeTask) -> Result<NodeResult> {
2355            Err(DagMlError::RuntimeValidation(format!(
2356                "HPO test tuner `{}` was dispatched through generic invoke",
2357                task.node_plan.node_id
2358            )))
2359        }
2360
2361        fn create_tuner_session(
2362            &self,
2363            task: &RuntimeHpoCampaignTask,
2364            context: &RuntimeHpoExecutionContext,
2365        ) -> Result<Box<dyn RuntimeTunerSession>> {
2366            assert_eq!(task.operation_id, context.operation_id);
2367            self.trace
2368                .lock()
2369                .unwrap()
2370                .push("session_factory".to_string());
2371            let payload = vec![7_u8];
2372            let proposals = (0..self.proposal_count)
2373                .map(|offset| {
2374                    let trial_id = i64::from(self.history_len + offset + 1);
2375                    let mut variant = context.base_variant.clone();
2376                    if self.history_len != 0 || self.proposal_count != 1 {
2377                        variant.variant_id = VariantId::new(format!("hpo:trial:{trial_id}"))
2378                            .map_err(|error| DagMlError::RuntimeValidation(error.to_string()))?;
2379                        variant.fingerprint = format!("hpo-test-{trial_id}");
2380                    }
2381                    Ok(RuntimeHpoProposal { trial_id, variant })
2382                })
2383                .collect::<Result<Vec<_>>>()?;
2384            Ok(Box::new(HpoTestSession {
2385                proposals: proposals.into_iter().rev().collect(),
2386                trace: Arc::clone(&self.trace),
2387                history_len: self.history_len,
2388                completed: BTreeMap::new(),
2389                failed: BTreeMap::new(),
2390                prune_at: self.prune_at,
2391                pruned: BTreeSet::new(),
2392                intermediates: BTreeMap::new(),
2393                checkpoint: N4moptCheckpointArtifact {
2394                    schema_version: N4MOPT_CHECKPOINT_SCHEMA_VERSION,
2395                    artifact_kind: N4MOPT_ARTIFACT_KIND.to_string(),
2396                    format: N4MOPT_FORMAT.to_string(),
2397                    abi_major: crate::hpo::METHODS_ABI_MAJOR,
2398                    abi_min_minor: crate::hpo::METHODS_N4MOPT_MIN_ABI_MINOR,
2399                    binding: HpoStudyBinding {
2400                        controller_id: context.study.controller_id.clone(),
2401                        study_id: context.study.study_id.clone(),
2402                        search_space_fingerprint: context
2403                            .study
2404                            .search_space
2405                            .fingerprint()
2406                            .map_err(|error| DagMlError::RuntimeValidation(error.to_string()))?,
2407                        optimizer_fingerprint: "optimizer:test".to_string(),
2408                    },
2409                    methods_abi: context.study.methods_abi.clone(),
2410                    payload_sha256: format!("{:x}", Sha256::digest(&payload)),
2411                    opaque_payload: payload,
2412                },
2413            }))
2414        }
2415    }
2416
2417    impl RuntimeTunerSession for HpoTestSession {
2418        fn trial_history_len(&self) -> Result<u32> {
2419            Ok(self.history_len)
2420        }
2421
2422        fn ask(&mut self) -> Result<Option<RuntimeHpoProposal>> {
2423            self.trace.lock().unwrap().push("ask".to_string());
2424            let proposal = self.proposals.pop();
2425            if proposal.is_some() {
2426                self.history_len += 1;
2427            }
2428            Ok(proposal)
2429        }
2430
2431        fn report_intermediate(
2432            &mut self,
2433            intermediate: RuntimeHpoIntermediate,
2434        ) -> Result<RuntimeHpoIntermediateOutcome> {
2435            assert!(intermediate.score.is_finite());
2436            let should_prune = self.prune_at == Some((intermediate.trial_id, intermediate.step));
2437            self.trace.lock().unwrap().push(format!(
2438                "intermediate:{}:{}",
2439                intermediate.trial_id, intermediate.step
2440            ));
2441            self.intermediates
2442                .entry(intermediate.trial_id)
2443                .or_default()
2444                .push(crate::hpo::HpoIntermediate {
2445                    sequence: i64::from(intermediate.step) + 1,
2446                    step: intermediate.step,
2447                    score: intermediate.score,
2448                    should_prune,
2449                });
2450            if should_prune {
2451                self.pruned.insert(intermediate.trial_id);
2452                Ok(RuntimeHpoIntermediateOutcome::Pruned)
2453            } else {
2454                Ok(RuntimeHpoIntermediateOutcome::Continue)
2455            }
2456        }
2457
2458        fn tell(&mut self, trial_id: i64, terminal: RuntimeHpoTerminal) -> Result<()> {
2459            assert!(trial_id > 0);
2460            match terminal {
2461                RuntimeHpoTerminal::Completed { score } if score.is_finite() => {
2462                    self.trace.lock().unwrap().push("tell".to_string());
2463                    self.completed.insert(trial_id, score);
2464                }
2465                RuntimeHpoTerminal::Failed { failure } => {
2466                    self.trace.lock().unwrap().push("tell_failed".to_string());
2467                    self.failed.insert(trial_id, failure);
2468                }
2469                other => {
2470                    return Err(DagMlError::RuntimeValidation(format!(
2471                        "test HPO session received unsupported terminal state {other:?}"
2472                    )));
2473                }
2474            }
2475            Ok(())
2476        }
2477
2478        fn checkpoint(&self) -> Result<N4moptCheckpointArtifact> {
2479            self.trace.lock().unwrap().push("checkpoint".to_string());
2480            Ok(self.checkpoint.clone())
2481        }
2482
2483        fn incumbent(
2484            &self,
2485            variants: &BTreeMap<i64, VariantId>,
2486        ) -> Result<Option<RuntimeHpoIncumbent>> {
2487            let Some((&trial_id, &score)) = self
2488                .completed
2489                .iter()
2490                .min_by(|left, right| left.1.total_cmp(right.1).then_with(|| left.0.cmp(right.0)))
2491            else {
2492                return Ok(None);
2493            };
2494            Ok(Some(RuntimeHpoIncumbent {
2495                trial_id,
2496                score,
2497                metric: "rmse".to_string(),
2498                direction: HpoDirection::Minimize,
2499                variant_id: variants.get(&trial_id).cloned().unwrap(),
2500            }))
2501        }
2502
2503        fn terminal_trial_snapshots(
2504            &self,
2505            variants: &BTreeMap<i64, VariantId>,
2506        ) -> Result<Vec<RuntimeHpoTerminalSnapshot>> {
2507            if self.completed.is_empty() {
2508                return Err(DagMlError::RuntimeValidation(
2509                    "test HPO session has no completed trial".to_string(),
2510                ));
2511            }
2512            let completed = &self.completed;
2513            let failed = &self.failed;
2514            Ok((1..=i64::from(self.history_len))
2515                .map(|id| {
2516                    let score = completed.get(&id).copied();
2517                    let is_completed = score.is_some();
2518                    let pruned = self.pruned.contains(&id);
2519                    let failure = failed
2520                        .get(&id)
2521                        .map(|failure| crate::hpo::HpoFailure {
2522                            code: failure.code.clone(),
2523                            message: failure.message.clone(),
2524                            retryable: failure.retryable,
2525                        })
2526                        .or_else(|| {
2527                            (!is_completed && !pruned).then(|| crate::hpo::HpoFailure {
2528                                code: "RESTORED_TEST_FAILURE".to_string(),
2529                                message: "synthetic restored terminal".to_string(),
2530                                retryable: false,
2531                            })
2532                        });
2533                    RuntimeHpoTerminalSnapshot {
2534                        trial: crate::hpo::HpoTrial {
2535                            id,
2536                            ask_sequence: id,
2537                            terminal_sequence: Some(id),
2538                            parameters: BTreeMap::new(),
2539                            parameter_order: Vec::new(),
2540                            status: if is_completed {
2541                                crate::hpo::HpoTrialStatus::Completed
2542                            } else if pruned {
2543                                crate::hpo::HpoTrialStatus::Pruned
2544                            } else {
2545                                crate::hpo::HpoTrialStatus::Failed
2546                            },
2547                            score,
2548                            rung: 0,
2549                            duration: 0.0,
2550                            intermediates: self.intermediates.get(&id).cloned().unwrap_or_default(),
2551                            failure,
2552                        },
2553                        variant_id: variants.get(&id).cloned(),
2554                    }
2555                })
2556                .collect())
2557        }
2558    }
2559
2560    fn node(id: &str, kind: NodeKind, outputs: Vec<PortSpec>) -> NodeSpec {
2561        NodeSpec {
2562            id: NodeId::new(id).unwrap(),
2563            kind,
2564            operator: None,
2565            params: BTreeMap::new(),
2566            ports: PortSchema {
2567                inputs: Vec::new(),
2568                outputs,
2569            },
2570            metadata: BTreeMap::new(),
2571            seed_label: None,
2572        }
2573    }
2574
2575    fn prediction_port(name: &str) -> PortSpec {
2576        PortSpec {
2577            name: name.to_string(),
2578            kind: PortKind::Prediction,
2579            representation: None,
2580            cardinality: crate::graph::PortCardinality::One,
2581            unit_level: None,
2582            alignment_key: None,
2583            target_level: None,
2584            description: String::new(),
2585        }
2586    }
2587
2588    fn manifest(id: &str, kind: NodeKind) -> ControllerManifest {
2589        ControllerManifest {
2590            controller_id: ControllerId::new(id).unwrap(),
2591            controller_version: "test".to_string(),
2592            operator_kind: kind,
2593            priority: 0,
2594            supported_phases: BTreeSet::from([Phase::FitCv]),
2595            input_ports: Vec::new(),
2596            output_ports: Vec::new(),
2597            data_requirements: None,
2598            capabilities: BTreeSet::from([
2599                ControllerCapability::Deterministic,
2600                ControllerCapability::EmitsPredictions,
2601            ]),
2602            operator_selectors: Vec::new(),
2603            fit_scope: ControllerFitScope::FoldTrain,
2604            rng_policy: RngPolicy::UsesCoreSeed,
2605            artifact_policy: ArtifactPolicy::Serializable,
2606        }
2607    }
2608
2609    #[test]
2610    fn hpo_campaign_invokes_registered_session_and_routes_oof_feedback() {
2611        let target = NodeId::new("model:score").unwrap();
2612        let graph = GraphSpec {
2613            id: "graph:hpo.scheduler".to_string(),
2614            interface: GraphInterface::default(),
2615            nodes: vec![node(
2616                "model:score",
2617                NodeKind::Model,
2618                vec![PortSpec {
2619                    name: "prediction".to_string(),
2620                    kind: PortKind::Prediction,
2621                    representation: None,
2622                    cardinality: crate::graph::PortCardinality::One,
2623                    unit_level: None,
2624                    alignment_key: None,
2625                    target_level: None,
2626                    description: String::new(),
2627                }],
2628            )],
2629            edges: Vec::new(),
2630            search_space_fingerprint: None,
2631            metadata: BTreeMap::new(),
2632        };
2633        let fold_set = FoldSet {
2634            id: "folds:hpo".to_string(),
2635            sample_ids: vec![
2636                SampleId::new("sample:one").unwrap(),
2637                SampleId::new("sample:two").unwrap(),
2638            ],
2639            folds: vec![
2640                FoldAssignment {
2641                    fold_id: FoldId::new("fold:0").unwrap(),
2642                    train_sample_ids: vec![SampleId::new("sample:two").unwrap()],
2643                    validation_sample_ids: vec![SampleId::new("sample:one").unwrap()],
2644                    metadata: BTreeMap::new(),
2645                },
2646                FoldAssignment {
2647                    fold_id: FoldId::new("fold:1").unwrap(),
2648                    train_sample_ids: vec![SampleId::new("sample:one").unwrap()],
2649                    validation_sample_ids: vec![SampleId::new("sample:two").unwrap()],
2650                    metadata: BTreeMap::new(),
2651                },
2652            ],
2653            sample_groups: BTreeMap::new(),
2654            partition_mode: FoldPartitionMode::Partition,
2655        };
2656        let mut registry = ControllerRegistry::new();
2657        registry
2658            .register(manifest("controller:model", NodeKind::Model))
2659            .unwrap();
2660        let plan = build_execution_plan(
2661            "plan:hpo.scheduler",
2662            graph,
2663            CampaignSpec {
2664                inner_cv: None,
2665                id: "campaign:hpo.scheduler".to_string(),
2666                root_seed: Some(13),
2667                leakage_policy: Default::default(),
2668                aggregation_policy: Default::default(),
2669                split_invocation: Some(SplitInvocation {
2670                    id: "split:hpo".to_string(),
2671                    controller_id: None,
2672                    leakage_policy: Default::default(),
2673                    params: BTreeMap::new(),
2674                    fold_set: Some(fold_set),
2675                }),
2676                generation: Default::default(),
2677                shape_plans: BTreeMap::new(),
2678                data_bindings: BTreeMap::new(),
2679                branch_view_plans: Vec::new(),
2680                metadata: BTreeMap::new(),
2681            },
2682            &registry,
2683        )
2684        .unwrap();
2685        let trace = Arc::new(Mutex::new(Vec::new()));
2686        let mut controllers = RuntimeControllerRegistry::new();
2687        controllers
2688            .register(Box::new(HpoTestTuner {
2689                id: ControllerId::new("controller:tuner").unwrap(),
2690                trace: Arc::clone(&trace),
2691                history_len: 0,
2692                proposal_count: 1,
2693                prune_at: None,
2694            }))
2695            .unwrap();
2696        controllers
2697            .register(Box::new(HpoTestModel {
2698                id: ControllerId::new("controller:model").unwrap(),
2699                trace: Arc::clone(&trace),
2700                fail_variant: None,
2701                score_by_trial: false,
2702            }))
2703            .unwrap();
2704        let hpo = RuntimeHpoExecutionContext {
2705            operation_id: "hpo:test".to_string(),
2706            controller_id: ControllerId::new("controller:tuner").unwrap(),
2707            target_node_id: target.clone(),
2708            base_variant: plan.variants[0].clone(),
2709            trial_budget_total: 1,
2710            study: MethodsHpoStudyConfig {
2711                controller_id: "controller:tuner".to_string(),
2712                study_id: "study:hpo.scheduler".to_string(),
2713                methods_abi: "test-abi".to_string(),
2714                search_space: HpoSearchSpace {
2715                    parameters: vec![HpoParameter::Int {
2716                        name: "n_components".to_string(),
2717                        low: 1,
2718                        high: 1,
2719                        step: 1,
2720                        log: false,
2721                    }],
2722                },
2723                optimizer: HpoOptimizerConfig {
2724                    sampler: HpoSampler::Random,
2725                    pruner: HpoPruner::None,
2726                    direction: HpoDirection::Minimize,
2727                    metric: HpoMetric::Rmse,
2728                    seed: 13,
2729                    n_startup_trials: 1,
2730                    max_resource: 0,
2731                    reduction_factor: 1,
2732                },
2733            },
2734            parameter_paths: BTreeMap::from([(
2735                "n_components".to_string(),
2736                "n_components".to_string(),
2737            )]),
2738            resume_checkpoint: None,
2739            resume_variants: BTreeMap::new(),
2740            resume_terminal_trials: Vec::new(),
2741            selection: RuntimeHpoSelectionTarget {
2742                producer_node: target,
2743                producer_port: "prediction".to_string(),
2744                metric: RegressionMetricKind::Rmse,
2745                direction: HpoDirection::Minimize,
2746            },
2747            provenance: RuntimeHpoProvenance {
2748                graph_fingerprint: plan.graph_fingerprint.clone(),
2749                campaign_fingerprint: plan.campaign_fingerprint.clone(),
2750                controller_fingerprint: plan.controller_fingerprint.clone(),
2751                data_identities_fingerprint: "identity:test".to_string(),
2752                fold_set_fingerprint: plan
2753                    .fold_set
2754                    .as_ref()
2755                    .map(stable_json_fingerprint)
2756                    .transpose()
2757                    .unwrap(),
2758                training_influence_fingerprint: "influence:test".to_string(),
2759                relation_fingerprint: "relation:test".to_string(),
2760            },
2761        };
2762        let provider = InMemoryDataProvider::new(ControllerId::new("controller:data").unwrap());
2763        let ctx = RunContext::new(RunId::new("run:hpo.scheduler").unwrap(), Some(13));
2764
2765        let result = SequentialScheduler
2766            .execute_hpo_campaign(&plan, &controllers, &provider, &ctx, &hpo)
2767            .unwrap();
2768
2769        assert_eq!(result.operation_id, "hpo:test");
2770        assert_eq!(result.candidates.len(), 1);
2771        assert_eq!(result.checkpoint.completed_proposals.len(), 1);
2772        assert_eq!(result.checkpoint.completed_reports.len(), 1);
2773        assert_eq!(result.candidates[0].lineage.len(), 2);
2774        assert_eq!(result.incumbent.variant_id, plan.variants[0].variant_id);
2775        let mut selected_ctx = RunContext::new(RunId::new("run:hpo.scheduler").unwrap(), Some(13));
2776        selected_ctx.variant_id = Some(plan.variants[0].variant_id.clone());
2777        let selected_results = SequentialScheduler
2778            .execute_campaign_phase_with_data_provider(
2779                &plan,
2780                &controllers,
2781                &provider,
2782                &mut selected_ctx,
2783                Phase::FitCv,
2784            )
2785            .unwrap();
2786        assert_eq!(selected_results.len(), 2);
2787        assert_eq!(selected_ctx.lineage.len(), 2);
2788        assert_eq!(
2789            trace.lock().unwrap().as_slice(),
2790            [
2791                "session_factory",
2792                "ask",
2793                "model_cv",
2794                "intermediate:1:0",
2795                "model_cv",
2796                "intermediate:1:1",
2797                "tell",
2798                "checkpoint",
2799                "model_cv",
2800                "model_cv"
2801            ]
2802        );
2803
2804        // A native prune decision after the first validation fold must stop
2805        // the candidate before the second fold is materialized.  The prior
2806        // completed trial remains the native incumbent and the pruned trial
2807        // never enters report-grade candidate evidence.
2808        let prune_trace = Arc::new(Mutex::new(Vec::new()));
2809        let mut prune_controllers = RuntimeControllerRegistry::new();
2810        prune_controllers
2811            .register(Box::new(HpoTestTuner {
2812                id: ControllerId::new("controller:tuner").unwrap(),
2813                trace: Arc::clone(&prune_trace),
2814                history_len: 0,
2815                proposal_count: 2,
2816                prune_at: Some((2, 0)),
2817            }))
2818            .unwrap();
2819        prune_controllers
2820            .register(Box::new(HpoTestModel {
2821                id: ControllerId::new("controller:model").unwrap(),
2822                trace: Arc::clone(&prune_trace),
2823                fail_variant: None,
2824                score_by_trial: false,
2825            }))
2826            .unwrap();
2827        let mut pruning_hpo = hpo.clone();
2828        pruning_hpo.trial_budget_total = 2;
2829        let pruned = SequentialScheduler
2830            .execute_hpo_campaign(
2831                &plan,
2832                &prune_controllers,
2833                &provider,
2834                &RunContext::new(RunId::new("run:hpo.pruned").unwrap(), Some(13)),
2835                &pruning_hpo,
2836            )
2837            .unwrap();
2838        assert_eq!(pruned.candidates.len(), 1);
2839        assert_eq!(pruned.checkpoint.completed_proposals.len(), 1);
2840        assert_eq!(pruned.checkpoint.completed_reports.len(), 1);
2841        assert_eq!(pruned.incumbent.trial_id, 1);
2842        assert_eq!(
2843            pruned
2844                .terminal_trials
2845                .iter()
2846                .map(|entry| (entry.trial.id, entry.trial.status))
2847                .collect::<Vec<_>>(),
2848            vec![
2849                (1, crate::hpo::HpoTrialStatus::Completed),
2850                (2, crate::hpo::HpoTrialStatus::Pruned),
2851            ]
2852        );
2853        assert_eq!(
2854            prune_trace
2855                .lock()
2856                .unwrap()
2857                .iter()
2858                .filter(|event| event.as_str() == "model_cv")
2859                .count(),
2860            3,
2861            "the pruned second trial must execute one fold, not both"
2862        );
2863
2864        // A controller/data failure after ask is terminalized as FAILED in
2865        // the native session.  It contributes neither candidate evidence nor
2866        // completed checkpoint entries, and no later fold/opaque output
2867        // handle from that variant can be scheduled.
2868        let failed_trace = Arc::new(Mutex::new(Vec::new()));
2869        let mut failed_controllers = RuntimeControllerRegistry::new();
2870        failed_controllers
2871            .register(Box::new(HpoTestTuner {
2872                id: ControllerId::new("controller:tuner").unwrap(),
2873                trace: Arc::clone(&failed_trace),
2874                history_len: 0,
2875                proposal_count: 2,
2876                prune_at: None,
2877            }))
2878            .unwrap();
2879        failed_controllers
2880            .register(Box::new(HpoTestModel {
2881                id: ControllerId::new("controller:model").unwrap(),
2882                trace: Arc::clone(&failed_trace),
2883                fail_variant: Some(VariantId::new("hpo:trial:2").unwrap()),
2884                score_by_trial: false,
2885            }))
2886            .unwrap();
2887        let failed = SequentialScheduler
2888            .execute_hpo_campaign(
2889                &plan,
2890                &failed_controllers,
2891                &provider,
2892                &RunContext::new(RunId::new("run:hpo.failed").unwrap(), Some(13)),
2893                &pruning_hpo,
2894            )
2895            .unwrap();
2896        assert_eq!(failed.candidates.len(), 1);
2897        assert_eq!(
2898            failed.candidates[0]
2899                .validation_predictions
2900                .predictions
2901                .len(),
2902            2
2903        );
2904        assert_eq!(failed.checkpoint.completed_proposals.len(), 1);
2905        assert_eq!(failed.checkpoint.completed_reports.len(), 1);
2906        assert_eq!(
2907            failed
2908                .terminal_trials
2909                .iter()
2910                .map(|entry| (entry.trial.id, entry.trial.status))
2911                .collect::<Vec<_>>(),
2912            vec![
2913                (1, crate::hpo::HpoTrialStatus::Completed),
2914                (2, crate::hpo::HpoTrialStatus::Failed),
2915            ]
2916        );
2917        let failed_trial = &failed.terminal_trials[1].trial;
2918        assert_eq!(
2919            failed_trial.failure.as_ref().unwrap().code,
2920            "DAGML_CV_ERROR"
2921        );
2922        assert!(failed_trial.intermediates.is_empty());
2923        assert_eq!(
2924            failed_trace
2925                .lock()
2926                .unwrap()
2927                .iter()
2928                .filter(|event| event.as_str() == "model_cv")
2929                .count(),
2930            3,
2931            "the failed second trial must not schedule another fold or output handle"
2932        );
2933        assert!(failed_trace
2934            .lock()
2935            .unwrap()
2936            .iter()
2937            .any(|event| event == "tell_failed"));
2938
2939        // A resampled fold set has no stable resource meaning across steps:
2940        // refuse it before the session factory, model or intermediate path.
2941        let mut resampled_plan = plan.clone();
2942        resampled_plan.fold_set.as_mut().unwrap().partition_mode = FoldPartitionMode::Resampled;
2943        let mut resampled_hpo = hpo.clone();
2944        resampled_hpo.provenance.fold_set_fingerprint =
2945            Some(stable_json_fingerprint(resampled_plan.fold_set.as_ref().unwrap()).unwrap());
2946        let trace_len_before = trace.lock().unwrap().len();
2947        let error = SequentialScheduler
2948            .execute_hpo_campaign(
2949                &resampled_plan,
2950                &controllers,
2951                &provider,
2952                &ctx,
2953                &resampled_hpo,
2954            )
2955            .unwrap_err();
2956        assert!(error
2957            .to_string()
2958            .contains("requires FoldPartitionMode::Partition"));
2959        assert_eq!(trace.lock().unwrap().len(), trace_len_before);
2960
2961        // An empty fold topology is rejected by plan validation even earlier,
2962        // with the same zero-session/zero-model/zero-intermediate guarantee.
2963        let mut empty_plan = plan.clone();
2964        empty_plan.fold_set.as_mut().unwrap().folds.clear();
2965        let trace_len_before = trace.lock().unwrap().len();
2966        let error = SequentialScheduler
2967            .execute_hpo_campaign(&empty_plan, &controllers, &provider, &ctx, &hpo)
2968            .unwrap_err();
2969        assert!(error.to_string().contains("fold set contains no folds"));
2970        assert_eq!(trace.lock().unwrap().len(), trace_len_before);
2971
2972        // Refuse a no-CV topology before the tuner session is constructed:
2973        // a final aggregate is not a substitute for real fold progression.
2974        let mut no_fold_plan = plan.clone();
2975        no_fold_plan.fold_set = None;
2976        let mut no_fold_hpo = hpo.clone();
2977        no_fold_hpo.provenance.fold_set_fingerprint = None;
2978        let trace_len_before = trace.lock().unwrap().len();
2979        let error = SequentialScheduler
2980            .execute_hpo_campaign(&no_fold_plan, &controllers, &provider, &ctx, &no_fold_hpo)
2981            .unwrap_err();
2982        assert!(error
2983            .to_string()
2984            .contains("requires an explicit validated fold set"));
2985        assert_eq!(trace.lock().unwrap().len(), trace_len_before);
2986
2987        // Nested stacking has inner and outer fold identities. Until the HPO
2988        // contract can bind one exact report-grade outer resource sequence,
2989        // it too must be refused before any native handle is constructed.
2990        let samples = (1..=6)
2991            .map(|index| SampleId::new(format!("nested:{index}")).unwrap())
2992            .collect::<Vec<_>>();
2993        let outer_folds = KFoldSpec {
2994            n_splits: 3,
2995            shuffle: false,
2996            seed: Some(7),
2997        }
2998        .split("folds:hpo.nested", &samples)
2999        .unwrap();
3000        let base_a = NodeId::new("model:nested.base.a").unwrap();
3001        let base_b = NodeId::new("model:nested.base.b").unwrap();
3002        let meta = NodeId::new("model:nested.meta").unwrap();
3003        let mut meta_node = NodeSpec {
3004            id: meta.clone(),
3005            kind: NodeKind::Model,
3006            operator: None,
3007            params: BTreeMap::new(),
3008            ports: PortSchema {
3009                inputs: vec![prediction_port("a"), prediction_port("b")],
3010                outputs: vec![prediction_port("prediction")],
3011            },
3012            metadata: BTreeMap::new(),
3013            seed_label: None,
3014        };
3015        meta_node.metadata.insert(
3016            NESTED_STACKING_EXECUTION_METADATA_KEY.to_string(),
3017            serde_json::json!(NESTED_STACKING_EXECUTION_V1),
3018        );
3019        let nested_graph = GraphSpec {
3020            id: "graph:hpo.nested".to_string(),
3021            interface: GraphInterface::default(),
3022            nodes: vec![
3023                node(
3024                    base_a.as_str(),
3025                    NodeKind::Model,
3026                    vec![prediction_port("prediction")],
3027                ),
3028                node(
3029                    base_b.as_str(),
3030                    NodeKind::Model,
3031                    vec![prediction_port("prediction")],
3032                ),
3033                meta_node,
3034            ],
3035            edges: vec![
3036                EdgeSpec {
3037                    source: PortRef {
3038                        node_id: base_a,
3039                        port_name: "prediction".to_string(),
3040                    },
3041                    target: PortRef {
3042                        node_id: meta.clone(),
3043                        port_name: "a".to_string(),
3044                    },
3045                    contract: EdgeContract {
3046                        requires_oof: true,
3047                        requires_fold_alignment: true,
3048                        ..EdgeContract::new(PortKind::Prediction, None)
3049                    },
3050                },
3051                EdgeSpec {
3052                    source: PortRef {
3053                        node_id: base_b,
3054                        port_name: "prediction".to_string(),
3055                    },
3056                    target: PortRef {
3057                        node_id: meta.clone(),
3058                        port_name: "b".to_string(),
3059                    },
3060                    contract: EdgeContract {
3061                        requires_oof: true,
3062                        requires_fold_alignment: true,
3063                        ..EdgeContract::new(PortKind::Prediction, None)
3064                    },
3065                },
3066            ],
3067            search_space_fingerprint: None,
3068            metadata: BTreeMap::new(),
3069        };
3070        let mut nested_campaign = plan.campaign.clone();
3071        nested_campaign.id = "campaign:hpo.nested".to_string();
3072        nested_campaign.inner_cv = Some(NestedCvSpec::KFold(KFoldSpec {
3073            n_splits: 2,
3074            shuffle: false,
3075            seed: Some(11),
3076        }));
3077        nested_campaign.split_invocation.as_mut().unwrap().fold_set = Some(outer_folds);
3078        let mut nested_registry = ControllerRegistry::new();
3079        let mut nested_model_manifest = manifest("controller:model", NodeKind::Model);
3080        nested_model_manifest
3081            .capabilities
3082            .insert(ControllerCapability::ConsumesOofPredictions);
3083        nested_registry.register(nested_model_manifest).unwrap();
3084        let nested_plan = build_execution_plan(
3085            "plan:hpo.nested",
3086            nested_graph,
3087            nested_campaign,
3088            &nested_registry,
3089        )
3090        .unwrap();
3091        let mut nested_hpo = hpo.clone();
3092        nested_hpo.target_node_id = meta.clone();
3093        nested_hpo.base_variant = nested_plan.variants[0].clone();
3094        nested_hpo.selection.producer_node = meta;
3095        nested_hpo.provenance.graph_fingerprint = nested_plan.graph_fingerprint.clone();
3096        nested_hpo.provenance.campaign_fingerprint = nested_plan.campaign_fingerprint.clone();
3097        nested_hpo.provenance.controller_fingerprint = nested_plan.controller_fingerprint.clone();
3098        nested_hpo.provenance.fold_set_fingerprint = nested_plan
3099            .fold_set
3100            .as_ref()
3101            .map(stable_json_fingerprint)
3102            .transpose()
3103            .unwrap();
3104        let trace_len_before = trace.lock().unwrap().len();
3105        let error = SequentialScheduler
3106            .execute_hpo_campaign(&nested_plan, &controllers, &provider, &ctx, &nested_hpo)
3107            .unwrap_err();
3108        assert!(error
3109            .to_string()
3110            .contains("does not support nested-stacking FIT_CV"));
3111        assert_eq!(trace.lock().unwrap().len(), trace_len_before);
3112
3113        // The native study may restore failed/pruned history that has no
3114        // completed proposal evidence. Its local count, not the coordinator's
3115        // persisted completed list, determines the remaining global budget.
3116        let resumed_trace = Arc::new(Mutex::new(Vec::new()));
3117        let mut resumed_controllers = RuntimeControllerRegistry::new();
3118        resumed_controllers
3119            .register(Box::new(HpoTestTuner {
3120                id: ControllerId::new("controller:tuner").unwrap(),
3121                trace: Arc::clone(&resumed_trace),
3122                history_len: 2,
3123                proposal_count: 2,
3124                prune_at: None,
3125            }))
3126            .unwrap();
3127        resumed_controllers
3128            .register(Box::new(HpoTestModel {
3129                id: ControllerId::new("controller:model").unwrap(),
3130                trace: Arc::clone(&resumed_trace),
3131                fail_variant: None,
3132                score_by_trial: false,
3133            }))
3134            .unwrap();
3135        let mut resumed_hpo = hpo.clone();
3136        resumed_hpo.trial_budget_total = 4;
3137        let resumed_ctx = RunContext::new(RunId::new("run:hpo.resumed").unwrap(), Some(13));
3138        let resumed = SequentialScheduler
3139            .execute_hpo_campaign(
3140                &plan,
3141                &resumed_controllers,
3142                &provider,
3143                &resumed_ctx,
3144                &resumed_hpo,
3145            )
3146            .unwrap();
3147        assert_eq!(resumed.candidates.len(), 2);
3148        assert_eq!(resumed.checkpoint.trial_history_len, 4);
3149        assert_eq!(
3150            resumed_trace
3151                .lock()
3152                .unwrap()
3153                .iter()
3154                .filter(|event| event.as_str() == "ask")
3155                .count(),
3156            2
3157        );
3158
3159        let mut over_budget_controllers = RuntimeControllerRegistry::new();
3160        over_budget_controllers
3161            .register(Box::new(HpoTestTuner {
3162                id: ControllerId::new("controller:tuner").unwrap(),
3163                trace: Arc::new(Mutex::new(Vec::new())),
3164                history_len: 5,
3165                proposal_count: 0,
3166                prune_at: None,
3167            }))
3168            .unwrap();
3169        let error = SequentialScheduler
3170            .execute_hpo_campaign(
3171                &plan,
3172                &over_budget_controllers,
3173                &provider,
3174                &resumed_ctx,
3175                &resumed_hpo,
3176            )
3177            .unwrap_err();
3178        assert!(error.to_string().contains("exceeds total trial budget"));
3179
3180        #[cfg(feature = "methods-optimizer-local")]
3181        {
3182            // Vertical native gate: the ordinary scheduler supplies the fold
3183            // scores, while the selected Methods C ABI alone owns TPE,
3184            // pruning, terminal state, opaque handles and N4MOPT resume.
3185            let library_path = std::env::var_os("N4M_LIBRARY_PATH")
3186                .expect("native HPO scheduler test requires N4M_LIBRARY_PATH");
3187            let runtime = MethodsRuntime::configure(library_path).unwrap();
3188            let native_trace = Arc::new(Mutex::new(Vec::new()));
3189            let mut native_controllers = RuntimeControllerRegistry::new();
3190            native_controllers
3191                .register(Box::new(MethodsHpoController::new(
3192                    ControllerId::new("controller:tuner").unwrap(),
3193                    runtime,
3194                )))
3195                .unwrap();
3196            native_controllers
3197                .register(Box::new(HpoTestModel {
3198                    id: ControllerId::new("controller:model").unwrap(),
3199                    trace: Arc::clone(&native_trace),
3200                    fail_variant: None,
3201                    score_by_trial: true,
3202                }))
3203                .unwrap();
3204            let mut native_hpo = hpo.clone();
3205            native_hpo.trial_budget_total = 3;
3206            native_hpo.study.study_id = "study:hpo.scheduler.native".to_string();
3207            native_hpo.study.methods_abi = "n4m-abi-2.2".to_string();
3208            native_hpo.study.optimizer.sampler = HpoSampler::Tpe;
3209            native_hpo.study.optimizer.pruner = HpoPruner::Median;
3210            native_hpo.study.optimizer.n_startup_trials = 2;
3211            native_hpo.study.optimizer.seed = 51;
3212            native_hpo.study.optimizer.reduction_factor = 0;
3213            native_hpo.study.search_space.parameters = vec![HpoParameter::Int {
3214                name: "n_components".to_string(),
3215                low: 1,
3216                high: 3,
3217                step: 1,
3218                log: false,
3219            }];
3220            let native = SequentialScheduler
3221                .execute_hpo_campaign(
3222                    &plan,
3223                    &native_controllers,
3224                    &provider,
3225                    &RunContext::new(RunId::new("run:hpo.native").unwrap(), Some(13)),
3226                    &native_hpo,
3227                )
3228                .unwrap();
3229            assert_eq!(native.checkpoint.trial_history_len, 3);
3230            assert_eq!(native.checkpoint.artifact.format, N4MOPT_FORMAT);
3231            assert_eq!(native.candidates.len(), 2);
3232            assert_eq!(
3233                native
3234                    .terminal_trials
3235                    .iter()
3236                    .map(|entry| entry.trial.status)
3237                    .collect::<Vec<_>>(),
3238                vec![
3239                    crate::hpo::HpoTrialStatus::Completed,
3240                    crate::hpo::HpoTrialStatus::Completed,
3241                    crate::hpo::HpoTrialStatus::Pruned,
3242                ]
3243            );
3244            assert!(native.terminal_trials[2]
3245                .trial
3246                .intermediates
3247                .iter()
3248                .any(|item| item.step == 0 && item.should_prune));
3249            assert_eq!(
3250                native_trace
3251                    .lock()
3252                    .unwrap()
3253                    .iter()
3254                    .filter(|event| event.as_str() == "model_cv")
3255                    .count(),
3256                5,
3257                "native pruning must stop the third trial after its first fold"
3258            );
3259
3260            let prior_checkpoint = native.checkpoint.artifact.clone();
3261            native_hpo.resume_checkpoint = Some(prior_checkpoint.clone());
3262            native_hpo.resume_variants = native
3263                .terminal_trials
3264                .iter()
3265                .filter_map(|snapshot| {
3266                    snapshot
3267                        .variant_id
3268                        .clone()
3269                        .map(|variant| (snapshot.trial.id, variant))
3270                })
3271                .collect();
3272            native_hpo.resume_terminal_trials = native.terminal_trials.clone();
3273            native_hpo.trial_budget_total = 4;
3274            let resumed = SequentialScheduler
3275                .execute_hpo_campaign(
3276                    &plan,
3277                    &native_controllers,
3278                    &provider,
3279                    &RunContext::new(RunId::new("run:hpo.native.resume").unwrap(), Some(13)),
3280                    &native_hpo,
3281                )
3282                .unwrap();
3283            assert_eq!(resumed.checkpoint.trial_history_len, 4);
3284            assert_eq!(resumed.terminal_trials.len(), 4);
3285            assert_eq!(
3286                &resumed.terminal_trials[..3],
3287                native.terminal_trials.as_slice()
3288            );
3289            assert_ne!(
3290                resumed.checkpoint.artifact.opaque_payload,
3291                prior_checkpoint.opaque_payload
3292            );
3293        }
3294    }
3295}
3296
3297pub(crate) fn attach_coordinator_input_lineage(
3298    result: &mut NodeResult,
3299    plan: &ExecutionPlan,
3300    node_id: &NodeId,
3301    upstream_lineage: &BTreeMap<NodeId, LineageId>,
3302) -> Result<()> {
3303    let inferred = inferred_input_lineage_for_node(plan, node_id, upstream_lineage);
3304    if result.lineage.input_lineage.is_empty() {
3305        result.lineage.input_lineage = inferred;
3306        return Ok(());
3307    }
3308
3309    let declared = result
3310        .lineage
3311        .input_lineage
3312        .iter()
3313        .cloned()
3314        .collect::<BTreeSet<_>>()
3315        .into_iter()
3316        .collect::<Vec<_>>();
3317    if declared != inferred {
3318        return Err(DagMlError::RuntimeValidation(format!(
3319            "lineage for node `{}` declared input lineage {:?}, expected {:?}",
3320            result.node_id, declared, inferred
3321        )));
3322    }
3323    result.lineage.input_lineage = declared;
3324    Ok(())
3325}
3326
3327/// The meta invocation in a nested-stacking FIT_CV scope consumes parent-bound
3328/// *inner* OOF blocks, not the outer blocks emitted immediately before it. The
3329/// ordinary per-scope lineage map cannot represent those records because every
3330/// inner fold ran in its own prior scope. Reconstruct the exact dependency set
3331/// from scheduler-owned nested evidence and attach it before recording the
3332/// meta result.
3333fn attach_nested_stacking_input_lineage(
3334    result: &mut NodeResult,
3335    plan: &ExecutionPlan,
3336    task: &NodeTask,
3337    ctx: &RunContext,
3338    nested: &NestedStackingInput<'_>,
3339) -> Result<()> {
3340    if task.phase != Phase::FitCv || task.node_plan.node_id != *nested.meta_node_id {
3341        return Ok(());
3342    }
3343    let inner_fold_ids = nested
3344        .inner
3345        .inner_fold_set
3346        .folds
3347        .iter()
3348        .map(|fold| fold.fold_id.clone())
3349        .collect::<BTreeSet<_>>();
3350    let source_nodes = incoming_oof_edges(plan, &task.node_plan)?
3351        .into_iter()
3352        .map(|edge| edge.source.node_id.clone())
3353        .collect::<BTreeSet<_>>();
3354    let expected = source_nodes
3355        .iter()
3356        .flat_map(|node_id| {
3357            inner_fold_ids
3358                .iter()
3359                .cloned()
3360                .map(move |fold_id| (node_id.clone(), fold_id))
3361        })
3362        .collect::<BTreeSet<_>>();
3363    let mut actual = BTreeMap::new();
3364    for record in ctx.lineage.records().filter(|record| {
3365        record.phase == Phase::FitCv
3366            && record.variant_id == task.variant_id
3367            && source_nodes.contains(&record.node_id)
3368            && record
3369                .fold_id
3370                .as_ref()
3371                .is_some_and(|fold_id| inner_fold_ids.contains(fold_id))
3372    }) {
3373        let fold_id = record
3374            .fold_id
3375            .clone()
3376            .expect("inner-fold predicate requires a fold id");
3377        if actual
3378            .insert((record.node_id.clone(), fold_id), record.record_id.clone())
3379            .is_some()
3380        {
3381            return Err(DagMlError::RuntimeValidation(
3382                "nested stacking meta input lineage contains duplicate inner-fold evidence"
3383                    .to_string(),
3384            ));
3385        }
3386    }
3387    if actual.keys().cloned().collect::<BTreeSet<_>>() != expected {
3388        return Err(DagMlError::RuntimeValidation(
3389            "nested stacking meta input lineage does not exactly cover inner OOF evidence"
3390                .to_string(),
3391        ));
3392    }
3393    let inferred = actual.into_values().collect::<Vec<_>>();
3394    if result.lineage.input_lineage.is_empty() {
3395        result.lineage.input_lineage = inferred;
3396        return Ok(());
3397    }
3398    let declared = result
3399        .lineage
3400        .input_lineage
3401        .iter()
3402        .cloned()
3403        .collect::<BTreeSet<_>>();
3404    if declared.into_iter().collect::<Vec<_>>() != inferred {
3405        return Err(DagMlError::RuntimeValidation(format!(
3406            "nested stacking meta lineage for node `{}` does not match inner OOF evidence",
3407            task.node_plan.node_id
3408        )));
3409    }
3410    result.lineage.input_lineage = inferred;
3411    Ok(())
3412}
3413
3414pub(crate) fn inferred_input_lineage_for_node(
3415    plan: &ExecutionPlan,
3416    node_id: &NodeId,
3417    upstream_lineage: &BTreeMap<NodeId, LineageId>,
3418) -> Vec<LineageId> {
3419    plan.graph_plan
3420        .graph
3421        .edges
3422        .iter()
3423        .filter(|edge| &edge.target.node_id == node_id && edge.contract.propagates_lineage)
3424        .filter_map(|edge| upstream_lineage.get(&edge.source.node_id).cloned())
3425        .collect::<BTreeSet<_>>()
3426        .into_iter()
3427        .collect()
3428}
3429pub(crate) fn collect_input_handles(
3430    plan: &ExecutionPlan,
3431    node_plan: &NodePlan,
3432    output_handles: &BTreeMap<NodeId, BTreeMap<String, HandleRef>>,
3433    output_data_views: &BTreeMap<NodeId, BTreeMap<String, DataProviderViewSpec>>,
3434    resources: &PhaseScopeResources<'_>,
3435    ctx: &RunContext,
3436    scope: &PhaseScope,
3437) -> Result<CollectedInputs> {
3438    let mut inputs = BTreeMap::new();
3439    let mut data_views = BTreeMap::new();
3440    let mut prediction_inputs = BTreeMap::new();
3441    let training_oof_edges = incoming_training_oof_edges(plan, node_plan, scope)?;
3442    // An OOF edge replaces exactly one raw producer port. Do not hide sibling
3443    // outputs from the same producer: a meta-node may legally consume both an
3444    // OOF prediction port and an auxiliary non-OOF port. PREDICT has no
3445    // Validation-OOF input, but its raw prediction port must still be masked so
3446    // only the explicit `:predict` off-fold input reaches the controller.
3447    let masked_oof_source_ports = if scope.phase == Phase::Predict {
3448        incoming_oof_edges(plan, node_plan)?
3449    } else {
3450        training_oof_edges.clone()
3451    }
3452    .into_iter()
3453    .map(|edge| (edge.source.node_id.clone(), edge.source.port_name.clone()))
3454    .collect::<BTreeSet<_>>();
3455    let bound_data_inputs = node_plan
3456        .data_bindings
3457        .iter()
3458        .map(|binding| binding.input_name.clone())
3459        .collect::<BTreeSet<_>>();
3460    // Only forward upstream handles for ports this node DECLARES an edge to.
3461    // A controller must never see a handle outside its declared port contract,
3462    // so a sibling consumer of the same producer cannot expose extra ports here.
3463    let declared_source_ports = plan
3464        .graph_plan
3465        .graph
3466        .edges
3467        .iter()
3468        .filter(|edge| edge.target.node_id == node_plan.node_id)
3469        .map(|edge| (edge.source.node_id.clone(), edge.source.port_name.clone()))
3470        .collect::<BTreeSet<_>>();
3471    for upstream in &node_plan.input_nodes {
3472        if let Some(handles) = output_handles.get(upstream) {
3473            for (port, handle) in handles {
3474                if !declared_source_ports.contains(&(upstream.clone(), port.clone())) {
3475                    continue;
3476                }
3477                if masked_oof_source_ports.contains(&(upstream.clone(), port.clone())) {
3478                    continue;
3479                }
3480                inputs.insert(format!("{upstream}.{port}"), handle.clone());
3481            }
3482        }
3483    }
3484    for edge in plan
3485        .graph_plan
3486        .graph
3487        .edges
3488        .iter()
3489        .filter(|edge| edge.target.node_id == node_plan.node_id)
3490        .filter(|edge| edge.contract.kind == PortKind::Data && !edge.contract.requires_oof)
3491    {
3492        if bound_data_inputs.contains(&edge.target.port_name) {
3493            continue;
3494        }
3495        let Some(handles) = output_handles.get(&edge.source.node_id) else {
3496            continue;
3497        };
3498        let Some(handle) = handles.get(&edge.source.port_name) else {
3499            continue;
3500        };
3501        let key = data_view_key(&edge.target.port_name);
3502        if inputs.insert(key.clone(), handle.clone()).is_some() {
3503            return Err(DagMlError::RuntimeValidation(format!(
3504                "node `{}` received duplicate data edge input `{key}`",
3505                node_plan.node_id
3506            )));
3507        }
3508        if let Some(source_views) = output_data_views.get(&edge.source.node_id) {
3509            if let Some(view) = source_views.get(&edge.source.port_name) {
3510                if data_views.insert(key.clone(), view.clone()).is_some() {
3511                    return Err(DagMlError::RuntimeValidation(format!(
3512                        "node `{}` received duplicate data edge view `{key}`",
3513                        node_plan.node_id
3514                    )));
3515                }
3516            }
3517            let source_validation_key = validation_data_view_key(&edge.source.port_name);
3518            if let Some(view) = source_views.get(&source_validation_key) {
3519                let validation_key = format!("{key}:validation");
3520                if data_views
3521                    .insert(validation_key.clone(), view.clone())
3522                    .is_some()
3523                {
3524                    return Err(DagMlError::RuntimeValidation(format!(
3525                        "node `{}` received duplicate data edge validation view `{validation_key}`",
3526                        node_plan.node_id
3527                    )));
3528                }
3529            }
3530        }
3531    }
3532    for edge in training_oof_edges {
3533        let key = format!("{}.{}", edge.source.node_id, edge.source.port_name);
3534        let Some(input) = collect_oof_prediction_input(plan, edge, ctx, scope, resources)? else {
3535            return Ok(CollectedInputs {
3536                handles: BTreeMap::new(),
3537                data_views: BTreeMap::new(),
3538                prediction_inputs: BTreeMap::new(),
3539                skip_node: true,
3540            });
3541        };
3542        if inputs.insert(key.clone(), input.handle).is_some() {
3543            return Err(DagMlError::RuntimeValidation(format!(
3544                "node `{}` received duplicate OOF prediction input `{key}`",
3545                node_plan.node_id
3546            )));
3547        }
3548        if prediction_inputs.insert(key.clone(), input.spec).is_some() {
3549            return Err(DagMlError::RuntimeValidation(format!(
3550                "node `{}` received duplicate OOF prediction spec `{key}`",
3551                node_plan.node_id
3552            )));
3553        }
3554    }
3555    // REFIT / PREDICT: deliver each base producer's off-fold (test / predict)
3556    // predictions to the stacking meta-node as a SEPARATE prediction input (suffixed
3557    // `:test` / `:predict`) so the host meta-model predicts from them. The FIT_CV
3558    // Validation-OOF input above is the meta-features the meta-model trains on; this
3559    // off-fold input is used ONLY for REFIT/PREDICT scoring/prediction, never FIT_CV
3560    // training — keeping the leakage invariant intact.
3561    if matches!(scope.phase, Phase::Refit | Phase::Predict) {
3562        let off_fold_suffix = scope.phase.as_str().to_ascii_lowercase();
3563        for edge in incoming_oof_edges(plan, node_plan)? {
3564            let Some(input) = collect_off_fold_prediction_input(plan, edge, ctx, scope)? else {
3565                continue;
3566            };
3567            let key = format!(
3568                "{}.{}:{off_fold_suffix}",
3569                edge.source.node_id, edge.source.port_name
3570            );
3571            if inputs.insert(key.clone(), input.handle).is_some() {
3572                return Err(DagMlError::RuntimeValidation(format!(
3573                    "node `{}` received duplicate off-fold prediction input `{key}`",
3574                    node_plan.node_id
3575                )));
3576            }
3577            if prediction_inputs.insert(key.clone(), input.spec).is_some() {
3578                return Err(DagMlError::RuntimeValidation(format!(
3579                    "node `{}` received duplicate off-fold prediction spec `{key}`",
3580                    node_plan.node_id
3581                )));
3582            }
3583        }
3584    }
3585    if !node_plan.data_bindings.is_empty() && resources.data_provider.is_none() {
3586        return Err(DagMlError::RuntimeValidation(format!(
3587            "node `{}` requires {} data binding(s) but no runtime data provider is registered",
3588            node_plan.node_id,
3589            node_plan.data_bindings.len()
3590        )));
3591    }
3592    if let Some(data_provider) = resources.data_provider {
3593        // Samples excluded from training (sample-local) are relevant only to
3594        // fitting scopes. A top-level PREDICT must not even resolve the CV
3595        // relation authority: its separately attested cohort below owns the
3596        // complete identity universe for that read.
3597        let excluded_samples = if scope.phase == Phase::Predict {
3598            BTreeSet::new()
3599        } else {
3600            coordinator_relations_for_node(node_plan, resources)?
3601                .map(|relations| relations.excluded_sample_ids())
3602                .unwrap_or_default()
3603        };
3604        let scope_fold_set = resources.fold_set_override.or(plan.fold_set.as_ref());
3605        for binding in &node_plan.data_bindings {
3606            let predict_cohort = if scope.phase == Phase::Predict {
3607                data_provider.predict_cohort(binding, scope.phase)?
3608            } else {
3609                None
3610            };
3611            let materialized = data_provider.materialize(&DataMaterializationRequest {
3612                run_id: ctx.run_id.clone(),
3613                node_id: node_plan.node_id.clone(),
3614                input_name: binding.input_name.clone(),
3615                phase: scope.phase,
3616                variant_id: scope.variant_id.clone(),
3617                fold_id: scope.fold_id.clone(),
3618                binding: binding.clone(),
3619                predict_cohort: predict_cohort.clone(),
3620            })?;
3621            let branch_view_for_node = branch_view_from_node_metadata(plan, &node_plan.node_id)?;
3622            let mut view = data_view_for_scope(
3623                binding,
3624                scope_fold_set,
3625                scope,
3626                branch_view_for_node.as_ref(),
3627                &excluded_samples,
3628            )?;
3629            if scope.phase == Phase::Refit
3630                && scope_fold_set.is_none()
3631                && view.partition == DataRequestPartition::FullTrain
3632                && view.sample_ids.is_none()
3633            {
3634                view.sample_ids = data_provider.refit_sample_ids(binding)?;
3635                if !view.include_excluded {
3636                    if let Some(sample_ids) = view.sample_ids.as_mut() {
3637                        sample_ids.retain(|sample_id| !excluded_samples.contains(sample_id));
3638                    }
3639                }
3640                view.validate()?;
3641            }
3642            if let Some(cohort) = predict_cohort.as_ref() {
3643                bind_predict_cohort_to_view(&mut view, cohort)?;
3644            }
3645            let key = data_view_key(&binding.input_name);
3646            let view_handle = make_data_view_handle(
3647                data_provider,
3648                ctx,
3649                node_plan,
3650                scope,
3651                binding,
3652                DataViewHandleInput {
3653                    data_handle: &materialized,
3654                    view: &view,
3655                    predict_cohort: predict_cohort.as_ref(),
3656                },
3657            )?;
3658            if data_views.insert(key.clone(), view).is_some() {
3659                return Err(DagMlError::RuntimeValidation(format!(
3660                    "node `{}` received duplicate data view `{key}`",
3661                    node_plan.node_id
3662                )));
3663            }
3664            if inputs.insert(key.clone(), view_handle).is_some() {
3665                return Err(DagMlError::RuntimeValidation(format!(
3666                    "node `{}` received duplicate data input `{key}`",
3667                    node_plan.node_id
3668                )));
3669            }
3670
3671            if let Some(validation_view) = validation_data_view_for_scope(
3672                binding,
3673                scope_fold_set,
3674                scope,
3675                branch_view_for_node.as_ref(),
3676                &excluded_samples,
3677            )? {
3678                let validation_key = format!("{key}:validation");
3679                let validation_handle = make_data_view_handle(
3680                    data_provider,
3681                    ctx,
3682                    node_plan,
3683                    scope,
3684                    binding,
3685                    DataViewHandleInput {
3686                        data_handle: &materialized,
3687                        view: &validation_view,
3688                        predict_cohort: None,
3689                    },
3690                )?;
3691                if data_views
3692                    .insert(validation_key.clone(), validation_view)
3693                    .is_some()
3694                {
3695                    return Err(DagMlError::RuntimeValidation(format!(
3696                        "node `{}` received duplicate validation data view `{validation_key}`",
3697                        node_plan.node_id
3698                    )));
3699                }
3700                if inputs
3701                    .insert(validation_key.clone(), validation_handle)
3702                    .is_some()
3703                {
3704                    return Err(DagMlError::RuntimeValidation(format!(
3705                        "node `{}` received duplicate validation data input `{validation_key}`",
3706                        node_plan.node_id
3707                    )));
3708                }
3709            }
3710        }
3711    }
3712    Ok(CollectedInputs {
3713        handles: inputs,
3714        data_views,
3715        prediction_inputs,
3716        skip_node: false,
3717    })
3718}
3719pub(crate) fn preload_replay_prediction_cache_store(
3720    bundle: &ExecutionBundle,
3721    prediction_cache_store: Option<&dyn RuntimePredictionCacheStore>,
3722    ctx: &mut RunContext,
3723) -> Result<()> {
3724    if bundle.prediction_requirements.is_empty() {
3725        return Ok(());
3726    }
3727    let store = prediction_cache_store.ok_or_else(|| {
3728        DagMlError::RuntimeValidation(format!(
3729            "bundle `{}` cannot preload OOF prediction caches without a prediction cache store",
3730            bundle.bundle_id
3731        ))
3732    })?;
3733    if !ctx.prediction_store.blocks().is_empty() {
3734        return Err(DagMlError::RuntimeValidation(format!(
3735            "bundle `{}` cannot preload OOF prediction caches into a non-empty prediction store",
3736            bundle.bundle_id
3737        )));
3738    }
3739    let contracts = replay_prediction_cache_contracts(bundle)?;
3740    for contract in contracts.values() {
3741        if contract.requirement.prediction_level == PredictionLevel::Sample {
3742            let blocks = store.load_blocks(&contract.cache.requirement_key)?;
3743            if blocks.iter().any(|block| {
3744                block.producer_node != contract.requirement.producer_node
3745                    || block.partition != contract.requirement.partition
3746            }) {
3747                return Err(DagMlError::RuntimeValidation(format!(
3748                    "prediction cache store returned blocks outside requirement `{}`",
3749                    contract.cache.requirement_key
3750                )));
3751            }
3752            let mut payload = build_prediction_cache_payload(&contract.requirement, &blocks)?;
3753            payload.cache_namespace_fingerprints =
3754                contract.cache.cache_namespace_fingerprints.clone();
3755            validate_prediction_cache_payload_matches_record(&payload, &contract.cache)?;
3756            for block in &payload.blocks {
3757                ctx.prediction_store.append(block.clone())?;
3758            }
3759        } else {
3760            let blocks = store.load_aggregated_blocks(&contract.cache.requirement_key)?;
3761            if blocks.iter().any(|block| {
3762                block.producer_node != contract.requirement.producer_node
3763                    || block.partition != contract.requirement.partition
3764                    || block.level != contract.requirement.prediction_level
3765            }) {
3766                return Err(DagMlError::RuntimeValidation(format!(
3767                    "prediction cache store returned aggregated blocks outside requirement `{}`",
3768                    contract.cache.requirement_key
3769                )));
3770            }
3771            let mut payload =
3772                build_aggregated_prediction_cache_payload(&contract.requirement, &blocks)?;
3773            payload.cache_namespace_fingerprints =
3774                contract.cache.cache_namespace_fingerprints.clone();
3775            validate_prediction_cache_payload_matches_record(&payload, &contract.cache)?;
3776        }
3777    }
3778    Ok(())
3779}
3780
3781pub(crate) fn replay_prediction_cache_contracts(
3782    bundle: &ExecutionBundle,
3783) -> Result<BTreeMap<String, ReplayPredictionCacheContract>> {
3784    bundle.validate()?;
3785    let requirements = bundle
3786        .prediction_requirements
3787        .iter()
3788        .map(|requirement| (requirement.key(), requirement))
3789        .collect::<BTreeMap<_, _>>();
3790    let mut contracts = BTreeMap::new();
3791    for cache in &bundle.prediction_caches {
3792        let requirement = requirements.get(&cache.requirement_key).ok_or_else(|| {
3793            DagMlError::RuntimeValidation(format!(
3794                "prediction cache `{}` references unknown prediction requirement `{}`",
3795                cache.cache_id, cache.requirement_key
3796            ))
3797        })?;
3798        contracts.insert(
3799            cache.requirement_key.clone(),
3800            ReplayPredictionCacheContract {
3801                requirement: (*requirement).clone(),
3802                cache: cache.clone(),
3803            },
3804        );
3805    }
3806    Ok(contracts)
3807}
3808
3809pub(crate) fn materialize_replay_artifact_handles(
3810    plan: &ExecutionPlan,
3811    bundle: &ExecutionBundle,
3812    replay_request: &ReplayPhaseRequest,
3813    artifact_store: &dyn RuntimeArtifactStore,
3814    ctx: &RunContext,
3815) -> Result<MaterializedReplayArtifacts> {
3816    let mut handles = BTreeMap::<NodeId, BTreeMap<String, HandleRef>>::new();
3817    let mut inputs = BTreeMap::<NodeId, BTreeMap<String, ArtifactInputSpec>>::new();
3818    for artifact in &bundle.refit_artifacts {
3819        artifact.validate()?;
3820        let node_plan = plan.node_plans.get(&artifact.node_id).ok_or_else(|| {
3821            DagMlError::RuntimeValidation(format!(
3822                "bundle `{}` artifact references unknown node `{}`",
3823                bundle.bundle_id, artifact.node_id
3824            ))
3825        })?;
3826        if !node_plan.supported_phases.contains(&replay_request.phase) {
3827            return Err(DagMlError::RuntimeValidation(format!(
3828                "bundle `{}` artifact node `{}` does not support replay phase {:?}",
3829                bundle.bundle_id, artifact.node_id, replay_request.phase
3830            )));
3831        }
3832        let handle = artifact_store.materialize(&ArtifactMaterializationRequest {
3833            run_id: ctx.run_id.clone(),
3834            bundle_id: bundle.bundle_id.clone(),
3835            node_id: artifact.node_id.clone(),
3836            phase: replay_request.phase,
3837            variant_id: bundle.selected_variant_id.clone(),
3838            controller_id: artifact.controller_id.clone(),
3839            artifact: artifact.artifact.clone(),
3840            params_fingerprint: artifact.params_fingerprint.clone(),
3841            training_loss_fingerprint: artifact.training_loss_fingerprint.clone(),
3842        })?;
3843        if !matches!(handle.kind, HandleKind::Model | HandleKind::Artifact) {
3844            return Err(DagMlError::RuntimeValidation(format!(
3845                "artifact `{}` materialized as unsupported handle kind {:?}",
3846                artifact.artifact.id, handle.kind
3847            )));
3848        }
3849        if handle.owner_controller != artifact.controller_id {
3850            return Err(DagMlError::RuntimeValidation(format!(
3851                "artifact `{}` handle owner `{}` does not match controller `{}`",
3852                artifact.artifact.id, handle.owner_controller, artifact.controller_id
3853            )));
3854        }
3855        let key = refit_artifact_input_key(&artifact.artifact.id);
3856        if handles
3857            .entry(artifact.node_id.clone())
3858            .or_default()
3859            .insert(key.clone(), handle)
3860            .is_some()
3861        {
3862            return Err(DagMlError::RuntimeValidation(format!(
3863                "duplicate replay artifact input `{key}` for node `{}`",
3864                artifact.node_id
3865            )));
3866        }
3867        if inputs
3868            .entry(artifact.node_id.clone())
3869            .or_default()
3870            .insert(key.clone(), ArtifactInputSpec::from_refit_record(artifact)?)
3871            .is_some()
3872        {
3873            return Err(DagMlError::RuntimeValidation(format!(
3874                "duplicate replay artifact metadata `{key}` for node `{}`",
3875                artifact.node_id
3876            )));
3877        }
3878    }
3879    Ok(MaterializedReplayArtifacts { handles, inputs })
3880}
3881
3882pub(crate) fn derive_task_seed(
3883    root_seed: Option<u64>,
3884    variant_id: Option<&VariantId>,
3885    fold_id: Option<&FoldId>,
3886    node_plan: &NodePlan,
3887    phase: Phase,
3888) -> Option<u64> {
3889    root_seed.map(|root| {
3890        let mut context = SeedContext::root(root);
3891        if let Some(variant_id) = variant_id {
3892            context = context.child(format!("variant:{variant_id}"));
3893        }
3894        if let Some(fold_id) = fold_id {
3895            context = context.child(format!("fold:{fold_id}"));
3896        }
3897        context
3898            .child(format!("node:{}", node_plan.node_id))
3899            .child(format!("phase:{phase:?}"))
3900            .derive_u64("task")
3901    })
3902}