Skip to main content

dag_ml_core/runtime/
task.rs

1// Auto-split from the former monolithic `runtime.rs` (pure refactor).
2use super::*;
3use crate::TrainingLossRoleReference;
4
5#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
6pub struct PredictionInputSpec {
7    pub producer_node: NodeId,
8    pub source_port: String,
9    pub target_port: String,
10    pub partition: PredictionPartition,
11    #[serde(default = "default_runtime_prediction_level")]
12    pub prediction_level: PredictionLevel,
13    pub fold_id: Option<FoldId>,
14    #[serde(default)]
15    pub fold_ids: Vec<FoldId>,
16    #[serde(default, skip_serializing_if = "Vec::is_empty")]
17    pub unit_ids: Vec<PredictionUnitId>,
18    #[serde(default)]
19    pub sample_ids: Vec<SampleId>,
20    /// Per-sample OOF prediction rows, aligned 1:1 with `sample_ids`
21    /// (width == `prediction_width`). Sourced only from Validation OOF blocks
22    /// so a host can build a stacking meta-feature matrix during FIT_CV/REFIT.
23    #[serde(default)]
24    pub values: Vec<Vec<f64>>,
25    pub prediction_width: usize,
26    #[serde(default)]
27    pub target_names: Vec<String>,
28}
29
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31pub struct ArtifactInputSpec {
32    pub node_id: NodeId,
33    pub controller_id: ControllerId,
34    pub artifact: ArtifactRef,
35    pub params_fingerprint: String,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub training_loss_fingerprint: Option<String>,
38    #[serde(default)]
39    pub data_requirement_keys: Vec<String>,
40    #[serde(default)]
41    pub prediction_requirement_keys: Vec<String>,
42}
43
44impl ArtifactInputSpec {
45    pub(crate) fn from_refit_record(record: &RefitArtifactRecord) -> Result<Self> {
46        record.validate()?;
47        Ok(Self {
48            node_id: record.node_id.clone(),
49            controller_id: record.controller_id.clone(),
50            artifact: record.artifact.clone(),
51            params_fingerprint: record.params_fingerprint.clone(),
52            training_loss_fingerprint: record.training_loss_fingerprint.clone(),
53            data_requirement_keys: record.data_requirement_keys.clone(),
54            prediction_requirement_keys: record.prediction_requirement_keys.clone(),
55        })
56    }
57}
58
59pub(crate) fn default_runtime_prediction_level() -> PredictionLevel {
60    PredictionLevel::Sample
61}
62
63#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
64pub struct NodeTask {
65    pub run_id: RunId,
66    pub node_plan: NodePlan,
67    pub phase: Phase,
68    pub variant_id: Option<VariantId>,
69    #[serde(default)]
70    pub variant: Option<VariantExecutionSpec>,
71    pub fold_id: Option<FoldId>,
72    #[serde(default)]
73    pub branch_path: Vec<BranchId>,
74    #[serde(default)]
75    pub input_handles: BTreeMap<String, HandleRef>,
76    #[serde(default)]
77    pub data_views: BTreeMap<String, DataProviderViewSpec>,
78    #[serde(default)]
79    pub prediction_inputs: BTreeMap<String, PredictionInputSpec>,
80    #[serde(default)]
81    pub artifact_inputs: BTreeMap<String, ArtifactInputSpec>,
82    /// Native-produced attestation templates for the training losses that must
83    /// execute in this task. The order matches `node_plan.training_losses`
84    /// after filtering for `phase`. A controller may copy an entry into its
85    /// lineage only after the corresponding local or built-in loss succeeds.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub required_loss_attestations: Vec<LossExecutionAttestation>,
88    /// Nested (inner) CV fold set for this node in the current outer fold, built
89    /// by the runtime from the outer fold's training samples when an effective
90    /// `inner_cv` policy applies (FIT_CV only). `None` otherwise. Leakage-safe by
91    /// construction (inner ⊆ outer-train); see [`crate::fold::NestedCvSpec`].
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub inner_fold_set: Option<FoldSet>,
94    #[serde(default, skip_serializing_if = "FitInfluenceTask::is_default")]
95    pub fit_influence: FitInfluenceTask,
96    pub seed: Option<u64>,
97}
98
99impl NodeTask {
100    pub fn required_loss_attestations_for(
101        node_plan: &NodePlan,
102        phase: Phase,
103    ) -> Result<Vec<LossExecutionAttestation>> {
104        node_plan
105            .training_losses_for_phase(phase)
106            .map(|role| LossExecutionAttestation::for_role(role, phase))
107            .collect()
108    }
109
110    pub fn validate_required_loss_attestations(&self) -> Result<()> {
111        let expected = Self::required_loss_attestations_for(&self.node_plan, self.phase)?;
112        if self.required_loss_attestations != expected {
113            return Err(DagMlError::RuntimeValidation(format!(
114                "task for node `{}` has loss execution requirements that do not match its ordered training losses for phase {:?}",
115                self.node_plan.node_id, self.phase
116            )));
117        }
118        Ok(())
119    }
120
121    /// Return one active training-loss role and its exact native attestation.
122    /// The index addresses losses after filtering the node plan for the task's
123    /// phase, avoiding any host-side reconstruction of role ordering.
124    pub fn training_loss_binding(
125        &self,
126        role_index: usize,
127    ) -> Result<(&TrainingLossRoleReference, &LossExecutionAttestation)> {
128        if !matches!(self.phase, Phase::FitCv | Phase::Refit) {
129            return Err(DagMlError::RuntimeValidation(
130                "training loss phase must be FIT_CV or REFIT".to_string(),
131            ));
132        }
133        self.validate_required_loss_attestations()?;
134        let role = self
135            .node_plan
136            .training_losses_for_phase(self.phase)
137            .nth(role_index)
138            .ok_or_else(|| {
139                DagMlError::RuntimeValidation(format!(
140                    "role_index {role_index} is outside the active training loss range"
141                ))
142            })?;
143        let attestation = self
144            .required_loss_attestations
145            .get(role_index)
146            .ok_or_else(|| {
147                DagMlError::RuntimeValidation(
148                    "validated training loss role has no matching attestation".to_string(),
149                )
150            })?;
151        Ok((role, attestation))
152    }
153}
154
155/// Typed scheduler operation used to create one invocation-local HPO session.
156/// It deliberately contains no graph `NodePlan`: the session controls variant
157/// campaigns for `target_node_id`, while all predictor work remains in the
158/// ordinary graph execution plan.
159#[derive(Clone, Debug, PartialEq)]
160pub struct RuntimeHpoCampaignTask {
161    pub run_id: RunId,
162    pub operation_id: String,
163    pub controller_id: ControllerId,
164    pub target_node_id: NodeId,
165    pub seed: Option<u64>,
166}
167
168/// Immutable coordinator evidence for one execution-local HPO campaign.
169///
170/// This is deliberately passed beside the tuner [`NodeTask`] rather than
171/// stored in the controller registry or serialized through a generic
172/// controller invocation.  A tuner session may retain thread-affine native
173/// state, but this context contains only portable coordinator facts.
174#[derive(Clone, Debug, PartialEq)]
175pub struct RuntimeHpoExecutionContext {
176    /// Stable identity of this scheduler-owned campaign operation.  This is
177    /// deliberately not a graph node: HPO controls variants of a predictor,
178    /// it is not part of the predictor topology.
179    pub operation_id: String,
180    /// Registered controller which owns the invocation-local native session.
181    pub controller_id: ControllerId,
182    /// The model node evaluated for every proposal.
183    pub target_node_id: NodeId,
184    /// The unexpanded variant from which the tuner proposes candidates.
185    pub base_variant: VariantPlan,
186    /// Total native study budget across the initial run and every resumed
187    /// scheduler call. This is never a per-call proposal count.
188    pub trial_budget_total: u32,
189    /// Typed native-study configuration owned by the registered tuner
190    /// controller.  The scheduler never constructs or restores this study.
191    pub study: crate::hpo::MethodsHpoStudyConfig,
192    /// Native search-space output name -> direct target-model parameter key.
193    /// The controller converts each proposal through this explicit mapping;
194    /// the scheduler only receives the resulting normal [`VariantPlan`].
195    pub parameter_paths: BTreeMap<String, String>,
196    /// Optional opaque native state from a prior compatible campaign.  It is
197    /// consumed only by the controller-local session factory and is never
198    /// sent to scheduler workers.
199    pub resume_checkpoint: Option<crate::hpo::N4moptCheckpointArtifact>,
200    /// Completed scheduler proposals restored with the opaque optimizer. They
201    /// give a native `best()` result its stable variant identity on resume.
202    pub resume_variants: BTreeMap<i64, VariantId>,
203    /// Full controller-attested native terminal ledger from the package being
204    /// restored. The session compares this before it may ask another trial.
205    pub resume_terminal_trials: Vec<RuntimeHpoTerminalSnapshot>,
206    /// The OOF report produced by the scheduler which is fed back to the
207    /// session after each candidate evaluation.
208    pub selection: RuntimeHpoSelectionTarget,
209    /// Immutable fingerprints which bind this campaign to the already
210    /// attested plan, data identities, fold set and influence manifest.
211    pub provenance: RuntimeHpoProvenance,
212}
213
214#[derive(Clone, Debug, PartialEq)]
215pub struct RuntimeHpoSelectionTarget {
216    pub producer_node: NodeId,
217    pub producer_port: String,
218    pub metric: RegressionMetricKind,
219    pub direction: crate::hpo::HpoDirection,
220}
221
222#[derive(Clone, Debug, PartialEq)]
223pub struct RuntimeHpoProvenance {
224    pub graph_fingerprint: String,
225    pub campaign_fingerprint: String,
226    pub controller_fingerprint: String,
227    pub data_identities_fingerprint: String,
228    pub fold_set_fingerprint: Option<String>,
229    pub training_influence_fingerprint: String,
230    pub relation_fingerprint: String,
231}
232
233impl RuntimeHpoExecutionContext {
234    pub fn validate_for_plan(&self, plan: &ExecutionPlan) -> Result<()> {
235        if self.trial_budget_total == 0 {
236            return Err(DagMlError::RuntimeValidation(
237                "runtime HPO trial_budget_total must be positive".to_string(),
238            ));
239        }
240        if self.study.controller_id.trim().is_empty()
241            || self.study.study_id.trim().is_empty()
242            || self.study.methods_abi.trim().is_empty()
243        {
244            return Err(DagMlError::RuntimeValidation(
245                "runtime HPO study identity must not be empty".to_string(),
246            ));
247        }
248        self.study.search_space.validate().map_err(|error| {
249            DagMlError::RuntimeValidation(format!("runtime HPO search space is invalid: {error}"))
250        })?;
251        if self.parameter_paths.is_empty()
252            || self.parameter_paths.keys().any(|key| key.trim().is_empty())
253            || self
254                .parameter_paths
255                .values()
256                .any(|value| value.trim().is_empty())
257            || self.parameter_paths.values().collect::<BTreeSet<_>>().len()
258                != self.parameter_paths.len()
259        {
260            return Err(DagMlError::RuntimeValidation(
261                "runtime HPO parameter_paths must be non-empty and map each target parameter once"
262                    .to_string(),
263            ));
264        }
265        if let Some(checkpoint) = &self.resume_checkpoint {
266            checkpoint.validate().map_err(|error| {
267                DagMlError::RuntimeValidation(format!(
268                    "runtime HPO resume checkpoint is invalid: {error}"
269                ))
270            })?;
271        }
272        if self.operation_id.trim().is_empty() {
273            return Err(DagMlError::RuntimeValidation(
274                "runtime HPO operation_id must not be empty".to_string(),
275            ));
276        }
277        if self.selection.producer_port.trim().is_empty() {
278            return Err(DagMlError::RuntimeValidation(
279                "runtime HPO selection producer_port must not be empty".to_string(),
280            ));
281        }
282        if self.selection.producer_node != self.target_node_id {
283            return Err(DagMlError::RuntimeValidation(
284                "runtime HPO selection producer must be the evaluated target node".to_string(),
285            ));
286        }
287        if self.study.controller_id != self.controller_id.as_str() {
288            return Err(DagMlError::RuntimeValidation(format!(
289                "runtime HPO study controller `{}` does not match campaign controller `{}`",
290                self.study.controller_id, self.controller_id
291            )));
292        }
293        let target = plan.node_plans.get(&self.target_node_id).ok_or_else(|| {
294            DagMlError::RuntimeValidation(format!(
295                "runtime HPO target node `{}` is absent from the execution plan",
296                self.target_node_id
297            ))
298        })?;
299        if target.kind != crate::graph::NodeKind::Model {
300            return Err(DagMlError::RuntimeValidation(format!(
301                "runtime HPO target `{}` must be a model node",
302                self.target_node_id
303            )));
304        }
305        if !plan
306            .variants
307            .iter()
308            .any(|variant| variant == &self.base_variant)
309        {
310            return Err(DagMlError::RuntimeValidation(
311                "runtime HPO base_variant is not present in the execution plan".to_string(),
312            ));
313        }
314        self.provenance.validate_for_plan(plan)
315    }
316}
317
318impl RuntimeHpoProvenance {
319    pub fn validate_for_plan(&self, plan: &ExecutionPlan) -> Result<()> {
320        let campaign_fingerprint = crate::hpo::campaign_provenance_fingerprint(&plan.campaign)?;
321        for (label, actual, expected) in [
322            (
323                "graph",
324                plan.graph_fingerprint.as_str(),
325                self.graph_fingerprint.as_str(),
326            ),
327            (
328                "campaign",
329                campaign_fingerprint.as_str(),
330                self.campaign_fingerprint.as_str(),
331            ),
332            (
333                "controller",
334                plan.controller_fingerprint.as_str(),
335                self.controller_fingerprint.as_str(),
336            ),
337        ] {
338            if expected.trim().is_empty() || actual != expected {
339                return Err(DagMlError::RuntimeValidation(format!(
340                    "runtime HPO provenance does not match the execution-plan {label} fingerprint"
341                )));
342            }
343        }
344        for (label, value) in [
345            ("data identities", self.data_identities_fingerprint.as_str()),
346            (
347                "training influence",
348                self.training_influence_fingerprint.as_str(),
349            ),
350            ("relation", self.relation_fingerprint.as_str()),
351        ] {
352            if value.trim().is_empty() {
353                return Err(DagMlError::RuntimeValidation(format!(
354                    "runtime HPO provenance has an empty {label} fingerprint"
355                )));
356            }
357        }
358        let actual_fold_fingerprint = plan
359            .fold_set
360            .as_ref()
361            .map(stable_json_fingerprint)
362            .transpose()?;
363        if self.fold_set_fingerprint != actual_fold_fingerprint {
364            return Err(DagMlError::RuntimeValidation(
365                "runtime HPO provenance does not match the execution-plan fold set".to_string(),
366            ));
367        }
368        Ok(())
369    }
370}
371
372/// One candidate proposed by a local tuner session.  It contains a normal
373/// plan variant only; no native optimizer/context can cross the scheduler
374/// boundary.
375#[derive(Clone, Debug, PartialEq)]
376pub struct RuntimeHpoProposal {
377    pub trial_id: i64,
378    pub variant: VariantPlan,
379}
380
381#[derive(Clone, Debug, PartialEq)]
382pub struct RuntimeHpoIntermediate {
383    pub trial_id: i64,
384    pub step: i32,
385    pub score: f64,
386}
387
388#[derive(Clone, Debug, Eq, PartialEq)]
389pub struct RuntimeHpoFailure {
390    pub code: String,
391    pub message: String,
392    pub retryable: bool,
393}
394
395#[derive(Clone, Debug, PartialEq)]
396pub enum RuntimeHpoTerminal {
397    Completed { score: f64 },
398    Failed { failure: RuntimeHpoFailure },
399}
400
401#[derive(Clone, Copy, Debug, Eq, PartialEq)]
402pub enum RuntimeHpoIntermediateOutcome {
403    Continue,
404    Pruned,
405}
406
407/// Scheduler-owned evidence retained for a successfully completed trial.
408/// Candidate OOF data remains report-only and is never reused as a training
409/// input; final SELECT/REFIT is intentionally outside this campaign call.
410#[derive(Clone, Debug)]
411pub struct RuntimeHpoCandidateEvaluation {
412    pub proposal: RuntimeHpoProposal,
413    pub score: f64,
414    pub validation_reports: Vec<RegressionMetricReport>,
415    pub validation_predictions: VariantValidationPredictions,
416    pub lineage: Vec<LineageRecord>,
417}
418
419/// The one cross-fold OOF report which terminalized a completed native trial.
420/// Keeping the native trial id beside report-grade scheduler evidence makes a
421/// checkpoint resumable without asking libn4m to invent coordinator scores.
422#[derive(Clone, Debug)]
423pub struct RuntimeHpoCompletedReport {
424    pub trial_id: i64,
425    pub variant_id: VariantId,
426    pub report: RegressionMetricReport,
427}
428
429/// Durable native optimizer state paired with exact coordinator evidence. No
430/// native Context or Optimizer object crosses this boundary.
431#[derive(Clone, Debug)]
432pub struct RuntimeHpoCheckpointResult {
433    pub artifact: crate::hpo::N4moptCheckpointArtifact,
434    pub provenance: RuntimeHpoProvenance,
435    pub operation_id: String,
436    pub controller_id: ControllerId,
437    pub target_node_id: NodeId,
438    /// Exact completed proposals, including their patched model parameters and
439    /// content fingerprints. A resumed coordinator must consume these values
440    /// directly; reconstructing trial variants from a checkpoint is refused.
441    pub completed_proposals: Vec<RuntimeHpoProposal>,
442    pub completed_reports: Vec<RuntimeHpoCompletedReport>,
443    /// Native study trial count after this scheduler call, including opaque
444    /// historical failed/pruned trials restored by the local session.
445    pub trial_history_len: u32,
446}
447
448/// One typed native incumbent, derived from the optimizer's `best()` only
449/// after every scheduler-observed proposal has reached a terminal state.
450#[derive(Clone, Debug, PartialEq)]
451pub struct RuntimeHpoIncumbent {
452    pub trial_id: i64,
453    pub score: f64,
454    pub metric: String,
455    pub direction: crate::hpo::HpoDirection,
456    pub variant_id: VariantId,
457}
458
459/// A controller-attested terminal native trial.  The native record is never
460/// reconstructed from checkpoint bytes by DAG-ML; it is obtained only from
461/// the invocation-local session which owns that checkpoint.
462#[derive(Clone, Debug, PartialEq)]
463pub struct RuntimeHpoTerminalSnapshot {
464    pub trial: crate::hpo::HpoTrial,
465    pub variant_id: Option<VariantId>,
466}
467
468#[derive(Clone, Debug)]
469pub struct RuntimeHpoCampaignResult {
470    pub operation_id: String,
471    pub controller_id: ControllerId,
472    pub target_node_id: NodeId,
473    pub candidates: Vec<RuntimeHpoCandidateEvaluation>,
474    /// Emitted only after all proposed trials have a scheduler-observed
475    /// terminal state and their report evidence validates.
476    pub checkpoint: RuntimeHpoCheckpointResult,
477    pub incumbent: RuntimeHpoIncumbent,
478    pub terminal_trials: Vec<RuntimeHpoTerminalSnapshot>,
479}
480
481#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
482#[serde(rename_all = "snake_case")]
483pub enum FitInfluenceMechanism {
484    UniformRows,
485    SampleWeights,
486    RowResampling,
487    BackendLossWeights,
488    ScorerOnly,
489}
490
491#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
492pub struct FitInfluenceTask {
493    pub requested_policy: FitInfluencePolicy,
494    pub effective_policy: FitInfluencePolicy,
495    pub mechanism: FitInfluenceMechanism,
496    #[serde(default, skip_serializing_if = "Vec::is_empty")]
497    pub row_weights: Vec<f64>,
498    #[serde(default, skip_serializing_if = "Vec::is_empty")]
499    pub warnings: Vec<String>,
500}
501
502impl Default for FitInfluenceTask {
503    fn default() -> Self {
504        Self {
505            requested_policy: FitInfluencePolicy::UniformRows,
506            effective_policy: FitInfluencePolicy::UniformRows,
507            mechanism: FitInfluenceMechanism::UniformRows,
508            row_weights: Vec::new(),
509            warnings: Vec::new(),
510        }
511    }
512}
513
514impl FitInfluenceTask {
515    fn is_default(&self) -> bool {
516        self == &Self::default()
517    }
518
519    pub fn diagnostic(&self) -> FitInfluenceDiagnostic {
520        FitInfluenceDiagnostic {
521            requested_policy: self.requested_policy,
522            effective_policy: self.effective_policy,
523            mechanism: self.mechanism,
524            fallback_used: !self.warnings.is_empty(),
525            row_weight_count: self.row_weights.len(),
526            warnings: self.warnings.clone(),
527        }
528    }
529
530    pub fn validate(&self) -> Result<()> {
531        if !self
532            .row_weights
533            .iter()
534            .all(|weight| weight.is_finite() && *weight > 0.0)
535        {
536            return Err(DagMlError::RuntimeValidation(
537                "fit influence row_weights must be finite and > 0".to_string(),
538            ));
539        }
540        if self
541            .warnings
542            .iter()
543            .any(|warning| warning.trim().is_empty())
544        {
545            return Err(DagMlError::RuntimeValidation(
546                "fit influence warnings must not be empty".to_string(),
547            ));
548        }
549        match self.effective_policy {
550            FitInfluencePolicy::EqualSampleInfluence | FitInfluencePolicy::BackendLossWeight
551                if self.row_weights.is_empty() =>
552            {
553                return Err(DagMlError::RuntimeValidation(format!(
554                    "fit influence {:?} requires row_weights",
555                    self.effective_policy
556                )));
557            }
558            _ => {}
559        }
560        if self.requested_policy == FitInfluencePolicy::StrictWeightSupport
561            && self.effective_policy == FitInfluencePolicy::UniformRows
562        {
563            return Err(DagMlError::RuntimeValidation(
564                "strict fit influence cannot fall back to uniform_rows".to_string(),
565            ));
566        }
567        Ok(())
568    }
569}
570
571#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
572#[serde(deny_unknown_fields)]
573pub struct FitInfluenceDiagnostic {
574    pub requested_policy: FitInfluencePolicy,
575    pub effective_policy: FitInfluencePolicy,
576    pub mechanism: FitInfluenceMechanism,
577    #[serde(default)]
578    pub fallback_used: bool,
579    #[serde(default)]
580    pub row_weight_count: usize,
581    #[serde(default, skip_serializing_if = "Vec::is_empty")]
582    pub warnings: Vec<String>,
583}
584
585impl FitInfluenceDiagnostic {
586    pub fn validate(&self, task: &NodeTask) -> Result<()> {
587        if self.requested_policy != task.fit_influence.requested_policy {
588            return Err(DagMlError::RuntimeValidation(format!(
589                "fit influence diagnostic requested_policy {:?} does not match task {:?}",
590                self.requested_policy, task.fit_influence.requested_policy
591            )));
592        }
593        if self.effective_policy != task.fit_influence.effective_policy {
594            return Err(DagMlError::RuntimeValidation(format!(
595                "fit influence diagnostic effective_policy {:?} does not match task {:?}",
596                self.effective_policy, task.fit_influence.effective_policy
597            )));
598        }
599        if self.mechanism != task.fit_influence.mechanism {
600            return Err(DagMlError::RuntimeValidation(format!(
601                "fit influence diagnostic mechanism {:?} does not match task {:?}",
602                self.mechanism, task.fit_influence.mechanism
603            )));
604        }
605        if self.row_weight_count != task.fit_influence.row_weights.len() {
606            return Err(DagMlError::RuntimeValidation(format!(
607                "fit influence diagnostic row_weight_count {} does not match task {}",
608                self.row_weight_count,
609                task.fit_influence.row_weights.len()
610            )));
611        }
612        if self.fallback_used == task.fit_influence.warnings.is_empty() {
613            return Err(DagMlError::RuntimeValidation(
614                "fit influence diagnostic fallback_used does not match task warnings".to_string(),
615            ));
616        }
617        if self.warnings != task.fit_influence.warnings {
618            return Err(DagMlError::RuntimeValidation(
619                "fit influence diagnostic warnings do not match task warnings".to_string(),
620            ));
621        }
622        if self
623            .warnings
624            .iter()
625            .any(|warning| warning.trim().is_empty())
626        {
627            return Err(DagMlError::RuntimeValidation(
628                "fit influence diagnostic warnings must not be empty".to_string(),
629            ));
630        }
631        Ok(())
632    }
633}
634
635#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
636pub struct VariantExecutionSpec {
637    pub variant_id: VariantId,
638    #[serde(default)]
639    pub choices: BTreeMap<String, GenerationChoice>,
640    pub fingerprint: String,
641    pub seed: Option<u64>,
642}
643
644impl VariantExecutionSpec {
645    pub fn from_plan(variant: &VariantPlan) -> Self {
646        Self {
647            variant_id: variant.variant_id.clone(),
648            choices: variant.choices.clone(),
649            fingerprint: variant.fingerprint.clone(),
650            seed: variant.seed,
651        }
652    }
653
654    pub fn validate(&self) -> Result<()> {
655        if self.fingerprint.trim().is_empty() {
656            return Err(DagMlError::RuntimeValidation(format!(
657                "variant `{}` has an empty fingerprint in task context",
658                self.variant_id
659            )));
660        }
661        for (dimension_name, choice) in &self.choices {
662            if dimension_name.trim().is_empty() {
663                return Err(DagMlError::RuntimeValidation(format!(
664                    "variant `{}` has an empty generation dimension name",
665                    self.variant_id
666                )));
667            }
668            if choice.label.trim().is_empty() {
669                return Err(DagMlError::RuntimeValidation(format!(
670                    "variant `{}` has an empty choice label for dimension `{dimension_name}`",
671                    self.variant_id
672                )));
673            }
674            for override_spec in &choice.param_overrides {
675                if override_spec.params.is_empty() {
676                    return Err(DagMlError::RuntimeValidation(format!(
677                        "variant `{}` has an empty param override for node `{}`",
678                        self.variant_id, override_spec.node_id
679                    )));
680                }
681                for param_key in override_spec.params.keys() {
682                    if param_key.trim().is_empty() {
683                        return Err(DagMlError::RuntimeValidation(format!(
684                            "variant `{}` has an empty param override key for node `{}`",
685                            self.variant_id, override_spec.node_id
686                        )));
687                    }
688                }
689            }
690        }
691        self.param_overrides_by_node()?;
692        Ok(())
693    }
694
695    pub fn effective_params_for_node(
696        &self,
697        node_id: &NodeId,
698        base_params: &BTreeMap<String, serde_json::Value>,
699    ) -> Result<BTreeMap<String, serde_json::Value>> {
700        let overrides_by_node = self.param_overrides_by_node()?;
701        let Some(overrides) = overrides_by_node.get(node_id) else {
702            return Ok(base_params.clone());
703        };
704        let mut params = base_params.clone();
705        params.extend(overrides.clone());
706        Ok(params)
707    }
708
709    fn param_overrides_by_node(
710        &self,
711    ) -> Result<BTreeMap<NodeId, BTreeMap<String, serde_json::Value>>> {
712        let mut overrides = BTreeMap::<NodeId, BTreeMap<String, serde_json::Value>>::new();
713        let mut owners = BTreeMap::<(NodeId, String), String>::new();
714        for (dimension_name, choice) in &self.choices {
715            for override_spec in &choice.param_overrides {
716                for (param_key, value) in &override_spec.params {
717                    let owner_key = (override_spec.node_id.clone(), param_key.clone());
718                    if let Some(previous) =
719                        owners.insert(owner_key, format!("{dimension_name}:{}", choice.label))
720                    {
721                        return Err(DagMlError::RuntimeValidation(format!(
722                            "variant `{}` has conflicting generation overrides for `{}.{}` from `{previous}` and `{}:{}`",
723                            self.variant_id,
724                            override_spec.node_id,
725                            param_key,
726                            dimension_name,
727                            choice.label
728                        )));
729                    }
730                    overrides
731                        .entry(override_spec.node_id.clone())
732                        .or_default()
733                        .insert(param_key.clone(), value.clone());
734                }
735            }
736        }
737        Ok(overrides)
738    }
739}
740
741/// An EXPLAIN-phase output block (ADR-12 explain contract). Explanations are a
742/// node *output* returned in the [`NodeResult`] — like predictions, they cross as
743/// data, not as an opaque host handle. The `payload` shape is controller-defined
744/// (e.g. per-feature importances); the core does not interpret it. Explanations
745/// are only valid in the `EXPLAIN` phase.
746#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
747#[serde(deny_unknown_fields)]
748pub struct ExplanationBlock {
749    /// Node whose model the explanation describes (must equal the producing node).
750    pub producer_node: NodeId,
751    #[serde(default, skip_serializing_if = "Option::is_none")]
752    pub producer_port: Option<String>,
753    /// Stable explanation method identifier, e.g. `shap`, `permutation_importance`.
754    pub method: String,
755    /// Optional target/output name the explanation pertains to.
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    pub target_name: Option<String>,
758    /// Controller-defined explanation payload as canonical JSON.
759    pub payload: serde_json::Value,
760}
761
762impl ExplanationBlock {
763    /// Validate the intrinsic shape of the explanation block (method/target_name
764    /// non-empty). Producer identity is checked against the node in
765    /// [`NodeResult::validate_for_task`].
766    pub fn validate(&self) -> Result<()> {
767        if self.method.trim().is_empty() {
768            return Err(DagMlError::RuntimeValidation(
769                "explanation method must be a non-empty identifier".to_string(),
770            ));
771        }
772        if let Some(name) = &self.target_name {
773            if name.trim().is_empty() {
774                return Err(DagMlError::RuntimeValidation(
775                    "explanation target_name must be non-empty when present".to_string(),
776                ));
777            }
778        }
779        Ok(())
780    }
781}
782
783#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
784#[serde(deny_unknown_fields)]
785pub struct NodeResult {
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    pub schema_version: Option<u32>,
788    pub node_id: NodeId,
789    #[serde(default)]
790    pub outputs: BTreeMap<String, HandleRef>,
791    #[serde(default)]
792    pub predictions: Vec<PredictionBlock>,
793    #[serde(default)]
794    pub observation_predictions: Vec<ObservationPredictionBlock>,
795    #[serde(default)]
796    pub aggregated_predictions: Vec<AggregatedPredictionBlock>,
797    #[serde(default)]
798    pub explanations: Vec<ExplanationBlock>,
799    #[serde(default)]
800    pub shape_deltas: Vec<ShapeDelta>,
801    #[serde(default)]
802    pub artifacts: Vec<ArtifactRef>,
803    #[serde(default)]
804    pub artifact_handles: BTreeMap<ArtifactId, HandleRef>,
805    #[serde(default, skip_serializing_if = "Vec::is_empty")]
806    pub fit_influence_diagnostics: Vec<FitInfluenceDiagnostic>,
807    /// Optional ground-truth targets the host controller emits alongside predictions so the core
808    /// can score natively (the runtime never sees feature matrices; `y_true` is data-tier and may
809    /// cross the ABI per the ownership table). Each block is identity-keyed by `unit_ids`.
810    #[serde(default, skip_serializing_if = "Vec::is_empty")]
811    pub regression_targets: Vec<RegressionTargetBlock>,
812    pub lineage: LineageRecord,
813}
814
815impl NodeResult {
816    pub fn validate_for_task(&self, task: &NodeTask) -> Result<()> {
817        if self.node_id != task.node_plan.node_id {
818            return Err(DagMlError::RuntimeValidation(format!(
819                "task for `{}` returned result for `{}`",
820                task.node_plan.node_id, self.node_id
821            )));
822        }
823        if self.lineage.node_id != task.node_plan.node_id {
824            return Err(DagMlError::RuntimeValidation(format!(
825                "lineage for task `{}` references node `{}`",
826                task.node_plan.node_id, self.lineage.node_id
827            )));
828        }
829        if self.lineage.phase != task.phase {
830            return Err(DagMlError::RuntimeValidation(format!(
831                "lineage for node `{}` has phase {:?}, expected {:?}",
832                task.node_plan.node_id, self.lineage.phase, task.phase
833            )));
834        }
835        if self.lineage.run_id != task.run_id {
836            return Err(DagMlError::RuntimeValidation(format!(
837                "lineage for node `{}` has run `{}`, expected `{}`",
838                task.node_plan.node_id, self.lineage.run_id, task.run_id
839            )));
840        }
841        if self.lineage.controller_id != task.node_plan.controller_id {
842            return Err(DagMlError::RuntimeValidation(format!(
843                "lineage for node `{}` has controller `{}`, expected `{}`",
844                task.node_plan.node_id, self.lineage.controller_id, task.node_plan.controller_id
845            )));
846        }
847        if self.lineage.controller_version != task.node_plan.controller_version {
848            return Err(DagMlError::RuntimeValidation(format!(
849                "lineage for node `{}` has controller version `{}`, expected `{}`",
850                task.node_plan.node_id,
851                self.lineage.controller_version,
852                task.node_plan.controller_version
853            )));
854        }
855        if self.lineage.variant_id != task.variant_id {
856            return Err(DagMlError::RuntimeValidation(format!(
857                "lineage for node `{}` has variant {:?}, expected {:?}",
858                task.node_plan.node_id, self.lineage.variant_id, task.variant_id
859            )));
860        }
861        if let Some(variant) = &task.variant {
862            variant.validate()?;
863            if Some(&variant.variant_id) != task.variant_id.as_ref() {
864                return Err(DagMlError::RuntimeValidation(format!(
865                    "task for node `{}` has variant context `{}` but variant_id {:?}",
866                    task.node_plan.node_id, variant.variant_id, task.variant_id
867                )));
868            }
869        }
870        if self.lineage.fold_id != task.fold_id {
871            return Err(DagMlError::RuntimeValidation(format!(
872                "lineage for node `{}` has fold {:?}, expected {:?}",
873                task.node_plan.node_id, self.lineage.fold_id, task.fold_id
874            )));
875        }
876        if self.lineage.branch_path != task.branch_path {
877            return Err(DagMlError::RuntimeValidation(format!(
878                "lineage for node `{}` has branch path {:?}, expected {:?}",
879                task.node_plan.node_id, self.lineage.branch_path, task.branch_path
880            )));
881        }
882        if self.lineage.seed != task.seed {
883            return Err(DagMlError::RuntimeValidation(format!(
884                "lineage for node `{}` has seed {:?}, expected {:?}",
885                task.node_plan.node_id, self.lineage.seed, task.seed
886            )));
887        }
888        if self.lineage.params_fingerprint != task.node_plan.params_fingerprint {
889            return Err(DagMlError::RuntimeValidation(format!(
890                "lineage for node `{}` has params fingerprint `{}`, expected `{}`",
891                task.node_plan.node_id,
892                self.lineage.params_fingerprint,
893                task.node_plan.params_fingerprint
894            )));
895        }
896        task.validate_required_loss_attestations()?;
897        let expected_losses = task
898            .node_plan
899            .training_losses_for_phase(task.phase)
900            .collect::<Vec<_>>();
901        if self.lineage.loss_attestations.len() != expected_losses.len() {
902            return Err(DagMlError::RuntimeValidation(format!(
903                "node `{}` returned {} loss attestations for {} resolved losses in phase {:?}",
904                task.node_plan.node_id,
905                self.lineage.loss_attestations.len(),
906                expected_losses.len(),
907                task.phase
908            )));
909        }
910        for (attestation, role) in self.lineage.loss_attestations.iter().zip(expected_losses) {
911            attestation.validate_against(role, &task.node_plan.node_id, task.phase)?;
912        }
913        if self.lineage.loss_attestations != task.required_loss_attestations {
914            return Err(DagMlError::RuntimeValidation(format!(
915                "node `{}` returned loss attestations that do not match the task requirements",
916                task.node_plan.node_id
917            )));
918        }
919        task.fit_influence.validate()?;
920        for diagnostic in &self.fit_influence_diagnostics {
921            diagnostic.validate(task)?;
922        }
923        validate_lineage_shape_fingerprints(&self.lineage, task)?;
924        if !self.explanations.is_empty() && task.phase != Phase::Explain {
925            return Err(DagMlError::RuntimeValidation(format!(
926                "node `{}` returned explanations outside the EXPLAIN phase",
927                task.node_plan.node_id
928            )));
929        }
930        for explanation in &self.explanations {
931            explanation.validate()?;
932            if explanation.producer_node != self.node_id {
933                return Err(DagMlError::RuntimeValidation(format!(
934                    "node `{}` returned an explanation produced by `{}`",
935                    self.node_id, explanation.producer_node
936                )));
937            }
938        }
939        for (port, handle) in &self.outputs {
940            if handle.owner_controller != task.node_plan.controller_id {
941                return Err(DagMlError::RuntimeValidation(format!(
942                    "node `{}` output `{port}` is owned by `{}`, expected `{}`",
943                    task.node_plan.node_id, handle.owner_controller, task.node_plan.controller_id
944                )));
945            }
946        }
947        let mut artifact_ids = BTreeSet::new();
948        for artifact in &self.artifacts {
949            artifact.validate()?;
950            if !artifact_ids.insert(artifact.id.clone()) {
951                return Err(DagMlError::RuntimeValidation(format!(
952                    "node `{}` emitted duplicate artifact `{}`",
953                    task.node_plan.node_id, artifact.id
954                )));
955            }
956            if artifact.controller_id != task.node_plan.controller_id {
957                return Err(DagMlError::RuntimeValidation(format!(
958                    "node `{}` emitted artifact `{}` for controller `{}`, expected `{}`",
959                    task.node_plan.node_id,
960                    artifact.id,
961                    artifact.controller_id,
962                    task.node_plan.controller_id
963                )));
964            }
965            let handle = self.artifact_handles.get(&artifact.id).ok_or_else(|| {
966                DagMlError::RuntimeValidation(format!(
967                    "node `{}` emitted artifact `{}` without artifact handle",
968                    task.node_plan.node_id, artifact.id
969                ))
970            })?;
971            if !matches!(handle.kind, HandleKind::Model | HandleKind::Artifact) {
972                return Err(DagMlError::RuntimeValidation(format!(
973                    "node `{}` emitted artifact `{}` with non-artifact/model handle kind {:?}",
974                    task.node_plan.node_id, artifact.id, handle.kind
975                )));
976            }
977            if handle.owner_controller != task.node_plan.controller_id {
978                return Err(DagMlError::RuntimeValidation(format!(
979                    "node `{}` emitted artifact `{}` owned by `{}`, expected `{}`",
980                    task.node_plan.node_id,
981                    artifact.id,
982                    handle.owner_controller,
983                    task.node_plan.controller_id
984                )));
985            }
986        }
987        for artifact_id in self.artifact_handles.keys() {
988            if !self
989                .artifacts
990                .iter()
991                .any(|artifact| &artifact.id == artifact_id)
992            {
993                return Err(DagMlError::RuntimeValidation(format!(
994                    "node `{}` emitted artifact handle for undeclared artifact `{artifact_id}`",
995                    task.node_plan.node_id
996                )));
997            }
998        }
999        for artifact in &self.artifacts {
1000            if !self
1001                .lineage
1002                .artifact_refs
1003                .iter()
1004                .any(|lineage_artifact| lineage_artifact == artifact)
1005            {
1006                return Err(DagMlError::RuntimeValidation(format!(
1007                    "node `{}` emitted artifact `{}` without matching lineage artifact ref",
1008                    task.node_plan.node_id, artifact.id
1009                )));
1010            }
1011        }
1012        for artifact in &self.lineage.artifact_refs {
1013            if !self
1014                .artifacts
1015                .iter()
1016                .any(|emitted_artifact| emitted_artifact == artifact)
1017            {
1018                return Err(DagMlError::RuntimeValidation(format!(
1019                    "node `{}` lineage references undeclared artifact `{}`",
1020                    task.node_plan.node_id, artifact.id
1021                )));
1022            }
1023        }
1024        for prediction in &self.predictions {
1025            prediction.validate_shape()?;
1026            if prediction.producer_node != task.node_plan.node_id {
1027                return Err(DagMlError::RuntimeValidation(format!(
1028                    "node `{}` emitted prediction for producer `{}`",
1029                    task.node_plan.node_id, prediction.producer_node
1030                )));
1031            }
1032            validate_prediction_scope(prediction, task)?;
1033        }
1034        for prediction in &self.observation_predictions {
1035            prediction.validate_shape()?;
1036            if prediction.producer_node != task.node_plan.node_id {
1037                return Err(DagMlError::RuntimeValidation(format!(
1038                    "node `{}` emitted observation prediction for producer `{}`",
1039                    task.node_plan.node_id, prediction.producer_node
1040                )));
1041            }
1042            validate_observation_prediction_scope(prediction, task)?;
1043        }
1044        for prediction in &self.aggregated_predictions {
1045            prediction.validate_shape()?;
1046            if prediction.producer_node != task.node_plan.node_id {
1047                return Err(DagMlError::RuntimeValidation(format!(
1048                    "node `{}` emitted aggregated prediction for producer `{}`",
1049                    task.node_plan.node_id, prediction.producer_node
1050                )));
1051            }
1052            validate_aggregated_prediction_scope(prediction, task)?;
1053        }
1054        for delta in &self.shape_deltas {
1055            delta.validate()?;
1056            if delta.node_id != task.node_plan.node_id {
1057                return Err(DagMlError::RuntimeValidation(format!(
1058                    "node `{}` emitted shape delta for `{}`",
1059                    task.node_plan.node_id, delta.node_id
1060                )));
1061            }
1062            validate_shape_delta_for_task(delta, task)?;
1063        }
1064        for target in &self.regression_targets {
1065            target.validate_shape()?;
1066        }
1067        self.lineage.validate()
1068    }
1069}
1070
1071pub(crate) fn validate_lineage_shape_fingerprints(
1072    lineage: &LineageRecord,
1073    task: &NodeTask,
1074) -> Result<()> {
1075    let Some(shape_plan) = &task.node_plan.shape_plan else {
1076        if lineage.data_model_shape_fingerprint.is_some()
1077            || lineage.aggregation_policy_fingerprint.is_some()
1078        {
1079            return Err(DagMlError::RuntimeValidation(format!(
1080                "lineage for node `{}` carries shape fingerprints but the node has no shape plan",
1081                task.node_plan.node_id
1082            )));
1083        }
1084        return Ok(());
1085    };
1086
1087    if let Some(actual) = &lineage.data_model_shape_fingerprint {
1088        let expected = stable_json_fingerprint(shape_plan)?;
1089        if actual != &expected {
1090            return Err(DagMlError::RuntimeValidation(format!(
1091                "lineage for node `{}` has data/model shape fingerprint `{actual}`, expected `{expected}`",
1092                task.node_plan.node_id
1093            )));
1094        }
1095    }
1096    if let Some(actual) = &lineage.aggregation_policy_fingerprint {
1097        let expected = stable_json_fingerprint(&shape_plan.aggregation_policy)?;
1098        if actual != &expected {
1099            return Err(DagMlError::RuntimeValidation(format!(
1100                "lineage for node `{}` has aggregation policy fingerprint `{actual}`, expected `{expected}`",
1101                task.node_plan.node_id
1102            )));
1103        }
1104    }
1105    Ok(())
1106}
1107
1108pub(crate) fn validate_shape_delta_for_task(delta: &ShapeDelta, task: &NodeTask) -> Result<()> {
1109    let Some(shape_plan) = &task.node_plan.shape_plan else {
1110        return Ok(());
1111    };
1112    if delta.kind == ShapeDeltaKind::Feature {
1113        if let Some(expected) = &shape_plan.feature_schema_fingerprint {
1114            if &delta.before_fingerprint != expected {
1115                return Err(DagMlError::RuntimeValidation(format!(
1116                    "node `{}` emitted feature shape delta from `{}`, expected current schema `{expected}`",
1117                    task.node_plan.node_id, delta.before_fingerprint
1118                )));
1119            }
1120        }
1121    }
1122    Ok(())
1123}
1124
1125pub(crate) fn validate_prediction_scope(
1126    prediction: &PredictionBlock,
1127    task: &NodeTask,
1128) -> Result<()> {
1129    if prediction.partition != PredictionPartition::Validation {
1130        return Ok(());
1131    }
1132    if prediction.fold_id != task.fold_id {
1133        return Err(DagMlError::RuntimeValidation(format!(
1134            "node `{}` emitted validation predictions for fold {:?}, expected {:?}",
1135            task.node_plan.node_id, prediction.fold_id, task.fold_id
1136        )));
1137    }
1138    if task.phase == Phase::FitCv
1139        && task.fold_id.is_some()
1140        && (!task.node_plan.data_bindings.is_empty() || !task.data_views.is_empty())
1141    {
1142        let validation_sample_ids = validation_view_sample_ids(task).ok_or_else(|| {
1143            DagMlError::RuntimeValidation(format!(
1144                "node `{}` emitted validation predictions without a fold-validation data view",
1145                task.node_plan.node_id
1146            ))
1147        })?;
1148        for sample_id in &prediction.sample_ids {
1149            if !validation_sample_ids.contains(sample_id) {
1150                return Err(DagMlError::RuntimeValidation(format!(
1151                    "node `{}` emitted validation prediction for sample `{}` outside its validation view",
1152                    task.node_plan.node_id, sample_id
1153                )));
1154            }
1155        }
1156    }
1157    Ok(())
1158}
1159
1160pub(crate) fn validate_observation_prediction_scope(
1161    prediction: &ObservationPredictionBlock,
1162    task: &NodeTask,
1163) -> Result<()> {
1164    if prediction.partition != PredictionPartition::Validation {
1165        return Ok(());
1166    }
1167    if prediction.fold_id != task.fold_id {
1168        return Err(DagMlError::RuntimeValidation(format!(
1169            "node `{}` emitted observation validation predictions for fold {:?}, expected {:?}",
1170            task.node_plan.node_id, prediction.fold_id, task.fold_id
1171        )));
1172    }
1173    Ok(())
1174}
1175
1176pub(crate) fn validate_aggregated_prediction_scope(
1177    prediction: &AggregatedPredictionBlock,
1178    task: &NodeTask,
1179) -> Result<()> {
1180    if prediction.partition != PredictionPartition::Validation {
1181        return Ok(());
1182    }
1183    if prediction.fold_id != task.fold_id {
1184        return Err(DagMlError::RuntimeValidation(format!(
1185            "node `{}` emitted aggregated validation predictions for fold {:?}, expected {:?}",
1186            task.node_plan.node_id, prediction.fold_id, task.fold_id
1187        )));
1188    }
1189    // Sample-level aggregated validation units must stay inside this fold's
1190    // validation view, mirroring `validate_prediction_scope`. Target / group
1191    // units are checked against their relation set in the aggregation path.
1192    if prediction.level == PredictionLevel::Sample
1193        && task.phase == Phase::FitCv
1194        && task.fold_id.is_some()
1195        && (!task.node_plan.data_bindings.is_empty() || !task.data_views.is_empty())
1196    {
1197        if let Some(validation_sample_ids) = validation_view_sample_ids(task) {
1198            for unit_id in &prediction.unit_ids {
1199                if let PredictionUnitId::Sample(sample_id) = unit_id {
1200                    if !validation_sample_ids.contains(sample_id) {
1201                        return Err(DagMlError::RuntimeValidation(format!(
1202                            "node `{}` emitted aggregated validation prediction for sample `{}` outside its validation view",
1203                            task.node_plan.node_id, sample_id
1204                        )));
1205                    }
1206                }
1207            }
1208        }
1209    }
1210    Ok(())
1211}
1212
1213pub(crate) fn validation_view_sample_ids(task: &NodeTask) -> Option<BTreeSet<SampleId>> {
1214    let mut sample_ids = BTreeSet::new();
1215    for view in task
1216        .data_views
1217        .values()
1218        .filter(|view| view.partition == DataRequestPartition::FoldValidation)
1219    {
1220        if let Some(view_sample_ids) = &view.sample_ids {
1221            sample_ids.extend(view_sample_ids.iter().cloned());
1222        }
1223    }
1224    (!sample_ids.is_empty()).then_some(sample_ids)
1225}
1226
1227pub(crate) fn fit_influence_task_for_node(
1228    plan: &ExecutionPlan,
1229    node_plan: &NodePlan,
1230    data_views: &BTreeMap<String, DataProviderViewSpec>,
1231) -> Result<FitInfluenceTask> {
1232    let manifest = plan
1233        .controller_manifests
1234        .get(&node_plan.controller_id)
1235        .ok_or_else(|| {
1236            DagMlError::RuntimeValidation(format!(
1237                "node `{}` references missing controller manifest `{}`",
1238                node_plan.node_id, node_plan.controller_id
1239            ))
1240        })?;
1241    let Some(model_input_spec) = manifest.model_input_spec()? else {
1242        return Ok(FitInfluenceTask::default());
1243    };
1244    let Some(requested_policy) = model_input_spec.fit_influence_policy else {
1245        return Ok(FitInfluenceTask::default());
1246    };
1247    resolve_fit_influence_task(
1248        requested_policy,
1249        &node_plan.controller_capabilities,
1250        data_views,
1251    )
1252}
1253
1254pub(crate) fn resolve_fit_influence_task(
1255    requested_policy: FitInfluencePolicy,
1256    capabilities: &BTreeSet<ControllerCapability>,
1257    data_views: &BTreeMap<String, DataProviderViewSpec>,
1258) -> Result<FitInfluenceTask> {
1259    let row_weights = equal_sample_influence_weights(data_views);
1260    match requested_policy {
1261        FitInfluencePolicy::UniformRows => Ok(FitInfluenceTask {
1262            requested_policy,
1263            effective_policy: FitInfluencePolicy::UniformRows,
1264            mechanism: FitInfluenceMechanism::UniformRows,
1265            row_weights: Vec::new(),
1266            warnings: Vec::new(),
1267        }),
1268        FitInfluencePolicy::ScorerOnly => Ok(FitInfluenceTask {
1269            requested_policy,
1270            effective_policy: FitInfluencePolicy::ScorerOnly,
1271            mechanism: FitInfluenceMechanism::ScorerOnly,
1272            row_weights: Vec::new(),
1273            warnings: Vec::new(),
1274        }),
1275        FitInfluencePolicy::EqualSampleInfluence => {
1276            require_fit_influence_support(capabilities, requested_policy)?;
1277            let weights = row_weights.ok_or_else(|| {
1278                DagMlError::RuntimeValidation(
1279                    "equal_sample_influence requires task row sample ids".to_string(),
1280                )
1281            })?;
1282            Ok(FitInfluenceTask {
1283                requested_policy,
1284                effective_policy: FitInfluencePolicy::EqualSampleInfluence,
1285                mechanism: FitInfluenceMechanism::SampleWeights,
1286                row_weights: weights,
1287                warnings: Vec::new(),
1288            })
1289        }
1290        FitInfluencePolicy::ResampleEqualized => {
1291            require_fit_influence_support(capabilities, requested_policy)?;
1292            Ok(FitInfluenceTask {
1293                requested_policy,
1294                effective_policy: FitInfluencePolicy::ResampleEqualized,
1295                mechanism: FitInfluenceMechanism::RowResampling,
1296                row_weights: Vec::new(),
1297                warnings: Vec::new(),
1298            })
1299        }
1300        FitInfluencePolicy::BackendLossWeight => {
1301            require_fit_influence_support(capabilities, requested_policy)?;
1302            let weights = row_weights.ok_or_else(|| {
1303                DagMlError::RuntimeValidation(
1304                    "backend_loss_weight requires task row sample ids".to_string(),
1305                )
1306            })?;
1307            Ok(FitInfluenceTask {
1308                requested_policy,
1309                effective_policy: FitInfluencePolicy::BackendLossWeight,
1310                mechanism: FitInfluenceMechanism::BackendLossWeights,
1311                row_weights: weights,
1312                warnings: Vec::new(),
1313            })
1314        }
1315        FitInfluencePolicy::StrictWeightSupport => {
1316            require_fit_influence_support(capabilities, requested_policy)?;
1317            strict_fit_influence_task(capabilities, row_weights, requested_policy)
1318        }
1319        FitInfluencePolicy::Auto => Ok(auto_fit_influence_task(capabilities, row_weights)),
1320    }
1321}
1322
1323pub(crate) fn require_fit_influence_support(
1324    capabilities: &BTreeSet<ControllerCapability>,
1325    policy: FitInfluencePolicy,
1326) -> Result<()> {
1327    if capabilities_support_fit_influence(capabilities, policy) {
1328        return Ok(());
1329    }
1330    Err(DagMlError::RuntimeValidation(format!(
1331        "controller capabilities do not support requested fit influence policy {:?}",
1332        policy
1333    )))
1334}
1335
1336pub(crate) fn strict_fit_influence_task(
1337    capabilities: &BTreeSet<ControllerCapability>,
1338    row_weights: Option<Vec<f64>>,
1339    requested_policy: FitInfluencePolicy,
1340) -> Result<FitInfluenceTask> {
1341    if capabilities.contains(&ControllerCapability::SupportsBackendLossWeights) {
1342        let weights = row_weights.ok_or_else(|| {
1343            DagMlError::RuntimeValidation(
1344                "strict_weight_support with backend loss weights requires task row sample ids"
1345                    .to_string(),
1346            )
1347        })?;
1348        return Ok(FitInfluenceTask {
1349            requested_policy,
1350            effective_policy: FitInfluencePolicy::BackendLossWeight,
1351            mechanism: FitInfluenceMechanism::BackendLossWeights,
1352            row_weights: weights,
1353            warnings: Vec::new(),
1354        });
1355    }
1356    if capabilities.contains(&ControllerCapability::SupportsSampleWeights) {
1357        let weights = row_weights.ok_or_else(|| {
1358            DagMlError::RuntimeValidation(
1359                "strict_weight_support with sample weights requires task row sample ids"
1360                    .to_string(),
1361            )
1362        })?;
1363        return Ok(FitInfluenceTask {
1364            requested_policy,
1365            effective_policy: FitInfluencePolicy::EqualSampleInfluence,
1366            mechanism: FitInfluenceMechanism::SampleWeights,
1367            row_weights: weights,
1368            warnings: Vec::new(),
1369        });
1370    }
1371    Ok(FitInfluenceTask {
1372        requested_policy,
1373        effective_policy: FitInfluencePolicy::ResampleEqualized,
1374        mechanism: FitInfluenceMechanism::RowResampling,
1375        row_weights: Vec::new(),
1376        warnings: Vec::new(),
1377    })
1378}
1379
1380pub(crate) fn auto_fit_influence_task(
1381    capabilities: &BTreeSet<ControllerCapability>,
1382    row_weights: Option<Vec<f64>>,
1383) -> FitInfluenceTask {
1384    if capabilities.contains(&ControllerCapability::SupportsSampleWeights) {
1385        if let Some(weights) = row_weights.clone() {
1386            return FitInfluenceTask {
1387                requested_policy: FitInfluencePolicy::Auto,
1388                effective_policy: FitInfluencePolicy::EqualSampleInfluence,
1389                mechanism: FitInfluenceMechanism::SampleWeights,
1390                row_weights: weights,
1391                warnings: Vec::new(),
1392            };
1393        }
1394    }
1395    if capabilities.contains(&ControllerCapability::SupportsRowResampling) {
1396        return FitInfluenceTask {
1397            requested_policy: FitInfluencePolicy::Auto,
1398            effective_policy: FitInfluencePolicy::ResampleEqualized,
1399            mechanism: FitInfluenceMechanism::RowResampling,
1400            row_weights: Vec::new(),
1401            warnings: Vec::new(),
1402        };
1403    }
1404    if capabilities.contains(&ControllerCapability::SupportsBackendLossWeights) {
1405        if let Some(weights) = row_weights {
1406            return FitInfluenceTask {
1407                requested_policy: FitInfluencePolicy::Auto,
1408                effective_policy: FitInfluencePolicy::BackendLossWeight,
1409                mechanism: FitInfluenceMechanism::BackendLossWeights,
1410                row_weights: weights,
1411                warnings: Vec::new(),
1412            };
1413        }
1414    }
1415    FitInfluenceTask {
1416        requested_policy: FitInfluencePolicy::Auto,
1417        effective_policy: FitInfluencePolicy::UniformRows,
1418        mechanism: FitInfluenceMechanism::UniformRows,
1419        row_weights: Vec::new(),
1420        warnings: vec![
1421            "auto fit influence fell back to uniform_rows because no supported weighting capability was usable".to_string(),
1422        ],
1423    }
1424}
1425
1426pub(crate) fn equal_sample_influence_weights(
1427    data_views: &BTreeMap<String, DataProviderViewSpec>,
1428) -> Option<Vec<f64>> {
1429    let row_sample_ids = data_views
1430        .values()
1431        .filter(|view| {
1432            matches!(
1433                view.partition,
1434                DataRequestPartition::FoldTrain | DataRequestPartition::FullTrain
1435            )
1436        })
1437        .filter_map(|view| view.sample_ids.as_ref())
1438        .find(|sample_ids| !sample_ids.is_empty())
1439        .or_else(|| {
1440            data_views
1441                .values()
1442                .filter_map(|view| view.sample_ids.as_ref())
1443                .find(|sample_ids| !sample_ids.is_empty())
1444        })?;
1445    let mut counts = BTreeMap::<&SampleId, usize>::new();
1446    for sample_id in row_sample_ids {
1447        *counts.entry(sample_id).or_default() += 1;
1448    }
1449    Some(
1450        row_sample_ids
1451            .iter()
1452            .map(|sample_id| 1.0 / *counts.get(sample_id).expect("counted sample id") as f64)
1453            .collect(),
1454    )
1455}
1456
1457pub(crate) fn record_fit_influence_diagnostic(task: &NodeTask, result: &mut NodeResult) {
1458    if task.fit_influence.is_default() || !result.fit_influence_diagnostics.is_empty() {
1459        return;
1460    }
1461    result
1462        .fit_influence_diagnostics
1463        .push(task.fit_influence.diagnostic());
1464}