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};
12use crate::bundle::{
13    build_aggregated_prediction_cache_payload, build_aggregated_prediction_cache_record,
14    build_execution_bundle_with_prediction_contracts, build_prediction_cache_payload,
15    build_prediction_cache_record, validate_prediction_cache_payload_matches_record,
16    BundlePredictionCachePayload, BundlePredictionCachePayloadSet, BundlePredictionCacheRecord,
17    BundlePredictionRequirement, ExecutionBundle, EXECUTION_BUNDLE_SCHEMA_VERSION,
18    LEGACY_EXECUTION_BUNDLE_SCHEMA_VERSION, LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
19    PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
20};
21use crate::campaign::stable_json_fingerprint;
22use crate::canonical::parse_typed_json;
23use crate::controller::{ControllerCapability, ControllerFitScope};
24use crate::data::data_binding_requirement_key;
25use crate::error::{DagMlError, Result};
26use crate::graph::{NodeKind, PortKind};
27use crate::ids::{BundleId, FoldId, LineageId, NodeId, RunId, SampleId, VariantId};
28use crate::metrics::{
29    RegressionMetricKind, ScoreSet, LEGACY_SCORE_SET_SCHEMA_VERSION, SCORE_SET_SCHEMA_VERSION,
30};
31use crate::oof::{PredictionBlock, PredictionPartition};
32use crate::phase::Phase;
33use crate::plan::ExecutionPlan;
34use crate::policy::PredictionLevel;
35use crate::runtime::{
36    plan_oof_partition_mode, select_best_variant_outcome_by_cv_for_target, InMemoryArtifactStore,
37    LineageRecord, NodeResult, ParallelScheduler, RunContext, RuntimeControllerRegistry,
38    RuntimeDataProvider, SequentialScheduler, VariantExecutionSpec,
39};
40use crate::selection::{
41    select_candidate, EvaluationScope, RefitStrategy, SelectionDecision, SelectionMetric,
42    SelectionPolicy,
43};
44use crate::training::{
45    contains_runtime_handle, ArtifactLoadMode, CacheNamespace, CvArtifactRetention,
46    FittedArtifactMode, OutputBinding, PackageArtifactBinding, ParameterNamespace, ParameterPatch,
47    PortablePredictorPackage, PredictionCacheRetention, PredictionKind, PredictionSource,
48    PredictorTemplate, ResolvedTrainingOutput, TrainingContractProjection, TrainingDataIdentity,
49    TrainingInfluenceKind, TrainingInfluenceManifest, TrainingOutcomeRef, TrainingRequest,
50    TrainingSchedulerBackend, TrainingSchedulerKind, OUTPUT_BINDING_SCHEMA_VERSION,
51    PARAMETER_PATCH_SCHEMA_VERSION, PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION,
52};
53
54pub const TRAINING_OUTCOME_SCHEMA_VERSION: u32 = 2;
55pub const LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION: u32 = 1;
56pub const MIN_READABLE_TRAINING_OUTCOME_SCHEMA_VERSION: u32 = 1;
57pub const BOUND_TRAINING_OUTPUT_SCHEMA_VERSION: u32 = 2;
58pub const TRAINING_OUTCOME_SCHEMA_ID: &str =
59    "https://github.com/GBeurier/dag-ml/schemas/training_outcome.v2.schema.json";
60
61/// One resolved output binding and the actual portable blocks it selected.
62#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct BoundTrainingOutput {
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub schema_version: Option<u32>,
67    pub binding: OutputBinding,
68    pub predictions: Vec<PredictionBlock>,
69    pub observation_predictions: Vec<ObservationPredictionBlock>,
70    pub aggregated_predictions: Vec<AggregatedPredictionBlock>,
71}
72
73#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum TrainingRefitStatus {
76    Completed,
77    Skipped,
78}
79
80/// Exact W0 refit state embedded in [`TrainingOutcome`].
81#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct TrainingRefitOutcome {
84    pub requested: bool,
85    pub status: TrainingRefitStatus,
86    pub strategy: Option<RefitStrategy>,
87}
88
89/// Portable result of COMPILE/PLAN/FIT_CV/SELECT and optional REFIT.
90#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct TrainingOutcome {
93    pub schema_version: u32,
94    pub outcome_id: String,
95    pub run_id: RunId,
96    pub training_request_fingerprint: String,
97    pub data_identities: Vec<TrainingDataIdentity>,
98    pub selection_output_id: String,
99    pub effective_plan: ExecutionPlan,
100    pub effective_plan_fingerprint: String,
101    pub selected_variant_id: VariantId,
102    pub selected_variant_fingerprint: String,
103    pub parameter_patches: Vec<ParameterPatch>,
104    pub refit: TrainingRefitOutcome,
105    pub score_set: ScoreSet,
106    pub outputs: Vec<BoundTrainingOutput>,
107    pub lineage: Vec<LineageRecord>,
108    pub portable_prediction_caches: Option<BundlePredictionCachePayloadSet>,
109    pub training_influence: TrainingInfluenceManifest,
110    pub execution_bundle: ExecutionBundle,
111    pub replayable_phases: Vec<Phase>,
112    pub warnings: Vec<String>,
113    pub diagnostics: BTreeMap<String, serde_json::Value>,
114    pub outcome_fingerprint: String,
115}
116
117/// Host-owned resources and portable identifiers for one native training run.
118///
119/// The operation deliberately accepts controllers and data through the same
120/// runtime abstractions as ordinary phase execution. It never invokes a
121/// controller directly and never implements a second fold/node loop.
122pub struct TrainingExecutionInput<'a> {
123    pub request: &'a TrainingRequest,
124    pub outcome_id: String,
125    pub run_id: RunId,
126    pub bundle_id: BundleId,
127    pub controllers: &'a RuntimeControllerRegistry,
128    pub data_provider: &'a dyn RuntimeDataProvider,
129    pub relations: &'a crate::relation::SampleRelationSet,
130    pub training_influence: &'a TrainingInfluenceManifest,
131    pub artifact_store: &'a mut InMemoryArtifactStore,
132    pub warnings: Vec<String>,
133    pub diagnostics: BTreeMap<String, serde_json::Value>,
134}
135
136#[derive(Clone, Debug)]
137enum NativeTrainingScheduler {
138    Sequential(SequentialScheduler),
139    Parallel(ParallelScheduler),
140}
141
142impl NativeTrainingScheduler {
143    fn from_request(request: &TrainingRequest) -> Result<Self> {
144        let options = &request.options.scheduler;
145        if options.backend == Some(TrainingSchedulerBackend::Processes) {
146            return Err(DagMlError::RuntimeValidation(
147                "native training does not yet implement the processes scheduler backend"
148                    .to_string(),
149            ));
150        }
151        match options.kind {
152            TrainingSchedulerKind::Sequential => Ok(Self::Sequential(SequentialScheduler)),
153            TrainingSchedulerKind::Parallel => Ok(Self::Parallel(ParallelScheduler::new(
154                usize::try_from(options.workers).map_err(|_| {
155                    DagMlError::RuntimeValidation(
156                        "training scheduler worker count does not fit usize".to_string(),
157                    )
158                })?,
159            )?)),
160        }
161    }
162
163    fn fit_cv(
164        &self,
165        plan: &ExecutionPlan,
166        controllers: &RuntimeControllerRegistry,
167        data_provider: &dyn RuntimeDataProvider,
168        ctx: &mut RunContext,
169    ) -> Result<Vec<NodeResult>> {
170        match self {
171            Self::Sequential(scheduler) => scheduler.execute_campaign_phase_with_data_provider(
172                plan,
173                controllers,
174                data_provider,
175                ctx,
176                Phase::FitCv,
177            ),
178            Self::Parallel(scheduler) => scheduler.execute_campaign_phase_with_data_provider(
179                plan,
180                controllers,
181                data_provider,
182                ctx,
183                Phase::FitCv,
184            ),
185        }
186    }
187
188    fn refit(
189        &self,
190        plan: &ExecutionPlan,
191        controllers: &RuntimeControllerRegistry,
192        data_provider: &dyn RuntimeDataProvider,
193        artifact_store: &mut InMemoryArtifactStore,
194        ctx: &mut RunContext,
195    ) -> Result<Vec<NodeResult>> {
196        match self {
197            Self::Sequential(scheduler) => scheduler
198                .execute_campaign_phase_with_data_provider_and_artifact_store(
199                    plan,
200                    controllers,
201                    data_provider,
202                    artifact_store,
203                    ctx,
204                    Phase::Refit,
205                ),
206            Self::Parallel(scheduler) => scheduler
207                .execute_campaign_phase_with_data_provider_and_artifact_store(
208                    plan,
209                    controllers,
210                    data_provider,
211                    artifact_store,
212                    ctx,
213                    Phase::Refit,
214                ),
215        }
216    }
217}
218
219/// Execute COMPILE/PLAN -> FIT_CV -> SELECT -> optional REFIT and return the
220/// complete portable W0 outcome.
221///
222/// Variant candidates are evaluated by the existing native selection helper;
223/// the winner is then rerun once in a retained context so its lineage, OOF
224/// caches, bound outputs, and optional refit artifacts all originate from one
225/// auditable execution. `SELECT` is called exactly once and `REFIT` at most once.
226pub fn execute_training(input: TrainingExecutionInput<'_>) -> Result<TrainingOutcome> {
227    if !input.artifact_store.is_empty() {
228        return Err(DagMlError::RuntimeValidation(
229            "native training requires an empty artifact store for an isolated outcome".to_string(),
230        ));
231    }
232    RunId::new(input.outcome_id.clone()).map_err(|error| {
233        DagMlError::RuntimeValidation(format!(
234            "native training outcome_id is not a portable identifier: {error}"
235        ))
236    })?;
237    validate_sorted_unique_text("training execution warnings", &input.warnings)?;
238    if contains_runtime_handle(&serde_json::Value::Object(
239        input.diagnostics.clone().into_iter().collect(),
240    )) {
241        return Err(DagMlError::RuntimeValidation(
242            "native training diagnostics cannot contain runtime handles".to_string(),
243        ));
244    }
245
246    let mut projection = input.request.project()?;
247    projection.plan = materialize_request_parameter_patches(projection.plan, input.request)?;
248    projection.validate()?;
249    validate_native_training_options(input.request)?;
250    validate_provider_attestations(
251        &projection,
252        input.request,
253        input.data_provider,
254        input.relations,
255    )?;
256    for node_plan in projection.plan.node_plans.values() {
257        if input.controllers.get(&node_plan.controller_id).is_none() {
258            return Err(DagMlError::RuntimeValidation(format!(
259                "native training controller `{}` for node `{}` is not registered",
260                node_plan.controller_id, node_plan.node_id
261            )));
262        }
263    }
264    if projection.predictor_node_ids
265        != projection
266            .plan
267            .node_plans
268            .keys()
269            .cloned()
270            .collect::<BTreeSet<_>>()
271    {
272        return Err(DagMlError::RuntimeValidation(
273            "native training currently requires the predictor closure to equal the executable plan; refusing to persist unrelated nodes"
274                .to_string(),
275        ));
276    }
277    input.training_influence.validate_for_projection(
278        &projection,
279        input.request,
280        input.relations,
281    )?;
282    let runtime_training_influence = TrainingInfluenceManifest::derive_for_projection(
283        &projection,
284        input.request,
285        input.relations,
286    )?;
287    if input.training_influence != &runtime_training_influence {
288        return Err(DagMlError::RuntimeValidation(
289            "native training influence manifest does not match runtime-derived evidence"
290                .to_string(),
291        ));
292    }
293    if projection.plan.variants.iter().any(|variant| {
294        variant
295            .choices
296            .values()
297            .any(|choice| !choice.param_overrides.is_empty())
298    }) && !input
299        .training_influence
300        .entries
301        .iter()
302        .any(|entry| entry.kind == TrainingInfluenceKind::HpoSelection)
303    {
304        return Err(DagMlError::RuntimeValidation(
305            "selectable parameter overrides require predeclared hpo_selection influence"
306                .to_string(),
307        ));
308    }
309
310    let scheduler = NativeTrainingScheduler::from_request(input.request)?;
311    let selection_metric = parse_selection_metric(input.request)?;
312    let metric_level = effective_selection_metric_level(input.request)?;
313    let selection_output = projection
314        .outputs
315        .iter()
316        .find(|output| output.output_id == input.request.options.selection_output_id)
317        .ok_or_else(|| {
318            DagMlError::RuntimeValidation(
319                "training selection output was not resolved by projection".to_string(),
320            )
321        })?;
322    let selection_output_id = selection_output.output_id.clone();
323    let selection_producer = selection_output.node_id.clone();
324    let selection_producer_port = selection_output.port_name.clone();
325    validate_selection_prediction_kind(selection_metric, selection_output.prediction_kind)?;
326    let selection = select_best_variant_outcome_by_cv_for_target(
327        &projection.plan,
328        &input.run_id,
329        Some(input.request.options.seed),
330        selection_metric,
331        (
332            &selection_producer,
333            Some(selection_producer_port.as_str()),
334            metric_level,
335        ),
336        |candidate_plan, candidate_ctx| {
337            scheduler
338                .fit_cv(
339                    candidate_plan,
340                    input.controllers,
341                    input.data_provider,
342                    candidate_ctx,
343                )
344                .map(|_| ())
345        },
346    )?
347    .ok_or_else(|| {
348        DagMlError::RuntimeValidation(
349            "native training SELECT received no scored candidate; controllers must emit targets"
350                .to_string(),
351        )
352    })?;
353
354    validate_selection_report_levels(
355        &selection.selection.validation_reports,
356        &selection_producer,
357        &Some(selection_producer_port.clone()),
358        metric_level,
359    )?;
360    let mut decision = selection.decision;
361    bind_selection_decision(&mut decision, input.request, metric_level)?;
362    let selected_variant_id = selection.selection.selected_variant_id;
363    let effective_plan = materialize_selected_variant(projection.plan, &selected_variant_id)?;
364    // Keep the original union variants for replay/identity while pinning every
365    // retained execution through RunContext.variant_id.
366    effective_plan.validate()?;
367    let selected_variant = effective_plan
368        .variants
369        .iter()
370        .find(|variant| variant.variant_id == selected_variant_id)
371        .cloned()
372        .ok_or_else(|| {
373            DagMlError::RuntimeValidation(
374                "selected variant disappeared while materializing the plan".to_string(),
375            )
376        })?;
377
378    let mut selected_ctx = RunContext::new(input.run_id.clone(), Some(input.request.options.seed));
379    selected_ctx.variant_id = Some(selected_variant_id.clone());
380    let fit_cv_results = scheduler.fit_cv(
381        &effective_plan,
382        input.controllers,
383        input.data_provider,
384        &mut selected_ctx,
385    )?;
386    selected_ctx.collect_cross_fold_validation_scores(plan_oof_partition_mode(&effective_plan))?;
387    validate_selected_rerun_reports(
388        &selection.selection.validation_reports,
389        &selected_ctx.score_collector,
390        &selected_variant_id,
391    )?;
392
393    let score_set = ScoreSet {
394        schema_version: SCORE_SET_SCHEMA_VERSION,
395        plan_id: effective_plan.id.clone(),
396        selection_metric: Some(selection_metric.name().to_string()),
397        reports: selection.selection.validation_reports,
398    };
399    score_set.validate()?;
400
401    let prediction_requirements = build_oof_prediction_requirements(
402        &effective_plan,
403        selected_ctx.prediction_store.blocks(),
404        selected_ctx.aggregated_prediction_store.blocks(),
405    )?;
406    let retain_caches =
407        input.request.options.artifacts.prediction_caches == PredictionCacheRetention::Retain;
408    let (prediction_caches, portable_prediction_caches) = if retain_caches {
409        let mut records = build_oof_prediction_cache_records(
410            &prediction_requirements,
411            selected_ctx.prediction_store.blocks(),
412            selected_ctx.aggregated_prediction_store.blocks(),
413        )?;
414        let mut payloads = build_oof_prediction_cache_payloads(
415            &prediction_requirements,
416            selected_ctx.prediction_store.blocks(),
417            selected_ctx.aggregated_prediction_store.blocks(),
418        )?;
419        attach_oof_prediction_cache_namespaces(
420            &effective_plan,
421            &input.request.data_identities,
422            &selected_variant_id,
423            input.request.options.seed,
424            &prediction_requirements,
425            &mut records,
426            &mut payloads,
427        )?;
428        (
429            records,
430            Some(BundlePredictionCachePayloadSet {
431                bundle_id: input.bundle_id.clone(),
432                schema_version: PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
433                caches: payloads,
434            }),
435        )
436    } else {
437        (Vec::new(), None)
438    };
439
440    let mut staged_artifact_store = InMemoryArtifactStore::new();
441    let refit_results = if input.request.options.refit {
442        scheduler.refit(
443            &effective_plan,
444            input.controllers,
445            input.data_provider,
446            &mut staged_artifact_store,
447            &mut selected_ctx,
448        )?
449    } else {
450        Vec::new()
451    };
452
453    let mut execution_bundle = build_execution_bundle_with_prediction_contracts(
454        input.bundle_id.clone(),
455        &effective_plan,
456        Some(selected_variant_id.clone()),
457        BTreeMap::from([(input.request.options.selection.id.clone(), decision)]),
458        staged_artifact_store.refit_artifacts(),
459        prediction_requirements,
460        prediction_caches,
461    )?;
462    execution_bundle.scores = Some(score_set.clone());
463    execution_bundle.validate_against_plan(&effective_plan)?;
464    if let Some(caches) = &portable_prediction_caches {
465        caches.validate_against_bundle(&execution_bundle)?;
466    }
467
468    let outputs = bind_training_outputs(
469        &projection.outputs,
470        input.request,
471        &effective_plan,
472        &fit_cv_results,
473        &refit_results,
474        &selected_ctx,
475    )?;
476    let mut lineage = selected_ctx
477        .lineage
478        .records()
479        .filter(|record| projection.predictor_node_ids.contains(&record.node_id))
480        .cloned()
481        .collect::<Vec<_>>();
482    for record in &mut lineage {
483        record.input_lineage.sort();
484        record
485            .artifact_refs
486            .sort_by(|left, right| left.id.cmp(&right.id));
487    }
488    lineage.sort_by(|left, right| left.record_id.cmp(&right.record_id));
489
490    let effective_plan_fingerprint =
491        tcv1_fingerprint(&effective_plan, "training outcome effective plan")?;
492    let parameter_patches =
493        merge_training_parameter_patches(&input.request.parameter_patches, &selected_variant)?;
494    // Derive the honest replayable phases from the *full effective predictor
495    // closure* and the artifacts/caches actually retained by this run, never
496    // from the refit flag alone. `derive_replayable_phases` is the single shared
497    // helper that standalone validation re-runs, so construction cannot advertise
498    // a capability the closure and retained state do not support.
499    let predictor_closure_nodes = predictor_closure(
500        &effective_plan,
501        outputs.iter().map(|output| output.binding.node_id.clone()),
502    )?;
503    let refit_outcome = TrainingRefitOutcome {
504        requested: input.request.options.refit,
505        status: if input.request.options.refit {
506            TrainingRefitStatus::Completed
507        } else {
508            TrainingRefitStatus::Skipped
509        },
510        strategy: input.request.options.refit_strategy,
511    };
512    let replayable_phases = derive_replayable_phases(
513        &effective_plan,
514        &predictor_closure_nodes,
515        &refit_outcome,
516        &execution_bundle,
517        portable_prediction_caches.as_ref(),
518    )?;
519    let mut outcome = TrainingOutcome {
520        schema_version: TRAINING_OUTCOME_SCHEMA_VERSION,
521        outcome_id: input.outcome_id,
522        run_id: input.run_id,
523        training_request_fingerprint: projection.request_fingerprint,
524        data_identities: input.request.data_identities.clone(),
525        selection_output_id,
526        effective_plan,
527        effective_plan_fingerprint,
528        selected_variant_id,
529        selected_variant_fingerprint: selected_variant.fingerprint,
530        parameter_patches,
531        refit: refit_outcome,
532        score_set,
533        outputs,
534        lineage,
535        portable_prediction_caches,
536        training_influence: runtime_training_influence,
537        execution_bundle,
538        replayable_phases,
539        warnings: input.warnings,
540        diagnostics: input.diagnostics,
541        outcome_fingerprint: zero_fingerprint(),
542    };
543    outcome = stabilize_training_outcome_for_tcv1(outcome)?;
544    outcome.validate()?;
545    *input.artifact_store = staged_artifact_store;
546    Ok(outcome)
547}
548
549fn stabilize_training_outcome_for_tcv1(mut outcome: TrainingOutcome) -> Result<TrainingOutcome> {
550    // Some metrics originate from floating-point computations whose first JSON
551    // spelling can deserialize to the same binary64 value but reserialize with a
552    // shorter decimal spelling. TCV1 fingerprints must sign the portable JSON
553    // shape that readers validate after deserialization, so normalize once
554    // through serde before computing the self fingerprint.
555    outcome.outcome_fingerprint = zero_fingerprint();
556    let json = serde_json::to_string(&outcome)?;
557    let mut normalized = serde_json::from_str::<TrainingOutcome>(&json)?;
558    normalized.outcome_fingerprint = normalized.compute_fingerprint()?;
559    Ok(normalized)
560}
561
562fn zero_fingerprint() -> String {
563    "0".repeat(64)
564}
565
566fn validate_native_training_options(request: &TrainingRequest) -> Result<()> {
567    let resources = &request.options.resources;
568    if resources.cpu_threads != request.options.scheduler.workers
569        || resources.memory_bytes.is_some()
570        || !resources.gpu_devices.is_empty()
571        || resources.wall_time_ms.is_some()
572    {
573        return Err(DagMlError::RuntimeValidation(
574            "native training V1 supports only cpu_threads=scheduler.workers with memory_bytes=null, gpu_devices=[], and wall_time_ms=null"
575                .to_string(),
576        ));
577    }
578    if request.options.artifacts.cv_artifacts != CvArtifactRetention::Discard {
579        return Err(DagMlError::RuntimeValidation(
580            "native training V1 supports only artifacts.cv_artifacts=discard".to_string(),
581        ));
582    }
583    if request.options.artifacts.fitted_artifacts != FittedArtifactMode::AllowHostSidecar {
584        return Err(DagMlError::RuntimeValidation(
585            "native training V1 cannot prove portable fitted payloads and currently requires artifacts.fitted_artifacts=allow_host_sidecar"
586                .to_string(),
587        ));
588    }
589    if request.options.artifacts.prediction_caches == PredictionCacheRetention::Discard
590        && request
591            .graph
592            .edges
593            .iter()
594            .any(|edge| edge.contract.requires_oof)
595    {
596        return Err(DagMlError::RuntimeValidation(
597            "native training V1 requires retained prediction caches for a stacking/requires_oof graph"
598                .to_string(),
599        ));
600    }
601    Ok(())
602}
603
604fn materialize_request_parameter_patches(
605    mut plan: ExecutionPlan,
606    request: &TrainingRequest,
607) -> Result<ExecutionPlan> {
608    for patch in &request.parameter_patches {
609        match patch.namespace {
610            ParameterNamespace::Operator => {}
611            ParameterNamespace::Structural => {
612                return Err(DagMlError::RuntimeValidation(
613                    "native training requires recompilation for structural parameter patches; D6 runtime accepts only operator value patches"
614                        .to_string(),
615                ));
616            }
617            ParameterNamespace::Fit | ParameterNamespace::Control => {
618                return Err(DagMlError::RuntimeValidation(format!(
619                    "native training does not expose {:?} parameter patches to controllers yet; refusing to ignore them",
620                    patch.namespace
621                )));
622            }
623        }
624        let node_plan = plan.node_plans.get_mut(&patch.node_id).ok_or_else(|| {
625            DagMlError::RuntimeValidation(format!(
626                "parameter patch references absent node `{}`",
627                patch.node_id
628            ))
629        })?;
630        deep_set_plan_param(
631            &mut node_plan.params,
632            &patch.path,
633            patch.value.clone(),
634            &patch.node_id,
635        )?;
636        node_plan.params_fingerprint = stable_json_fingerprint(&node_plan.params)?;
637    }
638    plan.validate()?;
639    Ok(plan)
640}
641
642fn deep_set_plan_param(
643    root: &mut BTreeMap<String, serde_json::Value>,
644    path: &[String],
645    value: serde_json::Value,
646    node_id: &NodeId,
647) -> Result<()> {
648    if path.is_empty() {
649        return contract_error("parameter patch path cannot be empty");
650    }
651    if path.len() == 1 {
652        root.insert(path[0].clone(), value);
653        return Ok(());
654    }
655    let first = root.get_mut(&path[0]).ok_or_else(|| {
656        DagMlError::RuntimeValidation(format!(
657            "parameter patch for `{node_id}` is missing intermediate path `{}`",
658            path[0]
659        ))
660    })?;
661    let mut cursor = first;
662    for segment in &path[1..path.len() - 1] {
663        let object = cursor.as_object_mut().ok_or_else(|| {
664            DagMlError::RuntimeValidation(format!(
665                "parameter patch for `{node_id}` crosses a scalar or array at `{segment}`"
666            ))
667        })?;
668        cursor = object.get_mut(segment).ok_or_else(|| {
669            DagMlError::RuntimeValidation(format!(
670                "parameter patch for `{node_id}` is missing intermediate path `{segment}`"
671            ))
672        })?;
673    }
674    let object = cursor.as_object_mut().ok_or_else(|| {
675        DagMlError::RuntimeValidation(format!(
676            "parameter patch for `{node_id}` crosses a scalar or array before final key"
677        ))
678    })?;
679    object.insert(path[path.len() - 1].clone(), value);
680    Ok(())
681}
682
683fn validate_provider_attestations(
684    projection: &TrainingContractProjection,
685    request: &TrainingRequest,
686    provider: &dyn RuntimeDataProvider,
687    relations: &crate::relation::SampleRelationSet,
688) -> Result<()> {
689    relations.validate()?;
690    let relation_fingerprint = relations.fingerprint()?;
691    let identities = request
692        .data_identities
693        .iter()
694        .map(|identity| (identity.requirement_key.as_str(), identity))
695        .collect::<BTreeMap<_, _>>();
696    for node_plan in projection.plan.node_plans.values() {
697        for binding in &node_plan.data_bindings {
698            let key = data_binding_requirement_key(&binding.node_id, &binding.input_name);
699            let expected = identities.get(key.as_str()).ok_or_else(|| {
700                DagMlError::RuntimeValidation(format!(
701                    "native training request has no data identity for `{key}`"
702                ))
703            })?;
704            let actual = provider.training_data_identity(binding)?.ok_or_else(|| {
705                DagMlError::RuntimeValidation(format!(
706                    "runtime data provider did not attest feature/target content for `{key}`"
707                ))
708            })?;
709            actual.validate()?;
710            if &actual != *expected {
711                return Err(DagMlError::RuntimeValidation(format!(
712                    "runtime data provider identity for `{key}` does not match signed training request"
713                )));
714            }
715            let provider_relations = provider.coordinator_relations(binding)?;
716            if binding.require_relations && provider_relations.is_none() {
717                return Err(DagMlError::RuntimeValidation(format!(
718                    "runtime data provider omitted required relations for `{key}`"
719                )));
720            }
721            if let Some(provider_relations) = provider_relations {
722                provider_relations.validate()?;
723                if provider_relations.fingerprint()? != relation_fingerprint
724                    || actual.relation_fingerprint != relation_fingerprint
725                {
726                    return Err(DagMlError::RuntimeValidation(format!(
727                        "runtime data provider relations for `{key}` differ from training influence relations"
728                    )));
729                }
730            }
731        }
732    }
733    Ok(())
734}
735
736fn parse_selection_metric(request: &TrainingRequest) -> Result<RegressionMetricKind> {
737    let metric = regression_metric_by_name(&request.options.selection.metric.name)?;
738    if request.options.selection.metric.objective != metric.objective() {
739        return Err(DagMlError::RuntimeValidation(format!(
740            "selection metric `{}` has objective {:?}, expected {:?}",
741            metric.name(),
742            request.options.selection.metric.objective,
743            metric.objective()
744        )));
745    }
746    Ok(metric)
747}
748
749fn regression_metric_by_name(name: &str) -> Result<RegressionMetricKind> {
750    RegressionMetricKind::from_name(name).ok_or_else(|| {
751        DagMlError::RuntimeValidation(format!(
752            "native training does not support selection metric `{name}`"
753        ))
754    })
755}
756
757fn validate_selection_prediction_kind(
758    metric: RegressionMetricKind,
759    prediction_kind: PredictionKind,
760) -> Result<()> {
761    RegressionMetricKind::resolve_for_prediction_kind(
762        metric.name(),
763        metric.objective(),
764        prediction_kind,
765    )
766    .map(|_| ())
767}
768
769fn effective_selection_metric_level(request: &TrainingRequest) -> Result<PredictionLevel> {
770    let campaign_level = request.campaign.aggregation_policy.selection_metric_level;
771    if request
772        .options
773        .selection
774        .required_metric_level
775        .is_some_and(|level| level != campaign_level)
776    {
777        return Err(DagMlError::RuntimeValidation(
778            "selection required_metric_level differs from campaign selection_metric_level"
779                .to_string(),
780        ));
781    }
782    if request.options.selection.evaluation_scope != Some(EvaluationScope::Oof) {
783        return Err(DagMlError::RuntimeValidation(
784            "native training V1 requires selection.evaluation_scope=oof".to_string(),
785        ));
786    }
787    if request.options.selection.reduction_id.is_some() {
788        return Err(DagMlError::RuntimeValidation(
789            "native training V1 does not execute selection reduction_id".to_string(),
790        ));
791    }
792    if request.options.selection.stacking_fit_contract.is_some() {
793        return Err(DagMlError::RuntimeValidation(
794            "native training V1 does not execute selection stacking_fit_contract".to_string(),
795        ));
796    }
797    if !request.options.selection.require_finite {
798        return Err(DagMlError::RuntimeValidation(
799            "native training V1 requires selection.require_finite=true".to_string(),
800        ));
801    }
802    if request.options.refit_strategy == Some(RefitStrategy::RefitEnsemble) {
803        return Err(DagMlError::RuntimeValidation(
804            "native training V1 does not implement refit_ensemble".to_string(),
805        ));
806    }
807    match (
808        request.options.refit,
809        request.options.selection.refit_slot_plan.as_ref(),
810    ) {
811        (false, Some(_)) => Err(DagMlError::RuntimeValidation(
812            "no-refit native training forbids selection.refit_slot_plan".to_string(),
813        )),
814        (true, Some(slot))
815            if slot.strategy != RefitStrategy::RefitOne
816                || slot.member_count != 1
817                || slot.selection_level != campaign_level
818                || slot.selection_metric != request.options.selection.metric
819                || slot.reduction_id.is_some() =>
820        {
821            Err(DagMlError::RuntimeValidation(
822                "selection.refit_slot_plan is not the exact native refit_one slot".to_string(),
823            ))
824        }
825        _ => Ok(campaign_level),
826    }
827}
828
829fn validate_selected_rerun_reports(
830    retained: &[crate::metrics::RegressionMetricReport],
831    rerun: &[crate::metrics::RegressionMetricReport],
832    selected_variant_id: &VariantId,
833) -> Result<()> {
834    let mut retained = retained
835        .iter()
836        .filter(|report| report.variant_id.as_ref() == Some(selected_variant_id))
837        .cloned()
838        .collect::<Vec<_>>();
839    let mut rerun = rerun
840        .iter()
841        .filter(|report| report.partition == PredictionPartition::Validation)
842        .cloned()
843        .map(|mut report| {
844            report.variant_id = Some(selected_variant_id.clone());
845            report.variant_label = None;
846            report
847        })
848        .collect::<Vec<_>>();
849    let sort = |reports: &mut Vec<crate::metrics::RegressionMetricReport>| {
850        reports.sort_by(|left, right| {
851            (
852                &left.producer_node,
853                &left.producer_port,
854                &left.fold_id,
855                &left.prediction_id,
856                &left.level,
857            )
858                .cmp(&(
859                    &right.producer_node,
860                    &right.producer_port,
861                    &right.fold_id,
862                    &right.prediction_id,
863                    &right.level,
864                ))
865        });
866    };
867    sort(&mut retained);
868    sort(&mut rerun);
869    if retained.is_empty() || retained != rerun {
870        return Err(DagMlError::RuntimeValidation(
871            "selected variant FIT_CV rerun diverged from the reports that justified SELECT"
872                .to_string(),
873        ));
874    }
875    Ok(())
876}
877
878fn validate_selection_report_levels(
879    reports: &[crate::metrics::RegressionMetricReport],
880    producer: &NodeId,
881    producer_port: &Option<String>,
882    expected: PredictionLevel,
883) -> Result<()> {
884    let target_reports = reports
885        .iter()
886        .filter(|report| {
887            &report.producer_node == producer
888                && &report.producer_port == producer_port
889                && report.level == expected
890        })
891        .collect::<Vec<_>>();
892    if target_reports.is_empty() {
893        return Err(DagMlError::RuntimeValidation(format!(
894            "native SELECT target `{producer}` port {producer_port:?} has no reports at required metric level {expected:?}"
895        )));
896    }
897    Ok(())
898}
899
900fn bind_selection_decision(
901    decision: &mut SelectionDecision,
902    request: &TrainingRequest,
903    metric_level: PredictionLevel,
904) -> Result<()> {
905    decision.policy_id = request.options.selection.id.clone();
906    decision.metric_level = Some(metric_level);
907    decision.evaluation_scope = Some(EvaluationScope::Oof);
908    decision.refit_slot_plan = request.options.selection.refit_slot_plan.clone();
909    decision.reduction_id = None;
910    decision.validate()
911}
912
913fn materialize_selected_variant(
914    mut plan: ExecutionPlan,
915    selected_variant_id: &VariantId,
916) -> Result<ExecutionPlan> {
917    let selected = plan
918        .variants
919        .iter()
920        .find(|variant| &variant.variant_id == selected_variant_id)
921        .cloned()
922        .ok_or_else(|| {
923            DagMlError::RuntimeValidation(format!(
924                "selected variant `{selected_variant_id}` is absent from plan"
925            ))
926        })?;
927    let variant = VariantExecutionSpec::from_plan(&selected);
928    variant.validate()?;
929    for (node_id, node_plan) in &mut plan.node_plans {
930        node_plan.params = variant.effective_params_for_node(node_id, &node_plan.params)?;
931        node_plan.params_fingerprint = stable_json_fingerprint(&node_plan.params)?;
932    }
933    plan.validate()?;
934    Ok(plan)
935}
936
937fn is_cv_ensemble_partition(partition: &PredictionPartition) -> bool {
938    match partition {
939        PredictionPartition::Validation => true,
940        PredictionPartition::Train | PredictionPartition::Test | PredictionPartition::Final => {
941            false
942        }
943    }
944}
945
946fn producer_port_matches_graph_output(
947    plan: &ExecutionPlan,
948    node_id: &NodeId,
949    port_name: &str,
950    producer_port: &Option<String>,
951) -> bool {
952    if let Some(producer_port) = producer_port {
953        return producer_port == port_name;
954    }
955    let Some(node) = plan
956        .graph_plan
957        .graph
958        .nodes
959        .iter()
960        .find(|node| &node.id == node_id)
961    else {
962        return false;
963    };
964    let prediction_ports = node
965        .ports
966        .outputs
967        .iter()
968        .filter(|port| port.kind == PortKind::Prediction)
969        .collect::<Vec<_>>();
970    prediction_ports.len() == 1 && prediction_ports[0].name == port_name
971}
972
973fn bind_training_outputs(
974    outputs: &[ResolvedTrainingOutput],
975    request: &TrainingRequest,
976    plan: &ExecutionPlan,
977    fit_cv_results: &[NodeResult],
978    refit_results: &[NodeResult],
979    ctx: &RunContext,
980) -> Result<Vec<BoundTrainingOutput>> {
981    let source = if request.options.refit {
982        refit_results
983    } else {
984        fit_cv_results
985    };
986    let aggregation_fingerprint = tcv1_fingerprint(
987        &plan.campaign.aggregation_policy,
988        "training output aggregation policy",
989    )?;
990    let mut bound = Vec::with_capacity(outputs.len());
991    for output in outputs {
992        let mut binding = OutputBinding {
993            schema_version: OUTPUT_BINDING_SCHEMA_VERSION,
994            binding_id: output.output_id.clone(),
995            node_id: output.node_id.clone(),
996            port_name: output.port_name.clone(),
997            prediction_level: output.prediction_level,
998            unit_level: output.unit_level,
999            prediction_kind: output.prediction_kind,
1000            prediction_source: if request.options.refit {
1001                PredictionSource::FinalRefit
1002            } else {
1003                PredictionSource::CvEnsemble
1004            },
1005            refit_strategy: request.options.refit_strategy,
1006            aggregation_fingerprint: aggregation_fingerprint.clone(),
1007            target_names: output.target_names.clone(),
1008            target_units: output.target_units.clone(),
1009            class_labels: output.class_labels.clone(),
1010            output_order: output.output_order,
1011            target_space: output.target_space.clone(),
1012            binding_fingerprint: zero_fingerprint(),
1013        };
1014        binding.binding_fingerprint = binding.compute_fingerprint()?;
1015
1016        let node_results = source
1017            .iter()
1018            .filter(|result| result.node_id == output.node_id)
1019            .collect::<Vec<_>>();
1020        let mut predictions = Vec::new();
1021        let mut observation_predictions = Vec::new();
1022        let mut aggregated_predictions = Vec::new();
1023        match output.prediction_level {
1024            PredictionLevel::Observation => {
1025                for result in node_results {
1026                    observation_predictions.extend(
1027                        result
1028                            .observation_predictions
1029                            .iter()
1030                            .filter(|block| {
1031                                producer_port_matches_graph_output(
1032                                    plan,
1033                                    &output.node_id,
1034                                    &output.port_name,
1035                                    &block.producer_port,
1036                                ) && (request.options.refit
1037                                    || is_cv_ensemble_partition(&block.partition))
1038                            })
1039                            .cloned(),
1040                    );
1041                }
1042            }
1043            PredictionLevel::Sample => {
1044                for result in node_results {
1045                    predictions.extend(
1046                        result
1047                            .predictions
1048                            .iter()
1049                            .filter(|block| {
1050                                producer_port_matches_graph_output(
1051                                    plan,
1052                                    &output.node_id,
1053                                    &output.port_name,
1054                                    &block.producer_port,
1055                                ) && (request.options.refit
1056                                    || is_cv_ensemble_partition(&block.partition))
1057                            })
1058                            .cloned(),
1059                    );
1060                    aggregated_predictions.extend(
1061                        result
1062                            .aggregated_predictions
1063                            .iter()
1064                            .filter(|block| {
1065                                producer_port_matches_graph_output(
1066                                    plan,
1067                                    &output.node_id,
1068                                    &output.port_name,
1069                                    &block.producer_port,
1070                                ) && block.level == PredictionLevel::Sample
1071                                    && (request.options.refit
1072                                        || is_cv_ensemble_partition(&block.partition))
1073                            })
1074                            .cloned(),
1075                    );
1076                }
1077                if !request.options.refit {
1078                    aggregated_predictions.extend(
1079                        ctx.oof_average_blocks
1080                            .iter()
1081                            .filter(|average| {
1082                                average.predictions.producer_node == output.node_id
1083                                    && producer_port_matches_graph_output(
1084                                        plan,
1085                                        &output.node_id,
1086                                        &output.port_name,
1087                                        &average.predictions.producer_port,
1088                                    )
1089                                    && is_cv_ensemble_partition(&average.predictions.partition)
1090                            })
1091                            .map(|average| average.predictions.clone()),
1092                    );
1093                }
1094            }
1095            PredictionLevel::Target | PredictionLevel::Group => {
1096                for result in node_results {
1097                    aggregated_predictions.extend(
1098                        result
1099                            .aggregated_predictions
1100                            .iter()
1101                            .filter(|block| {
1102                                producer_port_matches_graph_output(
1103                                    plan,
1104                                    &output.node_id,
1105                                    &output.port_name,
1106                                    &block.producer_port,
1107                                ) && block.level == output.prediction_level
1108                                    && (request.options.refit
1109                                        || is_cv_ensemble_partition(&block.partition))
1110                            })
1111                            .cloned(),
1112                    );
1113                }
1114            }
1115        }
1116        predictions.sort_by(|left, right| {
1117            (
1118                &left.partition,
1119                &left.fold_id,
1120                &left.prediction_id,
1121                &left.sample_ids,
1122            )
1123                .cmp(&(
1124                    &right.partition,
1125                    &right.fold_id,
1126                    &right.prediction_id,
1127                    &right.sample_ids,
1128                ))
1129        });
1130        observation_predictions.sort_by(|left, right| {
1131            (
1132                &left.partition,
1133                &left.fold_id,
1134                &left.prediction_id,
1135                &left.observation_ids,
1136            )
1137                .cmp(&(
1138                    &right.partition,
1139                    &right.fold_id,
1140                    &right.prediction_id,
1141                    &right.observation_ids,
1142                ))
1143        });
1144        aggregated_predictions.sort_by(|left, right| {
1145            (
1146                &left.partition,
1147                &left.fold_id,
1148                &left.prediction_id,
1149                &left.unit_ids,
1150            )
1151                .cmp(&(
1152                    &right.partition,
1153                    &right.fold_id,
1154                    &right.prediction_id,
1155                    &right.unit_ids,
1156                ))
1157        });
1158        aggregated_predictions.dedup();
1159        let output = BoundTrainingOutput {
1160            schema_version: Some(BOUND_TRAINING_OUTPUT_SCHEMA_VERSION),
1161            binding,
1162            predictions,
1163            observation_predictions,
1164            aggregated_predictions,
1165        };
1166        output.validate(plan)?;
1167        bound.push(output);
1168    }
1169    Ok(bound)
1170}
1171
1172/// Derive portable OOF requirements from the blocks produced by an existing
1173/// FIT_CV execution. Shared by the training operation and host capture paths.
1174pub fn build_oof_prediction_requirements(
1175    plan: &ExecutionPlan,
1176    blocks: &[PredictionBlock],
1177    aggregated_blocks: &[AggregatedPredictionBlock],
1178) -> Result<Vec<BundlePredictionRequirement>> {
1179    let mut requirements = Vec::new();
1180    for edge in plan
1181        .graph_plan
1182        .graph
1183        .edges
1184        .iter()
1185        .filter(|edge| edge.contract.requires_oof)
1186    {
1187        let source_plan = plan.node_plans.get(&edge.source.node_id).ok_or_else(|| {
1188            DagMlError::RuntimeValidation(format!(
1189                "OOF edge source `{}` has no node plan",
1190                edge.source.node_id
1191            ))
1192        })?;
1193        let prediction_level = source_plan
1194            .shape_plan
1195            .as_ref()
1196            .map(|shape| shape.aggregation_policy.aggregation_level)
1197            .unwrap_or(PredictionLevel::Sample);
1198        let mut fold_ids = BTreeSet::<FoldId>::new();
1199        let mut sample_ids = BTreeSet::<SampleId>::new();
1200        let mut unit_ids = BTreeSet::<PredictionUnitId>::new();
1201        let mut width = None;
1202        let mut target_names: Option<Vec<String>> = None;
1203
1204        match prediction_level {
1205            PredictionLevel::Sample => {
1206                let selected = blocks
1207                    .iter()
1208                    .filter(|block| {
1209                        block.producer_node == edge.source.node_id
1210                            && producer_port_matches_graph_output(
1211                                plan,
1212                                &edge.source.node_id,
1213                                &edge.source.port_name,
1214                                &block.producer_port,
1215                            )
1216                            && block.partition == PredictionPartition::Validation
1217                    })
1218                    .collect::<Vec<_>>();
1219                if selected.is_empty() {
1220                    return Err(DagMlError::RuntimeValidation(format!(
1221                        "OOF requirement `{}` -> `{}` has no validation sample blocks",
1222                        edge.source.node_id, edge.target.node_id
1223                    )));
1224                }
1225                for block in selected {
1226                    let block_width = block.validate_shape()?;
1227                    merge_oof_shape(
1228                        &edge.source.node_id,
1229                        &mut width,
1230                        &mut target_names,
1231                        block_width,
1232                        &block.target_names,
1233                    )?;
1234                    if let Some(fold_id) = &block.fold_id {
1235                        fold_ids.insert(fold_id.clone());
1236                    }
1237                    sample_ids.extend(block.sample_ids.iter().cloned());
1238                }
1239            }
1240            PredictionLevel::Target | PredictionLevel::Group => {
1241                let selected = aggregated_blocks
1242                    .iter()
1243                    .filter(|block| {
1244                        block.producer_node == edge.source.node_id
1245                            && producer_port_matches_graph_output(
1246                                plan,
1247                                &edge.source.node_id,
1248                                &edge.source.port_name,
1249                                &block.producer_port,
1250                            )
1251                            && block.partition == PredictionPartition::Validation
1252                            && block.level == prediction_level
1253                    })
1254                    .collect::<Vec<_>>();
1255                if selected.is_empty() {
1256                    return Err(DagMlError::RuntimeValidation(format!(
1257                        "OOF requirement `{}` -> `{}` has no validation {prediction_level:?} blocks",
1258                        edge.source.node_id, edge.target.node_id
1259                    )));
1260                }
1261                for block in selected {
1262                    let block_width = block.validate_shape()?;
1263                    merge_oof_shape(
1264                        &edge.source.node_id,
1265                        &mut width,
1266                        &mut target_names,
1267                        block_width,
1268                        &block.target_names,
1269                    )?;
1270                    if let Some(fold_id) = &block.fold_id {
1271                        fold_ids.insert(fold_id.clone());
1272                    }
1273                    unit_ids.extend(block.unit_ids.iter().cloned());
1274                }
1275            }
1276            PredictionLevel::Observation => {
1277                return Err(DagMlError::RuntimeValidation(format!(
1278                    "OOF requirement `{}` -> `{}` cannot persist observation-level predictions; aggregate before refit",
1279                    edge.source.node_id, edge.target.node_id
1280                )));
1281            }
1282        }
1283        let requirement = BundlePredictionRequirement {
1284            producer_node: edge.source.node_id.clone(),
1285            source_port: edge.source.port_name.clone(),
1286            consumer_node: edge.target.node_id.clone(),
1287            target_port: edge.target.port_name.clone(),
1288            partition: PredictionPartition::Validation,
1289            prediction_level,
1290            fold_ids: fold_ids.into_iter().collect(),
1291            unit_ids: unit_ids.into_iter().collect(),
1292            sample_ids: sample_ids.into_iter().collect(),
1293            prediction_width: width.unwrap_or_default(),
1294            target_names: target_names.unwrap_or_default(),
1295        };
1296        requirement.validate()?;
1297        requirements.push(requirement);
1298    }
1299    requirements.sort_by_key(BundlePredictionRequirement::key);
1300    Ok(requirements)
1301}
1302
1303fn merge_oof_shape(
1304    producer: &NodeId,
1305    expected_width: &mut Option<usize>,
1306    expected_names: &mut Option<Vec<String>>,
1307    width: usize,
1308    names: &[String],
1309) -> Result<()> {
1310    if expected_width.is_some_and(|expected| expected != width) {
1311        return Err(DagMlError::RuntimeValidation(format!(
1312            "OOF requirement for `{producer}` has inconsistent prediction width"
1313        )));
1314    }
1315    *expected_width = Some(width);
1316    let names = if names.is_empty() {
1317        (0..width).map(|index| format!("p{index}")).collect()
1318    } else {
1319        names.to_vec()
1320    };
1321    if expected_names
1322        .as_ref()
1323        .is_some_and(|expected| expected != &names)
1324    {
1325        return Err(DagMlError::RuntimeValidation(format!(
1326            "OOF requirement for `{producer}` has inconsistent target names"
1327        )));
1328    }
1329    *expected_names = Some(names);
1330    Ok(())
1331}
1332
1333pub fn build_oof_prediction_cache_records(
1334    requirements: &[BundlePredictionRequirement],
1335    blocks: &[PredictionBlock],
1336    aggregated_blocks: &[AggregatedPredictionBlock],
1337) -> Result<Vec<BundlePredictionCacheRecord>> {
1338    requirements
1339        .iter()
1340        .map(|requirement| match requirement.prediction_level {
1341            PredictionLevel::Sample => build_prediction_cache_record(requirement, blocks),
1342            PredictionLevel::Target | PredictionLevel::Group => {
1343                build_aggregated_prediction_cache_record(requirement, aggregated_blocks)
1344            }
1345            PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
1346                "prediction cache requirement `{}` cannot use observation-level predictions",
1347                requirement.key()
1348            ))),
1349        })
1350        .collect()
1351}
1352
1353pub fn build_oof_prediction_cache_payloads(
1354    requirements: &[BundlePredictionRequirement],
1355    blocks: &[PredictionBlock],
1356    aggregated_blocks: &[AggregatedPredictionBlock],
1357) -> Result<Vec<BundlePredictionCachePayload>> {
1358    requirements
1359        .iter()
1360        .map(|requirement| match requirement.prediction_level {
1361            PredictionLevel::Sample => build_prediction_cache_payload(requirement, blocks),
1362            PredictionLevel::Target | PredictionLevel::Group => {
1363                build_aggregated_prediction_cache_payload(requirement, aggregated_blocks)
1364            }
1365            PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
1366                "prediction cache requirement `{}` cannot use observation-level predictions",
1367                requirement.key()
1368            ))),
1369        })
1370        .collect()
1371}
1372
1373fn attach_oof_prediction_cache_namespaces(
1374    plan: &ExecutionPlan,
1375    data_identities: &[TrainingDataIdentity],
1376    selected_variant_id: &VariantId,
1377    seed: u64,
1378    requirements: &[BundlePredictionRequirement],
1379    records: &mut [BundlePredictionCacheRecord],
1380    payloads: &mut [BundlePredictionCachePayload],
1381) -> Result<()> {
1382    let requirements_by_key = requirements
1383        .iter()
1384        .map(|requirement| (requirement.key(), requirement))
1385        .collect::<BTreeMap<_, _>>();
1386    for record in records {
1387        let requirement = requirements_by_key
1388            .get(&record.requirement_key)
1389            .ok_or_else(|| {
1390                DagMlError::RuntimeValidation(format!(
1391                    "prediction cache `{}` references unknown OOF requirement `{}`",
1392                    record.cache_id, record.requirement_key
1393                ))
1394            })?;
1395        let fingerprints = oof_cache_namespace_fingerprints(
1396            plan,
1397            data_identities,
1398            selected_variant_id,
1399            seed,
1400            requirement,
1401            record,
1402        )?;
1403        record.cache_namespace_fingerprints = fingerprints.clone();
1404        let payload = payloads
1405            .iter_mut()
1406            .find(|payload| payload.requirement_key == record.requirement_key)
1407            .ok_or_else(|| {
1408                DagMlError::RuntimeValidation(format!(
1409                    "prediction cache `{}` has no portable payload for requirement `{}`",
1410                    record.cache_id, record.requirement_key
1411                ))
1412            })?;
1413        payload.cache_namespace_fingerprints = fingerprints;
1414        validate_prediction_cache_payload_matches_record(payload, record)?;
1415    }
1416    Ok(())
1417}
1418
1419fn oof_cache_namespace_fingerprints(
1420    plan: &ExecutionPlan,
1421    data_identities: &[TrainingDataIdentity],
1422    selected_variant_id: &VariantId,
1423    seed: u64,
1424    requirement: &BundlePredictionRequirement,
1425    record: &BundlePredictionCacheRecord,
1426) -> Result<Vec<String>> {
1427    let producer_plan = plan
1428        .node_plans
1429        .get(&requirement.producer_node)
1430        .ok_or_else(|| {
1431            DagMlError::RuntimeValidation(format!(
1432                "prediction cache `{}` producer node `{}` is absent from plan",
1433                record.cache_id, requirement.producer_node
1434            ))
1435        })?;
1436    let consumer_plan = plan
1437        .node_plans
1438        .get(&requirement.consumer_node)
1439        .ok_or_else(|| {
1440            DagMlError::RuntimeValidation(format!(
1441                "prediction cache `{}` consumer node `{}` is absent from plan",
1442                record.cache_id, requirement.consumer_node
1443            ))
1444        })?;
1445    let identity_binding = match (
1446        producer_plan.data_bindings.as_slice(),
1447        consumer_plan.data_bindings.as_slice(),
1448    ) {
1449        ([binding], _) => binding,
1450        ([], [binding]) => binding,
1451        (producer_bindings, consumer_bindings) => {
1452            let producer_count = producer_bindings.len();
1453            let consumer_count = consumer_bindings.len();
1454            return Err(DagMlError::RuntimeValidation(format!(
1455                "prediction cache `{}` cannot derive a unique CacheNamespace for edge `{}.{}` -> `{}.{}` with {producer_count} producer data binding(s) and {consumer_count} consumer data binding(s)",
1456                record.cache_id,
1457                requirement.producer_node,
1458                requirement.source_port,
1459                requirement.consumer_node,
1460                requirement.target_port
1461            )));
1462        }
1463    };
1464    if producer_plan.data_bindings.len() > 1 || consumer_plan.data_bindings.len() > 1 {
1465        return Err(DagMlError::RuntimeValidation(format!(
1466            "prediction cache `{}` cannot derive a unique CacheNamespace for edge `{}.{}` -> `{}.{}` with ambiguous data bindings",
1467            record.cache_id,
1468            requirement.producer_node,
1469            requirement.source_port,
1470            requirement.consumer_node,
1471            requirement.target_port
1472        )));
1473    }
1474    let data_requirement_key =
1475        data_binding_requirement_key(&identity_binding.node_id, &identity_binding.input_name);
1476    let identity = data_identities
1477        .iter()
1478        .find(|identity| identity.requirement_key == data_requirement_key)
1479        .ok_or_else(|| {
1480            DagMlError::RuntimeValidation(format!(
1481                "prediction cache `{}` has no training data identity for `{data_requirement_key}`",
1482                record.cache_id
1483            ))
1484        })?;
1485    let mut fingerprints = Vec::with_capacity(record.blocks.len());
1486    for block in &record.blocks {
1487        let fold_id = block.fold_id.clone().ok_or_else(|| {
1488            DagMlError::RuntimeValidation(format!(
1489                "prediction cache `{}` has a cache block without fold_id",
1490                record.cache_id
1491            ))
1492        })?;
1493        let namespace = CacheNamespace::new(
1494            requirement.key(),
1495            identity.requirement_key.clone(),
1496            requirement.producer_node.clone(),
1497            requirement.source_port.clone(),
1498            requirement.consumer_node.clone(),
1499            requirement.target_port.clone(),
1500            producer_plan.params_fingerprint.clone(),
1501            producer_plan.training_loss_fingerprint(Phase::FitCv)?,
1502            identity.identity_fingerprint.clone(),
1503            fold_id,
1504            selected_variant_id.to_string(),
1505            seed,
1506        )?;
1507        namespace.validate_for_identity(identity)?;
1508        fingerprints.push(namespace.namespace_fingerprint);
1509    }
1510    Ok(fingerprints)
1511}
1512
1513impl TrainingOutcome {
1514    /// Strictly parse a self-fingerprinted W0 outcome without losing the JSON
1515    /// integer-versus-binary64 token distinction before verification.
1516    pub fn from_json(json: &str) -> Result<Self> {
1517        let typed = parse_typed_json(json).map_err(|error| {
1518            DagMlError::CampaignValidation(format!(
1519                "training outcome is not strict TCV1 JSON: {error}"
1520            ))
1521        })?;
1522        let raw_fingerprint =
1523            typed
1524                .fingerprint_without("outcome_fingerprint")
1525                .map_err(|error| {
1526                    DagMlError::CampaignValidation(format!(
1527                        "training outcome fingerprint preimage is invalid: {error}"
1528                    ))
1529                })?;
1530        let outcome: Self = serde_json::from_str(json)?;
1531        if outcome.outcome_fingerprint != raw_fingerprint {
1532            return contract_error(
1533                "training outcome fingerprint does not match original TCV1 JSON",
1534            );
1535        }
1536        outcome.validate()?;
1537        Ok(outcome)
1538    }
1539
1540    pub fn compute_fingerprint(&self) -> Result<String> {
1541        tcv1_fingerprint_without(self, "outcome_fingerprint", "training outcome")
1542    }
1543
1544    pub fn data_identities_fingerprint(&self) -> Result<String> {
1545        tcv1_fingerprint(&self.data_identities, "training outcome data identities")
1546    }
1547
1548    pub fn execution_bundle_fingerprint(&self) -> Result<String> {
1549        tcv1_fingerprint(&self.execution_bundle, "training outcome execution bundle")
1550    }
1551
1552    /// Build the compact cross-link embedded by a portable predictor package.
1553    pub fn to_reference(&self) -> Result<TrainingOutcomeRef> {
1554        self.validate()?;
1555        validate_sha256(
1556            "training outcome request",
1557            &self.training_request_fingerprint,
1558        )?;
1559        Ok(TrainingOutcomeRef {
1560            outcome_id: self.outcome_id.clone(),
1561            outcome_fingerprint: self.outcome_fingerprint.clone(),
1562            training_request_fingerprint: self.training_request_fingerprint.clone(),
1563            effective_plan_fingerprint: self.effective_plan_fingerprint.clone(),
1564            execution_bundle_id: self.execution_bundle.bundle_id.clone(),
1565            execution_bundle_fingerprint: self.execution_bundle_fingerprint()?,
1566            data_identities_fingerprint: self.data_identities_fingerprint()?,
1567            output_binding_fingerprints: self
1568                .outputs
1569                .iter()
1570                .map(|output| output.binding.binding_fingerprint.clone())
1571                .collect(),
1572            training_influence_fingerprint: self.training_influence.manifest_fingerprint.clone(),
1573        })
1574    }
1575
1576    /// Export a self-contained portable predictor package contract from this
1577    /// training outcome. Runtime handles are never serialized; host-sidecar
1578    /// artifacts are represented only by their signed artifact descriptors and
1579    /// must be resolved into process-local handles by `PortablePredictorPackage::load_with`.
1580    pub fn to_portable_predictor_package(
1581        &self,
1582        package_id: impl Into<String>,
1583        fitted_artifact_mode: FittedArtifactMode,
1584        artifact_load_mode: ArtifactLoadMode,
1585    ) -> Result<PortablePredictorPackage> {
1586        self.validate()?;
1587        let mut template = PredictorTemplate {
1588            graph: self.effective_plan.graph_plan.graph.clone(),
1589            campaign: self.effective_plan.campaign.clone(),
1590            controller_manifests: self.effective_plan.controller_manifests.clone(),
1591            template_fingerprint: zero_fingerprint(),
1592        };
1593        template.template_fingerprint = template.compute_fingerprint()?;
1594
1595        let output_bindings = self
1596            .outputs
1597            .iter()
1598            .map(|output| output.binding.clone())
1599            .collect::<Vec<_>>();
1600        let predictor_node_ids = predictor_closure(
1601            &self.effective_plan,
1602            output_bindings
1603                .iter()
1604                .map(|binding| binding.node_id.clone()),
1605        )?
1606        .into_iter()
1607        .collect::<Vec<_>>();
1608        let mut artifact_bindings = self
1609            .execution_bundle
1610            .refit_artifacts
1611            .iter()
1612            .map(|record| PackageArtifactBinding {
1613                artifact_id: record.artifact.id.clone(),
1614                load_mode: artifact_load_mode,
1615            })
1616            .collect::<Vec<_>>();
1617        artifact_bindings.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id));
1618        let mut package = PortablePredictorPackage {
1619            schema_version: PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION,
1620            package_id: package_id.into(),
1621            template,
1622            training_request_fingerprint: self.training_request_fingerprint.clone(),
1623            training_outcome: self.to_reference()?,
1624            effective_plan: self.effective_plan.clone(),
1625            execution_bundle: self.execution_bundle.clone(),
1626            output_bindings,
1627            predictor_node_ids,
1628            training_influence: self.training_influence.clone(),
1629            data_identities: self.data_identities.clone(),
1630            fitted_artifact_mode,
1631            artifact_bindings,
1632            package_fingerprint: zero_fingerprint(),
1633        };
1634        package.package_fingerprint = package.compute_fingerprint()?;
1635        package.validate()?;
1636        Ok(package)
1637    }
1638
1639    pub fn validate(&self) -> Result<()> {
1640        if self.schema_version < MIN_READABLE_TRAINING_OUTCOME_SCHEMA_VERSION
1641            || self.schema_version > TRAINING_OUTCOME_SCHEMA_VERSION
1642        {
1643            return contract_error(format!(
1644                "training outcome schema_version {} is unsupported; maximum readable version is {}",
1645                self.schema_version, TRAINING_OUTCOME_SCHEMA_VERSION
1646            ));
1647        }
1648        RunId::new(self.outcome_id.clone()).map_err(|error| {
1649            DagMlError::CampaignValidation(format!(
1650                "training outcome_id is not a portable identifier: {error}"
1651            ))
1652        })?;
1653        validate_sha256(
1654            "training outcome request",
1655            &self.training_request_fingerprint,
1656        )?;
1657        validate_sha256("training outcome plan", &self.effective_plan_fingerprint)?;
1658        validate_sha256(
1659            "training outcome selected variant",
1660            &self.selected_variant_fingerprint,
1661        )?;
1662        validate_sha256("training outcome", &self.outcome_fingerprint)?;
1663        self.effective_plan.validate()?;
1664        if self.effective_plan_fingerprint
1665            != tcv1_fingerprint(&self.effective_plan, "training outcome effective plan")?
1666        {
1667            return contract_error(
1668                "training outcome effective_plan_fingerprint does not match TCV1 plan content",
1669            );
1670        }
1671
1672        let selected = self
1673            .effective_plan
1674            .variants
1675            .iter()
1676            .filter(|variant| variant.variant_id == self.selected_variant_id)
1677            .collect::<Vec<_>>();
1678        let [selected] = selected.as_slice() else {
1679            return contract_error(
1680                "training outcome selected_variant_id is absent or duplicated in effective plan",
1681            );
1682        };
1683        if selected.fingerprint != self.selected_variant_fingerprint {
1684            return contract_error(
1685                "training outcome selected_variant_fingerprint does not match effective plan",
1686            );
1687        }
1688        let expected_patches = selected_variant_parameter_patches(selected)?;
1689        validate_outcome_parameter_patches(
1690            &self.effective_plan,
1691            &self.parameter_patches,
1692            &expected_patches,
1693        )?;
1694        if !self.parameter_patches.is_empty()
1695            && !self
1696                .training_influence
1697                .entries
1698                .iter()
1699                .any(|entry| entry.kind == TrainingInfluenceKind::HpoSelection)
1700        {
1701            return contract_error(
1702                "training outcome parameter patches require hpo_selection influence",
1703            );
1704        }
1705
1706        self.validate_refit()?;
1707        self.score_set.validate()?;
1708        self.validate_version_family()?;
1709        if self.score_set.plan_id != self.effective_plan.id {
1710            return contract_error("training outcome score_set.plan_id does not match plan");
1711        }
1712        if !self
1713            .score_set
1714            .reports
1715            .iter()
1716            .any(|report| report.variant_id.as_ref() == Some(&self.selected_variant_id))
1717        {
1718            return contract_error("training outcome score_set has no report for selected variant");
1719        }
1720        self.validate_selection_decision()?;
1721
1722        let closure = self.validate_outputs()?;
1723        // V1 standalone-validation invariant: the effective predictor closure
1724        // must cover every effective plan node. Until unrelated-node execution
1725        // and persistence have an explicit policy, this lets replayable-phase
1726        // derivation trust `closure` as the full predictor without reconstructing
1727        // a partial schedule from outcome data alone.
1728        if closure
1729            != self
1730                .effective_plan
1731                .node_plans
1732                .keys()
1733                .cloned()
1734                .collect::<BTreeSet<_>>()
1735        {
1736            return contract_error(
1737                "training outcome predictor closure must equal all effective plan nodes in V1",
1738            );
1739        }
1740        self.training_influence.validate()?;
1741        validate_influence_against_closure(
1742            &self.training_influence,
1743            &self.effective_plan,
1744            &closure,
1745        )?;
1746        let base_fit_nodes = self
1747            .training_influence
1748            .entries
1749            .iter()
1750            .filter(|entry| {
1751                matches!(
1752                    entry.kind,
1753                    TrainingInfluenceKind::TransformFit
1754                        | TrainingInfluenceKind::ModelFit
1755                        | TrainingInfluenceKind::TrainedMetaAggregation
1756                )
1757            })
1758            .filter_map(|entry| entry.node_id.clone())
1759            .collect::<BTreeSet<_>>();
1760        if self
1761            .outputs
1762            .iter()
1763            .any(|output| !base_fit_nodes.contains(&output.binding.node_id))
1764        {
1765            return contract_error("training outcome output node has no fitting influence");
1766        }
1767
1768        self.execution_bundle
1769            .validate_against_plan(&self.effective_plan)?;
1770        if self.execution_bundle.selected_variant_id.as_ref() != Some(&self.selected_variant_id) {
1771            return contract_error(
1772                "training outcome execution bundle selected variant does not match outcome",
1773            );
1774        }
1775        if self.execution_bundle.scores.as_ref() != Some(&self.score_set) {
1776            return contract_error(
1777                "training outcome execution bundle scores do not equal score_set",
1778            );
1779        }
1780        self.validate_data_identities()?;
1781        validate_all_identity_relations(
1782            &self.data_identities,
1783            &self.training_influence.relation_fingerprint,
1784        )?;
1785        self.validate_artifacts(&closure)?;
1786        self.validate_lineage(&closure)?;
1787        match &self.portable_prediction_caches {
1788            Some(caches) => caches.validate_against_bundle(&self.execution_bundle)?,
1789            None if !self.execution_bundle.prediction_caches.is_empty() => {
1790                return contract_error(
1791                    "training outcome portable caches are null while bundle announces caches",
1792                );
1793            }
1794            None => {}
1795        }
1796
1797        let expected_replay = derive_replayable_phases(
1798            &self.effective_plan,
1799            &closure,
1800            &self.refit,
1801            &self.execution_bundle,
1802            self.portable_prediction_caches.as_ref(),
1803        )?;
1804        if self.replayable_phases != expected_replay {
1805            return contract_error(
1806                "training outcome replayable_phases do not match the phases derivable from the full predictor closure and retained state",
1807            );
1808        }
1809        validate_sorted_unique_text("training outcome warnings", &self.warnings)?;
1810        let portable = serde_json::to_value(self)?;
1811        if contains_runtime_handle(&portable) {
1812            return contract_error("training outcome must not contain runtime handles");
1813        }
1814        if self.outcome_fingerprint != self.compute_fingerprint()? {
1815            return contract_error("training outcome fingerprint does not match TCV1 content");
1816        }
1817        Ok(())
1818    }
1819
1820    fn validate_version_family(&self) -> Result<()> {
1821        let expected_score_version = match self.schema_version {
1822            LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => LEGACY_SCORE_SET_SCHEMA_VERSION,
1823            TRAINING_OUTCOME_SCHEMA_VERSION => SCORE_SET_SCHEMA_VERSION,
1824            _ => unreachable!("training outcome schema_version was range-checked"),
1825        };
1826        if self.score_set.schema_version != expected_score_version {
1827            return contract_error(format!(
1828                "training outcome schema_version {} requires score_set schema_version {}, got {}",
1829                self.schema_version, expected_score_version, self.score_set.schema_version
1830            ));
1831        }
1832        let expected_bundle_version = match self.schema_version {
1833            LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => LEGACY_EXECUTION_BUNDLE_SCHEMA_VERSION,
1834            TRAINING_OUTCOME_SCHEMA_VERSION => EXECUTION_BUNDLE_SCHEMA_VERSION,
1835            _ => unreachable!("training outcome schema_version was range-checked"),
1836        };
1837        if self.execution_bundle.schema_version != expected_bundle_version {
1838            return contract_error(format!(
1839                "training outcome schema_version {} requires execution_bundle schema_version {}, got {}",
1840                self.schema_version,
1841                expected_bundle_version,
1842                self.execution_bundle.schema_version
1843            ));
1844        }
1845        let expected_cache_version = match self.schema_version {
1846            LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => {
1847                LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION
1848            }
1849            TRAINING_OUTCOME_SCHEMA_VERSION => PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
1850            _ => unreachable!("training outcome schema_version was range-checked"),
1851        };
1852        if let Some(caches) = &self.portable_prediction_caches {
1853            if caches.schema_version != expected_cache_version {
1854                return contract_error(format!(
1855                    "training outcome schema_version {} requires prediction cache payload set schema_version {}, got {}",
1856                    self.schema_version, expected_cache_version, caches.schema_version
1857                ));
1858            }
1859        }
1860        for output in &self.outputs {
1861            match (self.schema_version, output.schema_version) {
1862                (LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION, None) => {}
1863                (LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION, Some(version)) => {
1864                    return contract_error(format!(
1865                        "training outcome V1 requires absent bound output schema_version, got {version}"
1866                    ));
1867                }
1868                (TRAINING_OUTCOME_SCHEMA_VERSION, Some(BOUND_TRAINING_OUTPUT_SCHEMA_VERSION)) => {}
1869                (TRAINING_OUTCOME_SCHEMA_VERSION, Some(version)) => {
1870                    return contract_error(format!(
1871                        "training outcome V2 requires bound output schema_version {}, got {version}",
1872                        BOUND_TRAINING_OUTPUT_SCHEMA_VERSION
1873                    ));
1874                }
1875                (TRAINING_OUTCOME_SCHEMA_VERSION, None) => {
1876                    return contract_error(
1877                        "training outcome V2 requires bound output schema_version",
1878                    );
1879                }
1880                _ => unreachable!("training outcome schema_version was range-checked"),
1881            }
1882        }
1883        Ok(())
1884    }
1885
1886    fn validate_data_identities(&self) -> Result<()> {
1887        if self.data_identities.is_empty() {
1888            return contract_error("training outcome requires data identities");
1889        }
1890        let mut previous: Option<&str> = None;
1891        for identity in &self.data_identities {
1892            identity.validate()?;
1893            if previous.is_some_and(|key| key >= identity.requirement_key.as_str()) {
1894                return contract_error(
1895                    "training outcome data identities must be sorted and unique",
1896                );
1897            }
1898            previous = Some(identity.requirement_key.as_str());
1899            let requirement = self
1900                .execution_bundle
1901                .data_requirements
1902                .iter()
1903                .find(|requirement| requirement.key() == identity.requirement_key)
1904                .ok_or_else(|| {
1905                    DagMlError::CampaignValidation(format!(
1906                        "training outcome data identity `{}` has no bundle requirement",
1907                        identity.requirement_key
1908                    ))
1909                })?;
1910            if requirement.schema_fingerprint != identity.schema_fingerprint
1911                || requirement.plan_fingerprint != identity.plan_fingerprint
1912                || requirement.relation_fingerprint.as_ref() != Some(&identity.relation_fingerprint)
1913            {
1914                return contract_error(
1915                    "training outcome data identity does not match execution bundle requirement",
1916                );
1917            }
1918        }
1919        if self.data_identities.len() != self.execution_bundle.data_requirements.len() {
1920            return contract_error(
1921                "training outcome data identities do not exactly cover bundle data requirements",
1922            );
1923        }
1924        Ok(())
1925    }
1926
1927    fn validate_selection_decision(&self) -> Result<()> {
1928        if self.selection_output_id.trim().is_empty() {
1929            return contract_error("training outcome selection_output_id is empty");
1930        }
1931        let bindings = self
1932            .outputs
1933            .iter()
1934            .filter(|output| output.binding.binding_id == self.selection_output_id)
1935            .collect::<Vec<_>>();
1936        let [selected_output] = bindings.as_slice() else {
1937            return contract_error(
1938                "training outcome selection_output_id does not resolve exactly one output",
1939            );
1940        };
1941        if self.execution_bundle.selections.len() != 1 {
1942            return contract_error(
1943                "training outcome execution bundle must contain exactly one SELECT decision",
1944            );
1945        }
1946        let (selection_key, decision) = self
1947            .execution_bundle
1948            .selections
1949            .iter()
1950            .next()
1951            .expect("selection length was checked");
1952        if selection_key != &decision.policy_id
1953            || decision.selected_candidate_id != self.selected_variant_id.as_str()
1954            || decision.metric_level != Some(selected_output.binding.prediction_level)
1955            || decision.evaluation_scope != Some(EvaluationScope::Oof)
1956            || self.score_set.selection_metric.as_deref() != Some(decision.metric_name.as_str())
1957            || selected_output.binding.prediction_level
1958                != self
1959                    .effective_plan
1960                    .campaign
1961                    .aggregation_policy
1962                    .selection_metric_level
1963        {
1964            return contract_error(
1965                "training outcome SELECT decision metadata is inconsistent with selected output",
1966            );
1967        }
1968        RegressionMetricKind::resolve_for_prediction_kind(
1969            &decision.metric_name,
1970            decision.objective,
1971            selected_output.binding.prediction_kind,
1972        )?;
1973        let mut reports_by_variant = BTreeMap::<VariantId, _>::new();
1974        for report in self.score_set.reports.iter().filter(|report| {
1975            report.producer_node == selected_output.binding.node_id
1976                && producer_port_matches_graph_output(
1977                    &self.effective_plan,
1978                    &selected_output.binding.node_id,
1979                    &selected_output.binding.port_name,
1980                    &report.producer_port,
1981                )
1982                && report.partition == PredictionPartition::Validation
1983                && report.level == selected_output.binding.prediction_level
1984                && report
1985                    .fold_id
1986                    .as_ref()
1987                    .is_some_and(|fold| fold.as_str() == "avg")
1988        }) {
1989            let variant_id = report.variant_id.clone().ok_or_else(|| {
1990                DagMlError::CampaignValidation(
1991                    "selection output average report has no variant_id".to_string(),
1992                )
1993            })?;
1994            if reports_by_variant
1995                .insert(variant_id, report.clone())
1996                .is_some()
1997            {
1998                return contract_error(
1999                    "training outcome has multiple selection average reports for one variant",
2000                );
2001            }
2002        }
2003        let expected_variants = self
2004            .effective_plan
2005            .variants
2006            .iter()
2007            .map(|variant| variant.variant_id.clone())
2008            .collect::<BTreeSet<_>>();
2009        if reports_by_variant.keys().cloned().collect::<BTreeSet<_>>() != expected_variants {
2010            return contract_error(
2011                "training outcome selection reports do not exactly cover plan variants",
2012            );
2013        }
2014        let candidates = reports_by_variant
2015            .into_iter()
2016            .map(|(variant_id, report)| report.into_candidate_score(variant_id.as_str()))
2017            .collect::<Result<Vec<_>>>()?;
2018        let reconstructed = select_candidate(
2019            &SelectionPolicy {
2020                id: decision.policy_id.clone(),
2021                metric: SelectionMetric {
2022                    name: decision.metric_name.clone(),
2023                    objective: decision.objective,
2024                },
2025                required_metric_level: decision.metric_level,
2026                require_finite: true,
2027                evaluation_scope: decision.evaluation_scope,
2028                refit_slot_plan: decision.refit_slot_plan.clone(),
2029                stacking_fit_contract: None,
2030                reduction_id: decision.reduction_id.clone(),
2031            },
2032            &candidates,
2033        )?;
2034        if &reconstructed != decision {
2035            return contract_error(
2036                "training outcome SELECT decision does not equal ranking reconstructed from scores",
2037            );
2038        }
2039        Ok(())
2040    }
2041
2042    fn validate_refit(&self) -> Result<()> {
2043        match (self.refit.requested, self.refit.status, self.refit.strategy) {
2044            (true, TrainingRefitStatus::Completed, Some(_)) => {
2045                if self
2046                    .outputs
2047                    .iter()
2048                    .any(|output| output.binding.prediction_source != PredictionSource::FinalRefit)
2049                {
2050                    return contract_error(
2051                        "completed refit outputs must use final_refit prediction source",
2052                    );
2053                }
2054            }
2055            (false, TrainingRefitStatus::Skipped, None) => {
2056                if self
2057                    .outputs
2058                    .iter()
2059                    .any(|output| output.binding.prediction_source == PredictionSource::FinalRefit)
2060                {
2061                    return contract_error("no-refit outputs cannot use final_refit");
2062                }
2063            }
2064            _ => return contract_error("training outcome refit state is inconsistent"),
2065        }
2066        Ok(())
2067    }
2068
2069    fn validate_outputs(&self) -> Result<BTreeSet<NodeId>> {
2070        if self.outputs.is_empty() {
2071            return contract_error("training outcome requires at least one bound output");
2072        }
2073        let mut previous: Option<&str> = None;
2074        let mut roots = Vec::new();
2075        for output in &self.outputs {
2076            if previous.is_some_and(|value| value >= output.binding.binding_id.as_str()) {
2077                return contract_error(
2078                    "training outcome outputs must be strictly sorted by binding_id",
2079                );
2080            }
2081            previous = Some(output.binding.binding_id.as_str());
2082            output.validate(&self.effective_plan)?;
2083            roots.push(output.binding.node_id.clone());
2084        }
2085        predictor_closure(&self.effective_plan, roots)
2086    }
2087
2088    fn validate_artifacts(&self, closure: &BTreeSet<NodeId>) -> Result<()> {
2089        if !self.refit.requested {
2090            if !self.execution_bundle.refit_artifacts.is_empty() {
2091                return contract_error("no-refit training outcome contains refit artifacts");
2092            }
2093            return Ok(());
2094        }
2095        if self.execution_bundle.refit_artifacts.is_empty() {
2096            return contract_error("completed refit requires at least one artifact");
2097        }
2098        let expected_artifact_nodes = closure
2099            .iter()
2100            .filter(|node_id| {
2101                let plan = &self.effective_plan.node_plans[*node_id];
2102                plan.supported_phases.contains(&Phase::Refit)
2103                    && plan
2104                        .controller_capabilities
2105                        .contains(&ControllerCapability::EmitsArtifacts)
2106            })
2107            .cloned()
2108            .collect::<BTreeSet<_>>();
2109        let artifact_nodes = self
2110            .execution_bundle
2111            .refit_artifacts
2112            .iter()
2113            .map(|record| record.node_id.clone())
2114            .collect::<BTreeSet<_>>();
2115        if artifact_nodes != expected_artifact_nodes {
2116            return contract_error(
2117                "refit artifact nodes do not exactly match predictor closure REFIT artifact emitters",
2118            );
2119        }
2120        for output in &self.outputs {
2121            if !artifact_nodes.contains(&output.binding.node_id) {
2122                return contract_error("final output node has no refit artifact");
2123            }
2124        }
2125        Ok(())
2126    }
2127
2128    fn validate_lineage(&self, closure: &BTreeSet<NodeId>) -> Result<()> {
2129        if self.lineage.is_empty() {
2130            return contract_error("training outcome requires portable lineage");
2131        }
2132        let record_ids = self
2133            .lineage
2134            .iter()
2135            .map(|record| record.record_id.clone())
2136            .collect::<Vec<_>>();
2137        if record_ids.windows(2).any(|pair| pair[0] >= pair[1]) {
2138            return contract_error("training outcome lineage must be sorted by record_id");
2139        }
2140        let by_id = self
2141            .lineage
2142            .iter()
2143            .map(|record| (record.record_id.clone(), record))
2144            .collect::<BTreeMap<_, _>>();
2145        if by_id.len() != self.lineage.len() {
2146            return contract_error("training outcome lineage contains duplicate record ids");
2147        }
2148        let mut coordinates = BTreeMap::new();
2149        for record in &self.lineage {
2150            record.validate()?;
2151            if record.run_id != self.run_id
2152                || record.variant_id.as_ref() != Some(&self.selected_variant_id)
2153                || !closure.contains(&record.node_id)
2154            {
2155                return contract_error(
2156                    "training outcome lineage run, variant, or predictor closure is inconsistent",
2157                );
2158            }
2159            if !matches!(record.phase, Phase::FitCv | Phase::Select | Phase::Refit) {
2160                return contract_error("training outcome lineage contains a non-training phase");
2161            }
2162            let plan = &self.effective_plan.node_plans[&record.node_id];
2163            if record.controller_id != plan.controller_id
2164                || record.controller_version != plan.controller_version
2165                || record.params_fingerprint != plan.params_fingerprint
2166            {
2167                return contract_error("training outcome lineage does not match node plan");
2168            }
2169            let expected_losses = plan
2170                .training_losses_for_phase(record.phase)
2171                .collect::<Vec<_>>();
2172            if record.loss_attestations.len() != expected_losses.len() {
2173                return contract_error(
2174                    "training outcome lineage loss attestations do not match node plan",
2175                );
2176            }
2177            for (attestation, role) in record.loss_attestations.iter().zip(expected_losses) {
2178                attestation.validate_against(role, &record.node_id, record.phase)?;
2179            }
2180            let key = (record.phase, record.fold_id.clone(), record.node_id.clone());
2181            if coordinates.insert(key, record).is_some() {
2182                return contract_error("training outcome lineage duplicates phase/fold/node");
2183            }
2184            if record
2185                .input_lineage
2186                .iter()
2187                .any(|input| !by_id.contains_key(input))
2188            {
2189                return contract_error("training outcome lineage references an unknown input");
2190            }
2191        }
2192        validate_lineage_coordinates(self, closure, &coordinates)
2193    }
2194}
2195
2196impl BoundTrainingOutput {
2197    pub(crate) fn validate(&self, plan: &ExecutionPlan) -> Result<()> {
2198        if let Some(schema_version) = self.schema_version {
2199            if schema_version != BOUND_TRAINING_OUTPUT_SCHEMA_VERSION {
2200                return contract_error(format!(
2201                    "bound training output schema_version {schema_version} is unsupported; current {}",
2202                    BOUND_TRAINING_OUTPUT_SCHEMA_VERSION
2203                ));
2204            }
2205        }
2206        self.binding.validate(&plan.graph_plan.graph)?;
2207        if self.predictions.is_empty()
2208            && self.observation_predictions.is_empty()
2209            && self.aggregated_predictions.is_empty()
2210        {
2211            return contract_error("bound training output contains no prediction block");
2212        }
2213        match self.binding.prediction_level {
2214            PredictionLevel::Observation
2215                if !self.predictions.is_empty() || !self.aggregated_predictions.is_empty() =>
2216            {
2217                return contract_error(
2218                    "observation output binding cannot contain sample or aggregated predictions",
2219                );
2220            }
2221            PredictionLevel::Sample if !self.observation_predictions.is_empty() => {
2222                return contract_error(
2223                    "sample output binding cannot contain observation predictions",
2224                );
2225            }
2226            PredictionLevel::Target | PredictionLevel::Group
2227                if !self.predictions.is_empty() || !self.observation_predictions.is_empty() =>
2228            {
2229                return contract_error(
2230                    "target/group output binding cannot contain sample or observation predictions",
2231                );
2232            }
2233            _ => {}
2234        }
2235        let expected_names = expected_output_columns(&self.binding);
2236        for block in &self.predictions {
2237            block.validate_shape()?;
2238            validate_bound_block(
2239                plan,
2240                &self.binding,
2241                BoundBlockRef {
2242                    producer: &block.producer_node,
2243                    producer_port: &block.producer_port,
2244                    partition: &block.partition,
2245                    fold_id: block.fold_id.as_ref(),
2246                    target_names: &block.target_names,
2247                },
2248                &expected_names,
2249            )?;
2250        }
2251        for block in &self.observation_predictions {
2252            block.validate_shape()?;
2253            validate_bound_block(
2254                plan,
2255                &self.binding,
2256                BoundBlockRef {
2257                    producer: &block.producer_node,
2258                    producer_port: &block.producer_port,
2259                    partition: &block.partition,
2260                    fold_id: block.fold_id.as_ref(),
2261                    target_names: &block.target_names,
2262                },
2263                &expected_names,
2264            )?;
2265        }
2266        for block in &self.aggregated_predictions {
2267            block.validate_shape()?;
2268            if block.level != self.binding.prediction_level {
2269                return contract_error(
2270                    "bound aggregated prediction level does not match output binding",
2271                );
2272            }
2273            validate_bound_block(
2274                plan,
2275                &self.binding,
2276                BoundBlockRef {
2277                    producer: &block.producer_node,
2278                    producer_port: &block.producer_port,
2279                    partition: &block.partition,
2280                    fold_id: block.fold_id.as_ref(),
2281                    target_names: &block.target_names,
2282                },
2283                &expected_names,
2284            )?;
2285        }
2286        match self.binding.prediction_level {
2287            PredictionLevel::Observation if self.observation_predictions.is_empty() => {
2288                return contract_error(
2289                    "observation output binding requires observation predictions",
2290                );
2291            }
2292            PredictionLevel::Target | PredictionLevel::Group
2293                if self.aggregated_predictions.is_empty() =>
2294            {
2295                return contract_error(
2296                    "target/group output binding requires aggregated predictions",
2297                );
2298            }
2299            _ => {}
2300        }
2301        Ok(())
2302    }
2303}
2304
2305struct BoundBlockRef<'a> {
2306    producer: &'a NodeId,
2307    producer_port: &'a Option<String>,
2308    partition: &'a PredictionPartition,
2309    fold_id: Option<&'a crate::ids::FoldId>,
2310    target_names: &'a [String],
2311}
2312
2313fn validate_bound_block(
2314    plan: &ExecutionPlan,
2315    binding: &OutputBinding,
2316    block: BoundBlockRef<'_>,
2317    expected_names: &[String],
2318) -> Result<()> {
2319    if block.producer != &binding.node_id
2320        || !producer_port_matches_graph_output(
2321            plan,
2322            &binding.node_id,
2323            &binding.port_name,
2324            block.producer_port,
2325        )
2326        || block.target_names != expected_names
2327    {
2328        return contract_error(
2329            "bound prediction producer, producer_port or target order does not match output binding",
2330        );
2331    }
2332    if binding.prediction_source == PredictionSource::FinalRefit
2333        && (block.partition != &PredictionPartition::Final || block.fold_id.is_some())
2334    {
2335        return contract_error("final_refit output blocks must use final partition without fold");
2336    }
2337    if binding.prediction_source == PredictionSource::CvEnsemble
2338        && (!is_cv_ensemble_partition(block.partition) || block.fold_id.is_none())
2339    {
2340        return contract_error(
2341            "cv_ensemble output blocks must use validation partition with a fold id",
2342        );
2343    }
2344    Ok(())
2345}
2346
2347fn expected_output_columns(binding: &OutputBinding) -> Vec<String> {
2348    if binding.prediction_kind == PredictionKind::ClassProbability {
2349        binding
2350            .target_names
2351            .iter()
2352            .zip(&binding.class_labels)
2353            .flat_map(|(target, labels)| {
2354                labels.iter().map(move |label| format!("{target}:{label}"))
2355            })
2356            .collect()
2357    } else {
2358        binding.target_names.clone()
2359    }
2360}
2361
2362fn selected_variant_parameter_patches(
2363    variant: &crate::generation::VariantPlan,
2364) -> Result<Vec<ParameterPatch>> {
2365    let mut patches = Vec::new();
2366    for choice in variant.choices.values() {
2367        for override_spec in &choice.param_overrides {
2368            for (key, value) in &override_spec.params {
2369                append_parameter_leaves(
2370                    &override_spec.node_id,
2371                    vec![key.clone()],
2372                    value,
2373                    &mut patches,
2374                )?;
2375            }
2376        }
2377    }
2378    patches.sort_by(|left, right| {
2379        (&left.node_id, left.namespace, &left.path).cmp(&(
2380            &right.node_id,
2381            right.namespace,
2382            &right.path,
2383        ))
2384    });
2385    if patches.windows(2).any(|pair| {
2386        pair[0].node_id == pair[1].node_id
2387            && pair[0].namespace == pair[1].namespace
2388            && pair[0].path == pair[1].path
2389    }) {
2390        return contract_error("selected variant overrides contain duplicate leaf paths");
2391    }
2392    Ok(patches)
2393}
2394
2395fn merge_training_parameter_patches(
2396    request_patches: &[ParameterPatch],
2397    selected_variant: &crate::generation::VariantPlan,
2398) -> Result<Vec<ParameterPatch>> {
2399    let mut patches = request_patches.to_vec();
2400    patches.extend(selected_variant_parameter_patches(selected_variant)?);
2401    sort_and_validate_training_parameter_patch_keys(&mut patches, false)?;
2402    Ok(patches)
2403}
2404
2405fn validate_outcome_parameter_patches(
2406    plan: &ExecutionPlan,
2407    patches: &[ParameterPatch],
2408    selected_variant_patches: &[ParameterPatch],
2409) -> Result<()> {
2410    let mut patches = patches.to_vec();
2411    sort_and_validate_training_parameter_patch_keys(&mut patches, true)?;
2412    let keys = patches
2413        .iter()
2414        .map(parameter_patch_key)
2415        .collect::<BTreeSet<_>>();
2416    for selected in selected_variant_patches {
2417        if !keys.contains(&parameter_patch_key(selected)) {
2418            return contract_error(
2419                "training outcome parameter_patches are missing a selected variant override",
2420            );
2421        }
2422    }
2423    for patch in &patches {
2424        validate_materialized_patch(plan, patch)?;
2425    }
2426    Ok(())
2427}
2428
2429fn sort_and_validate_training_parameter_patch_keys(
2430    patches: &mut [ParameterPatch],
2431    require_already_sorted: bool,
2432) -> Result<()> {
2433    for patch in patches.iter() {
2434        patch.validate()?;
2435        if patch.namespace != ParameterNamespace::Operator {
2436            return contract_error(
2437                "training outcome parameter_patches must use operator namespace",
2438            );
2439        }
2440    }
2441    let original = patches.to_vec();
2442    patches.sort_by(|left, right| parameter_patch_key(left).cmp(&parameter_patch_key(right)));
2443    if require_already_sorted && patches != original {
2444        return contract_error(
2445            "training outcome parameter_patches must be sorted by (node_id, namespace, path)",
2446        );
2447    }
2448    for pair in patches.windows(2) {
2449        let left = &pair[0];
2450        let right = &pair[1];
2451        if parameter_patch_key(left) == parameter_patch_key(right) {
2452            return contract_error(
2453                "training outcome parameter_patches contain duplicate leaf paths",
2454            );
2455        }
2456        if left.node_id == right.node_id
2457            && left.namespace == right.namespace
2458            && (right.path.starts_with(&left.path) || left.path.starts_with(&right.path))
2459        {
2460            return contract_error(
2461                "training outcome parameter_patches contain a conflicting parent/child path",
2462            );
2463        }
2464    }
2465    Ok(())
2466}
2467
2468fn parameter_patch_key(patch: &ParameterPatch) -> (&NodeId, ParameterNamespace, &[String]) {
2469    (&patch.node_id, patch.namespace, patch.path.as_slice())
2470}
2471
2472fn append_parameter_leaves(
2473    node_id: &NodeId,
2474    path: Vec<String>,
2475    value: &serde_json::Value,
2476    output: &mut Vec<ParameterPatch>,
2477) -> Result<()> {
2478    if let serde_json::Value::Object(object) = value {
2479        for (key, child) in object {
2480            let mut child_path = path.clone();
2481            child_path.push(key.clone());
2482            append_parameter_leaves(node_id, child_path, child, output)?;
2483        }
2484        return Ok(());
2485    }
2486    output.push(ParameterPatch {
2487        schema_version: PARAMETER_PATCH_SCHEMA_VERSION,
2488        node_id: node_id.clone(),
2489        namespace: ParameterNamespace::Operator,
2490        path,
2491        value: value.clone(),
2492    });
2493    Ok(())
2494}
2495
2496fn validate_materialized_patch(plan: &ExecutionPlan, patch: &ParameterPatch) -> Result<()> {
2497    patch.validate()?;
2498    if patch.namespace != ParameterNamespace::Operator {
2499        return contract_error("selected variant patches must use operator namespace");
2500    }
2501    let node = plan.node_plans.get(&patch.node_id).ok_or_else(|| {
2502        DagMlError::CampaignValidation(format!(
2503            "selected parameter patch references absent node `{}`",
2504            patch.node_id
2505        ))
2506    })?;
2507    let mut current = serde_json::Value::Object(node.params.clone().into_iter().collect());
2508    for segment in &patch.path {
2509        current = current
2510            .as_object()
2511            .and_then(|object| object.get(segment))
2512            .cloned()
2513            .ok_or_else(|| {
2514                DagMlError::CampaignValidation(format!(
2515                    "selected parameter patch path for `{}` is not materialized",
2516                    patch.node_id
2517                ))
2518            })?;
2519    }
2520    if current != patch.value {
2521        return contract_error("selected parameter patch value is not materialized in plan");
2522    }
2523    Ok(())
2524}
2525
2526fn predictor_closure(
2527    plan: &ExecutionPlan,
2528    roots: impl IntoIterator<Item = NodeId>,
2529) -> Result<BTreeSet<NodeId>> {
2530    let mut pending = roots.into_iter().collect::<Vec<_>>();
2531    let mut closure = BTreeSet::new();
2532    while let Some(node_id) = pending.pop() {
2533        if !closure.insert(node_id.clone()) {
2534            continue;
2535        }
2536        let node = plan.node_plans.get(&node_id).ok_or_else(|| {
2537            DagMlError::CampaignValidation(format!(
2538                "training outcome closure references absent node `{node_id}`"
2539            ))
2540        })?;
2541        pending.extend(node.input_nodes.iter().cloned());
2542    }
2543    Ok(closure)
2544}
2545
2546/// Per-node facts the replay derivation reads for one predictor-closure node.
2547struct NodeReplayFacts {
2548    supported_phases: BTreeSet<Phase>,
2549    /// Node carries fitted inference state that a later PREDICT/EXPLAIN must
2550    /// reload: it is `stateful` or emits artifacts (capabilities
2551    /// `Stateful || EmitsArtifacts`). This is deliberately NOT inferred from
2552    /// `artifact_policy`/`ReplayRequired` or from `fit_scope`: a stateless
2553    /// deterministic operator — e.g. a seeded augmentation, or a
2554    /// `replay_required` transform that simply recomputes at inference — carries
2555    /// no reloadable state, needs no retained artifact, and must not block
2556    /// forward replay.
2557    requires_retained_state: bool,
2558    /// A retained refit artifact for this node is present in the bundle.
2559    has_retained_artifact: bool,
2560}
2561
2562/// Per-edge facts for one `requires_oof` dependency wholly inside the closure.
2563struct OofEdgeReplayFacts {
2564    has_bundle_requirement: bool,
2565    has_cache_record: bool,
2566    has_portable_payload: bool,
2567}
2568
2569/// Everything the pure replay decision needs, extracted from the plan/bundle so
2570/// the decision itself is unit-testable in isolation without a full plan.
2571struct ClosureReplayFacts {
2572    nodes: Vec<NodeReplayFacts>,
2573    oof_edges: Vec<OofEdgeReplayFacts>,
2574}
2575
2576/// Pure replay decision over already-extracted closure facts.
2577///
2578/// Canonical order is `[REFIT, PREDICT, EXPLAIN]`. A completed refit never
2579/// re-advertises REFIT; it exposes forward inference only when *every* closure
2580/// node supports the phase and every state-retaining closure node has a retained
2581/// refit artifact. A skipped refit exposes REFIT only when every closure node
2582/// supports REFIT and every closure OOF dependency is backed by an exact bundle
2583/// requirement, a retained cache record and a portable payload. An empty result
2584/// is a valid, honest "no replay mode" answer.
2585fn derive_replayable_phases_from_facts(
2586    completed_refit: bool,
2587    facts: &ClosureReplayFacts,
2588) -> Vec<Phase> {
2589    let all_support = |phase: Phase| {
2590        facts
2591            .nodes
2592            .iter()
2593            .all(|node| node.supported_phases.contains(&phase))
2594    };
2595    let inference_state_present = facts
2596        .nodes
2597        .iter()
2598        .all(|node| !node.requires_retained_state || node.has_retained_artifact);
2599    let oof_self_contained = facts.oof_edges.iter().all(|edge| {
2600        edge.has_bundle_requirement && edge.has_cache_record && edge.has_portable_payload
2601    });
2602
2603    let mut phases = Vec::new();
2604    if completed_refit {
2605        if all_support(Phase::Predict) && inference_state_present {
2606            phases.push(Phase::Predict);
2607        }
2608        if all_support(Phase::Explain) && inference_state_present {
2609            phases.push(Phase::Explain);
2610        }
2611    } else if all_support(Phase::Refit) && oof_self_contained {
2612        phases.push(Phase::Refit);
2613    }
2614    phases
2615}
2616
2617/// Extract the minimal per-node and per-OOF-edge facts the replay decision reads
2618/// from the portable outcome state. Shared by both `derive_replayable_phases`
2619/// (full derivation) and `closure_predict_replayable` (package PREDICT gate).
2620/// Fallible: a closure node absent from `node_plans` is a contract error, never a
2621/// panic.
2622fn closure_replay_facts(
2623    plan: &ExecutionPlan,
2624    closure: &BTreeSet<NodeId>,
2625    execution_bundle: &ExecutionBundle,
2626    portable_prediction_caches: Option<&BundlePredictionCachePayloadSet>,
2627) -> Result<ClosureReplayFacts> {
2628    let artifact_nodes = execution_bundle
2629        .refit_artifacts
2630        .iter()
2631        .map(|record| record.node_id.clone())
2632        .collect::<BTreeSet<_>>();
2633    let requirement_keys = execution_bundle
2634        .prediction_requirements
2635        .iter()
2636        .map(|requirement| requirement.key())
2637        .collect::<BTreeSet<_>>();
2638    let cache_keys = execution_bundle
2639        .prediction_caches
2640        .iter()
2641        .map(|record| record.requirement_key.clone())
2642        .collect::<BTreeSet<_>>();
2643    let payload_keys = portable_prediction_caches
2644        .map(|set| {
2645            set.caches
2646                .iter()
2647                .map(|payload| payload.requirement_key.clone())
2648                .collect::<BTreeSet<_>>()
2649        })
2650        .unwrap_or_default();
2651
2652    let nodes = closure
2653        .iter()
2654        .map(|node_id| {
2655            let node_plan = plan.node_plans.get(node_id).ok_or_else(|| {
2656                DagMlError::CampaignValidation(format!(
2657                    "replay derivation references absent node `{node_id}`"
2658                ))
2659            })?;
2660            // A node carries fitted state that PREDICT/EXPLAIN must reload only
2661            // when it is `stateful` or emits artifacts. `artifact_policy` is not
2662            // used: a stateless `replay_required` operator (e.g. prospectr)
2663            // re-runs its deterministic transform at inference with no artifact.
2664            let requires_retained_state = node_plan
2665                .controller_capabilities
2666                .contains(&ControllerCapability::Stateful)
2667                || node_plan
2668                    .controller_capabilities
2669                    .contains(&ControllerCapability::EmitsArtifacts);
2670            Ok(NodeReplayFacts {
2671                supported_phases: node_plan.supported_phases.clone(),
2672                requires_retained_state,
2673                has_retained_artifact: artifact_nodes.contains(node_id),
2674            })
2675        })
2676        .collect::<Result<Vec<_>>>()?;
2677    let oof_edges = plan
2678        .graph_plan
2679        .graph
2680        .edges
2681        .iter()
2682        .filter(|edge| {
2683            edge.contract.requires_oof
2684                && closure.contains(&edge.source.node_id)
2685                && closure.contains(&edge.target.node_id)
2686        })
2687        .map(|edge| {
2688            let key = crate::bundle::bundle_prediction_requirement_key(
2689                &edge.source.node_id,
2690                &edge.source.port_name,
2691                &edge.target.node_id,
2692                &edge.target.port_name,
2693            );
2694            OofEdgeReplayFacts {
2695                has_bundle_requirement: requirement_keys.contains(&key),
2696                has_cache_record: cache_keys.contains(&key),
2697                has_portable_payload: payload_keys.contains(&key),
2698            }
2699        })
2700        .collect::<Vec<_>>();
2701
2702    Ok(ClosureReplayFacts { nodes, oof_edges })
2703}
2704
2705/// Deterministically derive the phases a training outcome can honestly replay.
2706///
2707/// This is the single shared helper used by both construction and standalone
2708/// validation. It reads only portable outcome state (the effective plan's
2709/// node/controller support, the predictor closure, the retained refit artifacts,
2710/// the OOF prediction requirements/cache records and the portable payloads), so
2711/// re-running it during validation reproduces the exact vector a producer must
2712/// have emitted and rejects any forged claim.
2713fn derive_replayable_phases(
2714    plan: &ExecutionPlan,
2715    closure: &BTreeSet<NodeId>,
2716    refit: &TrainingRefitOutcome,
2717    execution_bundle: &ExecutionBundle,
2718    portable_prediction_caches: Option<&BundlePredictionCachePayloadSet>,
2719) -> Result<Vec<Phase>> {
2720    let facts = closure_replay_facts(plan, closure, execution_bundle, portable_prediction_caches)?;
2721    Ok(derive_replayable_phases_from_facts(
2722        matches!(refit.status, TrainingRefitStatus::Completed),
2723        &facts,
2724    ))
2725}
2726
2727/// True when the full predictor `closure` can honestly replay PREDICT given the
2728/// artifacts retained in `execution_bundle`: every closure node supports PREDICT
2729/// and every state-retaining closure node has a retained refit artifact. A
2730/// [`PortablePredictorPackage`](crate::training::PortablePredictorPackage) is a
2731/// deployable predictor, so its construction requires this independently — it
2732/// must not infer portability from a merely non-empty claimed phase set. PREDICT
2733/// replay never consumes OOF payloads, so the OOF cache facts are irrelevant.
2734pub(crate) fn closure_predict_replayable(
2735    plan: &ExecutionPlan,
2736    closure: &BTreeSet<NodeId>,
2737    execution_bundle: &ExecutionBundle,
2738) -> Result<bool> {
2739    let facts = closure_replay_facts(plan, closure, execution_bundle, None)?;
2740    Ok(derive_replayable_phases_from_facts(true, &facts).contains(&Phase::Predict))
2741}
2742
2743fn expected_base_influence_kind(
2744    plan: &ExecutionPlan,
2745    node_id: &NodeId,
2746) -> Option<TrainingInfluenceKind> {
2747    let node_plan = &plan.node_plans[node_id];
2748    if matches!(
2749        node_plan.fit_scope,
2750        ControllerFitScope::Stateless | ControllerFitScope::InferenceOnly
2751    ) {
2752        return None;
2753    }
2754    let oof_consumer = plan
2755        .graph_plan
2756        .graph
2757        .edges
2758        .iter()
2759        .any(|edge| edge.contract.requires_oof && edge.target.node_id == *node_id);
2760    Some(
2761        if oof_consumer
2762            || node_plan
2763                .controller_capabilities
2764                .contains(&ControllerCapability::TrainsAggregation)
2765        {
2766            TrainingInfluenceKind::TrainedMetaAggregation
2767        } else if node_plan.kind == NodeKind::Model {
2768            TrainingInfluenceKind::ModelFit
2769        } else if node_plan.kind == NodeKind::Tuner {
2770            TrainingInfluenceKind::HpoSelection
2771        } else {
2772            TrainingInfluenceKind::TransformFit
2773        },
2774    )
2775}
2776
2777fn validate_influence_against_closure(
2778    influence: &TrainingInfluenceManifest,
2779    plan: &ExecutionPlan,
2780    closure: &BTreeSet<NodeId>,
2781) -> Result<()> {
2782    let mut actual_base = BTreeMap::<NodeId, BTreeSet<TrainingInfluenceKind>>::new();
2783    for entry in &influence.entries {
2784        let Some(node_id) = &entry.node_id else {
2785            continue;
2786        };
2787        if !closure.contains(node_id) {
2788            return contract_error("training influence node is outside predictor closure");
2789        }
2790        if !influence_kind_allowed_by_node_role_or_capability(plan, node_id, entry.kind) {
2791            return contract_error(
2792                "training influence kind is not allowed by node role or capability",
2793            );
2794        }
2795        if expected_base_influence_kind(plan, node_id) == Some(entry.kind) {
2796            actual_base
2797                .entry(node_id.clone())
2798                .or_default()
2799                .insert(entry.kind);
2800        }
2801    }
2802    let expected = closure
2803        .iter()
2804        .filter(|node_id| {
2805            plan.node_plans[*node_id]
2806                .supported_phases
2807                .contains(&Phase::FitCv)
2808                && expected_base_influence_kind(plan, node_id).is_some()
2809        })
2810        .cloned()
2811        .collect::<BTreeSet<_>>();
2812    if actual_base.keys().cloned().collect::<BTreeSet<_>>() != expected {
2813        return contract_error(
2814            "training influence fitting nodes do not exactly match predictor closure",
2815        );
2816    }
2817    for node_id in expected {
2818        if actual_base[&node_id]
2819            != BTreeSet::from([expected_base_influence_kind(plan, &node_id)
2820                .expect("expected fitting nodes have a base influence kind")])
2821        {
2822            return contract_error("training influence fitting kind does not match node role");
2823        }
2824    }
2825    Ok(())
2826}
2827
2828fn influence_kind_allowed_by_node_role_or_capability(
2829    plan: &ExecutionPlan,
2830    node_id: &NodeId,
2831    kind: TrainingInfluenceKind,
2832) -> bool {
2833    if expected_base_influence_kind(plan, node_id) == Some(kind) {
2834        return true;
2835    }
2836    let capabilities = &plan.node_plans[node_id].controller_capabilities;
2837    match kind {
2838        TrainingInfluenceKind::HpoSelection => {
2839            capabilities.contains(&ControllerCapability::PerformsInternalTuning)
2840        }
2841        TrainingInfluenceKind::EarlyStopping => {
2842            capabilities.contains(&ControllerCapability::UsesEarlyStopping)
2843        }
2844        TrainingInfluenceKind::WeightingResampling => {
2845            capabilities.contains(&ControllerCapability::UsesTrainingWeights)
2846        }
2847        TrainingInfluenceKind::TransformFit
2848        | TrainingInfluenceKind::ModelFit
2849        | TrainingInfluenceKind::TrainedMetaAggregation => false,
2850    }
2851}
2852
2853fn validate_lineage_coordinates(
2854    outcome: &TrainingOutcome,
2855    closure: &BTreeSet<NodeId>,
2856    coordinates: &BTreeMap<(Phase, Option<crate::ids::FoldId>, NodeId), &LineageRecord>,
2857) -> Result<()> {
2858    let fold_set = outcome.effective_plan.fold_set.as_ref().ok_or_else(|| {
2859        DagMlError::CampaignValidation(
2860            "training outcome FIT_CV lineage requires a fold_set".to_string(),
2861        )
2862    })?;
2863    let expected_fit = closure
2864        .iter()
2865        .filter(|node_id| {
2866            outcome.effective_plan.node_plans[*node_id]
2867                .supported_phases
2868                .contains(&Phase::FitCv)
2869        })
2870        .flat_map(|node_id| {
2871            fold_set
2872                .folds
2873                .iter()
2874                .map(move |fold| (Phase::FitCv, Some(fold.fold_id.clone()), node_id.clone()))
2875        })
2876        .collect::<BTreeSet<_>>();
2877    let actual_fit = coordinates
2878        .keys()
2879        .filter(|(phase, _, _)| *phase == Phase::FitCv)
2880        .cloned()
2881        .collect::<BTreeSet<_>>();
2882    if actual_fit != expected_fit {
2883        return contract_error(
2884            "training outcome FIT_CV lineage does not exactly cover closure folds",
2885        );
2886    }
2887    let expected_refit = if outcome.refit.requested {
2888        closure
2889            .iter()
2890            .filter(|node_id| {
2891                outcome.effective_plan.node_plans[*node_id]
2892                    .supported_phases
2893                    .contains(&Phase::Refit)
2894            })
2895            .map(|node_id| (Phase::Refit, None, node_id.clone()))
2896            .collect::<BTreeSet<_>>()
2897    } else {
2898        BTreeSet::new()
2899    };
2900    let actual_refit = coordinates
2901        .keys()
2902        .filter(|(phase, _, _)| *phase == Phase::Refit)
2903        .cloned()
2904        .collect::<BTreeSet<_>>();
2905    if actual_refit != expected_refit {
2906        return contract_error("training outcome REFIT lineage does not exactly cover closure");
2907    }
2908
2909    for ((phase, fold, node_id), record) in coordinates {
2910        if *phase == Phase::Select {
2911            continue;
2912        }
2913        let plan = &outcome.effective_plan.node_plans[node_id];
2914        let expected_inputs = plan
2915            .input_nodes
2916            .iter()
2917            .filter(|input| {
2918                outcome.effective_plan.node_plans[*input]
2919                    .supported_phases
2920                    .contains(phase)
2921            })
2922            .map(|input| {
2923                coordinates
2924                    .get(&(*phase, fold.clone(), input.clone()))
2925                    .map(|upstream| upstream.record_id.clone())
2926                    .ok_or_else(|| {
2927                        DagMlError::CampaignValidation(format!(
2928                            "training lineage is missing upstream `{input}`"
2929                        ))
2930                    })
2931            })
2932            .collect::<Result<Vec<LineageId>>>()?;
2933        let mut expected_inputs = expected_inputs;
2934        expected_inputs.sort();
2935        if record.input_lineage != expected_inputs {
2936            return contract_error(
2937                "training outcome lineage input_lineage does not exactly match plan",
2938            );
2939        }
2940        if *phase == Phase::FitCv && !record.artifact_refs.is_empty() {
2941            return contract_error("FIT_CV lineage must not retain refit artifacts");
2942        }
2943        if *phase == Phase::Refit {
2944            let mut expected_artifacts = outcome
2945                .execution_bundle
2946                .refit_artifacts
2947                .iter()
2948                .filter(|artifact| artifact.node_id == *node_id)
2949                .map(|artifact| artifact.artifact.clone())
2950                .collect::<Vec<_>>();
2951            expected_artifacts.sort_by(|left, right| left.id.cmp(&right.id));
2952            let mut actual_artifacts = record.artifact_refs.clone();
2953            actual_artifacts.sort_by(|left, right| left.id.cmp(&right.id));
2954            if actual_artifacts != expected_artifacts {
2955                return contract_error("REFIT lineage artifact_refs do not match execution bundle");
2956            }
2957        }
2958    }
2959    Ok(())
2960}
2961
2962fn tcv1_fingerprint<T: Serialize + ?Sized>(value: &T, label: &str) -> Result<String> {
2963    let json = serde_json::to_string(value)?;
2964    parse_typed_json(&json)
2965        .map_err(|error| {
2966            DagMlError::CampaignValidation(format!("{label} is not valid TCV1: {error}"))
2967        })?
2968        .fingerprint()
2969        .map_err(|error| {
2970            DagMlError::CampaignValidation(format!("{label} TCV1 fingerprint failed: {error}"))
2971        })
2972}
2973
2974fn tcv1_fingerprint_without<T: Serialize>(value: &T, field: &str, label: &str) -> Result<String> {
2975    let json = serde_json::to_string(value)?;
2976    parse_typed_json(&json)
2977        .map_err(|error| {
2978            DagMlError::CampaignValidation(format!("{label} is not valid TCV1: {error}"))
2979        })?
2980        .fingerprint_without(field)
2981        .map_err(|error| {
2982            DagMlError::CampaignValidation(format!("{label} TCV1 fingerprint failed: {error}"))
2983        })
2984}
2985
2986fn validate_sha256(label: &str, value: &str) -> Result<()> {
2987    if value.len() != 64
2988        || !value
2989            .bytes()
2990            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2991    {
2992        return contract_error(format!("{label} must be lowercase sha256"));
2993    }
2994    Ok(())
2995}
2996
2997fn validate_all_identity_relations(
2998    identities: &[TrainingDataIdentity],
2999    relation_fingerprint: &str,
3000) -> Result<()> {
3001    if identities
3002        .iter()
3003        .any(|identity| identity.relation_fingerprint != relation_fingerprint)
3004    {
3005        return contract_error(
3006            "training outcome data identities do not all bind the influence relation",
3007        );
3008    }
3009    Ok(())
3010}
3011
3012fn validate_sorted_unique_text(label: &str, values: &[String]) -> Result<()> {
3013    if values.iter().any(|value| value.trim().is_empty()) {
3014        return contract_error(format!("{label} contains an empty value"));
3015    }
3016    if values.windows(2).any(|pair| pair[0] >= pair[1]) {
3017        return contract_error(format!("{label} must be strictly sorted and unique"));
3018    }
3019    Ok(())
3020}
3021
3022fn contract_error<T>(message: impl Into<String>) -> Result<T> {
3023    Err(DagMlError::CampaignValidation(message.into()))
3024}
3025
3026#[cfg(test)]
3027mod replay_phase_tests {
3028    use super::{
3029        derive_replayable_phases_from_facts, ClosureReplayFacts, NodeReplayFacts,
3030        OofEdgeReplayFacts,
3031    };
3032    use crate::phase::Phase;
3033    use std::collections::BTreeSet;
3034
3035    fn node(
3036        supported: &[Phase],
3037        requires_retained_state: bool,
3038        has_retained_artifact: bool,
3039    ) -> NodeReplayFacts {
3040        NodeReplayFacts {
3041            supported_phases: supported.iter().copied().collect::<BTreeSet<_>>(),
3042            requires_retained_state,
3043            has_retained_artifact,
3044        }
3045    }
3046
3047    fn oof(
3048        has_bundle_requirement: bool,
3049        has_cache_record: bool,
3050        has_portable_payload: bool,
3051    ) -> OofEdgeReplayFacts {
3052        OofEdgeReplayFacts {
3053            has_bundle_requirement,
3054            has_cache_record,
3055            has_portable_payload,
3056        }
3057    }
3058
3059    // Completed refit whose full closure supports both forward phases and whose
3060    // state-retaining nodes (`Stateful || EmitsArtifacts`) all have a retained
3061    // artifact exposes PREDICT then EXPLAIN in canonical order and never
3062    // re-advertises REFIT.
3063    #[test]
3064    fn completed_refit_full_support_matrix_predict_then_explain() {
3065        let facts = ClosureReplayFacts {
3066            nodes: vec![
3067                node(
3068                    &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
3069                    true,
3070                    true,
3071                ),
3072                node(
3073                    &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
3074                    true,
3075                    true,
3076                ),
3077            ],
3078            oof_edges: vec![],
3079        };
3080        assert_eq!(
3081            derive_replayable_phases_from_facts(true, &facts),
3082            vec![Phase::Predict, Phase::Explain]
3083        );
3084    }
3085
3086    // The current completed-refit fixture: every closure node supports
3087    // FIT_CV/REFIT/PREDICT but not EXPLAIN, so only PREDICT is honest.
3088    #[test]
3089    fn completed_refit_predict_only_when_explain_unsupported() {
3090        let facts = ClosureReplayFacts {
3091            nodes: vec![
3092                node(&[Phase::FitCv, Phase::Refit, Phase::Predict], true, true),
3093                // A train-only augmentation node emits no artifact, so it does not
3094                // require retained inference state and must not block PREDICT.
3095                node(&[Phase::FitCv, Phase::Refit, Phase::Predict], false, false),
3096            ],
3097            oof_edges: vec![],
3098        };
3099        assert_eq!(
3100            derive_replayable_phases_from_facts(true, &facts),
3101            vec![Phase::Predict]
3102        );
3103    }
3104
3105    // A downstream node supporting PREDICT cannot rescue an upstream required
3106    // node that does not support it: the whole closure must support the phase.
3107    #[test]
3108    fn upstream_node_missing_phase_blocks_whole_closure() {
3109        let facts = ClosureReplayFacts {
3110            nodes: vec![
3111                // downstream predictor supports PREDICT and EXPLAIN
3112                node(
3113                    &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
3114                    true,
3115                    true,
3116                ),
3117                // upstream required transform supports neither
3118                node(&[Phase::FitCv, Phase::Refit], false, false),
3119            ],
3120            oof_edges: vec![],
3121        };
3122        assert_eq!(
3123            derive_replayable_phases_from_facts(true, &facts),
3124            Vec::<Phase>::new()
3125        );
3126    }
3127
3128    // A completed refit whose closure supports PREDICT but is missing the
3129    // retained artifact of a state-retaining node (here `requires_retained_state`)
3130    // has no honest replay mode: [] is the correct, preferable answer.
3131    #[test]
3132    fn completed_refit_missing_artifact_yields_empty() {
3133        let facts = ClosureReplayFacts {
3134            nodes: vec![node(
3135                &[Phase::FitCv, Phase::Refit, Phase::Predict],
3136                true,
3137                false,
3138            )],
3139            oof_edges: vec![],
3140        };
3141        assert_eq!(
3142            derive_replayable_phases_from_facts(true, &facts),
3143            Vec::<Phase>::new()
3144        );
3145    }
3146
3147    // No-refit outcome never advertises PREDICT/EXPLAIN even when supported, and
3148    // advertises REFIT only when every OOF dependency is fully self-contained
3149    // (exact bundle requirement + cache record + portable payload).
3150    #[test]
3151    fn no_refit_refit_requires_self_contained_oof_payload() {
3152        let supported = [Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain];
3153        let backed = ClosureReplayFacts {
3154            nodes: vec![node(&supported, true, false), node(&supported, true, false)],
3155            oof_edges: vec![oof(true, true, true)],
3156        };
3157        assert_eq!(
3158            derive_replayable_phases_from_facts(false, &backed),
3159            vec![Phase::Refit]
3160        );
3161
3162        // Missing portable payload -> not self-contained -> [].
3163        let missing_payload = ClosureReplayFacts {
3164            nodes: vec![node(&supported, true, false), node(&supported, true, false)],
3165            oof_edges: vec![oof(true, true, false)],
3166        };
3167        assert_eq!(
3168            derive_replayable_phases_from_facts(false, &missing_payload),
3169            Vec::<Phase>::new()
3170        );
3171
3172        // Missing cache record -> [].
3173        let missing_record = ClosureReplayFacts {
3174            nodes: vec![node(&supported, true, false)],
3175            oof_edges: vec![oof(true, false, true)],
3176        };
3177        assert_eq!(
3178            derive_replayable_phases_from_facts(false, &missing_record),
3179            Vec::<Phase>::new()
3180        );
3181    }
3182
3183    // A no-refit outcome with no OOF edges is vacuously self-contained: REFIT can
3184    // re-fit from data alone, so REFIT is honest when every node supports it.
3185    #[test]
3186    fn no_refit_without_oof_edges_is_vacuously_refit() {
3187        let facts = ClosureReplayFacts {
3188            nodes: vec![node(
3189                &[Phase::FitCv, Phase::Refit, Phase::Predict],
3190                false,
3191                false,
3192            )],
3193            oof_edges: vec![],
3194        };
3195        assert_eq!(
3196            derive_replayable_phases_from_facts(false, &facts),
3197            vec![Phase::Refit]
3198        );
3199    }
3200
3201    // A no-refit closure that does not fully support REFIT yields [].
3202    #[test]
3203    fn no_refit_without_refit_support_yields_empty() {
3204        let facts = ClosureReplayFacts {
3205            nodes: vec![
3206                node(&[Phase::FitCv, Phase::Refit], false, false),
3207                node(&[Phase::FitCv, Phase::Predict], false, false),
3208            ],
3209            oof_edges: vec![],
3210        };
3211        assert_eq!(
3212            derive_replayable_phases_from_facts(false, &facts),
3213            Vec::<Phase>::new()
3214        );
3215    }
3216
3217    // A stateless `replay_required` operator (e.g. prospectr): it is neither
3218    // `stateful` nor an artifact emitter, so `requires_retained_state` is false
3219    // and it stays PREDICT-replayable with no retained artifact — the operation
3220    // simply replays its deterministic transform at inference time.
3221    #[test]
3222    fn stateless_replay_required_operator_without_artifact_stays_predict_replayable() {
3223        let facts = ClosureReplayFacts {
3224            nodes: vec![node(
3225                &[Phase::FitCv, Phase::Refit, Phase::Predict],
3226                false,
3227                false,
3228            )],
3229            oof_edges: vec![],
3230        };
3231        assert_eq!(
3232            derive_replayable_phases_from_facts(true, &facts),
3233            vec![Phase::Predict]
3234        );
3235    }
3236
3237    // A `stateful` (or artifact-emitting) node that has no retained artifact
3238    // carries no reloadable inference state, so PREDICT must not be advertised.
3239    #[test]
3240    fn stateful_non_emitter_without_artifact_cannot_advertise_predict() {
3241        let facts = ClosureReplayFacts {
3242            nodes: vec![node(
3243                &[Phase::FitCv, Phase::Refit, Phase::Predict],
3244                true,
3245                false,
3246            )],
3247            oof_edges: vec![],
3248        };
3249        assert_eq!(
3250            derive_replayable_phases_from_facts(true, &facts),
3251            Vec::<Phase>::new()
3252        );
3253    }
3254}
3255
3256#[cfg(test)]
3257mod tests {
3258    use super::*;
3259
3260    const REFIT_FIXTURE: &str =
3261        include_str!("../../../examples/fixtures/estimator/training_outcome_refit.v1.json");
3262    const NO_REFIT_FIXTURE: &str =
3263        include_str!("../../../examples/fixtures/estimator/training_outcome_no_refit.v1.json");
3264
3265    #[test]
3266    fn cv_ensemble_partition_truth_table_retains_validation_only() {
3267        for (partition, expected) in [
3268            (PredictionPartition::Validation, true),
3269            (PredictionPartition::Train, false),
3270            (PredictionPartition::Test, false),
3271            (PredictionPartition::Final, false),
3272        ] {
3273            assert_eq!(
3274                is_cv_ensemble_partition(&partition),
3275                expected,
3276                "unexpected CvEnsemble retention decision for {partition:?}"
3277            );
3278        }
3279    }
3280
3281    #[test]
3282    fn independent_w0_training_outcomes_parse_and_round_trip_fingerprint() {
3283        for fixture in [REFIT_FIXTURE, NO_REFIT_FIXTURE] {
3284            let outcome = TrainingOutcome::from_json(fixture).expect("valid W0 outcome");
3285            assert_eq!(
3286                outcome.compute_fingerprint().unwrap(),
3287                outcome.outcome_fingerprint
3288            );
3289            let serialized = serde_json::to_string(&outcome).unwrap();
3290            let reparsed = TrainingOutcome::from_json(&serialized).unwrap();
3291            assert_eq!(reparsed, outcome);
3292        }
3293    }
3294
3295    #[test]
3296    fn strict_parser_rejects_tamper_and_unknown_field() {
3297        let mut tampered: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
3298        tampered["warnings"] = serde_json::json!(["tampered"]);
3299        assert!(TrainingOutcome::from_json(&serde_json::to_string(&tampered).unwrap()).is_err());
3300
3301        let mut unknown: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
3302        unknown["unknown_field"] = serde_json::json!(true);
3303        assert!(TrainingOutcome::from_json(&serde_json::to_string(&unknown).unwrap()).is_err());
3304    }
3305
3306    #[test]
3307    fn outcome_rejects_nested_runtime_handle_keys_defense_in_depth() {
3308        let mut outcome = TrainingOutcome::from_json(REFIT_FIXTURE).unwrap();
3309        outcome.diagnostics.insert(
3310            "nested".to_string(),
3311            serde_json::json!({"runtime_handle": "process-local"}),
3312        );
3313        outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
3314        let error = outcome.validate().unwrap_err();
3315        assert!(error.to_string().contains("runtime handles"), "{error}");
3316    }
3317
3318    #[test]
3319    fn strict_parser_rejects_future_version_even_when_resigned() {
3320        let mut future: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
3321        future["schema_version"] = serde_json::json!(2);
3322        let mut provisional: TrainingOutcome = serde_json::from_value(future.clone()).unwrap();
3323        provisional.outcome_fingerprint = provisional.compute_fingerprint().unwrap();
3324        future["outcome_fingerprint"] =
3325            serde_json::Value::String(provisional.outcome_fingerprint.clone());
3326        assert!(TrainingOutcome::from_json(&serde_json::to_string(&future).unwrap()).is_err());
3327    }
3328
3329    #[test]
3330    fn select_lineage_is_portable_but_foreign_phase_is_rejected() {
3331        let mut outcome = TrainingOutcome::from_json(REFIT_FIXTURE).unwrap();
3332        let mut select = outcome.lineage[0].clone();
3333        select.record_id = LineageId::new("lineage:select:audit").unwrap();
3334        select.phase = Phase::Select;
3335        select.fold_id = None;
3336        select.input_lineage.clear();
3337        select.artifact_refs.clear();
3338        outcome.lineage.push(select.clone());
3339        outcome
3340            .lineage
3341            .sort_by(|left, right| left.record_id.cmp(&right.record_id));
3342        outcome.outcome_fingerprint = zero_fingerprint();
3343        outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
3344        outcome.validate().unwrap();
3345
3346        let added = outcome
3347            .lineage
3348            .iter_mut()
3349            .find(|record| record.record_id.as_str() == "lineage:select:audit")
3350            .unwrap();
3351        added.phase = Phase::Predict;
3352        added.record_id = LineageId::new("lineage:predict:foreign").unwrap();
3353        outcome
3354            .lineage
3355            .sort_by(|left, right| left.record_id.cmp(&right.record_id));
3356        outcome.outcome_fingerprint = zero_fingerprint();
3357        outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
3358        assert!(outcome.validate().is_err());
3359    }
3360
3361    #[test]
3362    fn every_data_identity_must_bind_the_global_relation() {
3363        let relation = "a".repeat(64);
3364        let identity = |key: &str, relation_fingerprint: String| TrainingDataIdentity {
3365            requirement_key: key.to_string(),
3366            schema_fingerprint: "b".repeat(64),
3367            plan_fingerprint: "c".repeat(64),
3368            relation_fingerprint,
3369            data_content_fingerprint: "d".repeat(64),
3370            target_content_fingerprint: "e".repeat(64),
3371            identity_fingerprint: "f".repeat(64),
3372        };
3373        let identities = vec![
3374            identity("model:a.x", relation.clone()),
3375            identity("model:b.x", "9".repeat(64)),
3376        ];
3377        assert!(validate_all_identity_relations(&identities, &relation).is_err());
3378        let identities = vec![
3379            identity("model:a.x", relation.clone()),
3380            identity("model:b.x", relation.clone()),
3381        ];
3382        validate_all_identity_relations(&identities, &relation).unwrap();
3383    }
3384
3385    #[test]
3386    fn auxiliary_report_levels_do_not_override_selection_target_level() {
3387        let report = |producer: &str, level| crate::metrics::RegressionMetricReport {
3388            prediction_id: Some(format!("prediction:{producer}")),
3389            producer_node: NodeId::new(producer).unwrap(),
3390            producer_port: None,
3391            variant_id: Some(VariantId::new("variant:test").unwrap()),
3392            variant_label: None,
3393            partition: PredictionPartition::Validation,
3394            fold_id: Some(crate::ids::FoldId::new("avg").unwrap()),
3395            level,
3396            row_count: 2,
3397            target_width: 1,
3398            target_names: vec!["y".to_string()],
3399            metrics: BTreeMap::from([("rmse".to_string(), 0.1)]),
3400        };
3401        let reports = vec![
3402            report("model:target", PredictionLevel::Sample),
3403            report("model:target", PredictionLevel::Group),
3404            report("model:aux", PredictionLevel::Group),
3405        ];
3406        validate_selection_report_levels(
3407            &reports,
3408            &NodeId::new("model:target").unwrap(),
3409            &None,
3410            PredictionLevel::Sample,
3411        )
3412        .unwrap();
3413        assert!(validate_selection_report_levels(
3414            &reports,
3415            &NodeId::new("model:target").unwrap(),
3416            &None,
3417            PredictionLevel::Target,
3418        )
3419        .is_err());
3420    }
3421}