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 task;
78
79pub use artifact::*;
80pub use dataview::*;
81pub(crate) use merge::*;
82#[cfg(feature = "methods-optimizer")]
83pub use methods_replay::*;
84pub use oof::*;
85pub use prediction_store::*;
86pub use scheduler::*;
87pub(crate) use scoring::*;
88pub use task::*;
89
90pub struct BundleReplayExecution<'a> {
91 pub plan: &'a ExecutionPlan,
92 pub bundle: &'a ExecutionBundle,
93 pub replay_request: &'a ReplayPhaseRequest,
94 pub prediction_cache_store: Option<&'a dyn RuntimePredictionCacheStore>,
95 pub controllers: &'a RuntimeControllerRegistry,
96 pub data_provider: &'a dyn RuntimeDataProvider,
97 pub artifact_store: &'a dyn RuntimeArtifactStore,
98 pub data_envelopes: &'a BTreeMap<String, ExternalDataPlanEnvelope>,
99}
100
101#[derive(Default)]
102pub struct RuntimeControllerRegistry {
103 controllers: BTreeMap<ControllerId, Box<dyn RuntimeController>>,
104}
105
106impl RuntimeControllerRegistry {
107 pub fn new() -> Self {
108 Self::default()
109 }
110
111 pub fn register(&mut self, controller: Box<dyn RuntimeController>) -> Result<()> {
112 let id = controller.controller_id().clone();
113 if self.controllers.insert(id.clone(), controller).is_some() {
114 return Err(DagMlError::RuntimeValidation(format!(
115 "duplicate runtime controller `{id}`"
116 )));
117 }
118 Ok(())
119 }
120
121 pub fn get(&self, controller_id: &ControllerId) -> Option<&dyn RuntimeController> {
122 self.controllers.get(controller_id).map(Box::as_ref)
123 }
124}
125
126pub fn dispatch_custom_observation_aggregation(
127 plan: &ExecutionPlan,
128 controllers: &RuntimeControllerRegistry,
129 task_id: impl Into<String>,
130 block: ObservationPredictionBlock,
131 relations: SampleRelationSet,
132 policy: AggregationPolicy,
133 requested_sample_order: Vec<SampleId>,
134) -> Result<PredictionBlock> {
135 let controller_id = custom_aggregation_controller_id(&policy)?;
136 ensure_aggregation_controller_capability(plan, controller_id)?;
137 let task = AggregationControllerTask {
138 schema_version: crate::aggregation::AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
139 task_id: task_id.into(),
140 controller_id: controller_id.clone(),
141 policy,
142 reduction_plan: None,
143 input: AggregationControllerInput::ObservationToSample {
144 block,
145 relations,
146 requested_sample_order,
147 },
148 };
149 let result = dispatch_custom_aggregation_task(controllers, &task)?;
150 match result.output {
151 AggregationControllerOutput::Sample { block } => Ok(block),
152 AggregationControllerOutput::Unit { .. } => Err(DagMlError::RuntimeValidation(format!(
153 "aggregation controller task `{}` returned unit output for observation input",
154 task.task_id
155 ))),
156 }
157}
158
159pub fn dispatch_custom_sample_aggregation(
160 plan: &ExecutionPlan,
161 controllers: &RuntimeControllerRegistry,
162 task_id: impl Into<String>,
163 block: PredictionBlock,
164 relations: SampleRelationSet,
165 policy: AggregationPolicy,
166 requested_unit_order: Vec<PredictionUnitId>,
167) -> Result<AggregatedPredictionBlock> {
168 let controller_id = custom_aggregation_controller_id(&policy)?;
169 ensure_aggregation_controller_capability(plan, controller_id)?;
170 let task = AggregationControllerTask {
171 schema_version: crate::aggregation::AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
172 task_id: task_id.into(),
173 controller_id: controller_id.clone(),
174 policy,
175 reduction_plan: None,
176 input: AggregationControllerInput::SampleToUnit {
177 block,
178 relations,
179 requested_unit_order,
180 },
181 };
182 let result = dispatch_custom_aggregation_task(controllers, &task)?;
183 match result.output {
184 AggregationControllerOutput::Unit { block } => Ok(block),
185 AggregationControllerOutput::Sample { .. } => Err(DagMlError::RuntimeValidation(format!(
186 "aggregation controller task `{}` returned sample output for sample input",
187 task.task_id
188 ))),
189 }
190}
191
192pub fn dispatch_custom_aggregation_task(
193 controllers: &RuntimeControllerRegistry,
194 task: &AggregationControllerTask,
195) -> Result<AggregationControllerResult> {
196 task.validate()?;
197 let controller = controllers.get(&task.controller_id).ok_or_else(|| {
198 DagMlError::RuntimeValidation(format!(
199 "aggregation runtime controller `{}` is not registered",
200 task.controller_id
201 ))
202 })?;
203 let result = controller.invoke_aggregation(task)?;
204 result.validate_for_task(task)?;
205 Ok(result)
206}
207
208pub(crate) fn custom_aggregation_controller_id(
209 policy: &AggregationPolicy,
210) -> Result<&ControllerId> {
211 policy.validate()?;
212 policy
213 .custom_controller
214 .as_ref()
215 .map(|controller| &controller.controller_id)
216 .ok_or_else(|| {
217 DagMlError::RuntimeValidation(
218 "custom aggregation dispatch requires a custom_controller policy".to_string(),
219 )
220 })
221}
222
223pub(crate) fn ensure_aggregation_controller_capability(
224 plan: &ExecutionPlan,
225 controller_id: &ControllerId,
226) -> Result<()> {
227 let manifest = plan
228 .controller_manifests
229 .get(controller_id)
230 .ok_or_else(|| {
231 DagMlError::Planning(format!(
232 "missing aggregation controller manifest `{controller_id}`"
233 ))
234 })?;
235 if !manifest
236 .capabilities
237 .contains(&ControllerCapability::AggregatesPredictions)
238 {
239 return Err(DagMlError::Planning(format!(
240 "aggregation controller `{controller_id}` must declare aggregates_predictions"
241 )));
242 }
243 Ok(())
244}
245
246#[derive(Clone, Debug)]
247pub struct RunContext {
248 pub run_id: RunId,
249 pub root_seed: Option<u64>,
250 pub variant_id: Option<VariantId>,
251 pub prediction_store: InMemoryPredictionStore,
252 pub aggregated_prediction_store: InMemoryAggregatedPredictionStore,
253 pub lineage: InMemoryLineageRecorder,
254 pub score_collector: Vec<RegressionMetricReport>,
257 pub regression_target_records: Vec<RegressionTargetRecord>,
259 pub oof_average_blocks: Vec<OofAverageBlock>,
263 pub(crate) global_oof_aggregation: BTreeMap<NodeId, GlobalOofAggregationSpec>,
268}
269
270#[derive(Clone, Debug)]
271pub(crate) struct GlobalOofAggregationSpec {
272 pub(crate) policy: AggregationPolicy,
273 pub(crate) relations: SampleRelationSet,
274}
275
276impl RunContext {
277 pub fn new(run_id: RunId, root_seed: Option<u64>) -> Self {
278 Self {
279 run_id,
280 root_seed,
281 variant_id: None,
282 prediction_store: InMemoryPredictionStore::new(),
283 aggregated_prediction_store: InMemoryAggregatedPredictionStore::new(),
284 lineage: InMemoryLineageRecorder::new(),
285 score_collector: Vec::new(),
286 regression_target_records: Vec::new(),
287 oof_average_blocks: Vec::new(),
288 global_oof_aggregation: BTreeMap::new(),
289 }
290 }
291
292 pub(crate) fn configure_global_oof_aggregation(
297 &mut self,
298 plan: &ExecutionPlan,
299 data_provider: &dyn RuntimeDataProvider,
300 ) -> Result<()> {
301 self.global_oof_aggregation = global_oof_aggregation_specs(plan, data_provider)?;
302 Ok(())
303 }
304
305 pub fn collect_cross_fold_validation_scores(
317 &mut self,
318 partition_mode: FoldPartitionMode,
319 ) -> Result<()> {
320 let outcome = cross_fold_validation_reports(
321 self.prediction_store.blocks(),
322 &self.regression_target_records,
323 SCORE_METRICS,
324 partition_mode,
325 )?;
326 let outcome = apply_global_oof_aggregation(outcome, &self.global_oof_aggregation)?;
327 self.score_collector.extend(outcome.reports);
328 self.oof_average_blocks.extend(outcome.oof_averages);
329 Ok(())
330 }
331
332 pub fn build_score_set(
335 &self,
336 plan_id: impl Into<String>,
337 selection_metric: Option<String>,
338 ) -> Option<ScoreSet> {
339 if self.score_collector.is_empty() {
340 return None;
341 }
342 Some(ScoreSet {
343 schema_version: SCORE_SET_SCHEMA_VERSION,
344 plan_id: plan_id.into(),
345 selection_metric,
346 reports: self.score_collector.clone(),
347 })
348 }
349}
350
351#[derive(Clone, Debug)]
361pub struct VariantSelection {
362 pub selected_variant_id: VariantId,
365 pub validation_reports: Vec<RegressionMetricReport>,
369 pub variant_validation_predictions: Vec<VariantValidationPredictions>,
383}
384
385#[derive(Clone, Debug)]
392pub struct VariantSelectionOutcome {
393 pub selection: VariantSelection,
394 pub decision: SelectionDecision,
395}
396
397#[derive(Clone, Debug)]
403pub struct VariantValidationPredictions {
404 pub variant_id: VariantId,
407 pub variant_label: Option<String>,
410 pub predictions: Vec<PredictionBlock>,
414 pub regression_targets: Vec<RegressionTargetBlock>,
417 pub oof_average: Option<OofAverageBlock>,
421}
422
423pub fn select_best_variant_by_cv<F>(
453 plan: &ExecutionPlan,
454 run_id: &RunId,
455 root_seed: Option<u64>,
456 selection_metric: RegressionMetricKind,
457 run_single_variant_fit_cv: F,
458) -> Result<Option<VariantSelection>>
459where
460 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
461{
462 Ok(select_best_variant_outcome_by_cv(
463 plan,
464 run_id,
465 root_seed,
466 selection_metric,
467 run_single_variant_fit_cv,
468 )?
469 .map(|outcome| outcome.selection))
470}
471
472pub fn select_best_variant_outcome_by_cv<F>(
479 plan: &ExecutionPlan,
480 run_id: &RunId,
481 root_seed: Option<u64>,
482 selection_metric: RegressionMetricKind,
483 mut run_single_variant_fit_cv: F,
484) -> Result<Option<VariantSelectionOutcome>>
485where
486 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
487{
488 plan.validate()?;
489 if plan.variants.is_empty() {
490 return Err(DagMlError::RuntimeValidation(
491 "cannot select a variant for a plan with no variants".to_string(),
492 ));
493 }
494 score_and_rank_variants_by_cv(
497 &plan.variants,
498 run_id,
499 root_seed,
500 selection_metric,
501 plan_oof_partition_mode(plan),
502 None,
503 |variant| {
504 Ok(ExecutionPlan {
505 variants: vec![variant.clone()],
506 ..plan.clone()
507 })
508 },
509 |_variant| Ok(None),
512 &mut run_single_variant_fit_cv,
513 )
514}
515
516#[allow(clippy::too_many_arguments)]
520pub fn select_best_variant_outcome_by_cv_for_target<F>(
521 plan: &ExecutionPlan,
522 run_id: &RunId,
523 root_seed: Option<u64>,
524 selection_metric: RegressionMetricKind,
525 score_target: &NodeId,
526 score_target_port: Option<&str>,
527 score_target_level: PredictionLevel,
528 mut run_single_variant_fit_cv: F,
529) -> Result<Option<VariantSelectionOutcome>>
530where
531 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
532{
533 plan.validate()?;
534 if !plan.node_plans.contains_key(score_target) {
535 return Err(DagMlError::RuntimeValidation(format!(
536 "native SELECT score target `{score_target}` is absent from plan"
537 )));
538 }
539 score_and_rank_variants_by_cv(
540 &plan.variants,
541 run_id,
542 root_seed,
543 selection_metric,
544 plan_oof_partition_mode(plan),
545 Some((score_target, score_target_port, score_target_level)),
546 |variant| {
547 Ok(ExecutionPlan {
548 variants: vec![variant.clone()],
549 ..plan.clone()
550 })
551 },
552 |_variant| Ok(None),
553 &mut run_single_variant_fit_cv,
554 )
555}
556
557pub fn select_best_operator_variant_by_cv<F>(
581 union_plan: &ExecutionPlan,
582 model: &OperatorVariantModel,
583 run_id: &RunId,
584 root_seed: Option<u64>,
585 selection_metric: RegressionMetricKind,
586 mut run_single_variant_fit_cv: F,
587) -> Result<Option<VariantSelection>>
588where
589 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
590{
591 union_plan.validate()?;
592 model.validate()?;
593 let variants = enumerate_variants(&model.generation_spec(), root_seed)?;
594 if variants.is_empty() {
595 return Err(DagMlError::RuntimeValidation(format!(
596 "operator variant model `{}` produced no variants",
597 model.generator_id
598 )));
599 }
600 let all_choice_nodes = model
603 .active_nodes
604 .values()
605 .flatten()
606 .cloned()
607 .collect::<BTreeSet<NodeId>>();
608 Ok(score_and_rank_variants_by_cv(
612 &variants,
613 run_id,
614 root_seed,
615 selection_metric,
616 plan_oof_partition_mode(union_plan),
617 None,
618 |variant| {
619 let active_subsequence = operator_variant_active_subsequence(model, variant)?;
620 let active_nodes = model.active_nodes.get(active_subsequence).ok_or_else(|| {
621 DagMlError::RuntimeValidation(format!(
622 "operator variant model `{}` has no active-node set for `{active_subsequence}`",
623 model.generator_id
624 ))
625 })?;
626 prune_plan_to_active(union_plan, active_nodes, &all_choice_nodes, variant)
627 },
628 |variant| {
632 let active_subsequence = operator_variant_active_subsequence(model, variant)?;
633 Ok(model.variant_labels.get(active_subsequence).cloned())
634 },
635 &mut run_single_variant_fit_cv,
636 )?
637 .map(|outcome| outcome.selection))
638}
639
640fn operator_variant_active_subsequence<'a>(
644 model: &OperatorVariantModel,
645 variant: &'a VariantPlan,
646) -> Result<&'a str> {
647 let dimension_name = &model.dimension.name;
648 let choice = variant.choices.get(dimension_name).ok_or_else(|| {
649 DagMlError::RuntimeValidation(format!(
650 "operator variant `{}` is missing the operator dimension `{dimension_name}`",
651 variant.variant_id
652 ))
653 })?;
654 choice.active_subsequence.as_deref().ok_or_else(|| {
655 DagMlError::RuntimeValidation(format!(
656 "operator variant `{}` choice `{}` has no active_subsequence",
657 variant.variant_id, choice.label
658 ))
659 })
660}
661
662pub fn select_best_operator_variant_from_models<F>(
671 union_plan: &ExecutionPlan,
672 models: &[OperatorVariantModel],
673 run_id: &RunId,
674 root_seed: Option<u64>,
675 selection_metric: RegressionMetricKind,
676 run_single_variant_fit_cv: F,
677) -> Result<Option<VariantSelection>>
678where
679 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
680{
681 match models {
682 [] => Ok(None),
683 [model] => select_best_operator_variant_by_cv(
684 union_plan,
685 model,
686 run_id,
687 root_seed,
688 selection_metric,
689 run_single_variant_fit_cv,
690 ),
691 _ => Err(DagMlError::RuntimeValidation(format!(
692 "operator-SELECT does not support {} operator generators in one pipeline; this phase scopes to a flat single operator generator (generators: {})",
693 models.len(),
694 models
695 .iter()
696 .map(|model| model.generator_id.to_string())
697 .collect::<Vec<_>>()
698 .join(", ")
699 ))),
700 }
701}
702
703#[allow(clippy::too_many_arguments)]
712fn score_and_rank_variants_by_cv<M, L, F>(
713 variants: &[VariantPlan],
714 run_id: &RunId,
715 root_seed: Option<u64>,
716 selection_metric: RegressionMetricKind,
717 partition_mode: FoldPartitionMode,
718 score_target: Option<(&NodeId, Option<&str>, PredictionLevel)>,
719 mut make_variant_plan: M,
720 mut resolve_variant_label: L,
721 run_single_variant_fit_cv: &mut F,
722) -> Result<Option<VariantSelectionOutcome>>
723where
724 M: FnMut(&VariantPlan) -> Result<ExecutionPlan>,
725 L: FnMut(&VariantPlan) -> Result<Option<String>>,
726 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
727{
728 if variants.is_empty() {
729 return Err(DagMlError::RuntimeValidation(
730 "cannot select a variant for a plan with no variants".to_string(),
731 ));
732 }
733
734 let mut candidates: Vec<CandidateScore> = Vec::with_capacity(variants.len());
735 let mut variant_validation_reports: Vec<RegressionMetricReport> = Vec::new();
738 let mut variant_validation_predictions: Vec<VariantValidationPredictions> = Vec::new();
743 let mut any_scores_seen = false;
746 for variant in variants {
747 let variant_plan = make_variant_plan(variant)?;
748 let variant_label = resolve_variant_label(variant)?;
752 let mut ctx = RunContext::new(run_id.clone(), root_seed);
753 ctx.variant_id = Some(variant.variant_id.clone());
754 run_single_variant_fit_cv(&variant_plan, &mut ctx)?;
755 ctx.collect_cross_fold_validation_scores(partition_mode)?;
756 if !ctx.score_collector.is_empty() {
757 any_scores_seen = true;
758 }
759 let captured = capture_variant_validation_predictions(
768 &variant.variant_id,
769 variant_label.clone(),
770 &ctx,
771 );
772 if !captured.predictions.is_empty() || captured.oof_average.is_some() {
773 variant_validation_predictions.push(captured);
774 }
775 let avg_reports = ctx
781 .score_collector
782 .iter()
783 .filter(|report| {
784 report.partition == PredictionPartition::Validation
785 && score_target.is_none_or(|(target, target_port, level)| {
786 &report.producer_node == target
787 && target_port
788 .is_none_or(|port| report.producer_port.as_deref() == Some(port))
789 && report.level == level
790 })
791 && report
792 .fold_id
793 .as_ref()
794 .is_some_and(|fold| fold.as_str() == "avg")
795 })
796 .collect::<Vec<_>>();
797 match avg_reports.as_slice() {
798 [] => {}
799 [report] => candidates.push(
800 (*report)
801 .clone()
802 .into_candidate_score(variant.variant_id.as_str())?,
803 ),
804 _ => {
805 return Err(DagMlError::RuntimeValidation(format!(
806 "variant `{}` produced {} cross-fold OOF averages (multiple prediction producers); native SELECT needs a single score target",
807 variant.variant_id,
808 avg_reports.len()
809 )));
810 }
811 }
812 for mut report in ctx.score_collector {
818 if report.partition != PredictionPartition::Validation {
819 continue;
820 }
821 report.variant_id = Some(variant.variant_id.clone());
822 report.variant_label = variant_label.clone();
823 variant_validation_reports.push(report);
824 }
825 }
826
827 if candidates.is_empty() {
828 if any_scores_seen {
829 return Err(DagMlError::RuntimeValidation(
832 "variants produced scores but no cross-fold OOF average; cannot rank — need >=2 folds or an explicit score target".to_string(),
833 ));
834 }
835 return Ok(None);
837 }
838 if candidates.len() != variants.len() {
839 return Err(DagMlError::RuntimeValidation(format!(
840 "native variant SELECT scored only {} of {} variants; cannot rank variants fairly",
841 candidates.len(),
842 variants.len()
843 )));
844 }
845
846 let policy = SelectionPolicy {
847 id: format!("select:variant:{}", selection_metric.name()),
848 metric: SelectionMetric {
849 name: selection_metric.name().to_string(),
850 objective: selection_metric.objective(),
851 },
852 required_metric_level: None,
853 require_finite: true,
854 evaluation_scope: None,
855 refit_slot_plan: None,
856 stacking_fit_contract: None,
857 reduction_id: None,
858 };
859 let decision = select_candidate(&policy, &candidates)?;
860 let selected_variant_id =
861 VariantId::new(decision.selected_candidate_id.clone()).map_err(|error| {
862 DagMlError::RuntimeValidation(format!("selected variant id is invalid: {error}"))
863 })?;
864 Ok(Some(VariantSelectionOutcome {
865 selection: VariantSelection {
866 selected_variant_id,
867 validation_reports: variant_validation_reports,
868 variant_validation_predictions,
869 },
870 decision,
871 }))
872}
873
874pub(crate) fn capture_variant_validation_predictions(
890 variant_id: &VariantId,
891 variant_label: Option<String>,
892 ctx: &RunContext,
893) -> VariantValidationPredictions {
894 let mut predictions = Vec::new();
895 let mut regression_targets = Vec::new();
896 for block in ctx.prediction_store.blocks() {
897 if block.partition != PredictionPartition::Validation {
898 continue;
899 }
900 let Some(record) = ctx.regression_target_records.iter().find(|record| {
901 record.producer_node == block.producer_node
902 && record.producer_port == block.producer_port
903 && record.partition == PredictionPartition::Validation
904 && record.fold_id == block.fold_id
905 }) else {
906 continue;
907 };
908 predictions.push(block.clone());
909 regression_targets.push(target_block_aligned_to_samples(
910 &block.sample_ids,
911 &record.block,
912 ));
913 }
914 VariantValidationPredictions {
915 variant_id: variant_id.clone(),
916 variant_label,
917 predictions,
918 regression_targets,
919 oof_average: ctx
923 .oof_average_blocks
924 .iter()
925 .rev()
926 .find(|block| block.predictions.level != PredictionLevel::Sample)
927 .cloned()
928 .or_else(|| ctx.oof_average_blocks.first().cloned()),
929 }
930}
931
932fn target_block_aligned_to_samples(
939 sample_ids: &[SampleId],
940 targets: &RegressionTargetBlock,
941) -> RegressionTargetBlock {
942 let value_by_sample: BTreeMap<&SampleId, &Vec<f64>> = targets
943 .unit_ids
944 .iter()
945 .zip(&targets.values)
946 .filter_map(|(unit_id, row)| match unit_id {
947 PredictionUnitId::Sample(sample_id) => Some((sample_id, row)),
948 _ => None,
949 })
950 .collect();
951 if sample_ids
952 .iter()
953 .any(|sample_id| !value_by_sample.contains_key(sample_id))
954 {
955 return targets.clone();
956 }
957 RegressionTargetBlock {
958 level: PredictionLevel::Sample,
959 unit_ids: sample_ids
960 .iter()
961 .cloned()
962 .map(PredictionUnitId::Sample)
963 .collect(),
964 values: sample_ids
965 .iter()
966 .map(|sample_id| value_by_sample[sample_id].clone())
967 .collect(),
968 target_names: targets.target_names.clone(),
969 }
970}
971
972#[cfg(test)]
973mod explain_contract_tests {
974 use super::*;
975
976 fn block(method: &str) -> ExplanationBlock {
977 ExplanationBlock {
978 producer_node: NodeId::new("model:base").unwrap(),
979 producer_port: None,
980 method: method.to_string(),
981 target_name: Some("y".to_string()),
982 payload: serde_json::json!({"feature_importance": [0.5, 0.3, 0.2]}),
983 }
984 }
985
986 #[test]
987 fn validates_well_formed_explanation() {
988 assert!(block("shap").validate().is_ok());
989 }
990
991 #[test]
992 fn rejects_empty_method() {
993 assert!(block(" ").validate().is_err());
994 }
995
996 #[test]
997 fn rejects_empty_target_name() {
998 let mut b = block("shap");
999 b.target_name = Some(String::new());
1000 assert!(b.validate().is_err());
1001 }
1002
1003 #[test]
1004 fn round_trips_through_json() {
1005 let b = block("permutation_importance");
1006 let json = serde_json::to_string(&b).expect("serialize");
1007 let parsed: ExplanationBlock = serde_json::from_str(&json).expect("deserialize");
1008 assert_eq!(parsed, b);
1009 let mut without = block("shap");
1011 without.target_name = None;
1012 let json = serde_json::to_string(&without).expect("serialize");
1013 assert!(!json.contains("target_name"));
1014 }
1015}
1016
1017#[cfg(test)]
1018mod tests;