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