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, 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 oof;
73mod prediction_store;
74mod scheduler;
75mod scoring;
76mod task;
77
78pub use artifact::*;
79pub use dataview::*;
80pub(crate) use merge::*;
81pub use oof::*;
82pub use prediction_store::*;
83pub use scheduler::*;
84pub(crate) use scoring::*;
85pub use task::*;
86
87pub struct BundleReplayExecution<'a> {
88 pub plan: &'a ExecutionPlan,
89 pub bundle: &'a ExecutionBundle,
90 pub replay_request: &'a ReplayPhaseRequest,
91 pub prediction_cache_store: Option<&'a dyn RuntimePredictionCacheStore>,
92 pub controllers: &'a RuntimeControllerRegistry,
93 pub data_provider: &'a dyn RuntimeDataProvider,
94 pub artifact_store: &'a dyn RuntimeArtifactStore,
95 pub data_envelopes: &'a BTreeMap<String, ExternalDataPlanEnvelope>,
96}
97
98#[derive(Default)]
99pub struct RuntimeControllerRegistry {
100 controllers: BTreeMap<ControllerId, Box<dyn RuntimeController>>,
101}
102
103impl RuntimeControllerRegistry {
104 pub fn new() -> Self {
105 Self::default()
106 }
107
108 pub fn register(&mut self, controller: Box<dyn RuntimeController>) -> Result<()> {
109 let id = controller.controller_id().clone();
110 if self.controllers.insert(id.clone(), controller).is_some() {
111 return Err(DagMlError::RuntimeValidation(format!(
112 "duplicate runtime controller `{id}`"
113 )));
114 }
115 Ok(())
116 }
117
118 pub fn get(&self, controller_id: &ControllerId) -> Option<&dyn RuntimeController> {
119 self.controllers.get(controller_id).map(Box::as_ref)
120 }
121}
122
123pub fn dispatch_custom_observation_aggregation(
124 plan: &ExecutionPlan,
125 controllers: &RuntimeControllerRegistry,
126 task_id: impl Into<String>,
127 block: ObservationPredictionBlock,
128 relations: SampleRelationSet,
129 policy: AggregationPolicy,
130 requested_sample_order: Vec<SampleId>,
131) -> Result<PredictionBlock> {
132 let controller_id = custom_aggregation_controller_id(&policy)?;
133 ensure_aggregation_controller_capability(plan, controller_id)?;
134 let task = AggregationControllerTask {
135 schema_version: crate::aggregation::AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
136 task_id: task_id.into(),
137 controller_id: controller_id.clone(),
138 policy,
139 reduction_plan: None,
140 input: AggregationControllerInput::ObservationToSample {
141 block,
142 relations,
143 requested_sample_order,
144 },
145 };
146 let result = dispatch_custom_aggregation_task(controllers, &task)?;
147 match result.output {
148 AggregationControllerOutput::Sample { block } => Ok(block),
149 AggregationControllerOutput::Unit { .. } => Err(DagMlError::RuntimeValidation(format!(
150 "aggregation controller task `{}` returned unit output for observation input",
151 task.task_id
152 ))),
153 }
154}
155
156pub fn dispatch_custom_sample_aggregation(
157 plan: &ExecutionPlan,
158 controllers: &RuntimeControllerRegistry,
159 task_id: impl Into<String>,
160 block: PredictionBlock,
161 relations: SampleRelationSet,
162 policy: AggregationPolicy,
163 requested_unit_order: Vec<PredictionUnitId>,
164) -> Result<AggregatedPredictionBlock> {
165 let controller_id = custom_aggregation_controller_id(&policy)?;
166 ensure_aggregation_controller_capability(plan, controller_id)?;
167 let task = AggregationControllerTask {
168 schema_version: crate::aggregation::AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
169 task_id: task_id.into(),
170 controller_id: controller_id.clone(),
171 policy,
172 reduction_plan: None,
173 input: AggregationControllerInput::SampleToUnit {
174 block,
175 relations,
176 requested_unit_order,
177 },
178 };
179 let result = dispatch_custom_aggregation_task(controllers, &task)?;
180 match result.output {
181 AggregationControllerOutput::Unit { block } => Ok(block),
182 AggregationControllerOutput::Sample { .. } => Err(DagMlError::RuntimeValidation(format!(
183 "aggregation controller task `{}` returned sample output for sample input",
184 task.task_id
185 ))),
186 }
187}
188
189pub fn dispatch_custom_aggregation_task(
190 controllers: &RuntimeControllerRegistry,
191 task: &AggregationControllerTask,
192) -> Result<AggregationControllerResult> {
193 task.validate()?;
194 let controller = controllers.get(&task.controller_id).ok_or_else(|| {
195 DagMlError::RuntimeValidation(format!(
196 "aggregation runtime controller `{}` is not registered",
197 task.controller_id
198 ))
199 })?;
200 let result = controller.invoke_aggregation(task)?;
201 result.validate_for_task(task)?;
202 Ok(result)
203}
204
205pub(crate) fn custom_aggregation_controller_id(
206 policy: &AggregationPolicy,
207) -> Result<&ControllerId> {
208 policy.validate()?;
209 policy
210 .custom_controller
211 .as_ref()
212 .map(|controller| &controller.controller_id)
213 .ok_or_else(|| {
214 DagMlError::RuntimeValidation(
215 "custom aggregation dispatch requires a custom_controller policy".to_string(),
216 )
217 })
218}
219
220pub(crate) fn ensure_aggregation_controller_capability(
221 plan: &ExecutionPlan,
222 controller_id: &ControllerId,
223) -> Result<()> {
224 let manifest = plan
225 .controller_manifests
226 .get(controller_id)
227 .ok_or_else(|| {
228 DagMlError::Planning(format!(
229 "missing aggregation controller manifest `{controller_id}`"
230 ))
231 })?;
232 if !manifest
233 .capabilities
234 .contains(&ControllerCapability::AggregatesPredictions)
235 {
236 return Err(DagMlError::Planning(format!(
237 "aggregation controller `{controller_id}` must declare aggregates_predictions"
238 )));
239 }
240 Ok(())
241}
242
243#[derive(Clone, Debug)]
244pub struct RunContext {
245 pub run_id: RunId,
246 pub root_seed: Option<u64>,
247 pub variant_id: Option<VariantId>,
248 pub prediction_store: InMemoryPredictionStore,
249 pub aggregated_prediction_store: InMemoryAggregatedPredictionStore,
250 pub lineage: InMemoryLineageRecorder,
251 pub score_collector: Vec<RegressionMetricReport>,
254 pub regression_target_records: Vec<RegressionTargetRecord>,
256 pub oof_average_blocks: Vec<OofAverageBlock>,
260}
261
262impl RunContext {
263 pub fn new(run_id: RunId, root_seed: Option<u64>) -> Self {
264 Self {
265 run_id,
266 root_seed,
267 variant_id: None,
268 prediction_store: InMemoryPredictionStore::new(),
269 aggregated_prediction_store: InMemoryAggregatedPredictionStore::new(),
270 lineage: InMemoryLineageRecorder::new(),
271 score_collector: Vec::new(),
272 regression_target_records: Vec::new(),
273 oof_average_blocks: Vec::new(),
274 }
275 }
276
277 pub fn collect_cross_fold_validation_scores(
289 &mut self,
290 partition_mode: FoldPartitionMode,
291 ) -> Result<()> {
292 let outcome = cross_fold_validation_reports(
293 self.prediction_store.blocks(),
294 &self.regression_target_records,
295 SCORE_METRICS,
296 partition_mode,
297 )?;
298 self.score_collector.extend(outcome.reports);
299 self.oof_average_blocks.extend(outcome.oof_averages);
300 Ok(())
301 }
302
303 pub fn build_score_set(
306 &self,
307 plan_id: impl Into<String>,
308 selection_metric: Option<String>,
309 ) -> Option<ScoreSet> {
310 if self.score_collector.is_empty() {
311 return None;
312 }
313 Some(ScoreSet {
314 schema_version: SCORE_SET_SCHEMA_VERSION,
315 plan_id: plan_id.into(),
316 selection_metric,
317 reports: self.score_collector.clone(),
318 })
319 }
320}
321
322#[derive(Clone, Debug)]
332pub struct VariantSelection {
333 pub selected_variant_id: VariantId,
336 pub validation_reports: Vec<RegressionMetricReport>,
340 pub variant_validation_predictions: Vec<VariantValidationPredictions>,
354}
355
356#[derive(Clone, Debug)]
363pub struct VariantSelectionOutcome {
364 pub selection: VariantSelection,
365 pub decision: SelectionDecision,
366}
367
368#[derive(Clone, Debug)]
374pub struct VariantValidationPredictions {
375 pub variant_id: VariantId,
378 pub variant_label: Option<String>,
381 pub predictions: Vec<PredictionBlock>,
385 pub regression_targets: Vec<RegressionTargetBlock>,
388 pub oof_average: Option<OofAverageBlock>,
392}
393
394pub fn select_best_variant_by_cv<F>(
424 plan: &ExecutionPlan,
425 run_id: &RunId,
426 root_seed: Option<u64>,
427 selection_metric: RegressionMetricKind,
428 run_single_variant_fit_cv: F,
429) -> Result<Option<VariantSelection>>
430where
431 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
432{
433 Ok(select_best_variant_outcome_by_cv(
434 plan,
435 run_id,
436 root_seed,
437 selection_metric,
438 run_single_variant_fit_cv,
439 )?
440 .map(|outcome| outcome.selection))
441}
442
443pub fn select_best_variant_outcome_by_cv<F>(
450 plan: &ExecutionPlan,
451 run_id: &RunId,
452 root_seed: Option<u64>,
453 selection_metric: RegressionMetricKind,
454 mut run_single_variant_fit_cv: F,
455) -> Result<Option<VariantSelectionOutcome>>
456where
457 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
458{
459 plan.validate()?;
460 if plan.variants.is_empty() {
461 return Err(DagMlError::RuntimeValidation(
462 "cannot select a variant for a plan with no variants".to_string(),
463 ));
464 }
465 score_and_rank_variants_by_cv(
468 &plan.variants,
469 run_id,
470 root_seed,
471 selection_metric,
472 plan_oof_partition_mode(plan),
473 None,
474 |variant| {
475 Ok(ExecutionPlan {
476 variants: vec![variant.clone()],
477 ..plan.clone()
478 })
479 },
480 |_variant| Ok(None),
483 &mut run_single_variant_fit_cv,
484 )
485}
486
487pub fn select_best_variant_outcome_by_cv_for_target<F>(
491 plan: &ExecutionPlan,
492 run_id: &RunId,
493 root_seed: Option<u64>,
494 selection_metric: RegressionMetricKind,
495 score_target: (&NodeId, Option<&str>, PredictionLevel),
496 mut run_single_variant_fit_cv: F,
497) -> Result<Option<VariantSelectionOutcome>>
498where
499 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
500{
501 let (score_target, score_target_port, score_target_level) = score_target;
502 plan.validate()?;
503 if !plan.node_plans.contains_key(score_target) {
504 return Err(DagMlError::RuntimeValidation(format!(
505 "native SELECT score target `{score_target}` is absent from plan"
506 )));
507 }
508 score_and_rank_variants_by_cv(
509 &plan.variants,
510 run_id,
511 root_seed,
512 selection_metric,
513 plan_oof_partition_mode(plan),
514 Some((score_target, score_target_port, score_target_level)),
515 |variant| {
516 Ok(ExecutionPlan {
517 variants: vec![variant.clone()],
518 ..plan.clone()
519 })
520 },
521 |_variant| Ok(None),
522 &mut run_single_variant_fit_cv,
523 )
524}
525
526pub fn select_best_operator_variant_by_cv<F>(
550 union_plan: &ExecutionPlan,
551 model: &OperatorVariantModel,
552 run_id: &RunId,
553 root_seed: Option<u64>,
554 selection_metric: RegressionMetricKind,
555 mut run_single_variant_fit_cv: F,
556) -> Result<Option<VariantSelection>>
557where
558 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
559{
560 union_plan.validate()?;
561 model.validate()?;
562 let variants = enumerate_variants(&model.generation_spec(), root_seed)?;
563 if variants.is_empty() {
564 return Err(DagMlError::RuntimeValidation(format!(
565 "operator variant model `{}` produced no variants",
566 model.generator_id
567 )));
568 }
569 let all_choice_nodes = model
572 .active_nodes
573 .values()
574 .flatten()
575 .cloned()
576 .collect::<BTreeSet<NodeId>>();
577 Ok(score_and_rank_variants_by_cv(
581 &variants,
582 run_id,
583 root_seed,
584 selection_metric,
585 plan_oof_partition_mode(union_plan),
586 None,
587 |variant| {
588 let active_subsequence = operator_variant_active_subsequence(model, variant)?;
589 let active_nodes = model.active_nodes.get(active_subsequence).ok_or_else(|| {
590 DagMlError::RuntimeValidation(format!(
591 "operator variant model `{}` has no active-node set for `{active_subsequence}`",
592 model.generator_id
593 ))
594 })?;
595 prune_plan_to_active(union_plan, active_nodes, &all_choice_nodes, variant)
596 },
597 |variant| {
601 let active_subsequence = operator_variant_active_subsequence(model, variant)?;
602 Ok(model.variant_labels.get(active_subsequence).cloned())
603 },
604 &mut run_single_variant_fit_cv,
605 )?
606 .map(|outcome| outcome.selection))
607}
608
609fn operator_variant_active_subsequence<'a>(
613 model: &OperatorVariantModel,
614 variant: &'a VariantPlan,
615) -> Result<&'a str> {
616 let dimension_name = &model.dimension.name;
617 let choice = variant.choices.get(dimension_name).ok_or_else(|| {
618 DagMlError::RuntimeValidation(format!(
619 "operator variant `{}` is missing the operator dimension `{dimension_name}`",
620 variant.variant_id
621 ))
622 })?;
623 choice.active_subsequence.as_deref().ok_or_else(|| {
624 DagMlError::RuntimeValidation(format!(
625 "operator variant `{}` choice `{}` has no active_subsequence",
626 variant.variant_id, choice.label
627 ))
628 })
629}
630
631pub fn select_best_operator_variant_from_models<F>(
640 union_plan: &ExecutionPlan,
641 models: &[OperatorVariantModel],
642 run_id: &RunId,
643 root_seed: Option<u64>,
644 selection_metric: RegressionMetricKind,
645 run_single_variant_fit_cv: F,
646) -> Result<Option<VariantSelection>>
647where
648 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
649{
650 match models {
651 [] => Ok(None),
652 [model] => select_best_operator_variant_by_cv(
653 union_plan,
654 model,
655 run_id,
656 root_seed,
657 selection_metric,
658 run_single_variant_fit_cv,
659 ),
660 _ => Err(DagMlError::RuntimeValidation(format!(
661 "operator-SELECT does not support {} operator generators in one pipeline; this phase scopes to a flat single operator generator (generators: {})",
662 models.len(),
663 models
664 .iter()
665 .map(|model| model.generator_id.to_string())
666 .collect::<Vec<_>>()
667 .join(", ")
668 ))),
669 }
670}
671
672#[allow(clippy::too_many_arguments)]
681fn score_and_rank_variants_by_cv<M, L, F>(
682 variants: &[VariantPlan],
683 run_id: &RunId,
684 root_seed: Option<u64>,
685 selection_metric: RegressionMetricKind,
686 partition_mode: FoldPartitionMode,
687 score_target: Option<(&NodeId, Option<&str>, PredictionLevel)>,
688 mut make_variant_plan: M,
689 mut resolve_variant_label: L,
690 run_single_variant_fit_cv: &mut F,
691) -> Result<Option<VariantSelectionOutcome>>
692where
693 M: FnMut(&VariantPlan) -> Result<ExecutionPlan>,
694 L: FnMut(&VariantPlan) -> Result<Option<String>>,
695 F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
696{
697 if variants.is_empty() {
698 return Err(DagMlError::RuntimeValidation(
699 "cannot select a variant for a plan with no variants".to_string(),
700 ));
701 }
702
703 let mut candidates: Vec<CandidateScore> = Vec::with_capacity(variants.len());
704 let mut variant_validation_reports: Vec<RegressionMetricReport> = Vec::new();
707 let mut variant_validation_predictions: Vec<VariantValidationPredictions> = Vec::new();
712 let mut any_scores_seen = false;
715 for variant in variants {
716 let variant_plan = make_variant_plan(variant)?;
717 let variant_label = resolve_variant_label(variant)?;
721 let mut ctx = RunContext::new(run_id.clone(), root_seed);
722 ctx.variant_id = Some(variant.variant_id.clone());
723 run_single_variant_fit_cv(&variant_plan, &mut ctx)?;
724 ctx.collect_cross_fold_validation_scores(partition_mode)?;
725 if !ctx.score_collector.is_empty() {
726 any_scores_seen = true;
727 }
728 let captured = capture_variant_validation_predictions(
737 &variant.variant_id,
738 variant_label.clone(),
739 &ctx,
740 );
741 if !captured.predictions.is_empty() || captured.oof_average.is_some() {
742 variant_validation_predictions.push(captured);
743 }
744 let avg_reports = ctx
750 .score_collector
751 .iter()
752 .filter(|report| {
753 report.partition == PredictionPartition::Validation
754 && score_target.is_none_or(|(target, target_port, level)| {
755 &report.producer_node == target
756 && target_port
757 .is_none_or(|port| report.producer_port.as_deref() == Some(port))
758 && report.level == level
759 })
760 && report
761 .fold_id
762 .as_ref()
763 .is_some_and(|fold| fold.as_str() == "avg")
764 })
765 .collect::<Vec<_>>();
766 match avg_reports.as_slice() {
767 [] => {}
768 [report] => candidates.push(
769 (*report)
770 .clone()
771 .into_candidate_score(variant.variant_id.as_str())?,
772 ),
773 _ => {
774 return Err(DagMlError::RuntimeValidation(format!(
775 "variant `{}` produced {} cross-fold OOF averages (multiple prediction producers); native SELECT needs a single score target",
776 variant.variant_id,
777 avg_reports.len()
778 )));
779 }
780 }
781 for mut report in ctx.score_collector {
787 if report.partition != PredictionPartition::Validation {
788 continue;
789 }
790 report.variant_id = Some(variant.variant_id.clone());
791 report.variant_label = variant_label.clone();
792 variant_validation_reports.push(report);
793 }
794 }
795
796 if candidates.is_empty() {
797 if any_scores_seen {
798 return Err(DagMlError::RuntimeValidation(
801 "variants produced scores but no cross-fold OOF average; cannot rank — need >=2 folds or an explicit score target".to_string(),
802 ));
803 }
804 return Ok(None);
806 }
807 if candidates.len() != variants.len() {
808 return Err(DagMlError::RuntimeValidation(format!(
809 "native variant SELECT scored only {} of {} variants; cannot rank variants fairly",
810 candidates.len(),
811 variants.len()
812 )));
813 }
814
815 let policy = SelectionPolicy {
816 id: format!("select:variant:{}", selection_metric.name()),
817 metric: SelectionMetric {
818 name: selection_metric.name().to_string(),
819 objective: selection_metric.objective(),
820 },
821 required_metric_level: None,
822 require_finite: true,
823 evaluation_scope: None,
824 refit_slot_plan: None,
825 stacking_fit_contract: None,
826 reduction_id: None,
827 };
828 let decision = select_candidate(&policy, &candidates)?;
829 let selected_variant_id =
830 VariantId::new(decision.selected_candidate_id.clone()).map_err(|error| {
831 DagMlError::RuntimeValidation(format!("selected variant id is invalid: {error}"))
832 })?;
833 Ok(Some(VariantSelectionOutcome {
834 selection: VariantSelection {
835 selected_variant_id,
836 validation_reports: variant_validation_reports,
837 variant_validation_predictions,
838 },
839 decision,
840 }))
841}
842
843fn capture_variant_validation_predictions(
859 variant_id: &VariantId,
860 variant_label: Option<String>,
861 ctx: &RunContext,
862) -> VariantValidationPredictions {
863 let mut predictions = Vec::new();
864 let mut regression_targets = Vec::new();
865 for block in ctx.prediction_store.blocks() {
866 if block.partition != PredictionPartition::Validation {
867 continue;
868 }
869 let Some(record) = ctx.regression_target_records.iter().find(|record| {
870 record.producer_node == block.producer_node
871 && record.producer_port == block.producer_port
872 && record.partition == PredictionPartition::Validation
873 && record.fold_id == block.fold_id
874 }) else {
875 continue;
876 };
877 predictions.push(block.clone());
878 regression_targets.push(target_block_aligned_to_samples(
879 &block.sample_ids,
880 &record.block,
881 ));
882 }
883 VariantValidationPredictions {
884 variant_id: variant_id.clone(),
885 variant_label,
886 predictions,
887 regression_targets,
888 oof_average: ctx.oof_average_blocks.first().cloned(),
889 }
890}
891
892fn target_block_aligned_to_samples(
899 sample_ids: &[SampleId],
900 targets: &RegressionTargetBlock,
901) -> RegressionTargetBlock {
902 let value_by_sample: BTreeMap<&SampleId, &Vec<f64>> = targets
903 .unit_ids
904 .iter()
905 .zip(&targets.values)
906 .filter_map(|(unit_id, row)| match unit_id {
907 PredictionUnitId::Sample(sample_id) => Some((sample_id, row)),
908 _ => None,
909 })
910 .collect();
911 if sample_ids
912 .iter()
913 .any(|sample_id| !value_by_sample.contains_key(sample_id))
914 {
915 return targets.clone();
916 }
917 RegressionTargetBlock {
918 level: PredictionLevel::Sample,
919 unit_ids: sample_ids
920 .iter()
921 .cloned()
922 .map(PredictionUnitId::Sample)
923 .collect(),
924 values: sample_ids
925 .iter()
926 .map(|sample_id| value_by_sample[sample_id].clone())
927 .collect(),
928 target_names: targets.target_names.clone(),
929 }
930}
931
932#[cfg(test)]
933mod explain_contract_tests {
934 use super::*;
935
936 fn block(method: &str) -> ExplanationBlock {
937 ExplanationBlock {
938 producer_node: NodeId::new("model:base").unwrap(),
939 producer_port: None,
940 method: method.to_string(),
941 target_name: Some("y".to_string()),
942 payload: serde_json::json!({"feature_importance": [0.5, 0.3, 0.2]}),
943 }
944 }
945
946 #[test]
947 fn validates_well_formed_explanation() {
948 assert!(block("shap").validate().is_ok());
949 }
950
951 #[test]
952 fn rejects_empty_method() {
953 assert!(block(" ").validate().is_err());
954 }
955
956 #[test]
957 fn rejects_empty_target_name() {
958 let mut b = block("shap");
959 b.target_name = Some(String::new());
960 assert!(b.validate().is_err());
961 }
962
963 #[test]
964 fn round_trips_through_json() {
965 let b = block("permutation_importance");
966 let json = serde_json::to_string(&b).expect("serialize");
967 let parsed: ExplanationBlock = serde_json::from_str(&json).expect("deserialize");
968 assert_eq!(parsed, b);
969 let mut without = block("shap");
971 without.target_name = None;
972 let json = serde_json::to_string(&without).expect("serialize");
973 assert!(!json.contains("target_name"));
974 }
975}
976
977#[cfg(test)]
978mod tests;