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