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 merge;
72mod methods_replay;
73mod oof;
74mod prediction_store;
75mod scheduler;
76mod scoring;
77mod stacking;
78mod task;
79
80pub use artifact::*;
81pub use dataview::*;
82pub(crate) use merge::*;
83#[cfg(feature = "methods-optimizer")]
84pub use methods_replay::*;
85pub use oof::*;
86pub use prediction_store::*;
87pub use scheduler::*;
88pub(crate) use scoring::*;
89pub(crate) use stacking::*;
90pub use task::*;
91
92pub struct BundleReplayExecution<'a> {
93 pub plan: &'a ExecutionPlan,
94 pub bundle: &'a ExecutionBundle,
95 pub replay_request: &'a ReplayPhaseRequest,
96 pub prediction_cache_store: Option<&'a dyn RuntimePredictionCacheStore>,
97 pub controllers: &'a RuntimeControllerRegistry,
98 pub data_provider: &'a dyn RuntimeDataProvider,
99 pub artifact_store: &'a dyn RuntimeArtifactStore,
100 pub data_envelopes: &'a BTreeMap<String, ExternalDataPlanEnvelope>,
101}
102
103#[derive(Default)]
104pub struct RuntimeControllerRegistry {
105 controllers: BTreeMap<ControllerId, Box<dyn RuntimeController>>,
106}
107
108impl RuntimeControllerRegistry {
109 pub fn new() -> Self {
110 Self::default()
111 }
112
113 pub fn register(&mut self, controller: Box<dyn RuntimeController>) -> Result<()> {
114 let id = controller.controller_id().clone();
115 if self.controllers.insert(id.clone(), controller).is_some() {
116 return Err(DagMlError::RuntimeValidation(format!(
117 "duplicate runtime controller `{id}`"
118 )));
119 }
120 Ok(())
121 }
122
123 pub fn get(&self, controller_id: &ControllerId) -> Option<&dyn RuntimeController> {
124 self.controllers.get(controller_id).map(Box::as_ref)
125 }
126}
127
128pub fn dispatch_custom_observation_aggregation(
129 plan: &ExecutionPlan,
130 controllers: &RuntimeControllerRegistry,
131 task_id: impl Into<String>,
132 block: ObservationPredictionBlock,
133 relations: SampleRelationSet,
134 policy: AggregationPolicy,
135 requested_sample_order: Vec<SampleId>,
136) -> Result<PredictionBlock> {
137 let controller_id = custom_aggregation_controller_id(&policy)?;
138 ensure_aggregation_controller_capability(plan, controller_id)?;
139 let task = AggregationControllerTask {
140 schema_version: crate::aggregation::AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
141 task_id: task_id.into(),
142 controller_id: controller_id.clone(),
143 policy,
144 reduction_plan: None,
145 input: AggregationControllerInput::ObservationToSample {
146 block,
147 relations,
148 requested_sample_order,
149 },
150 };
151 let result = dispatch_custom_aggregation_task(controllers, &task)?;
152 match result.output {
153 AggregationControllerOutput::Sample { block } => Ok(block),
154 AggregationControllerOutput::Unit { .. } => Err(DagMlError::RuntimeValidation(format!(
155 "aggregation controller task `{}` returned unit output for observation input",
156 task.task_id
157 ))),
158 }
159}
160
161pub fn dispatch_custom_sample_aggregation(
162 plan: &ExecutionPlan,
163 controllers: &RuntimeControllerRegistry,
164 task_id: impl Into<String>,
165 block: PredictionBlock,
166 relations: SampleRelationSet,
167 policy: AggregationPolicy,
168 requested_unit_order: Vec<PredictionUnitId>,
169) -> Result<AggregatedPredictionBlock> {
170 let controller_id = custom_aggregation_controller_id(&policy)?;
171 ensure_aggregation_controller_capability(plan, controller_id)?;
172 let task = AggregationControllerTask {
173 schema_version: crate::aggregation::AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
174 task_id: task_id.into(),
175 controller_id: controller_id.clone(),
176 policy,
177 reduction_plan: None,
178 input: AggregationControllerInput::SampleToUnit {
179 block,
180 relations,
181 requested_unit_order,
182 },
183 };
184 let result = dispatch_custom_aggregation_task(controllers, &task)?;
185 match result.output {
186 AggregationControllerOutput::Unit { block } => Ok(block),
187 AggregationControllerOutput::Sample { .. } => Err(DagMlError::RuntimeValidation(format!(
188 "aggregation controller task `{}` returned sample output for sample input",
189 task.task_id
190 ))),
191 }
192}
193
194pub fn dispatch_custom_aggregation_task(
195 controllers: &RuntimeControllerRegistry,
196 task: &AggregationControllerTask,
197) -> Result<AggregationControllerResult> {
198 task.validate()?;
199 let controller = controllers.get(&task.controller_id).ok_or_else(|| {
200 DagMlError::RuntimeValidation(format!(
201 "aggregation runtime controller `{}` is not registered",
202 task.controller_id
203 ))
204 })?;
205 let result = controller.invoke_aggregation(task)?;
206 result.validate_for_task(task)?;
207 Ok(result)
208}
209
210pub(crate) fn custom_aggregation_controller_id(
211 policy: &AggregationPolicy,
212) -> Result<&ControllerId> {
213 policy.validate()?;
214 policy
215 .custom_controller
216 .as_ref()
217 .map(|controller| &controller.controller_id)
218 .ok_or_else(|| {
219 DagMlError::RuntimeValidation(
220 "custom aggregation dispatch requires a custom_controller policy".to_string(),
221 )
222 })
223}
224
225pub(crate) fn ensure_aggregation_controller_capability(
226 plan: &ExecutionPlan,
227 controller_id: &ControllerId,
228) -> Result<()> {
229 let manifest = plan
230 .controller_manifests
231 .get(controller_id)
232 .ok_or_else(|| {
233 DagMlError::Planning(format!(
234 "missing aggregation controller manifest `{controller_id}`"
235 ))
236 })?;
237 if !manifest
238 .capabilities
239 .contains(&ControllerCapability::AggregatesPredictions)
240 {
241 return Err(DagMlError::Planning(format!(
242 "aggregation controller `{controller_id}` must declare aggregates_predictions"
243 )));
244 }
245 Ok(())
246}
247
248#[derive(Clone, Debug)]
249pub struct RunContext {
250 pub run_id: RunId,
251 pub root_seed: Option<u64>,
252 pub variant_id: Option<VariantId>,
253 pub prediction_store: InMemoryPredictionStore,
254 pub aggregated_prediction_store: InMemoryAggregatedPredictionStore,
255 pub lineage: InMemoryLineageRecorder,
256 pub score_collector: Vec<RegressionMetricReport>,
259 pub regression_target_records: Vec<RegressionTargetRecord>,
261 pub oof_average_blocks: Vec<OofAverageBlock>,
265 pub(crate) global_oof_aggregation: BTreeMap<NodeId, GlobalOofAggregationSpec>,
270 pub(crate) validation_scoring_fold_ids: Option<BTreeSet<FoldId>>,
275}
276
277#[derive(Clone, Debug)]
278pub(crate) struct GlobalOofAggregationSpec {
279 pub(crate) policy: AggregationPolicy,
280 pub(crate) relations: SampleRelationSet,
281}
282
283impl RunContext {
284 pub fn new(run_id: RunId, root_seed: Option<u64>) -> Self {
285 Self {
286 run_id,
287 root_seed,
288 variant_id: None,
289 prediction_store: InMemoryPredictionStore::new(),
290 aggregated_prediction_store: InMemoryAggregatedPredictionStore::new(),
291 lineage: InMemoryLineageRecorder::new(),
292 score_collector: Vec::new(),
293 regression_target_records: Vec::new(),
294 oof_average_blocks: Vec::new(),
295 global_oof_aggregation: BTreeMap::new(),
296 validation_scoring_fold_ids: None,
297 }
298 }
299
300 pub(crate) fn configure_global_oof_aggregation(
305 &mut self,
306 plan: &ExecutionPlan,
307 data_provider: &dyn RuntimeDataProvider,
308 ) -> Result<()> {
309 self.global_oof_aggregation = global_oof_aggregation_specs(plan, data_provider)?;
310 Ok(())
311 }
312
313 pub fn collect_cross_fold_validation_scores(
325 &mut self,
326 partition_mode: FoldPartitionMode,
327 ) -> Result<()> {
328 let scoring_blocks = self
329 .prediction_store
330 .blocks()
331 .iter()
332 .filter(|block| {
333 block.partition != PredictionPartition::Validation
334 || self
335 .validation_scoring_fold_ids
336 .as_ref()
337 .is_none_or(|allowed| {
338 block
339 .fold_id
340 .as_ref()
341 .is_some_and(|fold_id| allowed.contains(fold_id))
342 })
343 })
344 .cloned()
345 .collect::<Vec<_>>();
346 let outcome = cross_fold_validation_reports(
347 &scoring_blocks,
348 &self.regression_target_records,
349 SCORE_METRICS,
350 partition_mode,
351 )?;
352 let outcome = apply_global_oof_aggregation(outcome, &self.global_oof_aggregation)?;
353 self.score_collector.extend(outcome.reports);
354 self.oof_average_blocks.extend(outcome.oof_averages);
355 Ok(())
356 }
357
358 pub fn build_score_set(
361 &self,
362 plan_id: impl Into<String>,
363 selection_metric: Option<String>,
364 ) -> Option<ScoreSet> {
365 if self.score_collector.is_empty() {
366 return None;
367 }
368 Some(ScoreSet {
369 schema_version: SCORE_SET_SCHEMA_VERSION,
370 plan_id: plan_id.into(),
371 selection_metric,
372 reports: self.score_collector.clone(),
373 })
374 }
375}
376
377#[derive(Clone, Debug)]
387pub struct VariantSelection {
388 pub selected_variant_id: VariantId,
391 pub validation_reports: Vec<RegressionMetricReport>,
395 pub variant_validation_predictions: Vec<VariantValidationPredictions>,
409}
410
411#[derive(Clone, Debug)]
418pub struct VariantSelectionOutcome {
419 pub selection: VariantSelection,
420 pub decision: SelectionDecision,
421}
422
423#[derive(Clone, Debug)]
429pub struct VariantValidationPredictions {
430 pub variant_id: VariantId,
433 pub variant_label: Option<String>,
436 pub predictions: Vec<PredictionBlock>,
440 pub regression_targets: Vec<RegressionTargetBlock>,
443 pub oof_average: Option<OofAverageBlock>,
447}
448
449pub fn select_best_variant_by_cv<F>(
479 plan: &ExecutionPlan,
480 run_id: &RunId,
481 root_seed: Option<u64>,
482 selection_metric: RegressionMetricKind,
483 run_single_variant_fit_cv: F,
484) -> Result<Option<VariantSelection>>
485where
486 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
487{
488 Ok(select_best_variant_outcome_by_cv(
489 plan,
490 run_id,
491 root_seed,
492 selection_metric,
493 run_single_variant_fit_cv,
494 )?
495 .map(|outcome| outcome.selection))
496}
497
498pub fn select_best_variant_outcome_by_cv<F>(
505 plan: &ExecutionPlan,
506 run_id: &RunId,
507 root_seed: Option<u64>,
508 selection_metric: RegressionMetricKind,
509 mut run_single_variant_fit_cv: F,
510) -> Result<Option<VariantSelectionOutcome>>
511where
512 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
513{
514 plan.validate()?;
515 if plan.variants.is_empty() {
516 return Err(DagMlError::RuntimeValidation(
517 "cannot select a variant for a plan with no variants".to_string(),
518 ));
519 }
520 score_and_rank_variants_by_cv(
523 &plan.variants,
524 run_id,
525 root_seed,
526 selection_metric,
527 plan_oof_partition_mode(plan),
528 None,
529 |variant| {
530 Ok(ExecutionPlan {
531 variants: vec![variant.clone()],
532 ..plan.clone()
533 })
534 },
535 |_variant| Ok(None),
538 &mut run_single_variant_fit_cv,
539 )
540}
541
542#[allow(clippy::too_many_arguments)]
546pub fn select_best_variant_outcome_by_cv_for_target<F>(
547 plan: &ExecutionPlan,
548 run_id: &RunId,
549 root_seed: Option<u64>,
550 selection_metric: RegressionMetricKind,
551 score_target: &NodeId,
552 score_target_port: Option<&str>,
553 score_target_level: PredictionLevel,
554 mut run_single_variant_fit_cv: F,
555) -> Result<Option<VariantSelectionOutcome>>
556where
557 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
558{
559 plan.validate()?;
560 if !plan.node_plans.contains_key(score_target) {
561 return Err(DagMlError::RuntimeValidation(format!(
562 "native SELECT score target `{score_target}` is absent from plan"
563 )));
564 }
565 score_and_rank_variants_by_cv(
566 &plan.variants,
567 run_id,
568 root_seed,
569 selection_metric,
570 plan_oof_partition_mode(plan),
571 Some((score_target, score_target_port, score_target_level)),
572 |variant| {
573 Ok(ExecutionPlan {
574 variants: vec![variant.clone()],
575 ..plan.clone()
576 })
577 },
578 |_variant| Ok(None),
579 &mut run_single_variant_fit_cv,
580 )
581}
582
583pub fn select_best_operator_variant_by_cv<F>(
607 union_plan: &ExecutionPlan,
608 model: &OperatorVariantModel,
609 run_id: &RunId,
610 root_seed: Option<u64>,
611 selection_metric: RegressionMetricKind,
612 mut run_single_variant_fit_cv: F,
613) -> Result<Option<VariantSelection>>
614where
615 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
616{
617 union_plan.validate()?;
618 model.validate()?;
619 let variants = enumerate_variants(&model.generation_spec(), root_seed)?;
620 if variants.is_empty() {
621 return Err(DagMlError::RuntimeValidation(format!(
622 "operator variant model `{}` produced no variants",
623 model.generator_id
624 )));
625 }
626 let all_choice_nodes = model
629 .active_nodes
630 .values()
631 .flatten()
632 .cloned()
633 .collect::<BTreeSet<NodeId>>();
634 Ok(score_and_rank_variants_by_cv(
638 &variants,
639 run_id,
640 root_seed,
641 selection_metric,
642 plan_oof_partition_mode(union_plan),
643 None,
644 |variant| {
645 let active_subsequence = operator_variant_active_subsequence(model, variant)?;
646 let active_nodes = model.active_nodes.get(active_subsequence).ok_or_else(|| {
647 DagMlError::RuntimeValidation(format!(
648 "operator variant model `{}` has no active-node set for `{active_subsequence}`",
649 model.generator_id
650 ))
651 })?;
652 prune_plan_to_active(union_plan, active_nodes, &all_choice_nodes, variant)
653 },
654 |variant| {
658 let active_subsequence = operator_variant_active_subsequence(model, variant)?;
659 Ok(model.variant_labels.get(active_subsequence).cloned())
660 },
661 &mut run_single_variant_fit_cv,
662 )?
663 .map(|outcome| outcome.selection))
664}
665
666fn operator_variant_active_subsequence<'a>(
670 model: &OperatorVariantModel,
671 variant: &'a VariantPlan,
672) -> Result<&'a str> {
673 let dimension_name = &model.dimension.name;
674 let choice = variant.choices.get(dimension_name).ok_or_else(|| {
675 DagMlError::RuntimeValidation(format!(
676 "operator variant `{}` is missing the operator dimension `{dimension_name}`",
677 variant.variant_id
678 ))
679 })?;
680 choice.active_subsequence.as_deref().ok_or_else(|| {
681 DagMlError::RuntimeValidation(format!(
682 "operator variant `{}` choice `{}` has no active_subsequence",
683 variant.variant_id, choice.label
684 ))
685 })
686}
687
688pub fn select_best_operator_variant_from_models<F>(
697 union_plan: &ExecutionPlan,
698 models: &[OperatorVariantModel],
699 run_id: &RunId,
700 root_seed: Option<u64>,
701 selection_metric: RegressionMetricKind,
702 run_single_variant_fit_cv: F,
703) -> Result<Option<VariantSelection>>
704where
705 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
706{
707 match models {
708 [] => Ok(None),
709 [model] => select_best_operator_variant_by_cv(
710 union_plan,
711 model,
712 run_id,
713 root_seed,
714 selection_metric,
715 run_single_variant_fit_cv,
716 ),
717 _ => Err(DagMlError::RuntimeValidation(format!(
718 "operator-SELECT does not support {} operator generators in one pipeline; this phase scopes to a flat single operator generator (generators: {})",
719 models.len(),
720 models
721 .iter()
722 .map(|model| model.generator_id.to_string())
723 .collect::<Vec<_>>()
724 .join(", ")
725 ))),
726 }
727}
728
729#[allow(clippy::too_many_arguments)]
738fn score_and_rank_variants_by_cv<M, L, F>(
739 variants: &[VariantPlan],
740 run_id: &RunId,
741 root_seed: Option<u64>,
742 selection_metric: RegressionMetricKind,
743 partition_mode: FoldPartitionMode,
744 score_target: Option<(&NodeId, Option<&str>, PredictionLevel)>,
745 mut make_variant_plan: M,
746 mut resolve_variant_label: L,
747 run_single_variant_fit_cv: &mut F,
748) -> Result<Option<VariantSelectionOutcome>>
749where
750 M: FnMut(&VariantPlan) -> Result<ExecutionPlan>,
751 L: FnMut(&VariantPlan) -> Result<Option<String>>,
752 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
753{
754 if variants.is_empty() {
755 return Err(DagMlError::RuntimeValidation(
756 "cannot select a variant for a plan with no variants".to_string(),
757 ));
758 }
759
760 let mut candidates: Vec<CandidateScore> = Vec::with_capacity(variants.len());
761 let mut variant_validation_reports: Vec<RegressionMetricReport> = Vec::new();
764 let mut variant_validation_predictions: Vec<VariantValidationPredictions> = Vec::new();
769 let mut any_scores_seen = false;
772 for variant in variants {
773 let variant_plan = make_variant_plan(variant)?;
774 let variant_label = resolve_variant_label(variant)?;
778 let mut ctx = RunContext::new(run_id.clone(), root_seed);
779 ctx.variant_id = Some(variant.variant_id.clone());
780 run_single_variant_fit_cv(&variant_plan, &mut ctx)?;
781 ctx.collect_cross_fold_validation_scores(partition_mode)?;
782 if !ctx.score_collector.is_empty() {
783 any_scores_seen = true;
784 }
785 let captured = capture_variant_validation_predictions(
794 &variant.variant_id,
795 variant_label.clone(),
796 &ctx,
797 );
798 if !captured.predictions.is_empty() || captured.oof_average.is_some() {
799 variant_validation_predictions.push(captured);
800 }
801 let avg_reports = ctx
807 .score_collector
808 .iter()
809 .filter(|report| {
810 report.partition == PredictionPartition::Validation
811 && score_target.is_none_or(|(target, target_port, level)| {
812 &report.producer_node == target
813 && target_port
814 .is_none_or(|port| report.producer_port.as_deref() == Some(port))
815 && report.level == level
816 })
817 && report
818 .fold_id
819 .as_ref()
820 .is_some_and(|fold| fold.as_str() == "avg")
821 })
822 .collect::<Vec<_>>();
823 match avg_reports.as_slice() {
824 [] => {}
825 [report] => candidates.push(
826 (*report)
827 .clone()
828 .into_candidate_score(variant.variant_id.as_str())?,
829 ),
830 _ => {
831 return Err(DagMlError::RuntimeValidation(format!(
832 "variant `{}` produced {} cross-fold OOF averages (multiple prediction producers); native SELECT needs a single score target",
833 variant.variant_id,
834 avg_reports.len()
835 )));
836 }
837 }
838 for mut report in ctx.score_collector {
844 if report.partition != PredictionPartition::Validation {
845 continue;
846 }
847 report.variant_id = Some(variant.variant_id.clone());
848 report.variant_label = variant_label.clone();
849 variant_validation_reports.push(report);
850 }
851 }
852
853 if candidates.is_empty() {
854 if any_scores_seen {
855 return Err(DagMlError::RuntimeValidation(
858 "variants produced scores but no cross-fold OOF average; cannot rank — need >=2 folds or an explicit score target".to_string(),
859 ));
860 }
861 return Ok(None);
863 }
864 if candidates.len() != variants.len() {
865 return Err(DagMlError::RuntimeValidation(format!(
866 "native variant SELECT scored only {} of {} variants; cannot rank variants fairly",
867 candidates.len(),
868 variants.len()
869 )));
870 }
871
872 let policy = SelectionPolicy {
873 id: format!("select:variant:{}", selection_metric.name()),
874 metric: SelectionMetric {
875 name: selection_metric.name().to_string(),
876 objective: selection_metric.objective(),
877 },
878 required_metric_level: None,
879 require_finite: true,
880 evaluation_scope: None,
881 refit_slot_plan: None,
882 stacking_fit_contract: None,
883 reduction_id: None,
884 };
885 let decision = select_candidate(&policy, &candidates)?;
886 let selected_variant_id =
887 VariantId::new(decision.selected_candidate_id.clone()).map_err(|error| {
888 DagMlError::RuntimeValidation(format!("selected variant id is invalid: {error}"))
889 })?;
890 Ok(Some(VariantSelectionOutcome {
891 selection: VariantSelection {
892 selected_variant_id,
893 validation_reports: variant_validation_reports,
894 variant_validation_predictions,
895 },
896 decision,
897 }))
898}
899
900pub(crate) fn capture_variant_validation_predictions(
916 variant_id: &VariantId,
917 variant_label: Option<String>,
918 ctx: &RunContext,
919) -> VariantValidationPredictions {
920 let mut predictions = Vec::new();
921 let mut regression_targets = Vec::new();
922 for block in ctx.prediction_store.blocks() {
923 if block.partition != PredictionPartition::Validation {
924 continue;
925 }
926 let Some(record) = ctx.regression_target_records.iter().find(|record| {
927 record.producer_node == block.producer_node
928 && record.producer_port == block.producer_port
929 && record.partition == PredictionPartition::Validation
930 && record.fold_id == block.fold_id
931 }) else {
932 continue;
933 };
934 predictions.push(block.clone());
935 regression_targets.push(target_block_aligned_to_samples(
936 &block.sample_ids,
937 &record.block,
938 ));
939 }
940 VariantValidationPredictions {
941 variant_id: variant_id.clone(),
942 variant_label,
943 predictions,
944 regression_targets,
945 oof_average: ctx
949 .oof_average_blocks
950 .iter()
951 .rev()
952 .find(|block| block.predictions.level != PredictionLevel::Sample)
953 .cloned()
954 .or_else(|| ctx.oof_average_blocks.first().cloned()),
955 }
956}
957
958fn target_block_aligned_to_samples(
965 sample_ids: &[SampleId],
966 targets: &RegressionTargetBlock,
967) -> RegressionTargetBlock {
968 let value_by_sample: BTreeMap<&SampleId, &Vec<f64>> = targets
969 .unit_ids
970 .iter()
971 .zip(&targets.values)
972 .filter_map(|(unit_id, row)| match unit_id {
973 PredictionUnitId::Sample(sample_id) => Some((sample_id, row)),
974 _ => None,
975 })
976 .collect();
977 if sample_ids
978 .iter()
979 .any(|sample_id| !value_by_sample.contains_key(sample_id))
980 {
981 return targets.clone();
982 }
983 RegressionTargetBlock {
984 level: PredictionLevel::Sample,
985 unit_ids: sample_ids
986 .iter()
987 .cloned()
988 .map(PredictionUnitId::Sample)
989 .collect(),
990 values: sample_ids
991 .iter()
992 .map(|sample_id| value_by_sample[sample_id].clone())
993 .collect(),
994 target_names: targets.target_names.clone(),
995 }
996}
997
998#[cfg(test)]
999mod explain_contract_tests {
1000 use super::*;
1001
1002 fn block(method: &str) -> ExplanationBlock {
1003 ExplanationBlock {
1004 producer_node: NodeId::new("model:base").unwrap(),
1005 producer_port: None,
1006 method: method.to_string(),
1007 target_name: Some("y".to_string()),
1008 payload: serde_json::json!({"feature_importance": [0.5, 0.3, 0.2]}),
1009 }
1010 }
1011
1012 #[test]
1013 fn validates_well_formed_explanation() {
1014 assert!(block("shap").validate().is_ok());
1015 }
1016
1017 #[test]
1018 fn rejects_empty_method() {
1019 assert!(block(" ").validate().is_err());
1020 }
1021
1022 #[test]
1023 fn rejects_empty_target_name() {
1024 let mut b = block("shap");
1025 b.target_name = Some(String::new());
1026 assert!(b.validate().is_err());
1027 }
1028
1029 #[test]
1030 fn round_trips_through_json() {
1031 let b = block("permutation_importance");
1032 let json = serde_json::to_string(&b).expect("serialize");
1033 let parsed: ExplanationBlock = serde_json::from_str(&json).expect("deserialize");
1034 assert_eq!(parsed, b);
1035 let mut without = block("shap");
1037 without.target_name = None;
1038 let json = serde_json::to_string(&without).expect("serialize");
1039 assert!(!json.contains("target_name"));
1040 }
1041}
1042
1043#[cfg(test)]
1044mod tests;