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