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