1use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10
11use crate::aggregation::{AggregatedPredictionBlock, ObservationPredictionBlock, PredictionUnitId};
12use crate::bundle::{
13 build_aggregated_prediction_cache_payload, build_aggregated_prediction_cache_record,
14 build_execution_bundle_with_prediction_contracts, build_prediction_cache_payload,
15 build_prediction_cache_record, validate_prediction_cache_payload_matches_record,
16 BundlePredictionCachePayload, BundlePredictionCachePayloadSet, BundlePredictionCacheRecord,
17 BundlePredictionRequirement, ExecutionBundle, EXECUTION_BUNDLE_SCHEMA_VERSION,
18 LEGACY_EXECUTION_BUNDLE_SCHEMA_VERSION, LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
19 PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
20};
21use crate::campaign::stable_json_fingerprint;
22use crate::canonical::parse_typed_json;
23use crate::controller::{ControllerCapability, ControllerFitScope};
24use crate::data::data_binding_requirement_key;
25use crate::error::{DagMlError, Result};
26use crate::graph::{NodeKind, PortKind};
27use crate::ids::{BundleId, FoldId, LineageId, NodeId, RunId, SampleId, VariantId};
28use crate::metrics::{
29 RegressionMetricKind, ScoreSet, LEGACY_SCORE_SET_SCHEMA_VERSION, SCORE_SET_SCHEMA_VERSION,
30};
31use crate::oof::{PredictionBlock, PredictionPartition};
32use crate::phase::Phase;
33use crate::plan::ExecutionPlan;
34use crate::policy::PredictionLevel;
35use crate::runtime::{
36 plan_oof_partition_mode, select_best_variant_outcome_by_cv_for_target, InMemoryArtifactStore,
37 LineageRecord, NodeResult, ParallelScheduler, RunContext, RuntimeControllerRegistry,
38 RuntimeDataProvider, SequentialScheduler, VariantExecutionSpec,
39};
40use crate::selection::{
41 select_candidate, EvaluationScope, RefitStrategy, SelectionDecision, SelectionMetric,
42 SelectionPolicy,
43};
44use crate::training::{
45 contains_runtime_handle, ArtifactLoadMode, CacheNamespace, CvArtifactRetention,
46 FittedArtifactMode, OutputBinding, PackageArtifactBinding, ParameterNamespace, ParameterPatch,
47 PortablePredictorPackage, PredictionCacheRetention, PredictionKind, PredictionSource,
48 PredictorTemplate, ResolvedTrainingOutput, TrainingContractProjection, TrainingDataIdentity,
49 TrainingInfluenceKind, TrainingInfluenceManifest, TrainingOutcomeRef, TrainingRequest,
50 TrainingSchedulerBackend, TrainingSchedulerKind, OUTPUT_BINDING_SCHEMA_VERSION,
51 PARAMETER_PATCH_SCHEMA_VERSION, PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION,
52};
53
54pub const TRAINING_OUTCOME_SCHEMA_VERSION: u32 = 2;
55pub const LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION: u32 = 1;
56pub const MIN_READABLE_TRAINING_OUTCOME_SCHEMA_VERSION: u32 = 1;
57pub const BOUND_TRAINING_OUTPUT_SCHEMA_VERSION: u32 = 2;
58pub const TRAINING_OUTCOME_SCHEMA_ID: &str =
59 "https://github.com/GBeurier/dag-ml/schemas/training_outcome.v2.schema.json";
60
61#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct BoundTrainingOutput {
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub schema_version: Option<u32>,
67 pub binding: OutputBinding,
68 pub predictions: Vec<PredictionBlock>,
69 pub observation_predictions: Vec<ObservationPredictionBlock>,
70 pub aggregated_predictions: Vec<AggregatedPredictionBlock>,
71}
72
73#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum TrainingRefitStatus {
76 Completed,
77 Skipped,
78}
79
80#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct TrainingRefitOutcome {
84 pub requested: bool,
85 pub status: TrainingRefitStatus,
86 pub strategy: Option<RefitStrategy>,
87}
88
89#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct TrainingOutcome {
93 pub schema_version: u32,
94 pub outcome_id: String,
95 pub run_id: RunId,
96 pub training_request_fingerprint: String,
97 pub data_identities: Vec<TrainingDataIdentity>,
98 pub selection_output_id: String,
99 pub effective_plan: ExecutionPlan,
100 pub effective_plan_fingerprint: String,
101 pub selected_variant_id: VariantId,
102 pub selected_variant_fingerprint: String,
103 pub parameter_patches: Vec<ParameterPatch>,
104 pub refit: TrainingRefitOutcome,
105 pub score_set: ScoreSet,
106 pub outputs: Vec<BoundTrainingOutput>,
107 pub lineage: Vec<LineageRecord>,
108 pub portable_prediction_caches: Option<BundlePredictionCachePayloadSet>,
109 pub training_influence: TrainingInfluenceManifest,
110 pub execution_bundle: ExecutionBundle,
111 pub replayable_phases: Vec<Phase>,
112 pub warnings: Vec<String>,
113 pub diagnostics: BTreeMap<String, serde_json::Value>,
114 pub outcome_fingerprint: String,
115}
116
117pub struct TrainingExecutionInput<'a> {
123 pub request: &'a TrainingRequest,
124 pub outcome_id: String,
125 pub run_id: RunId,
126 pub bundle_id: BundleId,
127 pub controllers: &'a RuntimeControllerRegistry,
128 pub data_provider: &'a dyn RuntimeDataProvider,
129 pub relations: &'a crate::relation::SampleRelationSet,
130 pub training_influence: &'a TrainingInfluenceManifest,
131 pub artifact_store: &'a mut InMemoryArtifactStore,
132 pub warnings: Vec<String>,
133 pub diagnostics: BTreeMap<String, serde_json::Value>,
134}
135
136#[derive(Clone, Debug)]
137enum NativeTrainingScheduler {
138 Sequential(SequentialScheduler),
139 Parallel(ParallelScheduler),
140}
141
142impl NativeTrainingScheduler {
143 fn from_request(request: &TrainingRequest) -> Result<Self> {
144 let options = &request.options.scheduler;
145 if options.backend == Some(TrainingSchedulerBackend::Processes) {
146 return Err(DagMlError::RuntimeValidation(
147 "native training does not yet implement the processes scheduler backend"
148 .to_string(),
149 ));
150 }
151 match options.kind {
152 TrainingSchedulerKind::Sequential => Ok(Self::Sequential(SequentialScheduler)),
153 TrainingSchedulerKind::Parallel => Ok(Self::Parallel(ParallelScheduler::new(
154 usize::try_from(options.workers).map_err(|_| {
155 DagMlError::RuntimeValidation(
156 "training scheduler worker count does not fit usize".to_string(),
157 )
158 })?,
159 )?)),
160 }
161 }
162
163 fn fit_cv(
164 &self,
165 plan: &ExecutionPlan,
166 controllers: &RuntimeControllerRegistry,
167 data_provider: &dyn RuntimeDataProvider,
168 ctx: &mut RunContext,
169 ) -> Result<Vec<NodeResult>> {
170 match self {
171 Self::Sequential(scheduler) => scheduler.execute_campaign_phase_with_data_provider(
172 plan,
173 controllers,
174 data_provider,
175 ctx,
176 Phase::FitCv,
177 ),
178 Self::Parallel(scheduler) => scheduler.execute_campaign_phase_with_data_provider(
179 plan,
180 controllers,
181 data_provider,
182 ctx,
183 Phase::FitCv,
184 ),
185 }
186 }
187
188 fn refit(
189 &self,
190 plan: &ExecutionPlan,
191 controllers: &RuntimeControllerRegistry,
192 data_provider: &dyn RuntimeDataProvider,
193 artifact_store: &mut InMemoryArtifactStore,
194 ctx: &mut RunContext,
195 ) -> Result<Vec<NodeResult>> {
196 match self {
197 Self::Sequential(scheduler) => scheduler
198 .execute_campaign_phase_with_data_provider_and_artifact_store(
199 plan,
200 controllers,
201 data_provider,
202 artifact_store,
203 ctx,
204 Phase::Refit,
205 ),
206 Self::Parallel(scheduler) => scheduler
207 .execute_campaign_phase_with_data_provider_and_artifact_store(
208 plan,
209 controllers,
210 data_provider,
211 artifact_store,
212 ctx,
213 Phase::Refit,
214 ),
215 }
216 }
217}
218
219pub fn execute_training(input: TrainingExecutionInput<'_>) -> Result<TrainingOutcome> {
227 if !input.artifact_store.is_empty() {
228 return Err(DagMlError::RuntimeValidation(
229 "native training requires an empty artifact store for an isolated outcome".to_string(),
230 ));
231 }
232 RunId::new(input.outcome_id.clone()).map_err(|error| {
233 DagMlError::RuntimeValidation(format!(
234 "native training outcome_id is not a portable identifier: {error}"
235 ))
236 })?;
237 validate_sorted_unique_text("training execution warnings", &input.warnings)?;
238 if contains_runtime_handle(&serde_json::Value::Object(
239 input.diagnostics.clone().into_iter().collect(),
240 )) {
241 return Err(DagMlError::RuntimeValidation(
242 "native training diagnostics cannot contain runtime handles".to_string(),
243 ));
244 }
245
246 let mut projection = input.request.project()?;
247 projection.plan = materialize_request_parameter_patches(projection.plan, input.request)?;
248 projection.validate()?;
249 validate_native_training_options(input.request)?;
250 validate_provider_attestations(
251 &projection,
252 input.request,
253 input.data_provider,
254 input.relations,
255 )?;
256 for node_plan in projection.plan.node_plans.values() {
257 if input.controllers.get(&node_plan.controller_id).is_none() {
258 return Err(DagMlError::RuntimeValidation(format!(
259 "native training controller `{}` for node `{}` is not registered",
260 node_plan.controller_id, node_plan.node_id
261 )));
262 }
263 }
264 if projection.predictor_node_ids
265 != projection
266 .plan
267 .node_plans
268 .keys()
269 .cloned()
270 .collect::<BTreeSet<_>>()
271 {
272 return Err(DagMlError::RuntimeValidation(
273 "native training currently requires the predictor closure to equal the executable plan; refusing to persist unrelated nodes"
274 .to_string(),
275 ));
276 }
277 input.training_influence.validate_for_projection(
278 &projection,
279 input.request,
280 input.relations,
281 )?;
282 let runtime_training_influence = TrainingInfluenceManifest::derive_for_projection(
283 &projection,
284 input.request,
285 input.relations,
286 )?;
287 if input.training_influence != &runtime_training_influence {
288 return Err(DagMlError::RuntimeValidation(
289 "native training influence manifest does not match runtime-derived evidence"
290 .to_string(),
291 ));
292 }
293 if projection.plan.variants.iter().any(|variant| {
294 variant
295 .choices
296 .values()
297 .any(|choice| !choice.param_overrides.is_empty())
298 }) && !input
299 .training_influence
300 .entries
301 .iter()
302 .any(|entry| entry.kind == TrainingInfluenceKind::HpoSelection)
303 {
304 return Err(DagMlError::RuntimeValidation(
305 "selectable parameter overrides require predeclared hpo_selection influence"
306 .to_string(),
307 ));
308 }
309
310 let scheduler = NativeTrainingScheduler::from_request(input.request)?;
311 let selection_metric = parse_selection_metric(input.request)?;
312 let metric_level = effective_selection_metric_level(input.request)?;
313 let selection_output = projection
314 .outputs
315 .iter()
316 .find(|output| output.output_id == input.request.options.selection_output_id)
317 .ok_or_else(|| {
318 DagMlError::RuntimeValidation(
319 "training selection output was not resolved by projection".to_string(),
320 )
321 })?;
322 let selection_output_id = selection_output.output_id.clone();
323 let selection_producer = selection_output.node_id.clone();
324 let selection_producer_port = selection_output.port_name.clone();
325 validate_selection_prediction_kind(selection_metric, selection_output.prediction_kind)?;
326 let selection = select_best_variant_outcome_by_cv_for_target(
327 &projection.plan,
328 &input.run_id,
329 Some(input.request.options.seed),
330 selection_metric,
331 (
332 &selection_producer,
333 Some(selection_producer_port.as_str()),
334 metric_level,
335 ),
336 |candidate_plan, candidate_ctx| {
337 scheduler
338 .fit_cv(
339 candidate_plan,
340 input.controllers,
341 input.data_provider,
342 candidate_ctx,
343 )
344 .map(|_| ())
345 },
346 )?
347 .ok_or_else(|| {
348 DagMlError::RuntimeValidation(
349 "native training SELECT received no scored candidate; controllers must emit targets"
350 .to_string(),
351 )
352 })?;
353
354 validate_selection_report_levels(
355 &selection.selection.validation_reports,
356 &selection_producer,
357 &Some(selection_producer_port.clone()),
358 metric_level,
359 )?;
360 let mut decision = selection.decision;
361 bind_selection_decision(&mut decision, input.request, metric_level)?;
362 let selected_variant_id = selection.selection.selected_variant_id;
363 let effective_plan = materialize_selected_variant(projection.plan, &selected_variant_id)?;
364 effective_plan.validate()?;
367 let selected_variant = effective_plan
368 .variants
369 .iter()
370 .find(|variant| variant.variant_id == selected_variant_id)
371 .cloned()
372 .ok_or_else(|| {
373 DagMlError::RuntimeValidation(
374 "selected variant disappeared while materializing the plan".to_string(),
375 )
376 })?;
377
378 let mut selected_ctx = RunContext::new(input.run_id.clone(), Some(input.request.options.seed));
379 selected_ctx.variant_id = Some(selected_variant_id.clone());
380 let fit_cv_results = scheduler.fit_cv(
381 &effective_plan,
382 input.controllers,
383 input.data_provider,
384 &mut selected_ctx,
385 )?;
386 selected_ctx.collect_cross_fold_validation_scores(plan_oof_partition_mode(&effective_plan))?;
387 validate_selected_rerun_reports(
388 &selection.selection.validation_reports,
389 &selected_ctx.score_collector,
390 &selected_variant_id,
391 )?;
392
393 let score_set = ScoreSet {
394 schema_version: SCORE_SET_SCHEMA_VERSION,
395 plan_id: effective_plan.id.clone(),
396 selection_metric: Some(selection_metric.name().to_string()),
397 reports: selection.selection.validation_reports,
398 };
399 score_set.validate()?;
400
401 let prediction_requirements = build_oof_prediction_requirements(
402 &effective_plan,
403 selected_ctx.prediction_store.blocks(),
404 selected_ctx.aggregated_prediction_store.blocks(),
405 )?;
406 let retain_caches =
407 input.request.options.artifacts.prediction_caches == PredictionCacheRetention::Retain;
408 let (prediction_caches, portable_prediction_caches) = if retain_caches {
409 let mut records = build_oof_prediction_cache_records(
410 &prediction_requirements,
411 selected_ctx.prediction_store.blocks(),
412 selected_ctx.aggregated_prediction_store.blocks(),
413 )?;
414 let mut payloads = build_oof_prediction_cache_payloads(
415 &prediction_requirements,
416 selected_ctx.prediction_store.blocks(),
417 selected_ctx.aggregated_prediction_store.blocks(),
418 )?;
419 attach_oof_prediction_cache_namespaces(
420 &effective_plan,
421 &input.request.data_identities,
422 &selected_variant_id,
423 input.request.options.seed,
424 &prediction_requirements,
425 &mut records,
426 &mut payloads,
427 )?;
428 (
429 records,
430 Some(BundlePredictionCachePayloadSet {
431 bundle_id: input.bundle_id.clone(),
432 schema_version: PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
433 caches: payloads,
434 }),
435 )
436 } else {
437 (Vec::new(), None)
438 };
439
440 let mut staged_artifact_store = InMemoryArtifactStore::new();
441 let refit_results = if input.request.options.refit {
442 scheduler.refit(
443 &effective_plan,
444 input.controllers,
445 input.data_provider,
446 &mut staged_artifact_store,
447 &mut selected_ctx,
448 )?
449 } else {
450 Vec::new()
451 };
452
453 let mut execution_bundle = build_execution_bundle_with_prediction_contracts(
454 input.bundle_id.clone(),
455 &effective_plan,
456 Some(selected_variant_id.clone()),
457 BTreeMap::from([(input.request.options.selection.id.clone(), decision)]),
458 staged_artifact_store.refit_artifacts(),
459 prediction_requirements,
460 prediction_caches,
461 )?;
462 execution_bundle.scores = Some(score_set.clone());
463 execution_bundle.validate_against_plan(&effective_plan)?;
464 if let Some(caches) = &portable_prediction_caches {
465 caches.validate_against_bundle(&execution_bundle)?;
466 }
467
468 let outputs = bind_training_outputs(
469 &projection.outputs,
470 input.request,
471 &effective_plan,
472 &fit_cv_results,
473 &refit_results,
474 &selected_ctx,
475 )?;
476 let mut lineage = selected_ctx
477 .lineage
478 .records()
479 .filter(|record| projection.predictor_node_ids.contains(&record.node_id))
480 .cloned()
481 .collect::<Vec<_>>();
482 for record in &mut lineage {
483 record.input_lineage.sort();
484 record
485 .artifact_refs
486 .sort_by(|left, right| left.id.cmp(&right.id));
487 }
488 lineage.sort_by(|left, right| left.record_id.cmp(&right.record_id));
489
490 let effective_plan_fingerprint =
491 tcv1_fingerprint(&effective_plan, "training outcome effective plan")?;
492 let parameter_patches =
493 merge_training_parameter_patches(&input.request.parameter_patches, &selected_variant)?;
494 let predictor_closure_nodes = predictor_closure(
500 &effective_plan,
501 outputs.iter().map(|output| output.binding.node_id.clone()),
502 )?;
503 let refit_outcome = TrainingRefitOutcome {
504 requested: input.request.options.refit,
505 status: if input.request.options.refit {
506 TrainingRefitStatus::Completed
507 } else {
508 TrainingRefitStatus::Skipped
509 },
510 strategy: input.request.options.refit_strategy,
511 };
512 let replayable_phases = derive_replayable_phases(
513 &effective_plan,
514 &predictor_closure_nodes,
515 &refit_outcome,
516 &execution_bundle,
517 portable_prediction_caches.as_ref(),
518 )?;
519 let mut outcome = TrainingOutcome {
520 schema_version: TRAINING_OUTCOME_SCHEMA_VERSION,
521 outcome_id: input.outcome_id,
522 run_id: input.run_id,
523 training_request_fingerprint: projection.request_fingerprint,
524 data_identities: input.request.data_identities.clone(),
525 selection_output_id,
526 effective_plan,
527 effective_plan_fingerprint,
528 selected_variant_id,
529 selected_variant_fingerprint: selected_variant.fingerprint,
530 parameter_patches,
531 refit: refit_outcome,
532 score_set,
533 outputs,
534 lineage,
535 portable_prediction_caches,
536 training_influence: runtime_training_influence,
537 execution_bundle,
538 replayable_phases,
539 warnings: input.warnings,
540 diagnostics: input.diagnostics,
541 outcome_fingerprint: zero_fingerprint(),
542 };
543 outcome = stabilize_training_outcome_for_tcv1(outcome)?;
544 outcome.validate()?;
545 *input.artifact_store = staged_artifact_store;
546 Ok(outcome)
547}
548
549fn stabilize_training_outcome_for_tcv1(mut outcome: TrainingOutcome) -> Result<TrainingOutcome> {
550 outcome.outcome_fingerprint = zero_fingerprint();
556 let json = serde_json::to_string(&outcome)?;
557 let mut normalized = serde_json::from_str::<TrainingOutcome>(&json)?;
558 normalized.outcome_fingerprint = normalized.compute_fingerprint()?;
559 Ok(normalized)
560}
561
562fn zero_fingerprint() -> String {
563 "0".repeat(64)
564}
565
566fn validate_native_training_options(request: &TrainingRequest) -> Result<()> {
567 let resources = &request.options.resources;
568 if resources.cpu_threads != request.options.scheduler.workers
569 || resources.memory_bytes.is_some()
570 || !resources.gpu_devices.is_empty()
571 || resources.wall_time_ms.is_some()
572 {
573 return Err(DagMlError::RuntimeValidation(
574 "native training V1 supports only cpu_threads=scheduler.workers with memory_bytes=null, gpu_devices=[], and wall_time_ms=null"
575 .to_string(),
576 ));
577 }
578 if request.options.artifacts.cv_artifacts != CvArtifactRetention::Discard {
579 return Err(DagMlError::RuntimeValidation(
580 "native training V1 supports only artifacts.cv_artifacts=discard".to_string(),
581 ));
582 }
583 if request.options.artifacts.fitted_artifacts != FittedArtifactMode::AllowHostSidecar {
584 return Err(DagMlError::RuntimeValidation(
585 "native training V1 cannot prove portable fitted payloads and currently requires artifacts.fitted_artifacts=allow_host_sidecar"
586 .to_string(),
587 ));
588 }
589 if request.options.artifacts.prediction_caches == PredictionCacheRetention::Discard
590 && request
591 .graph
592 .edges
593 .iter()
594 .any(|edge| edge.contract.requires_oof)
595 {
596 return Err(DagMlError::RuntimeValidation(
597 "native training V1 requires retained prediction caches for a stacking/requires_oof graph"
598 .to_string(),
599 ));
600 }
601 Ok(())
602}
603
604fn materialize_request_parameter_patches(
605 mut plan: ExecutionPlan,
606 request: &TrainingRequest,
607) -> Result<ExecutionPlan> {
608 for patch in &request.parameter_patches {
609 match patch.namespace {
610 ParameterNamespace::Operator => {}
611 ParameterNamespace::Structural => {
612 return Err(DagMlError::RuntimeValidation(
613 "native training requires recompilation for structural parameter patches; D6 runtime accepts only operator value patches"
614 .to_string(),
615 ));
616 }
617 ParameterNamespace::Fit | ParameterNamespace::Control => {
618 return Err(DagMlError::RuntimeValidation(format!(
619 "native training does not expose {:?} parameter patches to controllers yet; refusing to ignore them",
620 patch.namespace
621 )));
622 }
623 }
624 let node_plan = plan.node_plans.get_mut(&patch.node_id).ok_or_else(|| {
625 DagMlError::RuntimeValidation(format!(
626 "parameter patch references absent node `{}`",
627 patch.node_id
628 ))
629 })?;
630 deep_set_plan_param(
631 &mut node_plan.params,
632 &patch.path,
633 patch.value.clone(),
634 &patch.node_id,
635 )?;
636 node_plan.params_fingerprint = stable_json_fingerprint(&node_plan.params)?;
637 }
638 plan.validate()?;
639 Ok(plan)
640}
641
642fn deep_set_plan_param(
643 root: &mut BTreeMap<String, serde_json::Value>,
644 path: &[String],
645 value: serde_json::Value,
646 node_id: &NodeId,
647) -> Result<()> {
648 if path.is_empty() {
649 return contract_error("parameter patch path cannot be empty");
650 }
651 if path.len() == 1 {
652 root.insert(path[0].clone(), value);
653 return Ok(());
654 }
655 let first = root.get_mut(&path[0]).ok_or_else(|| {
656 DagMlError::RuntimeValidation(format!(
657 "parameter patch for `{node_id}` is missing intermediate path `{}`",
658 path[0]
659 ))
660 })?;
661 let mut cursor = first;
662 for segment in &path[1..path.len() - 1] {
663 let object = cursor.as_object_mut().ok_or_else(|| {
664 DagMlError::RuntimeValidation(format!(
665 "parameter patch for `{node_id}` crosses a scalar or array at `{segment}`"
666 ))
667 })?;
668 cursor = object.get_mut(segment).ok_or_else(|| {
669 DagMlError::RuntimeValidation(format!(
670 "parameter patch for `{node_id}` is missing intermediate path `{segment}`"
671 ))
672 })?;
673 }
674 let object = cursor.as_object_mut().ok_or_else(|| {
675 DagMlError::RuntimeValidation(format!(
676 "parameter patch for `{node_id}` crosses a scalar or array before final key"
677 ))
678 })?;
679 object.insert(path[path.len() - 1].clone(), value);
680 Ok(())
681}
682
683fn validate_provider_attestations(
684 projection: &TrainingContractProjection,
685 request: &TrainingRequest,
686 provider: &dyn RuntimeDataProvider,
687 relations: &crate::relation::SampleRelationSet,
688) -> Result<()> {
689 relations.validate()?;
690 let relation_fingerprint = relations.fingerprint()?;
691 let identities = request
692 .data_identities
693 .iter()
694 .map(|identity| (identity.requirement_key.as_str(), identity))
695 .collect::<BTreeMap<_, _>>();
696 for node_plan in projection.plan.node_plans.values() {
697 for binding in &node_plan.data_bindings {
698 let key = data_binding_requirement_key(&binding.node_id, &binding.input_name);
699 let expected = identities.get(key.as_str()).ok_or_else(|| {
700 DagMlError::RuntimeValidation(format!(
701 "native training request has no data identity for `{key}`"
702 ))
703 })?;
704 let actual = provider.training_data_identity(binding)?.ok_or_else(|| {
705 DagMlError::RuntimeValidation(format!(
706 "runtime data provider did not attest feature/target content for `{key}`"
707 ))
708 })?;
709 actual.validate()?;
710 if &actual != *expected {
711 return Err(DagMlError::RuntimeValidation(format!(
712 "runtime data provider identity for `{key}` does not match signed training request"
713 )));
714 }
715 let provider_relations = provider.coordinator_relations(binding)?;
716 if binding.require_relations && provider_relations.is_none() {
717 return Err(DagMlError::RuntimeValidation(format!(
718 "runtime data provider omitted required relations for `{key}`"
719 )));
720 }
721 if let Some(provider_relations) = provider_relations {
722 provider_relations.validate()?;
723 if provider_relations.fingerprint()? != relation_fingerprint
724 || actual.relation_fingerprint != relation_fingerprint
725 {
726 return Err(DagMlError::RuntimeValidation(format!(
727 "runtime data provider relations for `{key}` differ from training influence relations"
728 )));
729 }
730 }
731 }
732 }
733 Ok(())
734}
735
736fn parse_selection_metric(request: &TrainingRequest) -> Result<RegressionMetricKind> {
737 let metric = regression_metric_by_name(&request.options.selection.metric.name)?;
738 if request.options.selection.metric.objective != metric.objective() {
739 return Err(DagMlError::RuntimeValidation(format!(
740 "selection metric `{}` has objective {:?}, expected {:?}",
741 metric.name(),
742 request.options.selection.metric.objective,
743 metric.objective()
744 )));
745 }
746 Ok(metric)
747}
748
749fn regression_metric_by_name(name: &str) -> Result<RegressionMetricKind> {
750 RegressionMetricKind::from_name(name).ok_or_else(|| {
751 DagMlError::RuntimeValidation(format!(
752 "native training does not support selection metric `{name}`"
753 ))
754 })
755}
756
757fn validate_selection_prediction_kind(
758 metric: RegressionMetricKind,
759 prediction_kind: PredictionKind,
760) -> Result<()> {
761 RegressionMetricKind::resolve_for_prediction_kind(
762 metric.name(),
763 metric.objective(),
764 prediction_kind,
765 )
766 .map(|_| ())
767}
768
769fn effective_selection_metric_level(request: &TrainingRequest) -> Result<PredictionLevel> {
770 let campaign_level = request.campaign.aggregation_policy.selection_metric_level;
771 if request
772 .options
773 .selection
774 .required_metric_level
775 .is_some_and(|level| level != campaign_level)
776 {
777 return Err(DagMlError::RuntimeValidation(
778 "selection required_metric_level differs from campaign selection_metric_level"
779 .to_string(),
780 ));
781 }
782 if request.options.selection.evaluation_scope != Some(EvaluationScope::Oof) {
783 return Err(DagMlError::RuntimeValidation(
784 "native training V1 requires selection.evaluation_scope=oof".to_string(),
785 ));
786 }
787 if request.options.selection.reduction_id.is_some() {
788 return Err(DagMlError::RuntimeValidation(
789 "native training V1 does not execute selection reduction_id".to_string(),
790 ));
791 }
792 if request.options.selection.stacking_fit_contract.is_some() {
793 return Err(DagMlError::RuntimeValidation(
794 "native training V1 does not execute selection stacking_fit_contract".to_string(),
795 ));
796 }
797 if !request.options.selection.require_finite {
798 return Err(DagMlError::RuntimeValidation(
799 "native training V1 requires selection.require_finite=true".to_string(),
800 ));
801 }
802 if request.options.refit_strategy == Some(RefitStrategy::RefitEnsemble) {
803 return Err(DagMlError::RuntimeValidation(
804 "native training V1 does not implement refit_ensemble".to_string(),
805 ));
806 }
807 match (
808 request.options.refit,
809 request.options.selection.refit_slot_plan.as_ref(),
810 ) {
811 (false, Some(_)) => Err(DagMlError::RuntimeValidation(
812 "no-refit native training forbids selection.refit_slot_plan".to_string(),
813 )),
814 (true, Some(slot))
815 if slot.strategy != RefitStrategy::RefitOne
816 || slot.member_count != 1
817 || slot.selection_level != campaign_level
818 || slot.selection_metric != request.options.selection.metric
819 || slot.reduction_id.is_some() =>
820 {
821 Err(DagMlError::RuntimeValidation(
822 "selection.refit_slot_plan is not the exact native refit_one slot".to_string(),
823 ))
824 }
825 _ => Ok(campaign_level),
826 }
827}
828
829fn validate_selected_rerun_reports(
830 retained: &[crate::metrics::RegressionMetricReport],
831 rerun: &[crate::metrics::RegressionMetricReport],
832 selected_variant_id: &VariantId,
833) -> Result<()> {
834 let mut retained = retained
835 .iter()
836 .filter(|report| report.variant_id.as_ref() == Some(selected_variant_id))
837 .cloned()
838 .collect::<Vec<_>>();
839 let mut rerun = rerun
840 .iter()
841 .filter(|report| report.partition == PredictionPartition::Validation)
842 .cloned()
843 .map(|mut report| {
844 report.variant_id = Some(selected_variant_id.clone());
845 report.variant_label = None;
846 report
847 })
848 .collect::<Vec<_>>();
849 let sort = |reports: &mut Vec<crate::metrics::RegressionMetricReport>| {
850 reports.sort_by(|left, right| {
851 (
852 &left.producer_node,
853 &left.producer_port,
854 &left.fold_id,
855 &left.prediction_id,
856 &left.level,
857 )
858 .cmp(&(
859 &right.producer_node,
860 &right.producer_port,
861 &right.fold_id,
862 &right.prediction_id,
863 &right.level,
864 ))
865 });
866 };
867 sort(&mut retained);
868 sort(&mut rerun);
869 if retained.is_empty() || retained != rerun {
870 return Err(DagMlError::RuntimeValidation(
871 "selected variant FIT_CV rerun diverged from the reports that justified SELECT"
872 .to_string(),
873 ));
874 }
875 Ok(())
876}
877
878fn validate_selection_report_levels(
879 reports: &[crate::metrics::RegressionMetricReport],
880 producer: &NodeId,
881 producer_port: &Option<String>,
882 expected: PredictionLevel,
883) -> Result<()> {
884 let target_reports = reports
885 .iter()
886 .filter(|report| {
887 &report.producer_node == producer
888 && &report.producer_port == producer_port
889 && report.level == expected
890 })
891 .collect::<Vec<_>>();
892 if target_reports.is_empty() {
893 return Err(DagMlError::RuntimeValidation(format!(
894 "native SELECT target `{producer}` port {producer_port:?} has no reports at required metric level {expected:?}"
895 )));
896 }
897 Ok(())
898}
899
900fn bind_selection_decision(
901 decision: &mut SelectionDecision,
902 request: &TrainingRequest,
903 metric_level: PredictionLevel,
904) -> Result<()> {
905 decision.policy_id = request.options.selection.id.clone();
906 decision.metric_level = Some(metric_level);
907 decision.evaluation_scope = Some(EvaluationScope::Oof);
908 decision.refit_slot_plan = request.options.selection.refit_slot_plan.clone();
909 decision.reduction_id = None;
910 decision.validate()
911}
912
913fn materialize_selected_variant(
914 mut plan: ExecutionPlan,
915 selected_variant_id: &VariantId,
916) -> Result<ExecutionPlan> {
917 let selected = plan
918 .variants
919 .iter()
920 .find(|variant| &variant.variant_id == selected_variant_id)
921 .cloned()
922 .ok_or_else(|| {
923 DagMlError::RuntimeValidation(format!(
924 "selected variant `{selected_variant_id}` is absent from plan"
925 ))
926 })?;
927 let variant = VariantExecutionSpec::from_plan(&selected);
928 variant.validate()?;
929 for (node_id, node_plan) in &mut plan.node_plans {
930 node_plan.params = variant.effective_params_for_node(node_id, &node_plan.params)?;
931 node_plan.params_fingerprint = stable_json_fingerprint(&node_plan.params)?;
932 }
933 plan.validate()?;
934 Ok(plan)
935}
936
937fn is_cv_ensemble_partition(partition: &PredictionPartition) -> bool {
938 match partition {
939 PredictionPartition::Validation => true,
940 PredictionPartition::Train | PredictionPartition::Test | PredictionPartition::Final => {
941 false
942 }
943 }
944}
945
946fn producer_port_matches_graph_output(
947 plan: &ExecutionPlan,
948 node_id: &NodeId,
949 port_name: &str,
950 producer_port: &Option<String>,
951) -> bool {
952 if let Some(producer_port) = producer_port {
953 return producer_port == port_name;
954 }
955 let Some(node) = plan
956 .graph_plan
957 .graph
958 .nodes
959 .iter()
960 .find(|node| &node.id == node_id)
961 else {
962 return false;
963 };
964 let prediction_ports = node
965 .ports
966 .outputs
967 .iter()
968 .filter(|port| port.kind == PortKind::Prediction)
969 .collect::<Vec<_>>();
970 prediction_ports.len() == 1 && prediction_ports[0].name == port_name
971}
972
973fn bind_training_outputs(
974 outputs: &[ResolvedTrainingOutput],
975 request: &TrainingRequest,
976 plan: &ExecutionPlan,
977 fit_cv_results: &[NodeResult],
978 refit_results: &[NodeResult],
979 ctx: &RunContext,
980) -> Result<Vec<BoundTrainingOutput>> {
981 let source = if request.options.refit {
982 refit_results
983 } else {
984 fit_cv_results
985 };
986 let aggregation_fingerprint = tcv1_fingerprint(
987 &plan.campaign.aggregation_policy,
988 "training output aggregation policy",
989 )?;
990 let mut bound = Vec::with_capacity(outputs.len());
991 for output in outputs {
992 let mut binding = OutputBinding {
993 schema_version: OUTPUT_BINDING_SCHEMA_VERSION,
994 binding_id: output.output_id.clone(),
995 node_id: output.node_id.clone(),
996 port_name: output.port_name.clone(),
997 prediction_level: output.prediction_level,
998 unit_level: output.unit_level,
999 prediction_kind: output.prediction_kind,
1000 prediction_source: if request.options.refit {
1001 PredictionSource::FinalRefit
1002 } else {
1003 PredictionSource::CvEnsemble
1004 },
1005 refit_strategy: request.options.refit_strategy,
1006 aggregation_fingerprint: aggregation_fingerprint.clone(),
1007 target_names: output.target_names.clone(),
1008 target_units: output.target_units.clone(),
1009 class_labels: output.class_labels.clone(),
1010 output_order: output.output_order,
1011 target_space: output.target_space.clone(),
1012 binding_fingerprint: zero_fingerprint(),
1013 };
1014 binding.binding_fingerprint = binding.compute_fingerprint()?;
1015
1016 let node_results = source
1017 .iter()
1018 .filter(|result| result.node_id == output.node_id)
1019 .collect::<Vec<_>>();
1020 let mut predictions = Vec::new();
1021 let mut observation_predictions = Vec::new();
1022 let mut aggregated_predictions = Vec::new();
1023 match output.prediction_level {
1024 PredictionLevel::Observation => {
1025 for result in node_results {
1026 observation_predictions.extend(
1027 result
1028 .observation_predictions
1029 .iter()
1030 .filter(|block| {
1031 producer_port_matches_graph_output(
1032 plan,
1033 &output.node_id,
1034 &output.port_name,
1035 &block.producer_port,
1036 ) && (request.options.refit
1037 || is_cv_ensemble_partition(&block.partition))
1038 })
1039 .cloned(),
1040 );
1041 }
1042 }
1043 PredictionLevel::Sample => {
1044 for result in node_results {
1045 predictions.extend(
1046 result
1047 .predictions
1048 .iter()
1049 .filter(|block| {
1050 producer_port_matches_graph_output(
1051 plan,
1052 &output.node_id,
1053 &output.port_name,
1054 &block.producer_port,
1055 ) && (request.options.refit
1056 || is_cv_ensemble_partition(&block.partition))
1057 })
1058 .cloned(),
1059 );
1060 aggregated_predictions.extend(
1061 result
1062 .aggregated_predictions
1063 .iter()
1064 .filter(|block| {
1065 producer_port_matches_graph_output(
1066 plan,
1067 &output.node_id,
1068 &output.port_name,
1069 &block.producer_port,
1070 ) && block.level == PredictionLevel::Sample
1071 && (request.options.refit
1072 || is_cv_ensemble_partition(&block.partition))
1073 })
1074 .cloned(),
1075 );
1076 }
1077 if !request.options.refit {
1078 aggregated_predictions.extend(
1079 ctx.oof_average_blocks
1080 .iter()
1081 .filter(|average| {
1082 average.predictions.producer_node == output.node_id
1083 && producer_port_matches_graph_output(
1084 plan,
1085 &output.node_id,
1086 &output.port_name,
1087 &average.predictions.producer_port,
1088 )
1089 && is_cv_ensemble_partition(&average.predictions.partition)
1090 })
1091 .map(|average| average.predictions.clone()),
1092 );
1093 }
1094 }
1095 PredictionLevel::Target | PredictionLevel::Group => {
1096 for result in node_results {
1097 aggregated_predictions.extend(
1098 result
1099 .aggregated_predictions
1100 .iter()
1101 .filter(|block| {
1102 producer_port_matches_graph_output(
1103 plan,
1104 &output.node_id,
1105 &output.port_name,
1106 &block.producer_port,
1107 ) && block.level == output.prediction_level
1108 && (request.options.refit
1109 || is_cv_ensemble_partition(&block.partition))
1110 })
1111 .cloned(),
1112 );
1113 }
1114 }
1115 }
1116 predictions.sort_by(|left, right| {
1117 (
1118 &left.partition,
1119 &left.fold_id,
1120 &left.prediction_id,
1121 &left.sample_ids,
1122 )
1123 .cmp(&(
1124 &right.partition,
1125 &right.fold_id,
1126 &right.prediction_id,
1127 &right.sample_ids,
1128 ))
1129 });
1130 observation_predictions.sort_by(|left, right| {
1131 (
1132 &left.partition,
1133 &left.fold_id,
1134 &left.prediction_id,
1135 &left.observation_ids,
1136 )
1137 .cmp(&(
1138 &right.partition,
1139 &right.fold_id,
1140 &right.prediction_id,
1141 &right.observation_ids,
1142 ))
1143 });
1144 aggregated_predictions.sort_by(|left, right| {
1145 (
1146 &left.partition,
1147 &left.fold_id,
1148 &left.prediction_id,
1149 &left.unit_ids,
1150 )
1151 .cmp(&(
1152 &right.partition,
1153 &right.fold_id,
1154 &right.prediction_id,
1155 &right.unit_ids,
1156 ))
1157 });
1158 aggregated_predictions.dedup();
1159 let output = BoundTrainingOutput {
1160 schema_version: Some(BOUND_TRAINING_OUTPUT_SCHEMA_VERSION),
1161 binding,
1162 predictions,
1163 observation_predictions,
1164 aggregated_predictions,
1165 };
1166 output.validate(plan)?;
1167 bound.push(output);
1168 }
1169 Ok(bound)
1170}
1171
1172pub fn build_oof_prediction_requirements(
1175 plan: &ExecutionPlan,
1176 blocks: &[PredictionBlock],
1177 aggregated_blocks: &[AggregatedPredictionBlock],
1178) -> Result<Vec<BundlePredictionRequirement>> {
1179 let mut requirements = Vec::new();
1180 for edge in plan
1181 .graph_plan
1182 .graph
1183 .edges
1184 .iter()
1185 .filter(|edge| edge.contract.requires_oof)
1186 {
1187 let source_plan = plan.node_plans.get(&edge.source.node_id).ok_or_else(|| {
1188 DagMlError::RuntimeValidation(format!(
1189 "OOF edge source `{}` has no node plan",
1190 edge.source.node_id
1191 ))
1192 })?;
1193 let prediction_level = source_plan
1194 .shape_plan
1195 .as_ref()
1196 .map(|shape| shape.aggregation_policy.aggregation_level)
1197 .unwrap_or(PredictionLevel::Sample);
1198 let mut fold_ids = BTreeSet::<FoldId>::new();
1199 let mut sample_ids = BTreeSet::<SampleId>::new();
1200 let mut unit_ids = BTreeSet::<PredictionUnitId>::new();
1201 let mut width = None;
1202 let mut target_names: Option<Vec<String>> = None;
1203
1204 match prediction_level {
1205 PredictionLevel::Sample => {
1206 let selected = blocks
1207 .iter()
1208 .filter(|block| {
1209 block.producer_node == edge.source.node_id
1210 && producer_port_matches_graph_output(
1211 plan,
1212 &edge.source.node_id,
1213 &edge.source.port_name,
1214 &block.producer_port,
1215 )
1216 && block.partition == PredictionPartition::Validation
1217 })
1218 .collect::<Vec<_>>();
1219 if selected.is_empty() {
1220 return Err(DagMlError::RuntimeValidation(format!(
1221 "OOF requirement `{}` -> `{}` has no validation sample blocks",
1222 edge.source.node_id, edge.target.node_id
1223 )));
1224 }
1225 for block in selected {
1226 let block_width = block.validate_shape()?;
1227 merge_oof_shape(
1228 &edge.source.node_id,
1229 &mut width,
1230 &mut target_names,
1231 block_width,
1232 &block.target_names,
1233 )?;
1234 if let Some(fold_id) = &block.fold_id {
1235 fold_ids.insert(fold_id.clone());
1236 }
1237 sample_ids.extend(block.sample_ids.iter().cloned());
1238 }
1239 }
1240 PredictionLevel::Target | PredictionLevel::Group => {
1241 let selected = aggregated_blocks
1242 .iter()
1243 .filter(|block| {
1244 block.producer_node == edge.source.node_id
1245 && producer_port_matches_graph_output(
1246 plan,
1247 &edge.source.node_id,
1248 &edge.source.port_name,
1249 &block.producer_port,
1250 )
1251 && block.partition == PredictionPartition::Validation
1252 && block.level == prediction_level
1253 })
1254 .collect::<Vec<_>>();
1255 if selected.is_empty() {
1256 return Err(DagMlError::RuntimeValidation(format!(
1257 "OOF requirement `{}` -> `{}` has no validation {prediction_level:?} blocks",
1258 edge.source.node_id, edge.target.node_id
1259 )));
1260 }
1261 for block in selected {
1262 let block_width = block.validate_shape()?;
1263 merge_oof_shape(
1264 &edge.source.node_id,
1265 &mut width,
1266 &mut target_names,
1267 block_width,
1268 &block.target_names,
1269 )?;
1270 if let Some(fold_id) = &block.fold_id {
1271 fold_ids.insert(fold_id.clone());
1272 }
1273 unit_ids.extend(block.unit_ids.iter().cloned());
1274 }
1275 }
1276 PredictionLevel::Observation => {
1277 return Err(DagMlError::RuntimeValidation(format!(
1278 "OOF requirement `{}` -> `{}` cannot persist observation-level predictions; aggregate before refit",
1279 edge.source.node_id, edge.target.node_id
1280 )));
1281 }
1282 }
1283 let requirement = BundlePredictionRequirement {
1284 producer_node: edge.source.node_id.clone(),
1285 source_port: edge.source.port_name.clone(),
1286 consumer_node: edge.target.node_id.clone(),
1287 target_port: edge.target.port_name.clone(),
1288 partition: PredictionPartition::Validation,
1289 prediction_level,
1290 fold_ids: fold_ids.into_iter().collect(),
1291 unit_ids: unit_ids.into_iter().collect(),
1292 sample_ids: sample_ids.into_iter().collect(),
1293 prediction_width: width.unwrap_or_default(),
1294 target_names: target_names.unwrap_or_default(),
1295 };
1296 requirement.validate()?;
1297 requirements.push(requirement);
1298 }
1299 requirements.sort_by_key(BundlePredictionRequirement::key);
1300 Ok(requirements)
1301}
1302
1303fn merge_oof_shape(
1304 producer: &NodeId,
1305 expected_width: &mut Option<usize>,
1306 expected_names: &mut Option<Vec<String>>,
1307 width: usize,
1308 names: &[String],
1309) -> Result<()> {
1310 if expected_width.is_some_and(|expected| expected != width) {
1311 return Err(DagMlError::RuntimeValidation(format!(
1312 "OOF requirement for `{producer}` has inconsistent prediction width"
1313 )));
1314 }
1315 *expected_width = Some(width);
1316 let names = if names.is_empty() {
1317 (0..width).map(|index| format!("p{index}")).collect()
1318 } else {
1319 names.to_vec()
1320 };
1321 if expected_names
1322 .as_ref()
1323 .is_some_and(|expected| expected != &names)
1324 {
1325 return Err(DagMlError::RuntimeValidation(format!(
1326 "OOF requirement for `{producer}` has inconsistent target names"
1327 )));
1328 }
1329 *expected_names = Some(names);
1330 Ok(())
1331}
1332
1333pub fn build_oof_prediction_cache_records(
1334 requirements: &[BundlePredictionRequirement],
1335 blocks: &[PredictionBlock],
1336 aggregated_blocks: &[AggregatedPredictionBlock],
1337) -> Result<Vec<BundlePredictionCacheRecord>> {
1338 requirements
1339 .iter()
1340 .map(|requirement| match requirement.prediction_level {
1341 PredictionLevel::Sample => build_prediction_cache_record(requirement, blocks),
1342 PredictionLevel::Target | PredictionLevel::Group => {
1343 build_aggregated_prediction_cache_record(requirement, aggregated_blocks)
1344 }
1345 PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
1346 "prediction cache requirement `{}` cannot use observation-level predictions",
1347 requirement.key()
1348 ))),
1349 })
1350 .collect()
1351}
1352
1353pub fn build_oof_prediction_cache_payloads(
1354 requirements: &[BundlePredictionRequirement],
1355 blocks: &[PredictionBlock],
1356 aggregated_blocks: &[AggregatedPredictionBlock],
1357) -> Result<Vec<BundlePredictionCachePayload>> {
1358 requirements
1359 .iter()
1360 .map(|requirement| match requirement.prediction_level {
1361 PredictionLevel::Sample => build_prediction_cache_payload(requirement, blocks),
1362 PredictionLevel::Target | PredictionLevel::Group => {
1363 build_aggregated_prediction_cache_payload(requirement, aggregated_blocks)
1364 }
1365 PredictionLevel::Observation => Err(DagMlError::RuntimeValidation(format!(
1366 "prediction cache requirement `{}` cannot use observation-level predictions",
1367 requirement.key()
1368 ))),
1369 })
1370 .collect()
1371}
1372
1373fn attach_oof_prediction_cache_namespaces(
1374 plan: &ExecutionPlan,
1375 data_identities: &[TrainingDataIdentity],
1376 selected_variant_id: &VariantId,
1377 seed: u64,
1378 requirements: &[BundlePredictionRequirement],
1379 records: &mut [BundlePredictionCacheRecord],
1380 payloads: &mut [BundlePredictionCachePayload],
1381) -> Result<()> {
1382 let requirements_by_key = requirements
1383 .iter()
1384 .map(|requirement| (requirement.key(), requirement))
1385 .collect::<BTreeMap<_, _>>();
1386 for record in records {
1387 let requirement = requirements_by_key
1388 .get(&record.requirement_key)
1389 .ok_or_else(|| {
1390 DagMlError::RuntimeValidation(format!(
1391 "prediction cache `{}` references unknown OOF requirement `{}`",
1392 record.cache_id, record.requirement_key
1393 ))
1394 })?;
1395 let fingerprints = oof_cache_namespace_fingerprints(
1396 plan,
1397 data_identities,
1398 selected_variant_id,
1399 seed,
1400 requirement,
1401 record,
1402 )?;
1403 record.cache_namespace_fingerprints = fingerprints.clone();
1404 let payload = payloads
1405 .iter_mut()
1406 .find(|payload| payload.requirement_key == record.requirement_key)
1407 .ok_or_else(|| {
1408 DagMlError::RuntimeValidation(format!(
1409 "prediction cache `{}` has no portable payload for requirement `{}`",
1410 record.cache_id, record.requirement_key
1411 ))
1412 })?;
1413 payload.cache_namespace_fingerprints = fingerprints;
1414 validate_prediction_cache_payload_matches_record(payload, record)?;
1415 }
1416 Ok(())
1417}
1418
1419fn oof_cache_namespace_fingerprints(
1420 plan: &ExecutionPlan,
1421 data_identities: &[TrainingDataIdentity],
1422 selected_variant_id: &VariantId,
1423 seed: u64,
1424 requirement: &BundlePredictionRequirement,
1425 record: &BundlePredictionCacheRecord,
1426) -> Result<Vec<String>> {
1427 let producer_plan = plan
1428 .node_plans
1429 .get(&requirement.producer_node)
1430 .ok_or_else(|| {
1431 DagMlError::RuntimeValidation(format!(
1432 "prediction cache `{}` producer node `{}` is absent from plan",
1433 record.cache_id, requirement.producer_node
1434 ))
1435 })?;
1436 let consumer_plan = plan
1437 .node_plans
1438 .get(&requirement.consumer_node)
1439 .ok_or_else(|| {
1440 DagMlError::RuntimeValidation(format!(
1441 "prediction cache `{}` consumer node `{}` is absent from plan",
1442 record.cache_id, requirement.consumer_node
1443 ))
1444 })?;
1445 let identity_binding = match (
1446 producer_plan.data_bindings.as_slice(),
1447 consumer_plan.data_bindings.as_slice(),
1448 ) {
1449 ([binding], _) => binding,
1450 ([], [binding]) => binding,
1451 (producer_bindings, consumer_bindings) => {
1452 let producer_count = producer_bindings.len();
1453 let consumer_count = consumer_bindings.len();
1454 return Err(DagMlError::RuntimeValidation(format!(
1455 "prediction cache `{}` cannot derive a unique CacheNamespace for edge `{}.{}` -> `{}.{}` with {producer_count} producer data binding(s) and {consumer_count} consumer data binding(s)",
1456 record.cache_id,
1457 requirement.producer_node,
1458 requirement.source_port,
1459 requirement.consumer_node,
1460 requirement.target_port
1461 )));
1462 }
1463 };
1464 if producer_plan.data_bindings.len() > 1 || consumer_plan.data_bindings.len() > 1 {
1465 return Err(DagMlError::RuntimeValidation(format!(
1466 "prediction cache `{}` cannot derive a unique CacheNamespace for edge `{}.{}` -> `{}.{}` with ambiguous data bindings",
1467 record.cache_id,
1468 requirement.producer_node,
1469 requirement.source_port,
1470 requirement.consumer_node,
1471 requirement.target_port
1472 )));
1473 }
1474 let data_requirement_key =
1475 data_binding_requirement_key(&identity_binding.node_id, &identity_binding.input_name);
1476 let identity = data_identities
1477 .iter()
1478 .find(|identity| identity.requirement_key == data_requirement_key)
1479 .ok_or_else(|| {
1480 DagMlError::RuntimeValidation(format!(
1481 "prediction cache `{}` has no training data identity for `{data_requirement_key}`",
1482 record.cache_id
1483 ))
1484 })?;
1485 let mut fingerprints = Vec::with_capacity(record.blocks.len());
1486 for block in &record.blocks {
1487 let fold_id = block.fold_id.clone().ok_or_else(|| {
1488 DagMlError::RuntimeValidation(format!(
1489 "prediction cache `{}` has a cache block without fold_id",
1490 record.cache_id
1491 ))
1492 })?;
1493 let namespace = CacheNamespace::new(
1494 requirement.key(),
1495 identity.requirement_key.clone(),
1496 requirement.producer_node.clone(),
1497 requirement.source_port.clone(),
1498 requirement.consumer_node.clone(),
1499 requirement.target_port.clone(),
1500 producer_plan.params_fingerprint.clone(),
1501 producer_plan.training_loss_fingerprint(Phase::FitCv)?,
1502 identity.identity_fingerprint.clone(),
1503 fold_id,
1504 selected_variant_id.to_string(),
1505 seed,
1506 )?;
1507 namespace.validate_for_identity(identity)?;
1508 fingerprints.push(namespace.namespace_fingerprint);
1509 }
1510 Ok(fingerprints)
1511}
1512
1513impl TrainingOutcome {
1514 pub fn from_json(json: &str) -> Result<Self> {
1517 let typed = parse_typed_json(json).map_err(|error| {
1518 DagMlError::CampaignValidation(format!(
1519 "training outcome is not strict TCV1 JSON: {error}"
1520 ))
1521 })?;
1522 let raw_fingerprint =
1523 typed
1524 .fingerprint_without("outcome_fingerprint")
1525 .map_err(|error| {
1526 DagMlError::CampaignValidation(format!(
1527 "training outcome fingerprint preimage is invalid: {error}"
1528 ))
1529 })?;
1530 let outcome: Self = serde_json::from_str(json)?;
1531 if outcome.outcome_fingerprint != raw_fingerprint {
1532 return contract_error(
1533 "training outcome fingerprint does not match original TCV1 JSON",
1534 );
1535 }
1536 outcome.validate()?;
1537 Ok(outcome)
1538 }
1539
1540 pub fn compute_fingerprint(&self) -> Result<String> {
1541 tcv1_fingerprint_without(self, "outcome_fingerprint", "training outcome")
1542 }
1543
1544 pub fn data_identities_fingerprint(&self) -> Result<String> {
1545 tcv1_fingerprint(&self.data_identities, "training outcome data identities")
1546 }
1547
1548 pub fn execution_bundle_fingerprint(&self) -> Result<String> {
1549 tcv1_fingerprint(&self.execution_bundle, "training outcome execution bundle")
1550 }
1551
1552 pub fn to_reference(&self) -> Result<TrainingOutcomeRef> {
1554 self.validate()?;
1555 validate_sha256(
1556 "training outcome request",
1557 &self.training_request_fingerprint,
1558 )?;
1559 Ok(TrainingOutcomeRef {
1560 outcome_id: self.outcome_id.clone(),
1561 outcome_fingerprint: self.outcome_fingerprint.clone(),
1562 training_request_fingerprint: self.training_request_fingerprint.clone(),
1563 effective_plan_fingerprint: self.effective_plan_fingerprint.clone(),
1564 execution_bundle_id: self.execution_bundle.bundle_id.clone(),
1565 execution_bundle_fingerprint: self.execution_bundle_fingerprint()?,
1566 data_identities_fingerprint: self.data_identities_fingerprint()?,
1567 output_binding_fingerprints: self
1568 .outputs
1569 .iter()
1570 .map(|output| output.binding.binding_fingerprint.clone())
1571 .collect(),
1572 training_influence_fingerprint: self.training_influence.manifest_fingerprint.clone(),
1573 })
1574 }
1575
1576 pub fn to_portable_predictor_package(
1581 &self,
1582 package_id: impl Into<String>,
1583 fitted_artifact_mode: FittedArtifactMode,
1584 artifact_load_mode: ArtifactLoadMode,
1585 ) -> Result<PortablePredictorPackage> {
1586 self.validate()?;
1587 let mut template = PredictorTemplate {
1588 graph: self.effective_plan.graph_plan.graph.clone(),
1589 campaign: self.effective_plan.campaign.clone(),
1590 controller_manifests: self.effective_plan.controller_manifests.clone(),
1591 template_fingerprint: zero_fingerprint(),
1592 };
1593 template.template_fingerprint = template.compute_fingerprint()?;
1594
1595 let output_bindings = self
1596 .outputs
1597 .iter()
1598 .map(|output| output.binding.clone())
1599 .collect::<Vec<_>>();
1600 let predictor_node_ids = predictor_closure(
1601 &self.effective_plan,
1602 output_bindings
1603 .iter()
1604 .map(|binding| binding.node_id.clone()),
1605 )?
1606 .into_iter()
1607 .collect::<Vec<_>>();
1608 let mut artifact_bindings = self
1609 .execution_bundle
1610 .refit_artifacts
1611 .iter()
1612 .map(|record| PackageArtifactBinding {
1613 artifact_id: record.artifact.id.clone(),
1614 load_mode: artifact_load_mode,
1615 })
1616 .collect::<Vec<_>>();
1617 artifact_bindings.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id));
1618 let mut package = PortablePredictorPackage {
1619 schema_version: PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION,
1620 package_id: package_id.into(),
1621 template,
1622 training_request_fingerprint: self.training_request_fingerprint.clone(),
1623 training_outcome: self.to_reference()?,
1624 effective_plan: self.effective_plan.clone(),
1625 execution_bundle: self.execution_bundle.clone(),
1626 output_bindings,
1627 predictor_node_ids,
1628 training_influence: self.training_influence.clone(),
1629 data_identities: self.data_identities.clone(),
1630 fitted_artifact_mode,
1631 artifact_bindings,
1632 package_fingerprint: zero_fingerprint(),
1633 };
1634 package.package_fingerprint = package.compute_fingerprint()?;
1635 package.validate()?;
1636 Ok(package)
1637 }
1638
1639 pub fn validate(&self) -> Result<()> {
1640 if self.schema_version < MIN_READABLE_TRAINING_OUTCOME_SCHEMA_VERSION
1641 || self.schema_version > TRAINING_OUTCOME_SCHEMA_VERSION
1642 {
1643 return contract_error(format!(
1644 "training outcome schema_version {} is unsupported; maximum readable version is {}",
1645 self.schema_version, TRAINING_OUTCOME_SCHEMA_VERSION
1646 ));
1647 }
1648 RunId::new(self.outcome_id.clone()).map_err(|error| {
1649 DagMlError::CampaignValidation(format!(
1650 "training outcome_id is not a portable identifier: {error}"
1651 ))
1652 })?;
1653 validate_sha256(
1654 "training outcome request",
1655 &self.training_request_fingerprint,
1656 )?;
1657 validate_sha256("training outcome plan", &self.effective_plan_fingerprint)?;
1658 validate_sha256(
1659 "training outcome selected variant",
1660 &self.selected_variant_fingerprint,
1661 )?;
1662 validate_sha256("training outcome", &self.outcome_fingerprint)?;
1663 self.effective_plan.validate()?;
1664 if self.effective_plan_fingerprint
1665 != tcv1_fingerprint(&self.effective_plan, "training outcome effective plan")?
1666 {
1667 return contract_error(
1668 "training outcome effective_plan_fingerprint does not match TCV1 plan content",
1669 );
1670 }
1671
1672 let selected = self
1673 .effective_plan
1674 .variants
1675 .iter()
1676 .filter(|variant| variant.variant_id == self.selected_variant_id)
1677 .collect::<Vec<_>>();
1678 let [selected] = selected.as_slice() else {
1679 return contract_error(
1680 "training outcome selected_variant_id is absent or duplicated in effective plan",
1681 );
1682 };
1683 if selected.fingerprint != self.selected_variant_fingerprint {
1684 return contract_error(
1685 "training outcome selected_variant_fingerprint does not match effective plan",
1686 );
1687 }
1688 let expected_patches = selected_variant_parameter_patches(selected)?;
1689 validate_outcome_parameter_patches(
1690 &self.effective_plan,
1691 &self.parameter_patches,
1692 &expected_patches,
1693 )?;
1694 if !self.parameter_patches.is_empty()
1695 && !self
1696 .training_influence
1697 .entries
1698 .iter()
1699 .any(|entry| entry.kind == TrainingInfluenceKind::HpoSelection)
1700 {
1701 return contract_error(
1702 "training outcome parameter patches require hpo_selection influence",
1703 );
1704 }
1705
1706 self.validate_refit()?;
1707 self.score_set.validate()?;
1708 self.validate_version_family()?;
1709 if self.score_set.plan_id != self.effective_plan.id {
1710 return contract_error("training outcome score_set.plan_id does not match plan");
1711 }
1712 if !self
1713 .score_set
1714 .reports
1715 .iter()
1716 .any(|report| report.variant_id.as_ref() == Some(&self.selected_variant_id))
1717 {
1718 return contract_error("training outcome score_set has no report for selected variant");
1719 }
1720 self.validate_selection_decision()?;
1721
1722 let closure = self.validate_outputs()?;
1723 if closure
1729 != self
1730 .effective_plan
1731 .node_plans
1732 .keys()
1733 .cloned()
1734 .collect::<BTreeSet<_>>()
1735 {
1736 return contract_error(
1737 "training outcome predictor closure must equal all effective plan nodes in V1",
1738 );
1739 }
1740 self.training_influence.validate()?;
1741 validate_influence_against_closure(
1742 &self.training_influence,
1743 &self.effective_plan,
1744 &closure,
1745 )?;
1746 let base_fit_nodes = self
1747 .training_influence
1748 .entries
1749 .iter()
1750 .filter(|entry| {
1751 matches!(
1752 entry.kind,
1753 TrainingInfluenceKind::TransformFit
1754 | TrainingInfluenceKind::ModelFit
1755 | TrainingInfluenceKind::TrainedMetaAggregation
1756 )
1757 })
1758 .filter_map(|entry| entry.node_id.clone())
1759 .collect::<BTreeSet<_>>();
1760 if self
1761 .outputs
1762 .iter()
1763 .any(|output| !base_fit_nodes.contains(&output.binding.node_id))
1764 {
1765 return contract_error("training outcome output node has no fitting influence");
1766 }
1767
1768 self.execution_bundle
1769 .validate_against_plan(&self.effective_plan)?;
1770 if self.execution_bundle.selected_variant_id.as_ref() != Some(&self.selected_variant_id) {
1771 return contract_error(
1772 "training outcome execution bundle selected variant does not match outcome",
1773 );
1774 }
1775 if self.execution_bundle.scores.as_ref() != Some(&self.score_set) {
1776 return contract_error(
1777 "training outcome execution bundle scores do not equal score_set",
1778 );
1779 }
1780 self.validate_data_identities()?;
1781 validate_all_identity_relations(
1782 &self.data_identities,
1783 &self.training_influence.relation_fingerprint,
1784 )?;
1785 self.validate_artifacts(&closure)?;
1786 self.validate_lineage(&closure)?;
1787 match &self.portable_prediction_caches {
1788 Some(caches) => caches.validate_against_bundle(&self.execution_bundle)?,
1789 None if !self.execution_bundle.prediction_caches.is_empty() => {
1790 return contract_error(
1791 "training outcome portable caches are null while bundle announces caches",
1792 );
1793 }
1794 None => {}
1795 }
1796
1797 let expected_replay = derive_replayable_phases(
1798 &self.effective_plan,
1799 &closure,
1800 &self.refit,
1801 &self.execution_bundle,
1802 self.portable_prediction_caches.as_ref(),
1803 )?;
1804 if self.replayable_phases != expected_replay {
1805 return contract_error(
1806 "training outcome replayable_phases do not match the phases derivable from the full predictor closure and retained state",
1807 );
1808 }
1809 validate_sorted_unique_text("training outcome warnings", &self.warnings)?;
1810 let portable = serde_json::to_value(self)?;
1811 if contains_runtime_handle(&portable) {
1812 return contract_error("training outcome must not contain runtime handles");
1813 }
1814 if self.outcome_fingerprint != self.compute_fingerprint()? {
1815 return contract_error("training outcome fingerprint does not match TCV1 content");
1816 }
1817 Ok(())
1818 }
1819
1820 fn validate_version_family(&self) -> Result<()> {
1821 let expected_score_version = match self.schema_version {
1822 LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => LEGACY_SCORE_SET_SCHEMA_VERSION,
1823 TRAINING_OUTCOME_SCHEMA_VERSION => SCORE_SET_SCHEMA_VERSION,
1824 _ => unreachable!("training outcome schema_version was range-checked"),
1825 };
1826 if self.score_set.schema_version != expected_score_version {
1827 return contract_error(format!(
1828 "training outcome schema_version {} requires score_set schema_version {}, got {}",
1829 self.schema_version, expected_score_version, self.score_set.schema_version
1830 ));
1831 }
1832 let expected_bundle_version = match self.schema_version {
1833 LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => LEGACY_EXECUTION_BUNDLE_SCHEMA_VERSION,
1834 TRAINING_OUTCOME_SCHEMA_VERSION => EXECUTION_BUNDLE_SCHEMA_VERSION,
1835 _ => unreachable!("training outcome schema_version was range-checked"),
1836 };
1837 if self.execution_bundle.schema_version != expected_bundle_version {
1838 return contract_error(format!(
1839 "training outcome schema_version {} requires execution_bundle schema_version {}, got {}",
1840 self.schema_version,
1841 expected_bundle_version,
1842 self.execution_bundle.schema_version
1843 ));
1844 }
1845 let expected_cache_version = match self.schema_version {
1846 LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION => {
1847 LEGACY_PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION
1848 }
1849 TRAINING_OUTCOME_SCHEMA_VERSION => PREDICTION_CACHE_PAYLOAD_SCHEMA_VERSION,
1850 _ => unreachable!("training outcome schema_version was range-checked"),
1851 };
1852 if let Some(caches) = &self.portable_prediction_caches {
1853 if caches.schema_version != expected_cache_version {
1854 return contract_error(format!(
1855 "training outcome schema_version {} requires prediction cache payload set schema_version {}, got {}",
1856 self.schema_version, expected_cache_version, caches.schema_version
1857 ));
1858 }
1859 }
1860 for output in &self.outputs {
1861 match (self.schema_version, output.schema_version) {
1862 (LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION, None) => {}
1863 (LEGACY_TRAINING_OUTCOME_SCHEMA_VERSION, Some(version)) => {
1864 return contract_error(format!(
1865 "training outcome V1 requires absent bound output schema_version, got {version}"
1866 ));
1867 }
1868 (TRAINING_OUTCOME_SCHEMA_VERSION, Some(BOUND_TRAINING_OUTPUT_SCHEMA_VERSION)) => {}
1869 (TRAINING_OUTCOME_SCHEMA_VERSION, Some(version)) => {
1870 return contract_error(format!(
1871 "training outcome V2 requires bound output schema_version {}, got {version}",
1872 BOUND_TRAINING_OUTPUT_SCHEMA_VERSION
1873 ));
1874 }
1875 (TRAINING_OUTCOME_SCHEMA_VERSION, None) => {
1876 return contract_error(
1877 "training outcome V2 requires bound output schema_version",
1878 );
1879 }
1880 _ => unreachable!("training outcome schema_version was range-checked"),
1881 }
1882 }
1883 Ok(())
1884 }
1885
1886 fn validate_data_identities(&self) -> Result<()> {
1887 if self.data_identities.is_empty() {
1888 return contract_error("training outcome requires data identities");
1889 }
1890 let mut previous: Option<&str> = None;
1891 for identity in &self.data_identities {
1892 identity.validate()?;
1893 if previous.is_some_and(|key| key >= identity.requirement_key.as_str()) {
1894 return contract_error(
1895 "training outcome data identities must be sorted and unique",
1896 );
1897 }
1898 previous = Some(identity.requirement_key.as_str());
1899 let requirement = self
1900 .execution_bundle
1901 .data_requirements
1902 .iter()
1903 .find(|requirement| requirement.key() == identity.requirement_key)
1904 .ok_or_else(|| {
1905 DagMlError::CampaignValidation(format!(
1906 "training outcome data identity `{}` has no bundle requirement",
1907 identity.requirement_key
1908 ))
1909 })?;
1910 if requirement.schema_fingerprint != identity.schema_fingerprint
1911 || requirement.plan_fingerprint != identity.plan_fingerprint
1912 || requirement.relation_fingerprint.as_ref() != Some(&identity.relation_fingerprint)
1913 {
1914 return contract_error(
1915 "training outcome data identity does not match execution bundle requirement",
1916 );
1917 }
1918 }
1919 if self.data_identities.len() != self.execution_bundle.data_requirements.len() {
1920 return contract_error(
1921 "training outcome data identities do not exactly cover bundle data requirements",
1922 );
1923 }
1924 Ok(())
1925 }
1926
1927 fn validate_selection_decision(&self) -> Result<()> {
1928 if self.selection_output_id.trim().is_empty() {
1929 return contract_error("training outcome selection_output_id is empty");
1930 }
1931 let bindings = self
1932 .outputs
1933 .iter()
1934 .filter(|output| output.binding.binding_id == self.selection_output_id)
1935 .collect::<Vec<_>>();
1936 let [selected_output] = bindings.as_slice() else {
1937 return contract_error(
1938 "training outcome selection_output_id does not resolve exactly one output",
1939 );
1940 };
1941 if self.execution_bundle.selections.len() != 1 {
1942 return contract_error(
1943 "training outcome execution bundle must contain exactly one SELECT decision",
1944 );
1945 }
1946 let (selection_key, decision) = self
1947 .execution_bundle
1948 .selections
1949 .iter()
1950 .next()
1951 .expect("selection length was checked");
1952 if selection_key != &decision.policy_id
1953 || decision.selected_candidate_id != self.selected_variant_id.as_str()
1954 || decision.metric_level != Some(selected_output.binding.prediction_level)
1955 || decision.evaluation_scope != Some(EvaluationScope::Oof)
1956 || self.score_set.selection_metric.as_deref() != Some(decision.metric_name.as_str())
1957 || selected_output.binding.prediction_level
1958 != self
1959 .effective_plan
1960 .campaign
1961 .aggregation_policy
1962 .selection_metric_level
1963 {
1964 return contract_error(
1965 "training outcome SELECT decision metadata is inconsistent with selected output",
1966 );
1967 }
1968 RegressionMetricKind::resolve_for_prediction_kind(
1969 &decision.metric_name,
1970 decision.objective,
1971 selected_output.binding.prediction_kind,
1972 )?;
1973 let mut reports_by_variant = BTreeMap::<VariantId, _>::new();
1974 for report in self.score_set.reports.iter().filter(|report| {
1975 report.producer_node == selected_output.binding.node_id
1976 && producer_port_matches_graph_output(
1977 &self.effective_plan,
1978 &selected_output.binding.node_id,
1979 &selected_output.binding.port_name,
1980 &report.producer_port,
1981 )
1982 && report.partition == PredictionPartition::Validation
1983 && report.level == selected_output.binding.prediction_level
1984 && report
1985 .fold_id
1986 .as_ref()
1987 .is_some_and(|fold| fold.as_str() == "avg")
1988 }) {
1989 let variant_id = report.variant_id.clone().ok_or_else(|| {
1990 DagMlError::CampaignValidation(
1991 "selection output average report has no variant_id".to_string(),
1992 )
1993 })?;
1994 if reports_by_variant
1995 .insert(variant_id, report.clone())
1996 .is_some()
1997 {
1998 return contract_error(
1999 "training outcome has multiple selection average reports for one variant",
2000 );
2001 }
2002 }
2003 let expected_variants = self
2004 .effective_plan
2005 .variants
2006 .iter()
2007 .map(|variant| variant.variant_id.clone())
2008 .collect::<BTreeSet<_>>();
2009 if reports_by_variant.keys().cloned().collect::<BTreeSet<_>>() != expected_variants {
2010 return contract_error(
2011 "training outcome selection reports do not exactly cover plan variants",
2012 );
2013 }
2014 let candidates = reports_by_variant
2015 .into_iter()
2016 .map(|(variant_id, report)| report.into_candidate_score(variant_id.as_str()))
2017 .collect::<Result<Vec<_>>>()?;
2018 let reconstructed = select_candidate(
2019 &SelectionPolicy {
2020 id: decision.policy_id.clone(),
2021 metric: SelectionMetric {
2022 name: decision.metric_name.clone(),
2023 objective: decision.objective,
2024 },
2025 required_metric_level: decision.metric_level,
2026 require_finite: true,
2027 evaluation_scope: decision.evaluation_scope,
2028 refit_slot_plan: decision.refit_slot_plan.clone(),
2029 stacking_fit_contract: None,
2030 reduction_id: decision.reduction_id.clone(),
2031 },
2032 &candidates,
2033 )?;
2034 if &reconstructed != decision {
2035 return contract_error(
2036 "training outcome SELECT decision does not equal ranking reconstructed from scores",
2037 );
2038 }
2039 Ok(())
2040 }
2041
2042 fn validate_refit(&self) -> Result<()> {
2043 match (self.refit.requested, self.refit.status, self.refit.strategy) {
2044 (true, TrainingRefitStatus::Completed, Some(_)) => {
2045 if self
2046 .outputs
2047 .iter()
2048 .any(|output| output.binding.prediction_source != PredictionSource::FinalRefit)
2049 {
2050 return contract_error(
2051 "completed refit outputs must use final_refit prediction source",
2052 );
2053 }
2054 }
2055 (false, TrainingRefitStatus::Skipped, None) => {
2056 if self
2057 .outputs
2058 .iter()
2059 .any(|output| output.binding.prediction_source == PredictionSource::FinalRefit)
2060 {
2061 return contract_error("no-refit outputs cannot use final_refit");
2062 }
2063 }
2064 _ => return contract_error("training outcome refit state is inconsistent"),
2065 }
2066 Ok(())
2067 }
2068
2069 fn validate_outputs(&self) -> Result<BTreeSet<NodeId>> {
2070 if self.outputs.is_empty() {
2071 return contract_error("training outcome requires at least one bound output");
2072 }
2073 let mut previous: Option<&str> = None;
2074 let mut roots = Vec::new();
2075 for output in &self.outputs {
2076 if previous.is_some_and(|value| value >= output.binding.binding_id.as_str()) {
2077 return contract_error(
2078 "training outcome outputs must be strictly sorted by binding_id",
2079 );
2080 }
2081 previous = Some(output.binding.binding_id.as_str());
2082 output.validate(&self.effective_plan)?;
2083 roots.push(output.binding.node_id.clone());
2084 }
2085 predictor_closure(&self.effective_plan, roots)
2086 }
2087
2088 fn validate_artifacts(&self, closure: &BTreeSet<NodeId>) -> Result<()> {
2089 if !self.refit.requested {
2090 if !self.execution_bundle.refit_artifacts.is_empty() {
2091 return contract_error("no-refit training outcome contains refit artifacts");
2092 }
2093 return Ok(());
2094 }
2095 if self.execution_bundle.refit_artifacts.is_empty() {
2096 return contract_error("completed refit requires at least one artifact");
2097 }
2098 let expected_artifact_nodes = closure
2099 .iter()
2100 .filter(|node_id| {
2101 let plan = &self.effective_plan.node_plans[*node_id];
2102 plan.supported_phases.contains(&Phase::Refit)
2103 && plan
2104 .controller_capabilities
2105 .contains(&ControllerCapability::EmitsArtifacts)
2106 })
2107 .cloned()
2108 .collect::<BTreeSet<_>>();
2109 let artifact_nodes = self
2110 .execution_bundle
2111 .refit_artifacts
2112 .iter()
2113 .map(|record| record.node_id.clone())
2114 .collect::<BTreeSet<_>>();
2115 if artifact_nodes != expected_artifact_nodes {
2116 return contract_error(
2117 "refit artifact nodes do not exactly match predictor closure REFIT artifact emitters",
2118 );
2119 }
2120 for output in &self.outputs {
2121 if !artifact_nodes.contains(&output.binding.node_id) {
2122 return contract_error("final output node has no refit artifact");
2123 }
2124 }
2125 Ok(())
2126 }
2127
2128 fn validate_lineage(&self, closure: &BTreeSet<NodeId>) -> Result<()> {
2129 if self.lineage.is_empty() {
2130 return contract_error("training outcome requires portable lineage");
2131 }
2132 let record_ids = self
2133 .lineage
2134 .iter()
2135 .map(|record| record.record_id.clone())
2136 .collect::<Vec<_>>();
2137 if record_ids.windows(2).any(|pair| pair[0] >= pair[1]) {
2138 return contract_error("training outcome lineage must be sorted by record_id");
2139 }
2140 let by_id = self
2141 .lineage
2142 .iter()
2143 .map(|record| (record.record_id.clone(), record))
2144 .collect::<BTreeMap<_, _>>();
2145 if by_id.len() != self.lineage.len() {
2146 return contract_error("training outcome lineage contains duplicate record ids");
2147 }
2148 let mut coordinates = BTreeMap::new();
2149 for record in &self.lineage {
2150 record.validate()?;
2151 if record.run_id != self.run_id
2152 || record.variant_id.as_ref() != Some(&self.selected_variant_id)
2153 || !closure.contains(&record.node_id)
2154 {
2155 return contract_error(
2156 "training outcome lineage run, variant, or predictor closure is inconsistent",
2157 );
2158 }
2159 if !matches!(record.phase, Phase::FitCv | Phase::Select | Phase::Refit) {
2160 return contract_error("training outcome lineage contains a non-training phase");
2161 }
2162 let plan = &self.effective_plan.node_plans[&record.node_id];
2163 if record.controller_id != plan.controller_id
2164 || record.controller_version != plan.controller_version
2165 || record.params_fingerprint != plan.params_fingerprint
2166 {
2167 return contract_error("training outcome lineage does not match node plan");
2168 }
2169 let expected_losses = plan
2170 .training_losses_for_phase(record.phase)
2171 .collect::<Vec<_>>();
2172 if record.loss_attestations.len() != expected_losses.len() {
2173 return contract_error(
2174 "training outcome lineage loss attestations do not match node plan",
2175 );
2176 }
2177 for (attestation, role) in record.loss_attestations.iter().zip(expected_losses) {
2178 attestation.validate_against(role, &record.node_id, record.phase)?;
2179 }
2180 let key = (record.phase, record.fold_id.clone(), record.node_id.clone());
2181 if coordinates.insert(key, record).is_some() {
2182 return contract_error("training outcome lineage duplicates phase/fold/node");
2183 }
2184 if record
2185 .input_lineage
2186 .iter()
2187 .any(|input| !by_id.contains_key(input))
2188 {
2189 return contract_error("training outcome lineage references an unknown input");
2190 }
2191 }
2192 validate_lineage_coordinates(self, closure, &coordinates)
2193 }
2194}
2195
2196impl BoundTrainingOutput {
2197 pub(crate) fn validate(&self, plan: &ExecutionPlan) -> Result<()> {
2198 if let Some(schema_version) = self.schema_version {
2199 if schema_version != BOUND_TRAINING_OUTPUT_SCHEMA_VERSION {
2200 return contract_error(format!(
2201 "bound training output schema_version {schema_version} is unsupported; current {}",
2202 BOUND_TRAINING_OUTPUT_SCHEMA_VERSION
2203 ));
2204 }
2205 }
2206 self.binding.validate(&plan.graph_plan.graph)?;
2207 if self.predictions.is_empty()
2208 && self.observation_predictions.is_empty()
2209 && self.aggregated_predictions.is_empty()
2210 {
2211 return contract_error("bound training output contains no prediction block");
2212 }
2213 match self.binding.prediction_level {
2214 PredictionLevel::Observation
2215 if !self.predictions.is_empty() || !self.aggregated_predictions.is_empty() =>
2216 {
2217 return contract_error(
2218 "observation output binding cannot contain sample or aggregated predictions",
2219 );
2220 }
2221 PredictionLevel::Sample if !self.observation_predictions.is_empty() => {
2222 return contract_error(
2223 "sample output binding cannot contain observation predictions",
2224 );
2225 }
2226 PredictionLevel::Target | PredictionLevel::Group
2227 if !self.predictions.is_empty() || !self.observation_predictions.is_empty() =>
2228 {
2229 return contract_error(
2230 "target/group output binding cannot contain sample or observation predictions",
2231 );
2232 }
2233 _ => {}
2234 }
2235 let expected_names = expected_output_columns(&self.binding);
2236 for block in &self.predictions {
2237 block.validate_shape()?;
2238 validate_bound_block(
2239 plan,
2240 &self.binding,
2241 BoundBlockRef {
2242 producer: &block.producer_node,
2243 producer_port: &block.producer_port,
2244 partition: &block.partition,
2245 fold_id: block.fold_id.as_ref(),
2246 target_names: &block.target_names,
2247 },
2248 &expected_names,
2249 )?;
2250 }
2251 for block in &self.observation_predictions {
2252 block.validate_shape()?;
2253 validate_bound_block(
2254 plan,
2255 &self.binding,
2256 BoundBlockRef {
2257 producer: &block.producer_node,
2258 producer_port: &block.producer_port,
2259 partition: &block.partition,
2260 fold_id: block.fold_id.as_ref(),
2261 target_names: &block.target_names,
2262 },
2263 &expected_names,
2264 )?;
2265 }
2266 for block in &self.aggregated_predictions {
2267 block.validate_shape()?;
2268 if block.level != self.binding.prediction_level {
2269 return contract_error(
2270 "bound aggregated prediction level does not match output binding",
2271 );
2272 }
2273 validate_bound_block(
2274 plan,
2275 &self.binding,
2276 BoundBlockRef {
2277 producer: &block.producer_node,
2278 producer_port: &block.producer_port,
2279 partition: &block.partition,
2280 fold_id: block.fold_id.as_ref(),
2281 target_names: &block.target_names,
2282 },
2283 &expected_names,
2284 )?;
2285 }
2286 match self.binding.prediction_level {
2287 PredictionLevel::Observation if self.observation_predictions.is_empty() => {
2288 return contract_error(
2289 "observation output binding requires observation predictions",
2290 );
2291 }
2292 PredictionLevel::Target | PredictionLevel::Group
2293 if self.aggregated_predictions.is_empty() =>
2294 {
2295 return contract_error(
2296 "target/group output binding requires aggregated predictions",
2297 );
2298 }
2299 _ => {}
2300 }
2301 Ok(())
2302 }
2303}
2304
2305struct BoundBlockRef<'a> {
2306 producer: &'a NodeId,
2307 producer_port: &'a Option<String>,
2308 partition: &'a PredictionPartition,
2309 fold_id: Option<&'a crate::ids::FoldId>,
2310 target_names: &'a [String],
2311}
2312
2313fn validate_bound_block(
2314 plan: &ExecutionPlan,
2315 binding: &OutputBinding,
2316 block: BoundBlockRef<'_>,
2317 expected_names: &[String],
2318) -> Result<()> {
2319 if block.producer != &binding.node_id
2320 || !producer_port_matches_graph_output(
2321 plan,
2322 &binding.node_id,
2323 &binding.port_name,
2324 block.producer_port,
2325 )
2326 || block.target_names != expected_names
2327 {
2328 return contract_error(
2329 "bound prediction producer, producer_port or target order does not match output binding",
2330 );
2331 }
2332 if binding.prediction_source == PredictionSource::FinalRefit
2333 && (block.partition != &PredictionPartition::Final || block.fold_id.is_some())
2334 {
2335 return contract_error("final_refit output blocks must use final partition without fold");
2336 }
2337 if binding.prediction_source == PredictionSource::CvEnsemble
2338 && (!is_cv_ensemble_partition(block.partition) || block.fold_id.is_none())
2339 {
2340 return contract_error(
2341 "cv_ensemble output blocks must use validation partition with a fold id",
2342 );
2343 }
2344 Ok(())
2345}
2346
2347fn expected_output_columns(binding: &OutputBinding) -> Vec<String> {
2348 if binding.prediction_kind == PredictionKind::ClassProbability {
2349 binding
2350 .target_names
2351 .iter()
2352 .zip(&binding.class_labels)
2353 .flat_map(|(target, labels)| {
2354 labels.iter().map(move |label| format!("{target}:{label}"))
2355 })
2356 .collect()
2357 } else {
2358 binding.target_names.clone()
2359 }
2360}
2361
2362fn selected_variant_parameter_patches(
2363 variant: &crate::generation::VariantPlan,
2364) -> Result<Vec<ParameterPatch>> {
2365 let mut patches = Vec::new();
2366 for choice in variant.choices.values() {
2367 for override_spec in &choice.param_overrides {
2368 for (key, value) in &override_spec.params {
2369 append_parameter_leaves(
2370 &override_spec.node_id,
2371 vec![key.clone()],
2372 value,
2373 &mut patches,
2374 )?;
2375 }
2376 }
2377 }
2378 patches.sort_by(|left, right| {
2379 (&left.node_id, left.namespace, &left.path).cmp(&(
2380 &right.node_id,
2381 right.namespace,
2382 &right.path,
2383 ))
2384 });
2385 if patches.windows(2).any(|pair| {
2386 pair[0].node_id == pair[1].node_id
2387 && pair[0].namespace == pair[1].namespace
2388 && pair[0].path == pair[1].path
2389 }) {
2390 return contract_error("selected variant overrides contain duplicate leaf paths");
2391 }
2392 Ok(patches)
2393}
2394
2395fn merge_training_parameter_patches(
2396 request_patches: &[ParameterPatch],
2397 selected_variant: &crate::generation::VariantPlan,
2398) -> Result<Vec<ParameterPatch>> {
2399 let mut patches = request_patches.to_vec();
2400 patches.extend(selected_variant_parameter_patches(selected_variant)?);
2401 sort_and_validate_training_parameter_patch_keys(&mut patches, false)?;
2402 Ok(patches)
2403}
2404
2405fn validate_outcome_parameter_patches(
2406 plan: &ExecutionPlan,
2407 patches: &[ParameterPatch],
2408 selected_variant_patches: &[ParameterPatch],
2409) -> Result<()> {
2410 let mut patches = patches.to_vec();
2411 sort_and_validate_training_parameter_patch_keys(&mut patches, true)?;
2412 let keys = patches
2413 .iter()
2414 .map(parameter_patch_key)
2415 .collect::<BTreeSet<_>>();
2416 for selected in selected_variant_patches {
2417 if !keys.contains(¶meter_patch_key(selected)) {
2418 return contract_error(
2419 "training outcome parameter_patches are missing a selected variant override",
2420 );
2421 }
2422 }
2423 for patch in &patches {
2424 validate_materialized_patch(plan, patch)?;
2425 }
2426 Ok(())
2427}
2428
2429fn sort_and_validate_training_parameter_patch_keys(
2430 patches: &mut [ParameterPatch],
2431 require_already_sorted: bool,
2432) -> Result<()> {
2433 for patch in patches.iter() {
2434 patch.validate()?;
2435 if patch.namespace != ParameterNamespace::Operator {
2436 return contract_error(
2437 "training outcome parameter_patches must use operator namespace",
2438 );
2439 }
2440 }
2441 let original = patches.to_vec();
2442 patches.sort_by(|left, right| parameter_patch_key(left).cmp(¶meter_patch_key(right)));
2443 if require_already_sorted && patches != original {
2444 return contract_error(
2445 "training outcome parameter_patches must be sorted by (node_id, namespace, path)",
2446 );
2447 }
2448 for pair in patches.windows(2) {
2449 let left = &pair[0];
2450 let right = &pair[1];
2451 if parameter_patch_key(left) == parameter_patch_key(right) {
2452 return contract_error(
2453 "training outcome parameter_patches contain duplicate leaf paths",
2454 );
2455 }
2456 if left.node_id == right.node_id
2457 && left.namespace == right.namespace
2458 && (right.path.starts_with(&left.path) || left.path.starts_with(&right.path))
2459 {
2460 return contract_error(
2461 "training outcome parameter_patches contain a conflicting parent/child path",
2462 );
2463 }
2464 }
2465 Ok(())
2466}
2467
2468fn parameter_patch_key(patch: &ParameterPatch) -> (&NodeId, ParameterNamespace, &[String]) {
2469 (&patch.node_id, patch.namespace, patch.path.as_slice())
2470}
2471
2472fn append_parameter_leaves(
2473 node_id: &NodeId,
2474 path: Vec<String>,
2475 value: &serde_json::Value,
2476 output: &mut Vec<ParameterPatch>,
2477) -> Result<()> {
2478 if let serde_json::Value::Object(object) = value {
2479 for (key, child) in object {
2480 let mut child_path = path.clone();
2481 child_path.push(key.clone());
2482 append_parameter_leaves(node_id, child_path, child, output)?;
2483 }
2484 return Ok(());
2485 }
2486 output.push(ParameterPatch {
2487 schema_version: PARAMETER_PATCH_SCHEMA_VERSION,
2488 node_id: node_id.clone(),
2489 namespace: ParameterNamespace::Operator,
2490 path,
2491 value: value.clone(),
2492 });
2493 Ok(())
2494}
2495
2496fn validate_materialized_patch(plan: &ExecutionPlan, patch: &ParameterPatch) -> Result<()> {
2497 patch.validate()?;
2498 if patch.namespace != ParameterNamespace::Operator {
2499 return contract_error("selected variant patches must use operator namespace");
2500 }
2501 let node = plan.node_plans.get(&patch.node_id).ok_or_else(|| {
2502 DagMlError::CampaignValidation(format!(
2503 "selected parameter patch references absent node `{}`",
2504 patch.node_id
2505 ))
2506 })?;
2507 let mut current = serde_json::Value::Object(node.params.clone().into_iter().collect());
2508 for segment in &patch.path {
2509 current = current
2510 .as_object()
2511 .and_then(|object| object.get(segment))
2512 .cloned()
2513 .ok_or_else(|| {
2514 DagMlError::CampaignValidation(format!(
2515 "selected parameter patch path for `{}` is not materialized",
2516 patch.node_id
2517 ))
2518 })?;
2519 }
2520 if current != patch.value {
2521 return contract_error("selected parameter patch value is not materialized in plan");
2522 }
2523 Ok(())
2524}
2525
2526fn predictor_closure(
2527 plan: &ExecutionPlan,
2528 roots: impl IntoIterator<Item = NodeId>,
2529) -> Result<BTreeSet<NodeId>> {
2530 let mut pending = roots.into_iter().collect::<Vec<_>>();
2531 let mut closure = BTreeSet::new();
2532 while let Some(node_id) = pending.pop() {
2533 if !closure.insert(node_id.clone()) {
2534 continue;
2535 }
2536 let node = plan.node_plans.get(&node_id).ok_or_else(|| {
2537 DagMlError::CampaignValidation(format!(
2538 "training outcome closure references absent node `{node_id}`"
2539 ))
2540 })?;
2541 pending.extend(node.input_nodes.iter().cloned());
2542 }
2543 Ok(closure)
2544}
2545
2546struct NodeReplayFacts {
2548 supported_phases: BTreeSet<Phase>,
2549 requires_retained_state: bool,
2558 has_retained_artifact: bool,
2560}
2561
2562struct OofEdgeReplayFacts {
2564 has_bundle_requirement: bool,
2565 has_cache_record: bool,
2566 has_portable_payload: bool,
2567}
2568
2569struct ClosureReplayFacts {
2572 nodes: Vec<NodeReplayFacts>,
2573 oof_edges: Vec<OofEdgeReplayFacts>,
2574}
2575
2576fn derive_replayable_phases_from_facts(
2586 completed_refit: bool,
2587 facts: &ClosureReplayFacts,
2588) -> Vec<Phase> {
2589 let all_support = |phase: Phase| {
2590 facts
2591 .nodes
2592 .iter()
2593 .all(|node| node.supported_phases.contains(&phase))
2594 };
2595 let inference_state_present = facts
2596 .nodes
2597 .iter()
2598 .all(|node| !node.requires_retained_state || node.has_retained_artifact);
2599 let oof_self_contained = facts.oof_edges.iter().all(|edge| {
2600 edge.has_bundle_requirement && edge.has_cache_record && edge.has_portable_payload
2601 });
2602
2603 let mut phases = Vec::new();
2604 if completed_refit {
2605 if all_support(Phase::Predict) && inference_state_present {
2606 phases.push(Phase::Predict);
2607 }
2608 if all_support(Phase::Explain) && inference_state_present {
2609 phases.push(Phase::Explain);
2610 }
2611 } else if all_support(Phase::Refit) && oof_self_contained {
2612 phases.push(Phase::Refit);
2613 }
2614 phases
2615}
2616
2617fn closure_replay_facts(
2623 plan: &ExecutionPlan,
2624 closure: &BTreeSet<NodeId>,
2625 execution_bundle: &ExecutionBundle,
2626 portable_prediction_caches: Option<&BundlePredictionCachePayloadSet>,
2627) -> Result<ClosureReplayFacts> {
2628 let artifact_nodes = execution_bundle
2629 .refit_artifacts
2630 .iter()
2631 .map(|record| record.node_id.clone())
2632 .collect::<BTreeSet<_>>();
2633 let requirement_keys = execution_bundle
2634 .prediction_requirements
2635 .iter()
2636 .map(|requirement| requirement.key())
2637 .collect::<BTreeSet<_>>();
2638 let cache_keys = execution_bundle
2639 .prediction_caches
2640 .iter()
2641 .map(|record| record.requirement_key.clone())
2642 .collect::<BTreeSet<_>>();
2643 let payload_keys = portable_prediction_caches
2644 .map(|set| {
2645 set.caches
2646 .iter()
2647 .map(|payload| payload.requirement_key.clone())
2648 .collect::<BTreeSet<_>>()
2649 })
2650 .unwrap_or_default();
2651
2652 let nodes = closure
2653 .iter()
2654 .map(|node_id| {
2655 let node_plan = plan.node_plans.get(node_id).ok_or_else(|| {
2656 DagMlError::CampaignValidation(format!(
2657 "replay derivation references absent node `{node_id}`"
2658 ))
2659 })?;
2660 let requires_retained_state = node_plan
2665 .controller_capabilities
2666 .contains(&ControllerCapability::Stateful)
2667 || node_plan
2668 .controller_capabilities
2669 .contains(&ControllerCapability::EmitsArtifacts);
2670 Ok(NodeReplayFacts {
2671 supported_phases: node_plan.supported_phases.clone(),
2672 requires_retained_state,
2673 has_retained_artifact: artifact_nodes.contains(node_id),
2674 })
2675 })
2676 .collect::<Result<Vec<_>>>()?;
2677 let oof_edges = plan
2678 .graph_plan
2679 .graph
2680 .edges
2681 .iter()
2682 .filter(|edge| {
2683 edge.contract.requires_oof
2684 && closure.contains(&edge.source.node_id)
2685 && closure.contains(&edge.target.node_id)
2686 })
2687 .map(|edge| {
2688 let key = crate::bundle::bundle_prediction_requirement_key(
2689 &edge.source.node_id,
2690 &edge.source.port_name,
2691 &edge.target.node_id,
2692 &edge.target.port_name,
2693 );
2694 OofEdgeReplayFacts {
2695 has_bundle_requirement: requirement_keys.contains(&key),
2696 has_cache_record: cache_keys.contains(&key),
2697 has_portable_payload: payload_keys.contains(&key),
2698 }
2699 })
2700 .collect::<Vec<_>>();
2701
2702 Ok(ClosureReplayFacts { nodes, oof_edges })
2703}
2704
2705fn derive_replayable_phases(
2714 plan: &ExecutionPlan,
2715 closure: &BTreeSet<NodeId>,
2716 refit: &TrainingRefitOutcome,
2717 execution_bundle: &ExecutionBundle,
2718 portable_prediction_caches: Option<&BundlePredictionCachePayloadSet>,
2719) -> Result<Vec<Phase>> {
2720 let facts = closure_replay_facts(plan, closure, execution_bundle, portable_prediction_caches)?;
2721 Ok(derive_replayable_phases_from_facts(
2722 matches!(refit.status, TrainingRefitStatus::Completed),
2723 &facts,
2724 ))
2725}
2726
2727pub(crate) fn closure_predict_replayable(
2735 plan: &ExecutionPlan,
2736 closure: &BTreeSet<NodeId>,
2737 execution_bundle: &ExecutionBundle,
2738) -> Result<bool> {
2739 let facts = closure_replay_facts(plan, closure, execution_bundle, None)?;
2740 Ok(derive_replayable_phases_from_facts(true, &facts).contains(&Phase::Predict))
2741}
2742
2743fn expected_base_influence_kind(
2744 plan: &ExecutionPlan,
2745 node_id: &NodeId,
2746) -> Option<TrainingInfluenceKind> {
2747 let node_plan = &plan.node_plans[node_id];
2748 if matches!(
2749 node_plan.fit_scope,
2750 ControllerFitScope::Stateless | ControllerFitScope::InferenceOnly
2751 ) {
2752 return None;
2753 }
2754 let oof_consumer = plan
2755 .graph_plan
2756 .graph
2757 .edges
2758 .iter()
2759 .any(|edge| edge.contract.requires_oof && edge.target.node_id == *node_id);
2760 Some(
2761 if oof_consumer
2762 || node_plan
2763 .controller_capabilities
2764 .contains(&ControllerCapability::TrainsAggregation)
2765 {
2766 TrainingInfluenceKind::TrainedMetaAggregation
2767 } else if node_plan.kind == NodeKind::Model {
2768 TrainingInfluenceKind::ModelFit
2769 } else if node_plan.kind == NodeKind::Tuner {
2770 TrainingInfluenceKind::HpoSelection
2771 } else {
2772 TrainingInfluenceKind::TransformFit
2773 },
2774 )
2775}
2776
2777fn validate_influence_against_closure(
2778 influence: &TrainingInfluenceManifest,
2779 plan: &ExecutionPlan,
2780 closure: &BTreeSet<NodeId>,
2781) -> Result<()> {
2782 let mut actual_base = BTreeMap::<NodeId, BTreeSet<TrainingInfluenceKind>>::new();
2783 for entry in &influence.entries {
2784 let Some(node_id) = &entry.node_id else {
2785 continue;
2786 };
2787 if !closure.contains(node_id) {
2788 return contract_error("training influence node is outside predictor closure");
2789 }
2790 if !influence_kind_allowed_by_node_role_or_capability(plan, node_id, entry.kind) {
2791 return contract_error(
2792 "training influence kind is not allowed by node role or capability",
2793 );
2794 }
2795 if expected_base_influence_kind(plan, node_id) == Some(entry.kind) {
2796 actual_base
2797 .entry(node_id.clone())
2798 .or_default()
2799 .insert(entry.kind);
2800 }
2801 }
2802 let expected = closure
2803 .iter()
2804 .filter(|node_id| {
2805 plan.node_plans[*node_id]
2806 .supported_phases
2807 .contains(&Phase::FitCv)
2808 && expected_base_influence_kind(plan, node_id).is_some()
2809 })
2810 .cloned()
2811 .collect::<BTreeSet<_>>();
2812 if actual_base.keys().cloned().collect::<BTreeSet<_>>() != expected {
2813 return contract_error(
2814 "training influence fitting nodes do not exactly match predictor closure",
2815 );
2816 }
2817 for node_id in expected {
2818 if actual_base[&node_id]
2819 != BTreeSet::from([expected_base_influence_kind(plan, &node_id)
2820 .expect("expected fitting nodes have a base influence kind")])
2821 {
2822 return contract_error("training influence fitting kind does not match node role");
2823 }
2824 }
2825 Ok(())
2826}
2827
2828fn influence_kind_allowed_by_node_role_or_capability(
2829 plan: &ExecutionPlan,
2830 node_id: &NodeId,
2831 kind: TrainingInfluenceKind,
2832) -> bool {
2833 if expected_base_influence_kind(plan, node_id) == Some(kind) {
2834 return true;
2835 }
2836 let capabilities = &plan.node_plans[node_id].controller_capabilities;
2837 match kind {
2838 TrainingInfluenceKind::HpoSelection => {
2839 capabilities.contains(&ControllerCapability::PerformsInternalTuning)
2840 }
2841 TrainingInfluenceKind::EarlyStopping => {
2842 capabilities.contains(&ControllerCapability::UsesEarlyStopping)
2843 }
2844 TrainingInfluenceKind::WeightingResampling => {
2845 capabilities.contains(&ControllerCapability::UsesTrainingWeights)
2846 }
2847 TrainingInfluenceKind::TransformFit
2848 | TrainingInfluenceKind::ModelFit
2849 | TrainingInfluenceKind::TrainedMetaAggregation => false,
2850 }
2851}
2852
2853fn validate_lineage_coordinates(
2854 outcome: &TrainingOutcome,
2855 closure: &BTreeSet<NodeId>,
2856 coordinates: &BTreeMap<(Phase, Option<crate::ids::FoldId>, NodeId), &LineageRecord>,
2857) -> Result<()> {
2858 let fold_set = outcome.effective_plan.fold_set.as_ref().ok_or_else(|| {
2859 DagMlError::CampaignValidation(
2860 "training outcome FIT_CV lineage requires a fold_set".to_string(),
2861 )
2862 })?;
2863 let expected_fit = closure
2864 .iter()
2865 .filter(|node_id| {
2866 outcome.effective_plan.node_plans[*node_id]
2867 .supported_phases
2868 .contains(&Phase::FitCv)
2869 })
2870 .flat_map(|node_id| {
2871 fold_set
2872 .folds
2873 .iter()
2874 .map(move |fold| (Phase::FitCv, Some(fold.fold_id.clone()), node_id.clone()))
2875 })
2876 .collect::<BTreeSet<_>>();
2877 let actual_fit = coordinates
2878 .keys()
2879 .filter(|(phase, _, _)| *phase == Phase::FitCv)
2880 .cloned()
2881 .collect::<BTreeSet<_>>();
2882 if actual_fit != expected_fit {
2883 return contract_error(
2884 "training outcome FIT_CV lineage does not exactly cover closure folds",
2885 );
2886 }
2887 let expected_refit = if outcome.refit.requested {
2888 closure
2889 .iter()
2890 .filter(|node_id| {
2891 outcome.effective_plan.node_plans[*node_id]
2892 .supported_phases
2893 .contains(&Phase::Refit)
2894 })
2895 .map(|node_id| (Phase::Refit, None, node_id.clone()))
2896 .collect::<BTreeSet<_>>()
2897 } else {
2898 BTreeSet::new()
2899 };
2900 let actual_refit = coordinates
2901 .keys()
2902 .filter(|(phase, _, _)| *phase == Phase::Refit)
2903 .cloned()
2904 .collect::<BTreeSet<_>>();
2905 if actual_refit != expected_refit {
2906 return contract_error("training outcome REFIT lineage does not exactly cover closure");
2907 }
2908
2909 for ((phase, fold, node_id), record) in coordinates {
2910 if *phase == Phase::Select {
2911 continue;
2912 }
2913 let plan = &outcome.effective_plan.node_plans[node_id];
2914 let expected_inputs = plan
2915 .input_nodes
2916 .iter()
2917 .filter(|input| {
2918 outcome.effective_plan.node_plans[*input]
2919 .supported_phases
2920 .contains(phase)
2921 })
2922 .map(|input| {
2923 coordinates
2924 .get(&(*phase, fold.clone(), input.clone()))
2925 .map(|upstream| upstream.record_id.clone())
2926 .ok_or_else(|| {
2927 DagMlError::CampaignValidation(format!(
2928 "training lineage is missing upstream `{input}`"
2929 ))
2930 })
2931 })
2932 .collect::<Result<Vec<LineageId>>>()?;
2933 let mut expected_inputs = expected_inputs;
2934 expected_inputs.sort();
2935 if record.input_lineage != expected_inputs {
2936 return contract_error(
2937 "training outcome lineage input_lineage does not exactly match plan",
2938 );
2939 }
2940 if *phase == Phase::FitCv && !record.artifact_refs.is_empty() {
2941 return contract_error("FIT_CV lineage must not retain refit artifacts");
2942 }
2943 if *phase == Phase::Refit {
2944 let mut expected_artifacts = outcome
2945 .execution_bundle
2946 .refit_artifacts
2947 .iter()
2948 .filter(|artifact| artifact.node_id == *node_id)
2949 .map(|artifact| artifact.artifact.clone())
2950 .collect::<Vec<_>>();
2951 expected_artifacts.sort_by(|left, right| left.id.cmp(&right.id));
2952 let mut actual_artifacts = record.artifact_refs.clone();
2953 actual_artifacts.sort_by(|left, right| left.id.cmp(&right.id));
2954 if actual_artifacts != expected_artifacts {
2955 return contract_error("REFIT lineage artifact_refs do not match execution bundle");
2956 }
2957 }
2958 }
2959 Ok(())
2960}
2961
2962fn tcv1_fingerprint<T: Serialize + ?Sized>(value: &T, label: &str) -> Result<String> {
2963 let json = serde_json::to_string(value)?;
2964 parse_typed_json(&json)
2965 .map_err(|error| {
2966 DagMlError::CampaignValidation(format!("{label} is not valid TCV1: {error}"))
2967 })?
2968 .fingerprint()
2969 .map_err(|error| {
2970 DagMlError::CampaignValidation(format!("{label} TCV1 fingerprint failed: {error}"))
2971 })
2972}
2973
2974fn tcv1_fingerprint_without<T: Serialize>(value: &T, field: &str, label: &str) -> Result<String> {
2975 let json = serde_json::to_string(value)?;
2976 parse_typed_json(&json)
2977 .map_err(|error| {
2978 DagMlError::CampaignValidation(format!("{label} is not valid TCV1: {error}"))
2979 })?
2980 .fingerprint_without(field)
2981 .map_err(|error| {
2982 DagMlError::CampaignValidation(format!("{label} TCV1 fingerprint failed: {error}"))
2983 })
2984}
2985
2986fn validate_sha256(label: &str, value: &str) -> Result<()> {
2987 if value.len() != 64
2988 || !value
2989 .bytes()
2990 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2991 {
2992 return contract_error(format!("{label} must be lowercase sha256"));
2993 }
2994 Ok(())
2995}
2996
2997fn validate_all_identity_relations(
2998 identities: &[TrainingDataIdentity],
2999 relation_fingerprint: &str,
3000) -> Result<()> {
3001 if identities
3002 .iter()
3003 .any(|identity| identity.relation_fingerprint != relation_fingerprint)
3004 {
3005 return contract_error(
3006 "training outcome data identities do not all bind the influence relation",
3007 );
3008 }
3009 Ok(())
3010}
3011
3012fn validate_sorted_unique_text(label: &str, values: &[String]) -> Result<()> {
3013 if values.iter().any(|value| value.trim().is_empty()) {
3014 return contract_error(format!("{label} contains an empty value"));
3015 }
3016 if values.windows(2).any(|pair| pair[0] >= pair[1]) {
3017 return contract_error(format!("{label} must be strictly sorted and unique"));
3018 }
3019 Ok(())
3020}
3021
3022fn contract_error<T>(message: impl Into<String>) -> Result<T> {
3023 Err(DagMlError::CampaignValidation(message.into()))
3024}
3025
3026#[cfg(test)]
3027mod replay_phase_tests {
3028 use super::{
3029 derive_replayable_phases_from_facts, ClosureReplayFacts, NodeReplayFacts,
3030 OofEdgeReplayFacts,
3031 };
3032 use crate::phase::Phase;
3033 use std::collections::BTreeSet;
3034
3035 fn node(
3036 supported: &[Phase],
3037 requires_retained_state: bool,
3038 has_retained_artifact: bool,
3039 ) -> NodeReplayFacts {
3040 NodeReplayFacts {
3041 supported_phases: supported.iter().copied().collect::<BTreeSet<_>>(),
3042 requires_retained_state,
3043 has_retained_artifact,
3044 }
3045 }
3046
3047 fn oof(
3048 has_bundle_requirement: bool,
3049 has_cache_record: bool,
3050 has_portable_payload: bool,
3051 ) -> OofEdgeReplayFacts {
3052 OofEdgeReplayFacts {
3053 has_bundle_requirement,
3054 has_cache_record,
3055 has_portable_payload,
3056 }
3057 }
3058
3059 #[test]
3064 fn completed_refit_full_support_matrix_predict_then_explain() {
3065 let facts = ClosureReplayFacts {
3066 nodes: vec![
3067 node(
3068 &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
3069 true,
3070 true,
3071 ),
3072 node(
3073 &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
3074 true,
3075 true,
3076 ),
3077 ],
3078 oof_edges: vec![],
3079 };
3080 assert_eq!(
3081 derive_replayable_phases_from_facts(true, &facts),
3082 vec![Phase::Predict, Phase::Explain]
3083 );
3084 }
3085
3086 #[test]
3089 fn completed_refit_predict_only_when_explain_unsupported() {
3090 let facts = ClosureReplayFacts {
3091 nodes: vec![
3092 node(&[Phase::FitCv, Phase::Refit, Phase::Predict], true, true),
3093 node(&[Phase::FitCv, Phase::Refit, Phase::Predict], false, false),
3096 ],
3097 oof_edges: vec![],
3098 };
3099 assert_eq!(
3100 derive_replayable_phases_from_facts(true, &facts),
3101 vec![Phase::Predict]
3102 );
3103 }
3104
3105 #[test]
3108 fn upstream_node_missing_phase_blocks_whole_closure() {
3109 let facts = ClosureReplayFacts {
3110 nodes: vec![
3111 node(
3113 &[Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain],
3114 true,
3115 true,
3116 ),
3117 node(&[Phase::FitCv, Phase::Refit], false, false),
3119 ],
3120 oof_edges: vec![],
3121 };
3122 assert_eq!(
3123 derive_replayable_phases_from_facts(true, &facts),
3124 Vec::<Phase>::new()
3125 );
3126 }
3127
3128 #[test]
3132 fn completed_refit_missing_artifact_yields_empty() {
3133 let facts = ClosureReplayFacts {
3134 nodes: vec![node(
3135 &[Phase::FitCv, Phase::Refit, Phase::Predict],
3136 true,
3137 false,
3138 )],
3139 oof_edges: vec![],
3140 };
3141 assert_eq!(
3142 derive_replayable_phases_from_facts(true, &facts),
3143 Vec::<Phase>::new()
3144 );
3145 }
3146
3147 #[test]
3151 fn no_refit_refit_requires_self_contained_oof_payload() {
3152 let supported = [Phase::FitCv, Phase::Refit, Phase::Predict, Phase::Explain];
3153 let backed = ClosureReplayFacts {
3154 nodes: vec![node(&supported, true, false), node(&supported, true, false)],
3155 oof_edges: vec![oof(true, true, true)],
3156 };
3157 assert_eq!(
3158 derive_replayable_phases_from_facts(false, &backed),
3159 vec![Phase::Refit]
3160 );
3161
3162 let missing_payload = ClosureReplayFacts {
3164 nodes: vec![node(&supported, true, false), node(&supported, true, false)],
3165 oof_edges: vec![oof(true, true, false)],
3166 };
3167 assert_eq!(
3168 derive_replayable_phases_from_facts(false, &missing_payload),
3169 Vec::<Phase>::new()
3170 );
3171
3172 let missing_record = ClosureReplayFacts {
3174 nodes: vec![node(&supported, true, false)],
3175 oof_edges: vec![oof(true, false, true)],
3176 };
3177 assert_eq!(
3178 derive_replayable_phases_from_facts(false, &missing_record),
3179 Vec::<Phase>::new()
3180 );
3181 }
3182
3183 #[test]
3186 fn no_refit_without_oof_edges_is_vacuously_refit() {
3187 let facts = ClosureReplayFacts {
3188 nodes: vec![node(
3189 &[Phase::FitCv, Phase::Refit, Phase::Predict],
3190 false,
3191 false,
3192 )],
3193 oof_edges: vec![],
3194 };
3195 assert_eq!(
3196 derive_replayable_phases_from_facts(false, &facts),
3197 vec![Phase::Refit]
3198 );
3199 }
3200
3201 #[test]
3203 fn no_refit_without_refit_support_yields_empty() {
3204 let facts = ClosureReplayFacts {
3205 nodes: vec![
3206 node(&[Phase::FitCv, Phase::Refit], false, false),
3207 node(&[Phase::FitCv, Phase::Predict], false, false),
3208 ],
3209 oof_edges: vec![],
3210 };
3211 assert_eq!(
3212 derive_replayable_phases_from_facts(false, &facts),
3213 Vec::<Phase>::new()
3214 );
3215 }
3216
3217 #[test]
3222 fn stateless_replay_required_operator_without_artifact_stays_predict_replayable() {
3223 let facts = ClosureReplayFacts {
3224 nodes: vec![node(
3225 &[Phase::FitCv, Phase::Refit, Phase::Predict],
3226 false,
3227 false,
3228 )],
3229 oof_edges: vec![],
3230 };
3231 assert_eq!(
3232 derive_replayable_phases_from_facts(true, &facts),
3233 vec![Phase::Predict]
3234 );
3235 }
3236
3237 #[test]
3240 fn stateful_non_emitter_without_artifact_cannot_advertise_predict() {
3241 let facts = ClosureReplayFacts {
3242 nodes: vec![node(
3243 &[Phase::FitCv, Phase::Refit, Phase::Predict],
3244 true,
3245 false,
3246 )],
3247 oof_edges: vec![],
3248 };
3249 assert_eq!(
3250 derive_replayable_phases_from_facts(true, &facts),
3251 Vec::<Phase>::new()
3252 );
3253 }
3254}
3255
3256#[cfg(test)]
3257mod tests {
3258 use super::*;
3259
3260 const REFIT_FIXTURE: &str =
3261 include_str!("../../../examples/fixtures/estimator/training_outcome_refit.v1.json");
3262 const NO_REFIT_FIXTURE: &str =
3263 include_str!("../../../examples/fixtures/estimator/training_outcome_no_refit.v1.json");
3264
3265 #[test]
3266 fn cv_ensemble_partition_truth_table_retains_validation_only() {
3267 for (partition, expected) in [
3268 (PredictionPartition::Validation, true),
3269 (PredictionPartition::Train, false),
3270 (PredictionPartition::Test, false),
3271 (PredictionPartition::Final, false),
3272 ] {
3273 assert_eq!(
3274 is_cv_ensemble_partition(&partition),
3275 expected,
3276 "unexpected CvEnsemble retention decision for {partition:?}"
3277 );
3278 }
3279 }
3280
3281 #[test]
3282 fn independent_w0_training_outcomes_parse_and_round_trip_fingerprint() {
3283 for fixture in [REFIT_FIXTURE, NO_REFIT_FIXTURE] {
3284 let outcome = TrainingOutcome::from_json(fixture).expect("valid W0 outcome");
3285 assert_eq!(
3286 outcome.compute_fingerprint().unwrap(),
3287 outcome.outcome_fingerprint
3288 );
3289 let serialized = serde_json::to_string(&outcome).unwrap();
3290 let reparsed = TrainingOutcome::from_json(&serialized).unwrap();
3291 assert_eq!(reparsed, outcome);
3292 }
3293 }
3294
3295 #[test]
3296 fn strict_parser_rejects_tamper_and_unknown_field() {
3297 let mut tampered: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
3298 tampered["warnings"] = serde_json::json!(["tampered"]);
3299 assert!(TrainingOutcome::from_json(&serde_json::to_string(&tampered).unwrap()).is_err());
3300
3301 let mut unknown: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
3302 unknown["unknown_field"] = serde_json::json!(true);
3303 assert!(TrainingOutcome::from_json(&serde_json::to_string(&unknown).unwrap()).is_err());
3304 }
3305
3306 #[test]
3307 fn outcome_rejects_nested_runtime_handle_keys_defense_in_depth() {
3308 let mut outcome = TrainingOutcome::from_json(REFIT_FIXTURE).unwrap();
3309 outcome.diagnostics.insert(
3310 "nested".to_string(),
3311 serde_json::json!({"runtime_handle": "process-local"}),
3312 );
3313 outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
3314 let error = outcome.validate().unwrap_err();
3315 assert!(error.to_string().contains("runtime handles"), "{error}");
3316 }
3317
3318 #[test]
3319 fn strict_parser_rejects_future_version_even_when_resigned() {
3320 let mut future: serde_json::Value = serde_json::from_str(REFIT_FIXTURE).unwrap();
3321 future["schema_version"] = serde_json::json!(2);
3322 let mut provisional: TrainingOutcome = serde_json::from_value(future.clone()).unwrap();
3323 provisional.outcome_fingerprint = provisional.compute_fingerprint().unwrap();
3324 future["outcome_fingerprint"] =
3325 serde_json::Value::String(provisional.outcome_fingerprint.clone());
3326 assert!(TrainingOutcome::from_json(&serde_json::to_string(&future).unwrap()).is_err());
3327 }
3328
3329 #[test]
3330 fn select_lineage_is_portable_but_foreign_phase_is_rejected() {
3331 let mut outcome = TrainingOutcome::from_json(REFIT_FIXTURE).unwrap();
3332 let mut select = outcome.lineage[0].clone();
3333 select.record_id = LineageId::new("lineage:select:audit").unwrap();
3334 select.phase = Phase::Select;
3335 select.fold_id = None;
3336 select.input_lineage.clear();
3337 select.artifact_refs.clear();
3338 outcome.lineage.push(select.clone());
3339 outcome
3340 .lineage
3341 .sort_by(|left, right| left.record_id.cmp(&right.record_id));
3342 outcome.outcome_fingerprint = zero_fingerprint();
3343 outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
3344 outcome.validate().unwrap();
3345
3346 let added = outcome
3347 .lineage
3348 .iter_mut()
3349 .find(|record| record.record_id.as_str() == "lineage:select:audit")
3350 .unwrap();
3351 added.phase = Phase::Predict;
3352 added.record_id = LineageId::new("lineage:predict:foreign").unwrap();
3353 outcome
3354 .lineage
3355 .sort_by(|left, right| left.record_id.cmp(&right.record_id));
3356 outcome.outcome_fingerprint = zero_fingerprint();
3357 outcome.outcome_fingerprint = outcome.compute_fingerprint().unwrap();
3358 assert!(outcome.validate().is_err());
3359 }
3360
3361 #[test]
3362 fn every_data_identity_must_bind_the_global_relation() {
3363 let relation = "a".repeat(64);
3364 let identity = |key: &str, relation_fingerprint: String| TrainingDataIdentity {
3365 requirement_key: key.to_string(),
3366 schema_fingerprint: "b".repeat(64),
3367 plan_fingerprint: "c".repeat(64),
3368 relation_fingerprint,
3369 data_content_fingerprint: "d".repeat(64),
3370 target_content_fingerprint: "e".repeat(64),
3371 identity_fingerprint: "f".repeat(64),
3372 };
3373 let identities = vec![
3374 identity("model:a.x", relation.clone()),
3375 identity("model:b.x", "9".repeat(64)),
3376 ];
3377 assert!(validate_all_identity_relations(&identities, &relation).is_err());
3378 let identities = vec![
3379 identity("model:a.x", relation.clone()),
3380 identity("model:b.x", relation.clone()),
3381 ];
3382 validate_all_identity_relations(&identities, &relation).unwrap();
3383 }
3384
3385 #[test]
3386 fn auxiliary_report_levels_do_not_override_selection_target_level() {
3387 let report = |producer: &str, level| crate::metrics::RegressionMetricReport {
3388 prediction_id: Some(format!("prediction:{producer}")),
3389 producer_node: NodeId::new(producer).unwrap(),
3390 producer_port: None,
3391 variant_id: Some(VariantId::new("variant:test").unwrap()),
3392 variant_label: None,
3393 partition: PredictionPartition::Validation,
3394 fold_id: Some(crate::ids::FoldId::new("avg").unwrap()),
3395 level,
3396 row_count: 2,
3397 target_width: 1,
3398 target_names: vec!["y".to_string()],
3399 metrics: BTreeMap::from([("rmse".to_string(), 0.1)]),
3400 };
3401 let reports = vec![
3402 report("model:target", PredictionLevel::Sample),
3403 report("model:target", PredictionLevel::Group),
3404 report("model:aux", PredictionLevel::Group),
3405 ];
3406 validate_selection_report_levels(
3407 &reports,
3408 &NodeId::new("model:target").unwrap(),
3409 &None,
3410 PredictionLevel::Sample,
3411 )
3412 .unwrap();
3413 assert!(validate_selection_report_levels(
3414 &reports,
3415 &NodeId::new("model:target").unwrap(),
3416 &None,
3417 PredictionLevel::Target,
3418 )
3419 .is_err());
3420 }
3421}