1use 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 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub required_loss_attestations: Vec<LossExecutionAttestation>,
88 #[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 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#[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#[derive(Clone, Debug, PartialEq)]
175pub struct RuntimeHpoExecutionContext {
176 pub operation_id: String,
180 pub controller_id: ControllerId,
182 pub target_node_id: NodeId,
184 pub base_variant: VariantPlan,
186 pub trial_budget_total: u32,
189 pub study: crate::hpo::MethodsHpoStudyConfig,
192 pub parameter_paths: BTreeMap<String, String>,
196 pub resume_checkpoint: Option<crate::hpo::N4moptCheckpointArtifact>,
200 pub resume_variants: BTreeMap<i64, VariantId>,
203 pub resume_terminal_trials: Vec<RuntimeHpoTerminalSnapshot>,
206 pub selection: RuntimeHpoSelectionTarget,
209 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#[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#[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#[derive(Clone, Debug)]
423pub struct RuntimeHpoCompletedReport {
424 pub trial_id: i64,
425 pub variant_id: VariantId,
426 pub report: RegressionMetricReport,
427}
428
429#[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 pub completed_proposals: Vec<RuntimeHpoProposal>,
442 pub completed_reports: Vec<RuntimeHpoCompletedReport>,
443 pub trial_history_len: u32,
446}
447
448#[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#[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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
747#[serde(deny_unknown_fields)]
748pub struct ExplanationBlock {
749 pub producer_node: NodeId,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
752 pub producer_port: Option<String>,
753 pub method: String,
755 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub target_name: Option<String>,
758 pub payload: serde_json::Value,
760}
761
762impl ExplanationBlock {
763 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 #[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 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}