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