1use std::collections::{BTreeMap, BTreeSet};
9
10use serde::{Deserialize, Serialize};
11
12use crate::canonical::{
13 deserialize_external_contract, parse_typed_json, validate_typed_serde_value,
14};
15use crate::error::{DagMlError, Result};
16use crate::ids::{FoldId, NodeId};
17use crate::oof::PredictionPartition;
18use crate::phase::Phase;
19use crate::policy::PredictionLevel;
20use crate::selection::MetricObjective;
21use crate::training::PredictionKind;
22
23pub const LOSS_SPEC_SCHEMA_VERSION: u32 = 1;
24pub const METRIC_SPEC_SCHEMA_VERSION: u32 = 1;
25pub const IMPLEMENTATION_DESCRIPTOR_SCHEMA_VERSION: u32 = 1;
26pub const LOSS_ROLE_SCHEMA_VERSION: u32 = 1;
27pub const METRIC_ROLE_SCHEMA_VERSION: u32 = 1;
28pub const LOSS_EXECUTION_ATTESTATION_SCHEMA_VERSION: u32 = 1;
29pub const EARLY_STOPPING_RECORD_SCHEMA_VERSION: u32 = 1;
30
31pub const LOSS_SPEC_SCHEMA_ID: &str =
32 "https://github.com/GBeurier/dag-ml/schemas/loss_spec.v1.schema.json";
33pub const METRIC_SPEC_SCHEMA_ID: &str =
34 "https://github.com/GBeurier/dag-ml/schemas/metric_spec.v1.schema.json";
35pub const IMPLEMENTATION_DESCRIPTOR_SCHEMA_ID: &str =
36 "https://github.com/GBeurier/dag-ml/schemas/implementation_descriptor.v1.schema.json";
37pub const TRAINING_LOSS_ROLE_SCHEMA_ID: &str =
38 "https://github.com/GBeurier/dag-ml/schemas/training_loss_role.v1.schema.json";
39pub const METRIC_ROLE_SCHEMA_ID: &str =
40 "https://github.com/GBeurier/dag-ml/schemas/metric_role.v1.schema.json";
41pub const LOSS_EXECUTION_ATTESTATION_SCHEMA_ID: &str =
42 "https://github.com/GBeurier/dag-ml/schemas/loss_execution_attestation.v1.schema.json";
43pub const EARLY_STOPPING_RECORD_SCHEMA_ID: &str =
44 "https://github.com/GBeurier/dag-ml/schemas/early_stopping_record.v1.schema.json";
45
46const FINGERPRINT_LEN: usize = 64;
47const FORBIDDEN_EXECUTABLE_KEYS: &[&str] = &[
48 "bytecode",
49 "callable",
50 "code",
51 "function_source",
52 "import_path",
53 "module_path",
54 "pickle",
55 "serialized_callable",
56 "source_code",
57];
58
59#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum SemanticSpecKind {
62 BuiltIn,
63 Custom,
64}
65
66#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum LearningTaskKind {
69 Regression,
70 BinaryClassification,
71 MulticlassClassification,
72 MultilabelClassification,
73}
74
75#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum CriterionInput {
78 Target,
79 Prediction,
80 SampleWeight,
81 MissingMask,
82 Group,
83}
84
85#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum LossReduction {
88 Mean,
89 Sum,
90 WeightedMean,
91}
92
93#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum LossCapability {
96 Differentiable,
97 DistributedReduction,
98 PerOutput,
99 SupportsMissingMask,
100 SupportsSampleWeights,
101}
102
103#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum MetricReduction {
106 Global,
107 Mean,
108 Sum,
109 WeightedMean,
110}
111
112#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum MetricDecomposition {
115 Global,
116 PerOutput,
117 PerUnit,
118}
119
120#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum MetricCapability {
123 Decomposable,
124 DistributedReduction,
125 SupportsMissingMask,
126 SupportsSampleWeights,
127}
128
129#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum ImplementationSemanticKind {
132 Loss,
133 Metric,
134}
135
136#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum ImplementationCapability {
139 Deterministic,
140 Differentiable,
141 DistributedReduction,
142 NeedsGil,
143 ProcessSafe,
144 SupportsMissingMask,
145 SupportsSampleWeights,
146 ThreadSafe,
147}
148
149#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum PortabilityClass {
152 HostLocal,
153 PortableRegistered,
154 PortableBuiltIn,
155}
156
157#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum ReplayabilityClass {
160 ProcessLocal,
161 RegistryRequired,
162 Detached,
163}
164
165#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
166#[serde(deny_unknown_fields)]
167pub struct LossSpec {
168 pub schema_version: u32,
169 pub loss_id: String,
170 pub kind: SemanticSpecKind,
171 pub task_kinds: BTreeSet<LearningTaskKind>,
172 pub prediction_kinds: BTreeSet<PredictionKind>,
173 pub objective: MetricObjective,
174 pub reduction: LossReduction,
175 pub required_inputs: BTreeSet<CriterionInput>,
176 pub capabilities: BTreeSet<LossCapability>,
177 pub parameters: serde_json::Value,
178 pub spec_fingerprint: String,
179}
180
181impl LossSpec {
182 #[allow(clippy::too_many_arguments)]
183 pub fn new(
184 loss_id: impl Into<String>,
185 kind: SemanticSpecKind,
186 task_kinds: BTreeSet<LearningTaskKind>,
187 prediction_kinds: BTreeSet<PredictionKind>,
188 reduction: LossReduction,
189 required_inputs: BTreeSet<CriterionInput>,
190 capabilities: BTreeSet<LossCapability>,
191 parameters: serde_json::Value,
192 ) -> Result<Self> {
193 let mut spec = Self {
194 schema_version: LOSS_SPEC_SCHEMA_VERSION,
195 loss_id: loss_id.into(),
196 kind,
197 task_kinds,
198 prediction_kinds,
199 objective: MetricObjective::Minimize,
200 reduction,
201 required_inputs,
202 capabilities,
203 parameters,
204 spec_fingerprint: String::new(),
205 };
206 spec.spec_fingerprint = spec.compute_fingerprint()?;
207 spec.validate()?;
208 Ok(spec)
209 }
210
211 pub fn from_json(json: &str) -> Result<Self> {
212 let spec: Self =
213 deserialize_external_contract(json, "loss spec", DagMlError::CampaignValidation)?;
214 spec.validate()?;
215 Ok(spec)
216 }
217
218 pub fn compute_fingerprint(&self) -> Result<String> {
219 fingerprint_without(self, "spec_fingerprint", "loss spec")
220 }
221
222 pub fn validate(&self) -> Result<()> {
223 validate_schema_version("loss spec", self.schema_version, LOSS_SPEC_SCHEMA_VERSION)?;
224 validate_versioned_id("loss", &self.loss_id)?;
225 validate_nonempty_set("loss task_kinds", &self.task_kinds)?;
226 validate_nonempty_set("loss prediction_kinds", &self.prediction_kinds)?;
227 if self.objective != MetricObjective::Minimize {
228 return contract_error("loss objective must be minimize in schema version 1");
229 }
230 validate_required_target_prediction("loss", &self.required_inputs)?;
231 validate_parameters("loss", &self.parameters)?;
232 if self.reduction == LossReduction::WeightedMean
233 && !self.required_inputs.contains(&CriterionInput::SampleWeight)
234 {
235 return contract_error("weighted_mean loss requires sample_weight input");
236 }
237 if self.required_inputs.contains(&CriterionInput::SampleWeight)
238 && !self
239 .capabilities
240 .contains(&LossCapability::SupportsSampleWeights)
241 {
242 return contract_error(
243 "loss requiring sample_weight must declare supports_sample_weights",
244 );
245 }
246 if self.required_inputs.contains(&CriterionInput::MissingMask)
247 && !self
248 .capabilities
249 .contains(&LossCapability::SupportsMissingMask)
250 {
251 return contract_error(
252 "loss requiring missing_mask must declare supports_missing_mask",
253 );
254 }
255 validate_fingerprint("loss spec", &self.spec_fingerprint)?;
256 let expected = self.compute_fingerprint()?;
257 if self.spec_fingerprint != expected {
258 return contract_error(format!(
259 "loss spec fingerprint mismatch: declared {}, expected {expected}",
260 self.spec_fingerprint
261 ));
262 }
263 Ok(())
264 }
265
266 pub fn validate_compatibility(
267 &self,
268 task_kind: LearningTaskKind,
269 prediction_kind: PredictionKind,
270 ) -> Result<()> {
271 self.validate()?;
272 if !self.task_kinds.contains(&task_kind)
273 || !self.prediction_kinds.contains(&prediction_kind)
274 {
275 return contract_error(format!(
276 "loss `{}` is not compatible with task {task_kind:?} and prediction {prediction_kind:?}",
277 self.loss_id
278 ));
279 }
280 Ok(())
281 }
282}
283
284#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
285#[serde(deny_unknown_fields)]
286pub struct MetricSpec {
287 pub schema_version: u32,
288 pub metric_id: String,
289 pub kind: SemanticSpecKind,
290 pub task_kinds: BTreeSet<LearningTaskKind>,
291 pub prediction_kinds: BTreeSet<PredictionKind>,
292 pub objective: MetricObjective,
293 pub supported_levels: BTreeSet<PredictionLevel>,
294 pub decomposition: MetricDecomposition,
295 pub reduction: MetricReduction,
296 pub required_inputs: BTreeSet<CriterionInput>,
297 pub capabilities: BTreeSet<MetricCapability>,
298 pub parameters: serde_json::Value,
299 pub spec_fingerprint: String,
300}
301
302impl MetricSpec {
303 #[allow(clippy::too_many_arguments)]
304 pub fn new(
305 metric_id: impl Into<String>,
306 kind: SemanticSpecKind,
307 task_kinds: BTreeSet<LearningTaskKind>,
308 prediction_kinds: BTreeSet<PredictionKind>,
309 objective: MetricObjective,
310 supported_levels: BTreeSet<PredictionLevel>,
311 decomposition: MetricDecomposition,
312 reduction: MetricReduction,
313 required_inputs: BTreeSet<CriterionInput>,
314 capabilities: BTreeSet<MetricCapability>,
315 parameters: serde_json::Value,
316 ) -> Result<Self> {
317 let mut spec = Self {
318 schema_version: METRIC_SPEC_SCHEMA_VERSION,
319 metric_id: metric_id.into(),
320 kind,
321 task_kinds,
322 prediction_kinds,
323 objective,
324 supported_levels,
325 decomposition,
326 reduction,
327 required_inputs,
328 capabilities,
329 parameters,
330 spec_fingerprint: String::new(),
331 };
332 spec.spec_fingerprint = spec.compute_fingerprint()?;
333 spec.validate()?;
334 Ok(spec)
335 }
336
337 pub fn from_json(json: &str) -> Result<Self> {
338 let spec: Self =
339 deserialize_external_contract(json, "metric spec", DagMlError::CampaignValidation)?;
340 spec.validate()?;
341 Ok(spec)
342 }
343
344 pub fn compute_fingerprint(&self) -> Result<String> {
345 fingerprint_without(self, "spec_fingerprint", "metric spec")
346 }
347
348 pub fn validate(&self) -> Result<()> {
349 validate_schema_version(
350 "metric spec",
351 self.schema_version,
352 METRIC_SPEC_SCHEMA_VERSION,
353 )?;
354 validate_versioned_id("metric", &self.metric_id)?;
355 validate_nonempty_set("metric task_kinds", &self.task_kinds)?;
356 validate_nonempty_set("metric prediction_kinds", &self.prediction_kinds)?;
357 validate_nonempty_set("metric supported_levels", &self.supported_levels)?;
358 validate_required_target_prediction("metric", &self.required_inputs)?;
359 validate_parameters("metric", &self.parameters)?;
360 match (self.decomposition, self.reduction) {
361 (MetricDecomposition::Global, MetricReduction::Global) => {}
362 (MetricDecomposition::Global, _) => {
363 return contract_error("global metric decomposition requires global reduction");
364 }
365 (_, MetricReduction::Global) => {
366 return contract_error("decomposed metric cannot use global reduction");
367 }
368 _ => {}
369 }
370 if self.reduction == MetricReduction::WeightedMean
371 && !self.required_inputs.contains(&CriterionInput::SampleWeight)
372 {
373 return contract_error("weighted_mean metric requires sample_weight input");
374 }
375 if self.reduction == MetricReduction::WeightedMean
376 && self.decomposition != MetricDecomposition::PerUnit
377 {
378 return contract_error("weighted_mean metric requires per_unit decomposition");
379 }
380 if self.decomposition != MetricDecomposition::Global
381 && !self.capabilities.contains(&MetricCapability::Decomposable)
382 {
383 return contract_error("decomposed metric must declare decomposable capability");
384 }
385 if self.required_inputs.contains(&CriterionInput::SampleWeight)
386 && !self
387 .capabilities
388 .contains(&MetricCapability::SupportsSampleWeights)
389 {
390 return contract_error(
391 "metric requiring sample_weight must declare supports_sample_weights",
392 );
393 }
394 if self.required_inputs.contains(&CriterionInput::MissingMask)
395 && !self
396 .capabilities
397 .contains(&MetricCapability::SupportsMissingMask)
398 {
399 return contract_error(
400 "metric requiring missing_mask must declare supports_missing_mask",
401 );
402 }
403 validate_fingerprint("metric spec", &self.spec_fingerprint)?;
404 let expected = self.compute_fingerprint()?;
405 if self.spec_fingerprint != expected {
406 return contract_error(format!(
407 "metric spec fingerprint mismatch: declared {}, expected {expected}",
408 self.spec_fingerprint
409 ));
410 }
411 Ok(())
412 }
413
414 pub fn validate_compatibility(
415 &self,
416 task_kind: LearningTaskKind,
417 prediction_kind: PredictionKind,
418 level: PredictionLevel,
419 ) -> Result<()> {
420 self.validate()?;
421 if !self.task_kinds.contains(&task_kind)
422 || !self.prediction_kinds.contains(&prediction_kind)
423 || !self.supported_levels.contains(&level)
424 {
425 return contract_error(format!(
426 "metric `{}` is not compatible with task {task_kind:?}, prediction {prediction_kind:?}, and level {level:?}",
427 self.metric_id
428 ));
429 }
430 Ok(())
431 }
432}
433
434#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
435#[serde(deny_unknown_fields)]
436pub struct ImplementationDescriptor {
437 pub schema_version: u32,
438 pub semantic_kind: ImplementationSemanticKind,
439 pub semantic_id: String,
440 pub semantic_fingerprint: String,
441 pub provider_id: String,
442 pub binding_id: String,
443 pub implementation_version: String,
444 pub implementation_fingerprint: String,
445 pub supported_controller_families: BTreeSet<String>,
446 pub runtime_requirements: BTreeSet<String>,
447 pub capabilities: BTreeSet<ImplementationCapability>,
448 pub portability: PortabilityClass,
449 pub replayability: ReplayabilityClass,
450 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub registry_key: Option<String>,
452 pub descriptor_fingerprint: String,
453}
454
455impl ImplementationDescriptor {
456 #[allow(clippy::too_many_arguments)]
457 pub fn new(
458 semantic_kind: ImplementationSemanticKind,
459 semantic_id: impl Into<String>,
460 semantic_fingerprint: impl Into<String>,
461 provider_id: impl Into<String>,
462 binding_id: impl Into<String>,
463 implementation_version: impl Into<String>,
464 implementation_fingerprint: impl Into<String>,
465 supported_controller_families: BTreeSet<String>,
466 runtime_requirements: BTreeSet<String>,
467 capabilities: BTreeSet<ImplementationCapability>,
468 portability: PortabilityClass,
469 replayability: ReplayabilityClass,
470 registry_key: Option<String>,
471 ) -> Result<Self> {
472 let mut descriptor = Self {
473 schema_version: IMPLEMENTATION_DESCRIPTOR_SCHEMA_VERSION,
474 semantic_kind,
475 semantic_id: semantic_id.into(),
476 semantic_fingerprint: semantic_fingerprint.into(),
477 provider_id: provider_id.into(),
478 binding_id: binding_id.into(),
479 implementation_version: implementation_version.into(),
480 implementation_fingerprint: implementation_fingerprint.into(),
481 supported_controller_families,
482 runtime_requirements,
483 capabilities,
484 portability,
485 replayability,
486 registry_key,
487 descriptor_fingerprint: String::new(),
488 };
489 descriptor.descriptor_fingerprint = descriptor.compute_fingerprint()?;
490 descriptor.validate()?;
491 Ok(descriptor)
492 }
493
494 pub fn from_json(json: &str) -> Result<Self> {
495 let descriptor: Self = deserialize_external_contract(
496 json,
497 "implementation descriptor",
498 DagMlError::CampaignValidation,
499 )?;
500 descriptor.validate()?;
501 Ok(descriptor)
502 }
503
504 pub fn compute_fingerprint(&self) -> Result<String> {
505 fingerprint_without(self, "descriptor_fingerprint", "implementation descriptor")
506 }
507
508 pub fn validate(&self) -> Result<()> {
509 validate_schema_version(
510 "implementation descriptor",
511 self.schema_version,
512 IMPLEMENTATION_DESCRIPTOR_SCHEMA_VERSION,
513 )?;
514 validate_versioned_id("implementation semantic", &self.semantic_id)?;
515 validate_fingerprint("implementation semantic", &self.semantic_fingerprint)?;
516 validate_token("provider_id", &self.provider_id)?;
517 validate_token("binding_id", &self.binding_id)?;
518 validate_token("implementation_version", &self.implementation_version)?;
519 validate_fingerprint("implementation", &self.implementation_fingerprint)?;
520 validate_string_set(
521 "supported_controller_families",
522 &self.supported_controller_families,
523 )?;
524 validate_string_set("runtime_requirements", &self.runtime_requirements)?;
525 if let Some(registry_key) = &self.registry_key {
526 validate_token("registry_key", registry_key)?;
527 }
528 match self.portability {
529 PortabilityClass::HostLocal => {
530 if self.registry_key.is_none() {
531 return contract_error("host_local implementation requires registry_key");
532 }
533 if self.replayability == ReplayabilityClass::Detached {
534 return contract_error(
535 "host_local implementation cannot be detached-replayable",
536 );
537 }
538 }
539 PortabilityClass::PortableRegistered => {
540 if self.registry_key.is_none() {
541 return contract_error(
542 "portable_registered implementation requires registry_key",
543 );
544 }
545 if self.replayability != ReplayabilityClass::RegistryRequired {
546 return contract_error(
547 "portable_registered implementation requires registry_required replay",
548 );
549 }
550 }
551 PortabilityClass::PortableBuiltIn => {
552 if self.registry_key.is_some() {
553 return contract_error("portable_builtin implementation forbids registry_key");
554 }
555 if self.replayability != ReplayabilityClass::Detached {
556 return contract_error(
557 "portable_builtin implementation must be detached-replayable",
558 );
559 }
560 }
561 }
562 validate_fingerprint("implementation descriptor", &self.descriptor_fingerprint)?;
563 let expected = self.compute_fingerprint()?;
564 if self.descriptor_fingerprint != expected {
565 return contract_error(format!(
566 "implementation descriptor fingerprint mismatch: declared {}, expected {expected}",
567 self.descriptor_fingerprint
568 ));
569 }
570 Ok(())
571 }
572}
573
574#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
575#[serde(deny_unknown_fields)]
576pub struct LossReference {
577 pub spec: LossSpec,
578 pub implementation: ImplementationDescriptor,
579}
580
581impl LossReference {
582 pub fn validate(&self) -> Result<()> {
583 self.spec.validate()?;
584 self.implementation.validate()?;
585 validate_descriptor_semantic(
586 &self.implementation,
587 ImplementationSemanticKind::Loss,
588 &self.spec.loss_id,
589 &self.spec.spec_fingerprint,
590 )?;
591 for (required, provided, label) in [
592 (
593 self.spec
594 .capabilities
595 .contains(&LossCapability::Differentiable),
596 self.implementation
597 .capabilities
598 .contains(&ImplementationCapability::Differentiable),
599 "differentiable",
600 ),
601 (
602 self.spec
603 .capabilities
604 .contains(&LossCapability::SupportsSampleWeights),
605 self.implementation
606 .capabilities
607 .contains(&ImplementationCapability::SupportsSampleWeights),
608 "supports_sample_weights",
609 ),
610 (
611 self.spec
612 .capabilities
613 .contains(&LossCapability::SupportsMissingMask),
614 self.implementation
615 .capabilities
616 .contains(&ImplementationCapability::SupportsMissingMask),
617 "supports_missing_mask",
618 ),
619 (
620 self.spec
621 .capabilities
622 .contains(&LossCapability::DistributedReduction),
623 self.implementation
624 .capabilities
625 .contains(&ImplementationCapability::DistributedReduction),
626 "distributed_reduction",
627 ),
628 ] {
629 if required && !provided {
630 return contract_error(format!(
631 "loss implementation lacks required `{label}` capability"
632 ));
633 }
634 }
635 Ok(())
636 }
637}
638
639#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
640#[serde(deny_unknown_fields)]
641pub struct MetricReference {
642 pub spec: MetricSpec,
643 pub implementation: ImplementationDescriptor,
644}
645
646impl MetricReference {
647 pub fn validate(&self) -> Result<()> {
648 self.spec.validate()?;
649 self.implementation.validate()?;
650 validate_descriptor_semantic(
651 &self.implementation,
652 ImplementationSemanticKind::Metric,
653 &self.spec.metric_id,
654 &self.spec.spec_fingerprint,
655 )?;
656 for (required, provided, label) in [
657 (
658 self.spec
659 .capabilities
660 .contains(&MetricCapability::SupportsSampleWeights),
661 self.implementation
662 .capabilities
663 .contains(&ImplementationCapability::SupportsSampleWeights),
664 "supports_sample_weights",
665 ),
666 (
667 self.spec
668 .capabilities
669 .contains(&MetricCapability::SupportsMissingMask),
670 self.implementation
671 .capabilities
672 .contains(&ImplementationCapability::SupportsMissingMask),
673 "supports_missing_mask",
674 ),
675 (
676 self.spec
677 .capabilities
678 .contains(&MetricCapability::DistributedReduction),
679 self.implementation
680 .capabilities
681 .contains(&ImplementationCapability::DistributedReduction),
682 "distributed_reduction",
683 ),
684 ] {
685 if required && !provided {
686 return contract_error(format!(
687 "metric implementation lacks required `{label}` capability"
688 ));
689 }
690 }
691 Ok(())
692 }
693}
694
695#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
696#[serde(deny_unknown_fields)]
697pub struct TrainingLossRoleReference {
698 pub schema_version: u32,
699 pub node_id: NodeId,
700 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub output_id: Option<String>,
702 pub phases: BTreeSet<Phase>,
703 pub loss: LossReference,
704}
705
706impl TrainingLossRoleReference {
707 pub fn from_json(json: &str) -> Result<Self> {
708 let role: Self = deserialize_external_contract(
709 json,
710 "training loss role",
711 DagMlError::CampaignValidation,
712 )?;
713 role.validate()?;
714 Ok(role)
715 }
716
717 pub fn validate(&self) -> Result<()> {
718 validate_schema_version(
719 "training loss role",
720 self.schema_version,
721 LOSS_ROLE_SCHEMA_VERSION,
722 )?;
723 if let Some(output_id) = &self.output_id {
724 validate_token("loss output_id", output_id)?;
725 }
726 if self.phases.is_empty()
727 || self
728 .phases
729 .iter()
730 .any(|phase| !matches!(phase, Phase::FitCv | Phase::Refit))
731 {
732 return contract_error(
733 "training loss phases must be a non-empty subset of FIT_CV/REFIT",
734 );
735 }
736 self.loss.validate()
737 }
738}
739
740#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
741#[serde(deny_unknown_fields)]
742pub struct LossExecutionAttestation {
743 pub schema_version: u32,
744 pub node_id: NodeId,
745 #[serde(default, skip_serializing_if = "Option::is_none")]
746 pub output_id: Option<String>,
747 pub phase: Phase,
748 pub loss_id: String,
749 pub semantic_fingerprint: String,
750 pub implementation_fingerprint: String,
751 pub descriptor_fingerprint: String,
752 pub effective_parameters: serde_json::Value,
753 pub reduction: LossReduction,
754 pub attestation_fingerprint: String,
755}
756
757impl LossExecutionAttestation {
758 pub fn for_role(role: &TrainingLossRoleReference, phase: Phase) -> Result<Self> {
759 role.validate()?;
760 if !role.phases.contains(&phase) {
761 return contract_error(format!(
762 "training loss role for `{}` does not apply to phase {phase:?}",
763 role.node_id
764 ));
765 }
766 let mut attestation = Self {
767 schema_version: LOSS_EXECUTION_ATTESTATION_SCHEMA_VERSION,
768 node_id: role.node_id.clone(),
769 output_id: role.output_id.clone(),
770 phase,
771 loss_id: role.loss.spec.loss_id.clone(),
772 semantic_fingerprint: role.loss.spec.spec_fingerprint.clone(),
773 implementation_fingerprint: role.loss.implementation.implementation_fingerprint.clone(),
774 descriptor_fingerprint: role.loss.implementation.descriptor_fingerprint.clone(),
775 effective_parameters: role.loss.spec.parameters.clone(),
776 reduction: role.loss.spec.reduction,
777 attestation_fingerprint: String::new(),
778 };
779 attestation.attestation_fingerprint = attestation.compute_fingerprint()?;
780 attestation.validate_against(role, &attestation.node_id, phase)?;
781 Ok(attestation)
782 }
783
784 pub fn from_json(json: &str) -> Result<Self> {
785 let attestation: Self = deserialize_external_contract(
786 json,
787 "loss execution attestation",
788 DagMlError::RuntimeValidation,
789 )?;
790 attestation.validate()?;
791 Ok(attestation)
792 }
793
794 pub fn compute_fingerprint(&self) -> Result<String> {
795 fingerprint_without(
796 self,
797 "attestation_fingerprint",
798 "loss execution attestation",
799 )
800 }
801
802 pub fn validate(&self) -> Result<()> {
803 validate_schema_version(
804 "loss execution attestation",
805 self.schema_version,
806 LOSS_EXECUTION_ATTESTATION_SCHEMA_VERSION,
807 )?;
808 if let Some(output_id) = &self.output_id {
809 validate_token("loss attestation output_id", output_id)?;
810 }
811 if !matches!(self.phase, Phase::FitCv | Phase::Refit) {
812 return contract_error("loss execution attestation phase must be FIT_CV or REFIT");
813 }
814 validate_versioned_id("loss attestation", &self.loss_id)?;
815 validate_fingerprint(
816 "loss attestation semantic fingerprint",
817 &self.semantic_fingerprint,
818 )?;
819 validate_fingerprint(
820 "loss attestation implementation fingerprint",
821 &self.implementation_fingerprint,
822 )?;
823 validate_fingerprint(
824 "loss attestation descriptor fingerprint",
825 &self.descriptor_fingerprint,
826 )?;
827 validate_parameters("loss attestation", &self.effective_parameters)?;
828 validate_fingerprint("loss execution attestation", &self.attestation_fingerprint)?;
829 let expected = self.compute_fingerprint()?;
830 if self.attestation_fingerprint != expected {
831 return contract_error(format!(
832 "loss execution attestation fingerprint mismatch: declared {}, expected {expected}",
833 self.attestation_fingerprint
834 ));
835 }
836 Ok(())
837 }
838
839 pub fn validate_against(
840 &self,
841 role: &TrainingLossRoleReference,
842 node_id: &NodeId,
843 phase: Phase,
844 ) -> Result<()> {
845 self.validate()?;
846 role.validate()?;
847 if self.node_id != *node_id
848 || role.node_id != *node_id
849 || self.output_id != role.output_id
850 || self.phase != phase
851 || !role.phases.contains(&phase)
852 || self.loss_id != role.loss.spec.loss_id
853 || self.semantic_fingerprint != role.loss.spec.spec_fingerprint
854 || self.implementation_fingerprint
855 != role.loss.implementation.implementation_fingerprint
856 || self.descriptor_fingerprint != role.loss.implementation.descriptor_fingerprint
857 || self.effective_parameters != role.loss.spec.parameters
858 || self.reduction != role.loss.spec.reduction
859 {
860 return Err(DagMlError::RuntimeValidation(format!(
861 "loss execution attestation does not match the resolved {phase:?} loss for node `{node_id}`"
862 )));
863 }
864 Ok(())
865 }
866}
867
868#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
869#[serde(rename_all = "snake_case")]
870pub enum MetricRoleKind {
871 EarlyStopping,
872 Selection,
873 Reporting,
874 Tuning,
875 Pruning,
876 Threshold,
877 EnsembleWeighting,
878}
879
880#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
881#[serde(rename_all = "snake_case")]
882pub enum MissingMetricPolicy {
883 Error,
884 Skip,
885}
886
887#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
888#[serde(deny_unknown_fields)]
889pub struct MetricRoleReference {
890 pub schema_version: u32,
891 pub role_id: String,
892 pub role: MetricRoleKind,
893 #[serde(default, skip_serializing_if = "Option::is_none")]
894 pub output_id: Option<String>,
895 pub partition: PredictionPartition,
896 pub level: PredictionLevel,
897 pub missing_value_policy: MissingMetricPolicy,
898 pub metric: MetricReference,
899}
900
901impl MetricRoleReference {
902 pub fn from_json(json: &str) -> Result<Self> {
903 let role: Self =
904 deserialize_external_contract(json, "metric role", DagMlError::CampaignValidation)?;
905 role.validate()?;
906 Ok(role)
907 }
908
909 pub fn validate(&self) -> Result<()> {
910 validate_schema_version(
911 "metric role",
912 self.schema_version,
913 METRIC_ROLE_SCHEMA_VERSION,
914 )?;
915 validate_token("metric role_id", &self.role_id)?;
916 if let Some(output_id) = &self.output_id {
917 validate_token("metric output_id", output_id)?;
918 }
919 if self.missing_value_policy == MissingMetricPolicy::Skip
920 && self.role != MetricRoleKind::Reporting
921 {
922 return contract_error("only reporting metrics may skip missing values");
923 }
924 if !self.metric.spec.supported_levels.contains(&self.level) {
925 return contract_error(format!(
926 "metric role level {:?} is not supported by `{}`",
927 self.level, self.metric.spec.metric_id
928 ));
929 }
930 self.metric.validate()
931 }
932}
933
934#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
937#[serde(deny_unknown_fields)]
938pub struct EarlyStoppingRecord {
939 pub schema_version: u32,
940 pub node_id: NodeId,
941 pub phase: Phase,
942 #[serde(default, skip_serializing_if = "Option::is_none")]
943 pub fold_id: Option<FoldId>,
944 pub metric_role: MetricRoleReference,
945 pub best_iteration: u64,
947 pub observed_iterations: u64,
949 pub best_value: f64,
950 pub stopped_early: bool,
951 pub record_fingerprint: String,
952}
953
954impl EarlyStoppingRecord {
955 #[allow(clippy::too_many_arguments)]
956 pub fn new(
957 node_id: NodeId,
958 phase: Phase,
959 fold_id: Option<FoldId>,
960 metric_role: MetricRoleReference,
961 best_iteration: u64,
962 observed_iterations: u64,
963 best_value: f64,
964 stopped_early: bool,
965 ) -> Result<Self> {
966 let mut record = Self {
967 schema_version: EARLY_STOPPING_RECORD_SCHEMA_VERSION,
968 node_id,
969 phase,
970 fold_id,
971 metric_role,
972 best_iteration,
973 observed_iterations,
974 best_value,
975 stopped_early,
976 record_fingerprint: String::new(),
977 };
978 record.record_fingerprint = record.compute_fingerprint()?;
979 record.validate()?;
980 Ok(record)
981 }
982
983 pub fn from_json(json: &str) -> Result<Self> {
984 let record: Self = deserialize_external_contract(
985 json,
986 "early stopping record",
987 DagMlError::RuntimeValidation,
988 )?;
989 record.validate()?;
990 Ok(record)
991 }
992
993 pub fn compute_fingerprint(&self) -> Result<String> {
994 fingerprint_without(self, "record_fingerprint", "early stopping record")
995 }
996
997 pub fn validate(&self) -> Result<()> {
998 validate_schema_version(
999 "early stopping record",
1000 self.schema_version,
1001 EARLY_STOPPING_RECORD_SCHEMA_VERSION,
1002 )?;
1003 if !matches!(self.phase, Phase::FitCv | Phase::Refit) {
1004 return contract_error("early stopping phase must be FIT_CV or REFIT");
1005 }
1006 match (self.phase, self.fold_id.as_ref()) {
1007 (Phase::FitCv, None) => {
1008 return contract_error("FIT_CV early stopping requires fold_id")
1009 }
1010 (Phase::Refit, Some(_)) => {
1011 return contract_error("REFIT early stopping must not declare fold_id")
1012 }
1013 _ => {}
1014 }
1015 self.metric_role.validate()?;
1016 if self.metric_role.role != MetricRoleKind::EarlyStopping {
1017 return contract_error("early stopping record requires an early_stopping metric role");
1018 }
1019 if self.metric_role.partition != PredictionPartition::Validation {
1020 return contract_error(
1021 "early stopping metric role must monitor validation predictions",
1022 );
1023 }
1024 if self.observed_iterations == 0 || self.best_iteration >= self.observed_iterations {
1025 return contract_error(
1026 "early stopping best_iteration must be below non-zero observed_iterations",
1027 );
1028 }
1029 if !self.best_value.is_finite() {
1030 return contract_error("early stopping best_value must be finite");
1031 }
1032 validate_fingerprint("early stopping record", &self.record_fingerprint)?;
1033 let expected = self.compute_fingerprint()?;
1034 if self.record_fingerprint != expected {
1035 return contract_error(format!(
1036 "early stopping record fingerprint mismatch: declared {}, expected {expected}",
1037 self.record_fingerprint
1038 ));
1039 }
1040 Ok(())
1041 }
1042
1043 pub fn validate_against(
1044 &self,
1045 node_id: &NodeId,
1046 phase: Phase,
1047 fold_id: Option<&FoldId>,
1048 ) -> Result<()> {
1049 self.validate()?;
1050 if &self.node_id != node_id || self.phase != phase || self.fold_id.as_ref() != fold_id {
1051 return contract_error("early stopping record does not match lineage task scope");
1052 }
1053 Ok(())
1054 }
1055}
1056
1057#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
1058#[serde(rename_all = "snake_case")]
1059pub enum LossResolutionSource {
1060 ExplicitNodeOutput,
1061 ControllerProfile,
1062 CampaignDefault,
1063 TaskFamilyDefault,
1064}
1065
1066#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1067#[serde(deny_unknown_fields)]
1068pub struct LossResolutionRequest {
1069 pub task_kind: LearningTaskKind,
1070 pub prediction_kind: PredictionKind,
1071 #[serde(default, skip_serializing_if = "Option::is_none")]
1072 pub explicit_node_output: Option<LossSpec>,
1073 #[serde(default, skip_serializing_if = "Option::is_none")]
1074 pub controller_profile: Option<LossSpec>,
1075 #[serde(default, skip_serializing_if = "Option::is_none")]
1076 pub campaign_default: Option<LossSpec>,
1077 #[serde(default, skip_serializing_if = "Option::is_none")]
1078 pub task_family_default: Option<LossSpec>,
1079}
1080
1081#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1082#[serde(deny_unknown_fields)]
1083pub struct ResolvedLossSpec {
1084 pub source: LossResolutionSource,
1085 pub spec: LossSpec,
1086}
1087
1088impl LossResolutionRequest {
1089 pub fn resolve(&self) -> Result<ResolvedLossSpec> {
1090 let candidates = [
1091 (
1092 LossResolutionSource::ExplicitNodeOutput,
1093 self.explicit_node_output.as_ref(),
1094 ),
1095 (
1096 LossResolutionSource::ControllerProfile,
1097 self.controller_profile.as_ref(),
1098 ),
1099 (
1100 LossResolutionSource::CampaignDefault,
1101 self.campaign_default.as_ref(),
1102 ),
1103 (
1104 LossResolutionSource::TaskFamilyDefault,
1105 self.task_family_default.as_ref(),
1106 ),
1107 ];
1108 let Some((source, spec)) = candidates
1109 .into_iter()
1110 .find_map(|(source, spec)| spec.map(|spec| (source, spec)))
1111 else {
1112 return contract_error(format!(
1113 "no loss resolves for task {:?} and prediction {:?}",
1114 self.task_kind, self.prediction_kind
1115 ));
1116 };
1117 spec.validate_compatibility(self.task_kind, self.prediction_kind)?;
1118 Ok(ResolvedLossSpec {
1119 source,
1120 spec: spec.clone(),
1121 })
1122 }
1123}
1124
1125pub fn builtin_loss_catalog() -> Result<BTreeMap<String, LossSpec>> {
1126 let target_prediction = BTreeSet::from([CriterionInput::Target, CriterionInput::Prediction]);
1127 let differentiable = BTreeSet::from([LossCapability::Differentiable]);
1128 let regression = LossSpec::new(
1129 "dagml.loss.mse@1",
1130 SemanticSpecKind::BuiltIn,
1131 BTreeSet::from([LearningTaskKind::Regression]),
1132 BTreeSet::from([PredictionKind::RegressionPoint]),
1133 LossReduction::Mean,
1134 target_prediction.clone(),
1135 differentiable.clone(),
1136 empty_parameters(),
1137 )?;
1138 let binary = LossSpec::new(
1139 "dagml.loss.binary_cross_entropy@1",
1140 SemanticSpecKind::BuiltIn,
1141 BTreeSet::from([
1142 LearningTaskKind::BinaryClassification,
1143 LearningTaskKind::MultilabelClassification,
1144 ]),
1145 BTreeSet::from([PredictionKind::ClassLabel, PredictionKind::ClassProbability]),
1146 LossReduction::Mean,
1147 target_prediction.clone(),
1148 differentiable.clone(),
1149 empty_parameters(),
1150 )?;
1151 let multiclass = LossSpec::new(
1152 "dagml.loss.sparse_categorical_cross_entropy@1",
1153 SemanticSpecKind::BuiltIn,
1154 BTreeSet::from([LearningTaskKind::MulticlassClassification]),
1155 BTreeSet::from([PredictionKind::ClassLabel, PredictionKind::ClassProbability]),
1156 LossReduction::Mean,
1157 target_prediction,
1158 differentiable,
1159 empty_parameters(),
1160 )?;
1161 Ok([regression, binary, multiclass]
1162 .into_iter()
1163 .map(|spec| (spec.loss_id.clone(), spec))
1164 .collect())
1165}
1166
1167pub fn builtin_metric_catalog() -> Result<BTreeMap<String, MetricSpec>> {
1168 let all_levels = BTreeSet::from([
1169 PredictionLevel::Observation,
1170 PredictionLevel::Sample,
1171 PredictionLevel::Target,
1172 PredictionLevel::Group,
1173 ]);
1174 let target_prediction = BTreeSet::from([CriterionInput::Target, CriterionInput::Prediction]);
1175 let decomposable = BTreeSet::from([MetricCapability::Decomposable]);
1176 let mut specs = Vec::new();
1177 for (name, objective) in [
1178 ("mse", MetricObjective::Minimize),
1179 ("rmse", MetricObjective::Minimize),
1180 ("mae", MetricObjective::Minimize),
1181 ("r2", MetricObjective::Maximize),
1182 ] {
1183 specs.push(MetricSpec::new(
1184 format!("dagml.metric.{name}@1"),
1185 SemanticSpecKind::BuiltIn,
1186 BTreeSet::from([LearningTaskKind::Regression]),
1187 BTreeSet::from([PredictionKind::RegressionPoint]),
1188 objective,
1189 all_levels.clone(),
1190 MetricDecomposition::PerOutput,
1191 MetricReduction::Mean,
1192 target_prediction.clone(),
1193 decomposable.clone(),
1194 empty_parameters(),
1195 )?);
1196 }
1197 for (name, tasks) in [
1198 (
1199 "accuracy",
1200 BTreeSet::from([
1201 LearningTaskKind::BinaryClassification,
1202 LearningTaskKind::MulticlassClassification,
1203 LearningTaskKind::MultilabelClassification,
1204 ]),
1205 ),
1206 (
1207 "balanced_accuracy",
1208 BTreeSet::from([
1209 LearningTaskKind::BinaryClassification,
1210 LearningTaskKind::MulticlassClassification,
1211 ]),
1212 ),
1213 ] {
1214 specs.push(MetricSpec::new(
1215 format!("dagml.metric.{name}@1"),
1216 SemanticSpecKind::BuiltIn,
1217 tasks,
1218 BTreeSet::from([PredictionKind::ClassLabel]),
1219 MetricObjective::Maximize,
1220 all_levels.clone(),
1221 MetricDecomposition::PerOutput,
1222 MetricReduction::Mean,
1223 target_prediction.clone(),
1224 decomposable.clone(),
1225 empty_parameters(),
1226 )?);
1227 }
1228 Ok(specs
1229 .into_iter()
1230 .map(|spec| (spec.metric_id.clone(), spec))
1231 .collect())
1232}
1233
1234fn empty_parameters() -> serde_json::Value {
1235 serde_json::Value::Object(serde_json::Map::new())
1236}
1237
1238fn validate_schema_version(label: &str, actual: u32, expected: u32) -> Result<()> {
1239 if actual != expected {
1240 return contract_error(format!(
1241 "{label} schema_version {actual} is unsupported (expected {expected})"
1242 ));
1243 }
1244 Ok(())
1245}
1246
1247fn validate_versioned_id(label: &str, value: &str) -> Result<()> {
1248 validate_token(&format!("{label}_id"), value)?;
1249 let Some((base, version)) = value.rsplit_once('@') else {
1250 return contract_error(format!("{label} id `{value}` is not versioned"));
1251 };
1252 if base.is_empty()
1253 || version.is_empty()
1254 || version.starts_with('0')
1255 || !version.bytes().all(|byte| byte.is_ascii_digit())
1256 || base.contains('@')
1257 {
1258 return contract_error(format!(
1259 "{label} id `{value}` must end in exactly one positive `@<version>` suffix"
1260 ));
1261 }
1262 Ok(())
1263}
1264
1265pub(crate) fn validate_token(label: &str, value: &str) -> Result<()> {
1266 if value.is_empty()
1267 || value.trim() != value
1268 || value.chars().any(char::is_whitespace)
1269 || value.chars().any(char::is_control)
1270 {
1271 return contract_error(format!("{label} must be non-blank canonical text"));
1272 }
1273 Ok(())
1274}
1275
1276fn validate_string_set(label: &str, values: &BTreeSet<String>) -> Result<()> {
1277 for value in values {
1278 validate_token(label, value)?;
1279 }
1280 Ok(())
1281}
1282
1283fn validate_nonempty_set<T>(label: &str, values: &BTreeSet<T>) -> Result<()> {
1284 if values.is_empty() {
1285 return contract_error(format!("{label} must be non-empty"));
1286 }
1287 Ok(())
1288}
1289
1290fn validate_required_target_prediction(
1291 label: &str,
1292 required_inputs: &BTreeSet<CriterionInput>,
1293) -> Result<()> {
1294 if !required_inputs.contains(&CriterionInput::Target)
1295 || !required_inputs.contains(&CriterionInput::Prediction)
1296 {
1297 return contract_error(format!("{label} requires target and prediction inputs"));
1298 }
1299 Ok(())
1300}
1301
1302fn validate_parameters(label: &str, parameters: &serde_json::Value) -> Result<()> {
1303 if !parameters.is_object() {
1304 return contract_error(format!("{label} parameters must be a JSON object"));
1305 }
1306 validate_typed_serde_value(parameters).map_err(|error| {
1307 DagMlError::CampaignValidation(format!(
1308 "{label} parameters are outside strict TCV1: {error}"
1309 ))
1310 })?;
1311 validate_no_executable_payload(parameters, &format!("{label}.parameters"))
1312}
1313
1314fn validate_no_executable_payload(value: &serde_json::Value, path: &str) -> Result<()> {
1315 match value {
1316 serde_json::Value::Object(entries) => {
1317 for (key, value) in entries {
1318 let normalized_key = key.to_ascii_lowercase();
1319 if FORBIDDEN_EXECUTABLE_KEYS.contains(&normalized_key.as_str()) {
1320 return contract_error(format!(
1321 "{path}.{key} is an executable-code payload field"
1322 ));
1323 }
1324 validate_no_executable_payload(value, &format!("{path}.{key}"))?;
1325 }
1326 }
1327 serde_json::Value::Array(values) => {
1328 for (index, value) in values.iter().enumerate() {
1329 validate_no_executable_payload(value, &format!("{path}[{index}]"))?;
1330 }
1331 }
1332 _ => {}
1333 }
1334 Ok(())
1335}
1336
1337fn validate_descriptor_semantic(
1338 descriptor: &ImplementationDescriptor,
1339 expected_kind: ImplementationSemanticKind,
1340 expected_id: &str,
1341 expected_fingerprint: &str,
1342) -> Result<()> {
1343 if descriptor.semantic_kind != expected_kind
1344 || descriptor.semantic_id != expected_id
1345 || descriptor.semantic_fingerprint != expected_fingerprint
1346 {
1347 return contract_error(format!(
1348 "implementation descriptor semantic identity does not match {expected_kind:?} `{expected_id}`"
1349 ));
1350 }
1351 Ok(())
1352}
1353
1354pub(crate) fn validate_fingerprint(label: &str, value: &str) -> Result<()> {
1355 if value.len() != FINGERPRINT_LEN
1356 || !value
1357 .bytes()
1358 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1359 {
1360 return contract_error(format!("{label} fingerprint must be lowercase sha256 hex"));
1361 }
1362 Ok(())
1363}
1364
1365pub(crate) fn fingerprint_without<T: Serialize>(
1366 value: &T,
1367 field: &str,
1368 label: &str,
1369) -> Result<String> {
1370 let json = serde_json::to_string(value)?;
1371 parse_typed_json(&json)
1372 .and_then(|value| value.fingerprint_without(field))
1373 .map_err(|error| {
1374 DagMlError::CampaignValidation(format!(
1375 "cannot compute {label} TCV1 fingerprint: {error}"
1376 ))
1377 })
1378}
1379
1380fn contract_error<T>(message: impl Into<String>) -> Result<T> {
1381 Err(DagMlError::CampaignValidation(message.into()))
1382}
1383
1384#[cfg(test)]
1385mod tests {
1386 use serde_json::json;
1387
1388 use super::*;
1389
1390 fn custom_loss() -> LossSpec {
1391 LossSpec::new(
1392 "example.loss.asymmetric@1",
1393 SemanticSpecKind::Custom,
1394 BTreeSet::from([LearningTaskKind::Regression]),
1395 BTreeSet::from([PredictionKind::RegressionPoint]),
1396 LossReduction::Mean,
1397 BTreeSet::from([CriterionInput::Target, CriterionInput::Prediction]),
1398 BTreeSet::from([LossCapability::Differentiable]),
1399 json!({"under_weight": 2.0, "over_weight": 1.0}),
1400 )
1401 .unwrap()
1402 }
1403
1404 fn host_local_descriptor(
1405 kind: ImplementationSemanticKind,
1406 semantic_id: &str,
1407 semantic_fingerprint: &str,
1408 ) -> ImplementationDescriptor {
1409 ImplementationDescriptor::new(
1410 kind,
1411 semantic_id,
1412 semantic_fingerprint,
1413 "provider:python-local",
1414 "binding:python",
1415 "1.0.0",
1416 "1f4c71b0b758c5ed25b4e38b132b9ad56fb2f5ff2cf490f7eb8786c4350a62f7",
1417 BTreeSet::new(),
1418 BTreeSet::new(),
1419 BTreeSet::from([
1420 ImplementationCapability::Deterministic,
1421 ImplementationCapability::Differentiable,
1422 ImplementationCapability::NeedsGil,
1423 ]),
1424 PortabilityClass::HostLocal,
1425 ReplayabilityClass::RegistryRequired,
1426 Some("loss:run-123:asymmetric".to_string()),
1427 )
1428 .unwrap()
1429 }
1430
1431 fn early_stopping_metric_role() -> MetricRoleReference {
1432 let metric = builtin_metric_catalog().unwrap()["dagml.metric.rmse@1"].clone();
1433 MetricRoleReference {
1434 schema_version: METRIC_ROLE_SCHEMA_VERSION,
1435 role_id: "early-stopping:rmse".to_string(),
1436 role: MetricRoleKind::EarlyStopping,
1437 output_id: Some("prediction".to_string()),
1438 partition: PredictionPartition::Validation,
1439 level: PredictionLevel::Sample,
1440 missing_value_policy: MissingMetricPolicy::Error,
1441 metric: MetricReference {
1442 implementation: ImplementationDescriptor::new(
1443 ImplementationSemanticKind::Metric,
1444 &metric.metric_id,
1445 &metric.spec_fingerprint,
1446 "provider:dag-ml-core",
1447 "binding:rust",
1448 "1.0.0",
1449 "4991854599d650fd613dfd02b10d90a649ad7fec85f20a027d5e7b2a553f628b",
1450 BTreeSet::new(),
1451 BTreeSet::new(),
1452 BTreeSet::from([ImplementationCapability::Deterministic]),
1453 PortabilityClass::PortableBuiltIn,
1454 ReplayabilityClass::Detached,
1455 None,
1456 )
1457 .unwrap(),
1458 spec: metric,
1459 },
1460 }
1461 }
1462
1463 #[test]
1464 fn built_in_catalogs_are_versioned_and_self_fingerprinted() {
1465 let losses = builtin_loss_catalog().unwrap();
1466 assert_eq!(losses.len(), 3);
1467 assert!(losses.contains_key("dagml.loss.mse@1"));
1468 assert!(losses.values().all(|spec| spec.validate().is_ok()));
1469
1470 let metrics = builtin_metric_catalog().unwrap();
1471 assert_eq!(metrics.len(), 6);
1472 assert_eq!(
1473 metrics["dagml.metric.rmse@1"].objective,
1474 MetricObjective::Minimize
1475 );
1476 assert_eq!(
1477 metrics["dagml.metric.balanced_accuracy@1"].objective,
1478 MetricObjective::Maximize
1479 );
1480 assert!(metrics.values().all(|spec| spec.validate().is_ok()));
1481 }
1482
1483 #[test]
1484 fn loss_resolution_uses_declared_priority_and_never_falls_back_on_incompatibility() {
1485 let builtins = builtin_loss_catalog().unwrap();
1486 let explicit = custom_loss();
1487 let request = LossResolutionRequest {
1488 task_kind: LearningTaskKind::Regression,
1489 prediction_kind: PredictionKind::RegressionPoint,
1490 explicit_node_output: Some(explicit.clone()),
1491 controller_profile: Some(builtins["dagml.loss.mse@1"].clone()),
1492 campaign_default: None,
1493 task_family_default: None,
1494 };
1495 let resolved = request.resolve().unwrap();
1496 assert_eq!(resolved.source, LossResolutionSource::ExplicitNodeOutput);
1497 assert_eq!(resolved.spec, explicit);
1498
1499 let mut incompatible = request;
1500 incompatible.task_kind = LearningTaskKind::BinaryClassification;
1501 let error = incompatible.resolve().unwrap_err().to_string();
1502 assert!(error.contains("not compatible"));
1503 }
1504
1505 #[test]
1506 fn empty_unversioned_and_tampered_loss_specs_are_rejected() {
1507 let mut spec = custom_loss();
1508 spec.loss_id = "example.loss.asymmetric".to_string();
1509 assert!(spec
1510 .validate()
1511 .unwrap_err()
1512 .to_string()
1513 .contains("not versioned"));
1514
1515 let mut spec = custom_loss();
1516 spec.task_kinds.clear();
1517 assert!(spec
1518 .validate()
1519 .unwrap_err()
1520 .to_string()
1521 .contains("non-empty"));
1522
1523 let mut spec = custom_loss();
1524 spec.spec_fingerprint = "0".repeat(64);
1525 assert!(spec
1526 .validate()
1527 .unwrap_err()
1528 .to_string()
1529 .contains("fingerprint mismatch"));
1530 }
1531
1532 #[test]
1533 fn versioned_ids_are_canonical_decimal_without_an_artificial_width_limit() {
1534 let mut leading_zero = custom_loss();
1535 leading_zero.loss_id = "example.loss.asymmetric@01".to_string();
1536 leading_zero.spec_fingerprint = leading_zero.compute_fingerprint().unwrap();
1537 assert!(leading_zero
1538 .validate()
1539 .unwrap_err()
1540 .to_string()
1541 .contains("positive `@<version>` suffix"));
1542
1543 let mut large_version = custom_loss();
1544 large_version.loss_id = "example.loss.asymmetric@4294967296".to_string();
1545 large_version.spec_fingerprint = large_version.compute_fingerprint().unwrap();
1546 large_version.validate().unwrap();
1547 }
1548
1549 #[test]
1550 fn executable_payloads_and_nfc_colliding_parameters_are_rejected() {
1551 let error = LossSpec::new(
1552 "example.loss.code@1",
1553 SemanticSpecKind::Custom,
1554 BTreeSet::from([LearningTaskKind::Regression]),
1555 BTreeSet::from([PredictionKind::RegressionPoint]),
1556 LossReduction::Mean,
1557 BTreeSet::from([CriterionInput::Target, CriterionInput::Prediction]),
1558 BTreeSet::new(),
1559 json!({"callable": "lambda y, p: 0"}),
1560 )
1561 .unwrap_err()
1562 .to_string();
1563 assert!(error.contains("executable-code payload"));
1564
1565 let mut values = serde_json::Map::new();
1566 values.insert("é".to_string(), json!(1));
1567 values.insert("e\u{301}".to_string(), json!(2));
1568 let error = LossSpec::new(
1569 "example.loss.nfc@1",
1570 SemanticSpecKind::Custom,
1571 BTreeSet::from([LearningTaskKind::Regression]),
1572 BTreeSet::from([PredictionKind::RegressionPoint]),
1573 LossReduction::Mean,
1574 BTreeSet::from([CriterionInput::Target, CriterionInput::Prediction]),
1575 BTreeSet::new(),
1576 serde_json::Value::Object(values),
1577 )
1578 .unwrap_err()
1579 .to_string();
1580 assert!(error.contains("NFC-colliding"));
1581 }
1582
1583 #[test]
1584 fn reduction_and_input_capabilities_are_consistent() {
1585 let error = LossSpec::new(
1586 "example.loss.weighted@1",
1587 SemanticSpecKind::Custom,
1588 BTreeSet::from([LearningTaskKind::Regression]),
1589 BTreeSet::from([PredictionKind::RegressionPoint]),
1590 LossReduction::WeightedMean,
1591 BTreeSet::from([CriterionInput::Target, CriterionInput::Prediction]),
1592 BTreeSet::new(),
1593 empty_parameters(),
1594 )
1595 .unwrap_err()
1596 .to_string();
1597 assert!(error.contains("requires sample_weight"));
1598
1599 let error = MetricSpec::new(
1600 "example.metric.bad-decomposition@1",
1601 SemanticSpecKind::Custom,
1602 BTreeSet::from([LearningTaskKind::Regression]),
1603 BTreeSet::from([PredictionKind::RegressionPoint]),
1604 MetricObjective::Minimize,
1605 BTreeSet::from([PredictionLevel::Sample]),
1606 MetricDecomposition::PerUnit,
1607 MetricReduction::Global,
1608 BTreeSet::from([CriterionInput::Target, CriterionInput::Prediction]),
1609 BTreeSet::from([MetricCapability::Decomposable]),
1610 empty_parameters(),
1611 )
1612 .unwrap_err()
1613 .to_string();
1614 assert!(error.contains("cannot use global reduction"));
1615
1616 let error = MetricSpec::new(
1617 "example.metric.bad-weighted-output@1",
1618 SemanticSpecKind::Custom,
1619 BTreeSet::from([LearningTaskKind::Regression]),
1620 BTreeSet::from([PredictionKind::RegressionPoint]),
1621 MetricObjective::Minimize,
1622 BTreeSet::from([PredictionLevel::Sample]),
1623 MetricDecomposition::PerOutput,
1624 MetricReduction::WeightedMean,
1625 BTreeSet::from([
1626 CriterionInput::Target,
1627 CriterionInput::Prediction,
1628 CriterionInput::SampleWeight,
1629 ]),
1630 BTreeSet::from([
1631 MetricCapability::Decomposable,
1632 MetricCapability::SupportsSampleWeights,
1633 ]),
1634 empty_parameters(),
1635 )
1636 .unwrap_err()
1637 .to_string();
1638 assert!(error.contains("requires per_unit decomposition"));
1639 }
1640
1641 #[test]
1642 fn one_descriptor_contract_binds_loss_and_metric_without_merging_semantics() {
1643 let loss = custom_loss();
1644 let loss_reference = LossReference {
1645 implementation: host_local_descriptor(
1646 ImplementationSemanticKind::Loss,
1647 &loss.loss_id,
1648 &loss.spec_fingerprint,
1649 ),
1650 spec: loss,
1651 };
1652 loss_reference.validate().unwrap();
1653
1654 let metric = MetricSpec::new(
1655 "example.metric.bias@1",
1656 SemanticSpecKind::Custom,
1657 BTreeSet::from([LearningTaskKind::Regression]),
1658 BTreeSet::from([PredictionKind::RegressionPoint]),
1659 MetricObjective::Minimize,
1660 BTreeSet::from([PredictionLevel::Sample]),
1661 MetricDecomposition::Global,
1662 MetricReduction::Global,
1663 BTreeSet::from([CriterionInput::Target, CriterionInput::Prediction]),
1664 BTreeSet::new(),
1665 empty_parameters(),
1666 )
1667 .unwrap();
1668 let metric_reference = MetricReference {
1669 implementation: host_local_descriptor(
1670 ImplementationSemanticKind::Metric,
1671 &metric.metric_id,
1672 &metric.spec_fingerprint,
1673 ),
1674 spec: metric,
1675 };
1676 metric_reference.validate().unwrap();
1677 assert_ne!(
1678 loss_reference.implementation.semantic_kind,
1679 metric_reference.implementation.semantic_kind
1680 );
1681 }
1682
1683 #[test]
1684 fn local_descriptor_serializes_only_an_opaque_registry_key() {
1685 let loss = custom_loss();
1686 let descriptor = host_local_descriptor(
1687 ImplementationSemanticKind::Loss,
1688 &loss.loss_id,
1689 &loss.spec_fingerprint,
1690 );
1691 let serialized = serde_json::to_string(&descriptor).unwrap();
1692 assert!(serialized.contains("loss:run-123:asymmetric"));
1693 for forbidden in FORBIDDEN_EXECUTABLE_KEYS {
1694 assert!(!serialized.contains(&format!("\"{forbidden}\"")));
1695 }
1696 }
1697
1698 #[test]
1699 fn semantic_reference_rejects_wrong_kind_or_fingerprint() {
1700 let loss = custom_loss();
1701 let mut descriptor = host_local_descriptor(
1702 ImplementationSemanticKind::Loss,
1703 &loss.loss_id,
1704 &loss.spec_fingerprint,
1705 );
1706 descriptor.semantic_kind = ImplementationSemanticKind::Metric;
1707 descriptor.descriptor_fingerprint = descriptor.compute_fingerprint().unwrap();
1708 let error = LossReference {
1709 spec: loss,
1710 implementation: descriptor,
1711 }
1712 .validate()
1713 .unwrap_err()
1714 .to_string();
1715 assert!(error.contains("semantic identity"));
1716 }
1717
1718 #[test]
1719 fn roles_keep_training_loss_and_metric_policy_distinct() {
1720 let loss = custom_loss();
1721 let loss_role = TrainingLossRoleReference {
1722 schema_version: LOSS_ROLE_SCHEMA_VERSION,
1723 node_id: NodeId::new("model:custom").unwrap(),
1724 output_id: Some("prediction".to_string()),
1725 phases: BTreeSet::from([Phase::FitCv, Phase::Refit]),
1726 loss: LossReference {
1727 implementation: host_local_descriptor(
1728 ImplementationSemanticKind::Loss,
1729 &loss.loss_id,
1730 &loss.spec_fingerprint,
1731 ),
1732 spec: loss,
1733 },
1734 };
1735 loss_role.validate().unwrap();
1736
1737 let metric = builtin_metric_catalog().unwrap()["dagml.metric.rmse@1"].clone();
1738 let metric_role = MetricRoleReference {
1739 schema_version: METRIC_ROLE_SCHEMA_VERSION,
1740 role_id: "selection:rmse".to_string(),
1741 role: MetricRoleKind::Selection,
1742 output_id: Some("prediction".to_string()),
1743 partition: PredictionPartition::Validation,
1744 level: PredictionLevel::Sample,
1745 missing_value_policy: MissingMetricPolicy::Error,
1746 metric: MetricReference {
1747 implementation: ImplementationDescriptor::new(
1748 ImplementationSemanticKind::Metric,
1749 &metric.metric_id,
1750 &metric.spec_fingerprint,
1751 "provider:dag-ml-core",
1752 "binding:rust",
1753 "1.0.0",
1754 "4991854599d650fd613dfd02b10d90a649ad7fec85f20a027d5e7b2a553f628b",
1755 BTreeSet::new(),
1756 BTreeSet::new(),
1757 BTreeSet::from([ImplementationCapability::Deterministic]),
1758 PortabilityClass::PortableBuiltIn,
1759 ReplayabilityClass::Detached,
1760 None,
1761 )
1762 .unwrap(),
1763 spec: metric,
1764 },
1765 };
1766 metric_role.validate().unwrap();
1767
1768 let mut invalid_missing = metric_role;
1769 invalid_missing.missing_value_policy = MissingMetricPolicy::Skip;
1770 assert!(invalid_missing
1771 .validate()
1772 .unwrap_err()
1773 .to_string()
1774 .contains("only reporting"));
1775 }
1776
1777 #[test]
1778 fn early_stopping_record_is_fingerprinted_and_task_scoped() {
1779 let record = EarlyStoppingRecord::new(
1780 NodeId::new("model:custom").unwrap(),
1781 Phase::FitCv,
1782 Some(FoldId::new("fold:0").unwrap()),
1783 early_stopping_metric_role(),
1784 3,
1785 5,
1786 0.125,
1787 true,
1788 )
1789 .unwrap();
1790
1791 record.validate().unwrap();
1792 record
1793 .validate_against(
1794 &NodeId::new("model:custom").unwrap(),
1795 Phase::FitCv,
1796 Some(&FoldId::new("fold:0").unwrap()),
1797 )
1798 .unwrap();
1799 assert_eq!(
1800 EarlyStoppingRecord::from_json(&serde_json::to_string(&record).unwrap()).unwrap(),
1801 record
1802 );
1803 assert_eq!(record.record_fingerprint.len(), FINGERPRINT_LEN);
1804 }
1805
1806 #[test]
1807 fn early_stopping_record_rejects_wrong_role_values_and_scope() {
1808 let mut selection_role = early_stopping_metric_role();
1809 selection_role.role = MetricRoleKind::Selection;
1810 assert!(EarlyStoppingRecord::new(
1811 NodeId::new("model:custom").unwrap(),
1812 Phase::FitCv,
1813 Some(FoldId::new("fold:0").unwrap()),
1814 selection_role,
1815 3,
1816 5,
1817 0.125,
1818 true,
1819 )
1820 .unwrap_err()
1821 .to_string()
1822 .contains("early_stopping metric role"));
1823
1824 for (best_iteration, observed_iterations, best_value) in
1825 [(5, 5, 0.125), (0, 0, 0.125), (0, 1, f64::NAN)]
1826 {
1827 assert!(EarlyStoppingRecord::new(
1828 NodeId::new("model:custom").unwrap(),
1829 Phase::FitCv,
1830 Some(FoldId::new("fold:0").unwrap()),
1831 early_stopping_metric_role(),
1832 best_iteration,
1833 observed_iterations,
1834 best_value,
1835 true,
1836 )
1837 .is_err());
1838 }
1839
1840 assert!(EarlyStoppingRecord::new(
1841 NodeId::new("model:custom").unwrap(),
1842 Phase::FitCv,
1843 None,
1844 early_stopping_metric_role(),
1845 3,
1846 5,
1847 0.125,
1848 true,
1849 )
1850 .is_err());
1851 assert!(EarlyStoppingRecord::new(
1852 NodeId::new("model:custom").unwrap(),
1853 Phase::Predict,
1854 None,
1855 early_stopping_metric_role(),
1856 3,
1857 5,
1858 0.125,
1859 true,
1860 )
1861 .is_err());
1862
1863 let mut tampered = EarlyStoppingRecord::new(
1864 NodeId::new("model:custom").unwrap(),
1865 Phase::Refit,
1866 None,
1867 early_stopping_metric_role(),
1868 3,
1869 5,
1870 0.125,
1871 false,
1872 )
1873 .unwrap();
1874 tampered.best_value = 0.5;
1875 assert!(tampered
1876 .validate()
1877 .unwrap_err()
1878 .to_string()
1879 .contains("fingerprint mismatch"));
1880 }
1881
1882 #[test]
1883 fn strict_json_rejects_duplicate_keys_before_deserialization() {
1884 let valid = serde_json::to_string(&custom_loss()).unwrap();
1885 LossSpec::from_json(&valid).unwrap();
1886
1887 let duplicate = valid.replacen(
1888 "\"schema_version\":1",
1889 "\"schema_version\":1,\"schema_version\":1",
1890 1,
1891 );
1892 assert!(LossSpec::from_json(&duplicate)
1893 .unwrap_err()
1894 .to_string()
1895 .contains("duplicate JSON object key"));
1896 }
1897
1898 #[test]
1899 fn published_criteria_fixture_matches_rust_contracts_and_negative_cases() {
1900 let fixture: serde_json::Value = serde_json::from_str(include_str!(
1901 "../../../examples/fixtures/criteria/criteria_contracts.v1.json"
1902 ))
1903 .unwrap();
1904 let valid = fixture["valid"].as_object().unwrap();
1905
1906 let loss = LossSpec::from_json(&valid["loss_spec"].to_string()).unwrap();
1907 assert_eq!(
1908 loss.spec_fingerprint,
1909 "cf661225cc7137ab5ef9b87871ed5736a8479dd21587b7e17150c442b1e43eb0"
1910 );
1911 let metric = MetricSpec::from_json(&valid["metric_spec"].to_string()).unwrap();
1912 assert_eq!(
1913 metric.spec_fingerprint,
1914 "be54e6824479b10c398c229169bd324387c2fbb932e8fa879e37eba7d8821006"
1915 );
1916 for key in ["loss_implementation", "metric_implementation"] {
1917 ImplementationDescriptor::from_json(&valid[key].to_string()).unwrap();
1918 }
1919 TrainingLossRoleReference::from_json(&valid["training_loss_role"].to_string()).unwrap();
1920 LossExecutionAttestation::from_json(&valid["loss_execution_attestation"].to_string())
1921 .unwrap();
1922 MetricRoleReference::from_json(&valid["metric_role"].to_string()).unwrap();
1923 EarlyStoppingRecord::from_json(&valid["early_stopping_record"].to_string()).unwrap();
1924
1925 for case in fixture["invalid"].as_array().unwrap() {
1926 let document = case["document"].to_string();
1927 let result = match case["contract"].as_str().unwrap() {
1928 "loss_spec" => LossSpec::from_json(&document).map(|_| ()),
1929 "metric_spec" => MetricSpec::from_json(&document).map(|_| ()),
1930 "implementation_descriptor" => {
1931 ImplementationDescriptor::from_json(&document).map(|_| ())
1932 }
1933 "training_loss_role" => TrainingLossRoleReference::from_json(&document).map(|_| ()),
1934 "loss_execution_attestation" => {
1935 LossExecutionAttestation::from_json(&document).map(|_| ())
1936 }
1937 "metric_role" => MetricRoleReference::from_json(&document).map(|_| ()),
1938 "early_stopping_record" => EarlyStoppingRecord::from_json(&document).map(|_| ()),
1939 contract => panic!("unknown criteria fixture contract `{contract}`"),
1940 };
1941 assert!(
1942 result.is_err(),
1943 "negative case `{}` was accepted",
1944 case["id"]
1945 );
1946 }
1947 }
1948}