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            || self.effective_plan_fingerprint != recipe.effective_plan_fingerprint
990        {
991            return contract_error(
992                "portable refit execution bundle does not exactly bind the recipe effective plan"
993                    .to_string(),
994            );
995        }
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            || self.effective_plan_fingerprint != self.recipe.effective_plan_fingerprint
1183        {
1184            return contract_error(
1185                "portable refit outcome effective plan does not exactly match its recipe"
1186                    .to_string(),
1187            );
1188        }
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 mut bundle = PortableRefitExecutionBundleV3 {
1417        schema_version: PORTABLE_REFIT_EXECUTION_BUNDLE_V3_SCHEMA_VERSION,
1418        bundle_id: input.bundle_id,
1419        effective_plan_fingerprint: tcv1_fingerprint(
1420            input.target_plan,
1421            "portable refit package V3 effective plan",
1422        )?,
1423        selected_variant_id: input.recipe.selected_variant_id.clone(),
1424        refit_artifacts: input.execution.refit_artifacts.clone(),
1425        raw_artifact_payloads: input.execution.raw_artifact_payloads.clone(),
1426        bundle_fingerprint: zero_fingerprint(),
1427    };
1428    bundle.bundle_fingerprint = bundle.compute_fingerprint()?;
1429    let mut outcome = PortableRefitOutcomeV3 {
1430        schema_version: PORTABLE_REFIT_OUTCOME_V3_SCHEMA_VERSION,
1431        outcome_id: input.outcome_id,
1432        run_id: input.execution.run_id.clone(),
1433        recipe: input.recipe.clone(),
1434        provenance: input.execution.provenance.clone(),
1435        target_training_request: input.target_training_request.clone(),
1436        effective_plan: input.target_plan.clone(),
1437        effective_plan_fingerprint: bundle.effective_plan_fingerprint.clone(),
1438        selected_variant_id: input.recipe.selected_variant_id.clone(),
1439        selected_variant_fingerprint: input.recipe.selected_variant_fingerprint.clone(),
1440        output_bindings: input.source_package.output_bindings.clone(),
1441        predictor_node_ids: input.source_package.predictor_node_ids.clone(),
1442        data_identities: input.target_data_identities.to_vec(),
1443        training_influence: input.target_training_influence.clone(),
1444        execution_bundle: bundle,
1445        outcome_fingerprint: zero_fingerprint(),
1446    };
1447    outcome.outcome_fingerprint = outcome.compute_fingerprint()?;
1448    let mut package = PortableRefitPackageV3 {
1449        schema_version: PORTABLE_REFIT_PACKAGE_V3_SCHEMA_VERSION,
1450        package_id: input.package_id,
1451        outcome,
1452        package_fingerprint: zero_fingerprint(),
1453    };
1454    package.package_fingerprint = package.compute_fingerprint()?;
1455    package.validate()?;
1456    Ok(package)
1457}
1458
1459/// Native artifacts and execution evidence produced by the first, scheduler
1460/// only step of a V3 full refit.  The V3 outcome/package writer consumes this
1461/// result to create a new durable child; it must not mutate the parent.
1462#[derive(Clone, Debug)]
1463pub struct PortableFullRefitExecution {
1464    pub run_id: RunId,
1465    pub provenance: PortableRefitProvenance,
1466    pub results: Vec<NodeResult>,
1467    pub refit_artifacts: Vec<crate::bundle::RefitArtifactRecord>,
1468    /// Raw bytes are detached from their process-local controller immediately
1469    /// after the refit phase.  A future Package/Archive V3 writer consumes
1470    /// this map atomically with `refit_artifacts`; it must never ask a source
1471    /// controller to re-export an artifact after the execution has ended.
1472    pub raw_artifact_payloads: BTreeMap<ArtifactId, Vec<u8>>,
1473}
1474
1475/// Execute exactly one portable native full refit from a closed recipe.
1476///
1477/// The function has no V2 replay input and uses the scheduler's ordinary
1478/// `REFIT` phase directly. All source/recipe/cohort checks occur before the
1479/// data provider is queried. It intentionally returns execution evidence
1480/// rather than synthesising a TrainingOutcome: V3 persistence must add the
1481/// new outcome/bundle/package atomically in its owning writer.
1482pub fn execute_portable_full_refit(
1483    input: PortableFullRefitExecutionInput<'_>,
1484) -> Result<PortableFullRefitExecution> {
1485    input
1486        .recipe
1487        .validate_against_source_package(input.source_package)?;
1488    input.target_training_request.validate()?;
1489    if input.target_training_request.request_fingerprint
1490        != input.target_training_request_fingerprint
1491        || input.target_training_request.data_identities != input.target_data_identities
1492    {
1493        return contract_error(
1494            "portable full refit target request does not exactly bind provided cohort evidence"
1495                .to_string(),
1496        );
1497    }
1498    let provenance = PortableRefitProvenance::from_target_cohort(
1499        input.recipe,
1500        input.target_training_request_fingerprint,
1501        input.target_data_identities,
1502        input.target_training_influence,
1503    )?;
1504    input.target_plan.validate()?;
1505    let target_plan_fingerprint =
1506        tcv1_fingerprint(input.target_plan, "portable full refit effective plan")?;
1507    if target_plan_fingerprint != input.recipe.effective_plan_fingerprint {
1508        return contract_error(
1509            "portable full refit target plan does not exactly match the parent recipe".to_string(),
1510        );
1511    }
1512    let selected_variant = input
1513        .target_plan
1514        .variants
1515        .iter()
1516        .find(|variant| variant.variant_id == input.recipe.selected_variant_id)
1517        .ok_or_else(|| {
1518            DagMlError::RuntimeValidation(
1519                "portable full refit selected variant is absent from target plan".to_string(),
1520            )
1521        })?;
1522    if selected_variant.fingerprint != input.recipe.selected_variant_fingerprint {
1523        return contract_error(
1524            "portable full refit target selected variant does not match parent recipe".to_string(),
1525        );
1526    }
1527    let mut ctx = RunContext::new(input.run_id.clone(), input.target_plan.campaign.root_seed);
1528    ctx.variant_id = Some(input.recipe.selected_variant_id.clone());
1529    let mut artifact_store = InMemoryArtifactStore::new();
1530    let results = SequentialScheduler
1531        .execute_campaign_phase_with_data_provider_and_artifact_store(
1532            input.target_plan,
1533            input.controllers,
1534            input.data_provider,
1535            &mut artifact_store,
1536            &mut ctx,
1537            Phase::Refit,
1538        )?;
1539    let refit_artifacts = artifact_store.refit_artifacts();
1540    if refit_artifacts.is_empty() {
1541        return contract_error(
1542            "portable full refit produced no durable native artifact".to_string(),
1543        );
1544    }
1545    let mut raw_artifact_payloads = BTreeMap::new();
1546    for record in &refit_artifacts {
1547        record.validate()?;
1548        let controller = input
1549            .controllers
1550            .get(&record.controller_id)
1551            .ok_or_else(|| {
1552                DagMlError::RuntimeValidation(format!(
1553                "portable full refit artifact `{}` has no registered controller `{}` for export",
1554                record.artifact.id, record.controller_id
1555            ))
1556            })?;
1557        let payload = controller
1558            .export_artifact_payload(&record.artifact.id)?
1559            .ok_or_else(|| {
1560                DagMlError::RuntimeValidation(format!(
1561                    "portable full refit controller `{}` did not export durable payload `{}`",
1562                    record.controller_id, record.artifact.id
1563                ))
1564            })?;
1565        if raw_artifact_payloads
1566            .insert(record.artifact.id.clone(), payload)
1567            .is_some()
1568        {
1569            return contract_error(
1570                "portable full refit produced duplicate durable artifact identifiers".to_string(),
1571            );
1572        }
1573    }
1574    Ok(PortableFullRefitExecution {
1575        run_id: input.run_id,
1576        provenance,
1577        results,
1578        refit_artifacts,
1579        raw_artifact_payloads,
1580    })
1581}
1582
1583/// Execute COMPILE/PLAN -> FIT_CV -> SELECT -> optional REFIT and return the
1584/// complete portable W0 outcome.
1585///
1586/// Variant candidates are evaluated by the existing native selection helper;
1587/// the winner is then rerun once in a retained context so its lineage, OOF
1588/// caches, bound outputs, and optional refit artifacts all originate from one
1589/// auditable execution. `SELECT` is called exactly once and `REFIT` at most once.
1590pub fn execute_training(input: TrainingExecutionInput<'_>) -> Result<TrainingOutcome> {
1591    if !input.artifact_store.is_empty() {
1592        return Err(DagMlError::RuntimeValidation(
1593            "native training requires an empty artifact store for an isolated outcome".to_string(),
1594        ));
1595    }
1596    RunId::new(input.outcome_id.clone()).map_err(|error| {
1597        DagMlError::RuntimeValidation(format!(
1598            "native training outcome_id is not a portable identifier: {error}"
1599        ))
1600    })?;
1601    validate_sorted_unique_text("training execution warnings", &input.warnings)?;
1602    if contains_runtime_handle(&serde_json::Value::Object(
1603        input.diagnostics.clone().into_iter().collect(),
1604    )) {
1605        return Err(DagMlError::RuntimeValidation(
1606            "native training diagnostics cannot contain runtime handles".to_string(),
1607        ));
1608    }
1609
1610    let mut projection = input.request.project()?;
1611    projection.plan = materialize_request_parameter_patches(projection.plan, input.request)?;
1612    projection.validate()?;
1613    validate_native_training_options(input.request)?;
1614    input.training_influence.validate_for_projection(
1615        &projection,
1616        input.request,
1617        input.relations,
1618    )?;
1619    let runtime_training_influence = TrainingInfluenceManifest::derive_for_projection(
1620        &projection,
1621        input.request,
1622        input.relations,
1623    )?;
1624    if input.training_influence != &runtime_training_influence {
1625        return Err(DagMlError::RuntimeValidation(
1626            "native training influence manifest does not match runtime-derived evidence"
1627                .to_string(),
1628        ));
1629    }
1630    // Native HPO is preflighted before provider attestation/materialization so
1631    // unsupported model or tuning descriptors cannot incur any data cost.
1632    let native_hpo_descriptor = HpoExecutionContext {
1633        request: input.request,
1634        projection: &projection,
1635        controllers: input.controllers,
1636        data_provider: input.data_provider,
1637        relations: input.relations,
1638        training_influence: &runtime_training_influence,
1639        selection: &input.request.options.selection,
1640    }
1641    .preflight()?;
1642    validate_provider_attestations(
1643        &projection,
1644        input.request,
1645        input.data_provider,
1646        input.relations,
1647    )?;
1648    for node_plan in projection.plan.node_plans.values() {
1649        if input.controllers.get(&node_plan.controller_id).is_none() {
1650            return Err(DagMlError::RuntimeValidation(format!(
1651                "native training controller `{}` for node `{}` is not registered",
1652                node_plan.controller_id, node_plan.node_id
1653            )));
1654        }
1655    }
1656    let executable_nodes = projection
1657        .plan
1658        .node_plans
1659        .values()
1660        .filter(|node| !node.supported_phases.is_empty())
1661        .map(|node| node.node_id.clone())
1662        .collect::<BTreeSet<_>>();
1663    if projection.predictor_node_ids != executable_nodes {
1664        return Err(DagMlError::RuntimeValidation(
1665            "native training currently requires the predictor closure to equal the executable plan; refusing to persist unrelated nodes"
1666                .to_string(),
1667        ));
1668    }
1669    if projection.plan.variants.iter().any(|variant| {
1670        variant
1671            .choices
1672            .values()
1673            .any(|choice| !choice.param_overrides.is_empty())
1674    }) && !input
1675        .training_influence
1676        .entries
1677        .iter()
1678        .any(|entry| entry.kind == TrainingInfluenceKind::HpoSelection)
1679    {
1680        return Err(DagMlError::RuntimeValidation(
1681            "selectable parameter overrides require predeclared hpo_selection influence"
1682                .to_string(),
1683        ));
1684    }
1685    let scheduler = NativeTrainingScheduler::from_request(input.request)?;
1686    let selection_metric = parse_selection_metric(input.request)?;
1687    let metric_level = effective_selection_metric_level(input.request)?;
1688    let selection_output = projection
1689        .outputs
1690        .iter()
1691        .find(|output| output.output_id == input.request.options.selection_output_id)
1692        .ok_or_else(|| {
1693            DagMlError::RuntimeValidation(
1694                "training selection output was not resolved by projection".to_string(),
1695            )
1696        })?;
1697    let selection_output_id = selection_output.output_id.clone();
1698    let selection_producer = selection_output.node_id.clone();
1699    let selection_producer_port = selection_output.port_name.clone();
1700    validate_selection_prediction_kind(selection_metric, selection_output.prediction_kind)?;
1701    #[cfg(feature = "methods-optimizer")]
1702    let mut methods_hpo_resume_state = None;
1703    #[cfg(feature = "methods-optimizer")]
1704    #[cfg(feature = "methods-optimizer")]
1705    let selection = if let Some(descriptor) = native_hpo_descriptor.as_ref() {
1706        let hpo_execution = HpoExecutionContext {
1707            request: input.request,
1708            projection: &projection,
1709            controllers: input.controllers,
1710            data_provider: input.data_provider,
1711            relations: input.relations,
1712            training_influence: &runtime_training_influence,
1713            selection: &input.request.options.selection,
1714        };
1715        let (context, previous_resume_state) = hpo_execution.runtime_context(
1716            descriptor,
1717            selection_metric,
1718            &selection_producer,
1719            &selection_producer_port,
1720        )?;
1721        let campaign_context =
1722            RunContext::new(input.run_id.clone(), Some(input.request.options.seed));
1723        let campaign = SequentialScheduler.execute_hpo_campaign(
1724            &projection.plan,
1725            input.controllers,
1726            input.data_provider,
1727            &campaign_context,
1728            &context,
1729        )?;
1730        let (plan, selection, resume_state) =
1731            hpo_execution.selection_from_campaign(&context, previous_resume_state, campaign)?;
1732        projection.plan = plan;
1733        methods_hpo_resume_state = Some(resume_state);
1734        selection
1735    } else {
1736        select_best_variant_outcome_by_cv_for_target(
1737            &projection.plan,
1738            &input.run_id,
1739            Some(input.request.options.seed),
1740            selection_metric,
1741            &selection_producer,
1742            Some(selection_producer_port.as_str()),
1743            metric_level,
1744            |candidate_plan, candidate_ctx| {
1745                scheduler
1746                    .fit_cv(candidate_plan, input.controllers, input.data_provider, candidate_ctx)
1747                    .map(|_| ())
1748            },
1749        )?
1750        .ok_or_else(|| DagMlError::RuntimeValidation(
1751            "native training SELECT received no scored candidate; controllers must emit targets".to_string(),
1752        ))?
1753    };
1754    #[cfg(not(feature = "methods-optimizer"))]
1755    let selection = {
1756        let _ = native_hpo_descriptor;
1757        select_best_variant_outcome_by_cv_for_target(
1758            &projection.plan,
1759            &input.run_id,
1760            Some(input.request.options.seed),
1761            selection_metric,
1762            &selection_producer,
1763            Some(selection_producer_port.as_str()),
1764            metric_level,
1765            |candidate_plan, candidate_ctx| {
1766                scheduler
1767                    .fit_cv(candidate_plan, input.controllers, input.data_provider, candidate_ctx)
1768                    .map(|_| ())
1769            },
1770        )?
1771        .ok_or_else(|| DagMlError::RuntimeValidation(
1772            "native training SELECT received no scored candidate; controllers must emit targets".to_string(),
1773        ))?
1774    };
1775
1776    validate_selection_report_levels(
1777        &selection.selection.validation_reports,
1778        &selection_producer,
1779        &Some(selection_producer_port.clone()),
1780        metric_level,
1781    )?;
1782    let mut decision = selection.decision;
1783    bind_selection_decision(&mut decision, input.request, metric_level)?;
1784    let selected_variant_id = selection.selection.selected_variant_id;
1785    let effective_plan = materialize_selected_variant(projection.plan, &selected_variant_id)?;
1786    // Keep the original union variants for replay/identity while pinning every
1787    // retained execution through RunContext.variant_id.
1788    let selected_variant = effective_plan
1789        .variants
1790        .iter()
1791        .find(|variant| variant.variant_id == selected_variant_id)
1792        .cloned()
1793        .ok_or_else(|| {
1794            DagMlError::RuntimeValidation(
1795                "selected variant disappeared while materializing the plan".to_string(),
1796            )
1797        })?;
1798    effective_plan.validate()?;
1799
1800    let mut selected_ctx = RunContext::new(input.run_id.clone(), Some(input.request.options.seed));
1801    selected_ctx.variant_id = Some(selected_variant_id.clone());
1802    let fit_cv_results = scheduler.fit_cv(
1803        &effective_plan,
1804        input.controllers,
1805        input.data_provider,
1806        &mut selected_ctx,
1807    )?;
1808    selected_ctx.collect_cross_fold_validation_scores(plan_oof_partition_mode(&effective_plan))?;
1809    validate_selected_rerun_reports(
1810        &selection.selection.validation_reports,
1811        &selected_ctx.score_collector,
1812        &selected_variant_id,
1813    )?;
1814
1815    let score_set = ScoreSet {
1816        schema_version: SCORE_SET_SCHEMA_VERSION,
1817        plan_id: effective_plan.id.clone(),
1818        selection_metric: Some(selection_metric.name().to_string()),
1819        reports: selection.selection.validation_reports,
1820    };
1821    score_set.validate()?;
1822
1823    let prediction_requirements = build_oof_prediction_requirements(
1824        &effective_plan,
1825        selected_ctx.prediction_store.blocks(),
1826        selected_ctx.aggregated_prediction_store.blocks(),
1827    )?;
1828    let retain_caches =
1829        input.request.options.artifacts.prediction_caches == PredictionCacheRetention::Retain;
1830    let (prediction_caches, portable_prediction_caches) = if retain_caches {
1831        let mut records = build_oof_prediction_cache_records(
1832            &prediction_requirements,
1833            selected_ctx.prediction_store.blocks(),
1834            selected_ctx.aggregated_prediction_store.blocks(),
1835        )?;
1836        let mut payloads = build_oof_prediction_cache_payloads(
1837            &prediction_requirements,
1838            selected_ctx.prediction_store.blocks(),
1839            selected_ctx.aggregated_prediction_store.blocks(),
1840        )?;
1841        attach_oof_prediction_cache_namespaces(
1842            &effective_plan,
1843            &input.request.data_identities,
1844            &selected_variant_id,
1845            input.request.options.seed,
1846            &prediction_requirements,
1847            &mut records,
1848            &mut payloads,
1849        )?;
1850        (
1851            records,
1852            Some(BundlePredictionCachePayloadSet {
1853                bundle_id: input.bundle_id.clone(),
1854                schema_version: PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
1855                caches: payloads,
1856            }),
1857        )
1858    } else {
1859        (Vec::new(), None)
1860    };
1861
1862    let mut staged_artifact_store = InMemoryArtifactStore::new();
1863    let refit_results = if input.request.options.refit {
1864        scheduler.refit(
1865            &effective_plan,
1866            input.controllers,
1867            input.data_provider,
1868            &mut staged_artifact_store,
1869            &mut selected_ctx,
1870        )?
1871    } else {
1872        Vec::new()
1873    };
1874
1875    let mut execution_bundle = build_execution_bundle_with_prediction_contracts(
1876        input.bundle_id.clone(),
1877        &effective_plan,
1878        Some(selected_variant_id.clone()),
1879        BTreeMap::from([(input.request.options.selection.id.clone(), decision)]),
1880        staged_artifact_store.refit_artifacts(),
1881        prediction_requirements,
1882        prediction_caches,
1883    )?;
1884    #[cfg(feature = "methods-optimizer")]
1885    {
1886        execution_bundle.methods_hpo_resume_state = methods_hpo_resume_state.clone();
1887        for record in &execution_bundle.refit_artifacts {
1888            if record.artifact.kind != "n4m_model" {
1889                continue;
1890            }
1891            let controller = input
1892                .controllers
1893                .get(&record.controller_id)
1894                .ok_or_else(|| {
1895                    DagMlError::RuntimeValidation(format!(
1896                        "missing controller `{}` for N4MM export",
1897                        record.controller_id
1898                    ))
1899                })?;
1900            let bytes = controller
1901                .export_artifact_payload(&record.artifact.id)?
1902                .ok_or_else(|| {
1903                    DagMlError::RuntimeValidation(format!(
1904                        "Methods controller did not export durable N4MM payload `{}`",
1905                        record.artifact.id
1906                    ))
1907                })?;
1908            execution_bundle
1909                .raw_artifact_payloads
1910                .insert(record.artifact.id.clone(), bytes);
1911        }
1912    }
1913    execution_bundle.scores = Some(score_set.clone());
1914    execution_bundle.validate_against_plan(&effective_plan)?;
1915    if let Some(caches) = &portable_prediction_caches {
1916        caches.validate_against_bundle(&execution_bundle)?;
1917    }
1918
1919    let outputs = bind_training_outputs(
1920        &projection.outputs,
1921        input.request,
1922        &effective_plan,
1923        &fit_cv_results,
1924        &refit_results,
1925        &selected_ctx,
1926    )?;
1927    let mut lineage = selected_ctx
1928        .lineage
1929        .records()
1930        .filter(|record| projection.predictor_node_ids.contains(&record.node_id))
1931        .cloned()
1932        .collect::<Vec<_>>();
1933    for record in &mut lineage {
1934        record.input_lineage.sort();
1935        record
1936            .artifact_refs
1937            .sort_by(|left, right| left.id.cmp(&right.id));
1938    }
1939    lineage.sort_by(|left, right| left.record_id.cmp(&right.record_id));
1940
1941    let effective_plan_fingerprint =
1942        tcv1_fingerprint(&effective_plan, "training outcome effective plan")?;
1943    let parameter_patches =
1944        merge_training_parameter_patches(&input.request.parameter_patches, &selected_variant)?;
1945    // Derive the honest replayable phases from the *full effective predictor
1946    // closure* and the artifacts/caches actually retained by this run, never
1947    // from the refit flag alone. `derive_replayable_phases` is the single shared
1948    // helper that standalone validation re-runs, so construction cannot advertise
1949    // a capability the closure and retained state do not support.
1950    let predictor_closure_nodes = predictor_closure(
1951        &effective_plan,
1952        outputs.iter().map(|output| output.binding.node_id.clone()),
1953    )?;
1954    let refit_outcome = TrainingRefitOutcome {
1955        requested: input.request.options.refit,
1956        status: if input.request.options.refit {
1957            TrainingRefitStatus::Completed
1958        } else {
1959            TrainingRefitStatus::Skipped
1960        },
1961        strategy: input.request.options.refit_strategy,
1962    };
1963    let replayable_phases = derive_replayable_phases(
1964        &effective_plan,
1965        &predictor_closure_nodes,
1966        &refit_outcome,
1967        &execution_bundle,
1968        portable_prediction_caches.as_ref(),
1969    )?;
1970    let mut outcome = TrainingOutcome {
1971        schema_version: TRAINING_OUTCOME_SCHEMA_VERSION,
1972        outcome_id: input.outcome_id,
1973        run_id: input.run_id,
1974        training_request_fingerprint: projection.request_fingerprint,
1975        data_identities: input.request.data_identities.clone(),
1976        selection_output_id,
1977        effective_plan,
1978        effective_plan_fingerprint,
1979        selected_variant_id,
1980        selected_variant_fingerprint: selected_variant.fingerprint,
1981        parameter_patches,
1982        refit: refit_outcome,
1983        score_set,
1984        outputs,
1985        lineage,
1986        portable_prediction_caches,
1987        training_influence: runtime_training_influence,
1988        execution_bundle,
1989        conformal_calibration: None,
1990        conformal_calibration_replay: None,
1991        #[cfg(feature = "methods-optimizer")]
1992        methods_hpo_resume_state,
1993        #[cfg(not(feature = "methods-optimizer"))]
1994        methods_hpo_resume_state: None,
1995        replayable_phases,
1996        warnings: input.warnings,
1997        diagnostics: input.diagnostics,
1998        outcome_fingerprint: zero_fingerprint(),
1999    };
2000    outcome = stabilize_training_outcome_for_tcv1(outcome)?;
2001    outcome.validate()?;
2002    *input.artifact_store = staged_artifact_store;
2003    Ok(outcome)
2004}
2005
2006fn stabilize_training_outcome_for_tcv1(mut outcome: TrainingOutcome) -> Result<TrainingOutcome> {
2007    // TCV1 signs the lexical JSON number token, whereas serde first parses a
2008    // metric into binary64 and may subsequently select a different shortest
2009    // spelling for that same value. Sign only the fixed point that a strict
2010    // reader will itself obtain after deserialize/serialize; otherwise a newly
2011    // produced package can fail its own `TrainingOutcome::from_json` boundary.
2012    outcome.outcome_fingerprint = zero_fingerprint();
2013    for _ in 0..8 {
2014        let json = serde_json::to_string(&outcome)?;
2015        let before = parse_typed_json(&json).map_err(|error| {
2016            DagMlError::CampaignValidation(format!(
2017                "training outcome is not strict TCV1 JSON while normalizing: {error}"
2018            ))
2019        })?;
2020        let mut normalized = serde_json::from_str::<TrainingOutcome>(&json)?;
2021        normalized.outcome_fingerprint = zero_fingerprint();
2022        if let Some(calibration) = normalized.conformal_calibration.as_mut() {
2023            // A nested conformal record has its own TCV1 self-fingerprint.
2024            // Outcome normalization can canonicalize its binary64 lexical
2025            // representation, so re-sign it before emitting the enclosing
2026            // outcome and refresh the matching bundle reference atomically.
2027            calibration.calibration_fingerprint = calibration.compute_fingerprint()?;
2028            normalized.execution_bundle.conformal_calibration = Some(calibration.reference()?);
2029        }
2030        let normalized_json = serde_json::to_string(&normalized)?;
2031        let after = parse_typed_json(&normalized_json).map_err(|error| {
2032            DagMlError::CampaignValidation(format!(
2033                "training outcome is not strict TCV1 JSON after normalization: {error}"
2034            ))
2035        })?;
2036        if before != after {
2037            outcome = normalized;
2038            continue;
2039        }
2040
2041        normalized.outcome_fingerprint =
2042            after
2043                .fingerprint_without("outcome_fingerprint")
2044                .map_err(|error| {
2045                    DagMlError::CampaignValidation(format!(
2046                        "training outcome TCV1 fingerprint failed after normalization: {error}"
2047                    ))
2048                })?;
2049        let signed_json = serde_json::to_string(&normalized)?;
2050        let signed = TrainingOutcome::from_json(&signed_json)?;
2051        return Ok(signed);
2052    }
2053    Err(DagMlError::CampaignValidation(
2054        "training outcome TCV1 JSON did not reach a serde canonical fixed point".to_string(),
2055    ))
2056}
2057
2058fn zero_fingerprint() -> String {
2059    "0".repeat(64)
2060}
2061
2062fn validate_native_training_options(request: &TrainingRequest) -> Result<()> {
2063    let resources = &request.options.resources;
2064    if resources.cpu_threads != request.options.scheduler.workers
2065        || resources.memory_bytes.is_some()
2066        || !resources.gpu_devices.is_empty()
2067        || resources.wall_time_ms.is_some()
2068    {
2069        return Err(DagMlError::RuntimeValidation(
2070            "native training V1 supports only cpu_threads=scheduler.workers with memory_bytes=null, gpu_devices=[], and wall_time_ms=null"
2071                .to_string(),
2072        ));
2073    }
2074    if request.options.artifacts.cv_artifacts != CvArtifactRetention::Discard {
2075        return Err(DagMlError::RuntimeValidation(
2076            "native training V1 supports only artifacts.cv_artifacts=discard".to_string(),
2077        ));
2078    }
2079    if !matches!(
2080        request.options.artifacts.fitted_artifacts,
2081        FittedArtifactMode::AllowHostSidecar | FittedArtifactMode::PortableRequired
2082    ) {
2083        return Err(DagMlError::RuntimeValidation(
2084            "native training V1 requires artifacts.fitted_artifacts=allow_host_sidecar or portable_required"
2085                .to_string(),
2086        ));
2087    }
2088    if request.options.artifacts.prediction_caches == PredictionCacheRetention::Discard
2089        && request
2090            .graph
2091            .edges
2092            .iter()
2093            .any(|edge| edge.contract.requires_oof)
2094    {
2095        return Err(DagMlError::RuntimeValidation(
2096            "native training V1 requires retained prediction caches for a stacking/requires_oof graph"
2097                .to_string(),
2098        ));
2099    }
2100    Ok(())
2101}
2102
2103fn materialize_request_parameter_patches(
2104    mut plan: ExecutionPlan,
2105    request: &TrainingRequest,
2106) -> Result<ExecutionPlan> {
2107    for patch in &request.parameter_patches {
2108        match patch.namespace {
2109            ParameterNamespace::Operator => {}
2110            ParameterNamespace::Structural => {
2111                return Err(DagMlError::RuntimeValidation(
2112                    "native training requires recompilation for structural parameter patches; D6 runtime accepts only operator value patches"
2113                        .to_string(),
2114                ));
2115            }
2116            ParameterNamespace::Fit | ParameterNamespace::Control => {
2117                return Err(DagMlError::RuntimeValidation(format!(
2118                    "native training does not expose {:?} parameter patches to controllers yet; refusing to ignore them",
2119                    patch.namespace
2120                )));
2121            }
2122        }
2123        let node_plan = plan.node_plans.get_mut(&patch.node_id).ok_or_else(|| {
2124            DagMlError::RuntimeValidation(format!(
2125                "parameter patch references absent node `{}`",
2126                patch.node_id
2127            ))
2128        })?;
2129        deep_set_plan_param(
2130            &mut node_plan.params,
2131            &patch.path,
2132            patch.value.clone(),
2133            &patch.node_id,
2134        )?;
2135        node_plan.params_fingerprint = stable_json_fingerprint(&node_plan.params)?;
2136    }
2137    plan.validate()?;
2138    Ok(plan)
2139}
2140
2141fn deep_set_plan_param(
2142    root: &mut BTreeMap<String, serde_json::Value>,
2143    path: &[String],
2144    value: serde_json::Value,
2145    node_id: &NodeId,
2146) -> Result<()> {
2147    if path.is_empty() {
2148        return contract_error("parameter patch path cannot be empty");
2149    }
2150    if path.len() == 1 {
2151        root.insert(path[0].clone(), value);
2152        return Ok(());
2153    }
2154    let first = root.get_mut(&path[0]).ok_or_else(|| {
2155        DagMlError::RuntimeValidation(format!(
2156            "parameter patch for `{node_id}` is missing intermediate path `{}`",
2157            path[0]
2158        ))
2159    })?;
2160    let mut cursor = first;
2161    for segment in &path[1..path.len() - 1] {
2162        let object = cursor.as_object_mut().ok_or_else(|| {
2163            DagMlError::RuntimeValidation(format!(
2164                "parameter patch for `{node_id}` crosses a scalar or array at `{segment}`"
2165            ))
2166        })?;
2167        cursor = object.get_mut(segment).ok_or_else(|| {
2168            DagMlError::RuntimeValidation(format!(
2169                "parameter patch for `{node_id}` is missing intermediate path `{segment}`"
2170            ))
2171        })?;
2172    }
2173    let object = cursor.as_object_mut().ok_or_else(|| {
2174        DagMlError::RuntimeValidation(format!(
2175            "parameter patch for `{node_id}` crosses a scalar or array before final key"
2176        ))
2177    })?;
2178    object.insert(path[path.len() - 1].clone(), value);
2179    Ok(())
2180}
2181
2182fn validate_provider_attestations(
2183    projection: &TrainingContractProjection,
2184    request: &TrainingRequest,
2185    provider: &dyn RuntimeDataProvider,
2186    relations: &crate::relation::SampleRelationSet,
2187) -> Result<()> {
2188    relations.validate()?;
2189    let relation_fingerprint = relations.fingerprint()?;
2190    let identities = request
2191        .data_identities
2192        .iter()
2193        .map(|identity| (identity.requirement_key.as_str(), identity))
2194        .collect::<BTreeMap<_, _>>();
2195    for node_plan in projection.plan.node_plans.values() {
2196        for binding in &node_plan.data_bindings {
2197            let key = data_binding_requirement_key(&binding.node_id, &binding.input_name);
2198            let expected = identities.get(key.as_str()).ok_or_else(|| {
2199                DagMlError::RuntimeValidation(format!(
2200                    "native training request has no data identity for `{key}`"
2201                ))
2202            })?;
2203            let actual = provider.training_data_identity(binding)?.ok_or_else(|| {
2204                DagMlError::RuntimeValidation(format!(
2205                    "runtime data provider did not attest feature/target content for `{key}`"
2206                ))
2207            })?;
2208            actual.validate()?;
2209            if &actual != *expected {
2210                return Err(DagMlError::RuntimeValidation(format!(
2211                    "runtime data provider identity for `{key}` does not match signed training request"
2212                )));
2213            }
2214            let provider_relations = provider.coordinator_relations(binding)?;
2215            if binding.require_relations && provider_relations.is_none() {
2216                return Err(DagMlError::RuntimeValidation(format!(
2217                    "runtime data provider omitted required relations for `{key}`"
2218                )));
2219            }
2220            if let Some(provider_relations) = provider_relations {
2221                provider_relations.validate()?;
2222                if provider_relations.fingerprint()? != relation_fingerprint
2223                    || actual.relation_fingerprint != relation_fingerprint
2224                {
2225                    return Err(DagMlError::RuntimeValidation(format!(
2226                        "runtime data provider relations for `{key}` differ from training influence relations"
2227                    )));
2228                }
2229            }
2230        }
2231    }
2232    Ok(())
2233}
2234
2235fn parse_selection_metric(request: &TrainingRequest) -> Result<RegressionMetricKind> {
2236    let metric = regression_metric_by_name(&request.options.selection.metric.name)?;
2237    if request.options.selection.metric.objective != metric.objective() {
2238        return Err(DagMlError::RuntimeValidation(format!(
2239            "selection metric `{}` has objective {:?}, expected {:?}",
2240            metric.name(),
2241            request.options.selection.metric.objective,
2242            metric.objective()
2243        )));
2244    }
2245    Ok(metric)
2246}
2247
2248fn regression_metric_by_name(name: &str) -> Result<RegressionMetricKind> {
2249    RegressionMetricKind::from_name(name).ok_or_else(|| {
2250        DagMlError::RuntimeValidation(format!(
2251            "native training does not support selection metric `{name}`"
2252        ))
2253    })
2254}
2255
2256fn validate_selection_prediction_kind(
2257    metric: RegressionMetricKind,
2258    prediction_kind: PredictionKind,
2259) -> Result<()> {
2260    RegressionMetricKind::resolve_for_prediction_kind(
2261        metric.name(),
2262        metric.objective(),
2263        prediction_kind,
2264    )
2265    .map(|_| ())
2266}
2267
2268fn effective_selection_metric_level(request: &TrainingRequest) -> Result<PredictionLevel> {
2269    let campaign_level = request.campaign.aggregation_policy.selection_metric_level;
2270    if request
2271        .options
2272        .selection
2273        .required_metric_level
2274        .is_some_and(|level| level != campaign_level)
2275    {
2276        return Err(DagMlError::RuntimeValidation(
2277            "selection required_metric_level differs from campaign selection_metric_level"
2278                .to_string(),
2279        ));
2280    }
2281    if request.options.selection.evaluation_scope != Some(EvaluationScope::Oof) {
2282        return Err(DagMlError::RuntimeValidation(
2283            "native training V1 requires selection.evaluation_scope=oof".to_string(),
2284        ));
2285    }
2286    if request.options.selection.reduction_id.is_some() {
2287        return Err(DagMlError::RuntimeValidation(
2288            "native training V1 does not execute selection reduction_id".to_string(),
2289        ));
2290    }
2291    if request.options.selection.stacking_fit_contract.is_some() {
2292        return Err(DagMlError::RuntimeValidation(
2293            "native training V1 does not execute selection stacking_fit_contract".to_string(),
2294        ));
2295    }
2296    if !request.options.selection.require_finite {
2297        return Err(DagMlError::RuntimeValidation(
2298            "native training V1 requires selection.require_finite=true".to_string(),
2299        ));
2300    }
2301    if request.options.refit_strategy == Some(RefitStrategy::RefitEnsemble) {
2302        return Err(DagMlError::RuntimeValidation(
2303            "native training V1 does not implement refit_ensemble".to_string(),
2304        ));
2305    }
2306    match (
2307        request.options.refit,
2308        request.options.selection.refit_slot_plan.as_ref(),
2309    ) {
2310        (false, Some(_)) => Err(DagMlError::RuntimeValidation(
2311            "no-refit native training forbids selection.refit_slot_plan".to_string(),
2312        )),
2313        (true, Some(slot))
2314            if slot.strategy != RefitStrategy::RefitOne
2315                || slot.member_count != 1
2316                || slot.selection_level != campaign_level
2317                || slot.selection_metric != request.options.selection.metric
2318                || slot.reduction_id.is_some() =>
2319        {
2320            Err(DagMlError::RuntimeValidation(
2321                "selection.refit_slot_plan is not the exact native refit_one slot".to_string(),
2322            ))
2323        }
2324        _ => Ok(campaign_level),
2325    }
2326}
2327
2328fn validate_selected_rerun_reports(
2329    retained: &[crate::metrics::RegressionMetricReport],
2330    rerun: &[crate::metrics::RegressionMetricReport],
2331    selected_variant_id: &VariantId,
2332) -> Result<()> {
2333    let mut retained = retained
2334        .iter()
2335        .filter(|report| report.variant_id.as_ref() == Some(selected_variant_id))
2336        .cloned()
2337        .collect::<Vec<_>>();
2338    let mut rerun = rerun
2339        .iter()
2340        .filter(|report| report.partition == PredictionPartition::Validation)
2341        .cloned()
2342        .map(|mut report| {
2343            report.variant_id = Some(selected_variant_id.clone());
2344            report.variant_label = None;
2345            report
2346        })
2347        .collect::<Vec<_>>();
2348    // A durable Methods HPO resume state records the one sample-level OOF
2349    // average that terminalized each native trial, rather than inventing a
2350    // free per-fold score transcript.  In that explicit contract, compare the
2351    // selected rerun against precisely those terminal report identities.  The
2352    // ordinary path retains every validation report and therefore continues to
2353    // require exact full-report coverage below.
2354    let terminal_oof_only = retained.iter().all(|report| {
2355        report.partition == PredictionPartition::Validation
2356            && report
2357                .fold_id
2358                .as_ref()
2359                .is_some_and(|fold| fold.as_str() == "avg")
2360            && report.level == PredictionLevel::Sample
2361    });
2362    if terminal_oof_only {
2363        rerun.retain(|actual| {
2364            retained.iter().any(|expected| {
2365                expected.producer_node == actual.producer_node
2366                    && expected.producer_port == actual.producer_port
2367                    && expected.fold_id == actual.fold_id
2368                    && expected.prediction_id == actual.prediction_id
2369                    && expected.level == actual.level
2370            })
2371        });
2372    }
2373    let sort = |reports: &mut Vec<crate::metrics::RegressionMetricReport>| {
2374        reports.sort_by(|left, right| {
2375            (
2376                &left.producer_node,
2377                &left.producer_port,
2378                &left.fold_id,
2379                &left.prediction_id,
2380                &left.level,
2381            )
2382                .cmp(&(
2383                    &right.producer_node,
2384                    &right.producer_port,
2385                    &right.fold_id,
2386                    &right.prediction_id,
2387                    &right.level,
2388                ))
2389        });
2390    };
2391    sort(&mut retained);
2392    sort(&mut rerun);
2393    if retained.is_empty()
2394        || retained.len() != rerun.len()
2395        || retained
2396            .iter()
2397            .zip(&rerun)
2398            .any(|(left, right)| !reports_match_rerun_tolerance(left, right))
2399    {
2400        return Err(DagMlError::RuntimeValidation(
2401            "selected variant FIT_CV rerun diverged from the reports that justified SELECT"
2402                .to_string(),
2403        ));
2404    }
2405    Ok(())
2406}
2407
2408/// Native numerical libraries may differ by one rounding unit across a fresh
2409/// process/context.  Preserve report identity exactly, while comparing the
2410/// numeric evidence with the same tight tolerance used for portable replay.
2411fn reports_match_rerun_tolerance(
2412    left: &crate::metrics::RegressionMetricReport,
2413    right: &crate::metrics::RegressionMetricReport,
2414) -> bool {
2415    left.prediction_id == right.prediction_id
2416        && left.producer_node == right.producer_node
2417        && left.producer_port == right.producer_port
2418        && left.variant_id == right.variant_id
2419        && left.variant_label == right.variant_label
2420        && left.partition == right.partition
2421        && left.fold_id == right.fold_id
2422        && left.level == right.level
2423        && left.row_count == right.row_count
2424        && left.target_width == right.target_width
2425        && left.target_names == right.target_names
2426        && left.metrics.len() == right.metrics.len()
2427        && left.metrics.iter().all(|(name, value)| {
2428            right
2429                .metrics
2430                .get(name)
2431                .is_some_and(|other| (value - other).abs() <= 1.0e-12)
2432        })
2433}
2434
2435fn validate_selection_report_levels(
2436    reports: &[crate::metrics::RegressionMetricReport],
2437    producer: &NodeId,
2438    producer_port: &Option<String>,
2439    expected: PredictionLevel,
2440) -> Result<()> {
2441    let target_reports = reports
2442        .iter()
2443        .filter(|report| {
2444            &report.producer_node == producer
2445                && &report.producer_port == producer_port
2446                && report.level == expected
2447        })
2448        .collect::<Vec<_>>();
2449    if target_reports.is_empty() {
2450        return Err(DagMlError::RuntimeValidation(format!(
2451            "native SELECT target `{producer}` port {producer_port:?} has no reports at required metric level {expected:?}"
2452        )));
2453    }
2454    Ok(())
2455}
2456
2457fn bind_selection_decision(
2458    decision: &mut SelectionDecision,
2459    request: &TrainingRequest,
2460    metric_level: PredictionLevel,
2461) -> Result<()> {
2462    decision.policy_id = request.options.selection.id.clone();
2463    decision.metric_level = Some(metric_level);
2464    decision.evaluation_scope = Some(EvaluationScope::Oof);
2465    decision.refit_slot_plan = request.options.selection.refit_slot_plan.clone();
2466    decision.reduction_id = None;
2467    decision.validate()
2468}
2469
2470fn materialize_selected_variant(
2471    mut plan: ExecutionPlan,
2472    selected_variant_id: &VariantId,
2473) -> Result<ExecutionPlan> {
2474    let selected = plan
2475        .variants
2476        .iter()
2477        .find(|variant| &variant.variant_id == selected_variant_id)
2478        .cloned()
2479        .ok_or_else(|| {
2480            DagMlError::RuntimeValidation(format!(
2481                "selected variant `{selected_variant_id}` is absent from plan"
2482            ))
2483        })?;
2484    let variant = VariantExecutionSpec::from_plan(&selected);
2485    variant.validate()?;
2486    for (node_id, node_plan) in &mut plan.node_plans {
2487        node_plan.params = variant.effective_params_for_node(node_id, &node_plan.params)?;
2488        node_plan.params_fingerprint = stable_json_fingerprint(&node_plan.params)?;
2489    }
2490    plan.validate()?;
2491    Ok(plan)
2492}
2493
2494fn is_cv_ensemble_partition(partition: &PredictionPartition) -> bool {
2495    match partition {
2496        PredictionPartition::Validation => true,
2497        PredictionPartition::Train | PredictionPartition::Test | PredictionPartition::Final => {
2498            false
2499        }
2500    }
2501}
2502
2503fn producer_port_matches_graph_output(
2504    plan: &ExecutionPlan,
2505    node_id: &NodeId,
2506    port_name: &str,
2507    producer_port: &Option<String>,
2508) -> bool {
2509    if let Some(producer_port) = producer_port {
2510        return producer_port == port_name;
2511    }
2512    let Some(node) = plan
2513        .graph_plan
2514        .graph
2515        .nodes
2516        .iter()
2517        .find(|node| &node.id == node_id)
2518    else {
2519        return false;
2520    };
2521    let prediction_ports = node
2522        .ports
2523        .outputs
2524        .iter()
2525        .filter(|port| port.kind == PortKind::Prediction)
2526        .collect::<Vec<_>>();
2527    prediction_ports.len() == 1 && prediction_ports[0].name == port_name
2528}
2529
2530fn bind_training_outputs(
2531    outputs: &[ResolvedTrainingOutput],
2532    request: &TrainingRequest,
2533    plan: &ExecutionPlan,
2534    fit_cv_results: &[NodeResult],
2535    refit_results: &[NodeResult],
2536    ctx: &RunContext,
2537) -> Result<Vec<BoundTrainingOutput>> {
2538    let source = if request.options.refit {
2539        refit_results
2540    } else {
2541        fit_cv_results
2542    };
2543    let aggregation_fingerprint = tcv1_fingerprint(
2544        &plan.campaign.aggregation_policy,
2545        "training output aggregation policy",
2546    )?;
2547    let mut bound = Vec::with_capacity(outputs.len());
2548    for output in outputs {
2549        let mut binding = OutputBinding {
2550            schema_version: OUTPUT_BINDING_SCHEMA_VERSION,
2551            binding_id: output.output_id.clone(),
2552            node_id: output.node_id.clone(),
2553            port_name: output.port_name.clone(),
2554            prediction_level: output.prediction_level,
2555            unit_level: output.unit_level,
2556            prediction_kind: output.prediction_kind,
2557            prediction_source: if request.options.refit {
2558                PredictionSource::FinalRefit
2559            } else {
2560                PredictionSource::CvEnsemble
2561            },
2562            refit_strategy: request.options.refit_strategy,
2563            aggregation_fingerprint: aggregation_fingerprint.clone(),
2564            target_names: output.target_names.clone(),
2565            target_units: output.target_units.clone(),
2566            class_labels: output.class_labels.clone(),
2567            output_order: output.output_order,
2568            target_space: output.target_space.clone(),
2569            binding_fingerprint: zero_fingerprint(),
2570        };
2571        binding.binding_fingerprint = binding.compute_fingerprint()?;
2572
2573        let node_results = source
2574            .iter()
2575            .filter(|result| result.node_id == output.node_id)
2576            .collect::<Vec<_>>();
2577        let mut predictions = Vec::new();
2578        let mut observation_predictions = Vec::new();
2579        let mut aggregated_predictions = Vec::new();
2580        match output.prediction_level {
2581            PredictionLevel::Observation => {
2582                for result in node_results {
2583                    observation_predictions.extend(
2584                        result
2585                            .observation_predictions
2586                            .iter()
2587                            .filter(|block| {
2588                                producer_port_matches_graph_output(
2589                                    plan,
2590                                    &output.node_id,
2591                                    &output.port_name,
2592                                    &block.producer_port,
2593                                ) && (request.options.refit
2594                                    || is_cv_ensemble_partition(&block.partition))
2595                            })
2596                            .cloned(),
2597                    );
2598                }
2599            }
2600            PredictionLevel::Sample => {
2601                for result in node_results {
2602                    predictions.extend(
2603                        result
2604                            .predictions
2605                            .iter()
2606                            .filter(|block| {
2607                                producer_port_matches_graph_output(
2608                                    plan,
2609                                    &output.node_id,
2610                                    &output.port_name,
2611                                    &block.producer_port,
2612                                ) && (request.options.refit
2613                                    || is_cv_ensemble_partition(&block.partition))
2614                            })
2615                            .cloned(),
2616                    );
2617                    aggregated_predictions.extend(
2618                        result
2619                            .aggregated_predictions
2620                            .iter()
2621                            .filter(|block| {
2622                                producer_port_matches_graph_output(
2623                                    plan,
2624                                    &output.node_id,
2625                                    &output.port_name,
2626                                    &block.producer_port,
2627                                ) && block.level == PredictionLevel::Sample
2628                                    && (request.options.refit
2629                                        || is_cv_ensemble_partition(&block.partition))
2630                            })
2631                            .cloned(),
2632                    );
2633                }
2634                if !request.options.refit {
2635                    aggregated_predictions.extend(
2636                        ctx.oof_average_blocks
2637                            .iter()
2638                            .filter(|average| {
2639                                average.predictions.producer_node == output.node_id
2640                                    && producer_port_matches_graph_output(
2641                                        plan,
2642                                        &output.node_id,
2643                                        &output.port_name,
2644                                        &average.predictions.producer_port,
2645                                    )
2646                                    && is_cv_ensemble_partition(&average.predictions.partition)
2647                            })
2648                            .map(|average| average.predictions.clone()),
2649                    );
2650                }
2651            }
2652            PredictionLevel::Target | PredictionLevel::Group => {
2653                for result in node_results {
2654                    aggregated_predictions.extend(
2655                        result
2656                            .aggregated_predictions
2657                            .iter()
2658                            .filter(|block| {
2659                                producer_port_matches_graph_output(
2660                                    plan,
2661                                    &output.node_id,
2662                                    &output.port_name,
2663                                    &block.producer_port,
2664                                ) && block.level == output.prediction_level
2665                                    && (request.options.refit
2666                                        || is_cv_ensemble_partition(&block.partition))
2667                            })
2668                            .cloned(),
2669                    );
2670                }
2671            }
2672        }
2673        predictions.sort_by(|left, right| {
2674            (
2675                &left.partition,
2676                &left.fold_id,
2677                &left.prediction_id,
2678                &left.sample_ids,
2679            )
2680                .cmp(&(
2681                    &right.partition,
2682                    &right.fold_id,
2683                    &right.prediction_id,
2684                    &right.sample_ids,
2685                ))
2686        });
2687        observation_predictions.sort_by(|left, right| {
2688            (
2689                &left.partition,
2690                &left.fold_id,
2691                &left.prediction_id,
2692                &left.observation_ids,
2693            )
2694                .cmp(&(
2695                    &right.partition,
2696                    &right.fold_id,
2697                    &right.prediction_id,
2698                    &right.observation_ids,
2699                ))
2700        });
2701        aggregated_predictions.sort_by(|left, right| {
2702            (
2703                &left.partition,
2704                &left.fold_id,
2705                &left.prediction_id,
2706                &left.unit_ids,
2707            )
2708                .cmp(&(
2709                    &right.partition,
2710                    &right.fold_id,
2711                    &right.prediction_id,
2712                    &right.unit_ids,
2713                ))
2714        });
2715        aggregated_predictions.dedup();
2716        let output = BoundTrainingOutput {
2717            schema_version: Some(BOUND_TRAINING_OUTPUT_SCHEMA_VERSION),
2718            binding,
2719            predictions,
2720            observation_predictions,
2721            aggregated_predictions,
2722        };
2723        output.validate(plan)?;
2724        bound.push(output);
2725    }
2726    Ok(bound)
2727}
2728
2729/// Derive portable OOF requirements from the blocks produced by an existing
2730/// FIT_CV execution. Shared by the training operation and host capture paths.
2731pub fn build_oof_prediction_requirements(
2732    plan: &ExecutionPlan,
2733    blocks: &[PredictionBlock],
2734    aggregated_blocks: &[AggregatedPredictionBlock],
2735) -> Result<Vec<BundlePredictionRequirement>> {
2736    let mut requirements = Vec::new();
2737    for edge in plan
2738        .graph_plan
2739        .graph
2740        .edges
2741        .iter()
2742        .filter(|edge| edge.contract.requires_oof)
2743    {
2744        let source_plan = plan.node_plans.get(&edge.source.node_id).ok_or_else(|| {
2745            DagMlError::RuntimeValidation(format!(
2746                "OOF edge source `{}` has no node plan",
2747                edge.source.node_id
2748            ))
2749        })?;
2750        let prediction_level = source_plan
2751            .shape_plan
2752            .as_ref()
2753            .map(|shape| shape.aggregation_policy.aggregation_level)
2754            .unwrap_or(PredictionLevel::Sample);
2755        let mut fold_ids = BTreeSet::<FoldId>::new();
2756        let mut sample_ids = BTreeSet::<SampleId>::new();
2757        let mut unit_ids = BTreeSet::<PredictionUnitId>::new();
2758        let mut width = None;
2759        let mut target_names: Option<Vec<String>> = None;
2760
2761        match prediction_level {
2762            PredictionLevel::Sample => {
2763                let selected = blocks
2764                    .iter()
2765                    .filter(|block| {
2766                        block.producer_node == edge.source.node_id
2767                            && producer_port_matches_graph_output(
2768                                plan,
2769                                &edge.source.node_id,
2770                                &edge.source.port_name,
2771                                &block.producer_port,
2772                            )
2773                            && block.partition == PredictionPartition::Validation
2774                    })
2775                    .collect::<Vec<_>>();
2776                if selected.is_empty() {
2777                    return Err(DagMlError::RuntimeValidation(format!(
2778                        "OOF requirement `{}` -> `{}` has no validation sample blocks",
2779                        edge.source.node_id, edge.target.node_id
2780                    )));
2781                }
2782                for block in selected {
2783                    let block_width = block.validate_shape()?;
2784                    merge_oof_shape(
2785                        &edge.source.node_id,
2786                        &mut width,
2787                        &mut target_names,
2788                        block_width,
2789                        &block.target_names,
2790                    )?;
2791                    if let Some(fold_id) = &block.fold_id {
2792                        fold_ids.insert(fold_id.clone());
2793                    }
2794                    sample_ids.extend(block.sample_ids.iter().cloned());
2795                }
2796            }
2797            PredictionLevel::Target | PredictionLevel::Group => {
2798                let selected = aggregated_blocks
2799                    .iter()
2800                    .filter(|block| {
2801                        block.producer_node == edge.source.node_id
2802                            && producer_port_matches_graph_output(
2803                                plan,
2804                                &edge.source.node_id,
2805                                &edge.source.port_name,
2806                                &block.producer_port,
2807                            )
2808                            && block.partition == PredictionPartition::Validation
2809                            && block.level == prediction_level
2810                    })
2811                    .collect::<Vec<_>>();
2812                if selected.is_empty() {
2813                    return Err(DagMlError::RuntimeValidation(format!(
2814                        "OOF requirement `{}` -> `{}` has no validation {prediction_level:?} blocks",
2815                        edge.source.node_id, edge.target.node_id
2816                    )));
2817                }
2818                for block in selected {
2819                    let block_width = block.validate_shape()?;
2820                    merge_oof_shape(
2821                        &edge.source.node_id,
2822                        &mut width,
2823                        &mut target_names,
2824                        block_width,
2825                        &block.target_names,
2826                    )?;
2827                    if let Some(fold_id) = &block.fold_id {
2828                        fold_ids.insert(fold_id.clone());
2829                    }
2830                    unit_ids.extend(block.unit_ids.iter().cloned());
2831                }
2832            }
2833            PredictionLevel::Observation => {
2834                return Err(DagMlError::RuntimeValidation(format!(
2835                    "OOF requirement `{}` -> `{}` cannot persist observation-level predictions; aggregate before refit",
2836                    edge.source.node_id, edge.target.node_id
2837                )));
2838            }
2839        }
2840        let requirement = BundlePredictionRequirement {
2841            producer_node: edge.source.node_id.clone(),
2842            source_port: edge.source.port_name.clone(),
2843            consumer_node: edge.target.node_id.clone(),
2844            target_port: edge.target.port_name.clone(),
2845            partition: PredictionPartition::Validation,
2846            prediction_level,
2847            fold_ids: fold_ids.into_iter().collect(),
2848            unit_ids: unit_ids.into_iter().collect(),
2849            sample_ids: sample_ids.into_iter().collect(),
2850            prediction_width: width.unwrap_or_default(),
2851            target_names: target_names.unwrap_or_default(),
2852        };
2853        requirement.validate()?;
2854        requirements.push(requirement);
2855    }
2856    requirements.sort_by_key(BundlePredictionRequirement::key);
2857    Ok(requirements)
2858}
2859
2860fn merge_oof_shape(
2861    producer: &NodeId,
2862    expected_width: &mut Option<usize>,
2863    expected_names: &mut Option<Vec<String>>,
2864    width: usize,
2865    names: &[String],
2866) -> Result<()> {
2867    if expected_width.is_some_and(|expected| expected != width) {
2868        return Err(DagMlError::RuntimeValidation(format!(
2869            "OOF requirement for `{producer}` has inconsistent prediction width"
2870        )));
2871    }
2872    *expected_width = Some(width);
2873    let names = if names.is_empty() {
2874        (0..width).map(|index| format!("p{index}")).collect()
2875    } else {
2876        names.to_vec()
2877    };
2878    if expected_names
2879        .as_ref()
2880        .is_some_and(|expected| expected != &names)
2881    {
2882        return Err(DagMlError::RuntimeValidation(format!(
2883            "OOF requirement for `{producer}` has inconsistent target names"
2884        )));
2885    }
2886    *expected_names = Some(names);
2887    Ok(())
2888}
2889
2890pub fn build_oof_prediction_cache_records(
2891    requirements: &[BundlePredictionRequirement],
2892    blocks: &[PredictionBlock],
2893    aggregated_blocks: &[AggregatedPredictionBlock],
2894) -> Result<Vec<BundlePredictionCacheRecord>> {
2895    requirements
2896        .iter()
2897        .map(|requirement| match requirement.prediction_level {
2898            PredictionLevel::Sample => build_prediction_cache_record(requirement, blocks),
2899            PredictionLevel::Target | PredictionLevel::Group => {
2900                build_aggregated_prediction_cache_record(requirement, aggregated_blocks)
2901            }
2902            PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
2903                "prediction cache requirement `{}` cannot use observation-level predictions",
2904                requirement.key()
2905            ))),
2906        })
2907        .collect()
2908}
2909
2910pub fn build_oof_prediction_cache_payloads(
2911    requirements: &[BundlePredictionRequirement],
2912    blocks: &[PredictionBlock],
2913    aggregated_blocks: &[AggregatedPredictionBlock],
2914) -> Result<Vec<BundlePredictionCachePayload>> {
2915    requirements
2916        .iter()
2917        .map(|requirement| match requirement.prediction_level {
2918            PredictionLevel::Sample => build_prediction_cache_payload(requirement, blocks),
2919            PredictionLevel::Target | PredictionLevel::Group => {
2920                build_aggregated_prediction_cache_payload(requirement, aggregated_blocks)
2921            }
2922            PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
2923                "prediction cache requirement `{}` cannot use observation-level predictions",
2924                requirement.key()
2925            ))),
2926        })
2927        .collect()
2928}
2929
2930fn attach_oof_prediction_cache_namespaces(
2931    plan: &ExecutionPlan,
2932    data_identities: &[TrainingDataIdentity],
2933    selected_variant_id: &VariantId,
2934    seed: u64,
2935    requirements: &[BundlePredictionRequirement],
2936    records: &mut [BundlePredictionCacheRecord],
2937    payloads: &mut [BundlePredictionCachePayload],
2938) -> Result<()> {
2939    let requirements_by_key = requirements
2940        .iter()
2941        .map(|requirement| (requirement.key(), requirement))
2942        .collect::<BTreeMap<_, _>>();
2943    for record in records {
2944        let requirement = requirements_by_key
2945            .get(&record.requirement_key)
2946            .ok_or_else(|| {
2947                DagMlError::RuntimeValidation(format!(
2948                    "prediction cache `{}` references unknown OOF requirement `{}`",
2949                    record.cache_id, record.requirement_key
2950                ))
2951            })?;
2952        let fingerprints = oof_cache_namespace_fingerprints(
2953            plan,
2954            data_identities,
2955            selected_variant_id,
2956            seed,
2957            requirement,
2958            record,
2959        )?;
2960        record.cache_namespace_fingerprints = fingerprints.clone();
2961        let payload = payloads
2962            .iter_mut()
2963            .find(|payload| payload.requirement_key == record.requirement_key)
2964            .ok_or_else(|| {
2965                DagMlError::RuntimeValidation(format!(
2966                    "prediction cache `{}` has no portable payload for requirement `{}`",
2967                    record.cache_id, record.requirement_key
2968                ))
2969            })?;
2970        payload.cache_namespace_fingerprints = fingerprints;
2971        validate_prediction_cache_payload_matches_record(payload, record)?;
2972    }
2973    Ok(())
2974}
2975
2976fn oof_cache_namespace_fingerprints(
2977    plan: &ExecutionPlan,
2978    data_identities: &[TrainingDataIdentity],
2979    selected_variant_id: &VariantId,
2980    seed: u64,
2981    requirement: &BundlePredictionRequirement,
2982    record: &BundlePredictionCacheRecord,
2983) -> Result<Vec<String>> {
2984    let producer_plan = plan
2985        .node_plans
2986        .get(&requirement.producer_node)
2987        .ok_or_else(|| {
2988            DagMlError::RuntimeValidation(format!(
2989                "prediction cache `{}` producer node `{}` is absent from plan",
2990                record.cache_id, requirement.producer_node
2991            ))
2992        })?;
2993    let consumer_plan = plan
2994        .node_plans
2995        .get(&requirement.consumer_node)
2996        .ok_or_else(|| {
2997            DagMlError::RuntimeValidation(format!(
2998                "prediction cache `{}` consumer node `{}` is absent from plan",
2999                record.cache_id, requirement.consumer_node
3000            ))
3001        })?;
3002    let identity_binding = match (
3003        producer_plan.data_bindings.as_slice(),
3004        consumer_plan.data_bindings.as_slice(),
3005    ) {
3006        ([binding], _) => binding,
3007        ([], [binding]) => binding,
3008        (producer_bindings, consumer_bindings) => {
3009            let producer_count = producer_bindings.len();
3010            let consumer_count = consumer_bindings.len();
3011            return Err(DagMlError::RuntimeValidation(format!(
3012                "prediction cache `{}` cannot derive a unique CacheNamespace for edge `{}.{}` -> `{}.{}` with {producer_count} producer data binding(s) and {consumer_count} consumer data binding(s)",
3013                record.cache_id,
3014                requirement.producer_node,
3015                requirement.source_port,
3016                requirement.consumer_node,
3017                requirement.target_port
3018            )));
3019        }
3020    };
3021    if producer_plan.data_bindings.len() > 1 || consumer_plan.data_bindings.len() > 1 {
3022        return Err(DagMlError::RuntimeValidation(format!(
3023            "prediction cache `{}` cannot derive a unique CacheNamespace for edge `{}.{}` -> `{}.{}` with ambiguous data bindings",
3024            record.cache_id,
3025            requirement.producer_node,
3026            requirement.source_port,
3027            requirement.consumer_node,
3028            requirement.target_port
3029        )));
3030    }
3031    let data_requirement_key =
3032        data_binding_requirement_key(&identity_binding.node_id, &identity_binding.input_name);
3033    let identity = data_identities
3034        .iter()
3035        .find(|identity| identity.requirement_key == data_requirement_key)
3036        .ok_or_else(|| {
3037            DagMlError::RuntimeValidation(format!(
3038                "prediction cache `{}` has no training data identity for `{data_requirement_key}`",
3039                record.cache_id
3040            ))
3041        })?;
3042    let mut fingerprints = Vec::with_capacity(record.blocks.len());
3043    for block in &record.blocks {
3044        let fold_id = block.fold_id.clone().ok_or_else(|| {
3045            DagMlError::RuntimeValidation(format!(
3046                "prediction cache `{}` has a cache block without fold_id",
3047                record.cache_id
3048            ))
3049        })?;
3050        let namespace = CacheNamespace::new(
3051            requirement.key(),
3052            identity.requirement_key.clone(),
3053            requirement.producer_node.clone(),
3054            requirement.source_port.clone(),
3055            requirement.consumer_node.clone(),
3056            requirement.target_port.clone(),
3057            producer_plan.params_fingerprint.clone(),
3058            identity.identity_fingerprint.clone(),
3059            fold_id,
3060            selected_variant_id.to_string(),
3061            seed,
3062        )?;
3063        namespace.validate_for_identity(identity)?;
3064        fingerprints.push(namespace.namespace_fingerprint);
3065    }
3066    Ok(fingerprints)
3067}
3068
3069impl TrainingOutcome {
3070    /// Strictly parse a self-fingerprinted W0 outcome without losing the JSON
3071    /// integer-versus-binary64 token distinction before verification.
3072    pub fn from_json(json: &str) -> Result<Self> {
3073        let typed = parse_typed_json(json).map_err(|error| {
3074            DagMlError::CampaignValidation(format!(
3075                "training outcome is not strict TCV1 JSON: {error}"
3076            ))
3077        })?;
3078        let raw_fingerprint =
3079            typed
3080                .fingerprint_without("outcome_fingerprint")
3081                .map_err(|error| {
3082                    DagMlError::CampaignValidation(format!(
3083                        "training outcome fingerprint preimage is invalid: {error}"
3084                    ))
3085                })?;
3086        let outcome: Self = serde_json::from_str(json)?;
3087        if outcome.outcome_fingerprint != raw_fingerprint {
3088            return contract_error(
3089                "training outcome fingerprint does not match original TCV1 JSON",
3090            );
3091        }
3092        outcome.validate()?;
3093        Ok(outcome)
3094    }
3095
3096    pub fn compute_fingerprint(&self) -> Result<String> {
3097        tcv1_fingerprint_without(self, "outcome_fingerprint", "training outcome")
3098    }
3099
3100    pub fn data_identities_fingerprint(&self) -> Result<String> {
3101        tcv1_fingerprint(&self.data_identities, "training outcome data identities")
3102    }
3103
3104    pub fn execution_bundle_fingerprint(&self) -> Result<String> {
3105        tcv1_fingerprint(&self.execution_bundle, "training outcome execution bundle")
3106    }
3107
3108    fn pre_conformal_outcome(&self) -> Result<Self> {
3109        let mut source = self.clone();
3110        source.conformal_calibration = None;
3111        source.conformal_calibration_replay = None;
3112        source.execution_bundle.conformal_calibration = None;
3113        stabilize_training_outcome_for_tcv1(source)
3114    }
3115
3116    fn pre_conformal_outcome_fingerprint(&self) -> Result<String> {
3117        Ok(self.pre_conformal_outcome()?.outcome_fingerprint)
3118    }
3119
3120    /// Attach native split-conformal state after an ordinary identity-attested
3121    /// calibration replay.  The bundle retains a typed reference and the
3122    /// outcome owns the complete signed quantiles.
3123    pub(crate) fn attach_conformal_calibration(
3124        &mut self,
3125        calibration: ConformalCalibration,
3126        replay: TrainingReplayOutcome,
3127    ) -> Result<()> {
3128        self.validate()?;
3129        calibration.validate()?;
3130        let request = replay_request_from_outcome(&replay);
3131        replay.validate_against(self, &request)?;
3132        let binding = self
3133            .outputs
3134            .iter()
3135            .find(|output| output.binding.binding_id == calibration.binding_id)
3136            .ok_or_else(|| {
3137                DagMlError::RuntimeValidation(
3138                    "conformal calibration binding is absent from training outcome".to_string(),
3139                )
3140            })?;
3141        if binding.binding.target_names != calibration.target_names {
3142            return Err(DagMlError::RuntimeValidation(
3143                "conformal calibration target order does not match training outcome binding"
3144                    .to_string(),
3145            ));
3146        }
3147        let fold_set = self.effective_plan.fold_set.as_ref().ok_or_else(|| {
3148            DagMlError::RuntimeValidation(
3149                "conformal calibration requires a source FoldSet".to_string(),
3150            )
3151        })?;
3152        let context = &calibration.context;
3153        if context.predictor_binding_fingerprint != binding.binding.binding_fingerprint
3154            || context.source_training_outcome_fingerprint != self.outcome_fingerprint
3155            || context.data_identities_fingerprint != self.data_identities_fingerprint()?
3156            || context.fold_set_fingerprint != fold_set_fingerprint(fold_set)?
3157            || context.training_influence_fingerprint
3158                != self.training_influence.manifest_fingerprint
3159        {
3160            return Err(DagMlError::RuntimeValidation(
3161                "conformal calibration context does not exactly match its training outcome"
3162                    .to_string(),
3163            ));
3164        }
3165        let training_ids = self
3166            .training_influence
3167            .entries
3168            .iter()
3169            .flat_map(|entry| {
3170                entry
3171                    .physical_sample_ids
3172                    .iter()
3173                    .chain(entry.origin_sample_ids.iter())
3174            })
3175            .collect::<BTreeSet<_>>();
3176        if context
3177            .calibration_cohort
3178            .physical_sample_ids
3179            .iter()
3180            .chain(context.calibration_cohort.origin_sample_ids.iter())
3181            .any(|id| training_ids.contains(id))
3182        {
3183            return Err(DagMlError::RuntimeValidation(
3184                "conformal calibration cohort overlaps training influence closure".to_string(),
3185            ));
3186        }
3187        self.execution_bundle.conformal_calibration = Some(calibration.reference()?);
3188        self.conformal_calibration = Some(calibration);
3189        self.conformal_calibration_replay = Some(replay);
3190        *self = stabilize_training_outcome_for_tcv1(self.clone())?;
3191        self.validate()
3192    }
3193
3194    /// Build the compact cross-link embedded by a portable predictor package.
3195    pub fn to_reference(&self) -> Result<TrainingOutcomeRef> {
3196        self.validate()?;
3197        validate_sha256(
3198            "training outcome request",
3199            &self.training_request_fingerprint,
3200        )?;
3201        Ok(TrainingOutcomeRef {
3202            outcome_id: self.outcome_id.clone(),
3203            outcome_fingerprint: self.outcome_fingerprint.clone(),
3204            pre_conformal_outcome_fingerprint: self
3205                .conformal_calibration
3206                .as_ref()
3207                .map(|_| self.pre_conformal_outcome_fingerprint())
3208                .transpose()?,
3209            training_request_fingerprint: self.training_request_fingerprint.clone(),
3210            effective_plan_fingerprint: self.effective_plan_fingerprint.clone(),
3211            execution_bundle_id: self.execution_bundle.bundle_id.clone(),
3212            execution_bundle_fingerprint: self.execution_bundle_fingerprint()?,
3213            data_identities_fingerprint: self.data_identities_fingerprint()?,
3214            output_binding_fingerprints: self
3215                .outputs
3216                .iter()
3217                .map(|output| output.binding.binding_fingerprint.clone())
3218                .collect(),
3219            training_influence_fingerprint: self.training_influence.manifest_fingerprint.clone(),
3220        })
3221    }
3222
3223    /// Export a self-contained portable predictor package contract from this
3224    /// training outcome. Runtime handles are never serialized; host-sidecar
3225    /// artifacts are represented only by their signed artifact descriptors and
3226    /// must be resolved into process-local handles by `PortablePredictorPackage::load_with`.
3227    pub fn to_portable_predictor_package(
3228        &self,
3229        package_id: impl Into<String>,
3230        fitted_artifact_mode: FittedArtifactMode,
3231        artifact_load_mode: ArtifactLoadMode,
3232    ) -> Result<PortablePredictorPackage> {
3233        self.validate()?;
3234        let mut template = PredictorTemplate {
3235            graph: self.effective_plan.graph_plan.graph.clone(),
3236            campaign: self.effective_plan.campaign.clone(),
3237            controller_manifests: self.effective_plan.controller_manifests.clone(),
3238            template_fingerprint: zero_fingerprint(),
3239        };
3240        template.template_fingerprint = template.compute_fingerprint()?;
3241
3242        let output_bindings = self
3243            .outputs
3244            .iter()
3245            .map(|output| output.binding.clone())
3246            .collect::<Vec<_>>();
3247        let predictor_node_ids = predictor_closure(
3248            &self.effective_plan,
3249            output_bindings
3250                .iter()
3251                .map(|binding| binding.node_id.clone()),
3252        )?
3253        .into_iter()
3254        .collect::<Vec<_>>();
3255        let mut artifact_bindings = self
3256            .execution_bundle
3257            .refit_artifacts
3258            .iter()
3259            .map(|record| PackageArtifactBinding {
3260                artifact_id: record.artifact.id.clone(),
3261                load_mode: artifact_load_mode,
3262            })
3263            .collect::<Vec<_>>();
3264        artifact_bindings.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id));
3265        let mut package = PortablePredictorPackage {
3266            schema_version: PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION,
3267            package_id: package_id.into(),
3268            template,
3269            training_request_fingerprint: self.training_request_fingerprint.clone(),
3270            training_outcome: self.to_reference()?,
3271            effective_plan: self.effective_plan.clone(),
3272            execution_bundle: self.execution_bundle.clone(),
3273            conformal_calibration: self.conformal_calibration.clone(),
3274            conformal_calibration_replay: self.conformal_calibration_replay.clone(),
3275            output_bindings,
3276            predictor_node_ids,
3277            training_influence: self.training_influence.clone(),
3278            data_identities: self.data_identities.clone(),
3279            fitted_artifact_mode,
3280            artifact_bindings,
3281            package_fingerprint: zero_fingerprint(),
3282        };
3283        package.package_fingerprint = package.compute_fingerprint()?;
3284        package.validate()?;
3285        Ok(package)
3286    }
3287
3288    pub fn validate(&self) -> Result<()> {
3289        if self.schema_version < MIN_READABLE_TRAINING_OUTCOME_SCHEMA_VERSION
3290            || self.schema_version > TRAINING_OUTCOME_SCHEMA_VERSION
3291        {
3292            return contract_error(format!(
3293                "training outcome schema_version {} is unsupported; maximum readable version is {}",
3294                self.schema_version, TRAINING_OUTCOME_SCHEMA_VERSION
3295            ));
3296        }
3297        RunId::new(self.outcome_id.clone()).map_err(|error| {
3298            DagMlError::CampaignValidation(format!(
3299                "training outcome_id is not a portable identifier: {error}"
3300            ))
3301        })?;
3302        validate_sha256(
3303            "training outcome request",
3304            &self.training_request_fingerprint,
3305        )?;
3306        validate_sha256("training outcome plan", &self.effective_plan_fingerprint)?;
3307        validate_sha256(
3308            "training outcome selected variant",
3309            &self.selected_variant_fingerprint,
3310        )?;
3311        validate_sha256("training outcome", &self.outcome_fingerprint)?;
3312        self.effective_plan.validate()?;
3313        if self.effective_plan_fingerprint
3314            != tcv1_fingerprint(&self.effective_plan, "training outcome effective plan")?
3315        {
3316            return contract_error(
3317                "training outcome effective_plan_fingerprint does not match TCV1 plan content",
3318            );
3319        }
3320
3321        let selected = self
3322            .effective_plan
3323            .variants
3324            .iter()
3325            .filter(|variant| variant.variant_id == self.selected_variant_id)
3326            .collect::<Vec<_>>();
3327        let [selected] = selected.as_slice() else {
3328            return contract_error(
3329                "training outcome selected_variant_id is absent or duplicated in effective plan",
3330            );
3331        };
3332        if selected.fingerprint != self.selected_variant_fingerprint {
3333            return contract_error(
3334                "training outcome selected_variant_fingerprint does not match effective plan",
3335            );
3336        }
3337        let expected_patches = selected_variant_parameter_patches(selected)?;
3338        validate_outcome_parameter_patches(
3339            &self.effective_plan,
3340            &self.parameter_patches,
3341            &expected_patches,
3342        )?;
3343        if !self.parameter_patches.is_empty()
3344            && !self
3345                .training_influence
3346                .entries
3347                .iter()
3348                .any(|entry| entry.kind == TrainingInfluenceKind::HpoSelection)
3349        {
3350            return contract_error(
3351                "training outcome parameter patches require hpo_selection influence",
3352            );
3353        }
3354
3355        self.validate_refit()?;
3356        self.score_set.validate()?;
3357        self.validate_version_family()?;
3358        if self.schema_version == LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION
3359            && (self.conformal_calibration.is_some() || self.conformal_calibration_replay.is_some())
3360        {
3361            return contract_error(
3362                "training outcome V1 cannot carry conformal state; migrate to V2",
3363            );
3364        }
3365        if self.score_set.plan_id != self.effective_plan.id {
3366            return contract_error("training outcome score_set.plan_id does not match plan");
3367        }
3368        if !self
3369            .score_set
3370            .reports
3371            .iter()
3372            .any(|report| report.variant_id.as_ref() == Some(&self.selected_variant_id))
3373        {
3374            return contract_error("training outcome score_set has no report for selected variant");
3375        }
3376        self.validate_selection_decision()?;
3377
3378        let closure = self.validate_outputs()?;
3379        let expected_predictor_execution_closure = self
3380            .effective_plan
3381            .node_plans
3382            .keys()
3383            .cloned()
3384            .collect::<BTreeSet<_>>();
3385        if closure != expected_predictor_execution_closure {
3386            return contract_error(
3387                "training outcome predictor closure does not equal the explicit V1 predictor execution closure",
3388            );
3389        }
3390        self.training_influence.validate()?;
3391        validate_influence_against_closure(
3392            &self.training_influence,
3393            &self.effective_plan,
3394            &closure,
3395        )?;
3396        let base_fit_nodes = self
3397            .training_influence
3398            .entries
3399            .iter()
3400            .filter(|entry| {
3401                matches!(
3402                    entry.kind,
3403                    TrainingInfluenceKind::TransformFit
3404                        | TrainingInfluenceKind::ModelFit
3405                        | TrainingInfluenceKind::TrainedMetaAggregation
3406                )
3407            })
3408            .filter_map(|entry| entry.node_id.clone())
3409            .collect::<BTreeSet<_>>();
3410        if self
3411            .outputs
3412            .iter()
3413            .any(|output| !base_fit_nodes.contains(&output.binding.node_id))
3414        {
3415            return contract_error("training outcome output node has no fitting influence");
3416        }
3417
3418        self.execution_bundle
3419            .validate_against_plan(&self.effective_plan)?;
3420        if self.execution_bundle.selected_variant_id.as_ref() != Some(&self.selected_variant_id) {
3421            return contract_error(
3422                "training outcome execution bundle selected variant does not match outcome",
3423            );
3424        }
3425        if self.execution_bundle.scores.as_ref() != Some(&self.score_set) {
3426            return contract_error(
3427                "training outcome execution bundle scores do not equal score_set",
3428            );
3429        }
3430        if self.execution_bundle.methods_hpo_resume_state != self.methods_hpo_resume_state {
3431            return contract_error(
3432                "training outcome Methods HPO resume state does not equal execution bundle state",
3433            );
3434        }
3435        match (
3436            &self.conformal_calibration,
3437            &self.conformal_calibration_replay,
3438            &self.execution_bundle.conformal_calibration,
3439        ) {
3440            (Some(calibration), Some(replay), Some(reference)) => {
3441                reference.validate_against(calibration)?;
3442                let pre_conformal_source = self.pre_conformal_outcome()?;
3443                let replay_request = replay_request_from_outcome(replay);
3444                replay.validate_against(&pre_conformal_source, &replay_request)?;
3445                let binding = self
3446                    .outputs
3447                    .iter()
3448                    .find(|output| output.binding.binding_id == calibration.binding_id)
3449                    .ok_or_else(|| {
3450                        DagMlError::RuntimeValidation(
3451                            "conformal calibration binding is absent from training outcome"
3452                                .to_string(),
3453                        )
3454                    })?;
3455                let fold_set = self.effective_plan.fold_set.as_ref().ok_or_else(|| {
3456                    DagMlError::RuntimeValidation(
3457                        "conformal calibration requires a source FoldSet".to_string(),
3458                    )
3459                })?;
3460                let context = &calibration.context;
3461                if binding.binding.target_names != calibration.target_names
3462                    || context.predictor_binding_fingerprint != binding.binding.binding_fingerprint
3463                    || context.source_training_outcome_fingerprint
3464                        != pre_conformal_source.outcome_fingerprint
3465                    || context.calibration_replay_outcome_fingerprint != replay.outcome_fingerprint
3466                    || context.data_identities_fingerprint != self.data_identities_fingerprint()?
3467                    || context.fold_set_fingerprint != fold_set_fingerprint(fold_set)?
3468                    || context.training_influence_fingerprint
3469                        != self.training_influence.manifest_fingerprint
3470                {
3471                    return contract_error(
3472                        "training outcome conformal context does not exactly cross-link its pre-calibration source",
3473                    );
3474                }
3475                if context.relation_fingerprint == self.training_influence.relation_fingerprint {
3476                    return contract_error(
3477                        "training outcome calibration relation authority must be distinct from development relations",
3478                    );
3479                }
3480                let replay_output = replay
3481                    .outputs
3482                    .iter()
3483                    .find(|output| output.binding.binding_id == calibration.binding_id)
3484                    .ok_or_else(|| {
3485                        DagMlError::RuntimeValidation(
3486                            "conformal calibration replay is missing its selected binding"
3487                                .to_string(),
3488                        )
3489                    })?;
3490                let [point] = replay_output.predictions.as_slice() else {
3491                    return contract_error(
3492                        "conformal calibration replay requires exactly one selected point block",
3493                    );
3494                };
3495                if replay.phase != Phase::Predict
3496                    || replay_output.binding != binding.binding
3497                    || point.sample_ids != calibration.sample_ids
3498                    || point.sample_ids != context.calibration_cohort.physical_sample_ids
3499                    || replay.input_data_identities.iter().any(|identity| {
3500                        identity.relation_fingerprint != context.relation_fingerprint
3501                    })
3502                {
3503                    return contract_error(
3504                        "conformal calibration replay evidence does not match its selected binding, samples, or relation authority",
3505                    );
3506                }
3507                let training_ids = self
3508                    .training_influence
3509                    .entries
3510                    .iter()
3511                    .flat_map(|entry| {
3512                        entry
3513                            .physical_sample_ids
3514                            .iter()
3515                            .chain(entry.origin_sample_ids.iter())
3516                    })
3517                    .collect::<BTreeSet<_>>();
3518                if context
3519                    .calibration_cohort
3520                    .physical_sample_ids
3521                    .iter()
3522                    .chain(context.calibration_cohort.origin_sample_ids.iter())
3523                    .any(|id| training_ids.contains(id))
3524                {
3525                    return contract_error(
3526                        "conformal calibration cohort overlaps training influence closure",
3527                    );
3528                }
3529            }
3530            (None, None, None) => {}
3531            _ => {
3532                return contract_error(
3533                    "training outcome and execution bundle conformal state disagree",
3534                )
3535            }
3536        }
3537        if let Some(state) = &self.methods_hpo_resume_state {
3538            let terminal_reports = state
3539                .completed_reports
3540                .iter()
3541                .map(|completed| completed.report.clone())
3542                .collect::<Vec<_>>();
3543            if self.score_set.reports != terminal_reports {
3544                return contract_error(
3545                    "training outcome score_set does not exactly retain Methods HPO terminal OOF reports",
3546                );
3547            }
3548        }
3549        self.validate_data_identities()?;
3550        validate_all_identity_relations(
3551            &self.data_identities,
3552            &self.training_influence.relation_fingerprint,
3553        )?;
3554        self.validate_artifacts(&closure)?;
3555        self.validate_lineage(&closure)?;
3556        match &self.portable_prediction_caches {
3557            Some(caches) => caches.validate_against_bundle(&self.execution_bundle)?,
3558            None if !self.execution_bundle.prediction_caches.is_empty() => {
3559                return contract_error(
3560                    "training outcome portable caches are null while bundle announces caches",
3561                );
3562            }
3563            None => {}
3564        }
3565
3566        let expected_replay = derive_replayable_phases(
3567            &self.effective_plan,
3568            &closure,
3569            &self.refit,
3570            &self.execution_bundle,
3571            self.portable_prediction_caches.as_ref(),
3572        )?;
3573        if self.replayable_phases != expected_replay {
3574            return contract_error(
3575                "training outcome replayable_phases do not match the phases derivable from the full predictor closure and retained state",
3576            );
3577        }
3578        validate_sorted_unique_text("training outcome warnings", &self.warnings)?;
3579        let portable = serde_json::to_value(self)?;
3580        if contains_runtime_handle(&portable) {
3581            return contract_error("training outcome must not contain runtime handles");
3582        }
3583        if self.outcome_fingerprint != self.compute_fingerprint()? {
3584            return contract_error("training outcome fingerprint does not match TCV1 content");
3585        }
3586        Ok(())
3587    }
3588
3589    fn validate_version_family(&self) -> Result<()> {
3590        let expected_score_version = match self.schema_version {
3591            LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => LEGACY_SCORE_SET_SCHEMA_VERSION,
3592            TRAINING_OUTCOME_SCHEMA_VERSION => SCORE_SET_SCHEMA_VERSION,
3593            _ => unreachable!("training outcome schema_version was range-checked"),
3594        };
3595        if self.score_set.schema_version != expected_score_version {
3596            return contract_error(format!(
3597                "training outcome schema_version {} requires score_set schema_version {}, got {}",
3598                self.schema_version, expected_score_version, self.score_set.schema_version
3599            ));
3600        }
3601        let expected_bundle_version = match self.schema_version {
3602            LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => LEGACY_EXECUTION_BUNDLE_SCHEMA_VERSION,
3603            TRAINING_OUTCOME_SCHEMA_VERSION => EXECUTION_BUNDLE_SCHEMA_VERSION,
3604            _ => unreachable!("training outcome schema_version was range-checked"),
3605        };
3606        if self.execution_bundle.schema_version != expected_bundle_version {
3607            return contract_error(format!(
3608                "training outcome schema_version {} requires execution_bundle schema_version {}, got {}",
3609                self.schema_version,
3610                expected_bundle_version,
3611                self.execution_bundle.schema_version
3612            ));
3613        }
3614        let expected_cache_version = match self.schema_version {
3615            LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => {
3616                LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION
3617            }
3618            TRAINING_OUTCOME_SCHEMA_VERSION => PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
3619            _ => unreachable!("training outcome schema_version was range-checked"),
3620        };
3621        if let Some(caches) = &self.portable_prediction_caches {
3622            if caches.schema_version != expected_cache_version {
3623                return contract_error(format!(
3624                    "training outcome schema_version {} requires prediction cache payload set schema_version {}, got {}",
3625                    self.schema_version, expected_cache_version, caches.schema_version
3626                ));
3627            }
3628        }
3629        for output in &self.outputs {
3630            match (self.schema_version, output.schema_version) {
3631                (LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION, None) => {}
3632                (LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION, Some(version)) => {
3633                    return contract_error(format!(
3634                        "training outcome V1 requires absent bound output schema_version, got {version}"
3635                    ));
3636                }
3637                (TRAINING_OUTCOME_SCHEMA_VERSION, Some(BOUND_TRAINING_OUTPUT_SCHEMA_VERSION)) => {}
3638                (TRAINING_OUTCOME_SCHEMA_VERSION, Some(version)) => {
3639                    return contract_error(format!(
3640                        "training outcome V2 requires bound output schema_version {}, got {version}",
3641                        BOUND_TRAINING_OUTPUT_SCHEMA_VERSION
3642                    ));
3643                }
3644                (TRAINING_OUTCOME_SCHEMA_VERSION, None) => {
3645                    return contract_error(
3646                        "training outcome V2 requires bound output schema_version",
3647                    );
3648                }
3649                _ => unreachable!("training outcome schema_version was range-checked"),
3650            }
3651        }
3652        Ok(())
3653    }
3654
3655    fn validate_data_identities(&self) -> Result<()> {
3656        if self.data_identities.is_empty() {
3657            return contract_error("training outcome requires data identities");
3658        }
3659        let mut previous: Option<&str> = None;
3660        for identity in &self.data_identities {
3661            identity.validate()?;
3662            if previous.is_some_and(|key| key >= identity.requirement_key.as_str()) {
3663                return contract_error(
3664                    "training outcome data identities must be sorted and unique",
3665                );
3666            }
3667            previous = Some(identity.requirement_key.as_str());
3668            let requirement = self
3669                .execution_bundle
3670                .data_requirements
3671                .iter()
3672                .find(|requirement| requirement.key() == identity.requirement_key)
3673                .ok_or_else(|| {
3674                    DagMlError::CampaignValidation(format!(
3675                        "training outcome data identity `{}` has no bundle requirement",
3676                        identity.requirement_key
3677                    ))
3678                })?;
3679            if requirement.schema_fingerprint != identity.schema_fingerprint
3680                || requirement.plan_fingerprint != identity.plan_fingerprint
3681                || requirement.relation_fingerprint.as_ref() != Some(&identity.relation_fingerprint)
3682            {
3683                return contract_error(
3684                    "training outcome data identity does not match execution bundle requirement",
3685                );
3686            }
3687        }
3688        if self.data_identities.len() != self.execution_bundle.data_requirements.len() {
3689            return contract_error(
3690                "training outcome data identities do not exactly cover bundle data requirements",
3691            );
3692        }
3693        Ok(())
3694    }
3695
3696    fn validate_selection_decision(&self) -> Result<()> {
3697        if self.selection_output_id.trim().is_empty() {
3698            return contract_error("training outcome selection_output_id is empty");
3699        }
3700        let bindings = self
3701            .outputs
3702            .iter()
3703            .filter(|output| output.binding.binding_id == self.selection_output_id)
3704            .collect::<Vec<_>>();
3705        let [selected_output] = bindings.as_slice() else {
3706            return contract_error(
3707                "training outcome selection_output_id does not resolve exactly one output",
3708            );
3709        };
3710        if self.execution_bundle.selections.len() != 1 {
3711            return contract_error(
3712                "training outcome execution bundle must contain exactly one SELECT decision",
3713            );
3714        }
3715        let (selection_key, decision) = self
3716            .execution_bundle
3717            .selections
3718            .iter()
3719            .next()
3720            .expect("selection length was checked");
3721        if selection_key != &decision.policy_id
3722            || decision.selected_candidate_id != self.selected_variant_id.as_str()
3723            || decision.metric_level != Some(selected_output.binding.prediction_level)
3724            || decision.evaluation_scope != Some(EvaluationScope::Oof)
3725            || self.score_set.selection_metric.as_deref() != Some(decision.metric_name.as_str())
3726            || selected_output.binding.prediction_level
3727                != self
3728                    .effective_plan
3729                    .campaign
3730                    .aggregation_policy
3731                    .selection_metric_level
3732        {
3733            return contract_error(
3734                "training outcome SELECT decision metadata is inconsistent with selected output",
3735            );
3736        }
3737        RegressionMetricKind::resolve_for_prediction_kind(
3738            &decision.metric_name,
3739            decision.objective,
3740            selected_output.binding.prediction_kind,
3741        )?;
3742        let mut reports_by_variant = BTreeMap::<VariantId, _>::new();
3743        for report in self.score_set.reports.iter().filter(|report| {
3744            report.producer_node == selected_output.binding.node_id
3745                && producer_port_matches_graph_output(
3746                    &self.effective_plan,
3747                    &selected_output.binding.node_id,
3748                    &selected_output.binding.port_name,
3749                    &report.producer_port,
3750                )
3751                && report.partition == PredictionPartition::Validation
3752                && report.level == selected_output.binding.prediction_level
3753                && report
3754                    .fold_id
3755                    .as_ref()
3756                    .is_some_and(|fold| fold.as_str() == "avg")
3757        }) {
3758            let variant_id = report.variant_id.clone().ok_or_else(|| {
3759                DagMlError::CampaignValidation(
3760                    "selection output average report has no variant_id".to_string(),
3761                )
3762            })?;
3763            if reports_by_variant
3764                .insert(variant_id, report.clone())
3765                .is_some()
3766            {
3767                return contract_error(
3768                    "training outcome has multiple selection average reports for one variant",
3769                );
3770            }
3771        }
3772        let expected_variants = self
3773            .effective_plan
3774            .variants
3775            .iter()
3776            .map(|variant| variant.variant_id.clone())
3777            .collect::<BTreeSet<_>>();
3778        if reports_by_variant.keys().cloned().collect::<BTreeSet<_>>() != expected_variants {
3779            return contract_error(
3780                "training outcome selection reports do not exactly cover plan variants",
3781            );
3782        }
3783        let candidates = reports_by_variant
3784            .into_iter()
3785            .map(|(variant_id, report)| report.into_candidate_score(variant_id.as_str()))
3786            .collect::<Result<Vec<_>>>()?;
3787        let reconstructed = select_candidate(
3788            &SelectionPolicy {
3789                id: decision.policy_id.clone(),
3790                metric: SelectionMetric {
3791                    name: decision.metric_name.clone(),
3792                    objective: decision.objective,
3793                },
3794                required_metric_level: decision.metric_level,
3795                require_finite: true,
3796                evaluation_scope: decision.evaluation_scope,
3797                refit_slot_plan: decision.refit_slot_plan.clone(),
3798                stacking_fit_contract: None,
3799                reduction_id: decision.reduction_id.clone(),
3800            },
3801            &candidates,
3802        )?;
3803        if &reconstructed != decision {
3804            return contract_error(
3805                "training outcome SELECT decision does not equal ranking reconstructed from scores",
3806            );
3807        }
3808        Ok(())
3809    }
3810
3811    fn validate_refit(&self) -> Result<()> {
3812        match (self.refit.requested, self.refit.status, self.refit.strategy) {
3813            (true, TrainingRefitStatus::Completed, Some(_)) => {
3814                if self
3815                    .outputs
3816                    .iter()
3817                    .any(|output| output.binding.prediction_source != PredictionSource::FinalRefit)
3818                {
3819                    return contract_error(
3820                        "completed refit outputs must use final_refit prediction source",
3821                    );
3822                }
3823            }
3824            (false, TrainingRefitStatus::Skipped, None) => {
3825                if self
3826                    .outputs
3827                    .iter()
3828                    .any(|output| output.binding.prediction_source == PredictionSource::FinalRefit)
3829                {
3830                    return contract_error("no-refit outputs cannot use final_refit");
3831                }
3832            }
3833            _ => return contract_error("training outcome refit state is inconsistent"),
3834        }
3835        Ok(())
3836    }
3837
3838    fn validate_outputs(&self) -> Result<BTreeSet<NodeId>> {
3839        if self.outputs.is_empty() {
3840            return contract_error("training outcome requires at least one bound output");
3841        }
3842        let mut previous: Option<&str> = None;
3843        let mut roots = Vec::new();
3844        for output in &self.outputs {
3845            if previous.is_some_and(|value| value >= output.binding.binding_id.as_str()) {
3846                return contract_error(
3847                    "training outcome outputs must be strictly sorted by binding_id",
3848                );
3849            }
3850            previous = Some(output.binding.binding_id.as_str());
3851            output.validate(&self.effective_plan)?;
3852            roots.push(output.binding.node_id.clone());
3853        }
3854        predictor_closure(&self.effective_plan, roots)
3855    }
3856
3857    fn validate_artifacts(&self, closure: &BTreeSet<NodeId>) -> Result<()> {
3858        if !self.refit.requested {
3859            if !self.execution_bundle.refit_artifacts.is_empty() {
3860                return contract_error("no-refit training outcome contains refit artifacts");
3861            }
3862            return Ok(());
3863        }
3864        if self.execution_bundle.refit_artifacts.is_empty() {
3865            return contract_error("completed refit requires at least one artifact");
3866        }
3867        let expected_artifact_nodes = closure
3868            .iter()
3869            .filter(|node_id| {
3870                let plan = &self.effective_plan.node_plans[*node_id];
3871                plan.supported_phases.contains(&Phase::Refit)
3872                    && plan
3873                        .controller_capabilities
3874                        .contains(&ControllerCapability::EmitsArtifacts)
3875            })
3876            .cloned()
3877            .collect::<BTreeSet<_>>();
3878        let artifact_nodes = self
3879            .execution_bundle
3880            .refit_artifacts
3881            .iter()
3882            .map(|record| record.node_id.clone())
3883            .collect::<BTreeSet<_>>();
3884        if artifact_nodes != expected_artifact_nodes {
3885            return contract_error(
3886                "refit artifact nodes do not exactly match predictor closure REFIT artifact emitters",
3887            );
3888        }
3889        for output in &self.outputs {
3890            if !artifact_nodes.contains(&output.binding.node_id) {
3891                return contract_error("final output node has no refit artifact");
3892            }
3893        }
3894        Ok(())
3895    }
3896
3897    fn validate_lineage(&self, closure: &BTreeSet<NodeId>) -> Result<()> {
3898        if self.lineage.is_empty() {
3899            return contract_error("training outcome requires portable lineage");
3900        }
3901        let record_ids = self
3902            .lineage
3903            .iter()
3904            .map(|record| record.record_id.clone())
3905            .collect::<Vec<_>>();
3906        if record_ids.windows(2).any(|pair| pair[0] >= pair[1]) {
3907            return contract_error("training outcome lineage must be sorted by record_id");
3908        }
3909        let by_id = self
3910            .lineage
3911            .iter()
3912            .map(|record| (record.record_id.clone(), record))
3913            .collect::<BTreeMap<_, _>>();
3914        if by_id.len() != self.lineage.len() {
3915            return contract_error("training outcome lineage contains duplicate record ids");
3916        }
3917        let mut coordinates = BTreeMap::new();
3918        for record in &self.lineage {
3919            record.validate()?;
3920            if record.run_id != self.run_id
3921                || record.variant_id.as_ref() != Some(&self.selected_variant_id)
3922                || !closure.contains(&record.node_id)
3923            {
3924                return contract_error(
3925                    "training outcome lineage run, variant, or predictor closure is inconsistent",
3926                );
3927            }
3928            if !matches!(record.phase, Phase::FitCv | Phase::Select | Phase::Refit) {
3929                return contract_error("training outcome lineage contains a non-training phase");
3930            }
3931            let plan = &self.effective_plan.node_plans[&record.node_id];
3932            if record.controller_id != plan.controller_id
3933                || record.controller_version != plan.controller_version
3934                || record.params_fingerprint != plan.params_fingerprint
3935            {
3936                return contract_error("training outcome lineage does not match node plan");
3937            }
3938            let key = (record.phase, record.fold_id.clone(), record.node_id.clone());
3939            if coordinates.insert(key, record).is_some() {
3940                return contract_error("training outcome lineage duplicates phase/fold/node");
3941            }
3942            if record
3943                .input_lineage
3944                .iter()
3945                .any(|input| !by_id.contains_key(input))
3946            {
3947                return contract_error("training outcome lineage references an unknown input");
3948            }
3949        }
3950        validate_lineage_coordinates(self, closure, &coordinates)
3951    }
3952}
3953
3954impl BoundTrainingOutput {
3955    pub(crate) fn validate(&self, plan: &ExecutionPlan) -> Result<()> {
3956        if let Some(schema_version) = self.schema_version {
3957            if schema_version != BOUND_TRAINING_OUTPUT_SCHEMA_VERSION {
3958                return contract_error(format!(
3959                    "bound training output schema_version {schema_version} is unsupported; current {}",
3960                    BOUND_TRAINING_OUTPUT_SCHEMA_VERSION
3961                ));
3962            }
3963        }
3964        self.binding.validate(&plan.graph_plan.graph)?;
3965        if self.predictions.is_empty()
3966            && self.observation_predictions.is_empty()
3967            && self.aggregated_predictions.is_empty()
3968        {
3969            return contract_error("bound training output contains no prediction block");
3970        }
3971        match self.binding.prediction_level {
3972            PredictionLevel::Observation
3973                if !self.predictions.is_empty() || !self.aggregated_predictions.is_empty() =>
3974            {
3975                return contract_error(
3976                    "observation output binding cannot contain sample or aggregated predictions",
3977                );
3978            }
3979            PredictionLevel::Sample if !self.observation_predictions.is_empty() => {
3980                return contract_error(
3981                    "sample output binding cannot contain observation predictions",
3982                );
3983            }
3984            PredictionLevel::Target | PredictionLevel::Group
3985                if !self.predictions.is_empty() || !self.observation_predictions.is_empty() =>
3986            {
3987                return contract_error(
3988                    "target/group output binding cannot contain sample or observation predictions",
3989                );
3990            }
3991            _ => {}
3992        }
3993        let expected_names = expected_output_columns(&self.binding);
3994        for block in &self.predictions {
3995            block.validate_shape()?;
3996            validate_bound_block(
3997                plan,
3998                &self.binding,
3999                &block.producer_node,
4000                &block.producer_port,
4001                &block.partition,
4002                block.fold_id.as_ref(),
4003                &block.target_names,
4004                &expected_names,
4005            )?;
4006        }
4007        for block in &self.observation_predictions {
4008            block.validate_shape()?;
4009            validate_bound_block(
4010                plan,
4011                &self.binding,
4012                &block.producer_node,
4013                &block.producer_port,
4014                &block.partition,
4015                block.fold_id.as_ref(),
4016                &block.target_names,
4017                &expected_names,
4018            )?;
4019        }
4020        for block in &self.aggregated_predictions {
4021            block.validate_shape()?;
4022            if block.level != self.binding.prediction_level {
4023                return contract_error(
4024                    "bound aggregated prediction level does not match output binding",
4025                );
4026            }
4027            validate_bound_block(
4028                plan,
4029                &self.binding,
4030                &block.producer_node,
4031                &block.producer_port,
4032                &block.partition,
4033                block.fold_id.as_ref(),
4034                &block.target_names,
4035                &expected_names,
4036            )?;
4037        }
4038        match self.binding.prediction_level {
4039            PredictionLevel::Observation if self.observation_predictions.is_empty() => {
4040                return contract_error(
4041                    "observation output binding requires observation predictions",
4042                );
4043            }
4044            PredictionLevel::Target | PredictionLevel::Group
4045                if self.aggregated_predictions.is_empty() =>
4046            {
4047                return contract_error(
4048                    "target/group output binding requires aggregated predictions",
4049                );
4050            }
4051            _ => {}
4052        }
4053        Ok(())
4054    }
4055}
4056
4057#[allow(clippy::too_many_arguments)]
4058fn validate_bound_block(
4059    plan: &ExecutionPlan,
4060    binding: &OutputBinding,
4061    producer: &NodeId,
4062    producer_port: &Option<String>,
4063    partition: &PredictionPartition,
4064    fold_id: Option<&crate::ids::FoldId>,
4065    target_names: &[String],
4066    expected_names: &[String],
4067) -> Result<()> {
4068    if producer != &binding.node_id
4069        || !producer_port_matches_graph_output(
4070            plan,
4071            &binding.node_id,
4072            &binding.port_name,
4073            producer_port,
4074        )
4075        || target_names != expected_names
4076    {
4077        return contract_error(
4078            "bound prediction producer, producer_port or target order does not match output binding",
4079        );
4080    }
4081    if binding.prediction_source == PredictionSource::FinalRefit
4082        && (partition != &PredictionPartition::Final || fold_id.is_some())
4083    {
4084        return contract_error("final_refit output blocks must use final partition without fold");
4085    }
4086    if binding.prediction_source == PredictionSource::CvEnsemble
4087        && (!is_cv_ensemble_partition(partition) || fold_id.is_none())
4088    {
4089        return contract_error(
4090            "cv_ensemble output blocks must use validation partition with a fold id",
4091        );
4092    }
4093    Ok(())
4094}
4095
4096fn expected_output_columns(binding: &OutputBinding) -> Vec<String> {
4097    if binding.prediction_kind == PredictionKind::ClassProbability {
4098        binding
4099            .target_names
4100            .iter()
4101            .zip(&binding.class_labels)
4102            .flat_map(|(target, labels)| {
4103                labels.iter().map(move |label| format!("{target}:{label}"))
4104            })
4105            .collect()
4106    } else {
4107        binding.target_names.clone()
4108    }
4109}
4110
4111fn selected_variant_parameter_patches(
4112    variant: &crate::generation::VariantPlan,
4113) -> Result<Vec<ParameterPatch>> {
4114    let mut patches = Vec::new();
4115    for choice in variant.choices.values() {
4116        for override_spec in &choice.param_overrides {
4117            for (key, value) in &override_spec.params {
4118                append_parameter_leaves(
4119                    &override_spec.node_id,
4120                    vec![key.clone()],
4121                    value,
4122                    &mut patches,
4123                )?;
4124            }
4125        }
4126    }
4127    patches.sort_by(|left, right| {
4128        (&left.node_id, left.namespace, &left.path).cmp(&(
4129            &right.node_id,
4130            right.namespace,
4131            &right.path,
4132        ))
4133    });
4134    if patches.windows(2).any(|pair| {
4135        pair[0].node_id == pair[1].node_id
4136            && pair[0].namespace == pair[1].namespace
4137            && pair[0].path == pair[1].path
4138    }) {
4139        return contract_error("selected variant overrides contain duplicate leaf paths");
4140    }
4141    Ok(patches)
4142}
4143
4144fn merge_training_parameter_patches(
4145    request_patches: &[ParameterPatch],
4146    selected_variant: &crate::generation::VariantPlan,
4147) -> Result<Vec<ParameterPatch>> {
4148    let mut patches = request_patches.to_vec();
4149    patches.extend(selected_variant_parameter_patches(selected_variant)?);
4150    sort_and_validate_training_parameter_patch_keys(&mut patches, false)?;
4151    Ok(patches)
4152}
4153
4154fn validate_outcome_parameter_patches(
4155    plan: &ExecutionPlan,
4156    patches: &[ParameterPatch],
4157    selected_variant_patches: &[ParameterPatch],
4158) -> Result<()> {
4159    let mut patches = patches.to_vec();
4160    sort_and_validate_training_parameter_patch_keys(&mut patches, true)?;
4161    let keys = patches
4162        .iter()
4163        .map(parameter_patch_key)
4164        .collect::<BTreeSet<_>>();
4165    for selected in selected_variant_patches {
4166        if !keys.contains(&parameter_patch_key(selected)) {
4167            return contract_error(
4168                "training outcome parameter_patches are missing a selected variant override",
4169            );
4170        }
4171    }
4172    for patch in &patches {
4173        validate_materialized_patch(plan, patch)?;
4174    }
4175    Ok(())
4176}
4177
4178fn sort_and_validate_training_parameter_patch_keys(
4179    patches: &mut [ParameterPatch],
4180    require_already_sorted: bool,
4181) -> Result<()> {
4182    for patch in patches.iter() {
4183        patch.validate()?;
4184        if patch.namespace != ParameterNamespace::Operator {
4185            return contract_error(
4186                "training outcome parameter_patches must use operator namespace",
4187            );
4188        }
4189    }
4190    let original = patches.to_vec();
4191    patches.sort_by(|left, right| parameter_patch_key(left).cmp(&parameter_patch_key(right)));
4192    if require_already_sorted && patches != original {
4193        return contract_error(
4194            "training outcome parameter_patches must be sorted by (node_id, namespace, path)",
4195        );
4196    }
4197    for pair in patches.windows(2) {
4198        let left = &pair[0];
4199        let right = &pair[1];
4200        if parameter_patch_key(left) == parameter_patch_key(right) {
4201            return contract_error(
4202                "training outcome parameter_patches contain duplicate leaf paths",
4203            );
4204        }
4205        if left.node_id == right.node_id
4206            && left.namespace == right.namespace
4207            && (right.path.starts_with(&left.path) || left.path.starts_with(&right.path))
4208        {
4209            return contract_error(
4210                "training outcome parameter_patches contain a conflicting parent/child path",
4211            );
4212        }
4213    }
4214    Ok(())
4215}
4216
4217fn parameter_patch_key(patch: &ParameterPatch) -> (&NodeId, ParameterNamespace, &[String]) {
4218    (&patch.node_id, patch.namespace, patch.path.as_slice())
4219}
4220
4221fn append_parameter_leaves(
4222    node_id: &NodeId,
4223    path: Vec<String>,
4224    value: &serde_json::Value,
4225    output: &mut Vec<ParameterPatch>,
4226) -> Result<()> {
4227    if let serde_json::Value::Object(object) = value {
4228        for (key, child) in object {
4229            let mut child_path = path.clone();
4230            child_path.push(key.clone());
4231            append_parameter_leaves(node_id, child_path, child, output)?;
4232        }
4233        return Ok(());
4234    }
4235    output.push(ParameterPatch {
4236        schema_version: PARAMETER_PATCH_SCHEMA_VERSION,
4237        node_id: node_id.clone(),
4238        namespace: ParameterNamespace::Operator,
4239        path,
4240        value: value.clone(),
4241    });
4242    Ok(())
4243}
4244
4245fn validate_materialized_patch(plan: &ExecutionPlan, patch: &ParameterPatch) -> Result<()> {
4246    patch.validate()?;
4247    if patch.namespace != ParameterNamespace::Operator {
4248        return contract_error("selected variant patches must use operator namespace");
4249    }
4250    let node = plan.node_plans.get(&patch.node_id).ok_or_else(|| {
4251        DagMlError::CampaignValidation(format!(
4252            "selected parameter patch references absent node `{}`",
4253            patch.node_id
4254        ))
4255    })?;
4256    let mut current = serde_json::Value::Object(node.params.clone().into_iter().collect());
4257    for segment in &patch.path {
4258        current = current
4259            .as_object()
4260            .and_then(|object| object.get(segment))
4261            .cloned()
4262            .ok_or_else(|| {
4263                DagMlError::CampaignValidation(format!(
4264                    "selected parameter patch path for `{}` is not materialized",
4265                    patch.node_id
4266                ))
4267            })?;
4268    }
4269    if current != patch.value {
4270        return contract_error("selected parameter patch value is not materialized in plan");
4271    }
4272    Ok(())
4273}
4274
4275fn predictor_closure(
4276    plan: &ExecutionPlan,
4277    roots: impl IntoIterator<Item = NodeId>,
4278) -> Result<BTreeSet<NodeId>> {
4279    let mut pending = roots.into_iter().collect::<Vec<_>>();
4280    let mut closure = BTreeSet::new();
4281    while let Some(node_id) = pending.pop() {
4282        if !closure.insert(node_id.clone()) {
4283            continue;
4284        }
4285        let node = plan.node_plans.get(&node_id).ok_or_else(|| {
4286            DagMlError::CampaignValidation(format!(
4287                "training outcome closure references absent node `{node_id}`"
4288            ))
4289        })?;
4290        pending.extend(node.input_nodes.iter().cloned());
4291    }
4292    Ok(closure)
4293}
4294
4295/// Per-node facts the replay derivation reads for one predictor-closure node.
4296struct NodeReplayFacts {
4297    supported_phases: BTreeSet<Phase>,
4298    /// Node carries fitted inference state that a later PREDICT/EXPLAIN must
4299    /// reload: it is `stateful` or emits artifacts (capabilities
4300    /// `Stateful || EmitsArtifacts`). This is deliberately NOT inferred from
4301    /// `artifact_policy`/`ReplayRequired` or from `fit_scope`: a stateless
4302    /// deterministic operator — e.g. a seeded augmentation, or a
4303    /// `replay_required` transform that simply recomputes at inference — carries
4304    /// no reloadable state, needs no retained artifact, and must not block
4305    /// forward replay.
4306    requires_retained_state: bool,
4307    /// A retained refit artifact for this node is present in the bundle.
4308    has_retained_artifact: bool,
4309}
4310
4311/// Per-edge facts for one `requires_oof` dependency wholly inside the closure.
4312struct OofEdgeReplayFacts {
4313    has_bundle_requirement: bool,
4314    has_cache_record: bool,
4315    has_portable_payload: bool,
4316}
4317
4318/// Everything the pure replay decision needs, extracted from the plan/bundle so
4319/// the decision itself is unit-testable in isolation without a full plan.
4320struct ClosureReplayFacts {
4321    nodes: Vec<NodeReplayFacts>,
4322    oof_edges: Vec<OofEdgeReplayFacts>,
4323}
4324
4325/// Pure replay decision over already-extracted closure facts.
4326///
4327/// Canonical order is `[REFIT, PREDICT, EXPLAIN]`. A completed refit never
4328/// re-advertises REFIT; it exposes forward inference only when *every* closure
4329/// node supports the phase and every state-retaining closure node has a retained
4330/// refit artifact. A skipped refit exposes REFIT only when every closure node
4331/// supports REFIT and every closure OOF dependency is backed by an exact bundle
4332/// requirement, a retained cache record and a portable payload. An empty result
4333/// is a valid, honest "no replay mode" answer.
4334fn derive_replayable_phases_from_facts(
4335    completed_refit: bool,
4336    facts: &ClosureReplayFacts,
4337) -> Vec<Phase> {
4338    let all_support = |phase: Phase| {
4339        facts
4340            .nodes
4341            .iter()
4342            .all(|node| node.supported_phases.contains(&phase))
4343    };
4344    let inference_state_present = facts
4345        .nodes
4346        .iter()
4347        .all(|node| !node.requires_retained_state || node.has_retained_artifact);
4348    let oof_self_contained = facts.oof_edges.iter().all(|edge| {
4349        edge.has_bundle_requirement && edge.has_cache_record && edge.has_portable_payload
4350    });
4351
4352    let mut phases = Vec::new();
4353    if completed_refit {
4354        if all_support(Phase::Predict) && inference_state_present {
4355            phases.push(Phase::Predict);
4356        }
4357        if all_support(Phase::Explain) && inference_state_present {
4358            phases.push(Phase::Explain);
4359        }
4360    } else if all_support(Phase::Refit) && oof_self_contained {
4361        phases.push(Phase::Refit);
4362    }
4363    phases
4364}
4365
4366/// Extract the minimal per-node and per-OOF-edge facts the replay decision reads
4367/// from the portable outcome state. Shared by both `derive_replayable_phases`
4368/// (full derivation) and `closure_predict_replayable` (package PREDICT gate).
4369/// Fallible: a closure node absent from `node_plans` is a contract error, never a
4370/// panic.
4371fn closure_replay_facts(
4372    plan: &ExecutionPlan,
4373    closure: &BTreeSet<NodeId>,
4374    execution_bundle: &ExecutionBundle,
4375    portable_prediction_caches: Option<&BundlePredictionCachePayloadSet>,
4376) -> Result<ClosureReplayFacts> {
4377    let artifact_nodes = execution_bundle
4378        .refit_artifacts
4379        .iter()
4380        .map(|record| record.node_id.clone())
4381        .collect::<BTreeSet<_>>();
4382    let requirement_keys = execution_bundle
4383        .prediction_requirements
4384        .iter()
4385        .map(|requirement| requirement.key())
4386        .collect::<BTreeSet<_>>();
4387    let cache_keys = execution_bundle
4388        .prediction_caches
4389        .iter()
4390        .map(|record| record.requirement_key.clone())
4391        .collect::<BTreeSet<_>>();
4392    let payload_keys = portable_prediction_caches
4393        .map(|set| {
4394            set.caches
4395                .iter()
4396                .map(|payload| payload.requirement_key.clone())
4397                .collect::<BTreeSet<_>>()
4398        })
4399        .unwrap_or_default();
4400
4401    let nodes = closure
4402        .iter()
4403        .map(|node_id| {
4404            let node_plan = plan.node_plans.get(node_id).ok_or_else(|| {
4405                DagMlError::CampaignValidation(format!(
4406                    "replay derivation references absent node `{node_id}`"
4407                ))
4408            })?;
4409            // A node carries fitted state that PREDICT/EXPLAIN must reload only
4410            // when it is `stateful` or emits artifacts. `artifact_policy` is not
4411            // used: a stateless `replay_required` operator (e.g. prospectr)
4412            // re-runs its deterministic transform at inference with no artifact.
4413            let requires_retained_state = node_plan
4414                .controller_capabilities
4415                .contains(&ControllerCapability::Stateful)
4416                || node_plan
4417                    .controller_capabilities
4418                    .contains(&ControllerCapability::EmitsArtifacts);
4419            Ok(NodeReplayFacts {
4420                supported_phases: node_plan.supported_phases.clone(),
4421                requires_retained_state,
4422                has_retained_artifact: artifact_nodes.contains(node_id),
4423            })
4424        })
4425        .collect::<Result<Vec<_>>>()?;
4426    let oof_edges = plan
4427        .graph_plan
4428        .graph
4429        .edges
4430        .iter()
4431        .filter(|edge| {
4432            edge.contract.requires_oof
4433                && closure.contains(&edge.source.node_id)
4434                && closure.contains(&edge.target.node_id)
4435        })
4436        .map(|edge| {
4437            let key = crate::bundle::bundle_prediction_requirement_key(
4438                &edge.source.node_id,
4439                &edge.source.port_name,
4440                &edge.target.node_id,
4441                &edge.target.port_name,
4442            );
4443            OofEdgeReplayFacts {
4444                has_bundle_requirement: requirement_keys.contains(&key),
4445                has_cache_record: cache_keys.contains(&key),
4446                has_portable_payload: payload_keys.contains(&key),
4447            }
4448        })
4449        .collect::<Vec<_>>();
4450
4451    Ok(ClosureReplayFacts { nodes, oof_edges })
4452}
4453
4454/// Deterministically derive the phases a training outcome can honestly replay.
4455///
4456/// This is the single shared helper used by both construction and standalone
4457/// validation. It reads only portable outcome state (the effective plan's
4458/// node/controller support, the predictor closure, the retained refit artifacts,
4459/// the OOF prediction requirements/cache records and the portable payloads), so
4460/// re-running it during validation reproduces the exact vector a producer must
4461/// have emitted and rejects any forged claim.
4462fn derive_replayable_phases(
4463    plan: &ExecutionPlan,
4464    closure: &BTreeSet<NodeId>,
4465    refit: &TrainingRefitOutcome,
4466    execution_bundle: &ExecutionBundle,
4467    portable_prediction_caches: Option<&BundlePredictionCachePayloadSet>,
4468) -> Result<Vec<Phase>> {
4469    let facts = closure_replay_facts(plan, closure, execution_bundle, portable_prediction_caches)?;
4470    Ok(derive_replayable_phases_from_facts(
4471        matches!(refit.status, TrainingRefitStatus::Completed),
4472        &facts,
4473    ))
4474}
4475
4476/// True when the full predictor `closure` can honestly replay PREDICT given the
4477/// artifacts retained in `execution_bundle`: every closure node supports PREDICT
4478/// and every state-retaining closure node has a retained refit artifact. A
4479/// [`PortablePredictorPackage`](crate::training::PortablePredictorPackage) is a
4480/// deployable predictor, so its construction requires this independently — it
4481/// must not infer portability from a merely non-empty claimed phase set. PREDICT
4482/// replay never consumes OOF payloads, so the OOF cache facts are irrelevant.
4483pub(crate) fn closure_predict_replayable(
4484    plan: &ExecutionPlan,
4485    closure: &BTreeSet<NodeId>,
4486    execution_bundle: &ExecutionBundle,
4487) -> Result<bool> {
4488    let facts = closure_replay_facts(plan, closure, execution_bundle, None)?;
4489    Ok(derive_replayable_phases_from_facts(true, &facts).contains(&Phase::Predict))
4490}
4491
4492fn expected_base_influence_kind(
4493    plan: &ExecutionPlan,
4494    node_id: &NodeId,
4495) -> Option<TrainingInfluenceKind> {
4496    let node_plan = &plan.node_plans[node_id];
4497    if matches!(
4498        node_plan.fit_scope,
4499        ControllerFitScope::Stateless | ControllerFitScope::InferenceOnly
4500    ) {
4501        return None;
4502    }
4503    let oof_consumer = plan
4504        .graph_plan
4505        .graph
4506        .edges
4507        .iter()
4508        .any(|edge| edge.contract.requires_oof && edge.target.node_id == *node_id);
4509    Some(
4510        if oof_consumer
4511            || node_plan
4512                .controller_capabilities
4513                .contains(&ControllerCapability::TrainsAggregation)
4514        {
4515            TrainingInfluenceKind::TrainedMetaAggregation
4516        } else if node_plan.kind == NodeKind::Model {
4517            TrainingInfluenceKind::ModelFit
4518        } else if node_plan.kind == NodeKind::Tuner {
4519            TrainingInfluenceKind::HpoSelection
4520        } else {
4521            TrainingInfluenceKind::TransformFit
4522        },
4523    )
4524}
4525
4526fn validate_influence_against_closure(
4527    influence: &TrainingInfluenceManifest,
4528    plan: &ExecutionPlan,
4529    closure: &BTreeSet<NodeId>,
4530) -> Result<()> {
4531    let mut actual_base = BTreeMap::<NodeId, BTreeSet<TrainingInfluenceKind>>::new();
4532    for entry in &influence.entries {
4533        let Some(node_id) = &entry.node_id else {
4534            continue;
4535        };
4536        if !closure.contains(node_id) {
4537            return contract_error("training influence node is outside predictor closure");
4538        }
4539        if !influence_kind_allowed_by_node_role_or_capability(plan, node_id, entry.kind) {
4540            return contract_error(
4541                "training influence kind is not allowed by node role or capability",
4542            );
4543        }
4544        if expected_base_influence_kind(plan, node_id) == Some(entry.kind) {
4545            actual_base
4546                .entry(node_id.clone())
4547                .or_default()
4548                .insert(entry.kind);
4549        }
4550    }
4551    let expected = closure
4552        .iter()
4553        .filter(|node_id| {
4554            plan.node_plans[*node_id]
4555                .supported_phases
4556                .contains(&Phase::FitCv)
4557                && expected_base_influence_kind(plan, node_id).is_some()
4558        })
4559        .cloned()
4560        .collect::<BTreeSet<_>>();
4561    if actual_base.keys().cloned().collect::<BTreeSet<_>>() != expected {
4562        return contract_error(
4563            "training influence fitting nodes do not exactly match predictor closure",
4564        );
4565    }
4566    for node_id in expected {
4567        if actual_base[&node_id]
4568            != BTreeSet::from([expected_base_influence_kind(plan, &node_id)
4569                .expect("expected fitting nodes have a base influence kind")])
4570        {
4571            return contract_error("training influence fitting kind does not match node role");
4572        }
4573    }
4574    Ok(())
4575}
4576
4577fn influence_kind_allowed_by_node_role_or_capability(
4578    plan: &ExecutionPlan,
4579    node_id: &NodeId,
4580    kind: TrainingInfluenceKind,
4581) -> bool {
4582    if expected_base_influence_kind(plan, node_id) == Some(kind) {
4583        return true;
4584    }
4585    let capabilities = &plan.node_plans[node_id].controller_capabilities;
4586    match kind {
4587        TrainingInfluenceKind::HpoSelection => {
4588            capabilities.contains(&ControllerCapability::PerformsInternalTuning)
4589        }
4590        TrainingInfluenceKind::EarlyStopping => {
4591            capabilities.contains(&ControllerCapability::UsesEarlyStopping)
4592        }
4593        TrainingInfluenceKind::WeightingResampling => {
4594            capabilities.contains(&ControllerCapability::UsesTrainingWeights)
4595        }
4596        TrainingInfluenceKind::TransformFit
4597        | TrainingInfluenceKind::ModelFit
4598        | TrainingInfluenceKind::TrainedMetaAggregation => false,
4599    }
4600}
4601
4602fn validate_lineage_coordinates(
4603    outcome: &TrainingOutcome,
4604    closure: &BTreeSet<NodeId>,
4605    coordinates: &BTreeMap<(Phase, Option<crate::ids::FoldId>, NodeId), &LineageRecord>,
4606) -> Result<()> {
4607    let fold_set = outcome.effective_plan.fold_set.as_ref().ok_or_else(|| {
4608        DagMlError::CampaignValidation(
4609            "training outcome FIT_CV lineage requires a fold_set".to_string(),
4610        )
4611    })?;
4612    let expected_fit = closure
4613        .iter()
4614        .filter(|node_id| {
4615            outcome.effective_plan.node_plans[*node_id]
4616                .supported_phases
4617                .contains(&Phase::FitCv)
4618        })
4619        .flat_map(|node_id| {
4620            fold_set
4621                .folds
4622                .iter()
4623                .map(move |fold| (Phase::FitCv, Some(fold.fold_id.clone()), node_id.clone()))
4624        })
4625        .collect::<BTreeSet<_>>();
4626    let actual_fit = coordinates
4627        .keys()
4628        .filter(|(phase, _, _)| *phase == Phase::FitCv)
4629        .cloned()
4630        .collect::<BTreeSet<_>>();
4631    if actual_fit != expected_fit {
4632        return contract_error(
4633            "training outcome FIT_CV lineage does not exactly cover closure folds",
4634        );
4635    }
4636    let expected_refit = if outcome.refit.requested {
4637        closure
4638            .iter()
4639            .filter(|node_id| {
4640                outcome.effective_plan.node_plans[*node_id]
4641                    .supported_phases
4642                    .contains(&Phase::Refit)
4643            })
4644            .map(|node_id| (Phase::Refit, None, node_id.clone()))
4645            .collect::<BTreeSet<_>>()
4646    } else {
4647        BTreeSet::new()
4648    };
4649    let actual_refit = coordinates
4650        .keys()
4651        .filter(|(phase, _, _)| *phase == Phase::Refit)
4652        .cloned()
4653        .collect::<BTreeSet<_>>();
4654    if actual_refit != expected_refit {
4655        return contract_error("training outcome REFIT lineage does not exactly cover closure");
4656    }
4657
4658    for ((phase, fold, node_id), record) in coordinates {
4659        if *phase == Phase::Select {
4660            continue;
4661        }
4662        let plan = &outcome.effective_plan.node_plans[node_id];
4663        let expected_inputs = plan
4664            .input_nodes
4665            .iter()
4666            .filter(|input| {
4667                outcome.effective_plan.node_plans[*input]
4668                    .supported_phases
4669                    .contains(phase)
4670            })
4671            .map(|input| {
4672                coordinates
4673                    .get(&(*phase, fold.clone(), input.clone()))
4674                    .map(|upstream| upstream.record_id.clone())
4675                    .ok_or_else(|| {
4676                        DagMlError::CampaignValidation(format!(
4677                            "training lineage is missing upstream `{input}`"
4678                        ))
4679                    })
4680            })
4681            .collect::<Result<Vec<LineageId>>>()?;
4682        let mut expected_inputs = expected_inputs;
4683        expected_inputs.sort();
4684        if record.input_lineage != expected_inputs {
4685            return contract_error(
4686                "training outcome lineage input_lineage does not exactly match plan",
4687            );
4688        }
4689        if *phase == Phase::FitCv && !record.artifact_refs.is_empty() {
4690            return contract_error("FIT_CV lineage must not retain refit artifacts");
4691        }
4692        if *phase == Phase::Refit {
4693            let mut expected_artifacts = outcome
4694                .execution_bundle
4695                .refit_artifacts
4696                .iter()
4697                .filter(|artifact| artifact.node_id == *node_id)
4698                .map(|artifact| artifact.artifact.clone())
4699                .collect::<Vec<_>>();
4700            expected_artifacts.sort_by(|left, right| left.id.cmp(&right.id));
4701            let mut actual_artifacts = record.artifact_refs.clone();
4702            actual_artifacts.sort_by(|left, right| left.id.cmp(&right.id));
4703            if actual_artifacts != expected_artifacts {
4704                return contract_error("REFIT lineage artifact_refs do not match execution bundle");
4705            }
4706        }
4707    }
4708    Ok(())
4709}
4710
4711fn tcv1_fingerprint<T: Serialize + ?Sized>(value: &T, label: &str) -> Result<String> {
4712    let json = serde_json::to_string(value)?;
4713    parse_typed_json(&json)
4714        .map_err(|error| {
4715            DagMlError::CampaignValidation(format!("{label} is not valid TCV1: {error}"))
4716        })?
4717        .fingerprint()
4718        .map_err(|error| {
4719            DagMlError::CampaignValidation(format!("{label} TCV1 fingerprint failed: {error}"))
4720        })
4721}
4722
4723fn tcv1_fingerprint_without<T: Serialize>(value: &T, field: &str, label: &str) -> Result<String> {
4724    let json = serde_json::to_string(value)?;
4725    parse_typed_json(&json)
4726        .map_err(|error| {
4727            DagMlError::CampaignValidation(format!("{label} is not valid TCV1: {error}"))
4728        })?
4729        .fingerprint_without(field)
4730        .map_err(|error| {
4731            DagMlError::CampaignValidation(format!("{label} TCV1 fingerprint failed: {error}"))
4732        })
4733}
4734
4735fn validate_sha256(label: &str, value: &str) -> Result<()> {
4736    if value.len() != 64
4737        || !value
4738            .bytes()
4739            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
4740    {
4741        return contract_error(format!("{label} must be lowercase sha256"));
4742    }
4743    Ok(())
4744}
4745
4746fn validate_all_identity_relations(
4747    identities: &[TrainingDataIdentity],
4748    relation_fingerprint: &str,
4749) -> Result<()> {
4750    if identities
4751        .iter()
4752        .any(|identity| identity.relation_fingerprint != relation_fingerprint)
4753    {
4754        return contract_error(
4755            "training outcome data identities do not all bind the influence relation",
4756        );
4757    }
4758    Ok(())
4759}
4760
4761fn validate_sorted_unique_text(label: &str, values: &[String]) -> Result<()> {
4762    if values.iter().any(|value| value.trim().is_empty()) {
4763        return contract_error(format!("{label} contains an empty value"));
4764    }
4765    if values.windows(2).any(|pair| pair[0] >= pair[1]) {
4766        return contract_error(format!("{label} must be strictly sorted and unique"));
4767    }
4768    Ok(())
4769}
4770
4771fn contract_error<T>(message: impl Into<String>) -> Result<T> {
4772    Err(DagMlError::CampaignValidation(message.into()))
4773}
4774
4775#[cfg(test)]
4776mod replay_phase_tests {
4777    use super::{
4778        derive_replayable_phases_from_facts, ClosureReplayFacts, NodeReplayFacts,
4779        OofEdgeReplayFacts,
4780    };
4781    use crate::phase::Phase;
4782    use std::collections::BTreeSet;
4783
4784    fn node(
4785        supported: &[Phase],
4786        requires_retained_state: bool,
4787        has_retained_artifact: bool,
4788    ) -> NodeReplayFacts {
4789        NodeReplayFacts {
4790            supported_phases: supported.iter().copied().collect::<BTreeSet<_>>(),
4791            requires_retained_state,
4792            has_retained_artifact,
4793        }
4794    }
4795
4796    fn oof(
4797        has_bundle_requirement: bool,
4798        has_cache_record: bool,
4799        has_portable_payload: bool,
4800    ) -> OofEdgeReplayFacts {
4801        OofEdgeReplayFacts {
4802            has_bundle_requirement,
4803            has_cache_record,
4804            has_portable_payload,
4805        }
4806    }
4807
4808    // Completed refit whose full closure supports both forward phases and whose
4809    // state-retaining nodes (`Stateful || EmitsArtifacts`) all have a retained
4810    // artifact exposes PREDICT then EXPLAIN in canonical order and never
4811    // re-advertises REFIT.
4812    #[test]
4813    fn completed_refit_full_support_matrix_predict_then_explain() {
4814        let facts = ClosureReplayFacts {
4815            nodes: vec![
4816                node(
4817                    &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
4818                    true,
4819                    true,
4820                ),
4821                node(
4822                    &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
4823                    true,
4824                    true,
4825                ),
4826            ],
4827            oof_edges: vec![],
4828        };
4829        assert_eq!(
4830            derive_replayable_phases_from_facts(true, &facts),
4831            vec![Phase::Predict, Phase::Explain]
4832        );
4833    }
4834
4835    // The current completed-refit fixture: every closure node supports
4836    // FIT_CV/REFIT/PREDICT but not EXPLAIN, so only PREDICT is honest.
4837    #[test]
4838    fn completed_refit_predict_only_when_explain_unsupported() {
4839        let facts = ClosureReplayFacts {
4840            nodes: vec![
4841                node(&[Phase::FitCv, Phase::Refit, Phase::Predict], true, true),
4842                // A train-only augmentation node emits no artifact, so it does not
4843                // require retained inference state and must not block PREDICT.
4844                node(&[Phase::FitCv, Phase::Refit, Phase::Predict], false, false),
4845            ],
4846            oof_edges: vec![],
4847        };
4848        assert_eq!(
4849            derive_replayable_phases_from_facts(true, &facts),
4850            vec![Phase::Predict]
4851        );
4852    }
4853
4854    // A downstream node supporting PREDICT cannot rescue an upstream required
4855    // node that does not support it: the whole closure must support the phase.
4856    #[test]
4857    fn upstream_node_missing_phase_blocks_whole_closure() {
4858        let facts = ClosureReplayFacts {
4859            nodes: vec![
4860                // downstream predictor supports PREDICT and EXPLAIN
4861                node(
4862                    &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
4863                    true,
4864                    true,
4865                ),
4866                // upstream required transform supports neither
4867                node(&[Phase::FitCv, Phase::Refit], false, false),
4868            ],
4869            oof_edges: vec![],
4870        };
4871        assert_eq!(
4872            derive_replayable_phases_from_facts(true, &facts),
4873            Vec::<Phase>::new()
4874        );
4875    }
4876
4877    // A completed refit whose closure supports PREDICT but is missing the
4878    // retained artifact of a state-retaining node (here `requires_retained_state`)
4879    // has no honest replay mode: [] is the correct, preferable answer.
4880    #[test]
4881    fn completed_refit_missing_artifact_yields_empty() {
4882        let facts = ClosureReplayFacts {
4883            nodes: vec![node(
4884                &[Phase::FitCv, Phase::Refit, Phase::Predict],
4885                true,
4886                false,
4887            )],
4888            oof_edges: vec![],
4889        };
4890        assert_eq!(
4891            derive_replayable_phases_from_facts(true, &facts),
4892            Vec::<Phase>::new()
4893        );
4894    }
4895
4896    // No-refit outcome never advertises PREDICT/EXPLAIN even when supported, and
4897    // advertises REFIT only when every OOF dependency is fully self-contained
4898    // (exact bundle requirement + cache record + portable payload).
4899    #[test]
4900    fn no_refit_refit_requires_self_contained_oof_payload() {
4901        let supported = [Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain];
4902        let backed = ClosureReplayFacts {
4903            nodes: vec![node(&supported, true, false), node(&supported, true, false)],
4904            oof_edges: vec![oof(true, true, true)],
4905        };
4906        assert_eq!(
4907            derive_replayable_phases_from_facts(false, &backed),
4908            vec![Phase::Refit]
4909        );
4910
4911        // Missing portable payload -> not self-contained -> [].
4912        let missing_payload = ClosureReplayFacts {
4913            nodes: vec![node(&supported, true, false), node(&supported, true, false)],
4914            oof_edges: vec![oof(true, true, false)],
4915        };
4916        assert_eq!(
4917            derive_replayable_phases_from_facts(false, &missing_payload),
4918            Vec::<Phase>::new()
4919        );
4920
4921        // Missing cache record -> [].
4922        let missing_record = ClosureReplayFacts {
4923            nodes: vec![node(&supported, true, false)],
4924            oof_edges: vec![oof(true, false, true)],
4925        };
4926        assert_eq!(
4927            derive_replayable_phases_from_facts(false, &missing_record),
4928            Vec::<Phase>::new()
4929        );
4930    }
4931
4932    // A no-refit outcome with no OOF edges is vacuously self-contained: REFIT can
4933    // re-fit from data alone, so REFIT is honest when every node supports it.
4934    #[test]
4935    fn no_refit_without_oof_edges_is_vacuously_refit() {
4936        let facts = ClosureReplayFacts {
4937            nodes: vec![node(
4938                &[Phase::FitCv, Phase::Refit, Phase::Predict],
4939                false,
4940                false,
4941            )],
4942            oof_edges: vec![],
4943        };
4944        assert_eq!(
4945            derive_replayable_phases_from_facts(false, &facts),
4946            vec![Phase::Refit]
4947        );
4948    }
4949
4950    // A no-refit closure that does not fully support REFIT yields [].
4951    #[test]
4952    fn no_refit_without_refit_support_yields_empty() {
4953        let facts = ClosureReplayFacts {
4954            nodes: vec![
4955                node(&[Phase::FitCv, Phase::Refit], false, false),
4956                node(&[Phase::FitCv, Phase::Predict], false, false),
4957            ],
4958            oof_edges: vec![],
4959        };
4960        assert_eq!(
4961            derive_replayable_phases_from_facts(false, &facts),
4962            Vec::<Phase>::new()
4963        );
4964    }
4965
4966    // A stateless `replay_required` operator (e.g. prospectr): it is neither
4967    // `stateful` nor an artifact emitter, so `requires_retained_state` is false
4968    // and it stays PREDICT-replayable with no retained artifact — the operation
4969    // simply replays its deterministic transform at inference time.
4970    #[test]
4971    fn stateless_replay_required_operator_without_artifact_stays_predict_replayable() {
4972        let facts = ClosureReplayFacts {
4973            nodes: vec![node(
4974                &[Phase::FitCv, Phase::Refit, Phase::Predict],
4975                false,
4976                false,
4977            )],
4978            oof_edges: vec![],
4979        };
4980        assert_eq!(
4981            derive_replayable_phases_from_facts(true, &facts),
4982            vec![Phase::Predict]
4983        );
4984    }
4985
4986    // A `stateful` (or artifact-emitting) node that has no retained artifact
4987    // carries no reloadable inference state, so PREDICT must not be advertised.
4988    #[test]
4989    fn stateful_non_emitter_without_artifact_cannot_advertise_predict() {
4990        let facts = ClosureReplayFacts {
4991            nodes: vec![node(
4992                &[Phase::FitCv, Phase::Refit, Phase::Predict],
4993                true,
4994                false,
4995            )],
4996            oof_edges: vec![],
4997        };
4998        assert_eq!(
4999            derive_replayable_phases_from_facts(true, &facts),
5000            Vec::<Phase>::new()
5001        );
5002    }
5003}
5004
5005#[cfg(test)]
5006mod tests {
5007    use super::*;
5008
5009    #[cfg(dag_ml_workspace_contract_fixtures)]
5010    const REFIT_FIXTURE: &str =
5011        include_str!("../../../examples/fixtures/estimator/training_outcome_refit.v1.json");
5012    #[cfg(dag_ml_workspace_contract_fixtures)]
5013    const NO_REFIT_FIXTURE: &str =
5014        include_str!("../../../examples/fixtures/estimator/training_outcome_no_refit.v1.json");
5015
5016    #[test]
5017    fn cv_ensemble_partition_truth_table_retains_validation_only() {
5018        for (partition, expected) in [
5019            (PredictionPartition::Validation, true),
5020            (PredictionPartition::Train, false),
5021            (PredictionPartition::Test, false),
5022            (PredictionPartition::Final, false),
5023        ] {
5024            assert_eq!(
5025                is_cv_ensemble_partition(&partition),
5026                expected,
5027                "unexpected CvEnsemble retention decision for {partition:?}"
5028            );
5029        }
5030    }
5031
5032    #[cfg(dag_ml_workspace_contract_fixtures)]
5033    #[test]
5034    fn independent_w0_training_outcomes_parse_and_round_trip_fingerprint() {
5035        for fixture in [REFIT_FIXTURE, NO_REFIT_FIXTURE] {
5036            let outcome = TrainingOutcome::from_json(fixture).expect("valid W0 outcome");
5037            assert_eq!(
5038                outcome.compute_fingerprint().unwrap(),
5039                outcome.outcome_fingerprint
5040            );
5041            let serialized = serde_json::to_string(&outcome).unwrap();
5042            let reparsed = TrainingOutcome::from_json(&serialized).unwrap();
5043            assert_eq!(reparsed, outcome);
5044        }
5045    }
5046
5047    #[cfg(dag_ml_workspace_contract_fixtures)]
5048    #[test]
5049    fn strict_parser_rejects_tamper_and_unknown_field() {
5050        let mut tampered: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
5051        tampered["warnings"] = serde_json::json!(["tampered"]);
5052        assert!(TrainingOutcome::from_json(&serde_json::to_string(&tampered).unwrap()).is_err());
5053
5054        let mut unknown: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
5055        unknown["unknown_field"] = serde_json::json!(true);
5056        assert!(TrainingOutcome::from_json(&serde_json::to_string(&unknown).unwrap()).is_err());
5057    }
5058
5059    #[cfg(dag_ml_workspace_contract_fixtures)]
5060    #[test]
5061    fn outcome_rejects_nested_runtime_handle_keys_defense_in_depth() {
5062        let mut outcome = TrainingOutcome::from_json(REFIT_FIXTURE).unwrap();
5063        outcome.diagnostics.insert(
5064            "nested".to_string(),
5065            serde_json::json!({"runtime_handle": "process-local"}),
5066        );
5067        outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
5068        let error = outcome.validate().unwrap_err();
5069        assert!(error.to_string().contains("runtime handles"), "{error}");
5070    }
5071
5072    #[cfg(dag_ml_workspace_contract_fixtures)]
5073    #[test]
5074    fn strict_parser_rejects_future_version_even_when_resigned() {
5075        let mut future: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
5076        future["schema_version"] = serde_json::json!(2);
5077        let mut provisional: TrainingOutcome = serde_json::from_value(future.clone()).unwrap();
5078        provisional.outcome_fingerprint = provisional.compute_fingerprint().unwrap();
5079        future["outcome_fingerprint"] =
5080            serde_json::Value::String(provisional.outcome_fingerprint.clone());
5081        assert!(TrainingOutcome::from_json(&serde_json::to_string(&future).unwrap()).is_err());
5082    }
5083
5084    #[cfg(dag_ml_workspace_contract_fixtures)]
5085    #[test]
5086    fn select_lineage_is_portable_but_foreign_phase_is_rejected() {
5087        let mut outcome = TrainingOutcome::from_json(REFIT_FIXTURE).unwrap();
5088        let mut select = outcome.lineage[0].clone();
5089        select.record_id = LineageId::new("lineage:select:audit").unwrap();
5090        select.phase = Phase::Select;
5091        select.fold_id = None;
5092        select.input_lineage.clear();
5093        select.artifact_refs.clear();
5094        outcome.lineage.push(select.clone());
5095        outcome
5096            .lineage
5097            .sort_by(|left, right| left.record_id.cmp(&right.record_id));
5098        outcome.outcome_fingerprint = zero_fingerprint();
5099        outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
5100        outcome.validate().unwrap();
5101
5102        let added = outcome
5103            .lineage
5104            .iter_mut()
5105            .find(|record| record.record_id.as_str() == "lineage:select:audit")
5106            .unwrap();
5107        added.phase = Phase::Predict;
5108        added.record_id = LineageId::new("lineage:predict:foreign").unwrap();
5109        outcome
5110            .lineage
5111            .sort_by(|left, right| left.record_id.cmp(&right.record_id));
5112        outcome.outcome_fingerprint = zero_fingerprint();
5113        outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
5114        assert!(outcome.validate().is_err());
5115    }
5116
5117    #[test]
5118    fn every_data_identity_must_bind_the_global_relation() {
5119        let relation = "a".repeat(64);
5120        let identity = |key: &str, relation_fingerprint: String| TrainingDataIdentity {
5121            requirement_key: key.to_string(),
5122            schema_fingerprint: "b".repeat(64),
5123            plan_fingerprint: "c".repeat(64),
5124            relation_fingerprint,
5125            data_content_fingerprint: "d".repeat(64),
5126            target_content_fingerprint: "e".repeat(64),
5127            identity_fingerprint: "f".repeat(64),
5128        };
5129        let identities = vec![
5130            identity("model:a.x", relation.clone()),
5131            identity("model:b.x", "9".repeat(64)),
5132        ];
5133        assert!(validate_all_identity_relations(&identities, &relation).is_err());
5134        let identities = vec![
5135            identity("model:a.x", relation.clone()),
5136            identity("model:b.x", relation.clone()),
5137        ];
5138        validate_all_identity_relations(&identities, &relation).unwrap();
5139    }
5140
5141    #[test]
5142    fn auxiliary_report_levels_do_not_override_selection_target_level() {
5143        let report = |producer: &str, level| crate::metrics::RegressionMetricReport {
5144            prediction_id: Some(format!("prediction:{producer}")),
5145            producer_node: NodeId::new(producer).unwrap(),
5146            producer_port: None,
5147            variant_id: Some(VariantId::new("variant:test").unwrap()),
5148            variant_label: None,
5149            partition: PredictionPartition::Validation,
5150            fold_id: Some(crate::ids::FoldId::new("avg").unwrap()),
5151            level,
5152            row_count: 2,
5153            target_width: 1,
5154            target_names: vec!["y".to_string()],
5155            metrics: BTreeMap::from([("rmse".to_string(), 0.1)]),
5156        };
5157        let reports = vec![
5158            report("model:target", PredictionLevel::Sample),
5159            report("model:target", PredictionLevel::Group),
5160            report("model:aux", PredictionLevel::Group),
5161        ];
5162        validate_selection_report_levels(
5163            &reports,
5164            &NodeId::new("model:target").unwrap(),
5165            &None,
5166            PredictionLevel::Sample,
5167        )
5168        .unwrap();
5169        assert!(validate_selection_report_levels(
5170            &reports,
5171            &NodeId::new("model:target").unwrap(),
5172            &None,
5173            PredictionLevel::Target,
5174        )
5175        .is_err());
5176    }
5177}