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