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