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