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