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 merge;
72mod methods_replay;
73mod oof;
74mod prediction_store;
75mod scheduler;
76mod scoring;
77mod stacking;
78mod task;
79
80pub use artifact::*;
81pub use dataview::*;
82pub(crate) use merge::*;
83#[cfg(feature = "methods-optimizer")]
84pub use methods_replay::*;
85pub use oof::*;
86pub use prediction_store::*;
87pub use scheduler::*;
88pub(crate) use scoring::*;
89pub(crate) use stacking::*;
90pub use task::*;
91
92pub struct BundleReplayExecution<'a> {
93    pub plan: &'a ExecutionPlan,
94    pub bundle: &'a ExecutionBundle,
95    pub replay_request: &'a ReplayPhaseRequest,
96    pub prediction_cache_store: Option<&'a dyn RuntimePredictionCacheStore>,
97    pub controllers: &'a RuntimeControllerRegistry,
98    pub data_provider: &'a dyn RuntimeDataProvider,
99    pub artifact_store: &'a dyn RuntimeArtifactStore,
100    pub data_envelopes: &'a BTreeMap<String, ExternalDataPlanEnvelope>,
101}
102
103#[derive(Default)]
104pub struct RuntimeControllerRegistry {
105    controllers: BTreeMap<ControllerId, Box<dyn RuntimeController>>,
106}
107
108impl RuntimeControllerRegistry {
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    pub fn register(&mut self, controller: Box<dyn RuntimeController>) -> Result<()> {
114        let id = controller.controller_id().clone();
115        if self.controllers.insert(id.clone(), controller).is_some() {
116            return Err(DagMlError::RuntimeValidation(format!(
117                "duplicate runtime controller `{id}`"
118            )));
119        }
120        Ok(())
121    }
122
123    pub fn get(&self, controller_id: &ControllerId) -> Option<&dyn RuntimeController> {
124        self.controllers.get(controller_id).map(Box::as_ref)
125    }
126}
127
128pub fn dispatch_custom_observation_aggregation(
129    plan: &ExecutionPlan,
130    controllers: &RuntimeControllerRegistry,
131    task_id: impl Into<String>,
132    block: ObservationPredictionBlock,
133    relations: SampleRelationSet,
134    policy: AggregationPolicy,
135    requested_sample_order: Vec<SampleId>,
136) -> Result<PredictionBlock> {
137    let controller_id = custom_aggregation_controller_id(&policy)?;
138    ensure_aggregation_controller_capability(plan, controller_id)?;
139    let task = AggregationControllerTask {
140        schema_version: crate::aggregation::AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
141        task_id: task_id.into(),
142        controller_id: controller_id.clone(),
143        policy,
144        reduction_plan: None,
145        input: AggregationControllerInput::ObservationToSample {
146            block,
147            relations,
148            requested_sample_order,
149        },
150    };
151    let result = dispatch_custom_aggregation_task(controllers, &task)?;
152    match result.output {
153        AggregationControllerOutput::Sample { block } => Ok(block),
154        AggregationControllerOutput::Unit { .. } => Err(DagMlError::RuntimeValidation(format!(
155            "aggregation controller task `{}` returned unit output for observation input",
156            task.task_id
157        ))),
158    }
159}
160
161pub fn dispatch_custom_sample_aggregation(
162    plan: &ExecutionPlan,
163    controllers: &RuntimeControllerRegistry,
164    task_id: impl Into<String>,
165    block: PredictionBlock,
166    relations: SampleRelationSet,
167    policy: AggregationPolicy,
168    requested_unit_order: Vec<PredictionUnitId>,
169) -> Result<AggregatedPredictionBlock> {
170    let controller_id = custom_aggregation_controller_id(&policy)?;
171    ensure_aggregation_controller_capability(plan, controller_id)?;
172    let task = AggregationControllerTask {
173        schema_version: crate::aggregation::AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
174        task_id: task_id.into(),
175        controller_id: controller_id.clone(),
176        policy,
177        reduction_plan: None,
178        input: AggregationControllerInput::SampleToUnit {
179            block,
180            relations,
181            requested_unit_order,
182        },
183    };
184    let result = dispatch_custom_aggregation_task(controllers, &task)?;
185    match result.output {
186        AggregationControllerOutput::Unit { block } => Ok(block),
187        AggregationControllerOutput::Sample { .. } => Err(DagMlError::RuntimeValidation(format!(
188            "aggregation controller task `{}` returned sample output for sample input",
189            task.task_id
190        ))),
191    }
192}
193
194pub fn dispatch_custom_aggregation_task(
195    controllers: &RuntimeControllerRegistry,
196    task: &AggregationControllerTask,
197) -> Result<AggregationControllerResult> {
198    task.validate()?;
199    let controller = controllers.get(&task.controller_id).ok_or_else(|| {
200        DagMlError::RuntimeValidation(format!(
201            "aggregation runtime controller `{}` is not registered",
202            task.controller_id
203        ))
204    })?;
205    let result = controller.invoke_aggregation(task)?;
206    result.validate_for_task(task)?;
207    Ok(result)
208}
209
210pub(crate) fn custom_aggregation_controller_id(
211    policy: &AggregationPolicy,
212) -> Result<&ControllerId> {
213    policy.validate()?;
214    policy
215        .custom_controller
216        .as_ref()
217        .map(|controller| &controller.controller_id)
218        .ok_or_else(|| {
219            DagMlError::RuntimeValidation(
220                "custom aggregation dispatch requires a custom_controller policy".to_string(),
221            )
222        })
223}
224
225pub(crate) fn ensure_aggregation_controller_capability(
226    plan: &ExecutionPlan,
227    controller_id: &ControllerId,
228) -> Result<()> {
229    let manifest = plan
230        .controller_manifests
231        .get(controller_id)
232        .ok_or_else(|| {
233            DagMlError::Planning(format!(
234                "missing aggregation controller manifest `{controller_id}`"
235            ))
236        })?;
237    if !manifest
238        .capabilities
239        .contains(&ControllerCapability::AggregatesPredictions)
240    {
241        return Err(DagMlError::Planning(format!(
242            "aggregation controller `{controller_id}` must declare aggregates_predictions"
243        )));
244    }
245    Ok(())
246}
247
248#[derive(Clone, Debug)]
249pub struct RunContext {
250    pub run_id: RunId,
251    pub root_seed: Option<u64>,
252    pub variant_id: Option<VariantId>,
253    pub prediction_store: InMemoryPredictionStore,
254    pub aggregated_prediction_store: InMemoryAggregatedPredictionStore,
255    pub lineage: InMemoryLineageRecorder,
256    /// Native per-fold/per-partition score reports collected during the run (when the host emits
257    /// `regression_targets`).
258    pub score_collector: Vec<RegressionMetricReport>,
259    /// Per-fold `y_true` records, kept so cross-fold ensembles (the OOF average) can be scored.
260    pub regression_target_records: Vec<RegressionTargetRecord>,
261    /// The per-sample cross-fold OOF average blocks (+ `y_true`) collected alongside the scalar OOF
262    /// average reports — one per scored producer. Surfaced so the host can fill the `(validation, avg)`
263    /// row's per-sample y_pred; populated by `collect_cross_fold_validation_scores`, empty otherwise.
264    pub oof_average_blocks: Vec<OofAverageBlock>,
265    /// Declarative per-producer aggregation contracts that are applied only after every
266    /// validation fold has contributed its raw sample-level OOF block.  This is deliberately
267    /// separate from the per-task aggregation path: a semantic unit may span CV folds, so
268    /// reducing it inside one fold would change the experiment being scored.
269    pub(crate) global_oof_aggregation: BTreeMap<NodeId, GlobalOofAggregationSpec>,
270    /// When nested stacking retains child-fold evidence in this shared context,
271    /// only the listed parent folds are report-grade CV evidence.  Child OOF is
272    /// still available to the scheduler/meta learner, but can never enter
273    /// selection or cross-fold score aggregation.
274    pub(crate) validation_scoring_fold_ids: Option<BTreeSet<FoldId>>,
275}
276
277#[derive(Clone, Debug)]
278pub(crate) struct GlobalOofAggregationSpec {
279    pub(crate) policy: AggregationPolicy,
280    pub(crate) relations: SampleRelationSet,
281}
282
283impl RunContext {
284    pub fn new(run_id: RunId, root_seed: Option<u64>) -> Self {
285        Self {
286            run_id,
287            root_seed,
288            variant_id: None,
289            prediction_store: InMemoryPredictionStore::new(),
290            aggregated_prediction_store: InMemoryAggregatedPredictionStore::new(),
291            lineage: InMemoryLineageRecorder::new(),
292            score_collector: Vec::new(),
293            regression_target_records: Vec::new(),
294            oof_average_blocks: Vec::new(),
295            global_oof_aggregation: BTreeMap::new(),
296            validation_scoring_fold_ids: None,
297        }
298    }
299
300    /// Bind the relation-attested aggregation policies needed for global OOF scoring before the
301    /// caller executes `FIT_CV`.  The provider remains the authority for relations; this method
302    /// only records a validated, immutable snapshot in the run context so finalisation cannot
303    /// silently use a different grouping from execution.
304    pub(crate) fn configure_global_oof_aggregation(
305        &mut self,
306        plan: &ExecutionPlan,
307        data_provider: &dyn RuntimeDataProvider,
308    ) -> Result<()> {
309        self.global_oof_aggregation = global_oof_aggregation_specs(plan, data_provider)?;
310        Ok(())
311    }
312
313    /// Score the cross-fold OOF average from the collected per-fold validation predictions + targets
314    /// and append the reports (one per producer, `fold_id = "avg"`) to the score collector, plus —
315    /// additively — the per-sample OOF average block + `y_true` each report was computed from to
316    /// [`oof_average_blocks`](Self::oof_average_blocks) (so the host can fill the `(validation, avg)`
317    /// row's per-sample y_pred). Call after FIT_CV; a no-op when nothing was scored or no producer has
318    /// more than one fold.
319    ///
320    /// `partition_mode` is the campaign's [`FoldPartitionMode`]: `Partition` (KFold) requires a unique
321    /// per-producer OOF set, while `Resampled` (ShuffleSplit / repeated CV) permits a sample to be
322    /// validated in multiple folds (averaged when scored). Pass the plan's
323    /// [`fold_set`](ExecutionPlan::fold_set) mode (default `Partition` when there is no fold set).
324    pub fn collect_cross_fold_validation_scores(
325        &mut self,
326        partition_mode: FoldPartitionMode,
327    ) -> Result<()> {
328        let scoring_blocks = self
329            .prediction_store
330            .blocks()
331            .iter()
332            .filter(|block| {
333                block.partition != PredictionPartition::Validation
334                    || self
335                        .validation_scoring_fold_ids
336                        .as_ref()
337                        .is_none_or(|allowed| {
338                            block
339                                .fold_id
340                                .as_ref()
341                                .is_some_and(|fold_id| allowed.contains(fold_id))
342                        })
343            })
344            .cloned()
345            .collect::<Vec<_>>();
346        let outcome = cross_fold_validation_reports(
347            &scoring_blocks,
348            &self.regression_target_records,
349            SCORE_METRICS,
350            partition_mode,
351        )?;
352        let outcome = apply_global_oof_aggregation(outcome, &self.global_oof_aggregation)?;
353        self.score_collector.extend(outcome.reports);
354        self.oof_average_blocks.extend(outcome.oof_averages);
355        Ok(())
356    }
357
358    /// Build a [`ScoreSet`] from the collected reports (or `None` if scoring was off / produced
359    /// nothing), e.g. to attach to the [`ExecutionBundle`].
360    pub fn build_score_set(
361        &self,
362        plan_id: impl Into<String>,
363        selection_metric: Option<String>,
364    ) -> Option<ScoreSet> {
365        if self.score_collector.is_empty() {
366            return None;
367        }
368        Some(ScoreSet {
369            schema_version: SCORE_SET_SCHEMA_VERSION,
370            plan_id: plan_id.into(),
371            selection_metric,
372            reports: self.score_collector.clone(),
373        })
374    }
375}
376
377/// Outcome of native variant selection: the winning variant plus EVERY scored variant's
378/// cross-validation reports, each tagged with its own `variant_id`.
379///
380/// The reports are the per-fold + cross-fold-OOF-average VALIDATION (OOF) reports collected while
381/// ranking. They are emitted so a generated sweep can surface every variant's CV score — not only
382/// the winner's — to match the legacy per-variant `num_predictions`. These are REPORT-ONLY
383/// validation scores of non-selected models: they never feed any downstream training/feature path
384/// (no prediction blocks, no `RegressionTargetRecord`s, no handles leave selection — see
385/// [`select_best_variant_by_cv`]), so the OOF/leakage invariants are unaffected.
386#[derive(Clone, Debug)]
387pub struct VariantSelection {
388    /// The winning variant, ranked by `selection_metric`. The SELECT DECISION is identical to the
389    /// pre-existing behavior; `validation_reports` is purely additive context.
390    pub selected_variant_id: VariantId,
391    /// Per-variant VALIDATION (OOF) reports for ALL ranked variants (winner included), each tagged
392    /// with its `variant_id`. The cross-fold OOF average per producer is re-tagged with the variant
393    /// id (its native form has `variant_id = None`); the per-fold reports already carry it.
394    pub validation_reports: Vec<RegressionMetricReport>,
395    /// Per-variant VALIDATION (OOF) PREDICTIONS for ALL ranked variants (winner included), captured
396    /// from each variant's transient FIT_CV [`RunContext`] BEFORE it is dropped, re-tagged with the
397    /// variant's id + content fingerprint. The scalar [`validation_reports`](Self::validation_reports)
398    /// above carry only the score; these carry the per-sample y_pred (+ id-matched y_true) so a host
399    /// can fill a non-selected variant's per-fold prediction rows, not just its CV score.
400    ///
401    /// LEAKAGE: these are each variant's OWN validation (OOF) predictions, re-tagged with that
402    /// variant's id (which prevents cross-variant mixing). They are surfaced for host
403    /// persistence/display only — every transient CV run executes FIT_CV ONLY (no Final/Test/refit),
404    /// so by construction this carries no train/refit predictions, and the captured blocks never feed
405    /// a training/feature path or cross a `requires_oof` edge. This is strictly ADDITIVE — the same
406    /// values the scalar reports were computed from, exposed per sample — analogous to the additive
407    /// OOF-average block surfacing; no leakage validator is relaxed.
408    pub variant_validation_predictions: Vec<VariantValidationPredictions>,
409}
410
411/// Extended result of native variant selection.
412///
413/// The historical [`VariantSelection`] remains source-compatible for callers
414/// that construct or destructure it. Training orchestration uses this additive
415/// result to retain the exact [`SelectionDecision`] produced by the one and
416/// only ranking pass.
417#[derive(Clone, Debug)]
418pub struct VariantSelectionOutcome {
419    pub selection: VariantSelection,
420    pub decision: SelectionDecision,
421}
422
423/// One scored variant's VALIDATION (OOF) predictions, captured from its transient FIT_CV
424/// [`RunContext`] and re-tagged with the variant's id + content fingerprint so a host can fill that
425/// variant's per-sample prediction rows. REPORT-grade output paired with
426/// [`VariantSelection::validation_reports`]: it never feeds a training/feature path (see the field
427/// docs on [`VariantSelection::variant_validation_predictions`]).
428#[derive(Clone, Debug)]
429pub struct VariantValidationPredictions {
430    /// The variant these predictions belong to — the re-tag that keeps them from mixing with another
431    /// variant's predictions.
432    pub variant_id: VariantId,
433    /// The variant's Phase-5 content fingerprint (`variant_label`), `None` for param-variant /
434    /// single-variant SELECT (which carry no operator-variant fingerprint).
435    pub variant_label: Option<String>,
436    /// Per-fold VALIDATION (OOF) prediction blocks (`partition = Validation`), one per `(producer,
437    /// fold)`, paired POSITION-FOR-POSITION with [`regression_targets`](Self::regression_targets) (the
438    /// matching y_true for the same producer/fold/samples).
439    pub predictions: Vec<PredictionBlock>,
440    /// The id-matched y_true blocks for [`predictions`](Self::predictions), one per prediction block in
441    /// the SAME order.
442    pub regression_targets: Vec<RegressionTargetBlock>,
443    /// The per-sample cross-fold OOF AVERAGE block (+ id-matched y_true), if the variant produced one
444    /// (`None` for a single-fold splitter). The same averaged values the variant's scalar `avg` report
445    /// was computed from, exposed per sample.
446    pub oof_average: Option<OofAverageBlock>,
447}
448
449/// Pick the best variant of a multi-variant plan by its cross-validation score, natively.
450///
451/// "Option A": each variant is scored with its OWN single-variant FIT_CV — the plan is cloned with
452/// `variants = vec![variant]` so the existing per-producer cross-fold OOF averaging
453/// ([`RunContext::collect_cross_fold_validation_scores`]) is unambiguous (one variant in scope, so a
454/// validation `PredictionBlock` belongs to exactly one variant). The OOF-average report per variant
455/// becomes a [`CandidateScore`], and [`select_candidate`] ranks them by `selection_metric` (the
456/// metric's [`objective`](RegressionMetricKind::objective) drives the direction — RMSE minimizes,
457/// accuracy maximizes). The winning candidate id maps back to its [`VariantId`].
458///
459/// Beyond ranking, every scored variant's VALIDATION (OOF) reports — the per-fold reports and the
460/// cross-fold OOF average, each tagged with its `variant_id` — are accumulated and returned in
461/// [`VariantSelection::validation_reports`] so the caller can surface ALL variants' CV scores (not
462/// just the winner's) in the final bundle. This is OOF-safe: the per-variant CV runs happen in
463/// transient `RunContext`s whose prediction stores and `RegressionTargetRecord`s are dropped here;
464/// only the scalar score reports (derived from `y_true`) survive, so a non-selected variant's OOF
465/// predictions can NEVER reach any downstream training/feature path.
466///
467/// Native scoring is opt-in: it only happens when the host emits `regression_targets`. So this
468/// returns `Ok(None)` when NO variant produced a cross-fold OOF average (scoring is off, the normal
469/// case today) — the caller should then fall back to its default variant, behaving exactly as before.
470/// When EVERY variant scored, it returns `Ok(Some(best))`. A partially-scored set (some variants
471/// scored, others not) is an inconsistent host and is rejected so variants are never ranked unfairly.
472///
473/// `run_single_variant_fit_cv` runs FIT_CV for the single-variant plan into the supplied context
474/// (the caller supplies the scheduler/data-provider wiring); this keeps the selection logic free of
475/// host runtime details and unit-testable with mock controllers. Cloning a one-variant plan is
476/// valid: `node_plans`/`fold_set` are plan-level (not keyed per variant) and variant params are
477/// applied per-node at task build time, so the per-variant CV is isolated.
478pub fn select_best_variant_by_cv<F>(
479    plan: &ExecutionPlan,
480    run_id: &RunId,
481    root_seed: Option<u64>,
482    selection_metric: RegressionMetricKind,
483    run_single_variant_fit_cv: F,
484) -> Result<Option<VariantSelection>>
485where
486    F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
487{
488    Ok(select_best_variant_outcome_by_cv(
489        plan,
490        run_id,
491        root_seed,
492        selection_metric,
493        run_single_variant_fit_cv,
494    )?
495    .map(|outcome| outcome.selection))
496}
497
498/// Select the best plan variant and retain the exact decision produced by the
499/// shared native ranking pass.
500///
501/// This is the training-operation counterpart of
502/// [`select_best_variant_by_cv`]. It does not perform an additional SELECT;
503/// the legacy helper simply projects this result back to its historical type.
504pub fn select_best_variant_outcome_by_cv<F>(
505    plan: &ExecutionPlan,
506    run_id: &RunId,
507    root_seed: Option<u64>,
508    selection_metric: RegressionMetricKind,
509    mut run_single_variant_fit_cv: F,
510) -> Result<Option<VariantSelectionOutcome>>
511where
512    F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
513{
514    plan.validate()?;
515    if plan.variants.is_empty() {
516        return Err(DagMlError::RuntimeValidation(
517            "cannot select a variant for a plan with no variants".to_string(),
518        ));
519    }
520    // Mechanism A: each variant is the FULL union plan narrowed to that single variant — params are
521    // applied per-node at task-build time, so cloning a one-variant plan is the per-variant scope.
522    score_and_rank_variants_by_cv(
523        &plan.variants,
524        run_id,
525        root_seed,
526        selection_metric,
527        plan_oof_partition_mode(plan),
528        None,
529        |variant| {
530            Ok(ExecutionPlan {
531                variants: vec![variant.clone()],
532                ..plan.clone()
533            })
534        },
535        // Param-variant SELECT (Mechanism A) has no operator-variant content fingerprint, so reports
536        // carry `variant_id` only (no `variant_label`) — exactly the pre-Phase-5 shape.
537        |_variant| Ok(None),
538        &mut run_single_variant_fit_cv,
539    )
540}
541
542/// Select a plan variant using only the cross-fold OOF average emitted by one
543/// explicitly resolved score-target producer. All producers' validation
544/// reports remain retained in the returned outcome for audit.
545#[allow(clippy::too_many_arguments)]
546pub fn select_best_variant_outcome_by_cv_for_target<F>(
547    plan: &ExecutionPlan,
548    run_id: &RunId,
549    root_seed: Option<u64>,
550    selection_metric: RegressionMetricKind,
551    score_target: &NodeId,
552    score_target_port: Option<&str>,
553    score_target_level: PredictionLevel,
554    mut run_single_variant_fit_cv: F,
555) -> Result<Option<VariantSelectionOutcome>>
556where
557    F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
558{
559    plan.validate()?;
560    if !plan.node_plans.contains_key(score_target) {
561        return Err(DagMlError::RuntimeValidation(format!(
562            "native SELECT score target `{score_target}` is absent from plan"
563        )));
564    }
565    score_and_rank_variants_by_cv(
566        &plan.variants,
567        run_id,
568        root_seed,
569        selection_metric,
570        plan_oof_partition_mode(plan),
571        Some((score_target, score_target_port, score_target_level)),
572        |variant| {
573            Ok(ExecutionPlan {
574                variants: vec![variant.clone()],
575                ..plan.clone()
576            })
577        },
578        |_variant| Ok(None),
579        &mut run_single_variant_fit_cv,
580    )
581}
582
583/// Pick the best OPERATOR variant of an operator-generator UNION plan by its cross-validation score.
584///
585/// Where [`select_best_variant_by_cv`] narrows the SAME union plan to one variant (Mechanism A: param
586/// variants), operator-SELECT scores each candidate on its PRUNED plan: the Mechanism-B union
587/// compiles an operator generator as a STACKING graph (`choice -> merge:generator_predictions ->
588/// model:meta`), but operator `_or_` is SELECT, not stacking — so each candidate is the union pruned
589/// down to one choice's sub-sequence + the shared prefix, with the generator merge + meta-model +
590/// every inactive choice ELIDED (see [`prune_plan_to_active`]). The pruned candidate has exactly ONE
591/// terminal producer, so the single-producer guard in the shared ranking loop is satisfied.
592///
593/// `model` is the [`OperatorVariantModel`] lowered from the (single, flat) operator generator;
594/// `union_plan` is the compiled UNION plan; `selection_metric` drives the ranking direction
595/// (`RegressionMetricKind::objective`). MULTIPLE operator generators are REJECTED here (consistent
596/// with the Phase-3 nested-rejection: this phase scopes to a flat single operator generator).
597///
598/// LEAKAGE: each variant runs in a fresh, variant-pinned [`RunContext`] over its PRUNED graph — the
599/// inactive choices' models are physically absent, so they are never fit and no `requires_oof` edge
600/// can pull an inactive variant's OOF. The non-selected variants' OOF predictions never leave their
601/// transient contexts (only their scalar VALIDATION reports survive), exactly as in
602/// [`select_best_variant_by_cv`].
603///
604/// Returns `Ok(None)` when scoring is off (no host targets) — the caller keeps its default — and
605/// `Ok(Some(best))` when every variant scored.
606pub fn select_best_operator_variant_by_cv<F>(
607    union_plan: &ExecutionPlan,
608    model: &OperatorVariantModel,
609    run_id: &RunId,
610    root_seed: Option<u64>,
611    selection_metric: RegressionMetricKind,
612    mut run_single_variant_fit_cv: F,
613) -> Result<Option<VariantSelection>>
614where
615    F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
616{
617    union_plan.validate()?;
618    model.validate()?;
619    let variants = enumerate_variants(&model.generation_spec(), root_seed)?;
620    if variants.is_empty() {
621        return Err(DagMlError::RuntimeValidation(format!(
622            "operator variant model `{}` produced no variants",
623            model.generator_id
624        )));
625    }
626    // The union of every choice's active set: subtracted from each candidate's ancestors so a prune
627    // never pulls in a sibling choice (or the elided merge/meta).
628    let all_choice_nodes = model
629        .active_nodes
630        .values()
631        .flatten()
632        .cloned()
633        .collect::<BTreeSet<NodeId>>();
634    // Map each enumerated variant back to its operator choice (the choice's `active_subsequence`
635    // keys `active_nodes`) via `operator_variant_active_subsequence`. The model is a single operator
636    // dimension, so each variant carries exactly one choice.
637    Ok(score_and_rank_variants_by_cv(
638        &variants,
639        run_id,
640        root_seed,
641        selection_metric,
642        plan_oof_partition_mode(union_plan),
643        None,
644        |variant| {
645            let active_subsequence = operator_variant_active_subsequence(model, variant)?;
646            let active_nodes = model.active_nodes.get(active_subsequence).ok_or_else(|| {
647                DagMlError::RuntimeValidation(format!(
648                    "operator variant model `{}` has no active-node set for `{active_subsequence}`",
649                    model.generator_id
650                ))
651            })?;
652            prune_plan_to_active(union_plan, active_nodes, &all_choice_nodes, variant)
653        },
654        // Phase 5: stamp the choice's cross-language content fingerprint on every report. The
655        // operator model's `variant_labels` is the choice-keyed sha256; when a model was hand-built
656        // without labels (the older execution fixtures), the map is empty and reports carry no label.
657        |variant| {
658            let active_subsequence = operator_variant_active_subsequence(model, variant)?;
659            Ok(model.variant_labels.get(active_subsequence).cloned())
660        },
661        &mut run_single_variant_fit_cv,
662    )?
663    .map(|outcome| outcome.selection))
664}
665
666/// Resolve the `active_subsequence` (choice key) of an enumerated operator variant against its
667/// model's single operator dimension. Shared by the prune-plan and the `variant_label` resolvers so
668/// both agree on the choice a variant names.
669fn operator_variant_active_subsequence<'a>(
670    model: &OperatorVariantModel,
671    variant: &'a VariantPlan,
672) -> Result<&'a str> {
673    let dimension_name = &model.dimension.name;
674    let choice = variant.choices.get(dimension_name).ok_or_else(|| {
675        DagMlError::RuntimeValidation(format!(
676            "operator variant `{}` is missing the operator dimension `{dimension_name}`",
677            variant.variant_id
678        ))
679    })?;
680    choice.active_subsequence.as_deref().ok_or_else(|| {
681        DagMlError::RuntimeValidation(format!(
682            "operator variant `{}` choice `{}` has no active_subsequence",
683            variant.variant_id, choice.label
684        ))
685    })
686}
687
688/// Route operator-SELECT from the operator-variant models lowered off a pipeline DSL
689/// ([`compile_operator_variant_models`](crate::compile_operator_variant_models)).
690///
691/// This phase scopes to a FLAT, SINGLE operator generator (consistent with the Phase-3
692/// nested-generator rejection), so MORE THAN ONE operator generator is rejected with a clear error.
693/// An empty slice means the spec has no operator generator at all — there is nothing to operator-SELECT,
694/// so it returns `Ok(None)` (the caller keeps its default variant). Exactly one model delegates to
695/// [`select_best_operator_variant_by_cv`].
696pub fn select_best_operator_variant_from_models<F>(
697    union_plan: &ExecutionPlan,
698    models: &[OperatorVariantModel],
699    run_id: &RunId,
700    root_seed: Option<u64>,
701    selection_metric: RegressionMetricKind,
702    run_single_variant_fit_cv: F,
703) -> Result<Option<VariantSelection>>
704where
705    F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
706{
707    match models {
708        [] => Ok(None),
709        [model] => select_best_operator_variant_by_cv(
710            union_plan,
711            model,
712            run_id,
713            root_seed,
714            selection_metric,
715            run_single_variant_fit_cv,
716        ),
717        _ => Err(DagMlError::RuntimeValidation(format!(
718            "operator-SELECT does not support {} operator generators in one pipeline; this phase scopes to a flat single operator generator (generators: {})",
719            models.len(),
720            models
721                .iter()
722                .map(|model| model.generator_id.to_string())
723                .collect::<Vec<_>>()
724                .join(", ")
725        ))),
726    }
727}
728
729/// The shared scoring + ranking loop behind [`select_best_variant_by_cv`] and
730/// [`select_best_operator_variant_by_cv`]: per variant, build its per-variant plan (`make_variant_plan`),
731/// run FIT_CV into a fresh variant-pinned [`RunContext`], collect the cross-fold OOF average, and
732/// rank by `selection_metric`. The two callers differ ONLY in `make_variant_plan` (clone-the-union
733/// vs. prune-to-active); everything below — the single-producer guard, the all-or-nothing scoring
734/// gate, the loser-report retention, and [`select_candidate`] ranking — is identical and lives here.
735/// `resolve_variant_label` resolves each variant's Phase-5 content fingerprint (the two closures
736/// keep the shared loop free of caller-specific plumbing).
737#[allow(clippy::too_many_arguments)]
738fn score_and_rank_variants_by_cv<M, L, F>(
739    variants: &[VariantPlan],
740    run_id: &RunId,
741    root_seed: Option<u64>,
742    selection_metric: RegressionMetricKind,
743    partition_mode: FoldPartitionMode,
744    score_target: Option<(&NodeId, Option<&str>, PredictionLevel)>,
745    mut make_variant_plan: M,
746    mut resolve_variant_label: L,
747    run_single_variant_fit_cv: &mut F,
748) -> Result<Option<VariantSelectionOutcome>>
749where
750    M: FnMut(&VariantPlan) -> Result<ExecutionPlan>,
751    L: FnMut(&VariantPlan) -> Result<Option<String>>,
752    F: FnMut(&ExecutionPlan, &mut RunContext) -> Result<()>,
753{
754    if variants.is_empty() {
755        return Err(DagMlError::RuntimeValidation(
756            "cannot select a variant for a plan with no variants".to_string(),
757        ));
758    }
759
760    let mut candidates: Vec<CandidateScore> = Vec::with_capacity(variants.len());
761    // Every ranked variant's VALIDATION (OOF) reports, each tagged with its variant_id, accumulated
762    // so the caller can emit ALL variants' CV scores (not just the winner's) in the bundle.
763    let mut variant_validation_reports: Vec<RegressionMetricReport> = Vec::new();
764    // Every ranked variant's VALIDATION (OOF) PREDICTIONS, captured from its transient ctx and
765    // re-tagged with its variant id + content fingerprint, so the caller can fill a non-selected
766    // variant's per-sample prediction rows (not just its scalar CV score). Captured per variant; the
767    // caller filters to the LOSERS (the winner's predictions come fresh from the real FIT_CV pass).
768    let mut variant_validation_predictions: Vec<VariantValidationPredictions> = Vec::new();
769    // Tracks whether ANY variant emitted scores at all (host targets present), so an empty candidate
770    // set can be told apart from "scoring genuinely off" (no targets) — see the post-loop branch.
771    let mut any_scores_seen = false;
772    for variant in variants {
773        let variant_plan = make_variant_plan(variant)?;
774        // Phase 5: the operator-variant content fingerprint for this variant (the choice's
775        // `variant_label`), resolved the SAME way `variant_id` is — `None` for param-variant /
776        // single-variant SELECT, `Some(<sha256>)` for an operator choice.
777        let variant_label = resolve_variant_label(variant)?;
778        let mut ctx = RunContext::new(run_id.clone(), root_seed);
779        ctx.variant_id = Some(variant.variant_id.clone());
780        run_single_variant_fit_cv(&variant_plan, &mut ctx)?;
781        ctx.collect_cross_fold_validation_scores(partition_mode)?;
782        if !ctx.score_collector.is_empty() {
783            any_scores_seen = true;
784        }
785        // ADDITIVE prediction capture (paired with the scalar report retention below). Each per-fold
786        // VALIDATION (OOF) `PredictionBlock` in this variant's transient store is captured together
787        // with its id-matched y_true, plus the cross-fold OOF AVERAGE block — re-tagged with the
788        // variant's id + content fingerprint. Only `Validation` blocks are captured: the transient run
789        // executes FIT_CV ONLY (no Final/Test/refit), so this is OOF-only by construction, and the
790        // re-tag prevents cross-variant mixing. The same values the scalar reports were computed from,
791        // exposed per sample — strictly additive (the captured blocks never feed a training/feature
792        // path or cross a `requires_oof` edge).
793        let captured = capture_variant_validation_predictions(
794            &variant.variant_id,
795            variant_label.clone(),
796            &ctx,
797        );
798        if !captured.predictions.is_empty() || captured.oof_average.is_some() {
799            variant_validation_predictions.push(captured);
800        }
801        // `cross_fold_validation_reports` emits one cross-fold OOF average PER producer. Native SELECT
802        // ranks a variant by a single score, so a multi-producer DAG is ambiguous and refused rather
803        // than silently ranked on whichever producer happened to be first (an explicit score-target
804        // producer is a future extension). For operator-SELECT the pruned candidate has exactly one
805        // terminal producer, so this guard is satisfied by construction.
806        let avg_reports = ctx
807            .score_collector
808            .iter()
809            .filter(|report| {
810                report.partition == PredictionPartition::Validation
811                    && score_target.is_none_or(|(target, target_port, level)| {
812                        &report.producer_node == target
813                            && target_port
814                                .is_none_or(|port| report.producer_port.as_deref() == Some(port))
815                            && report.level == level
816                    })
817                    && report
818                        .fold_id
819                        .as_ref()
820                        .is_some_and(|fold| fold.as_str() == "avg")
821            })
822            .collect::<Vec<_>>();
823        match avg_reports.as_slice() {
824            [] => {}
825            [report] => candidates.push(
826                (*report)
827                    .clone()
828                    .into_candidate_score(variant.variant_id.as_str())?,
829            ),
830            _ => {
831                return Err(DagMlError::RuntimeValidation(format!(
832                    "variant `{}` produced {} cross-fold OOF averages (multiple prediction producers); native SELECT needs a single score target",
833                    variant.variant_id,
834                    avg_reports.len()
835                )));
836            }
837        }
838        // Retain this variant's VALIDATION reports (per-fold + cross-fold avg) tagged with its own
839        // variant_id. The avg report's native form has `variant_id = None`, so stamp it here; the
840        // per-fold reports already carry it from `apply_result_scoring`. Only Validation reports are
841        // kept — the transient CV runs FIT_CV only (no Final/Test), so this is OOF-only by
842        // construction, but the filter makes the report-only guarantee explicit.
843        for mut report in ctx.score_collector {
844            if report.partition != PredictionPartition::Validation {
845                continue;
846            }
847            report.variant_id = Some(variant.variant_id.clone());
848            report.variant_label = variant_label.clone();
849            variant_validation_reports.push(report);
850        }
851    }
852
853    if candidates.is_empty() {
854        if any_scores_seen {
855            // Targets WERE emitted, but no producer yielded a cross-fold average (e.g. a single fold,
856            // where the average is skipped). We cannot rank — surface it instead of falling back.
857            return Err(DagMlError::RuntimeValidation(
858                "variants produced scores but no cross-fold OOF average; cannot rank — need >=2 folds or an explicit score target".to_string(),
859            ));
860        }
861        // Native scoring is genuinely off (no host targets) — let the caller keep its default variant.
862        return Ok(None);
863    }
864    if candidates.len() != variants.len() {
865        return Err(DagMlError::RuntimeValidation(format!(
866            "native variant SELECT scored only {} of {} variants; cannot rank variants fairly",
867            candidates.len(),
868            variants.len()
869        )));
870    }
871
872    let policy = SelectionPolicy {
873        id: format!("select:variant:{}", selection_metric.name()),
874        metric: SelectionMetric {
875            name: selection_metric.name().to_string(),
876            objective: selection_metric.objective(),
877        },
878        required_metric_level: None,
879        require_finite: true,
880        evaluation_scope: None,
881        refit_slot_plan: None,
882        stacking_fit_contract: None,
883        reduction_id: None,
884    };
885    let decision = select_candidate(&policy, &candidates)?;
886    let selected_variant_id =
887        VariantId::new(decision.selected_candidate_id.clone()).map_err(|error| {
888            DagMlError::RuntimeValidation(format!("selected variant id is invalid: {error}"))
889        })?;
890    Ok(Some(VariantSelectionOutcome {
891        selection: VariantSelection {
892            selected_variant_id,
893            validation_reports: variant_validation_reports,
894            variant_validation_predictions,
895        },
896        decision,
897    }))
898}
899
900/// Capture one variant's per-fold VALIDATION (OOF) predictions (paired with id-matched y_true) and
901/// its cross-fold OOF AVERAGE block from a transient FIT_CV [`RunContext`], re-tagged with the
902/// variant's id + content fingerprint. ADDITIVE + leakage-safe: only `Validation` blocks are read (a
903/// transient run is FIT_CV-only, so no Final/Test/refit block exists), and the captured blocks are
904/// copies surfaced for host display — they never feed a training/feature path. The per-fold y_true is
905/// the same record `apply_result_scoring` retained for the score, found by `(producer, fold)`; a
906/// prediction with no matching record is skipped (it could not have been scored either).
907///
908/// The matched target record covers exactly the prediction block's SAMPLE SET (see
909/// `sample_targets_match_block`) but its rows may be in a DIFFERENT ORDER than `block.sample_ids` — a
910/// host controller may validly emit its `regression_targets` in any order. The scoring path realigns
911/// by unit id, but the host surfaces these blocks POSITIONALLY (y_pred from `block.sample_ids`/`values`
912/// paired row-for-row with `regression_targets.values`), so the y_true is REBUILT in `block.sample_ids`
913/// order here — exactly as [`oof_average_block`](crate::metrics) does for the avg — so a host pairs
914/// y_pred ↔ y_true per sample without re-sorting.
915pub(crate) fn capture_variant_validation_predictions(
916    variant_id: &VariantId,
917    variant_label: Option<String>,
918    ctx: &RunContext,
919) -> VariantValidationPredictions {
920    let mut predictions = Vec::new();
921    let mut regression_targets = Vec::new();
922    for block in ctx.prediction_store.blocks() {
923        if block.partition != PredictionPartition::Validation {
924            continue;
925        }
926        let Some(record) = ctx.regression_target_records.iter().find(|record| {
927            record.producer_node == block.producer_node
928                && record.producer_port == block.producer_port
929                && record.partition == PredictionPartition::Validation
930                && record.fold_id == block.fold_id
931        }) else {
932            continue;
933        };
934        predictions.push(block.clone());
935        regression_targets.push(target_block_aligned_to_samples(
936            &block.sample_ids,
937            &record.block,
938        ));
939    }
940    VariantValidationPredictions {
941        variant_id: variant_id.clone(),
942        variant_label,
943        predictions,
944        regression_targets,
945        // A target/group global OOF aggregate is appended after its source sample average.  Prefer
946        // that semantic score surface when present; legacy/sample-only executions retain the
947        // historical first average unchanged.
948        oof_average: ctx
949            .oof_average_blocks
950            .iter()
951            .rev()
952            .find(|block| block.predictions.level != PredictionLevel::Sample)
953            .cloned()
954            .or_else(|| ctx.oof_average_blocks.first().cloned()),
955    }
956}
957
958/// Rebuild a per-fold VALIDATION `y_true` block in `sample_ids` ORDER so a host can pair it
959/// POSITIONALLY with the prediction block's `values` (the host surfaces direct prediction/target pairs
960/// by row position, not by id). `targets` covers exactly the same SAMPLE SET as `sample_ids` (the
961/// `sample_targets_match_block` precondition under which this record was retained), so every sample has
962/// a row; a missing one would indicate a broken invariant, so the original block is returned unchanged
963/// rather than dropping rows. Mirrors the avg realignment in [`oof_average_block`](crate::metrics).
964fn target_block_aligned_to_samples(
965    sample_ids: &[SampleId],
966    targets: &RegressionTargetBlock,
967) -> RegressionTargetBlock {
968    let value_by_sample: BTreeMap<&SampleId, &Vec<f64>> = targets
969        .unit_ids
970        .iter()
971        .zip(&targets.values)
972        .filter_map(|(unit_id, row)| match unit_id {
973            PredictionUnitId::Sample(sample_id) => Some((sample_id, row)),
974            _ => None,
975        })
976        .collect();
977    if sample_ids
978        .iter()
979        .any(|sample_id| !value_by_sample.contains_key(sample_id))
980    {
981        return targets.clone();
982    }
983    RegressionTargetBlock {
984        level: PredictionLevel::Sample,
985        unit_ids: sample_ids
986            .iter()
987            .cloned()
988            .map(PredictionUnitId::Sample)
989            .collect(),
990        values: sample_ids
991            .iter()
992            .map(|sample_id| value_by_sample[sample_id].clone())
993            .collect(),
994        target_names: targets.target_names.clone(),
995    }
996}
997
998#[cfg(test)]
999mod explain_contract_tests {
1000    use super::*;
1001
1002    fn block(method: &str) -> ExplanationBlock {
1003        ExplanationBlock {
1004            producer_node: NodeId::new("model:base").unwrap(),
1005            producer_port: None,
1006            method: method.to_string(),
1007            target_name: Some("y".to_string()),
1008            payload: serde_json::json!({"feature_importance": [0.5, 0.3, 0.2]}),
1009        }
1010    }
1011
1012    #[test]
1013    fn validates_well_formed_explanation() {
1014        assert!(block("shap").validate().is_ok());
1015    }
1016
1017    #[test]
1018    fn rejects_empty_method() {
1019        assert!(block("  ").validate().is_err());
1020    }
1021
1022    #[test]
1023    fn rejects_empty_target_name() {
1024        let mut b = block("shap");
1025        b.target_name = Some(String::new());
1026        assert!(b.validate().is_err());
1027    }
1028
1029    #[test]
1030    fn round_trips_through_json() {
1031        let b = block("permutation_importance");
1032        let json = serde_json::to_string(&b).expect("serialize");
1033        let parsed: ExplanationBlock = serde_json::from_str(&json).expect("deserialize");
1034        assert_eq!(parsed, b);
1035        // `target_name` is omitted when absent.
1036        let mut without = block("shap");
1037        without.target_name = None;
1038        let json = serde_json::to_string(&without).expect("serialize");
1039        assert!(!json.contains("target_name"));
1040    }
1041}
1042
1043#[cfg(test)]
1044mod tests;