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