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