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