1pub(crate) use std::cell::RefCell;
10pub(crate) use std::collections::{BTreeMap, BTreeSet};
11pub(crate) use std::fs;
12pub(crate) use std::io::Read;
13pub(crate) use std::path::{Path, PathBuf};
14
15pub(crate) use serde::{Deserialize, Serialize};
16pub(crate) use sha2::{Digest, Sha256};
17
18pub(crate) use crate::aggregation::{
19 aggregate_observation_predictions, aggregate_sample_predictions_by_unit,
20 reduce_predictions_across_branches, reduce_proba_mean_across_branches,
21 AggregatedPredictionBlock, AggregationControllerInput, AggregationControllerOutput,
22 AggregationControllerResult, AggregationControllerTask, ObservationPredictionBlock,
23 PredictionUnitId,
24};
25pub(crate) use crate::bundle::{
26 build_aggregated_prediction_cache_payload, build_prediction_cache_payload,
27 bundle_prediction_requirement_key, validate_prediction_cache_payload_matches_record,
28 BundlePredictionCachePayload, BundlePredictionCachePayloadSet, BundlePredictionCacheRecord,
29 BundlePredictionRequirement, ExecutionBundle, RefitArtifactRecord, ReplayPhaseRequest,
30};
31pub(crate) use crate::campaign::stable_json_fingerprint;
32pub(crate) use crate::controller::{capabilities_support_fit_influence, ControllerCapability};
33pub(crate) use crate::criteria::{EarlyStoppingRecord, LossExecutionAttestation};
34pub(crate) use crate::data::{
35 data_binding_requirement_key, DataBinding, DataRequestPartition, ExternalDataPlanEnvelope,
36 RepresentationCompatibilityReport, RepresentationPlan, RepresentationReplayManifest,
37};
38pub(crate) use crate::error::{DagMlError, Result};
39pub(crate) use crate::fold::{FoldAssignment, FoldPartitionMode, FoldSet};
40pub(crate) use crate::generation::{
41 enumerate_variants, GenerationChoice, OperatorVariantModel, VariantPlan,
42};
43pub(crate) use crate::graph::{EdgeSpec, NodeKind, PortKind};
44pub(crate) use crate::ids::{
45 ArtifactId, BranchId, BundleId, ControllerId, FoldId, LineageId, NodeId, RunId, SampleId,
46 VariantId,
47};
48pub(crate) use crate::metrics::{
49 cross_fold_validation_reports, reassemble_merge_targets, score_regression_aggregated_block,
50 score_regression_prediction_block, OofAverageBlock, RegressionMetricKind,
51 RegressionMetricReport, RegressionTargetBlock, RegressionTargetRecord, ScoreSet,
52 SCORE_SET_SCHEMA_VERSION,
53};
54pub(crate) use crate::oof::{
55 PredictionBlock, PredictionPartition, StackingOofRefitContract, StackingOofRefitDecision,
56 StackingOofRefitPolicy,
57};
58pub(crate) use crate::phase::Phase;
59pub(crate) use crate::plan::{prune_plan_to_active, CampaignSpec, ExecutionPlan, NodePlan};
60pub(crate) use crate::policy::{
61 AggregationPolicy, FitInfluencePolicy, PredictionLevel, ShapeDelta, ShapeDeltaKind,
62};
63pub(crate) use crate::relation::SampleRelationSet;
64pub(crate) use crate::rng::SeedContext;
65pub(crate) use crate::selection::{
66 select_candidate, CandidateScore, SelectionDecision, SelectionMetric, SelectionPolicy,
67};
68
69mod artifact;
70mod dataview;
71mod host_hpo;
72mod merge;
73mod methods_replay;
74mod oof;
75mod prediction_store;
76mod scheduler;
77mod scoring;
78mod stacking;
79mod task;
80
81pub use artifact::*;
82pub use dataview::*;
83pub use host_hpo::*;
84pub(crate) use merge::*;
85#[cfg(feature = "methods-optimizer")]
86pub use methods_replay::*;
87pub use oof::*;
88pub use prediction_store::*;
89pub use scheduler::*;
90pub(crate) use scoring::*;
91pub(crate) use stacking::*;
92pub use task::*;
93
94pub struct BundleReplayExecution<'a> {
95 pub plan: &'a ExecutionPlan,
96 pub bundle: &'a ExecutionBundle,
97 pub replay_request: &'a ReplayPhaseRequest,
98 pub prediction_cache_store: Option<&'a dyn RuntimePredictionCacheStore>,
99 pub controllers: &'a RuntimeControllerRegistry,
100 pub data_provider: &'a dyn RuntimeDataProvider,
101 pub artifact_store: &'a dyn RuntimeArtifactStore,
102 pub data_envelopes: &'a BTreeMap<String, ExternalDataPlanEnvelope>,
103}
104
105#[derive(Default)]
106pub struct RuntimeControllerRegistry {
107 controllers: BTreeMap<ControllerId, Box<dyn RuntimeController>>,
108}
109
110impl RuntimeControllerRegistry {
111 pub fn new() -> Self {
112 Self::default()
113 }
114
115 pub fn register(&mut self, controller: Box<dyn RuntimeController>) -> Result<()> {
116 let id = controller.controller_id().clone();
117 if self.controllers.insert(id.clone(), controller).is_some() {
118 return Err(DagMlError::RuntimeValidation(format!(
119 "duplicate runtime controller `{id}`"
120 )));
121 }
122 Ok(())
123 }
124
125 pub fn get(&self, controller_id: &ControllerId) -> Option<&dyn RuntimeController> {
126 self.controllers.get(controller_id).map(Box::as_ref)
127 }
128}
129
130pub fn dispatch_custom_observation_aggregation(
131 plan: &ExecutionPlan,
132 controllers: &RuntimeControllerRegistry,
133 task_id: impl Into<String>,
134 block: ObservationPredictionBlock,
135 relations: SampleRelationSet,
136 policy: AggregationPolicy,
137 requested_sample_order: Vec<SampleId>,
138) -> Result<PredictionBlock> {
139 let controller_id = custom_aggregation_controller_id(&policy)?;
140 ensure_aggregation_controller_capability(plan, controller_id)?;
141 let task = AggregationControllerTask {
142 schema_version: crate::aggregation::AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
143 task_id: task_id.into(),
144 controller_id: controller_id.clone(),
145 policy,
146 reduction_plan: None,
147 input: AggregationControllerInput::ObservationToSample {
148 block,
149 relations,
150 requested_sample_order,
151 },
152 };
153 let result = dispatch_custom_aggregation_task(controllers, &task)?;
154 match result.output {
155 AggregationControllerOutput::Sample { block } => Ok(block),
156 AggregationControllerOutput::Unit { .. } => Err(DagMlError::RuntimeValidation(format!(
157 "aggregation controller task `{}` returned unit output for observation input",
158 task.task_id
159 ))),
160 }
161}
162
163pub fn dispatch_custom_sample_aggregation(
164 plan: &ExecutionPlan,
165 controllers: &RuntimeControllerRegistry,
166 task_id: impl Into<String>,
167 block: PredictionBlock,
168 relations: SampleRelationSet,
169 policy: AggregationPolicy,
170 requested_unit_order: Vec<PredictionUnitId>,
171) -> Result<AggregatedPredictionBlock> {
172 let controller_id = custom_aggregation_controller_id(&policy)?;
173 ensure_aggregation_controller_capability(plan, controller_id)?;
174 let task = AggregationControllerTask {
175 schema_version: crate::aggregation::AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
176 task_id: task_id.into(),
177 controller_id: controller_id.clone(),
178 policy,
179 reduction_plan: None,
180 input: AggregationControllerInput::SampleToUnit {
181 block,
182 relations,
183 requested_unit_order,
184 },
185 };
186 let result = dispatch_custom_aggregation_task(controllers, &task)?;
187 match result.output {
188 AggregationControllerOutput::Unit { block } => Ok(block),
189 AggregationControllerOutput::Sample { .. } => Err(DagMlError::RuntimeValidation(format!(
190 "aggregation controller task `{}` returned sample output for sample input",
191 task.task_id
192 ))),
193 }
194}
195
196pub fn dispatch_custom_aggregation_task(
197 controllers: &RuntimeControllerRegistry,
198 task: &AggregationControllerTask,
199) -> Result<AggregationControllerResult> {
200 task.validate()?;
201 let controller = controllers.get(&task.controller_id).ok_or_else(|| {
202 DagMlError::RuntimeValidation(format!(
203 "aggregation runtime controller `{}` is not registered",
204 task.controller_id
205 ))
206 })?;
207 let result = controller.invoke_aggregation(task)?;
208 result.validate_for_task(task)?;
209 Ok(result)
210}
211
212pub(crate) fn custom_aggregation_controller_id(
213 policy: &AggregationPolicy,
214) -> Result<&ControllerId> {
215 policy.validate()?;
216 policy
217 .custom_controller
218 .as_ref()
219 .map(|controller| &controller.controller_id)
220 .ok_or_else(|| {
221 DagMlError::RuntimeValidation(
222 "custom aggregation dispatch requires a custom_controller policy".to_string(),
223 )
224 })
225}
226
227pub(crate) fn ensure_aggregation_controller_capability(
228 plan: &ExecutionPlan,
229 controller_id: &ControllerId,
230) -> Result<()> {
231 let manifest = plan
232 .controller_manifests
233 .get(controller_id)
234 .ok_or_else(|| {
235 DagMlError::Planning(format!(
236 "missing aggregation controller manifest `{controller_id}`"
237 ))
238 })?;
239 if !manifest
240 .capabilities
241 .contains(&ControllerCapability::AggregatesPredictions)
242 {
243 return Err(DagMlError::Planning(format!(
244 "aggregation controller `{controller_id}` must declare aggregates_predictions"
245 )));
246 }
247 Ok(())
248}
249
250#[derive(Clone, Debug)]
251pub struct RunContext {
252 pub run_id: RunId,
253 pub root_seed: Option<u64>,
254 pub variant_id: Option<VariantId>,
255 pub prediction_store: InMemoryPredictionStore,
256 pub aggregated_prediction_store: InMemoryAggregatedPredictionStore,
257 pub lineage: InMemoryLineageRecorder,
258 pub score_collector: Vec<RegressionMetricReport>,
261 pub regression_target_records: Vec<RegressionTargetRecord>,
263 pub oof_average_blocks: Vec<OofAverageBlock>,
267 pub(crate) global_oof_aggregation: BTreeMap<NodeId, GlobalOofAggregationSpec>,
272 pub(crate) validation_scoring_fold_ids: Option<BTreeSet<FoldId>>,
277}
278
279#[derive(Clone, Debug)]
280pub(crate) struct GlobalOofAggregationSpec {
281 pub(crate) policy: AggregationPolicy,
282 pub(crate) relations: SampleRelationSet,
283}
284
285impl RunContext {
286 pub fn new(run_id: RunId, root_seed: Option<u64>) -> Self {
287 Self {
288 run_id,
289 root_seed,
290 variant_id: None,
291 prediction_store: InMemoryPredictionStore::new(),
292 aggregated_prediction_store: InMemoryAggregatedPredictionStore::new(),
293 lineage: InMemoryLineageRecorder::new(),
294 score_collector: Vec::new(),
295 regression_target_records: Vec::new(),
296 oof_average_blocks: Vec::new(),
297 global_oof_aggregation: BTreeMap::new(),
298 validation_scoring_fold_ids: None,
299 }
300 }
301
302 pub(crate) fn configure_global_oof_aggregation(
307 &mut self,
308 plan: &ExecutionPlan,
309 data_provider: &dyn RuntimeDataProvider,
310 ) -> Result<()> {
311 self.global_oof_aggregation = global_oof_aggregation_specs(plan, data_provider)?;
312 Ok(())
313 }
314
315 pub fn collect_cross_fold_validation_scores(
327 &mut self,
328 partition_mode: FoldPartitionMode,
329 ) -> Result<()> {
330 let scoring_blocks = self
331 .prediction_store
332 .blocks()
333 .iter()
334 .filter(|block| {
335 block.partition != PredictionPartition::Validation
336 || self
337 .validation_scoring_fold_ids
338 .as_ref()
339 .is_none_or(|allowed| {
340 block
341 .fold_id
342 .as_ref()
343 .is_some_and(|fold_id| allowed.contains(fold_id))
344 })
345 })
346 .cloned()
347 .collect::<Vec<_>>();
348 let outcome = cross_fold_validation_reports(
349 &scoring_blocks,
350 &self
351 .regression_target_records
352 .iter()
353 .filter(|record| {
354 record.partition != PredictionPartition::Validation
355 || self
356 .validation_scoring_fold_ids
357 .as_ref()
358 .is_none_or(|allowed| {
359 record
360 .fold_id
361 .as_ref()
362 .is_some_and(|fold| allowed.contains(fold))
363 })
364 })
365 .cloned()
366 .collect::<Vec<_>>(),
367 SCORE_METRICS,
368 partition_mode,
369 )?;
370 let outcome = apply_global_oof_aggregation(outcome, &self.global_oof_aggregation)?;
371 self.score_collector.extend(outcome.reports);
372 self.oof_average_blocks.extend(outcome.oof_averages);
373 Ok(())
374 }
375
376 pub fn build_score_set(
379 &self,
380 plan_id: impl Into<String>,
381 selection_metric: Option<String>,
382 ) -> Option<ScoreSet> {
383 if self.score_collector.is_empty() {
384 return None;
385 }
386 Some(ScoreSet {
387 schema_version: SCORE_SET_SCHEMA_VERSION,
388 plan_id: plan_id.into(),
389 selection_metric,
390 reports: self.score_collector.clone(),
391 })
392 }
393}
394
395#[derive(Clone, Debug)]
405pub struct VariantSelection {
406 pub selected_variant_id: VariantId,
409 pub validation_reports: Vec<RegressionMetricReport>,
413 pub variant_validation_predictions: Vec<VariantValidationPredictions>,
427}
428
429#[derive(Clone, Debug)]
436pub struct VariantSelectionOutcome {
437 pub selection: VariantSelection,
438 pub decision: SelectionDecision,
439}
440
441#[derive(Clone, Debug)]
447pub struct VariantValidationPredictions {
448 pub variant_id: VariantId,
451 pub variant_label: Option<String>,
454 pub predictions: Vec<PredictionBlock>,
458 pub regression_targets: Vec<RegressionTargetBlock>,
461 pub oof_average: Option<OofAverageBlock>,
465}
466
467pub fn select_best_variant_by_cv<F>(
497 plan: &ExecutionPlan,
498 run_id: &RunId,
499 root_seed: Option<u64>,
500 selection_metric: RegressionMetricKind,
501 run_single_variant_fit_cv: F,
502) -> Result<Option<VariantSelection>>
503where
504 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
505{
506 Ok(select_best_variant_outcome_by_cv(
507 plan,
508 run_id,
509 root_seed,
510 selection_metric,
511 run_single_variant_fit_cv,
512 )?
513 .map(|outcome| outcome.selection))
514}
515
516pub fn select_best_variant_outcome_by_cv<F>(
523 plan: &ExecutionPlan,
524 run_id: &RunId,
525 root_seed: Option<u64>,
526 selection_metric: RegressionMetricKind,
527 mut run_single_variant_fit_cv: F,
528) -> Result<Option<VariantSelectionOutcome>>
529where
530 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
531{
532 plan.validate()?;
533 if plan.variants.is_empty() {
534 return Err(DagMlError::RuntimeValidation(
535 "cannot select a variant for a plan with no variants".to_string(),
536 ));
537 }
538 score_and_rank_variants_by_cv(
541 &plan.variants,
542 run_id,
543 root_seed,
544 selection_metric,
545 plan_oof_partition_mode(plan),
546 None,
547 |variant| {
548 Ok(ExecutionPlan {
549 variants: vec![variant.clone()],
550 ..plan.clone()
551 })
552 },
553 |_variant| Ok(None),
556 &mut run_single_variant_fit_cv,
557 )
558}
559
560#[allow(clippy::too_many_arguments)]
564pub fn select_best_variant_outcome_by_cv_for_target<F>(
565 plan: &ExecutionPlan,
566 run_id: &RunId,
567 root_seed: Option<u64>,
568 selection_metric: RegressionMetricKind,
569 score_target: &NodeId,
570 score_target_port: Option<&str>,
571 score_target_level: PredictionLevel,
572 mut run_single_variant_fit_cv: F,
573) -> Result<Option<VariantSelectionOutcome>>
574where
575 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
576{
577 plan.validate()?;
578 if !plan.node_plans.contains_key(score_target) {
579 return Err(DagMlError::RuntimeValidation(format!(
580 "native SELECT score target `{score_target}` is absent from plan"
581 )));
582 }
583 score_and_rank_variants_by_cv(
584 &plan.variants,
585 run_id,
586 root_seed,
587 selection_metric,
588 plan_oof_partition_mode(plan),
589 Some((score_target, score_target_port, score_target_level)),
590 |variant| {
591 Ok(ExecutionPlan {
592 variants: vec![variant.clone()],
593 ..plan.clone()
594 })
595 },
596 |_variant| Ok(None),
597 &mut run_single_variant_fit_cv,
598 )
599}
600
601pub fn select_best_operator_variant_by_cv<F>(
625 union_plan: &ExecutionPlan,
626 model: &OperatorVariantModel,
627 run_id: &RunId,
628 root_seed: Option<u64>,
629 selection_metric: RegressionMetricKind,
630 mut run_single_variant_fit_cv: F,
631) -> Result<Option<VariantSelection>>
632where
633 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
634{
635 union_plan.validate()?;
636 model.validate()?;
637 let variants = enumerate_variants(&model.generation_spec(), root_seed)?;
638 if variants.is_empty() {
639 return Err(DagMlError::RuntimeValidation(format!(
640 "operator variant model `{}` produced no variants",
641 model.generator_id
642 )));
643 }
644 let all_choice_nodes = model
647 .active_nodes
648 .values()
649 .flatten()
650 .cloned()
651 .collect::<BTreeSet<NodeId>>();
652 Ok(score_and_rank_variants_by_cv(
656 &variants,
657 run_id,
658 root_seed,
659 selection_metric,
660 plan_oof_partition_mode(union_plan),
661 None,
662 |variant| {
663 let active_subsequence = operator_variant_active_subsequence(model, variant)?;
664 let active_nodes = model.active_nodes.get(active_subsequence).ok_or_else(|| {
665 DagMlError::RuntimeValidation(format!(
666 "operator variant model `{}` has no active-node set for `{active_subsequence}`",
667 model.generator_id
668 ))
669 })?;
670 prune_plan_to_active(union_plan, active_nodes, &all_choice_nodes, variant)
671 },
672 |variant| {
676 let active_subsequence = operator_variant_active_subsequence(model, variant)?;
677 Ok(model.variant_labels.get(active_subsequence).cloned())
678 },
679 &mut run_single_variant_fit_cv,
680 )?
681 .map(|outcome| outcome.selection))
682}
683
684fn operator_variant_active_subsequence<'a>(
688 model: &OperatorVariantModel,
689 variant: &'a VariantPlan,
690) -> Result<&'a str> {
691 let dimension_name = &model.dimension.name;
692 let choice = variant.choices.get(dimension_name).ok_or_else(|| {
693 DagMlError::RuntimeValidation(format!(
694 "operator variant `{}` is missing the operator dimension `{dimension_name}`",
695 variant.variant_id
696 ))
697 })?;
698 choice.active_subsequence.as_deref().ok_or_else(|| {
699 DagMlError::RuntimeValidation(format!(
700 "operator variant `{}` choice `{}` has no active_subsequence",
701 variant.variant_id, choice.label
702 ))
703 })
704}
705
706pub fn select_best_operator_variant_from_models<F>(
715 union_plan: &ExecutionPlan,
716 models: &[OperatorVariantModel],
717 run_id: &RunId,
718 root_seed: Option<u64>,
719 selection_metric: RegressionMetricKind,
720 run_single_variant_fit_cv: F,
721) -> Result<Option<VariantSelection>>
722where
723 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
724{
725 match models {
726 [] => Ok(None),
727 [model] => select_best_operator_variant_by_cv(
728 union_plan,
729 model,
730 run_id,
731 root_seed,
732 selection_metric,
733 run_single_variant_fit_cv,
734 ),
735 _ => Err(DagMlError::RuntimeValidation(format!(
736 "operator-SELECT does not support {} operator generators in one pipeline; this phase scopes to a flat single operator generator (generators: {})",
737 models.len(),
738 models
739 .iter()
740 .map(|model| model.generator_id.to_string())
741 .collect::<Vec<_>>()
742 .join(", ")
743 ))),
744 }
745}
746
747#[allow(clippy::too_many_arguments)]
756fn score_and_rank_variants_by_cv<M, L, F>(
757 variants: &[VariantPlan],
758 run_id: &RunId,
759 root_seed: Option<u64>,
760 selection_metric: RegressionMetricKind,
761 partition_mode: FoldPartitionMode,
762 score_target: Option<(&NodeId, Option<&str>, PredictionLevel)>,
763 mut make_variant_plan: M,
764 mut resolve_variant_label: L,
765 run_single_variant_fit_cv: &mut F,
766) -> Result<Option<VariantSelectionOutcome>>
767where
768 M: FnMut(&VariantPlan) -> Result<ExecutionPlan>,
769 L: FnMut(&VariantPlan) -> Result<Option<String>>,
770 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
771{
772 if variants.is_empty() {
773 return Err(DagMlError::RuntimeValidation(
774 "cannot select a variant for a plan with no variants".to_string(),
775 ));
776 }
777
778 let mut candidates: Vec<CandidateScore> = Vec::with_capacity(variants.len());
779 let mut variant_validation_reports: Vec<RegressionMetricReport> = Vec::new();
782 let mut variant_validation_predictions: Vec<VariantValidationPredictions> = Vec::new();
787 let mut any_scores_seen = false;
790 for variant in variants {
791 let variant_plan = make_variant_plan(variant)?;
792 let variant_label = resolve_variant_label(variant)?;
796 let mut ctx = RunContext::new(run_id.clone(), root_seed);
797 ctx.variant_id = Some(variant.variant_id.clone());
798 run_single_variant_fit_cv(&variant_plan, &mut ctx)?;
799 ctx.collect_cross_fold_validation_scores(partition_mode)?;
800 if !ctx.score_collector.is_empty() {
801 any_scores_seen = true;
802 }
803 let captured = capture_variant_validation_predictions(
812 &variant.variant_id,
813 variant_label.clone(),
814 &ctx,
815 );
816 if !captured.predictions.is_empty() || captured.oof_average.is_some() {
817 variant_validation_predictions.push(captured);
818 }
819 let avg_reports = ctx
825 .score_collector
826 .iter()
827 .filter(|report| {
828 report.partition == PredictionPartition::Validation
829 && score_target.is_none_or(|(target, target_port, level)| {
830 &report.producer_node == target
831 && target_port
832 .is_none_or(|port| report.producer_port.as_deref() == Some(port))
833 && report.level == level
834 })
835 && report
836 .fold_id
837 .as_ref()
838 .is_some_and(|fold| fold.as_str() == "avg")
839 })
840 .collect::<Vec<_>>();
841 match avg_reports.as_slice() {
842 [] => {}
843 [report] => candidates.push(
844 (*report)
845 .clone()
846 .into_candidate_score(variant.variant_id.as_str())?,
847 ),
848 _ => {
849 return Err(DagMlError::RuntimeValidation(format!(
850 "variant `{}` produced {} cross-fold OOF averages (multiple prediction producers); native SELECT needs a single score target",
851 variant.variant_id,
852 avg_reports.len()
853 )));
854 }
855 }
856 for mut report in ctx.score_collector {
862 if report.partition != PredictionPartition::Validation {
863 continue;
864 }
865 report.variant_id = Some(variant.variant_id.clone());
866 report.variant_label = variant_label.clone();
867 variant_validation_reports.push(report);
868 }
869 }
870
871 if candidates.is_empty() {
872 if any_scores_seen {
873 return Err(DagMlError::RuntimeValidation(
876 "variants produced scores but no cross-fold OOF average; cannot rank — need >=2 folds or an explicit score target".to_string(),
877 ));
878 }
879 return Ok(None);
881 }
882 if candidates.len() != variants.len() {
883 return Err(DagMlError::RuntimeValidation(format!(
884 "native variant SELECT scored only {} of {} variants; cannot rank variants fairly",
885 candidates.len(),
886 variants.len()
887 )));
888 }
889
890 let policy = SelectionPolicy {
891 id: format!("select:variant:{}", selection_metric.name()),
892 metric: SelectionMetric {
893 name: selection_metric.name().to_string(),
894 objective: selection_metric.objective(),
895 },
896 required_metric_level: None,
897 require_finite: true,
898 evaluation_scope: None,
899 refit_slot_plan: None,
900 stacking_fit_contract: None,
901 reduction_id: None,
902 };
903 let decision = select_candidate(&policy, &candidates)?;
904 let selected_variant_id =
905 VariantId::new(decision.selected_candidate_id.clone()).map_err(|error| {
906 DagMlError::RuntimeValidation(format!("selected variant id is invalid: {error}"))
907 })?;
908 Ok(Some(VariantSelectionOutcome {
909 selection: VariantSelection {
910 selected_variant_id,
911 validation_reports: variant_validation_reports,
912 variant_validation_predictions,
913 },
914 decision,
915 }))
916}
917
918pub(crate) fn capture_variant_validation_predictions(
934 variant_id: &VariantId,
935 variant_label: Option<String>,
936 ctx: &RunContext,
937) -> VariantValidationPredictions {
938 let mut predictions = Vec::new();
939 let mut regression_targets = Vec::new();
940 for block in ctx.prediction_store.blocks() {
941 if block.partition != PredictionPartition::Validation {
942 continue;
943 }
944 let Some(record) = ctx.regression_target_records.iter().find(|record| {
945 record.producer_node == block.producer_node
946 && record.producer_port == block.producer_port
947 && record.partition == PredictionPartition::Validation
948 && record.fold_id == block.fold_id
949 }) else {
950 continue;
951 };
952 predictions.push(block.clone());
953 regression_targets.push(target_block_aligned_to_samples(
954 &block.sample_ids,
955 &record.block,
956 ));
957 }
958 VariantValidationPredictions {
959 variant_id: variant_id.clone(),
960 variant_label,
961 predictions,
962 regression_targets,
963 oof_average: ctx
967 .oof_average_blocks
968 .iter()
969 .rev()
970 .find(|block| block.predictions.level != PredictionLevel::Sample)
971 .cloned()
972 .or_else(|| ctx.oof_average_blocks.first().cloned()),
973 }
974}
975
976fn target_block_aligned_to_samples(
983 sample_ids: &[SampleId],
984 targets: &RegressionTargetBlock,
985) -> RegressionTargetBlock {
986 let value_by_sample: BTreeMap<&SampleId, &Vec<f64>> = targets
987 .unit_ids
988 .iter()
989 .zip(&targets.values)
990 .filter_map(|(unit_id, row)| match unit_id {
991 PredictionUnitId::Sample(sample_id) => Some((sample_id, row)),
992 _ => None,
993 })
994 .collect();
995 if sample_ids
996 .iter()
997 .any(|sample_id| !value_by_sample.contains_key(sample_id))
998 {
999 return targets.clone();
1000 }
1001 RegressionTargetBlock {
1002 level: PredictionLevel::Sample,
1003 unit_ids: sample_ids
1004 .iter()
1005 .cloned()
1006 .map(PredictionUnitId::Sample)
1007 .collect(),
1008 values: sample_ids
1009 .iter()
1010 .map(|sample_id| value_by_sample[sample_id].clone())
1011 .collect(),
1012 target_names: targets.target_names.clone(),
1013 }
1014}
1015
1016#[cfg(test)]
1017mod explain_contract_tests {
1018 use super::*;
1019
1020 fn block(method: &str) -> ExplanationBlock {
1021 ExplanationBlock {
1022 producer_node: NodeId::new("model:base").unwrap(),
1023 producer_port: None,
1024 method: method.to_string(),
1025 target_name: Some("y".to_string()),
1026 payload: serde_json::json!({"feature_importance": [0.5, 0.3, 0.2]}),
1027 }
1028 }
1029
1030 #[test]
1031 fn validates_well_formed_explanation() {
1032 assert!(block("shap").validate().is_ok());
1033 }
1034
1035 #[test]
1036 fn rejects_empty_method() {
1037 assert!(block(" ").validate().is_err());
1038 }
1039
1040 #[test]
1041 fn rejects_empty_target_name() {
1042 let mut b = block("shap");
1043 b.target_name = Some(String::new());
1044 assert!(b.validate().is_err());
1045 }
1046
1047 #[test]
1048 fn round_trips_through_json() {
1049 let b = block("permutation_importance");
1050 let json = serde_json::to_string(&b).expect("serialize");
1051 let parsed: ExplanationBlock = serde_json::from_str(&json).expect("deserialize");
1052 assert_eq!(parsed, b);
1053 let mut without = block("shap");
1055 without.target_name = None;
1056 let json = serde_json::to_string(&without).expect("serialize");
1057 assert!(!json.contains("target_name"));
1058 }
1059}
1060
1061#[cfg(test)]
1062mod tests;