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