Skip to main content

dag_ml_core/
training_runtime.rs

1//! Native training orchestration outcome and common runtime entry point.
2//!
3//! The portable contracts in this module use Typed Canonical Value v1 (TCV1).
4//! Historical graph, plan, controller, parameter, and bundle fingerprints keep
5//! their pre-existing algorithms.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::aggregation::{AggregatedPredictionBlock, ObservationPredictionBlock, PredictionUnitId};
13#[cfg(feature = "methods-optimizer")]
14use crate::bundle::MethodsHpoResumeSelection;
15use crate::bundle::{
16    build_aggregated_prediction_cache_payload, build_aggregated_prediction_cache_record,
17    build_execution_bundle_with_prediction_contracts, build_prediction_cache_payload,
18    build_prediction_cache_record, validate_prediction_cache_payload_matches_record,
19    BundlePredictionCachePayload, BundlePredictionCachePayloadSet, BundlePredictionCacheRecord,
20    BundlePredictionRequirement, ExecutionBundle, MethodsHpoResumeState, RefitArtifactRecord,
21    EXECUTION_BUNDLE_SCHEMA_VERSION, LEGACY_EXECUTION_BUNDLE_SCHEMA_VERSION,
22    LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION, PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
23};
24use crate::campaign::stable_json_fingerprint;
25use crate::canonical::parse_typed_json;
26use crate::conformal_runtime::ConformalCalibration;
27use crate::controller::{ControllerCapability, ControllerFitScope};
28use crate::data::data_binding_requirement_key;
29use crate::error::{DagMlError, Result};
30use crate::fold::fold_set_fingerprint;
31use crate::graph::{NodeKind, PortKind};
32use crate::hpo::{methods_optimizer_preflight, MethodsHpoStudyConfig};
33use crate::ids::{ArtifactId, BundleId, FoldId, LineageId, NodeId, RunId, SampleId, VariantId};
34use crate::metrics::{
35    RegressionMetricKind, ScoreSet, LEGACY_SCORE_SET_SCHEMA_VERSION, SCORE_SET_SCHEMA_VERSION,
36};
37use crate::oof::{PredictionBlock, PredictionPartition};
38use crate::phase::Phase;
39use crate::plan::ExecutionPlan;
40use crate::policy::PredictionLevel;
41#[cfg(feature = "methods-optimizer")]
42use crate::replay::methods_hpo_resume_state_from_package_json;
43use crate::replay::{replay_request_from_outcome, TrainingReplayOutcome};
44use crate::runtime::{
45    is_nested_stacking_meta_node, nested_stacking_campaign_plan, plan_oof_partition_mode,
46    select_best_variant_outcome_by_cv_for_target, InMemoryArtifactStore, LineageRecord, NodeResult,
47    ParallelScheduler, RunContext, RuntimeControllerRegistry, RuntimeDataProvider,
48    SequentialScheduler, VariantExecutionSpec,
49};
50#[cfg(feature = "methods-optimizer")]
51use crate::runtime::{
52    RuntimeHpoExecutionContext, RuntimeHpoProvenance, RuntimeHpoSelectionTarget, VariantSelection,
53    VariantSelectionOutcome,
54};
55use crate::selection::{
56    select_candidate, EvaluationScope, RefitStrategy, SelectionDecision, SelectionMetric,
57    SelectionPolicy,
58};
59use crate::training::{
60    contains_runtime_handle, ArtifactLoadMode, CacheNamespace, CvArtifactRetention,
61    FittedArtifactMode, OutputBinding, PackageArtifactBinding, ParameterNamespace, ParameterPatch,
62    PortablePredictorPackage, PortableRefitProvenance, PortableRefitRecipe,
63    PredictionCacheRetention, PredictionKind, PredictionSource, PredictorTemplate,
64    ResolvedTrainingOutput, TrainingContractProjection, TrainingDataIdentity,
65    TrainingInfluenceKind, TrainingInfluenceManifest, TrainingOutcomeRef, TrainingRequest,
66    TrainingSchedulerBackend, TrainingSchedulerKind, OUTPUT_BINDING_SCHEMA_VERSION,
67    PARAMETER_PATCH_SCHEMA_VERSION, PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION,
68};
69
70pub const TRAINING_OUTCOME_SCHEMA_VERSION: u32 = 2;
71pub const LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION: u32 = 1;
72pub const MIN_READABLE_TRAINING_OUTCOME_SCHEMA_VERSION: u32 = 1;
73pub const BOUND_TRAINING_OUTPUT_SCHEMA_VERSION: u32 = 2;
74pub const TRAINING_OUTCOME_SCHEMA_ID: &str =
75    "https://github.com/GBeurier/dag-ml/schemas/training_outcome.v2.schema.json";
76/// V3 is a new refit-child family, not a readable variant of `TrainingOutcome`
77/// or `PortablePredictorPackage` V1/V2.
78pub const PORTABLE_REFIT_OUTCOME_V3_SCHEMA_VERSION: u32 = 3;
79pub const PORTABLE_REFIT_EXECUTION_BUNDLE_V3_SCHEMA_VERSION: u32 = 3;
80pub const PORTABLE_REFIT_PACKAGE_V3_SCHEMA_VERSION: u32 = 3;
81
82/// One resolved output binding and the actual portable blocks it selected.
83#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct BoundTrainingOutput {
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub schema_version: Option<u32>,
88    pub binding: OutputBinding,
89    pub predictions: Vec<PredictionBlock>,
90    pub observation_predictions: Vec<ObservationPredictionBlock>,
91    pub aggregated_predictions: Vec<AggregatedPredictionBlock>,
92}
93
94#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum TrainingRefitStatus {
97    Completed,
98    Skipped,
99}
100
101/// Exact W0 refit state embedded in [`TrainingOutcome`].
102#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct TrainingRefitOutcome {
105    pub requested: bool,
106    pub status: TrainingRefitStatus,
107    pub strategy: Option<RefitStrategy>,
108}
109
110/// Portable result of COMPILE/PLAN/FIT_CV/SELECT and optional REFIT.
111#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct TrainingOutcome {
114    pub schema_version: u32,
115    pub outcome_id: String,
116    pub run_id: RunId,
117    pub training_request_fingerprint: String,
118    pub data_identities: Vec<TrainingDataIdentity>,
119    pub selection_output_id: String,
120    pub effective_plan: ExecutionPlan,
121    pub effective_plan_fingerprint: String,
122    pub selected_variant_id: VariantId,
123    pub selected_variant_fingerprint: String,
124    pub parameter_patches: Vec<ParameterPatch>,
125    pub refit: TrainingRefitOutcome,
126    pub score_set: ScoreSet,
127    pub outputs: Vec<BoundTrainingOutput>,
128    pub lineage: Vec<LineageRecord>,
129    pub portable_prediction_caches: Option<BundlePredictionCachePayloadSet>,
130    pub training_influence: TrainingInfluenceManifest,
131    pub execution_bundle: ExecutionBundle,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub conformal_calibration: Option<ConformalCalibration>,
134    /// Complete pre-calibration replay evidence. V2 calibration attachment
135    /// retains this beside the derived quantiles so a loaded package can
136    /// independently revalidate the replay/source/sample closure.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub conformal_calibration_replay: Option<TrainingReplayOutcome>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub methods_hpo_resume_state: Option<MethodsHpoResumeState>,
141    pub replayable_phases: Vec<Phase>,
142    pub warnings: Vec<String>,
143    pub diagnostics: BTreeMap<String, serde_json::Value>,
144    pub outcome_fingerprint: String,
145}
146
147/// Host-owned resources and portable identifiers for one native training run.
148///
149/// The operation deliberately accepts controllers and data through the same
150/// runtime abstractions as ordinary phase execution. It never invokes a
151/// controller directly and never implements a second fold/node loop.
152pub struct TrainingExecutionInput<'a> {
153    pub request: &'a TrainingRequest,
154    pub outcome_id: String,
155    pub run_id: RunId,
156    pub bundle_id: BundleId,
157    pub controllers: &'a RuntimeControllerRegistry,
158    pub data_provider: &'a dyn RuntimeDataProvider,
159    pub relations: &'a crate::relation::SampleRelationSet,
160    pub training_influence: &'a TrainingInfluenceManifest,
161    pub artifact_store: &'a mut InMemoryArtifactStore,
162    pub warnings: Vec<String>,
163    pub diagnostics: BTreeMap<String, serde_json::Value>,
164}
165
166/// Training-owned state for a native Methods HPO invocation.
167///
168/// This object is deliberately constructed by [`execute_training`] and never
169/// stored in [`RuntimeControllerRegistry`].  A Methods `Context`/`Optimizer`
170/// is thread-affine, while the registry is long-lived and `Send + Sync`.  The
171/// context therefore gives the eventual native session the exact invocation
172/// evidence it is allowed to use without making any of it controller-global.
173///
174/// Native HPO is a typed campaign operation in `CampaignSpec.metadata`, bound
175/// to a target model and a registered controller. It is intentionally outside
176/// the predictor graph: candidate model tasks remain ordinary scheduler work.
177struct HpoExecutionContext<'a> {
178    request: &'a TrainingRequest,
179    projection: &'a TrainingContractProjection,
180    controllers: &'a RuntimeControllerRegistry,
181    data_provider: &'a dyn RuntimeDataProvider,
182    relations: &'a crate::relation::SampleRelationSet,
183    training_influence: &'a TrainingInfluenceManifest,
184    selection: &'a SelectionPolicy,
185}
186
187#[cfg(feature = "methods-optimizer")]
188impl HpoExecutionContext<'_> {
189    /// Assemble the explicit, attested scheduler contract for one native HPO
190    /// campaign.  This is deliberately the only route from training into an
191    /// execution-local tuner session: no native study is created or restored
192    /// by training itself.
193    fn runtime_context(
194        &self,
195        descriptor: &PortableMethodsHpoDescriptor,
196        selection_metric: RegressionMetricKind,
197        producer: &NodeId,
198        producer_port: &str,
199    ) -> Result<(RuntimeHpoExecutionContext, Option<MethodsHpoResumeState>)> {
200        if self.projection.plan.variants.len() != 1 {
201            return Err(DagMlError::RuntimeValidation(
202                "native Methods HPO v1 requires a single unexpanded base variant".to_string(),
203            ));
204        }
205        let controller_id = crate::ControllerId::new(descriptor.study.controller_id.clone())
206            .map_err(|error| {
207                DagMlError::RuntimeValidation(format!(
208                    "native Methods HPO has an invalid controller id: {error}"
209                ))
210            })?;
211        let provenance = RuntimeHpoProvenance {
212            graph_fingerprint: self.projection.plan.graph_fingerprint.clone(),
213            campaign_fingerprint: crate::hpo::campaign_provenance_fingerprint(
214                &self.projection.plan.campaign,
215            )?,
216            controller_fingerprint: self.projection.plan.controller_fingerprint.clone(),
217            data_identities_fingerprint: tcv1_fingerprint(
218                &self.request.data_identities,
219                "native Methods HPO data identities",
220            )?,
221            fold_set_fingerprint: self
222                .projection
223                .plan
224                .fold_set
225                .as_ref()
226                .map(stable_json_fingerprint)
227                .transpose()?,
228            training_influence_fingerprint: self.training_influence.manifest_fingerprint.clone(),
229            relation_fingerprint: self.relations.fingerprint()?,
230        };
231        let resume_state = descriptor
232            .resume_package_json
233            .as_deref()
234            .map(methods_hpo_resume_state_from_package_json)
235            .transpose()?;
236        if let Some(state) = &resume_state {
237            validate_methods_hpo_resume_state(
238                state,
239                &self.projection.plan,
240                descriptor.operation_id.as_str(),
241                &controller_id,
242                descriptor,
243                self.selection.id.as_str(),
244                selection_metric,
245                producer,
246                producer_port,
247                &provenance,
248            )?;
249        }
250        Ok((
251            RuntimeHpoExecutionContext {
252                operation_id: descriptor.operation_id.clone(),
253                controller_id,
254                target_node_id: descriptor.target_node_id.clone(),
255                base_variant: self.projection.plan.variants[0].clone(),
256                // This is the global optimizer budget, including trials held
257                // only in the opaque restored checkpoint (failed/pruned
258                // trials have no selectable proposal evidence). The scheduler
259                // owns the typed history-count query and derives remaining
260                // work; training must never subtract completed candidates.
261                trial_budget_total: descriptor.trials,
262                study: descriptor.study.clone(),
263                parameter_paths: descriptor.parameter_paths.clone(),
264                resume_checkpoint: resume_state.as_ref().map(|state| state.checkpoint.clone()),
265                resume_variants: resume_state
266                    .as_ref()
267                    .map(|state| {
268                        state
269                            .completed_proposals
270                            .iter()
271                            .map(|proposal| {
272                                (proposal.trial_id, proposal.variant.variant_id.clone())
273                            })
274                            .collect()
275                    })
276                    .unwrap_or_default(),
277                resume_terminal_trials: resume_state
278                    .as_ref()
279                    .map(|state| {
280                        state
281                            .terminal_trials
282                            .iter()
283                            .map(|evidence| crate::runtime::RuntimeHpoTerminalSnapshot {
284                                trial: evidence.trial.clone(),
285                                variant_id: evidence.variant_id.clone(),
286                            })
287                            .collect()
288                    })
289                    .unwrap_or_default(),
290                selection: RuntimeHpoSelectionTarget {
291                    producer_node: producer.clone(),
292                    producer_port: producer_port.to_string(),
293                    metric: selection_metric,
294                    direction: match self.selection.metric.objective {
295                        crate::selection::MetricObjective::Minimize => {
296                            crate::hpo::HpoDirection::Minimize
297                        }
298                        crate::selection::MetricObjective::Maximize => {
299                            crate::hpo::HpoDirection::Maximize
300                        }
301                    },
302                },
303                provenance,
304            },
305            resume_state,
306        ))
307    }
308
309    /// Select once from the scheduler's completed candidate evidence.  Native
310    /// optimizer state has already been terminalized by the session; this
311    /// method only makes the normal DAG-ML selection decision and retains the
312    /// report-grade OOF evidence it was based on.
313    fn selection_from_campaign(
314        &self,
315        context: &RuntimeHpoExecutionContext,
316        previous_resume_state: Option<MethodsHpoResumeState>,
317        campaign: crate::runtime::RuntimeHpoCampaignResult,
318    ) -> Result<(
319        ExecutionPlan,
320        VariantSelectionOutcome,
321        MethodsHpoResumeState,
322    )> {
323        if campaign.operation_id != context.operation_id
324            || campaign.controller_id != context.controller_id
325            || campaign.target_node_id != context.target_node_id
326        {
327            return Err(DagMlError::RuntimeValidation(
328                "native Methods HPO campaign operation identity mismatch".to_string(),
329            ));
330        }
331        if campaign.checkpoint.provenance != context.provenance {
332            return Err(DagMlError::RuntimeValidation(
333                "native Methods HPO campaign checkpoint provenance does not match attested training evidence"
334                    .to_string(),
335            ));
336        }
337        let resume_selection = MethodsHpoResumeSelection {
338            selection_id: self.selection.id.clone(),
339            target_node_id: context.target_node_id.clone(),
340            producer_port: context.selection.producer_port.clone(),
341            metric: context.selection.metric.name().to_string(),
342        };
343        // Native checkpoint/history evidence is cumulative, whereas the
344        // scheduler intentionally returns proposal/report/candidate evidence
345        // only for this invocation. Join both halves before enforcing exact
346        // completed-trial coverage; the constructor retains the strict final
347        // validation and rejects incompatible or non-prefix prior state.
348        let resume_state = MethodsHpoResumeState::from_runtime_checkpoint_with_previous(
349            campaign.checkpoint.clone(),
350            resume_selection,
351            campaign.candidates.clone(),
352            campaign.incumbent.clone(),
353            campaign.terminal_trials.clone(),
354            previous_resume_state,
355        )?;
356        let mut variants = resume_state
357            .completed_proposals
358            .iter()
359            .map(|proposal| proposal.variant.clone())
360            .collect::<Vec<_>>();
361        variants.sort_by(|left, right| left.variant_id.cmp(&right.variant_id));
362        if variants.is_empty() {
363            return Err(DagMlError::RuntimeValidation(
364                "native Methods HPO campaign completed no selectable candidates".to_string(),
365            ));
366        }
367        let mut candidate_scores = resume_state
368            .completed_reports
369            .iter()
370            .map(|completed| {
371                completed
372                    .report
373                    .clone()
374                    .into_candidate_score(completed.variant_id.as_str())
375            })
376            .collect::<Result<Vec<_>>>()?;
377        candidate_scores.sort_by(|left, right| left.candidate_id.cmp(&right.candidate_id));
378        if candidate_scores.len() != variants.len()
379            || candidate_scores
380                .iter()
381                .map(|candidate| candidate.candidate_id.as_str())
382                .collect::<BTreeSet<_>>()
383                != variants
384                    .iter()
385                    .map(|variant| variant.variant_id.as_str())
386                    .collect::<BTreeSet<_>>()
387        {
388            return Err(DagMlError::RuntimeValidation(
389                "native Methods HPO completed reports do not exactly cover scheduler candidate variants"
390                    .to_string(),
391            ));
392        }
393        let decision = select_candidate(self.selection, &candidate_scores)?;
394        let selected_variant_id =
395            VariantId::new(decision.selected_candidate_id.clone()).map_err(|error| {
396                DagMlError::RuntimeValidation(format!(
397                    "native Methods HPO selected invalid candidate variant: {error}"
398                ))
399            })?;
400        let incumbent = &resume_state.incumbent;
401        if incumbent.metric != self.selection.metric.name
402            || incumbent.direction
403                != match self.selection.metric.objective {
404                    crate::selection::MetricObjective::Minimize => {
405                        crate::hpo::HpoDirection::Minimize
406                    }
407                    crate::selection::MetricObjective::Maximize => {
408                        crate::hpo::HpoDirection::Maximize
409                    }
410                }
411            || incumbent.variant_id != selected_variant_id
412        {
413            return Err(DagMlError::RuntimeValidation(
414                "native Methods HPO incumbent does not exactly match DAG-ML selection metric, direction, and variant"
415                    .to_string(),
416            ));
417        }
418        let incumbent_report = resume_state
419            .completed_reports
420            .iter()
421            .find(|report| report.trial_id == incumbent.trial_id)
422            .ok_or_else(|| {
423                DagMlError::RuntimeValidation(
424                    "native Methods HPO incumbent has no completed scheduler report".to_string(),
425                )
426            })?;
427        if incumbent_report.variant_id != incumbent.variant_id
428            || incumbent_report.score.to_bits() != incumbent.score.to_bits()
429            || candidate_scores
430                .iter()
431                .filter(|candidate| {
432                    candidate
433                        .metrics
434                        .get(&self.selection.metric.name)
435                        .is_some_and(|score| score.to_bits() == incumbent.score.to_bits())
436                })
437                .count()
438                != 1
439        {
440            return Err(DagMlError::RuntimeValidation(
441                "native Methods HPO incumbent score is tied, drifted, or not uniquely attested by scheduler evidence"
442                    .to_string(),
443            ));
444        }
445        let validation_reports = resume_state
446            .completed_reports
447            .iter()
448            .map(|completed| completed.report.clone())
449            .collect::<Vec<_>>();
450        let validation_predictions = campaign
451            .candidates
452            .iter()
453            .map(|candidate| candidate.validation_predictions.clone())
454            .collect::<Vec<_>>();
455        let mut plan = self.projection.plan.clone();
456        plan.variants = variants;
457        plan.validate()?;
458        Ok((
459            plan,
460            VariantSelectionOutcome {
461                selection: VariantSelection {
462                    selected_variant_id,
463                    validation_reports,
464                    variant_validation_predictions: validation_predictions,
465                },
466                decision,
467            },
468            resume_state,
469        ))
470    }
471}
472
473#[cfg(feature = "methods-optimizer")]
474#[allow(clippy::too_many_arguments)]
475fn validate_methods_hpo_resume_state(
476    state: &MethodsHpoResumeState,
477    plan: &ExecutionPlan,
478    operation_id: &str,
479    controller_id: &crate::ControllerId,
480    descriptor: &PortableMethodsHpoDescriptor,
481    selection_id: &str,
482    selection_metric: RegressionMetricKind,
483    producer: &NodeId,
484    producer_port: &str,
485    provenance: &RuntimeHpoProvenance,
486) -> Result<()> {
487    state.validate_against_plan(plan)?;
488    let expected_fold = provenance.fold_set_fingerprint.as_deref().ok_or_else(|| {
489        DagMlError::RuntimeValidation(
490            "native Methods HPO resume requires an attested execution-plan fold set".to_string(),
491        )
492    })?;
493    if state.operation_id != operation_id
494        || state.controller_id != *controller_id
495        || state.target_node_id != descriptor.target_node_id
496        || state.checkpoint.binding.controller_id != descriptor.study.controller_id
497        || state.checkpoint.binding.study_id != descriptor.study.study_id
498        || state.provenance.graph_fingerprint != provenance.graph_fingerprint
499        || state.provenance.campaign_fingerprint != provenance.campaign_fingerprint
500        || state.provenance.controller_fingerprint != provenance.controller_fingerprint
501        || state.provenance.data_identities_fingerprint != provenance.data_identities_fingerprint
502        || state.provenance.fold_set_fingerprint != expected_fold
503        || state.provenance.training_influence_fingerprint
504            != provenance.training_influence_fingerprint
505        || state.provenance.relation_fingerprint != provenance.relation_fingerprint
506        || state.provenance.selection.selection_id != selection_id
507        || state.provenance.selection.target_node_id != *producer
508        || state.provenance.selection.producer_port != producer_port
509        || state.provenance.selection.metric != selection_metric.name()
510    {
511        return Err(DagMlError::RuntimeValidation(
512            "native Methods HPO resume state does not match this attested plan, data, fold, influence, or selection identity"
513                .to_string(),
514        ));
515    }
516    Ok(())
517}
518
519#[derive(Clone, Debug, Deserialize)]
520#[serde(deny_unknown_fields)]
521struct PortableMethodsHpoDescriptor {
522    operation_id: String,
523    study: MethodsHpoStudyConfig,
524    trials: u32,
525    /// Optional complete portable predictor package from a previous,
526    /// compatible campaign. A resume is accepted only after the package's
527    /// cross-links validate through the replay-owned loader; callers cannot
528    /// inject a free checkpoint, report, or proposal list here.
529    #[serde(default)]
530    #[cfg_attr(not(feature = "methods-optimizer"), allow(dead_code))]
531    resume_package_json: Option<String>,
532    target_node_id: NodeId,
533    /// Native parameter name -> direct model parameter key.  Nested paths are
534    /// deliberately not accepted in v1: a candidate patch must remain a
535    /// replayable ordinary model parameter override.
536    parameter_paths: BTreeMap<String, String>,
537}
538
539impl HpoExecutionContext<'_> {
540    /// Validate native-HPO ownership before any provider attestation or data
541    /// view can be requested.  This is a hard preflight, not a soft fallback:
542    /// an unsupported tuner must never be silently delegated to generic
543    /// controller state.
544    fn preflight(&self) -> Result<Option<PortableMethodsHpoDescriptor>> {
545        let Some(raw) = self
546            .projection
547            .plan
548            .campaign
549            .metadata
550            .get("methods_hpo_operation")
551        else {
552            return Ok(None);
553        };
554        let descriptor: PortableMethodsHpoDescriptor = serde_json::from_value(raw.clone())
555            .map_err(|error| {
556                DagMlError::RuntimeValidation(format!(
557                    "campaign methods_hpo_operation descriptor is invalid: {error}",
558                ))
559            })?;
560        validate_portable_methods_hpo_descriptor(&descriptor, &self.projection.plan)?;
561        validate_methods_hpo_selection_alignment(&descriptor, self.selection)?;
562
563        // Check the feature-owned native runtime before consulting controller
564        // registration or any provider capability.  A portable HPO descriptor
565        // must fail closed when the local Methods overlay is absent, and that
566        // refusal must not be masked by unrelated host state.
567        methods_optimizer_preflight().map_err(|error| {
568            DagMlError::RuntimeValidation(format!(
569                "native Methods HPO preflight failed before data access: {error}"
570            ))
571        })?;
572
573        let controller_id = crate::ControllerId::new(descriptor.study.controller_id.clone())
574            .map_err(|error| {
575                DagMlError::RuntimeValidation(format!(
576                    "native Methods HPO descriptor has invalid controller id: {error}"
577                ))
578            })?;
579        if self.controllers.get(&controller_id).is_none() {
580            return Err(DagMlError::RuntimeValidation(format!(
581                "native Methods HPO campaign controller `{controller_id}` is not registered",
582            )));
583        }
584
585        let target = self
586            .projection
587            .plan
588            .node_plans
589            .get(&descriptor.target_node_id)
590            .expect("portable Methods HPO descriptor target was validated");
591        if target.controller_id.as_str() != crate::hpo::METHODS_PLS_CONTROLLER_ID {
592            return Err(DagMlError::RuntimeValidation(format!(
593                "native Methods HPO target `{}` must resolve to `{}`; host/plugin model controllers are refused",
594                descriptor.target_node_id,
595                crate::hpo::METHODS_PLS_CONTROLLER_ID,
596            )));
597        }
598        if self.request.options.scheduler.kind != TrainingSchedulerKind::Sequential {
599            return Err(DagMlError::RuntimeValidation(
600                "native Methods HPO v1 requires the sequential scheduler because its approved provider numerical view is not Sync".to_string(),
601            ));
602        }
603        self.data_provider.methods_pls_capability()?;
604
605        // Keep all borrowed execution evidence live in this local context.
606        // These accesses make the ownership relationship explicit and prevent
607        // accidental construction from detached controller state.
608        let _ = (
609            self.request.request_id.as_str(),
610            self.controllers,
611            self.data_provider,
612            self.relations,
613            self.training_influence,
614            self.selection.id.as_str(),
615        );
616        Ok(Some(descriptor))
617    }
618}
619
620fn validate_portable_methods_hpo_descriptor(
621    descriptor: &PortableMethodsHpoDescriptor,
622    plan: &ExecutionPlan,
623) -> Result<()> {
624    if descriptor.trials == 0 {
625        return Err(DagMlError::RuntimeValidation(
626            "native Methods HPO descriptor trials must be positive".to_string(),
627        ));
628    }
629    if descriptor.operation_id.trim().is_empty() {
630        return Err(DagMlError::RuntimeValidation(
631            "native Methods HPO operation_id must be non-empty".to_string(),
632        ));
633    }
634    descriptor.study.search_space.validate().map_err(|error| {
635        DagMlError::RuntimeValidation(format!(
636            "native Methods HPO search space is invalid: {error}"
637        ))
638    })?;
639    let target = plan
640        .node_plans
641        .get(&descriptor.target_node_id)
642        .ok_or_else(|| {
643            DagMlError::RuntimeValidation(format!(
644                "native Methods HPO target model `{}` is absent from the execution plan",
645                descriptor.target_node_id
646            ))
647        })?;
648    if target.kind != NodeKind::Model {
649        return Err(DagMlError::RuntimeValidation(format!(
650            "native Methods HPO target `{}` must be a model node",
651            descriptor.target_node_id
652        )));
653    }
654    let graph_node = plan
655        .graph_plan
656        .graph
657        .nodes
658        .iter()
659        .find(|node| node.id == descriptor.target_node_id)
660        .ok_or_else(|| {
661            DagMlError::RuntimeValidation(format!(
662                "native Methods HPO target `{}` is absent from the graph",
663                descriptor.target_node_id
664            ))
665        })?;
666    let portable_pls = graph_node
667        .operator
668        .as_ref()
669        .and_then(serde_json::Value::as_str)
670        .is_some_and(|operator| operator.eq_ignore_ascii_case("pls"));
671    if !portable_pls {
672        return Err(DagMlError::RuntimeValidation(format!(
673            "native Methods HPO v1 supports only a portable `pls` target; `{}` is not one",
674            descriptor.target_node_id
675        )));
676    }
677    // This is an intentionally narrow portable projection.  The Methods PLS
678    // controller and its execution-local tuner session can attest only the
679    // direct `n_components` model parameter today; accepting an arbitrary
680    // native search space here would create variants whose parameter effects
681    // cannot be proven in the normal scheduler task/lineage path.
682    let [crate::hpo::HpoParameter::Int {
683        name,
684        low,
685        high,
686        step,
687        log,
688    }] = descriptor.study.search_space.parameters.as_slice()
689    else {
690        return Err(DagMlError::RuntimeValidation(
691            "native Methods HPO v1 supports exactly one integer `n_components` search parameter"
692                .to_string(),
693        ));
694    };
695    if name != "n_components" || *low != 1 || *high != 3 || *step != 1 || *log {
696        return Err(DagMlError::RuntimeValidation(
697            "native Methods HPO v1 requires active `n_components` integer bounds 1..=3, step=1, log=false"
698                .to_string(),
699        ));
700    }
701    if descriptor.parameter_paths
702        != BTreeMap::from([("n_components".to_string(), "n_components".to_string())])
703    {
704        return Err(DagMlError::RuntimeValidation(
705            "native Methods HPO v1 requires parameter_paths {`n_components`: `n_components`}"
706                .to_string(),
707        ));
708    }
709    Ok(())
710}
711
712fn validate_methods_hpo_selection_alignment(
713    descriptor: &PortableMethodsHpoDescriptor,
714    selection: &SelectionPolicy,
715) -> Result<()> {
716    let expected_metric = match selection.metric.name.as_str() {
717        "rmse" => crate::hpo::HpoMetric::Rmse,
718        "mse" => crate::hpo::HpoMetric::Mse,
719        "mae" => crate::hpo::HpoMetric::Mae,
720        "r2" => crate::hpo::HpoMetric::R2,
721        "accuracy" => crate::hpo::HpoMetric::Accuracy,
722        "balanced_accuracy" => crate::hpo::HpoMetric::BalancedAccuracy,
723        other => {
724            return Err(DagMlError::RuntimeValidation(format!(
725                "native Methods HPO cannot align unsupported selection metric `{other}`"
726            )))
727        }
728    };
729    if descriptor.study.optimizer.metric != expected_metric {
730        return Err(DagMlError::RuntimeValidation(format!(
731            "native Methods HPO metric {:?} disagrees with selection metric `{}`",
732            descriptor.study.optimizer.metric, selection.metric.name
733        )));
734    }
735    let expected_direction = match selection.metric.objective {
736        crate::selection::MetricObjective::Minimize => crate::hpo::HpoDirection::Minimize,
737        crate::selection::MetricObjective::Maximize => crate::hpo::HpoDirection::Maximize,
738    };
739    if !matches!(
740        descriptor.study.optimizer.direction,
741        crate::hpo::HpoDirection::Auto
742    ) && descriptor.study.optimizer.direction != expected_direction
743    {
744        return Err(DagMlError::RuntimeValidation(format!(
745            "native Methods HPO direction {:?} disagrees with selection objective {:?}",
746            descriptor.study.optimizer.direction, selection.metric.objective
747        )));
748    }
749    Ok(())
750}
751
752#[derive(Clone, Debug)]
753enum NativeTrainingScheduler {
754    Sequential(SequentialScheduler),
755    Parallel(ParallelScheduler),
756}
757
758impl NativeTrainingScheduler {
759    fn from_request(request: &TrainingRequest) -> Result<Self> {
760        let options = &request.options.scheduler;
761        if options.backend == Some(TrainingSchedulerBackend::Processes) {
762            return Err(DagMlError::RuntimeValidation(
763                "native training does not yet implement the processes scheduler backend"
764                    .to_string(),
765            ));
766        }
767        match options.kind {
768            TrainingSchedulerKind::Sequential => Ok(Self::Sequential(SequentialScheduler)),
769            TrainingSchedulerKind::Parallel => Ok(Self::Parallel(ParallelScheduler::new(
770                usize::try_from(options.workers).map_err(|_| {
771                    DagMlError::RuntimeValidation(
772                        "training scheduler worker count does not fit usize".to_string(),
773                    )
774                })?,
775            )?)),
776        }
777    }
778
779    fn fit_cv(
780        &self,
781        plan: &ExecutionPlan,
782        controllers: &RuntimeControllerRegistry,
783        data_provider: &dyn RuntimeDataProvider,
784        ctx: &mut RunContext,
785    ) -> Result<Vec<NodeResult>> {
786        // Register target/group aggregation before executing the folds.  The runtime applies this
787        // only after the raw sample OOF rows have been reassembled, so an aggregate unit is never
788        // accidentally reduced one fold at a time.
789        ctx.configure_global_oof_aggregation(plan, data_provider)?;
790        match self {
791            Self::Sequential(scheduler) => scheduler.execute_campaign_phase_with_data_provider(
792                plan,
793                controllers,
794                data_provider,
795                ctx,
796                Phase::FitCv,
797            ),
798            Self::Parallel(scheduler) => scheduler.execute_campaign_phase_with_data_provider(
799                plan,
800                controllers,
801                data_provider,
802                ctx,
803                Phase::FitCv,
804            ),
805        }
806    }
807
808    fn refit(
809        &self,
810        plan: &ExecutionPlan,
811        controllers: &RuntimeControllerRegistry,
812        data_provider: &dyn RuntimeDataProvider,
813        artifact_store: &mut InMemoryArtifactStore,
814        ctx: &mut RunContext,
815    ) -> Result<Vec<NodeResult>> {
816        match self {
817            Self::Sequential(scheduler) => scheduler
818                .execute_campaign_phase_with_data_provider_and_artifact_store(
819                    plan,
820                    controllers,
821                    data_provider,
822                    artifact_store,
823                    ctx,
824                    Phase::Refit,
825                ),
826            Self::Parallel(scheduler) => scheduler
827                .execute_campaign_phase_with_data_provider_and_artifact_store(
828                    plan,
829                    controllers,
830                    data_provider,
831                    artifact_store,
832                    ctx,
833                    Phase::Refit,
834                ),
835        }
836    }
837}
838
839/// Target-bound input for the scheduler-owned full-refit half of Package V3.
840/// This is deliberately not a replay request: it accepts a new training
841/// cohort attestation and never touches a source execution bundle, source
842/// scores, source predictions, or source artifacts.
843pub struct PortableFullRefitExecutionInput<'a> {
844    pub recipe: &'a PortableRefitRecipe,
845    pub source_package: &'a PortablePredictorPackage,
846    pub target_plan: &'a ExecutionPlan,
847    pub target_training_request: &'a TrainingRequest,
848    pub target_training_request_fingerprint: String,
849    pub target_data_identities: &'a [TrainingDataIdentity],
850    pub target_training_influence: &'a TrainingInfluenceManifest,
851    pub run_id: RunId,
852    pub controllers: &'a RuntimeControllerRegistry,
853    pub data_provider: &'a dyn RuntimeDataProvider,
854}
855
856/// Refit-only child execution bundle for Package V3.
857///
858/// Unlike [`ExecutionBundle`], this contract cannot carry a prior selection,
859/// CV score, OOF cache, calibration state, or process-local handle.  Its only
860/// durable payloads are the artifacts produced by the newly requested REFIT.
861#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
862#[serde(deny_unknown_fields)]
863pub struct PortableRefitExecutionBundleV3 {
864    pub schema_version: u32,
865    pub bundle_id: BundleId,
866    pub effective_plan_fingerprint: String,
867    pub selected_variant_id: VariantId,
868    pub refit_artifacts: Vec<RefitArtifactRecord>,
869    pub raw_artifact_payloads: BTreeMap<ArtifactId, Vec<u8>>,
870    pub bundle_fingerprint: String,
871}
872
873impl PortableRefitExecutionBundleV3 {
874    pub fn compute_fingerprint(&self) -> Result<String> {
875        tcv1_fingerprint_without(
876            self,
877            "bundle_fingerprint",
878            "portable refit execution bundle V3",
879        )
880    }
881
882    pub fn validate(
883        &self,
884        recipe: &PortableRefitRecipe,
885        effective_plan: &ExecutionPlan,
886    ) -> Result<()> {
887        if self.schema_version != PORTABLE_REFIT_EXECUTION_BUNDLE_V3_SCHEMA_VERSION {
888            return contract_error(format!(
889                "portable refit execution bundle V3 has unsupported schema_version {}; expected {}",
890                self.schema_version, PORTABLE_REFIT_EXECUTION_BUNDLE_V3_SCHEMA_VERSION
891            ));
892        }
893        BundleId::new(self.bundle_id.as_str().to_string()).map_err(|error| {
894            DagMlError::RuntimeValidation(format!(
895                "portable refit execution bundle id is not portable: {error}"
896            ))
897        })?;
898        recipe.validate()?;
899        effective_plan.validate()?;
900        validate_sha256(
901            "portable refit execution bundle plan",
902            &self.effective_plan_fingerprint,
903        )?;
904        validate_sha256("portable refit execution bundle", &self.bundle_fingerprint)?;
905        if self.effective_plan_fingerprint
906            != tcv1_fingerprint(effective_plan, "portable refit execution bundle plan")?
907        {
908            return contract_error(
909                "portable refit execution bundle does not exactly bind its effective plan"
910                    .to_string(),
911            );
912        }
913        validate_portable_refit_target_plan(recipe, effective_plan)?;
914        if self.selected_variant_id != recipe.selected_variant_id {
915            return contract_error(
916                "portable refit execution bundle selected variant does not match recipe"
917                    .to_string(),
918            );
919        }
920        if self.refit_artifacts.is_empty()
921            || !self.refit_artifacts.windows(2).all(|pair| {
922                (pair[0].node_id.clone(), pair[0].artifact.id.clone())
923                    < (pair[1].node_id.clone(), pair[1].artifact.id.clone())
924            })
925        {
926            return contract_error(
927                "portable refit execution bundle artifacts must be non-empty and strictly sorted by node and artifact"
928                    .to_string(),
929            );
930        }
931        let expected_artifact_ids = self
932            .refit_artifacts
933            .iter()
934            .map(|record| record.artifact.id.clone())
935            .collect::<BTreeSet<_>>();
936        if expected_artifact_ids.len() != self.refit_artifacts.len()
937            || expected_artifact_ids.len() != self.raw_artifact_payloads.len()
938            || expected_artifact_ids != self.raw_artifact_payloads.keys().cloned().collect()
939        {
940            return contract_error(
941                "portable refit execution bundle raw artifacts do not exactly cover refit records"
942                    .to_string(),
943            );
944        }
945        for record in &self.refit_artifacts {
946            record.validate()?;
947            record.artifact.validate_portable()?;
948            let node = effective_plan
949                .node_plans
950                .get(&record.node_id)
951                .ok_or_else(|| {
952                    DagMlError::RuntimeValidation(format!(
953                        "portable refit artifact `{}` references node absent from effective plan",
954                        record.artifact.id
955                    ))
956                })?;
957            if node.controller_id != record.controller_id
958                || node.controller_id != record.artifact.controller_id
959            {
960                return contract_error(format!(
961                    "portable refit artifact `{}` controller does not match effective plan",
962                    record.artifact.id
963                ));
964            }
965            if !recipe.controllers.iter().any(|controller| {
966                controller.node_id == record.node_id
967                    && controller.controller_id == record.controller_id
968            }) {
969                return contract_error(format!(
970                    "portable refit artifact `{}` is not owned by a recipe controller",
971                    record.artifact.id
972                ));
973            }
974            let payload = self
975                .raw_artifact_payloads
976                .get(&record.artifact.id)
977                .ok_or_else(|| {
978                    DagMlError::RuntimeValidation(format!(
979                        "portable refit artifact `{}` has no detached raw payload",
980                        record.artifact.id
981                    ))
982                })?;
983            if payload.is_empty() {
984                return contract_error(format!(
985                    "portable refit artifact `{}` has an empty raw payload",
986                    record.artifact.id
987                ));
988            }
989            if record.artifact.size_bytes != Some(payload.len() as u64)
990                || record.artifact.content_fingerprint.as_deref()
991                    != Some(format!("{:x}", Sha256::digest(payload)).as_str())
992            {
993                return contract_error(format!(
994                    "portable refit artifact `{}` raw payload does not match its durable descriptor",
995                    record.artifact.id
996                ));
997            }
998        }
999        if self.bundle_fingerprint != self.compute_fingerprint()? {
1000            return contract_error(
1001                "portable refit execution bundle fingerprint does not match TCV1 content"
1002                    .to_string(),
1003            );
1004        }
1005        Ok(())
1006    }
1007
1008    /// Recreate the scheduler's internal bundle shape for a V3 PREDICT or
1009    /// EXPLAIN invocation.  This projection is deliberately process-local:
1010    /// it is derived anew from the V3 child and is never persisted as a V2
1011    /// `ExecutionBundle` or exposed through a V1/V2 package reader.
1012    pub fn to_runtime_replay_bundle(
1013        &self,
1014        recipe: &PortableRefitRecipe,
1015        effective_plan: &ExecutionPlan,
1016    ) -> Result<ExecutionBundle> {
1017        self.validate(recipe, effective_plan)?;
1018        let mut bundle = build_execution_bundle_with_prediction_contracts(
1019            self.bundle_id.clone(),
1020            effective_plan,
1021            Some(self.selected_variant_id.clone()),
1022            BTreeMap::new(),
1023            self.refit_artifacts.clone(),
1024            Vec::new(),
1025            Vec::new(),
1026        )?;
1027        bundle.raw_artifact_payloads = self.raw_artifact_payloads.clone();
1028        // Validate the concrete runtime projection against the V3 plan.  This
1029        // is an execution-only conversion; it cannot serialize the result.
1030        bundle.validate_against_plan(effective_plan)?;
1031        Ok(bundle)
1032    }
1033}
1034
1035/// A new V3 training outcome produced by a target-bound full refit.
1036///
1037/// It deliberately omits selection reports, OOF predictions, source lineage,
1038/// and conformal state.  Those prove the parent selection, not the new cohort.
1039#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1040#[serde(deny_unknown_fields)]
1041pub struct PortableRefitOutcomeV3 {
1042    pub schema_version: u32,
1043    pub outcome_id: String,
1044    pub run_id: RunId,
1045    pub recipe: PortableRefitRecipe,
1046    pub provenance: PortableRefitProvenance,
1047    pub target_training_request: TrainingRequest,
1048    pub effective_plan: ExecutionPlan,
1049    pub effective_plan_fingerprint: String,
1050    pub selected_variant_id: VariantId,
1051    pub selected_variant_fingerprint: String,
1052    pub output_bindings: Vec<OutputBinding>,
1053    pub predictor_node_ids: Vec<NodeId>,
1054    pub data_identities: Vec<TrainingDataIdentity>,
1055    pub training_influence: TrainingInfluenceManifest,
1056    pub execution_bundle: PortableRefitExecutionBundleV3,
1057    pub outcome_fingerprint: String,
1058}
1059
1060impl PortableRefitOutcomeV3 {
1061    pub fn compute_fingerprint(&self) -> Result<String> {
1062        tcv1_fingerprint_without(self, "outcome_fingerprint", "portable refit outcome V3")
1063    }
1064
1065    pub fn validate(&self) -> Result<()> {
1066        if self.schema_version != PORTABLE_REFIT_OUTCOME_V3_SCHEMA_VERSION {
1067            return contract_error(format!(
1068                "portable refit outcome V3 has unsupported schema_version {}; expected {}",
1069                self.schema_version, PORTABLE_REFIT_OUTCOME_V3_SCHEMA_VERSION
1070            ));
1071        }
1072        RunId::new(self.outcome_id.clone()).map_err(|error| {
1073            DagMlError::RuntimeValidation(format!(
1074                "portable refit outcome id is not portable: {error}"
1075            ))
1076        })?;
1077        self.recipe.validate()?;
1078        self.provenance.validate_against_recipe(&self.recipe)?;
1079        self.target_training_request.validate()?;
1080        if self.target_training_request.request_fingerprint
1081            != self.provenance.target_training_request_fingerprint
1082            || self.target_training_request.data_identities != self.data_identities
1083        {
1084            return contract_error(
1085                "portable refit outcome target training request does not exactly match target provenance"
1086                    .to_string(),
1087            );
1088        }
1089        self.effective_plan.validate()?;
1090        validate_sha256(
1091            "portable refit outcome effective plan",
1092            &self.effective_plan_fingerprint,
1093        )?;
1094        validate_sha256("portable refit outcome", &self.outcome_fingerprint)?;
1095        if self.effective_plan_fingerprint
1096            != tcv1_fingerprint(
1097                &self.effective_plan,
1098                "portable refit outcome effective plan",
1099            )?
1100        {
1101            return contract_error(
1102                "portable refit outcome effective plan fingerprint does not match its content"
1103                    .to_string(),
1104            );
1105        }
1106        validate_portable_refit_target_plan(&self.recipe, &self.effective_plan)?;
1107        let selected = self
1108            .effective_plan
1109            .variants
1110            .iter()
1111            .find(|variant| variant.variant_id == self.selected_variant_id)
1112            .ok_or_else(|| {
1113                DagMlError::RuntimeValidation(
1114                    "portable refit outcome selected variant is absent from effective plan"
1115                        .to_string(),
1116                )
1117            })?;
1118        if self.selected_variant_id != self.recipe.selected_variant_id
1119            || self.selected_variant_fingerprint != selected.fingerprint
1120            || self.selected_variant_fingerprint != self.recipe.selected_variant_fingerprint
1121        {
1122            return contract_error(
1123                "portable refit outcome selected variant does not exactly match its recipe"
1124                    .to_string(),
1125            );
1126        }
1127        if self.output_bindings.is_empty() || self.predictor_node_ids.is_empty() {
1128            return contract_error(
1129                "portable refit outcome requires output bindings and predictor nodes".to_string(),
1130            );
1131        }
1132        let mut binding_fingerprints = Vec::with_capacity(self.output_bindings.len());
1133        for binding in &self.output_bindings {
1134            binding.validate(&self.effective_plan.graph_plan.graph)?;
1135            binding_fingerprints.push(binding.binding_fingerprint.clone());
1136        }
1137        binding_fingerprints.sort();
1138        if binding_fingerprints != self.recipe.target_binding_fingerprints {
1139            return contract_error(
1140                "portable refit outcome output bindings do not exactly match recipe targets"
1141                    .to_string(),
1142            );
1143        }
1144        if self
1145            .predictor_node_ids
1146            .windows(2)
1147            .any(|pair| pair[0] >= pair[1])
1148            || self
1149                .predictor_node_ids
1150                .iter()
1151                .any(|node_id| !self.effective_plan.node_plans.contains_key(node_id))
1152        {
1153            return contract_error(
1154                "portable refit outcome predictor nodes must be strictly sorted plan nodes"
1155                    .to_string(),
1156            );
1157        }
1158        if self.data_identities.is_empty()
1159            || !self
1160                .data_identities
1161                .windows(2)
1162                .all(|pair| pair[0].requirement_key < pair[1].requirement_key)
1163        {
1164            return contract_error(
1165                "portable refit outcome data identities must be non-empty and strictly sorted"
1166                    .to_string(),
1167            );
1168        }
1169        for identity in &self.data_identities {
1170            identity.validate()?;
1171        }
1172        self.training_influence.validate()?;
1173        if tcv1_fingerprint(
1174            &self.data_identities,
1175            "portable refit outcome data identities",
1176        )? != self.provenance.target_data_identities_fingerprint
1177            || self.training_influence.manifest_fingerprint
1178                != self.provenance.target_training_influence_fingerprint
1179            || self.training_influence.relation_fingerprint
1180                != self.provenance.target_relation_fingerprint
1181            || self.data_identities.iter().any(|identity| {
1182                identity.relation_fingerprint != self.provenance.target_relation_fingerprint
1183            })
1184        {
1185            return contract_error(
1186                "portable refit outcome target cohort does not exactly match its provenance"
1187                    .to_string(),
1188            );
1189        }
1190        self.execution_bundle
1191            .validate(&self.recipe, &self.effective_plan)?;
1192        if self.execution_bundle.selected_variant_id != self.selected_variant_id {
1193            return contract_error(
1194                "portable refit outcome bundle selected variant does not match outcome".to_string(),
1195            );
1196        }
1197        if self.outcome_fingerprint != self.compute_fingerprint()? {
1198            return contract_error(
1199                "portable refit outcome fingerprint does not match TCV1 content".to_string(),
1200            );
1201        }
1202        Ok(())
1203    }
1204
1205    /// Derive the scheduler-only replay bundle from this validated V3 child.
1206    /// The returned object is an internal execution projection and must not be
1207    /// serialized as an `ExecutionBundle` package artifact.
1208    pub fn to_runtime_replay_bundle(&self) -> Result<ExecutionBundle> {
1209        self.validate()?;
1210        self.execution_bundle
1211            .to_runtime_replay_bundle(&self.recipe, &self.effective_plan)
1212    }
1213}
1214
1215/// Closed Package V3 child created by a successful full refit.
1216#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1217#[serde(deny_unknown_fields)]
1218pub struct PortableRefitPackageV3 {
1219    pub schema_version: u32,
1220    pub package_id: String,
1221    pub outcome: PortableRefitOutcomeV3,
1222    pub package_fingerprint: String,
1223}
1224
1225impl PortableRefitPackageV3 {
1226    pub fn compute_fingerprint(&self) -> Result<String> {
1227        tcv1_fingerprint_without(self, "package_fingerprint", "portable refit package V3")
1228    }
1229
1230    pub fn from_json(json: &str) -> Result<Self> {
1231        let raw_fingerprint = parse_typed_json(json)
1232            .map_err(|error| {
1233                DagMlError::RuntimeValidation(format!(
1234                    "portable refit package V3 is not strict TCV1 JSON: {error}"
1235                ))
1236            })?
1237            .fingerprint_without("package_fingerprint")
1238            .map_err(|error| {
1239                DagMlError::RuntimeValidation(format!(
1240                    "portable refit package V3 fingerprint preimage is invalid: {error}"
1241                ))
1242            })?;
1243        let package: Self = serde_json::from_str(json)?;
1244        if package.package_fingerprint != raw_fingerprint {
1245            return contract_error(
1246                "portable refit package V3 fingerprint does not match original TCV1 JSON"
1247                    .to_string(),
1248            );
1249        }
1250        package.validate()?;
1251        Ok(package)
1252    }
1253
1254    pub fn validate(&self) -> Result<()> {
1255        if self.schema_version != PORTABLE_REFIT_PACKAGE_V3_SCHEMA_VERSION {
1256            return contract_error(format!(
1257                "portable refit package V3 has unsupported schema_version {}; expected {}",
1258                self.schema_version, PORTABLE_REFIT_PACKAGE_V3_SCHEMA_VERSION
1259            ));
1260        }
1261        RunId::new(self.package_id.clone()).map_err(|error| {
1262            DagMlError::RuntimeValidation(format!(
1263                "portable refit package id is not portable: {error}"
1264            ))
1265        })?;
1266        validate_sha256("portable refit package", &self.package_fingerprint)?;
1267        self.outcome.validate()?;
1268        if self.package_fingerprint != self.compute_fingerprint()? {
1269            return contract_error(
1270                "portable refit package V3 fingerprint does not match TCV1 content".to_string(),
1271            );
1272        }
1273        Ok(())
1274    }
1275}
1276
1277/// Input for the DAG-ML-owned V3 child writer.  The caller supplies only
1278/// fresh execution evidence and identifiers; source-package content is used
1279/// exclusively to revalidate the closed parent recipe and output projection.
1280pub struct PortableRefitPackageV3BuildInput<'a> {
1281    pub package_id: String,
1282    pub outcome_id: String,
1283    pub bundle_id: BundleId,
1284    pub recipe: &'a PortableRefitRecipe,
1285    pub source_package: &'a PortablePredictorPackage,
1286    pub target_plan: &'a ExecutionPlan,
1287    pub target_training_request: &'a TrainingRequest,
1288    pub target_data_identities: &'a [TrainingDataIdentity],
1289    pub target_training_influence: &'a TrainingInfluenceManifest,
1290    pub execution: &'a PortableFullRefitExecution,
1291}
1292
1293/// Atomically construct the outcome, execution bundle and package child of a
1294/// successful full refit.  No V2 score, selection, prediction cache,
1295/// conformal state or process-local handle is copied into this V3 family.
1296pub fn build_portable_refit_package_v3(
1297    input: PortableRefitPackageV3BuildInput<'_>,
1298) -> Result<PortableRefitPackageV3> {
1299    input
1300        .recipe
1301        .validate_against_source_package(input.source_package)?;
1302    if input.source_package.schema_version != PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION {
1303        return contract_error(
1304            "portable refit package V3 requires an exact Package V2 parent".to_string(),
1305        );
1306    }
1307    input
1308        .execution
1309        .provenance
1310        .validate_against_recipe(input.recipe)?;
1311    input.target_training_request.validate()?;
1312    if input.target_training_request.request_fingerprint
1313        != input
1314            .execution
1315            .provenance
1316            .target_training_request_fingerprint
1317        || input.target_training_request.data_identities != input.target_data_identities
1318    {
1319        return contract_error(
1320            "portable refit package V3 target request does not exactly bind target cohort evidence"
1321                .to_string(),
1322        );
1323    }
1324    if input
1325        .execution
1326        .provenance
1327        .target_training_request_fingerprint
1328        == input.recipe.parent_outcome.training_request_fingerprint
1329    {
1330        return contract_error(
1331            "portable refit package V3 execution reuses its parent training request".to_string(),
1332        );
1333    }
1334    let expected_target_plan = derive_portable_full_refit_target_plan(
1335        input.recipe,
1336        input.source_package,
1337        input.target_training_request,
1338    )?;
1339    if input.target_plan != &expected_target_plan
1340        || input.execution.effective_plan != expected_target_plan
1341    {
1342        return contract_error(
1343            "portable refit package V3 plan is not the deterministic parent-plus-cohort derivation"
1344                .to_string(),
1345        );
1346    }
1347    // A V3 child is an already-trained REFIT artifact package.  Validation
1348    // OOF inputs were necessary to fit a stacking meta-estimator, but they
1349    // are neither a PREDICT dependency nor part of V3's deliberately
1350    // cache-free contract.  Persisting their V2 cache keys would make the
1351    // derived runtime bundle demand source-CV evidence that V3 correctly
1352    // does not carry.
1353    let mut refit_artifacts = input.execution.refit_artifacts.clone();
1354    for record in &mut refit_artifacts {
1355        record.prediction_requirement_keys.clear();
1356    }
1357    let mut bundle = PortableRefitExecutionBundleV3 {
1358        schema_version: PORTABLE_REFIT_EXECUTION_BUNDLE_V3_SCHEMA_VERSION,
1359        bundle_id: input.bundle_id,
1360        effective_plan_fingerprint: tcv1_fingerprint(
1361            input.target_plan,
1362            "portable refit package V3 effective plan",
1363        )?,
1364        selected_variant_id: input.recipe.selected_variant_id.clone(),
1365        refit_artifacts,
1366        raw_artifact_payloads: input.execution.raw_artifact_payloads.clone(),
1367        bundle_fingerprint: zero_fingerprint(),
1368    };
1369    bundle.bundle_fingerprint = bundle.compute_fingerprint()?;
1370    let mut outcome = PortableRefitOutcomeV3 {
1371        schema_version: PORTABLE_REFIT_OUTCOME_V3_SCHEMA_VERSION,
1372        outcome_id: input.outcome_id,
1373        run_id: input.execution.run_id.clone(),
1374        recipe: input.recipe.clone(),
1375        provenance: input.execution.provenance.clone(),
1376        target_training_request: input.target_training_request.clone(),
1377        effective_plan: input.target_plan.clone(),
1378        effective_plan_fingerprint: bundle.effective_plan_fingerprint.clone(),
1379        selected_variant_id: input.recipe.selected_variant_id.clone(),
1380        selected_variant_fingerprint: input.recipe.selected_variant_fingerprint.clone(),
1381        output_bindings: input.source_package.output_bindings.clone(),
1382        predictor_node_ids: input.source_package.predictor_node_ids.clone(),
1383        data_identities: input.target_data_identities.to_vec(),
1384        training_influence: input.target_training_influence.clone(),
1385        execution_bundle: bundle,
1386        outcome_fingerprint: zero_fingerprint(),
1387    };
1388    outcome.outcome_fingerprint = outcome.compute_fingerprint()?;
1389    let mut package = PortableRefitPackageV3 {
1390        schema_version: PORTABLE_REFIT_PACKAGE_V3_SCHEMA_VERSION,
1391        package_id: input.package_id,
1392        outcome,
1393        package_fingerprint: zero_fingerprint(),
1394    };
1395    package.package_fingerprint = package.compute_fingerprint()?;
1396    package.validate()?;
1397    Ok(package)
1398}
1399
1400/// Native artifacts and execution evidence produced by the first, scheduler
1401/// only step of a V3 full refit.  The V3 outcome/package writer consumes this
1402/// result to create a new durable child; it must not mutate the parent.
1403#[derive(Clone, Debug)]
1404pub struct PortableFullRefitExecution {
1405    pub run_id: RunId,
1406    pub provenance: PortableRefitProvenance,
1407    /// Exact target-cohort plan derived from the parent recipe.  The child
1408    /// writer cross-checks this value so execution evidence cannot be paired
1409    /// with a different plan after REFIT completed.
1410    pub effective_plan: ExecutionPlan,
1411    pub results: Vec<NodeResult>,
1412    pub refit_artifacts: Vec<crate::bundle::RefitArtifactRecord>,
1413    /// Raw bytes are detached from their process-local controller immediately
1414    /// after the refit phase.  A future Package/Archive V3 writer consumes
1415    /// this map atomically with `refit_artifacts`; it must never ask a source
1416    /// controller to re-export an artifact after the execution has ended.
1417    pub raw_artifact_payloads: BTreeMap<ArtifactId, Vec<u8>>,
1418}
1419
1420/// Derive the only execution plan a V3 full REFIT may use for a new cohort.
1421///
1422/// The parent Package V2 remains the authority for graph topology, selected
1423/// parameters, variants and controller policy.  A freshly signed target
1424/// request is authoritative only for the cohort-bound data bindings and fold
1425/// universe.  This is deliberately a derivation rather than a permissive
1426/// comparison: callers cannot choose a plan that happens to look compatible.
1427pub fn derive_portable_full_refit_target_plan(
1428    recipe: &PortableRefitRecipe,
1429    source_package: &PortablePredictorPackage,
1430    target_training_request: &TrainingRequest,
1431) -> Result<ExecutionPlan> {
1432    recipe.validate_against_source_package(source_package)?;
1433    if !target_training_request.parameter_patches.is_empty()
1434        || !target_training_request.patch_policies.is_empty()
1435    {
1436        return contract_error(
1437            "portable full refit target request must not carry parameter patches".to_string(),
1438        );
1439    }
1440    let target_projection = target_training_request.project()?;
1441    let target_plan = target_projection.plan;
1442    let source_plan = &source_package.effective_plan;
1443
1444    if source_plan.graph_plan.graph != target_plan.graph_plan.graph
1445        || source_plan.controller_manifests != target_plan.controller_manifests
1446    {
1447        return contract_error(
1448            "portable full refit target request does not match the parent graph/controller topology"
1449                .to_string(),
1450        );
1451    }
1452    validate_portable_refit_node_shape(source_plan, &target_plan)?;
1453    validate_portable_refit_binding_shape(source_plan, &target_plan)?;
1454
1455    let mut derived = source_plan.clone();
1456    derived.campaign.data_bindings = target_plan.campaign.data_bindings.clone();
1457    derived.fold_set = target_plan.fold_set.clone();
1458    match (
1459        derived.campaign.split_invocation.as_mut(),
1460        target_plan.campaign.split_invocation.as_ref(),
1461    ) {
1462        (Some(source_split), Some(target_split)) => {
1463            source_split.fold_set = target_split.fold_set.clone();
1464        }
1465        (None, None) => {}
1466        _ => {
1467            return contract_error(
1468                "portable full refit target request changes whether the parent has a fold universe"
1469                    .to_string(),
1470            );
1471        }
1472    }
1473    for (node_id, node) in &mut derived.node_plans {
1474        node.data_bindings = target_plan
1475            .node_plans
1476            .get(node_id)
1477            .ok_or_else(|| {
1478                DagMlError::RuntimeValidation(format!(
1479                    "portable full refit target plan is missing parent node `{node_id}`"
1480                ))
1481            })?
1482            .data_bindings
1483            .clone();
1484    }
1485    derived.campaign_fingerprint = stable_json_fingerprint(&derived.campaign)?;
1486    derived.validate()?;
1487    validate_portable_refit_target_plan(recipe, &derived)?;
1488    Ok(derived)
1489}
1490
1491/// Validate a persisted V3 target plan against the parent recipe without
1492/// reusing the parent's cohort-specific plan fingerprint.
1493pub fn validate_portable_refit_target_plan(
1494    recipe: &PortableRefitRecipe,
1495    plan: &ExecutionPlan,
1496) -> Result<()> {
1497    recipe.validate()?;
1498    plan.validate()?;
1499    let selected_variant = plan
1500        .variants
1501        .iter()
1502        .find(|variant| variant.variant_id == recipe.selected_variant_id)
1503        .ok_or_else(|| {
1504            DagMlError::RuntimeValidation(
1505                "portable full refit selected variant is absent from target plan".to_string(),
1506            )
1507        })?;
1508    let selected_parameters = plan
1509        .node_plans
1510        .iter()
1511        .map(|(node_id, node)| (node_id.clone(), node.params.clone()))
1512        .collect::<BTreeMap<_, _>>();
1513    if selected_variant.fingerprint != recipe.selected_variant_fingerprint
1514        || tcv1_fingerprint(
1515            &(selected_variant.fingerprint.clone(), selected_parameters),
1516            "portable refit selected parameter projection",
1517        )? != recipe.selected_parameter_projection_fingerprint
1518    {
1519        return contract_error(
1520            "portable full refit target selected parameters do not match the parent recipe"
1521                .to_string(),
1522        );
1523    }
1524    for controller in &recipe.controllers {
1525        let node = plan.node_plans.get(&controller.node_id).ok_or_else(|| {
1526            DagMlError::RuntimeValidation(format!(
1527                "portable full refit recipe controller node `{}` is absent from target plan",
1528                controller.node_id
1529            ))
1530        })?;
1531        let manifest = plan
1532            .controller_manifests
1533            .get(&node.controller_id)
1534            .ok_or_else(|| {
1535                DagMlError::RuntimeValidation(format!(
1536                    "portable full refit target node `{}` has no controller manifest",
1537                    node.node_id
1538                ))
1539            })?;
1540        if node.controller_id != controller.controller_id
1541            || node.controller_version != controller.controller_version
1542            || node.controller_capabilities != controller.capabilities
1543            || tcv1_fingerprint(manifest, "portable refit controller manifest")?
1544                != controller.manifest_fingerprint
1545        {
1546            return contract_error(format!(
1547                "portable full refit target controller `{}` does not match the parent recipe",
1548                controller.node_id
1549            ));
1550        }
1551    }
1552    Ok(())
1553}
1554
1555fn validate_portable_refit_node_shape(
1556    source: &ExecutionPlan,
1557    target: &ExecutionPlan,
1558) -> Result<()> {
1559    if source.node_plans.keys().collect::<BTreeSet<_>>()
1560        != target.node_plans.keys().collect::<BTreeSet<_>>()
1561    {
1562        return contract_error(
1563            "portable full refit target request node set differs from the parent".to_string(),
1564        );
1565    }
1566    for node_id in source.node_plans.keys() {
1567        let mut parent = source.node_plans[node_id].clone();
1568        let mut candidate = target.node_plans[node_id].clone();
1569        // Parent parameter values are the selected model.  The new cohort's
1570        // request is not allowed to override them, so they are intentionally
1571        // excluded from the target-request shape comparison.
1572        parent.params.clear();
1573        candidate.params.clear();
1574        parent.params_fingerprint = stable_json_fingerprint(&parent.params)?;
1575        candidate.params_fingerprint = stable_json_fingerprint(&candidate.params)?;
1576        parent.data_bindings.clear();
1577        candidate.data_bindings.clear();
1578        if parent != candidate {
1579            return contract_error(format!(
1580                "portable full refit target node `{node_id}` changes parent execution shape"
1581            ));
1582        }
1583    }
1584    Ok(())
1585}
1586
1587fn validate_portable_refit_binding_shape(
1588    source: &ExecutionPlan,
1589    target: &ExecutionPlan,
1590) -> Result<()> {
1591    let source_bindings = portable_refit_binding_map(source)?;
1592    let target_bindings = portable_refit_binding_map(target)?;
1593    if source_bindings.keys().collect::<BTreeSet<_>>()
1594        != target_bindings.keys().collect::<BTreeSet<_>>()
1595    {
1596        return contract_error(
1597            "portable full refit target request data-binding coordinates differ from the parent"
1598                .to_string(),
1599        );
1600    }
1601    for (key, parent) in source_bindings {
1602        let candidate = &target_bindings[&key];
1603        let mut parent = parent.clone();
1604        let mut candidate = candidate.clone();
1605        // Cohort identity is deliberately re-attested by the target request,
1606        // data identities and influence manifest.  All actual execution/data
1607        // view semantics remain exact below.
1608        parent.request_id = "portable_refit:target_cohort".to_string();
1609        candidate.request_id = parent.request_id.clone();
1610        parent.schema_fingerprint = "0".repeat(64);
1611        candidate.schema_fingerprint = parent.schema_fingerprint.clone();
1612        parent.relation_fingerprint = parent.relation_fingerprint.as_ref().map(|_| "0".repeat(64));
1613        candidate.relation_fingerprint = candidate
1614            .relation_fingerprint
1615            .as_ref()
1616            .map(|_| "0".repeat(64));
1617        if parent != candidate {
1618            return contract_error(format!(
1619                "portable full refit target binding `{key}` changes parent data-view semantics"
1620            ));
1621        }
1622    }
1623    Ok(())
1624}
1625
1626fn portable_refit_binding_map(
1627    plan: &ExecutionPlan,
1628) -> Result<BTreeMap<String, crate::data::DataBinding>> {
1629    let mut bindings = BTreeMap::new();
1630    for binding in plan.campaign.data_bindings.values().flatten() {
1631        let key = data_binding_requirement_key(&binding.node_id, &binding.input_name);
1632        if bindings.insert(key.clone(), binding.clone()).is_some() {
1633            return contract_error(format!(
1634                "portable full refit plan has duplicate data-binding key `{key}`"
1635            ));
1636        }
1637    }
1638    Ok(bindings)
1639}
1640
1641/// Execute exactly one portable native full refit from a closed recipe.
1642///
1643/// The function has no V2 replay input. A declared nested-stacking target
1644/// first rebuilds its own selected-variant inner/outer OOF evidence, then runs
1645/// the ordinary `REFIT` phase. This is target-cohort execution only: it never
1646/// resumes source CV, selection, scores, caches, or artifacts. Non-stacking
1647/// plans retain the direct REFIT path. All source/recipe/cohort checks occur
1648/// before the data provider is queried. It intentionally returns execution
1649/// evidence rather than synthesising a TrainingOutcome: V3 persistence must
1650/// add the new outcome/bundle/package atomically in its owning writer.
1651pub fn execute_portable_full_refit(
1652    input: PortableFullRefitExecutionInput<'_>,
1653) -> Result<PortableFullRefitExecution> {
1654    input
1655        .recipe
1656        .validate_against_source_package(input.source_package)?;
1657    input.target_training_request.validate()?;
1658    if input.target_training_request.request_fingerprint
1659        != input.target_training_request_fingerprint
1660        || input.target_training_request.data_identities != input.target_data_identities
1661    {
1662        return contract_error(
1663            "portable full refit target request does not exactly bind provided cohort evidence"
1664                .to_string(),
1665        );
1666    }
1667    let provenance = PortableRefitProvenance::from_target_cohort(
1668        input.recipe,
1669        input.target_training_request_fingerprint,
1670        input.target_data_identities,
1671        input.target_training_influence,
1672    )?;
1673    let derived_target_plan = derive_portable_full_refit_target_plan(
1674        input.recipe,
1675        input.source_package,
1676        input.target_training_request,
1677    )?;
1678    if input.target_plan != &derived_target_plan {
1679        return contract_error(
1680            "portable full refit target plan is not the deterministic parent-plus-cohort derivation"
1681                .to_string(),
1682        );
1683    }
1684    let selected_variant = input
1685        .target_plan
1686        .variants
1687        .iter()
1688        .find(|variant| variant.variant_id == input.recipe.selected_variant_id)
1689        .ok_or_else(|| {
1690            DagMlError::RuntimeValidation(
1691                "portable full refit selected variant is absent from target plan".to_string(),
1692            )
1693        })?;
1694    if selected_variant.fingerprint != input.recipe.selected_variant_fingerprint {
1695        return contract_error(
1696            "portable full refit target selected variant does not match parent recipe".to_string(),
1697        );
1698    }
1699    let mut ctx = RunContext::new(input.run_id.clone(), derived_target_plan.campaign.root_seed);
1700    ctx.variant_id = Some(input.recipe.selected_variant_id.clone());
1701    if let Some(nested) = nested_stacking_campaign_plan(&derived_target_plan)? {
1702        // Ridge's full refit trains only from target-cohort OOF base features.
1703        // Recreate those scheduler-owned nested scopes for the already selected
1704        // variant; this is not a CV/SELECT replay of the parent package.
1705        SequentialScheduler.execute_campaign_phase_with_data_provider(
1706            &derived_target_plan,
1707            input.controllers,
1708            input.data_provider,
1709            &mut ctx,
1710            Phase::FitCv,
1711        )?;
1712        // The following REFIT consumes only report-grade outer OOF rows. Check
1713        // their presence at the phase boundary so a nested target cannot fall
1714        // through to a less actionable generic OOF failure while rebuilding a
1715        // stack on a fresh cohort.
1716        let expected_outer_folds = nested
1717            .outer_scopes
1718            .iter()
1719            .map(|scope| scope.outer_fold_id.clone())
1720            .collect::<BTreeSet<_>>();
1721        for edge in derived_target_plan
1722            .graph_plan
1723            .graph
1724            .edges
1725            .iter()
1726            .filter(|edge| edge.target.node_id == nested.meta_node_id && edge.contract.requires_oof)
1727        {
1728            let observed_outer_folds = ctx
1729                .prediction_store
1730                .find(
1731                    Some(&edge.source.node_id),
1732                    Some(&PredictionPartition::Validation),
1733                    None,
1734                )
1735                .into_iter()
1736                .filter(|block| {
1737                    block
1738                        .fold_id
1739                        .as_ref()
1740                        .is_some_and(|fold_id| expected_outer_folds.contains(fold_id))
1741                })
1742                .map(|block| block.fold_id.clone().expect("filtered above"))
1743                .collect::<BTreeSet<_>>();
1744            if observed_outer_folds != expected_outer_folds {
1745                return Err(DagMlError::OofValidation(format!(
1746                    "portable full refit nested FIT_CV did not retain exact outer OOF folds for `{}.{}` -> `{}.{}`; expected {:?}, observed {:?}",
1747                    edge.source.node_id,
1748                    edge.source.port_name,
1749                    edge.target.node_id,
1750                    edge.target.port_name,
1751                    expected_outer_folds,
1752                    observed_outer_folds,
1753                )));
1754            }
1755        }
1756    }
1757    let mut artifact_store = InMemoryArtifactStore::new();
1758    let results = SequentialScheduler
1759        .execute_campaign_phase_with_data_provider_and_artifact_store(
1760            &derived_target_plan,
1761            input.controllers,
1762            input.data_provider,
1763            &mut artifact_store,
1764            &mut ctx,
1765            Phase::Refit,
1766        )?;
1767    let refit_artifacts = artifact_store.refit_artifacts();
1768    if refit_artifacts.is_empty() {
1769        return contract_error(
1770            "portable full refit produced no durable native artifact".to_string(),
1771        );
1772    }
1773    let mut raw_artifact_payloads = BTreeMap::new();
1774    for record in &refit_artifacts {
1775        record.validate()?;
1776        let controller = input
1777            .controllers
1778            .get(&record.controller_id)
1779            .ok_or_else(|| {
1780                DagMlError::RuntimeValidation(format!(
1781                "portable full refit artifact `{}` has no registered controller `{}` for export",
1782                record.artifact.id, record.controller_id
1783            ))
1784            })?;
1785        let payload = controller
1786            .export_artifact_payload(&record.artifact.id)?
1787            .ok_or_else(|| {
1788                DagMlError::RuntimeValidation(format!(
1789                    "portable full refit controller `{}` did not export durable payload `{}`",
1790                    record.controller_id, record.artifact.id
1791                ))
1792            })?;
1793        if raw_artifact_payloads
1794            .insert(record.artifact.id.clone(), payload)
1795            .is_some()
1796        {
1797            return contract_error(
1798                "portable full refit produced duplicate durable artifact identifiers".to_string(),
1799            );
1800        }
1801    }
1802    Ok(PortableFullRefitExecution {
1803        run_id: input.run_id,
1804        provenance,
1805        effective_plan: derived_target_plan,
1806        results,
1807        refit_artifacts,
1808        raw_artifact_payloads,
1809    })
1810}
1811
1812/// Execute COMPILE/PLAN -> FIT_CV -> SELECT -> optional REFIT and return the
1813/// complete portable W0 outcome.
1814///
1815/// Variant candidates are evaluated by the existing native selection helper;
1816/// the winner is then rerun once in a retained context so its lineage, OOF
1817/// caches, bound outputs, and optional refit artifacts all originate from one
1818/// auditable execution. `SELECT` is called exactly once and `REFIT` at most once.
1819pub fn execute_training(input: TrainingExecutionInput<'_>) -> Result<TrainingOutcome> {
1820    if !input.artifact_store.is_empty() {
1821        return Err(DagMlError::RuntimeValidation(
1822            "native training requires an empty artifact store for an isolated outcome".to_string(),
1823        ));
1824    }
1825    RunId::new(input.outcome_id.clone()).map_err(|error| {
1826        DagMlError::RuntimeValidation(format!(
1827            "native training outcome_id is not a portable identifier: {error}"
1828        ))
1829    })?;
1830    validate_sorted_unique_text("training execution warnings", &input.warnings)?;
1831    if contains_runtime_handle(&serde_json::Value::Object(
1832        input.diagnostics.clone().into_iter().collect(),
1833    )) {
1834        return Err(DagMlError::RuntimeValidation(
1835            "native training diagnostics cannot contain runtime handles".to_string(),
1836        ));
1837    }
1838
1839    let mut projection = input.request.project()?;
1840    projection.plan = materialize_request_parameter_patches(projection.plan, input.request)?;
1841    projection.validate()?;
1842    validate_native_training_options(input.request)?;
1843    input.training_influence.validate_for_projection(
1844        &projection,
1845        input.request,
1846        input.relations,
1847    )?;
1848    let runtime_training_influence = TrainingInfluenceManifest::derive_for_projection(
1849        &projection,
1850        input.request,
1851        input.relations,
1852    )?;
1853    if input.training_influence != &runtime_training_influence {
1854        return Err(DagMlError::RuntimeValidation(
1855            "native training influence manifest does not match runtime-derived evidence"
1856                .to_string(),
1857        ));
1858    }
1859    // Native HPO is preflighted before provider attestation/materialization so
1860    // unsupported model or tuning descriptors cannot incur any data cost.
1861    let native_hpo_descriptor = HpoExecutionContext {
1862        request: input.request,
1863        projection: &projection,
1864        controllers: input.controllers,
1865        data_provider: input.data_provider,
1866        relations: input.relations,
1867        training_influence: &runtime_training_influence,
1868        selection: &input.request.options.selection,
1869    }
1870    .preflight()?;
1871    validate_provider_attestations(
1872        &projection,
1873        input.request,
1874        input.data_provider,
1875        input.relations,
1876    )?;
1877    for node_plan in projection.plan.node_plans.values() {
1878        if input.controllers.get(&node_plan.controller_id).is_none() {
1879            return Err(DagMlError::RuntimeValidation(format!(
1880                "native training controller `{}` for node `{}` is not registered",
1881                node_plan.controller_id, node_plan.node_id
1882            )));
1883        }
1884    }
1885    let executable_nodes = projection
1886        .plan
1887        .node_plans
1888        .values()
1889        .filter(|node| !node.supported_phases.is_empty())
1890        .map(|node| node.node_id.clone())
1891        .collect::<BTreeSet<_>>();
1892    if projection.predictor_node_ids != executable_nodes {
1893        return Err(DagMlError::RuntimeValidation(
1894            "native training currently requires the predictor closure to equal the executable plan; refusing to persist unrelated nodes"
1895                .to_string(),
1896        ));
1897    }
1898    if projection.plan.variants.iter().any(|variant| {
1899        variant
1900            .choices
1901            .values()
1902            .any(|choice| !choice.param_overrides.is_empty())
1903    }) && !input
1904        .training_influence
1905        .entries
1906        .iter()
1907        .any(|entry| entry.kind == TrainingInfluenceKind::HpoSelection)
1908    {
1909        return Err(DagMlError::RuntimeValidation(
1910            "selectable parameter overrides require predeclared hpo_selection influence"
1911                .to_string(),
1912        ));
1913    }
1914    let scheduler = NativeTrainingScheduler::from_request(input.request)?;
1915    let selection_metric = parse_selection_metric(input.request)?;
1916    let metric_level = effective_selection_metric_level(input.request)?;
1917    let selection_output = projection
1918        .outputs
1919        .iter()
1920        .find(|output| output.output_id == input.request.options.selection_output_id)
1921        .ok_or_else(|| {
1922            DagMlError::RuntimeValidation(
1923                "training selection output was not resolved by projection".to_string(),
1924            )
1925        })?;
1926    let selection_output_id = selection_output.output_id.clone();
1927    let selection_producer = selection_output.node_id.clone();
1928    let selection_producer_port = selection_output.port_name.clone();
1929    validate_selection_prediction_kind(selection_metric, selection_output.prediction_kind)?;
1930    #[cfg(feature = "methods-optimizer")]
1931    let mut methods_hpo_resume_state = None;
1932    #[cfg(feature = "methods-optimizer")]
1933    #[cfg(feature = "methods-optimizer")]
1934    let selection = if let Some(descriptor) = native_hpo_descriptor.as_ref() {
1935        let hpo_execution = HpoExecutionContext {
1936            request: input.request,
1937            projection: &projection,
1938            controllers: input.controllers,
1939            data_provider: input.data_provider,
1940            relations: input.relations,
1941            training_influence: &runtime_training_influence,
1942            selection: &input.request.options.selection,
1943        };
1944        let (context, previous_resume_state) = hpo_execution.runtime_context(
1945            descriptor,
1946            selection_metric,
1947            &selection_producer,
1948            &selection_producer_port,
1949        )?;
1950        let campaign_context =
1951            RunContext::new(input.run_id.clone(), Some(input.request.options.seed));
1952        let campaign = SequentialScheduler.execute_hpo_campaign(
1953            &projection.plan,
1954            input.controllers,
1955            input.data_provider,
1956            &campaign_context,
1957            &context,
1958        )?;
1959        let (plan, selection, resume_state) =
1960            hpo_execution.selection_from_campaign(&context, previous_resume_state, campaign)?;
1961        projection.plan = plan;
1962        methods_hpo_resume_state = Some(resume_state);
1963        selection
1964    } else {
1965        select_best_variant_outcome_by_cv_for_target(
1966            &projection.plan,
1967            &input.run_id,
1968            Some(input.request.options.seed),
1969            selection_metric,
1970            &selection_producer,
1971            Some(selection_producer_port.as_str()),
1972            metric_level,
1973            |candidate_plan, candidate_ctx| {
1974                scheduler
1975                    .fit_cv(candidate_plan, input.controllers, input.data_provider, candidate_ctx)
1976                    .map(|_| ())
1977            },
1978        )?
1979        .ok_or_else(|| DagMlError::RuntimeValidation(
1980            "native training SELECT received no scored candidate; controllers must emit targets".to_string(),
1981        ))?
1982    };
1983    #[cfg(not(feature = "methods-optimizer"))]
1984    let selection = {
1985        let _ = native_hpo_descriptor;
1986        select_best_variant_outcome_by_cv_for_target(
1987            &projection.plan,
1988            &input.run_id,
1989            Some(input.request.options.seed),
1990            selection_metric,
1991            &selection_producer,
1992            Some(selection_producer_port.as_str()),
1993            metric_level,
1994            |candidate_plan, candidate_ctx| {
1995                scheduler
1996                    .fit_cv(candidate_plan, input.controllers, input.data_provider, candidate_ctx)
1997                    .map(|_| ())
1998            },
1999        )?
2000        .ok_or_else(|| DagMlError::RuntimeValidation(
2001            "native training SELECT received no scored candidate; controllers must emit targets".to_string(),
2002        ))?
2003    };
2004
2005    validate_selection_report_levels(
2006        &selection.selection.validation_reports,
2007        &selection_producer,
2008        &Some(selection_producer_port.clone()),
2009        metric_level,
2010    )?;
2011    let mut decision = selection.decision;
2012    bind_selection_decision(&mut decision, input.request, metric_level)?;
2013    let selected_variant_id = selection.selection.selected_variant_id;
2014    let effective_plan = materialize_selected_variant(projection.plan, &selected_variant_id)?;
2015    // Keep the original union variants for replay/identity while pinning every
2016    // retained execution through RunContext.variant_id.
2017    let selected_variant = effective_plan
2018        .variants
2019        .iter()
2020        .find(|variant| variant.variant_id == selected_variant_id)
2021        .cloned()
2022        .ok_or_else(|| {
2023            DagMlError::RuntimeValidation(
2024                "selected variant disappeared while materializing the plan".to_string(),
2025            )
2026        })?;
2027    effective_plan.validate()?;
2028
2029    let mut selected_ctx = RunContext::new(input.run_id.clone(), Some(input.request.options.seed));
2030    selected_ctx.variant_id = Some(selected_variant_id.clone());
2031    let fit_cv_results = scheduler.fit_cv(
2032        &effective_plan,
2033        input.controllers,
2034        input.data_provider,
2035        &mut selected_ctx,
2036    )?;
2037    selected_ctx.collect_cross_fold_validation_scores(plan_oof_partition_mode(&effective_plan))?;
2038    validate_selected_rerun_reports(
2039        &selection.selection.validation_reports,
2040        &selected_ctx.score_collector,
2041        &selected_variant_id,
2042    )?;
2043
2044    let score_set = ScoreSet {
2045        schema_version: SCORE_SET_SCHEMA_VERSION,
2046        plan_id: effective_plan.id.clone(),
2047        selection_metric: Some(selection_metric.name().to_string()),
2048        reports: selection.selection.validation_reports,
2049    };
2050    score_set.validate()?;
2051
2052    let prediction_requirements = build_oof_prediction_requirements(
2053        &effective_plan,
2054        selected_ctx.prediction_store.blocks(),
2055        selected_ctx.aggregated_prediction_store.blocks(),
2056    )?;
2057    let retain_caches =
2058        input.request.options.artifacts.prediction_caches == PredictionCacheRetention::Retain;
2059    let (prediction_caches, portable_prediction_caches) = if retain_caches {
2060        let mut records = build_oof_prediction_cache_records(
2061            &prediction_requirements,
2062            selected_ctx.prediction_store.blocks(),
2063            selected_ctx.aggregated_prediction_store.blocks(),
2064        )?;
2065        let mut payloads = build_oof_prediction_cache_payloads(
2066            &prediction_requirements,
2067            selected_ctx.prediction_store.blocks(),
2068            selected_ctx.aggregated_prediction_store.blocks(),
2069        )?;
2070        attach_oof_prediction_cache_namespaces(
2071            &effective_plan,
2072            &input.request.data_identities,
2073            &selected_variant_id,
2074            input.request.options.seed,
2075            &prediction_requirements,
2076            &mut records,
2077            &mut payloads,
2078        )?;
2079        (
2080            records,
2081            Some(BundlePredictionCachePayloadSet {
2082                bundle_id: input.bundle_id.clone(),
2083                schema_version: PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
2084                caches: payloads,
2085            }),
2086        )
2087    } else {
2088        (Vec::new(), None)
2089    };
2090
2091    let mut staged_artifact_store = InMemoryArtifactStore::new();
2092    let refit_results = if input.request.options.refit {
2093        scheduler.refit(
2094            &effective_plan,
2095            input.controllers,
2096            input.data_provider,
2097            &mut staged_artifact_store,
2098            &mut selected_ctx,
2099        )?
2100    } else {
2101        Vec::new()
2102    };
2103
2104    let mut execution_bundle = build_execution_bundle_with_prediction_contracts(
2105        input.bundle_id.clone(),
2106        &effective_plan,
2107        Some(selected_variant_id.clone()),
2108        BTreeMap::from([(input.request.options.selection.id.clone(), decision)]),
2109        staged_artifact_store.refit_artifacts(),
2110        prediction_requirements,
2111        prediction_caches,
2112    )?;
2113    #[cfg(feature = "methods-optimizer")]
2114    {
2115        execution_bundle.methods_hpo_resume_state = methods_hpo_resume_state.clone();
2116        for record in &execution_bundle.refit_artifacts {
2117            if record.artifact.kind != "n4m_model" {
2118                continue;
2119            }
2120            let controller = input
2121                .controllers
2122                .get(&record.controller_id)
2123                .ok_or_else(|| {
2124                    DagMlError::RuntimeValidation(format!(
2125                        "missing controller `{}` for N4MM export",
2126                        record.controller_id
2127                    ))
2128                })?;
2129            let bytes = controller
2130                .export_artifact_payload(&record.artifact.id)?
2131                .ok_or_else(|| {
2132                    DagMlError::RuntimeValidation(format!(
2133                        "Methods controller did not export durable N4MM payload `{}`",
2134                        record.artifact.id
2135                    ))
2136                })?;
2137            execution_bundle
2138                .raw_artifact_payloads
2139                .insert(record.artifact.id.clone(), bytes);
2140        }
2141    }
2142    execution_bundle.scores = Some(score_set.clone());
2143    execution_bundle.validate_against_plan(&effective_plan)?;
2144    if let Some(caches) = &portable_prediction_caches {
2145        caches.validate_against_bundle(&execution_bundle)?;
2146    }
2147
2148    let outputs = bind_training_outputs(
2149        &projection.outputs,
2150        input.request,
2151        &effective_plan,
2152        &fit_cv_results,
2153        &refit_results,
2154        &selected_ctx,
2155    )?;
2156    let mut lineage = selected_ctx
2157        .lineage
2158        .records()
2159        .filter(|record| projection.predictor_node_ids.contains(&record.node_id))
2160        .cloned()
2161        .collect::<Vec<_>>();
2162    for record in &mut lineage {
2163        record.input_lineage.sort();
2164        record
2165            .artifact_refs
2166            .sort_by(|left, right| left.id.cmp(&right.id));
2167    }
2168    lineage.sort_by(|left, right| left.record_id.cmp(&right.record_id));
2169
2170    let effective_plan_fingerprint =
2171        tcv1_fingerprint(&effective_plan, "training outcome effective plan")?;
2172    let parameter_patches =
2173        merge_training_parameter_patches(&input.request.parameter_patches, &selected_variant)?;
2174    // Derive the honest replayable phases from the *full effective predictor
2175    // closure* and the artifacts/caches actually retained by this run, never
2176    // from the refit flag alone. `derive_replayable_phases` is the single shared
2177    // helper that standalone validation re-runs, so construction cannot advertise
2178    // a capability the closure and retained state do not support.
2179    let predictor_closure_nodes = predictor_closure(
2180        &effective_plan,
2181        outputs.iter().map(|output| output.binding.node_id.clone()),
2182    )?;
2183    let refit_outcome = TrainingRefitOutcome {
2184        requested: input.request.options.refit,
2185        status: if input.request.options.refit {
2186            TrainingRefitStatus::Completed
2187        } else {
2188            TrainingRefitStatus::Skipped
2189        },
2190        strategy: input.request.options.refit_strategy,
2191    };
2192    let replayable_phases = derive_replayable_phases(
2193        &effective_plan,
2194        &predictor_closure_nodes,
2195        &refit_outcome,
2196        &execution_bundle,
2197        portable_prediction_caches.as_ref(),
2198    )?;
2199    let mut outcome = TrainingOutcome {
2200        schema_version: TRAINING_OUTCOME_SCHEMA_VERSION,
2201        outcome_id: input.outcome_id,
2202        run_id: input.run_id,
2203        training_request_fingerprint: projection.request_fingerprint,
2204        data_identities: input.request.data_identities.clone(),
2205        selection_output_id,
2206        effective_plan,
2207        effective_plan_fingerprint,
2208        selected_variant_id,
2209        selected_variant_fingerprint: selected_variant.fingerprint,
2210        parameter_patches,
2211        refit: refit_outcome,
2212        score_set,
2213        outputs,
2214        lineage,
2215        portable_prediction_caches,
2216        training_influence: runtime_training_influence,
2217        execution_bundle,
2218        conformal_calibration: None,
2219        conformal_calibration_replay: None,
2220        #[cfg(feature = "methods-optimizer")]
2221        methods_hpo_resume_state,
2222        #[cfg(not(feature = "methods-optimizer"))]
2223        methods_hpo_resume_state: None,
2224        replayable_phases,
2225        warnings: input.warnings,
2226        diagnostics: input.diagnostics,
2227        outcome_fingerprint: zero_fingerprint(),
2228    };
2229    outcome = stabilize_training_outcome_for_tcv1(outcome)?;
2230    outcome.validate()?;
2231    *input.artifact_store = staged_artifact_store;
2232    Ok(outcome)
2233}
2234
2235fn stabilize_training_outcome_for_tcv1(mut outcome: TrainingOutcome) -> Result<TrainingOutcome> {
2236    // TCV1 signs the lexical JSON number token, whereas serde first parses a
2237    // metric into binary64 and may subsequently select a different shortest
2238    // spelling for that same value. Sign only the fixed point that a strict
2239    // reader will itself obtain after deserialize/serialize; otherwise a newly
2240    // produced package can fail its own `TrainingOutcome::from_json` boundary.
2241    outcome.outcome_fingerprint = zero_fingerprint();
2242    for _ in 0..8 {
2243        let json = serde_json::to_string(&outcome)?;
2244        let before = parse_typed_json(&json).map_err(|error| {
2245            DagMlError::CampaignValidation(format!(
2246                "training outcome is not strict TCV1 JSON while normalizing: {error}"
2247            ))
2248        })?;
2249        let mut normalized = serde_json::from_str::<TrainingOutcome>(&json)?;
2250        normalized.outcome_fingerprint = zero_fingerprint();
2251        if let Some(calibration) = normalized.conformal_calibration.as_mut() {
2252            // A nested conformal record has its own TCV1 self-fingerprint.
2253            // Outcome normalization can canonicalize its binary64 lexical
2254            // representation, so re-sign it before emitting the enclosing
2255            // outcome and refresh the matching bundle reference atomically.
2256            calibration.calibration_fingerprint = calibration.compute_fingerprint()?;
2257            normalized.execution_bundle.conformal_calibration = Some(calibration.reference()?);
2258        }
2259        let normalized_json = serde_json::to_string(&normalized)?;
2260        let after = parse_typed_json(&normalized_json).map_err(|error| {
2261            DagMlError::CampaignValidation(format!(
2262                "training outcome is not strict TCV1 JSON after normalization: {error}"
2263            ))
2264        })?;
2265        if before != after {
2266            outcome = normalized;
2267            continue;
2268        }
2269
2270        normalized.outcome_fingerprint =
2271            after
2272                .fingerprint_without("outcome_fingerprint")
2273                .map_err(|error| {
2274                    DagMlError::CampaignValidation(format!(
2275                        "training outcome TCV1 fingerprint failed after normalization: {error}"
2276                    ))
2277                })?;
2278        let signed_json = serde_json::to_string(&normalized)?;
2279        let signed = TrainingOutcome::from_json(&signed_json)?;
2280        return Ok(signed);
2281    }
2282    Err(DagMlError::CampaignValidation(
2283        "training outcome TCV1 JSON did not reach a serde canonical fixed point".to_string(),
2284    ))
2285}
2286
2287fn zero_fingerprint() -> String {
2288    "0".repeat(64)
2289}
2290
2291fn validate_native_training_options(request: &TrainingRequest) -> Result<()> {
2292    let resources = &request.options.resources;
2293    if resources.cpu_threads != request.options.scheduler.workers
2294        || resources.memory_bytes.is_some()
2295        || !resources.gpu_devices.is_empty()
2296        || resources.wall_time_ms.is_some()
2297    {
2298        return Err(DagMlError::RuntimeValidation(
2299            "native training V1 supports only cpu_threads=scheduler.workers with memory_bytes=null, gpu_devices=[], and wall_time_ms=null"
2300                .to_string(),
2301        ));
2302    }
2303    if request.options.artifacts.cv_artifacts != CvArtifactRetention::Discard {
2304        return Err(DagMlError::RuntimeValidation(
2305            "native training V1 supports only artifacts.cv_artifacts=discard".to_string(),
2306        ));
2307    }
2308    if !matches!(
2309        request.options.artifacts.fitted_artifacts,
2310        FittedArtifactMode::AllowHostSidecar | FittedArtifactMode::PortableRequired
2311    ) {
2312        return Err(DagMlError::RuntimeValidation(
2313            "native training V1 requires artifacts.fitted_artifacts=allow_host_sidecar or portable_required"
2314                .to_string(),
2315        ));
2316    }
2317    if request.options.artifacts.prediction_caches == PredictionCacheRetention::Discard
2318        && request
2319            .graph
2320            .edges
2321            .iter()
2322            .any(|edge| edge.contract.requires_oof)
2323    {
2324        return Err(DagMlError::RuntimeValidation(
2325            "native training V1 requires retained prediction caches for a stacking/requires_oof graph"
2326                .to_string(),
2327        ));
2328    }
2329    Ok(())
2330}
2331
2332fn materialize_request_parameter_patches(
2333    mut plan: ExecutionPlan,
2334    request: &TrainingRequest,
2335) -> Result<ExecutionPlan> {
2336    for patch in &request.parameter_patches {
2337        match patch.namespace {
2338            ParameterNamespace::Operator => {}
2339            ParameterNamespace::Structural => {
2340                return Err(DagMlError::RuntimeValidation(
2341                    "native training requires recompilation for structural parameter patches; D6 runtime accepts only operator value patches"
2342                        .to_string(),
2343                ));
2344            }
2345            ParameterNamespace::Fit | ParameterNamespace::Control => {
2346                return Err(DagMlError::RuntimeValidation(format!(
2347                    "native training does not expose {:?} parameter patches to controllers yet; refusing to ignore them",
2348                    patch.namespace
2349                )));
2350            }
2351        }
2352        let node_plan = plan.node_plans.get_mut(&patch.node_id).ok_or_else(|| {
2353            DagMlError::RuntimeValidation(format!(
2354                "parameter patch references absent node `{}`",
2355                patch.node_id
2356            ))
2357        })?;
2358        deep_set_plan_param(
2359            &mut node_plan.params,
2360            &patch.path,
2361            patch.value.clone(),
2362            &patch.node_id,
2363        )?;
2364        node_plan.params_fingerprint = stable_json_fingerprint(&node_plan.params)?;
2365    }
2366    plan.validate()?;
2367    Ok(plan)
2368}
2369
2370fn deep_set_plan_param(
2371    root: &mut BTreeMap<String, serde_json::Value>,
2372    path: &[String],
2373    value: serde_json::Value,
2374    node_id: &NodeId,
2375) -> Result<()> {
2376    if path.is_empty() {
2377        return contract_error("parameter patch path cannot be empty");
2378    }
2379    if path.len() == 1 {
2380        root.insert(path[0].clone(), value);
2381        return Ok(());
2382    }
2383    let first = root.get_mut(&path[0]).ok_or_else(|| {
2384        DagMlError::RuntimeValidation(format!(
2385            "parameter patch for `{node_id}` is missing intermediate path `{}`",
2386            path[0]
2387        ))
2388    })?;
2389    let mut cursor = first;
2390    for segment in &path[1..path.len() - 1] {
2391        let object = cursor.as_object_mut().ok_or_else(|| {
2392            DagMlError::RuntimeValidation(format!(
2393                "parameter patch for `{node_id}` crosses a scalar or array at `{segment}`"
2394            ))
2395        })?;
2396        cursor = object.get_mut(segment).ok_or_else(|| {
2397            DagMlError::RuntimeValidation(format!(
2398                "parameter patch for `{node_id}` is missing intermediate path `{segment}`"
2399            ))
2400        })?;
2401    }
2402    let object = cursor.as_object_mut().ok_or_else(|| {
2403        DagMlError::RuntimeValidation(format!(
2404            "parameter patch for `{node_id}` crosses a scalar or array before final key"
2405        ))
2406    })?;
2407    object.insert(path[path.len() - 1].clone(), value);
2408    Ok(())
2409}
2410
2411fn validate_provider_attestations(
2412    projection: &TrainingContractProjection,
2413    request: &TrainingRequest,
2414    provider: &dyn RuntimeDataProvider,
2415    relations: &crate::relation::SampleRelationSet,
2416) -> Result<()> {
2417    relations.validate()?;
2418    let relation_fingerprint = relations.fingerprint()?;
2419    let identities = request
2420        .data_identities
2421        .iter()
2422        .map(|identity| (identity.requirement_key.as_str(), identity))
2423        .collect::<BTreeMap<_, _>>();
2424    for node_plan in projection.plan.node_plans.values() {
2425        for binding in &node_plan.data_bindings {
2426            let key = data_binding_requirement_key(&binding.node_id, &binding.input_name);
2427            let expected = identities.get(key.as_str()).ok_or_else(|| {
2428                DagMlError::RuntimeValidation(format!(
2429                    "native training request has no data identity for `{key}`"
2430                ))
2431            })?;
2432            let actual = provider.training_data_identity(binding)?.ok_or_else(|| {
2433                DagMlError::RuntimeValidation(format!(
2434                    "runtime data provider did not attest feature/target content for `{key}`"
2435                ))
2436            })?;
2437            actual.validate()?;
2438            if &actual != *expected {
2439                return Err(DagMlError::RuntimeValidation(format!(
2440                    "runtime data provider identity for `{key}` does not match signed training request"
2441                )));
2442            }
2443            let provider_relations = provider.coordinator_relations(binding)?;
2444            if binding.require_relations && provider_relations.is_none() {
2445                return Err(DagMlError::RuntimeValidation(format!(
2446                    "runtime data provider omitted required relations for `{key}`"
2447                )));
2448            }
2449            if let Some(provider_relations) = provider_relations {
2450                provider_relations.validate()?;
2451                if provider_relations.fingerprint()? != relation_fingerprint
2452                    || actual.relation_fingerprint != relation_fingerprint
2453                {
2454                    return Err(DagMlError::RuntimeValidation(format!(
2455                        "runtime data provider relations for `{key}` differ from training influence relations"
2456                    )));
2457                }
2458            }
2459        }
2460    }
2461    Ok(())
2462}
2463
2464fn parse_selection_metric(request: &TrainingRequest) -> Result<RegressionMetricKind> {
2465    let metric = regression_metric_by_name(&request.options.selection.metric.name)?;
2466    if request.options.selection.metric.objective != metric.objective() {
2467        return Err(DagMlError::RuntimeValidation(format!(
2468            "selection metric `{}` has objective {:?}, expected {:?}",
2469            metric.name(),
2470            request.options.selection.metric.objective,
2471            metric.objective()
2472        )));
2473    }
2474    Ok(metric)
2475}
2476
2477fn regression_metric_by_name(name: &str) -> Result<RegressionMetricKind> {
2478    RegressionMetricKind::from_name(name).ok_or_else(|| {
2479        DagMlError::RuntimeValidation(format!(
2480            "native training does not support selection metric `{name}`"
2481        ))
2482    })
2483}
2484
2485fn validate_selection_prediction_kind(
2486    metric: RegressionMetricKind,
2487    prediction_kind: PredictionKind,
2488) -> Result<()> {
2489    RegressionMetricKind::resolve_for_prediction_kind(
2490        metric.name(),
2491        metric.objective(),
2492        prediction_kind,
2493    )
2494    .map(|_| ())
2495}
2496
2497fn effective_selection_metric_level(request: &TrainingRequest) -> Result<PredictionLevel> {
2498    let campaign_level = request.campaign.aggregation_policy.selection_metric_level;
2499    if request
2500        .options
2501        .selection
2502        .required_metric_level
2503        .is_some_and(|level| level != campaign_level)
2504    {
2505        return Err(DagMlError::RuntimeValidation(
2506            "selection required_metric_level differs from campaign selection_metric_level"
2507                .to_string(),
2508        ));
2509    }
2510    if request.options.selection.evaluation_scope != Some(EvaluationScope::Oof) {
2511        return Err(DagMlError::RuntimeValidation(
2512            "native training V1 requires selection.evaluation_scope=oof".to_string(),
2513        ));
2514    }
2515    if request.options.selection.reduction_id.is_some() {
2516        return Err(DagMlError::RuntimeValidation(
2517            "native training V1 does not execute selection reduction_id".to_string(),
2518        ));
2519    }
2520    if request.options.selection.stacking_fit_contract.is_some() {
2521        return Err(DagMlError::RuntimeValidation(
2522            "native training V1 does not execute selection stacking_fit_contract".to_string(),
2523        ));
2524    }
2525    if !request.options.selection.require_finite {
2526        return Err(DagMlError::RuntimeValidation(
2527            "native training V1 requires selection.require_finite=true".to_string(),
2528        ));
2529    }
2530    if request.options.refit_strategy == Some(RefitStrategy::RefitEnsemble) {
2531        return Err(DagMlError::RuntimeValidation(
2532            "native training V1 does not implement refit_ensemble".to_string(),
2533        ));
2534    }
2535    match (
2536        request.options.refit,
2537        request.options.selection.refit_slot_plan.as_ref(),
2538    ) {
2539        (false, Some(_)) => Err(DagMlError::RuntimeValidation(
2540            "no-refit native training forbids selection.refit_slot_plan".to_string(),
2541        )),
2542        (true, Some(slot))
2543            if slot.strategy != RefitStrategy::RefitOne
2544                || slot.member_count != 1
2545                || slot.selection_level != campaign_level
2546                || slot.selection_metric != request.options.selection.metric
2547                || slot.reduction_id.is_some() =>
2548        {
2549            Err(DagMlError::RuntimeValidation(
2550                "selection.refit_slot_plan is not the exact native refit_one slot".to_string(),
2551            ))
2552        }
2553        _ => Ok(campaign_level),
2554    }
2555}
2556
2557fn validate_selected_rerun_reports(
2558    retained: &[crate::metrics::RegressionMetricReport],
2559    rerun: &[crate::metrics::RegressionMetricReport],
2560    selected_variant_id: &VariantId,
2561) -> Result<()> {
2562    let mut retained = retained
2563        .iter()
2564        .filter(|report| report.variant_id.as_ref() == Some(selected_variant_id))
2565        .cloned()
2566        .collect::<Vec<_>>();
2567    let mut rerun = rerun
2568        .iter()
2569        .filter(|report| report.partition == PredictionPartition::Validation)
2570        .cloned()
2571        .map(|mut report| {
2572            report.variant_id = Some(selected_variant_id.clone());
2573            report.variant_label = None;
2574            report
2575        })
2576        .collect::<Vec<_>>();
2577    // A durable Methods HPO resume state records the one sample-level OOF
2578    // average that terminalized each native trial, rather than inventing a
2579    // free per-fold score transcript.  In that explicit contract, compare the
2580    // selected rerun against precisely those terminal report identities.  The
2581    // ordinary path retains every validation report and therefore continues to
2582    // require exact full-report coverage below.
2583    let terminal_oof_only = retained.iter().all(|report| {
2584        report.partition == PredictionPartition::Validation
2585            && report
2586                .fold_id
2587                .as_ref()
2588                .is_some_and(|fold| fold.as_str() == "avg")
2589            && report.level == PredictionLevel::Sample
2590    });
2591    if terminal_oof_only {
2592        rerun.retain(|actual| {
2593            retained.iter().any(|expected| {
2594                expected.producer_node == actual.producer_node
2595                    && expected.producer_port == actual.producer_port
2596                    && expected.fold_id == actual.fold_id
2597                    && expected.prediction_id == actual.prediction_id
2598                    && expected.level == actual.level
2599            })
2600        });
2601    }
2602    let sort = |reports: &mut Vec<crate::metrics::RegressionMetricReport>| {
2603        reports.sort_by(|left, right| {
2604            (
2605                &left.producer_node,
2606                &left.producer_port,
2607                &left.fold_id,
2608                &left.prediction_id,
2609                &left.level,
2610            )
2611                .cmp(&(
2612                    &right.producer_node,
2613                    &right.producer_port,
2614                    &right.fold_id,
2615                    &right.prediction_id,
2616                    &right.level,
2617                ))
2618        });
2619    };
2620    sort(&mut retained);
2621    sort(&mut rerun);
2622    if retained.is_empty()
2623        || retained.len() != rerun.len()
2624        || retained
2625            .iter()
2626            .zip(&rerun)
2627            .any(|(left, right)| !reports_match_rerun_tolerance(left, right))
2628    {
2629        return Err(DagMlError::RuntimeValidation(
2630            "selected variant FIT_CV rerun diverged from the reports that justified SELECT"
2631                .to_string(),
2632        ));
2633    }
2634    Ok(())
2635}
2636
2637/// Native numerical libraries may differ by one rounding unit across a fresh
2638/// process/context.  Preserve report identity exactly, while comparing the
2639/// numeric evidence with the same tight tolerance used for portable replay.
2640fn reports_match_rerun_tolerance(
2641    left: &crate::metrics::RegressionMetricReport,
2642    right: &crate::metrics::RegressionMetricReport,
2643) -> bool {
2644    left.prediction_id == right.prediction_id
2645        && left.producer_node == right.producer_node
2646        && left.producer_port == right.producer_port
2647        && left.variant_id == right.variant_id
2648        && left.variant_label == right.variant_label
2649        && left.partition == right.partition
2650        && left.fold_id == right.fold_id
2651        && left.level == right.level
2652        && left.row_count == right.row_count
2653        && left.target_width == right.target_width
2654        && left.target_names == right.target_names
2655        && left.metrics.len() == right.metrics.len()
2656        && left.metrics.iter().all(|(name, value)| {
2657            right
2658                .metrics
2659                .get(name)
2660                .is_some_and(|other| (value - other).abs() <= 1.0e-12)
2661        })
2662}
2663
2664fn validate_selection_report_levels(
2665    reports: &[crate::metrics::RegressionMetricReport],
2666    producer: &NodeId,
2667    producer_port: &Option<String>,
2668    expected: PredictionLevel,
2669) -> Result<()> {
2670    let target_reports = reports
2671        .iter()
2672        .filter(|report| {
2673            &report.producer_node == producer
2674                && &report.producer_port == producer_port
2675                && report.level == expected
2676        })
2677        .collect::<Vec<_>>();
2678    if target_reports.is_empty() {
2679        return Err(DagMlError::RuntimeValidation(format!(
2680            "native SELECT target `{producer}` port {producer_port:?} has no reports at required metric level {expected:?}"
2681        )));
2682    }
2683    Ok(())
2684}
2685
2686fn bind_selection_decision(
2687    decision: &mut SelectionDecision,
2688    request: &TrainingRequest,
2689    metric_level: PredictionLevel,
2690) -> Result<()> {
2691    decision.policy_id = request.options.selection.id.clone();
2692    decision.metric_level = Some(metric_level);
2693    decision.evaluation_scope = Some(EvaluationScope::Oof);
2694    decision.refit_slot_plan = request.options.selection.refit_slot_plan.clone();
2695    decision.reduction_id = None;
2696    decision.validate()
2697}
2698
2699fn materialize_selected_variant(
2700    mut plan: ExecutionPlan,
2701    selected_variant_id: &VariantId,
2702) -> Result<ExecutionPlan> {
2703    let selected = plan
2704        .variants
2705        .iter()
2706        .find(|variant| &variant.variant_id == selected_variant_id)
2707        .cloned()
2708        .ok_or_else(|| {
2709            DagMlError::RuntimeValidation(format!(
2710                "selected variant `{selected_variant_id}` is absent from plan"
2711            ))
2712        })?;
2713    let variant = VariantExecutionSpec::from_plan(&selected);
2714    variant.validate()?;
2715    for (node_id, node_plan) in &mut plan.node_plans {
2716        node_plan.params = variant.effective_params_for_node(node_id, &node_plan.params)?;
2717        node_plan.params_fingerprint = stable_json_fingerprint(&node_plan.params)?;
2718    }
2719    plan.validate()?;
2720    Ok(plan)
2721}
2722
2723fn is_cv_ensemble_partition(partition: &PredictionPartition) -> bool {
2724    match partition {
2725        PredictionPartition::Validation => true,
2726        PredictionPartition::Train | PredictionPartition::Test | PredictionPartition::Final => {
2727            false
2728        }
2729    }
2730}
2731
2732fn producer_port_matches_graph_output(
2733    plan: &ExecutionPlan,
2734    node_id: &NodeId,
2735    port_name: &str,
2736    producer_port: &Option<String>,
2737) -> bool {
2738    if let Some(producer_port) = producer_port {
2739        return producer_port == port_name;
2740    }
2741    let Some(node) = plan
2742        .graph_plan
2743        .graph
2744        .nodes
2745        .iter()
2746        .find(|node| &node.id == node_id)
2747    else {
2748        return false;
2749    };
2750    let prediction_ports = node
2751        .ports
2752        .outputs
2753        .iter()
2754        .filter(|port| port.kind == PortKind::Prediction)
2755        .collect::<Vec<_>>();
2756    prediction_ports.len() == 1 && prediction_ports[0].name == port_name
2757}
2758
2759fn bind_training_outputs(
2760    outputs: &[ResolvedTrainingOutput],
2761    request: &TrainingRequest,
2762    plan: &ExecutionPlan,
2763    fit_cv_results: &[NodeResult],
2764    refit_results: &[NodeResult],
2765    ctx: &RunContext,
2766) -> Result<Vec<BoundTrainingOutput>> {
2767    let source = if request.options.refit {
2768        refit_results
2769    } else {
2770        fit_cv_results
2771    };
2772    let aggregation_fingerprint = tcv1_fingerprint(
2773        &plan.campaign.aggregation_policy,
2774        "training output aggregation policy",
2775    )?;
2776    let mut bound = Vec::with_capacity(outputs.len());
2777    for output in outputs {
2778        let mut binding = OutputBinding {
2779            schema_version: OUTPUT_BINDING_SCHEMA_VERSION,
2780            binding_id: output.output_id.clone(),
2781            node_id: output.node_id.clone(),
2782            port_name: output.port_name.clone(),
2783            prediction_level: output.prediction_level,
2784            unit_level: output.unit_level,
2785            prediction_kind: output.prediction_kind,
2786            prediction_source: if request.options.refit {
2787                PredictionSource::FinalRefit
2788            } else {
2789                PredictionSource::CvEnsemble
2790            },
2791            refit_strategy: request.options.refit_strategy,
2792            aggregation_fingerprint: aggregation_fingerprint.clone(),
2793            target_names: output.target_names.clone(),
2794            target_units: output.target_units.clone(),
2795            class_labels: output.class_labels.clone(),
2796            output_order: output.output_order,
2797            target_space: output.target_space.clone(),
2798            binding_fingerprint: zero_fingerprint(),
2799        };
2800        binding.binding_fingerprint = binding.compute_fingerprint()?;
2801
2802        let node_results = source
2803            .iter()
2804            .filter(|result| result.node_id == output.node_id)
2805            .collect::<Vec<_>>();
2806        let mut predictions = Vec::new();
2807        let mut observation_predictions = Vec::new();
2808        let mut aggregated_predictions = Vec::new();
2809        match output.prediction_level {
2810            PredictionLevel::Observation => {
2811                for result in node_results {
2812                    observation_predictions.extend(
2813                        result
2814                            .observation_predictions
2815                            .iter()
2816                            .filter(|block| {
2817                                producer_port_matches_graph_output(
2818                                    plan,
2819                                    &output.node_id,
2820                                    &output.port_name,
2821                                    &block.producer_port,
2822                                ) && (request.options.refit
2823                                    || is_cv_ensemble_partition(&block.partition))
2824                            })
2825                            .cloned(),
2826                    );
2827                }
2828            }
2829            PredictionLevel::Sample => {
2830                for result in node_results {
2831                    predictions.extend(
2832                        result
2833                            .predictions
2834                            .iter()
2835                            .filter(|block| {
2836                                producer_port_matches_graph_output(
2837                                    plan,
2838                                    &output.node_id,
2839                                    &output.port_name,
2840                                    &block.producer_port,
2841                                ) && (request.options.refit
2842                                    || is_cv_ensemble_partition(&block.partition))
2843                            })
2844                            .cloned(),
2845                    );
2846                    aggregated_predictions.extend(
2847                        result
2848                            .aggregated_predictions
2849                            .iter()
2850                            .filter(|block| {
2851                                producer_port_matches_graph_output(
2852                                    plan,
2853                                    &output.node_id,
2854                                    &output.port_name,
2855                                    &block.producer_port,
2856                                ) && block.level == PredictionLevel::Sample
2857                                    && (request.options.refit
2858                                        || is_cv_ensemble_partition(&block.partition))
2859                            })
2860                            .cloned(),
2861                    );
2862                }
2863                if !request.options.refit {
2864                    aggregated_predictions.extend(
2865                        ctx.oof_average_blocks
2866                            .iter()
2867                            .filter(|average| {
2868                                average.predictions.producer_node == output.node_id
2869                                    && producer_port_matches_graph_output(
2870                                        plan,
2871                                        &output.node_id,
2872                                        &output.port_name,
2873                                        &average.predictions.producer_port,
2874                                    )
2875                                    && is_cv_ensemble_partition(&average.predictions.partition)
2876                            })
2877                            .map(|average| average.predictions.clone()),
2878                    );
2879                }
2880            }
2881            PredictionLevel::Target | PredictionLevel::Group => {
2882                for result in node_results {
2883                    aggregated_predictions.extend(
2884                        result
2885                            .aggregated_predictions
2886                            .iter()
2887                            .filter(|block| {
2888                                producer_port_matches_graph_output(
2889                                    plan,
2890                                    &output.node_id,
2891                                    &output.port_name,
2892                                    &block.producer_port,
2893                                ) && block.level == output.prediction_level
2894                                    && (request.options.refit
2895                                        || is_cv_ensemble_partition(&block.partition))
2896                            })
2897                            .cloned(),
2898                    );
2899                }
2900            }
2901        }
2902        predictions.sort_by(|left, right| {
2903            (
2904                &left.partition,
2905                &left.fold_id,
2906                &left.prediction_id,
2907                &left.sample_ids,
2908            )
2909                .cmp(&(
2910                    &right.partition,
2911                    &right.fold_id,
2912                    &right.prediction_id,
2913                    &right.sample_ids,
2914                ))
2915        });
2916        observation_predictions.sort_by(|left, right| {
2917            (
2918                &left.partition,
2919                &left.fold_id,
2920                &left.prediction_id,
2921                &left.observation_ids,
2922            )
2923                .cmp(&(
2924                    &right.partition,
2925                    &right.fold_id,
2926                    &right.prediction_id,
2927                    &right.observation_ids,
2928                ))
2929        });
2930        aggregated_predictions.sort_by(|left, right| {
2931            (
2932                &left.partition,
2933                &left.fold_id,
2934                &left.prediction_id,
2935                &left.unit_ids,
2936            )
2937                .cmp(&(
2938                    &right.partition,
2939                    &right.fold_id,
2940                    &right.prediction_id,
2941                    &right.unit_ids,
2942                ))
2943        });
2944        aggregated_predictions.dedup();
2945        let output = BoundTrainingOutput {
2946            schema_version: Some(BOUND_TRAINING_OUTPUT_SCHEMA_VERSION),
2947            binding,
2948            predictions,
2949            observation_predictions,
2950            aggregated_predictions,
2951        };
2952        output.validate(plan)?;
2953        bound.push(output);
2954    }
2955    Ok(bound)
2956}
2957
2958/// Derive portable OOF requirements from the blocks produced by an existing
2959/// FIT_CV execution. Shared by the training operation and host capture paths.
2960pub fn build_oof_prediction_requirements(
2961    plan: &ExecutionPlan,
2962    blocks: &[PredictionBlock],
2963    aggregated_blocks: &[AggregatedPredictionBlock],
2964) -> Result<Vec<BundlePredictionRequirement>> {
2965    let mut requirements = Vec::new();
2966    for edge in plan
2967        .graph_plan
2968        .graph
2969        .edges
2970        .iter()
2971        .filter(|edge| edge.contract.requires_oof)
2972    {
2973        // Nested stacking retains two validation-OOF evidence classes in one
2974        // run context: parent outer-fold rows for report/refit, and child
2975        // inner-fold rows used solely to train each outer-fold meta model.
2976        // Portable caches are the refit pool, so they must retain only the
2977        // report-grade outer rows.  Keeping child rows here would duplicate
2978        // sample ids across outer scopes and violate the cache's exact OOF
2979        // identity contract.
2980        let report_fold_ids = if is_nested_stacking_meta_node(plan, &edge.target.node_id)? {
2981            Some(
2982                plan.fold_set
2983                    .as_ref()
2984                    .ok_or_else(|| {
2985                        DagMlError::RuntimeValidation(
2986                            "nested stacking requires an attested outer fold set".to_string(),
2987                        )
2988                    })?
2989                    .folds
2990                    .iter()
2991                    .map(|fold| fold.fold_id.clone())
2992                    .collect::<BTreeSet<_>>(),
2993            )
2994        } else {
2995            None
2996        };
2997        let source_plan = plan.node_plans.get(&edge.source.node_id).ok_or_else(|| {
2998            DagMlError::RuntimeValidation(format!(
2999                "OOF edge source `{}` has no node plan",
3000                edge.source.node_id
3001            ))
3002        })?;
3003        let prediction_level = source_plan
3004            .shape_plan
3005            .as_ref()
3006            .map(|shape| shape.aggregation_policy.aggregation_level)
3007            .unwrap_or(PredictionLevel::Sample);
3008        let mut fold_ids = BTreeSet::<FoldId>::new();
3009        let mut sample_ids = BTreeSet::<SampleId>::new();
3010        let mut unit_ids = BTreeSet::<PredictionUnitId>::new();
3011        let mut width = None;
3012        let mut target_names: Option<Vec<String>> = None;
3013
3014        match prediction_level {
3015            PredictionLevel::Sample => {
3016                let selected = blocks
3017                    .iter()
3018                    .filter(|block| {
3019                        block.producer_node == edge.source.node_id
3020                            && producer_port_matches_graph_output(
3021                                plan,
3022                                &edge.source.node_id,
3023                                &edge.source.port_name,
3024                                &block.producer_port,
3025                            )
3026                            && block.partition == PredictionPartition::Validation
3027                            && report_fold_ids.as_ref().is_none_or(|fold_ids| {
3028                                block
3029                                    .fold_id
3030                                    .as_ref()
3031                                    .is_some_and(|fold_id| fold_ids.contains(fold_id))
3032                            })
3033                    })
3034                    .collect::<Vec<_>>();
3035                if selected.is_empty() {
3036                    return Err(DagMlError::RuntimeValidation(format!(
3037                        "OOF requirement `{}` -> `{}` has no validation sample blocks",
3038                        edge.source.node_id, edge.target.node_id
3039                    )));
3040                }
3041                for block in selected {
3042                    let block_width = block.validate_shape()?;
3043                    merge_oof_shape(
3044                        &edge.source.node_id,
3045                        &mut width,
3046                        &mut target_names,
3047                        block_width,
3048                        &block.target_names,
3049                    )?;
3050                    if let Some(fold_id) = &block.fold_id {
3051                        fold_ids.insert(fold_id.clone());
3052                    }
3053                    sample_ids.extend(block.sample_ids.iter().cloned());
3054                }
3055            }
3056            PredictionLevel::Target | PredictionLevel::Group => {
3057                let selected = aggregated_blocks
3058                    .iter()
3059                    .filter(|block| {
3060                        block.producer_node == edge.source.node_id
3061                            && producer_port_matches_graph_output(
3062                                plan,
3063                                &edge.source.node_id,
3064                                &edge.source.port_name,
3065                                &block.producer_port,
3066                            )
3067                            && block.partition == PredictionPartition::Validation
3068                            && block.level == prediction_level
3069                            && report_fold_ids.as_ref().is_none_or(|fold_ids| {
3070                                block
3071                                    .fold_id
3072                                    .as_ref()
3073                                    .is_some_and(|fold_id| fold_ids.contains(fold_id))
3074                            })
3075                    })
3076                    .collect::<Vec<_>>();
3077                if selected.is_empty() {
3078                    return Err(DagMlError::RuntimeValidation(format!(
3079                        "OOF requirement `{}` -> `{}` has no validation {prediction_level:?} blocks",
3080                        edge.source.node_id, edge.target.node_id
3081                    )));
3082                }
3083                for block in selected {
3084                    let block_width = block.validate_shape()?;
3085                    merge_oof_shape(
3086                        &edge.source.node_id,
3087                        &mut width,
3088                        &mut target_names,
3089                        block_width,
3090                        &block.target_names,
3091                    )?;
3092                    if let Some(fold_id) = &block.fold_id {
3093                        fold_ids.insert(fold_id.clone());
3094                    }
3095                    unit_ids.extend(block.unit_ids.iter().cloned());
3096                }
3097            }
3098            PredictionLevel::Observation => {
3099                return Err(DagMlError::RuntimeValidation(format!(
3100                    "OOF requirement `{}` -> `{}` cannot persist observation-level predictions; aggregate before refit",
3101                    edge.source.node_id, edge.target.node_id
3102                )));
3103            }
3104        }
3105        let requirement = BundlePredictionRequirement {
3106            producer_node: edge.source.node_id.clone(),
3107            source_port: edge.source.port_name.clone(),
3108            consumer_node: edge.target.node_id.clone(),
3109            target_port: edge.target.port_name.clone(),
3110            partition: PredictionPartition::Validation,
3111            prediction_level,
3112            fold_ids: fold_ids.into_iter().collect(),
3113            unit_ids: unit_ids.into_iter().collect(),
3114            sample_ids: sample_ids.into_iter().collect(),
3115            prediction_width: width.unwrap_or_default(),
3116            target_names: target_names.unwrap_or_default(),
3117        };
3118        requirement.validate()?;
3119        requirements.push(requirement);
3120    }
3121    requirements.sort_by_key(BundlePredictionRequirement::key);
3122    Ok(requirements)
3123}
3124
3125fn merge_oof_shape(
3126    producer: &NodeId,
3127    expected_width: &mut Option<usize>,
3128    expected_names: &mut Option<Vec<String>>,
3129    width: usize,
3130    names: &[String],
3131) -> Result<()> {
3132    if expected_width.is_some_and(|expected| expected != width) {
3133        return Err(DagMlError::RuntimeValidation(format!(
3134            "OOF requirement for `{producer}` has inconsistent prediction width"
3135        )));
3136    }
3137    *expected_width = Some(width);
3138    let names = if names.is_empty() {
3139        (0..width).map(|index| format!("p{index}")).collect()
3140    } else {
3141        names.to_vec()
3142    };
3143    if expected_names
3144        .as_ref()
3145        .is_some_and(|expected| expected != &names)
3146    {
3147        return Err(DagMlError::RuntimeValidation(format!(
3148            "OOF requirement for `{producer}` has inconsistent target names"
3149        )));
3150    }
3151    *expected_names = Some(names);
3152    Ok(())
3153}
3154
3155pub fn build_oof_prediction_cache_records(
3156    requirements: &[BundlePredictionRequirement],
3157    blocks: &[PredictionBlock],
3158    aggregated_blocks: &[AggregatedPredictionBlock],
3159) -> Result<Vec<BundlePredictionCacheRecord>> {
3160    requirements
3161        .iter()
3162        .map(|requirement| match requirement.prediction_level {
3163            PredictionLevel::Sample => build_prediction_cache_record(requirement, blocks),
3164            PredictionLevel::Target | PredictionLevel::Group => {
3165                build_aggregated_prediction_cache_record(requirement, aggregated_blocks)
3166            }
3167            PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
3168                "prediction cache requirement `{}` cannot use observation-level predictions",
3169                requirement.key()
3170            ))),
3171        })
3172        .collect()
3173}
3174
3175pub fn build_oof_prediction_cache_payloads(
3176    requirements: &[BundlePredictionRequirement],
3177    blocks: &[PredictionBlock],
3178    aggregated_blocks: &[AggregatedPredictionBlock],
3179) -> Result<Vec<BundlePredictionCachePayload>> {
3180    requirements
3181        .iter()
3182        .map(|requirement| match requirement.prediction_level {
3183            PredictionLevel::Sample => build_prediction_cache_payload(requirement, blocks),
3184            PredictionLevel::Target | PredictionLevel::Group => {
3185                build_aggregated_prediction_cache_payload(requirement, aggregated_blocks)
3186            }
3187            PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
3188                "prediction cache requirement `{}` cannot use observation-level predictions",
3189                requirement.key()
3190            ))),
3191        })
3192        .collect()
3193}
3194
3195fn attach_oof_prediction_cache_namespaces(
3196    plan: &ExecutionPlan,
3197    data_identities: &[TrainingDataIdentity],
3198    selected_variant_id: &VariantId,
3199    seed: u64,
3200    requirements: &[BundlePredictionRequirement],
3201    records: &mut [BundlePredictionCacheRecord],
3202    payloads: &mut [BundlePredictionCachePayload],
3203) -> Result<()> {
3204    let requirements_by_key = requirements
3205        .iter()
3206        .map(|requirement| (requirement.key(), requirement))
3207        .collect::<BTreeMap<_, _>>();
3208    for record in records {
3209        let requirement = requirements_by_key
3210            .get(&record.requirement_key)
3211            .ok_or_else(|| {
3212                DagMlError::RuntimeValidation(format!(
3213                    "prediction cache `{}` references unknown OOF requirement `{}`",
3214                    record.cache_id, record.requirement_key
3215                ))
3216            })?;
3217        let fingerprints = oof_cache_namespace_fingerprints(
3218            plan,
3219            data_identities,
3220            selected_variant_id,
3221            seed,
3222            requirement,
3223            record,
3224        )?;
3225        record.cache_namespace_fingerprints = fingerprints.clone();
3226        let payload = payloads
3227            .iter_mut()
3228            .find(|payload| payload.requirement_key == record.requirement_key)
3229            .ok_or_else(|| {
3230                DagMlError::RuntimeValidation(format!(
3231                    "prediction cache `{}` has no portable payload for requirement `{}`",
3232                    record.cache_id, record.requirement_key
3233                ))
3234            })?;
3235        payload.cache_namespace_fingerprints = fingerprints;
3236        validate_prediction_cache_payload_matches_record(payload, record)?;
3237    }
3238    Ok(())
3239}
3240
3241fn oof_cache_namespace_fingerprints(
3242    plan: &ExecutionPlan,
3243    data_identities: &[TrainingDataIdentity],
3244    selected_variant_id: &VariantId,
3245    seed: u64,
3246    requirement: &BundlePredictionRequirement,
3247    record: &BundlePredictionCacheRecord,
3248) -> Result<Vec<String>> {
3249    let producer_plan = plan
3250        .node_plans
3251        .get(&requirement.producer_node)
3252        .ok_or_else(|| {
3253            DagMlError::RuntimeValidation(format!(
3254                "prediction cache `{}` producer node `{}` is absent from plan",
3255                record.cache_id, requirement.producer_node
3256            ))
3257        })?;
3258    let consumer_plan = plan
3259        .node_plans
3260        .get(&requirement.consumer_node)
3261        .ok_or_else(|| {
3262            DagMlError::RuntimeValidation(format!(
3263                "prediction cache `{}` consumer node `{}` is absent from plan",
3264                record.cache_id, requirement.consumer_node
3265            ))
3266        })?;
3267    let identity_binding = match (
3268        producer_plan.data_bindings.as_slice(),
3269        consumer_plan.data_bindings.as_slice(),
3270    ) {
3271        ([binding], _) => binding,
3272        ([], [binding]) => binding,
3273        (producer_bindings, consumer_bindings) => {
3274            let producer_count = producer_bindings.len();
3275            let consumer_count = consumer_bindings.len();
3276            return Err(DagMlError::RuntimeValidation(format!(
3277                "prediction cache `{}` cannot derive a unique CacheNamespace for edge `{}.{}` -> `{}.{}` with {producer_count} producer data binding(s) and {consumer_count} consumer data binding(s)",
3278                record.cache_id,
3279                requirement.producer_node,
3280                requirement.source_port,
3281                requirement.consumer_node,
3282                requirement.target_port
3283            )));
3284        }
3285    };
3286    if producer_plan.data_bindings.len() > 1 || consumer_plan.data_bindings.len() > 1 {
3287        return Err(DagMlError::RuntimeValidation(format!(
3288            "prediction cache `{}` cannot derive a unique CacheNamespace for edge `{}.{}` -> `{}.{}` with ambiguous data bindings",
3289            record.cache_id,
3290            requirement.producer_node,
3291            requirement.source_port,
3292            requirement.consumer_node,
3293            requirement.target_port
3294        )));
3295    }
3296    let data_requirement_key =
3297        data_binding_requirement_key(&identity_binding.node_id, &identity_binding.input_name);
3298    let identity = data_identities
3299        .iter()
3300        .find(|identity| identity.requirement_key == data_requirement_key)
3301        .ok_or_else(|| {
3302            DagMlError::RuntimeValidation(format!(
3303                "prediction cache `{}` has no training data identity for `{data_requirement_key}`",
3304                record.cache_id
3305            ))
3306        })?;
3307    let mut fingerprints = Vec::with_capacity(record.blocks.len());
3308    for block in &record.blocks {
3309        let fold_id = block.fold_id.clone().ok_or_else(|| {
3310            DagMlError::RuntimeValidation(format!(
3311                "prediction cache `{}` has a cache block without fold_id",
3312                record.cache_id
3313            ))
3314        })?;
3315        let namespace = CacheNamespace::new(
3316            requirement.key(),
3317            identity.requirement_key.clone(),
3318            requirement.producer_node.clone(),
3319            requirement.source_port.clone(),
3320            requirement.consumer_node.clone(),
3321            requirement.target_port.clone(),
3322            producer_plan.params_fingerprint.clone(),
3323            identity.identity_fingerprint.clone(),
3324            fold_id,
3325            selected_variant_id.to_string(),
3326            seed,
3327        )?;
3328        namespace.validate_for_identity(identity)?;
3329        fingerprints.push(namespace.namespace_fingerprint);
3330    }
3331    Ok(fingerprints)
3332}
3333
3334impl TrainingOutcome {
3335    /// Strictly parse a self-fingerprinted W0 outcome without losing the JSON
3336    /// integer-versus-binary64 token distinction before verification.
3337    pub fn from_json(json: &str) -> Result<Self> {
3338        let typed = parse_typed_json(json).map_err(|error| {
3339            DagMlError::CampaignValidation(format!(
3340                "training outcome is not strict TCV1 JSON: {error}"
3341            ))
3342        })?;
3343        let raw_fingerprint =
3344            typed
3345                .fingerprint_without("outcome_fingerprint")
3346                .map_err(|error| {
3347                    DagMlError::CampaignValidation(format!(
3348                        "training outcome fingerprint preimage is invalid: {error}"
3349                    ))
3350                })?;
3351        let outcome: Self = serde_json::from_str(json)?;
3352        if outcome.outcome_fingerprint != raw_fingerprint {
3353            return contract_error(
3354                "training outcome fingerprint does not match original TCV1 JSON",
3355            );
3356        }
3357        outcome.validate()?;
3358        Ok(outcome)
3359    }
3360
3361    pub fn compute_fingerprint(&self) -> Result<String> {
3362        tcv1_fingerprint_without(self, "outcome_fingerprint", "training outcome")
3363    }
3364
3365    pub fn data_identities_fingerprint(&self) -> Result<String> {
3366        tcv1_fingerprint(&self.data_identities, "training outcome data identities")
3367    }
3368
3369    pub fn execution_bundle_fingerprint(&self) -> Result<String> {
3370        tcv1_fingerprint(&self.execution_bundle, "training outcome execution bundle")
3371    }
3372
3373    fn pre_conformal_outcome(&self) -> Result<Self> {
3374        let mut source = self.clone();
3375        source.conformal_calibration = None;
3376        source.conformal_calibration_replay = None;
3377        source.execution_bundle.conformal_calibration = None;
3378        stabilize_training_outcome_for_tcv1(source)
3379    }
3380
3381    fn pre_conformal_outcome_fingerprint(&self) -> Result<String> {
3382        Ok(self.pre_conformal_outcome()?.outcome_fingerprint)
3383    }
3384
3385    /// Attach native split-conformal state after an ordinary identity-attested
3386    /// calibration replay.  The bundle retains a typed reference and the
3387    /// outcome owns the complete signed quantiles.
3388    pub(crate) fn attach_conformal_calibration(
3389        &mut self,
3390        calibration: ConformalCalibration,
3391        replay: TrainingReplayOutcome,
3392    ) -> Result<()> {
3393        self.validate()?;
3394        calibration.validate()?;
3395        let request = replay_request_from_outcome(&replay);
3396        replay.validate_against(self, &request)?;
3397        let binding = self
3398            .outputs
3399            .iter()
3400            .find(|output| output.binding.binding_id == calibration.binding_id)
3401            .ok_or_else(|| {
3402                DagMlError::RuntimeValidation(
3403                    "conformal calibration binding is absent from training outcome".to_string(),
3404                )
3405            })?;
3406        if binding.binding.target_names != calibration.target_names {
3407            return Err(DagMlError::RuntimeValidation(
3408                "conformal calibration target order does not match training outcome binding"
3409                    .to_string(),
3410            ));
3411        }
3412        let fold_set = self.effective_plan.fold_set.as_ref().ok_or_else(|| {
3413            DagMlError::RuntimeValidation(
3414                "conformal calibration requires a source FoldSet".to_string(),
3415            )
3416        })?;
3417        let context = &calibration.context;
3418        if context.predictor_binding_fingerprint != binding.binding.binding_fingerprint
3419            || context.source_training_outcome_fingerprint != self.outcome_fingerprint
3420            || context.data_identities_fingerprint != self.data_identities_fingerprint()?
3421            || context.fold_set_fingerprint != fold_set_fingerprint(fold_set)?
3422            || context.training_influence_fingerprint
3423                != self.training_influence.manifest_fingerprint
3424        {
3425            return Err(DagMlError::RuntimeValidation(
3426                "conformal calibration context does not exactly match its training outcome"
3427                    .to_string(),
3428            ));
3429        }
3430        let training_ids = self
3431            .training_influence
3432            .entries
3433            .iter()
3434            .flat_map(|entry| {
3435                entry
3436                    .physical_sample_ids
3437                    .iter()
3438                    .chain(entry.origin_sample_ids.iter())
3439            })
3440            .collect::<BTreeSet<_>>();
3441        if context
3442            .calibration_cohort
3443            .physical_sample_ids
3444            .iter()
3445            .chain(context.calibration_cohort.origin_sample_ids.iter())
3446            .any(|id| training_ids.contains(id))
3447        {
3448            return Err(DagMlError::RuntimeValidation(
3449                "conformal calibration cohort overlaps training influence closure".to_string(),
3450            ));
3451        }
3452        self.execution_bundle.conformal_calibration = Some(calibration.reference()?);
3453        self.conformal_calibration = Some(calibration);
3454        self.conformal_calibration_replay = Some(replay);
3455        *self = stabilize_training_outcome_for_tcv1(self.clone())?;
3456        self.validate()
3457    }
3458
3459    /// Build the compact cross-link embedded by a portable predictor package.
3460    pub fn to_reference(&self) -> Result<TrainingOutcomeRef> {
3461        self.validate()?;
3462        validate_sha256(
3463            "training outcome request",
3464            &self.training_request_fingerprint,
3465        )?;
3466        Ok(TrainingOutcomeRef {
3467            outcome_id: self.outcome_id.clone(),
3468            outcome_fingerprint: self.outcome_fingerprint.clone(),
3469            pre_conformal_outcome_fingerprint: self
3470                .conformal_calibration
3471                .as_ref()
3472                .map(|_| self.pre_conformal_outcome_fingerprint())
3473                .transpose()?,
3474            training_request_fingerprint: self.training_request_fingerprint.clone(),
3475            effective_plan_fingerprint: self.effective_plan_fingerprint.clone(),
3476            execution_bundle_id: self.execution_bundle.bundle_id.clone(),
3477            execution_bundle_fingerprint: self.execution_bundle_fingerprint()?,
3478            data_identities_fingerprint: self.data_identities_fingerprint()?,
3479            output_binding_fingerprints: self
3480                .outputs
3481                .iter()
3482                .map(|output| output.binding.binding_fingerprint.clone())
3483                .collect(),
3484            training_influence_fingerprint: self.training_influence.manifest_fingerprint.clone(),
3485        })
3486    }
3487
3488    /// Export a self-contained portable predictor package contract from this
3489    /// training outcome. Runtime handles are never serialized; host-sidecar
3490    /// artifacts are represented only by their signed artifact descriptors and
3491    /// must be resolved into process-local handles by `PortablePredictorPackage::load_with`.
3492    pub fn to_portable_predictor_package(
3493        &self,
3494        package_id: impl Into<String>,
3495        fitted_artifact_mode: FittedArtifactMode,
3496        artifact_load_mode: ArtifactLoadMode,
3497    ) -> Result<PortablePredictorPackage> {
3498        self.validate()?;
3499        let mut template = PredictorTemplate {
3500            graph: self.effective_plan.graph_plan.graph.clone(),
3501            campaign: self.effective_plan.campaign.clone(),
3502            controller_manifests: self.effective_plan.controller_manifests.clone(),
3503            template_fingerprint: zero_fingerprint(),
3504        };
3505        template.template_fingerprint = template.compute_fingerprint()?;
3506
3507        let output_bindings = self
3508            .outputs
3509            .iter()
3510            .map(|output| output.binding.clone())
3511            .collect::<Vec<_>>();
3512        let predictor_node_ids = predictor_closure(
3513            &self.effective_plan,
3514            output_bindings
3515                .iter()
3516                .map(|binding| binding.node_id.clone()),
3517        )?
3518        .into_iter()
3519        .collect::<Vec<_>>();
3520        let mut artifact_bindings = self
3521            .execution_bundle
3522            .refit_artifacts
3523            .iter()
3524            .map(|record| PackageArtifactBinding {
3525                artifact_id: record.artifact.id.clone(),
3526                load_mode: artifact_load_mode,
3527            })
3528            .collect::<Vec<_>>();
3529        artifact_bindings.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id));
3530        let mut package = PortablePredictorPackage {
3531            schema_version: PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION,
3532            package_id: package_id.into(),
3533            template,
3534            training_request_fingerprint: self.training_request_fingerprint.clone(),
3535            training_outcome: self.to_reference()?,
3536            effective_plan: self.effective_plan.clone(),
3537            execution_bundle: self.execution_bundle.clone(),
3538            conformal_calibration: self.conformal_calibration.clone(),
3539            conformal_calibration_replay: self.conformal_calibration_replay.clone(),
3540            output_bindings,
3541            predictor_node_ids,
3542            training_influence: self.training_influence.clone(),
3543            data_identities: self.data_identities.clone(),
3544            fitted_artifact_mode,
3545            artifact_bindings,
3546            package_fingerprint: zero_fingerprint(),
3547        };
3548        package.package_fingerprint = package.compute_fingerprint()?;
3549        package.validate()?;
3550        Ok(package)
3551    }
3552
3553    pub fn validate(&self) -> Result<()> {
3554        if self.schema_version < MIN_READABLE_TRAINING_OUTCOME_SCHEMA_VERSION
3555            || self.schema_version > TRAINING_OUTCOME_SCHEMA_VERSION
3556        {
3557            return contract_error(format!(
3558                "training outcome schema_version {} is unsupported; maximum readable version is {}",
3559                self.schema_version, TRAINING_OUTCOME_SCHEMA_VERSION
3560            ));
3561        }
3562        RunId::new(self.outcome_id.clone()).map_err(|error| {
3563            DagMlError::CampaignValidation(format!(
3564                "training outcome_id is not a portable identifier: {error}"
3565            ))
3566        })?;
3567        validate_sha256(
3568            "training outcome request",
3569            &self.training_request_fingerprint,
3570        )?;
3571        validate_sha256("training outcome plan", &self.effective_plan_fingerprint)?;
3572        validate_sha256(
3573            "training outcome selected variant",
3574            &self.selected_variant_fingerprint,
3575        )?;
3576        validate_sha256("training outcome", &self.outcome_fingerprint)?;
3577        self.effective_plan.validate()?;
3578        if self.effective_plan_fingerprint
3579            != tcv1_fingerprint(&self.effective_plan, "training outcome effective plan")?
3580        {
3581            return contract_error(
3582                "training outcome effective_plan_fingerprint does not match TCV1 plan content",
3583            );
3584        }
3585
3586        let selected = self
3587            .effective_plan
3588            .variants
3589            .iter()
3590            .filter(|variant| variant.variant_id == self.selected_variant_id)
3591            .collect::<Vec<_>>();
3592        let [selected] = selected.as_slice() else {
3593            return contract_error(
3594                "training outcome selected_variant_id is absent or duplicated in effective plan",
3595            );
3596        };
3597        if selected.fingerprint != self.selected_variant_fingerprint {
3598            return contract_error(
3599                "training outcome selected_variant_fingerprint does not match effective plan",
3600            );
3601        }
3602        let expected_patches = selected_variant_parameter_patches(selected)?;
3603        validate_outcome_parameter_patches(
3604            &self.effective_plan,
3605            &self.parameter_patches,
3606            &expected_patches,
3607        )?;
3608        if !self.parameter_patches.is_empty()
3609            && !self
3610                .training_influence
3611                .entries
3612                .iter()
3613                .any(|entry| entry.kind == TrainingInfluenceKind::HpoSelection)
3614        {
3615            return contract_error(
3616                "training outcome parameter patches require hpo_selection influence",
3617            );
3618        }
3619
3620        self.validate_refit()?;
3621        self.score_set.validate()?;
3622        self.validate_version_family()?;
3623        if self.schema_version == LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION
3624            && (self.conformal_calibration.is_some() || self.conformal_calibration_replay.is_some())
3625        {
3626            return contract_error(
3627                "training outcome V1 cannot carry conformal state; migrate to V2",
3628            );
3629        }
3630        if self.score_set.plan_id != self.effective_plan.id {
3631            return contract_error("training outcome score_set.plan_id does not match plan");
3632        }
3633        if !self
3634            .score_set
3635            .reports
3636            .iter()
3637            .any(|report| report.variant_id.as_ref() == Some(&self.selected_variant_id))
3638        {
3639            return contract_error("training outcome score_set has no report for selected variant");
3640        }
3641        self.validate_selection_decision()?;
3642
3643        let closure = self.validate_outputs()?;
3644        let expected_predictor_execution_closure = self
3645            .effective_plan
3646            .node_plans
3647            .keys()
3648            .cloned()
3649            .collect::<BTreeSet<_>>();
3650        if closure != expected_predictor_execution_closure {
3651            return contract_error(
3652                "training outcome predictor closure does not equal the explicit V1 predictor execution closure",
3653            );
3654        }
3655        self.training_influence.validate()?;
3656        validate_influence_against_closure(
3657            &self.training_influence,
3658            &self.effective_plan,
3659            &closure,
3660        )?;
3661        let base_fit_nodes = self
3662            .training_influence
3663            .entries
3664            .iter()
3665            .filter(|entry| {
3666                matches!(
3667                    entry.kind,
3668                    TrainingInfluenceKind::TransformFit
3669                        | TrainingInfluenceKind::ModelFit
3670                        | TrainingInfluenceKind::TrainedMetaAggregation
3671                )
3672            })
3673            .filter_map(|entry| entry.node_id.clone())
3674            .collect::<BTreeSet<_>>();
3675        if self
3676            .outputs
3677            .iter()
3678            .any(|output| !base_fit_nodes.contains(&output.binding.node_id))
3679        {
3680            return contract_error("training outcome output node has no fitting influence");
3681        }
3682
3683        self.execution_bundle
3684            .validate_against_plan(&self.effective_plan)?;
3685        if self.execution_bundle.selected_variant_id.as_ref() != Some(&self.selected_variant_id) {
3686            return contract_error(
3687                "training outcome execution bundle selected variant does not match outcome",
3688            );
3689        }
3690        if self.execution_bundle.scores.as_ref() != Some(&self.score_set) {
3691            return contract_error(
3692                "training outcome execution bundle scores do not equal score_set",
3693            );
3694        }
3695        if self.execution_bundle.methods_hpo_resume_state != self.methods_hpo_resume_state {
3696            return contract_error(
3697                "training outcome Methods HPO resume state does not equal execution bundle state",
3698            );
3699        }
3700        match (
3701            &self.conformal_calibration,
3702            &self.conformal_calibration_replay,
3703            &self.execution_bundle.conformal_calibration,
3704        ) {
3705            (Some(calibration), Some(replay), Some(reference)) => {
3706                reference.validate_against(calibration)?;
3707                let pre_conformal_source = self.pre_conformal_outcome()?;
3708                let replay_request = replay_request_from_outcome(replay);
3709                replay.validate_against(&pre_conformal_source, &replay_request)?;
3710                let binding = self
3711                    .outputs
3712                    .iter()
3713                    .find(|output| output.binding.binding_id == calibration.binding_id)
3714                    .ok_or_else(|| {
3715                        DagMlError::RuntimeValidation(
3716                            "conformal calibration binding is absent from training outcome"
3717                                .to_string(),
3718                        )
3719                    })?;
3720                let fold_set = self.effective_plan.fold_set.as_ref().ok_or_else(|| {
3721                    DagMlError::RuntimeValidation(
3722                        "conformal calibration requires a source FoldSet".to_string(),
3723                    )
3724                })?;
3725                let context = &calibration.context;
3726                if binding.binding.target_names != calibration.target_names
3727                    || context.predictor_binding_fingerprint != binding.binding.binding_fingerprint
3728                    || context.source_training_outcome_fingerprint
3729                        != pre_conformal_source.outcome_fingerprint
3730                    || context.calibration_replay_outcome_fingerprint != replay.outcome_fingerprint
3731                    || context.data_identities_fingerprint != self.data_identities_fingerprint()?
3732                    || context.fold_set_fingerprint != fold_set_fingerprint(fold_set)?
3733                    || context.training_influence_fingerprint
3734                        != self.training_influence.manifest_fingerprint
3735                {
3736                    return contract_error(
3737                        "training outcome conformal context does not exactly cross-link its pre-calibration source",
3738                    );
3739                }
3740                if context.relation_fingerprint == self.training_influence.relation_fingerprint {
3741                    return contract_error(
3742                        "training outcome calibration relation authority must be distinct from development relations",
3743                    );
3744                }
3745                let replay_output = replay
3746                    .outputs
3747                    .iter()
3748                    .find(|output| output.binding.binding_id == calibration.binding_id)
3749                    .ok_or_else(|| {
3750                        DagMlError::RuntimeValidation(
3751                            "conformal calibration replay is missing its selected binding"
3752                                .to_string(),
3753                        )
3754                    })?;
3755                let [point] = replay_output.predictions.as_slice() else {
3756                    return contract_error(
3757                        "conformal calibration replay requires exactly one selected point block",
3758                    );
3759                };
3760                if replay.phase != Phase::Predict
3761                    || replay_output.binding != binding.binding
3762                    || point.sample_ids != calibration.sample_ids
3763                    || point.sample_ids != context.calibration_cohort.physical_sample_ids
3764                    || replay.input_data_identities.iter().any(|identity| {
3765                        identity.relation_fingerprint != context.relation_fingerprint
3766                    })
3767                {
3768                    return contract_error(
3769                        "conformal calibration replay evidence does not match its selected binding, samples, or relation authority",
3770                    );
3771                }
3772                let training_ids = self
3773                    .training_influence
3774                    .entries
3775                    .iter()
3776                    .flat_map(|entry| {
3777                        entry
3778                            .physical_sample_ids
3779                            .iter()
3780                            .chain(entry.origin_sample_ids.iter())
3781                    })
3782                    .collect::<BTreeSet<_>>();
3783                if context
3784                    .calibration_cohort
3785                    .physical_sample_ids
3786                    .iter()
3787                    .chain(context.calibration_cohort.origin_sample_ids.iter())
3788                    .any(|id| training_ids.contains(id))
3789                {
3790                    return contract_error(
3791                        "conformal calibration cohort overlaps training influence closure",
3792                    );
3793                }
3794            }
3795            (None, None, None) => {}
3796            _ => {
3797                return contract_error(
3798                    "training outcome and execution bundle conformal state disagree",
3799                )
3800            }
3801        }
3802        if let Some(state) = &self.methods_hpo_resume_state {
3803            let terminal_reports = state
3804                .completed_reports
3805                .iter()
3806                .map(|completed| completed.report.clone())
3807                .collect::<Vec<_>>();
3808            if self.score_set.reports != terminal_reports {
3809                return contract_error(
3810                    "training outcome score_set does not exactly retain Methods HPO terminal OOF reports",
3811                );
3812            }
3813        }
3814        self.validate_data_identities()?;
3815        validate_all_identity_relations(
3816            &self.data_identities,
3817            &self.training_influence.relation_fingerprint,
3818        )?;
3819        self.validate_artifacts(&closure)?;
3820        self.validate_lineage(&closure)?;
3821        match &self.portable_prediction_caches {
3822            Some(caches) => caches.validate_against_bundle(&self.execution_bundle)?,
3823            None if !self.execution_bundle.prediction_caches.is_empty() => {
3824                return contract_error(
3825                    "training outcome portable caches are null while bundle announces caches",
3826                );
3827            }
3828            None => {}
3829        }
3830
3831        let expected_replay = derive_replayable_phases(
3832            &self.effective_plan,
3833            &closure,
3834            &self.refit,
3835            &self.execution_bundle,
3836            self.portable_prediction_caches.as_ref(),
3837        )?;
3838        if self.replayable_phases != expected_replay {
3839            return contract_error(
3840                "training outcome replayable_phases do not match the phases derivable from the full predictor closure and retained state",
3841            );
3842        }
3843        validate_sorted_unique_text("training outcome warnings", &self.warnings)?;
3844        let portable = serde_json::to_value(self)?;
3845        if contains_runtime_handle(&portable) {
3846            return contract_error("training outcome must not contain runtime handles");
3847        }
3848        if self.outcome_fingerprint != self.compute_fingerprint()? {
3849            return contract_error("training outcome fingerprint does not match TCV1 content");
3850        }
3851        Ok(())
3852    }
3853
3854    fn validate_version_family(&self) -> Result<()> {
3855        let expected_score_version = match self.schema_version {
3856            LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => LEGACY_SCORE_SET_SCHEMA_VERSION,
3857            TRAINING_OUTCOME_SCHEMA_VERSION => SCORE_SET_SCHEMA_VERSION,
3858            _ => unreachable!("training outcome schema_version was range-checked"),
3859        };
3860        if self.score_set.schema_version != expected_score_version {
3861            return contract_error(format!(
3862                "training outcome schema_version {} requires score_set schema_version {}, got {}",
3863                self.schema_version, expected_score_version, self.score_set.schema_version
3864            ));
3865        }
3866        let expected_bundle_version = match self.schema_version {
3867            LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => LEGACY_EXECUTION_BUNDLE_SCHEMA_VERSION,
3868            TRAINING_OUTCOME_SCHEMA_VERSION => EXECUTION_BUNDLE_SCHEMA_VERSION,
3869            _ => unreachable!("training outcome schema_version was range-checked"),
3870        };
3871        if self.execution_bundle.schema_version != expected_bundle_version {
3872            return contract_error(format!(
3873                "training outcome schema_version {} requires execution_bundle schema_version {}, got {}",
3874                self.schema_version,
3875                expected_bundle_version,
3876                self.execution_bundle.schema_version
3877            ));
3878        }
3879        let expected_cache_version = match self.schema_version {
3880            LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => {
3881                LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION
3882            }
3883            TRAINING_OUTCOME_SCHEMA_VERSION => PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
3884            _ => unreachable!("training outcome schema_version was range-checked"),
3885        };
3886        if let Some(caches) = &self.portable_prediction_caches {
3887            if caches.schema_version != expected_cache_version {
3888                return contract_error(format!(
3889                    "training outcome schema_version {} requires prediction cache payload set schema_version {}, got {}",
3890                    self.schema_version, expected_cache_version, caches.schema_version
3891                ));
3892            }
3893        }
3894        for output in &self.outputs {
3895            match (self.schema_version, output.schema_version) {
3896                (LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION, None) => {}
3897                (LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION, Some(version)) => {
3898                    return contract_error(format!(
3899                        "training outcome V1 requires absent bound output schema_version, got {version}"
3900                    ));
3901                }
3902                (TRAINING_OUTCOME_SCHEMA_VERSION, Some(BOUND_TRAINING_OUTPUT_SCHEMA_VERSION)) => {}
3903                (TRAINING_OUTCOME_SCHEMA_VERSION, Some(version)) => {
3904                    return contract_error(format!(
3905                        "training outcome V2 requires bound output schema_version {}, got {version}",
3906                        BOUND_TRAINING_OUTPUT_SCHEMA_VERSION
3907                    ));
3908                }
3909                (TRAINING_OUTCOME_SCHEMA_VERSION, None) => {
3910                    return contract_error(
3911                        "training outcome V2 requires bound output schema_version",
3912                    );
3913                }
3914                _ => unreachable!("training outcome schema_version was range-checked"),
3915            }
3916        }
3917        Ok(())
3918    }
3919
3920    fn validate_data_identities(&self) -> Result<()> {
3921        if self.data_identities.is_empty() {
3922            return contract_error("training outcome requires data identities");
3923        }
3924        let mut previous: Option<&str> = None;
3925        for identity in &self.data_identities {
3926            identity.validate()?;
3927            if previous.is_some_and(|key| key >= identity.requirement_key.as_str()) {
3928                return contract_error(
3929                    "training outcome data identities must be sorted and unique",
3930                );
3931            }
3932            previous = Some(identity.requirement_key.as_str());
3933            let requirement = self
3934                .execution_bundle
3935                .data_requirements
3936                .iter()
3937                .find(|requirement| requirement.key() == identity.requirement_key)
3938                .ok_or_else(|| {
3939                    DagMlError::CampaignValidation(format!(
3940                        "training outcome data identity `{}` has no bundle requirement",
3941                        identity.requirement_key
3942                    ))
3943                })?;
3944            if requirement.schema_fingerprint != identity.schema_fingerprint
3945                || requirement.plan_fingerprint != identity.plan_fingerprint
3946                || requirement.relation_fingerprint.as_ref() != Some(&identity.relation_fingerprint)
3947            {
3948                return contract_error(
3949                    "training outcome data identity does not match execution bundle requirement",
3950                );
3951            }
3952        }
3953        if self.data_identities.len() != self.execution_bundle.data_requirements.len() {
3954            return contract_error(
3955                "training outcome data identities do not exactly cover bundle data requirements",
3956            );
3957        }
3958        Ok(())
3959    }
3960
3961    fn validate_selection_decision(&self) -> Result<()> {
3962        if self.selection_output_id.trim().is_empty() {
3963            return contract_error("training outcome selection_output_id is empty");
3964        }
3965        let bindings = self
3966            .outputs
3967            .iter()
3968            .filter(|output| output.binding.binding_id == self.selection_output_id)
3969            .collect::<Vec<_>>();
3970        let [selected_output] = bindings.as_slice() else {
3971            return contract_error(
3972                "training outcome selection_output_id does not resolve exactly one output",
3973            );
3974        };
3975        if self.execution_bundle.selections.len() != 1 {
3976            return contract_error(
3977                "training outcome execution bundle must contain exactly one SELECT decision",
3978            );
3979        }
3980        let (selection_key, decision) = self
3981            .execution_bundle
3982            .selections
3983            .iter()
3984            .next()
3985            .expect("selection length was checked");
3986        if selection_key != &decision.policy_id
3987            || decision.selected_candidate_id != self.selected_variant_id.as_str()
3988            || decision.metric_level != Some(selected_output.binding.prediction_level)
3989            || decision.evaluation_scope != Some(EvaluationScope::Oof)
3990            || self.score_set.selection_metric.as_deref() != Some(decision.metric_name.as_str())
3991            || selected_output.binding.prediction_level
3992                != self
3993                    .effective_plan
3994                    .campaign
3995                    .aggregation_policy
3996                    .selection_metric_level
3997        {
3998            return contract_error(
3999                "training outcome SELECT decision metadata is inconsistent with selected output",
4000            );
4001        }
4002        RegressionMetricKind::resolve_for_prediction_kind(
4003            &decision.metric_name,
4004            decision.objective,
4005            selected_output.binding.prediction_kind,
4006        )?;
4007        let mut reports_by_variant = BTreeMap::<VariantId, _>::new();
4008        for report in self.score_set.reports.iter().filter(|report| {
4009            report.producer_node == selected_output.binding.node_id
4010                && producer_port_matches_graph_output(
4011                    &self.effective_plan,
4012                    &selected_output.binding.node_id,
4013                    &selected_output.binding.port_name,
4014                    &report.producer_port,
4015                )
4016                && report.partition == PredictionPartition::Validation
4017                && report.level == selected_output.binding.prediction_level
4018                && report
4019                    .fold_id
4020                    .as_ref()
4021                    .is_some_and(|fold| fold.as_str() == "avg")
4022        }) {
4023            let variant_id = report.variant_id.clone().ok_or_else(|| {
4024                DagMlError::CampaignValidation(
4025                    "selection output average report has no variant_id".to_string(),
4026                )
4027            })?;
4028            if reports_by_variant
4029                .insert(variant_id, report.clone())
4030                .is_some()
4031            {
4032                return contract_error(
4033                    "training outcome has multiple selection average reports for one variant",
4034                );
4035            }
4036        }
4037        let expected_variants = self
4038            .effective_plan
4039            .variants
4040            .iter()
4041            .map(|variant| variant.variant_id.clone())
4042            .collect::<BTreeSet<_>>();
4043        if reports_by_variant.keys().cloned().collect::<BTreeSet<_>>() != expected_variants {
4044            return contract_error(
4045                "training outcome selection reports do not exactly cover plan variants",
4046            );
4047        }
4048        let candidates = reports_by_variant
4049            .into_iter()
4050            .map(|(variant_id, report)| report.into_candidate_score(variant_id.as_str()))
4051            .collect::<Result<Vec<_>>>()?;
4052        let reconstructed = select_candidate(
4053            &SelectionPolicy {
4054                id: decision.policy_id.clone(),
4055                metric: SelectionMetric {
4056                    name: decision.metric_name.clone(),
4057                    objective: decision.objective,
4058                },
4059                required_metric_level: decision.metric_level,
4060                require_finite: true,
4061                evaluation_scope: decision.evaluation_scope,
4062                refit_slot_plan: decision.refit_slot_plan.clone(),
4063                stacking_fit_contract: None,
4064                reduction_id: decision.reduction_id.clone(),
4065            },
4066            &candidates,
4067        )?;
4068        if &reconstructed != decision {
4069            return contract_error(
4070                "training outcome SELECT decision does not equal ranking reconstructed from scores",
4071            );
4072        }
4073        Ok(())
4074    }
4075
4076    fn validate_refit(&self) -> Result<()> {
4077        match (self.refit.requested, self.refit.status, self.refit.strategy) {
4078            (true, TrainingRefitStatus::Completed, Some(_)) => {
4079                if self
4080                    .outputs
4081                    .iter()
4082                    .any(|output| output.binding.prediction_source != PredictionSource::FinalRefit)
4083                {
4084                    return contract_error(
4085                        "completed refit outputs must use final_refit prediction source",
4086                    );
4087                }
4088            }
4089            (false, TrainingRefitStatus::Skipped, None) => {
4090                if self
4091                    .outputs
4092                    .iter()
4093                    .any(|output| output.binding.prediction_source == PredictionSource::FinalRefit)
4094                {
4095                    return contract_error("no-refit outputs cannot use final_refit");
4096                }
4097            }
4098            _ => return contract_error("training outcome refit state is inconsistent"),
4099        }
4100        Ok(())
4101    }
4102
4103    fn validate_outputs(&self) -> Result<BTreeSet<NodeId>> {
4104        if self.outputs.is_empty() {
4105            return contract_error("training outcome requires at least one bound output");
4106        }
4107        let mut previous: Option<&str> = None;
4108        let mut roots = Vec::new();
4109        for output in &self.outputs {
4110            if previous.is_some_and(|value| value >= output.binding.binding_id.as_str()) {
4111                return contract_error(
4112                    "training outcome outputs must be strictly sorted by binding_id",
4113                );
4114            }
4115            previous = Some(output.binding.binding_id.as_str());
4116            output.validate(&self.effective_plan)?;
4117            roots.push(output.binding.node_id.clone());
4118        }
4119        predictor_closure(&self.effective_plan, roots)
4120    }
4121
4122    fn validate_artifacts(&self, closure: &BTreeSet<NodeId>) -> Result<()> {
4123        if !self.refit.requested {
4124            if !self.execution_bundle.refit_artifacts.is_empty() {
4125                return contract_error("no-refit training outcome contains refit artifacts");
4126            }
4127            return Ok(());
4128        }
4129        if self.execution_bundle.refit_artifacts.is_empty() {
4130            return contract_error("completed refit requires at least one artifact");
4131        }
4132        let expected_artifact_nodes = closure
4133            .iter()
4134            .filter(|node_id| {
4135                let plan = &self.effective_plan.node_plans[*node_id];
4136                plan.supported_phases.contains(&Phase::Refit)
4137                    && plan
4138                        .controller_capabilities
4139                        .contains(&ControllerCapability::EmitsArtifacts)
4140            })
4141            .cloned()
4142            .collect::<BTreeSet<_>>();
4143        let artifact_nodes = self
4144            .execution_bundle
4145            .refit_artifacts
4146            .iter()
4147            .map(|record| record.node_id.clone())
4148            .collect::<BTreeSet<_>>();
4149        if artifact_nodes != expected_artifact_nodes {
4150            return contract_error(
4151                "refit artifact nodes do not exactly match predictor closure REFIT artifact emitters",
4152            );
4153        }
4154        for output in &self.outputs {
4155            if !artifact_nodes.contains(&output.binding.node_id) {
4156                return contract_error("final output node has no refit artifact");
4157            }
4158        }
4159        Ok(())
4160    }
4161
4162    fn validate_lineage(&self, closure: &BTreeSet<NodeId>) -> Result<()> {
4163        if self.lineage.is_empty() {
4164            return contract_error("training outcome requires portable lineage");
4165        }
4166        let record_ids = self
4167            .lineage
4168            .iter()
4169            .map(|record| record.record_id.clone())
4170            .collect::<Vec<_>>();
4171        if record_ids.windows(2).any(|pair| pair[0] >= pair[1]) {
4172            return contract_error("training outcome lineage must be sorted by record_id");
4173        }
4174        let by_id = self
4175            .lineage
4176            .iter()
4177            .map(|record| (record.record_id.clone(), record))
4178            .collect::<BTreeMap<_, _>>();
4179        if by_id.len() != self.lineage.len() {
4180            return contract_error("training outcome lineage contains duplicate record ids");
4181        }
4182        let mut coordinates = BTreeMap::new();
4183        for record in &self.lineage {
4184            record.validate()?;
4185            if record.run_id != self.run_id
4186                || record.variant_id.as_ref() != Some(&self.selected_variant_id)
4187                || !closure.contains(&record.node_id)
4188            {
4189                return contract_error(
4190                    "training outcome lineage run, variant, or predictor closure is inconsistent",
4191                );
4192            }
4193            if !matches!(record.phase, Phase::FitCv | Phase::Select | Phase::Refit) {
4194                return contract_error("training outcome lineage contains a non-training phase");
4195            }
4196            let plan = &self.effective_plan.node_plans[&record.node_id];
4197            if record.controller_id != plan.controller_id
4198                || record.controller_version != plan.controller_version
4199                || record.params_fingerprint != plan.params_fingerprint
4200            {
4201                return contract_error("training outcome lineage does not match node plan");
4202            }
4203            let key = (record.phase, record.fold_id.clone(), record.node_id.clone());
4204            if coordinates.insert(key, record).is_some() {
4205                return contract_error("training outcome lineage duplicates phase/fold/node");
4206            }
4207            if record
4208                .input_lineage
4209                .iter()
4210                .any(|input| !by_id.contains_key(input))
4211            {
4212                return contract_error("training outcome lineage references an unknown input");
4213            }
4214        }
4215        validate_lineage_coordinates(self, closure, &coordinates)
4216    }
4217}
4218
4219impl BoundTrainingOutput {
4220    pub(crate) fn validate(&self, plan: &ExecutionPlan) -> Result<()> {
4221        if let Some(schema_version) = self.schema_version {
4222            if schema_version != BOUND_TRAINING_OUTPUT_SCHEMA_VERSION {
4223                return contract_error(format!(
4224                    "bound training output schema_version {schema_version} is unsupported; current {}",
4225                    BOUND_TRAINING_OUTPUT_SCHEMA_VERSION
4226                ));
4227            }
4228        }
4229        self.binding.validate(&plan.graph_plan.graph)?;
4230        if self.predictions.is_empty()
4231            && self.observation_predictions.is_empty()
4232            && self.aggregated_predictions.is_empty()
4233        {
4234            return contract_error("bound training output contains no prediction block");
4235        }
4236        match self.binding.prediction_level {
4237            PredictionLevel::Observation
4238                if !self.predictions.is_empty() || !self.aggregated_predictions.is_empty() =>
4239            {
4240                return contract_error(
4241                    "observation output binding cannot contain sample or aggregated predictions",
4242                );
4243            }
4244            PredictionLevel::Sample if !self.observation_predictions.is_empty() => {
4245                return contract_error(
4246                    "sample output binding cannot contain observation predictions",
4247                );
4248            }
4249            PredictionLevel::Target | PredictionLevel::Group
4250                if !self.predictions.is_empty() || !self.observation_predictions.is_empty() =>
4251            {
4252                return contract_error(
4253                    "target/group output binding cannot contain sample or observation predictions",
4254                );
4255            }
4256            _ => {}
4257        }
4258        let expected_names = expected_output_columns(&self.binding);
4259        for block in &self.predictions {
4260            block.validate_shape()?;
4261            validate_bound_block(
4262                plan,
4263                &self.binding,
4264                &block.producer_node,
4265                &block.producer_port,
4266                &block.partition,
4267                block.fold_id.as_ref(),
4268                &block.target_names,
4269                &expected_names,
4270            )?;
4271        }
4272        for block in &self.observation_predictions {
4273            block.validate_shape()?;
4274            validate_bound_block(
4275                plan,
4276                &self.binding,
4277                &block.producer_node,
4278                &block.producer_port,
4279                &block.partition,
4280                block.fold_id.as_ref(),
4281                &block.target_names,
4282                &expected_names,
4283            )?;
4284        }
4285        for block in &self.aggregated_predictions {
4286            block.validate_shape()?;
4287            if block.level != self.binding.prediction_level {
4288                return contract_error(
4289                    "bound aggregated prediction level does not match output binding",
4290                );
4291            }
4292            validate_bound_block(
4293                plan,
4294                &self.binding,
4295                &block.producer_node,
4296                &block.producer_port,
4297                &block.partition,
4298                block.fold_id.as_ref(),
4299                &block.target_names,
4300                &expected_names,
4301            )?;
4302        }
4303        match self.binding.prediction_level {
4304            PredictionLevel::Observation if self.observation_predictions.is_empty() => {
4305                return contract_error(
4306                    "observation output binding requires observation predictions",
4307                );
4308            }
4309            PredictionLevel::Target | PredictionLevel::Group
4310                if self.aggregated_predictions.is_empty() =>
4311            {
4312                return contract_error(
4313                    "target/group output binding requires aggregated predictions",
4314                );
4315            }
4316            _ => {}
4317        }
4318        Ok(())
4319    }
4320}
4321
4322#[allow(clippy::too_many_arguments)]
4323fn validate_bound_block(
4324    plan: &ExecutionPlan,
4325    binding: &OutputBinding,
4326    producer: &NodeId,
4327    producer_port: &Option<String>,
4328    partition: &PredictionPartition,
4329    fold_id: Option<&crate::ids::FoldId>,
4330    target_names: &[String],
4331    expected_names: &[String],
4332) -> Result<()> {
4333    if producer != &binding.node_id
4334        || !producer_port_matches_graph_output(
4335            plan,
4336            &binding.node_id,
4337            &binding.port_name,
4338            producer_port,
4339        )
4340        || target_names != expected_names
4341    {
4342        return contract_error(
4343            "bound prediction producer, producer_port or target order does not match output binding",
4344        );
4345    }
4346    if binding.prediction_source == PredictionSource::FinalRefit
4347        && (partition != &PredictionPartition::Final || fold_id.is_some())
4348    {
4349        return contract_error("final_refit output blocks must use final partition without fold");
4350    }
4351    if binding.prediction_source == PredictionSource::CvEnsemble
4352        && (!is_cv_ensemble_partition(partition) || fold_id.is_none())
4353    {
4354        return contract_error(
4355            "cv_ensemble output blocks must use validation partition with a fold id",
4356        );
4357    }
4358    Ok(())
4359}
4360
4361fn expected_output_columns(binding: &OutputBinding) -> Vec<String> {
4362    if binding.prediction_kind == PredictionKind::ClassProbability {
4363        binding
4364            .target_names
4365            .iter()
4366            .zip(&binding.class_labels)
4367            .flat_map(|(target, labels)| {
4368                labels.iter().map(move |label| format!("{target}:{label}"))
4369            })
4370            .collect()
4371    } else {
4372        binding.target_names.clone()
4373    }
4374}
4375
4376fn selected_variant_parameter_patches(
4377    variant: &crate::generation::VariantPlan,
4378) -> Result<Vec<ParameterPatch>> {
4379    let mut patches = Vec::new();
4380    for choice in variant.choices.values() {
4381        for override_spec in &choice.param_overrides {
4382            for (key, value) in &override_spec.params {
4383                append_parameter_leaves(
4384                    &override_spec.node_id,
4385                    vec![key.clone()],
4386                    value,
4387                    &mut patches,
4388                )?;
4389            }
4390        }
4391    }
4392    patches.sort_by(|left, right| {
4393        (&left.node_id, left.namespace, &left.path).cmp(&(
4394            &right.node_id,
4395            right.namespace,
4396            &right.path,
4397        ))
4398    });
4399    if patches.windows(2).any(|pair| {
4400        pair[0].node_id == pair[1].node_id
4401            && pair[0].namespace == pair[1].namespace
4402            && pair[0].path == pair[1].path
4403    }) {
4404        return contract_error("selected variant overrides contain duplicate leaf paths");
4405    }
4406    Ok(patches)
4407}
4408
4409fn merge_training_parameter_patches(
4410    request_patches: &[ParameterPatch],
4411    selected_variant: &crate::generation::VariantPlan,
4412) -> Result<Vec<ParameterPatch>> {
4413    let mut patches = request_patches.to_vec();
4414    patches.extend(selected_variant_parameter_patches(selected_variant)?);
4415    sort_and_validate_training_parameter_patch_keys(&mut patches, false)?;
4416    Ok(patches)
4417}
4418
4419fn validate_outcome_parameter_patches(
4420    plan: &ExecutionPlan,
4421    patches: &[ParameterPatch],
4422    selected_variant_patches: &[ParameterPatch],
4423) -> Result<()> {
4424    let mut patches = patches.to_vec();
4425    sort_and_validate_training_parameter_patch_keys(&mut patches, true)?;
4426    let keys = patches
4427        .iter()
4428        .map(parameter_patch_key)
4429        .collect::<BTreeSet<_>>();
4430    for selected in selected_variant_patches {
4431        if !keys.contains(&parameter_patch_key(selected)) {
4432            return contract_error(
4433                "training outcome parameter_patches are missing a selected variant override",
4434            );
4435        }
4436    }
4437    for patch in &patches {
4438        validate_materialized_patch(plan, patch)?;
4439    }
4440    Ok(())
4441}
4442
4443fn sort_and_validate_training_parameter_patch_keys(
4444    patches: &mut [ParameterPatch],
4445    require_already_sorted: bool,
4446) -> Result<()> {
4447    for patch in patches.iter() {
4448        patch.validate()?;
4449        if patch.namespace != ParameterNamespace::Operator {
4450            return contract_error(
4451                "training outcome parameter_patches must use operator namespace",
4452            );
4453        }
4454    }
4455    let original = patches.to_vec();
4456    patches.sort_by(|left, right| parameter_patch_key(left).cmp(&parameter_patch_key(right)));
4457    if require_already_sorted && patches != original {
4458        return contract_error(
4459            "training outcome parameter_patches must be sorted by (node_id, namespace, path)",
4460        );
4461    }
4462    for pair in patches.windows(2) {
4463        let left = &pair[0];
4464        let right = &pair[1];
4465        if parameter_patch_key(left) == parameter_patch_key(right) {
4466            return contract_error(
4467                "training outcome parameter_patches contain duplicate leaf paths",
4468            );
4469        }
4470        if left.node_id == right.node_id
4471            && left.namespace == right.namespace
4472            && (right.path.starts_with(&left.path) || left.path.starts_with(&right.path))
4473        {
4474            return contract_error(
4475                "training outcome parameter_patches contain a conflicting parent/child path",
4476            );
4477        }
4478    }
4479    Ok(())
4480}
4481
4482fn parameter_patch_key(patch: &ParameterPatch) -> (&NodeId, ParameterNamespace, &[String]) {
4483    (&patch.node_id, patch.namespace, patch.path.as_slice())
4484}
4485
4486fn append_parameter_leaves(
4487    node_id: &NodeId,
4488    path: Vec<String>,
4489    value: &serde_json::Value,
4490    output: &mut Vec<ParameterPatch>,
4491) -> Result<()> {
4492    if let serde_json::Value::Object(object) = value {
4493        for (key, child) in object {
4494            let mut child_path = path.clone();
4495            child_path.push(key.clone());
4496            append_parameter_leaves(node_id, child_path, child, output)?;
4497        }
4498        return Ok(());
4499    }
4500    output.push(ParameterPatch {
4501        schema_version: PARAMETER_PATCH_SCHEMA_VERSION,
4502        node_id: node_id.clone(),
4503        namespace: ParameterNamespace::Operator,
4504        path,
4505        value: value.clone(),
4506    });
4507    Ok(())
4508}
4509
4510fn validate_materialized_patch(plan: &ExecutionPlan, patch: &ParameterPatch) -> Result<()> {
4511    patch.validate()?;
4512    if patch.namespace != ParameterNamespace::Operator {
4513        return contract_error("selected variant patches must use operator namespace");
4514    }
4515    let node = plan.node_plans.get(&patch.node_id).ok_or_else(|| {
4516        DagMlError::CampaignValidation(format!(
4517            "selected parameter patch references absent node `{}`",
4518            patch.node_id
4519        ))
4520    })?;
4521    let mut current = serde_json::Value::Object(node.params.clone().into_iter().collect());
4522    for segment in &patch.path {
4523        current = current
4524            .as_object()
4525            .and_then(|object| object.get(segment))
4526            .cloned()
4527            .ok_or_else(|| {
4528                DagMlError::CampaignValidation(format!(
4529                    "selected parameter patch path for `{}` is not materialized",
4530                    patch.node_id
4531                ))
4532            })?;
4533    }
4534    if current != patch.value {
4535        return contract_error("selected parameter patch value is not materialized in plan");
4536    }
4537    Ok(())
4538}
4539
4540fn predictor_closure(
4541    plan: &ExecutionPlan,
4542    roots: impl IntoIterator<Item = NodeId>,
4543) -> Result<BTreeSet<NodeId>> {
4544    let mut pending = roots.into_iter().collect::<Vec<_>>();
4545    let mut closure = BTreeSet::new();
4546    while let Some(node_id) = pending.pop() {
4547        if !closure.insert(node_id.clone()) {
4548            continue;
4549        }
4550        let node = plan.node_plans.get(&node_id).ok_or_else(|| {
4551            DagMlError::CampaignValidation(format!(
4552                "training outcome closure references absent node `{node_id}`"
4553            ))
4554        })?;
4555        pending.extend(node.input_nodes.iter().cloned());
4556    }
4557    Ok(closure)
4558}
4559
4560/// Per-node facts the replay derivation reads for one predictor-closure node.
4561struct NodeReplayFacts {
4562    supported_phases: BTreeSet<Phase>,
4563    /// Node carries fitted inference state that a later PREDICT/EXPLAIN must
4564    /// reload: it is `stateful` or emits artifacts (capabilities
4565    /// `Stateful || EmitsArtifacts`). This is deliberately NOT inferred from
4566    /// `artifact_policy`/`ReplayRequired` or from `fit_scope`: a stateless
4567    /// deterministic operator — e.g. a seeded augmentation, or a
4568    /// `replay_required` transform that simply recomputes at inference — carries
4569    /// no reloadable state, needs no retained artifact, and must not block
4570    /// forward replay.
4571    requires_retained_state: bool,
4572    /// A retained refit artifact for this node is present in the bundle.
4573    has_retained_artifact: bool,
4574}
4575
4576/// Per-edge facts for one `requires_oof` dependency wholly inside the closure.
4577struct OofEdgeReplayFacts {
4578    has_bundle_requirement: bool,
4579    has_cache_record: bool,
4580    has_portable_payload: bool,
4581}
4582
4583/// Everything the pure replay decision needs, extracted from the plan/bundle so
4584/// the decision itself is unit-testable in isolation without a full plan.
4585struct ClosureReplayFacts {
4586    nodes: Vec<NodeReplayFacts>,
4587    oof_edges: Vec<OofEdgeReplayFacts>,
4588}
4589
4590/// Pure replay decision over already-extracted closure facts.
4591///
4592/// Canonical order is `[REFIT, PREDICT, EXPLAIN]`. A completed refit never
4593/// re-advertises REFIT; it exposes forward inference only when *every* closure
4594/// node supports the phase and every state-retaining closure node has a retained
4595/// refit artifact. A skipped refit exposes REFIT only when every closure node
4596/// supports REFIT and every closure OOF dependency is backed by an exact bundle
4597/// requirement, a retained cache record and a portable payload. An empty result
4598/// is a valid, honest "no replay mode" answer.
4599fn derive_replayable_phases_from_facts(
4600    completed_refit: bool,
4601    facts: &ClosureReplayFacts,
4602) -> Vec<Phase> {
4603    let all_support = |phase: Phase| {
4604        facts
4605            .nodes
4606            .iter()
4607            .all(|node| node.supported_phases.contains(&phase))
4608    };
4609    let inference_state_present = facts
4610        .nodes
4611        .iter()
4612        .all(|node| !node.requires_retained_state || node.has_retained_artifact);
4613    let oof_self_contained = facts.oof_edges.iter().all(|edge| {
4614        edge.has_bundle_requirement && edge.has_cache_record && edge.has_portable_payload
4615    });
4616
4617    let mut phases = Vec::new();
4618    if completed_refit {
4619        if all_support(Phase::Predict) && inference_state_present {
4620            phases.push(Phase::Predict);
4621        }
4622        if all_support(Phase::Explain) && inference_state_present {
4623            phases.push(Phase::Explain);
4624        }
4625    } else if all_support(Phase::Refit) && oof_self_contained {
4626        phases.push(Phase::Refit);
4627    }
4628    phases
4629}
4630
4631/// Extract the minimal per-node and per-OOF-edge facts the replay decision reads
4632/// from the portable outcome state. Shared by both `derive_replayable_phases`
4633/// (full derivation) and `closure_predict_replayable` (package PREDICT gate).
4634/// Fallible: a closure node absent from `node_plans` is a contract error, never a
4635/// panic.
4636fn closure_replay_facts(
4637    plan: &ExecutionPlan,
4638    closure: &BTreeSet<NodeId>,
4639    execution_bundle: &ExecutionBundle,
4640    portable_prediction_caches: Option<&BundlePredictionCachePayloadSet>,
4641) -> Result<ClosureReplayFacts> {
4642    let artifact_nodes = execution_bundle
4643        .refit_artifacts
4644        .iter()
4645        .map(|record| record.node_id.clone())
4646        .collect::<BTreeSet<_>>();
4647    let requirement_keys = execution_bundle
4648        .prediction_requirements
4649        .iter()
4650        .map(|requirement| requirement.key())
4651        .collect::<BTreeSet<_>>();
4652    let cache_keys = execution_bundle
4653        .prediction_caches
4654        .iter()
4655        .map(|record| record.requirement_key.clone())
4656        .collect::<BTreeSet<_>>();
4657    let payload_keys = portable_prediction_caches
4658        .map(|set| {
4659            set.caches
4660                .iter()
4661                .map(|payload| payload.requirement_key.clone())
4662                .collect::<BTreeSet<_>>()
4663        })
4664        .unwrap_or_default();
4665
4666    let nodes = closure
4667        .iter()
4668        .map(|node_id| {
4669            let node_plan = plan.node_plans.get(node_id).ok_or_else(|| {
4670                DagMlError::CampaignValidation(format!(
4671                    "replay derivation references absent node `{node_id}`"
4672                ))
4673            })?;
4674            // A node carries fitted state that PREDICT/EXPLAIN must reload only
4675            // when it is `stateful` or emits artifacts. `artifact_policy` is not
4676            // used: a stateless `replay_required` operator (e.g. prospectr)
4677            // re-runs its deterministic transform at inference with no artifact.
4678            let requires_retained_state = node_plan
4679                .controller_capabilities
4680                .contains(&ControllerCapability::Stateful)
4681                || node_plan
4682                    .controller_capabilities
4683                    .contains(&ControllerCapability::EmitsArtifacts);
4684            Ok(NodeReplayFacts {
4685                supported_phases: node_plan.supported_phases.clone(),
4686                requires_retained_state,
4687                has_retained_artifact: artifact_nodes.contains(node_id),
4688            })
4689        })
4690        .collect::<Result<Vec<_>>>()?;
4691    let oof_edges = plan
4692        .graph_plan
4693        .graph
4694        .edges
4695        .iter()
4696        .filter(|edge| {
4697            edge.contract.requires_oof
4698                && closure.contains(&edge.source.node_id)
4699                && closure.contains(&edge.target.node_id)
4700        })
4701        .map(|edge| {
4702            let key = crate::bundle::bundle_prediction_requirement_key(
4703                &edge.source.node_id,
4704                &edge.source.port_name,
4705                &edge.target.node_id,
4706                &edge.target.port_name,
4707            );
4708            OofEdgeReplayFacts {
4709                has_bundle_requirement: requirement_keys.contains(&key),
4710                has_cache_record: cache_keys.contains(&key),
4711                has_portable_payload: payload_keys.contains(&key),
4712            }
4713        })
4714        .collect::<Vec<_>>();
4715
4716    Ok(ClosureReplayFacts { nodes, oof_edges })
4717}
4718
4719/// Deterministically derive the phases a training outcome can honestly replay.
4720///
4721/// This is the single shared helper used by both construction and standalone
4722/// validation. It reads only portable outcome state (the effective plan's
4723/// node/controller support, the predictor closure, the retained refit artifacts,
4724/// the OOF prediction requirements/cache records and the portable payloads), so
4725/// re-running it during validation reproduces the exact vector a producer must
4726/// have emitted and rejects any forged claim.
4727fn derive_replayable_phases(
4728    plan: &ExecutionPlan,
4729    closure: &BTreeSet<NodeId>,
4730    refit: &TrainingRefitOutcome,
4731    execution_bundle: &ExecutionBundle,
4732    portable_prediction_caches: Option<&BundlePredictionCachePayloadSet>,
4733) -> Result<Vec<Phase>> {
4734    let facts = closure_replay_facts(plan, closure, execution_bundle, portable_prediction_caches)?;
4735    Ok(derive_replayable_phases_from_facts(
4736        matches!(refit.status, TrainingRefitStatus::Completed),
4737        &facts,
4738    ))
4739}
4740
4741/// True when the full predictor `closure` can honestly replay PREDICT given the
4742/// artifacts retained in `execution_bundle`: every closure node supports PREDICT
4743/// and every state-retaining closure node has a retained refit artifact. A
4744/// [`PortablePredictorPackage`](crate::training::PortablePredictorPackage) is a
4745/// deployable predictor, so its construction requires this independently — it
4746/// must not infer portability from a merely non-empty claimed phase set. PREDICT
4747/// replay never consumes OOF payloads, so the OOF cache facts are irrelevant.
4748pub(crate) fn closure_predict_replayable(
4749    plan: &ExecutionPlan,
4750    closure: &BTreeSet<NodeId>,
4751    execution_bundle: &ExecutionBundle,
4752) -> Result<bool> {
4753    let facts = closure_replay_facts(plan, closure, execution_bundle, None)?;
4754    Ok(derive_replayable_phases_from_facts(true, &facts).contains(&Phase::Predict))
4755}
4756
4757fn expected_base_influence_kind(
4758    plan: &ExecutionPlan,
4759    node_id: &NodeId,
4760) -> Option<TrainingInfluenceKind> {
4761    let node_plan = &plan.node_plans[node_id];
4762    if matches!(
4763        node_plan.fit_scope,
4764        ControllerFitScope::Stateless | ControllerFitScope::InferenceOnly
4765    ) {
4766        return None;
4767    }
4768    let oof_consumer = plan
4769        .graph_plan
4770        .graph
4771        .edges
4772        .iter()
4773        .any(|edge| edge.contract.requires_oof && edge.target.node_id == *node_id);
4774    Some(
4775        if oof_consumer
4776            || node_plan
4777                .controller_capabilities
4778                .contains(&ControllerCapability::TrainsAggregation)
4779        {
4780            TrainingInfluenceKind::TrainedMetaAggregation
4781        } else if node_plan.kind == NodeKind::Model {
4782            TrainingInfluenceKind::ModelFit
4783        } else if node_plan.kind == NodeKind::Tuner {
4784            TrainingInfluenceKind::HpoSelection
4785        } else {
4786            TrainingInfluenceKind::TransformFit
4787        },
4788    )
4789}
4790
4791fn validate_influence_against_closure(
4792    influence: &TrainingInfluenceManifest,
4793    plan: &ExecutionPlan,
4794    closure: &BTreeSet<NodeId>,
4795) -> Result<()> {
4796    let mut actual_base = BTreeMap::<NodeId, BTreeSet<TrainingInfluenceKind>>::new();
4797    for entry in &influence.entries {
4798        let Some(node_id) = &entry.node_id else {
4799            continue;
4800        };
4801        if !closure.contains(node_id) {
4802            return contract_error("training influence node is outside predictor closure");
4803        }
4804        if !influence_kind_allowed_by_node_role_or_capability(plan, node_id, entry.kind) {
4805            return contract_error(
4806                "training influence kind is not allowed by node role or capability",
4807            );
4808        }
4809        if expected_base_influence_kind(plan, node_id) == Some(entry.kind) {
4810            actual_base
4811                .entry(node_id.clone())
4812                .or_default()
4813                .insert(entry.kind);
4814        }
4815    }
4816    let expected = closure
4817        .iter()
4818        .filter(|node_id| {
4819            plan.node_plans[*node_id]
4820                .supported_phases
4821                .contains(&Phase::FitCv)
4822                && expected_base_influence_kind(plan, node_id).is_some()
4823        })
4824        .cloned()
4825        .collect::<BTreeSet<_>>();
4826    if actual_base.keys().cloned().collect::<BTreeSet<_>>() != expected {
4827        return contract_error(
4828            "training influence fitting nodes do not exactly match predictor closure",
4829        );
4830    }
4831    for node_id in expected {
4832        if actual_base[&node_id]
4833            != BTreeSet::from([expected_base_influence_kind(plan, &node_id)
4834                .expect("expected fitting nodes have a base influence kind")])
4835        {
4836            return contract_error("training influence fitting kind does not match node role");
4837        }
4838    }
4839    Ok(())
4840}
4841
4842fn influence_kind_allowed_by_node_role_or_capability(
4843    plan: &ExecutionPlan,
4844    node_id: &NodeId,
4845    kind: TrainingInfluenceKind,
4846) -> bool {
4847    if expected_base_influence_kind(plan, node_id) == Some(kind) {
4848        return true;
4849    }
4850    let capabilities = &plan.node_plans[node_id].controller_capabilities;
4851    match kind {
4852        TrainingInfluenceKind::HpoSelection => {
4853            capabilities.contains(&ControllerCapability::PerformsInternalTuning)
4854        }
4855        TrainingInfluenceKind::EarlyStopping => {
4856            capabilities.contains(&ControllerCapability::UsesEarlyStopping)
4857        }
4858        TrainingInfluenceKind::WeightingResampling => {
4859            capabilities.contains(&ControllerCapability::UsesTrainingWeights)
4860        }
4861        TrainingInfluenceKind::TransformFit
4862        | TrainingInfluenceKind::ModelFit
4863        | TrainingInfluenceKind::TrainedMetaAggregation => false,
4864    }
4865}
4866
4867fn validate_lineage_coordinates(
4868    outcome: &TrainingOutcome,
4869    closure: &BTreeSet<NodeId>,
4870    coordinates: &BTreeMap<(Phase, Option<crate::ids::FoldId>, NodeId), &LineageRecord>,
4871) -> Result<()> {
4872    let fold_set = outcome.effective_plan.fold_set.as_ref().ok_or_else(|| {
4873        DagMlError::CampaignValidation(
4874            "training outcome FIT_CV lineage requires a fold_set".to_string(),
4875        )
4876    })?;
4877    let expected_fit = closure
4878        .iter()
4879        .filter(|node_id| {
4880            outcome.effective_plan.node_plans[*node_id]
4881                .supported_phases
4882                .contains(&Phase::FitCv)
4883        })
4884        .flat_map(|node_id| {
4885            fold_set
4886                .folds
4887                .iter()
4888                .map(move |fold| (Phase::FitCv, Some(fold.fold_id.clone()), node_id.clone()))
4889        })
4890        .collect::<BTreeSet<_>>();
4891    let mut expected_fit = expected_fit;
4892    if let Some(nested) = nested_stacking_campaign_plan(&outcome.effective_plan)? {
4893        // A nested stacking campaign has two explicit FIT_CV scopes. Every
4894        // closure node still runs once on each report-grade outer fold, while
4895        // base dependencies additionally run on each parent-bound inner fold
4896        // to build the meta-model's training features. Those inner records are
4897        // required evidence, not duplicate outer-fold lineage.
4898        for outer in &nested.outer_scopes {
4899            for inner_fold in &outer.inner.inner_fold_set.folds {
4900                for node_id in nested.base_node_ids.intersection(closure) {
4901                    if outcome.effective_plan.node_plans[node_id]
4902                        .supported_phases
4903                        .contains(&Phase::FitCv)
4904                    {
4905                        expected_fit.insert((
4906                            Phase::FitCv,
4907                            Some(inner_fold.fold_id.clone()),
4908                            node_id.clone(),
4909                        ));
4910                    }
4911                }
4912            }
4913        }
4914    }
4915    let actual_fit = coordinates
4916        .keys()
4917        .filter(|(phase, _, _)| *phase == Phase::FitCv)
4918        .cloned()
4919        .collect::<BTreeSet<_>>();
4920    if actual_fit != expected_fit {
4921        return contract_error(
4922            "training outcome FIT_CV lineage does not exactly cover closure folds",
4923        );
4924    }
4925    let expected_refit = if outcome.refit.requested {
4926        closure
4927            .iter()
4928            .filter(|node_id| {
4929                outcome.effective_plan.node_plans[*node_id]
4930                    .supported_phases
4931                    .contains(&Phase::Refit)
4932            })
4933            .map(|node_id| (Phase::Refit, None, node_id.clone()))
4934            .collect::<BTreeSet<_>>()
4935    } else {
4936        BTreeSet::new()
4937    };
4938    let actual_refit = coordinates
4939        .keys()
4940        .filter(|(phase, _, _)| *phase == Phase::Refit)
4941        .cloned()
4942        .collect::<BTreeSet<_>>();
4943    if actual_refit != expected_refit {
4944        return contract_error("training outcome REFIT lineage does not exactly cover closure");
4945    }
4946
4947    for ((phase, fold, node_id), record) in coordinates {
4948        if *phase == Phase::Select {
4949            continue;
4950        }
4951        let plan = &outcome.effective_plan.node_plans[node_id];
4952        if *phase == Phase::FitCv {
4953            if let Some(nested) = nested_stacking_campaign_plan(&outcome.effective_plan)? {
4954                if *node_id == nested.meta_node_id {
4955                    let outer_fold = fold.as_ref().ok_or_else(|| {
4956                        DagMlError::CampaignValidation(
4957                            "nested stacking meta FIT_CV lineage has no outer fold".to_string(),
4958                        )
4959                    })?;
4960                    let outer = nested
4961                        .outer_scopes
4962                        .iter()
4963                        .find(|outer| outer.outer_fold_id == *outer_fold)
4964                        .ok_or_else(|| {
4965                            DagMlError::CampaignValidation(format!(
4966                                "nested stacking meta FIT_CV lineage uses unknown outer fold `{outer_fold}`"
4967                            ))
4968                        })?;
4969                    let mut expected_inputs = outcome
4970                        .effective_plan
4971                        .graph_plan
4972                        .graph
4973                        .edges
4974                        .iter()
4975                        .filter(|edge| {
4976                            edge.target.node_id == *node_id && edge.contract.requires_oof
4977                        })
4978                        .flat_map(|edge| {
4979                            outer.inner.inner_fold_set.folds.iter().map(move |inner| {
4980                                coordinates
4981                                    .get(&(
4982                                        Phase::FitCv,
4983                                        Some(inner.fold_id.clone()),
4984                                        edge.source.node_id.clone(),
4985                                    ))
4986                                    .map(|upstream| upstream.record_id.clone())
4987                                    .ok_or_else(|| {
4988                                        DagMlError::CampaignValidation(format!(
4989                                            "nested stacking lineage is missing inner upstream `{}` for outer fold `{outer_fold}`",
4990                                            edge.source.node_id
4991                                        ))
4992                                    })
4993                            })
4994                        })
4995                        .collect::<Result<Vec<LineageId>>>()?;
4996                    expected_inputs.sort();
4997                    if record.input_lineage != expected_inputs {
4998                        return contract_error(
4999                            "nested stacking meta lineage does not exactly match inner OOF inputs",
5000                        );
5001                    }
5002                    if !record.artifact_refs.is_empty() {
5003                        return contract_error("FIT_CV lineage must not retain refit artifacts");
5004                    }
5005                    continue;
5006                }
5007            }
5008        }
5009        let expected_inputs = plan
5010            .input_nodes
5011            .iter()
5012            .filter(|input| {
5013                outcome.effective_plan.node_plans[*input]
5014                    .supported_phases
5015                    .contains(phase)
5016            })
5017            .map(|input| {
5018                coordinates
5019                    .get(&(*phase, fold.clone(), input.clone()))
5020                    .map(|upstream| upstream.record_id.clone())
5021                    .ok_or_else(|| {
5022                        DagMlError::CampaignValidation(format!(
5023                            "training lineage is missing upstream `{input}`"
5024                        ))
5025                    })
5026            })
5027            .collect::<Result<Vec<LineageId>>>()?;
5028        let mut expected_inputs = expected_inputs;
5029        expected_inputs.sort();
5030        if record.input_lineage != expected_inputs {
5031            return contract_error(
5032                "training outcome lineage input_lineage does not exactly match plan",
5033            );
5034        }
5035        if *phase == Phase::FitCv && !record.artifact_refs.is_empty() {
5036            return contract_error("FIT_CV lineage must not retain refit artifacts");
5037        }
5038        if *phase == Phase::Refit {
5039            let mut expected_artifacts = outcome
5040                .execution_bundle
5041                .refit_artifacts
5042                .iter()
5043                .filter(|artifact| artifact.node_id == *node_id)
5044                .map(|artifact| artifact.artifact.clone())
5045                .collect::<Vec<_>>();
5046            expected_artifacts.sort_by(|left, right| left.id.cmp(&right.id));
5047            let mut actual_artifacts = record.artifact_refs.clone();
5048            actual_artifacts.sort_by(|left, right| left.id.cmp(&right.id));
5049            if actual_artifacts != expected_artifacts {
5050                return contract_error("REFIT lineage artifact_refs do not match execution bundle");
5051            }
5052        }
5053    }
5054    Ok(())
5055}
5056
5057fn tcv1_fingerprint<T: Serialize + ?Sized>(value: &T, label: &str) -> Result<String> {
5058    let json = serde_json::to_string(value)?;
5059    parse_typed_json(&json)
5060        .map_err(|error| {
5061            DagMlError::CampaignValidation(format!("{label} is not valid TCV1: {error}"))
5062        })?
5063        .fingerprint()
5064        .map_err(|error| {
5065            DagMlError::CampaignValidation(format!("{label} TCV1 fingerprint failed: {error}"))
5066        })
5067}
5068
5069fn tcv1_fingerprint_without<T: Serialize>(value: &T, field: &str, label: &str) -> Result<String> {
5070    let json = serde_json::to_string(value)?;
5071    parse_typed_json(&json)
5072        .map_err(|error| {
5073            DagMlError::CampaignValidation(format!("{label} is not valid TCV1: {error}"))
5074        })?
5075        .fingerprint_without(field)
5076        .map_err(|error| {
5077            DagMlError::CampaignValidation(format!("{label} TCV1 fingerprint failed: {error}"))
5078        })
5079}
5080
5081fn validate_sha256(label: &str, value: &str) -> Result<()> {
5082    if value.len() != 64
5083        || !value
5084            .bytes()
5085            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
5086    {
5087        return contract_error(format!("{label} must be lowercase sha256"));
5088    }
5089    Ok(())
5090}
5091
5092fn validate_all_identity_relations(
5093    identities: &[TrainingDataIdentity],
5094    relation_fingerprint: &str,
5095) -> Result<()> {
5096    if identities
5097        .iter()
5098        .any(|identity| identity.relation_fingerprint != relation_fingerprint)
5099    {
5100        return contract_error(
5101            "training outcome data identities do not all bind the influence relation",
5102        );
5103    }
5104    Ok(())
5105}
5106
5107fn validate_sorted_unique_text(label: &str, values: &[String]) -> Result<()> {
5108    if values.iter().any(|value| value.trim().is_empty()) {
5109        return contract_error(format!("{label} contains an empty value"));
5110    }
5111    if values.windows(2).any(|pair| pair[0] >= pair[1]) {
5112        return contract_error(format!("{label} must be strictly sorted and unique"));
5113    }
5114    Ok(())
5115}
5116
5117fn contract_error<T>(message: impl Into<String>) -> Result<T> {
5118    Err(DagMlError::CampaignValidation(message.into()))
5119}
5120
5121#[cfg(test)]
5122mod replay_phase_tests {
5123    use super::{
5124        derive_replayable_phases_from_facts, ClosureReplayFacts, NodeReplayFacts,
5125        OofEdgeReplayFacts,
5126    };
5127    use crate::phase::Phase;
5128    use std::collections::BTreeSet;
5129
5130    fn node(
5131        supported: &[Phase],
5132        requires_retained_state: bool,
5133        has_retained_artifact: bool,
5134    ) -> NodeReplayFacts {
5135        NodeReplayFacts {
5136            supported_phases: supported.iter().copied().collect::<BTreeSet<_>>(),
5137            requires_retained_state,
5138            has_retained_artifact,
5139        }
5140    }
5141
5142    fn oof(
5143        has_bundle_requirement: bool,
5144        has_cache_record: bool,
5145        has_portable_payload: bool,
5146    ) -> OofEdgeReplayFacts {
5147        OofEdgeReplayFacts {
5148            has_bundle_requirement,
5149            has_cache_record,
5150            has_portable_payload,
5151        }
5152    }
5153
5154    // Completed refit whose full closure supports both forward phases and whose
5155    // state-retaining nodes (`Stateful || EmitsArtifacts`) all have a retained
5156    // artifact exposes PREDICT then EXPLAIN in canonical order and never
5157    // re-advertises REFIT.
5158    #[test]
5159    fn completed_refit_full_support_matrix_predict_then_explain() {
5160        let facts = ClosureReplayFacts {
5161            nodes: vec![
5162                node(
5163                    &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
5164                    true,
5165                    true,
5166                ),
5167                node(
5168                    &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
5169                    true,
5170                    true,
5171                ),
5172            ],
5173            oof_edges: vec![],
5174        };
5175        assert_eq!(
5176            derive_replayable_phases_from_facts(true, &facts),
5177            vec![Phase::Predict, Phase::Explain]
5178        );
5179    }
5180
5181    // The current completed-refit fixture: every closure node supports
5182    // FIT_CV/REFIT/PREDICT but not EXPLAIN, so only PREDICT is honest.
5183    #[test]
5184    fn completed_refit_predict_only_when_explain_unsupported() {
5185        let facts = ClosureReplayFacts {
5186            nodes: vec![
5187                node(&[Phase::FitCv, Phase::Refit, Phase::Predict], true, true),
5188                // A train-only augmentation node emits no artifact, so it does not
5189                // require retained inference state and must not block PREDICT.
5190                node(&[Phase::FitCv, Phase::Refit, Phase::Predict], false, false),
5191            ],
5192            oof_edges: vec![],
5193        };
5194        assert_eq!(
5195            derive_replayable_phases_from_facts(true, &facts),
5196            vec![Phase::Predict]
5197        );
5198    }
5199
5200    // A downstream node supporting PREDICT cannot rescue an upstream required
5201    // node that does not support it: the whole closure must support the phase.
5202    #[test]
5203    fn upstream_node_missing_phase_blocks_whole_closure() {
5204        let facts = ClosureReplayFacts {
5205            nodes: vec![
5206                // downstream predictor supports PREDICT and EXPLAIN
5207                node(
5208                    &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
5209                    true,
5210                    true,
5211                ),
5212                // upstream required transform supports neither
5213                node(&[Phase::FitCv, Phase::Refit], false, false),
5214            ],
5215            oof_edges: vec![],
5216        };
5217        assert_eq!(
5218            derive_replayable_phases_from_facts(true, &facts),
5219            Vec::<Phase>::new()
5220        );
5221    }
5222
5223    // A completed refit whose closure supports PREDICT but is missing the
5224    // retained artifact of a state-retaining node (here `requires_retained_state`)
5225    // has no honest replay mode: [] is the correct, preferable answer.
5226    #[test]
5227    fn completed_refit_missing_artifact_yields_empty() {
5228        let facts = ClosureReplayFacts {
5229            nodes: vec![node(
5230                &[Phase::FitCv, Phase::Refit, Phase::Predict],
5231                true,
5232                false,
5233            )],
5234            oof_edges: vec![],
5235        };
5236        assert_eq!(
5237            derive_replayable_phases_from_facts(true, &facts),
5238            Vec::<Phase>::new()
5239        );
5240    }
5241
5242    // No-refit outcome never advertises PREDICT/EXPLAIN even when supported, and
5243    // advertises REFIT only when every OOF dependency is fully self-contained
5244    // (exact bundle requirement + cache record + portable payload).
5245    #[test]
5246    fn no_refit_refit_requires_self_contained_oof_payload() {
5247        let supported = [Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain];
5248        let backed = ClosureReplayFacts {
5249            nodes: vec![node(&supported, true, false), node(&supported, true, false)],
5250            oof_edges: vec![oof(true, true, true)],
5251        };
5252        assert_eq!(
5253            derive_replayable_phases_from_facts(false, &backed),
5254            vec![Phase::Refit]
5255        );
5256
5257        // Missing portable payload -> not self-contained -> [].
5258        let missing_payload = ClosureReplayFacts {
5259            nodes: vec![node(&supported, true, false), node(&supported, true, false)],
5260            oof_edges: vec![oof(true, true, false)],
5261        };
5262        assert_eq!(
5263            derive_replayable_phases_from_facts(false, &missing_payload),
5264            Vec::<Phase>::new()
5265        );
5266
5267        // Missing cache record -> [].
5268        let missing_record = ClosureReplayFacts {
5269            nodes: vec![node(&supported, true, false)],
5270            oof_edges: vec![oof(true, false, true)],
5271        };
5272        assert_eq!(
5273            derive_replayable_phases_from_facts(false, &missing_record),
5274            Vec::<Phase>::new()
5275        );
5276    }
5277
5278    // A no-refit outcome with no OOF edges is vacuously self-contained: REFIT can
5279    // re-fit from data alone, so REFIT is honest when every node supports it.
5280    #[test]
5281    fn no_refit_without_oof_edges_is_vacuously_refit() {
5282        let facts = ClosureReplayFacts {
5283            nodes: vec![node(
5284                &[Phase::FitCv, Phase::Refit, Phase::Predict],
5285                false,
5286                false,
5287            )],
5288            oof_edges: vec![],
5289        };
5290        assert_eq!(
5291            derive_replayable_phases_from_facts(false, &facts),
5292            vec![Phase::Refit]
5293        );
5294    }
5295
5296    // A no-refit closure that does not fully support REFIT yields [].
5297    #[test]
5298    fn no_refit_without_refit_support_yields_empty() {
5299        let facts = ClosureReplayFacts {
5300            nodes: vec![
5301                node(&[Phase::FitCv, Phase::Refit], false, false),
5302                node(&[Phase::FitCv, Phase::Predict], false, false),
5303            ],
5304            oof_edges: vec![],
5305        };
5306        assert_eq!(
5307            derive_replayable_phases_from_facts(false, &facts),
5308            Vec::<Phase>::new()
5309        );
5310    }
5311
5312    // A stateless `replay_required` operator (e.g. prospectr): it is neither
5313    // `stateful` nor an artifact emitter, so `requires_retained_state` is false
5314    // and it stays PREDICT-replayable with no retained artifact — the operation
5315    // simply replays its deterministic transform at inference time.
5316    #[test]
5317    fn stateless_replay_required_operator_without_artifact_stays_predict_replayable() {
5318        let facts = ClosureReplayFacts {
5319            nodes: vec![node(
5320                &[Phase::FitCv, Phase::Refit, Phase::Predict],
5321                false,
5322                false,
5323            )],
5324            oof_edges: vec![],
5325        };
5326        assert_eq!(
5327            derive_replayable_phases_from_facts(true, &facts),
5328            vec![Phase::Predict]
5329        );
5330    }
5331
5332    // A `stateful` (or artifact-emitting) node that has no retained artifact
5333    // carries no reloadable inference state, so PREDICT must not be advertised.
5334    #[test]
5335    fn stateful_non_emitter_without_artifact_cannot_advertise_predict() {
5336        let facts = ClosureReplayFacts {
5337            nodes: vec![node(
5338                &[Phase::FitCv, Phase::Refit, Phase::Predict],
5339                true,
5340                false,
5341            )],
5342            oof_edges: vec![],
5343        };
5344        assert_eq!(
5345            derive_replayable_phases_from_facts(true, &facts),
5346            Vec::<Phase>::new()
5347        );
5348    }
5349}
5350
5351#[cfg(test)]
5352mod tests {
5353    use super::*;
5354
5355    #[cfg(dag_ml_workspace_contract_fixtures)]
5356    const REFIT_FIXTURE: &str =
5357        include_str!("../../../examples/fixtures/estimator/training_outcome_refit.v1.json");
5358    #[cfg(dag_ml_workspace_contract_fixtures)]
5359    const NO_REFIT_FIXTURE: &str =
5360        include_str!("../../../examples/fixtures/estimator/training_outcome_no_refit.v1.json");
5361
5362    #[test]
5363    fn cv_ensemble_partition_truth_table_retains_validation_only() {
5364        for (partition, expected) in [
5365            (PredictionPartition::Validation, true),
5366            (PredictionPartition::Train, false),
5367            (PredictionPartition::Test, false),
5368            (PredictionPartition::Final, false),
5369        ] {
5370            assert_eq!(
5371                is_cv_ensemble_partition(&partition),
5372                expected,
5373                "unexpected CvEnsemble retention decision for {partition:?}"
5374            );
5375        }
5376    }
5377
5378    #[cfg(dag_ml_workspace_contract_fixtures)]
5379    #[test]
5380    fn independent_w0_training_outcomes_parse_and_round_trip_fingerprint() {
5381        for fixture in [REFIT_FIXTURE, NO_REFIT_FIXTURE] {
5382            let outcome = TrainingOutcome::from_json(fixture).expect("valid W0 outcome");
5383            assert_eq!(
5384                outcome.compute_fingerprint().unwrap(),
5385                outcome.outcome_fingerprint
5386            );
5387            let serialized = serde_json::to_string(&outcome).unwrap();
5388            let reparsed = TrainingOutcome::from_json(&serialized).unwrap();
5389            assert_eq!(reparsed, outcome);
5390        }
5391    }
5392
5393    #[cfg(dag_ml_workspace_contract_fixtures)]
5394    #[test]
5395    fn strict_parser_rejects_tamper_and_unknown_field() {
5396        let mut tampered: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
5397        tampered["warnings"] = serde_json::json!(["tampered"]);
5398        assert!(TrainingOutcome::from_json(&serde_json::to_string(&tampered).unwrap()).is_err());
5399
5400        let mut unknown: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
5401        unknown["unknown_field"] = serde_json::json!(true);
5402        assert!(TrainingOutcome::from_json(&serde_json::to_string(&unknown).unwrap()).is_err());
5403    }
5404
5405    #[cfg(dag_ml_workspace_contract_fixtures)]
5406    #[test]
5407    fn outcome_rejects_nested_runtime_handle_keys_defense_in_depth() {
5408        let mut outcome = TrainingOutcome::from_json(REFIT_FIXTURE).unwrap();
5409        outcome.diagnostics.insert(
5410            "nested".to_string(),
5411            serde_json::json!({"runtime_handle": "process-local"}),
5412        );
5413        outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
5414        let error = outcome.validate().unwrap_err();
5415        assert!(error.to_string().contains("runtime handles"), "{error}");
5416    }
5417
5418    #[cfg(dag_ml_workspace_contract_fixtures)]
5419    #[test]
5420    fn strict_parser_rejects_future_version_even_when_resigned() {
5421        let mut future: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
5422        future["schema_version"] = serde_json::json!(2);
5423        let mut provisional: TrainingOutcome = serde_json::from_value(future.clone()).unwrap();
5424        provisional.outcome_fingerprint = provisional.compute_fingerprint().unwrap();
5425        future["outcome_fingerprint"] =
5426            serde_json::Value::String(provisional.outcome_fingerprint.clone());
5427        assert!(TrainingOutcome::from_json(&serde_json::to_string(&future).unwrap()).is_err());
5428    }
5429
5430    #[cfg(dag_ml_workspace_contract_fixtures)]
5431    #[test]
5432    fn select_lineage_is_portable_but_foreign_phase_is_rejected() {
5433        let mut outcome = TrainingOutcome::from_json(REFIT_FIXTURE).unwrap();
5434        let mut select = outcome.lineage[0].clone();
5435        select.record_id = LineageId::new("lineage:select:audit").unwrap();
5436        select.phase = Phase::Select;
5437        select.fold_id = None;
5438        select.input_lineage.clear();
5439        select.artifact_refs.clear();
5440        outcome.lineage.push(select.clone());
5441        outcome
5442            .lineage
5443            .sort_by(|left, right| left.record_id.cmp(&right.record_id));
5444        outcome.outcome_fingerprint = zero_fingerprint();
5445        outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
5446        outcome.validate().unwrap();
5447
5448        let added = outcome
5449            .lineage
5450            .iter_mut()
5451            .find(|record| record.record_id.as_str() == "lineage:select:audit")
5452            .unwrap();
5453        added.phase = Phase::Predict;
5454        added.record_id = LineageId::new("lineage:predict:foreign").unwrap();
5455        outcome
5456            .lineage
5457            .sort_by(|left, right| left.record_id.cmp(&right.record_id));
5458        outcome.outcome_fingerprint = zero_fingerprint();
5459        outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
5460        assert!(outcome.validate().is_err());
5461    }
5462
5463    #[test]
5464    fn every_data_identity_must_bind_the_global_relation() {
5465        let relation = "a".repeat(64);
5466        let identity = |key: &str, relation_fingerprint: String| TrainingDataIdentity {
5467            requirement_key: key.to_string(),
5468            schema_fingerprint: "b".repeat(64),
5469            plan_fingerprint: "c".repeat(64),
5470            relation_fingerprint,
5471            data_content_fingerprint: "d".repeat(64),
5472            target_content_fingerprint: "e".repeat(64),
5473            identity_fingerprint: "f".repeat(64),
5474        };
5475        let identities = vec![
5476            identity("model:a.x", relation.clone()),
5477            identity("model:b.x", "9".repeat(64)),
5478        ];
5479        assert!(validate_all_identity_relations(&identities, &relation).is_err());
5480        let identities = vec![
5481            identity("model:a.x", relation.clone()),
5482            identity("model:b.x", relation.clone()),
5483        ];
5484        validate_all_identity_relations(&identities, &relation).unwrap();
5485    }
5486
5487    #[test]
5488    fn auxiliary_report_levels_do_not_override_selection_target_level() {
5489        let report = |producer: &str, level| crate::metrics::RegressionMetricReport {
5490            prediction_id: Some(format!("prediction:{producer}")),
5491            producer_node: NodeId::new(producer).unwrap(),
5492            producer_port: None,
5493            variant_id: Some(VariantId::new("variant:test").unwrap()),
5494            variant_label: None,
5495            partition: PredictionPartition::Validation,
5496            fold_id: Some(crate::ids::FoldId::new("avg").unwrap()),
5497            level,
5498            row_count: 2,
5499            target_width: 1,
5500            target_names: vec!["y".to_string()],
5501            metrics: BTreeMap::from([("rmse".to_string(), 0.1)]),
5502        };
5503        let reports = vec![
5504            report("model:target", PredictionLevel::Sample),
5505            report("model:target", PredictionLevel::Group),
5506            report("model:aux", PredictionLevel::Group),
5507        ];
5508        validate_selection_report_levels(
5509            &reports,
5510            &NodeId::new("model:target").unwrap(),
5511            &None,
5512            PredictionLevel::Sample,
5513        )
5514        .unwrap();
5515        assert!(validate_selection_report_levels(
5516            &reports,
5517            &NodeId::new("model:target").unwrap(),
5518            &None,
5519            PredictionLevel::Target,
5520        )
5521        .is_err());
5522    }
5523}