Skip to main content

dag_ml_core/runtime/
mod.rs

1//! Runtime execution: schedulers, controllers, stores, OOF/merge logic.
2//!
3//! Split from the former monolithic `runtime.rs` into cohesive submodules
4//! (pure refactor — code moved verbatim). `mod.rs` owns the run context,
5//! the controller registry, the custom-aggregation dispatch entry points,
6//! native variant selection, and re-exports the full runtime surface so
7//! `pub use runtime::*` in `lib.rs` resolves identically.
8
9pub(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    /// Native per-fold/per-partition score reports collected during the run (when the host emits
259    /// `regression_targets`).
260    pub score_collector: Vec<RegressionMetricReport>,
261    /// Per-fold `y_true` records, kept so cross-fold ensembles (the OOF average) can be scored.
262    pub regression_target_records: Vec<RegressionTargetRecord>,
263    /// The per-sample cross-fold OOF average blocks (+ `y_true`) collected alongside the scalar OOF
264    /// average reports — one per scored producer. Surfaced so the host can fill the `(validation, avg)`
265    /// row's per-sample y_pred; populated by `collect_cross_fold_validation_scores`, empty otherwise.
266    pub oof_average_blocks: Vec<OofAverageBlock>,
267    /// Declarative per-producer aggregation contracts that are applied only after every
268    /// validation fold has contributed its raw sample-level OOF block.  This is deliberately
269    /// separate from the per-task aggregation path: a semantic unit may span CV folds, so
270    /// reducing it inside one fold would change the experiment being scored.
271    pub(crate) global_oof_aggregation: BTreeMap<NodeId, GlobalOofAggregationSpec>,
272    /// When nested stacking retains child-fold evidence in this shared context,
273    /// only the listed parent folds are report-grade CV evidence.  Child OOF is
274    /// still available to the scheduler/meta learner, but can never enter
275    /// selection or cross-fold score aggregation.
276    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    /// Bind the relation-attested aggregation policies needed for global OOF scoring before the
303    /// caller executes `FIT_CV`.  The provider remains the authority for relations; this method
304    /// only records a validated, immutable snapshot in the run context so finalisation cannot
305    /// silently use a different grouping from execution.
306    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    /// Score the cross-fold OOF average from the collected per-fold validation predictions + targets
316    /// and append the reports (one per producer, `fold_id = "avg"`) to the score collector, plus —
317    /// additively — the per-sample OOF average block + `y_true` each report was computed from to
318    /// [`oof_average_blocks`](Self::oof_average_blocks) (so the host can fill the `(validation, avg)`
319    /// row's per-sample y_pred). Call after FIT_CV; a no-op when nothing was scored or no producer has
320    /// more than one fold.
321    ///
322    /// `partition_mode` is the campaign's [`FoldPartitionMode`]: `Partition` (KFold) requires a unique
323    /// per-producer OOF set, while `Resampled` (ShuffleSplit / repeated CV) permits a sample to be
324    /// validated in multiple folds (averaged when scored). Pass the plan's
325    /// [`fold_set`](ExecutionPlan::fold_set) mode (default `Partition` when there is no fold set).
326    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    /// Build a [`ScoreSet`] from the collected reports (or `None` if scoring was off / produced
377    /// nothing), e.g. to attach to the [`ExecutionBundle`].
378    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/// Outcome of native variant selection: the winning variant plus EVERY scored variant's
396/// cross-validation reports, each tagged with its own `variant_id`.
397///
398/// The reports are the per-fold + cross-fold-OOF-average VALIDATION (OOF) reports collected while
399/// ranking. They are emitted so a generated sweep can surface every variant's CV score — not only
400/// the winner's — to match the legacy per-variant `num_predictions`. These are REPORT-ONLY
401/// validation scores of non-selected models: they never feed any downstream training/feature path
402/// (no prediction blocks, no `RegressionTargetRecord`s, no handles leave selection — see
403/// [`select_best_variant_by_cv`]), so the OOF/leakage invariants are unaffected.
404#[derive(Clone, Debug)]
405pub struct VariantSelection {
406    /// The winning variant, ranked by `selection_metric`. The SELECT DECISION is identical to the
407    /// pre-existing behavior; `validation_reports` is purely additive context.
408    pub selected_variant_id: VariantId,
409    /// Per-variant VALIDATION (OOF) reports for ALL ranked variants (winner included), each tagged
410    /// with its `variant_id`. The cross-fold OOF average per producer is re-tagged with the variant
411    /// id (its native form has `variant_id = None`); the per-fold reports already carry it.
412    pub validation_reports: Vec<RegressionMetricReport>,
413    /// Per-variant VALIDATION (OOF) PREDICTIONS for ALL ranked variants (winner included), captured
414    /// from each variant's transient FIT_CV [`RunContext`] BEFORE it is dropped, re-tagged with the
415    /// variant's id + content fingerprint. The scalar [`validation_reports`](Self::validation_reports)
416    /// above carry only the score; these carry the per-sample y_pred (+ id-matched y_true) so a host
417    /// can fill a non-selected variant's per-fold prediction rows, not just its CV score.
418    ///
419    /// LEAKAGE: these are each variant's OWN validation (OOF) predictions, re-tagged with that
420    /// variant's id (which prevents cross-variant mixing). They are surfaced for host
421    /// persistence/display only — every transient CV run executes FIT_CV ONLY (no Final/Test/refit),
422    /// so by construction this carries no train/refit predictions, and the captured blocks never feed
423    /// a training/feature path or cross a `requires_oof` edge. This is strictly ADDITIVE — the same
424    /// values the scalar reports were computed from, exposed per sample — analogous to the additive
425    /// OOF-average block surfacing; no leakage validator is relaxed.
426    pub variant_validation_predictions: Vec<VariantValidationPredictions>,
427}
428
429/// Extended result of native variant selection.
430///
431/// The historical [`VariantSelection`] remains source-compatible for callers
432/// that construct or destructure it. Training orchestration uses this additive
433/// result to retain the exact [`SelectionDecision`] produced by the one and
434/// only ranking pass.
435#[derive(Clone, Debug)]
436pub struct VariantSelectionOutcome {
437    pub selection: VariantSelection,
438    pub decision: SelectionDecision,
439}
440
441/// One scored variant's VALIDATION (OOF) predictions, captured from its transient FIT_CV
442/// [`RunContext`] and re-tagged with the variant's id + content fingerprint so a host can fill that
443/// variant's per-sample prediction rows. REPORT-grade output paired with
444/// [`VariantSelection::validation_reports`]: it never feeds a training/feature path (see the field
445/// docs on [`VariantSelection::variant_validation_predictions`]).
446#[derive(Clone, Debug)]
447pub struct VariantValidationPredictions {
448    /// The variant these predictions belong to — the re-tag that keeps them from mixing with another
449    /// variant's predictions.
450    pub variant_id: VariantId,
451    /// The variant's Phase-5 content fingerprint (`variant_label`), `None` for param-variant /
452    /// single-variant SELECT (which carry no operator-variant fingerprint).
453    pub variant_label: Option<String>,
454    /// Per-fold VALIDATION (OOF) prediction blocks (`partition = Validation`), one per `(producer,
455    /// fold)`, paired POSITION-FOR-POSITION with [`regression_targets`](Self::regression_targets) (the
456    /// matching y_true for the same producer/fold/samples).
457    pub predictions: Vec<PredictionBlock>,
458    /// The id-matched y_true blocks for [`predictions`](Self::predictions), one per prediction block in
459    /// the SAME order.
460    pub regression_targets: Vec<RegressionTargetBlock>,
461    /// The per-sample cross-fold OOF AVERAGE block (+ id-matched y_true), if the variant produced one
462    /// (`None` for a single-fold splitter). The same averaged values the variant's scalar `avg` report
463    /// was computed from, exposed per sample.
464    pub oof_average: Option<OofAverageBlock>,
465}
466
467/// Pick the best variant of a multi-variant plan by its cross-validation score, natively.
468///
469/// "Option A": each variant is scored with its OWN single-variant FIT_CV — the plan is cloned with
470/// `variants = vec![variant]` so the existing per-producer cross-fold OOF averaging
471/// ([`RunContext::collect_cross_fold_validation_scores`]) is unambiguous (one variant in scope, so a
472/// validation `PredictionBlock` belongs to exactly one variant). The OOF-average report per variant
473/// becomes a [`CandidateScore`], and [`select_candidate`] ranks them by `selection_metric` (the
474/// metric's [`objective`](RegressionMetricKind::objective) drives the direction — RMSE minimizes,
475/// accuracy maximizes). The winning candidate id maps back to its [`VariantId`].
476///
477/// Beyond ranking, every scored variant's VALIDATION (OOF) reports — the per-fold reports and the
478/// cross-fold OOF average, each tagged with its `variant_id` — are accumulated and returned in
479/// [`VariantSelection::validation_reports`] so the caller can surface ALL variants' CV scores (not
480/// just the winner's) in the final bundle. This is OOF-safe: the per-variant CV runs happen in
481/// transient `RunContext`s whose prediction stores and `RegressionTargetRecord`s are dropped here;
482/// only the scalar score reports (derived from `y_true`) survive, so a non-selected variant's OOF
483/// predictions can NEVER reach any downstream training/feature path.
484///
485/// Native scoring is opt-in: it only happens when the host emits `regression_targets`. So this
486/// returns `Ok(None)` when NO variant produced a cross-fold OOF average (scoring is off, the normal
487/// case today) — the caller should then fall back to its default variant, behaving exactly as before.
488/// When EVERY variant scored, it returns `Ok(Some(best))`. A partially-scored set (some variants
489/// scored, others not) is an inconsistent host and is rejected so variants are never ranked unfairly.
490///
491/// `run_single_variant_fit_cv` runs FIT_CV for the single-variant plan into the supplied context
492/// (the caller supplies the scheduler/data-provider wiring); this keeps the selection logic free of
493/// host runtime details and unit-testable with mock controllers. Cloning a one-variant plan is
494/// valid: `node_plans`/`fold_set` are plan-level (not keyed per variant) and variant params are
495/// applied per-node at task build time, so the per-variant CV is isolated.
496pub 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
516/// Select the best plan variant and retain the exact decision produced by the
517/// shared native ranking pass.
518///
519/// This is the training-operation counterpart of
520/// [`select_best_variant_by_cv`]. It does not perform an additional SELECT;
521/// the legacy helper simply projects this result back to its historical type.
522pub 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    // Mechanism A: each variant is the FULL union plan narrowed to that single variant — params are
539    // applied per-node at task-build time, so cloning a one-variant plan is the per-variant scope.
540    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        // Param-variant SELECT (Mechanism A) has no operator-variant content fingerprint, so reports
554        // carry `variant_id` only (no `variant_label`) — exactly the pre-Phase-5 shape.
555        |_variant| Ok(None),
556        &mut run_single_variant_fit_cv,
557    )
558}
559
560/// Select a plan variant using only the cross-fold OOF average emitted by one
561/// explicitly resolved score-target producer. All producers' validation
562/// reports remain retained in the returned outcome for audit.
563#[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
601/// Pick the best OPERATOR variant of an operator-generator UNION plan by its cross-validation score.
602///
603/// Where [`select_best_variant_by_cv`] narrows the SAME union plan to one variant (Mechanism A: param
604/// variants), operator-SELECT scores each candidate on its PRUNED plan: the Mechanism-B union
605/// compiles an operator generator as a STACKING graph (`choice -> merge:generator_predictions ->
606/// model:meta`), but operator `_or_` is SELECT, not stacking — so each candidate is the union pruned
607/// down to one choice's sub-sequence + the shared prefix, with the generator merge + meta-model +
608/// every inactive choice ELIDED (see [`prune_plan_to_active`]). The pruned candidate has exactly ONE
609/// terminal producer, so the single-producer guard in the shared ranking loop is satisfied.
610///
611/// `model` is the [`OperatorVariantModel`] lowered from the (single, flat) operator generator;
612/// `union_plan` is the compiled UNION plan; `selection_metric` drives the ranking direction
613/// (`RegressionMetricKind::objective`). MULTIPLE operator generators are REJECTED here (consistent
614/// with the Phase-3 nested-rejection: this phase scopes to a flat single operator generator).
615///
616/// LEAKAGE: each variant runs in a fresh, variant-pinned [`RunContext`] over its PRUNED graph — the
617/// inactive choices' models are physically absent, so they are never fit and no `requires_oof` edge
618/// can pull an inactive variant's OOF. The non-selected variants' OOF predictions never leave their
619/// transient contexts (only their scalar VALIDATION reports survive), exactly as in
620/// [`select_best_variant_by_cv`].
621///
622/// Returns `Ok(None)` when scoring is off (no host targets) — the caller keeps its default — and
623/// `Ok(Some(best))` when every variant scored.
624pub 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    // The union of every choice's active set: subtracted from each candidate's ancestors so a prune
645    // never pulls in a sibling choice (or the elided merge/meta).
646    let all_choice_nodes = model
647        .active_nodes
648        .values()
649        .flatten()
650        .cloned()
651        .collect::<BTreeSet<NodeId>>();
652    // Map each enumerated variant back to its operator choice (the choice's `active_subsequence`
653    // keys `active_nodes`) via `operator_variant_active_subsequence`. The model is a single operator
654    // dimension, so each variant carries exactly one choice.
655    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        // Phase 5: stamp the choice's cross-language content fingerprint on every report. The
673        // operator model's `variant_labels` is the choice-keyed sha256; when a model was hand-built
674        // without labels (the older execution fixtures), the map is empty and reports carry no label.
675        |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
684/// Resolve the `active_subsequence` (choice key) of an enumerated operator variant against its
685/// model's single operator dimension. Shared by the prune-plan and the `variant_label` resolvers so
686/// both agree on the choice a variant names.
687fn 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
706/// Route operator-SELECT from the operator-variant models lowered off a pipeline DSL
707/// ([`compile_operator_variant_models`](crate::compile_operator_variant_models)).
708///
709/// This phase scopes to a FLAT, SINGLE operator generator (consistent with the Phase-3
710/// nested-generator rejection), so MORE THAN ONE operator generator is rejected with a clear error.
711/// An empty slice means the spec has no operator generator at all — there is nothing to operator-SELECT,
712/// so it returns `Ok(None)` (the caller keeps its default variant). Exactly one model delegates to
713/// [`select_best_operator_variant_by_cv`].
714pub 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/// The shared scoring + ranking loop behind [`select_best_variant_by_cv`] and
748/// [`select_best_operator_variant_by_cv`]: per variant, build its per-variant plan (`make_variant_plan`),
749/// run FIT_CV into a fresh variant-pinned [`RunContext`], collect the cross-fold OOF average, and
750/// rank by `selection_metric`. The two callers differ ONLY in `make_variant_plan` (clone-the-union
751/// vs. prune-to-active); everything below — the single-producer guard, the all-or-nothing scoring
752/// gate, the loser-report retention, and [`select_candidate`] ranking — is identical and lives here.
753/// `resolve_variant_label` resolves each variant's Phase-5 content fingerprint (the two closures
754/// keep the shared loop free of caller-specific plumbing).
755#[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    // Every ranked variant's VALIDATION (OOF) reports, each tagged with its variant_id, accumulated
780    // so the caller can emit ALL variants' CV scores (not just the winner's) in the bundle.
781    let mut variant_validation_reports: Vec<RegressionMetricReport> = Vec::new();
782    // Every ranked variant's VALIDATION (OOF) PREDICTIONS, captured from its transient ctx and
783    // re-tagged with its variant id + content fingerprint, so the caller can fill a non-selected
784    // variant's per-sample prediction rows (not just its scalar CV score). Captured per variant; the
785    // caller filters to the LOSERS (the winner's predictions come fresh from the real FIT_CV pass).
786    let mut variant_validation_predictions: Vec<VariantValidationPredictions> = Vec::new();
787    // Tracks whether ANY variant emitted scores at all (host targets present), so an empty candidate
788    // set can be told apart from "scoring genuinely off" (no targets) — see the post-loop branch.
789    let mut any_scores_seen = false;
790    for variant in variants {
791        let variant_plan = make_variant_plan(variant)?;
792        // Phase 5: the operator-variant content fingerprint for this variant (the choice's
793        // `variant_label`), resolved the SAME way `variant_id` is — `None` for param-variant /
794        // single-variant SELECT, `Some(<sha256>)` for an operator choice.
795        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        // ADDITIVE prediction capture (paired with the scalar report retention below). Each per-fold
804        // VALIDATION (OOF) `PredictionBlock` in this variant's transient store is captured together
805        // with its id-matched y_true, plus the cross-fold OOF AVERAGE block — re-tagged with the
806        // variant's id + content fingerprint. Only `Validation` blocks are captured: the transient run
807        // executes FIT_CV ONLY (no Final/Test/refit), so this is OOF-only by construction, and the
808        // re-tag prevents cross-variant mixing. The same values the scalar reports were computed from,
809        // exposed per sample — strictly additive (the captured blocks never feed a training/feature
810        // path or cross a `requires_oof` edge).
811        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        // `cross_fold_validation_reports` emits one cross-fold OOF average PER producer. Native SELECT
820        // ranks a variant by a single score, so a multi-producer DAG is ambiguous and refused rather
821        // than silently ranked on whichever producer happened to be first (an explicit score-target
822        // producer is a future extension). For operator-SELECT the pruned candidate has exactly one
823        // terminal producer, so this guard is satisfied by construction.
824        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        // Retain this variant's VALIDATION reports (per-fold + cross-fold avg) tagged with its own
857        // variant_id. The avg report's native form has `variant_id = None`, so stamp it here; the
858        // per-fold reports already carry it from `apply_result_scoring`. Only Validation reports are
859        // kept — the transient CV runs FIT_CV only (no Final/Test), so this is OOF-only by
860        // construction, but the filter makes the report-only guarantee explicit.
861        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            // Targets WERE emitted, but no producer yielded a cross-fold average (e.g. a single fold,
874            // where the average is skipped). We cannot rank — surface it instead of falling back.
875            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        // Native scoring is genuinely off (no host targets) — let the caller keep its default variant.
880        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
918/// Capture one variant's per-fold VALIDATION (OOF) predictions (paired with id-matched y_true) and
919/// its cross-fold OOF AVERAGE block from a transient FIT_CV [`RunContext`], re-tagged with the
920/// variant's id + content fingerprint. ADDITIVE + leakage-safe: only `Validation` blocks are read (a
921/// transient run is FIT_CV-only, so no Final/Test/refit block exists), and the captured blocks are
922/// copies surfaced for host display — they never feed a training/feature path. The per-fold y_true is
923/// the same record `apply_result_scoring` retained for the score, found by `(producer, fold)`; a
924/// prediction with no matching record is skipped (it could not have been scored either).
925///
926/// The matched target record covers exactly the prediction block's SAMPLE SET (see
927/// `sample_targets_match_block`) but its rows may be in a DIFFERENT ORDER than `block.sample_ids` — a
928/// host controller may validly emit its `regression_targets` in any order. The scoring path realigns
929/// by unit id, but the host surfaces these blocks POSITIONALLY (y_pred from `block.sample_ids`/`values`
930/// paired row-for-row with `regression_targets.values`), so the y_true is REBUILT in `block.sample_ids`
931/// order here — exactly as [`oof_average_block`](crate::metrics) does for the avg — so a host pairs
932/// y_pred ↔ y_true per sample without re-sorting.
933pub(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        // A target/group global OOF aggregate is appended after its source sample average.  Prefer
964        // that semantic score surface when present; legacy/sample-only executions retain the
965        // historical first average unchanged.
966        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
976/// Rebuild a per-fold VALIDATION `y_true` block in `sample_ids` ORDER so a host can pair it
977/// POSITIONALLY with the prediction block's `values` (the host surfaces direct prediction/target pairs
978/// by row position, not by id). `targets` covers exactly the same SAMPLE SET as `sample_ids` (the
979/// `sample_targets_match_block` precondition under which this record was retained), so every sample has
980/// a row; a missing one would indicate a broken invariant, so the original block is returned unchanged
981/// rather than dropping rows. Mirrors the avg realignment in [`oof_average_block`](crate::metrics).
982fn 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        // `target_name` is omitted when absent.
1054        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;