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 request.options.artifacts.fitted_artifacts != FittedArtifactMode::AllowHostSidecar {
1403 return Err(DagMlError::RuntimeValidation(
1404 "native training V1 cannot prove portable fitted payloads and currently requires artifacts.fitted_artifacts=allow_host_sidecar"
1405 .to_string(),
1406 ));
1407 }
1408 if request.options.artifacts.prediction_caches == PredictionCacheRetention::Discard
1409 && request
1410 .graph
1411 .edges
1412 .iter()
1413 .any(|edge| edge.contract.requires_oof)
1414 {
1415 return Err(DagMlError::RuntimeValidation(
1416 "native training V1 requires retained prediction caches for a stacking/requires_oof graph"
1417 .to_string(),
1418 ));
1419 }
1420 Ok(())
1421}
1422
1423fn materialize_request_parameter_patches(
1424 mut plan: ExecutionPlan,
1425 request: &TrainingRequest,
1426) -> Result<ExecutionPlan> {
1427 for patch in &request.parameter_patches {
1428 match patch.namespace {
1429 ParameterNamespace::Operator => {}
1430 ParameterNamespace::Structural => {
1431 return Err(DagMlError::RuntimeValidation(
1432 "native training requires recompilation for structural parameter patches; D6 runtime accepts only operator value patches"
1433 .to_string(),
1434 ));
1435 }
1436 ParameterNamespace::Fit | ParameterNamespace::Control => {
1437 return Err(DagMlError::RuntimeValidation(format!(
1438 "native training does not expose {:?} parameter patches to controllers yet; refusing to ignore them",
1439 patch.namespace
1440 )));
1441 }
1442 }
1443 let node_plan = plan.node_plans.get_mut(&patch.node_id).ok_or_else(|| {
1444 DagMlError::RuntimeValidation(format!(
1445 "parameter patch references absent node `{}`",
1446 patch.node_id
1447 ))
1448 })?;
1449 deep_set_plan_param(
1450 &mut node_plan.params,
1451 &patch.path,
1452 patch.value.clone(),
1453 &patch.node_id,
1454 )?;
1455 node_plan.params_fingerprint = stable_json_fingerprint(&node_plan.params)?;
1456 }
1457 plan.validate()?;
1458 Ok(plan)
1459}
1460
1461fn deep_set_plan_param(
1462 root: &mut BTreeMap<String, serde_json::Value>,
1463 path: &[String],
1464 value: serde_json::Value,
1465 node_id: &NodeId,
1466) -> Result<()> {
1467 if path.is_empty() {
1468 return contract_error("parameter patch path cannot be empty");
1469 }
1470 if path.len() == 1 {
1471 root.insert(path[0].clone(), value);
1472 return Ok(());
1473 }
1474 let first = root.get_mut(&path[0]).ok_or_else(|| {
1475 DagMlError::RuntimeValidation(format!(
1476 "parameter patch for `{node_id}` is missing intermediate path `{}`",
1477 path[0]
1478 ))
1479 })?;
1480 let mut cursor = first;
1481 for segment in &path[1..path.len() - 1] {
1482 let object = cursor.as_object_mut().ok_or_else(|| {
1483 DagMlError::RuntimeValidation(format!(
1484 "parameter patch for `{node_id}` crosses a scalar or array at `{segment}`"
1485 ))
1486 })?;
1487 cursor = object.get_mut(segment).ok_or_else(|| {
1488 DagMlError::RuntimeValidation(format!(
1489 "parameter patch for `{node_id}` is missing intermediate path `{segment}`"
1490 ))
1491 })?;
1492 }
1493 let object = cursor.as_object_mut().ok_or_else(|| {
1494 DagMlError::RuntimeValidation(format!(
1495 "parameter patch for `{node_id}` crosses a scalar or array before final key"
1496 ))
1497 })?;
1498 object.insert(path[path.len() - 1].clone(), value);
1499 Ok(())
1500}
1501
1502fn validate_provider_attestations(
1503 projection: &TrainingContractProjection,
1504 request: &TrainingRequest,
1505 provider: &dyn RuntimeDataProvider,
1506 relations: &crate::relation::SampleRelationSet,
1507) -> Result<()> {
1508 relations.validate()?;
1509 let relation_fingerprint = relations.fingerprint()?;
1510 let identities = request
1511 .data_identities
1512 .iter()
1513 .map(|identity| (identity.requirement_key.as_str(), identity))
1514 .collect::<BTreeMap<_, _>>();
1515 for node_plan in projection.plan.node_plans.values() {
1516 for binding in &node_plan.data_bindings {
1517 let key = data_binding_requirement_key(&binding.node_id, &binding.input_name);
1518 let expected = identities.get(key.as_str()).ok_or_else(|| {
1519 DagMlError::RuntimeValidation(format!(
1520 "native training request has no data identity for `{key}`"
1521 ))
1522 })?;
1523 let actual = provider.training_data_identity(binding)?.ok_or_else(|| {
1524 DagMlError::RuntimeValidation(format!(
1525 "runtime data provider did not attest feature/target content for `{key}`"
1526 ))
1527 })?;
1528 actual.validate()?;
1529 if &actual != *expected {
1530 return Err(DagMlError::RuntimeValidation(format!(
1531 "runtime data provider identity for `{key}` does not match signed training request"
1532 )));
1533 }
1534 let provider_relations = provider.coordinator_relations(binding)?;
1535 if binding.require_relations && provider_relations.is_none() {
1536 return Err(DagMlError::RuntimeValidation(format!(
1537 "runtime data provider omitted required relations for `{key}`"
1538 )));
1539 }
1540 if let Some(provider_relations) = provider_relations {
1541 provider_relations.validate()?;
1542 if provider_relations.fingerprint()? != relation_fingerprint
1543 || actual.relation_fingerprint != relation_fingerprint
1544 {
1545 return Err(DagMlError::RuntimeValidation(format!(
1546 "runtime data provider relations for `{key}` differ from training influence relations"
1547 )));
1548 }
1549 }
1550 }
1551 }
1552 Ok(())
1553}
1554
1555fn parse_selection_metric(request: &TrainingRequest) -> Result<RegressionMetricKind> {
1556 let metric = regression_metric_by_name(&request.options.selection.metric.name)?;
1557 if request.options.selection.metric.objective != metric.objective() {
1558 return Err(DagMlError::RuntimeValidation(format!(
1559 "selection metric `{}` has objective {:?}, expected {:?}",
1560 metric.name(),
1561 request.options.selection.metric.objective,
1562 metric.objective()
1563 )));
1564 }
1565 Ok(metric)
1566}
1567
1568fn regression_metric_by_name(name: &str) -> Result<RegressionMetricKind> {
1569 RegressionMetricKind::from_name(name).ok_or_else(|| {
1570 DagMlError::RuntimeValidation(format!(
1571 "native training does not support selection metric `{name}`"
1572 ))
1573 })
1574}
1575
1576fn validate_selection_prediction_kind(
1577 metric: RegressionMetricKind,
1578 prediction_kind: PredictionKind,
1579) -> Result<()> {
1580 RegressionMetricKind::resolve_for_prediction_kind(
1581 metric.name(),
1582 metric.objective(),
1583 prediction_kind,
1584 )
1585 .map(|_| ())
1586}
1587
1588fn effective_selection_metric_level(request: &TrainingRequest) -> Result<PredictionLevel> {
1589 let campaign_level = request.campaign.aggregation_policy.selection_metric_level;
1590 if request
1591 .options
1592 .selection
1593 .required_metric_level
1594 .is_some_and(|level| level != campaign_level)
1595 {
1596 return Err(DagMlError::RuntimeValidation(
1597 "selection required_metric_level differs from campaign selection_metric_level"
1598 .to_string(),
1599 ));
1600 }
1601 if request.options.selection.evaluation_scope != Some(EvaluationScope::Oof) {
1602 return Err(DagMlError::RuntimeValidation(
1603 "native training V1 requires selection.evaluation_scope=oof".to_string(),
1604 ));
1605 }
1606 if request.options.selection.reduction_id.is_some() {
1607 return Err(DagMlError::RuntimeValidation(
1608 "native training V1 does not execute selection reduction_id".to_string(),
1609 ));
1610 }
1611 if request.options.selection.stacking_fit_contract.is_some() {
1612 return Err(DagMlError::RuntimeValidation(
1613 "native training V1 does not execute selection stacking_fit_contract".to_string(),
1614 ));
1615 }
1616 if !request.options.selection.require_finite {
1617 return Err(DagMlError::RuntimeValidation(
1618 "native training V1 requires selection.require_finite=true".to_string(),
1619 ));
1620 }
1621 if request.options.refit_strategy == Some(RefitStrategy::RefitEnsemble) {
1622 return Err(DagMlError::RuntimeValidation(
1623 "native training V1 does not implement refit_ensemble".to_string(),
1624 ));
1625 }
1626 match (
1627 request.options.refit,
1628 request.options.selection.refit_slot_plan.as_ref(),
1629 ) {
1630 (false, Some(_)) => Err(DagMlError::RuntimeValidation(
1631 "no-refit native training forbids selection.refit_slot_plan".to_string(),
1632 )),
1633 (true, Some(slot))
1634 if slot.strategy != RefitStrategy::RefitOne
1635 || slot.member_count != 1
1636 || slot.selection_level != campaign_level
1637 || slot.selection_metric != request.options.selection.metric
1638 || slot.reduction_id.is_some() =>
1639 {
1640 Err(DagMlError::RuntimeValidation(
1641 "selection.refit_slot_plan is not the exact native refit_one slot".to_string(),
1642 ))
1643 }
1644 _ => Ok(campaign_level),
1645 }
1646}
1647
1648fn validate_selected_rerun_reports(
1649 retained: &[crate::metrics::RegressionMetricReport],
1650 rerun: &[crate::metrics::RegressionMetricReport],
1651 selected_variant_id: &VariantId,
1652) -> Result<()> {
1653 let mut retained = retained
1654 .iter()
1655 .filter(|report| report.variant_id.as_ref() == Some(selected_variant_id))
1656 .cloned()
1657 .collect::<Vec<_>>();
1658 let mut rerun = rerun
1659 .iter()
1660 .filter(|report| report.partition == PredictionPartition::Validation)
1661 .cloned()
1662 .map(|mut report| {
1663 report.variant_id = Some(selected_variant_id.clone());
1664 report.variant_label = None;
1665 report
1666 })
1667 .collect::<Vec<_>>();
1668 let terminal_oof_only = retained.iter().all(|report| {
1675 report.partition == PredictionPartition::Validation
1676 && report
1677 .fold_id
1678 .as_ref()
1679 .is_some_and(|fold| fold.as_str() == "avg")
1680 && report.level == PredictionLevel::Sample
1681 });
1682 if terminal_oof_only {
1683 rerun.retain(|actual| {
1684 retained.iter().any(|expected| {
1685 expected.producer_node == actual.producer_node
1686 && expected.producer_port == actual.producer_port
1687 && expected.fold_id == actual.fold_id
1688 && expected.prediction_id == actual.prediction_id
1689 && expected.level == actual.level
1690 })
1691 });
1692 }
1693 let sort = |reports: &mut Vec<crate::metrics::RegressionMetricReport>| {
1694 reports.sort_by(|left, right| {
1695 (
1696 &left.producer_node,
1697 &left.producer_port,
1698 &left.fold_id,
1699 &left.prediction_id,
1700 &left.level,
1701 )
1702 .cmp(&(
1703 &right.producer_node,
1704 &right.producer_port,
1705 &right.fold_id,
1706 &right.prediction_id,
1707 &right.level,
1708 ))
1709 });
1710 };
1711 sort(&mut retained);
1712 sort(&mut rerun);
1713 if retained.is_empty()
1714 || retained.len() != rerun.len()
1715 || retained
1716 .iter()
1717 .zip(&rerun)
1718 .any(|(left, right)| !reports_match_rerun_tolerance(left, right))
1719 {
1720 return Err(DagMlError::RuntimeValidation(
1721 "selected variant FIT_CV rerun diverged from the reports that justified SELECT"
1722 .to_string(),
1723 ));
1724 }
1725 Ok(())
1726}
1727
1728fn reports_match_rerun_tolerance(
1732 left: &crate::metrics::RegressionMetricReport,
1733 right: &crate::metrics::RegressionMetricReport,
1734) -> bool {
1735 left.prediction_id == right.prediction_id
1736 && left.producer_node == right.producer_node
1737 && left.producer_port == right.producer_port
1738 && left.variant_id == right.variant_id
1739 && left.variant_label == right.variant_label
1740 && left.partition == right.partition
1741 && left.fold_id == right.fold_id
1742 && left.level == right.level
1743 && left.row_count == right.row_count
1744 && left.target_width == right.target_width
1745 && left.target_names == right.target_names
1746 && left.metrics.len() == right.metrics.len()
1747 && left.metrics.iter().all(|(name, value)| {
1748 right
1749 .metrics
1750 .get(name)
1751 .is_some_and(|other| (value - other).abs() <= 1.0e-12)
1752 })
1753}
1754
1755fn validate_selection_report_levels(
1756 reports: &[crate::metrics::RegressionMetricReport],
1757 producer: &NodeId,
1758 producer_port: &Option<String>,
1759 expected: PredictionLevel,
1760) -> Result<()> {
1761 let target_reports = reports
1762 .iter()
1763 .filter(|report| {
1764 &report.producer_node == producer
1765 && &report.producer_port == producer_port
1766 && report.level == expected
1767 })
1768 .collect::<Vec<_>>();
1769 if target_reports.is_empty() {
1770 return Err(DagMlError::RuntimeValidation(format!(
1771 "native SELECT target `{producer}` port {producer_port:?} has no reports at required metric level {expected:?}"
1772 )));
1773 }
1774 Ok(())
1775}
1776
1777fn bind_selection_decision(
1778 decision: &mut SelectionDecision,
1779 request: &TrainingRequest,
1780 metric_level: PredictionLevel,
1781) -> Result<()> {
1782 decision.policy_id = request.options.selection.id.clone();
1783 decision.metric_level = Some(metric_level);
1784 decision.evaluation_scope = Some(EvaluationScope::Oof);
1785 decision.refit_slot_plan = request.options.selection.refit_slot_plan.clone();
1786 decision.reduction_id = None;
1787 decision.validate()
1788}
1789
1790fn materialize_selected_variant(
1791 mut plan: ExecutionPlan,
1792 selected_variant_id: &VariantId,
1793) -> Result<ExecutionPlan> {
1794 let selected = plan
1795 .variants
1796 .iter()
1797 .find(|variant| &variant.variant_id == selected_variant_id)
1798 .cloned()
1799 .ok_or_else(|| {
1800 DagMlError::RuntimeValidation(format!(
1801 "selected variant `{selected_variant_id}` is absent from plan"
1802 ))
1803 })?;
1804 let variant = VariantExecutionSpec::from_plan(&selected);
1805 variant.validate()?;
1806 for (node_id, node_plan) in &mut plan.node_plans {
1807 node_plan.params = variant.effective_params_for_node(node_id, &node_plan.params)?;
1808 node_plan.params_fingerprint = stable_json_fingerprint(&node_plan.params)?;
1809 }
1810 plan.validate()?;
1811 Ok(plan)
1812}
1813
1814fn is_cv_ensemble_partition(partition: &PredictionPartition) -> bool {
1815 match partition {
1816 PredictionPartition::Validation => true,
1817 PredictionPartition::Train | PredictionPartition::Test | PredictionPartition::Final => {
1818 false
1819 }
1820 }
1821}
1822
1823fn producer_port_matches_graph_output(
1824 plan: &ExecutionPlan,
1825 node_id: &NodeId,
1826 port_name: &str,
1827 producer_port: &Option<String>,
1828) -> bool {
1829 if let Some(producer_port) = producer_port {
1830 return producer_port == port_name;
1831 }
1832 let Some(node) = plan
1833 .graph_plan
1834 .graph
1835 .nodes
1836 .iter()
1837 .find(|node| &node.id == node_id)
1838 else {
1839 return false;
1840 };
1841 let prediction_ports = node
1842 .ports
1843 .outputs
1844 .iter()
1845 .filter(|port| port.kind == PortKind::Prediction)
1846 .collect::<Vec<_>>();
1847 prediction_ports.len() == 1 && prediction_ports[0].name == port_name
1848}
1849
1850fn bind_training_outputs(
1851 outputs: &[ResolvedTrainingOutput],
1852 request: &TrainingRequest,
1853 plan: &ExecutionPlan,
1854 fit_cv_results: &[NodeResult],
1855 refit_results: &[NodeResult],
1856 ctx: &RunContext,
1857) -> Result<Vec<BoundTrainingOutput>> {
1858 let source = if request.options.refit {
1859 refit_results
1860 } else {
1861 fit_cv_results
1862 };
1863 let aggregation_fingerprint = tcv1_fingerprint(
1864 &plan.campaign.aggregation_policy,
1865 "training output aggregation policy",
1866 )?;
1867 let mut bound = Vec::with_capacity(outputs.len());
1868 for output in outputs {
1869 let mut binding = OutputBinding {
1870 schema_version: OUTPUT_BINDING_SCHEMA_VERSION,
1871 binding_id: output.output_id.clone(),
1872 node_id: output.node_id.clone(),
1873 port_name: output.port_name.clone(),
1874 prediction_level: output.prediction_level,
1875 unit_level: output.unit_level,
1876 prediction_kind: output.prediction_kind,
1877 prediction_source: if request.options.refit {
1878 PredictionSource::FinalRefit
1879 } else {
1880 PredictionSource::CvEnsemble
1881 },
1882 refit_strategy: request.options.refit_strategy,
1883 aggregation_fingerprint: aggregation_fingerprint.clone(),
1884 target_names: output.target_names.clone(),
1885 target_units: output.target_units.clone(),
1886 class_labels: output.class_labels.clone(),
1887 output_order: output.output_order,
1888 target_space: output.target_space.clone(),
1889 binding_fingerprint: zero_fingerprint(),
1890 };
1891 binding.binding_fingerprint = binding.compute_fingerprint()?;
1892
1893 let node_results = source
1894 .iter()
1895 .filter(|result| result.node_id == output.node_id)
1896 .collect::<Vec<_>>();
1897 let mut predictions = Vec::new();
1898 let mut observation_predictions = Vec::new();
1899 let mut aggregated_predictions = Vec::new();
1900 match output.prediction_level {
1901 PredictionLevel::Observation => {
1902 for result in node_results {
1903 observation_predictions.extend(
1904 result
1905 .observation_predictions
1906 .iter()
1907 .filter(|block| {
1908 producer_port_matches_graph_output(
1909 plan,
1910 &output.node_id,
1911 &output.port_name,
1912 &block.producer_port,
1913 ) && (request.options.refit
1914 || is_cv_ensemble_partition(&block.partition))
1915 })
1916 .cloned(),
1917 );
1918 }
1919 }
1920 PredictionLevel::Sample => {
1921 for result in node_results {
1922 predictions.extend(
1923 result
1924 .predictions
1925 .iter()
1926 .filter(|block| {
1927 producer_port_matches_graph_output(
1928 plan,
1929 &output.node_id,
1930 &output.port_name,
1931 &block.producer_port,
1932 ) && (request.options.refit
1933 || is_cv_ensemble_partition(&block.partition))
1934 })
1935 .cloned(),
1936 );
1937 aggregated_predictions.extend(
1938 result
1939 .aggregated_predictions
1940 .iter()
1941 .filter(|block| {
1942 producer_port_matches_graph_output(
1943 plan,
1944 &output.node_id,
1945 &output.port_name,
1946 &block.producer_port,
1947 ) && block.level == PredictionLevel::Sample
1948 && (request.options.refit
1949 || is_cv_ensemble_partition(&block.partition))
1950 })
1951 .cloned(),
1952 );
1953 }
1954 if !request.options.refit {
1955 aggregated_predictions.extend(
1956 ctx.oof_average_blocks
1957 .iter()
1958 .filter(|average| {
1959 average.predictions.producer_node == output.node_id
1960 && producer_port_matches_graph_output(
1961 plan,
1962 &output.node_id,
1963 &output.port_name,
1964 &average.predictions.producer_port,
1965 )
1966 && is_cv_ensemble_partition(&average.predictions.partition)
1967 })
1968 .map(|average| average.predictions.clone()),
1969 );
1970 }
1971 }
1972 PredictionLevel::Target | PredictionLevel::Group => {
1973 for result in node_results {
1974 aggregated_predictions.extend(
1975 result
1976 .aggregated_predictions
1977 .iter()
1978 .filter(|block| {
1979 producer_port_matches_graph_output(
1980 plan,
1981 &output.node_id,
1982 &output.port_name,
1983 &block.producer_port,
1984 ) && block.level == output.prediction_level
1985 && (request.options.refit
1986 || is_cv_ensemble_partition(&block.partition))
1987 })
1988 .cloned(),
1989 );
1990 }
1991 }
1992 }
1993 predictions.sort_by(|left, right| {
1994 (
1995 &left.partition,
1996 &left.fold_id,
1997 &left.prediction_id,
1998 &left.sample_ids,
1999 )
2000 .cmp(&(
2001 &right.partition,
2002 &right.fold_id,
2003 &right.prediction_id,
2004 &right.sample_ids,
2005 ))
2006 });
2007 observation_predictions.sort_by(|left, right| {
2008 (
2009 &left.partition,
2010 &left.fold_id,
2011 &left.prediction_id,
2012 &left.observation_ids,
2013 )
2014 .cmp(&(
2015 &right.partition,
2016 &right.fold_id,
2017 &right.prediction_id,
2018 &right.observation_ids,
2019 ))
2020 });
2021 aggregated_predictions.sort_by(|left, right| {
2022 (
2023 &left.partition,
2024 &left.fold_id,
2025 &left.prediction_id,
2026 &left.unit_ids,
2027 )
2028 .cmp(&(
2029 &right.partition,
2030 &right.fold_id,
2031 &right.prediction_id,
2032 &right.unit_ids,
2033 ))
2034 });
2035 aggregated_predictions.dedup();
2036 let output = BoundTrainingOutput {
2037 schema_version: Some(BOUND_TRAINING_OUTPUT_SCHEMA_VERSION),
2038 binding,
2039 predictions,
2040 observation_predictions,
2041 aggregated_predictions,
2042 };
2043 output.validate(plan)?;
2044 bound.push(output);
2045 }
2046 Ok(bound)
2047}
2048
2049pub fn build_oof_prediction_requirements(
2052 plan: &ExecutionPlan,
2053 blocks: &[PredictionBlock],
2054 aggregated_blocks: &[AggregatedPredictionBlock],
2055) -> Result<Vec<BundlePredictionRequirement>> {
2056 let mut requirements = Vec::new();
2057 for edge in plan
2058 .graph_plan
2059 .graph
2060 .edges
2061 .iter()
2062 .filter(|edge| edge.contract.requires_oof)
2063 {
2064 let source_plan = plan.node_plans.get(&edge.source.node_id).ok_or_else(|| {
2065 DagMlError::RuntimeValidation(format!(
2066 "OOF edge source `{}` has no node plan",
2067 edge.source.node_id
2068 ))
2069 })?;
2070 let prediction_level = source_plan
2071 .shape_plan
2072 .as_ref()
2073 .map(|shape| shape.aggregation_policy.aggregation_level)
2074 .unwrap_or(PredictionLevel::Sample);
2075 let mut fold_ids = BTreeSet::<FoldId>::new();
2076 let mut sample_ids = BTreeSet::<SampleId>::new();
2077 let mut unit_ids = BTreeSet::<PredictionUnitId>::new();
2078 let mut width = None;
2079 let mut target_names: Option<Vec<String>> = None;
2080
2081 match prediction_level {
2082 PredictionLevel::Sample => {
2083 let selected = blocks
2084 .iter()
2085 .filter(|block| {
2086 block.producer_node == edge.source.node_id
2087 && producer_port_matches_graph_output(
2088 plan,
2089 &edge.source.node_id,
2090 &edge.source.port_name,
2091 &block.producer_port,
2092 )
2093 && block.partition == PredictionPartition::Validation
2094 })
2095 .collect::<Vec<_>>();
2096 if selected.is_empty() {
2097 return Err(DagMlError::RuntimeValidation(format!(
2098 "OOF requirement `{}` -> `{}` has no validation sample blocks",
2099 edge.source.node_id, edge.target.node_id
2100 )));
2101 }
2102 for block in selected {
2103 let block_width = block.validate_shape()?;
2104 merge_oof_shape(
2105 &edge.source.node_id,
2106 &mut width,
2107 &mut target_names,
2108 block_width,
2109 &block.target_names,
2110 )?;
2111 if let Some(fold_id) = &block.fold_id {
2112 fold_ids.insert(fold_id.clone());
2113 }
2114 sample_ids.extend(block.sample_ids.iter().cloned());
2115 }
2116 }
2117 PredictionLevel::Target | PredictionLevel::Group => {
2118 let selected = aggregated_blocks
2119 .iter()
2120 .filter(|block| {
2121 block.producer_node == edge.source.node_id
2122 && producer_port_matches_graph_output(
2123 plan,
2124 &edge.source.node_id,
2125 &edge.source.port_name,
2126 &block.producer_port,
2127 )
2128 && block.partition == PredictionPartition::Validation
2129 && block.level == prediction_level
2130 })
2131 .collect::<Vec<_>>();
2132 if selected.is_empty() {
2133 return Err(DagMlError::RuntimeValidation(format!(
2134 "OOF requirement `{}` -> `{}` has no validation {prediction_level:?} blocks",
2135 edge.source.node_id, edge.target.node_id
2136 )));
2137 }
2138 for block in selected {
2139 let block_width = block.validate_shape()?;
2140 merge_oof_shape(
2141 &edge.source.node_id,
2142 &mut width,
2143 &mut target_names,
2144 block_width,
2145 &block.target_names,
2146 )?;
2147 if let Some(fold_id) = &block.fold_id {
2148 fold_ids.insert(fold_id.clone());
2149 }
2150 unit_ids.extend(block.unit_ids.iter().cloned());
2151 }
2152 }
2153 PredictionLevel::Observation => {
2154 return Err(DagMlError::RuntimeValidation(format!(
2155 "OOF requirement `{}` -> `{}` cannot persist observation-level predictions; aggregate before refit",
2156 edge.source.node_id, edge.target.node_id
2157 )));
2158 }
2159 }
2160 let requirement = BundlePredictionRequirement {
2161 producer_node: edge.source.node_id.clone(),
2162 source_port: edge.source.port_name.clone(),
2163 consumer_node: edge.target.node_id.clone(),
2164 target_port: edge.target.port_name.clone(),
2165 partition: PredictionPartition::Validation,
2166 prediction_level,
2167 fold_ids: fold_ids.into_iter().collect(),
2168 unit_ids: unit_ids.into_iter().collect(),
2169 sample_ids: sample_ids.into_iter().collect(),
2170 prediction_width: width.unwrap_or_default(),
2171 target_names: target_names.unwrap_or_default(),
2172 };
2173 requirement.validate()?;
2174 requirements.push(requirement);
2175 }
2176 requirements.sort_by_key(BundlePredictionRequirement::key);
2177 Ok(requirements)
2178}
2179
2180fn merge_oof_shape(
2181 producer: &NodeId,
2182 expected_width: &mut Option<usize>,
2183 expected_names: &mut Option<Vec<String>>,
2184 width: usize,
2185 names: &[String],
2186) -> Result<()> {
2187 if expected_width.is_some_and(|expected| expected != width) {
2188 return Err(DagMlError::RuntimeValidation(format!(
2189 "OOF requirement for `{producer}` has inconsistent prediction width"
2190 )));
2191 }
2192 *expected_width = Some(width);
2193 let names = if names.is_empty() {
2194 (0..width).map(|index| format!("p{index}")).collect()
2195 } else {
2196 names.to_vec()
2197 };
2198 if expected_names
2199 .as_ref()
2200 .is_some_and(|expected| expected != &names)
2201 {
2202 return Err(DagMlError::RuntimeValidation(format!(
2203 "OOF requirement for `{producer}` has inconsistent target names"
2204 )));
2205 }
2206 *expected_names = Some(names);
2207 Ok(())
2208}
2209
2210pub fn build_oof_prediction_cache_records(
2211 requirements: &[BundlePredictionRequirement],
2212 blocks: &[PredictionBlock],
2213 aggregated_blocks: &[AggregatedPredictionBlock],
2214) -> Result<Vec<BundlePredictionCacheRecord>> {
2215 requirements
2216 .iter()
2217 .map(|requirement| match requirement.prediction_level {
2218 PredictionLevel::Sample => build_prediction_cache_record(requirement, blocks),
2219 PredictionLevel::Target | PredictionLevel::Group => {
2220 build_aggregated_prediction_cache_record(requirement, aggregated_blocks)
2221 }
2222 PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
2223 "prediction cache requirement `{}` cannot use observation-level predictions",
2224 requirement.key()
2225 ))),
2226 })
2227 .collect()
2228}
2229
2230pub fn build_oof_prediction_cache_payloads(
2231 requirements: &[BundlePredictionRequirement],
2232 blocks: &[PredictionBlock],
2233 aggregated_blocks: &[AggregatedPredictionBlock],
2234) -> Result<Vec<BundlePredictionCachePayload>> {
2235 requirements
2236 .iter()
2237 .map(|requirement| match requirement.prediction_level {
2238 PredictionLevel::Sample => build_prediction_cache_payload(requirement, blocks),
2239 PredictionLevel::Target | PredictionLevel::Group => {
2240 build_aggregated_prediction_cache_payload(requirement, aggregated_blocks)
2241 }
2242 PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
2243 "prediction cache requirement `{}` cannot use observation-level predictions",
2244 requirement.key()
2245 ))),
2246 })
2247 .collect()
2248}
2249
2250fn attach_oof_prediction_cache_namespaces(
2251 plan: &ExecutionPlan,
2252 data_identities: &[TrainingDataIdentity],
2253 selected_variant_id: &VariantId,
2254 seed: u64,
2255 requirements: &[BundlePredictionRequirement],
2256 records: &mut [BundlePredictionCacheRecord],
2257 payloads: &mut [BundlePredictionCachePayload],
2258) -> Result<()> {
2259 let requirements_by_key = requirements
2260 .iter()
2261 .map(|requirement| (requirement.key(), requirement))
2262 .collect::<BTreeMap<_, _>>();
2263 for record in records {
2264 let requirement = requirements_by_key
2265 .get(&record.requirement_key)
2266 .ok_or_else(|| {
2267 DagMlError::RuntimeValidation(format!(
2268 "prediction cache `{}` references unknown OOF requirement `{}`",
2269 record.cache_id, record.requirement_key
2270 ))
2271 })?;
2272 let fingerprints = oof_cache_namespace_fingerprints(
2273 plan,
2274 data_identities,
2275 selected_variant_id,
2276 seed,
2277 requirement,
2278 record,
2279 )?;
2280 record.cache_namespace_fingerprints = fingerprints.clone();
2281 let payload = payloads
2282 .iter_mut()
2283 .find(|payload| payload.requirement_key == record.requirement_key)
2284 .ok_or_else(|| {
2285 DagMlError::RuntimeValidation(format!(
2286 "prediction cache `{}` has no portable payload for requirement `{}`",
2287 record.cache_id, record.requirement_key
2288 ))
2289 })?;
2290 payload.cache_namespace_fingerprints = fingerprints;
2291 validate_prediction_cache_payload_matches_record(payload, record)?;
2292 }
2293 Ok(())
2294}
2295
2296fn oof_cache_namespace_fingerprints(
2297 plan: &ExecutionPlan,
2298 data_identities: &[TrainingDataIdentity],
2299 selected_variant_id: &VariantId,
2300 seed: u64,
2301 requirement: &BundlePredictionRequirement,
2302 record: &BundlePredictionCacheRecord,
2303) -> Result<Vec<String>> {
2304 let producer_plan = plan
2305 .node_plans
2306 .get(&requirement.producer_node)
2307 .ok_or_else(|| {
2308 DagMlError::RuntimeValidation(format!(
2309 "prediction cache `{}` producer node `{}` is absent from plan",
2310 record.cache_id, requirement.producer_node
2311 ))
2312 })?;
2313 let consumer_plan = plan
2314 .node_plans
2315 .get(&requirement.consumer_node)
2316 .ok_or_else(|| {
2317 DagMlError::RuntimeValidation(format!(
2318 "prediction cache `{}` consumer node `{}` is absent from plan",
2319 record.cache_id, requirement.consumer_node
2320 ))
2321 })?;
2322 let identity_binding = match (
2323 producer_plan.data_bindings.as_slice(),
2324 consumer_plan.data_bindings.as_slice(),
2325 ) {
2326 ([binding], _) => binding,
2327 ([], [binding]) => binding,
2328 (producer_bindings, consumer_bindings) => {
2329 let producer_count = producer_bindings.len();
2330 let consumer_count = consumer_bindings.len();
2331 return Err(DagMlError::RuntimeValidation(format!(
2332 "prediction cache `{}` cannot derive a unique CacheNamespace for edge `{}.{}` -> `{}.{}` with {producer_count} producer data binding(s) and {consumer_count} consumer data binding(s)",
2333 record.cache_id,
2334 requirement.producer_node,
2335 requirement.source_port,
2336 requirement.consumer_node,
2337 requirement.target_port
2338 )));
2339 }
2340 };
2341 if producer_plan.data_bindings.len() > 1 || consumer_plan.data_bindings.len() > 1 {
2342 return Err(DagMlError::RuntimeValidation(format!(
2343 "prediction cache `{}` cannot derive a unique CacheNamespace for edge `{}.{}` -> `{}.{}` with ambiguous data bindings",
2344 record.cache_id,
2345 requirement.producer_node,
2346 requirement.source_port,
2347 requirement.consumer_node,
2348 requirement.target_port
2349 )));
2350 }
2351 let data_requirement_key =
2352 data_binding_requirement_key(&identity_binding.node_id, &identity_binding.input_name);
2353 let identity = data_identities
2354 .iter()
2355 .find(|identity| identity.requirement_key == data_requirement_key)
2356 .ok_or_else(|| {
2357 DagMlError::RuntimeValidation(format!(
2358 "prediction cache `{}` has no training data identity for `{data_requirement_key}`",
2359 record.cache_id
2360 ))
2361 })?;
2362 let mut fingerprints = Vec::with_capacity(record.blocks.len());
2363 for block in &record.blocks {
2364 let fold_id = block.fold_id.clone().ok_or_else(|| {
2365 DagMlError::RuntimeValidation(format!(
2366 "prediction cache `{}` has a cache block without fold_id",
2367 record.cache_id
2368 ))
2369 })?;
2370 let namespace = CacheNamespace::new(
2371 requirement.key(),
2372 identity.requirement_key.clone(),
2373 requirement.producer_node.clone(),
2374 requirement.source_port.clone(),
2375 requirement.consumer_node.clone(),
2376 requirement.target_port.clone(),
2377 producer_plan.params_fingerprint.clone(),
2378 identity.identity_fingerprint.clone(),
2379 fold_id,
2380 selected_variant_id.to_string(),
2381 seed,
2382 )?;
2383 namespace.validate_for_identity(identity)?;
2384 fingerprints.push(namespace.namespace_fingerprint);
2385 }
2386 Ok(fingerprints)
2387}
2388
2389impl TrainingOutcome {
2390 pub fn from_json(json: &str) -> Result<Self> {
2393 let typed = parse_typed_json(json).map_err(|error| {
2394 DagMlError::CampaignValidation(format!(
2395 "training outcome is not strict TCV1 JSON: {error}"
2396 ))
2397 })?;
2398 let raw_fingerprint =
2399 typed
2400 .fingerprint_without("outcome_fingerprint")
2401 .map_err(|error| {
2402 DagMlError::CampaignValidation(format!(
2403 "training outcome fingerprint preimage is invalid: {error}"
2404 ))
2405 })?;
2406 let outcome: Self = serde_json::from_str(json)?;
2407 if outcome.outcome_fingerprint != raw_fingerprint {
2408 return contract_error(
2409 "training outcome fingerprint does not match original TCV1 JSON",
2410 );
2411 }
2412 outcome.validate()?;
2413 Ok(outcome)
2414 }
2415
2416 pub fn compute_fingerprint(&self) -> Result<String> {
2417 tcv1_fingerprint_without(self, "outcome_fingerprint", "training outcome")
2418 }
2419
2420 pub fn data_identities_fingerprint(&self) -> Result<String> {
2421 tcv1_fingerprint(&self.data_identities, "training outcome data identities")
2422 }
2423
2424 pub fn execution_bundle_fingerprint(&self) -> Result<String> {
2425 tcv1_fingerprint(&self.execution_bundle, "training outcome execution bundle")
2426 }
2427
2428 fn pre_conformal_outcome(&self) -> Result<Self> {
2429 let mut source = self.clone();
2430 source.conformal_calibration = None;
2431 source.conformal_calibration_replay = None;
2432 source.execution_bundle.conformal_calibration = None;
2433 stabilize_training_outcome_for_tcv1(source)
2434 }
2435
2436 fn pre_conformal_outcome_fingerprint(&self) -> Result<String> {
2437 Ok(self.pre_conformal_outcome()?.outcome_fingerprint)
2438 }
2439
2440 pub(crate) fn attach_conformal_calibration(
2444 &mut self,
2445 calibration: ConformalCalibration,
2446 replay: TrainingReplayOutcome,
2447 ) -> Result<()> {
2448 self.validate()?;
2449 calibration.validate()?;
2450 let request = replay_request_from_outcome(&replay);
2451 replay.validate_against(self, &request)?;
2452 let binding = self
2453 .outputs
2454 .iter()
2455 .find(|output| output.binding.binding_id == calibration.binding_id)
2456 .ok_or_else(|| {
2457 DagMlError::RuntimeValidation(
2458 "conformal calibration binding is absent from training outcome".to_string(),
2459 )
2460 })?;
2461 if binding.binding.target_names != calibration.target_names {
2462 return Err(DagMlError::RuntimeValidation(
2463 "conformal calibration target order does not match training outcome binding"
2464 .to_string(),
2465 ));
2466 }
2467 let fold_set = self.effective_plan.fold_set.as_ref().ok_or_else(|| {
2468 DagMlError::RuntimeValidation(
2469 "conformal calibration requires a source FoldSet".to_string(),
2470 )
2471 })?;
2472 let context = &calibration.context;
2473 if context.predictor_binding_fingerprint != binding.binding.binding_fingerprint
2474 || context.source_training_outcome_fingerprint != self.outcome_fingerprint
2475 || context.data_identities_fingerprint != self.data_identities_fingerprint()?
2476 || context.fold_set_fingerprint != fold_set_fingerprint(fold_set)?
2477 || context.training_influence_fingerprint
2478 != self.training_influence.manifest_fingerprint
2479 {
2480 return Err(DagMlError::RuntimeValidation(
2481 "conformal calibration context does not exactly match its training outcome"
2482 .to_string(),
2483 ));
2484 }
2485 let training_ids = self
2486 .training_influence
2487 .entries
2488 .iter()
2489 .flat_map(|entry| {
2490 entry
2491 .physical_sample_ids
2492 .iter()
2493 .chain(entry.origin_sample_ids.iter())
2494 })
2495 .collect::<BTreeSet<_>>();
2496 if context
2497 .calibration_cohort
2498 .physical_sample_ids
2499 .iter()
2500 .chain(context.calibration_cohort.origin_sample_ids.iter())
2501 .any(|id| training_ids.contains(id))
2502 {
2503 return Err(DagMlError::RuntimeValidation(
2504 "conformal calibration cohort overlaps training influence closure".to_string(),
2505 ));
2506 }
2507 self.execution_bundle.conformal_calibration = Some(calibration.reference()?);
2508 self.conformal_calibration = Some(calibration);
2509 self.conformal_calibration_replay = Some(replay);
2510 *self = stabilize_training_outcome_for_tcv1(self.clone())?;
2511 self.validate()
2512 }
2513
2514 pub fn to_reference(&self) -> Result<TrainingOutcomeRef> {
2516 self.validate()?;
2517 validate_sha256(
2518 "training outcome request",
2519 &self.training_request_fingerprint,
2520 )?;
2521 Ok(TrainingOutcomeRef {
2522 outcome_id: self.outcome_id.clone(),
2523 outcome_fingerprint: self.outcome_fingerprint.clone(),
2524 pre_conformal_outcome_fingerprint: self
2525 .conformal_calibration
2526 .as_ref()
2527 .map(|_| self.pre_conformal_outcome_fingerprint())
2528 .transpose()?,
2529 training_request_fingerprint: self.training_request_fingerprint.clone(),
2530 effective_plan_fingerprint: self.effective_plan_fingerprint.clone(),
2531 execution_bundle_id: self.execution_bundle.bundle_id.clone(),
2532 execution_bundle_fingerprint: self.execution_bundle_fingerprint()?,
2533 data_identities_fingerprint: self.data_identities_fingerprint()?,
2534 output_binding_fingerprints: self
2535 .outputs
2536 .iter()
2537 .map(|output| output.binding.binding_fingerprint.clone())
2538 .collect(),
2539 training_influence_fingerprint: self.training_influence.manifest_fingerprint.clone(),
2540 })
2541 }
2542
2543 pub fn to_portable_predictor_package(
2548 &self,
2549 package_id: impl Into<String>,
2550 fitted_artifact_mode: FittedArtifactMode,
2551 artifact_load_mode: ArtifactLoadMode,
2552 ) -> Result<PortablePredictorPackage> {
2553 self.validate()?;
2554 let mut template = PredictorTemplate {
2555 graph: self.effective_plan.graph_plan.graph.clone(),
2556 campaign: self.effective_plan.campaign.clone(),
2557 controller_manifests: self.effective_plan.controller_manifests.clone(),
2558 template_fingerprint: zero_fingerprint(),
2559 };
2560 template.template_fingerprint = template.compute_fingerprint()?;
2561
2562 let output_bindings = self
2563 .outputs
2564 .iter()
2565 .map(|output| output.binding.clone())
2566 .collect::<Vec<_>>();
2567 let predictor_node_ids = predictor_closure(
2568 &self.effective_plan,
2569 output_bindings
2570 .iter()
2571 .map(|binding| binding.node_id.clone()),
2572 )?
2573 .into_iter()
2574 .collect::<Vec<_>>();
2575 let mut artifact_bindings = self
2576 .execution_bundle
2577 .refit_artifacts
2578 .iter()
2579 .map(|record| PackageArtifactBinding {
2580 artifact_id: record.artifact.id.clone(),
2581 load_mode: artifact_load_mode,
2582 })
2583 .collect::<Vec<_>>();
2584 artifact_bindings.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id));
2585 let mut package = PortablePredictorPackage {
2586 schema_version: PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION,
2587 package_id: package_id.into(),
2588 template,
2589 training_request_fingerprint: self.training_request_fingerprint.clone(),
2590 training_outcome: self.to_reference()?,
2591 effective_plan: self.effective_plan.clone(),
2592 execution_bundle: self.execution_bundle.clone(),
2593 conformal_calibration: self.conformal_calibration.clone(),
2594 conformal_calibration_replay: self.conformal_calibration_replay.clone(),
2595 output_bindings,
2596 predictor_node_ids,
2597 training_influence: self.training_influence.clone(),
2598 data_identities: self.data_identities.clone(),
2599 fitted_artifact_mode,
2600 artifact_bindings,
2601 package_fingerprint: zero_fingerprint(),
2602 };
2603 package.package_fingerprint = package.compute_fingerprint()?;
2604 package.validate()?;
2605 Ok(package)
2606 }
2607
2608 pub fn validate(&self) -> Result<()> {
2609 if self.schema_version < MIN_READABLE_TRAINING_OUTCOME_SCHEMA_VERSION
2610 || self.schema_version > TRAINING_OUTCOME_SCHEMA_VERSION
2611 {
2612 return contract_error(format!(
2613 "training outcome schema_version {} is unsupported; maximum readable version is {}",
2614 self.schema_version, TRAINING_OUTCOME_SCHEMA_VERSION
2615 ));
2616 }
2617 RunId::new(self.outcome_id.clone()).map_err(|error| {
2618 DagMlError::CampaignValidation(format!(
2619 "training outcome_id is not a portable identifier: {error}"
2620 ))
2621 })?;
2622 validate_sha256(
2623 "training outcome request",
2624 &self.training_request_fingerprint,
2625 )?;
2626 validate_sha256("training outcome plan", &self.effective_plan_fingerprint)?;
2627 validate_sha256(
2628 "training outcome selected variant",
2629 &self.selected_variant_fingerprint,
2630 )?;
2631 validate_sha256("training outcome", &self.outcome_fingerprint)?;
2632 self.effective_plan.validate()?;
2633 if self.effective_plan_fingerprint
2634 != tcv1_fingerprint(&self.effective_plan, "training outcome effective plan")?
2635 {
2636 return contract_error(
2637 "training outcome effective_plan_fingerprint does not match TCV1 plan content",
2638 );
2639 }
2640
2641 let selected = self
2642 .effective_plan
2643 .variants
2644 .iter()
2645 .filter(|variant| variant.variant_id == self.selected_variant_id)
2646 .collect::<Vec<_>>();
2647 let [selected] = selected.as_slice() else {
2648 return contract_error(
2649 "training outcome selected_variant_id is absent or duplicated in effective plan",
2650 );
2651 };
2652 if selected.fingerprint != self.selected_variant_fingerprint {
2653 return contract_error(
2654 "training outcome selected_variant_fingerprint does not match effective plan",
2655 );
2656 }
2657 let expected_patches = selected_variant_parameter_patches(selected)?;
2658 validate_outcome_parameter_patches(
2659 &self.effective_plan,
2660 &self.parameter_patches,
2661 &expected_patches,
2662 )?;
2663 if !self.parameter_patches.is_empty()
2664 && !self
2665 .training_influence
2666 .entries
2667 .iter()
2668 .any(|entry| entry.kind == TrainingInfluenceKind::HpoSelection)
2669 {
2670 return contract_error(
2671 "training outcome parameter patches require hpo_selection influence",
2672 );
2673 }
2674
2675 self.validate_refit()?;
2676 self.score_set.validate()?;
2677 self.validate_version_family()?;
2678 if self.schema_version == LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION
2679 && (self.conformal_calibration.is_some() || self.conformal_calibration_replay.is_some())
2680 {
2681 return contract_error(
2682 "training outcome V1 cannot carry conformal state; migrate to V2",
2683 );
2684 }
2685 if self.score_set.plan_id != self.effective_plan.id {
2686 return contract_error("training outcome score_set.plan_id does not match plan");
2687 }
2688 if !self
2689 .score_set
2690 .reports
2691 .iter()
2692 .any(|report| report.variant_id.as_ref() == Some(&self.selected_variant_id))
2693 {
2694 return contract_error("training outcome score_set has no report for selected variant");
2695 }
2696 self.validate_selection_decision()?;
2697
2698 let closure = self.validate_outputs()?;
2699 let expected_predictor_execution_closure = self
2700 .effective_plan
2701 .node_plans
2702 .keys()
2703 .cloned()
2704 .collect::<BTreeSet<_>>();
2705 if closure != expected_predictor_execution_closure {
2706 return contract_error(
2707 "training outcome predictor closure does not equal the explicit V1 predictor execution closure",
2708 );
2709 }
2710 self.training_influence.validate()?;
2711 validate_influence_against_closure(
2712 &self.training_influence,
2713 &self.effective_plan,
2714 &closure,
2715 )?;
2716 let base_fit_nodes = self
2717 .training_influence
2718 .entries
2719 .iter()
2720 .filter(|entry| {
2721 matches!(
2722 entry.kind,
2723 TrainingInfluenceKind::TransformFit
2724 | TrainingInfluenceKind::ModelFit
2725 | TrainingInfluenceKind::TrainedMetaAggregation
2726 )
2727 })
2728 .filter_map(|entry| entry.node_id.clone())
2729 .collect::<BTreeSet<_>>();
2730 if self
2731 .outputs
2732 .iter()
2733 .any(|output| !base_fit_nodes.contains(&output.binding.node_id))
2734 {
2735 return contract_error("training outcome output node has no fitting influence");
2736 }
2737
2738 self.execution_bundle
2739 .validate_against_plan(&self.effective_plan)?;
2740 if self.execution_bundle.selected_variant_id.as_ref() != Some(&self.selected_variant_id) {
2741 return contract_error(
2742 "training outcome execution bundle selected variant does not match outcome",
2743 );
2744 }
2745 if self.execution_bundle.scores.as_ref() != Some(&self.score_set) {
2746 return contract_error(
2747 "training outcome execution bundle scores do not equal score_set",
2748 );
2749 }
2750 if self.execution_bundle.methods_hpo_resume_state != self.methods_hpo_resume_state {
2751 return contract_error(
2752 "training outcome Methods HPO resume state does not equal execution bundle state",
2753 );
2754 }
2755 match (
2756 &self.conformal_calibration,
2757 &self.conformal_calibration_replay,
2758 &self.execution_bundle.conformal_calibration,
2759 ) {
2760 (Some(calibration), Some(replay), Some(reference)) => {
2761 reference.validate_against(calibration)?;
2762 let pre_conformal_source = self.pre_conformal_outcome()?;
2763 let replay_request = replay_request_from_outcome(replay);
2764 replay.validate_against(&pre_conformal_source, &replay_request)?;
2765 let binding = self
2766 .outputs
2767 .iter()
2768 .find(|output| output.binding.binding_id == calibration.binding_id)
2769 .ok_or_else(|| {
2770 DagMlError::RuntimeValidation(
2771 "conformal calibration binding is absent from training outcome"
2772 .to_string(),
2773 )
2774 })?;
2775 let fold_set = self.effective_plan.fold_set.as_ref().ok_or_else(|| {
2776 DagMlError::RuntimeValidation(
2777 "conformal calibration requires a source FoldSet".to_string(),
2778 )
2779 })?;
2780 let context = &calibration.context;
2781 if binding.binding.target_names != calibration.target_names
2782 || context.predictor_binding_fingerprint != binding.binding.binding_fingerprint
2783 || context.source_training_outcome_fingerprint
2784 != pre_conformal_source.outcome_fingerprint
2785 || context.calibration_replay_outcome_fingerprint != replay.outcome_fingerprint
2786 || context.data_identities_fingerprint != self.data_identities_fingerprint()?
2787 || context.fold_set_fingerprint != fold_set_fingerprint(fold_set)?
2788 || context.training_influence_fingerprint
2789 != self.training_influence.manifest_fingerprint
2790 {
2791 return contract_error(
2792 "training outcome conformal context does not exactly cross-link its pre-calibration source",
2793 );
2794 }
2795 if context.relation_fingerprint == self.training_influence.relation_fingerprint {
2796 return contract_error(
2797 "training outcome calibration relation authority must be distinct from development relations",
2798 );
2799 }
2800 let replay_output = replay
2801 .outputs
2802 .iter()
2803 .find(|output| output.binding.binding_id == calibration.binding_id)
2804 .ok_or_else(|| {
2805 DagMlError::RuntimeValidation(
2806 "conformal calibration replay is missing its selected binding"
2807 .to_string(),
2808 )
2809 })?;
2810 let [point] = replay_output.predictions.as_slice() else {
2811 return contract_error(
2812 "conformal calibration replay requires exactly one selected point block",
2813 );
2814 };
2815 if replay.phase != Phase::Predict
2816 || replay_output.binding != binding.binding
2817 || point.sample_ids != calibration.sample_ids
2818 || point.sample_ids != context.calibration_cohort.physical_sample_ids
2819 || replay.input_data_identities.iter().any(|identity| {
2820 identity.relation_fingerprint != context.relation_fingerprint
2821 })
2822 {
2823 return contract_error(
2824 "conformal calibration replay evidence does not match its selected binding, samples, or relation authority",
2825 );
2826 }
2827 let training_ids = self
2828 .training_influence
2829 .entries
2830 .iter()
2831 .flat_map(|entry| {
2832 entry
2833 .physical_sample_ids
2834 .iter()
2835 .chain(entry.origin_sample_ids.iter())
2836 })
2837 .collect::<BTreeSet<_>>();
2838 if context
2839 .calibration_cohort
2840 .physical_sample_ids
2841 .iter()
2842 .chain(context.calibration_cohort.origin_sample_ids.iter())
2843 .any(|id| training_ids.contains(id))
2844 {
2845 return contract_error(
2846 "conformal calibration cohort overlaps training influence closure",
2847 );
2848 }
2849 }
2850 (None, None, None) => {}
2851 _ => {
2852 return contract_error(
2853 "training outcome and execution bundle conformal state disagree",
2854 )
2855 }
2856 }
2857 if let Some(state) = &self.methods_hpo_resume_state {
2858 let terminal_reports = state
2859 .completed_reports
2860 .iter()
2861 .map(|completed| completed.report.clone())
2862 .collect::<Vec<_>>();
2863 if self.score_set.reports != terminal_reports {
2864 return contract_error(
2865 "training outcome score_set does not exactly retain Methods HPO terminal OOF reports",
2866 );
2867 }
2868 }
2869 self.validate_data_identities()?;
2870 validate_all_identity_relations(
2871 &self.data_identities,
2872 &self.training_influence.relation_fingerprint,
2873 )?;
2874 self.validate_artifacts(&closure)?;
2875 self.validate_lineage(&closure)?;
2876 match &self.portable_prediction_caches {
2877 Some(caches) => caches.validate_against_bundle(&self.execution_bundle)?,
2878 None if !self.execution_bundle.prediction_caches.is_empty() => {
2879 return contract_error(
2880 "training outcome portable caches are null while bundle announces caches",
2881 );
2882 }
2883 None => {}
2884 }
2885
2886 let expected_replay = derive_replayable_phases(
2887 &self.effective_plan,
2888 &closure,
2889 &self.refit,
2890 &self.execution_bundle,
2891 self.portable_prediction_caches.as_ref(),
2892 )?;
2893 if self.replayable_phases != expected_replay {
2894 return contract_error(
2895 "training outcome replayable_phases do not match the phases derivable from the full predictor closure and retained state",
2896 );
2897 }
2898 validate_sorted_unique_text("training outcome warnings", &self.warnings)?;
2899 let portable = serde_json::to_value(self)?;
2900 if contains_runtime_handle(&portable) {
2901 return contract_error("training outcome must not contain runtime handles");
2902 }
2903 if self.outcome_fingerprint != self.compute_fingerprint()? {
2904 return contract_error("training outcome fingerprint does not match TCV1 content");
2905 }
2906 Ok(())
2907 }
2908
2909 fn validate_version_family(&self) -> Result<()> {
2910 let expected_score_version = match self.schema_version {
2911 LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => LEGACY_SCORE_SET_SCHEMA_VERSION,
2912 TRAINING_OUTCOME_SCHEMA_VERSION => SCORE_SET_SCHEMA_VERSION,
2913 _ => unreachable!("training outcome schema_version was range-checked"),
2914 };
2915 if self.score_set.schema_version != expected_score_version {
2916 return contract_error(format!(
2917 "training outcome schema_version {} requires score_set schema_version {}, got {}",
2918 self.schema_version, expected_score_version, self.score_set.schema_version
2919 ));
2920 }
2921 let expected_bundle_version = match self.schema_version {
2922 LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => LEGACY_EXECUTION_BUNDLE_SCHEMA_VERSION,
2923 TRAINING_OUTCOME_SCHEMA_VERSION => EXECUTION_BUNDLE_SCHEMA_VERSION,
2924 _ => unreachable!("training outcome schema_version was range-checked"),
2925 };
2926 if self.execution_bundle.schema_version != expected_bundle_version {
2927 return contract_error(format!(
2928 "training outcome schema_version {} requires execution_bundle schema_version {}, got {}",
2929 self.schema_version,
2930 expected_bundle_version,
2931 self.execution_bundle.schema_version
2932 ));
2933 }
2934 let expected_cache_version = match self.schema_version {
2935 LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => {
2936 LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION
2937 }
2938 TRAINING_OUTCOME_SCHEMA_VERSION => PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
2939 _ => unreachable!("training outcome schema_version was range-checked"),
2940 };
2941 if let Some(caches) = &self.portable_prediction_caches {
2942 if caches.schema_version != expected_cache_version {
2943 return contract_error(format!(
2944 "training outcome schema_version {} requires prediction cache payload set schema_version {}, got {}",
2945 self.schema_version, expected_cache_version, caches.schema_version
2946 ));
2947 }
2948 }
2949 for output in &self.outputs {
2950 match (self.schema_version, output.schema_version) {
2951 (LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION, None) => {}
2952 (LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION, Some(version)) => {
2953 return contract_error(format!(
2954 "training outcome V1 requires absent bound output schema_version, got {version}"
2955 ));
2956 }
2957 (TRAINING_OUTCOME_SCHEMA_VERSION, Some(BOUND_TRAINING_OUTPUT_SCHEMA_VERSION)) => {}
2958 (TRAINING_OUTCOME_SCHEMA_VERSION, Some(version)) => {
2959 return contract_error(format!(
2960 "training outcome V2 requires bound output schema_version {}, got {version}",
2961 BOUND_TRAINING_OUTPUT_SCHEMA_VERSION
2962 ));
2963 }
2964 (TRAINING_OUTCOME_SCHEMA_VERSION, None) => {
2965 return contract_error(
2966 "training outcome V2 requires bound output schema_version",
2967 );
2968 }
2969 _ => unreachable!("training outcome schema_version was range-checked"),
2970 }
2971 }
2972 Ok(())
2973 }
2974
2975 fn validate_data_identities(&self) -> Result<()> {
2976 if self.data_identities.is_empty() {
2977 return contract_error("training outcome requires data identities");
2978 }
2979 let mut previous: Option<&str> = None;
2980 for identity in &self.data_identities {
2981 identity.validate()?;
2982 if previous.is_some_and(|key| key >= identity.requirement_key.as_str()) {
2983 return contract_error(
2984 "training outcome data identities must be sorted and unique",
2985 );
2986 }
2987 previous = Some(identity.requirement_key.as_str());
2988 let requirement = self
2989 .execution_bundle
2990 .data_requirements
2991 .iter()
2992 .find(|requirement| requirement.key() == identity.requirement_key)
2993 .ok_or_else(|| {
2994 DagMlError::CampaignValidation(format!(
2995 "training outcome data identity `{}` has no bundle requirement",
2996 identity.requirement_key
2997 ))
2998 })?;
2999 if requirement.schema_fingerprint != identity.schema_fingerprint
3000 || requirement.plan_fingerprint != identity.plan_fingerprint
3001 || requirement.relation_fingerprint.as_ref() != Some(&identity.relation_fingerprint)
3002 {
3003 return contract_error(
3004 "training outcome data identity does not match execution bundle requirement",
3005 );
3006 }
3007 }
3008 if self.data_identities.len() != self.execution_bundle.data_requirements.len() {
3009 return contract_error(
3010 "training outcome data identities do not exactly cover bundle data requirements",
3011 );
3012 }
3013 Ok(())
3014 }
3015
3016 fn validate_selection_decision(&self) -> Result<()> {
3017 if self.selection_output_id.trim().is_empty() {
3018 return contract_error("training outcome selection_output_id is empty");
3019 }
3020 let bindings = self
3021 .outputs
3022 .iter()
3023 .filter(|output| output.binding.binding_id == self.selection_output_id)
3024 .collect::<Vec<_>>();
3025 let [selected_output] = bindings.as_slice() else {
3026 return contract_error(
3027 "training outcome selection_output_id does not resolve exactly one output",
3028 );
3029 };
3030 if self.execution_bundle.selections.len() != 1 {
3031 return contract_error(
3032 "training outcome execution bundle must contain exactly one SELECT decision",
3033 );
3034 }
3035 let (selection_key, decision) = self
3036 .execution_bundle
3037 .selections
3038 .iter()
3039 .next()
3040 .expect("selection length was checked");
3041 if selection_key != &decision.policy_id
3042 || decision.selected_candidate_id != self.selected_variant_id.as_str()
3043 || decision.metric_level != Some(selected_output.binding.prediction_level)
3044 || decision.evaluation_scope != Some(EvaluationScope::Oof)
3045 || self.score_set.selection_metric.as_deref() != Some(decision.metric_name.as_str())
3046 || selected_output.binding.prediction_level
3047 != self
3048 .effective_plan
3049 .campaign
3050 .aggregation_policy
3051 .selection_metric_level
3052 {
3053 return contract_error(
3054 "training outcome SELECT decision metadata is inconsistent with selected output",
3055 );
3056 }
3057 RegressionMetricKind::resolve_for_prediction_kind(
3058 &decision.metric_name,
3059 decision.objective,
3060 selected_output.binding.prediction_kind,
3061 )?;
3062 let mut reports_by_variant = BTreeMap::<VariantId, _>::new();
3063 for report in self.score_set.reports.iter().filter(|report| {
3064 report.producer_node == selected_output.binding.node_id
3065 && producer_port_matches_graph_output(
3066 &self.effective_plan,
3067 &selected_output.binding.node_id,
3068 &selected_output.binding.port_name,
3069 &report.producer_port,
3070 )
3071 && report.partition == PredictionPartition::Validation
3072 && report.level == selected_output.binding.prediction_level
3073 && report
3074 .fold_id
3075 .as_ref()
3076 .is_some_and(|fold| fold.as_str() == "avg")
3077 }) {
3078 let variant_id = report.variant_id.clone().ok_or_else(|| {
3079 DagMlError::CampaignValidation(
3080 "selection output average report has no variant_id".to_string(),
3081 )
3082 })?;
3083 if reports_by_variant
3084 .insert(variant_id, report.clone())
3085 .is_some()
3086 {
3087 return contract_error(
3088 "training outcome has multiple selection average reports for one variant",
3089 );
3090 }
3091 }
3092 let expected_variants = self
3093 .effective_plan
3094 .variants
3095 .iter()
3096 .map(|variant| variant.variant_id.clone())
3097 .collect::<BTreeSet<_>>();
3098 if reports_by_variant.keys().cloned().collect::<BTreeSet<_>>() != expected_variants {
3099 return contract_error(
3100 "training outcome selection reports do not exactly cover plan variants",
3101 );
3102 }
3103 let candidates = reports_by_variant
3104 .into_iter()
3105 .map(|(variant_id, report)| report.into_candidate_score(variant_id.as_str()))
3106 .collect::<Result<Vec<_>>>()?;
3107 let reconstructed = select_candidate(
3108 &SelectionPolicy {
3109 id: decision.policy_id.clone(),
3110 metric: SelectionMetric {
3111 name: decision.metric_name.clone(),
3112 objective: decision.objective,
3113 },
3114 required_metric_level: decision.metric_level,
3115 require_finite: true,
3116 evaluation_scope: decision.evaluation_scope,
3117 refit_slot_plan: decision.refit_slot_plan.clone(),
3118 stacking_fit_contract: None,
3119 reduction_id: decision.reduction_id.clone(),
3120 },
3121 &candidates,
3122 )?;
3123 if &reconstructed != decision {
3124 return contract_error(
3125 "training outcome SELECT decision does not equal ranking reconstructed from scores",
3126 );
3127 }
3128 Ok(())
3129 }
3130
3131 fn validate_refit(&self) -> Result<()> {
3132 match (self.refit.requested, self.refit.status, self.refit.strategy) {
3133 (true, TrainingRefitStatus::Completed, Some(_)) => {
3134 if self
3135 .outputs
3136 .iter()
3137 .any(|output| output.binding.prediction_source != PredictionSource::FinalRefit)
3138 {
3139 return contract_error(
3140 "completed refit outputs must use final_refit prediction source",
3141 );
3142 }
3143 }
3144 (false, TrainingRefitStatus::Skipped, None) => {
3145 if self
3146 .outputs
3147 .iter()
3148 .any(|output| output.binding.prediction_source == PredictionSource::FinalRefit)
3149 {
3150 return contract_error("no-refit outputs cannot use final_refit");
3151 }
3152 }
3153 _ => return contract_error("training outcome refit state is inconsistent"),
3154 }
3155 Ok(())
3156 }
3157
3158 fn validate_outputs(&self) -> Result<BTreeSet<NodeId>> {
3159 if self.outputs.is_empty() {
3160 return contract_error("training outcome requires at least one bound output");
3161 }
3162 let mut previous: Option<&str> = None;
3163 let mut roots = Vec::new();
3164 for output in &self.outputs {
3165 if previous.is_some_and(|value| value >= output.binding.binding_id.as_str()) {
3166 return contract_error(
3167 "training outcome outputs must be strictly sorted by binding_id",
3168 );
3169 }
3170 previous = Some(output.binding.binding_id.as_str());
3171 output.validate(&self.effective_plan)?;
3172 roots.push(output.binding.node_id.clone());
3173 }
3174 predictor_closure(&self.effective_plan, roots)
3175 }
3176
3177 fn validate_artifacts(&self, closure: &BTreeSet<NodeId>) -> Result<()> {
3178 if !self.refit.requested {
3179 if !self.execution_bundle.refit_artifacts.is_empty() {
3180 return contract_error("no-refit training outcome contains refit artifacts");
3181 }
3182 return Ok(());
3183 }
3184 if self.execution_bundle.refit_artifacts.is_empty() {
3185 return contract_error("completed refit requires at least one artifact");
3186 }
3187 let expected_artifact_nodes = closure
3188 .iter()
3189 .filter(|node_id| {
3190 let plan = &self.effective_plan.node_plans[*node_id];
3191 plan.supported_phases.contains(&Phase::Refit)
3192 && plan
3193 .controller_capabilities
3194 .contains(&ControllerCapability::EmitsArtifacts)
3195 })
3196 .cloned()
3197 .collect::<BTreeSet<_>>();
3198 let artifact_nodes = self
3199 .execution_bundle
3200 .refit_artifacts
3201 .iter()
3202 .map(|record| record.node_id.clone())
3203 .collect::<BTreeSet<_>>();
3204 if artifact_nodes != expected_artifact_nodes {
3205 return contract_error(
3206 "refit artifact nodes do not exactly match predictor closure REFIT artifact emitters",
3207 );
3208 }
3209 for output in &self.outputs {
3210 if !artifact_nodes.contains(&output.binding.node_id) {
3211 return contract_error("final output node has no refit artifact");
3212 }
3213 }
3214 Ok(())
3215 }
3216
3217 fn validate_lineage(&self, closure: &BTreeSet<NodeId>) -> Result<()> {
3218 if self.lineage.is_empty() {
3219 return contract_error("training outcome requires portable lineage");
3220 }
3221 let record_ids = self
3222 .lineage
3223 .iter()
3224 .map(|record| record.record_id.clone())
3225 .collect::<Vec<_>>();
3226 if record_ids.windows(2).any(|pair| pair[0] >= pair[1]) {
3227 return contract_error("training outcome lineage must be sorted by record_id");
3228 }
3229 let by_id = self
3230 .lineage
3231 .iter()
3232 .map(|record| (record.record_id.clone(), record))
3233 .collect::<BTreeMap<_, _>>();
3234 if by_id.len() != self.lineage.len() {
3235 return contract_error("training outcome lineage contains duplicate record ids");
3236 }
3237 let mut coordinates = BTreeMap::new();
3238 for record in &self.lineage {
3239 record.validate()?;
3240 if record.run_id != self.run_id
3241 || record.variant_id.as_ref() != Some(&self.selected_variant_id)
3242 || !closure.contains(&record.node_id)
3243 {
3244 return contract_error(
3245 "training outcome lineage run, variant, or predictor closure is inconsistent",
3246 );
3247 }
3248 if !matches!(record.phase, Phase::FitCv | Phase::Select | Phase::Refit) {
3249 return contract_error("training outcome lineage contains a non-training phase");
3250 }
3251 let plan = &self.effective_plan.node_plans[&record.node_id];
3252 if record.controller_id != plan.controller_id
3253 || record.controller_version != plan.controller_version
3254 || record.params_fingerprint != plan.params_fingerprint
3255 {
3256 return contract_error("training outcome lineage does not match node plan");
3257 }
3258 let key = (record.phase, record.fold_id.clone(), record.node_id.clone());
3259 if coordinates.insert(key, record).is_some() {
3260 return contract_error("training outcome lineage duplicates phase/fold/node");
3261 }
3262 if record
3263 .input_lineage
3264 .iter()
3265 .any(|input| !by_id.contains_key(input))
3266 {
3267 return contract_error("training outcome lineage references an unknown input");
3268 }
3269 }
3270 validate_lineage_coordinates(self, closure, &coordinates)
3271 }
3272}
3273
3274impl BoundTrainingOutput {
3275 pub(crate) fn validate(&self, plan: &ExecutionPlan) -> Result<()> {
3276 if let Some(schema_version) = self.schema_version {
3277 if schema_version != BOUND_TRAINING_OUTPUT_SCHEMA_VERSION {
3278 return contract_error(format!(
3279 "bound training output schema_version {schema_version} is unsupported; current {}",
3280 BOUND_TRAINING_OUTPUT_SCHEMA_VERSION
3281 ));
3282 }
3283 }
3284 self.binding.validate(&plan.graph_plan.graph)?;
3285 if self.predictions.is_empty()
3286 && self.observation_predictions.is_empty()
3287 && self.aggregated_predictions.is_empty()
3288 {
3289 return contract_error("bound training output contains no prediction block");
3290 }
3291 match self.binding.prediction_level {
3292 PredictionLevel::Observation
3293 if !self.predictions.is_empty() || !self.aggregated_predictions.is_empty() =>
3294 {
3295 return contract_error(
3296 "observation output binding cannot contain sample or aggregated predictions",
3297 );
3298 }
3299 PredictionLevel::Sample if !self.observation_predictions.is_empty() => {
3300 return contract_error(
3301 "sample output binding cannot contain observation predictions",
3302 );
3303 }
3304 PredictionLevel::Target | PredictionLevel::Group
3305 if !self.predictions.is_empty() || !self.observation_predictions.is_empty() =>
3306 {
3307 return contract_error(
3308 "target/group output binding cannot contain sample or observation predictions",
3309 );
3310 }
3311 _ => {}
3312 }
3313 let expected_names = expected_output_columns(&self.binding);
3314 for block in &self.predictions {
3315 block.validate_shape()?;
3316 validate_bound_block(
3317 plan,
3318 &self.binding,
3319 &block.producer_node,
3320 &block.producer_port,
3321 &block.partition,
3322 block.fold_id.as_ref(),
3323 &block.target_names,
3324 &expected_names,
3325 )?;
3326 }
3327 for block in &self.observation_predictions {
3328 block.validate_shape()?;
3329 validate_bound_block(
3330 plan,
3331 &self.binding,
3332 &block.producer_node,
3333 &block.producer_port,
3334 &block.partition,
3335 block.fold_id.as_ref(),
3336 &block.target_names,
3337 &expected_names,
3338 )?;
3339 }
3340 for block in &self.aggregated_predictions {
3341 block.validate_shape()?;
3342 if block.level != self.binding.prediction_level {
3343 return contract_error(
3344 "bound aggregated prediction level does not match output binding",
3345 );
3346 }
3347 validate_bound_block(
3348 plan,
3349 &self.binding,
3350 &block.producer_node,
3351 &block.producer_port,
3352 &block.partition,
3353 block.fold_id.as_ref(),
3354 &block.target_names,
3355 &expected_names,
3356 )?;
3357 }
3358 match self.binding.prediction_level {
3359 PredictionLevel::Observation if self.observation_predictions.is_empty() => {
3360 return contract_error(
3361 "observation output binding requires observation predictions",
3362 );
3363 }
3364 PredictionLevel::Target | PredictionLevel::Group
3365 if self.aggregated_predictions.is_empty() =>
3366 {
3367 return contract_error(
3368 "target/group output binding requires aggregated predictions",
3369 );
3370 }
3371 _ => {}
3372 }
3373 Ok(())
3374 }
3375}
3376
3377#[allow(clippy::too_many_arguments)]
3378fn validate_bound_block(
3379 plan: &ExecutionPlan,
3380 binding: &OutputBinding,
3381 producer: &NodeId,
3382 producer_port: &Option<String>,
3383 partition: &PredictionPartition,
3384 fold_id: Option<&crate::ids::FoldId>,
3385 target_names: &[String],
3386 expected_names: &[String],
3387) -> Result<()> {
3388 if producer != &binding.node_id
3389 || !producer_port_matches_graph_output(
3390 plan,
3391 &binding.node_id,
3392 &binding.port_name,
3393 producer_port,
3394 )
3395 || target_names != expected_names
3396 {
3397 return contract_error(
3398 "bound prediction producer, producer_port or target order does not match output binding",
3399 );
3400 }
3401 if binding.prediction_source == PredictionSource::FinalRefit
3402 && (partition != &PredictionPartition::Final || fold_id.is_some())
3403 {
3404 return contract_error("final_refit output blocks must use final partition without fold");
3405 }
3406 if binding.prediction_source == PredictionSource::CvEnsemble
3407 && (!is_cv_ensemble_partition(partition) || fold_id.is_none())
3408 {
3409 return contract_error(
3410 "cv_ensemble output blocks must use validation partition with a fold id",
3411 );
3412 }
3413 Ok(())
3414}
3415
3416fn expected_output_columns(binding: &OutputBinding) -> Vec<String> {
3417 if binding.prediction_kind == PredictionKind::ClassProbability {
3418 binding
3419 .target_names
3420 .iter()
3421 .zip(&binding.class_labels)
3422 .flat_map(|(target, labels)| {
3423 labels.iter().map(move |label| format!("{target}:{label}"))
3424 })
3425 .collect()
3426 } else {
3427 binding.target_names.clone()
3428 }
3429}
3430
3431fn selected_variant_parameter_patches(
3432 variant: &crate::generation::VariantPlan,
3433) -> Result<Vec<ParameterPatch>> {
3434 let mut patches = Vec::new();
3435 for choice in variant.choices.values() {
3436 for override_spec in &choice.param_overrides {
3437 for (key, value) in &override_spec.params {
3438 append_parameter_leaves(
3439 &override_spec.node_id,
3440 vec![key.clone()],
3441 value,
3442 &mut patches,
3443 )?;
3444 }
3445 }
3446 }
3447 patches.sort_by(|left, right| {
3448 (&left.node_id, left.namespace, &left.path).cmp(&(
3449 &right.node_id,
3450 right.namespace,
3451 &right.path,
3452 ))
3453 });
3454 if patches.windows(2).any(|pair| {
3455 pair[0].node_id == pair[1].node_id
3456 && pair[0].namespace == pair[1].namespace
3457 && pair[0].path == pair[1].path
3458 }) {
3459 return contract_error("selected variant overrides contain duplicate leaf paths");
3460 }
3461 Ok(patches)
3462}
3463
3464fn merge_training_parameter_patches(
3465 request_patches: &[ParameterPatch],
3466 selected_variant: &crate::generation::VariantPlan,
3467) -> Result<Vec<ParameterPatch>> {
3468 let mut patches = request_patches.to_vec();
3469 patches.extend(selected_variant_parameter_patches(selected_variant)?);
3470 sort_and_validate_training_parameter_patch_keys(&mut patches, false)?;
3471 Ok(patches)
3472}
3473
3474fn validate_outcome_parameter_patches(
3475 plan: &ExecutionPlan,
3476 patches: &[ParameterPatch],
3477 selected_variant_patches: &[ParameterPatch],
3478) -> Result<()> {
3479 let mut patches = patches.to_vec();
3480 sort_and_validate_training_parameter_patch_keys(&mut patches, true)?;
3481 let keys = patches
3482 .iter()
3483 .map(parameter_patch_key)
3484 .collect::<BTreeSet<_>>();
3485 for selected in selected_variant_patches {
3486 if !keys.contains(¶meter_patch_key(selected)) {
3487 return contract_error(
3488 "training outcome parameter_patches are missing a selected variant override",
3489 );
3490 }
3491 }
3492 for patch in &patches {
3493 validate_materialized_patch(plan, patch)?;
3494 }
3495 Ok(())
3496}
3497
3498fn sort_and_validate_training_parameter_patch_keys(
3499 patches: &mut [ParameterPatch],
3500 require_already_sorted: bool,
3501) -> Result<()> {
3502 for patch in patches.iter() {
3503 patch.validate()?;
3504 if patch.namespace != ParameterNamespace::Operator {
3505 return contract_error(
3506 "training outcome parameter_patches must use operator namespace",
3507 );
3508 }
3509 }
3510 let original = patches.to_vec();
3511 patches.sort_by(|left, right| parameter_patch_key(left).cmp(¶meter_patch_key(right)));
3512 if require_already_sorted && patches != original {
3513 return contract_error(
3514 "training outcome parameter_patches must be sorted by (node_id, namespace, path)",
3515 );
3516 }
3517 for pair in patches.windows(2) {
3518 let left = &pair[0];
3519 let right = &pair[1];
3520 if parameter_patch_key(left) == parameter_patch_key(right) {
3521 return contract_error(
3522 "training outcome parameter_patches contain duplicate leaf paths",
3523 );
3524 }
3525 if left.node_id == right.node_id
3526 && left.namespace == right.namespace
3527 && (right.path.starts_with(&left.path) || left.path.starts_with(&right.path))
3528 {
3529 return contract_error(
3530 "training outcome parameter_patches contain a conflicting parent/child path",
3531 );
3532 }
3533 }
3534 Ok(())
3535}
3536
3537fn parameter_patch_key(patch: &ParameterPatch) -> (&NodeId, ParameterNamespace, &[String]) {
3538 (&patch.node_id, patch.namespace, patch.path.as_slice())
3539}
3540
3541fn append_parameter_leaves(
3542 node_id: &NodeId,
3543 path: Vec<String>,
3544 value: &serde_json::Value,
3545 output: &mut Vec<ParameterPatch>,
3546) -> Result<()> {
3547 if let serde_json::Value::Object(object) = value {
3548 for (key, child) in object {
3549 let mut child_path = path.clone();
3550 child_path.push(key.clone());
3551 append_parameter_leaves(node_id, child_path, child, output)?;
3552 }
3553 return Ok(());
3554 }
3555 output.push(ParameterPatch {
3556 schema_version: PARAMETER_PATCH_SCHEMA_VERSION,
3557 node_id: node_id.clone(),
3558 namespace: ParameterNamespace::Operator,
3559 path,
3560 value: value.clone(),
3561 });
3562 Ok(())
3563}
3564
3565fn validate_materialized_patch(plan: &ExecutionPlan, patch: &ParameterPatch) -> Result<()> {
3566 patch.validate()?;
3567 if patch.namespace != ParameterNamespace::Operator {
3568 return contract_error("selected variant patches must use operator namespace");
3569 }
3570 let node = plan.node_plans.get(&patch.node_id).ok_or_else(|| {
3571 DagMlError::CampaignValidation(format!(
3572 "selected parameter patch references absent node `{}`",
3573 patch.node_id
3574 ))
3575 })?;
3576 let mut current = serde_json::Value::Object(node.params.clone().into_iter().collect());
3577 for segment in &patch.path {
3578 current = current
3579 .as_object()
3580 .and_then(|object| object.get(segment))
3581 .cloned()
3582 .ok_or_else(|| {
3583 DagMlError::CampaignValidation(format!(
3584 "selected parameter patch path for `{}` is not materialized",
3585 patch.node_id
3586 ))
3587 })?;
3588 }
3589 if current != patch.value {
3590 return contract_error("selected parameter patch value is not materialized in plan");
3591 }
3592 Ok(())
3593}
3594
3595fn predictor_closure(
3596 plan: &ExecutionPlan,
3597 roots: impl IntoIterator<Item = NodeId>,
3598) -> Result<BTreeSet<NodeId>> {
3599 let mut pending = roots.into_iter().collect::<Vec<_>>();
3600 let mut closure = BTreeSet::new();
3601 while let Some(node_id) = pending.pop() {
3602 if !closure.insert(node_id.clone()) {
3603 continue;
3604 }
3605 let node = plan.node_plans.get(&node_id).ok_or_else(|| {
3606 DagMlError::CampaignValidation(format!(
3607 "training outcome closure references absent node `{node_id}`"
3608 ))
3609 })?;
3610 pending.extend(node.input_nodes.iter().cloned());
3611 }
3612 Ok(closure)
3613}
3614
3615struct NodeReplayFacts {
3617 supported_phases: BTreeSet<Phase>,
3618 requires_retained_state: bool,
3627 has_retained_artifact: bool,
3629}
3630
3631struct OofEdgeReplayFacts {
3633 has_bundle_requirement: bool,
3634 has_cache_record: bool,
3635 has_portable_payload: bool,
3636}
3637
3638struct ClosureReplayFacts {
3641 nodes: Vec<NodeReplayFacts>,
3642 oof_edges: Vec<OofEdgeReplayFacts>,
3643}
3644
3645fn derive_replayable_phases_from_facts(
3655 completed_refit: bool,
3656 facts: &ClosureReplayFacts,
3657) -> Vec<Phase> {
3658 let all_support = |phase: Phase| {
3659 facts
3660 .nodes
3661 .iter()
3662 .all(|node| node.supported_phases.contains(&phase))
3663 };
3664 let inference_state_present = facts
3665 .nodes
3666 .iter()
3667 .all(|node| !node.requires_retained_state || node.has_retained_artifact);
3668 let oof_self_contained = facts.oof_edges.iter().all(|edge| {
3669 edge.has_bundle_requirement && edge.has_cache_record && edge.has_portable_payload
3670 });
3671
3672 let mut phases = Vec::new();
3673 if completed_refit {
3674 if all_support(Phase::Predict) && inference_state_present {
3675 phases.push(Phase::Predict);
3676 }
3677 if all_support(Phase::Explain) && inference_state_present {
3678 phases.push(Phase::Explain);
3679 }
3680 } else if all_support(Phase::Refit) && oof_self_contained {
3681 phases.push(Phase::Refit);
3682 }
3683 phases
3684}
3685
3686fn closure_replay_facts(
3692 plan: &ExecutionPlan,
3693 closure: &BTreeSet<NodeId>,
3694 execution_bundle: &ExecutionBundle,
3695 portable_prediction_caches: Option<&BundlePredictionCachePayloadSet>,
3696) -> Result<ClosureReplayFacts> {
3697 let artifact_nodes = execution_bundle
3698 .refit_artifacts
3699 .iter()
3700 .map(|record| record.node_id.clone())
3701 .collect::<BTreeSet<_>>();
3702 let requirement_keys = execution_bundle
3703 .prediction_requirements
3704 .iter()
3705 .map(|requirement| requirement.key())
3706 .collect::<BTreeSet<_>>();
3707 let cache_keys = execution_bundle
3708 .prediction_caches
3709 .iter()
3710 .map(|record| record.requirement_key.clone())
3711 .collect::<BTreeSet<_>>();
3712 let payload_keys = portable_prediction_caches
3713 .map(|set| {
3714 set.caches
3715 .iter()
3716 .map(|payload| payload.requirement_key.clone())
3717 .collect::<BTreeSet<_>>()
3718 })
3719 .unwrap_or_default();
3720
3721 let nodes = closure
3722 .iter()
3723 .map(|node_id| {
3724 let node_plan = plan.node_plans.get(node_id).ok_or_else(|| {
3725 DagMlError::CampaignValidation(format!(
3726 "replay derivation references absent node `{node_id}`"
3727 ))
3728 })?;
3729 let requires_retained_state = node_plan
3734 .controller_capabilities
3735 .contains(&ControllerCapability::Stateful)
3736 || node_plan
3737 .controller_capabilities
3738 .contains(&ControllerCapability::EmitsArtifacts);
3739 Ok(NodeReplayFacts {
3740 supported_phases: node_plan.supported_phases.clone(),
3741 requires_retained_state,
3742 has_retained_artifact: artifact_nodes.contains(node_id),
3743 })
3744 })
3745 .collect::<Result<Vec<_>>>()?;
3746 let oof_edges = plan
3747 .graph_plan
3748 .graph
3749 .edges
3750 .iter()
3751 .filter(|edge| {
3752 edge.contract.requires_oof
3753 && closure.contains(&edge.source.node_id)
3754 && closure.contains(&edge.target.node_id)
3755 })
3756 .map(|edge| {
3757 let key = crate::bundle::bundle_prediction_requirement_key(
3758 &edge.source.node_id,
3759 &edge.source.port_name,
3760 &edge.target.node_id,
3761 &edge.target.port_name,
3762 );
3763 OofEdgeReplayFacts {
3764 has_bundle_requirement: requirement_keys.contains(&key),
3765 has_cache_record: cache_keys.contains(&key),
3766 has_portable_payload: payload_keys.contains(&key),
3767 }
3768 })
3769 .collect::<Vec<_>>();
3770
3771 Ok(ClosureReplayFacts { nodes, oof_edges })
3772}
3773
3774fn derive_replayable_phases(
3783 plan: &ExecutionPlan,
3784 closure: &BTreeSet<NodeId>,
3785 refit: &TrainingRefitOutcome,
3786 execution_bundle: &ExecutionBundle,
3787 portable_prediction_caches: Option<&BundlePredictionCachePayloadSet>,
3788) -> Result<Vec<Phase>> {
3789 let facts = closure_replay_facts(plan, closure, execution_bundle, portable_prediction_caches)?;
3790 Ok(derive_replayable_phases_from_facts(
3791 matches!(refit.status, TrainingRefitStatus::Completed),
3792 &facts,
3793 ))
3794}
3795
3796pub(crate) fn closure_predict_replayable(
3804 plan: &ExecutionPlan,
3805 closure: &BTreeSet<NodeId>,
3806 execution_bundle: &ExecutionBundle,
3807) -> Result<bool> {
3808 let facts = closure_replay_facts(plan, closure, execution_bundle, None)?;
3809 Ok(derive_replayable_phases_from_facts(true, &facts).contains(&Phase::Predict))
3810}
3811
3812fn expected_base_influence_kind(
3813 plan: &ExecutionPlan,
3814 node_id: &NodeId,
3815) -> Option<TrainingInfluenceKind> {
3816 let node_plan = &plan.node_plans[node_id];
3817 if matches!(
3818 node_plan.fit_scope,
3819 ControllerFitScope::Stateless | ControllerFitScope::InferenceOnly
3820 ) {
3821 return None;
3822 }
3823 let oof_consumer = plan
3824 .graph_plan
3825 .graph
3826 .edges
3827 .iter()
3828 .any(|edge| edge.contract.requires_oof && edge.target.node_id == *node_id);
3829 Some(
3830 if oof_consumer
3831 || node_plan
3832 .controller_capabilities
3833 .contains(&ControllerCapability::TrainsAggregation)
3834 {
3835 TrainingInfluenceKind::TrainedMetaAggregation
3836 } else if node_plan.kind == NodeKind::Model {
3837 TrainingInfluenceKind::ModelFit
3838 } else if node_plan.kind == NodeKind::Tuner {
3839 TrainingInfluenceKind::HpoSelection
3840 } else {
3841 TrainingInfluenceKind::TransformFit
3842 },
3843 )
3844}
3845
3846fn validate_influence_against_closure(
3847 influence: &TrainingInfluenceManifest,
3848 plan: &ExecutionPlan,
3849 closure: &BTreeSet<NodeId>,
3850) -> Result<()> {
3851 let mut actual_base = BTreeMap::<NodeId, BTreeSet<TrainingInfluenceKind>>::new();
3852 for entry in &influence.entries {
3853 let Some(node_id) = &entry.node_id else {
3854 continue;
3855 };
3856 if !closure.contains(node_id) {
3857 return contract_error("training influence node is outside predictor closure");
3858 }
3859 if !influence_kind_allowed_by_node_role_or_capability(plan, node_id, entry.kind) {
3860 return contract_error(
3861 "training influence kind is not allowed by node role or capability",
3862 );
3863 }
3864 if expected_base_influence_kind(plan, node_id) == Some(entry.kind) {
3865 actual_base
3866 .entry(node_id.clone())
3867 .or_default()
3868 .insert(entry.kind);
3869 }
3870 }
3871 let expected = closure
3872 .iter()
3873 .filter(|node_id| {
3874 plan.node_plans[*node_id]
3875 .supported_phases
3876 .contains(&Phase::FitCv)
3877 && expected_base_influence_kind(plan, node_id).is_some()
3878 })
3879 .cloned()
3880 .collect::<BTreeSet<_>>();
3881 if actual_base.keys().cloned().collect::<BTreeSet<_>>() != expected {
3882 return contract_error(
3883 "training influence fitting nodes do not exactly match predictor closure",
3884 );
3885 }
3886 for node_id in expected {
3887 if actual_base[&node_id]
3888 != BTreeSet::from([expected_base_influence_kind(plan, &node_id)
3889 .expect("expected fitting nodes have a base influence kind")])
3890 {
3891 return contract_error("training influence fitting kind does not match node role");
3892 }
3893 }
3894 Ok(())
3895}
3896
3897fn influence_kind_allowed_by_node_role_or_capability(
3898 plan: &ExecutionPlan,
3899 node_id: &NodeId,
3900 kind: TrainingInfluenceKind,
3901) -> bool {
3902 if expected_base_influence_kind(plan, node_id) == Some(kind) {
3903 return true;
3904 }
3905 let capabilities = &plan.node_plans[node_id].controller_capabilities;
3906 match kind {
3907 TrainingInfluenceKind::HpoSelection => {
3908 capabilities.contains(&ControllerCapability::PerformsInternalTuning)
3909 }
3910 TrainingInfluenceKind::EarlyStopping => {
3911 capabilities.contains(&ControllerCapability::UsesEarlyStopping)
3912 }
3913 TrainingInfluenceKind::WeightingResampling => {
3914 capabilities.contains(&ControllerCapability::UsesTrainingWeights)
3915 }
3916 TrainingInfluenceKind::TransformFit
3917 | TrainingInfluenceKind::ModelFit
3918 | TrainingInfluenceKind::TrainedMetaAggregation => false,
3919 }
3920}
3921
3922fn validate_lineage_coordinates(
3923 outcome: &TrainingOutcome,
3924 closure: &BTreeSet<NodeId>,
3925 coordinates: &BTreeMap<(Phase, Option<crate::ids::FoldId>, NodeId), &LineageRecord>,
3926) -> Result<()> {
3927 let fold_set = outcome.effective_plan.fold_set.as_ref().ok_or_else(|| {
3928 DagMlError::CampaignValidation(
3929 "training outcome FIT_CV lineage requires a fold_set".to_string(),
3930 )
3931 })?;
3932 let expected_fit = closure
3933 .iter()
3934 .filter(|node_id| {
3935 outcome.effective_plan.node_plans[*node_id]
3936 .supported_phases
3937 .contains(&Phase::FitCv)
3938 })
3939 .flat_map(|node_id| {
3940 fold_set
3941 .folds
3942 .iter()
3943 .map(move |fold| (Phase::FitCv, Some(fold.fold_id.clone()), node_id.clone()))
3944 })
3945 .collect::<BTreeSet<_>>();
3946 let actual_fit = coordinates
3947 .keys()
3948 .filter(|(phase, _, _)| *phase == Phase::FitCv)
3949 .cloned()
3950 .collect::<BTreeSet<_>>();
3951 if actual_fit != expected_fit {
3952 return contract_error(
3953 "training outcome FIT_CV lineage does not exactly cover closure folds",
3954 );
3955 }
3956 let expected_refit = if outcome.refit.requested {
3957 closure
3958 .iter()
3959 .filter(|node_id| {
3960 outcome.effective_plan.node_plans[*node_id]
3961 .supported_phases
3962 .contains(&Phase::Refit)
3963 })
3964 .map(|node_id| (Phase::Refit, None, node_id.clone()))
3965 .collect::<BTreeSet<_>>()
3966 } else {
3967 BTreeSet::new()
3968 };
3969 let actual_refit = coordinates
3970 .keys()
3971 .filter(|(phase, _, _)| *phase == Phase::Refit)
3972 .cloned()
3973 .collect::<BTreeSet<_>>();
3974 if actual_refit != expected_refit {
3975 return contract_error("training outcome REFIT lineage does not exactly cover closure");
3976 }
3977
3978 for ((phase, fold, node_id), record) in coordinates {
3979 if *phase == Phase::Select {
3980 continue;
3981 }
3982 let plan = &outcome.effective_plan.node_plans[node_id];
3983 let expected_inputs = plan
3984 .input_nodes
3985 .iter()
3986 .filter(|input| {
3987 outcome.effective_plan.node_plans[*input]
3988 .supported_phases
3989 .contains(phase)
3990 })
3991 .map(|input| {
3992 coordinates
3993 .get(&(*phase, fold.clone(), input.clone()))
3994 .map(|upstream| upstream.record_id.clone())
3995 .ok_or_else(|| {
3996 DagMlError::CampaignValidation(format!(
3997 "training lineage is missing upstream `{input}`"
3998 ))
3999 })
4000 })
4001 .collect::<Result<Vec<LineageId>>>()?;
4002 let mut expected_inputs = expected_inputs;
4003 expected_inputs.sort();
4004 if record.input_lineage != expected_inputs {
4005 return contract_error(
4006 "training outcome lineage input_lineage does not exactly match plan",
4007 );
4008 }
4009 if *phase == Phase::FitCv && !record.artifact_refs.is_empty() {
4010 return contract_error("FIT_CV lineage must not retain refit artifacts");
4011 }
4012 if *phase == Phase::Refit {
4013 let mut expected_artifacts = outcome
4014 .execution_bundle
4015 .refit_artifacts
4016 .iter()
4017 .filter(|artifact| artifact.node_id == *node_id)
4018 .map(|artifact| artifact.artifact.clone())
4019 .collect::<Vec<_>>();
4020 expected_artifacts.sort_by(|left, right| left.id.cmp(&right.id));
4021 let mut actual_artifacts = record.artifact_refs.clone();
4022 actual_artifacts.sort_by(|left, right| left.id.cmp(&right.id));
4023 if actual_artifacts != expected_artifacts {
4024 return contract_error("REFIT lineage artifact_refs do not match execution bundle");
4025 }
4026 }
4027 }
4028 Ok(())
4029}
4030
4031fn tcv1_fingerprint<T: Serialize + ?Sized>(value: &T, label: &str) -> Result<String> {
4032 let json = serde_json::to_string(value)?;
4033 parse_typed_json(&json)
4034 .map_err(|error| {
4035 DagMlError::CampaignValidation(format!("{label} is not valid TCV1: {error}"))
4036 })?
4037 .fingerprint()
4038 .map_err(|error| {
4039 DagMlError::CampaignValidation(format!("{label} TCV1 fingerprint failed: {error}"))
4040 })
4041}
4042
4043fn tcv1_fingerprint_without<T: Serialize>(value: &T, field: &str, label: &str) -> Result<String> {
4044 let json = serde_json::to_string(value)?;
4045 parse_typed_json(&json)
4046 .map_err(|error| {
4047 DagMlError::CampaignValidation(format!("{label} is not valid TCV1: {error}"))
4048 })?
4049 .fingerprint_without(field)
4050 .map_err(|error| {
4051 DagMlError::CampaignValidation(format!("{label} TCV1 fingerprint failed: {error}"))
4052 })
4053}
4054
4055fn validate_sha256(label: &str, value: &str) -> Result<()> {
4056 if value.len() != 64
4057 || !value
4058 .bytes()
4059 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
4060 {
4061 return contract_error(format!("{label} must be lowercase sha256"));
4062 }
4063 Ok(())
4064}
4065
4066fn validate_all_identity_relations(
4067 identities: &[TrainingDataIdentity],
4068 relation_fingerprint: &str,
4069) -> Result<()> {
4070 if identities
4071 .iter()
4072 .any(|identity| identity.relation_fingerprint != relation_fingerprint)
4073 {
4074 return contract_error(
4075 "training outcome data identities do not all bind the influence relation",
4076 );
4077 }
4078 Ok(())
4079}
4080
4081fn validate_sorted_unique_text(label: &str, values: &[String]) -> Result<()> {
4082 if values.iter().any(|value| value.trim().is_empty()) {
4083 return contract_error(format!("{label} contains an empty value"));
4084 }
4085 if values.windows(2).any(|pair| pair[0] >= pair[1]) {
4086 return contract_error(format!("{label} must be strictly sorted and unique"));
4087 }
4088 Ok(())
4089}
4090
4091fn contract_error<T>(message: impl Into<String>) -> Result<T> {
4092 Err(DagMlError::CampaignValidation(message.into()))
4093}
4094
4095#[cfg(test)]
4096mod replay_phase_tests {
4097 use super::{
4098 derive_replayable_phases_from_facts, ClosureReplayFacts, NodeReplayFacts,
4099 OofEdgeReplayFacts,
4100 };
4101 use crate::phase::Phase;
4102 use std::collections::BTreeSet;
4103
4104 fn node(
4105 supported: &[Phase],
4106 requires_retained_state: bool,
4107 has_retained_artifact: bool,
4108 ) -> NodeReplayFacts {
4109 NodeReplayFacts {
4110 supported_phases: supported.iter().copied().collect::<BTreeSet<_>>(),
4111 requires_retained_state,
4112 has_retained_artifact,
4113 }
4114 }
4115
4116 fn oof(
4117 has_bundle_requirement: bool,
4118 has_cache_record: bool,
4119 has_portable_payload: bool,
4120 ) -> OofEdgeReplayFacts {
4121 OofEdgeReplayFacts {
4122 has_bundle_requirement,
4123 has_cache_record,
4124 has_portable_payload,
4125 }
4126 }
4127
4128 #[test]
4133 fn completed_refit_full_support_matrix_predict_then_explain() {
4134 let facts = ClosureReplayFacts {
4135 nodes: vec![
4136 node(
4137 &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
4138 true,
4139 true,
4140 ),
4141 node(
4142 &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
4143 true,
4144 true,
4145 ),
4146 ],
4147 oof_edges: vec![],
4148 };
4149 assert_eq!(
4150 derive_replayable_phases_from_facts(true, &facts),
4151 vec![Phase::Predict, Phase::Explain]
4152 );
4153 }
4154
4155 #[test]
4158 fn completed_refit_predict_only_when_explain_unsupported() {
4159 let facts = ClosureReplayFacts {
4160 nodes: vec![
4161 node(&[Phase::FitCv, Phase::Refit, Phase::Predict], true, true),
4162 node(&[Phase::FitCv, Phase::Refit, Phase::Predict], false, false),
4165 ],
4166 oof_edges: vec![],
4167 };
4168 assert_eq!(
4169 derive_replayable_phases_from_facts(true, &facts),
4170 vec![Phase::Predict]
4171 );
4172 }
4173
4174 #[test]
4177 fn upstream_node_missing_phase_blocks_whole_closure() {
4178 let facts = ClosureReplayFacts {
4179 nodes: vec![
4180 node(
4182 &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
4183 true,
4184 true,
4185 ),
4186 node(&[Phase::FitCv, Phase::Refit], false, false),
4188 ],
4189 oof_edges: vec![],
4190 };
4191 assert_eq!(
4192 derive_replayable_phases_from_facts(true, &facts),
4193 Vec::<Phase>::new()
4194 );
4195 }
4196
4197 #[test]
4201 fn completed_refit_missing_artifact_yields_empty() {
4202 let facts = ClosureReplayFacts {
4203 nodes: vec![node(
4204 &[Phase::FitCv, Phase::Refit, Phase::Predict],
4205 true,
4206 false,
4207 )],
4208 oof_edges: vec![],
4209 };
4210 assert_eq!(
4211 derive_replayable_phases_from_facts(true, &facts),
4212 Vec::<Phase>::new()
4213 );
4214 }
4215
4216 #[test]
4220 fn no_refit_refit_requires_self_contained_oof_payload() {
4221 let supported = [Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain];
4222 let backed = ClosureReplayFacts {
4223 nodes: vec![node(&supported, true, false), node(&supported, true, false)],
4224 oof_edges: vec![oof(true, true, true)],
4225 };
4226 assert_eq!(
4227 derive_replayable_phases_from_facts(false, &backed),
4228 vec![Phase::Refit]
4229 );
4230
4231 let missing_payload = ClosureReplayFacts {
4233 nodes: vec![node(&supported, true, false), node(&supported, true, false)],
4234 oof_edges: vec![oof(true, true, false)],
4235 };
4236 assert_eq!(
4237 derive_replayable_phases_from_facts(false, &missing_payload),
4238 Vec::<Phase>::new()
4239 );
4240
4241 let missing_record = ClosureReplayFacts {
4243 nodes: vec![node(&supported, true, false)],
4244 oof_edges: vec![oof(true, false, true)],
4245 };
4246 assert_eq!(
4247 derive_replayable_phases_from_facts(false, &missing_record),
4248 Vec::<Phase>::new()
4249 );
4250 }
4251
4252 #[test]
4255 fn no_refit_without_oof_edges_is_vacuously_refit() {
4256 let facts = ClosureReplayFacts {
4257 nodes: vec![node(
4258 &[Phase::FitCv, Phase::Refit, Phase::Predict],
4259 false,
4260 false,
4261 )],
4262 oof_edges: vec![],
4263 };
4264 assert_eq!(
4265 derive_replayable_phases_from_facts(false, &facts),
4266 vec![Phase::Refit]
4267 );
4268 }
4269
4270 #[test]
4272 fn no_refit_without_refit_support_yields_empty() {
4273 let facts = ClosureReplayFacts {
4274 nodes: vec![
4275 node(&[Phase::FitCv, Phase::Refit], false, false),
4276 node(&[Phase::FitCv, Phase::Predict], false, false),
4277 ],
4278 oof_edges: vec![],
4279 };
4280 assert_eq!(
4281 derive_replayable_phases_from_facts(false, &facts),
4282 Vec::<Phase>::new()
4283 );
4284 }
4285
4286 #[test]
4291 fn stateless_replay_required_operator_without_artifact_stays_predict_replayable() {
4292 let facts = ClosureReplayFacts {
4293 nodes: vec![node(
4294 &[Phase::FitCv, Phase::Refit, Phase::Predict],
4295 false,
4296 false,
4297 )],
4298 oof_edges: vec![],
4299 };
4300 assert_eq!(
4301 derive_replayable_phases_from_facts(true, &facts),
4302 vec![Phase::Predict]
4303 );
4304 }
4305
4306 #[test]
4309 fn stateful_non_emitter_without_artifact_cannot_advertise_predict() {
4310 let facts = ClosureReplayFacts {
4311 nodes: vec![node(
4312 &[Phase::FitCv, Phase::Refit, Phase::Predict],
4313 true,
4314 false,
4315 )],
4316 oof_edges: vec![],
4317 };
4318 assert_eq!(
4319 derive_replayable_phases_from_facts(true, &facts),
4320 Vec::<Phase>::new()
4321 );
4322 }
4323}
4324
4325#[cfg(test)]
4326mod tests {
4327 use super::*;
4328
4329 #[cfg(dag_ml_workspace_contract_fixtures)]
4330 const REFIT_FIXTURE: &str =
4331 include_str!("../../../examples/fixtures/estimator/training_outcome_refit.v1.json");
4332 #[cfg(dag_ml_workspace_contract_fixtures)]
4333 const NO_REFIT_FIXTURE: &str =
4334 include_str!("../../../examples/fixtures/estimator/training_outcome_no_refit.v1.json");
4335
4336 #[test]
4337 fn cv_ensemble_partition_truth_table_retains_validation_only() {
4338 for (partition, expected) in [
4339 (PredictionPartition::Validation, true),
4340 (PredictionPartition::Train, false),
4341 (PredictionPartition::Test, false),
4342 (PredictionPartition::Final, false),
4343 ] {
4344 assert_eq!(
4345 is_cv_ensemble_partition(&partition),
4346 expected,
4347 "unexpected CvEnsemble retention decision for {partition:?}"
4348 );
4349 }
4350 }
4351
4352 #[cfg(dag_ml_workspace_contract_fixtures)]
4353 #[test]
4354 fn independent_w0_training_outcomes_parse_and_round_trip_fingerprint() {
4355 for fixture in [REFIT_FIXTURE, NO_REFIT_FIXTURE] {
4356 let outcome = TrainingOutcome::from_json(fixture).expect("valid W0 outcome");
4357 assert_eq!(
4358 outcome.compute_fingerprint().unwrap(),
4359 outcome.outcome_fingerprint
4360 );
4361 let serialized = serde_json::to_string(&outcome).unwrap();
4362 let reparsed = TrainingOutcome::from_json(&serialized).unwrap();
4363 assert_eq!(reparsed, outcome);
4364 }
4365 }
4366
4367 #[cfg(dag_ml_workspace_contract_fixtures)]
4368 #[test]
4369 fn strict_parser_rejects_tamper_and_unknown_field() {
4370 let mut tampered: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
4371 tampered["warnings"] = serde_json::json!(["tampered"]);
4372 assert!(TrainingOutcome::from_json(&serde_json::to_string(&tampered).unwrap()).is_err());
4373
4374 let mut unknown: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
4375 unknown["unknown_field"] = serde_json::json!(true);
4376 assert!(TrainingOutcome::from_json(&serde_json::to_string(&unknown).unwrap()).is_err());
4377 }
4378
4379 #[cfg(dag_ml_workspace_contract_fixtures)]
4380 #[test]
4381 fn outcome_rejects_nested_runtime_handle_keys_defense_in_depth() {
4382 let mut outcome = TrainingOutcome::from_json(REFIT_FIXTURE).unwrap();
4383 outcome.diagnostics.insert(
4384 "nested".to_string(),
4385 serde_json::json!({"runtime_handle": "process-local"}),
4386 );
4387 outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
4388 let error = outcome.validate().unwrap_err();
4389 assert!(error.to_string().contains("runtime handles"), "{error}");
4390 }
4391
4392 #[cfg(dag_ml_workspace_contract_fixtures)]
4393 #[test]
4394 fn strict_parser_rejects_future_version_even_when_resigned() {
4395 let mut future: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
4396 future["schema_version"] = serde_json::json!(2);
4397 let mut provisional: TrainingOutcome = serde_json::from_value(future.clone()).unwrap();
4398 provisional.outcome_fingerprint = provisional.compute_fingerprint().unwrap();
4399 future["outcome_fingerprint"] =
4400 serde_json::Value::String(provisional.outcome_fingerprint.clone());
4401 assert!(TrainingOutcome::from_json(&serde_json::to_string(&future).unwrap()).is_err());
4402 }
4403
4404 #[cfg(dag_ml_workspace_contract_fixtures)]
4405 #[test]
4406 fn select_lineage_is_portable_but_foreign_phase_is_rejected() {
4407 let mut outcome = TrainingOutcome::from_json(REFIT_FIXTURE).unwrap();
4408 let mut select = outcome.lineage[0].clone();
4409 select.record_id = LineageId::new("lineage:select:audit").unwrap();
4410 select.phase = Phase::Select;
4411 select.fold_id = None;
4412 select.input_lineage.clear();
4413 select.artifact_refs.clear();
4414 outcome.lineage.push(select.clone());
4415 outcome
4416 .lineage
4417 .sort_by(|left, right| left.record_id.cmp(&right.record_id));
4418 outcome.outcome_fingerprint = zero_fingerprint();
4419 outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
4420 outcome.validate().unwrap();
4421
4422 let added = outcome
4423 .lineage
4424 .iter_mut()
4425 .find(|record| record.record_id.as_str() == "lineage:select:audit")
4426 .unwrap();
4427 added.phase = Phase::Predict;
4428 added.record_id = LineageId::new("lineage:predict:foreign").unwrap();
4429 outcome
4430 .lineage
4431 .sort_by(|left, right| left.record_id.cmp(&right.record_id));
4432 outcome.outcome_fingerprint = zero_fingerprint();
4433 outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
4434 assert!(outcome.validate().is_err());
4435 }
4436
4437 #[test]
4438 fn every_data_identity_must_bind_the_global_relation() {
4439 let relation = "a".repeat(64);
4440 let identity = |key: &str, relation_fingerprint: String| TrainingDataIdentity {
4441 requirement_key: key.to_string(),
4442 schema_fingerprint: "b".repeat(64),
4443 plan_fingerprint: "c".repeat(64),
4444 relation_fingerprint,
4445 data_content_fingerprint: "d".repeat(64),
4446 target_content_fingerprint: "e".repeat(64),
4447 identity_fingerprint: "f".repeat(64),
4448 };
4449 let identities = vec![
4450 identity("model:a.x", relation.clone()),
4451 identity("model:b.x", "9".repeat(64)),
4452 ];
4453 assert!(validate_all_identity_relations(&identities, &relation).is_err());
4454 let identities = vec![
4455 identity("model:a.x", relation.clone()),
4456 identity("model:b.x", relation.clone()),
4457 ];
4458 validate_all_identity_relations(&identities, &relation).unwrap();
4459 }
4460
4461 #[test]
4462 fn auxiliary_report_levels_do_not_override_selection_target_level() {
4463 let report = |producer: &str, level| crate::metrics::RegressionMetricReport {
4464 prediction_id: Some(format!("prediction:{producer}")),
4465 producer_node: NodeId::new(producer).unwrap(),
4466 producer_port: None,
4467 variant_id: Some(VariantId::new("variant:test").unwrap()),
4468 variant_label: None,
4469 partition: PredictionPartition::Validation,
4470 fold_id: Some(crate::ids::FoldId::new("avg").unwrap()),
4471 level,
4472 row_count: 2,
4473 target_width: 1,
4474 target_names: vec!["y".to_string()],
4475 metrics: BTreeMap::from([("rmse".to_string(), 0.1)]),
4476 };
4477 let reports = vec![
4478 report("model:target", PredictionLevel::Sample),
4479 report("model:target", PredictionLevel::Group),
4480 report("model:aux", PredictionLevel::Group),
4481 ];
4482 validate_selection_report_levels(
4483 &reports,
4484 &NodeId::new("model:target").unwrap(),
4485 &None,
4486 PredictionLevel::Sample,
4487 )
4488 .unwrap();
4489 assert!(validate_selection_report_levels(
4490 &reports,
4491 &NodeId::new("model:target").unwrap(),
4492 &None,
4493 PredictionLevel::Target,
4494 )
4495 .is_err());
4496 }
4497}