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;