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