Skip to main content

dag_ml_core/
criteria.rs

1//! Backend-neutral training-loss and metric semantic contracts.
2//!
3//! This module owns serializable semantics and implementation identity only.
4//! Host controllers retain model objects, tensors, autodiff graphs and callback
5//! objects. Process-local executable implementations are resolved by binding
6//! registries from opaque [`ImplementationDescriptor::registry_key`] values.
7
8use 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/// Controller-reported early-stopping outcome, kept separate from persisted
935/// OOF/reporting scores and bound to one exact metric role and task scope.
936#[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    /// Zero-based iteration at which `best_value` was observed.
946    pub best_iteration: u64,
947    /// Number of completed iterations observed by the monitor.
948    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}