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