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(
127 &self,
128 role_index: usize,
129 ) -> Result<(&TrainingLossRoleReference, &LossExecutionAttestation)> {
130 if !matches!(self.phase, Phase::FitCv | Phase::Refit) {
131 return Err(DagMlError::RuntimeValidation(
132 "training loss phase must be FIT_CV or REFIT".to_string(),
133 ));
134 }
135 self.validate_required_loss_attestations()?;
136 let role = self
137 .node_plan
138 .training_losses_for_phase(self.phase)
139 .nth(role_index)
140 .ok_or_else(|| {
141 DagMlError::RuntimeValidation(format!(
142 "role_index {role_index} is outside the active training loss range"
143 ))
144 })?;
145 let attestation = self
146 .required_loss_attestations
147 .get(role_index)
148 .ok_or_else(|| {
149 DagMlError::RuntimeValidation(
150 "validated training loss role has no matching attestation".to_string(),
151 )
152 })?;
153 Ok((role, attestation))
154 }
155}
156
157#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum FitInfluenceMechanism {
160 UniformRows,
161 SampleWeights,
162 RowResampling,
163 BackendLossWeights,
164 ScorerOnly,
165}
166
167#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
168pub struct FitInfluenceTask {
169 pub requested_policy: FitInfluencePolicy,
170 pub effective_policy: FitInfluencePolicy,
171 pub mechanism: FitInfluenceMechanism,
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
173 pub row_weights: Vec<f64>,
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub warnings: Vec<String>,
176}
177
178impl Default for FitInfluenceTask {
179 fn default() -> Self {
180 Self {
181 requested_policy: FitInfluencePolicy::UniformRows,
182 effective_policy: FitInfluencePolicy::UniformRows,
183 mechanism: FitInfluenceMechanism::UniformRows,
184 row_weights: Vec::new(),
185 warnings: Vec::new(),
186 }
187 }
188}
189
190impl FitInfluenceTask {
191 fn is_default(&self) -> bool {
192 self == &Self::default()
193 }
194
195 pub fn diagnostic(&self) -> FitInfluenceDiagnostic {
196 FitInfluenceDiagnostic {
197 requested_policy: self.requested_policy,
198 effective_policy: self.effective_policy,
199 mechanism: self.mechanism,
200 fallback_used: !self.warnings.is_empty(),
201 row_weight_count: self.row_weights.len(),
202 warnings: self.warnings.clone(),
203 }
204 }
205
206 pub fn validate(&self) -> Result<()> {
207 if !self
208 .row_weights
209 .iter()
210 .all(|weight| weight.is_finite() && *weight > 0.0)
211 {
212 return Err(DagMlError::RuntimeValidation(
213 "fit influence row_weights must be finite and > 0".to_string(),
214 ));
215 }
216 if self
217 .warnings
218 .iter()
219 .any(|warning| warning.trim().is_empty())
220 {
221 return Err(DagMlError::RuntimeValidation(
222 "fit influence warnings must not be empty".to_string(),
223 ));
224 }
225 match self.effective_policy {
226 FitInfluencePolicy::EqualSampleInfluence | FitInfluencePolicy::BackendLossWeight
227 if self.row_weights.is_empty() =>
228 {
229 return Err(DagMlError::RuntimeValidation(format!(
230 "fit influence {:?} requires row_weights",
231 self.effective_policy
232 )));
233 }
234 _ => {}
235 }
236 if self.requested_policy == FitInfluencePolicy::StrictWeightSupport
237 && self.effective_policy == FitInfluencePolicy::UniformRows
238 {
239 return Err(DagMlError::RuntimeValidation(
240 "strict fit influence cannot fall back to uniform_rows".to_string(),
241 ));
242 }
243 Ok(())
244 }
245}
246
247#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249pub struct FitInfluenceDiagnostic {
250 pub requested_policy: FitInfluencePolicy,
251 pub effective_policy: FitInfluencePolicy,
252 pub mechanism: FitInfluenceMechanism,
253 #[serde(default)]
254 pub fallback_used: bool,
255 #[serde(default)]
256 pub row_weight_count: usize,
257 #[serde(default, skip_serializing_if = "Vec::is_empty")]
258 pub warnings: Vec<String>,
259}
260
261impl FitInfluenceDiagnostic {
262 pub fn validate(&self, task: &NodeTask) -> Result<()> {
263 if self.requested_policy != task.fit_influence.requested_policy {
264 return Err(DagMlError::RuntimeValidation(format!(
265 "fit influence diagnostic requested_policy {:?} does not match task {:?}",
266 self.requested_policy, task.fit_influence.requested_policy
267 )));
268 }
269 if self.effective_policy != task.fit_influence.effective_policy {
270 return Err(DagMlError::RuntimeValidation(format!(
271 "fit influence diagnostic effective_policy {:?} does not match task {:?}",
272 self.effective_policy, task.fit_influence.effective_policy
273 )));
274 }
275 if self.mechanism != task.fit_influence.mechanism {
276 return Err(DagMlError::RuntimeValidation(format!(
277 "fit influence diagnostic mechanism {:?} does not match task {:?}",
278 self.mechanism, task.fit_influence.mechanism
279 )));
280 }
281 if self.row_weight_count != task.fit_influence.row_weights.len() {
282 return Err(DagMlError::RuntimeValidation(format!(
283 "fit influence diagnostic row_weight_count {} does not match task {}",
284 self.row_weight_count,
285 task.fit_influence.row_weights.len()
286 )));
287 }
288 if self.fallback_used == task.fit_influence.warnings.is_empty() {
289 return Err(DagMlError::RuntimeValidation(
290 "fit influence diagnostic fallback_used does not match task warnings".to_string(),
291 ));
292 }
293 if self.warnings != task.fit_influence.warnings {
294 return Err(DagMlError::RuntimeValidation(
295 "fit influence diagnostic warnings do not match task warnings".to_string(),
296 ));
297 }
298 if self
299 .warnings
300 .iter()
301 .any(|warning| warning.trim().is_empty())
302 {
303 return Err(DagMlError::RuntimeValidation(
304 "fit influence diagnostic warnings must not be empty".to_string(),
305 ));
306 }
307 Ok(())
308 }
309}
310
311#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
312pub struct VariantExecutionSpec {
313 pub variant_id: VariantId,
314 #[serde(default)]
315 pub choices: BTreeMap<String, GenerationChoice>,
316 pub fingerprint: String,
317 pub seed: Option<u64>,
318}
319
320impl VariantExecutionSpec {
321 pub fn from_plan(variant: &VariantPlan) -> Self {
322 Self {
323 variant_id: variant.variant_id.clone(),
324 choices: variant.choices.clone(),
325 fingerprint: variant.fingerprint.clone(),
326 seed: variant.seed,
327 }
328 }
329
330 pub fn validate(&self) -> Result<()> {
331 if self.fingerprint.trim().is_empty() {
332 return Err(DagMlError::RuntimeValidation(format!(
333 "variant `{}` has an empty fingerprint in task context",
334 self.variant_id
335 )));
336 }
337 for (dimension_name, choice) in &self.choices {
338 if dimension_name.trim().is_empty() {
339 return Err(DagMlError::RuntimeValidation(format!(
340 "variant `{}` has an empty generation dimension name",
341 self.variant_id
342 )));
343 }
344 if choice.label.trim().is_empty() {
345 return Err(DagMlError::RuntimeValidation(format!(
346 "variant `{}` has an empty choice label for dimension `{dimension_name}`",
347 self.variant_id
348 )));
349 }
350 for override_spec in &choice.param_overrides {
351 if override_spec.params.is_empty() {
352 return Err(DagMlError::RuntimeValidation(format!(
353 "variant `{}` has an empty param override for node `{}`",
354 self.variant_id, override_spec.node_id
355 )));
356 }
357 for param_key in override_spec.params.keys() {
358 if param_key.trim().is_empty() {
359 return Err(DagMlError::RuntimeValidation(format!(
360 "variant `{}` has an empty param override key for node `{}`",
361 self.variant_id, override_spec.node_id
362 )));
363 }
364 }
365 }
366 }
367 self.param_overrides_by_node()?;
368 Ok(())
369 }
370
371 pub fn effective_params_for_node(
372 &self,
373 node_id: &NodeId,
374 base_params: &BTreeMap<String, serde_json::Value>,
375 ) -> Result<BTreeMap<String, serde_json::Value>> {
376 let overrides_by_node = self.param_overrides_by_node()?;
377 let Some(overrides) = overrides_by_node.get(node_id) else {
378 return Ok(base_params.clone());
379 };
380 let mut params = base_params.clone();
381 params.extend(overrides.clone());
382 Ok(params)
383 }
384
385 fn param_overrides_by_node(
386 &self,
387 ) -> Result<BTreeMap<NodeId, BTreeMap<String, serde_json::Value>>> {
388 let mut overrides = BTreeMap::<NodeId, BTreeMap<String, serde_json::Value>>::new();
389 let mut owners = BTreeMap::<(NodeId, String), String>::new();
390 for (dimension_name, choice) in &self.choices {
391 for override_spec in &choice.param_overrides {
392 for (param_key, value) in &override_spec.params {
393 let owner_key = (override_spec.node_id.clone(), param_key.clone());
394 if let Some(previous) =
395 owners.insert(owner_key, format!("{dimension_name}:{}", choice.label))
396 {
397 return Err(DagMlError::RuntimeValidation(format!(
398 "variant `{}` has conflicting generation overrides for `{}.{}` from `{previous}` and `{}:{}`",
399 self.variant_id,
400 override_spec.node_id,
401 param_key,
402 dimension_name,
403 choice.label
404 )));
405 }
406 overrides
407 .entry(override_spec.node_id.clone())
408 .or_default()
409 .insert(param_key.clone(), value.clone());
410 }
411 }
412 }
413 Ok(overrides)
414 }
415}
416
417#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
423#[serde(deny_unknown_fields)]
424pub struct ExplanationBlock {
425 pub producer_node: NodeId,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub producer_port: Option<String>,
429 pub method: String,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub target_name: Option<String>,
434 pub payload: serde_json::Value,
436}
437
438impl ExplanationBlock {
439 pub fn validate(&self) -> Result<()> {
443 if self.method.trim().is_empty() {
444 return Err(DagMlError::RuntimeValidation(
445 "explanation method must be a non-empty identifier".to_string(),
446 ));
447 }
448 if let Some(name) = &self.target_name {
449 if name.trim().is_empty() {
450 return Err(DagMlError::RuntimeValidation(
451 "explanation target_name must be non-empty when present".to_string(),
452 ));
453 }
454 }
455 Ok(())
456 }
457}
458
459#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
460#[serde(deny_unknown_fields)]
461pub struct NodeResult {
462 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub schema_version: Option<u32>,
464 pub node_id: NodeId,
465 #[serde(default)]
466 pub outputs: BTreeMap<String, HandleRef>,
467 #[serde(default)]
468 pub predictions: Vec<PredictionBlock>,
469 #[serde(default)]
470 pub observation_predictions: Vec<ObservationPredictionBlock>,
471 #[serde(default)]
472 pub aggregated_predictions: Vec<AggregatedPredictionBlock>,
473 #[serde(default)]
474 pub explanations: Vec<ExplanationBlock>,
475 #[serde(default)]
476 pub shape_deltas: Vec<ShapeDelta>,
477 #[serde(default)]
478 pub artifacts: Vec<ArtifactRef>,
479 #[serde(default)]
480 pub artifact_handles: BTreeMap<ArtifactId, HandleRef>,
481 #[serde(default, skip_serializing_if = "Vec::is_empty")]
482 pub fit_influence_diagnostics: Vec<FitInfluenceDiagnostic>,
483 #[serde(default, skip_serializing_if = "Vec::is_empty")]
487 pub regression_targets: Vec<RegressionTargetBlock>,
488 pub lineage: LineageRecord,
489}
490
491impl NodeResult {
492 pub fn validate_for_task(&self, task: &NodeTask) -> Result<()> {
493 if self.node_id != task.node_plan.node_id {
494 return Err(DagMlError::RuntimeValidation(format!(
495 "task for `{}` returned result for `{}`",
496 task.node_plan.node_id, self.node_id
497 )));
498 }
499 if self.lineage.node_id != task.node_plan.node_id {
500 return Err(DagMlError::RuntimeValidation(format!(
501 "lineage for task `{}` references node `{}`",
502 task.node_plan.node_id, self.lineage.node_id
503 )));
504 }
505 if self.lineage.phase != task.phase {
506 return Err(DagMlError::RuntimeValidation(format!(
507 "lineage for node `{}` has phase {:?}, expected {:?}",
508 task.node_plan.node_id, self.lineage.phase, task.phase
509 )));
510 }
511 if self.lineage.run_id != task.run_id {
512 return Err(DagMlError::RuntimeValidation(format!(
513 "lineage for node `{}` has run `{}`, expected `{}`",
514 task.node_plan.node_id, self.lineage.run_id, task.run_id
515 )));
516 }
517 if self.lineage.controller_id != task.node_plan.controller_id {
518 return Err(DagMlError::RuntimeValidation(format!(
519 "lineage for node `{}` has controller `{}`, expected `{}`",
520 task.node_plan.node_id, self.lineage.controller_id, task.node_plan.controller_id
521 )));
522 }
523 if self.lineage.controller_version != task.node_plan.controller_version {
524 return Err(DagMlError::RuntimeValidation(format!(
525 "lineage for node `{}` has controller version `{}`, expected `{}`",
526 task.node_plan.node_id,
527 self.lineage.controller_version,
528 task.node_plan.controller_version
529 )));
530 }
531 if self.lineage.variant_id != task.variant_id {
532 return Err(DagMlError::RuntimeValidation(format!(
533 "lineage for node `{}` has variant {:?}, expected {:?}",
534 task.node_plan.node_id, self.lineage.variant_id, task.variant_id
535 )));
536 }
537 if let Some(variant) = &task.variant {
538 variant.validate()?;
539 if Some(&variant.variant_id) != task.variant_id.as_ref() {
540 return Err(DagMlError::RuntimeValidation(format!(
541 "task for node `{}` has variant context `{}` but variant_id {:?}",
542 task.node_plan.node_id, variant.variant_id, task.variant_id
543 )));
544 }
545 }
546 if self.lineage.fold_id != task.fold_id {
547 return Err(DagMlError::RuntimeValidation(format!(
548 "lineage for node `{}` has fold {:?}, expected {:?}",
549 task.node_plan.node_id, self.lineage.fold_id, task.fold_id
550 )));
551 }
552 if self.lineage.branch_path != task.branch_path {
553 return Err(DagMlError::RuntimeValidation(format!(
554 "lineage for node `{}` has branch path {:?}, expected {:?}",
555 task.node_plan.node_id, self.lineage.branch_path, task.branch_path
556 )));
557 }
558 if self.lineage.seed != task.seed {
559 return Err(DagMlError::RuntimeValidation(format!(
560 "lineage for node `{}` has seed {:?}, expected {:?}",
561 task.node_plan.node_id, self.lineage.seed, task.seed
562 )));
563 }
564 if self.lineage.params_fingerprint != task.node_plan.params_fingerprint {
565 return Err(DagMlError::RuntimeValidation(format!(
566 "lineage for node `{}` has params fingerprint `{}`, expected `{}`",
567 task.node_plan.node_id,
568 self.lineage.params_fingerprint,
569 task.node_plan.params_fingerprint
570 )));
571 }
572 task.validate_required_loss_attestations()?;
573 let expected_losses = task
574 .node_plan
575 .training_losses_for_phase(task.phase)
576 .collect::<Vec<_>>();
577 if self.lineage.loss_attestations.len() != expected_losses.len() {
578 return Err(DagMlError::RuntimeValidation(format!(
579 "node `{}` returned {} loss attestations for {} resolved losses in phase {:?}",
580 task.node_plan.node_id,
581 self.lineage.loss_attestations.len(),
582 expected_losses.len(),
583 task.phase
584 )));
585 }
586 for (attestation, role) in self.lineage.loss_attestations.iter().zip(expected_losses) {
587 attestation.validate_against(role, &task.node_plan.node_id, task.phase)?;
588 }
589 if self.lineage.loss_attestations != task.required_loss_attestations {
590 return Err(DagMlError::RuntimeValidation(format!(
591 "node `{}` returned loss attestations that do not match the task requirements",
592 task.node_plan.node_id
593 )));
594 }
595 task.fit_influence.validate()?;
596 for diagnostic in &self.fit_influence_diagnostics {
597 diagnostic.validate(task)?;
598 }
599 validate_lineage_shape_fingerprints(&self.lineage, task)?;
600 if !self.explanations.is_empty() && task.phase != Phase::Explain {
601 return Err(DagMlError::RuntimeValidation(format!(
602 "node `{}` returned explanations outside the EXPLAIN phase",
603 task.node_plan.node_id
604 )));
605 }
606 for explanation in &self.explanations {
607 explanation.validate()?;
608 if explanation.producer_node != self.node_id {
609 return Err(DagMlError::RuntimeValidation(format!(
610 "node `{}` returned an explanation produced by `{}`",
611 self.node_id, explanation.producer_node
612 )));
613 }
614 }
615 for (port, handle) in &self.outputs {
616 if handle.owner_controller != task.node_plan.controller_id {
617 return Err(DagMlError::RuntimeValidation(format!(
618 "node `{}` output `{port}` is owned by `{}`, expected `{}`",
619 task.node_plan.node_id, handle.owner_controller, task.node_plan.controller_id
620 )));
621 }
622 }
623 let mut artifact_ids = BTreeSet::new();
624 for artifact in &self.artifacts {
625 artifact.validate()?;
626 if !artifact_ids.insert(artifact.id.clone()) {
627 return Err(DagMlError::RuntimeValidation(format!(
628 "node `{}` emitted duplicate artifact `{}`",
629 task.node_plan.node_id, artifact.id
630 )));
631 }
632 if artifact.controller_id != task.node_plan.controller_id {
633 return Err(DagMlError::RuntimeValidation(format!(
634 "node `{}` emitted artifact `{}` for controller `{}`, expected `{}`",
635 task.node_plan.node_id,
636 artifact.id,
637 artifact.controller_id,
638 task.node_plan.controller_id
639 )));
640 }
641 let handle = self.artifact_handles.get(&artifact.id).ok_or_else(|| {
642 DagMlError::RuntimeValidation(format!(
643 "node `{}` emitted artifact `{}` without artifact handle",
644 task.node_plan.node_id, artifact.id
645 ))
646 })?;
647 if !matches!(handle.kind, HandleKind::Model | HandleKind::Artifact) {
648 return Err(DagMlError::RuntimeValidation(format!(
649 "node `{}` emitted artifact `{}` with non-artifact/model handle kind {:?}",
650 task.node_plan.node_id, artifact.id, handle.kind
651 )));
652 }
653 if handle.owner_controller != task.node_plan.controller_id {
654 return Err(DagMlError::RuntimeValidation(format!(
655 "node `{}` emitted artifact `{}` owned by `{}`, expected `{}`",
656 task.node_plan.node_id,
657 artifact.id,
658 handle.owner_controller,
659 task.node_plan.controller_id
660 )));
661 }
662 }
663 for artifact_id in self.artifact_handles.keys() {
664 if !self
665 .artifacts
666 .iter()
667 .any(|artifact| &artifact.id == artifact_id)
668 {
669 return Err(DagMlError::RuntimeValidation(format!(
670 "node `{}` emitted artifact handle for undeclared artifact `{artifact_id}`",
671 task.node_plan.node_id
672 )));
673 }
674 }
675 for artifact in &self.artifacts {
676 if !self
677 .lineage
678 .artifact_refs
679 .iter()
680 .any(|lineage_artifact| lineage_artifact == artifact)
681 {
682 return Err(DagMlError::RuntimeValidation(format!(
683 "node `{}` emitted artifact `{}` without matching lineage artifact ref",
684 task.node_plan.node_id, artifact.id
685 )));
686 }
687 }
688 for artifact in &self.lineage.artifact_refs {
689 if !self
690 .artifacts
691 .iter()
692 .any(|emitted_artifact| emitted_artifact == artifact)
693 {
694 return Err(DagMlError::RuntimeValidation(format!(
695 "node `{}` lineage references undeclared artifact `{}`",
696 task.node_plan.node_id, artifact.id
697 )));
698 }
699 }
700 for prediction in &self.predictions {
701 prediction.validate_shape()?;
702 if prediction.producer_node != task.node_plan.node_id {
703 return Err(DagMlError::RuntimeValidation(format!(
704 "node `{}` emitted prediction for producer `{}`",
705 task.node_plan.node_id, prediction.producer_node
706 )));
707 }
708 validate_prediction_scope(prediction, task)?;
709 }
710 for prediction in &self.observation_predictions {
711 prediction.validate_shape()?;
712 if prediction.producer_node != task.node_plan.node_id {
713 return Err(DagMlError::RuntimeValidation(format!(
714 "node `{}` emitted observation prediction for producer `{}`",
715 task.node_plan.node_id, prediction.producer_node
716 )));
717 }
718 validate_observation_prediction_scope(prediction, task)?;
719 }
720 for prediction in &self.aggregated_predictions {
721 prediction.validate_shape()?;
722 if prediction.producer_node != task.node_plan.node_id {
723 return Err(DagMlError::RuntimeValidation(format!(
724 "node `{}` emitted aggregated prediction for producer `{}`",
725 task.node_plan.node_id, prediction.producer_node
726 )));
727 }
728 validate_aggregated_prediction_scope(prediction, task)?;
729 }
730 for delta in &self.shape_deltas {
731 delta.validate()?;
732 if delta.node_id != task.node_plan.node_id {
733 return Err(DagMlError::RuntimeValidation(format!(
734 "node `{}` emitted shape delta for `{}`",
735 task.node_plan.node_id, delta.node_id
736 )));
737 }
738 validate_shape_delta_for_task(delta, task)?;
739 }
740 for target in &self.regression_targets {
741 target.validate_shape()?;
742 }
743 self.lineage.validate()
744 }
745}
746
747pub(crate) fn validate_lineage_shape_fingerprints(
748 lineage: &LineageRecord,
749 task: &NodeTask,
750) -> Result<()> {
751 let Some(shape_plan) = &task.node_plan.shape_plan else {
752 if lineage.data_model_shape_fingerprint.is_some()
753 || lineage.aggregation_policy_fingerprint.is_some()
754 {
755 return Err(DagMlError::RuntimeValidation(format!(
756 "lineage for node `{}` carries shape fingerprints but the node has no shape plan",
757 task.node_plan.node_id
758 )));
759 }
760 return Ok(());
761 };
762
763 if let Some(actual) = &lineage.data_model_shape_fingerprint {
764 let expected = stable_json_fingerprint(shape_plan)?;
765 if actual != &expected {
766 return Err(DagMlError::RuntimeValidation(format!(
767 "lineage for node `{}` has data/model shape fingerprint `{actual}`, expected `{expected}`",
768 task.node_plan.node_id
769 )));
770 }
771 }
772 if let Some(actual) = &lineage.aggregation_policy_fingerprint {
773 let expected = stable_json_fingerprint(&shape_plan.aggregation_policy)?;
774 if actual != &expected {
775 return Err(DagMlError::RuntimeValidation(format!(
776 "lineage for node `{}` has aggregation policy fingerprint `{actual}`, expected `{expected}`",
777 task.node_plan.node_id
778 )));
779 }
780 }
781 Ok(())
782}
783
784pub(crate) fn validate_shape_delta_for_task(delta: &ShapeDelta, task: &NodeTask) -> Result<()> {
785 let Some(shape_plan) = &task.node_plan.shape_plan else {
786 return Ok(());
787 };
788 if delta.kind == ShapeDeltaKind::Feature {
789 if let Some(expected) = &shape_plan.feature_schema_fingerprint {
790 if &delta.before_fingerprint != expected {
791 return Err(DagMlError::RuntimeValidation(format!(
792 "node `{}` emitted feature shape delta from `{}`, expected current schema `{expected}`",
793 task.node_plan.node_id, delta.before_fingerprint
794 )));
795 }
796 }
797 }
798 Ok(())
799}
800
801pub(crate) fn validate_prediction_scope(
802 prediction: &PredictionBlock,
803 task: &NodeTask,
804) -> Result<()> {
805 if prediction.partition != PredictionPartition::Validation {
806 return Ok(());
807 }
808 if prediction.fold_id != task.fold_id {
809 return Err(DagMlError::RuntimeValidation(format!(
810 "node `{}` emitted validation predictions for fold {:?}, expected {:?}",
811 task.node_plan.node_id, prediction.fold_id, task.fold_id
812 )));
813 }
814 if task.phase == Phase::FitCv
815 && task.fold_id.is_some()
816 && (!task.node_plan.data_bindings.is_empty() || !task.data_views.is_empty())
817 {
818 let validation_sample_ids = validation_view_sample_ids(task).ok_or_else(|| {
819 DagMlError::RuntimeValidation(format!(
820 "node `{}` emitted validation predictions without a fold-validation data view",
821 task.node_plan.node_id
822 ))
823 })?;
824 for sample_id in &prediction.sample_ids {
825 if !validation_sample_ids.contains(sample_id) {
826 return Err(DagMlError::RuntimeValidation(format!(
827 "node `{}` emitted validation prediction for sample `{}` outside its validation view",
828 task.node_plan.node_id, sample_id
829 )));
830 }
831 }
832 }
833 Ok(())
834}
835
836pub(crate) fn validate_observation_prediction_scope(
837 prediction: &ObservationPredictionBlock,
838 task: &NodeTask,
839) -> Result<()> {
840 if prediction.partition != PredictionPartition::Validation {
841 return Ok(());
842 }
843 if prediction.fold_id != task.fold_id {
844 return Err(DagMlError::RuntimeValidation(format!(
845 "node `{}` emitted observation validation predictions for fold {:?}, expected {:?}",
846 task.node_plan.node_id, prediction.fold_id, task.fold_id
847 )));
848 }
849 Ok(())
850}
851
852pub(crate) fn validate_aggregated_prediction_scope(
853 prediction: &AggregatedPredictionBlock,
854 task: &NodeTask,
855) -> Result<()> {
856 if prediction.partition != PredictionPartition::Validation {
857 return Ok(());
858 }
859 if prediction.fold_id != task.fold_id {
860 return Err(DagMlError::RuntimeValidation(format!(
861 "node `{}` emitted aggregated validation predictions for fold {:?}, expected {:?}",
862 task.node_plan.node_id, prediction.fold_id, task.fold_id
863 )));
864 }
865 if prediction.level == PredictionLevel::Sample
869 && task.phase == Phase::FitCv
870 && task.fold_id.is_some()
871 && (!task.node_plan.data_bindings.is_empty() || !task.data_views.is_empty())
872 {
873 if let Some(validation_sample_ids) = validation_view_sample_ids(task) {
874 for unit_id in &prediction.unit_ids {
875 if let PredictionUnitId::Sample(sample_id) = unit_id {
876 if !validation_sample_ids.contains(sample_id) {
877 return Err(DagMlError::RuntimeValidation(format!(
878 "node `{}` emitted aggregated validation prediction for sample `{}` outside its validation view",
879 task.node_plan.node_id, sample_id
880 )));
881 }
882 }
883 }
884 }
885 }
886 Ok(())
887}
888
889pub(crate) fn validation_view_sample_ids(task: &NodeTask) -> Option<BTreeSet<SampleId>> {
890 let mut sample_ids = BTreeSet::new();
891 for view in task
892 .data_views
893 .values()
894 .filter(|view| view.partition == DataRequestPartition::FoldValidation)
895 {
896 if let Some(view_sample_ids) = &view.sample_ids {
897 sample_ids.extend(view_sample_ids.iter().cloned());
898 }
899 }
900 (!sample_ids.is_empty()).then_some(sample_ids)
901}
902
903pub(crate) fn fit_influence_task_for_node(
904 plan: &ExecutionPlan,
905 node_plan: &NodePlan,
906 data_views: &BTreeMap<String, DataProviderViewSpec>,
907) -> Result<FitInfluenceTask> {
908 let manifest = plan
909 .controller_manifests
910 .get(&node_plan.controller_id)
911 .ok_or_else(|| {
912 DagMlError::RuntimeValidation(format!(
913 "node `{}` references missing controller manifest `{}`",
914 node_plan.node_id, node_plan.controller_id
915 ))
916 })?;
917 let Some(model_input_spec) = manifest.model_input_spec()? else {
918 return Ok(FitInfluenceTask::default());
919 };
920 let Some(requested_policy) = model_input_spec.fit_influence_policy else {
921 return Ok(FitInfluenceTask::default());
922 };
923 resolve_fit_influence_task(
924 requested_policy,
925 &node_plan.controller_capabilities,
926 data_views,
927 )
928}
929
930pub(crate) fn resolve_fit_influence_task(
931 requested_policy: FitInfluencePolicy,
932 capabilities: &BTreeSet<ControllerCapability>,
933 data_views: &BTreeMap<String, DataProviderViewSpec>,
934) -> Result<FitInfluenceTask> {
935 let row_weights = equal_sample_influence_weights(data_views);
936 match requested_policy {
937 FitInfluencePolicy::UniformRows => Ok(FitInfluenceTask {
938 requested_policy,
939 effective_policy: FitInfluencePolicy::UniformRows,
940 mechanism: FitInfluenceMechanism::UniformRows,
941 row_weights: Vec::new(),
942 warnings: Vec::new(),
943 }),
944 FitInfluencePolicy::ScorerOnly => Ok(FitInfluenceTask {
945 requested_policy,
946 effective_policy: FitInfluencePolicy::ScorerOnly,
947 mechanism: FitInfluenceMechanism::ScorerOnly,
948 row_weights: Vec::new(),
949 warnings: Vec::new(),
950 }),
951 FitInfluencePolicy::EqualSampleInfluence => {
952 require_fit_influence_support(capabilities, requested_policy)?;
953 let weights = row_weights.ok_or_else(|| {
954 DagMlError::RuntimeValidation(
955 "equal_sample_influence requires task row sample ids".to_string(),
956 )
957 })?;
958 Ok(FitInfluenceTask {
959 requested_policy,
960 effective_policy: FitInfluencePolicy::EqualSampleInfluence,
961 mechanism: FitInfluenceMechanism::SampleWeights,
962 row_weights: weights,
963 warnings: Vec::new(),
964 })
965 }
966 FitInfluencePolicy::ResampleEqualized => {
967 require_fit_influence_support(capabilities, requested_policy)?;
968 Ok(FitInfluenceTask {
969 requested_policy,
970 effective_policy: FitInfluencePolicy::ResampleEqualized,
971 mechanism: FitInfluenceMechanism::RowResampling,
972 row_weights: Vec::new(),
973 warnings: Vec::new(),
974 })
975 }
976 FitInfluencePolicy::BackendLossWeight => {
977 require_fit_influence_support(capabilities, requested_policy)?;
978 let weights = row_weights.ok_or_else(|| {
979 DagMlError::RuntimeValidation(
980 "backend_loss_weight requires task row sample ids".to_string(),
981 )
982 })?;
983 Ok(FitInfluenceTask {
984 requested_policy,
985 effective_policy: FitInfluencePolicy::BackendLossWeight,
986 mechanism: FitInfluenceMechanism::BackendLossWeights,
987 row_weights: weights,
988 warnings: Vec::new(),
989 })
990 }
991 FitInfluencePolicy::StrictWeightSupport => {
992 require_fit_influence_support(capabilities, requested_policy)?;
993 strict_fit_influence_task(capabilities, row_weights, requested_policy)
994 }
995 FitInfluencePolicy::Auto => Ok(auto_fit_influence_task(capabilities, row_weights)),
996 }
997}
998
999pub(crate) fn require_fit_influence_support(
1000 capabilities: &BTreeSet<ControllerCapability>,
1001 policy: FitInfluencePolicy,
1002) -> Result<()> {
1003 if capabilities_support_fit_influence(capabilities, policy) {
1004 return Ok(());
1005 }
1006 Err(DagMlError::RuntimeValidation(format!(
1007 "controller capabilities do not support requested fit influence policy {:?}",
1008 policy
1009 )))
1010}
1011
1012pub(crate) fn strict_fit_influence_task(
1013 capabilities: &BTreeSet<ControllerCapability>,
1014 row_weights: Option<Vec<f64>>,
1015 requested_policy: FitInfluencePolicy,
1016) -> Result<FitInfluenceTask> {
1017 if capabilities.contains(&ControllerCapability::SupportsBackendLossWeights) {
1018 let weights = row_weights.ok_or_else(|| {
1019 DagMlError::RuntimeValidation(
1020 "strict_weight_support with backend loss weights requires task row sample ids"
1021 .to_string(),
1022 )
1023 })?;
1024 return Ok(FitInfluenceTask {
1025 requested_policy,
1026 effective_policy: FitInfluencePolicy::BackendLossWeight,
1027 mechanism: FitInfluenceMechanism::BackendLossWeights,
1028 row_weights: weights,
1029 warnings: Vec::new(),
1030 });
1031 }
1032 if capabilities.contains(&ControllerCapability::SupportsSampleWeights) {
1033 let weights = row_weights.ok_or_else(|| {
1034 DagMlError::RuntimeValidation(
1035 "strict_weight_support with sample weights requires task row sample ids"
1036 .to_string(),
1037 )
1038 })?;
1039 return Ok(FitInfluenceTask {
1040 requested_policy,
1041 effective_policy: FitInfluencePolicy::EqualSampleInfluence,
1042 mechanism: FitInfluenceMechanism::SampleWeights,
1043 row_weights: weights,
1044 warnings: Vec::new(),
1045 });
1046 }
1047 Ok(FitInfluenceTask {
1048 requested_policy,
1049 effective_policy: FitInfluencePolicy::ResampleEqualized,
1050 mechanism: FitInfluenceMechanism::RowResampling,
1051 row_weights: Vec::new(),
1052 warnings: Vec::new(),
1053 })
1054}
1055
1056pub(crate) fn auto_fit_influence_task(
1057 capabilities: &BTreeSet<ControllerCapability>,
1058 row_weights: Option<Vec<f64>>,
1059) -> FitInfluenceTask {
1060 if capabilities.contains(&ControllerCapability::SupportsSampleWeights) {
1061 if let Some(weights) = row_weights.clone() {
1062 return FitInfluenceTask {
1063 requested_policy: FitInfluencePolicy::Auto,
1064 effective_policy: FitInfluencePolicy::EqualSampleInfluence,
1065 mechanism: FitInfluenceMechanism::SampleWeights,
1066 row_weights: weights,
1067 warnings: Vec::new(),
1068 };
1069 }
1070 }
1071 if capabilities.contains(&ControllerCapability::SupportsRowResampling) {
1072 return FitInfluenceTask {
1073 requested_policy: FitInfluencePolicy::Auto,
1074 effective_policy: FitInfluencePolicy::ResampleEqualized,
1075 mechanism: FitInfluenceMechanism::RowResampling,
1076 row_weights: Vec::new(),
1077 warnings: Vec::new(),
1078 };
1079 }
1080 if capabilities.contains(&ControllerCapability::SupportsBackendLossWeights) {
1081 if let Some(weights) = row_weights {
1082 return FitInfluenceTask {
1083 requested_policy: FitInfluencePolicy::Auto,
1084 effective_policy: FitInfluencePolicy::BackendLossWeight,
1085 mechanism: FitInfluenceMechanism::BackendLossWeights,
1086 row_weights: weights,
1087 warnings: Vec::new(),
1088 };
1089 }
1090 }
1091 FitInfluenceTask {
1092 requested_policy: FitInfluencePolicy::Auto,
1093 effective_policy: FitInfluencePolicy::UniformRows,
1094 mechanism: FitInfluenceMechanism::UniformRows,
1095 row_weights: Vec::new(),
1096 warnings: vec![
1097 "auto fit influence fell back to uniform_rows because no supported weighting capability was usable".to_string(),
1098 ],
1099 }
1100}
1101
1102pub(crate) fn equal_sample_influence_weights(
1103 data_views: &BTreeMap<String, DataProviderViewSpec>,
1104) -> Option<Vec<f64>> {
1105 let row_sample_ids = data_views
1106 .values()
1107 .filter(|view| {
1108 matches!(
1109 view.partition,
1110 DataRequestPartition::FoldTrain | DataRequestPartition::FullTrain
1111 )
1112 })
1113 .filter_map(|view| view.sample_ids.as_ref())
1114 .find(|sample_ids| !sample_ids.is_empty())
1115 .or_else(|| {
1116 data_views
1117 .values()
1118 .filter_map(|view| view.sample_ids.as_ref())
1119 .find(|sample_ids| !sample_ids.is_empty())
1120 })?;
1121 let mut counts = BTreeMap::<&SampleId, usize>::new();
1122 for sample_id in row_sample_ids {
1123 *counts.entry(sample_id).or_default() += 1;
1124 }
1125 Some(
1126 row_sample_ids
1127 .iter()
1128 .map(|sample_id| 1.0 / *counts.get(sample_id).expect("counted sample id") as f64)
1129 .collect(),
1130 )
1131}
1132
1133pub(crate) fn record_fit_influence_diagnostic(task: &NodeTask, result: &mut NodeResult) {
1134 if task.fit_influence.is_default() || !result.fit_influence_diagnostics.is_empty() {
1135 return;
1136 }
1137 result
1138 .fit_influence_diagnostics
1139 .push(task.fit_influence.diagnostic());
1140}