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